use anyhow::{Result, bail};
pub fn parse_topology_string(topology: &str) -> Result<(u32, u32, u32, u32)> {
let parts: Vec<&str> = topology.split(',').collect();
if parts.len() != 4 {
bail!(
"invalid topology '{topology}': expected 'numa_nodes,llcs,cores,threads' \
(e.g. '1,2,4,1')"
);
}
let fields: [(&str, &str); 4] = [
("numa_nodes", parts[0]),
("llcs", parts[1]),
("cores", parts[2]),
("threads", parts[3]),
];
let mut vals: [u32; 4] = [0; 4];
for (i, (name, raw)) in fields.iter().enumerate() {
vals[i] = raw
.parse::<u32>()
.map_err(|_| anyhow::anyhow!("invalid {name} value: '{raw}'"))?;
}
let [numa_nodes, llcs, cores, threads] = vals;
if numa_nodes == 0 || llcs == 0 || cores == 0 || threads == 0 {
bail!("invalid topology '{topology}': all values must be >= 1");
}
Ok((numa_nodes, llcs, cores, threads))
}
pub fn parse_disk_size_mib(s: &str) -> Result<u32> {
let lower = s.trim().to_ascii_lowercase();
if lower.is_empty() {
bail!("invalid disk size '{s}': empty");
}
if lower.ends_with("kb") || lower.ends_with("mb") || lower.ends_with("gb") {
bail!(
"invalid disk size '{s}': SI suffixes (kb/mb/gb) are \
not supported. Use one of b, kib, mib, gib \
(case-insensitive)."
);
}
let (num_str, suffix, unit_bytes): (&str, &str, u64) =
if let Some(rest) = lower.strip_suffix("gib") {
(rest, "gib", 1u64 << 30)
} else if let Some(rest) = lower.strip_suffix("mib") {
(rest, "mib", 1u64 << 20)
} else if let Some(rest) = lower.strip_suffix("kib") {
(rest, "kib", 1u64 << 10)
} else if let Some(rest) = lower.strip_suffix('b') {
(rest, "b", 1u64)
} else {
bail!(
"invalid disk size '{s}': missing unit suffix. Use one of \
b, kib, mib, gib (case-insensitive)."
);
};
let n = num_str.trim().parse::<u64>().map_err(|_| {
anyhow::anyhow!(
"invalid disk size '{s}': numeric portion '{num_str}' before \
'{suffix}' is not an unsigned integer"
)
})?;
let bytes = n
.checked_mul(unit_bytes)
.ok_or_else(|| anyhow::anyhow!("invalid disk size '{s}': {n}{suffix} overflows u64"))?;
if bytes == 0 {
bail!("invalid disk size '{s}': must be > 0");
}
let mib = 1u64 << 20;
if bytes % mib != 0 {
bail!(
"invalid disk size '{s}': {bytes} bytes is not a whole number \
of mebibytes (MiB). Round to a multiple of 1 MiB (= 1048576 \
bytes)."
);
}
let mib_count = bytes / mib;
if mib_count > u32::MAX as u64 {
bail!(
"invalid disk size '{s}': {mib_count} MiB exceeds u32::MAX \
(DiskConfig.capacity_mb is u32)"
);
}
Ok(mib_count as u32)
}
pub const DISK_HELP: &str = "Attach a raw virtio-blk disk to /dev/vda. \
Accepts a human-readable size with a unit suffix (case-insensitive): \
b, kib, mib, gib. IEC-only — SI variants (kb/mb/gb) are rejected to \
keep the contract unambiguous. The size must be a positive whole \
number of MiB (e.g. 256mib, 1gib). Omit to boot without a disk.";
pub fn parse_disk_arg(s: Option<&str>) -> Result<Option<crate::vmm::disk_config::DiskConfig>> {
match s {
Some(raw) => {
let mib = parse_disk_size_mib(raw)?;
Ok(Some(crate::vmm::disk_config::DiskConfig {
capacity_mb: mib,
..crate::vmm::disk_config::DiskConfig::default()
}))
}
None => Ok(None),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_topology_string_happy_path() {
let (n, l, c, t) = parse_topology_string("1,2,4,8").expect("valid");
assert_eq!((n, l, c, t), (1, 2, 4, 8));
}
#[test]
fn parse_topology_string_rejects_too_few_parts() {
let err = parse_topology_string("1,2,4").expect_err("3 parts must fail");
let rendered = format!("{err:#}");
assert!(
rendered.contains("invalid topology '1,2,4'"),
"error must echo the bad input: {rendered}",
);
assert!(
rendered.contains("numa_nodes,llcs,cores,threads"),
"error must name the expected shape: {rendered}",
);
}
#[test]
fn parse_topology_string_rejects_too_many_parts() {
let err = parse_topology_string("1,2,4,8,16").expect_err("5 parts must fail");
assert!(format!("{err:#}").contains("invalid topology"));
}
#[test]
fn parse_topology_string_names_failing_field() {
for (pos, field) in [(0, "numa_nodes"), (1, "llcs"), (2, "cores"), (3, "threads")] {
let mut parts = ["1"; 4];
parts[pos] = "abc";
let input = parts.join(",");
let err = parse_topology_string(&input).expect_err("non-numeric must fail");
let rendered = format!("{err:#}");
assert!(
rendered.contains(&format!("invalid {field} value: 'abc'")),
"pos {pos}: error must name the `{field}` field, got: {rendered}",
);
}
}
#[test]
fn parse_topology_string_rejects_zero_dimensions() {
for pos in 0..4 {
let mut parts = ["1"; 4];
parts[pos] = "0";
let input = parts.join(",");
let err = parse_topology_string(&input).expect_err("zero must fail");
let rendered = format!("{err:#}");
assert!(
rendered.contains(">= 1"),
"pos {pos}: error must cite the >=1 rule: {rendered}",
);
}
}
#[test]
fn parse_topology_string_accepts_u32_max() {
let big = u32::MAX;
let input = format!("{big},{big},{big},{big}");
let (n, l, c, t) = parse_topology_string(&input).expect("u32::MAX valid");
assert_eq!((n, l, c, t), (big, big, big, big));
}
#[test]
fn parse_topology_string_rejects_u32_overflow() {
let too_big = (u32::MAX as u64) + 1;
let input = format!("1,{too_big},4,1");
let err = parse_topology_string(&input).expect_err("overflow must fail");
assert!(
format!("{err:#}").contains(&format!("invalid llcs value: '{too_big}'")),
"overflow must surface field + bad token: {err:#}",
);
}
#[test]
fn parse_disk_size_mib_iec_suffixes() {
assert_eq!(parse_disk_size_mib("256mib").unwrap(), 256);
assert_eq!(parse_disk_size_mib("1gib").unwrap(), 1024);
assert_eq!(parse_disk_size_mib("10GIB").unwrap(), 10 * 1024);
assert_eq!(parse_disk_size_mib("1024kib").unwrap(), 1);
}
#[test]
fn parse_disk_size_mib_rejects_si_suffixes() {
for input in ["1kb", "1mb", "1gb", "256MB", "10GB"] {
let err = parse_disk_size_mib(input)
.expect_err(&format!("SI suffix '{input}' must be rejected"));
let rendered = format!("{err:#}");
assert!(
rendered.contains("SI suffixes"),
"expected SI-rejection diagnostic for {input:?}, got: {rendered}",
);
}
}
#[test]
fn parse_disk_size_mib_byte_suffix() {
assert_eq!(parse_disk_size_mib("1048576b").unwrap(), 1);
let err = parse_disk_size_mib("1048575b").expect_err("off-by-one byte must fail");
assert!(format!("{err:#}").contains("not a whole number"));
}
#[test]
fn parse_disk_size_mib_normalizes_input() {
assert_eq!(parse_disk_size_mib(" 256MiB ").unwrap(), 256);
assert_eq!(parse_disk_size_mib("1GiB").unwrap(), 1024);
}
#[test]
fn parse_disk_size_mib_rejects_missing_suffix() {
let err = parse_disk_size_mib("256").expect_err("bare integer must fail");
let rendered = format!("{err:#}");
assert!(rendered.contains("missing unit suffix"));
assert!(rendered.contains("kib"));
assert!(rendered.contains("mib"));
assert!(rendered.contains("gib"));
}
#[test]
fn parse_disk_size_mib_rejects_empty() {
assert!(parse_disk_size_mib("").is_err());
assert!(parse_disk_size_mib(" ").is_err());
}
#[test]
fn parse_disk_size_mib_rejects_zero() {
let err = parse_disk_size_mib("0mib").expect_err("zero must fail");
assert!(format!("{err:#}").contains("must be > 0"));
}
#[test]
fn parse_disk_size_mib_rejects_garbage_number() {
assert!(parse_disk_size_mib("abcmib").is_err());
assert!(parse_disk_size_mib("-5mib").is_err());
assert!(parse_disk_size_mib("3.5mib").is_err());
}
#[test]
fn parse_disk_size_mib_rejects_unknown_suffix() {
let err = parse_disk_size_mib("1tb").expect_err("tb is not currently accepted");
let rendered = format!("{err:#}");
assert!(rendered.contains("invalid disk size '1tb'"));
}
#[test]
fn parse_disk_size_mib_rejects_u32_overflow() {
let too_big_mib = (u32::MAX as u64) + 1;
let input = format!("{too_big_mib}mib");
let err = parse_disk_size_mib(&input).expect_err("> u32::MAX MiB must fail");
assert!(format!("{err:#}").contains("exceeds u32::MAX"));
}
#[test]
fn parse_disk_size_mib_rejects_u64_overflow() {
let input = format!("{}gib", u64::MAX);
let err = parse_disk_size_mib(&input).expect_err("u64 overflow must fail");
assert!(format!("{err:#}").contains("overflows u64"));
}
#[test]
fn parse_disk_arg_none_yields_no_disk() {
let got = parse_disk_arg(None).expect("None input must not error");
assert!(
got.is_none(),
"absent --disk must produce Ok(None), got: {got:?}",
);
}
#[test]
fn parse_disk_arg_some_size_uses_default_other_fields() {
let got = parse_disk_arg(Some("256mib"))
.expect("256mib must parse")
.expect("Some(...) input must yield Some(DiskConfig)");
let expected = crate::vmm::disk_config::DiskConfig {
capacity_mb: 256,
..crate::vmm::disk_config::DiskConfig::default()
};
assert_eq!(
got, expected,
"parse_disk_arg(\"256mib\") must equal DiskConfig::default() \
with capacity_mb=256: got {got:?}, expected {expected:?}",
);
}
#[test]
fn parse_disk_arg_garbage_propagates_size_error() {
let err =
parse_disk_arg(Some("garbage")).expect_err("malformed size must propagate parse error");
let rendered = format!("{err:#}");
assert!(
rendered.contains("invalid disk size"),
"expected size-parse diagnostic in disk-arg error, got: {rendered}",
);
}
}