Skip to main content

fxcp_core/
qcow2.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2// Copyright (C) 2025-2026 Joel Wiramu Pauling <aenertia@aenertia.net>
3//
4// fxcp-core/src/qcow2.rs  --  qcow2 image format detection and dirty bitmap parsing
5
6//! Pure-Rust qcow2 metadata parser for format-aware replication optimization.
7//!
8//! Provides:
9//! - File detection via magic bytes (`0x514649fb`)
10//! - Header parsing (cluster size, backing file, virtual size)
11//! - Persistent dirty bitmap parsing for delta replication
12//!
13//! All operations are read-only  --  foxingd never modifies qcow2 metadata.
14//! All integers in qcow2 are big-endian.
15
16use std::io;
17use std::os::unix::fs::FileExt;
18use std::path::Path;
19use tracing::{debug, warn};
20
21use crate::error::FxcpError;
22use crate::hashing::DirtyRange;
23use crate::constants;
24
25/// Maximum bitmap directory size we'll allocate (64 MB).
26const MAX_BITMAP_DIR_SIZE: u64 = 64 * 1024 * 1024;
27/// Maximum header extension data size we'll allocate (16 MB).
28const MAX_EXT_DATA_SIZE: u64 = 16 * 1024 * 1024;
29/// Maximum backing file path length (PATH_MAX).
30const MAX_BACKING_FILE_SIZE: u32 = 4096;
31
32/// Parsed qcow2 header metadata.
33#[derive(Debug, Clone)]
34pub struct Qcow2Info {
35    pub version: u32,
36    pub cluster_bits: u8,
37    pub cluster_size: u32,
38    pub virtual_size: u64,
39    pub backing_file: Option<String>,
40    pub has_bitmaps: bool,
41    pub l1_size: u32,
42    pub l1_table_offset: u64,
43}
44
45/// Information about a persistent dirty bitmap.
46#[derive(Debug, Clone)]
47pub struct BitmapInfo {
48    pub name: String,
49    pub granularity_bits: u8,
50    pub granularity: u64,
51    pub flags: u32,
52    pub bitmap_table_offset: u64,
53    pub bitmap_table_size: u32,
54    pub is_recording: bool,
55    pub is_inconsistent: bool,
56}
57
58/// Detect whether a file is a qcow2 image by reading the header.
59/// Returns `Ok(None)` for non-qcow2 files (magic/version mismatch  --  expected for most files).
60/// Returns `Err` for genuine I/O or parse failures.
61pub fn detect(path: &Path) -> Result<Option<Qcow2Info>, FxcpError> {
62    let file = std::fs::File::open(path).map_err(|e| {
63        FxcpError::Io(io::Error::new(e.kind(), format!("qcow2 detect: open {:?}: {}", path, e)))
64    })?;
65    let mut buf = [0u8; constants::QCOW2_HEADER_V3_SIZE];
66    match file.read_exact_at(&mut buf, 0) {
67        Ok(()) => {}
68        Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
69        Err(e) => return Err(FxcpError::Io(io::Error::new(
70            e.kind(), format!("qcow2 detect: read header {:?}: {}", path, e),
71        ))),
72    }
73
74    let magic = u32::from_be_bytes(buf[0..4].try_into().map_err(|_| {
75        FxcpError::Io(io::Error::new(io::ErrorKind::InvalidData, "qcow2 detect: magic slice conversion failed"))
76    })?);
77    if magic != constants::QCOW2_MAGIC { return Ok(None); }
78
79    let version = u32::from_be_bytes(buf[4..8].try_into().map_err(|_| {
80        FxcpError::Io(io::Error::new(io::ErrorKind::InvalidData, "qcow2 detect: version slice conversion failed"))
81    })?);
82    if version != 2 && version != 3 { return Ok(None); }
83
84    let backing_file_offset = u64::from_be_bytes(buf[8..16].try_into().map_err(|_| {
85        FxcpError::Io(io::Error::new(io::ErrorKind::InvalidData, "qcow2 detect: backing_file_offset slice conversion failed"))
86    })?);
87    let backing_file_size = u32::from_be_bytes(buf[16..20].try_into().map_err(|_| {
88        FxcpError::Io(io::Error::new(io::ErrorKind::InvalidData, "qcow2 detect: backing_file_size slice conversion failed"))
89    })?);
90    let cluster_bits_u32 = u32::from_be_bytes(buf[20..24].try_into().map_err(|_| {
91        FxcpError::Io(io::Error::new(io::ErrorKind::InvalidData, "qcow2 detect: cluster_bits slice conversion failed"))
92    })?);
93    let cluster_bits = u8::try_from(cluster_bits_u32).map_err(|_| {
94        FxcpError::Io(io::Error::new(io::ErrorKind::InvalidData,
95            format!("qcow2 detect: cluster_bits {} exceeds u8::MAX", cluster_bits_u32)))
96    })?;
97    let virtual_size = u64::from_be_bytes(buf[24..32].try_into().map_err(|_| {
98        FxcpError::Io(io::Error::new(io::ErrorKind::InvalidData, "qcow2 detect: virtual_size slice conversion failed"))
99    })?);
100    let l1_size = u32::from_be_bytes(buf[36..40].try_into().map_err(|_| {
101        FxcpError::Io(io::Error::new(io::ErrorKind::InvalidData, "qcow2 detect: l1_size slice conversion failed"))
102    })?);
103    let l1_table_offset = u64::from_be_bytes(buf[40..48].try_into().map_err(|_| {
104        FxcpError::Io(io::Error::new(io::ErrorKind::InvalidData, "qcow2 detect: l1_table_offset slice conversion failed"))
105    })?);
106
107    if !(9..=21).contains(&cluster_bits) { return Ok(None); }
108    let cluster_size = 1u32 << cluster_bits;
109
110    // Read backing file name if present
111    if backing_file_size > MAX_BACKING_FILE_SIZE { return Ok(None); }
112    let backing_file = if backing_file_offset > 0 && backing_file_size > 0 && backing_file_size <= constants::QCOW2_MAX_BACKING_PATH {
113        let mut name_buf = vec![0u8; backing_file_size as usize];
114        file.read_exact_at(&mut name_buf, backing_file_offset).map_err(|e| {
115            FxcpError::Io(io::Error::new(e.kind(), format!("qcow2 detect: read backing file name {:?}: {}", path, e)))
116        })?;
117        String::from_utf8(name_buf).ok()
118    } else {
119        None
120    };
121
122    // Check for bitmaps extension (v3 only)
123    let has_bitmaps = if version >= 3 {
124        let header_length = u32::from_be_bytes(buf[100..104].try_into().map_err(|_| {
125            FxcpError::Io(io::Error::new(io::ErrorKind::InvalidData, "qcow2 detect: header_length slice conversion failed"))
126        })?) as u64;
127        scan_header_extensions(&file, header_length, constants::QCOW2_BITMAPS_EXTENSION).is_some()
128    } else {
129        false
130    };
131
132    debug!("qcow2: detected {:?} v{} cluster={}KB vsize={}GB bitmaps={}",
133           path, version, cluster_size / 1024, virtual_size / (1024*1024*1024), has_bitmaps);
134
135    Ok(Some(Qcow2Info {
136        version,
137        cluster_bits,
138        cluster_size,
139        virtual_size,
140        backing_file,
141        has_bitmaps,
142        l1_size,
143        l1_table_offset,
144    }))
145}
146
147/// List all persistent bitmaps in a qcow2 image.
148pub fn list_bitmaps(path: &Path) -> io::Result<Vec<BitmapInfo>> {
149    let file = std::fs::File::open(path)?;
150    let mut header = [0u8; 104];
151    file.read_exact_at(&mut header, 0)?;
152
153    let magic = u32::from_be_bytes(header[0..4].try_into().map_err(|_| {
154        io::Error::new(io::ErrorKind::InvalidData, "qcow2 header magic slice conversion failed")
155    })?);
156    if magic != constants::QCOW2_MAGIC {
157        return Err(io::Error::new(io::ErrorKind::InvalidData, "not a qcow2 file"));
158    }
159    let version = u32::from_be_bytes(header[4..8].try_into().map_err(|_| {
160        io::Error::new(io::ErrorKind::InvalidData, "qcow2 header version slice conversion failed")
161    })?);
162    if version < 3 {
163        return Ok(vec![]); // v2 has no bitmap support
164    }
165
166    let header_length = u32::from_be_bytes(header[100..104].try_into().map_err(|_| {
167        io::Error::new(io::ErrorKind::InvalidData, "qcow2 header length slice conversion failed")
168    })?) as u64;
169
170    // Find bitmaps extension
171    let ext_data = match scan_header_extensions(&file, header_length, constants::QCOW2_BITMAPS_EXTENSION) {
172        Some(d) => d,
173        None => return Ok(vec![]),
174    };
175
176    if ext_data.len() < 24 {
177        return Err(io::Error::new(io::ErrorKind::InvalidData, "bitmaps extension too short"));
178    }
179
180    let nb_bitmaps = u32::from_be_bytes(ext_data[0..4].try_into().map_err(|_| {
181        io::Error::new(io::ErrorKind::InvalidData, "qcow2 nb_bitmaps slice conversion failed")
182    })?);
183    let bitmap_dir_size = u64::from_be_bytes(ext_data[8..16].try_into().map_err(|_| {
184        io::Error::new(io::ErrorKind::InvalidData, "qcow2 bitmap_dir_size slice conversion failed")
185    })?);
186    let bitmap_dir_offset = u64::from_be_bytes(ext_data[16..24].try_into().map_err(|_| {
187        io::Error::new(io::ErrorKind::InvalidData, "qcow2 bitmap_dir_offset slice conversion failed")
188    })?);
189
190    if bitmap_dir_size > MAX_BITMAP_DIR_SIZE {
191        return Err(io::Error::new(
192            io::ErrorKind::InvalidData,
193            format!("qcow2 bitmap_dir_size {} exceeds max {}", bitmap_dir_size, MAX_BITMAP_DIR_SIZE),
194        ));
195    }
196    let mut dir_buf = vec![0u8; bitmap_dir_size as usize];
197    file.read_exact_at(&mut dir_buf, bitmap_dir_offset)?;
198
199    let mut bitmaps = Vec::with_capacity(nb_bitmaps as usize);
200    let mut pos = 0usize;
201
202    for _ in 0..nb_bitmaps {
203        if pos + 24 > dir_buf.len() { break; }
204
205        let bitmap_table_offset = u64::from_be_bytes(dir_buf[pos..pos+8].try_into().map_err(|_| {
206            io::Error::new(io::ErrorKind::InvalidData, "qcow2 bitmap_table_offset slice conversion failed")
207        })?);
208        let bitmap_table_size = u32::from_be_bytes(dir_buf[pos+8..pos+12].try_into().map_err(|_| {
209            io::Error::new(io::ErrorKind::InvalidData, "qcow2 bitmap_table_size slice conversion failed")
210        })?);
211        let flags = u32::from_be_bytes(dir_buf[pos+12..pos+16].try_into().map_err(|_| {
212            io::Error::new(io::ErrorKind::InvalidData, "qcow2 bitmap flags slice conversion failed")
213        })?);
214        let bm_type = dir_buf[pos+16];
215        let granularity_bits = dir_buf[pos+17];
216        let name_size = u16::from_be_bytes(dir_buf[pos+18..pos+20].try_into().map_err(|_| {
217            io::Error::new(io::ErrorKind::InvalidData, "qcow2 bitmap name_size slice conversion failed")
218        })?) as usize;
219        let extra_data_size = u32::from_be_bytes(dir_buf[pos+20..pos+24].try_into().map_err(|_| {
220            io::Error::new(io::ErrorKind::InvalidData, "qcow2 bitmap extra_data_size slice conversion failed")
221        })?) as usize;
222
223        let name_start = pos + 24 + extra_data_size;
224        if name_start + name_size > dir_buf.len() { break; }
225
226        let name = String::from_utf8_lossy(&dir_buf[name_start..name_start+name_size]).to_string();
227
228        if bm_type == constants::QCOW2_BITMAP_TYPE_DIRTY {
229            bitmaps.push(BitmapInfo {
230                name,
231                granularity_bits,
232                granularity: 1u64 << granularity_bits,
233                flags,
234                bitmap_table_offset,
235                bitmap_table_size,
236                is_recording: flags & constants::QCOW2_BITMAP_FLAG_AUTO != 0,
237                is_inconsistent: flags & constants::QCOW2_BITMAP_FLAG_IN_USE != 0,
238            });
239        }
240
241        // Advance to next entry (padded to 8 bytes)
242        let entry_size = 24 + extra_data_size + name_size;
243        pos += (entry_size + 7) & !7; // Round up to 8-byte boundary
244    }
245
246    debug!("qcow2: found {} dirty tracking bitmaps in {:?}", bitmaps.len(), path);
247    Ok(bitmaps)
248}
249
250/// Read a specific dirty bitmap and return the list of dirty regions.
251/// Returns dirty regions as `Vec<DirtyRange>` compatible with `copy_delta()`.
252pub fn read_dirty_bitmap(path: &Path, bitmap_name: &str) -> io::Result<Vec<DirtyRange>> {
253    let info = detect(path)
254        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("qcow2 detect failed: {}", e)))?
255        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "not a qcow2 file"))?;
256
257    let bitmaps = list_bitmaps(path)?;
258    let bitmap = bitmaps.iter().find(|b| b.name == bitmap_name).ok_or_else(|| {
259        io::Error::new(io::ErrorKind::NotFound, format!("bitmap '{}' not found", bitmap_name))
260    })?;
261
262    if bitmap.is_inconsistent {
263        warn!("qcow2: bitmap '{}' has in_use flag  --  inconsistent, skipping", bitmap_name);
264        return Err(io::Error::new(io::ErrorKind::InvalidData, "bitmap is inconsistent (in_use flag set)"));
265    }
266
267    let file = std::fs::File::open(path)?;
268    let cluster_size = info.cluster_size as u64;
269    let granularity = bitmap.granularity;
270
271    // Read bitmap table (one-level structure)
272    let table_bytes = bitmap.bitmap_table_size as usize * 8;
273    let mut table_buf = vec![0u8; table_bytes];
274    file.read_exact_at(&mut table_buf, bitmap.bitmap_table_offset)?;
275
276    let mut dirty_regions = Vec::new();
277    let mut current_dirty: Option<DirtyRange> = None;
278
279    for table_idx in 0..bitmap.bitmap_table_size as usize {
280        let entry = u64::from_be_bytes(table_buf[table_idx*8..(table_idx+1)*8].try_into().map_err(|_| {
281            io::Error::new(io::ErrorKind::InvalidData, "qcow2 bitmap table entry slice conversion failed")
282        })?);
283
284        // Extract host offset (bits 9-55)
285        let host_offset = entry & constants::QCOW2_L2_OFFSET_MASK;
286        let all_flag = entry & 1; // bit 0: all-zeros or all-ones when unallocated
287
288        if host_offset == 0 {
289            if all_flag == 1 {
290                // All ones  --  entire cluster's worth of bitmap data is dirty
291                let bits_per_cluster = cluster_size * 8;
292                let base_byte = table_idx as u64 * bits_per_cluster * granularity;
293                for bit in 0..bits_per_cluster {
294                    let region_offset = base_byte + bit * granularity;
295                    if region_offset >= info.virtual_size { break; }
296                    let length = granularity.min(info.virtual_size - region_offset);
297                    merge_dirty_region(&mut dirty_regions, &mut current_dirty, region_offset, length);
298                }
299            }
300            // all_flag == 0: all zeros, no dirty regions in this table entry
301            continue;
302        }
303
304        // Read bitmap data cluster
305        let mut bitmap_data = vec![0u8; cluster_size as usize];
306        if file.read_exact_at(&mut bitmap_data, host_offset).is_err() {
307            warn!("qcow2: failed to read bitmap data at offset {}", host_offset);
308            continue;
309        }
310
311        // Each bit in bitmap data corresponds to `granularity` bytes of virtual disk
312        let bits_per_cluster = cluster_size * 8;
313        let base_byte = table_idx as u64 * bits_per_cluster * granularity;
314
315        for byte_idx in 0..cluster_size as usize {
316            let byte = bitmap_data[byte_idx];
317            if byte == 0 { continue; } // Fast skip: no dirty bits in this byte
318            for bit in 0..8u64 {
319                if byte & (1 << bit) != 0 {
320                    let bit_number = byte_idx as u64 * 8 + bit;
321                    let region_offset = base_byte + bit_number * granularity;
322                    if region_offset >= info.virtual_size { break; }
323                    let length = granularity.min(info.virtual_size - region_offset);
324                    merge_dirty_region(&mut dirty_regions, &mut current_dirty, region_offset, length);
325                }
326            }
327        }
328    }
329
330    // Flush last pending dirty region
331    if let Some(pending) = current_dirty {
332        dirty_regions.push(pending);
333    }
334
335    debug!("qcow2: bitmap '{}' has {} dirty regions in {:?}",
336           bitmap_name, dirty_regions.len(), path);
337    Ok(dirty_regions)
338}
339
340/// Merge adjacent dirty regions into contiguous ranges.
341fn merge_dirty_region(
342    regions: &mut Vec<DirtyRange>,
343    current: &mut Option<DirtyRange>,
344    offset: u64,
345    length: u64,
346) {
347    match current {
348        Some(c) if offset <= c.offset + c.length => {
349            // Adjacent or overlapping  --  extend
350            let new_end = (offset + length).max(c.offset + c.length);
351            c.length = new_end - c.offset;
352        }
353        _ => {
354            if let Some(prev) = current.take() {
355                regions.push(prev);
356            }
357            *current = Some(DirtyRange { offset, length });
358        }
359    }
360}
361
362/// Scan header extensions for a specific type.
363/// Returns the extension data if found.
364fn scan_header_extensions(file: &std::fs::File, header_length: u64, target_type: u32) -> Option<Vec<u8>> {
365    let mut pos = header_length;
366
367    for _ in 0..64 { // Safety limit
368        let mut ext_header = [0u8; 8];
369        file.read_exact_at(&mut ext_header, pos).ok()?;
370
371        let ext_type = u32::from_be_bytes(ext_header[0..4].try_into().ok()?);
372        let ext_len = u32::from_be_bytes(ext_header[4..8].try_into().ok()?) as u64;
373
374        if ext_type == 0 { break; } // End of extensions
375
376        if ext_type == target_type {
377            if ext_len > MAX_EXT_DATA_SIZE { return None; }
378            let mut data = vec![0u8; ext_len as usize];
379            file.read_exact_at(&mut data, pos + 8).ok()?;
380            return Some(data);
381        }
382
383        // Advance to next extension (padded to 8 bytes)
384        pos += 8 + ((ext_len + 7) & !7);
385    }
386
387    None
388}
389
390#[cfg(test)]
391mod tests {
392    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
393    use super::*;
394    use std::io::Write;
395
396    fn create_minimal_qcow2(path: &Path) -> io::Result<()> {
397        let mut f = std::fs::File::create(path)?;
398        let mut header = [0u8; 104];
399
400        // Magic
401        header[0..4].copy_from_slice(&crate::constants::QCOW2_MAGIC.to_be_bytes());
402        // Version 3
403        header[4..8].copy_from_slice(&3u32.to_be_bytes());
404        // cluster_bits = 16 (64KB)
405        header[20..24].copy_from_slice(&16u32.to_be_bytes());
406        // virtual size = 1GB
407        header[24..32].copy_from_slice(&(1024u64 * 1024 * 1024).to_be_bytes());
408        // l1_size = 1
409        header[36..40].copy_from_slice(&1u32.to_be_bytes());
410        // l1_table_offset = 0x10000 (first cluster boundary)
411        header[40..48].copy_from_slice(&0x10000u64.to_be_bytes());
412        // header_length = 104
413        header[100..104].copy_from_slice(&104u32.to_be_bytes());
414
415        f.write_all(&header)?;
416        Ok(())
417    }
418
419    #[test]
420    fn test_detect_qcow2() {
421        let dir = tempfile::tempdir().unwrap();
422        let qcow2_path = dir.path().join("test.qcow2");
423        create_minimal_qcow2(&qcow2_path).unwrap();
424
425        let info = detect(&qcow2_path).unwrap().unwrap();
426        assert_eq!(info.version, 3);
427        assert_eq!(info.cluster_bits, 16);
428        assert_eq!(info.cluster_size, 65536);
429        assert_eq!(info.virtual_size, 1024 * 1024 * 1024);
430        assert!(info.backing_file.is_none());
431    }
432
433    #[test]
434    fn test_detect_non_qcow2() {
435        let dir = tempfile::tempdir().unwrap();
436        let path = dir.path().join("regular.dat");
437        std::fs::write(&path, b"hello world this is not qcow2").unwrap();
438
439        assert!(detect(&path).unwrap().is_none());
440    }
441
442    #[test]
443    fn test_detect_too_small() {
444        let dir = tempfile::tempdir().unwrap();
445        let path = dir.path().join("tiny");
446        std::fs::write(&path, b"hi").unwrap();
447
448        assert!(detect(&path).unwrap().is_none());
449    }
450
451    #[test]
452    fn test_detect_nonexistent_returns_io_error() {
453        let dir = tempfile::tempdir().unwrap();
454        let path = dir.path().join("does_not_exist.qcow2");
455
456        let err = detect(&path).unwrap_err();
457        let msg = format!("{}", err);
458        assert!(msg.contains("qcow2 detect"), "error should contain context: {}", msg);
459        assert!(msg.contains("does_not_exist.qcow2"), "error should contain path: {}", msg);
460    }
461
462    #[test]
463    fn test_list_bitmaps_no_bitmaps() {
464        let dir = tempfile::tempdir().unwrap();
465        let qcow2_path = dir.path().join("nobitmaps.qcow2");
466        create_minimal_qcow2(&qcow2_path).unwrap();
467
468        let bitmaps = list_bitmaps(&qcow2_path).unwrap();
469        assert!(bitmaps.is_empty());
470    }
471}