use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
use crate::error::{Qcow2Error, Result};
const MAX_SNAPSHOTS: u32 = 65_536;
const MAX_STR_LEN: u32 = 65_536;
const MAX_EXTRA_DATA: u32 = 65_536;
const SNAPSHOT_HEADER_FIXED: usize = 40;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Qcow2Snapshot {
pub id: String,
pub name: String,
pub date_unix_secs: u32,
pub date_nsecs: u32,
pub vm_state_size: u32,
}
fn be_u16(data: &[u8], off: usize) -> u16 {
let mut b = [0u8; 2];
if let Some(s) = data.get(off..off + 2) {
b.copy_from_slice(s);
}
u16::from_be_bytes(b)
}
fn be_u32(data: &[u8], off: usize) -> u32 {
let mut b = [0u8; 4];
if let Some(s) = data.get(off..off + 4) {
b.copy_from_slice(s);
}
u32::from_be_bytes(b)
}
fn be_u64(data: &[u8], off: usize) -> u64 {
let mut b = [0u8; 8];
if let Some(s) = data.get(off..off + 8) {
b.copy_from_slice(s);
}
u64::from_be_bytes(b)
}
pub fn snapshots(path: &Path) -> Result<Vec<Qcow2Snapshot>> {
let mut file = File::open(path)?;
let mut hdr = [0u8; 104];
let n = file.read(&mut hdr)?;
let hdr = &hdr[..n];
if hdr.len() < crate::header::MIN_HEADER_SIZE {
return Err(Qcow2Error::FileTooSmall);
}
if be_u32(hdr, 0) != crate::header::MAGIC {
return Err(Qcow2Error::BadMagic);
}
let nb_snapshots = be_u32(hdr, 60).min(MAX_SNAPSHOTS);
let snapshots_offset = be_u64(hdr, 64);
if nb_snapshots == 0 || snapshots_offset == 0 {
return Ok(Vec::new());
}
file.seek(SeekFrom::Start(snapshots_offset))?;
let mut out = Vec::with_capacity(nb_snapshots as usize);
for _ in 0..nb_snapshots {
let mut fixed = [0u8; SNAPSHOT_HEADER_FIXED];
if read_full(&mut file, &mut fixed).is_err() {
break;
}
let id_str_size = u32::from(be_u16(&fixed, 12)).min(MAX_STR_LEN);
let name_size = u32::from(be_u16(&fixed, 14)).min(MAX_STR_LEN);
let date_unix_secs = be_u32(&fixed, 16);
let date_nsecs = be_u32(&fixed, 20);
let vm_state_size = be_u32(&fixed, 32);
let extra_data_size = be_u32(&fixed, 36).min(MAX_EXTRA_DATA);
if extra_data_size > 0 && skip(&mut file, u64::from(extra_data_size)).is_err() {
break;
}
let Some(id) = read_string(&mut file, id_str_size) else {
break;
};
let Some(name) = read_string(&mut file, name_size) else {
break;
};
let consumed = SNAPSHOT_HEADER_FIXED
+ extra_data_size as usize
+ id_str_size as usize
+ name_size as usize;
let pad = (8 - (consumed % 8)) % 8;
if pad > 0 && skip(&mut file, pad as u64).is_err() {
}
out.push(Qcow2Snapshot {
id,
name,
date_unix_secs,
date_nsecs,
vm_state_size,
});
}
Ok(out)
}
fn read_full(file: &mut File, buf: &mut [u8]) -> std::io::Result<()> {
file.read_exact(buf)
}
fn skip(file: &mut File, n: u64) -> std::io::Result<()> {
let len = file.metadata()?.len();
let pos = file.stream_position()?;
let target = pos.saturating_add(n);
if target > len {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"snapshot record extends past end of file",
));
}
file.seek(SeekFrom::Start(target))?;
Ok(())
}
fn read_string(file: &mut File, len: u32) -> Option<String> {
if len == 0 {
return Some(String::new());
}
let mut buf = vec![0u8; len as usize];
file.read_exact(&mut buf).ok()?;
Some(String::from_utf8_lossy(&buf).into_owned())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::io::Write;
fn write_tmp(data: &[u8]) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(data).unwrap();
f
}
fn header(nb: u32, snap_off: u64) -> Vec<u8> {
let mut h = vec![0u8; 104];
h[0..4].copy_from_slice(&crate::header::MAGIC.to_be_bytes());
h[4..8].copy_from_slice(&3u32.to_be_bytes());
h[20..24].copy_from_slice(&16u32.to_be_bytes()); h[60..64].copy_from_slice(&nb.to_be_bytes());
h[64..72].copy_from_slice(&snap_off.to_be_bytes());
h
}
fn entry(
secs: u32,
nsecs: u32,
vm_state_size: u32,
extra: &[u8],
id: &[u8],
name: &[u8],
) -> Vec<u8> {
let mut e = vec![0u8; SNAPSHOT_HEADER_FIXED];
e[12..14].copy_from_slice(&(id.len() as u16).to_be_bytes());
e[14..16].copy_from_slice(&(name.len() as u16).to_be_bytes());
e[16..20].copy_from_slice(&secs.to_be_bytes());
e[20..24].copy_from_slice(&nsecs.to_be_bytes());
e[32..36].copy_from_slice(&vm_state_size.to_be_bytes());
e[36..40].copy_from_slice(&(extra.len() as u32).to_be_bytes());
e.extend_from_slice(extra);
e.extend_from_slice(id);
e.extend_from_slice(name);
let consumed = SNAPSHOT_HEADER_FIXED + extra.len() + id.len() + name.len();
let pad = (8 - (consumed % 8)) % 8;
e.extend(std::iter::repeat_n(0u8, pad));
e
}
#[test]
fn no_snapshots_returns_empty() {
let img = header(0, 0);
let f = write_tmp(&img);
assert!(snapshots(f.path()).unwrap().is_empty());
}
#[test]
fn nonzero_count_but_zero_offset_returns_empty() {
let img = header(3, 0);
let f = write_tmp(&img);
assert!(snapshots(f.path()).unwrap().is_empty());
}
#[test]
fn bad_magic_is_rejected() {
let mut img = header(0, 0);
img[0] = 0;
let f = write_tmp(&img);
assert!(matches!(snapshots(f.path()), Err(Qcow2Error::BadMagic)));
}
#[test]
fn too_small_is_rejected() {
let f = write_tmp(&[0u8; 8]);
assert!(matches!(snapshots(f.path()), Err(Qcow2Error::FileTooSmall)));
}
#[test]
fn open_nonexistent_is_io_error() {
assert!(snapshots(Path::new("/tmp/no_such_qcow2_snaps.qcow2")).is_err());
}
#[test]
fn parses_two_entries_with_extra_data() {
let snap_off = 65_536u64;
let mut img = header(2, snap_off);
img.resize(snap_off as usize, 0);
let extra1 = {
let mut x = vec![0u8; 16];
x[0..8].copy_from_slice(&4096u64.to_be_bytes());
x
};
img.extend(entry(1_700_000_000, 123, 4096, &extra1, b"1", b"alpha"));
img.extend(entry(1_700_000_050, 0, 0, &[], b"2", b"beta-name"));
let f = write_tmp(&img);
let snaps = snapshots(f.path()).unwrap();
assert_eq!(snaps.len(), 2);
assert_eq!(snaps[0].id, "1");
assert_eq!(snaps[0].name, "alpha");
assert_eq!(snaps[0].date_unix_secs, 1_700_000_000);
assert_eq!(snaps[0].date_nsecs, 123);
assert_eq!(snaps[0].vm_state_size, 4096);
assert_eq!(snaps[1].id, "2");
assert_eq!(snaps[1].name, "beta-name");
assert_eq!(snaps[1].date_unix_secs, 1_700_000_050);
assert_eq!(snaps[1].vm_state_size, 0);
}
#[test]
fn truncated_table_stops_gracefully() {
let snap_off = 512u64;
let mut img = header(3, snap_off);
img.resize(snap_off as usize, 0);
img.extend(entry(1_700_000_000, 0, 0, &[], b"1", b"only"));
img.extend_from_slice(&[0u8; 10]);
let f = write_tmp(&img);
let snaps = snapshots(f.path()).unwrap();
assert_eq!(snaps.len(), 1, "should recover the one fully-parsed entry");
assert_eq!(snaps[0].name, "only");
}
#[test]
fn count_is_capped_to_max_snapshots() {
let snap_off = 512u64;
let mut img = header(u32::MAX, snap_off);
img.resize(snap_off as usize, 0);
let f = write_tmp(&img);
assert!(snapshots(f.path()).unwrap().is_empty());
}
#[test]
fn oversized_string_sizes_are_capped() {
let snap_off = 512u64;
let mut img = header(1, snap_off);
img.resize(snap_off as usize, 0);
let mut e = vec![0u8; SNAPSHOT_HEADER_FIXED];
e[12..14].copy_from_slice(&u16::MAX.to_be_bytes());
e[14..16].copy_from_slice(&u16::MAX.to_be_bytes());
img.extend(e);
let f = write_tmp(&img);
assert!(snapshots(f.path()).unwrap().is_empty());
}
#[test]
fn extra_data_runs_off_end_stops_gracefully() {
let snap_off = 512u64;
let mut img = header(1, snap_off);
img.resize(snap_off as usize, 0);
let mut e = vec![0u8; SNAPSHOT_HEADER_FIXED];
e[36..40].copy_from_slice(&1000u32.to_be_bytes());
img.extend(e);
let f = write_tmp(&img);
assert!(snapshots(f.path()).unwrap().is_empty());
}
}