#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
use std::fs::File;
use std::io::{self, Read, Seek, SeekFrom};
use std::path::Path;
mod error;
mod header;
mod refcount;
mod snapshots;
pub use error::Qcow2Error;
pub use header::Qcow2Info;
pub use refcount::{refcount_report, Qcow2RefcountReport};
pub use snapshots::{snapshots, Qcow2Snapshot};
use header::Qcow2Header;
pub fn inspect(path: &Path) -> Result<Qcow2Info, Qcow2Error> {
let mut file = File::open(path)?;
let mut hdr_buf = [0u8; 8192];
let n = read_window(&mut file, &mut hdr_buf)?;
Qcow2Info::parse(&hdr_buf[..n])
}
fn read_window(file: &mut File, buf: &mut [u8]) -> io::Result<usize> {
let mut filled = 0;
while filled < buf.len() {
match file.read(&mut buf[filled..])? {
0 => break,
n => filled += n,
}
}
Ok(filled)
}
pub struct Qcow2Reader {
file: File,
virtual_disk_size: u64,
cluster_size: u64,
l1_table: Vec<u64>, l2_bits: u32, l2_mask: u64,
pos: u64,
}
impl Qcow2Reader {
pub fn open(path: &Path) -> Result<Self, Qcow2Error> {
const MAX_L1_ENTRIES: u32 = 1 << 20;
let mut file = File::open(path)?;
let mut hdr_buf = [0u8; 104];
let hdr_read = file.read(&mut hdr_buf)?;
let hdr = Qcow2Header::parse(&hdr_buf[..hdr_read])?;
let cluster_size = 1u64 << hdr.cluster_bits;
let l2_entries = cluster_size / 8;
let l2_bits = hdr.cluster_bits - 3; let l2_mask = l2_entries - 1;
if hdr.l1_size > MAX_L1_ENTRIES {
return Err(Qcow2Error::L1TableTooLarge(hdr.l1_size));
}
file.seek(SeekFrom::Start(hdr.l1_table_offset))?;
let l1_bytes = u64::from(hdr.l1_size) * 8;
let mut l1_buf = vec![0u8; l1_bytes as usize];
file.read_exact(&mut l1_buf)?;
let l1_table: Vec<u64> = l1_buf
.chunks_exact(8)
.map(|c| {
let mut a = [0u8; 8];
a.copy_from_slice(c); u64::from_be_bytes(a)
})
.collect();
Ok(Qcow2Reader {
file,
virtual_disk_size: hdr.disk_size,
cluster_size,
l1_table,
l2_bits,
l2_mask,
pos: 0,
})
}
pub fn virtual_disk_size(&self) -> u64 {
self.virtual_disk_size
}
fn cluster_ref_for(&mut self, virtual_offset: u64) -> io::Result<ClusterRef> {
let cluster_idx = virtual_offset >> self.cluster_size.trailing_zeros();
let l1_idx = (cluster_idx >> self.l2_bits) as usize;
let l2_idx = cluster_idx & self.l2_mask;
let l1_entry = self.l1_table.get(l1_idx).copied().unwrap_or(0);
let l2_table_offset = l1_entry & 0x7FFF_FFFF_FFFF_FFFF; if l2_table_offset == 0 {
return Ok(ClusterRef::Unallocated);
}
let l2_entry_pos = l2_table_offset + l2_idx * 8;
self.file.seek(SeekFrom::Start(l2_entry_pos))?;
let mut l2_bytes = [0u8; 8];
self.file.read_exact(&mut l2_bytes)?;
let l2_entry = u64::from_be_bytes(l2_bytes);
if l2_entry & (1 << 62) != 0 {
let cluster_bits = self.cluster_size.trailing_zeros(); let split = 63u32 - cluster_bits; let count_mask = (1u64 << (cluster_bits - 1)) - 1; let file_offset = l2_entry & ((1u64 << split) - 1);
let nb_sectors = ((l2_entry >> split) & count_mask) + 1;
let compressed_bytes = (nb_sectors * 512) as usize;
return Ok(ClusterRef::Compressed { file_offset, compressed_bytes });
}
if l2_entry & 1 != 0 {
return Ok(ClusterRef::ZeroCluster);
}
let cluster_offset = l2_entry & 0x3FFF_FFFF_FFFF_FFFF;
if cluster_offset == 0 {
return Ok(ClusterRef::Unallocated);
}
Ok(ClusterRef::Normal(cluster_offset))
}
fn decompress_cluster(&mut self, file_offset: u64, compressed_bytes: usize) -> io::Result<Vec<u8>> {
use flate2::read::DeflateDecoder;
self.file.seek(SeekFrom::Start(file_offset))?;
let mut raw = vec![0u8; compressed_bytes];
let mut filled = 0;
while filled < compressed_bytes {
match self.file.read(&mut raw[filled..])? {
0 => break, n => filled += n,
}
}
let mut decoder = DeflateDecoder::new(&raw[..filled]);
let mut out = Vec::with_capacity(self.cluster_size as usize);
decoder.read_to_end(&mut out).map_err(|e| {
io::Error::new(io::ErrorKind::InvalidData, format!("qcow2 deflate: {e}"))
})?;
if out.len() < self.cluster_size as usize {
out.resize(self.cluster_size as usize, 0);
}
Ok(out)
}
}
enum ClusterRef {
Unallocated,
ZeroCluster,
Normal(u64),
Compressed { file_offset: u64, compressed_bytes: usize },
}
impl Read for Qcow2Reader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if self.pos >= self.virtual_disk_size || buf.is_empty() {
return Ok(0);
}
let remaining_virtual = (self.virtual_disk_size - self.pos) as usize;
let offset_in_cluster = (self.pos & (self.cluster_size - 1)) as usize;
let remaining_in_cluster = self.cluster_size as usize - offset_in_cluster;
let to_read = buf.len().min(remaining_virtual).min(remaining_in_cluster);
let n = match self.cluster_ref_for(self.pos)? {
ClusterRef::Normal(cluster_offset) => {
let file_off = cluster_offset + offset_in_cluster as u64;
self.file.seek(SeekFrom::Start(file_off))?;
self.file.read(&mut buf[..to_read])?
}
ClusterRef::Compressed { file_offset, compressed_bytes } => {
let decompressed = self.decompress_cluster(file_offset, compressed_bytes)?;
let src = &decompressed[offset_in_cluster..offset_in_cluster + to_read];
buf[..to_read].copy_from_slice(src);
to_read
}
ClusterRef::ZeroCluster | ClusterRef::Unallocated => {
buf[..to_read].fill(0);
to_read
}
};
self.pos += n as u64;
Ok(n)
}
}
impl Seek for Qcow2Reader {
fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
let new_pos = match pos {
SeekFrom::Start(n) => n as i64,
SeekFrom::Current(n) => self.pos as i64 + n,
SeekFrom::End(n) => self.virtual_disk_size as i64 + n,
};
if new_pos < 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"seek before start",
));
}
self.pos = new_pos as u64;
Ok(self.pos)
}
}
#[cfg(feature = "test-helpers")]
pub mod testutil;
#[cfg(not(feature = "test-helpers"))]
mod testutil;
#[cfg(test)]
mod tests {
use super::*;
use testutil::test_qcow2;
fn write_tmp(data: &[u8]) -> tempfile::NamedTempFile {
use std::io::Write;
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(data).unwrap();
f
}
fn qcow2_header_bytes(cluster_bits: u32) -> Vec<u8> {
let mut h = vec![0u8; 72];
h[0..4].copy_from_slice(&0x5146_49fb_u32.to_be_bytes()); h[4..8].copy_from_slice(&2u32.to_be_bytes()); h[20..24].copy_from_slice(&cluster_bits.to_be_bytes()); h[24..32].copy_from_slice(&512u64.to_be_bytes()); h[36..40].copy_from_slice(&0u32.to_be_bytes()); h[40..48].copy_from_slice(&0u64.to_be_bytes()); h
}
#[test]
fn cluster_bits_too_large_rejected() {
let f = write_tmp(&qcow2_header_bytes(200));
assert!(Qcow2Reader::open(f.path()).is_err());
}
#[test]
fn cluster_bits_zero_rejected() {
let f = write_tmp(&qcow2_header_bytes(0));
assert!(Qcow2Reader::open(f.path()).is_err());
}
#[test]
fn cluster_bits_below_minimum_rejected() {
let f = write_tmp(&qcow2_header_bytes(2));
assert!(Qcow2Reader::open(f.path()).is_err());
}
#[test]
fn open_nonexistent_returns_err() {
assert!(Qcow2Reader::open(Path::new("/tmp/no_such.qcow2")).is_err());
}
#[test]
fn open_empty_file_returns_err() {
let f = write_tmp(&[]);
assert!(Qcow2Reader::open(f.path()).is_err());
}
#[test]
fn open_non_qcow2_file_returns_err() {
let f = write_tmp(b"this is not a qcow2 image at all");
assert!(Qcow2Reader::open(f.path()).is_err());
}
#[test]
fn qcow2_virtual_disk_size() {
let img = test_qcow2(&[0u8; 512]);
let f = write_tmp(&img);
let reader = Qcow2Reader::open(f.path()).expect("open");
assert_eq!(reader.virtual_disk_size(), testutil::CLUSTER_SIZE as u64);
}
#[test]
fn qcow2_read_returns_cluster_data() {
let mut data = vec![0u8; 512];
data[42] = 0xDE;
data[43] = 0xAD;
let img = test_qcow2(&data);
let f = write_tmp(&img);
let mut reader = Qcow2Reader::open(f.path()).expect("open");
let mut buf = vec![0u8; 512];
reader.read_exact(&mut buf).expect("read");
assert_eq!(buf[42], 0xDE);
assert_eq!(buf[43], 0xAD);
}
#[test]
fn seek_and_read_at_offset() {
let mut data = vec![0u8; testutil::CLUSTER_SIZE];
data[100] = 0xBE;
data[101] = 0xEF;
let img = test_qcow2(&data);
let f = write_tmp(&img);
let mut reader = Qcow2Reader::open(f.path()).expect("open");
reader.seek(SeekFrom::Start(100)).unwrap();
let mut buf = [0u8; 2];
reader.read_exact(&mut buf).unwrap();
assert_eq!(buf, [0xBE, 0xEF]);
}
#[test]
fn qcow2_reader_is_send() {
fn assert_send<T: Send>() {}
assert_send::<Qcow2Reader>();
}
proptest::proptest! {
#[test]
fn open_never_panics_on_arbitrary_bytes(
bytes in proptest::collection::vec(proptest::prelude::any::<u8>(), 0..8192)
) {
let f = write_tmp(&bytes);
let _ = Qcow2Reader::open(f.path());
}
#[test]
fn open_never_panics_on_valid_magic_plus_garbage(
suffix in proptest::collection::vec(proptest::prelude::any::<u8>(), 0..8192)
) {
let mut bytes = vec![0u8; 8];
bytes[0..4].copy_from_slice(&0x5146_49fb_u32.to_be_bytes());
bytes[4..8].copy_from_slice(&2u32.to_be_bytes());
bytes.extend_from_slice(&suffix);
let f = write_tmp(&bytes);
let _ = Qcow2Reader::open(f.path());
}
}
#[test]
fn zero_plain_cluster_reads_as_zeros() {
use std::io::Write;
let img = test_qcow2(&[0xABu8; 512]); let mut patched = img.clone();
let l2_offset = 1536usize;
patched[l2_offset..l2_offset + 8].copy_from_slice(&1u64.to_be_bytes());
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(&patched).unwrap();
let mut reader = Qcow2Reader::open(f.path()).expect("open");
let mut buf = [0xFFu8; 512];
reader.seek(SeekFrom::Start(0)).unwrap();
reader.read_exact(&mut buf).expect("read");
assert_eq!(
buf,
[0u8; 512],
"ZERO_PLAIN cluster (L2 entry=1) must read as all zeros"
);
}
#[test]
fn reads_match_qemu_raw_convert() {
const QEMU_IMG: &str = "/opt/homebrew/bin/qemu-img";
if !Path::new(QEMU_IMG).exists() {
return;
}
let tmp = tempfile::tempdir().expect("tempdir");
let size: usize = 1 << 20;
let raw_data: Vec<u8> = (0..size).map(|i| (i ^ (i >> 8)) as u8).collect();
let raw_path = tmp.path().join("source.raw");
std::fs::write(&raw_path, &raw_data).expect("write raw");
let qcow2_path = tmp.path().join("test.qcow2");
let status = std::process::Command::new(QEMU_IMG)
.args(["convert", "-O", "qcow2",
raw_path.to_str().unwrap(),
qcow2_path.to_str().unwrap()])
.status()
.expect("spawn qemu-img");
assert!(status.success(), "qemu-img convert failed");
let mut reader = Qcow2Reader::open(&qcow2_path).expect("open");
assert_eq!(reader.virtual_disk_size(), size as u64);
let cluster = 65536usize;
for &offset in &[0usize, 511, cluster, cluster + 512, size - 512] {
let len = 512.min(size - offset);
let mut buf = vec![0u8; len];
reader.seek(SeekFrom::Start(offset as u64)).expect("seek");
reader.read_exact(&mut buf).expect("read");
assert_eq!(
buf,
raw_data[offset..offset + len],
"byte mismatch at offset {offset:#x}",
);
}
}
#[test]
fn corpus_cirros_reads_match_qemu_raw_convert() {
const QEMU_IMG: &str = "/opt/homebrew/bin/qemu-img";
if !Path::new(QEMU_IMG).exists() {
return;
}
let corpus = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/data/cirros-0.6.3-x86_64-disk.img");
if !corpus.exists() {
return; }
let tmp = tempfile::tempdir().expect("tempdir");
let raw_path = tmp.path().join("cirros.raw");
let ok = std::process::Command::new(QEMU_IMG)
.args(["convert", "-O", "raw",
corpus.to_str().unwrap(),
raw_path.to_str().unwrap()])
.status().expect("spawn qemu-img").success();
assert!(ok, "qemu-img convert failed");
let ref_data = std::fs::read(&raw_path).expect("read raw");
let mut reader = Qcow2Reader::open(&corpus).expect("open corpus");
assert_eq!(reader.virtual_disk_size(), ref_data.len() as u64,
"virtual_disk_size must match reference raw length");
let vsize = ref_data.len();
let cluster = 65536usize;
let samples = [
0usize, 446, 510, cluster, cluster * 10, vsize / 2, vsize / 2 + cluster, vsize - 512, ];
for &offset in &samples {
let len = 512.min(vsize - offset);
let mut buf = vec![0u8; len];
reader.seek(SeekFrom::Start(offset as u64)).expect("seek");
reader.read_exact(&mut buf).expect("read");
assert_eq!(
buf, ref_data[offset..offset + len],
"byte mismatch at offset {offset:#x}",
);
}
}
}