1use 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
25const MAX_BITMAP_DIR_SIZE: u64 = 64 * 1024 * 1024;
27const MAX_EXT_DATA_SIZE: u64 = 16 * 1024 * 1024;
29const MAX_BACKING_FILE_SIZE: u32 = 4096;
31
32#[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#[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
58pub 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 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 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
147pub 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![]); }
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 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 let entry_size = 24 + extra_data_size + name_size;
243 pos += (entry_size + 7) & !7; }
245
246 debug!("qcow2: found {} dirty tracking bitmaps in {:?}", bitmaps.len(), path);
247 Ok(bitmaps)
248}
249
250pub 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 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 let host_offset = entry & constants::QCOW2_L2_OFFSET_MASK;
286 let all_flag = entry & 1; if host_offset == 0 {
289 if all_flag == 1 {
290 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 continue;
302 }
303
304 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 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; } 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 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
340fn 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 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
362fn 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 { 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; } 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 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 header[0..4].copy_from_slice(&crate::constants::QCOW2_MAGIC.to_be_bytes());
402 header[4..8].copy_from_slice(&3u32.to_be_bytes());
404 header[20..24].copy_from_slice(&16u32.to_be_bytes());
406 header[24..32].copy_from_slice(&(1024u64 * 1024 * 1024).to_be_bytes());
408 header[36..40].copy_from_slice(&1u32.to_be_bytes());
410 header[40..48].copy_from_slice(&0x10000u64.to_be_bytes());
412 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}