use serde::{Deserialize, Serialize};
use super::common::{DriveId, SafePath};
#[derive(Debug, Clone, Copy, Eq, PartialEq, Default, Serialize, Deserialize)]
pub enum CacheType {
#[default]
Unsafe,
Writeback,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Default, Serialize, Deserialize)]
pub enum IoEngine {
#[default]
Sync,
Async,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RawDriveConfig {
pub drive_id: String,
pub path_on_host: String,
#[serde(default)]
pub is_root_device: bool,
#[serde(default)]
pub is_read_only: bool,
#[serde(default)]
pub cache_type: CacheType,
#[serde(default)]
pub io_engine: IoEngine,
#[serde(default)]
pub partuuid: Option<String>,
#[serde(default)]
pub rate_limiter: Option<serde_json::Value>,
#[serde(default)]
pub socket: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct DriveConfig {
pub drive_id: DriveId,
pub path_on_host: SafePath,
pub is_root_device: bool,
pub is_read_only: bool,
pub cache_type: CacheType,
pub io_engine: IoEngine,
pub partuuid: Option<String>,
pub rate_limiter: Option<serde_json::Value>,
pub vhost_user_socket: Option<String>,
}
fn validate_partuuid(uuid: &str) -> Result<(), String> {
if uuid.is_empty() || uuid.len() > 64 {
return Err(format!(
"Invalid partuuid: must be 1..=64 bytes (got {} bytes)",
uuid.len()
));
}
if !uuid.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
return Err("Invalid partuuid: only [A-Za-z0-9-] permitted".into());
}
Ok(())
}
impl TryFrom<RawDriveConfig> for DriveConfig {
type Error = String;
fn try_from(raw: RawDriveConfig) -> Result<Self, Self::Error> {
let drive_id = DriveId::new(raw.drive_id)?;
let path_on_host =
SafePath::new(raw.path_on_host).map_err(|e| format!("Invalid path_on_host: {e}"))?;
if let Some(p) = raw.partuuid.as_deref() {
validate_partuuid(p)?;
}
let socket = match raw.socket {
Some(s) if s.is_empty() => return Err("Invalid socket: must not be empty".into()),
Some(s) if s.len() > 1024 => {
return Err("Invalid socket: exceeds 1024 bytes".into());
}
other => other,
};
Ok(Self {
drive_id,
path_on_host,
is_root_device: raw.is_root_device,
is_read_only: raw.is_read_only,
cache_type: raw.cache_type,
io_engine: raw.io_engine,
partuuid: raw.partuuid,
rate_limiter: raw.rate_limiter,
vhost_user_socket: socket,
})
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RawDrivePatch {
pub drive_id: String,
#[serde(default)]
pub path_on_host: Option<String>,
#[serde(default)]
pub rate_limiter: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct DrivePatch {
pub drive_id: DriveId,
pub path_on_host: Option<SafePath>,
pub rate_limiter: Option<serde_json::Value>,
}
impl TryFrom<RawDrivePatch> for DrivePatch {
type Error = String;
fn try_from(raw: RawDrivePatch) -> Result<Self, Self::Error> {
let drive_id = DriveId::new(raw.drive_id)?;
let path_on_host = match raw.path_on_host {
Some(p) => Some(SafePath::new(p).map_err(|e| format!("Invalid path_on_host: {e}"))?),
None => None,
};
Ok(Self {
drive_id,
path_on_host,
rate_limiter: raw.rate_limiter,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn raw(id: &str) -> RawDriveConfig {
RawDriveConfig {
drive_id: id.into(),
path_on_host: "/tmp/img.bin".into(),
is_root_device: false,
is_read_only: false,
cache_type: CacheType::Unsafe,
io_engine: IoEngine::Sync,
partuuid: None,
rate_limiter: None,
socket: None,
}
}
#[test]
fn test_should_accept_minimal_drive() {
let cfg = DriveConfig::try_from(raw("rootfs")).unwrap();
assert_eq!(cfg.drive_id.as_str(), "rootfs");
}
#[test]
fn test_should_reject_invalid_drive_id() {
assert!(DriveConfig::try_from(raw("root-fs")).is_err());
}
#[test]
fn test_should_validate_partuuid() {
let mut r = raw("rootfs");
r.partuuid = Some("ABC123-def456".into());
assert!(DriveConfig::try_from(r).is_ok());
}
#[test]
fn test_should_reject_partuuid_with_special_chars() {
let mut r = raw("rootfs");
r.partuuid = Some("ABC*1".into());
assert!(DriveConfig::try_from(r).is_err());
}
#[test]
fn test_should_default_io_engine_to_sync() {
let json = r#"{"drive_id":"rootfs","path_on_host":"/tmp/img.bin"}"#;
let raw: RawDriveConfig = serde_json::from_str(json).unwrap();
assert_eq!(raw.io_engine, IoEngine::Sync);
}
#[test]
fn test_should_round_trip_drive_patch() {
let json = r#"{"drive_id":"rootfs","path_on_host":"/tmp/x.bin"}"#;
let raw: RawDrivePatch = serde_json::from_str(json).unwrap();
let p = DrivePatch::try_from(raw).unwrap();
assert_eq!(p.drive_id.as_str(), "rootfs");
assert!(p.path_on_host.is_some());
}
}