use super::error::{DiskError, DiskResult};
use super::BlockDevice;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::RwLock;
pub struct Qcow2Image {
path: PathBuf,
virtual_size: u64,
cluster_size: u32,
#[cfg(feature = "disk-image")]
inner: Arc<RwLock<Qcow2Inner>>,
}
#[cfg(feature = "disk-image")]
struct Qcow2Inner {
_placeholder: (),
}
impl Qcow2Image {
pub async fn open(path: impl AsRef<Path>) -> DiskResult<Self> {
let path = path.as_ref().to_path_buf();
let metadata = tokio::fs::metadata(&path).await.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
DiskError::InvalidFormat {
path: path.clone(),
reason: "File not found".to_string(),
}
} else {
DiskError::Io(e)
}
})?;
let mut file = tokio::fs::File::open(&path).await?;
let header = Self::read_header(&mut file).await?;
if header.encryption_method != 0 {
return Err(DiskError::Unsupported {
feature: "QCOW2 encryption".to_string(),
});
}
Ok(Self {
path,
virtual_size: header.size,
cluster_size: 1 << header.cluster_bits,
#[cfg(feature = "disk-image")]
inner: Arc::new(RwLock::new(Qcow2Inner { _placeholder: () })),
})
}
async fn read_header(file: &mut tokio::fs::File) -> DiskResult<Qcow2Header> {
use tokio::io::AsyncReadExt;
let mut buf = [0u8; 104]; file.read_exact(&mut buf[..72]).await?;
let magic = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
if magic != 0x514649fb {
return Err(DiskError::InvalidFormat {
path: PathBuf::new(),
reason: format!("Invalid QCOW2 magic: 0x{:08x} (expected 0x514649fb)", magic),
});
}
let version = u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]);
if version != 2 && version != 3 {
return Err(DiskError::InvalidFormat {
path: PathBuf::new(),
reason: format!("Unsupported QCOW2 version: {} (supported: 2, 3)", version),
});
}
Ok(Qcow2Header {
version,
backing_file_offset: u64::from_be_bytes(buf[8..16].try_into().unwrap()),
backing_file_size: u32::from_be_bytes(buf[16..20].try_into().unwrap()),
cluster_bits: u32::from_be_bytes(buf[20..24].try_into().unwrap()),
size: u64::from_be_bytes(buf[24..32].try_into().unwrap()),
encryption_method: u32::from_be_bytes(buf[32..36].try_into().unwrap()),
l1_size: u32::from_be_bytes(buf[36..40].try_into().unwrap()),
l1_table_offset: u64::from_be_bytes(buf[40..48].try_into().unwrap()),
refcount_table_offset: u64::from_be_bytes(buf[48..56].try_into().unwrap()),
refcount_table_clusters: u32::from_be_bytes(buf[56..60].try_into().unwrap()),
nb_snapshots: u32::from_be_bytes(buf[60..64].try_into().unwrap()),
snapshots_offset: u64::from_be_bytes(buf[64..72].try_into().unwrap()),
})
}
pub fn cluster_size(&self) -> u32 {
self.cluster_size
}
pub async fn has_backing_file(&self) -> bool {
if let Ok(mut file) = tokio::fs::File::open(&self.path).await {
if let Ok(header) = Self::read_header(&mut file).await {
return header.backing_file_offset != 0 && header.backing_file_size > 0;
}
}
false
}
pub async fn backing_file_path(&self) -> Option<String> {
use tokio::io::{AsyncReadExt, AsyncSeekExt};
let mut file = tokio::fs::File::open(&self.path).await.ok()?;
let header = Self::read_header(&mut file).await.ok()?;
if header.backing_file_offset == 0 || header.backing_file_size == 0 {
return None;
}
file.seek(std::io::SeekFrom::Start(header.backing_file_offset))
.await
.ok()?;
let mut backing_name = vec![0u8; header.backing_file_size as usize];
file.read_exact(&mut backing_name).await.ok()?;
String::from_utf8(backing_name).ok()
}
pub async fn resolve_backing_file(&self) -> Option<PathBuf> {
let backing_path = self.backing_file_path().await?;
let backing = PathBuf::from(&backing_path);
if backing.is_absolute() {
Some(backing)
} else {
self.path.parent().map(|dir| dir.join(&backing_path))
}
}
}
#[derive(Debug)]
struct Qcow2Header {
version: u32,
backing_file_offset: u64,
backing_file_size: u32,
cluster_bits: u32,
size: u64,
encryption_method: u32,
l1_size: u32,
l1_table_offset: u64,
refcount_table_offset: u64,
refcount_table_clusters: u32,
nb_snapshots: u32,
snapshots_offset: u64,
}
#[cfg(feature = "disk-image")]
impl BlockDevice for Qcow2Image {
async fn read_at(&self, buf: &mut [u8], offset: u64) -> DiskResult<usize> {
use tokio::io::{AsyncReadExt, AsyncSeekExt};
if offset >= self.virtual_size {
return Err(DiskError::OutOfBounds {
requested: offset,
size: self.virtual_size,
});
}
let available = (self.virtual_size - offset) as usize;
let to_read = buf.len().min(available);
let cluster_size = self.cluster_size as u64;
let start_cluster = offset / cluster_size;
let end_cluster = (offset + to_read as u64 - 1) / cluster_size;
let mut file = tokio::fs::File::open(&self.path).await?;
let header = Self::read_header(&mut file).await?;
let mut bytes_read = 0;
let mut current_offset = offset;
for cluster_idx in start_cluster..=end_cluster {
let cluster_start_offset = cluster_idx * cluster_size;
let offset_in_cluster = current_offset - cluster_start_offset;
let remaining_in_cluster = (cluster_size - offset_in_cluster) as usize;
let bytes_to_read = remaining_in_cluster.min(to_read - bytes_read);
let l1_index = cluster_idx / (cluster_size / 8);
let l1_entry_offset = header.l1_table_offset + l1_index * 8;
file.seek(std::io::SeekFrom::Start(l1_entry_offset)).await?;
let mut l1_buf = [0u8; 8];
file.read_exact(&mut l1_buf).await?;
let l1_entry = u64::from_be_bytes(l1_buf);
if l1_entry == 0 {
if let Some(backing_path) = self.resolve_backing_file().await {
if let Ok(backing) = Qcow2Image::open(&backing_path).await {
let backing_buf = &mut buf[bytes_read..bytes_read + bytes_to_read];
backing.read_at(backing_buf, current_offset).await?;
} else {
buf[bytes_read..bytes_read + bytes_to_read].fill(0);
}
} else {
buf[bytes_read..bytes_read + bytes_to_read].fill(0);
}
} else {
let l2_table_offset = l1_entry & 0x00ffffffffffff00;
let l2_index = (cluster_idx % (cluster_size / 8)) as u64;
let l2_entry_offset = l2_table_offset + l2_index * 8;
file.seek(std::io::SeekFrom::Start(l2_entry_offset)).await?;
let mut l2_buf = [0u8; 8];
file.read_exact(&mut l2_buf).await?;
let l2_entry = u64::from_be_bytes(l2_buf);
if l2_entry == 0 {
if let Some(backing_path) = self.resolve_backing_file().await {
if let Ok(backing) = Qcow2Image::open(&backing_path).await {
let backing_buf = &mut buf[bytes_read..bytes_read + bytes_to_read];
backing.read_at(backing_buf, current_offset).await?;
} else {
buf[bytes_read..bytes_read + bytes_to_read].fill(0);
}
} else {
buf[bytes_read..bytes_read + bytes_to_read].fill(0);
}
} else {
let compressed = (l2_entry >> 62) & 1 == 1;
if compressed {
return Err(DiskError::Unsupported {
feature: "QCOW2 compressed clusters".to_string(),
});
}
let data_offset = (l2_entry & 0x00ffffffffffff00) + offset_in_cluster;
file.seek(std::io::SeekFrom::Start(data_offset)).await?;
file.read_exact(&mut buf[bytes_read..bytes_read + bytes_to_read])
.await?;
}
}
bytes_read += bytes_to_read;
current_offset += bytes_to_read as u64;
}
Ok(bytes_read)
}
fn size(&self) -> u64 {
self.virtual_size
}
fn block_size(&self) -> u32 {
512 }
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_invalid_magic_detection() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("not_qcow2.img");
tokio::fs::write(&path, b"NOT A QCOW2 FILE").await.unwrap();
let result = Qcow2Image::open(&path).await;
assert!(matches!(result, Err(DiskError::InvalidFormat { .. })));
}
}