use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::path::Path;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct Snapshot {
#[allow(dead_code)]
pub id: String,
pub name: String,
pub size: String,
pub date: String,
#[allow(dead_code)]
pub vm_clock: String,
}
#[derive(Debug, Deserialize)]
struct QemuImgInfo {
#[serde(default)]
format: String,
#[serde(rename = "virtual-size", default)]
virtual_size: u64,
#[serde(rename = "actual-size", default)]
actual_size: Option<u64>,
#[serde(rename = "cluster-size", default)]
cluster_size: Option<u64>,
#[serde(rename = "backing-filename")]
backing_filename: Option<String>,
#[serde(default)]
snapshots: Vec<QemuSnapshot>,
}
#[derive(Debug, Deserialize)]
struct QemuSnapshot {
id: String,
name: String,
#[serde(rename = "vm-state-size", default)]
vm_state_size: u64,
#[serde(rename = "date-sec", default)]
date_sec: i64,
#[serde(rename = "date-nsec", default)]
date_nsec: i64,
#[serde(rename = "vm-clock-sec", default)]
vm_clock_sec: i64,
#[serde(rename = "vm-clock-nsec", default)]
vm_clock_nsec: i64,
}
fn path_to_str(path: &Path) -> Result<&str> {
path.to_str()
.ok_or_else(|| anyhow::anyhow!("Path contains invalid UTF-8: {:?}", path))
}
pub fn validate_snapshot_name(name: &str) -> Result<String> {
let trimmed = name.trim();
if trimmed.is_empty() {
bail!("Snapshot name cannot be empty");
}
if trimmed.len() > 128 {
bail!("Snapshot name too long (max 128 characters)");
}
let sanitized: String = trimmed
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'_' }
})
.collect();
let sanitized = if sanitized.starts_with('-') {
format!("_{}", sanitized)
} else {
sanitized
};
Ok(sanitized)
}
pub fn list_snapshots(disk_path: &Path) -> Result<Vec<Snapshot>> {
let disk_str = path_to_str(disk_path)?;
let output = Command::new("qemu-img")
.args(["info", "--output=json", disk_str])
.output()
.context("Failed to run qemu-img info")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("qemu-img info failed: {}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let info: QemuImgInfo =
serde_json::from_str(&stdout).context("Failed to parse qemu-img JSON output")?;
let snapshots = info
.snapshots
.into_iter()
.map(|s| {
let size = format_size(s.vm_state_size);
let date = format_timestamp(s.date_sec, s.date_nsec);
let vm_clock = format_vm_clock(s.vm_clock_sec, s.vm_clock_nsec);
Snapshot {
id: s.id,
name: s.name,
size,
date,
vm_clock,
}
})
.collect();
Ok(snapshots)
}
fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.1}G", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1}M", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1}K", bytes as f64 / KB as f64)
} else {
format!("{}B", bytes)
}
}
fn format_timestamp(secs: i64, _nsecs: i64) -> String {
use chrono::{Local, TimeZone};
if let Some(dt) = Local.timestamp_opt(secs, 0).single() {
dt.format("%Y-%m-%d %H:%M:%S").to_string()
} else {
"unknown".to_string()
}
}
fn format_vm_clock(secs: i64, nsecs: i64) -> String {
let total_secs = secs as f64 + (nsecs as f64 / 1_000_000_000.0);
let hours = (total_secs / 3600.0) as u64;
let minutes = ((total_secs % 3600.0) / 60.0) as u64;
let seconds = total_secs % 60.0;
format!("{:02}:{:02}:{:06.3}", hours, minutes, seconds)
}
pub fn create_snapshot(disk_path: &Path, name: &str) -> Result<()> {
let disk_str = path_to_str(disk_path)?;
let sanitized_name = validate_snapshot_name(name)?;
let output = Command::new("qemu-img")
.args(["snapshot", "-c", &sanitized_name, disk_str])
.output()
.context("Failed to run qemu-img snapshot -c")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to create snapshot: {}", stderr);
}
Ok(())
}
pub fn restore_snapshot(disk_path: &Path, name: &str) -> Result<()> {
let disk_str = path_to_str(disk_path)?;
let sanitized_name = validate_snapshot_name(name)?;
let output = Command::new("qemu-img")
.args(["snapshot", "-a", &sanitized_name, disk_str])
.output()
.context("Failed to run qemu-img snapshot -a")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to restore snapshot: {}", stderr);
}
Ok(())
}
pub fn delete_snapshot(disk_path: &Path, name: &str) -> Result<()> {
let disk_str = path_to_str(disk_path)?;
let sanitized_name = validate_snapshot_name(name)?;
let output = Command::new("qemu-img")
.args(["snapshot", "-d", &sanitized_name, disk_str])
.output()
.context("Failed to run qemu-img snapshot -d")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to delete snapshot: {}", stderr);
}
Ok(())
}
#[allow(dead_code)]
pub fn get_disk_info(disk_path: &Path) -> Result<DiskInfo> {
let disk_str = path_to_str(disk_path)?;
let output = Command::new("qemu-img")
.args(["info", "--output=json", disk_str])
.output()
.context("Failed to run qemu-img info")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to get disk info: {}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let info: QemuImgInfo =
serde_json::from_str(&stdout).context("Failed to parse qemu-img JSON output")?;
Ok(DiskInfo {
format: info.format,
virtual_size: format_size(info.virtual_size),
disk_size: info
.actual_size
.map(format_size)
.unwrap_or_else(|| "unknown".to_string()),
cluster_size: info.cluster_size.map(format_size),
backing_file: info.backing_filename,
})
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct DiskInfo {
pub format: String,
pub virtual_size: String,
pub disk_size: String,
pub cluster_size: Option<String>,
pub backing_file: Option<String>,
}
#[cfg(test)]
#[path = "tests/snapshot.rs"]
mod tests;