Skip to main content

openvmm_entry/
cli_args.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! CLI argument parsing.
5//!
6//! Code in this module must not instantiate any complex VM objects!
7//!
8//! In other words, this module is only responsible for marshalling raw CLI
9//! strings into typed Rust structs/enums, and should consist of entirely _pure
10//! functions_.
11//!
12//! e.g: instead of opening a `File` directly, parse the specified file path
13//! into a `PathBuf`, and allow later parts of the init flow to handle opening
14//! the file.
15
16// NOTE: This module itself is not pub, but the Options struct below is
17//       re-exported as pub in main to make this lint fire. It won't fire on
18//       anything else on this file though.
19#![warn(missing_docs)]
20
21use crate::usb::UsbBackend;
22use anyhow::Context;
23use clap::Parser;
24use clap::ValueEnum;
25use cxl_spec::CfmwsWindowRestrictions;
26use guid::Guid;
27use net_backend_resources::mac_address::MacAddress;
28use openvmm_defs::config::DEFAULT_PCAT_BOOT_ORDER;
29use openvmm_defs::config::DeviceVtl;
30use openvmm_defs::config::PcatBootDevice;
31use openvmm_defs::config::Vtl2BaseAddressType;
32use openvmm_defs::config::X2ApicConfig;
33use std::ffi::OsString;
34use std::net::SocketAddr;
35use std::path::PathBuf;
36use std::str::FromStr;
37use thiserror::Error;
38
39/// Parse CLI options, using a thread with a larger stack on Windows to avoid
40/// stack overflow in debug builds due to clap's deep stack usage.
41/// See <https://github.com/clap-rs/clap/issues/5134>.
42pub(crate) fn parse_options() -> Options {
43    // In non-optimized builds, clap uses an embarrassing amount of stack space
44    // to construct the `Command` instance for `Options`, more than the Windows
45    // default of 1MB. This has been known since 2023:
46    // <https://github.com/clap-rs/clap/issues/5134>, but no one has stepped up
47    // to fix it.
48    //
49    // Work around this by running the code on a thread with lots of stack
50    // space. This is easier and more reliable than configuring the PE binary to
51    // have a larger stack.
52    fn on_big_stack<R: Send>(f: impl Send + FnOnce() -> R) -> R {
53        if cfg!(windows) {
54            std::thread::scope(|s| {
55                std::thread::Builder::new()
56                    .stack_size(0x400000)
57                    .spawn_scoped(s, f)
58                    .unwrap()
59                    .join()
60                    .unwrap()
61            })
62        } else {
63            f()
64        }
65    }
66
67    on_big_stack(Options::parse)
68}
69
70const DEFAULT_MEMORY_SIZE: u64 = 1024 * 1024 * 1024;
71
72/// Guest memory configuration parsed from `--memory` (and, flattened, from
73/// `--numa`).
74///
75/// Fields are the raw parsed options; callers apply the defaults (`size`
76/// defaults to [`DEFAULT_MEMORY_SIZE`]; `transparent_hugepages` defaults to
77/// `!hugepages`). Cross-field validation lives in [`MemoryCli::validate`].
78#[derive(Debug, Clone, Default, PartialEq, Eq, vmm_cli::KeyValueArgs)]
79pub struct MemoryCli {
80    /// Guest RAM size. Defaults to [`DEFAULT_MEMORY_SIZE`] when unset.
81    pub size: Option<vmm_cli::MemorySize>,
82    /// Whether shared file-backed memory was explicitly requested (tri-state:
83    /// unset / `on` / `off`).
84    #[kv(flag)]
85    pub shared: Option<bool>,
86    /// Whether to prefetch guest RAM.
87    #[kv(flag)]
88    pub prefetch: bool,
89    /// Whether to use transparent huge pages. When unset, defaults to enabled
90    /// unless `hugepages` is set.
91    #[kv(flag, key = "thp")]
92    pub transparent_hugepages: Option<bool>,
93    /// Whether to use explicit hugetlb memfd backing for guest RAM.
94    #[kv(flag)]
95    pub hugepages: bool,
96    /// Explicit hugetlb page size.
97    pub hugepage_size: Option<vmm_cli::MemorySize>,
98    /// File used to back guest RAM.
99    pub file: Option<PathBuf>,
100}
101
102impl MemoryCli {
103    /// Validate cross-field constraints shared by `--memory` and `--numa`.
104    pub fn validate(&self) -> anyhow::Result<()> {
105        if self.hugepage_size.is_some() && !self.hugepages {
106            anyhow::bail!("hugepage_size requires hugepages=on");
107        }
108        if self.hugepages {
109            if self.shared == Some(false) {
110                anyhow::bail!("hugepages=on conflicts with shared=off");
111            }
112            if self.file.is_some() {
113                anyhow::bail!("hugepages=on conflicts with file=...");
114            }
115        }
116        Ok(())
117    }
118}
119
120/// NUMA node configuration parsed from `--numa`.
121#[derive(Debug, Clone, PartialEq, Eq, vmm_cli::KeyValueArgs)]
122pub struct NumaNodeCli {
123    /// Memory configuration (size, shared, prefetch, hugepages, etc.).
124    #[kv(flatten)]
125    pub memory: MemoryCli,
126    /// Host NUMA node to bind memory allocation to.
127    pub host_numa_node: Option<u32>,
128    /// Explicit VP indices for this node.
129    pub vps: Option<vmm_cli::BracketRangeList>,
130}
131
132/// NUMA distance parsed from `--numa-distance`.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct NumaDistanceCli {
135    /// Source node index.
136    pub src: u32,
137    /// Destination node index.
138    pub dst: u32,
139    /// Distance value (10-255, 255 = unreachable).
140    pub distance: u8,
141}
142
143/// OpenVMM virtual machine monitor.
144///
145/// This is not yet a stable interface and may change radically between
146/// versions.
147#[derive(Parser)]
148#[command(
149    name = "openvmm",
150    version = openvmm_build_info::get().version(),
151    long_version = openvmm_build_info::get().long_version(),
152)]
153pub struct Options {
154    /// processor count
155    #[clap(short = 'p', long, value_name = "COUNT", default_value = "1")]
156    pub processors: u32,
157
158    /// guest RAM configuration (`SIZE` or `key=value[,key=value...]`)
159    #[clap(
160        short = 'm',
161        long,
162        value_name = "PARAMS",
163        default_value = "1GB",
164        value_parser = parse_memory_config,
165        conflicts_with = "numa",
166        long_help = r#"Configure guest RAM.
167
168Syntax: SIZE | key=value[,key=value...]
169
170Size suffixes accept K, M, G, and T, optionally followed by B.
171
172Options:
173    size=<SIZE>              guest RAM size, default 1GB
174    shared[=on|off]          use shared file-backed RAM, default on
175    prefetch[=on|off]        pre-populate guest RAM mappings
176    thp[=on|off]             mark guest RAM as THP-eligible (Linux), default on
177    hugepages[=on|off]       allocate RAM from hugetlb/large pages (Linux, Windows)
178    hugepage_size=<SIZE>     hugepage size, default 2MB; requires hugepages=on
179    file=<PATH>              use an existing file as guest RAM backing
180
181Examples:
182    --memory 4G
183    --memory size=64GB,hugepages=on,hugepage_size=2MB
184    --memory size=4G,file=path/to/memory.bin
185    --memory size=4G,thp=off"#
186    )]
187    pub memory: MemoryCli,
188
189    /// NUMA node configuration (repeatable, one per node).
190    ///
191    /// Each --numa specifies one guest NUMA node. Mutually exclusive with
192    /// --memory.
193    #[clap(
194        long,
195        value_name = "PARAMS",
196        value_parser = parse_numa_node,
197        conflicts_with = "memory",
198        long_help = r#"Configure a guest NUMA node (repeatable, one per node).
199
200Syntax: key=value[,key=value...]
201
202Options:
203    size=<SIZE>              RAM for this node (required)
204    shared[=on|off]          use shared file-backed RAM, default on
205    prefetch[=on|off]        pre-populate guest RAM mappings
206    thp[=on|off]             mark node RAM as THP-eligible (Linux), default on
207    hugepages[=on|off]       allocate RAM from hugetlb/large pages (Linux, Windows)
208    hugepage_size=<SIZE>     hugepage size, default 2MB; requires hugepages=on
209    host_numa_node=<N>       bind allocation to host NUMA node N
210    vps=<LIST>               explicit VP indices (e.g. "[0,1,2,3]")
211
212  VP lists use bracket syntax with comma-separated indices and dash
213  ranges: vps=[0,1] or vps=[0-3] or vps=[0,1,4-5]. An empty list, vps=[],
214  declares a CPU-less node (e.g. a generic-initiator target); unlike a
215  non-empty list, it may be combined with nodes that omit vps.
216
217Examples:
218    --numa size=2G --numa size=2G
219    --numa size=2G,host_numa_node=0 --numa size=2G,host_numa_node=1
220    --numa size=2G,hugepages=on,vps=[0,1] --numa size=2G,vps=[2,3]
221    --numa size=2G,vps=[0-3] --numa size=2G,vps=[4-7]
222    --numa size=2G --numa size=0,vps=[]"#
223    )]
224    pub numa: Option<Vec<NumaNodeCli>>,
225
226    /// NUMA distance (repeatable). Format: SRC:DST:DISTANCE.
227    ///
228    /// SRC and DST are 0-based node indices. DISTANCE is 10-255 (10 = local, 255 = unreachable).
229    /// Specify each direction explicitly (not auto-symmetric).
230    #[clap(long, value_name = "SRC:DST:DIST", value_parser = parse_numa_distance, conflicts_with = "memory", requires = "numa")]
231    pub numa_distance: Option<Vec<NumaDistanceCli>>,
232
233    /// use shared memory segment
234    #[clap(short = 'M', long, hide = true)]
235    pub shared_memory: bool,
236
237    /// prefetch guest RAM
238    #[clap(long = "prefetch", hide = true, conflicts_with = "numa")]
239    pub deprecated_prefetch: bool,
240
241    /// back guest RAM with a file instead of anonymous memory.
242    /// The file is created/opened and sized to the guest RAM size.
243    /// Enables snapshot save (fsync) and restore (open + mmap).
244    #[clap(
245        long = "memory-backing-file",
246        value_name = "FILE",
247        hide = true,
248        conflicts_with_all = ["deprecated_private_memory", "numa"]
249    )]
250    pub deprecated_memory_backing_file: Option<PathBuf>,
251
252    /// Restore VM from a snapshot directory (implies file-backed memory from
253    /// the snapshot's memory.bin). Cannot be used with --memory-backing-file.
254    /// The same boot and device flags used at save time must also be supplied.
255    #[clap(
256        long,
257        value_name = "DIR",
258        conflicts_with_all = ["deprecated_memory_backing_file", "numa"]
259    )]
260    pub restore_snapshot: Option<PathBuf>,
261
262    /// use private anonymous memory for guest RAM
263    #[clap(long = "private-memory", hide = true, conflicts_with_all = ["deprecated_memory_backing_file", "restore_snapshot", "numa"])]
264    pub deprecated_private_memory: bool,
265
266    /// enable transparent huge pages for guest RAM (Linux only; deprecated, THP is on by default)
267    #[clap(long = "thp", hide = true, conflicts_with = "numa")]
268    pub deprecated_thp: bool,
269
270    /// start in paused state
271    #[clap(short = 'P', long)]
272    pub paused: bool,
273
274    /// kernel image (when using linux direct boot)
275    #[clap(short = 'k', long, value_name = "FILE", default_value = default_value_from_arch_env("OPENVMM_LINUX_DIRECT_KERNEL"))]
276    pub kernel: OptionalPathBuf,
277
278    /// initrd image (when using linux direct boot)
279    #[clap(short = 'r', long, value_name = "FILE", default_value = default_value_from_arch_env("OPENVMM_LINUX_DIRECT_INITRD"))]
280    pub initrd: OptionalPathBuf,
281
282    /// extra kernel command line args
283    #[clap(short = 'c', long, value_name = "STRING")]
284    pub cmdline: Vec<String>,
285
286    /// enable HV#1 capabilities
287    #[clap(long)]
288    pub hv: bool,
289
290    /// Boot UEFI without exposing hypervisor (HV#1) enlightenments. Requires
291    /// `--no-vmbus` since VMBus depends on the hypervisor.
292    #[clap(
293        long,
294        requires_all = ["uefi", "no_vmbus"],
295        conflicts_with_all = ["hv", "vtl2", "get", "pcat", "igvm"]
296    )]
297    pub no_hv: bool,
298
299    /// Use a full device tree instead of ACPI tables for ARM64 Linux direct
300    /// boot. By default, ARM64 uses ACPI mode (stub DT + EFI + ACPI tables).
301    /// This flag selects the legacy DT-only path. Rejected on x86.
302    #[clap(long, conflicts_with_all = ["uefi", "pcat", "igvm", "smbios"])]
303    pub device_tree: bool,
304
305    /// SMBIOS (DMI) identity overrides (repeatable).
306    #[clap(
307        long,
308        value_name = "PARAMS",
309        value_parser = parse_smbios,
310        long_help = r#"Override the guest's SMBIOS (DMI) identity.
311
312Syntax: type=N,key=value[,key=value...]
313
314`type` selects the SMBIOS structure and is required; there is no default.
315Unset keys use the loader's built-in default identity. Repeat --smbios to set
316fields across multiple structure types.
317
318OpenVMM Linux direct boot supports all listed fields. OpenHCL Linux direct
319boot and UEFI boot support Type 1 fields only. PCAT boot supports only Type 1
320`serial` and `uuid`. Unsupported fields are rejected with an error.
321
322Type 0 (BIOS Information):
323    vendor=<STRING>          BIOS vendor
324    version=<STRING>         BIOS version
325    date=<STRING>            BIOS release date
326    release=<MAJOR.MINOR>    System BIOS Major/Minor Release (e.g. 4.1)
327
328Type 1 (System Information):
329    manufacturer=<STRING>    system manufacturer (sys_vendor)
330    product=<STRING>         product name (product_name)
331    version=<STRING>         product version (product_version)
332    serial=<STRING>          serial number (product_serial)
333    uuid=<GUID|random>       system UUID (product_uuid); `random` generates a
334                             fresh per-VM GUID. Unset => all-zero GUID.
335    sku=<STRING>             SKU number (product_sku)
336    family=<STRING>          product family (product_family)
337
338Examples:
339    --smbios type=1,manufacturer=Contoso,product="Virtual Machine"
340    --smbios type=1,uuid=12345678-9abc-def0-1234-56789abcdef0
341    --smbios type=1,uuid=random
342    --smbios type=0,vendor=Contoso,version=1.0"#
343    )]
344    pub smbios: Vec<SmbiosCli>,
345
346    /// enable vtl2 - only supported in WHP and simulated without hypervisor support currently
347    ///
348    /// Currently implies --get.
349    #[clap(long, requires("hv"))]
350    pub vtl2: bool,
351
352    /// Add GET and related devices for using the OpenHCL paravisor to the
353    /// highest enabled VTL.
354    #[clap(long, requires("hv"))]
355    pub get: bool,
356
357    /// Disable GET and related devices for using the OpenHCL paravisor, even
358    /// when --vtl2 is passed.
359    #[clap(long, conflicts_with("get"))]
360    pub no_get: bool,
361
362    /// Run without VMBus, even if --hv or --uefi are specified.
363    #[clap(
364        long,
365        conflicts_with_all = [
366            "vmbus_vsock_path",
367            "vmbus_vtl2_vsock_path",
368            "vmbus_redirect",
369            "vmbus_max_version",
370            "vmbus_com1_serial",
371            "vmbus_com2_serial",
372            "vtl2",
373            "get",
374            "pcat",
375        ],
376    )]
377    pub no_vmbus: bool,
378
379    /// disable the VTL0 alias map presented to VTL2 by default
380    #[clap(long, requires("vtl2"))]
381    pub no_alias_map: bool,
382
383    /// enable isolation
384    #[clap(long)]
385    pub isolation: Option<IsolationCli>,
386
387    /// enable restricted interrupt injection for SNP Linux direct boot
388    #[clap(long)]
389    pub snp_restricted_injection: bool,
390
391    /// the hybrid vsock listener path
392    #[clap(long, value_name = "PATH", alias = "vsock-path")]
393    pub vmbus_vsock_path: Option<String>,
394
395    /// the VTL2 hybrid vsock listener path
396    #[clap(long, value_name = "PATH", requires("vtl2"), alias = "vtl2-vsock-path")]
397    pub vmbus_vtl2_vsock_path: Option<String>,
398
399    /// the late map vtl0 ram access policy when vtl2 is enabled
400    #[clap(long, requires("vtl2"), default_value = "halt")]
401    pub late_map_vtl0_policy: Vtl0LateMapPolicyCli,
402
403    /// attach a disk (can be passed multiple times)
404    #[clap(long_help = r#"
405e.g: --disk memdiff:file:/path/to/disk.vhd
406
407syntax: <path> | kind:<arg>[,flag,opt=arg,...]
408
409valid disk kinds:
410    `mem:<len>`                    memory backed disk
411        <len>: length of ramdisk, e.g.: `1G`
412    `memdiff:<disk>`               memory backed diff disk
413        <disk>: lower disk, e.g.: `file:base.img`
414    `file:<path>[;direct][;create=<len>]`   file-backed disk
415        <path>: path to file
416        `;direct`: bypass the OS page cache
417    `sql:<path>[;create=<len>]`    SQLite-backed disk (dev/test)
418    `sqldiff:<path>[;create]:<disk>` SQLite diff layer on a backing disk
419    `autocache:<key>:<disk>`       auto-cached SQLite layer (use `autocache::<disk>` to omit key; needs OPENVMM_AUTO_CACHE_PATH)
420    `blob:<type>:<url>`            HTTP blob (read-only)
421        <type>: `flat` or `vhd1`
422    `crypt:<cipher>:<key_file>:<disk>` encrypted disk wrapper
423        <cipher>: `xts-aes-256`
424    `prwrap:<disk>`                persistent reservations wrapper
425
426flags:
427    `ro`                           open disk as read-only
428    `dvd`                          specifies that device is cd/dvd and it is read_only
429    `vtl2`                         assign this disk to VTL2
430    `uh`                           relay this disk to VTL0 through SCSI-to-OpenHCL (show to VTL0 as SCSI)
431    `uh-nvme`                      relay this disk to VTL0 through NVMe-to-OpenHCL (show to VTL0 as SCSI)
432
433options:
434    `pcie_port=<name>`             present the disk using pcie under the specified port, incompatible with `dvd`, `vtl2`, `uh`, and `uh-nvme`
435    `on=<name>`                    attach to a named controller (NVMe or SCSI), incompatible with `pcie_port` and `vtl2`
436    `nsid=<N>`                     NVMe namespace ID (1-based), requires `on`; auto-assigned if omitted
437    `lun=<N>`                      SCSI LUN (0-based), requires `on`; auto-assigned if omitted
438    `relay=<ctrl>[:<loc>]`         relay through OpenHCL to the named OpenHCL controller, with optional location (LUN or NSID)
439"#)]
440    #[clap(long, value_name = "FILE")]
441    pub disk: Vec<DiskCli>,
442
443    /// \[deprecated\] attach a disk via an NVMe controller
444    ///
445    /// Use --nvme-pci and --disk on=\<name\> instead.
446    #[clap(long_help = r#"
447e.g: --nvme memdiff:file:/path/to/disk.vhd
448
449syntax: <path> | kind:<arg>[,flag,opt=arg,...]
450
451valid disk kinds:
452    `mem:<len>`                    memory backed disk
453        <len>: length of ramdisk, e.g.: `1G`
454    `memdiff:<disk>`               memory backed diff disk
455        <disk>: lower disk, e.g.: `file:base.img`
456    `file:<path>[;direct][;create=<len>]`   file-backed disk
457        <path>: path to file
458        `;direct`: bypass the OS page cache
459    `sql:<path>[;create=<len>]`    SQLite-backed disk (dev/test)
460    `sqldiff:<path>[;create]:<disk>` SQLite diff layer on a backing disk
461    `autocache:<key>:<disk>`       auto-cached SQLite layer (use `autocache::<disk>` to omit key; needs OPENVMM_AUTO_CACHE_PATH)
462    `blob:<type>:<url>`            HTTP blob (read-only)
463        <type>: `flat` or `vhd1`
464    `crypt:<cipher>:<key_file>:<disk>` encrypted disk wrapper
465        <cipher>: `xts-aes-256`
466    `prwrap:<disk>`                persistent reservations wrapper
467
468flags:
469    `ro`                           open disk as read-only
470    `vtl2`                         assign this disk to VTL2
471    `uh`                           relay this disk to VTL0 through SCSI-to-OpenHCL (show to VTL0 as NVMe)
472    `uh-nvme`                      relay this disk to VTL0 through NVMe-to-OpenHCL (show to VTL0 as NVMe)
473
474options:
475    `pcie_port=<name>`             present the disk using pcie under the specified port, incompatible with `vtl2`, `uh`, and `uh-nvme`
476"#)]
477    #[clap(long)]
478    pub nvme: Vec<DiskCli>,
479
480    /// create a named NVMe controller
481    #[clap(long_help = r#"
482Create a named NVMe controller with an explicit transport.
483
484syntax: id=<name>,pcie_port=<port> | id=<name>,vpci[=<guid>]
485
486The controller name can be referenced by `--disk` with the `on=<name>`
487option to attach namespaces to this controller.
488
489options:
490    `id=<name>`                    controller name (required)
491    `pcie_port=<port>`             present on PCIe under the specified port
492    `vpci[=<guid>]`                present via VPCI; optional instance GUID
493    `vtl2`                         assign to VTL2 (default VTL0)
494
495Exactly one of `pcie_port` or `vpci` must be specified.
496
497Examples:
498    --nvme-pci id=nvme0,pcie_port=p0
499    --nvme-pci id=nvme1,vpci
500    --nvme-pci id=nvme2,vpci=008091f6-9688-497d-9091-af347dc9173c
501"#)]
502    #[clap(long = "nvme-pci")]
503    pub nvme_pci: Vec<NvmeControllerCli>,
504
505    /// create a named VMBus SCSI controller
506    #[clap(long_help = r#"
507Create a named VMBus SCSI controller.
508
509syntax: id=<name>[,sub_channels=<N>][,vtl2]
510
511The controller name can be referenced by `--disk` with the `on=<name>`
512option to attach disks to this controller.
513
514options:
515    `id=<name>`                    controller name (required)
516    `sub_channels=<N>`             number of sub-channels (default 0)
517    `vtl2`                         assign to VTL2 (default VTL0)
518
519Examples:
520    --vmbus-scsi id=scsi0
521    --vmbus-scsi id=scsi1,sub_channels=4
522"#)]
523    #[clap(long = "vmbus-scsi")]
524    pub vmbus_scsi: Vec<ScsiControllerCli>,
525
526    /// register an OpenHCL-managed storage controller (relay target)
527    #[clap(long_help = r#"
528Register an OpenHCL-managed storage controller that can be used as a
529relay target with `--disk ... relay=<name>`.
530
531syntax: id=<name>,type=scsi|nvme[,guid=<guid>]
532
533options:
534    `id=<name>`                    controller name (required)
535    `type=scsi|nvme`               controller protocol (required)
536    `guid=<guid>`                  instance GUID (auto-derived from name if omitted)
537
538Examples:
539    --openhcl-controller id=vtl0-scsi,type=scsi
540    --openhcl-controller id=vtl0-nvme,type=nvme,guid=09a59b81-...
541"#)]
542    #[clap(long = "openhcl-controller")]
543    pub openhcl_controller: Vec<OpenhclControllerCli>,
544
545    /// attach a CXL Type-3 test endpoint on a PCIe root port
546    #[clap(long = "cxl-test", value_name = "mem:<len>,pcie_port=<name>")]
547    pub cxl_test: Vec<CxlTestDeviceCli>,
548
549    /// attach a disk via a virtio-blk controller
550    #[clap(long_help = r#"
551e.g: --virtio-blk memdiff:file:/path/to/disk.vhd
552
553syntax: <path> | kind:<arg>[,flag,opt=arg,...]
554
555valid disk kinds:
556    `mem:<len>`                    memory backed disk
557        <len>: length of ramdisk, e.g.: `1G`
558    `memdiff:<disk>`               memory backed diff disk
559        <disk>: lower disk, e.g.: `file:base.img`
560    `file:<path>[;direct]`                  file-backed disk
561        <path>: path to file
562        `;direct`: bypass the OS page cache
563
564flags:
565    `ro`                           open disk as read-only
566
567options:
568    `pcie_port=<name>`             present the disk using pcie under the specified port
569    `serial=<value>`               1-20 printable ASCII bytes except comma and brackets
570"#)]
571    #[clap(long = "virtio-blk")]
572    pub virtio_blk: Vec<DiskCli>,
573
574    /// Attach a vhost-user device via a Unix socket.
575    ///
576    /// The first positional argument is the socket path. Options:
577    ///
578    /// ```text
579    ///   type=blk|fs                        — device type (shorthand)
580    ///   device_id=N                        — numeric virtio device ID
581    ///   tag=NAME                           — mount tag (required for type=fs)
582    ///   num_queues=N                       — queue count (type=blk/fs only)
583    ///   queue_size=N                       — per-queue size (type=blk/fs only)
584    ///   queue_sizes=[N,N,N]                — per-queue sizes (device_id= only)
585    ///   pcie_port=NAME                     — present on PCIe under the specified port
586    /// ```
587    ///
588    /// Examples:
589    ///
590    /// ```text
591    ///   --vhost-user /tmp/vhost.sock,type=blk
592    ///   --vhost-user /tmp/vhost.sock,type=blk,num_queues=4,queue_size=512
593    ///   --vhost-user /tmp/vhost.sock,device_id=2,queue_sizes=[128,128]
594    ///   --vhost-user /tmp/vhost.sock,type=blk,pcie_port=port0
595    ///   --vhost-user /tmp/virtiofsd.sock,type=fs,tag=myfs
596    ///   --vhost-user /tmp/virtiofsd.sock,type=fs,tag=myfs,num_queues=2,queue_size=1024
597    /// ```
598    #[cfg(target_os = "linux")]
599    #[clap(long = "vhost-user")]
600    pub vhost_user: Vec<VhostUserCli>,
601
602    /// number of sub-channels for the SCSI controller
603    #[clap(long, value_name = "COUNT", default_value = "0")]
604    pub scsi_sub_channels: u16,
605
606    /// expose a virtual NIC
607    #[clap(long)]
608    pub nic: bool,
609
610    /// expose a virtual NIC with the given backend (consomme | dio | tap | none)
611    ///
612    /// Prefix with `uh:` to add this NIC via Mana emulation through OpenHCL,
613    /// `vtl2:` to assign this NIC to VTL2, or `pcie_port=<port_name>:` to
614    /// expose the NIC over emulated PCIe at the specified port.
615    /// Prefix with `mac=<XX-XX-XX-XX-XX-XX>:` to set the guest MAC address.
616    /// Prefix a virtio NIC with `rx_gso:` to advertise guest TCP segmentation
617    /// offload. This is disabled by default until mergeable receive buffers are
618    /// supported.
619    ///
620    /// For consomme, forward host ports into the guest with `hostfwd=`:
621    ///   --net consomme:hostfwd=tcp::3389-:3389
622    ///   --net consomme:hostfwd=tcp:127.0.0.1:8080-:80
623    ///   --net consomme:hostfwd=tcp:\[::1\]:8080-:80
624    ///   --net consomme:10.0.0.0/24,hostfwd=tcp::22-:22,hostfwd=udp::5000-:5000
625    #[clap(long)]
626    pub net: Vec<NicConfigCli>,
627
628    /// expose a virtual NIC using the Windows kernel-mode vmswitch.
629    ///
630    /// Specify the switch ID or "default" for the default switch.
631    #[clap(long, value_name = "SWITCH_ID")]
632    pub kernel_vmnic: Vec<String>,
633
634    /// expose a graphics device
635    #[clap(long)]
636    pub gfx: bool,
637
638    /// support a graphics device in vtl2
639    #[clap(long, requires("vtl2"), conflicts_with("gfx"))]
640    pub vtl2_gfx: bool,
641
642    /// VNC server configuration (listen address, port, client limit, etc.).
643    #[clap(flatten)]
644    pub vnc: VncCli,
645
646    /// set the APIC ID offset, for testing APIC IDs that don't match VP index
647    #[cfg(guest_arch = "x86_64")]
648    #[clap(long, default_value_t)]
649    pub apic_id_offset: u32,
650
651    /// the maximum number of VPs per socket
652    #[clap(long)]
653    pub vps_per_socket: Option<u32>,
654
655    /// enable or disable SMT (hyperthreading) (auto | force | off)
656    #[clap(long, default_value = "auto")]
657    pub smt: SmtConfigCli,
658
659    /// configure x2apic (auto | supported | off | on)
660    #[cfg(guest_arch = "x86_64")]
661    #[clap(long, default_value = "auto", value_parser = parse_x2apic)]
662    pub x2apic: X2ApicConfig,
663
664    /// configure PCIe MSI controller for aarch64 (auto | its | v2m)
665    #[cfg(guest_arch = "aarch64")]
666    #[clap(long, default_value = "auto")]
667    pub gic_msi: GicMsiCli,
668
669    /// configure SMMUv3 IOMMU for an aarch64 PCIe root complex (repeatable).
670    ///
671    /// Syntax: `rc=<name>[,accel][,oas=auto|N][,ssidsize=auto|N]`.
672    #[cfg(guest_arch = "aarch64")]
673    #[clap(long, value_name = "SMMU_CONFIG")]
674    pub smmu: Vec<SmmuCli>,
675
676    /// COM1 binding, optionally prefixed with `debugger-mode:` (see below)
677    /// (console | stderr | listen=\<path\> | file=\<path\> (overwrites) | listen=tcp:\<ip\>:\<port\> | term[=\<program\>]\[,name=\<windowtitle\>\] | null | none)
678    ///
679    /// Prefix the binding with `debugger-mode:` to run this COM port in
680    /// debugger mode for WinDbg kernel debugging over serial (KD), e.g.
681    /// `--com1 debugger-mode:listen=<path>` or
682    /// `--com1 debugger-mode:listen=tcp:<ip>:<port>`. In debugger mode OpenVMM
683    /// keeps this port's backend drained and may drop bytes instead of applying
684    /// backpressure, so the KD transport does not deadlock across guest
685    /// resets/reboots (KD recovers dropped bytes via its own retransmission).
686    /// Debugger mode is independent per COM port.
687    #[clap(long, value_name = "SERIAL")]
688    pub com1: Option<ComSerialConfigCli>,
689
690    /// COM2 binding, optionally prefixed with `debugger-mode:` (see --com1)
691    /// (console | stderr | listen=\<path\> | file=\<path\> (overwrites) | listen=tcp:\<ip\>:\<port\> | term[=\<program\>]\[,name=\<windowtitle\>\] | null | none)
692    #[clap(long, value_name = "SERIAL")]
693    pub com2: Option<ComSerialConfigCli>,
694
695    /// COM3 binding, optionally prefixed with `debugger-mode:` (see --com1)
696    /// (console | stderr | listen=\<path\> | file=\<path\> (overwrites) | listen=tcp:\<ip\>:\<port\> | term[=\<program\>]\[,name=\<windowtitle\>\] | null | none)
697    #[clap(long, value_name = "SERIAL")]
698    pub com3: Option<ComSerialConfigCli>,
699
700    /// COM4 binding, optionally prefixed with `debugger-mode:` (see --com1)
701    /// (console | stderr | listen=\<path\> | file=\<path\> (overwrites) | listen=tcp:\<ip\>:\<port\> | term[=\<program\>]\[,name=\<windowtitle\>\] | null | none)
702    #[clap(long, value_name = "SERIAL")]
703    pub com4: Option<ComSerialConfigCli>,
704
705    /// vmbus com1 serial binding (console | stderr | listen=\<path\> | file=\<path\> (overwrites) | listen=tcp:\<ip\>:\<port\> | term[=\<program\>]\[,name=\<windowtitle\>\] | null | none)
706    #[structopt(long, value_name = "SERIAL")]
707    pub vmbus_com1_serial: Option<SerialConfigCli>,
708
709    /// vmbus com2 serial binding (console | stderr | listen=\<path\> | file=\<path\> (overwrites) | listen=tcp:\<ip\>:\<port\> | term[=\<program\>]\[,name=\<windowtitle\>\] | null | none)
710    #[structopt(long, value_name = "SERIAL")]
711    pub vmbus_com2_serial: Option<SerialConfigCli>,
712
713    /// Only allow guest to host serial traffic
714    #[clap(long)]
715    pub serial_tx_only: bool,
716
717    /// debugcon binding (port:serial, where port is a u16, and serial is (console | stderr | listen=\<path\> | file=\<path\> (overwrites) | listen=tcp:\<ip\>:\<port\> | term[=\<program\>]\[,name=\<windowtitle\>\] | null | none))
718    #[clap(long, value_name = "SERIAL")]
719    pub debugcon: Option<DebugconSerialConfigCli>,
720
721    /// boot UEFI firmware, optionally configured with comma-separated options
722    #[clap(
723        long,
724        short = 'e',
725        value_name = "OPTIONS",
726        num_args = 0..=1,
727        default_missing_value = ""
728    )]
729    pub uefi: Option<UefiCli>,
730
731    /// UEFI firmware file (deprecated; use --uefi firmware=FILE)
732    #[clap(
733        long = "uefi-firmware",
734        hide = true,
735        requires("uefi"),
736        conflicts_with("igvm"),
737        value_name = "FILE"
738    )]
739    pub deprecated_uefi_firmware: Option<PathBuf>,
740
741    /// enable UEFI debugging on COM1 (deprecated; use --uefi debug)
742    #[clap(long = "uefi-debug", hide = true, requires("uefi"))]
743    pub deprecated_uefi_debug: bool,
744
745    /// enable memory protections in UEFI (deprecated; use --uefi enable_memory_protections)
746    #[clap(long = "uefi-enable-memory-protections", hide = true, requires("uefi"))]
747    pub deprecated_uefi_enable_memory_protections: bool,
748
749    /// force UEFI to bounce-buffer all DMA traffic (deprecated; use --uefi force_dma_bounce)
750    #[clap(long = "uefi-force-dma-bounce", hide = true, requires("uefi"))]
751    pub deprecated_uefi_force_dma_bounce: bool,
752
753    /// continue with invalid UEFI firmware version information (deprecated; use --uefi force_firmware_version)
754    #[clap(long = "uefi-force-firmware-version", hide = true, requires("uefi"))]
755    pub deprecated_uefi_force_firmware_version: bool,
756
757    /// set PCAT boot order as comma-separated string of boot device types
758    /// (e.g: floppy,hdd,optical,net).
759    ///
760    /// If less than 4 entries are added, entries are added according to their
761    /// default boot order (optical,hdd,net,floppy)
762    ///
763    /// e.g: passing "floppy,optical" will result in a boot order equivalent to
764    /// "floppy,optical,hdd,net".
765    ///
766    /// Passing duplicate types is an error.
767    #[clap(long, requires("pcat"))]
768    pub pcat_boot_order: Option<PcatBootOrderCli>,
769
770    /// Boot with PCAT BIOS firmware and piix4 devices
771    #[clap(long, conflicts_with("uefi"))]
772    pub pcat: bool,
773
774    /// PCAT firmware file
775    #[clap(long, requires("pcat"), value_name = "FILE")]
776    pub pcat_firmware: Option<PathBuf>,
777
778    /// boot an IGVM with required firmware and personality options
779    #[clap(
780        long,
781        conflicts_with_all = ["kernel", "pcat"],
782        value_name = "OPTIONS",
783        long_help = r#"Boot an IGVM file.
784
785Syntax: --igvm firmware=<FILE>,personality=<openhcl|uefi|linux-direct>
786
787Both firmware and personality are required; neither has a default.
788The openhcl personality requires explicit --hv --vtl2."#
789    )]
790    pub igvm: Option<IgvmCli>,
791
792    /// specify igvm vtl2 relocation type
793    /// (absolute=\<addr\>, disable, auto=\<filesize,or memory size\>, vtl2=\<filesize,or memory size\>,)
794    #[clap(long, requires("igvm"), default_value = "auto=filesize", value_parser = parse_vtl2_relocation)]
795    pub igvm_vtl2_relocation_type: Vtl2BaseAddressType,
796
797    /// add a virtio_9p device (e.g. myfs,C:\)
798    ///
799    /// Prefix with `pcie_port=<port_name>:` to expose the device over
800    /// emulated PCIe at the specified port.
801    #[clap(long, value_name = "[pcie_port=PORT:]tag,root_path")]
802    pub virtio_9p: Vec<FsArgs>,
803
804    /// output debug info from the 9p server
805    #[clap(long)]
806    pub virtio_9p_debug: bool,
807
808    /// add a virtio_fs device (e.g. myfs,C:\,uid=1000,gid=2000)
809    ///
810    /// Prefix with `pcie_port=<port_name>:` to expose the device over
811    /// emulated PCIe at the specified port.
812    #[clap(long, value_name = "[pcie_port=PORT:]tag,root_path,[options]")]
813    pub virtio_fs: Vec<FsArgsWithOptions>,
814
815    /// add a virtio_fs device for sharing memory (e.g. myfs,\SectionDirectoryPath)
816    ///
817    /// Prefix with `pcie_port=<port_name>:` to expose the device over
818    /// emulated PCIe at the specified port.
819    #[clap(long, value_name = "[pcie_port=PORT:]tag,root_path")]
820    pub virtio_fs_shmem: Vec<FsArgs>,
821
822    /// add a virtio_fs device under the selected bus (auto | mmio | pci | pcie:port | vpci)
823    #[clap(long, value_name = "BUS", default_value = "auto")]
824    pub virtio_fs_bus: VirtioBusCli,
825
826    /// virtio PMEM device
827    ///
828    /// Prefix with `pcie_port=<port_name>:` to expose the device over
829    /// emulated PCIe at the specified port.
830    #[clap(long, value_name = "[pcie_port=PORT:]PATH")]
831    pub virtio_pmem: Option<VirtioPmemArgs>,
832
833    /// add a virtio entropy (RNG) device
834    #[clap(long)]
835    pub virtio_rng: bool,
836
837    /// add a virtio-rng device under the selected bus (auto | mmio | pci | pcie:port | vpci)
838    #[clap(long, value_name = "BUS", default_value = "auto")]
839    pub virtio_rng_bus: VirtioBusCli,
840
841    /// attach the virtio-rng device to the specified PCIe port (overrides --virtio-rng-bus)
842    #[clap(long, value_name = "PORT", requires("virtio_rng"))]
843    pub virtio_rng_pcie_port: Option<String>,
844
845    /// add a virtio real-time clock device, optionally configured with
846    /// comma-separated options (bus=auto|mmio|pci|pcie:PORT|vpci)
847    #[clap(
848        long,
849        value_name = "OPTIONS",
850        num_args = 0..=1,
851        default_missing_value = ""
852    )]
853    pub virtio_rtc: Option<VirtioRtcCli>,
854
855    /// virtio console device backed by a serial backend (/dev/hvc0 in guest)
856    ///
857    /// Accepts serial config (console | stderr | listen=\<path\> |
858    /// file=\<path\> (overwrites) | listen=tcp:\<ip\>:\<port\> |
859    /// term[=\<program\>]\[,name=\<windowtitle\>\] | null | none)
860    #[clap(long)]
861    pub virtio_console: Option<SerialConfigCli>,
862
863    /// attach the virtio-console device to the specified PCIe port
864    #[clap(long, value_name = "PORT", requires("virtio_console"))]
865    pub virtio_console_pcie_port: Option<String>,
866
867    /// select the bus for virtio vsock devices (pci | pcie\[:port\] | mmio)
868    #[clap(long, value_name = "BUS", value_parser = parse_virtio_vsock_bus)]
869    pub virtio_vsock_bus: Option<VirtioBusCli>,
870
871    /// add a virtio vsock device with the given Unix socket base path
872    #[clap(long, value_name = "PATH")]
873    pub virtio_vsock_path: Option<String>,
874
875    /// expose the guest in the host AF_VSOCK namespace using the Linux
876    /// vhost_vsock kernel backend
877    #[cfg(target_os = "linux")]
878    #[clap(
879        long,
880        value_name = "CID",
881        conflicts_with = "virtio_vsock_path",
882        value_parser = parse_vhost_vsock_cid
883    )]
884    pub virtio_vsock_vhost_cid: Option<u32>,
885
886    /// expose a virtio network with the given backend (dio | vmnic | tap |
887    /// none)
888    ///
889    /// Prefix with `uh:` to add this NIC via Mana emulation through OpenHCL,
890    /// `vtl2:` to assign this NIC to VTL2, or `pcie_port=<port_name>:` to
891    /// expose the NIC over emulated PCIe at the specified port.
892    /// Prefix with `mac=<XX-XX-XX-XX-XX-XX>:` to set the guest MAC address.
893    #[clap(long)]
894    pub virtio_net: Vec<NicConfigCli>,
895
896    /// send log output from the worker process to a file instead of stderr. the file will be overwritten.
897    #[clap(long, value_name = "PATH")]
898    pub log_file: Option<PathBuf>,
899
900    /// write the process ID to the specified file on startup, and remove it on
901    /// exit. the file is not removed if the process is killed with SIGKILL or
902    /// crashes. no file locking is performed.
903    #[clap(long, value_name = "PATH")]
904    pub pidfile: Option<PathBuf>,
905
906    /// \[deprecated\] run as a ttrpc server on the specified Unix socket
907    ///
908    /// Use `--rpc path=<PATH>,transport=ttrpc` instead.
909    #[clap(long, value_name = "SOCKETPATH")]
910    pub ttrpc: Option<PathBuf>,
911
912    /// \[deprecated\] run as a grpc server on the specified Unix socket
913    ///
914    /// Use `--rpc path=<PATH>,transport=grpc` instead.
915    #[clap(long, value_name = "SOCKETPATH", conflicts_with("ttrpc"))]
916    pub grpc: Option<PathBuf>,
917
918    /// run as an RPC server on the specified Unix socket
919    #[clap(long_help = r#"
920Run as an RPC server on the specified Unix socket.
921
922syntax: path=<PATH>[,transport=<TRANSPORT>]
923
924options:
925    `path=<PATH>`                  Unix socket path to listen on (required)
926    `transport=<TRANSPORT>`        wire transport to accept (default: auto)
927
928valid transports:
929    `auto`                         auto-detect ttrpc vs. gRPC per connection
930    `ttrpc`                        accept ttrpc clients only
931    `grpc`                         accept gRPC clients only
932
933Examples:
934    --rpc path=/tmp/openvmm.sock
935    --rpc path=/tmp/openvmm.sock,transport=ttrpc
936"#)]
937    #[clap(
938        long,
939        value_name = "path=PATH[,transport=TRANSPORT]",
940        conflicts_with("ttrpc"),
941        conflicts_with("grpc")
942    )]
943    pub rpc: Option<RpcCli>,
944
945    /// do not launch child processes
946    #[clap(long)]
947    pub single_process: bool,
948
949    /// device to assign (can be passed multiple times)
950    #[cfg(windows)]
951    #[clap(long, value_name = "PATH")]
952    pub device: Vec<String>,
953
954    /// instead of showing the frontpage the VM will shutdown instead (deprecated; use --uefi disable_frontpage)
955    #[clap(long = "disable-frontpage", hide = true, requires("uefi"))]
956    pub deprecated_disable_frontpage: bool,
957
958    /// add a vtpm device, optionally selecting version 138 or 185 (default: 185)
959    #[clap(
960        long,
961        value_name = "VERSION",
962        num_args = 0..=1,
963        default_missing_value = "185"
964    )]
965    pub tpm: Option<TpmVersionCli>,
966
967    /// the mesh worker host name.
968    ///
969    /// Used internally for debugging and diagnostics.
970    #[clap(long, default_value = "control", hide(true))]
971    #[expect(clippy::option_option)]
972    pub internal_worker: Option<Option<String>>,
973
974    /// redirect the VTL 0 vmbus control plane to a proxy in VTL 2.
975    #[clap(long, requires("vtl2"))]
976    pub vmbus_redirect: bool,
977
978    /// limit the maximum protocol version allowed by vmbus; used for testing purposes
979    #[clap(long, value_parser = vmbus_core::parse_vmbus_version)]
980    pub vmbus_max_version: Option<u32>,
981
982    /// The disk to use for the VMGS.
983    ///
984    /// If this is not provided, guest state will be stored in memory.
985    #[clap(long_help = r#"
986e.g: --vmgs memdiff:file:/path/to/file.vmgs
987
988syntax: <path> | kind:<arg>[,flag]
989
990valid disk kinds:
991    `mem:<len>`                     memory backed disk
992        <len>: length of ramdisk, e.g.: `1G` or `VMGS_DEFAULT`
993    `memdiff:<disk>[;create=<len>]` memory backed diff disk
994        <disk>: lower disk, e.g.: `file:base.img`
995    `file:<path>`                   file-backed disk
996        <path>: path to file
997
998flags:
999    `fmt`                           reprovision the VMGS before boot
1000    `fmt-on-fail`                   reprovision the VMGS before boot if it is corrupted
1001"#)]
1002    #[clap(long)]
1003    pub vmgs: Option<VmgsCli>,
1004
1005    /// Use GspById guest state encryption policy with a test seed
1006    #[clap(long, requires("vmgs"))]
1007    pub test_gsp_by_id: bool,
1008
1009    /// VGA firmware file
1010    #[clap(long, requires("pcat"), value_name = "FILE")]
1011    pub vga_firmware: Option<PathBuf>,
1012
1013    /// enable secure boot
1014    #[clap(long)]
1015    pub secure_boot: bool,
1016
1017    /// use secure boot template
1018    #[clap(long)]
1019    pub secure_boot_template: Option<SecureBootTemplateCli>,
1020
1021    /// custom uefi nvram json file
1022    #[clap(long, value_name = "PATH")]
1023    pub custom_uefi_json: Option<PathBuf>,
1024
1025    /// the path to a named pipe (Windows) or Unix socket (Linux) to relay to the connected
1026    /// tty.
1027    ///
1028    /// This is a hidden argument used internally.
1029    #[clap(long, hide(true))]
1030    pub relay_console_path: Option<PathBuf>,
1031
1032    /// the title of the console window spawned from the relay console.
1033    ///
1034    /// This is a hidden argument used internally.
1035    #[clap(long, hide(true))]
1036    pub relay_console_title: Option<String>,
1037
1038    /// enable in-hypervisor gdb debugger
1039    #[clap(long, value_name = "PORT")]
1040    pub gdb: Option<u16>,
1041
1042    /// enable emulated MANA devices with the given network backend (see --net)
1043    ///
1044    /// Prefix with `pcie_port=<port_name>:` to expose the nic over emulated PCIe
1045    /// at the specified port.
1046    /// Prefix with `mac=<XX-XX-XX-XX-XX-XX>:` to set the guest MAC address.
1047    #[clap(long)]
1048    pub mana: Vec<NicConfigCli>,
1049
1050    /// use a specific hypervisor interface, with optional backend-specific
1051    /// parameters.
1052    ///
1053    /// Format: `name` or `name:key=val,key,...`
1054    ///
1055    /// WHP parameters (x86_64 guests only):
1056    ///   user_mode_apic       - use user-mode APIC emulator
1057    ///   no_enlightenments    - disable in-hypervisor enlightenments
1058    ///
1059    /// Examples:
1060    ///   --hypervisor whp
1061    ///   --hypervisor whp:user_mode_apic
1062    ///   --hypervisor whp:user_mode_apic,no_enlightenments
1063    ///   --hypervisor kvm
1064    #[clap(long)]
1065    pub hypervisor: Option<String>,
1066
1067    /// expose hardware virtualization (VMX/SVM) to the guest so it can run its
1068    /// own hypervisor.
1069    ///
1070    /// Only supported on x86_64, and only by backends that support nested
1071    /// virtualization (currently WHP, KVM, and MSHV). Requires host support.
1072    #[clap(long)]
1073    pub nested_virt: bool,
1074
1075    /// attach an ide drive (can be passed multiple times)
1076    ///
1077    /// Each ide controller has two channels. Each channel can have up to two
1078    /// attachments.
1079    ///
1080    /// If the `s` flag is not passed then the drive will we be attached to the
1081    /// primary ide channel if space is available. If two attachments have already
1082    /// been added to the primary channel then the drive will be attached to the
1083    /// secondary channel.
1084    #[clap(long_help = r#"
1085e.g: --ide memdiff:file:/path/to/disk.vhd
1086
1087syntax: <path> | kind:<arg>[,flag,opt=arg,...]
1088
1089valid disk kinds:
1090    `mem:<len>`                    memory backed disk
1091        <len>: length of ramdisk, e.g.: `1G`
1092    `memdiff:<disk>`               memory backed diff disk
1093        <disk>: lower disk, e.g.: `file:base.img`
1094    `file:<path>[;create=<len>]`   file-backed disk
1095        <path>: path to file
1096    `sql:<path>[;create=<len>]`    SQLite-backed disk (dev/test)
1097    `sqldiff:<path>[;create]:<disk>` SQLite diff layer on a backing disk
1098    `blob:<type>:<url>`            HTTP blob (read-only)
1099        <type>: `flat` or `vhd1`
1100    `crypt:<cipher>:<key_file>:<disk>` encrypted disk wrapper
1101        <cipher>: `xts-aes-256`
1102
1103additional wrapper kinds (e.g., `autocache`, `prwrap`) are also supported;
1104this list is not exhaustive.
1105
1106flags:
1107    `ro`                           open disk as read-only
1108    `s`                            attach drive to secondary ide channel
1109    `dvd`                          specifies that device is cd/dvd and it is read_only
1110"#)]
1111    #[clap(long, value_name = "FILE", requires("pcat"))]
1112    pub ide: Vec<IdeDiskCli>,
1113
1114    /// attach a floppy drive (should be able to be passed multiple times). VM must be generation 1 (no UEFI)
1115    ///
1116    #[clap(long_help = r#"
1117e.g: --floppy memdiff:file:/path/to/disk.vfd,ro
1118
1119syntax: <path> | kind:<arg>[,flag,opt=arg,...]
1120
1121valid disk kinds:
1122    `mem:<len>`                    memory backed disk
1123        <len>: length of ramdisk, e.g.: `1G`
1124    `memdiff:<disk>`               memory backed diff disk
1125        <disk>: lower disk, e.g.: `file:base.img`
1126    `file:<path>[;create=<len>]`   file-backed disk
1127        <path>: path to file
1128    `sql:<path>[;create=<len>]`    SQLite-backed disk (dev/test)
1129    `sqldiff:<path>[;create]:<disk>` SQLite diff layer on a backing disk
1130    `blob:<type>:<url>`            HTTP blob (read-only)
1131        <type>: `flat` or `vhd1`
1132    `crypt:<cipher>:<key_file>:<disk>` encrypted disk wrapper
1133        <cipher>: `xts-aes-256`
1134
1135flags:
1136    `ro`                           open disk as read-only
1137"#)]
1138    #[clap(long, value_name = "FILE", requires("pcat"))]
1139    pub floppy: Vec<FloppyDiskCli>,
1140
1141    /// enable guest watchdog device
1142    #[clap(long)]
1143    pub guest_watchdog: bool,
1144
1145    /// Enable OpenHCL's crash dump device, writing ELF core dumps of
1146    /// VTL2 user-mode components of OpenHCL in the given directory.
1147    #[clap(long)]
1148    pub openhcl_dump_path: Option<PathBuf>,
1149
1150    /// what to do when the guest requests a reset: reset it (default), halt the
1151    /// VM for inspection, or exit the VMM process (use `exit:<code>` to set the
1152    /// exit status)
1153    #[clap(long, value_name = "ACTION", default_value = "reset", value_parser = parse_guest_power_action)]
1154    pub guest_reset_action: GuestPowerAction,
1155
1156    /// what to do when the guest powers off or hibernates: halt the VM for
1157    /// inspection (default), reset it, or exit the VMM process (use
1158    /// `exit:<code>` to set the exit status)
1159    #[clap(long, value_name = "ACTION", default_value = "halt", value_parser = parse_guest_power_action)]
1160    pub guest_shutdown_action: GuestPowerAction,
1161
1162    /// what to do when the guest triple-faults: halt the VM for inspection
1163    /// (default), reset it, or exit the VMM process (use `exit:<code>` to set
1164    /// the exit status)
1165    #[clap(long, value_name = "ACTION", default_value = "halt", value_parser = parse_guest_power_action)]
1166    pub guest_crash_action: GuestPowerAction,
1167
1168    /// when the guest triple-faults, write a WinDbg-compatible `.vmrs` dump of
1169    /// the whole VM's VP state and guest memory to the specified path before
1170    /// applying the crash action
1171    ///
1172    /// This is a host-side, whole-VM dump triggered by a triple fault, distinct
1173    /// from `--openhcl-dump-path` (which captures an ELF core dump of user-mode
1174    /// components in OpenHCL).
1175    #[clap(long, value_name = "PATH")]
1176    pub crash_dump_path: Option<PathBuf>,
1177
1178    /// what to do when the guest watchdog fires (the guest stopped petting it):
1179    /// reset the VM (default), halt it for inspection, or exit the VMM process
1180    /// (use `exit:<code>` to set the exit status). Requires `--guest-watchdog`.
1181    #[clap(long, value_name = "ACTION", default_value = "reset", value_parser = parse_guest_power_action, requires = "guest_watchdog")]
1182    pub guest_watchdog_action: GuestPowerAction,
1183
1184    /// write saved state .proto files to the specified path
1185    #[clap(long)]
1186    pub write_saved_state_proto: Option<PathBuf>,
1187
1188    /// specify the IMC hive file for booting Windows
1189    #[clap(long)]
1190    pub imc: Option<PathBuf>,
1191
1192    /// expose a battery device
1193    #[clap(long)]
1194    pub battery: bool,
1195
1196    /// enable guest hibernation
1197    #[clap(long)]
1198    pub hibernation: bool,
1199
1200    /// set the UEFI console mode (deprecated; use --uefi console=MODE)
1201    #[clap(long = "uefi-console-mode", hide = true, requires("uefi"))]
1202    pub deprecated_uefi_console_mode: Option<UefiConsoleModeCli>,
1203
1204    /// set the EFI diagnostics log level
1205    #[clap(long_help = r#"
1206Set the EFI diagnostics log level.
1207
1208options:
1209    default                        default (ERROR and WARN only)
1210    info                           info (ERROR, WARN, and INFO)
1211    full                           full (all log levels)
1212"#)]
1213    #[clap(long = "efi-diagnostics-log-level", hide = true, requires("uefi"))]
1214    pub deprecated_efi_diagnostics_log_level: Option<EfiDiagnosticsLogLevelCli>,
1215
1216    /// Perform a default boot even if boot entries exist and fail (deprecated; use --uefi default_boot_always_attempt)
1217    #[clap(long = "default-boot-always-attempt", hide = true)]
1218    pub deprecated_default_boot_always_attempt: bool,
1219
1220    /// Enable AMD IOMMU (AMD-Vi) emulation on specified root complexes.
1221    /// Repeat for each root complex that should have an IOMMU, e.g.:
1222    ///   --amd-iommu rc0 --amd-iommu rc1
1223    /// The IOMMU appears at device 0 function 0 on each specified root
1224    /// complex. Requires --pcie-root-complex.
1225    #[cfg(guest_arch = "x86_64")]
1226    #[clap(long)]
1227    pub amd_iommu: Vec<String>,
1228
1229    /// Enable Intel VT-d IOMMU emulation on specified root complexes.
1230    /// Repeat for each root complex that should have an IOMMU, e.g.:
1231    ///   --intel-vtd rc0 --intel-vtd rc1
1232    /// Mutually exclusive with --amd-iommu within the same VM.
1233    /// Requires --pcie-root-complex.
1234    #[cfg(guest_arch = "x86_64")]
1235    #[clap(long)]
1236    pub intel_vtd: Vec<String>,
1237
1238    /// Attach a PCI Express root complex to the VM
1239    #[clap(long_help = r#"
1240Attach root complexes to the VM.
1241
1242Examples:
1243    # Attach root complex rc0 on segment 0 with bus and MMIO ranges
1244    --pcie-root-complex rc0,segment=0,start_bus=0,end_bus=255,low_mmio=4M,high_mmio=1G
1245
1246    # Configure HDM window size and restrictions (bitmask)
1247    --pcie-root-complex rc1,hdm=2G,hdm_window_restrictions=0x21
1248
1249Syntax: <name>[,opt=arg,...]
1250
1251Options:
1252    `segment=<value>`              configures the PCI Express segment, default 0
1253    `start_bus=<value>`            lowest valid bus number, default 0
1254    `end_bus=<value>`              highest valid bus number, default 255
1255    `low_mmio=<size>`              low MMIO window size, default 64M
1256    `high_mmio=<size>`             high MMIO window size, default 1G
1257    `low_mmio_base=<addr>`         pin low MMIO window base address (0x-prefixed hex)
1258    `high_mmio_base=<addr>`        pin high MMIO window base address (0x-prefixed hex)
1259    `hdm=<size>`                   HDM decoder MMIO window size (CFMWS window), default 1G
1260    `hdm_window_restrictions=<m>`  CFMWS window restriction bitmask (u16, decimal or 0x-prefixed hex),
1261                                   default DEVICE_COHERENT (bit 0, value 0x1)
1262    `preserve_bars`                keep pinned BARs at their assigned addresses
1263    `node=<value>`                 NUMA node the root complex is associated with
1264"#)]
1265    #[clap(long, conflicts_with("pcat"))]
1266    pub pcie_root_complex: Vec<PcieRootComplexCli>,
1267
1268    /// Place PCIe ECAM below 4 GiB for guest kernels that cannot discover high ECAM
1269    #[clap(long, requires("pcie_root_complex"), conflicts_with("pcat"))]
1270    pub pcie_ecam_below_4gb: bool,
1271
1272    /// Attach a PCI Express root port to the VM
1273    #[clap(long_help = r#"
1274Attach root ports to root complexes.
1275
1276Examples:
1277    # Attach root port rc0rp0 to root complex rc0
1278    --pcie-root-port rc0:rc0rp0
1279
1280    # Attach root port rc0rp1 to root complex rc0 with hotplug support
1281    --pcie-root-port rc0:rc0rp1,hotplug
1282
1283    # Attach root port rc0rp2 at device 5, function 0
1284    --pcie-root-port rc0:rc0rp2,addr=5
1285
1286    # Attach root port rc0rp3 at device 5, function 1
1287    --pcie-root-port rc0:rc0rp3,addr=5.1
1288
1289Syntax: <root_complex_name>:<name>[,opt,opt=arg,...]
1290
1291Options:
1292    `addr=<dev>[.<fn>]`            device/function to place this port at (default:
1293                                   lowest available); dev 0-31, fn 0-7
1294    `hotplug`                      enable hotplug support for this root port
1295    `acs=<mask>`                   ACS capability bitmask (u16, decimal or 0x-prefixed hex)
1296    `cxl`                          configure this root port as CXL-capable
1297    `pasid`                        configure this port to support PASID for downstream devices
1298"#)]
1299    #[clap(long, conflicts_with("pcat"))]
1300    pub pcie_root_port: Vec<PcieRootPortCli>,
1301
1302    /// Attach a PCI Express switch to the VM
1303    #[clap(long_help = r#"
1304Attach switches to root ports or downstream switch ports to create PCIe hierarchies.
1305
1306Examples:
1307    # Connect switch0 (with 4 downstream switch ports) directly to root port rp0
1308    --pcie-switch rp0:switch0,num_downstream_ports=4
1309
1310    # Connect switch1 (with 2 downstream switch ports) to downstream port 0 of switch0
1311    --pcie-switch switch0-downstream-0:switch1,num_downstream_ports=2
1312
1313    # Create a 3-level hierarchy: rp0 -> switch0 -> switch1 -> switch2
1314    --pcie-switch rp0:switch0
1315    --pcie-switch switch0-downstream-0:switch1
1316    --pcie-switch switch1-downstream-1:switch2
1317
1318    # Enable hotplug on all downstream switch ports of switch0
1319    --pcie-switch rp0:switch0,hotplug
1320
1321    # Enable PASID on all downstream switch ports of switch0
1322    --pcie-switch rp0:switch0,pasid
1323
1324Syntax: <port_name>:<name>[,opt,opt=arg,...]
1325
1326    port_name can be:
1327        - Root port name (e.g., "rp0") to connect directly to a root port
1328        - Downstream port name (e.g., "switch0-downstream-1") to connect to another switch
1329
1330Options:
1331    `hotplug`                       enable hotplug support for all downstream switch ports
1332    `num_downstream_ports=<value>`  number of downstream ports, default 4
1333    `acs=<mask>`                    ACS capability bitmask for downstream switch ports
1334    `pasid`                         configure this port to support PASID for downstream devices
1335"#)]
1336    #[clap(long, conflicts_with("pcat"))]
1337    pub pcie_switch: Vec<GenericPcieSwitchCli>,
1338
1339    /// Declare the device behind a PCIe port as an SRAT generic initiator
1340    #[clap(long_help = r#"
1341Declare that the device directly behind a PCIe port is a generic initiator
1342(GI) for a NUMA node, generating an SRAT Generic Initiator Affinity structure.
1343
1344The port may be a root port or a switch downstream port, so this works for
1345devices that sit behind a switch (e.g. a GPU placed under a switch shared
1346with a NIC for peer-to-peer DMA). The port is resolved by name against the
1347live topology after switch downstream ports are enumerated.
1348
1349Examples:
1350    # The device behind switch downstream port sw1-downstream-0 is a generic
1351    # initiator for NUMA node 1
1352    --pcie-generic-initiator port=sw1-downstream-0,node=1
1353
1354    # Also works for a root port name
1355    --pcie-generic-initiator port=rp0,node=2
1356
1357Syntax: port=<port_name>,node=<node>
1358"#)]
1359    #[clap(
1360        long = "pcie-generic-initiator",
1361        value_name = "port=<name>,node=<node>",
1362        conflicts_with("pcat")
1363    )]
1364    pub pcie_generic_initiator: Vec<PcieGenericInitiatorCli>,
1365
1366    /// Attach a PCIe remote device to a downstream port
1367    #[clap(long_help = r#"
1368Attach PCIe devices to root ports or downstream switch ports
1369which are implemented in a simulator running in a remote process.
1370
1371Examples:
1372    # Attach to root port rc0rp0 with default socket
1373    --pcie-remote rc0rp0
1374
1375    # Attach with custom socket address
1376    --pcie-remote rc0rp0,socket=0.0.0.0:48914
1377
1378    # Specify HU and controller identifiers
1379    --pcie-remote rc0rp0,hu=1,controller=0
1380
1381    # Multiple devices on different ports
1382    --pcie-remote rc0rp0,socket=0.0.0.0:48914
1383    --pcie-remote rc0rp1,socket=0.0.0.0:48915
1384
1385Syntax: <port_name>[,opt=arg,...]
1386
1387Options:
1388    `socket=<address>`              TCP socket (default: localhost:48914)
1389    `hu=<value>`                    Hardware unit identifier (default: 0)
1390    `controller=<value>`            Controller identifier (default: 0)
1391"#)]
1392    #[clap(long, conflicts_with("pcat"))]
1393    pub pcie_remote: Vec<PcieRemoteCli>,
1394
1395    // TODO: Unhide both USB options once USB emulation is ready for use.
1396    /// Attach an emulated xHCI USB controller to a PCIe port (repeatable).
1397    #[clap(long, hide = true, value_name = "PORT_NAME", conflicts_with("pcat"))]
1398    pub usb_xhci: Vec<UsbXhciCli>,
1399
1400    /// Attach a device backend to an xHCI USB controller (repeatable).
1401    #[clap(
1402        long,
1403        hide = true,
1404        value_name = "SPEC",
1405        requires("usb_xhci"),
1406        long_help = r#"Attach an initial USB device to a controller declared with --usb-xhci.
1407
1408Syntax: KIND[,on=PCIE_PORT][,connector=N][,OPTION=VALUE,...]
1409
1410With one controller, on defaults to its PCIe port; otherwise on is required.
1411Connectors are one-based and optional. Explicit numbers are reserved first;
1412omitted connectors use the first free number in command-line order.
1413An empty controller needs no --usb-device options.
1414
1415Kinds: mock-loopback, mock-hid, mock-storage, mock-mutt, mock-supermutt,
1416       winusb, winusb-all.
1417
1418The first four mocks accept vid=ID,pid=ID,serial=STRING overrides.
1419IDs accept decimal or 0x-prefixed hexadecimal values.
1420mock-storage also accepts blocks=N in 512-byte logical blocks.
1421mock-supermutt accepts no device-specific options.
1422winusb requires instance_id=ID, a Windows PnP instance ID.
1423winusb-all selects eligible present devices at startup and assigns free
1424connectors. It accepts optional on=PCIE_PORT, but no connector.
1425
1426Examples:
1427    --usb-xhci rc0rp0 --usb-device mock-hid --usb-device mock-storage
1428    --usb-device mock-storage,on=rc0rp0,connector=2,blocks=65536
1429    --usb-device 'winusb,instance_id=USB\DEVICE\INSTANCE'
1430    --usb-device winusb-all"#
1431    )]
1432    pub usb_device: Vec<UsbDeviceCli>,
1433
1434    /// Assign a host PCI device to the guest via VFIO (Linux only)
1435    #[clap(long_help = r#"
1436Assign a host PCI device to the guest via Linux VFIO.
1437
1438The device must be bound to vfio-pci on the host before starting the VM.
1439
1440Examples:
1441    --vfio host=0000:01:00.0,port=rp0
1442    --vfio host=0000:01:00.0,port=rp0,iommu=iommu0
1443
1444Keys:
1445    host=<pci_bdf>    (required) PCI address on the host
1446    port=<name>       (required) Root port or downstream switch port name
1447    iommu=<id>        (optional) Reference to an --iommu object. When present,
1448                      uses VFIO cdev + iommufd instead of the legacy group path.
1449"#)]
1450    #[cfg(target_os = "linux")]
1451    #[clap(long, conflicts_with("pcat"))]
1452    pub vfio: Vec<VfioDeviceCli>,
1453
1454    /// Create an iommufd context for VFIO cdev device assignment
1455    #[clap(long_help = r#"
1456Declare an iommufd context. Opens /dev/iommu so it can be referenced by
1457--vfio devices via the iommu=<id> key. The associated IOAS is allocated
1458the first time a --vfio device referring to this id is opened.
1459
1460Requires Linux kernel >= 6.6 with iommufd support.
1461
1462Examples:
1463    --iommu id=iommu0 --vfio host=0000:01:00.0,port=rp0,iommu=iommu0
1464
1465Syntax: id=<name>
1466"#)]
1467    #[cfg(target_os = "linux")]
1468    #[clap(long, conflicts_with("pcat"))]
1469    pub iommu: Vec<IommuCli>,
1470}
1471
1472impl Options {
1473    /// Returns the structured UEFI configuration with deprecated options merged in.
1474    pub fn effective_uefi(&self) -> anyhow::Result<Option<UefiCli>> {
1475        let mut uefi = match &self.uefi {
1476            Some(uefi) => uefi.clone(),
1477            None => return Ok(None),
1478        };
1479
1480        if let Some(firmware) = &self.deprecated_uefi_firmware {
1481            if uefi.firmware.is_some() {
1482                anyhow::bail!("--uefi firmware=... conflicts with --uefi-firmware");
1483            }
1484            uefi.firmware = Some(firmware.clone());
1485        }
1486        if let Some(console) = self.deprecated_uefi_console_mode {
1487            if uefi.console.is_some() {
1488                anyhow::bail!("--uefi console=... conflicts with --uefi-console-mode");
1489            }
1490            uefi.console = Some(console);
1491        }
1492        if let Some(diagnostics) = self.deprecated_efi_diagnostics_log_level {
1493            if uefi.diagnostics.is_some() {
1494                anyhow::bail!("--uefi diagnostics=... conflicts with --efi-diagnostics-log-level");
1495            }
1496            uefi.diagnostics = Some(diagnostics);
1497        }
1498
1499        uefi.debug |= self.deprecated_uefi_debug;
1500        uefi.enable_memory_protections |= self.deprecated_uefi_enable_memory_protections;
1501        uefi.force_dma_bounce |= self.deprecated_uefi_force_dma_bounce;
1502        uefi.force_firmware_version |= self.deprecated_uefi_force_firmware_version;
1503        uefi.disable_frontpage |= self.deprecated_disable_frontpage;
1504        uefi.default_boot_always_attempt |= self.deprecated_default_boot_always_attempt;
1505        Ok(Some(uefi))
1506    }
1507
1508    /// Returns the effective guest RAM size.
1509    pub fn memory_size(&self) -> u64 {
1510        self.memory.size.map(|m| m.0).unwrap_or(DEFAULT_MEMORY_SIZE)
1511    }
1512
1513    /// Returns whether guest RAM should be prefetched.
1514    pub fn prefetch_memory(&self) -> bool {
1515        self.memory.prefetch || self.deprecated_prefetch
1516    }
1517
1518    /// Returns whether guest RAM should use private anonymous backing.
1519    pub fn private_memory(&self) -> bool {
1520        self.memory.shared == Some(false) || self.deprecated_private_memory
1521    }
1522
1523    /// Returns whether guest RAM should be marked THP-eligible.
1524    pub fn transparent_hugepages(&self) -> bool {
1525        self.memory
1526            .transparent_hugepages
1527            .unwrap_or(!self.memory.hugepages)
1528            || self.deprecated_thp
1529    }
1530
1531    /// Returns the effective file backing path for guest RAM.
1532    pub fn memory_backing_file(&self) -> Option<&PathBuf> {
1533        self.memory
1534            .file
1535            .as_ref()
1536            .or(self.deprecated_memory_backing_file.as_ref())
1537    }
1538
1539    /// Validates combinations that span the new `--memory` parser and legacy aliases.
1540    ///
1541    /// Only checks that cannot be expressed elsewhere live here. Conflicts
1542    /// within a single `--memory` string are enforced by the parser, and
1543    /// semantic constraints (platform support, private-vs-shared, huge pages
1544    /// vs. legacy RAM, etc.) are enforced by the membacking builder at VM
1545    /// build time; those are not duplicated here.
1546    pub fn validate_memory_options(&self) -> anyhow::Result<()> {
1547        if self.memory.file.is_some() && self.deprecated_memory_backing_file.is_some() {
1548            anyhow::bail!("--memory file=... conflicts with --memory-backing-file");
1549        }
1550        if self.memory.file.is_some() && self.restore_snapshot.is_some() {
1551            anyhow::bail!("--memory file=... conflicts with --restore-snapshot");
1552        }
1553        if self.memory.shared == Some(true) && self.deprecated_private_memory {
1554            anyhow::bail!("--memory shared=on conflicts with --private-memory");
1555        }
1556        Ok(())
1557    }
1558
1559    /// Validates isolation-specific command-line option combinations.
1560    pub fn validate_isolation_options(&self) -> anyhow::Result<()> {
1561        if self.snp_restricted_injection && !matches!(self.isolation, Some(IsolationCli::Snp)) {
1562            anyhow::bail!("--snp-restricted-injection requires --isolation snp");
1563        }
1564        if self.snp_restricted_injection
1565            && self
1566                .hypervisor
1567                .as_deref()
1568                .and_then(|value| value.split(':').next())
1569                != Some("mshv")
1570        {
1571            anyhow::bail!("--snp-restricted-injection requires --hypervisor mshv");
1572        }
1573        if self.snp_restricted_injection
1574            && (self.uefi.is_some()
1575                || self.pcat
1576                || self.igvm.is_some()
1577                || self.restore_snapshot.is_some())
1578        {
1579            anyhow::bail!("--snp-restricted-injection requires Linux direct boot");
1580        }
1581        if matches!(self.isolation, Some(IsolationCli::Snp)) {
1582            if self.uefi.is_some() {
1583                anyhow::bail!("SNP isolation currently only supports Linux direct boot");
1584            }
1585            if self.memory.hugepages
1586                || self
1587                    .numa
1588                    .as_ref()
1589                    .is_some_and(|nodes| nodes.iter().any(|node| node.memory.hugepages))
1590            {
1591                anyhow::bail!("SNP isolation currently does not support hugetlb memory");
1592            }
1593        }
1594        Ok(())
1595    }
1596
1597    /// Validates IGVM personality selection.
1598    pub fn validate_igvm_options(&self) -> anyhow::Result<()> {
1599        let Some(igvm) = &self.igvm else {
1600            return Ok(());
1601        };
1602        match igvm.personality {
1603            IgvmPersonalityCli::Openhcl => {
1604                anyhow::ensure!(
1605                    self.hv && self.vtl2,
1606                    "--igvm personality=openhcl requires --hv --vtl2"
1607                );
1608            }
1609            IgvmPersonalityCli::Uefi | IgvmPersonalityCli::LinuxDirect => {
1610                anyhow::ensure!(
1611                    !self.vtl2,
1612                    "--vtl2 requires --igvm personality=openhcl for IGVM boots"
1613                );
1614                anyhow::ensure!(
1615                    self.uefi.is_none(),
1616                    "--uefi is only supported with --igvm personality=openhcl"
1617                );
1618            }
1619        }
1620        Ok(())
1621    }
1622}
1623
1624#[derive(Clone, Debug, PartialEq, vmm_cli::KeyValueArgs)]
1625pub struct IgvmCli {
1626    pub firmware: PathBuf,
1627    pub personality: IgvmPersonalityCli,
1628}
1629
1630#[derive(Clone, Debug, Default, vmm_cli::KeyValueArgs)]
1631pub struct UefiCli {
1632    pub firmware: Option<PathBuf>,
1633    #[kv(flag)]
1634    pub debug: bool,
1635    #[kv(flag)]
1636    pub enable_memory_protections: bool,
1637    #[kv(flag)]
1638    pub force_dma_bounce: bool,
1639    #[kv(flag)]
1640    pub force_firmware_version: bool,
1641    #[kv(flag)]
1642    pub disable_frontpage: bool,
1643    pub console: Option<UefiConsoleModeCli>,
1644    pub diagnostics: Option<EfiDiagnosticsLogLevelCli>,
1645    #[kv(flag)]
1646    pub default_boot_always_attempt: bool,
1647}
1648
1649/// Options for `--virtio-rtc`.
1650#[derive(Clone, Debug, PartialEq, Eq, vmm_cli::KeyValueArgs)]
1651pub struct VirtioRtcCli {
1652    /// Bus to attach the device to (auto | mmio | pci | pcie:PORT | vpci).
1653    #[kv(default = VirtioBusCli::Auto)]
1654    pub bus: VirtioBusCli,
1655}
1656
1657#[derive(Clone, Debug, PartialEq)]
1658pub struct FsArgs {
1659    pub tag: String,
1660    pub path: String,
1661    pub pcie_port: Option<String>,
1662}
1663
1664impl FromStr for FsArgs {
1665    type Err = anyhow::Error;
1666
1667    fn from_str(s: &str) -> Result<Self, Self::Err> {
1668        let (pcie_port, s) = parse_pcie_port_prefix(s);
1669        let mut s = s.split(',');
1670        let (Some(tag), Some(path), None) = (s.next(), s.next(), s.next()) else {
1671            anyhow::bail!("expected [pcie_port=<port>:]<tag>,<path>");
1672        };
1673        Ok(Self {
1674            tag: tag.to_owned(),
1675            path: path.to_owned(),
1676            pcie_port,
1677        })
1678    }
1679}
1680
1681#[derive(Clone, Debug, PartialEq)]
1682pub struct FsArgsWithOptions {
1683    /// The file system tag.
1684    pub tag: String,
1685    /// The root path.
1686    pub path: String,
1687    /// The extra options, joined with ';'.
1688    pub options: String,
1689    /// Optional PCIe port name.
1690    pub pcie_port: Option<String>,
1691}
1692
1693impl FromStr for FsArgsWithOptions {
1694    type Err = anyhow::Error;
1695
1696    fn from_str(s: &str) -> Result<Self, Self::Err> {
1697        let (pcie_port, s) = parse_pcie_port_prefix(s);
1698        let mut s = s.split(',');
1699        let (Some(tag), Some(path)) = (s.next(), s.next()) else {
1700            anyhow::bail!("expected [pcie_port=<port>:]<tag>,<path>[,<options>]");
1701        };
1702        let options = s.collect::<Vec<_>>().join(";");
1703        Ok(Self {
1704            tag: tag.to_owned(),
1705            path: path.to_owned(),
1706            options,
1707            pcie_port,
1708        })
1709    }
1710}
1711
1712/// What the VMM does on a guest power event (reset, power-off/hibernate,
1713/// triple-fault, or watchdog timeout). Parsed from `reset`, `halt`, `exit`, or
1714/// `exit:<code>`; a bare `exit` uses status 0.
1715#[derive(Copy, Clone, Debug, PartialEq, Eq)]
1716pub enum GuestPowerAction {
1717    /// Restart the guest.
1718    Reset,
1719    /// Stop the VM but keep the VMM process, so it can be inspected or
1720    /// restarted from the REPL.
1721    Halt,
1722    /// Exit the VMM process with this status code.
1723    Exit(u8),
1724}
1725
1726/// Parse a [`GuestPowerAction`] from `reset`, `halt`, `exit`, or `exit:<code>`.
1727/// A bare `exit` exits with status 0; `exit:<code>` exits with `<code>` (0-255).
1728fn parse_guest_power_action(s: &str) -> Result<GuestPowerAction, String> {
1729    match s {
1730        "reset" => Ok(GuestPowerAction::Reset),
1731        "halt" => Ok(GuestPowerAction::Halt),
1732        "exit" => Ok(GuestPowerAction::Exit(0)),
1733        _ => match s.strip_prefix("exit:") {
1734            Some(code) => code
1735                .parse::<u8>()
1736                .map(GuestPowerAction::Exit)
1737                .map_err(|err| format!("invalid exit code '{code}' (expected 0-255): {err}")),
1738            None => Err(format!(
1739                "expected reset, halt, exit, or exit:<code>, got '{s}'"
1740            )),
1741        },
1742    }
1743}
1744
1745#[derive(Clone, Debug, PartialEq, Eq)]
1746pub enum VirtioBusCli {
1747    Auto,
1748    Mmio,
1749    Pci,
1750    Pcie(String),
1751    Vpci,
1752}
1753
1754impl FromStr for VirtioBusCli {
1755    type Err = String;
1756
1757    fn from_str(value: &str) -> Result<Self, Self::Err> {
1758        parse_virtio_bus(value, false)
1759    }
1760}
1761
1762fn parse_virtio_bus(value: &str, ignore_case: bool) -> Result<VirtioBusCli, String> {
1763    let eq = |value: &str, expected: &str| {
1764        if ignore_case {
1765            value.eq_ignore_ascii_case(expected)
1766        } else {
1767            value == expected
1768        }
1769    };
1770
1771    match value.split_once(':') {
1772        None if eq(value, "auto") => Ok(VirtioBusCli::Auto),
1773        None if eq(value, "mmio") => Ok(VirtioBusCli::Mmio),
1774        None if eq(value, "pci") => Ok(VirtioBusCli::Pci),
1775        None if eq(value, "vpci") => Ok(VirtioBusCli::Vpci),
1776        Some((bus, port)) if eq(bus, "pcie") && !port.is_empty() && !port.contains(':') => {
1777            Ok(VirtioBusCli::Pcie(port.to_string()))
1778        }
1779        _ => Err("expected auto, mmio, pci, pcie:<port>, or vpci".to_string()),
1780    }
1781}
1782
1783fn parse_virtio_vsock_bus(value: &str) -> Result<VirtioBusCli, String> {
1784    if value.eq_ignore_ascii_case("pcie") {
1785        return Ok(VirtioBusCli::Pcie("vsock".to_string()));
1786    }
1787
1788    match parse_virtio_bus(value, true) {
1789        Ok(bus @ (VirtioBusCli::Mmio | VirtioBusCli::Pci | VirtioBusCli::Pcie(_))) => Ok(bus),
1790        _ => Err("expected mmio, pci, pcie, or pcie:<port>".to_string()),
1791    }
1792}
1793
1794#[cfg(target_os = "linux")]
1795fn parse_vhost_vsock_cid(value: &str) -> Result<u32, String> {
1796    let cid = value
1797        .parse::<u32>()
1798        .map_err(|error| format!("invalid CID '{value}': {error}"))?;
1799    if !(3..u32::MAX).contains(&cid) {
1800        return Err(format!("CID must be between 3 and {}", u32::MAX - 1));
1801    }
1802    Ok(cid)
1803}
1804
1805/// Parse an optional `pcie_port=<name>:` prefix from a CLI argument string.
1806///
1807/// Returns `(Some(port_name), rest)` if the prefix is present, or
1808/// `(None, original)` if not.
1809fn parse_pcie_port_prefix(s: &str) -> (Option<String>, &str) {
1810    if let Some(rest) = s.strip_prefix("pcie_port=") {
1811        if let Some((port, rest)) = rest.split_once(':') {
1812            if !port.is_empty() {
1813                return (Some(port.to_string()), rest);
1814            }
1815        }
1816    }
1817    (None, s)
1818}
1819
1820#[derive(Clone, Debug, PartialEq)]
1821pub struct VirtioPmemArgs {
1822    pub path: String,
1823    pub pcie_port: Option<String>,
1824}
1825
1826impl FromStr for VirtioPmemArgs {
1827    type Err = anyhow::Error;
1828
1829    fn from_str(s: &str) -> Result<Self, Self::Err> {
1830        let (pcie_port, s) = parse_pcie_port_prefix(s);
1831        if s.is_empty() {
1832            anyhow::bail!("expected [pcie_port=<port>:]<path>");
1833        }
1834        Ok(Self {
1835            path: s.to_owned(),
1836            pcie_port,
1837        })
1838    }
1839}
1840
1841#[derive(clap::ValueEnum, Clone, Copy)]
1842pub enum SecureBootTemplateCli {
1843    Windows,
1844    UefiCa,
1845}
1846
1847/// TPM reference implementation version selected by `--tpm`.
1848#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
1849pub enum TpmVersionCli {
1850    /// TPM reference implementation version 1.38.
1851    #[value(name = "138", alias = "1.38")]
1852    V138,
1853    /// TPM reference implementation version 1.85.
1854    #[value(name = "185", alias = "1.85")]
1855    V185,
1856}
1857
1858fn parse_memory(s: &str) -> anyhow::Result<u64> {
1859    if s == "VMGS_DEFAULT" {
1860        Ok(vmgs_format::VMGS_DEFAULT_CAPACITY)
1861    } else {
1862        || -> Option<u64> {
1863            let mut b = s.as_bytes();
1864            if s.ends_with('B') {
1865                b = &b[..b.len() - 1]
1866            }
1867            if b.is_empty() {
1868                return None;
1869            }
1870            let multi = match b[b.len() - 1] as char {
1871                'T' => Some(1024 * 1024 * 1024 * 1024),
1872                'G' => Some(1024 * 1024 * 1024),
1873                'M' => Some(1024 * 1024),
1874                'K' => Some(1024),
1875                _ => None,
1876            };
1877            if multi.is_some() {
1878                b = &b[..b.len() - 1]
1879            }
1880            let n: u64 = std::str::from_utf8(b).ok()?.parse().ok()?;
1881            n.checked_mul(multi.unwrap_or(1))
1882        }()
1883        .with_context(|| format!("invalid memory size '{0}'", s))
1884    }
1885}
1886
1887/// Parses an address, which must be a `0x`-prefixed hexadecimal value.
1888fn parse_address(s: &str) -> anyhow::Result<u64> {
1889    let hex = s
1890        .strip_prefix("0x")
1891        .or_else(|| s.strip_prefix("0X"))
1892        .with_context(|| format!("invalid address '{s}', expected a 0x-prefixed hex value"))?;
1893    u64::from_str_radix(hex, 16).with_context(|| format!("invalid address '{s}'"))
1894}
1895
1896fn parse_acs_capability_mask(value: &str) -> anyhow::Result<u16> {
1897    if let Some(hex) = value
1898        .strip_prefix("0x")
1899        .or_else(|| value.strip_prefix("0X"))
1900    {
1901        u16::from_str_radix(hex, 16).context("invalid ACS capability mask")
1902    } else {
1903        value.parse::<u16>().context("invalid ACS capability mask")
1904    }
1905}
1906
1907/// A parsed SMBIOS `release=MAJOR.MINOR` value: exactly two `u8` components
1908/// separated by a dot.
1909#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1910pub struct SmbiosRelease(pub u8, pub u8);
1911
1912impl FromStr for SmbiosRelease {
1913    type Err = anyhow::Error;
1914
1915    fn from_str(value: &str) -> anyhow::Result<Self> {
1916        let (major, minor) = value
1917            .split_once('.')
1918            .with_context(|| format!("invalid smbios release '{value}', expected MAJOR.MINOR"))?;
1919        let major = major
1920            .parse::<u8>()
1921            .with_context(|| format!("invalid smbios release major '{major}'"))?;
1922        let minor = minor
1923            .parse::<u8>()
1924            .with_context(|| format!("invalid smbios release minor '{minor}'"))?;
1925        Ok(SmbiosRelease(major, minor))
1926    }
1927}
1928
1929/// A parsed SMBIOS `uuid=` value: either an explicit GUID or the literal
1930/// `random`, which requests a freshly generated per-VM GUID.
1931#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1932pub enum SmbiosUuid {
1933    /// Generate a fresh random GUID when the VM is created.
1934    Random,
1935    /// Use this fixed GUID.
1936    Fixed(Guid),
1937}
1938
1939impl FromStr for SmbiosUuid {
1940    type Err = anyhow::Error;
1941
1942    fn from_str(value: &str) -> anyhow::Result<Self> {
1943        if value == "random" {
1944            Ok(SmbiosUuid::Random)
1945        } else {
1946            let guid = value
1947                .parse::<Guid>()
1948                .with_context(|| format!("invalid smbios uuid '{value}'"))?;
1949            Ok(SmbiosUuid::Fixed(guid))
1950        }
1951    }
1952}
1953
1954/// SMBIOS Type 0 (BIOS Information) overrides parsed from a `--smbios type=0,…`
1955/// argument. Field names mirror `SmbiosBiosOverrides`.
1956#[derive(Debug, Clone, Default, PartialEq, Eq, vmm_cli::KeyValueArgs)]
1957pub struct SmbiosBiosCli {
1958    /// BIOS vendor (`vendor`).
1959    pub vendor: Option<String>,
1960    /// BIOS version (`version`).
1961    pub version: Option<String>,
1962    /// BIOS release date (`date`).
1963    #[kv(key = "date")]
1964    pub release_date: Option<String>,
1965    /// System BIOS Major/Minor Release (`release=MAJOR.MINOR`).
1966    pub release: Option<SmbiosRelease>,
1967}
1968
1969/// SMBIOS Type 1 (System Information) overrides parsed from a `--smbios type=1,…`
1970/// argument. Field names mirror `SmbiosSystemOverrides`.
1971#[derive(Debug, Clone, Default, PartialEq, Eq, vmm_cli::KeyValueArgs)]
1972pub struct SmbiosSystemCli {
1973    /// System manufacturer (`manufacturer`).
1974    pub manufacturer: Option<String>,
1975    /// System product name (`product`).
1976    #[kv(key = "product")]
1977    pub product_name: Option<String>,
1978    /// System version (`version`).
1979    pub version: Option<String>,
1980    /// System serial number (`serial`).
1981    #[kv(key = "serial")]
1982    pub serial_number: Option<String>,
1983    /// System SKU number (`sku`).
1984    #[kv(key = "sku")]
1985    pub sku_number: Option<String>,
1986    /// System family (`family`).
1987    pub family: Option<String>,
1988    /// System UUID (`uuid=GUID` or `uuid=random`).
1989    pub uuid: Option<SmbiosUuid>,
1990}
1991
1992/// SMBIOS (DMI) identity overrides parsed from `--smbios` arguments, grouped by
1993/// SMBIOS structure type to mirror the required `type=N` CLI prefix and the
1994/// loader's `SmbiosConfig` layout.
1995///
1996/// Each `--smbios` argument targets exactly one type; multiple arguments are
1997/// merged with [`SmbiosCli::merge`].
1998#[derive(Debug, Clone, Default, PartialEq, Eq)]
1999pub struct SmbiosCli {
2000    /// Type 0 (BIOS Information) overrides.
2001    pub bios: SmbiosBiosCli,
2002    /// Type 1 (System Information) overrides.
2003    pub system: SmbiosSystemCli,
2004}
2005
2006/// Set an override slot, erroring if it is already set.
2007fn smbios_set_once<T>(slot: &mut Option<T>, key: &str, value: T) -> anyhow::Result<()> {
2008    anyhow::ensure!(slot.is_none(), "duplicate smbios option '{key}'");
2009    *slot = Some(value);
2010    Ok(())
2011}
2012
2013impl SmbiosCli {
2014    /// Merge another parsed `--smbios` argument into this one, erroring if any
2015    /// field is set by both.
2016    pub fn merge(&mut self, other: SmbiosCli) -> anyhow::Result<()> {
2017        let SmbiosCli {
2018            bios:
2019                SmbiosBiosCli {
2020                    vendor,
2021                    version: bios_version,
2022                    release_date,
2023                    release,
2024                },
2025            system:
2026                SmbiosSystemCli {
2027                    manufacturer,
2028                    product_name,
2029                    version: system_version,
2030                    serial_number,
2031                    sku_number,
2032                    family,
2033                    uuid,
2034                },
2035        } = other;
2036        merge_smbios_field(&mut self.bios.vendor, vendor, 0, "vendor")?;
2037        merge_smbios_field(&mut self.bios.version, bios_version, 0, "version")?;
2038        merge_smbios_field(&mut self.bios.release_date, release_date, 0, "date")?;
2039        merge_smbios_field(&mut self.bios.release, release, 0, "release")?;
2040        merge_smbios_field(
2041            &mut self.system.manufacturer,
2042            manufacturer,
2043            1,
2044            "manufacturer",
2045        )?;
2046        merge_smbios_field(&mut self.system.product_name, product_name, 1, "product")?;
2047        merge_smbios_field(&mut self.system.version, system_version, 1, "version")?;
2048        merge_smbios_field(&mut self.system.serial_number, serial_number, 1, "serial")?;
2049        merge_smbios_field(&mut self.system.sku_number, sku_number, 1, "sku")?;
2050        merge_smbios_field(&mut self.system.family, family, 1, "family")?;
2051        merge_smbios_field(&mut self.system.uuid, uuid, 1, "uuid")?;
2052        Ok(())
2053    }
2054}
2055
2056/// Merge a single override field, erroring if both sides are set. `typ` is the
2057/// SMBIOS structure type the field belongs to, included in the error message.
2058fn merge_smbios_field<T>(
2059    dst: &mut Option<T>,
2060    src: Option<T>,
2061    typ: u8,
2062    key: &str,
2063) -> anyhow::Result<()> {
2064    if let Some(value) = src {
2065        smbios_set_once(dst, &format!("type={typ} {key}"), value)?;
2066    }
2067    Ok(())
2068}
2069
2070/// Parse a single `--smbios` argument: `type=N,key=value[,key=value...]`.
2071///
2072/// `type=N` is required (there is no default) and selects the key namespace;
2073/// the remaining `key=value` pairs are parsed by the matching per-type
2074/// `KeyValueArgs` struct. Unknown types and unknown keys are hard errors (no
2075/// silent drop).
2076fn parse_smbios(s: &str) -> anyhow::Result<SmbiosCli> {
2077    let mut typ: Option<u8> = None;
2078    let mut rest = Vec::new();
2079    for part in s.split(',') {
2080        let (key, value) = part
2081            .split_once('=')
2082            .with_context(|| format!("invalid smbios option '{part}', expected key=value"))?;
2083        if key.is_empty() || value.is_empty() {
2084            anyhow::bail!("invalid smbios option '{part}', expected key=value");
2085        }
2086        if key == "type" {
2087            anyhow::ensure!(typ.is_none(), "duplicate smbios option 'type'");
2088            typ = Some(
2089                value
2090                    .parse::<u8>()
2091                    .with_context(|| format!("invalid smbios type '{value}'"))?,
2092            );
2093        } else {
2094            rest.push(part);
2095        }
2096    }
2097
2098    let typ = typ.context("smbios option requires 'type=N' (e.g. 'type=1')")?;
2099    let rest = rest.join(",");
2100    Ok(match typ {
2101        0 => SmbiosCli {
2102            bios: rest.parse()?,
2103            ..Default::default()
2104        },
2105        1 => SmbiosCli {
2106            system: rest.parse()?,
2107            ..Default::default()
2108        },
2109        other => anyhow::bail!("unsupported smbios type '{other}' (expected 0 or 1)"),
2110    })
2111}
2112
2113fn parse_memory_config(s: &str) -> anyhow::Result<MemoryCli> {
2114    // Bare shortcut: `--memory 64G` sets only the size.
2115    let memory = if !s.contains('=') && !s.contains(',') {
2116        MemoryCli {
2117            size: Some(s.parse::<vmm_cli::MemorySize>()?),
2118            ..Default::default()
2119        }
2120    } else {
2121        s.parse::<MemoryCli>()?
2122    };
2123    memory.validate()?;
2124    Ok(memory)
2125}
2126
2127fn parse_numa_node(s: &str) -> anyhow::Result<NumaNodeCli> {
2128    let node: NumaNodeCli = s.parse()?;
2129    anyhow::ensure!(
2130        node.memory.size.is_some(),
2131        "numa node requires 'size' option"
2132    );
2133    anyhow::ensure!(
2134        node.memory.file.is_none(),
2135        "'file' is not supported in --numa"
2136    );
2137    node.memory.validate()?;
2138    Ok(node)
2139}
2140
2141fn parse_numa_distance(s: &str) -> anyhow::Result<NumaDistanceCli> {
2142    let parts: Vec<&str> = s.split(':').collect();
2143    anyhow::ensure!(
2144        parts.len() == 3,
2145        "expected SRC:DST:DISTANCE format, got '{s}'"
2146    );
2147    let src = parts[0].parse::<u32>().context("invalid source node")?;
2148    let dst = parts[1]
2149        .parse::<u32>()
2150        .context("invalid destination node")?;
2151    let distance = parts[2].parse::<u8>().context("invalid distance")?;
2152    anyhow::ensure!(
2153        distance >= 10,
2154        "distance must be >= 10 (10 = local), got {distance}"
2155    );
2156    Ok(NumaDistanceCli { src, dst, distance })
2157}
2158
2159/// Parse a number from a string that could be prefixed with 0x to indicate hex.
2160fn parse_number(s: &str) -> Result<u64, std::num::ParseIntError> {
2161    match s.strip_prefix("0x") {
2162        Some(rest) => u64::from_str_radix(rest, 16),
2163        None => s.parse::<u64>(),
2164    }
2165}
2166
2167#[derive(Clone, Debug, PartialEq)]
2168pub enum DiskCliKind {
2169    // mem:<len>
2170    Memory(u64),
2171    // memdiff:<kind>
2172    MemoryDiff(Box<DiskCliKind>),
2173    // sql:<path>[;create=<len>]
2174    Sqlite {
2175        path: PathBuf,
2176        create_with_len: Option<u64>,
2177    },
2178    // sqldiff:<path>[;create]:<kind>
2179    SqliteDiff {
2180        path: PathBuf,
2181        create: bool,
2182        disk: Box<DiskCliKind>,
2183    },
2184    // autocache:[key]:<kind>
2185    AutoCacheSqlite {
2186        cache_path: String,
2187        key: Option<String>,
2188        disk: Box<DiskCliKind>,
2189    },
2190    // prwrap:<kind>
2191    PersistentReservationsWrapper(Box<DiskCliKind>),
2192    // file:<path>[;direct][;create=<len>]
2193    File {
2194        path: PathBuf,
2195        create_with_len: Option<u64>,
2196        direct: bool,
2197    },
2198    // blob:<type>:<url>
2199    Blob {
2200        kind: BlobKind,
2201        url: String,
2202    },
2203    // crypt:<cipher>:<key_file>:<kind>
2204    Crypt {
2205        cipher: DiskCipher,
2206        key_file: PathBuf,
2207        disk: Box<DiskCliKind>,
2208    },
2209    // delay:<delay_ms>:<kind>
2210    DelayDiskWrapper {
2211        delay_ms: u64,
2212        disk: Box<DiskCliKind>,
2213    },
2214}
2215
2216#[derive(ValueEnum, Clone, Copy, Debug, PartialEq)]
2217pub enum DiskCipher {
2218    #[clap(name = "xts-aes-256")]
2219    XtsAes256,
2220}
2221
2222#[derive(Copy, Clone, Debug, PartialEq)]
2223pub enum BlobKind {
2224    Flat,
2225    Vhd1,
2226}
2227
2228struct FileOpts {
2229    path: PathBuf,
2230    create_with_len: Option<u64>,
2231    direct: bool,
2232}
2233
2234fn parse_file_opts(arg: &str) -> anyhow::Result<FileOpts> {
2235    let mut path = arg;
2236    let mut create_with_len = None;
2237    let mut direct = false;
2238
2239    // Parse semicolon-delimited options after the path.
2240    if let Some((p, rest)) = arg.split_once(';') {
2241        path = p;
2242        for opt in rest.split(';') {
2243            if let Some(len) = opt.strip_prefix("create=") {
2244                create_with_len = Some(parse_memory(len)?);
2245            } else if opt == "direct" {
2246                direct = true;
2247            } else {
2248                anyhow::bail!("invalid file option '{opt}', expected 'create=<len>' or 'direct'");
2249            }
2250        }
2251    }
2252
2253    Ok(FileOpts {
2254        path: path.into(),
2255        create_with_len,
2256        direct,
2257    })
2258}
2259
2260impl DiskCliKind {
2261    /// Parse an `autocache:[key]:<kind>` disk spec, given the cache path
2262    /// (normally read from `OPENVMM_AUTO_CACHE_PATH`).
2263    fn parse_autocache(
2264        arg: &str,
2265        cache_path: Result<String, std::env::VarError>,
2266    ) -> anyhow::Result<Self> {
2267        let (key, kind) = arg.split_once(':').context("expected [key]:kind")?;
2268        let cache_path = cache_path.context("must set cache path via OPENVMM_AUTO_CACHE_PATH")?;
2269        Ok(DiskCliKind::AutoCacheSqlite {
2270            cache_path,
2271            key: (!key.is_empty()).then(|| key.to_string()),
2272            disk: Box::new(kind.parse()?),
2273        })
2274    }
2275}
2276
2277impl FromStr for DiskCliKind {
2278    type Err = anyhow::Error;
2279
2280    fn from_str(s: &str) -> anyhow::Result<Self> {
2281        let disk = match s.split_once(':') {
2282            // convenience support for passing bare paths as file disks
2283            None => {
2284                let FileOpts {
2285                    path,
2286                    create_with_len,
2287                    direct,
2288                } = parse_file_opts(s)?;
2289                DiskCliKind::File {
2290                    path,
2291                    create_with_len,
2292                    direct,
2293                }
2294            }
2295            Some((kind, arg)) => match kind {
2296                "mem" => DiskCliKind::Memory(parse_memory(arg)?),
2297                "memdiff" => DiskCliKind::MemoryDiff(Box::new(arg.parse()?)),
2298                "sql" => {
2299                    let FileOpts {
2300                        path,
2301                        create_with_len,
2302                        direct,
2303                    } = parse_file_opts(arg)?;
2304                    if direct {
2305                        anyhow::bail!("'direct' is not supported for 'sql' disks");
2306                    }
2307                    DiskCliKind::Sqlite {
2308                        path,
2309                        create_with_len,
2310                    }
2311                }
2312                "sqldiff" => {
2313                    let (path_and_opts, kind) =
2314                        arg.split_once(':').context("expected path[;opts]:kind")?;
2315                    let disk = Box::new(kind.parse()?);
2316                    match path_and_opts.split_once(';') {
2317                        Some((path, create)) => {
2318                            if create != "create" {
2319                                anyhow::bail!("invalid syntax after ';', expected 'create'")
2320                            }
2321                            DiskCliKind::SqliteDiff {
2322                                path: path.into(),
2323                                create: true,
2324                                disk,
2325                            }
2326                        }
2327                        None => DiskCliKind::SqliteDiff {
2328                            path: path_and_opts.into(),
2329                            create: false,
2330                            disk,
2331                        },
2332                    }
2333                }
2334                "autocache" => {
2335                    Self::parse_autocache(arg, std::env::var("OPENVMM_AUTO_CACHE_PATH"))?
2336                }
2337                "prwrap" => DiskCliKind::PersistentReservationsWrapper(Box::new(arg.parse()?)),
2338                "delay" => {
2339                    let (delay_ms, kind) = arg.split_once(':').context("expected delay_ms:kind")?;
2340                    DiskCliKind::DelayDiskWrapper {
2341                        delay_ms: delay_ms.parse().context("invalid disk delay")?,
2342                        disk: Box::new(kind.parse()?),
2343                    }
2344                }
2345                "file" => {
2346                    let FileOpts {
2347                        path,
2348                        create_with_len,
2349                        direct,
2350                    } = parse_file_opts(arg)?;
2351                    DiskCliKind::File {
2352                        path,
2353                        create_with_len,
2354                        direct,
2355                    }
2356                }
2357                "blob" => {
2358                    let (blob_kind, url) = arg.split_once(':').context("expected kind:url")?;
2359                    let blob_kind = match blob_kind {
2360                        "flat" => BlobKind::Flat,
2361                        "vhd1" => BlobKind::Vhd1,
2362                        _ => anyhow::bail!("unknown blob kind {blob_kind}"),
2363                    };
2364                    DiskCliKind::Blob {
2365                        kind: blob_kind,
2366                        url: url.to_string(),
2367                    }
2368                }
2369                "crypt" => {
2370                    let (cipher, (key, kind)) = arg
2371                        .split_once(':')
2372                        .and_then(|(cipher, arg)| Some((cipher, arg.split_once(':')?)))
2373                        .context("expected cipher:key_file:kind")?;
2374                    DiskCliKind::Crypt {
2375                        cipher: ValueEnum::from_str(cipher, false)
2376                            .map_err(|err| anyhow::anyhow!("invalid cipher: {err}"))?,
2377                        key_file: PathBuf::from(key),
2378                        disk: Box::new(kind.parse()?),
2379                    }
2380                }
2381                kind => {
2382                    // here's a fun edge case: what if the user passes `--disk d:\path\to\disk.img`?
2383                    //
2384                    // in this case, we actually want to treat that leading `d:` as part of the
2385                    // path, rather than as a disk with `kind == 'd'`
2386                    let FileOpts {
2387                        path,
2388                        create_with_len,
2389                        direct,
2390                    } = parse_file_opts(s)?;
2391                    if path.has_root() {
2392                        DiskCliKind::File {
2393                            path,
2394                            create_with_len,
2395                            direct,
2396                        }
2397                    } else {
2398                        anyhow::bail!("invalid disk kind {kind}");
2399                    }
2400                }
2401            },
2402        };
2403        Ok(disk)
2404    }
2405}
2406
2407/// Wire transport selection for `--rpc`.
2408#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
2409pub enum RpcTransportCli {
2410    /// Auto-detect ttrpc vs. gRPC per connection, based on the first byte of
2411    /// the stream.
2412    #[default]
2413    Auto,
2414    /// Accept ttrpc clients only.
2415    Ttrpc,
2416    /// Accept gRPC clients only.
2417    Grpc,
2418}
2419
2420/// RPC server configuration parsed from `--rpc`.
2421#[derive(Clone, Debug, PartialEq, Eq)]
2422pub struct RpcCli {
2423    /// Unix socket path to listen on.
2424    pub path: PathBuf,
2425    /// Wire transport to accept.
2426    pub transport: RpcTransportCli,
2427}
2428
2429impl FromStr for RpcCli {
2430    type Err = anyhow::Error;
2431
2432    fn from_str(s: &str) -> anyhow::Result<Self> {
2433        let mut path = None;
2434        let mut transport = None;
2435        for part in s.split(',') {
2436            let (key, value) = part
2437                .split_once('=')
2438                .with_context(|| format!("invalid rpc option '{part}', expected key=value"))?;
2439            match key {
2440                "path" => {
2441                    anyhow::ensure!(path.is_none(), "duplicate option 'path'");
2442                    anyhow::ensure!(!value.is_empty(), "'path' requires a value");
2443                    path = Some(PathBuf::from(value));
2444                }
2445                "transport" => {
2446                    anyhow::ensure!(transport.is_none(), "duplicate option 'transport'");
2447                    transport = Some(match value {
2448                        "auto" => RpcTransportCli::Auto,
2449                        "ttrpc" => RpcTransportCli::Ttrpc,
2450                        "grpc" => RpcTransportCli::Grpc,
2451                        _ => anyhow::bail!(
2452                            "invalid transport '{value}', expected auto, ttrpc, or grpc"
2453                        ),
2454                    });
2455                }
2456                _ => anyhow::bail!("unknown rpc option '{key}'"),
2457            }
2458        }
2459
2460        Ok(RpcCli {
2461            path: path.context("'path' is required")?,
2462            transport: transport.unwrap_or_default(),
2463        })
2464    }
2465}
2466
2467#[derive(Clone)]
2468pub struct VmgsCli {
2469    pub kind: DiskCliKind,
2470    pub provision: ProvisionCondition,
2471}
2472
2473#[derive(Copy, Clone)]
2474pub enum ProvisionCondition {
2475    OnEmpty,
2476    OnFailure,
2477    Single,
2478}
2479
2480impl FromStr for VmgsCli {
2481    type Err = anyhow::Error;
2482
2483    fn from_str(s: &str) -> anyhow::Result<Self> {
2484        let (kind, opt) = s
2485            .split_once(',')
2486            .map(|(k, o)| (k, Some(o)))
2487            .unwrap_or((s, None));
2488        let kind = kind.parse()?;
2489
2490        let provision = match opt {
2491            None => ProvisionCondition::OnEmpty,
2492            Some("fmt-on-fail") => ProvisionCondition::OnFailure,
2493            Some("fmt") => ProvisionCondition::Single,
2494            Some(opt) => anyhow::bail!("unknown option: '{opt}'"),
2495        };
2496
2497        Ok(VmgsCli { kind, provision })
2498    }
2499}
2500
2501/// VNC server configuration options.
2502#[derive(clap::Args)]
2503pub struct VncCli {
2504    /// Listen for VNC connections. Implied by --gfx.
2505    #[clap(long)]
2506    pub vnc: bool,
2507
2508    /// VNC port number
2509    #[clap(long, value_name = "PORT", default_value = "5900")]
2510    pub vnc_port: u16,
2511
2512    /// VNC listen address (use 0.0.0.0 for all IPv4, :: for dual-stack IPv4+IPv6).
2513    /// Accepts a bare IP address (combined with --vnc-port) or a full socket
2514    /// address like [::1]:5900 (overrides --vnc-port).
2515    #[clap(long, value_name = "ADDRESS", default_value = "127.0.0.1")]
2516    pub vnc_listen: String,
2517
2518    /// Maximum concurrent VNC clients (~8MB memory per client for framebuffer buffers)
2519    #[clap(long, value_name = "COUNT", default_value = "16")]
2520    pub vnc_max_clients: usize,
2521
2522    /// When the client limit is reached, disconnect the oldest client
2523    /// instead of rejecting the new connection
2524    #[clap(long)]
2525    pub vnc_evict_oldest: bool,
2526}
2527
2528// <kind>[,ro]
2529#[derive(Clone)]
2530pub struct DiskCli {
2531    pub vtl: DeviceVtl,
2532    pub kind: DiskCliKind,
2533    pub read_only: bool,
2534    pub is_dvd: bool,
2535    pub underhill: Option<UnderhillDiskSource>,
2536    pub pcie_port: Option<String>,
2537    pub serial: Option<String>,
2538    pub controller: Option<String>,
2539    pub nsid: Option<u32>,
2540    pub lun: Option<u8>,
2541    pub relay: Option<(String, Option<u32>)>,
2542}
2543
2544#[derive(Copy, Clone)]
2545pub enum UnderhillDiskSource {
2546    Scsi,
2547    Nvme,
2548}
2549
2550/// A `relay=<name>[:<location>]` target for an OpenHCL-managed controller.
2551struct RelayTarget {
2552    name: String,
2553    location: Option<u32>,
2554}
2555
2556impl FromStr for RelayTarget {
2557    type Err = anyhow::Error;
2558
2559    fn from_str(s: &str) -> anyhow::Result<Self> {
2560        Ok(if let Some((name, loc)) = s.split_once(':') {
2561            RelayTarget {
2562                name: name.to_string(),
2563                location: Some(loc.parse::<u32>().context("invalid relay location")?),
2564            }
2565        } else {
2566            RelayTarget {
2567                name: s.to_string(),
2568                location: None,
2569            }
2570        })
2571    }
2572}
2573
2574/// Raw `--disk`/`--nvme`/`--virtio-blk` options, resolved and validated into a
2575/// [`DiskCli`] by its `FromStr`.
2576#[derive(vmm_cli::KeyValueArgs)]
2577struct DiskArgs {
2578    #[kv(positional)]
2579    kind: DiskCliKind,
2580    #[kv(flag)]
2581    ro: bool,
2582    #[kv(flag)]
2583    dvd: bool,
2584    #[kv(flag, key = "vtl2", present = DeviceVtl::Vtl2, absent = DeviceVtl::Vtl0)]
2585    vtl: DeviceVtl,
2586    #[kv(flag)]
2587    uh: bool,
2588    #[kv(flag, key = "uh-nvme")]
2589    uh_nvme: bool,
2590    pcie_port: Option<String>,
2591    serial: Option<String>,
2592    #[kv(key = "on")]
2593    controller: Option<String>,
2594    nsid: Option<u32>,
2595    lun: Option<u8>,
2596    relay: Option<RelayTarget>,
2597}
2598
2599impl FromStr for DiskCli {
2600    type Err = anyhow::Error;
2601
2602    fn from_str(s: &str) -> anyhow::Result<Self> {
2603        let args: DiskArgs = s.parse()?;
2604
2605        let underhill = match (args.uh, args.uh_nvme) {
2606            (false, false) => None,
2607            (true, false) => Some(UnderhillDiskSource::Scsi),
2608            (false, true) => Some(UnderhillDiskSource::Nvme),
2609            (true, true) => anyhow::bail!("`uh` and `uh-nvme` are mutually exclusive"),
2610        };
2611        let read_only = args.ro || args.dvd;
2612        let is_dvd = args.dvd;
2613        let vtl = args.vtl;
2614        let pcie_port = args.pcie_port;
2615        let serial = args.serial;
2616        let controller = args.controller;
2617        let nsid = args.nsid;
2618        let lun = args.lun;
2619        let relay = args.relay.map(|r| (r.name, r.location));
2620
2621        if serial.as_ref().is_some_and(|serial| {
2622            serial.bytes().any(|byte| {
2623                !(byte.is_ascii_graphic() || byte == b' ') || matches!(byte, b'[' | b']')
2624            })
2625        }) {
2626            anyhow::bail!("`serial` must contain only printable ASCII characters and no brackets");
2627        }
2628
2629        if underhill.is_some() && vtl != DeviceVtl::Vtl0 {
2630            anyhow::bail!("`uh` or `uh-nvme` is incompatible with `vtl2`");
2631        }
2632
2633        if pcie_port.is_some() && (underhill.is_some() || vtl != DeviceVtl::Vtl0 || is_dvd) {
2634            anyhow::bail!("`pcie_port` is incompatible with `uh`, `uh-nvme`, `vtl2`, and `dvd`");
2635        }
2636
2637        if controller.is_some() && pcie_port.is_some() {
2638            anyhow::bail!("`on` is incompatible with `pcie_port`");
2639        }
2640
2641        if controller.is_some() && vtl != DeviceVtl::Vtl0 {
2642            anyhow::bail!(
2643                "`vtl2` is incompatible with `on`; the controller's VTL determines placement"
2644            );
2645        }
2646
2647        if controller.is_some() && underhill.is_some() {
2648            anyhow::bail!("`on` is incompatible with `uh` and `uh-nvme`; use `relay` instead");
2649        }
2650
2651        if nsid.is_some() && controller.is_none() {
2652            anyhow::bail!("`nsid` requires `on`");
2653        }
2654
2655        if lun.is_some() && controller.is_none() {
2656            anyhow::bail!("`lun` requires `on`");
2657        }
2658
2659        if nsid.is_some() && lun.is_some() {
2660            anyhow::bail!("`nsid` and `lun` are mutually exclusive");
2661        }
2662
2663        if relay.is_some() && controller.is_none() {
2664            anyhow::bail!("`relay` requires `on`");
2665        }
2666
2667        if relay.is_some() && underhill.is_some() {
2668            anyhow::bail!("`relay` is incompatible with `uh` and `uh-nvme`");
2669        }
2670
2671        Ok(DiskCli {
2672            vtl,
2673            kind: args.kind,
2674            read_only,
2675            is_dvd,
2676            underhill,
2677            pcie_port,
2678            serial,
2679            controller,
2680            nsid,
2681            lun,
2682            relay,
2683        })
2684    }
2685}
2686
2687/// The transport for a named NVMe controller.
2688#[derive(Clone, Debug, PartialEq, vmm_cli::KeyValueGroup)]
2689pub enum NvmeControllerTransport {
2690    /// Present via PCIe on the specified root port.
2691    #[kv(key = "pcie_port")]
2692    Pcie(String),
2693    /// Present via VPCI with an optional instance GUID.
2694    #[kv(key = "vpci")]
2695    Vpci(Option<Guid>),
2696}
2697
2698/// CLI arguments for a named NVMe controller.
2699#[derive(Clone, Debug, vmm_cli::KeyValueArgs)]
2700pub struct NvmeControllerCli {
2701    /// Controller name, referenced by `--disk on=<name>`.
2702    pub id: String,
2703    /// Transport configuration.
2704    #[kv(flatten)]
2705    pub transport: NvmeControllerTransport,
2706    /// VTL assignment (default VTL0).
2707    #[kv(flag, key = "vtl2", present = DeviceVtl::Vtl2, absent = DeviceVtl::Vtl0)]
2708    pub vtl: DeviceVtl,
2709}
2710
2711/// CLI arguments for a named VMBus SCSI controller.
2712#[derive(Clone, Debug, vmm_cli::KeyValueArgs)]
2713pub struct ScsiControllerCli {
2714    /// Controller name, referenced by `--disk on=<name>`.
2715    pub id: String,
2716    /// Number of sub-channels.
2717    #[kv(default)]
2718    pub sub_channels: u16,
2719    /// VTL assignment (default VTL0).
2720    #[kv(flag, key = "vtl2", present = DeviceVtl::Vtl2, absent = DeviceVtl::Vtl0)]
2721    pub vtl: DeviceVtl,
2722}
2723
2724/// Protocol type for an OpenHCL-managed controller.
2725#[derive(Copy, Clone, Debug, PartialEq)]
2726pub enum OpenhclControllerType {
2727    Scsi,
2728    Nvme,
2729}
2730
2731/// CLI arguments for an OpenHCL-managed storage controller (relay target).
2732#[derive(Clone, Debug, vmm_cli::KeyValueArgs)]
2733pub struct OpenhclControllerCli {
2734    /// Controller name, referenced by `--disk ... relay=<name>`.
2735    pub id: String,
2736    /// Controller protocol.
2737    #[kv(key = "type")]
2738    pub controller_type: OpenhclControllerType,
2739    /// Instance GUID (auto-derived from name if omitted).
2740    pub guid: Option<Guid>,
2741}
2742
2743impl FromStr for OpenhclControllerType {
2744    type Err = anyhow::Error;
2745
2746    fn from_str(s: &str) -> anyhow::Result<Self> {
2747        Ok(match s {
2748            "scsi" => OpenhclControllerType::Scsi,
2749            "nvme" => OpenhclControllerType::Nvme,
2750            other => anyhow::bail!("unknown controller type: '{other}'"),
2751        })
2752    }
2753}
2754
2755/// CLI arguments for a CXL Type-3 test endpoint.
2756#[derive(Clone, Debug, PartialEq)]
2757pub struct CxlTestDeviceCli {
2758    /// Size of HDM memory the test device should expose and back.
2759    pub hdm_size: u64,
2760    /// PCIe root port name where the device is attached.
2761    pub pcie_port: String,
2762}
2763
2764impl FromStr for CxlTestDeviceCli {
2765    type Err = anyhow::Error;
2766
2767    fn from_str(s: &str) -> anyhow::Result<Self> {
2768        let mut opts = s.split(',');
2769        let first = opts.next().context("expected CXL test device config")?;
2770        let (kind, arg) = first
2771            .split_once(':')
2772            .context("expected CXL test syntax: mem:<len>")?;
2773
2774        if kind != "mem" {
2775            anyhow::bail!("unsupported CXL test backing kind '{kind}', expected 'mem'");
2776        }
2777
2778        let hdm_size = parse_memory(arg).context("failed to parse CXL test HDM size")?;
2779        let mut pcie_port = None;
2780
2781        for opt in opts {
2782            let mut kv = opt.split('=');
2783            let key = kv.next().unwrap_or_default();
2784            match key {
2785                "pcie_port" => {
2786                    let val = kv.next();
2787                    if val.is_none_or(|v| v.is_empty()) {
2788                        anyhow::bail!("`pcie_port` requires a port name");
2789                    }
2790                    pcie_port = Some(val.unwrap().to_string());
2791                }
2792                _ => anyhow::bail!("unknown option: '{opt}'"),
2793            }
2794        }
2795
2796        let Some(pcie_port) = pcie_port else {
2797            anyhow::bail!("`pcie_port=<name>` is required for `--cxl-test`");
2798        };
2799
2800        Ok(Self {
2801            hdm_size,
2802            pcie_port,
2803        })
2804    }
2805}
2806
2807// <kind>[,ro,s]
2808#[derive(Clone)]
2809pub struct IdeDiskCli {
2810    pub kind: DiskCliKind,
2811    pub read_only: bool,
2812    pub channel: Option<u8>,
2813    pub device: Option<u8>,
2814    pub is_dvd: bool,
2815}
2816
2817impl FromStr for IdeDiskCli {
2818    type Err = anyhow::Error;
2819
2820    fn from_str(s: &str) -> anyhow::Result<Self> {
2821        let mut opts = s.split(',');
2822        let kind = opts.next().unwrap().parse()?;
2823
2824        let mut read_only = false;
2825        let mut channel = None;
2826        let mut device = None;
2827        let mut is_dvd = false;
2828        for opt in opts {
2829            let mut s = opt.split('=');
2830            let opt = s.next().unwrap();
2831            match opt {
2832                "ro" => read_only = true,
2833                "p" => channel = Some(0),
2834                "s" => channel = Some(1),
2835                "0" => device = Some(0),
2836                "1" => device = Some(1),
2837                "dvd" => {
2838                    is_dvd = true;
2839                    read_only = true;
2840                }
2841                _ => anyhow::bail!("unknown option: '{opt}'"),
2842            }
2843        }
2844
2845        Ok(IdeDiskCli {
2846            kind,
2847            read_only,
2848            channel,
2849            device,
2850            is_dvd,
2851        })
2852    }
2853}
2854
2855// <kind>[,ro]
2856#[derive(Clone, Debug, PartialEq, vmm_cli::KeyValueArgs)]
2857pub struct FloppyDiskCli {
2858    #[kv(positional)]
2859    pub kind: DiskCliKind,
2860    #[kv(flag, key = "ro")]
2861    pub read_only: bool,
2862}
2863
2864#[derive(Clone)]
2865pub struct DebugconSerialConfigCli {
2866    pub port: u16,
2867    pub serial: SerialConfigCli,
2868}
2869
2870impl FromStr for DebugconSerialConfigCli {
2871    type Err = String;
2872
2873    fn from_str(s: &str) -> Result<Self, Self::Err> {
2874        let Some((port, serial)) = s.split_once(',') else {
2875            return Err("invalid format (missing comma between port and serial)".into());
2876        };
2877
2878        let port: u16 = parse_number(port)
2879            .map_err(|_| "could not parse port".to_owned())?
2880            .try_into()
2881            .map_err(|_| "port must be 16-bit")?;
2882        let serial: SerialConfigCli = serial.parse()?;
2883
2884        Ok(Self { port, serial })
2885    }
2886}
2887
2888/// A COM port binding, optionally prefixed with `debugger-mode:` to run the
2889/// port in debugger mode for WinDbg / KD-over-serial.
2890#[derive(Clone, Debug, PartialEq)]
2891pub struct ComSerialConfigCli {
2892    /// Whether this COM port runs in debugger mode (for WinDbg / KD-over-serial).
2893    pub debugger_mode: bool,
2894    /// The serial backend for this COM port.
2895    pub backend: SerialConfigCli,
2896}
2897
2898impl FromStr for ComSerialConfigCli {
2899    type Err = String;
2900
2901    fn from_str(s: &str) -> Result<Self, Self::Err> {
2902        match s.strip_prefix("debugger-mode:") {
2903            Some(rest) => Ok(Self {
2904                debugger_mode: true,
2905                backend: rest.parse()?,
2906            }),
2907            None => Ok(Self {
2908                debugger_mode: false,
2909                backend: s.parse()?,
2910            }),
2911        }
2912    }
2913}
2914
2915/// (console | stderr | listen=\<path\> | file=\<path\> (overwrites) | listen=tcp:\<ip\>:\<port\> | term[=\<program\>]\[,name=\<windowtitle\>\] | null | none)
2916#[derive(Clone, Debug, PartialEq)]
2917pub enum SerialConfigCli {
2918    None,
2919    Null,
2920    Console,
2921    NewConsole(Option<PathBuf>, Option<String>),
2922    Stderr,
2923    Pipe(PathBuf),
2924    Tcp(SocketAddr),
2925    File(PathBuf),
2926}
2927
2928impl FromStr for SerialConfigCli {
2929    type Err = String;
2930
2931    fn from_str(s: &str) -> Result<Self, Self::Err> {
2932        let keyvalues = SerialConfigCli::parse_keyvalues(s)?;
2933
2934        let first_key = match keyvalues.first() {
2935            Some(first_pair) => first_pair.0.as_str(),
2936            None => Err("invalid serial configuration: no values supplied")?,
2937        };
2938        let first_value = keyvalues.first().unwrap().1.as_ref();
2939
2940        let ret = match first_key {
2941            "none" => SerialConfigCli::None,
2942            "null" => SerialConfigCli::Null,
2943            "console" => SerialConfigCli::Console,
2944            "stderr" => SerialConfigCli::Stderr,
2945            "file" => match first_value {
2946                Some(path) => SerialConfigCli::File(path.into()),
2947                None => Err("invalid serial configuration: file requires a value")?,
2948            },
2949            "term" => {
2950                // If user supplies a name key, use it to title the window
2951                let window_name = keyvalues.iter().find(|(key, _)| key == "name");
2952                let window_name = match window_name {
2953                    Some((_, Some(name))) => Some(name.clone()),
2954                    _ => None,
2955                };
2956
2957                SerialConfigCli::NewConsole(first_value.map(|p| p.into()), window_name)
2958            }
2959            "listen" => match first_value {
2960                Some(path) if let Some(tcp) = path.strip_prefix("tcp:") => {
2961                    let addr = tcp
2962                        .parse()
2963                        .map_err(|err| format!("invalid tcp address: {err}"))?;
2964                    SerialConfigCli::Tcp(addr)
2965                }
2966                Some(path) => SerialConfigCli::Pipe(path.into()),
2967                None => Err(
2968                    "invalid serial configuration: listen requires a value of tcp:addr or pipe",
2969                )?,
2970            },
2971            _ => {
2972                return Err(format!(
2973                    "invalid serial configuration: '{}' is not a known option",
2974                    first_key
2975                ));
2976            }
2977        };
2978
2979        Ok(ret)
2980    }
2981}
2982
2983impl SerialConfigCli {
2984    /// Parse a comma separated list of key=value options into a vector of
2985    /// key/value pairs.
2986    fn parse_keyvalues(s: &str) -> Result<Vec<(String, Option<String>)>, String> {
2987        let mut ret = Vec::new();
2988
2989        // For each comma separated item in the supplied list
2990        for item in s.split(',') {
2991            // Split on the = for key and value
2992            // If no = is found, treat key as key and value as None
2993            let mut eqsplit = item.split('=');
2994            let key = eqsplit.next();
2995            let value = eqsplit.next();
2996
2997            if let Some(key) = key {
2998                ret.push((key.to_owned(), value.map(|x| x.to_owned())));
2999            } else {
3000                // An empty key is invalid
3001                return Err("invalid key=value pair in serial config".into());
3002            }
3003        }
3004        Ok(ret)
3005    }
3006}
3007
3008#[derive(Clone, Debug, PartialEq)]
3009pub enum EndpointConfigCli {
3010    None,
3011    Consomme {
3012        cidr: Option<String>,
3013        host_fwd: Vec<HostPortConfigCli>,
3014    },
3015    Dio {
3016        id: Option<String>,
3017    },
3018    Tap {
3019        name: String,
3020    },
3021}
3022
3023/// Parsed host port forwarding configuration from the CLI.
3024#[derive(Clone, Debug, PartialEq)]
3025pub struct HostPortConfigCli {
3026    pub protocol: HostPortProtocolCli,
3027    pub host_address: Option<std::net::IpAddr>,
3028    pub host_port: u16,
3029    pub guest_port: u16,
3030}
3031
3032/// Protocol for host port forwarding.
3033#[derive(Clone, Debug, PartialEq)]
3034pub enum HostPortProtocolCli {
3035    Tcp,
3036    Udp,
3037}
3038
3039fn parse_hostfwd(s: &str) -> Result<HostPortConfigCli, String> {
3040    // Format: protocol:[hostaddr]:hostport-[guestaddr]:guestport
3041    // Examples: "tcp::3389-:3389", "tcp:127.0.0.1:8080-:80", "tcp:[::1]:8080-:80"
3042    let (host_part, guest_part) = s.split_once('-').ok_or_else(|| {
3043        format!(
3044            "invalid hostfwd format '{s}', \
3045             expected 'proto:[hostaddr]:hostport-[guestaddr]:guestport'"
3046        )
3047    })?;
3048
3049    // Extract protocol from host part (first colon-delimited field)
3050    let (proto, host_addr_port) = host_part.split_once(':').ok_or_else(|| {
3051        format!("invalid hostfwd host part '{host_part}', expected 'proto:[hostaddr]:hostport'")
3052    })?;
3053    let protocol = match proto {
3054        "tcp" => HostPortProtocolCli::Tcp,
3055        "udp" => HostPortProtocolCli::Udp,
3056        other => {
3057            return Err(format!(
3058                "unknown hostfwd protocol '{other}', expected 'tcp' or 'udp'"
3059            ));
3060        }
3061    };
3062
3063    let (host_address, host_port) = parse_addr_port(host_addr_port)
3064        .map_err(|e| format!("invalid hostfwd host address/port: {e}"))?;
3065    let (_, guest_port) = parse_addr_port(guest_part)
3066        .map_err(|e| format!("invalid hostfwd guest address/port: {e}"))?;
3067
3068    Ok(HostPortConfigCli {
3069        protocol,
3070        host_address,
3071        host_port,
3072        guest_port,
3073    })
3074}
3075
3076/// Parse an address-port pair in one of these forms:
3077/// - `[ipv6addr]:port`
3078/// - `addr:port`
3079/// - `:port`  (empty address)
3080/// - `port`   (no address)
3081fn parse_addr_port(s: &str) -> Result<(Option<std::net::IpAddr>, u16), String> {
3082    if let Some(rest) = s.strip_prefix('[') {
3083        // Bracketed IPv6 address: [addr]:port
3084        let (addr, port) = rest
3085            .split_once("]:")
3086            .ok_or_else(|| format!("expected '[addr]:port', got '[{rest}'"))?;
3087        let port: u16 = port.parse().map_err(|_| format!("invalid port '{port}'"))?;
3088        let addr: std::net::IpAddr = addr
3089            .parse()
3090            .map_err(|e| format!("invalid address '{addr}': {e}"))?;
3091        Ok((Some(addr), port))
3092    } else {
3093        match s.rsplit_once(':') {
3094            Some((addr, port)) => {
3095                let port: u16 = port.parse().map_err(|_| format!("invalid port '{port}'"))?;
3096                let addr = if addr.is_empty() {
3097                    None
3098                } else {
3099                    let parsed: std::net::IpAddr = addr
3100                        .parse()
3101                        .map_err(|e| format!("invalid address '{addr}': {e}"))?;
3102                    Some(parsed)
3103                };
3104                Ok((addr, port))
3105            }
3106            None => {
3107                let port: u16 = s.parse().map_err(|_| format!("invalid port '{s}'"))?;
3108                Ok((None, port))
3109            }
3110        }
3111    }
3112}
3113
3114impl FromStr for EndpointConfigCli {
3115    type Err = String;
3116
3117    fn from_str(s: &str) -> Result<Self, Self::Err> {
3118        let ret = match s.split(':').collect::<Vec<_>>().as_slice() {
3119            ["none"] => EndpointConfigCli::None,
3120            ["consomme", rest @ ..] => {
3121                let remaining = rest.join(":");
3122                let mut cidr = None;
3123                let mut host_fwd = Vec::new();
3124                for opt in remaining.split(',').filter(|s| !s.is_empty()) {
3125                    if let Some(fwd) = opt.strip_prefix("hostfwd=") {
3126                        host_fwd.push(parse_hostfwd(fwd)?);
3127                    } else if cidr.is_none() {
3128                        cidr = Some(opt.to_owned());
3129                    } else {
3130                        return Err(format!("unexpected consomme option '{opt}'"));
3131                    }
3132                }
3133                EndpointConfigCli::Consomme { cidr, host_fwd }
3134            }
3135            ["dio", s @ ..] => EndpointConfigCli::Dio {
3136                id: s.first().map(|s| (*s).to_owned()),
3137            },
3138            ["tap", name] => EndpointConfigCli::Tap {
3139                name: (*name).to_owned(),
3140            },
3141            _ => return Err("invalid network backend".into()),
3142        };
3143
3144        Ok(ret)
3145    }
3146}
3147
3148#[derive(Clone, Debug, PartialEq)]
3149pub struct NicConfigCli {
3150    pub vtl: DeviceVtl,
3151    pub endpoint: EndpointConfigCli,
3152    /// MAC address to expose to the guest. A random address is used if omitted.
3153    pub mac_address: Option<MacAddress>,
3154    pub max_queues: Option<u16>,
3155    pub enable_rx_gso: bool,
3156    pub underhill: bool,
3157    pub pcie_port: Option<String>,
3158}
3159
3160impl FromStr for NicConfigCli {
3161    type Err = String;
3162
3163    fn from_str(mut s: &str) -> Result<Self, Self::Err> {
3164        let mut vtl = DeviceVtl::Vtl0;
3165        let mut mac_address = None;
3166        let mut max_queues = None;
3167        let mut enable_rx_gso = false;
3168        let mut underhill = false;
3169        let mut pcie_port = None;
3170        while let Some((opt, rest)) = s.split_once(':') {
3171            if let Some((opt, val)) = opt.split_once('=') {
3172                match opt {
3173                    "queues" => {
3174                        max_queues = Some(val.parse().map_err(|_| "failed to parse queue count")?);
3175                    }
3176                    "mac" => {
3177                        mac_address = Some(val.parse().map_err(|_| "failed to parse MAC address")?);
3178                    }
3179                    "pcie_port" => {
3180                        if val.is_empty() {
3181                            return Err("`pcie_port=` requires port name argument".into());
3182                        }
3183                        pcie_port = Some(val.to_string());
3184                    }
3185                    _ => break,
3186                }
3187            } else {
3188                match opt {
3189                    "vtl2" => {
3190                        vtl = DeviceVtl::Vtl2;
3191                    }
3192                    "uh" => underhill = true,
3193                    "rx_gso" => enable_rx_gso = true,
3194                    _ => break,
3195                }
3196            }
3197            s = rest;
3198        }
3199
3200        if underhill && vtl != DeviceVtl::Vtl0 {
3201            return Err("`uh` is incompatible with `vtl2`".into());
3202        }
3203
3204        if pcie_port.is_some() && (underhill || vtl != DeviceVtl::Vtl0) {
3205            return Err("`pcie_port` is incompatible with `uh` and `vtl2`".into());
3206        }
3207
3208        let endpoint = s.parse()?;
3209        Ok(NicConfigCli {
3210            vtl,
3211            endpoint,
3212            mac_address,
3213            max_queues,
3214            enable_rx_gso,
3215            underhill,
3216            pcie_port,
3217        })
3218    }
3219}
3220
3221#[derive(Debug, Error)]
3222#[error("unknown VTL2 relocation type: {0}")]
3223pub struct UnknownVtl2RelocationType(String);
3224
3225fn parse_vtl2_relocation(s: &str) -> Result<Vtl2BaseAddressType, UnknownVtl2RelocationType> {
3226    match s {
3227        "disable" => Ok(Vtl2BaseAddressType::File),
3228        s if s.starts_with("auto=") => {
3229            let s = s.strip_prefix("auto=").unwrap_or_default();
3230            let size = if s == "filesize" {
3231                None
3232            } else {
3233                let size = parse_memory(s).map_err(|e| {
3234                    UnknownVtl2RelocationType(format!(
3235                        "unable to parse memory size from {} for 'auto=' type, {e}",
3236                        e
3237                    ))
3238                })?;
3239                Some(size)
3240            };
3241            Ok(Vtl2BaseAddressType::MemoryLayout { size })
3242        }
3243        s if s.starts_with("absolute=") => {
3244            let s = s.strip_prefix("absolute=");
3245            let addr = parse_number(s.unwrap_or_default()).map_err(|e| {
3246                UnknownVtl2RelocationType(format!(
3247                    "unable to parse number from {} for 'absolute=' type",
3248                    e
3249                ))
3250            })?;
3251            Ok(Vtl2BaseAddressType::Absolute(addr))
3252        }
3253        s if s.starts_with("vtl2=") => {
3254            let s = s.strip_prefix("vtl2=").unwrap_or_default();
3255            let size = if s == "filesize" {
3256                None
3257            } else {
3258                let size = parse_memory(s).map_err(|e| {
3259                    UnknownVtl2RelocationType(format!(
3260                        "unable to parse memory size from {} for 'vtl2=' type, {e}",
3261                        e
3262                    ))
3263                })?;
3264                Some(size)
3265            };
3266            Ok(Vtl2BaseAddressType::Vtl2Allocate { size })
3267        }
3268        _ => Err(UnknownVtl2RelocationType(s.to_owned())),
3269    }
3270}
3271
3272#[derive(Debug, Copy, Clone, PartialEq)]
3273pub enum SmtConfigCli {
3274    Auto,
3275    Force,
3276    Off,
3277}
3278
3279#[derive(Debug, Error)]
3280#[error("expected auto, force, or off")]
3281pub struct BadSmtConfig;
3282
3283impl FromStr for SmtConfigCli {
3284    type Err = BadSmtConfig;
3285
3286    fn from_str(s: &str) -> Result<Self, Self::Err> {
3287        let r = match s {
3288            "auto" => Self::Auto,
3289            "force" => Self::Force,
3290            "off" => Self::Off,
3291            _ => return Err(BadSmtConfig),
3292        };
3293        Ok(r)
3294    }
3295}
3296
3297#[cfg_attr(not(guest_arch = "x86_64"), expect(dead_code))]
3298fn parse_x2apic(s: &str) -> Result<X2ApicConfig, &'static str> {
3299    let r = match s {
3300        "auto" => X2ApicConfig::Auto,
3301        "supported" => X2ApicConfig::Supported,
3302        "off" => X2ApicConfig::Unsupported,
3303        "on" => X2ApicConfig::Enabled,
3304        _ => return Err("expected auto, supported, off, or on"),
3305    };
3306    Ok(r)
3307}
3308
3309#[derive(Debug, Copy, Clone, ValueEnum)]
3310pub enum Vtl0LateMapPolicyCli {
3311    Off,
3312    Log,
3313    Halt,
3314    Exception,
3315}
3316
3317/// PCIe MSI controller selection for aarch64.
3318#[derive(Debug, Copy, Clone, Default, ValueEnum)]
3319pub enum GicMsiCli {
3320    /// Use ITS when available, fall back to GICv2m.
3321    #[default]
3322    Auto,
3323    /// Force GICv3 ITS (LPI-based MSIs).
3324    Its,
3325    /// Force GICv2m (SPI-based MSIs).
3326    V2m,
3327}
3328
3329#[derive(Debug, Copy, Clone, ValueEnum)]
3330pub enum IsolationCli {
3331    Vbs,
3332    Snp,
3333}
3334
3335#[derive(Debug, Copy, Clone, Eq, PartialEq, ValueEnum)]
3336pub enum IgvmPersonalityCli {
3337    /// OpenHCL paravisor hosted in VTL2.
3338    Openhcl,
3339    /// UEFI firmware loaded directly from the IGVM.
3340    Uefi,
3341    /// Linux kernel loaded directly from the IGVM.
3342    LinuxDirect,
3343}
3344
3345impl FromStr for IgvmPersonalityCli {
3346    type Err = String;
3347
3348    fn from_str(value: &str) -> Result<Self, Self::Err> {
3349        ValueEnum::from_str(value, false)
3350    }
3351}
3352
3353#[derive(Debug, Copy, Clone, PartialEq)]
3354pub struct PcatBootOrderCli(pub [PcatBootDevice; 4]);
3355
3356impl FromStr for PcatBootOrderCli {
3357    type Err = &'static str;
3358
3359    fn from_str(s: &str) -> Result<Self, Self::Err> {
3360        let mut default_order = DEFAULT_PCAT_BOOT_ORDER.map(Some);
3361        let mut order = Vec::new();
3362
3363        for item in s.split(',') {
3364            let device = match item {
3365                "optical" => PcatBootDevice::Optical,
3366                "hdd" => PcatBootDevice::HardDrive,
3367                "net" => PcatBootDevice::Network,
3368                "floppy" => PcatBootDevice::Floppy,
3369                _ => return Err("unknown boot device type"),
3370            };
3371
3372            let default_pos = default_order
3373                .iter()
3374                .position(|x| x == &Some(device))
3375                .ok_or("cannot pass duplicate boot devices")?;
3376
3377            order.push(default_order[default_pos].take().unwrap());
3378        }
3379
3380        order.extend(default_order.into_iter().flatten());
3381        assert_eq!(order.len(), 4);
3382
3383        Ok(Self(order.try_into().unwrap()))
3384    }
3385}
3386
3387#[derive(Copy, Clone, Debug, ValueEnum)]
3388pub enum UefiConsoleModeCli {
3389    Default,
3390    Com1,
3391    Com2,
3392    None,
3393}
3394
3395impl FromStr for UefiConsoleModeCli {
3396    type Err = String;
3397
3398    fn from_str(value: &str) -> Result<Self, Self::Err> {
3399        ValueEnum::from_str(value, false)
3400    }
3401}
3402
3403#[derive(Copy, Clone, Debug, Default, ValueEnum)]
3404pub enum EfiDiagnosticsLogLevelCli {
3405    #[default]
3406    Default,
3407    Info,
3408    Full,
3409}
3410
3411impl FromStr for EfiDiagnosticsLogLevelCli {
3412    type Err = String;
3413
3414    fn from_str(value: &str) -> Result<Self, Self::Err> {
3415        ValueEnum::from_str(value, false)
3416    }
3417}
3418
3419#[derive(Clone, Debug, PartialEq)]
3420pub struct PcieRootComplexCli {
3421    pub name: String,
3422    pub segment: u16,
3423    pub start_bus: u8,
3424    pub end_bus: u8,
3425    pub low_mmio: u32,
3426    pub high_mmio: u64,
3427    pub low_mmio_base: Option<u64>,
3428    pub high_mmio_base: Option<u64>,
3429    pub preserve_bars: bool,
3430    pub hdm: u64,
3431    pub hdm_window_restrictions: CfmwsWindowRestrictions,
3432    pub vnode: Option<u32>,
3433}
3434
3435/// A `0x`-prefixed hexadecimal address, used for MMIO base overrides.
3436struct HexAddress(u64);
3437
3438impl FromStr for HexAddress {
3439    type Err = anyhow::Error;
3440
3441    fn from_str(s: &str) -> anyhow::Result<Self> {
3442        Ok(HexAddress(parse_address(s)?))
3443    }
3444}
3445
3446/// A CFMWS window-restrictions bitmask (`0x21` or `33`).
3447struct CfmwsWindowRestrictionsCli(CfmwsWindowRestrictions);
3448
3449impl FromStr for CfmwsWindowRestrictionsCli {
3450    type Err = anyhow::Error;
3451
3452    fn from_str(s: &str) -> anyhow::Result<Self> {
3453        Ok(CfmwsWindowRestrictionsCli(
3454            parse_cxl_cfmws_window_restriction_u16_bitmask(s)?,
3455        ))
3456    }
3457}
3458
3459/// Raw `--pcie-root-complex` options, parsed declaratively. Validated and
3460/// mapped into the public [`PcieRootComplexCli`] by its `FromStr`.
3461#[derive(vmm_cli::KeyValueArgs)]
3462struct PcieRootComplexArgs {
3463    #[kv(positional)]
3464    name: String,
3465    #[kv(default)]
3466    segment: u16,
3467    #[kv(default)]
3468    start_bus: u8,
3469    #[kv(default = 255)]
3470    end_bus: u8,
3471    #[kv(default = vmm_cli::MemorySize(64 * 1024 * 1024))]
3472    low_mmio: vmm_cli::MemorySize,
3473    #[kv(default = vmm_cli::MemorySize(1024 * 1024 * 1024))]
3474    high_mmio: vmm_cli::MemorySize,
3475    low_mmio_base: Option<HexAddress>,
3476    high_mmio_base: Option<HexAddress>,
3477    #[kv(flag)]
3478    preserve_bars: bool,
3479    #[kv(default = vmm_cli::MemorySize(1024 * 1024 * 1024))]
3480    hdm: vmm_cli::MemorySize,
3481    #[kv(default = CfmwsWindowRestrictionsCli(CfmwsWindowRestrictions::DEVICE_COHERENT))]
3482    hdm_window_restrictions: CfmwsWindowRestrictionsCli,
3483    #[kv(key = "node")]
3484    vnode: Option<u32>,
3485}
3486
3487impl FromStr for PcieRootComplexCli {
3488    type Err = anyhow::Error;
3489
3490    fn from_str(s: &str) -> Result<Self, Self::Err> {
3491        let args: PcieRootComplexArgs = s.parse()?;
3492
3493        if args.start_bus > args.end_bus {
3494            anyhow::bail!("start_bus must be <= end_bus");
3495        }
3496
3497        let low_mmio = u32::try_from(args.low_mmio.0).context("low MMIO size exceeds 32 bits")?;
3498
3499        Ok(PcieRootComplexCli {
3500            name: args.name,
3501            segment: args.segment,
3502            start_bus: args.start_bus,
3503            end_bus: args.end_bus,
3504            low_mmio,
3505            high_mmio: args.high_mmio.0,
3506            low_mmio_base: args.low_mmio_base.map(|a| a.0),
3507            high_mmio_base: args.high_mmio_base.map(|a| a.0),
3508            preserve_bars: args.preserve_bars,
3509            hdm: args.hdm.0,
3510            hdm_window_restrictions: args.hdm_window_restrictions.0,
3511            vnode: args.vnode,
3512        })
3513    }
3514}
3515
3516fn parse_cxl_cfmws_window_restriction_u16_bitmask(
3517    s: &str,
3518) -> anyhow::Result<CfmwsWindowRestrictions> {
3519    let bits = if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
3520        u16::from_str_radix(hex, 16).context("invalid hex bitmask")?
3521    } else {
3522        u16::from_str(s).context("invalid decimal bitmask")?
3523    };
3524
3525    CfmwsWindowRestrictions::try_from_bits(bits)
3526        .context("bitmask includes reserved CFMWS window restriction bits")
3527}
3528
3529#[derive(Clone, Debug, PartialEq)]
3530pub struct PcieRootPortCli {
3531    pub root_complex_name: String,
3532    pub name: String,
3533    pub devfn: Option<u8>,
3534    pub hotplug: bool,
3535    pub acs_capabilities_supported: Option<u16>,
3536    pub cxl: bool,
3537    pub pasid: bool,
3538}
3539
3540/// A colon-joined `parent:child` name pair used as the positional head of
3541/// `--pcie-root-port` and `--pcie-switch`.
3542struct PortNamePair {
3543    parent: String,
3544    child: String,
3545}
3546
3547impl FromStr for PortNamePair {
3548    type Err = anyhow::Error;
3549
3550    fn from_str(s: &str) -> anyhow::Result<Self> {
3551        let mut it = s.split(':');
3552        let parent = it
3553            .next()
3554            .filter(|x| !x.is_empty())
3555            .context("expected parent name")?;
3556        let child = it
3557            .next()
3558            .filter(|x| !x.is_empty())
3559            .context("expected child name")?;
3560        anyhow::ensure!(it.next().is_none(), "unexpected token in '{s}'");
3561        Ok(PortNamePair {
3562            parent: parent.to_string(),
3563            child: child.to_string(),
3564        })
3565    }
3566}
3567
3568/// A PCIe device/function address (`XX[.Y]`) parsed into a devfn.
3569struct PcieAddr(u8);
3570
3571impl FromStr for PcieAddr {
3572    type Err = anyhow::Error;
3573
3574    fn from_str(s: &str) -> anyhow::Result<Self> {
3575        Ok(PcieAddr(parse_pcie_addr(s)?))
3576    }
3577}
3578
3579/// An ACS capability bitmask (hex `0x..` or decimal).
3580struct AcsMask(u16);
3581
3582impl FromStr for AcsMask {
3583    type Err = anyhow::Error;
3584
3585    fn from_str(s: &str) -> anyhow::Result<Self> {
3586        Ok(AcsMask(parse_acs_capability_mask(s)?))
3587    }
3588}
3589
3590/// Raw `--pcie-root-port` options, mapped into [`PcieRootPortCli`].
3591#[derive(vmm_cli::KeyValueArgs)]
3592struct RootPortArgs {
3593    #[kv(positional)]
3594    names: PortNamePair,
3595    addr: Option<PcieAddr>,
3596    #[kv(flag)]
3597    hotplug: bool,
3598    acs: Option<AcsMask>,
3599    #[kv(flag)]
3600    cxl: bool,
3601    #[kv(flag)]
3602    pasid: bool,
3603}
3604
3605impl FromStr for PcieRootPortCli {
3606    type Err = anyhow::Error;
3607
3608    fn from_str(s: &str) -> Result<Self, Self::Err> {
3609        let args: RootPortArgs = s.parse()?;
3610        Ok(PcieRootPortCli {
3611            root_complex_name: args.names.parent,
3612            name: args.names.child,
3613            devfn: args.addr.map(|a| a.0),
3614            hotplug: args.hotplug,
3615            acs_capabilities_supported: args.acs.map(|a| a.0),
3616            cxl: args.cxl,
3617            pasid: args.pasid,
3618        })
3619    }
3620}
3621
3622/// Parses a PCIe address of the form `XX[.Y]`, where `XX` is the device number
3623/// (0-31) and the optional `Y` is the function number (0-7), into a devfn
3624/// (`device << 3 | function`).
3625fn parse_pcie_addr(s: &str) -> anyhow::Result<u8> {
3626    let parse_int = |v: &str| -> anyhow::Result<u8> {
3627        if let Some(hex) = v.strip_prefix("0x").or_else(|| v.strip_prefix("0X")) {
3628            u8::from_str_radix(hex, 16).context("invalid hex number")
3629        } else {
3630            v.parse().context("invalid number")
3631        }
3632    };
3633
3634    let mut parts = s.split('.');
3635    let device = parse_int(parts.next().context("expected device number")?)?;
3636    let function = match parts.next() {
3637        Some(f) => parse_int(f)?,
3638        None => 0,
3639    };
3640    if parts.next().is_some() {
3641        anyhow::bail!("unexpected token in addr '{s}'");
3642    }
3643    if device > 31 {
3644        anyhow::bail!("device number {device} out of range (0-31)");
3645    }
3646    if function > 7 {
3647        anyhow::bail!("function number {function} out of range (0-7)");
3648    }
3649    Ok((device << 3) | function)
3650}
3651
3652#[derive(Clone, Debug, PartialEq)]
3653pub struct GenericPcieSwitchCli {
3654    pub port_name: String,
3655    pub name: String,
3656    pub num_downstream_ports: u8,
3657    pub hotplug: bool,
3658    pub acs_capabilities_supported: Option<u16>,
3659    pub pasid: bool,
3660}
3661
3662/// Raw `--pcie-switch` options, mapped into [`GenericPcieSwitchCli`].
3663#[derive(vmm_cli::KeyValueArgs)]
3664struct SwitchArgs {
3665    #[kv(positional)]
3666    names: PortNamePair,
3667    #[kv(default = 4)]
3668    num_downstream_ports: u8,
3669    #[kv(flag)]
3670    hotplug: bool,
3671    acs: Option<AcsMask>,
3672    #[kv(flag)]
3673    pasid: bool,
3674}
3675
3676impl FromStr for GenericPcieSwitchCli {
3677    type Err = anyhow::Error;
3678
3679    fn from_str(s: &str) -> Result<Self, Self::Err> {
3680        let args: SwitchArgs = s.parse()?;
3681        Ok(GenericPcieSwitchCli {
3682            port_name: args.names.parent,
3683            name: args.names.child,
3684            num_downstream_ports: args.num_downstream_ports,
3685            hotplug: args.hotplug,
3686            acs_capabilities_supported: args.acs.map(|a| a.0),
3687            pasid: args.pasid,
3688        })
3689    }
3690}
3691
3692/// CLI configuration mapping a PCIe port name to a generic-initiator NUMA node.
3693#[derive(Clone, Debug, PartialEq, vmm_cli::KeyValueArgs)]
3694pub struct PcieGenericInitiatorCli {
3695    /// Name of the PCIe port (root port or switch downstream port) behind
3696    /// which the generic-initiator device resides.
3697    #[kv(key = "port")]
3698    pub port_name: String,
3699    /// NUMA node the device is a generic initiator for.
3700    pub node: u32,
3701}
3702
3703/// CLI configuration for an emulated xHCI controller.
3704#[derive(Clone, Debug, PartialEq, vmm_cli::KeyValueArgs)]
3705pub struct UsbXhciCli {
3706    /// Name of the PCIe port used by the controller.
3707    #[kv(positional)]
3708    pub port_name: String,
3709}
3710
3711/// USB vendor or product ID in decimal or hexadecimal notation.
3712#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3713pub struct UsbIdCli(pub u16);
3714
3715impl FromStr for UsbIdCli {
3716    type Err = anyhow::Error;
3717
3718    fn from_str(value: &str) -> Result<Self, Self::Err> {
3719        Ok(Self(
3720            if let Some(hex) = value
3721                .strip_prefix("0x")
3722                .or_else(|| value.strip_prefix("0X"))
3723            {
3724                u16::from_str_radix(hex, 16).context("invalid hexadecimal USB ID")?
3725            } else {
3726                value.parse().context("invalid decimal USB ID")?
3727            },
3728        ))
3729    }
3730}
3731
3732/// A validated, one-based physical USB connector.
3733#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3734pub struct UsbConnectorCli(u8);
3735
3736impl UsbConnectorCli {
3737    /// The user-supplied one-based connector number.
3738    pub fn number(self) -> u8 {
3739        self.0
3740    }
3741}
3742
3743impl FromStr for UsbConnectorCli {
3744    type Err = anyhow::Error;
3745
3746    fn from_str(value: &str) -> Result<Self, Self::Err> {
3747        let connector = value.parse()?;
3748        anyhow::ensure!(connector != 0, "USB connector numbers are one-based");
3749        Ok(Self(connector))
3750    }
3751}
3752
3753/// Optional placement overrides for a single USB device.
3754#[derive(Clone, Debug, vmm_cli::KeyValueArgs)]
3755pub struct UsbDeviceLocationCli {
3756    /// Controller's PCIe port; omitted when the sole controller should be selected.
3757    pub on: Option<String>,
3758    /// One-based USB connector; omitted to select the first free connector.
3759    pub connector: Option<UsbConnectorCli>,
3760}
3761
3762/// A concrete attachment or an automatic device-discovery request.
3763#[derive(Clone, Debug)]
3764pub enum UsbDeviceCli {
3765    /// Attach one typed device with optional controller and connector overrides.
3766    Device {
3767        /// Controller and connector selection.
3768        location: UsbDeviceLocationCli,
3769        /// Backend-specific configuration.
3770        backend: UsbBackend,
3771    },
3772    /// Discover WinUSB devices and attach them to free connectors.
3773    WinUsbAll {
3774        /// Controller's PCIe port; omitted when the sole controller should be selected.
3775        on: Option<String>,
3776    },
3777}
3778
3779impl UsbDeviceCli {
3780    /// Explicit controller PCIe port, or none when it should be inferred.
3781    pub fn controller_pcie_port(&self) -> Option<&str> {
3782        match self {
3783            Self::Device { location, .. } => location.on.as_deref(),
3784            Self::WinUsbAll { on } => on.as_deref(),
3785        }
3786    }
3787}
3788
3789/// Connector selection and optional identity overrides shared by mock devices.
3790#[derive(vmm_cli::KeyValueArgs)]
3791struct UsbMockArgs {
3792    /// Optional controller PCIe port and USB connector overrides.
3793    #[kv(flatten)]
3794    location: UsbDeviceLocationCli,
3795    /// Optional USB vendor ID override.
3796    vid: Option<UsbIdCli>,
3797    /// Optional USB product ID override.
3798    pid: Option<UsbIdCli>,
3799    /// Optional serial-number override.
3800    serial: Option<String>,
3801}
3802
3803impl UsbMockArgs {
3804    fn into_parts(
3805        self,
3806    ) -> (
3807        UsbDeviceLocationCli,
3808        usb_config_resources::UsbIdentityConfig,
3809    ) {
3810        (
3811            self.location,
3812            usb_config_resources::UsbIdentityConfig {
3813                vendor_id: self.vid.map(|id| id.0),
3814                product_id: self.pid.map(|id| id.0),
3815                serial_number: self.serial,
3816            },
3817        )
3818    }
3819}
3820
3821/// Mock-storage arguments, including an optional capacity override.
3822#[derive(vmm_cli::KeyValueArgs)]
3823struct UsbStorageArgs {
3824    /// Connector selection and optional device identity overrides.
3825    #[kv(flatten)]
3826    mock: UsbMockArgs,
3827    /// Optional capacity in 512-byte logical blocks.
3828    blocks: Option<u64>,
3829}
3830
3831/// Explicit host-device selection for the `winusb` kind.
3832#[derive(vmm_cli::KeyValueArgs)]
3833struct UsbWinUsbArgs {
3834    /// Optional controller PCIe port and USB connector overrides.
3835    #[kv(flatten)]
3836    location: UsbDeviceLocationCli,
3837    /// Windows PnP instance ID of the host device to select.
3838    instance_id: String,
3839}
3840
3841/// Startup discovery arguments for the `winusb-all` kind.
3842#[derive(vmm_cli::KeyValueArgs)]
3843struct UsbWinUsbAllArgs {
3844    /// Controller's PCIe port; omitted when the sole controller should be selected.
3845    on: Option<String>,
3846}
3847
3848impl FromStr for UsbDeviceCli {
3849    type Err = anyhow::Error;
3850
3851    fn from_str(value: &str) -> Result<Self, Self::Err> {
3852        use usb_config_resources::*;
3853        let (kind, options) = value.split_once(',').unwrap_or((value, ""));
3854        let (location, backend) = match kind {
3855            "mock-loopback" => {
3856                let args: UsbMockArgs = options.parse()?;
3857                let (location, identity) = args.into_parts();
3858                (
3859                    location,
3860                    UsbBackend::Loopback(MockLoopbackConfig { identity }),
3861                )
3862            }
3863            "mock-hid" => {
3864                let args: UsbMockArgs = options.parse()?;
3865                let (location, identity) = args.into_parts();
3866                (location, UsbBackend::Hid(MockHidConfig { identity }))
3867            }
3868            "mock-storage" => {
3869                let args: UsbStorageArgs = options.parse()?;
3870                anyhow::ensure!(
3871                    args.blocks != Some(0),
3872                    "mock-storage blocks must be greater than zero"
3873                );
3874                let (location, identity) = args.mock.into_parts();
3875                (
3876                    location,
3877                    UsbBackend::Storage(MockStorageConfig {
3878                        identity,
3879                        block_count: args.blocks,
3880                    }),
3881                )
3882            }
3883            "mock-mutt" => {
3884                let args: UsbMockArgs = options.parse()?;
3885                let (location, identity) = args.into_parts();
3886                (location, UsbBackend::Mutt(MockMuttConfig { identity }))
3887            }
3888            "mock-supermutt" => (options.parse()?, UsbBackend::SuperMutt(MockSuperMuttConfig)),
3889            "winusb" => {
3890                let args: UsbWinUsbArgs = options.parse()?;
3891                anyhow::ensure!(
3892                    !args.instance_id.trim().is_empty() && !args.instance_id.contains('\0'),
3893                    "WinUSB instance IDs must be nonempty and contain no NUL characters"
3894                );
3895                (
3896                    args.location,
3897                    UsbBackend::WinUsb(WinUsbConfig {
3898                        instance_id: args.instance_id,
3899                    }),
3900                )
3901            }
3902            "winusb-all" => {
3903                let args: UsbWinUsbAllArgs = options.parse()?;
3904                return Ok(Self::WinUsbAll { on: args.on });
3905            }
3906            _ => anyhow::bail!("unknown USB device kind {kind:?}"),
3907        };
3908        Ok(Self::Device { location, backend })
3909    }
3910}
3911
3912/// CLI configuration for a PCIe remote device.
3913#[derive(Clone, Debug, PartialEq, vmm_cli::KeyValueArgs)]
3914pub struct PcieRemoteCli {
3915    /// Name of the PCIe downstream port to attach to.
3916    #[kv(positional)]
3917    pub port_name: String,
3918    /// TCP socket address for the remote simulator.
3919    #[kv(key = "socket")]
3920    pub socket_addr: Option<String>,
3921    /// Hardware unit identifier for plug request.
3922    #[kv(default)]
3923    pub hu: u16,
3924    /// Controller identifier for plug request.
3925    #[kv(default)]
3926    pub controller: u16,
3927}
3928
3929/// CLI configuration for a VFIO-assigned PCI device.
3930///
3931/// Syntax: `host=<bdf>,port=<name>[,iommu=<id>][,barN=host|barN=0x<addr>]`
3932#[cfg(target_os = "linux")]
3933#[derive(Clone, Debug)]
3934pub struct VfioDeviceCli {
3935    /// Name of the PCIe downstream port to attach to.
3936    pub port_name: String,
3937    /// PCI BDF address of the device on the host (e.g., "0000:01:00.0").
3938    pub pci_id: String,
3939    /// Optional iommufd context ID. When set, uses VFIO cdev + iommufd
3940    /// instead of the legacy group/container path.
3941    pub iommu: Option<String>,
3942    /// Per-BAR pre-programming configuration.
3943    pub bar_addresses: [vfio_assigned_device_resources::BarAddressConfig; 6],
3944}
3945
3946/// Per-BAR address configuration parsed from the CLI.
3947#[cfg(target_os = "linux")]
3948struct BarAddressCli(vfio_assigned_device_resources::BarAddressConfig);
3949
3950#[cfg(target_os = "linux")]
3951impl FromStr for BarAddressCli {
3952    type Err = anyhow::Error;
3953
3954    fn from_str(s: &str) -> anyhow::Result<Self> {
3955        let config = if s == "host" {
3956            vfio_assigned_device_resources::BarAddressConfig::HostAssigned
3957        } else if let Some(value) = s.strip_prefix("0x") {
3958            let address = u64::from_str_radix(value, 16).context("invalid BAR address")?;
3959            anyhow::ensure!(address != 0, "BAR address must be nonzero");
3960            vfio_assigned_device_resources::BarAddressConfig::Fixed(address)
3961        } else {
3962            anyhow::bail!("expected 'host' or a hexadecimal address starting with '0x'");
3963        };
3964        Ok(Self(config))
3965    }
3966}
3967
3968/// Per-BAR address configuration, flattened into
3969/// [`VfioArgs`].
3970#[cfg(target_os = "linux")]
3971#[derive(vmm_cli::KeyValueArgs)]
3972struct BarFlags {
3973    bar0: Option<BarAddressCli>,
3974    bar1: Option<BarAddressCli>,
3975    bar2: Option<BarAddressCli>,
3976    bar3: Option<BarAddressCli>,
3977    bar4: Option<BarAddressCli>,
3978    bar5: Option<BarAddressCli>,
3979}
3980
3981/// Raw `--vfio` options, resolved and validated into a [`VfioDeviceCli`].
3982#[cfg(target_os = "linux")]
3983#[derive(vmm_cli::KeyValueArgs)]
3984struct VfioArgs {
3985    host: String,
3986    port: String,
3987    iommu: Option<String>,
3988    #[kv(flatten)]
3989    bars: BarFlags,
3990}
3991
3992#[cfg(target_os = "linux")]
3993impl FromStr for VfioDeviceCli {
3994    type Err = anyhow::Error;
3995
3996    fn from_str(s: &str) -> Result<Self, Self::Err> {
3997        let args: VfioArgs = s.parse()?;
3998
3999        // Reject path separators to prevent sysfs path traversal via Path::join.
4000        if args.host.contains('/') || args.host.contains("..") {
4001            anyhow::bail!("PCI address must not contain path separators");
4002        }
4003
4004        let bars = args.bars;
4005        let bar_addresses = [
4006            bars.bar0.map(|bar| bar.0).unwrap_or_default(),
4007            bars.bar1.map(|bar| bar.0).unwrap_or_default(),
4008            bars.bar2.map(|bar| bar.0).unwrap_or_default(),
4009            bars.bar3.map(|bar| bar.0).unwrap_or_default(),
4010            bars.bar4.map(|bar| bar.0).unwrap_or_default(),
4011            bars.bar5.map(|bar| bar.0).unwrap_or_default(),
4012        ];
4013
4014        Ok(VfioDeviceCli {
4015            port_name: args.port,
4016            pci_id: args.host,
4017            iommu: args.iommu,
4018            bar_addresses,
4019        })
4020    }
4021}
4022
4023/// CLI configuration for an SMMUv3 instance.
4024///
4025/// Syntax: `rc=<name>[,accel][,oas=auto|N][,ssidsize=auto|N]`.
4026/// Both `oas` and `ssidsize` default to `auto`.
4027#[cfg(guest_arch = "aarch64")]
4028#[derive(Clone, Debug, vmm_cli::KeyValueArgs)]
4029pub struct SmmuCli {
4030    /// Name of the PCIe root complex this SMMU covers.
4031    #[kv(key = "rc")]
4032    pub rc_name: String,
4033    /// Enable HW-accelerated nested translation (iommufd).
4034    #[kv(flag)]
4035    pub accel: bool,
4036    /// Output address size policy.
4037    #[kv(default)]
4038    pub oas: SmmuOasCli,
4039    /// SubstreamID width policy.
4040    #[kv(default)]
4041    pub ssidsize: SmmuSsidSizeCli,
4042}
4043
4044/// Output address size (OAS) policy parsed from `--smmu`.
4045#[cfg(guest_arch = "aarch64")]
4046#[derive(Clone, Copy, Debug, Default)]
4047pub enum SmmuOasCli {
4048    /// Advertise a fixed default OAS (see the `--smmu` docs for the sizing
4049    /// policy and when a larger fixed OAS is required).
4050    #[default]
4051    Auto,
4052    /// Fixed OAS in bits (one of 32, 36, 40, 42, 44, 48, 52).
4053    Fixed(u8),
4054}
4055
4056#[cfg(guest_arch = "aarch64")]
4057impl FromStr for SmmuOasCli {
4058    type Err = anyhow::Error;
4059
4060    fn from_str(s: &str) -> Result<Self, Self::Err> {
4061        Ok(if s == "auto" {
4062            SmmuOasCli::Auto
4063        } else {
4064            SmmuOasCli::Fixed(s.parse().context("oas must be 'auto' or a number")?)
4065        })
4066    }
4067}
4068
4069/// SubstreamID width (SSIDSIZE) policy parsed from `--smmu`.
4070#[cfg(guest_arch = "aarch64")]
4071#[derive(Clone, Copy, Debug, Default)]
4072pub enum SmmuSsidSizeCli {
4073    /// Disable substreams in software mode; under acceleration, adopt the
4074    /// host's width before device start freezes the capabilities.
4075    #[default]
4076    Auto,
4077    /// Fixed width in bits (0..=20). Zero disables substreams.
4078    Fixed(u8),
4079}
4080
4081#[cfg(guest_arch = "aarch64")]
4082impl FromStr for SmmuSsidSizeCli {
4083    type Err = anyhow::Error;
4084
4085    fn from_str(s: &str) -> Result<Self, Self::Err> {
4086        Ok(if s == "auto" {
4087            Self::Auto
4088        } else {
4089            Self::Fixed(s.parse().context("ssidsize must be 'auto' or a number")?)
4090        })
4091    }
4092}
4093
4094/// CLI configuration for an iommufd context.
4095///
4096/// Syntax: `id=<name>`
4097#[cfg(target_os = "linux")]
4098#[derive(Clone, Debug, vmm_cli::KeyValueArgs)]
4099pub struct IommuCli {
4100    /// Unique identifier for this iommufd context.
4101    pub id: String,
4102}
4103
4104/// Read a environment variable that may / may-not have a target-specific
4105/// prefix. e.g: `default_value_from_arch_env("FOO")` would first try and read
4106/// from `FOO`, and if that's not found, it will try `X86_64_FOO`.
4107///
4108/// Must return an `OsString`, in order to be compatible with `clap`'s
4109/// default_value code. As such - to encode the absence of the env-var, an empty
4110/// OsString is returned.
4111fn default_value_from_arch_env(name: &str) -> OsString {
4112    let prefix = if cfg!(guest_arch = "x86_64") {
4113        "X86_64"
4114    } else if cfg!(guest_arch = "aarch64") {
4115        "AARCH64"
4116    } else {
4117        return Default::default();
4118    };
4119    let prefixed = format!("{}_{}", prefix, name);
4120    std::env::var_os(name)
4121        .or_else(|| std::env::var_os(prefixed))
4122        .unwrap_or_default()
4123}
4124
4125pub fn default_uefi_firmware() -> Option<PathBuf> {
4126    OptionalPathBuf::from(default_value_from_arch_env("OPENVMM_UEFI_FIRMWARE").as_os_str()).0
4127}
4128
4129/// Workaround to use `Option<PathBuf>` alongside [`default_value_from_arch_env`]
4130#[derive(Clone)]
4131pub struct OptionalPathBuf(pub Option<PathBuf>);
4132
4133impl From<&std::ffi::OsStr> for OptionalPathBuf {
4134    fn from(s: &std::ffi::OsStr) -> Self {
4135        OptionalPathBuf(if s.is_empty() { None } else { Some(s.into()) })
4136    }
4137}
4138
4139#[cfg(target_os = "linux")]
4140#[derive(Clone)]
4141pub enum VhostUserDeviceTypeCli {
4142    /// Block device — config from backend via GET_CONFIG, with num_queues
4143    /// patched by the frontend.
4144    Blk {
4145        num_queues: Option<u16>,
4146        queue_size: Option<u16>,
4147    },
4148    /// Filesystem device — frontend-owned config with mount tag.
4149    Fs {
4150        tag: String,
4151        num_queues: Option<u16>,
4152        queue_size: Option<u16>,
4153    },
4154    /// Generic device identified by numeric virtio device ID.
4155    Other {
4156        device_id: u16,
4157        queue_sizes: Vec<u16>,
4158    },
4159}
4160
4161#[cfg(target_os = "linux")]
4162#[derive(Clone)]
4163pub struct VhostUserCli {
4164    pub socket_path: String,
4165    pub device_type: VhostUserDeviceTypeCli,
4166    pub pcie_port: Option<String>,
4167}
4168
4169/// Raw `--vhost-user` options, resolved into a [`VhostUserCli`] by its
4170/// `FromStr`.
4171#[cfg(target_os = "linux")]
4172#[derive(vmm_cli::KeyValueArgs)]
4173struct VhostUserArgs {
4174    #[kv(positional)]
4175    socket_path: String,
4176    #[kv(key = "type")]
4177    type_name: Option<String>,
4178    device_id: Option<u16>,
4179    tag: Option<String>,
4180    pcie_port: Option<String>,
4181    num_queues: Option<u16>,
4182    queue_size: Option<u16>,
4183    queue_sizes: Option<vmm_cli::BracketList<u16>>,
4184}
4185
4186#[cfg(target_os = "linux")]
4187impl FromStr for VhostUserCli {
4188    type Err = anyhow::Error;
4189
4190    fn from_str(s: &str) -> anyhow::Result<Self> {
4191        let mut args: VhostUserArgs = s.parse()?;
4192        let type_name = args.type_name.take();
4193
4194        if type_name.is_some() == args.device_id.is_some() {
4195            anyhow::bail!("must specify type=<name> or device_id=<N>");
4196        }
4197
4198        // Each variant consumes the options it accepts; whatever is left over
4199        // afterward was used with the wrong device type.
4200        let device_type = match type_name.as_deref() {
4201            Some("fs") => VhostUserDeviceTypeCli::Fs {
4202                tag: args.tag.take().context("type=fs requires tag=<name>")?,
4203                num_queues: args.num_queues.take(),
4204                queue_size: args.queue_size.take(),
4205            },
4206            Some("blk") => VhostUserDeviceTypeCli::Blk {
4207                num_queues: args.num_queues.take(),
4208                queue_size: args.queue_size.take(),
4209            },
4210            Some(ty) => anyhow::bail!("unknown vhost-user device type: '{ty}'"),
4211            None => {
4212                let queue_sizes = args
4213                    .queue_sizes
4214                    .take()
4215                    .context("device_id= requires queue_sizes=[N,N,...]")?
4216                    .0;
4217                anyhow::ensure!(!queue_sizes.is_empty(), "queue_sizes must be non-empty");
4218                VhostUserDeviceTypeCli::Other {
4219                    device_id: args.device_id.unwrap(),
4220                    queue_sizes,
4221                }
4222            }
4223        };
4224
4225        if args.tag.is_some() {
4226            anyhow::bail!("tag= is only valid for type=fs");
4227        }
4228        if args.queue_sizes.is_some() {
4229            anyhow::bail!("queue_sizes= is only valid for device_id=");
4230        }
4231        if args.num_queues.is_some() || args.queue_size.is_some() {
4232            anyhow::bail!(
4233                "num_queues= and queue_size= are not valid for device_id=; use queue_sizes="
4234            );
4235        }
4236
4237        Ok(VhostUserCli {
4238            socket_path: args.socket_path,
4239            device_type,
4240            pcie_port: args.pcie_port,
4241        })
4242    }
4243}
4244
4245#[cfg(test)]
4246mod tests {
4247    use super::*;
4248
4249    use std::path::Path;
4250    use test_with_tracing::test;
4251
4252    #[test]
4253    fn vmgs_provision_conditions() {
4254        for (option, expected) in [
4255            ("", ProvisionCondition::OnEmpty),
4256            (",fmt-on-fail", ProvisionCondition::OnFailure),
4257            (",fmt", ProvisionCondition::Single),
4258        ] {
4259            let vmgs: VmgsCli = format!("mem:VMGS_DEFAULT{option}").parse().unwrap();
4260            assert!(matches!(
4261                (vmgs.provision, expected),
4262                (ProvisionCondition::OnEmpty, ProvisionCondition::OnEmpty)
4263                    | (ProvisionCondition::OnFailure, ProvisionCondition::OnFailure)
4264                    | (ProvisionCondition::Single, ProvisionCondition::Single)
4265            ));
4266        }
4267    }
4268
4269    /// Controller arguments contain one PCIe port name and no device-specific options.
4270    #[test]
4271    fn test_parse_usb_controller() {
4272        let controller = UsbXhciCli::from_str("rc0rp0").unwrap();
4273        assert_eq!(controller.port_name, "rc0rp0");
4274        assert!(UsbXhciCli::from_str("").is_err());
4275        assert!(UsbXhciCli::from_str("rc0rp0,connector=1").is_err());
4276    }
4277
4278    /// Bare mock kinds select the right variant and leave optional fields unset.
4279    #[test]
4280    fn test_parse_usb_mock_defaults() {
4281        for kind in [
4282            "mock-loopback",
4283            "mock-hid",
4284            "mock-storage",
4285            "mock-mutt",
4286            "mock-supermutt",
4287        ] {
4288            let UsbDeviceCli::Device { location, backend } = UsbDeviceCli::from_str(kind).unwrap()
4289            else {
4290                panic!("expected a device for {kind}");
4291            };
4292            assert_eq!(location.on, None);
4293            assert_eq!(location.connector, None);
4294            let identity = match (kind, backend) {
4295                ("mock-loopback", UsbBackend::Loopback(config)) => config.identity,
4296                ("mock-hid", UsbBackend::Hid(config)) => config.identity,
4297                ("mock-storage", UsbBackend::Storage(config)) => {
4298                    assert_eq!(config.block_count, None);
4299                    config.identity
4300                }
4301                ("mock-mutt", UsbBackend::Mutt(config)) => config.identity,
4302                ("mock-supermutt", UsbBackend::SuperMutt(_)) => continue,
4303                _ => panic!("wrong backend variant for {kind}"),
4304            };
4305            assert_eq!(identity, usb_config_resources::UsbIdentityConfig::default());
4306        }
4307    }
4308
4309    /// Mock placement and identity overrides retain their parsed values for each kind.
4310    #[test]
4311    fn test_parse_usb_mock_overrides() {
4312        for kind in ["mock-loopback", "mock-hid", "mock-storage", "mock-mutt"] {
4313            let capacity = if kind == "mock-storage" {
4314                ",blocks=4096"
4315            } else {
4316                ""
4317            };
4318            let selection = format!(
4319                "{kind},on=usb0,connector=7,vid=0x1234,pid=65535,serial=SERIAL42{capacity}"
4320            );
4321            let UsbDeviceCli::Device { location, backend } =
4322                UsbDeviceCli::from_str(&selection).unwrap()
4323            else {
4324                panic!("expected a device for {kind}");
4325            };
4326            assert_eq!(location.on.as_deref(), Some("usb0"));
4327            assert_eq!(location.connector.map(UsbConnectorCli::number), Some(7));
4328            let identity = match (kind, backend) {
4329                ("mock-loopback", UsbBackend::Loopback(config)) => config.identity,
4330                ("mock-hid", UsbBackend::Hid(config)) => config.identity,
4331                ("mock-storage", UsbBackend::Storage(config)) => {
4332                    assert_eq!(config.block_count, Some(4096));
4333                    config.identity
4334                }
4335                ("mock-mutt", UsbBackend::Mutt(config)) => config.identity,
4336                _ => panic!("wrong backend variant for {kind}"),
4337            };
4338            assert_eq!(identity.vendor_id, Some(0x1234));
4339            assert_eq!(identity.product_id, Some(65535));
4340            assert_eq!(identity.serial_number.as_deref(), Some("SERIAL42"));
4341        }
4342    }
4343
4344    /// Host-device IDs and optional placement remain distinct from discovery requests.
4345    #[test]
4346    fn test_parse_usb_host_selections() {
4347        for (selection, expected_pcie_port, expected_connector) in [
4348            (r"winusb,instance_id=USB\A&B C", None, None),
4349            (
4350                r"winusb,on=usb0,connector=3,instance_id=USB\A&B C",
4351                Some("usb0"),
4352                Some(3),
4353            ),
4354        ] {
4355            let UsbDeviceCli::Device {
4356                location,
4357                backend: UsbBackend::WinUsb(config),
4358            } = UsbDeviceCli::from_str(selection).unwrap()
4359            else {
4360                panic!("expected explicit WinUSB selection");
4361            };
4362            assert_eq!(location.on.as_deref(), expected_pcie_port);
4363            assert_eq!(
4364                location.connector.map(UsbConnectorCli::number),
4365                expected_connector
4366            );
4367            assert_eq!(config.instance_id, r"USB\A&B C");
4368        }
4369        for (selection, expected_pcie_port) in
4370            [("winusb-all", None), ("winusb-all,on=usb1", Some("usb1"))]
4371        {
4372            let UsbDeviceCli::WinUsbAll { on } = UsbDeviceCli::from_str(selection).unwrap() else {
4373                panic!("expected WinUSB discovery request");
4374            };
4375            assert_eq!(on.as_deref(), expected_pcie_port);
4376        }
4377    }
4378
4379    /// Reject malformed values, repeated fields, and options belonging to another kind.
4380    #[test]
4381    fn test_parse_usb_invalid_options() {
4382        for selection in [
4383            "",
4384            "unknown",
4385            "usb0:hid",
4386            "mock-hid,connector=0",
4387            "mock-hid,connector=256",
4388            "mock-hid,vid=0x10000",
4389            "mock-hid,pid=bad",
4390            "mock-hid,on=usb0,on=usb1",
4391            "mock-hid,blocks=1",
4392            "mock-hid,serial=",
4393            "mock-storage,blocks=0",
4394            "mock-supermutt,vid=1",
4395            "winusb",
4396            "winusb,instance_id=",
4397            "winusb,instance_id=USB\0INSTANCE",
4398            "winusb,instance_id=USB,serial=x",
4399            "winusb-all,connector=1",
4400        ] {
4401            assert!(
4402                UsbDeviceCli::from_str(selection).is_err(),
4403                "accepted {selection:?}"
4404            );
4405        }
4406    }
4407
4408    /// `--version` reports the resolved build identity rather than clap's
4409    /// default, which would be the parser crate's own name and version.
4410    #[test]
4411    fn version_reports_build_info() {
4412        let short = version_output(["openvmm", "-V"]);
4413        assert_eq!(
4414            short,
4415            format!("openvmm {}\n", openvmm_build_info::get().version())
4416        );
4417
4418        let long = version_output(["openvmm", "--version"]);
4419        assert_eq!(
4420            long,
4421            format!("openvmm {}\n", openvmm_build_info::get().long_version())
4422        );
4423    }
4424
4425    fn version_output(args: [&str; 2]) -> String {
4426        let Err(error) = Options::try_parse_from(args) else {
4427            panic!("{args:?} unexpectedly parsed as runtime options");
4428        };
4429        assert_eq!(error.kind(), clap::error::ErrorKind::DisplayVersion);
4430        error.to_string()
4431    }
4432
4433    #[test]
4434    fn test_parse_rpc() {
4435        // explicit path, default transport
4436        let rpc = RpcCli::from_str("path=/tmp/openvmm.sock").unwrap();
4437        assert_eq!(rpc.path, Path::new("/tmp/openvmm.sock"));
4438        assert_eq!(rpc.transport, RpcTransportCli::Auto);
4439
4440        // explicit transport
4441        for (s, transport) in [
4442            ("auto", RpcTransportCli::Auto),
4443            ("ttrpc", RpcTransportCli::Ttrpc),
4444            ("grpc", RpcTransportCli::Grpc),
4445        ] {
4446            let rpc = RpcCli::from_str(&format!("path=/tmp/s.sock,transport={s}")).unwrap();
4447            assert_eq!(rpc.path, Path::new("/tmp/s.sock"));
4448            assert_eq!(rpc.transport, transport);
4449        }
4450
4451        // errors
4452        assert!(RpcCli::from_str("").is_err());
4453        assert!(RpcCli::from_str("transport=ttrpc").is_err());
4454        assert!(RpcCli::from_str("path=").is_err());
4455        assert!(RpcCli::from_str("path=/tmp/s.sock,transport=bogus").is_err());
4456        assert!(RpcCli::from_str("path=/tmp/s.sock,bogus=1").is_err());
4457        assert!(RpcCli::from_str("path=/a,path=/b").is_err());
4458    }
4459
4460    #[test]
4461    fn test_parse_file_opts() {
4462        // file: prefix with create
4463        let disk = DiskCliKind::from_str("file:test.vhd;create=1G").unwrap();
4464        assert!(matches!(
4465            &disk,
4466            DiskCliKind::File { path, create_with_len: Some(len), direct: false }
4467                if path == Path::new("test.vhd") && *len == 1024 * 1024 * 1024
4468        ));
4469
4470        // bare path with create (no file: prefix)
4471        let disk = DiskCliKind::from_str("test.vhd;create=1G").unwrap();
4472        assert!(matches!(
4473            &disk,
4474            DiskCliKind::File { path, create_with_len: Some(len), direct: false }
4475                if path == Path::new("test.vhd") && *len == 1024 * 1024 * 1024
4476        ));
4477
4478        // direct flag
4479        let disk = DiskCliKind::from_str("file:/dev/sdb;direct").unwrap();
4480        assert!(matches!(
4481            &disk,
4482            DiskCliKind::File { path, create_with_len: None, direct: true }
4483                if path == Path::new("/dev/sdb")
4484        ));
4485
4486        // direct + create in either order
4487        let disk = DiskCliKind::from_str("file:disk.img;direct;create=1G").unwrap();
4488        assert!(matches!(
4489            &disk,
4490            DiskCliKind::File { path, create_with_len: Some(len), direct: true }
4491                if path == Path::new("disk.img") && *len == 1024 * 1024 * 1024
4492        ));
4493
4494        let disk = DiskCliKind::from_str("file:disk.img;create=1G;direct").unwrap();
4495        assert!(matches!(
4496            &disk,
4497            DiskCliKind::File { path, create_with_len: Some(len), direct: true }
4498                if path == Path::new("disk.img") && *len == 1024 * 1024 * 1024
4499        ));
4500
4501        // plain path, no options
4502        let disk = DiskCliKind::from_str("file:disk.img").unwrap();
4503        assert!(matches!(
4504            &disk,
4505            DiskCliKind::File { path, create_with_len: None, direct: false }
4506                if path == Path::new("disk.img")
4507        ));
4508
4509        // invalid option rejected
4510        assert!(DiskCliKind::from_str("file:disk.img;bogus").is_err());
4511
4512        // direct rejected for sql disks
4513        assert!(DiskCliKind::from_str("sql:db.sqlite;direct").is_err());
4514    }
4515
4516    #[test]
4517    fn test_parse_memory_disk() {
4518        let s = "mem:1G";
4519        let disk = DiskCliKind::from_str(s).unwrap();
4520        match disk {
4521            DiskCliKind::Memory(size) => {
4522                assert_eq!(size, 1024 * 1024 * 1024); // 1G
4523            }
4524            _ => panic!("Expected Memory variant"),
4525        }
4526    }
4527
4528    #[test]
4529    fn test_parse_pcie_disk() {
4530        assert_eq!(
4531            DiskCli::from_str("mem:1G,pcie_port=p0").unwrap().pcie_port,
4532            Some("p0".to_string())
4533        );
4534        assert_eq!(
4535            DiskCli::from_str("file:path.vhdx,pcie_port=p0")
4536                .unwrap()
4537                .pcie_port,
4538            Some("p0".to_string())
4539        );
4540        assert_eq!(
4541            DiskCli::from_str("memdiff:file:path.vhdx,pcie_port=p0")
4542                .unwrap()
4543                .pcie_port,
4544            Some("p0".to_string())
4545        );
4546
4547        // Missing port name
4548        assert!(DiskCli::from_str("file:disk.vhd,pcie_port=").is_err());
4549
4550        // Incompatible with various other disk fields
4551        assert!(DiskCli::from_str("file:disk.vhd,pcie_port=p0,vtl2").is_err());
4552        assert!(DiskCli::from_str("file:disk.vhd,pcie_port=p0,uh").is_err());
4553        assert!(DiskCli::from_str("file:disk.vhd,pcie_port=p0,uh-nvme").is_err());
4554    }
4555
4556    #[test]
4557    fn test_parse_memory_diff_disk() {
4558        let s = "memdiff:file:base.img";
4559        let disk = DiskCliKind::from_str(s).unwrap();
4560        match disk {
4561            DiskCliKind::MemoryDiff(inner) => match *inner {
4562                DiskCliKind::File {
4563                    path,
4564                    create_with_len,
4565                    ..
4566                } => {
4567                    assert_eq!(path, PathBuf::from("base.img"));
4568                    assert_eq!(create_with_len, None);
4569                }
4570                _ => panic!("Expected File variant inside MemoryDiff"),
4571            },
4572            _ => panic!("Expected MemoryDiff variant"),
4573        }
4574    }
4575
4576    #[test]
4577    fn test_parse_sqlite_disk() {
4578        let s = "sql:db.sqlite;create=2G";
4579        let disk = DiskCliKind::from_str(s).unwrap();
4580        match disk {
4581            DiskCliKind::Sqlite {
4582                path,
4583                create_with_len,
4584            } => {
4585                assert_eq!(path, PathBuf::from("db.sqlite"));
4586                assert_eq!(create_with_len, Some(2 * 1024 * 1024 * 1024));
4587            }
4588            _ => panic!("Expected Sqlite variant"),
4589        }
4590
4591        // Test without create option
4592        let s = "sql:db.sqlite";
4593        let disk = DiskCliKind::from_str(s).unwrap();
4594        match disk {
4595            DiskCliKind::Sqlite {
4596                path,
4597                create_with_len,
4598            } => {
4599                assert_eq!(path, PathBuf::from("db.sqlite"));
4600                assert_eq!(create_with_len, None);
4601            }
4602            _ => panic!("Expected Sqlite variant"),
4603        }
4604    }
4605
4606    #[test]
4607    fn test_parse_sqlite_diff_disk() {
4608        // Test with create option
4609        let s = "sqldiff:diff.sqlite;create:file:base.img";
4610        let disk = DiskCliKind::from_str(s).unwrap();
4611        match disk {
4612            DiskCliKind::SqliteDiff { path, create, disk } => {
4613                assert_eq!(path, PathBuf::from("diff.sqlite"));
4614                assert!(create);
4615                match *disk {
4616                    DiskCliKind::File {
4617                        path,
4618                        create_with_len,
4619                        ..
4620                    } => {
4621                        assert_eq!(path, PathBuf::from("base.img"));
4622                        assert_eq!(create_with_len, None);
4623                    }
4624                    _ => panic!("Expected File variant inside SqliteDiff"),
4625                }
4626            }
4627            _ => panic!("Expected SqliteDiff variant"),
4628        }
4629
4630        // Test without create option
4631        let s = "sqldiff:diff.sqlite:file:base.img";
4632        let disk = DiskCliKind::from_str(s).unwrap();
4633        match disk {
4634            DiskCliKind::SqliteDiff { path, create, disk } => {
4635                assert_eq!(path, PathBuf::from("diff.sqlite"));
4636                assert!(!create);
4637                match *disk {
4638                    DiskCliKind::File {
4639                        path,
4640                        create_with_len,
4641                        ..
4642                    } => {
4643                        assert_eq!(path, PathBuf::from("base.img"));
4644                        assert_eq!(create_with_len, None);
4645                    }
4646                    _ => panic!("Expected File variant inside SqliteDiff"),
4647                }
4648            }
4649            _ => panic!("Expected SqliteDiff variant"),
4650        }
4651    }
4652
4653    #[test]
4654    fn test_parse_autocache_sqlite_disk() {
4655        // Test with cache path provided
4656        let disk =
4657            DiskCliKind::parse_autocache(":file:disk.vhd", Ok("/tmp/cache".to_string())).unwrap();
4658        assert!(matches!(
4659            disk,
4660            DiskCliKind::AutoCacheSqlite {
4661                cache_path,
4662                key,
4663                disk: _disk,
4664            } if cache_path == "/tmp/cache" && key.is_none()
4665        ));
4666
4667        // Test with key
4668        let disk =
4669            DiskCliKind::parse_autocache("mykey:file:disk.vhd", Ok("/tmp/cache".to_string()))
4670                .unwrap();
4671        assert!(matches!(
4672            disk,
4673            DiskCliKind::AutoCacheSqlite {
4674                cache_path,
4675                key: Some(key),
4676                disk: _disk,
4677            } if cache_path == "/tmp/cache" && key == "mykey"
4678        ));
4679
4680        // Test without cache path
4681        assert!(
4682            DiskCliKind::parse_autocache(":file:disk.vhd", Err(std::env::VarError::NotPresent),)
4683                .is_err()
4684        );
4685    }
4686
4687    #[test]
4688    fn test_parse_disk_errors() {
4689        assert!(DiskCliKind::from_str("invalid:").is_err());
4690        assert!(DiskCliKind::from_str("memory:extra").is_err());
4691
4692        // Test sqlite: without environment variable
4693        assert!(DiskCliKind::from_str("sqlite:").is_err());
4694    }
4695
4696    #[test]
4697    fn test_parse_errors() {
4698        // Invalid memory size
4699        assert!(DiskCliKind::from_str("mem:invalid").is_err());
4700
4701        // Invalid syntax for SQLiteDiff
4702        assert!(DiskCliKind::from_str("sqldiff:path").is_err());
4703
4704        // Missing OPENVMM_AUTO_CACHE_PATH for AutoCacheSqlite
4705        assert!(
4706            DiskCliKind::parse_autocache("key:file:disk.vhd", Err(std::env::VarError::NotPresent),)
4707                .is_err()
4708        );
4709
4710        // Invalid blob kind
4711        assert!(DiskCliKind::from_str("blob:invalid:url").is_err());
4712
4713        // Invalid cipher
4714        assert!(DiskCliKind::from_str("crypt:invalid:key.bin:file:disk.vhd").is_err());
4715
4716        // Invalid format for crypt (missing parts)
4717        assert!(DiskCliKind::from_str("crypt:xts-aes-256:key.bin").is_err());
4718
4719        // Invalid disk kind
4720        assert!(DiskCliKind::from_str("invalid:path").is_err());
4721
4722        // Missing create size
4723        assert!(DiskCliKind::from_str("file:disk.vhd;create=").is_err());
4724    }
4725
4726    #[test]
4727    fn test_fs_args_from_str() {
4728        let args = FsArgs::from_str("tag1,/path/to/fs").unwrap();
4729        assert_eq!(args.tag, "tag1");
4730        assert_eq!(args.path, "/path/to/fs");
4731
4732        // Test error cases
4733        assert!(FsArgs::from_str("tag1").is_err());
4734        assert!(FsArgs::from_str("tag1,/path,extra").is_err());
4735    }
4736
4737    #[test]
4738    fn test_fs_args_with_options_from_str() {
4739        let args = FsArgsWithOptions::from_str("tag1,/path/to/fs,opt1,opt2").unwrap();
4740        assert_eq!(args.tag, "tag1");
4741        assert_eq!(args.path, "/path/to/fs");
4742        assert_eq!(args.options, "opt1;opt2");
4743
4744        // Test without options
4745        let args = FsArgsWithOptions::from_str("tag1,/path/to/fs").unwrap();
4746        assert_eq!(args.tag, "tag1");
4747        assert_eq!(args.path, "/path/to/fs");
4748        assert_eq!(args.options, "");
4749
4750        // Test error case
4751        assert!(FsArgsWithOptions::from_str("tag1").is_err());
4752    }
4753
4754    #[test]
4755    fn test_serial_config_from_str() {
4756        assert_eq!(
4757            SerialConfigCli::from_str("none").unwrap(),
4758            SerialConfigCli::None
4759        );
4760        assert_eq!(
4761            SerialConfigCli::from_str("null").unwrap(),
4762            SerialConfigCli::Null
4763        );
4764        assert_eq!(
4765            SerialConfigCli::from_str("console").unwrap(),
4766            SerialConfigCli::Console
4767        );
4768        assert_eq!(
4769            SerialConfigCli::from_str("stderr").unwrap(),
4770            SerialConfigCli::Stderr
4771        );
4772
4773        // Test file config
4774        let file_config = SerialConfigCli::from_str("file=/path/to/file").unwrap();
4775        if let SerialConfigCli::File(path) = file_config {
4776            assert_eq!(path.to_str().unwrap(), "/path/to/file");
4777        } else {
4778            panic!("Expected File variant");
4779        }
4780
4781        // Test term config with name, but no specific path
4782        match SerialConfigCli::from_str("term,name=MyTerm").unwrap() {
4783            SerialConfigCli::NewConsole(None, Some(name)) => {
4784                assert_eq!(name, "MyTerm");
4785            }
4786            _ => panic!("Expected NewConsole variant with name"),
4787        }
4788
4789        // Test term config without name, but no specific path
4790        match SerialConfigCli::from_str("term").unwrap() {
4791            SerialConfigCli::NewConsole(None, None) => (),
4792            _ => panic!("Expected NewConsole variant without name"),
4793        }
4794
4795        // Test term config with name
4796        match SerialConfigCli::from_str("term=/dev/pts/0,name=MyTerm").unwrap() {
4797            SerialConfigCli::NewConsole(Some(path), Some(name)) => {
4798                assert_eq!(path.to_str().unwrap(), "/dev/pts/0");
4799                assert_eq!(name, "MyTerm");
4800            }
4801            _ => panic!("Expected NewConsole variant with name"),
4802        }
4803
4804        // Test term config without name
4805        match SerialConfigCli::from_str("term=/dev/pts/0").unwrap() {
4806            SerialConfigCli::NewConsole(Some(path), None) => {
4807                assert_eq!(path.to_str().unwrap(), "/dev/pts/0");
4808            }
4809            _ => panic!("Expected NewConsole variant without name"),
4810        }
4811
4812        // Test TCP config
4813        match SerialConfigCli::from_str("listen=tcp:127.0.0.1:1234").unwrap() {
4814            SerialConfigCli::Tcp(addr) => {
4815                assert_eq!(addr.to_string(), "127.0.0.1:1234");
4816            }
4817            _ => panic!("Expected Tcp variant"),
4818        }
4819
4820        // Test pipe config
4821        match SerialConfigCli::from_str("listen=/path/to/pipe").unwrap() {
4822            SerialConfigCli::Pipe(path) => {
4823                assert_eq!(path.to_str().unwrap(), "/path/to/pipe");
4824            }
4825            _ => panic!("Expected Pipe variant"),
4826        }
4827
4828        // Test error cases
4829        assert!(SerialConfigCli::from_str("").is_err());
4830        assert!(SerialConfigCli::from_str("unknown").is_err());
4831        assert!(SerialConfigCli::from_str("file").is_err());
4832        assert!(SerialConfigCli::from_str("listen").is_err());
4833        assert!(SerialConfigCli::from_str("listen=tcp:").is_err());
4834        assert!(SerialConfigCli::from_str("listen=tcp:invalid").is_err());
4835    }
4836
4837    #[test]
4838    fn test_endpoint_config_from_str() {
4839        // Test none
4840        assert!(matches!(
4841            EndpointConfigCli::from_str("none").unwrap(),
4842            EndpointConfigCli::None
4843        ));
4844
4845        // Test consomme without cidr
4846        match EndpointConfigCli::from_str("consomme").unwrap() {
4847            EndpointConfigCli::Consomme {
4848                cidr: None,
4849                host_fwd,
4850            } => assert!(host_fwd.is_empty()),
4851            _ => panic!("Expected Consomme variant without cidr"),
4852        }
4853
4854        // Test consomme with cidr
4855        match EndpointConfigCli::from_str("consomme:192.168.0.0/24").unwrap() {
4856            EndpointConfigCli::Consomme {
4857                cidr: Some(cidr),
4858                host_fwd,
4859            } => {
4860                assert_eq!(cidr, "192.168.0.0/24");
4861                assert!(host_fwd.is_empty());
4862            }
4863            _ => panic!("Expected Consomme variant with cidr"),
4864        }
4865
4866        // Test consomme with hostfwd
4867        match EndpointConfigCli::from_str("consomme:hostfwd=udp:127.0.0.1:5000-:5000").unwrap() {
4868            EndpointConfigCli::Consomme { cidr, host_fwd } => {
4869                assert!(cidr.is_none());
4870                assert_eq!(host_fwd.len(), 1);
4871                assert_eq!(host_fwd[0].protocol, HostPortProtocolCli::Udp);
4872                assert_eq!(
4873                    host_fwd[0].host_address,
4874                    Some(std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)))
4875                );
4876                assert_eq!(host_fwd[0].host_port, 5000);
4877                assert_eq!(host_fwd[0].guest_port, 5000);
4878            }
4879            _ => panic!("Expected Consomme variant with hostfwd"),
4880        }
4881
4882        // Test consomme with cidr and hostfwd
4883        match EndpointConfigCli::from_str("consomme:10.0.0.0/24,hostfwd=tcp::2222-:22").unwrap() {
4884            EndpointConfigCli::Consomme { cidr, host_fwd } => {
4885                assert_eq!(cidr.as_deref(), Some("10.0.0.0/24"));
4886                assert_eq!(host_fwd.len(), 1);
4887                assert_eq!(host_fwd[0].protocol, HostPortProtocolCli::Tcp);
4888                assert_eq!(host_fwd[0].host_port, 2222);
4889                assert_eq!(host_fwd[0].guest_port, 22);
4890            }
4891            _ => panic!("Expected Consomme variant with cidr and hostfwd"),
4892        }
4893
4894        // Test consomme with multiple hostfwd
4895        match EndpointConfigCli::from_str("consomme:hostfwd=tcp::2222-:22,hostfwd=tcp::3389-:3389")
4896            .unwrap()
4897        {
4898            EndpointConfigCli::Consomme { cidr, host_fwd } => {
4899                assert!(cidr.is_none());
4900                assert_eq!(host_fwd.len(), 2);
4901                assert_eq!(host_fwd[0].host_port, 2222);
4902                assert_eq!(host_fwd[0].guest_port, 22);
4903                assert_eq!(host_fwd[1].host_port, 3389);
4904                assert_eq!(host_fwd[1].guest_port, 3389);
4905            }
4906            _ => panic!("Expected Consomme variant with multiple hostfwd"),
4907        }
4908
4909        // Test consomme with different host and guest ports
4910        match EndpointConfigCli::from_str("consomme:hostfwd=tcp:127.0.0.1:8080-:80").unwrap() {
4911            EndpointConfigCli::Consomme { cidr, host_fwd } => {
4912                assert!(cidr.is_none());
4913                assert_eq!(host_fwd.len(), 1);
4914                assert_eq!(host_fwd[0].protocol, HostPortProtocolCli::Tcp);
4915                assert_eq!(
4916                    host_fwd[0].host_address,
4917                    Some(std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)))
4918                );
4919                assert_eq!(host_fwd[0].host_port, 8080);
4920                assert_eq!(host_fwd[0].guest_port, 80);
4921            }
4922            _ => panic!("Expected Consomme variant with host/guest port mapping"),
4923        }
4924
4925        // Test consomme with guest address (accepted but ignored by backend)
4926        match EndpointConfigCli::from_str("consomme:hostfwd=tcp::8080-10.0.0.2:80").unwrap() {
4927            EndpointConfigCli::Consomme { cidr, host_fwd } => {
4928                assert!(cidr.is_none());
4929                assert_eq!(host_fwd[0].host_port, 8080);
4930                assert_eq!(host_fwd[0].guest_port, 80);
4931            }
4932            _ => panic!("Expected Consomme variant with guest address"),
4933        }
4934
4935        // Test consomme with IPv6 host address (bracketed)
4936        match EndpointConfigCli::from_str("consomme:hostfwd=tcp:[::1]:8080-:80").unwrap() {
4937            EndpointConfigCli::Consomme { cidr, host_fwd } => {
4938                assert!(cidr.is_none());
4939                assert_eq!(host_fwd.len(), 1);
4940                assert_eq!(host_fwd[0].protocol, HostPortProtocolCli::Tcp);
4941                assert_eq!(
4942                    host_fwd[0].host_address,
4943                    Some(std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST))
4944                );
4945                assert_eq!(host_fwd[0].host_port, 8080);
4946                assert_eq!(host_fwd[0].guest_port, 80);
4947            }
4948            _ => panic!("Expected Consomme variant with IPv6 hostfwd"),
4949        }
4950
4951        // Test consomme with IPv6 guest address (bracketed)
4952        match EndpointConfigCli::from_str("consomme:hostfwd=tcp::8080-[::1]:80").unwrap() {
4953            EndpointConfigCli::Consomme { cidr, host_fwd } => {
4954                assert!(cidr.is_none());
4955                assert_eq!(host_fwd[0].host_port, 8080);
4956                assert_eq!(host_fwd[0].guest_port, 80);
4957            }
4958            _ => panic!("Expected Consomme variant with IPv6 guest address"),
4959        }
4960
4961        // Test dio without id
4962        match EndpointConfigCli::from_str("dio").unwrap() {
4963            EndpointConfigCli::Dio { id: None } => (),
4964            _ => panic!("Expected Dio variant without id"),
4965        }
4966
4967        // Test dio with id
4968        match EndpointConfigCli::from_str("dio:test_id").unwrap() {
4969            EndpointConfigCli::Dio { id: Some(id) } => {
4970                assert_eq!(id, "test_id");
4971            }
4972            _ => panic!("Expected Dio variant with id"),
4973        }
4974
4975        // Test tap
4976        match EndpointConfigCli::from_str("tap:tap0").unwrap() {
4977            EndpointConfigCli::Tap { name } => {
4978                assert_eq!(name, "tap0");
4979            }
4980            _ => panic!("Expected Tap variant"),
4981        }
4982
4983        // Test error case
4984        assert!(EndpointConfigCli::from_str("invalid").is_err());
4985    }
4986
4987    #[test]
4988    fn test_nic_config_from_str() {
4989        use openvmm_defs::config::DeviceVtl;
4990
4991        // Test basic endpoint
4992        let config = NicConfigCli::from_str("none").unwrap();
4993        assert_eq!(config.vtl, DeviceVtl::Vtl0);
4994        assert!(config.mac_address.is_none());
4995        assert!(config.max_queues.is_none());
4996        assert!(!config.enable_rx_gso);
4997        assert!(!config.underhill);
4998        assert!(config.pcie_port.is_none());
4999        assert!(matches!(config.endpoint, EndpointConfigCli::None));
5000
5001        let config = NicConfigCli::from_str("rx_gso:tap:tap0").unwrap();
5002        assert!(config.enable_rx_gso);
5003        assert!(matches!(
5004            config.endpoint,
5005            EndpointConfigCli::Tap { ref name } if name == "tap0"
5006        ));
5007
5008        // Test with vtl2
5009        let config = NicConfigCli::from_str("vtl2:none").unwrap();
5010        assert_eq!(config.vtl, DeviceVtl::Vtl2);
5011        assert!(config.pcie_port.is_none());
5012        assert!(matches!(config.endpoint, EndpointConfigCli::None));
5013
5014        // Test with queues
5015        let config = NicConfigCli::from_str("queues=4:none").unwrap();
5016        assert_eq!(config.max_queues, Some(4));
5017        assert!(config.pcie_port.is_none());
5018        assert!(matches!(config.endpoint, EndpointConfigCli::None));
5019
5020        // Test with explicit MAC address
5021        let config = NicConfigCli::from_str("mac=00-15-5d-12-12-13:none").unwrap();
5022        assert_eq!(
5023            config.mac_address,
5024            Some(MacAddress::new([0x00, 0x15, 0x5d, 0x12, 0x12, 0x13]))
5025        );
5026        assert!(matches!(config.endpoint, EndpointConfigCli::None));
5027
5028        // Test with underhill
5029        let config = NicConfigCli::from_str("uh:none").unwrap();
5030        assert!(config.underhill);
5031        assert!(config.pcie_port.is_none());
5032        assert!(matches!(config.endpoint, EndpointConfigCli::None));
5033
5034        // Test with pcie_port
5035        let config = NicConfigCli::from_str("pcie_port=rp0:none").unwrap();
5036        assert_eq!(config.pcie_port.unwrap(), "rp0".to_string());
5037        assert!(matches!(config.endpoint, EndpointConfigCli::None));
5038
5039        // Test error cases
5040        assert!(NicConfigCli::from_str("queues=invalid:none").is_err());
5041        assert!(NicConfigCli::from_str("mac=invalid:none").is_err());
5042        assert!(NicConfigCli::from_str("uh:vtl2:none").is_err()); // uh incompatible with vtl2
5043        assert!(NicConfigCli::from_str("pcie_port=rp0:vtl2:none").is_err());
5044        assert!(NicConfigCli::from_str("uh:pcie_port=rp0:none").is_err());
5045        assert!(NicConfigCli::from_str("pcie_port=:none").is_err());
5046        assert!(NicConfigCli::from_str("pcie_port:none").is_err());
5047    }
5048
5049    #[test]
5050    fn test_parse_pcie_port_prefix() {
5051        // Successful prefix parsing
5052        let (port, rest) = parse_pcie_port_prefix("pcie_port=rp0:tag,path");
5053        assert_eq!(port.unwrap(), "rp0");
5054        assert_eq!(rest, "tag,path");
5055
5056        // No prefix
5057        let (port, rest) = parse_pcie_port_prefix("tag,path");
5058        assert!(port.is_none());
5059        assert_eq!(rest, "tag,path");
5060
5061        // Empty port name — not parsed as a prefix
5062        let (port, rest) = parse_pcie_port_prefix("pcie_port=:tag,path");
5063        assert!(port.is_none());
5064        assert_eq!(rest, "pcie_port=:tag,path");
5065
5066        // Missing colon — not parsed as a prefix
5067        let (port, rest) = parse_pcie_port_prefix("pcie_port=rp0");
5068        assert!(port.is_none());
5069        assert_eq!(rest, "pcie_port=rp0");
5070    }
5071
5072    #[test]
5073    fn test_cxl_test_device_cli_parse_valid() {
5074        let cfg = CxlTestDeviceCli::from_str("mem:1G,pcie_port=rp0").unwrap();
5075        assert_eq!(cfg.hdm_size, 1024 * 1024 * 1024);
5076        assert_eq!(cfg.pcie_port, "rp0");
5077    }
5078
5079    #[test]
5080    fn test_cxl_test_device_cli_parse_invalid() {
5081        assert!(CxlTestDeviceCli::from_str("file:disk.img,pcie_port=rp0").is_err());
5082        assert!(CxlTestDeviceCli::from_str("mem:1G").is_err());
5083        assert!(CxlTestDeviceCli::from_str("mem:1G,pcie_port=").is_err());
5084    }
5085
5086    #[test]
5087    fn test_fs_args_pcie_port() {
5088        // Without pcie_port
5089        let args = FsArgs::from_str("myfs,/path").unwrap();
5090        assert_eq!(args.tag, "myfs");
5091        assert_eq!(args.path, "/path");
5092        assert!(args.pcie_port.is_none());
5093
5094        // With pcie_port
5095        let args = FsArgs::from_str("pcie_port=rp0:myfs,/path").unwrap();
5096        assert_eq!(args.pcie_port.unwrap(), "rp0");
5097        assert_eq!(args.tag, "myfs");
5098        assert_eq!(args.path, "/path");
5099
5100        // Error: wrong number of fields
5101        assert!(FsArgs::from_str("myfs").is_err());
5102        assert!(FsArgs::from_str("pcie_port=rp0:myfs").is_err());
5103    }
5104
5105    #[test]
5106    fn test_fs_args_with_options_pcie_port() {
5107        // Without pcie_port
5108        let args = FsArgsWithOptions::from_str("myfs,/path,uid=1000").unwrap();
5109        assert_eq!(args.tag, "myfs");
5110        assert_eq!(args.path, "/path");
5111        assert_eq!(args.options, "uid=1000");
5112        assert!(args.pcie_port.is_none());
5113
5114        // With pcie_port
5115        let args = FsArgsWithOptions::from_str("pcie_port=rp0:myfs,/path,uid=1000").unwrap();
5116        assert_eq!(args.pcie_port.unwrap(), "rp0");
5117        assert_eq!(args.tag, "myfs");
5118        assert_eq!(args.path, "/path");
5119        assert_eq!(args.options, "uid=1000");
5120
5121        // Error: missing path
5122        assert!(FsArgsWithOptions::from_str("myfs").is_err());
5123    }
5124
5125    #[test]
5126    fn test_virtio_pmem_args_pcie_port() {
5127        // Without pcie_port
5128        let args = VirtioPmemArgs::from_str("/path/to/file").unwrap();
5129        assert_eq!(args.path, "/path/to/file");
5130        assert!(args.pcie_port.is_none());
5131
5132        // With pcie_port
5133        let args = VirtioPmemArgs::from_str("pcie_port=rp0:/path/to/file").unwrap();
5134        assert_eq!(args.pcie_port.unwrap(), "rp0");
5135        assert_eq!(args.path, "/path/to/file");
5136
5137        // Error: empty path
5138        assert!(VirtioPmemArgs::from_str("").is_err());
5139        assert!(VirtioPmemArgs::from_str("pcie_port=rp0:").is_err());
5140    }
5141
5142    #[test]
5143    fn test_smt_config_from_str() {
5144        assert_eq!(SmtConfigCli::from_str("auto").unwrap(), SmtConfigCli::Auto);
5145        assert_eq!(
5146            SmtConfigCli::from_str("force").unwrap(),
5147            SmtConfigCli::Force
5148        );
5149        assert_eq!(SmtConfigCli::from_str("off").unwrap(), SmtConfigCli::Off);
5150
5151        // Test error cases
5152        assert!(SmtConfigCli::from_str("invalid").is_err());
5153        assert!(SmtConfigCli::from_str("").is_err());
5154    }
5155
5156    #[test]
5157    fn test_pcat_boot_order_from_str() {
5158        // Test single device
5159        let order = PcatBootOrderCli::from_str("optical").unwrap();
5160        assert_eq!(order.0[0], PcatBootDevice::Optical);
5161
5162        // Test multiple devices
5163        let order = PcatBootOrderCli::from_str("hdd,net").unwrap();
5164        assert_eq!(order.0[0], PcatBootDevice::HardDrive);
5165        assert_eq!(order.0[1], PcatBootDevice::Network);
5166
5167        // Test error cases
5168        assert!(PcatBootOrderCli::from_str("invalid").is_err());
5169        assert!(PcatBootOrderCli::from_str("optical,optical").is_err()); // duplicate device
5170    }
5171
5172    #[test]
5173    fn test_floppy_disk_from_str() {
5174        // Test basic disk
5175        let disk = FloppyDiskCli::from_str("file:/path/to/floppy.img").unwrap();
5176        assert!(!disk.read_only);
5177        match disk.kind {
5178            DiskCliKind::File {
5179                path,
5180                create_with_len,
5181                ..
5182            } => {
5183                assert_eq!(path.to_str().unwrap(), "/path/to/floppy.img");
5184                assert_eq!(create_with_len, None);
5185            }
5186            _ => panic!("Expected File variant"),
5187        }
5188
5189        // Test with read-only flag
5190        let disk = FloppyDiskCli::from_str("file:/path/to/floppy.img,ro").unwrap();
5191        assert!(disk.read_only);
5192
5193        // Test error cases
5194        assert!(FloppyDiskCli::from_str("").is_err());
5195        assert!(FloppyDiskCli::from_str("file:/path/to/floppy.img,invalid").is_err());
5196    }
5197
5198    #[test]
5199    fn test_pcie_root_complex_from_str() {
5200        const ONE_MB: u64 = 1024 * 1024;
5201        const ONE_GB: u64 = 1024 * ONE_MB;
5202
5203        const DEFAULT_LOW_MMIO: u32 = (64 * ONE_MB) as u32;
5204        const DEFAULT_HIGH_MMIO: u64 = ONE_GB;
5205        const DEFAULT_HDM: u64 = ONE_GB;
5206        const DEFAULT_HDM_WINDOW_RESTRICTIONS: CfmwsWindowRestrictions =
5207            CfmwsWindowRestrictions::DEVICE_COHERENT;
5208
5209        assert_eq!(
5210            PcieRootComplexCli::from_str("rc0").unwrap(),
5211            PcieRootComplexCli {
5212                name: "rc0".to_string(),
5213                segment: 0,
5214                start_bus: 0,
5215                end_bus: 255,
5216                low_mmio: DEFAULT_LOW_MMIO,
5217                high_mmio: DEFAULT_HIGH_MMIO,
5218                hdm: DEFAULT_HDM,
5219                hdm_window_restrictions: DEFAULT_HDM_WINDOW_RESTRICTIONS,
5220                vnode: None,
5221                low_mmio_base: None,
5222                high_mmio_base: None,
5223                preserve_bars: false,
5224            }
5225        );
5226
5227        assert_eq!(
5228            PcieRootComplexCli::from_str("rc1,segment=1").unwrap(),
5229            PcieRootComplexCli {
5230                name: "rc1".to_string(),
5231                segment: 1,
5232                start_bus: 0,
5233                end_bus: 255,
5234                low_mmio: DEFAULT_LOW_MMIO,
5235                high_mmio: DEFAULT_HIGH_MMIO,
5236                hdm: DEFAULT_HDM,
5237                hdm_window_restrictions: DEFAULT_HDM_WINDOW_RESTRICTIONS,
5238                vnode: None,
5239                low_mmio_base: None,
5240                high_mmio_base: None,
5241                preserve_bars: false,
5242            }
5243        );
5244
5245        assert_eq!(
5246            PcieRootComplexCli::from_str("rc2,start_bus=32").unwrap(),
5247            PcieRootComplexCli {
5248                name: "rc2".to_string(),
5249                segment: 0,
5250                start_bus: 32,
5251                end_bus: 255,
5252                low_mmio: DEFAULT_LOW_MMIO,
5253                high_mmio: DEFAULT_HIGH_MMIO,
5254                hdm: DEFAULT_HDM,
5255                hdm_window_restrictions: DEFAULT_HDM_WINDOW_RESTRICTIONS,
5256                vnode: None,
5257                low_mmio_base: None,
5258                high_mmio_base: None,
5259                preserve_bars: false,
5260            }
5261        );
5262
5263        assert_eq!(
5264            PcieRootComplexCli::from_str("rc3,end_bus=31").unwrap(),
5265            PcieRootComplexCli {
5266                name: "rc3".to_string(),
5267                segment: 0,
5268                start_bus: 0,
5269                end_bus: 31,
5270                low_mmio: DEFAULT_LOW_MMIO,
5271                high_mmio: DEFAULT_HIGH_MMIO,
5272                hdm: DEFAULT_HDM,
5273                hdm_window_restrictions: DEFAULT_HDM_WINDOW_RESTRICTIONS,
5274                vnode: None,
5275                low_mmio_base: None,
5276                high_mmio_base: None,
5277                preserve_bars: false,
5278            }
5279        );
5280
5281        assert_eq!(
5282            PcieRootComplexCli::from_str("rc4,start_bus=32,end_bus=127,high_mmio=2G").unwrap(),
5283            PcieRootComplexCli {
5284                name: "rc4".to_string(),
5285                segment: 0,
5286                start_bus: 32,
5287                end_bus: 127,
5288                low_mmio: DEFAULT_LOW_MMIO,
5289                high_mmio: 2 * ONE_GB,
5290                hdm: DEFAULT_HDM,
5291                hdm_window_restrictions: DEFAULT_HDM_WINDOW_RESTRICTIONS,
5292                vnode: None,
5293                low_mmio_base: None,
5294                high_mmio_base: None,
5295                preserve_bars: false,
5296            }
5297        );
5298
5299        assert_eq!(
5300            PcieRootComplexCli::from_str("rc5,segment=2,start_bus=32,end_bus=127").unwrap(),
5301            PcieRootComplexCli {
5302                name: "rc5".to_string(),
5303                segment: 2,
5304                start_bus: 32,
5305                end_bus: 127,
5306                low_mmio: DEFAULT_LOW_MMIO,
5307                high_mmio: DEFAULT_HIGH_MMIO,
5308                hdm: DEFAULT_HDM,
5309                hdm_window_restrictions: DEFAULT_HDM_WINDOW_RESTRICTIONS,
5310                vnode: None,
5311                low_mmio_base: None,
5312                high_mmio_base: None,
5313                preserve_bars: false,
5314            }
5315        );
5316
5317        assert_eq!(
5318            PcieRootComplexCli::from_str("rc6,low_mmio=1M,high_mmio=64G").unwrap(),
5319            PcieRootComplexCli {
5320                name: "rc6".to_string(),
5321                segment: 0,
5322                start_bus: 0,
5323                end_bus: 255,
5324                low_mmio: ONE_MB as u32,
5325                high_mmio: 64 * ONE_GB,
5326                hdm: DEFAULT_HDM,
5327                hdm_window_restrictions: DEFAULT_HDM_WINDOW_RESTRICTIONS,
5328                vnode: None,
5329                low_mmio_base: None,
5330                high_mmio_base: None,
5331                preserve_bars: false,
5332            }
5333        );
5334
5335        assert_eq!(
5336            PcieRootComplexCli::from_str("rc7,hdm=2G").unwrap(),
5337            PcieRootComplexCli {
5338                name: "rc7".to_string(),
5339                segment: 0,
5340                start_bus: 0,
5341                end_bus: 255,
5342                low_mmio: DEFAULT_LOW_MMIO,
5343                high_mmio: DEFAULT_HIGH_MMIO,
5344                hdm: 2 * ONE_GB,
5345                hdm_window_restrictions: DEFAULT_HDM_WINDOW_RESTRICTIONS,
5346                vnode: None,
5347                low_mmio_base: None,
5348                high_mmio_base: None,
5349                preserve_bars: false,
5350            }
5351        );
5352
5353        assert_eq!(
5354            PcieRootComplexCli::from_str("rc8,hdm_window_restrictions=0x21").unwrap(),
5355            PcieRootComplexCli {
5356                name: "rc8".to_string(),
5357                segment: 0,
5358                start_bus: 0,
5359                end_bus: 255,
5360                low_mmio: DEFAULT_LOW_MMIO,
5361                high_mmio: DEFAULT_HIGH_MMIO,
5362                hdm: DEFAULT_HDM,
5363                hdm_window_restrictions: CfmwsWindowRestrictions::try_from_bits(0x21).unwrap(),
5364                vnode: None,
5365                low_mmio_base: None,
5366                high_mmio_base: None,
5367                preserve_bars: false,
5368            }
5369        );
5370
5371        // Error cases
5372        assert!(PcieRootComplexCli::from_str("").is_err());
5373        assert!(PcieRootComplexCli::from_str("poorly,").is_err());
5374        assert!(PcieRootComplexCli::from_str("configured,complex").is_err());
5375        assert!(PcieRootComplexCli::from_str("fails,start_bus=foo").is_err());
5376        assert!(PcieRootComplexCli::from_str("fails,start_bus=32,end_bus=31").is_err());
5377        assert!(PcieRootComplexCli::from_str("rc,start_bus=256").is_err());
5378        assert!(PcieRootComplexCli::from_str("rc,end_bus=256").is_err());
5379        assert!(PcieRootComplexCli::from_str("rc,low_mmio=5G").is_err());
5380        assert!(PcieRootComplexCli::from_str("rc,low_mmio=aG").is_err());
5381        assert!(PcieRootComplexCli::from_str("rc,high_mmio=bad").is_err());
5382        assert!(PcieRootComplexCli::from_str("rc,high_mmio").is_err());
5383        assert!(PcieRootComplexCli::from_str("rc,hdm=bad").is_err());
5384        assert!(PcieRootComplexCli::from_str("rc,hdm").is_err());
5385        assert!(PcieRootComplexCli::from_str("rc,hdm_window_restrictions=bad").is_err());
5386        assert!(PcieRootComplexCli::from_str("rc,hdm_window_restrictions").is_err());
5387        assert!(PcieRootComplexCli::from_str("rc,cxl").is_err());
5388
5389        // node option
5390        assert_eq!(
5391            PcieRootComplexCli::from_str("rc9,node=1").unwrap(),
5392            PcieRootComplexCli {
5393                name: "rc9".to_string(),
5394                segment: 0,
5395                start_bus: 0,
5396                end_bus: 255,
5397                low_mmio: DEFAULT_LOW_MMIO,
5398                high_mmio: DEFAULT_HIGH_MMIO,
5399                hdm: DEFAULT_HDM,
5400                hdm_window_restrictions: DEFAULT_HDM_WINDOW_RESTRICTIONS,
5401                vnode: Some(1),
5402                low_mmio_base: None,
5403                high_mmio_base: None,
5404                preserve_bars: false,
5405            }
5406        );
5407    }
5408
5409    #[test]
5410    fn test_pcie_root_port_from_str() {
5411        assert_eq!(
5412            PcieRootPortCli::from_str("rc0:rc0rp0").unwrap(),
5413            PcieRootPortCli {
5414                root_complex_name: "rc0".to_string(),
5415                name: "rc0rp0".to_string(),
5416                devfn: None,
5417                hotplug: false,
5418                acs_capabilities_supported: None,
5419                cxl: false,
5420                pasid: false,
5421            }
5422        );
5423
5424        assert_eq!(
5425            PcieRootPortCli::from_str("my_rc:port2").unwrap(),
5426            PcieRootPortCli {
5427                root_complex_name: "my_rc".to_string(),
5428                name: "port2".to_string(),
5429                devfn: None,
5430                hotplug: false,
5431                acs_capabilities_supported: None,
5432                cxl: false,
5433                pasid: false,
5434            }
5435        );
5436
5437        // Test with hotplug flag
5438        assert_eq!(
5439            PcieRootPortCli::from_str("my_rc:port2,hotplug").unwrap(),
5440            PcieRootPortCli {
5441                root_complex_name: "my_rc".to_string(),
5442                name: "port2".to_string(),
5443                devfn: None,
5444                hotplug: true,
5445                acs_capabilities_supported: None,
5446                cxl: false,
5447                pasid: false,
5448            }
5449        );
5450
5451        assert_eq!(
5452            PcieRootPortCli::from_str("my_rc:port3,acs=0").unwrap(),
5453            PcieRootPortCli {
5454                root_complex_name: "my_rc".to_string(),
5455                name: "port3".to_string(),
5456                devfn: None,
5457                hotplug: false,
5458                acs_capabilities_supported: Some(0),
5459                cxl: false,
5460                pasid: false,
5461            }
5462        );
5463
5464        assert_eq!(
5465            PcieRootPortCli::from_str("my_rc:port3,acs=0x5f").unwrap(),
5466            PcieRootPortCli {
5467                root_complex_name: "my_rc".to_string(),
5468                name: "port3".to_string(),
5469                devfn: None,
5470                hotplug: false,
5471                acs_capabilities_supported: Some(0x005f),
5472                cxl: false,
5473                pasid: false,
5474            }
5475        );
5476
5477        assert_eq!(
5478            PcieRootPortCli::from_str("my_rc:port4,cxl").unwrap(),
5479            PcieRootPortCli {
5480                root_complex_name: "my_rc".to_string(),
5481                name: "port4".to_string(),
5482                devfn: None,
5483                hotplug: false,
5484                acs_capabilities_supported: None,
5485                cxl: true,
5486                pasid: false,
5487            }
5488        );
5489
5490        // Test addr= (device only, and device.function)
5491        assert_eq!(
5492            PcieRootPortCli::from_str("my_rc:port5,addr=5").unwrap(),
5493            PcieRootPortCli {
5494                root_complex_name: "my_rc".to_string(),
5495                name: "port5".to_string(),
5496                devfn: Some(5 << 3),
5497                hotplug: false,
5498                acs_capabilities_supported: None,
5499                cxl: false,
5500                pasid: false,
5501            }
5502        );
5503        assert_eq!(
5504            PcieRootPortCli::from_str("my_rc:port6,addr=5.1").unwrap(),
5505            PcieRootPortCli {
5506                root_complex_name: "my_rc".to_string(),
5507                name: "port6".to_string(),
5508                devfn: Some((5 << 3) | 1),
5509                hotplug: false,
5510                acs_capabilities_supported: None,
5511                cxl: false,
5512                pasid: false,
5513            }
5514        );
5515        assert_eq!(
5516            PcieRootPortCli::from_str("my_rc:port7,addr=0x1f.7").unwrap(),
5517            PcieRootPortCli {
5518                root_complex_name: "my_rc".to_string(),
5519                name: "port7".to_string(),
5520                devfn: Some(0xff),
5521                hotplug: false,
5522                acs_capabilities_supported: None,
5523                cxl: false,
5524                pasid: false,
5525            }
5526        );
5527
5528        assert_eq!(
5529            PcieRootPortCli::from_str("my_rc:port8,pasid").unwrap(),
5530            PcieRootPortCli {
5531                root_complex_name: "my_rc".to_string(),
5532                name: "port8".to_string(),
5533                devfn: None,
5534                hotplug: false,
5535                acs_capabilities_supported: None,
5536                cxl: false,
5537                pasid: true,
5538            }
5539        );
5540
5541        // Error cases
5542        assert!(PcieRootPortCli::from_str("").is_err());
5543        assert!(PcieRootPortCli::from_str("rp0").is_err());
5544        assert!(PcieRootPortCli::from_str("rp0,opt").is_err());
5545        assert!(PcieRootPortCli::from_str("rc0:rp0:rp3").is_err());
5546        assert!(PcieRootPortCli::from_str("rc0:rp0,invalid_option").is_err());
5547        assert!(PcieRootPortCli::from_str("rc0:rp0,cxl=true").is_err());
5548        assert!(PcieRootPortCli::from_str("rc0:rp0,addr=32").is_err());
5549        assert!(PcieRootPortCli::from_str("rc0:rp0,addr=0.8").is_err());
5550        assert!(PcieRootPortCli::from_str("rc0:rp0,addr=1.2.3").is_err());
5551        assert!(PcieRootPortCli::from_str("rc0:rp0,addr").is_err());
5552        assert!(PcieRootPortCli::from_str("rc0:rp0,pasid=foo").is_err());
5553    }
5554
5555    #[test]
5556    fn test_pcie_generic_initiator_from_str() {
5557        assert_eq!(
5558            PcieGenericInitiatorCli::from_str("port=rp0,node=1").unwrap(),
5559            PcieGenericInitiatorCli {
5560                port_name: "rp0".to_string(),
5561                node: 1,
5562            }
5563        );
5564
5565        // Order should not matter.
5566        assert_eq!(
5567            PcieGenericInitiatorCli::from_str("node=2,port=sw0-downstream-1").unwrap(),
5568            PcieGenericInitiatorCli {
5569                port_name: "sw0-downstream-1".to_string(),
5570                node: 2,
5571            }
5572        );
5573
5574        // Error cases
5575        assert!(PcieGenericInitiatorCli::from_str("").is_err());
5576        assert!(PcieGenericInitiatorCli::from_str("port=rp0").is_err());
5577        assert!(PcieGenericInitiatorCli::from_str("node=1").is_err());
5578        assert!(PcieGenericInitiatorCli::from_str("rp0=1").is_err());
5579        assert!(PcieGenericInitiatorCli::from_str("port=,node=1").is_err());
5580        assert!(PcieGenericInitiatorCli::from_str("port=rp0,node=x").is_err());
5581        assert!(PcieGenericInitiatorCli::from_str("port=rp0,node=1,extra").is_err());
5582    }
5583
5584    #[test]
5585    fn test_pcie_switch_from_str() {
5586        assert_eq!(
5587            GenericPcieSwitchCli::from_str("rp0:switch0").unwrap(),
5588            GenericPcieSwitchCli {
5589                port_name: "rp0".to_string(),
5590                name: "switch0".to_string(),
5591                num_downstream_ports: 4,
5592                hotplug: false,
5593                acs_capabilities_supported: None,
5594                pasid: false,
5595            }
5596        );
5597
5598        assert_eq!(
5599            GenericPcieSwitchCli::from_str("port1:my_switch,num_downstream_ports=4").unwrap(),
5600            GenericPcieSwitchCli {
5601                port_name: "port1".to_string(),
5602                name: "my_switch".to_string(),
5603                num_downstream_ports: 4,
5604                hotplug: false,
5605                acs_capabilities_supported: None,
5606                pasid: false,
5607            }
5608        );
5609
5610        assert_eq!(
5611            GenericPcieSwitchCli::from_str("rp2:sw,num_downstream_ports=8").unwrap(),
5612            GenericPcieSwitchCli {
5613                port_name: "rp2".to_string(),
5614                name: "sw".to_string(),
5615                num_downstream_ports: 8,
5616                hotplug: false,
5617                acs_capabilities_supported: None,
5618                pasid: false,
5619            }
5620        );
5621
5622        // Test hierarchical connections
5623        assert_eq!(
5624            GenericPcieSwitchCli::from_str("switch0-downstream-1:child_switch").unwrap(),
5625            GenericPcieSwitchCli {
5626                port_name: "switch0-downstream-1".to_string(),
5627                name: "child_switch".to_string(),
5628                num_downstream_ports: 4,
5629                hotplug: false,
5630                acs_capabilities_supported: None,
5631                pasid: false,
5632            }
5633        );
5634
5635        // Test hotplug flag
5636        assert_eq!(
5637            GenericPcieSwitchCli::from_str("rp0:switch0,hotplug").unwrap(),
5638            GenericPcieSwitchCli {
5639                port_name: "rp0".to_string(),
5640                name: "switch0".to_string(),
5641                num_downstream_ports: 4,
5642                hotplug: true,
5643                acs_capabilities_supported: None,
5644                pasid: false,
5645            }
5646        );
5647
5648        // Test hotplug with num_downstream_ports
5649        assert_eq!(
5650            GenericPcieSwitchCli::from_str("rp0:switch0,num_downstream_ports=8,hotplug").unwrap(),
5651            GenericPcieSwitchCli {
5652                port_name: "rp0".to_string(),
5653                name: "switch0".to_string(),
5654                num_downstream_ports: 8,
5655                hotplug: true,
5656                acs_capabilities_supported: None,
5657                pasid: false,
5658            }
5659        );
5660
5661        assert_eq!(
5662            GenericPcieSwitchCli::from_str("rp0:switch0,acs=0").unwrap(),
5663            GenericPcieSwitchCli {
5664                port_name: "rp0".to_string(),
5665                name: "switch0".to_string(),
5666                num_downstream_ports: 4,
5667                hotplug: false,
5668                acs_capabilities_supported: Some(0),
5669                pasid: false,
5670            }
5671        );
5672
5673        assert_eq!(
5674            GenericPcieSwitchCli::from_str("rp0:switch0,acs=95").unwrap(),
5675            GenericPcieSwitchCli {
5676                port_name: "rp0".to_string(),
5677                name: "switch0".to_string(),
5678                num_downstream_ports: 4,
5679                hotplug: false,
5680                acs_capabilities_supported: Some(95),
5681                pasid: false,
5682            }
5683        );
5684
5685        assert_eq!(
5686            GenericPcieSwitchCli::from_str("rp0:switch0,pasid").unwrap(),
5687            GenericPcieSwitchCli {
5688                port_name: "rp0".to_string(),
5689                name: "switch0".to_string(),
5690                num_downstream_ports: 4,
5691                hotplug: false,
5692                acs_capabilities_supported: None,
5693                pasid: true,
5694            }
5695        );
5696
5697        // Error cases
5698        assert!(GenericPcieSwitchCli::from_str("").is_err());
5699        assert!(GenericPcieSwitchCli::from_str("switch0").is_err());
5700        assert!(GenericPcieSwitchCli::from_str("rp0:switch0:extra").is_err());
5701        assert!(GenericPcieSwitchCli::from_str("rp0:switch0,invalid_opt=value").is_err());
5702        assert!(GenericPcieSwitchCli::from_str("rp0:switch0,num_downstream_ports=bad").is_err());
5703        assert!(GenericPcieSwitchCli::from_str("rp0:switch0,num_downstream_ports=").is_err());
5704        assert!(GenericPcieSwitchCli::from_str("rp0:switch0,invalid_flag").is_err());
5705        assert!(GenericPcieSwitchCli::from_str("rp0:switch0,pasid=bar").is_err());
5706    }
5707
5708    #[test]
5709    fn test_pcie_remote_from_str() {
5710        // Basic port name only
5711        assert_eq!(
5712            PcieRemoteCli::from_str("rc0rp0").unwrap(),
5713            PcieRemoteCli {
5714                port_name: "rc0rp0".to_string(),
5715                socket_addr: None,
5716                hu: 0,
5717                controller: 0,
5718            }
5719        );
5720
5721        // With socket address
5722        assert_eq!(
5723            PcieRemoteCli::from_str("rc0rp0,socket=localhost:22567").unwrap(),
5724            PcieRemoteCli {
5725                port_name: "rc0rp0".to_string(),
5726                socket_addr: Some("localhost:22567".to_string()),
5727                hu: 0,
5728                controller: 0,
5729            }
5730        );
5731
5732        // With all options
5733        assert_eq!(
5734            PcieRemoteCli::from_str("myport,socket=localhost:22568,hu=1,controller=2").unwrap(),
5735            PcieRemoteCli {
5736                port_name: "myport".to_string(),
5737                socket_addr: Some("localhost:22568".to_string()),
5738                hu: 1,
5739                controller: 2,
5740            }
5741        );
5742
5743        // Only hu and controller
5744        assert_eq!(
5745            PcieRemoteCli::from_str("port0,hu=5,controller=3").unwrap(),
5746            PcieRemoteCli {
5747                port_name: "port0".to_string(),
5748                socket_addr: None,
5749                hu: 5,
5750                controller: 3,
5751            }
5752        );
5753
5754        // Error cases
5755        assert!(PcieRemoteCli::from_str("").is_err());
5756        assert!(PcieRemoteCli::from_str("port,socket=").is_err());
5757        assert!(PcieRemoteCli::from_str("port,hu=").is_err());
5758        assert!(PcieRemoteCli::from_str("port,hu=bad").is_err());
5759        assert!(PcieRemoteCli::from_str("port,controller=").is_err());
5760        assert!(PcieRemoteCli::from_str("port,controller=bad").is_err());
5761        assert!(PcieRemoteCli::from_str("port,unknown=value").is_err());
5762    }
5763
5764    #[test]
5765    fn test_parse_memory_units() {
5766        assert_eq!(parse_memory("64G").unwrap(), 64 * 1024 * 1024 * 1024);
5767        assert_eq!(parse_memory("64GB").unwrap(), 64 * 1024 * 1024 * 1024);
5768        assert_eq!(parse_memory("3MB").unwrap(), 3 * 1024 * 1024);
5769        assert_eq!(parse_memory("512KB").unwrap(), 512 * 1024);
5770        assert!(parse_memory("3MiB").is_err());
5771    }
5772
5773    #[test]
5774    fn test_memory_config_size_only() {
5775        assert_eq!(
5776            parse_memory_config("64G").unwrap(),
5777            MemoryCli {
5778                size: Some(vmm_cli::MemorySize(64 * 1024 * 1024 * 1024)),
5779                ..Default::default()
5780            }
5781        );
5782    }
5783
5784    #[test]
5785    fn test_memory_config_key_value() {
5786        assert_eq!(
5787            parse_memory_config("size=2G,shared=off,prefetch=on,thp=on").unwrap(),
5788            MemoryCli {
5789                size: Some(vmm_cli::MemorySize(2 * 1024 * 1024 * 1024)),
5790                shared: Some(false),
5791                prefetch: true,
5792                transparent_hugepages: Some(true),
5793                ..Default::default()
5794            }
5795        );
5796
5797        assert_eq!(
5798            parse_memory_config("size=4GB,hugepages=on,hugepage_size=2MB").unwrap(),
5799            MemoryCli {
5800                size: Some(vmm_cli::MemorySize(4 * 1024 * 1024 * 1024)),
5801                hugepages: true,
5802                hugepage_size: Some(vmm_cli::MemorySize(2 * 1024 * 1024)),
5803                ..Default::default()
5804            }
5805        );
5806
5807        assert_eq!(
5808            parse_memory_config("file=/tmp/memory.bin").unwrap(),
5809            MemoryCli {
5810                file: Some(PathBuf::from("/tmp/memory.bin")),
5811                ..Default::default()
5812            }
5813        );
5814    }
5815
5816    #[test]
5817    fn test_memory_config_rejects_invalid_combinations() {
5818        assert!(parse_memory_config("size=1G,size=2G").is_err());
5819        assert!(parse_memory_config("hugepage_size=2M").is_err());
5820        assert!(parse_memory_config("hugepages=on,shared=off").is_err());
5821        assert!(parse_memory_config("hugepages=on,file=/tmp/memory.bin").is_err());
5822
5823        // Semantic validation of the hugepage size happens in the memory
5824        // builder, not in CLI parsing.
5825        assert_eq!(
5826            parse_memory_config("hugepages=on,hugepage_size=3MB")
5827                .unwrap()
5828                .hugepage_size,
5829            Some(vmm_cli::MemorySize(3 * 1024 * 1024))
5830        );
5831    }
5832
5833    #[test]
5834    fn test_smbios_requires_type() {
5835        // `type=` is mandatory; there is no default.
5836        assert!(parse_smbios("manufacturer=Contoso,family=Foo").is_err());
5837    }
5838
5839    #[test]
5840    fn test_smbios_all_system_keys() {
5841        let parsed = parse_smbios(
5842            "type=1,manufacturer=M,product=P,version=V,serial=S,sku=K,family=F,\
5843             uuid=12345678-9abc-def0-1234-56789abcdef0",
5844        )
5845        .unwrap();
5846        assert_eq!(
5847            parsed,
5848            SmbiosCli {
5849                system: SmbiosSystemCli {
5850                    manufacturer: Some("M".to_string()),
5851                    product_name: Some("P".to_string()),
5852                    version: Some("V".to_string()),
5853                    serial_number: Some("S".to_string()),
5854                    sku_number: Some("K".to_string()),
5855                    family: Some("F".to_string()),
5856                    uuid: Some(SmbiosUuid::Fixed(
5857                        "12345678-9abc-def0-1234-56789abcdef0"
5858                            .parse::<Guid>()
5859                            .unwrap()
5860                    )),
5861                },
5862                ..Default::default()
5863            }
5864        );
5865    }
5866
5867    #[test]
5868    fn test_smbios_bios_keys() {
5869        let parsed =
5870            parse_smbios("type=0,vendor=Contoso,version=1.0,date=01/01/2026,release=4.1").unwrap();
5871        assert_eq!(
5872            parsed,
5873            SmbiosCli {
5874                bios: SmbiosBiosCli {
5875                    vendor: Some("Contoso".to_string()),
5876                    version: Some("1.0".to_string()),
5877                    release_date: Some("01/01/2026".to_string()),
5878                    release: Some(SmbiosRelease(4, 1)),
5879                },
5880                ..Default::default()
5881            }
5882        );
5883    }
5884
5885    #[test]
5886    fn test_smbios_release_parsing() {
5887        // Valid MAJOR.MINOR.
5888        assert_eq!(
5889            parse_smbios("type=0,release=4.1").unwrap().bios.release,
5890            Some(SmbiosRelease(4, 1))
5891        );
5892        assert_eq!(
5893            parse_smbios("type=0,release=255.0").unwrap().bios.release,
5894            Some(SmbiosRelease(255, 0))
5895        );
5896        // Missing the dot, extra components, out-of-range, and non-numeric all
5897        // error.
5898        assert!(parse_smbios("type=0,release=4").is_err());
5899        assert!(parse_smbios("type=0,release=4.1.0").is_err());
5900        assert!(parse_smbios("type=0,release=256.0").is_err());
5901        assert!(parse_smbios("type=0,release=x.y").is_err());
5902        // `release` is a type=0 key only.
5903        assert!(parse_smbios("type=1,release=4.1").is_err());
5904    }
5905
5906    #[test]
5907    fn test_smbios_uuid() {
5908        // An explicit GUID parses to `Fixed`.
5909        assert_eq!(
5910            parse_smbios("type=1,uuid=12345678-9abc-def0-1234-56789abcdef0")
5911                .unwrap()
5912                .system
5913                .uuid,
5914            Some(SmbiosUuid::Fixed(
5915                "12345678-9abc-def0-1234-56789abcdef0"
5916                    .parse::<Guid>()
5917                    .unwrap()
5918            ))
5919        );
5920        // The literal `random` parses to `Random`.
5921        assert_eq!(
5922            parse_smbios("type=1,uuid=random").unwrap().system.uuid,
5923            Some(SmbiosUuid::Random)
5924        );
5925    }
5926
5927    #[test]
5928    fn test_smbios_version_disambiguated_by_type() {
5929        // `version` belongs to BIOS under type=0 and System under type=1.
5930        let bios = parse_smbios("type=0,version=1.0").unwrap();
5931        assert_eq!(bios.bios.version.as_deref(), Some("1.0"));
5932        assert_eq!(bios.system.version, None);
5933
5934        let system = parse_smbios("type=1,version=2.0").unwrap();
5935        assert_eq!(system.system.version.as_deref(), Some("2.0"));
5936        assert_eq!(system.bios.version, None);
5937    }
5938
5939    #[test]
5940    fn test_smbios_rejects_invalid() {
5941        // Missing type.
5942        assert!(parse_smbios("manufacturer=M").is_err());
5943        // Unknown key for the type.
5944        assert!(parse_smbios("type=0,manufacturer=M").is_err());
5945        assert!(parse_smbios("type=1,nonsense=x").is_err());
5946        // Unknown / unsupported type.
5947        assert!(parse_smbios("type=2,vendor=M").is_err());
5948        assert!(parse_smbios("type=bad,vendor=M").is_err());
5949        // Malformed key=value.
5950        assert!(parse_smbios("type=1,manufacturer").is_err());
5951        assert!(parse_smbios("=value").is_err());
5952        assert!(parse_smbios("type=1,manufacturer=").is_err());
5953        // Duplicate key within one argument.
5954        assert!(parse_smbios("type=1,manufacturer=A,manufacturer=B").is_err());
5955        assert!(parse_smbios("type=0,type=1,vendor=M").is_err());
5956        // Invalid UUID.
5957        assert!(parse_smbios("type=1,uuid=not-a-guid").is_err());
5958    }
5959
5960    #[test]
5961    fn test_smbios_merge_across_arguments() {
5962        let mut merged = SmbiosCli::default();
5963        merged
5964            .merge(parse_smbios("type=0,vendor=Contoso").unwrap())
5965            .unwrap();
5966        merged
5967            .merge(parse_smbios("type=1,manufacturer=M,family=F").unwrap())
5968            .unwrap();
5969        assert_eq!(
5970            merged,
5971            SmbiosCli {
5972                bios: SmbiosBiosCli {
5973                    vendor: Some("Contoso".to_string()),
5974                    ..Default::default()
5975                },
5976                system: SmbiosSystemCli {
5977                    manufacturer: Some("M".to_string()),
5978                    family: Some("F".to_string()),
5979                    ..Default::default()
5980                },
5981            }
5982        );
5983    }
5984
5985    #[test]
5986    fn test_smbios_merge_rejects_conflicts() {
5987        let mut merged = SmbiosCli::default();
5988        merged
5989            .merge(parse_smbios("type=1,manufacturer=A").unwrap())
5990            .unwrap();
5991        // The same field set by a second argument is a hard error.
5992        assert!(
5993            merged
5994                .merge(parse_smbios("type=1,manufacturer=B").unwrap())
5995                .is_err()
5996        );
5997    }
5998
5999    #[test]
6000    fn test_smbios_arg_repeatable() {
6001        let opt = Options::try_parse_from([
6002            "openvmm",
6003            "--smbios",
6004            "type=1,manufacturer=Contoso",
6005            "--smbios",
6006            "type=0,vendor=Acme",
6007        ])
6008        .unwrap();
6009        assert_eq!(opt.smbios.len(), 2);
6010        assert_eq!(
6011            opt.smbios[0].system.manufacturer.as_deref(),
6012            Some("Contoso")
6013        );
6014        assert_eq!(opt.smbios[1].bios.vendor.as_deref(), Some("Acme"));
6015    }
6016
6017    #[test]
6018    fn test_memory_options_merge_legacy_aliases() {
6019        let opt = Options::try_parse_from([
6020            "openvmm",
6021            "--memory",
6022            "2G",
6023            "--prefetch",
6024            "--private-memory",
6025            "--thp",
6026        ])
6027        .unwrap();
6028        opt.validate_memory_options().unwrap();
6029        assert_eq!(opt.memory_size(), 2 * 1024 * 1024 * 1024);
6030        assert!(opt.prefetch_memory());
6031        assert!(opt.private_memory());
6032        assert!(opt.transparent_hugepages());
6033    }
6034
6035    #[test]
6036    fn test_serial_debugger_mode_option_parsed() {
6037        // No COM port configured: no debugger mode.
6038        let opt = Options::try_parse_from(["openvmm"]).unwrap();
6039        assert!(opt.com1.is_none());
6040
6041        // A plain backend is not in debugger mode.
6042        let opt = Options::try_parse_from(["openvmm", "--com1", "none"]).unwrap();
6043        let com1 = opt.com1.unwrap();
6044        assert!(!com1.debugger_mode);
6045        assert_eq!(com1.backend, SerialConfigCli::None);
6046
6047        // The `debugger-mode:` prefix enables debugger mode for just that port,
6048        // and the remainder still parses as the backend.
6049        let opt = Options::try_parse_from([
6050            "openvmm",
6051            "--com1",
6052            "debugger-mode:listen=/tmp/kd",
6053            "--com2",
6054            "none",
6055        ])
6056        .unwrap();
6057        let com1 = opt.com1.unwrap();
6058        assert!(com1.debugger_mode);
6059        assert_eq!(com1.backend, SerialConfigCli::Pipe("/tmp/kd".into()));
6060        // Other ports remain independent (not in debugger mode).
6061        assert!(!opt.com2.unwrap().debugger_mode);
6062
6063        // The prefix must not eat colons in the backend (e.g. a tcp address).
6064        let opt = Options::try_parse_from([
6065            "openvmm",
6066            "--com1",
6067            "debugger-mode:listen=tcp:127.0.0.1:5555",
6068        ])
6069        .unwrap();
6070        let com1 = opt.com1.unwrap();
6071        assert!(com1.debugger_mode);
6072        assert_eq!(
6073            com1.backend,
6074            SerialConfigCli::Tcp("127.0.0.1:5555".parse().unwrap())
6075        );
6076    }
6077
6078    #[test]
6079    fn test_no_hv_requires_no_vmbus() {
6080        assert!(Options::try_parse_from(["openvmm", "--no-hv"]).is_err());
6081
6082        let opt = Options::try_parse_from(["openvmm", "--uefi", "--no-hv", "--no-vmbus"]).unwrap();
6083        assert!(opt.no_hv);
6084        assert!(opt.no_vmbus);
6085
6086        assert!(
6087            Options::try_parse_from(["openvmm", "--uefi", "--no-hv", "--no-vmbus", "--hv"])
6088                .is_err()
6089        );
6090    }
6091
6092    #[test]
6093    fn test_memory_options_allow_legacy_thp_with_new_private_memory() {
6094        let opt = Options::try_parse_from(["openvmm", "--memory", "shared=off", "--thp"]).unwrap();
6095        opt.validate_memory_options().unwrap();
6096        assert!(opt.private_memory());
6097        assert!(opt.transparent_hugepages());
6098    }
6099
6100    #[test]
6101    fn test_memory_options_reject_conflicting_legacy_aliases() {
6102        let opt = Options::try_parse_from(["openvmm", "--memory", "shared=on", "--private-memory"])
6103            .unwrap();
6104        assert!(opt.validate_memory_options().is_err());
6105    }
6106
6107    #[test]
6108    fn test_isolation_options_reject_snp_uefi() {
6109        let opt = Options::try_parse_from(["openvmm", "--isolation", "snp", "--uefi"]).unwrap();
6110
6111        assert_eq!(
6112            opt.validate_isolation_options().unwrap_err().to_string(),
6113            "SNP isolation currently only supports Linux direct boot"
6114        );
6115    }
6116
6117    #[test]
6118    fn test_isolation_options_reject_snp_hugepages() {
6119        for args in [
6120            vec![
6121                "openvmm",
6122                "--isolation",
6123                "snp",
6124                "--memory",
6125                "size=1G,hugepages=on",
6126            ],
6127            vec![
6128                "openvmm",
6129                "--isolation",
6130                "snp",
6131                "--numa",
6132                "size=1G,hugepages=on",
6133            ],
6134        ] {
6135            let opt = Options::try_parse_from(args).unwrap();
6136            assert_eq!(
6137                opt.validate_isolation_options().unwrap_err().to_string(),
6138                "SNP isolation currently does not support hugetlb memory"
6139            );
6140        }
6141    }
6142
6143    #[test]
6144    fn test_restricted_injection_requires_snp() {
6145        let opt = Options::try_parse_from(["openvmm", "--snp-restricted-injection"]).unwrap();
6146
6147        assert_eq!(
6148            opt.validate_isolation_options().unwrap_err().to_string(),
6149            "--snp-restricted-injection requires --isolation snp"
6150        );
6151
6152        let opt = Options::try_parse_from([
6153            "openvmm",
6154            "--isolation",
6155            "snp",
6156            "--snp-restricted-injection",
6157        ])
6158        .unwrap();
6159        assert_eq!(
6160            opt.validate_isolation_options().unwrap_err().to_string(),
6161            "--snp-restricted-injection requires --hypervisor mshv"
6162        );
6163
6164        let opt = Options::try_parse_from([
6165            "openvmm",
6166            "--hypervisor",
6167            "mshv",
6168            "--isolation",
6169            "snp",
6170            "--snp-restricted-injection",
6171        ])
6172        .unwrap();
6173        opt.validate_isolation_options().unwrap();
6174
6175        let opt = Options::try_parse_from([
6176            "openvmm",
6177            "--hypervisor",
6178            "mshv",
6179            "--isolation",
6180            "snp",
6181            "--snp-restricted-injection",
6182            "--pcat",
6183        ])
6184        .unwrap();
6185        assert_eq!(
6186            opt.validate_isolation_options().unwrap_err().to_string(),
6187            "--snp-restricted-injection requires Linux direct boot"
6188        );
6189
6190        let opt = Options::try_parse_from([
6191            "openvmm",
6192            "--hypervisor",
6193            "mshv",
6194            "--isolation",
6195            "snp",
6196            "--snp-restricted-injection",
6197            "--restore-snapshot",
6198            "snapshot",
6199        ])
6200        .unwrap();
6201        assert_eq!(
6202            opt.validate_isolation_options().unwrap_err().to_string(),
6203            "--snp-restricted-injection requires Linux direct boot"
6204        );
6205    }
6206
6207    #[test]
6208    fn test_isolation_options_allow_vbs_uefi() {
6209        let opt = Options::try_parse_from(["openvmm", "--isolation", "vbs", "--uefi"]).unwrap();
6210
6211        opt.validate_isolation_options().unwrap();
6212    }
6213
6214    #[test]
6215    fn test_isolation_options_allow_vbs_hugepages() {
6216        let opt = Options::try_parse_from([
6217            "openvmm",
6218            "--isolation",
6219            "vbs",
6220            "--memory",
6221            "size=1G,hugepages=on",
6222        ])
6223        .unwrap();
6224
6225        opt.validate_isolation_options().unwrap();
6226    }
6227
6228    #[test]
6229    fn test_igvm_requires_firmware_and_personality() {
6230        for (options, missing) in [
6231            ("firmware=guest.igvm", "personality"),
6232            ("personality=openhcl", "firmware"),
6233        ] {
6234            for extra in [vec![], vec!["--hv", "--vtl2"]] {
6235                let mut args = vec!["openvmm", "--igvm", options];
6236                args.extend(extra);
6237                let err = Options::try_parse_from(args).err().unwrap();
6238                assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
6239                assert!(err.to_string().contains(missing));
6240                assert!(err.to_string().contains("required"));
6241            }
6242        }
6243        let opt = Options::try_parse_from(["openvmm"]).unwrap();
6244        opt.validate_igvm_options().unwrap();
6245    }
6246
6247    #[test]
6248    fn test_igvm_personality_values() {
6249        for (value, expected) in [
6250            ("openhcl", IgvmPersonalityCli::Openhcl),
6251            ("uefi", IgvmPersonalityCli::Uefi),
6252            ("linux-direct", IgvmPersonalityCli::LinuxDirect),
6253        ] {
6254            let options = format!("firmware=guest.igvm,personality={value}");
6255            let mut args = vec!["openvmm", "--igvm", &options];
6256            if expected == IgvmPersonalityCli::Openhcl {
6257                args.extend(["--hv", "--vtl2"]);
6258            }
6259            let opt = Options::try_parse_from(args).unwrap();
6260            opt.validate_igvm_options().unwrap();
6261            assert_eq!(
6262                opt.igvm,
6263                Some(IgvmCli {
6264                    firmware: PathBuf::from("guest.igvm"),
6265                    personality: expected,
6266                })
6267            );
6268        }
6269    }
6270
6271    #[test]
6272    fn test_igvm_options_accept_reordered_keys_and_windows_paths() {
6273        let opt = Options::try_parse_from([
6274            "openvmm",
6275            "--igvm",
6276            r"personality=uefi,firmware=C:\VM Images\guest=1.igvm",
6277        ])
6278        .unwrap();
6279        assert_eq!(
6280            opt.igvm,
6281            Some(IgvmCli {
6282                firmware: PathBuf::from(r"C:\VM Images\guest=1.igvm"),
6283                personality: IgvmPersonalityCli::Uefi,
6284            })
6285        );
6286    }
6287
6288    #[test]
6289    fn test_igvm_options_reject_invalid_values() {
6290        for options in [
6291            "",
6292            "guest.igvm",
6293            "firmware=,personality=uefi",
6294            "firmware=guest.igvm,personality=",
6295            "firmware=guest.igvm,personality=pcat",
6296            "firmware=guest.igvm,personality=UEFI",
6297            "firmware=guest.igvm,personality=uefi,unknown=on",
6298            "firmware=guest.igvm,firmware=other.igvm,personality=uefi",
6299            "firmware=guest.igvm,personality=uefi,personality=linux-direct",
6300        ] {
6301            let err = Options::try_parse_from(["openvmm", "--igvm", options])
6302                .err()
6303                .unwrap();
6304            assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
6305        }
6306    }
6307
6308    #[test]
6309    fn test_igvm_rejects_separate_personality_flag() {
6310        let err = Options::try_parse_from([
6311            "openvmm",
6312            "--igvm",
6313            "firmware=guest.igvm,personality=uefi",
6314            "--igvm-personality",
6315            "uefi",
6316        ])
6317        .err()
6318        .unwrap();
6319        assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
6320    }
6321
6322    #[test]
6323    fn test_openhcl_igvm_personality() {
6324        let opt = Options::try_parse_from([
6325            "openvmm",
6326            "--igvm",
6327            "firmware=guest.igvm,personality=openhcl",
6328            "--hv",
6329            "--vtl2",
6330        ])
6331        .unwrap();
6332        opt.validate_igvm_options().unwrap();
6333        assert_eq!(
6334            opt.igvm.as_ref().unwrap().personality,
6335            IgvmPersonalityCli::Openhcl
6336        );
6337        assert!(opt.effective_uefi().unwrap().is_none());
6338    }
6339
6340    #[test]
6341    fn test_openhcl_igvm_requires_explicit_vtl2_and_hv() {
6342        for extra in [vec![], vec!["--hv"], vec!["--vtl2"]] {
6343            let mut args = vec![
6344                "openvmm",
6345                "--igvm",
6346                "firmware=guest.igvm,personality=openhcl",
6347            ];
6348            args.extend(extra);
6349            match Options::try_parse_from(args) {
6350                Ok(opt) => assert_eq!(
6351                    opt.validate_igvm_options().unwrap_err().to_string(),
6352                    "--igvm personality=openhcl requires --hv --vtl2"
6353                ),
6354                Err(err) => {
6355                    assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
6356                    assert!(err.to_string().contains("--hv"));
6357                }
6358            }
6359        }
6360    }
6361
6362    #[test]
6363    fn test_non_openhcl_igvm_personalities_reject_vtl2_and_uefi() {
6364        for personality in ["uefi", "linux-direct"] {
6365            let options = format!("firmware=guest.igvm,personality={personality}");
6366            for extra in [vec!["--uefi"], vec!["--hv", "--vtl2"]] {
6367                let mut args = vec!["openvmm", "--igvm", &options];
6368                args.extend(extra);
6369                let opt = Options::try_parse_from(args).unwrap();
6370                assert!(opt.validate_igvm_options().is_err());
6371            }
6372        }
6373    }
6374
6375    #[test]
6376    fn test_openhcl_igvm_accepts_uefi_settings() {
6377        let opt = Options::try_parse_from([
6378            "openvmm",
6379            "--igvm",
6380            "firmware=guest.igvm,personality=openhcl",
6381            "--hv",
6382            "--vtl2",
6383            "--uefi",
6384            "console=com1,disable_frontpage",
6385        ])
6386        .unwrap();
6387        opt.validate_igvm_options().unwrap();
6388        let uefi = opt.effective_uefi().unwrap().unwrap();
6389        assert!(matches!(uefi.console, Some(UefiConsoleModeCli::Com1)));
6390        assert!(uefi.disable_frontpage);
6391    }
6392
6393    #[test]
6394    fn test_igvm_personality_conflicts_with_pcat() {
6395        for personality in ["openhcl", "uefi", "linux-direct"] {
6396            let options = format!("firmware=guest.igvm,personality={personality}");
6397            assert!(Options::try_parse_from(["openvmm", "--igvm", &options, "--pcat"]).is_err());
6398        }
6399    }
6400
6401    #[test]
6402    fn test_pidfile_option_parsed() {
6403        let opt = Options::try_parse_from(["openvmm", "--pidfile", "/tmp/test.pid"]).unwrap();
6404        assert_eq!(opt.pidfile, Some(PathBuf::from("/tmp/test.pid")));
6405    }
6406
6407    #[test]
6408    fn test_tpm_version_option() {
6409        let opt = Options::try_parse_from(["openvmm"]).unwrap();
6410        assert_eq!(opt.tpm, None);
6411
6412        let opt = Options::try_parse_from(["openvmm", "--tpm"]).unwrap();
6413        assert_eq!(opt.tpm, Some(TpmVersionCli::V185));
6414
6415        let opt = Options::try_parse_from(["openvmm", "--tpm", "--uefi"]).unwrap();
6416        assert_eq!(opt.tpm, Some(TpmVersionCli::V185));
6417        assert!(opt.uefi.is_some());
6418
6419        let opt = Options::try_parse_from(["openvmm", "--tpm", "138"]).unwrap();
6420        assert_eq!(opt.tpm, Some(TpmVersionCli::V138));
6421
6422        let opt = Options::try_parse_from(["openvmm", "--tpm=185"]).unwrap();
6423        assert_eq!(opt.tpm, Some(TpmVersionCli::V185));
6424
6425        let opt = Options::try_parse_from(["openvmm", "--tpm", "1.38"]).unwrap();
6426        assert_eq!(opt.tpm, Some(TpmVersionCli::V138));
6427
6428        assert!(Options::try_parse_from(["openvmm", "--tpm", "137"]).is_err());
6429    }
6430
6431    #[test]
6432    fn test_uefi_options() {
6433        let options = Options::try_parse_from(["openvmm", "--uefi"]).unwrap();
6434        assert!(options.effective_uefi().unwrap().is_some());
6435
6436        let options = Options::try_parse_from([
6437            "openvmm",
6438            "--uefi",
6439            "firmware=foo/bar,debug,force_firmware_version,console=com1,diagnostics=full",
6440        ])
6441        .unwrap();
6442        let uefi = options.effective_uefi().unwrap().unwrap();
6443        assert_eq!(uefi.firmware, Some("foo/bar".into()));
6444        assert!(uefi.debug);
6445        assert!(uefi.force_firmware_version);
6446        assert!(matches!(uefi.console, Some(UefiConsoleModeCli::Com1)));
6447        assert!(matches!(
6448            uefi.diagnostics,
6449            Some(EfiDiagnosticsLogLevelCli::Full)
6450        ));
6451
6452        let options = Options::try_parse_from([
6453            "openvmm",
6454            "--uefi",
6455            "--uefi-firmware",
6456            "legacy.fd",
6457            "--uefi-debug",
6458        ])
6459        .unwrap();
6460        let uefi = options.effective_uefi().unwrap().unwrap();
6461        assert_eq!(uefi.firmware, Some("legacy.fd".into()));
6462        assert!(uefi.debug);
6463
6464        assert!(Options::try_parse_from(["openvmm", "--uefi", "unknown"]).is_err());
6465        let options = Options::try_parse_from(["openvmm", "--uefi", "debug=on"]).unwrap();
6466        assert!(options.effective_uefi().unwrap().unwrap().debug);
6467        let options = Options::try_parse_from(["openvmm", "--uefi", "debug=off"]).unwrap();
6468        assert!(!options.effective_uefi().unwrap().unwrap().debug);
6469        assert!(Options::try_parse_from(["openvmm", "--uefi", "debug,debug"]).is_err());
6470
6471        let options = Options::try_parse_from([
6472            "openvmm",
6473            "--uefi",
6474            "firmware=new.fd",
6475            "--uefi-firmware",
6476            "legacy.fd",
6477        ])
6478        .unwrap();
6479        assert!(options.effective_uefi().is_err());
6480    }
6481
6482    #[test]
6483    fn test_guest_power_action_flags() {
6484        // Defaults preserve the historical behavior: reset and watchdog reboot,
6485        // power-off and crash keep the stopped VM.
6486        let opt = Options::try_parse_from(["openvmm"]).unwrap();
6487        assert_eq!(opt.guest_reset_action, GuestPowerAction::Reset);
6488        assert_eq!(opt.guest_shutdown_action, GuestPowerAction::Halt);
6489        assert_eq!(opt.guest_crash_action, GuestPowerAction::Halt);
6490        assert_eq!(opt.guest_watchdog_action, GuestPowerAction::Reset);
6491        // The CLI defaults must match the shared GuestPowerActions::default() the
6492        // ttrpc server uses, so the two launch paths never drift.
6493        assert_eq!(
6494            crate::vm_controller::GuestPowerActions {
6495                shutdown: opt.guest_shutdown_action,
6496                reset: opt.guest_reset_action,
6497                crash: opt.guest_crash_action,
6498                watchdog: opt.guest_watchdog_action,
6499            },
6500            crate::vm_controller::GuestPowerActions::default(),
6501        );
6502
6503        let opt = Options::try_parse_from([
6504            "openvmm",
6505            "--guest-watchdog",
6506            "--guest-reset-action",
6507            "exit",
6508            "--guest-shutdown-action",
6509            "exit:5",
6510            "--guest-crash-action",
6511            "reset",
6512            "--guest-watchdog-action",
6513            "halt",
6514        ])
6515        .unwrap();
6516        // A bare `exit` is status 0; `exit:5` carries the code through.
6517        assert_eq!(opt.guest_reset_action, GuestPowerAction::Exit(0));
6518        assert_eq!(opt.guest_shutdown_action, GuestPowerAction::Exit(5));
6519        assert_eq!(opt.guest_crash_action, GuestPowerAction::Reset);
6520        assert_eq!(opt.guest_watchdog_action, GuestPowerAction::Halt);
6521
6522        // Malformed and out-of-range exit codes are rejected (status is 0-255).
6523        assert!(Options::try_parse_from(["openvmm", "--guest-reset-action", "exit:nope"]).is_err());
6524        assert!(Options::try_parse_from(["openvmm", "--guest-reset-action", "exit:300"]).is_err());
6525        assert!(Options::try_parse_from(["openvmm", "--guest-reset-action", "exit:-1"]).is_err());
6526
6527        // --guest-watchdog-action requires the watchdog device (--guest-watchdog).
6528        assert!(Options::try_parse_from(["openvmm", "--guest-watchdog-action", "halt"]).is_err());
6529    }
6530
6531    #[cfg(target_os = "linux")]
6532    #[test]
6533    fn test_vfio_device_cli_parse() {
6534        use vfio_assigned_device_resources::BarAddressConfig;
6535
6536        // Required keys only.
6537        let v = VfioDeviceCli::from_str("host=0000:01:00.0,port=rp0").unwrap();
6538        assert_eq!(v.pci_id, "0000:01:00.0");
6539        assert_eq!(v.port_name, "rp0");
6540        assert_eq!(v.iommu, None);
6541
6542        // With optional iommu= key. Keys may appear in any order.
6543        let v = VfioDeviceCli::from_str("port=rp1,iommu=iommu0,host=0000:02:00.0").unwrap();
6544        assert_eq!(v.pci_id, "0000:02:00.0");
6545        assert_eq!(v.port_name, "rp1");
6546        assert_eq!(v.iommu.as_deref(), Some("iommu0"));
6547
6548        let v = VfioDeviceCli::from_str(
6549            "host=0000:03:00.0,port=rp2,bar0=host,bar2=0x80000000,bar4=0x110000000000",
6550        )
6551        .unwrap();
6552        assert_eq!(v.bar_addresses[0], BarAddressConfig::HostAssigned);
6553        assert_eq!(v.bar_addresses[1], BarAddressConfig::GuestAssigned);
6554        assert_eq!(v.bar_addresses[2], BarAddressConfig::Fixed(0x80000000));
6555        assert_eq!(v.bar_addresses[4], BarAddressConfig::Fixed(0x110000000000));
6556    }
6557
6558    #[cfg(target_os = "linux")]
6559    #[test]
6560    fn test_vfio_device_cli_errors() {
6561        // Missing required keys.
6562        assert!(VfioDeviceCli::from_str("port=rp0").is_err());
6563        assert!(VfioDeviceCli::from_str("host=0000:01:00.0").is_err());
6564
6565        // Unknown key.
6566        assert!(VfioDeviceCli::from_str("host=0000:01:00.0,port=rp0,foo=bar").is_err());
6567
6568        // Duplicate keys are rejected.
6569        assert!(VfioDeviceCli::from_str("host=0000:01:00.0,host=0000:02:00.0,port=rp0").is_err());
6570        assert!(VfioDeviceCli::from_str("host=0000:01:00.0,port=rp0,port=rp1").is_err());
6571        assert!(VfioDeviceCli::from_str("host=0000:01:00.0,port=rp0,iommu=a,iommu=b").is_err());
6572
6573        // Empty values are rejected.
6574        assert!(VfioDeviceCli::from_str("host=,port=rp0").is_err());
6575        assert!(VfioDeviceCli::from_str("host=0000:01:00.0,port=").is_err());
6576        assert!(VfioDeviceCli::from_str("host=0000:01:00.0,port=rp0,iommu=").is_err());
6577
6578        // Missing '=' separator.
6579        assert!(VfioDeviceCli::from_str("host").is_err());
6580        assert!(VfioDeviceCli::from_str("host=0000:01:00.0,port=rp0,iommu").is_err());
6581
6582        // Path-traversal characters in the host BDF are rejected.
6583        assert!(VfioDeviceCli::from_str("host=../../etc/passwd,port=rp0").is_err());
6584        assert!(VfioDeviceCli::from_str("host=foo/bar,port=rp0").is_err());
6585
6586        // Invalid and duplicate BAR configurations are rejected.
6587        assert!(VfioDeviceCli::from_str("host=0000:01:00.0,port=rp0,bar0=0").is_err());
6588        assert!(VfioDeviceCli::from_str("host=0000:01:00.0,port=rp0,bar0=0x0").is_err());
6589        assert!(VfioDeviceCli::from_str("host=0000:01:00.0,port=rp0,bar0=0xnope").is_err());
6590        assert!(VfioDeviceCli::from_str("host=0000:01:00.0,port=rp0,bar0=pt").is_err());
6591        assert!(
6592            VfioDeviceCli::from_str("host=0000:01:00.0,port=rp0,bar0=0x1000,bar0=host").is_err()
6593        );
6594    }
6595
6596    #[cfg(target_os = "linux")]
6597    #[test]
6598    fn test_iommu_cli_parse() {
6599        let c = IommuCli::from_str("id=iommu0").unwrap();
6600        assert_eq!(c.id, "iommu0");
6601
6602        // Wrong key.
6603        assert!(IommuCli::from_str("name=iommu0").is_err());
6604
6605        // Missing '=' separator.
6606        assert!(IommuCli::from_str("iommu0").is_err());
6607
6608        // Empty id.
6609        assert!(IommuCli::from_str("id=").is_err());
6610    }
6611
6612    #[cfg(target_os = "linux")]
6613    #[test]
6614    fn test_vhost_user_cli() {
6615        // type=blk: socket is positional; num_queues/queue_size optional.
6616        let v = VhostUserCli::from_str("/run/blk.sock,type=blk,num_queues=2").unwrap();
6617        assert_eq!(v.socket_path, "/run/blk.sock");
6618        assert!(matches!(
6619            v.device_type,
6620            VhostUserDeviceTypeCli::Blk {
6621                num_queues: Some(2),
6622                queue_size: None
6623            }
6624        ));
6625        assert_eq!(v.pcie_port, None);
6626
6627        // type=fs requires a tag.
6628        let v = VhostUserCli::from_str("/run/fs.sock,type=fs,tag=myfs,pcie_port=p0").unwrap();
6629        assert!(matches!(
6630            &v.device_type,
6631            VhostUserDeviceTypeCli::Fs { tag, .. } if tag == "myfs"
6632        ));
6633        assert_eq!(v.pcie_port.as_deref(), Some("p0"));
6634
6635        // device_id with a bracketed queue_sizes list.
6636        let v = VhostUserCli::from_str("/run/x.sock,device_id=9,queue_sizes=[16,32]").unwrap();
6637        assert!(matches!(
6638            &v.device_type,
6639            VhostUserDeviceTypeCli::Other { device_id: 9, queue_sizes } if *queue_sizes == vec![16, 32]
6640        ));
6641
6642        // Errors.
6643        assert!(VhostUserCli::from_str("/run/x.sock").is_err()); // neither type nor device_id
6644        assert!(VhostUserCli::from_str("/run/x.sock,type=blk,device_id=1").is_err()); // both
6645        assert!(VhostUserCli::from_str("/run/x.sock,type=fs").is_err()); // fs without tag
6646        assert!(VhostUserCli::from_str("/run/x.sock,type=zzz").is_err()); // unknown type
6647        assert!(VhostUserCli::from_str("/run/x.sock,type=blk,tag=t").is_err()); // tag on non-fs
6648        assert!(VhostUserCli::from_str("/run/x.sock,type=blk,queue_sizes=[1]").is_err()); // queue_sizes on non-device_id
6649        assert!(VhostUserCli::from_str("/run/x.sock,device_id=1,num_queues=2").is_err()); // num_queues on device_id
6650        assert!(VhostUserCli::from_str("/run/x.sock,device_id=1,queue_sizes=[]").is_err()); // empty list
6651        assert!(VhostUserCli::from_str("/run/x.sock,device_id=1").is_err()); // device_id without queue_sizes
6652    }
6653
6654    #[cfg(target_os = "linux")]
6655    #[test]
6656    fn test_vhost_vsock_cli() {
6657        let opt = Options::try_parse_from(["openvmm", "--virtio-vsock-vhost-cid", "3"]).unwrap();
6658        assert_eq!(opt.virtio_vsock_vhost_cid, Some(3));
6659
6660        let opt =
6661            Options::try_parse_from(["openvmm", "--virtio-vsock-path", "/tmp/vsock"]).unwrap();
6662        assert_eq!(opt.virtio_vsock_path.as_deref(), Some("/tmp/vsock"));
6663
6664        assert!(Options::try_parse_from(["openvmm", "--virtio-vsock-vhost-cid", "2"]).is_err());
6665        assert!(
6666            Options::try_parse_from([
6667                "openvmm",
6668                "--virtio-vsock-vhost-cid",
6669                "3",
6670                "--virtio-vsock-path",
6671                "/tmp/vsock",
6672            ])
6673            .is_err()
6674        );
6675    }
6676
6677    #[test]
6678    fn test_virtio_vsock_bus_cli() {
6679        let opt = Options::try_parse_from(["openvmm", "--virtio-vsock-bus", "mmio"]).unwrap();
6680        assert_eq!(opt.virtio_vsock_bus, Some(VirtioBusCli::Mmio));
6681
6682        let opt = Options::try_parse_from(["openvmm", "--virtio-vsock-bus", "pci"]).unwrap();
6683        assert_eq!(opt.virtio_vsock_bus, Some(VirtioBusCli::Pci));
6684
6685        let opt = Options::try_parse_from(["openvmm", "--virtio-vsock-bus", "pcie"]).unwrap();
6686        assert_eq!(
6687            opt.virtio_vsock_bus,
6688            Some(VirtioBusCli::Pcie("vsock".to_string()))
6689        );
6690
6691        let opt =
6692            Options::try_parse_from(["openvmm", "--virtio-vsock-bus", "pcie:custom"]).unwrap();
6693        assert_eq!(
6694            opt.virtio_vsock_bus,
6695            Some(VirtioBusCli::Pcie("custom".to_string()))
6696        );
6697
6698        let opt =
6699            Options::try_parse_from(["openvmm", "--virtio-vsock-bus", "PCIE:custom"]).unwrap();
6700        assert_eq!(
6701            opt.virtio_vsock_bus,
6702            Some(VirtioBusCli::Pcie("custom".to_string()))
6703        );
6704
6705        assert!(Options::try_parse_from(["openvmm", "--virtio-vsock-bus", "auto"]).is_err());
6706        assert!(Options::try_parse_from(["openvmm", "--virtio-vsock-bus", "vpci"]).is_err());
6707        assert!(Options::try_parse_from(["openvmm", "--virtio-vsock-bus", "pcie:"]).is_err());
6708        assert!(
6709            Options::try_parse_from(["openvmm", "--virtio-vsock-bus", "pcie:custom:extra"])
6710                .is_err()
6711        );
6712
6713        let opt =
6714            Options::try_parse_from(["openvmm", "--virtio-rng", "--virtio-rng-bus", "pcie:custom"])
6715                .unwrap();
6716        assert_eq!(opt.virtio_rng_bus, VirtioBusCli::Pcie("custom".to_string()));
6717    }
6718
6719    #[test]
6720    fn test_virtio_rtc_cli() {
6721        let opt = Options::try_parse_from(["openvmm"]).unwrap();
6722        assert!(opt.virtio_rtc.is_none());
6723
6724        let opt =
6725            Options::try_parse_from(["openvmm", "--virtio-rtc", "--processors", "2"]).unwrap();
6726        assert_eq!(
6727            opt.virtio_rtc,
6728            Some(VirtioRtcCli {
6729                bus: VirtioBusCli::Auto
6730            })
6731        );
6732
6733        let opt = Options::try_parse_from(["openvmm", "--virtio-rtc", "bus=pcie:rp0"]).unwrap();
6734        assert_eq!(
6735            opt.virtio_rtc,
6736            Some(VirtioRtcCli {
6737                bus: VirtioBusCli::Pcie("rp0".to_string())
6738            })
6739        );
6740
6741        for value in ["mmio", "bus", "bus=", "bus=pcie:", "bus=pcie:rp0,bus=mmio"] {
6742            assert!(Options::try_parse_from(["openvmm", "--virtio-rtc", value]).is_err());
6743        }
6744    }
6745
6746    #[test]
6747    fn test_nvme_controller_cli_pcie() {
6748        let c = NvmeControllerCli::from_str("id=nvme0,pcie_port=p0").unwrap();
6749        assert_eq!(c.id, "nvme0");
6750        assert_eq!(c.transport, NvmeControllerTransport::Pcie("p0".into()));
6751    }
6752
6753    #[test]
6754    fn test_nvme_controller_cli_vpci_no_guid() {
6755        let c = NvmeControllerCli::from_str("id=nvme1,vpci").unwrap();
6756        assert_eq!(c.id, "nvme1");
6757        assert!(matches!(c.transport, NvmeControllerTransport::Vpci(None)));
6758        assert!(NvmeControllerCli::from_str("id=nvme1,vpci=").is_err());
6759    }
6760
6761    #[test]
6762    fn test_nvme_controller_cli_vpci_with_guid() {
6763        let c = NvmeControllerCli::from_str("id=nvme2,vpci=008091f6-9688-497d-9091-af347dc9173c")
6764            .unwrap();
6765        assert_eq!(c.id, "nvme2");
6766        assert!(matches!(
6767            c.transport,
6768            NvmeControllerTransport::Vpci(Some(_))
6769        ));
6770    }
6771
6772    #[test]
6773    fn test_nvme_controller_cli_errors() {
6774        // Missing id.
6775        assert!(NvmeControllerCli::from_str("pcie_port=p0").is_err());
6776        // Missing transport.
6777        assert!(NvmeControllerCli::from_str("id=nvme0").is_err());
6778        // Both transports.
6779        assert!(NvmeControllerCli::from_str("id=nvme0,pcie_port=p0,vpci").is_err());
6780        // Unknown option.
6781        assert!(NvmeControllerCli::from_str("id=nvme0,pcie_port=p0,foo=bar").is_err());
6782        // Empty id.
6783        assert!(NvmeControllerCli::from_str("id=,pcie_port=p0").is_err());
6784        // Empty pcie_port.
6785        assert!(NvmeControllerCli::from_str("id=nvme0,pcie_port=").is_err());
6786        // Invalid GUID.
6787        let err = NvmeControllerCli::from_str("id=nvme0,vpci=not-a-guid").unwrap_err();
6788        assert!(err.to_string().contains("invalid value for option 'vpci'"));
6789    }
6790
6791    #[test]
6792    fn test_disk_cli_controller() {
6793        let d = DiskCli::from_str("file:disk.vhd,on=nvme0").unwrap();
6794        assert_eq!(d.controller.as_deref(), Some("nvme0"));
6795        assert_eq!(d.nsid, None);
6796    }
6797
6798    #[test]
6799    fn test_disk_cli_serial() {
6800        let d = DiskCli::from_str("file:disk.vhd,serial=DATA-DISK").unwrap();
6801        assert_eq!(d.serial.as_deref(), Some("DATA-DISK"));
6802        assert!(DiskCli::from_str("file:disk.vhd,serial=").is_err());
6803        assert!(DiskCli::from_str("file:disk.vhd,serial=DATA[1]").is_err());
6804        assert!(DiskCli::from_str("file:disk.vhd,serial=DATA,DISK").is_err());
6805        assert!(DiskCli::from_str("file:disk.vhd,serial=DATA\u{1f}DISK").is_err());
6806        assert!(DiskCli::from_str("file:disk.vhd,serial=non-ascii-\u{e9}").is_err());
6807    }
6808
6809    #[test]
6810    fn test_disk_cli_controller_with_nsid() {
6811        let d = DiskCli::from_str("file:disk.vhd,on=nvme0,nsid=3").unwrap();
6812        assert_eq!(d.controller.as_deref(), Some("nvme0"));
6813        assert_eq!(d.nsid, Some(3));
6814    }
6815
6816    #[test]
6817    fn test_disk_cli_controller_errors() {
6818        // nsid without on.
6819        assert!(DiskCli::from_str("file:disk.vhd,nsid=1").is_err());
6820        // lun without on.
6821        assert!(DiskCli::from_str("file:disk.vhd,lun=0").is_err());
6822        // on with pcie_port.
6823        assert!(DiskCli::from_str("file:disk.vhd,on=nvme0,pcie_port=p0").is_err());
6824        // Empty controller name.
6825        assert!(DiskCli::from_str("file:disk.vhd,on=").is_err());
6826        // Invalid nsid.
6827        assert!(DiskCli::from_str("file:disk.vhd,on=nvme0,nsid=abc").is_err());
6828        // nsid and lun together.
6829        assert!(DiskCli::from_str("file:disk.vhd,on=c,nsid=1,lun=0").is_err());
6830    }
6831
6832    #[test]
6833    fn test_disk_cli_controller_with_lun() {
6834        let d = DiskCli::from_str("file:disk.vhd,on=scsi0,lun=3").unwrap();
6835        assert_eq!(d.controller.as_deref(), Some("scsi0"));
6836        assert_eq!(d.lun, Some(3));
6837        assert_eq!(d.nsid, None);
6838    }
6839
6840    #[test]
6841    fn test_scsi_controller_cli() {
6842        let c = ScsiControllerCli::from_str("id=scsi0").unwrap();
6843        assert_eq!(c.id, "scsi0");
6844        assert_eq!(c.sub_channels, 0);
6845    }
6846
6847    #[test]
6848    fn test_scsi_controller_cli_with_sub_channels() {
6849        let c = ScsiControllerCli::from_str("id=scsi1,sub_channels=4").unwrap();
6850        assert_eq!(c.id, "scsi1");
6851        assert_eq!(c.sub_channels, 4);
6852    }
6853
6854    #[test]
6855    fn test_scsi_controller_cli_errors() {
6856        // Missing id.
6857        assert!(ScsiControllerCli::from_str("sub_channels=4").is_err());
6858        // Empty id.
6859        assert!(ScsiControllerCli::from_str("id=").is_err());
6860        // Unknown option.
6861        assert!(ScsiControllerCli::from_str("id=scsi0,foo=bar").is_err());
6862        // Invalid sub_channels.
6863        assert!(ScsiControllerCli::from_str("id=scsi0,sub_channels=abc").is_err());
6864    }
6865
6866    #[test]
6867    fn test_disk_cli_relay() {
6868        let d = DiskCli::from_str("file:disk.vhd,on=src,relay=tgt").unwrap();
6869        assert_eq!(d.relay.as_ref().unwrap().0, "tgt");
6870        assert_eq!(d.relay.as_ref().unwrap().1, None);
6871    }
6872
6873    #[test]
6874    fn test_disk_cli_relay_with_location() {
6875        let d = DiskCli::from_str("file:disk.vhd,on=src,relay=tgt:3").unwrap();
6876        assert_eq!(d.relay.as_ref().unwrap().0, "tgt");
6877        assert_eq!(d.relay.as_ref().unwrap().1, Some(3));
6878    }
6879
6880    #[test]
6881    fn test_disk_cli_relay_errors() {
6882        // relay without on.
6883        assert!(DiskCli::from_str("file:disk.vhd,relay=tgt").is_err());
6884        // relay with uh.
6885        assert!(DiskCli::from_str("file:disk.vhd,on=src,relay=tgt,uh").is_err());
6886        // relay with invalid location.
6887        assert!(DiskCli::from_str("file:disk.vhd,on=src,relay=tgt:abc").is_err());
6888        // empty relay.
6889        assert!(DiskCli::from_str("file:disk.vhd,on=src,relay=").is_err());
6890    }
6891
6892    #[test]
6893    fn test_nvme_controller_cli_vtl2() {
6894        let c = NvmeControllerCli::from_str("id=nvme0,vpci,vtl2").unwrap();
6895        assert_eq!(c.vtl, DeviceVtl::Vtl2);
6896    }
6897
6898    #[test]
6899    fn test_scsi_controller_cli_vtl2() {
6900        let c = ScsiControllerCli::from_str("id=scsi0,vtl2").unwrap();
6901        assert_eq!(c.vtl, DeviceVtl::Vtl2);
6902    }
6903
6904    #[test]
6905    fn test_openhcl_controller_cli() {
6906        let c = OpenhclControllerCli::from_str("id=vtl0-scsi,type=scsi").unwrap();
6907        assert_eq!(c.id, "vtl0-scsi");
6908        assert_eq!(c.controller_type, OpenhclControllerType::Scsi);
6909        assert_eq!(c.guid, None);
6910    }
6911
6912    #[test]
6913    fn test_openhcl_controller_cli_nvme_with_guid() {
6914        let c = OpenhclControllerCli::from_str(
6915            "id=vtl0-nvme,type=nvme,guid=09a59b81-2bf6-4164-81d7-3a0dc977ba65",
6916        )
6917        .unwrap();
6918        assert_eq!(c.controller_type, OpenhclControllerType::Nvme);
6919        assert!(c.guid.is_some());
6920    }
6921
6922    #[test]
6923    fn test_openhcl_controller_cli_errors() {
6924        // Missing id.
6925        assert!(OpenhclControllerCli::from_str("type=scsi").is_err());
6926        // Missing type.
6927        assert!(OpenhclControllerCli::from_str("id=foo").is_err());
6928        // Invalid type.
6929        assert!(OpenhclControllerCli::from_str("id=foo,type=ide").is_err());
6930        // Invalid guid.
6931        assert!(OpenhclControllerCli::from_str("id=foo,type=scsi,guid=bad").is_err());
6932    }
6933
6934    #[test]
6935    fn test_parse_vp_list() {
6936        use vmm_cli::BracketRangeList;
6937
6938        let parse = |s: &str| {
6939            s.parse::<BracketRangeList>()
6940                .and_then(|v| v.expand_below(1024))
6941        };
6942
6943        // Individual indices.
6944        assert_eq!(parse("[0,1,2,3]").unwrap(), vec![0, 1, 2, 3]);
6945
6946        // Single index.
6947        assert_eq!(parse("[5]").unwrap(), vec![5]);
6948
6949        // Dash range.
6950        assert_eq!(parse("[0-3]").unwrap(), vec![0, 1, 2, 3]);
6951
6952        // Mixed indices and ranges.
6953        assert_eq!(parse("[0,1,4-6,10]").unwrap(), vec![0, 1, 4, 5, 6, 10]);
6954
6955        // Whitespace tolerance.
6956        assert_eq!(parse("[0, 1, 2-4]").unwrap(), vec![0, 1, 2, 3, 4]);
6957
6958        // Missing brackets.
6959        assert!(parse("0,1,2").is_err());
6960        assert!(parse("0-3").is_err());
6961
6962        // Inverted range.
6963        assert!(parse("[3-0]").is_err());
6964
6965        // Non-numeric.
6966        assert!(parse("[a,b]").is_err());
6967    }
6968
6969    #[test]
6970    fn test_parse_numa_node() {
6971        use super::parse_numa_node;
6972
6973        // Basic node with size only.
6974        let n = parse_numa_node("size=2G").unwrap();
6975        assert_eq!(
6976            n.memory.size,
6977            Some(vmm_cli::MemorySize(2 * 1024 * 1024 * 1024))
6978        );
6979        assert!(n.vps.is_none());
6980        assert!(n.host_numa_node.is_none());
6981
6982        // Node with bracket VP list.
6983        let n = parse_numa_node("size=1G,vps=[0,1,2,3]").unwrap();
6984        assert_eq!(n.vps.unwrap().expand_below(1024).unwrap(), [0, 1, 2, 3]);
6985
6986        // Node with VP range in brackets.
6987        let n = parse_numa_node("size=1G,vps=[0-3]").unwrap();
6988        assert_eq!(n.vps.unwrap().expand_below(1024).unwrap(), [0, 1, 2, 3]);
6989
6990        // Node with host_numa_node.
6991        let n = parse_numa_node("size=1G,host_numa_node=1").unwrap();
6992        assert_eq!(n.host_numa_node, Some(1));
6993
6994        // All options together.
6995        let n = parse_numa_node("size=1G,vps=[0,1],host_numa_node=0,hugepages=on").unwrap();
6996        assert_eq!(n.vps.unwrap().expand_below(1024).unwrap(), [0, 1]);
6997        assert_eq!(n.host_numa_node, Some(0));
6998        assert!(n.memory.hugepages);
6999
7000        // Missing size.
7001        assert!(parse_numa_node("vps=[0,1]").is_err());
7002
7003        // Bare vps without brackets.
7004        assert!(parse_numa_node("size=1G,vps=0,1").is_err());
7005
7006        // Duplicate vps.
7007        assert!(parse_numa_node("size=1G,vps=[0],vps=[1]").is_err());
7008
7009        // `file` is rejected for NUMA nodes.
7010        assert!(parse_numa_node("size=1G,file=/tmp/x").is_err());
7011
7012        // Empty vps=[] for memory-only node.
7013        let n = parse_numa_node("size=1G,vps=[]").unwrap();
7014        assert!(n.vps.unwrap().0.is_empty());
7015    }
7016
7017    #[test]
7018    fn test_parse_numa_distance() {
7019        use super::parse_numa_distance;
7020
7021        let d = parse_numa_distance("0:1:20").unwrap();
7022        assert_eq!(d.src, 0);
7023        assert_eq!(d.dst, 1);
7024        assert_eq!(d.distance, 20);
7025
7026        // Self-distance.
7027        let d = parse_numa_distance("0:0:10").unwrap();
7028        assert_eq!(d.distance, 10);
7029
7030        // Distance below minimum.
7031        assert!(parse_numa_distance("0:1:5").is_err());
7032
7033        // Wrong format.
7034        assert!(parse_numa_distance("0:1").is_err());
7035        assert!(parse_numa_distance("0:1:20:extra").is_err());
7036    }
7037
7038    #[cfg(guest_arch = "aarch64")]
7039    #[test]
7040    fn test_smmu_cli_from_str() {
7041        // Minimal: only rc=, sizing policies default to auto, accel off.
7042        let s = SmmuCli::from_str("rc=pcie0").unwrap();
7043        assert_eq!(s.rc_name, "pcie0");
7044        assert!(!s.accel);
7045        assert!(matches!(s.oas, SmmuOasCli::Auto));
7046        assert!(matches!(s.ssidsize, SmmuSsidSizeCli::Auto));
7047
7048        // accel flag.
7049        let s = SmmuCli::from_str("rc=pcie0,accel").unwrap();
7050        assert_eq!(s.rc_name, "pcie0");
7051        assert!(s.accel);
7052        assert!(matches!(s.oas, SmmuOasCli::Auto));
7053        assert!(matches!(s.ssidsize, SmmuSsidSizeCli::Auto));
7054
7055        // Explicit oas=auto.
7056        let s = SmmuCli::from_str("rc=pcie0,oas=auto").unwrap();
7057        assert!(matches!(s.oas, SmmuOasCli::Auto));
7058
7059        // Fixed oas.
7060        let s = SmmuCli::from_str("rc=pcie0,oas=52").unwrap();
7061        assert!(matches!(s.oas, SmmuOasCli::Fixed(52)));
7062
7063        // All keys/flags together, order independent.
7064        let s = SmmuCli::from_str("ssidsize=12,oas=48,accel,rc=pcie1").unwrap();
7065        assert_eq!(s.rc_name, "pcie1");
7066        assert!(s.accel);
7067        assert!(matches!(s.oas, SmmuOasCli::Fixed(48)));
7068        assert!(matches!(s.ssidsize, SmmuSsidSizeCli::Fixed(12)));
7069
7070        // Missing required rc=.
7071        assert!(SmmuCli::from_str("accel").is_err());
7072        assert!(SmmuCli::from_str("oas=52").is_err());
7073
7074        // Empty rc= value.
7075        assert!(SmmuCli::from_str("rc=").is_err());
7076
7077        // Duplicate rc= key.
7078        assert!(SmmuCli::from_str("rc=pcie0,rc=pcie1").is_err());
7079
7080        // Non-numeric oas value.
7081        assert!(SmmuCli::from_str("rc=pcie0,oas=big").is_err());
7082
7083        // Unknown key.
7084        assert!(SmmuCli::from_str("rc=pcie0,foo=bar").is_err());
7085
7086        // Unknown flag.
7087        assert!(SmmuCli::from_str("rc=pcie0,turbo").is_err());
7088    }
7089
7090    #[cfg(guest_arch = "aarch64")]
7091    #[test]
7092    fn test_smmu_cli_ssidsize() {
7093        for accel in ["", ",accel"] {
7094            let s = SmmuCli::from_str(&format!("rc=pcie0,ssidsize=auto{accel}")).unwrap();
7095            assert!(matches!(s.ssidsize, SmmuSsidSizeCli::Auto));
7096
7097            // Device configuration validates supported widths, not the parser.
7098            for bits in [0, 14, 20, 21, 255] {
7099                let s = SmmuCli::from_str(&format!("rc=pcie0,ssidsize={bits}{accel}")).unwrap();
7100                assert!(matches!(s.ssidsize, SmmuSsidSizeCli::Fixed(n) if n == bits));
7101            }
7102        }
7103
7104        for value in ["256", "-1", "1.5", "", "big", "AUTO"] {
7105            assert!(
7106                SmmuCli::from_str(&format!("rc=pcie0,ssidsize={value}")).is_err(),
7107                "accepted invalid ssidsize={value}"
7108            );
7109        }
7110        assert!(SmmuCli::from_str("ssidsize=12").is_err());
7111        assert!(SmmuCli::from_str("rc=pcie0,ssidsize=auto,ssidsize=12").is_err());
7112    }
7113}