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 anyhow::Context;
22use clap::Parser;
23use clap::ValueEnum;
24use cxl_spec::spec::CfmwsWindowRestrictions;
25use guid::Guid;
26use openvmm_defs::config::DEFAULT_PCAT_BOOT_ORDER;
27use openvmm_defs::config::DeviceVtl;
28use openvmm_defs::config::PcatBootDevice;
29use openvmm_defs::config::Vtl2BaseAddressType;
30use openvmm_defs::config::X2ApicConfig;
31use std::ffi::OsString;
32use std::net::SocketAddr;
33use std::path::PathBuf;
34use std::str::FromStr;
35use thiserror::Error;
36
37/// Parse CLI options, using a thread with a larger stack on Windows to avoid
38/// stack overflow in debug builds due to clap's deep stack usage.
39/// See <https://github.com/clap-rs/clap/issues/5134>.
40pub(crate) fn parse_options() -> Options {
41    // In non-optimized builds, clap uses an embarrassing amount of stack space
42    // to construct the `Command` instance for `Options`, more than the Windows
43    // default of 1MB. This has been known since 2023:
44    // <https://github.com/clap-rs/clap/issues/5134>, but no one has stepped up
45    // to fix it.
46    //
47    // Work around this by running the code on a thread with lots of stack
48    // space. This is easier and more reliable than configuring the PE binary to
49    // have a larger stack.
50    fn on_big_stack<R: Send>(f: impl Send + FnOnce() -> R) -> R {
51        if cfg!(windows) {
52            std::thread::scope(|s| {
53                std::thread::Builder::new()
54                    .stack_size(0x400000)
55                    .spawn_scoped(s, f)
56                    .unwrap()
57                    .join()
58                    .unwrap()
59            })
60        } else {
61            f()
62        }
63    }
64
65    on_big_stack(Options::parse)
66}
67
68const DEFAULT_MEMORY_SIZE: u64 = 1024 * 1024 * 1024;
69
70/// Guest memory configuration parsed from `--memory`.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct MemoryCli {
73    /// Guest RAM size in bytes.
74    pub mem_size: u64,
75    /// Whether shared file-backed memory was explicitly requested.
76    pub shared: Option<bool>,
77    /// Whether to prefetch guest RAM.
78    pub prefetch: bool,
79    /// Whether to use transparent huge pages for private guest RAM.
80    pub transparent_hugepages: bool,
81    /// Whether to use explicit hugetlb memfd backing for guest RAM.
82    pub hugepages: bool,
83    /// Explicit hugetlb page size in bytes.
84    pub hugepage_size: Option<u64>,
85    /// File used to back guest RAM.
86    pub file: Option<PathBuf>,
87}
88
89/// NUMA node configuration parsed from `--numa`.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct NumaNodeCli {
92    /// Memory configuration (size, shared, prefetch, hugepages, etc.)
93    pub memory: MemoryCli,
94    /// Host NUMA node to bind memory allocation to.
95    pub host_numa_node: Option<u32>,
96    /// Explicit VP indices for this node.
97    pub vps: Option<Vec<u32>>,
98}
99
100/// NUMA distance parsed from `--numa-distance`.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct NumaDistanceCli {
103    /// Source node index.
104    pub src: u32,
105    /// Destination node index.
106    pub dst: u32,
107    /// Distance value (10-255, 255 = unreachable).
108    pub distance: u8,
109}
110
111/// OpenVMM virtual machine monitor.
112///
113/// This is not yet a stable interface and may change radically between
114/// versions.
115#[derive(Parser)]
116pub struct Options {
117    /// processor count
118    #[clap(short = 'p', long, value_name = "COUNT", default_value = "1")]
119    pub processors: u32,
120
121    /// guest RAM configuration (`SIZE` or `key=value[,key=value...]`)
122    #[clap(
123        short = 'm',
124        long,
125        value_name = "PARAMS",
126        default_value = "1GB",
127        value_parser = parse_memory_config,
128        conflicts_with = "numa",
129        long_help = r#"Configure guest RAM.
130
131Syntax: SIZE | key=value[,key=value...]
132
133Size suffixes accept K, M, G, and T, optionally followed by B.
134
135Options:
136    size=<SIZE>              guest RAM size, default 1GB
137    shared=on|off            use shared file-backed RAM, default on
138    prefetch=on|off          pre-populate shared RAM mappings
139    thp=on|off               mark private RAM as THP-eligible; requires shared=off
140    hugepages=on|off         allocate RAM from Linux hugetlb pages
141    hugepage_size=<SIZE>     hugetlb page size, default 2MB; requires hugepages=on
142    file=<PATH>              use an existing file as guest RAM backing
143
144Examples:
145    --memory 4G
146    --memory size=64GB,hugepages=on,hugepage_size=2MB
147    --memory size=4G,file=path/to/memory.bin
148    --memory size=4G,shared=off,thp=on"#
149    )]
150    pub memory: MemoryCli,
151
152    /// NUMA node configuration (repeatable, one per node).
153    ///
154    /// Each --numa specifies one guest NUMA node. Mutually exclusive with
155    /// --memory.
156    #[clap(
157        long,
158        value_name = "PARAMS",
159        value_parser = parse_numa_node,
160        conflicts_with = "memory",
161        long_help = r#"Configure a guest NUMA node (repeatable, one per node).
162
163Syntax: key=value[,key=value...]
164
165Options:
166    size=<SIZE>              RAM for this node (required)
167    shared=on|off            use shared file-backed RAM, default on
168    prefetch=on|off          pre-populate shared RAM mappings
169    thp=on|off               mark private RAM as THP-eligible; requires shared=off
170    hugepages=on|off         allocate RAM from hugetlb pages
171    hugepage_size=<SIZE>     hugetlb page size; requires hugepages=on
172    host_numa_node=<N>       bind allocation to host NUMA node N
173    vps=<LIST>               explicit VP indices (e.g. "[0,1,2,3]")
174
175  VP lists use bracket syntax with comma-separated indices and dash
176  ranges: vps=[0,1] or vps=[0-3] or vps=[0,1,4-5].
177
178Examples:
179    --numa size=2G --numa size=2G
180    --numa size=2G,host_numa_node=0 --numa size=2G,host_numa_node=1
181    --numa size=2G,hugepages=on,vps=[0,1] --numa size=2G,vps=[2,3]
182    --numa size=2G,vps=[0-3] --numa size=2G,vps=[4-7]"#
183    )]
184    pub numa: Option<Vec<NumaNodeCli>>,
185
186    /// NUMA distance (repeatable). Format: SRC:DST:DISTANCE.
187    ///
188    /// SRC and DST are 0-based node indices. DISTANCE is 10-255 (10 = local, 255 = unreachable).
189    /// Specify each direction explicitly (not auto-symmetric).
190    #[clap(long, value_name = "SRC:DST:DIST", value_parser = parse_numa_distance, conflicts_with = "memory", requires = "numa")]
191    pub numa_distance: Option<Vec<NumaDistanceCli>>,
192
193    /// use shared memory segment
194    #[clap(short = 'M', long, hide = true)]
195    pub shared_memory: bool,
196
197    /// prefetch guest RAM
198    #[clap(long = "prefetch", hide = true, conflicts_with = "numa")]
199    pub deprecated_prefetch: bool,
200
201    /// back guest RAM with a file instead of anonymous memory.
202    /// The file is created/opened and sized to the guest RAM size.
203    /// Enables snapshot save (fsync) and restore (open + mmap).
204    #[clap(
205        long = "memory-backing-file",
206        value_name = "FILE",
207        hide = true,
208        conflicts_with_all = ["deprecated_private_memory", "numa"]
209    )]
210    pub deprecated_memory_backing_file: Option<PathBuf>,
211
212    /// Restore VM from a snapshot directory (implies file-backed memory from
213    /// the snapshot's memory.bin). Cannot be used with --memory-backing-file.
214    #[clap(
215        long,
216        value_name = "DIR",
217        conflicts_with_all = ["deprecated_memory_backing_file", "numa"]
218    )]
219    pub restore_snapshot: Option<PathBuf>,
220
221    /// use private anonymous memory for guest RAM
222    #[clap(long = "private-memory", hide = true, conflicts_with_all = ["deprecated_memory_backing_file", "restore_snapshot", "numa"])]
223    pub deprecated_private_memory: bool,
224
225    /// enable transparent huge pages for guest RAM (Linux only, requires --private-memory)
226    #[clap(long = "thp", hide = true, conflicts_with = "numa")]
227    pub deprecated_thp: bool,
228
229    /// start in paused state
230    #[clap(short = 'P', long)]
231    pub paused: bool,
232
233    /// kernel image (when using linux direct boot)
234    #[clap(short = 'k', long, value_name = "FILE", default_value = default_value_from_arch_env("OPENVMM_LINUX_DIRECT_KERNEL"))]
235    pub kernel: OptionalPathBuf,
236
237    /// initrd image (when using linux direct boot)
238    #[clap(short = 'r', long, value_name = "FILE", default_value = default_value_from_arch_env("OPENVMM_LINUX_DIRECT_INITRD"))]
239    pub initrd: OptionalPathBuf,
240
241    /// extra kernel command line args
242    #[clap(short = 'c', long, value_name = "STRING")]
243    pub cmdline: Vec<String>,
244
245    /// enable HV#1 capabilities
246    #[clap(long)]
247    pub hv: bool,
248
249    /// Use a full device tree instead of ACPI tables for ARM64 Linux direct
250    /// boot. By default, ARM64 uses ACPI mode (stub DT + EFI + ACPI tables).
251    /// This flag selects the legacy DT-only path. Rejected on x86.
252    #[clap(long, conflicts_with_all = ["uefi", "pcat", "igvm"])]
253    pub device_tree: bool,
254
255    /// enable vtl2 - only supported in WHP and simulated without hypervisor support currently
256    ///
257    /// Currently implies --get.
258    #[clap(long, requires("hv"))]
259    pub vtl2: bool,
260
261    /// Add GET and related devices for using the OpenHCL paravisor to the
262    /// highest enabled VTL.
263    #[clap(long, requires("hv"))]
264    pub get: bool,
265
266    /// Disable GET and related devices for using the OpenHCL paravisor, even
267    /// when --vtl2 is passed.
268    #[clap(long, conflicts_with("get"))]
269    pub no_get: bool,
270
271    /// Run without VMBus, even if --hv or --uefi are specified.
272    #[clap(
273        long,
274        conflicts_with_all = [
275            "vmbus_vsock_path",
276            "vmbus_vtl2_vsock_path",
277            "vmbus_redirect",
278            "vmbus_max_version",
279            "vmbus_com1_serial",
280            "vmbus_com2_serial",
281            "disk",
282            "vtl2",
283            "get",
284            "pcat",
285        ],
286    )]
287    pub no_vmbus: bool,
288
289    /// disable the VTL0 alias map presented to VTL2 by default
290    #[clap(long, requires("vtl2"))]
291    pub no_alias_map: bool,
292
293    /// enable isolation emulation
294    #[clap(long, requires("vtl2"))]
295    pub isolation: Option<IsolationCli>,
296
297    /// the hybrid vsock listener path
298    #[clap(long, value_name = "PATH", alias = "vsock-path")]
299    pub vmbus_vsock_path: Option<String>,
300
301    /// the VTL2 hybrid vsock listener path
302    #[clap(long, value_name = "PATH", requires("vtl2"), alias = "vtl2-vsock-path")]
303    pub vmbus_vtl2_vsock_path: Option<String>,
304
305    /// the late map vtl0 ram access policy when vtl2 is enabled
306    #[clap(long, requires("vtl2"), default_value = "halt")]
307    pub late_map_vtl0_policy: Vtl0LateMapPolicyCli,
308
309    /// attach a disk (can be passed multiple times)
310    #[clap(long_help = r#"
311e.g: --disk memdiff:file:/path/to/disk.vhd
312
313syntax: <path> | kind:<arg>[,flag,opt=arg,...]
314
315valid disk kinds:
316    `mem:<len>`                    memory backed disk
317        <len>: length of ramdisk, e.g.: `1G`
318    `memdiff:<disk>`               memory backed diff disk
319        <disk>: lower disk, e.g.: `file:base.img`
320    `file:<path>[;direct][;create=<len>]`   file-backed disk
321        <path>: path to file
322        `;direct`: bypass the OS page cache
323    `sql:<path>[;create=<len>]`    SQLite-backed disk (dev/test)
324    `sqldiff:<path>[;create]:<disk>` SQLite diff layer on a backing disk
325    `autocache:<key>:<disk>`       auto-cached SQLite layer (use `autocache::<disk>` to omit key; needs OPENVMM_AUTO_CACHE_PATH)
326    `blob:<type>:<url>`            HTTP blob (read-only)
327        <type>: `flat` or `vhd1`
328    `crypt:<cipher>:<key_file>:<disk>` encrypted disk wrapper
329        <cipher>: `xts-aes-256`
330    `prwrap:<disk>`                persistent reservations wrapper
331
332flags:
333    `ro`                           open disk as read-only
334    `dvd`                          specifies that device is cd/dvd and it is read_only
335    `vtl2`                         assign this disk to VTL2
336    `uh`                           relay this disk to VTL0 through SCSI-to-OpenHCL (show to VTL0 as SCSI)
337    `uh-nvme`                      relay this disk to VTL0 through NVMe-to-OpenHCL (show to VTL0 as SCSI)
338
339options:
340    `pcie_port=<name>`             present the disk using pcie under the specified port, incompatible with `dvd`, `vtl2`, `uh`, and `uh-nvme`
341    `on=<name>`                    attach to a named controller (NVMe or SCSI), incompatible with `pcie_port` and `vtl2`
342    `nsid=<N>`                     NVMe namespace ID (1-based), requires `on`; auto-assigned if omitted
343    `lun=<N>`                      SCSI LUN (0-based), requires `on`; auto-assigned if omitted
344    `relay=<ctrl>[:<loc>]`         relay through OpenHCL to the named OpenHCL controller, with optional location (LUN or NSID)
345"#)]
346    #[clap(long, value_name = "FILE")]
347    pub disk: Vec<DiskCli>,
348
349    /// \[deprecated\] attach a disk via an NVMe controller
350    ///
351    /// Use --nvme-pci and --disk on=\<name\> instead.
352    #[clap(long_help = r#"
353e.g: --nvme memdiff:file:/path/to/disk.vhd
354
355syntax: <path> | kind:<arg>[,flag,opt=arg,...]
356
357valid disk kinds:
358    `mem:<len>`                    memory backed disk
359        <len>: length of ramdisk, e.g.: `1G`
360    `memdiff:<disk>`               memory backed diff disk
361        <disk>: lower disk, e.g.: `file:base.img`
362    `file:<path>[;direct][;create=<len>]`   file-backed disk
363        <path>: path to file
364        `;direct`: bypass the OS page cache
365    `sql:<path>[;create=<len>]`    SQLite-backed disk (dev/test)
366    `sqldiff:<path>[;create]:<disk>` SQLite diff layer on a backing disk
367    `autocache:<key>:<disk>`       auto-cached SQLite layer (use `autocache::<disk>` to omit key; needs OPENVMM_AUTO_CACHE_PATH)
368    `blob:<type>:<url>`            HTTP blob (read-only)
369        <type>: `flat` or `vhd1`
370    `crypt:<cipher>:<key_file>:<disk>` encrypted disk wrapper
371        <cipher>: `xts-aes-256`
372    `prwrap:<disk>`                persistent reservations wrapper
373
374flags:
375    `ro`                           open disk as read-only
376    `vtl2`                         assign this disk to VTL2
377    `uh`                           relay this disk to VTL0 through SCSI-to-OpenHCL (show to VTL0 as NVMe)
378    `uh-nvme`                      relay this disk to VTL0 through NVMe-to-OpenHCL (show to VTL0 as NVMe)
379
380options:
381    `pcie_port=<name>`             present the disk using pcie under the specified port, incompatible with `vtl2`, `uh`, and `uh-nvme`
382"#)]
383    #[clap(long)]
384    pub nvme: Vec<DiskCli>,
385
386    /// create a named NVMe controller
387    #[clap(long_help = r#"
388Create a named NVMe controller with an explicit transport.
389
390syntax: id=<name>,pcie_port=<port> | id=<name>,vpci[=<guid>]
391
392The controller name can be referenced by `--disk` with the `on=<name>`
393option to attach namespaces to this controller.
394
395options:
396    `id=<name>`                    controller name (required)
397    `pcie_port=<port>`             present on PCIe under the specified port
398    `vpci[=<guid>]`                present via VPCI; optional instance GUID
399    `vtl2`                         assign to VTL2 (default VTL0)
400
401Exactly one of `pcie_port` or `vpci` must be specified.
402
403Examples:
404    --nvme-pci id=nvme0,pcie_port=p0
405    --nvme-pci id=nvme1,vpci
406    --nvme-pci id=nvme2,vpci=008091f6-9688-497d-9091-af347dc9173c
407"#)]
408    #[clap(long = "nvme-pci")]
409    pub nvme_pci: Vec<NvmeControllerCli>,
410
411    /// create a named VMBus SCSI controller
412    #[clap(long_help = r#"
413Create a named VMBus SCSI controller.
414
415syntax: id=<name>[,sub_channels=<N>][,vtl2]
416
417The controller name can be referenced by `--disk` with the `on=<name>`
418option to attach disks to this controller.
419
420options:
421    `id=<name>`                    controller name (required)
422    `sub_channels=<N>`             number of sub-channels (default 0)
423    `vtl2`                         assign to VTL2 (default VTL0)
424
425Examples:
426    --vmbus-scsi id=scsi0
427    --vmbus-scsi id=scsi1,sub_channels=4
428"#)]
429    #[clap(long = "vmbus-scsi")]
430    pub vmbus_scsi: Vec<ScsiControllerCli>,
431
432    /// register an OpenHCL-managed storage controller (relay target)
433    #[clap(long_help = r#"
434Register an OpenHCL-managed storage controller that can be used as a
435relay target with `--disk ... relay=<name>`.
436
437syntax: id=<name>,type=scsi|nvme[,guid=<guid>]
438
439options:
440    `id=<name>`                    controller name (required)
441    `type=scsi|nvme`               controller protocol (required)
442    `guid=<guid>`                  instance GUID (auto-derived from name if omitted)
443
444Examples:
445    --openhcl-controller id=vtl0-scsi,type=scsi
446    --openhcl-controller id=vtl0-nvme,type=nvme,guid=09a59b81-...
447"#)]
448    #[clap(long = "openhcl-controller")]
449    pub openhcl_controller: Vec<OpenhclControllerCli>,
450
451    /// attach a CXL Type-3 test endpoint on a PCIe root port
452    #[clap(long = "cxl-test", value_name = "mem:<len>,pcie_port=<name>")]
453    pub cxl_test: Vec<CxlTestDeviceCli>,
454
455    /// attach a disk via a virtio-blk controller
456    #[clap(long_help = r#"
457e.g: --virtio-blk memdiff:file:/path/to/disk.vhd
458
459syntax: <path> | kind:<arg>[,flag,opt=arg,...]
460
461valid disk kinds:
462    `mem:<len>`                    memory backed disk
463        <len>: length of ramdisk, e.g.: `1G`
464    `memdiff:<disk>`               memory backed diff disk
465        <disk>: lower disk, e.g.: `file:base.img`
466    `file:<path>[;direct]`                  file-backed disk
467        <path>: path to file
468        `;direct`: bypass the OS page cache
469
470flags:
471    `ro`                           open disk as read-only
472
473options:
474    `pcie_port=<name>`             present the disk using pcie under the specified port
475"#)]
476    #[clap(long = "virtio-blk")]
477    pub virtio_blk: Vec<DiskCli>,
478
479    /// Attach a vhost-user device via a Unix socket.
480    ///
481    /// The first positional argument is the socket path. Options:
482    ///
483    /// ```text
484    ///   type=blk|fs                        — device type (shorthand)
485    ///   device_id=N                        — numeric virtio device ID
486    ///   tag=NAME                           — mount tag (required for type=fs)
487    ///   num_queues=N                       — queue count (type=blk/fs only)
488    ///   queue_size=N                       — per-queue size (type=blk/fs only)
489    ///   queue_sizes=[N,N,N]                — per-queue sizes (device_id= only)
490    ///   pcie_port=NAME                     — present on PCIe under the specified port
491    /// ```
492    ///
493    /// Examples:
494    ///
495    /// ```text
496    ///   --vhost-user /tmp/vhost.sock,type=blk
497    ///   --vhost-user /tmp/vhost.sock,type=blk,num_queues=4,queue_size=512
498    ///   --vhost-user /tmp/vhost.sock,device_id=2,queue_sizes=[128,128]
499    ///   --vhost-user /tmp/vhost.sock,type=blk,pcie_port=port0
500    ///   --vhost-user /tmp/virtiofsd.sock,type=fs,tag=myfs
501    ///   --vhost-user /tmp/virtiofsd.sock,type=fs,tag=myfs,num_queues=2,queue_size=1024
502    /// ```
503    #[cfg(target_os = "linux")]
504    #[clap(long = "vhost-user")]
505    pub vhost_user: Vec<VhostUserCli>,
506
507    /// number of sub-channels for the SCSI controller
508    #[clap(long, value_name = "COUNT", default_value = "0")]
509    pub scsi_sub_channels: u16,
510
511    /// expose a virtual NIC
512    #[clap(long)]
513    pub nic: bool,
514
515    /// expose a virtual NIC with the given backend (consomme | dio | tap | none)
516    ///
517    /// Prefix with `uh:` to add this NIC via Mana emulation through OpenHCL,
518    /// `vtl2:` to assign this NIC to VTL2, or `pcie_port=<port_name>:` to
519    /// expose the NIC over emulated PCIe at the specified port.
520    ///
521    /// For consomme, forward host ports into the guest with `hostfwd=`:
522    ///   --net consomme:hostfwd=tcp::3389-:3389
523    ///   --net consomme:hostfwd=tcp:127.0.0.1:8080-:80
524    ///   --net consomme:hostfwd=tcp:\[::1\]:8080-:80
525    ///   --net consomme:10.0.0.0/24,hostfwd=tcp::22-:22,hostfwd=udp::5000-:5000
526    #[clap(long)]
527    pub net: Vec<NicConfigCli>,
528
529    /// expose a virtual NIC using the Windows kernel-mode vmswitch.
530    ///
531    /// Specify the switch ID or "default" for the default switch.
532    #[clap(long, value_name = "SWITCH_ID")]
533    pub kernel_vmnic: Vec<String>,
534
535    /// expose a graphics device
536    #[clap(long)]
537    pub gfx: bool,
538
539    /// support a graphics device in vtl2
540    #[clap(long, requires("vtl2"), conflicts_with("gfx"))]
541    pub vtl2_gfx: bool,
542
543    /// VNC server configuration (listen address, port, client limit, etc.).
544    #[clap(flatten)]
545    pub vnc: VncCli,
546
547    /// set the APIC ID offset, for testing APIC IDs that don't match VP index
548    #[cfg(guest_arch = "x86_64")]
549    #[clap(long, default_value_t)]
550    pub apic_id_offset: u32,
551
552    /// the maximum number of VPs per socket
553    #[clap(long)]
554    pub vps_per_socket: Option<u32>,
555
556    /// enable or disable SMT (hyperthreading) (auto | force | off)
557    #[clap(long, default_value = "auto")]
558    pub smt: SmtConfigCli,
559
560    /// configure x2apic (auto | supported | off | on)
561    #[cfg(guest_arch = "x86_64")]
562    #[clap(long, default_value = "auto", value_parser = parse_x2apic)]
563    pub x2apic: X2ApicConfig,
564
565    /// configure PCIe MSI controller for aarch64 (auto | its | v2m)
566    #[cfg(guest_arch = "aarch64")]
567    #[clap(long, default_value = "auto")]
568    pub gic_msi: GicMsiCli,
569
570    /// enable SMMUv3 IOMMU for an aarch64 PCIe root complex (repeatable, e.g. --smmu rc0 --smmu rc1)
571    #[cfg(guest_arch = "aarch64")]
572    #[clap(long, value_name = "RC_NAME")]
573    pub smmu: Vec<String>,
574
575    /// COM1 binding (console | stderr | listen=\<path\> | file=\<path\> (overwrites) | listen=tcp:\<ip\>:\<port\> | term[=\<program\>]\[,name=\<windowtitle\>\] | none)
576    #[clap(long, value_name = "SERIAL")]
577    pub com1: Option<SerialConfigCli>,
578
579    /// COM2 binding (console | stderr | listen=\<path\> | file=\<path\> (overwrites) | listen=tcp:\<ip\>:\<port\> | term[=\<program\>]\[,name=\<windowtitle\>\] | none)
580    #[clap(long, value_name = "SERIAL")]
581    pub com2: Option<SerialConfigCli>,
582
583    /// COM3 binding (console | stderr | listen=\<path\> | file=\<path\> (overwrites) | listen=tcp:\<ip\>:\<port\> | term[=\<program\>]\[,name=\<windowtitle\>\] | none)
584    #[clap(long, value_name = "SERIAL")]
585    pub com3: Option<SerialConfigCli>,
586
587    /// COM4 binding (console | stderr | listen=\<path\> | file=\<path\> (overwrites) | listen=tcp:\<ip\>:\<port\> | term[=\<program\>]\[,name=\<windowtitle\>\] | none)
588    #[clap(long, value_name = "SERIAL")]
589    pub com4: Option<SerialConfigCli>,
590
591    /// vmbus com1 serial binding (console | stderr | listen=\<path\> | file=\<path\> (overwrites) | listen=tcp:\<ip\>:\<port\> | term[=\<program\>]\[,name=\<windowtitle\>\] | none)
592    #[structopt(long, value_name = "SERIAL")]
593    pub vmbus_com1_serial: Option<SerialConfigCli>,
594
595    /// vmbus com2 serial binding (console | stderr | listen=\<path\> | file=\<path\> (overwrites) | listen=tcp:\<ip\>:\<port\> | term[=\<program\>]\[,name=\<windowtitle\>\] | none)
596    #[structopt(long, value_name = "SERIAL")]
597    pub vmbus_com2_serial: Option<SerialConfigCli>,
598
599    /// Only allow guest to host serial traffic
600    #[clap(long)]
601    pub serial_tx_only: bool,
602
603    /// 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\>\] | none))
604    #[clap(long, value_name = "SERIAL")]
605    pub debugcon: Option<DebugconSerialConfigCli>,
606
607    /// boot UEFI firmware
608    #[clap(long, short = 'e')]
609    pub uefi: bool,
610
611    /// UEFI firmware file
612    #[clap(long, requires("uefi"), conflicts_with("igvm"), value_name = "FILE", default_value = default_value_from_arch_env("OPENVMM_UEFI_FIRMWARE"))]
613    pub uefi_firmware: OptionalPathBuf,
614
615    /// enable UEFI debugging on COM1
616    #[clap(long, requires("uefi"))]
617    pub uefi_debug: bool,
618
619    /// enable memory protections in UEFI
620    #[clap(long, requires("uefi"))]
621    pub uefi_enable_memory_protections: bool,
622
623    /// set PCAT boot order as comma-separated string of boot device types
624    /// (e.g: floppy,hdd,optical,net).
625    ///
626    /// If less than 4 entries are added, entries are added according to their
627    /// default boot order (optical,hdd,net,floppy)
628    ///
629    /// e.g: passing "floppy,optical" will result in a boot order equivalent to
630    /// "floppy,optical,hdd,net".
631    ///
632    /// Passing duplicate types is an error.
633    #[clap(long, requires("pcat"))]
634    pub pcat_boot_order: Option<PcatBootOrderCli>,
635
636    /// Boot with PCAT BIOS firmware and piix4 devices
637    #[clap(long, conflicts_with("uefi"))]
638    pub pcat: bool,
639
640    /// PCAT firmware file
641    #[clap(long, requires("pcat"), value_name = "FILE")]
642    pub pcat_firmware: Option<PathBuf>,
643
644    /// boot IGVM file
645    #[clap(long, conflicts_with("kernel"), value_name = "FILE")]
646    pub igvm: Option<PathBuf>,
647
648    /// specify igvm vtl2 relocation type
649    /// (absolute=\<addr\>, disable, auto=\<filesize,or memory size\>, vtl2=\<filesize,or memory size\>,)
650    #[clap(long, requires("igvm"), default_value = "auto=filesize", value_parser = parse_vtl2_relocation)]
651    pub igvm_vtl2_relocation_type: Vtl2BaseAddressType,
652
653    /// add a virtio_9p device (e.g. myfs,C:\)
654    ///
655    /// Prefix with `pcie_port=<port_name>:` to expose the device over
656    /// emulated PCIe at the specified port.
657    #[clap(long, value_name = "[pcie_port=PORT:]tag,root_path")]
658    pub virtio_9p: Vec<FsArgs>,
659
660    /// output debug info from the 9p server
661    #[clap(long)]
662    pub virtio_9p_debug: bool,
663
664    /// add a virtio_fs device (e.g. myfs,C:\,uid=1000,gid=2000)
665    ///
666    /// Prefix with `pcie_port=<port_name>:` to expose the device over
667    /// emulated PCIe at the specified port.
668    #[clap(long, value_name = "[pcie_port=PORT:]tag,root_path,[options]")]
669    pub virtio_fs: Vec<FsArgsWithOptions>,
670
671    /// add a virtio_fs device for sharing memory (e.g. myfs,\SectionDirectoryPath)
672    ///
673    /// Prefix with `pcie_port=<port_name>:` to expose the device over
674    /// emulated PCIe at the specified port.
675    #[clap(long, value_name = "[pcie_port=PORT:]tag,root_path")]
676    pub virtio_fs_shmem: Vec<FsArgs>,
677
678    /// add a virtio_fs device under either the PCI or MMIO bus, or whatever the hypervisor supports (pci | mmio | auto)
679    #[clap(long, value_name = "BUS", default_value = "auto")]
680    pub virtio_fs_bus: VirtioBusCli,
681
682    /// virtio PMEM device
683    ///
684    /// Prefix with `pcie_port=<port_name>:` to expose the device over
685    /// emulated PCIe at the specified port.
686    #[clap(long, value_name = "[pcie_port=PORT:]PATH")]
687    pub virtio_pmem: Option<VirtioPmemArgs>,
688
689    /// add a virtio entropy (RNG) device
690    #[clap(long)]
691    pub virtio_rng: bool,
692
693    /// add a virtio-rng device under either the PCI or MMIO bus, or whatever the hypervisor supports (pci | mmio | vpci | auto)
694    #[clap(long, value_name = "BUS", default_value = "auto")]
695    pub virtio_rng_bus: VirtioBusCli,
696
697    /// attach the virtio-rng device to the specified PCIe port (overrides --virtio-rng-bus)
698    #[clap(long, value_name = "PORT", requires("virtio_rng"))]
699    pub virtio_rng_pcie_port: Option<String>,
700
701    /// virtio console device backed by a serial backend (/dev/hvc0 in guest)
702    ///
703    /// Accepts serial config (console | stderr | listen=\<path\> |
704    /// file=\<path\> (overwrites) | listen=tcp:\<ip\>:\<port\> |
705    /// term[=\<program\>]\[,name=\<windowtitle\>\] | none)
706    #[clap(long)]
707    pub virtio_console: Option<SerialConfigCli>,
708
709    /// attach the virtio-console device to the specified PCIe port
710    #[clap(long, value_name = "PORT", requires("virtio_console"))]
711    pub virtio_console_pcie_port: Option<String>,
712
713    /// add a virtio vsock device with the given Unix socket base path
714    #[clap(long, value_name = "PATH")]
715    pub virtio_vsock_path: Option<String>,
716
717    /// expose a virtio network with the given backend (dio | vmnic | tap |
718    /// none)
719    ///
720    /// Prefix with `uh:` to add this NIC via Mana emulation through OpenHCL,
721    /// `vtl2:` to assign this NIC to VTL2, or `pcie_port=<port_name>:` to
722    /// expose the NIC over emulated PCIe at the specified port.
723    #[clap(long)]
724    pub virtio_net: Vec<NicConfigCli>,
725
726    /// send log output from the worker process to a file instead of stderr. the file will be overwritten.
727    #[clap(long, value_name = "PATH")]
728    pub log_file: Option<PathBuf>,
729
730    /// write the process ID to the specified file on startup, and remove it on
731    /// exit. the file is not removed if the process is killed with SIGKILL or
732    /// crashes. no file locking is performed.
733    #[clap(long, value_name = "PATH")]
734    pub pidfile: Option<PathBuf>,
735
736    /// run as a ttrpc server on the specified Unix socket
737    #[clap(long, value_name = "SOCKETPATH")]
738    pub ttrpc: Option<PathBuf>,
739
740    /// run as a grpc server on the specified Unix socket
741    #[clap(long, value_name = "SOCKETPATH", conflicts_with("ttrpc"))]
742    pub grpc: Option<PathBuf>,
743
744    /// do not launch child processes
745    #[clap(long)]
746    pub single_process: bool,
747
748    /// device to assign (can be passed multiple times)
749    #[cfg(windows)]
750    #[clap(long, value_name = "PATH")]
751    pub device: Vec<String>,
752
753    /// instead of showing the frontpage the VM will shutdown instead
754    #[clap(long, requires("uefi"))]
755    pub disable_frontpage: bool,
756
757    /// add a vtpm device
758    #[clap(long)]
759    pub tpm: bool,
760
761    /// the mesh worker host name.
762    ///
763    /// Used internally for debugging and diagnostics.
764    #[clap(long, default_value = "control", hide(true))]
765    #[expect(clippy::option_option)]
766    pub internal_worker: Option<Option<String>>,
767
768    /// redirect the VTL 0 vmbus control plane to a proxy in VTL 2.
769    #[clap(long, requires("vtl2"))]
770    pub vmbus_redirect: bool,
771
772    /// limit the maximum protocol version allowed by vmbus; used for testing purposes
773    #[clap(long, value_parser = vmbus_core::parse_vmbus_version)]
774    pub vmbus_max_version: Option<u32>,
775
776    /// The disk to use for the VMGS.
777    ///
778    /// If this is not provided, guest state will be stored in memory.
779    #[clap(long_help = r#"
780e.g: --vmgs memdiff:file:/path/to/file.vmgs
781
782syntax: <path> | kind:<arg>[,flag]
783
784valid disk kinds:
785    `mem:<len>`                     memory backed disk
786        <len>: length of ramdisk, e.g.: `1G` or `VMGS_DEFAULT`
787    `memdiff:<disk>[;create=<len>]` memory backed diff disk
788        <disk>: lower disk, e.g.: `file:base.img`
789    `file:<path>`                   file-backed disk
790        <path>: path to file
791
792flags:
793    `fmt`                           reprovision the VMGS before boot
794    `fmt-on-fail`                   reprovision the VMGS before boot if it is corrupted
795"#)]
796    #[clap(long)]
797    pub vmgs: Option<VmgsCli>,
798
799    /// Use GspById guest state encryption policy with a test seed
800    #[clap(long, requires("vmgs"))]
801    pub test_gsp_by_id: bool,
802
803    /// VGA firmware file
804    #[clap(long, requires("pcat"), value_name = "FILE")]
805    pub vga_firmware: Option<PathBuf>,
806
807    /// enable secure boot
808    #[clap(long)]
809    pub secure_boot: bool,
810
811    /// use secure boot template
812    #[clap(long)]
813    pub secure_boot_template: Option<SecureBootTemplateCli>,
814
815    /// custom uefi nvram json file
816    #[clap(long, value_name = "PATH")]
817    pub custom_uefi_json: Option<PathBuf>,
818
819    /// the path to a named pipe (Windows) or Unix socket (Linux) to relay to the connected
820    /// tty.
821    ///
822    /// This is a hidden argument used internally.
823    #[clap(long, hide(true))]
824    pub relay_console_path: Option<PathBuf>,
825
826    /// the title of the console window spawned from the relay console.
827    ///
828    /// This is a hidden argument used internally.
829    #[clap(long, hide(true))]
830    pub relay_console_title: Option<String>,
831
832    /// enable in-hypervisor gdb debugger
833    #[clap(long, value_name = "PORT")]
834    pub gdb: Option<u16>,
835
836    /// enable emulated MANA devices with the given network backend (see --net)
837    ///
838    /// Prefix with `pcie_port=<port_name>:` to expose the nic over emulated PCIe
839    /// at the specified port.
840    #[clap(long)]
841    pub mana: Vec<NicConfigCli>,
842
843    /// use a specific hypervisor interface, with optional backend-specific
844    /// parameters.
845    ///
846    /// Format: `name` or `name:key=val,key,...`
847    ///
848    /// WHP parameters (x86_64 guests only):
849    ///   user_mode_apic       - use user-mode APIC emulator
850    ///   no_enlightenments    - disable in-hypervisor enlightenments
851    ///   nested_virt          - expose VMX/SVM to the guest so it can run
852    ///                          its own hypervisor (requires
853    ///                          user_mode_apic=false and host WHP
854    ///                          support)
855    ///
856    /// KVM parameters (x86_64 guests only):
857    ///   nested_virt          - expose VMX/SVM to the guest so it can run
858    ///                          its own hypervisor (requires host KVM
859    ///                          nested-virt support)
860    ///
861    /// Examples:
862    ///   --hypervisor whp
863    ///   --hypervisor whp:user_mode_apic
864    ///   --hypervisor whp:user_mode_apic,no_enlightenments
865    ///   --hypervisor whp:nested_virt
866    ///   --hypervisor kvm
867    ///   --hypervisor kvm:nested_virt
868    #[clap(long)]
869    pub hypervisor: Option<String>,
870
871    /// (dev utility) boot linux using a custom (raw) DSDT table.
872    ///
873    /// This is a _very_ niche utility, and it's unlikely you'll need to use it.
874    ///
875    /// e.g: this flag helped bring up certain Hyper-V Generation 1 legacy
876    /// devices without needing to port the associated ACPI code into OpenVMM's
877    /// DSDT builder.
878    #[clap(long, value_name = "FILE", conflicts_with_all(&["uefi", "pcat", "igvm"]))]
879    pub custom_dsdt: Option<PathBuf>,
880
881    /// attach an ide drive (can be passed multiple times)
882    ///
883    /// Each ide controller has two channels. Each channel can have up to two
884    /// attachments.
885    ///
886    /// If the `s` flag is not passed then the drive will we be attached to the
887    /// primary ide channel if space is available. If two attachments have already
888    /// been added to the primary channel then the drive will be attached to the
889    /// secondary channel.
890    #[clap(long_help = r#"
891e.g: --ide memdiff:file:/path/to/disk.vhd
892
893syntax: <path> | kind:<arg>[,flag,opt=arg,...]
894
895valid disk kinds:
896    `mem:<len>`                    memory backed disk
897        <len>: length of ramdisk, e.g.: `1G`
898    `memdiff:<disk>`               memory backed diff disk
899        <disk>: lower disk, e.g.: `file:base.img`
900    `file:<path>[;create=<len>]`   file-backed disk
901        <path>: path to file
902    `sql:<path>[;create=<len>]`    SQLite-backed disk (dev/test)
903    `sqldiff:<path>[;create]:<disk>` SQLite diff layer on a backing disk
904    `blob:<type>:<url>`            HTTP blob (read-only)
905        <type>: `flat` or `vhd1`
906    `crypt:<cipher>:<key_file>:<disk>` encrypted disk wrapper
907        <cipher>: `xts-aes-256`
908
909additional wrapper kinds (e.g., `autocache`, `prwrap`) are also supported;
910this list is not exhaustive.
911
912flags:
913    `ro`                           open disk as read-only
914    `s`                            attach drive to secondary ide channel
915    `dvd`                          specifies that device is cd/dvd and it is read_only
916"#)]
917    #[clap(long, value_name = "FILE", requires("pcat"))]
918    pub ide: Vec<IdeDiskCli>,
919
920    /// attach a floppy drive (should be able to be passed multiple times). VM must be generation 1 (no UEFI)
921    ///
922    #[clap(long_help = r#"
923e.g: --floppy memdiff:file:/path/to/disk.vfd,ro
924
925syntax: <path> | kind:<arg>[,flag,opt=arg,...]
926
927valid disk kinds:
928    `mem:<len>`                    memory backed disk
929        <len>: length of ramdisk, e.g.: `1G`
930    `memdiff:<disk>`               memory backed diff disk
931        <disk>: lower disk, e.g.: `file:base.img`
932    `file:<path>[;create=<len>]`   file-backed disk
933        <path>: path to file
934    `sql:<path>[;create=<len>]`    SQLite-backed disk (dev/test)
935    `sqldiff:<path>[;create]:<disk>` SQLite diff layer on a backing disk
936    `blob:<type>:<url>`            HTTP blob (read-only)
937        <type>: `flat` or `vhd1`
938    `crypt:<cipher>:<key_file>:<disk>` encrypted disk wrapper
939        <cipher>: `xts-aes-256`
940
941flags:
942    `ro`                           open disk as read-only
943"#)]
944    #[clap(long, value_name = "FILE", requires("pcat"))]
945    pub floppy: Vec<FloppyDiskCli>,
946
947    /// enable guest watchdog device
948    #[clap(long)]
949    pub guest_watchdog: bool,
950
951    /// enable OpenHCL's guest crash dump device, targeting the specified path
952    #[clap(long)]
953    pub openhcl_dump_path: Option<PathBuf>,
954
955    /// halt the VM when the guest requests a reset, instead of resetting it
956    #[clap(long)]
957    pub halt_on_reset: bool,
958
959    /// write saved state .proto files to the specified path
960    #[clap(long)]
961    pub write_saved_state_proto: Option<PathBuf>,
962
963    /// specify the IMC hive file for booting Windows
964    #[clap(long)]
965    pub imc: Option<PathBuf>,
966
967    /// expose a battery device
968    #[clap(long)]
969    pub battery: bool,
970
971    /// set the uefi console mode
972    #[clap(long)]
973    pub uefi_console_mode: Option<UefiConsoleModeCli>,
974
975    /// set the EFI diagnostics log level
976    #[clap(long_help = r#"
977Set the EFI diagnostics log level.
978
979options:
980    default                        default (ERROR and WARN only)
981    info                           info (ERROR, WARN, and INFO)
982    full                           full (all log levels)
983"#)]
984    #[clap(long, requires("uefi"))]
985    pub efi_diagnostics_log_level: Option<EfiDiagnosticsLogLevelCli>,
986
987    /// Perform a default boot even if boot entries exist and fail
988    #[clap(long)]
989    pub default_boot_always_attempt: bool,
990
991    /// Enable AMD IOMMU (AMD-Vi) emulation on specified root complexes.
992    /// Repeat for each root complex that should have an IOMMU, e.g.:
993    ///   --amd-iommu rc0 --amd-iommu rc1
994    /// The IOMMU appears at device 0 function 0 on each specified root
995    /// complex. Requires --pcie-root-complex.
996    #[cfg(guest_arch = "x86_64")]
997    #[clap(long)]
998    pub amd_iommu: Vec<String>,
999
1000    /// Attach a PCI Express root complex to the VM
1001    #[clap(long_help = r#"
1002Attach root complexes to the VM.
1003
1004Examples:
1005    # Attach root complex rc0 on segment 0 with bus and MMIO ranges
1006    --pcie-root-complex rc0,segment=0,start_bus=0,end_bus=255,low_mmio=4M,high_mmio=1G
1007
1008    # Configure HDM window size and restrictions (bitmask)
1009    --pcie-root-complex rc1,hdm=2G,hdm_window_restrictions=0x21
1010
1011Syntax: <name>[,opt=arg,...]
1012
1013Options:
1014    `segment=<value>`              configures the PCI Express segment, default 0
1015    `start_bus=<value>`            lowest valid bus number, default 0
1016    `end_bus=<value>`              highest valid bus number, default 255
1017    `low_mmio=<size>`              low MMIO window size, default 64M
1018    `high_mmio=<size>`             high MMIO window size, default 1G
1019    `hdm=<size>`                   HDM decoder MMIO window size (CFMWS window), default 1G
1020    `hdm_window_restrictions=<m>`  CFMWS window restriction bitmask (u16, decimal or 0x-prefixed hex),
1021                                   default DEVICE_COHERENT (bit 0, value 0x1)
1022"#)]
1023    #[clap(long, conflicts_with("pcat"))]
1024    pub pcie_root_complex: Vec<PcieRootComplexCli>,
1025
1026    /// Attach a PCI Express root port to the VM
1027    #[clap(long_help = r#"
1028Attach root ports to root complexes.
1029
1030Examples:
1031    # Attach root port rc0rp0 to root complex rc0
1032    --pcie-root-port rc0:rc0rp0
1033
1034    # Attach root port rc0rp1 to root complex rc0 with hotplug support
1035    --pcie-root-port rc0:rc0rp1,hotplug
1036
1037Syntax: <root_complex_name>:<name>[,opt,opt=arg,...]
1038
1039Options:
1040    `hotplug`                      enable hotplug support for this root port
1041    `acs=<mask>`                   ACS capability bitmask (u16, decimal or 0x-prefixed hex)
1042    `cxl`                          configure this root port as CXL-capable
1043"#)]
1044    #[clap(long, conflicts_with("pcat"))]
1045    pub pcie_root_port: Vec<PcieRootPortCli>,
1046
1047    /// Attach a PCI Express switch to the VM
1048    #[clap(long_help = r#"
1049Attach switches to root ports or downstream switch ports to create PCIe hierarchies.
1050
1051Examples:
1052    # Connect switch0 (with 4 downstream switch ports) directly to root port rp0
1053    --pcie-switch rp0:switch0,num_downstream_ports=4
1054
1055    # Connect switch1 (with 2 downstream switch ports) to downstream port 0 of switch0
1056    --pcie-switch switch0-downstream-0:switch1,num_downstream_ports=2
1057
1058    # Create a 3-level hierarchy: rp0 -> switch0 -> switch1 -> switch2
1059    --pcie-switch rp0:switch0
1060    --pcie-switch switch0-downstream-0:switch1
1061    --pcie-switch switch1-downstream-1:switch2
1062
1063    # Enable hotplug on all downstream switch ports of switch0
1064    --pcie-switch rp0:switch0,hotplug
1065
1066Syntax: <port_name>:<name>[,opt,opt=arg,...]
1067
1068    port_name can be:
1069        - Root port name (e.g., "rp0") to connect directly to a root port
1070        - Downstream port name (e.g., "switch0-downstream-1") to connect to another switch
1071
1072Options:
1073    `hotplug`                       enable hotplug support for all downstream switch ports
1074    `num_downstream_ports=<value>`  number of downstream ports, default 4
1075    `acs=<mask>`                    ACS capability bitmask for downstream switch ports
1076"#)]
1077    #[clap(long, conflicts_with("pcat"))]
1078    pub pcie_switch: Vec<GenericPcieSwitchCli>,
1079
1080    /// Attach a PCIe remote device to a downstream port
1081    #[clap(long_help = r#"
1082Attach PCIe devices to root ports or downstream switch ports
1083which are implemented in a simulator running in a remote process.
1084
1085Examples:
1086    # Attach to root port rc0rp0 with default socket
1087    --pcie-remote rc0rp0
1088
1089    # Attach with custom socket address
1090    --pcie-remote rc0rp0,socket=0.0.0.0:48914
1091
1092    # Specify HU and controller identifiers
1093    --pcie-remote rc0rp0,hu=1,controller=0
1094
1095    # Multiple devices on different ports
1096    --pcie-remote rc0rp0,socket=0.0.0.0:48914
1097    --pcie-remote rc0rp1,socket=0.0.0.0:48915
1098
1099Syntax: <port_name>[,opt=arg,...]
1100
1101Options:
1102    `socket=<address>`              TCP socket (default: localhost:48914)
1103    `hu=<value>`                    Hardware unit identifier (default: 0)
1104    `controller=<value>`            Controller identifier (default: 0)
1105"#)]
1106    #[clap(long, conflicts_with("pcat"))]
1107    pub pcie_remote: Vec<PcieRemoteCli>,
1108
1109    /// Assign a host PCI device to the guest via VFIO (Linux only)
1110    #[clap(long_help = r#"
1111Assign a host PCI device to the guest via Linux VFIO.
1112
1113The device must be bound to vfio-pci on the host before starting the VM.
1114
1115Examples:
1116    --vfio host=0000:01:00.0,port=rp0
1117    --vfio host=0000:01:00.0,port=rp0,iommu=iommu0
1118
1119Keys:
1120    host=<pci_bdf>    (required) PCI address on the host
1121    port=<name>       (required) Root port or downstream switch port name
1122    iommu=<id>        (optional) Reference to an --iommu object. When present,
1123                      uses VFIO cdev + iommufd instead of the legacy group path.
1124"#)]
1125    #[cfg(target_os = "linux")]
1126    #[clap(long, conflicts_with("pcat"))]
1127    pub vfio: Vec<VfioDeviceCli>,
1128
1129    /// Create an iommufd context for VFIO cdev device assignment
1130    #[clap(long_help = r#"
1131Declare an iommufd context. Opens /dev/iommu so it can be referenced by
1132--vfio devices via the iommu=<id> key. The associated IOAS is allocated
1133the first time a --vfio device referring to this id is opened.
1134
1135Requires Linux kernel >= 6.6 with iommufd support.
1136
1137Examples:
1138    --iommu id=iommu0 --vfio host=0000:01:00.0,port=rp0,iommu=iommu0
1139
1140Syntax: id=<name>
1141"#)]
1142    #[cfg(target_os = "linux")]
1143    #[clap(long, conflicts_with("pcat"))]
1144    pub iommu: Vec<IommuCli>,
1145}
1146
1147impl Options {
1148    /// Returns the effective guest RAM size.
1149    pub fn memory_size(&self) -> u64 {
1150        self.memory.mem_size
1151    }
1152
1153    /// Returns whether guest RAM should be prefetched.
1154    pub fn prefetch_memory(&self) -> bool {
1155        self.memory.prefetch || self.deprecated_prefetch
1156    }
1157
1158    /// Returns whether guest RAM should use private anonymous backing.
1159    pub fn private_memory(&self) -> bool {
1160        self.memory.shared == Some(false) || self.deprecated_private_memory
1161    }
1162
1163    /// Returns whether guest RAM should be marked THP-eligible.
1164    pub fn transparent_hugepages(&self) -> bool {
1165        self.memory.transparent_hugepages || self.deprecated_thp
1166    }
1167
1168    /// Returns the effective file backing path for guest RAM.
1169    pub fn memory_backing_file(&self) -> Option<&PathBuf> {
1170        self.memory
1171            .file
1172            .as_ref()
1173            .or(self.deprecated_memory_backing_file.as_ref())
1174    }
1175
1176    /// Validates combinations that span the new `--memory` parser and legacy aliases.
1177    pub fn validate_memory_options(&self) -> anyhow::Result<()> {
1178        if self.memory.file.is_some() && self.deprecated_memory_backing_file.is_some() {
1179            anyhow::bail!("--memory file=... conflicts with --memory-backing-file");
1180        }
1181        if self.memory.file.is_some() && self.restore_snapshot.is_some() {
1182            anyhow::bail!("--memory file=... conflicts with --restore-snapshot");
1183        }
1184        if self.memory.shared == Some(true) && self.deprecated_private_memory {
1185            anyhow::bail!("--memory shared=on conflicts with --private-memory");
1186        }
1187        if self.memory_backing_file().is_some() && self.private_memory() {
1188            anyhow::bail!("file-backed memory conflicts with private memory");
1189        }
1190        if self.transparent_hugepages() && !self.private_memory() {
1191            anyhow::bail!("transparent huge pages requires private memory mode");
1192        }
1193        if self.memory.hugepages {
1194            if !cfg!(target_os = "linux") {
1195                anyhow::bail!("hugepages are only supported on Linux");
1196            }
1197            if self.private_memory() {
1198                anyhow::bail!("hugepages conflict with private memory");
1199            }
1200            if self.memory_backing_file().is_some() || self.restore_snapshot.is_some() {
1201                anyhow::bail!("hugepages conflict with file-backed memory");
1202            }
1203            if self.pcat {
1204                anyhow::bail!("hugepages conflict with x86 legacy RAM splitting");
1205            }
1206        }
1207        Ok(())
1208    }
1209}
1210
1211#[derive(Clone, Debug, PartialEq)]
1212pub struct FsArgs {
1213    pub tag: String,
1214    pub path: String,
1215    pub pcie_port: Option<String>,
1216}
1217
1218impl FromStr for FsArgs {
1219    type Err = anyhow::Error;
1220
1221    fn from_str(s: &str) -> Result<Self, Self::Err> {
1222        let (pcie_port, s) = parse_pcie_port_prefix(s);
1223        let mut s = s.split(',');
1224        let (Some(tag), Some(path), None) = (s.next(), s.next(), s.next()) else {
1225            anyhow::bail!("expected [pcie_port=<port>:]<tag>,<path>");
1226        };
1227        Ok(Self {
1228            tag: tag.to_owned(),
1229            path: path.to_owned(),
1230            pcie_port,
1231        })
1232    }
1233}
1234
1235#[derive(Clone, Debug, PartialEq)]
1236pub struct FsArgsWithOptions {
1237    /// The file system tag.
1238    pub tag: String,
1239    /// The root path.
1240    pub path: String,
1241    /// The extra options, joined with ';'.
1242    pub options: String,
1243    /// Optional PCIe port name.
1244    pub pcie_port: Option<String>,
1245}
1246
1247impl FromStr for FsArgsWithOptions {
1248    type Err = anyhow::Error;
1249
1250    fn from_str(s: &str) -> Result<Self, Self::Err> {
1251        let (pcie_port, s) = parse_pcie_port_prefix(s);
1252        let mut s = s.split(',');
1253        let (Some(tag), Some(path)) = (s.next(), s.next()) else {
1254            anyhow::bail!("expected [pcie_port=<port>:]<tag>,<path>[,<options>]");
1255        };
1256        let options = s.collect::<Vec<_>>().join(";");
1257        Ok(Self {
1258            tag: tag.to_owned(),
1259            path: path.to_owned(),
1260            options,
1261            pcie_port,
1262        })
1263    }
1264}
1265
1266#[derive(Copy, Clone, clap::ValueEnum)]
1267pub enum VirtioBusCli {
1268    Auto,
1269    Mmio,
1270    Pci,
1271    Vpci,
1272}
1273
1274/// Parse an optional `pcie_port=<name>:` prefix from a CLI argument string.
1275///
1276/// Returns `(Some(port_name), rest)` if the prefix is present, or
1277/// `(None, original)` if not.
1278fn parse_pcie_port_prefix(s: &str) -> (Option<String>, &str) {
1279    if let Some(rest) = s.strip_prefix("pcie_port=") {
1280        if let Some((port, rest)) = rest.split_once(':') {
1281            if !port.is_empty() {
1282                return (Some(port.to_string()), rest);
1283            }
1284        }
1285    }
1286    (None, s)
1287}
1288
1289#[derive(Clone, Debug, PartialEq)]
1290pub struct VirtioPmemArgs {
1291    pub path: String,
1292    pub pcie_port: Option<String>,
1293}
1294
1295impl FromStr for VirtioPmemArgs {
1296    type Err = anyhow::Error;
1297
1298    fn from_str(s: &str) -> Result<Self, Self::Err> {
1299        let (pcie_port, s) = parse_pcie_port_prefix(s);
1300        if s.is_empty() {
1301            anyhow::bail!("expected [pcie_port=<port>:]<path>");
1302        }
1303        Ok(Self {
1304            path: s.to_owned(),
1305            pcie_port,
1306        })
1307    }
1308}
1309
1310#[derive(clap::ValueEnum, Clone, Copy)]
1311pub enum SecureBootTemplateCli {
1312    Windows,
1313    UefiCa,
1314}
1315
1316fn parse_memory(s: &str) -> anyhow::Result<u64> {
1317    if s == "VMGS_DEFAULT" {
1318        Ok(vmgs_format::VMGS_DEFAULT_CAPACITY)
1319    } else {
1320        || -> Option<u64> {
1321            let mut b = s.as_bytes();
1322            if s.ends_with('B') {
1323                b = &b[..b.len() - 1]
1324            }
1325            if b.is_empty() {
1326                return None;
1327            }
1328            let multi = match b[b.len() - 1] as char {
1329                'T' => Some(1024 * 1024 * 1024 * 1024),
1330                'G' => Some(1024 * 1024 * 1024),
1331                'M' => Some(1024 * 1024),
1332                'K' => Some(1024),
1333                _ => None,
1334            };
1335            if multi.is_some() {
1336                b = &b[..b.len() - 1]
1337            }
1338            let n: u64 = std::str::from_utf8(b).ok()?.parse().ok()?;
1339            n.checked_mul(multi.unwrap_or(1))
1340        }()
1341        .with_context(|| format!("invalid memory size '{0}'", s))
1342    }
1343}
1344
1345fn parse_acs_capability_mask(value: &str) -> anyhow::Result<u16> {
1346    if let Some(hex) = value
1347        .strip_prefix("0x")
1348        .or_else(|| value.strip_prefix("0X"))
1349    {
1350        u16::from_str_radix(hex, 16).context("invalid ACS capability mask")
1351    } else {
1352        value.parse::<u16>().context("invalid ACS capability mask")
1353    }
1354}
1355
1356fn parse_memory_toggle(key: &str, value: &str) -> anyhow::Result<bool> {
1357    match value {
1358        "on" => Ok(true),
1359        "off" => Ok(false),
1360        _ => anyhow::bail!("invalid {key} value '{value}', expected 'on' or 'off'"),
1361    }
1362}
1363
1364/// Accumulator for shared memory option parsing (size, shared, prefetch, thp,
1365/// hugepages, hugepage_size). Used by both `parse_memory_config` and
1366/// `parse_numa_node`.
1367#[derive(Default)]
1368struct MemoryOptionAccum {
1369    mem_size: Option<u64>,
1370    shared: Option<bool>,
1371    prefetch: Option<bool>,
1372    transparent_hugepages: Option<bool>,
1373    hugepages: Option<bool>,
1374    hugepage_size: Option<u64>,
1375}
1376
1377impl MemoryOptionAccum {
1378    /// Try to parse a key=value pair as a common memory option.
1379    /// Returns `Ok(true)` if the key was recognized, `Ok(false)` if not.
1380    fn try_parse(&mut self, key: &str, value: &str) -> anyhow::Result<bool> {
1381        match key {
1382            "size" => {
1383                anyhow::ensure!(self.mem_size.is_none(), "duplicate option 'size'");
1384                self.mem_size = Some(parse_memory(value)?);
1385            }
1386            "shared" => {
1387                anyhow::ensure!(self.shared.is_none(), "duplicate option 'shared'");
1388                self.shared = Some(parse_memory_toggle(key, value)?);
1389            }
1390            "prefetch" => {
1391                anyhow::ensure!(self.prefetch.is_none(), "duplicate option 'prefetch'");
1392                self.prefetch = Some(parse_memory_toggle(key, value)?);
1393            }
1394            "thp" => {
1395                anyhow::ensure!(
1396                    self.transparent_hugepages.is_none(),
1397                    "duplicate option 'thp'"
1398                );
1399                self.transparent_hugepages = Some(parse_memory_toggle(key, value)?);
1400            }
1401            "hugepages" => {
1402                anyhow::ensure!(self.hugepages.is_none(), "duplicate option 'hugepages'");
1403                self.hugepages = Some(parse_memory_toggle(key, value)?);
1404            }
1405            "hugepage_size" => {
1406                anyhow::ensure!(
1407                    self.hugepage_size.is_none(),
1408                    "duplicate option 'hugepage_size'"
1409                );
1410                self.hugepage_size = Some(parse_memory(value)?);
1411            }
1412            _ => return Ok(false),
1413        }
1414        Ok(true)
1415    }
1416
1417    /// Validate common constraints and build a `MemoryCli`.
1418    fn finish(self, default_size: u64, file: Option<PathBuf>) -> anyhow::Result<MemoryCli> {
1419        if self.transparent_hugepages == Some(true) && self.shared != Some(false) {
1420            anyhow::bail!("thp=on requires shared=off");
1421        }
1422        if self.hugepage_size.is_some() && self.hugepages != Some(true) {
1423            anyhow::bail!("hugepage_size requires hugepages=on");
1424        }
1425        if self.hugepages == Some(true) {
1426            if self.shared == Some(false) {
1427                anyhow::bail!("hugepages=on conflicts with shared=off");
1428            }
1429            if file.is_some() {
1430                anyhow::bail!("hugepages=on conflicts with file=...");
1431            }
1432        }
1433        Ok(MemoryCli {
1434            mem_size: self.mem_size.unwrap_or(default_size),
1435            shared: self.shared,
1436            prefetch: self.prefetch.unwrap_or(false),
1437            transparent_hugepages: self.transparent_hugepages.unwrap_or(false),
1438            hugepages: self.hugepages.unwrap_or(false),
1439            hugepage_size: self.hugepage_size,
1440            file,
1441        })
1442    }
1443}
1444
1445fn parse_memory_config(s: &str) -> anyhow::Result<MemoryCli> {
1446    if !s.contains('=') && !s.contains(',') {
1447        return Ok(MemoryCli {
1448            mem_size: parse_memory(s)?,
1449            shared: None,
1450            prefetch: false,
1451            transparent_hugepages: false,
1452            hugepages: false,
1453            hugepage_size: None,
1454            file: None,
1455        });
1456    }
1457
1458    let mut accum = MemoryOptionAccum::default();
1459    let mut file = None;
1460
1461    for part in s.split(',') {
1462        let (key, value) = part
1463            .split_once('=')
1464            .with_context(|| format!("invalid memory option '{part}', expected key=value"))?;
1465        if key.is_empty() || value.is_empty() {
1466            anyhow::bail!("invalid memory option '{part}', expected key=value");
1467        }
1468
1469        if accum.try_parse(key, value)? {
1470            continue;
1471        }
1472        match key {
1473            "file" => {
1474                anyhow::ensure!(file.is_none(), "duplicate memory option 'file'");
1475                file = Some(PathBuf::from(value));
1476            }
1477            _ => anyhow::bail!("unknown memory option '{key}'"),
1478        }
1479    }
1480
1481    accum.finish(DEFAULT_MEMORY_SIZE, file)
1482}
1483
1484/// Split a comma-delimited option string, but skip commas inside `[]`.
1485fn split_options(s: &str) -> anyhow::Result<Vec<&str>> {
1486    let mut parts = Vec::new();
1487    let mut depth = 0u32;
1488    let mut start = 0;
1489    for (i, c) in s.char_indices() {
1490        match c {
1491            '[' => depth += 1,
1492            ']' => {
1493                anyhow::ensure!(depth > 0, "unmatched ']' in '{s}'");
1494                depth -= 1;
1495            }
1496            ',' if depth == 0 => {
1497                parts.push(&s[start..i]);
1498                start = i + 1;
1499            }
1500            _ => {}
1501        }
1502    }
1503    anyhow::ensure!(depth == 0, "unmatched '[' in '{s}'");
1504    parts.push(&s[start..]);
1505    Ok(parts)
1506}
1507
1508/// Parse a VP list value in bracket syntax: `[0,1,4-5]`.
1509/// Returns individual VP indices.
1510fn parse_vp_list(value: &str) -> anyhow::Result<Vec<u32>> {
1511    let inner = value
1512        .strip_prefix('[')
1513        .and_then(|s| s.strip_suffix(']'))
1514        .with_context(|| {
1515            format!("vps value must use bracket syntax, e.g. [0,1,2-3], got '{value}'")
1516        })?;
1517
1518    if inner.is_empty() {
1519        return Ok(Vec::new());
1520    }
1521
1522    let mut vps = Vec::new();
1523    for item in inner.split(',') {
1524        let item = item.trim();
1525        if let Some((lo, hi)) = item.split_once('-') {
1526            let lo = lo.trim().parse::<u32>().context("invalid vp index")?;
1527            let hi = hi.trim().parse::<u32>().context("invalid vp index")?;
1528            anyhow::ensure!(lo <= hi, "invalid vp range {lo}-{hi}");
1529            vps.extend(lo..=hi);
1530        } else {
1531            vps.push(item.parse::<u32>().context("invalid vp index")?);
1532        }
1533    }
1534    Ok(vps)
1535}
1536
1537fn parse_numa_node(s: &str) -> anyhow::Result<NumaNodeCli> {
1538    let mut accum = MemoryOptionAccum::default();
1539    let mut host_numa_node = None;
1540    let mut vps: Option<Vec<u32>> = None;
1541
1542    for part in split_options(s)? {
1543        let (key, value) = part
1544            .split_once('=')
1545            .with_context(|| format!("invalid numa option '{part}', expected key=value"))?;
1546
1547        if accum.try_parse(key, value)? {
1548            continue;
1549        }
1550        match key {
1551            "host_numa_node" => {
1552                anyhow::ensure!(
1553                    host_numa_node.is_none(),
1554                    "duplicate numa option 'host_numa_node'"
1555                );
1556                host_numa_node = Some(value.parse::<u32>().context("invalid host_numa_node")?);
1557            }
1558            "vps" => {
1559                anyhow::ensure!(vps.is_none(), "duplicate numa option 'vps'");
1560                vps = Some(parse_vp_list(value)?);
1561            }
1562            _ => anyhow::bail!("unknown numa option '{key}'"),
1563        }
1564    }
1565
1566    anyhow::ensure!(accum.mem_size.is_some(), "numa node requires 'size' option");
1567    let memory = accum.finish(0, None)?;
1568
1569    Ok(NumaNodeCli {
1570        memory,
1571        host_numa_node,
1572        vps,
1573    })
1574}
1575
1576fn parse_numa_distance(s: &str) -> anyhow::Result<NumaDistanceCli> {
1577    let parts: Vec<&str> = s.split(':').collect();
1578    anyhow::ensure!(
1579        parts.len() == 3,
1580        "expected SRC:DST:DISTANCE format, got '{s}'"
1581    );
1582    let src = parts[0].parse::<u32>().context("invalid source node")?;
1583    let dst = parts[1]
1584        .parse::<u32>()
1585        .context("invalid destination node")?;
1586    let distance = parts[2].parse::<u8>().context("invalid distance")?;
1587    anyhow::ensure!(
1588        distance >= 10,
1589        "distance must be >= 10 (10 = local), got {distance}"
1590    );
1591    Ok(NumaDistanceCli { src, dst, distance })
1592}
1593
1594/// Parse a number from a string that could be prefixed with 0x to indicate hex.
1595fn parse_number(s: &str) -> Result<u64, std::num::ParseIntError> {
1596    match s.strip_prefix("0x") {
1597        Some(rest) => u64::from_str_radix(rest, 16),
1598        None => s.parse::<u64>(),
1599    }
1600}
1601
1602#[derive(Clone, Debug, PartialEq)]
1603pub enum DiskCliKind {
1604    // mem:<len>
1605    Memory(u64),
1606    // memdiff:<kind>
1607    MemoryDiff(Box<DiskCliKind>),
1608    // sql:<path>[;create=<len>]
1609    Sqlite {
1610        path: PathBuf,
1611        create_with_len: Option<u64>,
1612    },
1613    // sqldiff:<path>[;create]:<kind>
1614    SqliteDiff {
1615        path: PathBuf,
1616        create: bool,
1617        disk: Box<DiskCliKind>,
1618    },
1619    // autocache:[key]:<kind>
1620    AutoCacheSqlite {
1621        cache_path: String,
1622        key: Option<String>,
1623        disk: Box<DiskCliKind>,
1624    },
1625    // prwrap:<kind>
1626    PersistentReservationsWrapper(Box<DiskCliKind>),
1627    // file:<path>[;direct][;create=<len>]
1628    File {
1629        path: PathBuf,
1630        create_with_len: Option<u64>,
1631        direct: bool,
1632    },
1633    // blob:<type>:<url>
1634    Blob {
1635        kind: BlobKind,
1636        url: String,
1637    },
1638    // crypt:<cipher>:<key_file>:<kind>
1639    Crypt {
1640        cipher: DiskCipher,
1641        key_file: PathBuf,
1642        disk: Box<DiskCliKind>,
1643    },
1644    // delay:<delay_ms>:<kind>
1645    DelayDiskWrapper {
1646        delay_ms: u64,
1647        disk: Box<DiskCliKind>,
1648    },
1649}
1650
1651#[derive(ValueEnum, Clone, Copy, Debug, PartialEq)]
1652pub enum DiskCipher {
1653    #[clap(name = "xts-aes-256")]
1654    XtsAes256,
1655}
1656
1657#[derive(Copy, Clone, Debug, PartialEq)]
1658pub enum BlobKind {
1659    Flat,
1660    Vhd1,
1661}
1662
1663struct FileOpts {
1664    path: PathBuf,
1665    create_with_len: Option<u64>,
1666    direct: bool,
1667}
1668
1669fn parse_file_opts(arg: &str) -> anyhow::Result<FileOpts> {
1670    let mut path = arg;
1671    let mut create_with_len = None;
1672    let mut direct = false;
1673
1674    // Parse semicolon-delimited options after the path.
1675    if let Some((p, rest)) = arg.split_once(';') {
1676        path = p;
1677        for opt in rest.split(';') {
1678            if let Some(len) = opt.strip_prefix("create=") {
1679                create_with_len = Some(parse_memory(len)?);
1680            } else if opt == "direct" {
1681                direct = true;
1682            } else {
1683                anyhow::bail!("invalid file option '{opt}', expected 'create=<len>' or 'direct'");
1684            }
1685        }
1686    }
1687
1688    Ok(FileOpts {
1689        path: path.into(),
1690        create_with_len,
1691        direct,
1692    })
1693}
1694
1695impl DiskCliKind {
1696    /// Parse an `autocache:[key]:<kind>` disk spec, given the cache path
1697    /// (normally read from `OPENVMM_AUTO_CACHE_PATH`).
1698    fn parse_autocache(
1699        arg: &str,
1700        cache_path: Result<String, std::env::VarError>,
1701    ) -> anyhow::Result<Self> {
1702        let (key, kind) = arg.split_once(':').context("expected [key]:kind")?;
1703        let cache_path = cache_path.context("must set cache path via OPENVMM_AUTO_CACHE_PATH")?;
1704        Ok(DiskCliKind::AutoCacheSqlite {
1705            cache_path,
1706            key: (!key.is_empty()).then(|| key.to_string()),
1707            disk: Box::new(kind.parse()?),
1708        })
1709    }
1710}
1711
1712impl FromStr for DiskCliKind {
1713    type Err = anyhow::Error;
1714
1715    fn from_str(s: &str) -> anyhow::Result<Self> {
1716        let disk = match s.split_once(':') {
1717            // convenience support for passing bare paths as file disks
1718            None => {
1719                let FileOpts {
1720                    path,
1721                    create_with_len,
1722                    direct,
1723                } = parse_file_opts(s)?;
1724                DiskCliKind::File {
1725                    path,
1726                    create_with_len,
1727                    direct,
1728                }
1729            }
1730            Some((kind, arg)) => match kind {
1731                "mem" => DiskCliKind::Memory(parse_memory(arg)?),
1732                "memdiff" => DiskCliKind::MemoryDiff(Box::new(arg.parse()?)),
1733                "sql" => {
1734                    let FileOpts {
1735                        path,
1736                        create_with_len,
1737                        direct,
1738                    } = parse_file_opts(arg)?;
1739                    if direct {
1740                        anyhow::bail!("'direct' is not supported for 'sql' disks");
1741                    }
1742                    DiskCliKind::Sqlite {
1743                        path,
1744                        create_with_len,
1745                    }
1746                }
1747                "sqldiff" => {
1748                    let (path_and_opts, kind) =
1749                        arg.split_once(':').context("expected path[;opts]:kind")?;
1750                    let disk = Box::new(kind.parse()?);
1751                    match path_and_opts.split_once(';') {
1752                        Some((path, create)) => {
1753                            if create != "create" {
1754                                anyhow::bail!("invalid syntax after ';', expected 'create'")
1755                            }
1756                            DiskCliKind::SqliteDiff {
1757                                path: path.into(),
1758                                create: true,
1759                                disk,
1760                            }
1761                        }
1762                        None => DiskCliKind::SqliteDiff {
1763                            path: path_and_opts.into(),
1764                            create: false,
1765                            disk,
1766                        },
1767                    }
1768                }
1769                "autocache" => {
1770                    Self::parse_autocache(arg, std::env::var("OPENVMM_AUTO_CACHE_PATH"))?
1771                }
1772                "prwrap" => DiskCliKind::PersistentReservationsWrapper(Box::new(arg.parse()?)),
1773                "file" => {
1774                    let FileOpts {
1775                        path,
1776                        create_with_len,
1777                        direct,
1778                    } = parse_file_opts(arg)?;
1779                    DiskCliKind::File {
1780                        path,
1781                        create_with_len,
1782                        direct,
1783                    }
1784                }
1785                "blob" => {
1786                    let (blob_kind, url) = arg.split_once(':').context("expected kind:url")?;
1787                    let blob_kind = match blob_kind {
1788                        "flat" => BlobKind::Flat,
1789                        "vhd1" => BlobKind::Vhd1,
1790                        _ => anyhow::bail!("unknown blob kind {blob_kind}"),
1791                    };
1792                    DiskCliKind::Blob {
1793                        kind: blob_kind,
1794                        url: url.to_string(),
1795                    }
1796                }
1797                "crypt" => {
1798                    let (cipher, (key, kind)) = arg
1799                        .split_once(':')
1800                        .and_then(|(cipher, arg)| Some((cipher, arg.split_once(':')?)))
1801                        .context("expected cipher:key_file:kind")?;
1802                    DiskCliKind::Crypt {
1803                        cipher: ValueEnum::from_str(cipher, false)
1804                            .map_err(|err| anyhow::anyhow!("invalid cipher: {err}"))?,
1805                        key_file: PathBuf::from(key),
1806                        disk: Box::new(kind.parse()?),
1807                    }
1808                }
1809                kind => {
1810                    // here's a fun edge case: what if the user passes `--disk d:\path\to\disk.img`?
1811                    //
1812                    // in this case, we actually want to treat that leading `d:` as part of the
1813                    // path, rather than as a disk with `kind == 'd'`
1814                    let FileOpts {
1815                        path,
1816                        create_with_len,
1817                        direct,
1818                    } = parse_file_opts(s)?;
1819                    if path.has_root() {
1820                        DiskCliKind::File {
1821                            path,
1822                            create_with_len,
1823                            direct,
1824                        }
1825                    } else {
1826                        anyhow::bail!("invalid disk kind {kind}");
1827                    }
1828                }
1829            },
1830        };
1831        Ok(disk)
1832    }
1833}
1834
1835#[derive(Clone)]
1836pub struct VmgsCli {
1837    pub kind: DiskCliKind,
1838    pub provision: ProvisionVmgs,
1839}
1840
1841#[derive(Copy, Clone)]
1842pub enum ProvisionVmgs {
1843    OnEmpty,
1844    OnFailure,
1845    True,
1846}
1847
1848impl FromStr for VmgsCli {
1849    type Err = anyhow::Error;
1850
1851    fn from_str(s: &str) -> anyhow::Result<Self> {
1852        let (kind, opt) = s
1853            .split_once(',')
1854            .map(|(k, o)| (k, Some(o)))
1855            .unwrap_or((s, None));
1856        let kind = kind.parse()?;
1857
1858        let provision = match opt {
1859            None => ProvisionVmgs::OnEmpty,
1860            Some("fmt-on-fail") => ProvisionVmgs::OnFailure,
1861            Some("fmt") => ProvisionVmgs::True,
1862            Some(opt) => anyhow::bail!("unknown option: '{opt}'"),
1863        };
1864
1865        Ok(VmgsCli { kind, provision })
1866    }
1867}
1868
1869/// VNC server configuration options.
1870#[derive(clap::Args)]
1871pub struct VncCli {
1872    /// Listen for VNC connections. Implied by --gfx.
1873    #[clap(long)]
1874    pub vnc: bool,
1875
1876    /// VNC port number
1877    #[clap(long, value_name = "PORT", default_value = "5900")]
1878    pub vnc_port: u16,
1879
1880    /// VNC listen address (use 0.0.0.0 for all IPv4, :: for dual-stack IPv4+IPv6).
1881    /// Accepts a bare IP address (combined with --vnc-port) or a full socket
1882    /// address like [::1]:5900 (overrides --vnc-port).
1883    #[clap(long, value_name = "ADDRESS", default_value = "127.0.0.1")]
1884    pub vnc_listen: String,
1885
1886    /// Maximum concurrent VNC clients (~8MB memory per client for framebuffer buffers)
1887    #[clap(long, value_name = "COUNT", default_value = "16")]
1888    pub vnc_max_clients: usize,
1889
1890    /// When the client limit is reached, disconnect the oldest client
1891    /// instead of rejecting the new connection
1892    #[clap(long)]
1893    pub vnc_evict_oldest: bool,
1894}
1895
1896// <kind>[,ro]
1897#[derive(Clone)]
1898pub struct DiskCli {
1899    pub vtl: DeviceVtl,
1900    pub kind: DiskCliKind,
1901    pub read_only: bool,
1902    pub is_dvd: bool,
1903    pub underhill: Option<UnderhillDiskSource>,
1904    pub pcie_port: Option<String>,
1905    pub controller: Option<String>,
1906    pub nsid: Option<u32>,
1907    pub lun: Option<u8>,
1908    pub relay: Option<(String, Option<u32>)>,
1909}
1910
1911#[derive(Copy, Clone)]
1912pub enum UnderhillDiskSource {
1913    Scsi,
1914    Nvme,
1915}
1916
1917impl FromStr for DiskCli {
1918    type Err = anyhow::Error;
1919
1920    fn from_str(s: &str) -> anyhow::Result<Self> {
1921        let mut opts = s.split(',');
1922        let kind = opts.next().unwrap().parse()?;
1923
1924        let mut read_only = false;
1925        let mut is_dvd = false;
1926        let mut underhill = None;
1927        let mut vtl = DeviceVtl::Vtl0;
1928        let mut pcie_port = None;
1929        let mut controller = None;
1930        let mut nsid = None;
1931        let mut lun = None;
1932        let mut relay = None;
1933        for opt in opts {
1934            let mut s = opt.split('=');
1935            let opt = s.next().unwrap();
1936            match opt {
1937                "ro" => read_only = true,
1938                "dvd" => {
1939                    is_dvd = true;
1940                    read_only = true;
1941                }
1942                "vtl2" => {
1943                    vtl = DeviceVtl::Vtl2;
1944                }
1945                "uh" => underhill = Some(UnderhillDiskSource::Scsi),
1946                "uh-nvme" => underhill = Some(UnderhillDiskSource::Nvme),
1947                "pcie_port" => {
1948                    let port = s.next();
1949                    if port.is_none_or(|p| p.is_empty()) {
1950                        anyhow::bail!("`pcie_port` requires a port name");
1951                    }
1952                    pcie_port = Some(String::from(port.unwrap()));
1953                }
1954                "on" => {
1955                    let name = s.next();
1956                    if name.is_none_or(|n| n.is_empty()) {
1957                        anyhow::bail!("`on` requires a controller name");
1958                    }
1959                    controller = Some(String::from(name.unwrap()));
1960                }
1961                "nsid" => {
1962                    let val = s.next().context("`nsid` requires a value")?;
1963                    nsid = Some(val.parse::<u32>().context("invalid `nsid` value")?);
1964                }
1965                "lun" => {
1966                    let val = s.next().context("`lun` requires a value")?;
1967                    lun = Some(val.parse::<u8>().context("invalid `lun` value")?);
1968                }
1969                "relay" => {
1970                    let val = s.next();
1971                    if val.is_none_or(|v| v.is_empty()) {
1972                        anyhow::bail!("`relay` requires a target controller name");
1973                    }
1974                    let val = val.unwrap();
1975                    // Parse "name" or "name:location"
1976                    if let Some((name, loc)) = val.split_once(':') {
1977                        let loc = loc.parse::<u32>().context("invalid relay location")?;
1978                        relay = Some((name.to_string(), Some(loc)));
1979                    } else {
1980                        relay = Some((val.to_string(), None));
1981                    }
1982                }
1983                opt => anyhow::bail!("unknown option: '{opt}'"),
1984            }
1985        }
1986
1987        if underhill.is_some() && vtl != DeviceVtl::Vtl0 {
1988            anyhow::bail!("`uh` or `uh-nvme` is incompatible with `vtl2`");
1989        }
1990
1991        if pcie_port.is_some() && (underhill.is_some() || vtl != DeviceVtl::Vtl0 || is_dvd) {
1992            anyhow::bail!("`pcie_port` is incompatible with `uh`, `uh-nvme`, `vtl2`, and `dvd`");
1993        }
1994
1995        if controller.is_some() && pcie_port.is_some() {
1996            anyhow::bail!("`on` is incompatible with `pcie_port`");
1997        }
1998
1999        if controller.is_some() && vtl != DeviceVtl::Vtl0 {
2000            anyhow::bail!(
2001                "`vtl2` is incompatible with `on`; the controller's VTL determines placement"
2002            );
2003        }
2004
2005        if controller.is_some() && underhill.is_some() {
2006            anyhow::bail!("`on` is incompatible with `uh` and `uh-nvme`; use `relay` instead");
2007        }
2008
2009        if nsid.is_some() && controller.is_none() {
2010            anyhow::bail!("`nsid` requires `on`");
2011        }
2012
2013        if lun.is_some() && controller.is_none() {
2014            anyhow::bail!("`lun` requires `on`");
2015        }
2016
2017        if nsid.is_some() && lun.is_some() {
2018            anyhow::bail!("`nsid` and `lun` are mutually exclusive");
2019        }
2020
2021        if relay.is_some() && controller.is_none() {
2022            anyhow::bail!("`relay` requires `on`");
2023        }
2024
2025        if relay.is_some() && underhill.is_some() {
2026            anyhow::bail!("`relay` is incompatible with `uh` and `uh-nvme`");
2027        }
2028
2029        Ok(DiskCli {
2030            vtl,
2031            kind,
2032            read_only,
2033            is_dvd,
2034            underhill,
2035            pcie_port,
2036            controller,
2037            nsid,
2038            lun,
2039            relay,
2040        })
2041    }
2042}
2043
2044/// The transport for a named NVMe controller.
2045#[derive(Clone, Debug, PartialEq)]
2046pub enum NvmeControllerTransport {
2047    /// Present via PCIe on the specified root port.
2048    Pcie(String),
2049    /// Present via VPCI with an optional instance GUID.
2050    Vpci(Option<Guid>),
2051}
2052
2053/// CLI arguments for a named NVMe controller.
2054#[derive(Clone, Debug)]
2055pub struct NvmeControllerCli {
2056    /// Controller name, referenced by `--disk on=<name>`.
2057    pub id: String,
2058    /// Transport configuration.
2059    pub transport: NvmeControllerTransport,
2060    /// VTL assignment (default VTL0).
2061    pub vtl: DeviceVtl,
2062}
2063
2064impl FromStr for NvmeControllerCli {
2065    type Err = anyhow::Error;
2066
2067    fn from_str(s: &str) -> anyhow::Result<Self> {
2068        let mut id = None;
2069        let mut pcie_port = None;
2070        let mut vpci = None;
2071        let mut vpci_set = false;
2072        let mut vtl = DeviceVtl::Vtl0;
2073
2074        for part in s.split(',') {
2075            let mut kv = part.split('=');
2076            let key = kv.next().unwrap();
2077            match key {
2078                "id" => {
2079                    let val = kv.next();
2080                    if val.is_none_or(|v| v.is_empty()) {
2081                        anyhow::bail!("`id` requires a name");
2082                    }
2083                    id = Some(val.unwrap().to_string());
2084                }
2085                "pcie_port" => {
2086                    let val = kv.next();
2087                    if val.is_none_or(|v| v.is_empty()) {
2088                        anyhow::bail!("`pcie_port` requires a port name");
2089                    }
2090                    pcie_port = Some(val.unwrap().to_string());
2091                }
2092                "vpci" => {
2093                    vpci_set = true;
2094                    if let Some(val) = kv.next() {
2095                        if !val.is_empty() {
2096                            vpci = Some(val.parse::<Guid>().context("invalid GUID for `vpci`")?);
2097                        }
2098                    }
2099                }
2100                "vtl2" => {
2101                    vtl = DeviceVtl::Vtl2;
2102                }
2103                other => anyhow::bail!("unknown option: '{other}'"),
2104            }
2105        }
2106
2107        let id = id.context("`id` is required")?;
2108
2109        let transport = match (pcie_port, vpci_set) {
2110            (Some(port), false) => NvmeControllerTransport::Pcie(port),
2111            (None, true) => NvmeControllerTransport::Vpci(vpci),
2112            (Some(_), true) => {
2113                anyhow::bail!("`pcie_port` and `vpci` are mutually exclusive")
2114            }
2115            (None, false) => {
2116                anyhow::bail!("one of `pcie_port` or `vpci` is required")
2117            }
2118        };
2119
2120        Ok(NvmeControllerCli { id, transport, vtl })
2121    }
2122}
2123
2124/// CLI arguments for a named VMBus SCSI controller.
2125#[derive(Clone, Debug)]
2126pub struct ScsiControllerCli {
2127    /// Controller name, referenced by `--disk on=<name>`.
2128    pub id: String,
2129    /// Number of sub-channels.
2130    pub sub_channels: u16,
2131    /// VTL assignment (default VTL0).
2132    pub vtl: DeviceVtl,
2133}
2134
2135impl FromStr for ScsiControllerCli {
2136    type Err = anyhow::Error;
2137
2138    fn from_str(s: &str) -> anyhow::Result<Self> {
2139        let mut id = None;
2140        let mut sub_channels = 0u16;
2141        let mut vtl = DeviceVtl::Vtl0;
2142
2143        for part in s.split(',') {
2144            let mut kv = part.split('=');
2145            let key = kv.next().unwrap();
2146            match key {
2147                "id" => {
2148                    let val = kv.next();
2149                    if val.is_none_or(|v| v.is_empty()) {
2150                        anyhow::bail!("`id` requires a name");
2151                    }
2152                    id = Some(val.unwrap().to_string());
2153                }
2154                "sub_channels" => {
2155                    let val = kv.next().context("`sub_channels` requires a value")?;
2156                    sub_channels = val.parse().context("invalid `sub_channels` value")?;
2157                }
2158                "vtl2" => {
2159                    vtl = DeviceVtl::Vtl2;
2160                }
2161                other => anyhow::bail!("unknown option: '{other}'"),
2162            }
2163        }
2164
2165        let id = id.context("`id` is required")?;
2166
2167        Ok(ScsiControllerCli {
2168            id,
2169            sub_channels,
2170            vtl,
2171        })
2172    }
2173}
2174
2175/// Protocol type for an OpenHCL-managed controller.
2176#[derive(Copy, Clone, Debug, PartialEq)]
2177pub enum OpenhclControllerType {
2178    Scsi,
2179    Nvme,
2180}
2181
2182/// CLI arguments for an OpenHCL-managed storage controller (relay target).
2183#[derive(Clone, Debug)]
2184pub struct OpenhclControllerCli {
2185    /// Controller name, referenced by `--disk ... relay=<name>`.
2186    pub id: String,
2187    /// Controller protocol.
2188    pub controller_type: OpenhclControllerType,
2189    /// Instance GUID (auto-derived from name if omitted).
2190    pub guid: Option<Guid>,
2191}
2192
2193impl FromStr for OpenhclControllerCli {
2194    type Err = anyhow::Error;
2195
2196    fn from_str(s: &str) -> anyhow::Result<Self> {
2197        let mut id = None;
2198        let mut controller_type = None;
2199        let mut guid = None;
2200
2201        for part in s.split(',') {
2202            let mut kv = part.split('=');
2203            let key = kv.next().unwrap();
2204            match key {
2205                "id" => {
2206                    let val = kv.next();
2207                    if val.is_none_or(|v| v.is_empty()) {
2208                        anyhow::bail!("`id` requires a name");
2209                    }
2210                    id = Some(val.unwrap().to_string());
2211                }
2212                "type" => {
2213                    let val = kv.next().context("`type` requires a value")?;
2214                    controller_type = Some(match val {
2215                        "scsi" => OpenhclControllerType::Scsi,
2216                        "nvme" => OpenhclControllerType::Nvme,
2217                        other => anyhow::bail!("unknown controller type: '{other}'"),
2218                    });
2219                }
2220                "guid" => {
2221                    let val = kv.next().context("`guid` requires a value")?;
2222                    guid = Some(val.parse::<Guid>().context("invalid GUID")?);
2223                }
2224                other => anyhow::bail!("unknown option: '{other}'"),
2225            }
2226        }
2227
2228        let id = id.context("`id` is required")?;
2229        let controller_type = controller_type.context("`type` is required")?;
2230
2231        Ok(OpenhclControllerCli {
2232            id,
2233            controller_type,
2234            guid,
2235        })
2236    }
2237}
2238
2239/// CLI arguments for a CXL Type-3 test endpoint.
2240#[derive(Clone, Debug, PartialEq)]
2241pub struct CxlTestDeviceCli {
2242    /// Size of HDM memory the test device should expose and back.
2243    pub hdm_size: u64,
2244    /// PCIe root port name where the device is attached.
2245    pub pcie_port: String,
2246}
2247
2248impl FromStr for CxlTestDeviceCli {
2249    type Err = anyhow::Error;
2250
2251    fn from_str(s: &str) -> anyhow::Result<Self> {
2252        let mut opts = s.split(',');
2253        let first = opts.next().context("expected CXL test device config")?;
2254        let (kind, arg) = first
2255            .split_once(':')
2256            .context("expected CXL test syntax: mem:<len>")?;
2257
2258        if kind != "mem" {
2259            anyhow::bail!("unsupported CXL test backing kind '{kind}', expected 'mem'");
2260        }
2261
2262        let hdm_size = parse_memory(arg).context("failed to parse CXL test HDM size")?;
2263        let mut pcie_port = None;
2264
2265        for opt in opts {
2266            let mut kv = opt.split('=');
2267            let key = kv.next().unwrap_or_default();
2268            match key {
2269                "pcie_port" => {
2270                    let val = kv.next();
2271                    if val.is_none_or(|v| v.is_empty()) {
2272                        anyhow::bail!("`pcie_port` requires a port name");
2273                    }
2274                    pcie_port = Some(val.unwrap().to_string());
2275                }
2276                _ => anyhow::bail!("unknown option: '{opt}'"),
2277            }
2278        }
2279
2280        let Some(pcie_port) = pcie_port else {
2281            anyhow::bail!("`pcie_port=<name>` is required for `--cxl-test`");
2282        };
2283
2284        Ok(Self {
2285            hdm_size,
2286            pcie_port,
2287        })
2288    }
2289}
2290
2291// <kind>[,ro,s]
2292#[derive(Clone)]
2293pub struct IdeDiskCli {
2294    pub kind: DiskCliKind,
2295    pub read_only: bool,
2296    pub channel: Option<u8>,
2297    pub device: Option<u8>,
2298    pub is_dvd: bool,
2299}
2300
2301impl FromStr for IdeDiskCli {
2302    type Err = anyhow::Error;
2303
2304    fn from_str(s: &str) -> anyhow::Result<Self> {
2305        let mut opts = s.split(',');
2306        let kind = opts.next().unwrap().parse()?;
2307
2308        let mut read_only = false;
2309        let mut channel = None;
2310        let mut device = None;
2311        let mut is_dvd = false;
2312        for opt in opts {
2313            let mut s = opt.split('=');
2314            let opt = s.next().unwrap();
2315            match opt {
2316                "ro" => read_only = true,
2317                "p" => channel = Some(0),
2318                "s" => channel = Some(1),
2319                "0" => device = Some(0),
2320                "1" => device = Some(1),
2321                "dvd" => {
2322                    is_dvd = true;
2323                    read_only = true;
2324                }
2325                _ => anyhow::bail!("unknown option: '{opt}'"),
2326            }
2327        }
2328
2329        Ok(IdeDiskCli {
2330            kind,
2331            read_only,
2332            channel,
2333            device,
2334            is_dvd,
2335        })
2336    }
2337}
2338
2339// <kind>[,ro]
2340#[derive(Clone, Debug, PartialEq)]
2341pub struct FloppyDiskCli {
2342    pub kind: DiskCliKind,
2343    pub read_only: bool,
2344}
2345
2346impl FromStr for FloppyDiskCli {
2347    type Err = anyhow::Error;
2348
2349    fn from_str(s: &str) -> anyhow::Result<Self> {
2350        if s.is_empty() {
2351            anyhow::bail!("empty disk spec");
2352        }
2353        let mut opts = s.split(',');
2354        let kind = opts.next().unwrap().parse()?;
2355
2356        let mut read_only = false;
2357        for opt in opts {
2358            let mut s = opt.split('=');
2359            let opt = s.next().unwrap();
2360            match opt {
2361                "ro" => read_only = true,
2362                _ => anyhow::bail!("unknown option: '{opt}'"),
2363            }
2364        }
2365
2366        Ok(FloppyDiskCli { kind, read_only })
2367    }
2368}
2369
2370#[derive(Clone)]
2371pub struct DebugconSerialConfigCli {
2372    pub port: u16,
2373    pub serial: SerialConfigCli,
2374}
2375
2376impl FromStr for DebugconSerialConfigCli {
2377    type Err = String;
2378
2379    fn from_str(s: &str) -> Result<Self, Self::Err> {
2380        let Some((port, serial)) = s.split_once(',') else {
2381            return Err("invalid format (missing comma between port and serial)".into());
2382        };
2383
2384        let port: u16 = parse_number(port)
2385            .map_err(|_| "could not parse port".to_owned())?
2386            .try_into()
2387            .map_err(|_| "port must be 16-bit")?;
2388        let serial: SerialConfigCli = serial.parse()?;
2389
2390        Ok(Self { port, serial })
2391    }
2392}
2393
2394/// (console | stderr | listen=\<path\> | file=\<path\> (overwrites) | listen=tcp:\<ip\>:\<port\> | term[=\<program\>]\[,name=\<windowtitle\>\] | none)
2395#[derive(Clone, Debug, PartialEq)]
2396pub enum SerialConfigCli {
2397    None,
2398    Console,
2399    NewConsole(Option<PathBuf>, Option<String>),
2400    Stderr,
2401    Pipe(PathBuf),
2402    Tcp(SocketAddr),
2403    File(PathBuf),
2404}
2405
2406impl FromStr for SerialConfigCli {
2407    type Err = String;
2408
2409    fn from_str(s: &str) -> Result<Self, Self::Err> {
2410        let keyvalues = SerialConfigCli::parse_keyvalues(s)?;
2411
2412        let first_key = match keyvalues.first() {
2413            Some(first_pair) => first_pair.0.as_str(),
2414            None => Err("invalid serial configuration: no values supplied")?,
2415        };
2416        let first_value = keyvalues.first().unwrap().1.as_ref();
2417
2418        let ret = match first_key {
2419            "none" => SerialConfigCli::None,
2420            "console" => SerialConfigCli::Console,
2421            "stderr" => SerialConfigCli::Stderr,
2422            "file" => match first_value {
2423                Some(path) => SerialConfigCli::File(path.into()),
2424                None => Err("invalid serial configuration: file requires a value")?,
2425            },
2426            "term" => {
2427                // If user supplies a name key, use it to title the window
2428                let window_name = keyvalues.iter().find(|(key, _)| key == "name");
2429                let window_name = match window_name {
2430                    Some((_, Some(name))) => Some(name.clone()),
2431                    _ => None,
2432                };
2433
2434                SerialConfigCli::NewConsole(first_value.map(|p| p.into()), window_name)
2435            }
2436            "listen" => match first_value {
2437                Some(path) => {
2438                    if let Some(tcp) = path.strip_prefix("tcp:") {
2439                        let addr = tcp
2440                            .parse()
2441                            .map_err(|err| format!("invalid tcp address: {err}"))?;
2442                        SerialConfigCli::Tcp(addr)
2443                    } else {
2444                        SerialConfigCli::Pipe(path.into())
2445                    }
2446                }
2447                None => Err(
2448                    "invalid serial configuration: listen requires a value of tcp:addr or pipe",
2449                )?,
2450            },
2451            _ => {
2452                return Err(format!(
2453                    "invalid serial configuration: '{}' is not a known option",
2454                    first_key
2455                ));
2456            }
2457        };
2458
2459        Ok(ret)
2460    }
2461}
2462
2463impl SerialConfigCli {
2464    /// Parse a comma separated list of key=value options into a vector of
2465    /// key/value pairs.
2466    fn parse_keyvalues(s: &str) -> Result<Vec<(String, Option<String>)>, String> {
2467        let mut ret = Vec::new();
2468
2469        // For each comma separated item in the supplied list
2470        for item in s.split(',') {
2471            // Split on the = for key and value
2472            // If no = is found, treat key as key and value as None
2473            let mut eqsplit = item.split('=');
2474            let key = eqsplit.next();
2475            let value = eqsplit.next();
2476
2477            if let Some(key) = key {
2478                ret.push((key.to_owned(), value.map(|x| x.to_owned())));
2479            } else {
2480                // An empty key is invalid
2481                return Err("invalid key=value pair in serial config".into());
2482            }
2483        }
2484        Ok(ret)
2485    }
2486}
2487
2488#[derive(Clone, Debug, PartialEq)]
2489pub enum EndpointConfigCli {
2490    None,
2491    Consomme {
2492        cidr: Option<String>,
2493        host_fwd: Vec<HostPortConfigCli>,
2494    },
2495    Dio {
2496        id: Option<String>,
2497    },
2498    Tap {
2499        name: String,
2500    },
2501}
2502
2503/// Parsed host port forwarding configuration from the CLI.
2504#[derive(Clone, Debug, PartialEq)]
2505pub struct HostPortConfigCli {
2506    pub protocol: HostPortProtocolCli,
2507    pub host_address: Option<std::net::IpAddr>,
2508    pub host_port: u16,
2509    pub guest_port: u16,
2510}
2511
2512/// Protocol for host port forwarding.
2513#[derive(Clone, Debug, PartialEq)]
2514pub enum HostPortProtocolCli {
2515    Tcp,
2516    Udp,
2517}
2518
2519fn parse_hostfwd(s: &str) -> Result<HostPortConfigCli, String> {
2520    // Format: protocol:[hostaddr]:hostport-[guestaddr]:guestport
2521    // Examples: "tcp::3389-:3389", "tcp:127.0.0.1:8080-:80", "tcp:[::1]:8080-:80"
2522    let (host_part, guest_part) = s.split_once('-').ok_or_else(|| {
2523        format!(
2524            "invalid hostfwd format '{s}', \
2525             expected 'proto:[hostaddr]:hostport-[guestaddr]:guestport'"
2526        )
2527    })?;
2528
2529    // Extract protocol from host part (first colon-delimited field)
2530    let (proto, host_addr_port) = host_part.split_once(':').ok_or_else(|| {
2531        format!("invalid hostfwd host part '{host_part}', expected 'proto:[hostaddr]:hostport'")
2532    })?;
2533    let protocol = match proto {
2534        "tcp" => HostPortProtocolCli::Tcp,
2535        "udp" => HostPortProtocolCli::Udp,
2536        other => {
2537            return Err(format!(
2538                "unknown hostfwd protocol '{other}', expected 'tcp' or 'udp'"
2539            ));
2540        }
2541    };
2542
2543    let (host_address, host_port) = parse_addr_port(host_addr_port)
2544        .map_err(|e| format!("invalid hostfwd host address/port: {e}"))?;
2545    let (_, guest_port) = parse_addr_port(guest_part)
2546        .map_err(|e| format!("invalid hostfwd guest address/port: {e}"))?;
2547
2548    Ok(HostPortConfigCli {
2549        protocol,
2550        host_address,
2551        host_port,
2552        guest_port,
2553    })
2554}
2555
2556/// Parse an address-port pair in one of these forms:
2557/// - `[ipv6addr]:port`
2558/// - `addr:port`
2559/// - `:port`  (empty address)
2560/// - `port`   (no address)
2561fn parse_addr_port(s: &str) -> Result<(Option<std::net::IpAddr>, u16), String> {
2562    if let Some(rest) = s.strip_prefix('[') {
2563        // Bracketed IPv6 address: [addr]:port
2564        let (addr, port) = rest
2565            .split_once("]:")
2566            .ok_or_else(|| format!("expected '[addr]:port', got '[{rest}'"))?;
2567        let port: u16 = port.parse().map_err(|_| format!("invalid port '{port}'"))?;
2568        let addr: std::net::IpAddr = addr
2569            .parse()
2570            .map_err(|e| format!("invalid address '{addr}': {e}"))?;
2571        Ok((Some(addr), port))
2572    } else {
2573        match s.rsplit_once(':') {
2574            Some((addr, port)) => {
2575                let port: u16 = port.parse().map_err(|_| format!("invalid port '{port}'"))?;
2576                let addr = if addr.is_empty() {
2577                    None
2578                } else {
2579                    let parsed: std::net::IpAddr = addr
2580                        .parse()
2581                        .map_err(|e| format!("invalid address '{addr}': {e}"))?;
2582                    Some(parsed)
2583                };
2584                Ok((addr, port))
2585            }
2586            None => {
2587                let port: u16 = s.parse().map_err(|_| format!("invalid port '{s}'"))?;
2588                Ok((None, port))
2589            }
2590        }
2591    }
2592}
2593
2594impl FromStr for EndpointConfigCli {
2595    type Err = String;
2596
2597    fn from_str(s: &str) -> Result<Self, Self::Err> {
2598        let ret = match s.split(':').collect::<Vec<_>>().as_slice() {
2599            ["none"] => EndpointConfigCli::None,
2600            ["consomme", rest @ ..] => {
2601                let remaining = rest.join(":");
2602                let mut cidr = None;
2603                let mut host_fwd = Vec::new();
2604                for opt in remaining.split(',').filter(|s| !s.is_empty()) {
2605                    if let Some(fwd) = opt.strip_prefix("hostfwd=") {
2606                        host_fwd.push(parse_hostfwd(fwd)?);
2607                    } else if cidr.is_none() {
2608                        cidr = Some(opt.to_owned());
2609                    } else {
2610                        return Err(format!("unexpected consomme option '{opt}'"));
2611                    }
2612                }
2613                EndpointConfigCli::Consomme { cidr, host_fwd }
2614            }
2615            ["dio", s @ ..] => EndpointConfigCli::Dio {
2616                id: s.first().map(|s| (*s).to_owned()),
2617            },
2618            ["tap", name] => EndpointConfigCli::Tap {
2619                name: (*name).to_owned(),
2620            },
2621            _ => return Err("invalid network backend".into()),
2622        };
2623
2624        Ok(ret)
2625    }
2626}
2627
2628#[derive(Clone, Debug, PartialEq)]
2629pub struct NicConfigCli {
2630    pub vtl: DeviceVtl,
2631    pub endpoint: EndpointConfigCli,
2632    pub max_queues: Option<u16>,
2633    pub underhill: bool,
2634    pub pcie_port: Option<String>,
2635}
2636
2637impl FromStr for NicConfigCli {
2638    type Err = String;
2639
2640    fn from_str(mut s: &str) -> Result<Self, Self::Err> {
2641        let mut vtl = DeviceVtl::Vtl0;
2642        let mut max_queues = None;
2643        let mut underhill = false;
2644        let mut pcie_port = None;
2645        while let Some((opt, rest)) = s.split_once(':') {
2646            if let Some((opt, val)) = opt.split_once('=') {
2647                match opt {
2648                    "queues" => {
2649                        max_queues = Some(val.parse().map_err(|_| "failed to parse queue count")?);
2650                    }
2651                    "pcie_port" => {
2652                        if val.is_empty() {
2653                            return Err("`pcie_port=` requires port name argument".into());
2654                        }
2655                        pcie_port = Some(val.to_string());
2656                    }
2657                    _ => break,
2658                }
2659            } else {
2660                match opt {
2661                    "vtl2" => {
2662                        vtl = DeviceVtl::Vtl2;
2663                    }
2664                    "uh" => underhill = true,
2665                    _ => break,
2666                }
2667            }
2668            s = rest;
2669        }
2670
2671        if underhill && vtl != DeviceVtl::Vtl0 {
2672            return Err("`uh` is incompatible with `vtl2`".into());
2673        }
2674
2675        if pcie_port.is_some() && (underhill || vtl != DeviceVtl::Vtl0) {
2676            return Err("`pcie_port` is incompatible with `uh` and `vtl2`".into());
2677        }
2678
2679        let endpoint = s.parse()?;
2680        Ok(NicConfigCli {
2681            vtl,
2682            endpoint,
2683            max_queues,
2684            underhill,
2685            pcie_port,
2686        })
2687    }
2688}
2689
2690#[derive(Debug, Error)]
2691#[error("unknown VTL2 relocation type: {0}")]
2692pub struct UnknownVtl2RelocationType(String);
2693
2694fn parse_vtl2_relocation(s: &str) -> Result<Vtl2BaseAddressType, UnknownVtl2RelocationType> {
2695    match s {
2696        "disable" => Ok(Vtl2BaseAddressType::File),
2697        s if s.starts_with("auto=") => {
2698            let s = s.strip_prefix("auto=").unwrap_or_default();
2699            let size = if s == "filesize" {
2700                None
2701            } else {
2702                let size = parse_memory(s).map_err(|e| {
2703                    UnknownVtl2RelocationType(format!(
2704                        "unable to parse memory size from {} for 'auto=' type, {e}",
2705                        e
2706                    ))
2707                })?;
2708                Some(size)
2709            };
2710            Ok(Vtl2BaseAddressType::MemoryLayout { size })
2711        }
2712        s if s.starts_with("absolute=") => {
2713            let s = s.strip_prefix("absolute=");
2714            let addr = parse_number(s.unwrap_or_default()).map_err(|e| {
2715                UnknownVtl2RelocationType(format!(
2716                    "unable to parse number from {} for 'absolute=' type",
2717                    e
2718                ))
2719            })?;
2720            Ok(Vtl2BaseAddressType::Absolute(addr))
2721        }
2722        s if s.starts_with("vtl2=") => {
2723            let s = s.strip_prefix("vtl2=").unwrap_or_default();
2724            let size = if s == "filesize" {
2725                None
2726            } else {
2727                let size = parse_memory(s).map_err(|e| {
2728                    UnknownVtl2RelocationType(format!(
2729                        "unable to parse memory size from {} for 'vtl2=' type, {e}",
2730                        e
2731                    ))
2732                })?;
2733                Some(size)
2734            };
2735            Ok(Vtl2BaseAddressType::Vtl2Allocate { size })
2736        }
2737        _ => Err(UnknownVtl2RelocationType(s.to_owned())),
2738    }
2739}
2740
2741#[derive(Debug, Copy, Clone, PartialEq)]
2742pub enum SmtConfigCli {
2743    Auto,
2744    Force,
2745    Off,
2746}
2747
2748#[derive(Debug, Error)]
2749#[error("expected auto, force, or off")]
2750pub struct BadSmtConfig;
2751
2752impl FromStr for SmtConfigCli {
2753    type Err = BadSmtConfig;
2754
2755    fn from_str(s: &str) -> Result<Self, Self::Err> {
2756        let r = match s {
2757            "auto" => Self::Auto,
2758            "force" => Self::Force,
2759            "off" => Self::Off,
2760            _ => return Err(BadSmtConfig),
2761        };
2762        Ok(r)
2763    }
2764}
2765
2766#[cfg_attr(not(guest_arch = "x86_64"), expect(dead_code))]
2767fn parse_x2apic(s: &str) -> Result<X2ApicConfig, &'static str> {
2768    let r = match s {
2769        "auto" => X2ApicConfig::Auto,
2770        "supported" => X2ApicConfig::Supported,
2771        "off" => X2ApicConfig::Unsupported,
2772        "on" => X2ApicConfig::Enabled,
2773        _ => return Err("expected auto, supported, off, or on"),
2774    };
2775    Ok(r)
2776}
2777
2778#[derive(Debug, Copy, Clone, ValueEnum)]
2779pub enum Vtl0LateMapPolicyCli {
2780    Off,
2781    Log,
2782    Halt,
2783    Exception,
2784}
2785
2786/// PCIe MSI controller selection for aarch64.
2787#[derive(Debug, Copy, Clone, Default, ValueEnum)]
2788pub enum GicMsiCli {
2789    /// Use ITS when available, fall back to GICv2m.
2790    #[default]
2791    Auto,
2792    /// Force GICv3 ITS (LPI-based MSIs).
2793    Its,
2794    /// Force GICv2m (SPI-based MSIs).
2795    V2m,
2796}
2797
2798#[derive(Debug, Copy, Clone, ValueEnum)]
2799pub enum IsolationCli {
2800    Vbs,
2801}
2802
2803#[derive(Debug, Copy, Clone, PartialEq)]
2804pub struct PcatBootOrderCli(pub [PcatBootDevice; 4]);
2805
2806impl FromStr for PcatBootOrderCli {
2807    type Err = &'static str;
2808
2809    fn from_str(s: &str) -> Result<Self, Self::Err> {
2810        let mut default_order = DEFAULT_PCAT_BOOT_ORDER.map(Some);
2811        let mut order = Vec::new();
2812
2813        for item in s.split(',') {
2814            let device = match item {
2815                "optical" => PcatBootDevice::Optical,
2816                "hdd" => PcatBootDevice::HardDrive,
2817                "net" => PcatBootDevice::Network,
2818                "floppy" => PcatBootDevice::Floppy,
2819                _ => return Err("unknown boot device type"),
2820            };
2821
2822            let default_pos = default_order
2823                .iter()
2824                .position(|x| x == &Some(device))
2825                .ok_or("cannot pass duplicate boot devices")?;
2826
2827            order.push(default_order[default_pos].take().unwrap());
2828        }
2829
2830        order.extend(default_order.into_iter().flatten());
2831        assert_eq!(order.len(), 4);
2832
2833        Ok(Self(order.try_into().unwrap()))
2834    }
2835}
2836
2837#[derive(Copy, Clone, Debug, ValueEnum)]
2838pub enum UefiConsoleModeCli {
2839    Default,
2840    Com1,
2841    Com2,
2842    None,
2843}
2844
2845#[derive(Copy, Clone, Debug, Default, ValueEnum)]
2846pub enum EfiDiagnosticsLogLevelCli {
2847    #[default]
2848    Default,
2849    Info,
2850    Full,
2851}
2852
2853#[derive(Clone, Debug, PartialEq)]
2854pub struct PcieRootComplexCli {
2855    pub name: String,
2856    pub segment: u16,
2857    pub start_bus: u8,
2858    pub end_bus: u8,
2859    pub low_mmio: u32,
2860    pub high_mmio: u64,
2861    pub hdm: u64,
2862    pub hdm_window_restrictions: CfmwsWindowRestrictions,
2863}
2864
2865impl FromStr for PcieRootComplexCli {
2866    type Err = anyhow::Error;
2867
2868    fn from_str(s: &str) -> Result<Self, Self::Err> {
2869        const DEFAULT_PCIE_CRS_LOW_SIZE: u32 = 64 * 1024 * 1024; // 64M
2870        const DEFAULT_PCIE_CRS_HIGH_SIZE: u64 = 1024 * 1024 * 1024; // 1G
2871        const DEFAULT_PCIE_HDM_SIZE: u64 = 1024 * 1024 * 1024; // 1G
2872        const DEFAULT_HDM_WINDOW_RESTRICTIONS: CfmwsWindowRestrictions =
2873            CfmwsWindowRestrictions::DEVICE_COHERENT;
2874
2875        let mut opts = s.split(',');
2876        let name = opts.next().context("expected root complex name")?;
2877        if name.is_empty() {
2878            anyhow::bail!("must provide a root complex name");
2879        }
2880
2881        let mut segment = 0;
2882        let mut start_bus = 0;
2883        let mut end_bus = 255;
2884        let mut low_mmio = DEFAULT_PCIE_CRS_LOW_SIZE;
2885        let mut high_mmio = DEFAULT_PCIE_CRS_HIGH_SIZE;
2886        let mut hdm = DEFAULT_PCIE_HDM_SIZE;
2887        let mut hdm_window_restrictions = DEFAULT_HDM_WINDOW_RESTRICTIONS;
2888        for opt in opts {
2889            let mut s = opt.split('=');
2890            let opt = s.next().context("expected option")?;
2891            match opt {
2892                "segment" => {
2893                    let seg_str = s.next().context("expected segment number")?;
2894                    segment = u16::from_str(seg_str).context("failed to parse segment number")?;
2895                }
2896                "start_bus" => {
2897                    let bus_str = s.next().context("expected start bus number")?;
2898                    start_bus =
2899                        u8::from_str(bus_str).context("failed to parse start bus number")?;
2900                }
2901                "end_bus" => {
2902                    let bus_str = s.next().context("expected end bus number")?;
2903                    end_bus = u8::from_str(bus_str).context("failed to parse end bus number")?;
2904                }
2905                "low_mmio" => {
2906                    let low_mmio_str = s.next().context("expected low MMIO size")?;
2907                    low_mmio = parse_memory(low_mmio_str)
2908                        .context("failed to parse low MMIO size")?
2909                        .try_into()?;
2910                }
2911                "high_mmio" => {
2912                    let high_mmio_str = s.next().context("expected high MMIO size")?;
2913                    high_mmio =
2914                        parse_memory(high_mmio_str).context("failed to parse high MMIO size")?;
2915                }
2916                "hdm" => {
2917                    let hdm_str = s.next().context("expected HDM decoder size")?;
2918                    hdm = parse_memory(hdm_str).context("failed to parse HDM decoder size")?;
2919                }
2920                "hdm_window_restrictions" => {
2921                    let mask_str = s
2922                        .next()
2923                        .context("expected HDM window restrictions bitmask")?;
2924                    hdm_window_restrictions =
2925                        parse_cxl_cfmws_window_restriction_u16_bitmask(mask_str)
2926                            .context("failed to parse HDM window restrictions bitmask")?;
2927                }
2928                opt => anyhow::bail!("unknown option: '{opt}'"),
2929            }
2930        }
2931
2932        if start_bus >= end_bus {
2933            anyhow::bail!("start_bus must be less than or equal to end_bus");
2934        }
2935
2936        Ok(PcieRootComplexCli {
2937            name: name.to_string(),
2938            segment,
2939            start_bus,
2940            end_bus,
2941            low_mmio,
2942            high_mmio,
2943            hdm,
2944            hdm_window_restrictions,
2945        })
2946    }
2947}
2948
2949fn parse_cxl_cfmws_window_restriction_u16_bitmask(
2950    s: &str,
2951) -> anyhow::Result<CfmwsWindowRestrictions> {
2952    let bits = if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
2953        u16::from_str_radix(hex, 16).context("invalid hex bitmask")?
2954    } else {
2955        u16::from_str(s).context("invalid decimal bitmask")?
2956    };
2957
2958    CfmwsWindowRestrictions::try_from_bits(bits)
2959        .context("bitmask includes reserved CFMWS window restriction bits")
2960}
2961
2962#[derive(Clone, Debug, PartialEq)]
2963pub struct PcieRootPortCli {
2964    pub root_complex_name: String,
2965    pub name: String,
2966    pub hotplug: bool,
2967    pub acs_capabilities_supported: Option<u16>,
2968    pub cxl: bool,
2969}
2970
2971impl FromStr for PcieRootPortCli {
2972    type Err = anyhow::Error;
2973
2974    fn from_str(s: &str) -> Result<Self, Self::Err> {
2975        let mut opts = s.split(',');
2976        let names = opts.next().context("expected root port identifiers")?;
2977        if names.is_empty() {
2978            anyhow::bail!("must provide root port identifiers");
2979        }
2980
2981        let mut s = names.split(':');
2982        let rc_name = s.next().context("expected name of parent root complex")?;
2983        let rp_name = s.next().context("expected root port name")?;
2984
2985        if let Some(extra) = s.next() {
2986            anyhow::bail!("unexpected token: '{extra}'")
2987        }
2988
2989        let mut hotplug = false;
2990        let mut acs_capabilities_supported = None;
2991        let mut cxl = false;
2992
2993        // Parse optional flags
2994        for opt in opts {
2995            let mut kv = opt.split('=');
2996            let key = kv.next().context("expected option name")?;
2997            let value = kv.next();
2998
2999            match key {
3000                "hotplug" => {
3001                    if value.is_some() {
3002                        anyhow::bail!("hotplug option does not take a value")
3003                    }
3004                    hotplug = true;
3005                }
3006                "acs" => {
3007                    let value = value.context("acs option requires a value")?;
3008                    if kv.next().is_some() {
3009                        anyhow::bail!("acs option expects a single value")
3010                    }
3011                    acs_capabilities_supported = Some(parse_acs_capability_mask(value)?);
3012                }
3013                "cxl" => {
3014                    if value.is_some() {
3015                        anyhow::bail!("cxl option does not take a value")
3016                    }
3017                    cxl = true;
3018                }
3019                _ => anyhow::bail!("unexpected option: '{opt}'"),
3020            }
3021        }
3022
3023        Ok(PcieRootPortCli {
3024            root_complex_name: rc_name.to_string(),
3025            name: rp_name.to_string(),
3026            hotplug,
3027            acs_capabilities_supported,
3028            cxl,
3029        })
3030    }
3031}
3032
3033#[derive(Clone, Debug, PartialEq)]
3034pub struct GenericPcieSwitchCli {
3035    pub port_name: String,
3036    pub name: String,
3037    pub num_downstream_ports: u8,
3038    pub hotplug: bool,
3039    pub acs_capabilities_supported: Option<u16>,
3040}
3041
3042impl FromStr for GenericPcieSwitchCli {
3043    type Err = anyhow::Error;
3044
3045    fn from_str(s: &str) -> Result<Self, Self::Err> {
3046        let mut opts = s.split(',');
3047        let names = opts.next().context("expected switch identifiers")?;
3048        if names.is_empty() {
3049            anyhow::bail!("must provide switch identifiers");
3050        }
3051
3052        let mut s = names.split(':');
3053        let port_name = s.next().context("expected name of parent port")?;
3054        let switch_name = s.next().context("expected switch name")?;
3055
3056        if let Some(extra) = s.next() {
3057            anyhow::bail!("unexpected token: '{extra}'")
3058        }
3059
3060        let mut num_downstream_ports = 4u8; // Default value
3061        let mut hotplug = false;
3062        let mut acs_capabilities_supported = None;
3063
3064        for opt in opts {
3065            let mut kv = opt.split('=');
3066            let key = kv.next().context("expected option name")?;
3067
3068            match key {
3069                "num_downstream_ports" => {
3070                    let value = kv.next().context("expected option value")?;
3071                    if let Some(extra) = kv.next() {
3072                        anyhow::bail!("unexpected token: '{extra}'")
3073                    }
3074                    num_downstream_ports = value.parse().context("invalid num_downstream_ports")?;
3075                }
3076                "hotplug" => {
3077                    if kv.next().is_some() {
3078                        anyhow::bail!("hotplug option does not take a value")
3079                    }
3080                    hotplug = true;
3081                }
3082                "acs" => {
3083                    let value = kv.next().context("acs option requires a value")?;
3084                    if kv.next().is_some() {
3085                        anyhow::bail!("acs option expects a single value")
3086                    }
3087                    acs_capabilities_supported = Some(parse_acs_capability_mask(value)?);
3088                }
3089                _ => anyhow::bail!("unknown option: '{key}'"),
3090            }
3091        }
3092
3093        Ok(GenericPcieSwitchCli {
3094            port_name: port_name.to_string(),
3095            name: switch_name.to_string(),
3096            num_downstream_ports,
3097            hotplug,
3098            acs_capabilities_supported,
3099        })
3100    }
3101}
3102
3103/// CLI configuration for a PCIe remote device.
3104#[derive(Clone, Debug, PartialEq)]
3105pub struct PcieRemoteCli {
3106    /// Name of the PCIe downstream port to attach to.
3107    pub port_name: String,
3108    /// TCP socket address for the remote simulator.
3109    pub socket_addr: Option<String>,
3110    /// Hardware unit identifier for plug request.
3111    pub hu: u16,
3112    /// Controller identifier for plug request.
3113    pub controller: u16,
3114}
3115
3116impl FromStr for PcieRemoteCli {
3117    type Err = anyhow::Error;
3118
3119    fn from_str(s: &str) -> Result<Self, Self::Err> {
3120        let mut opts = s.split(',');
3121        let port_name = opts.next().context("expected port name")?;
3122        if port_name.is_empty() {
3123            anyhow::bail!("must provide a port name");
3124        }
3125
3126        let mut socket_addr = None;
3127        let mut hu = 0u16;
3128        let mut controller = 0u16;
3129
3130        for opt in opts {
3131            let mut kv = opt.split('=');
3132            let key = kv.next().context("expected option name")?;
3133            let value = kv.next();
3134
3135            match key {
3136                "socket" => {
3137                    let addr = value.context("socket requires an address")?;
3138                    if let Some(extra) = kv.next() {
3139                        anyhow::bail!("unexpected token: '{extra}'")
3140                    }
3141                    if addr.is_empty() {
3142                        anyhow::bail!("socket address cannot be empty");
3143                    }
3144                    socket_addr = Some(addr.to_string());
3145                }
3146                "hu" => {
3147                    let val = value.context("hu requires a value")?;
3148                    if let Some(extra) = kv.next() {
3149                        anyhow::bail!("unexpected token: '{extra}'")
3150                    }
3151                    hu = val.parse().context("failed to parse hu")?;
3152                }
3153                "controller" => {
3154                    let val = value.context("controller requires a value")?;
3155                    if let Some(extra) = kv.next() {
3156                        anyhow::bail!("unexpected token: '{extra}'")
3157                    }
3158                    controller = val.parse().context("failed to parse controller")?;
3159                }
3160                _ => anyhow::bail!("unknown option: '{key}'"),
3161            }
3162        }
3163
3164        Ok(PcieRemoteCli {
3165            port_name: port_name.to_string(),
3166            socket_addr,
3167            hu,
3168            controller,
3169        })
3170    }
3171}
3172
3173/// CLI configuration for a VFIO-assigned PCI device.
3174///
3175/// Syntax: `host=<bdf>,port=<name>[,iommu=<id>]`
3176#[cfg(target_os = "linux")]
3177#[derive(Clone, Debug)]
3178pub struct VfioDeviceCli {
3179    /// Name of the PCIe downstream port to attach to.
3180    pub port_name: String,
3181    /// PCI BDF address of the device on the host (e.g., "0000:01:00.0").
3182    pub pci_id: String,
3183    /// Optional iommufd context ID. When set, uses VFIO cdev + iommufd
3184    /// instead of the legacy group/container path.
3185    pub iommu: Option<String>,
3186}
3187
3188#[cfg(target_os = "linux")]
3189impl FromStr for VfioDeviceCli {
3190    type Err = anyhow::Error;
3191
3192    fn from_str(s: &str) -> Result<Self, Self::Err> {
3193        let mut host: Option<String> = None;
3194        let mut port: Option<String> = None;
3195        let mut iommu: Option<String> = None;
3196
3197        for kv in s.split(',') {
3198            let (key, value) = kv
3199                .split_once('=')
3200                .context("expected key=value pair (e.g., host=0000:01:00.0,port=rp0)")?;
3201            if value.is_empty() {
3202                anyhow::bail!("--vfio: '{key}=' value cannot be empty");
3203            }
3204            match key {
3205                "host" => {
3206                    if host.is_some() {
3207                        anyhow::bail!("duplicate --vfio key: 'host'");
3208                    }
3209                    host = Some(value.to_string());
3210                }
3211                "port" => {
3212                    if port.is_some() {
3213                        anyhow::bail!("duplicate --vfio key: 'port'");
3214                    }
3215                    port = Some(value.to_string());
3216                }
3217                "iommu" => {
3218                    if iommu.is_some() {
3219                        anyhow::bail!("duplicate --vfio key: 'iommu'");
3220                    }
3221                    iommu = Some(value.to_string());
3222                }
3223                _ => anyhow::bail!("unknown --vfio key: '{key}'"),
3224            }
3225        }
3226
3227        let pci_id = host.context("--vfio: 'host=' is required")?;
3228        let port_name = port.context("--vfio: 'port=' is required")?;
3229
3230        // Reject path separators to prevent sysfs path traversal via Path::join.
3231        if pci_id.contains('/') || pci_id.contains("..") {
3232            anyhow::bail!("PCI address must not contain path separators");
3233        }
3234
3235        Ok(VfioDeviceCli {
3236            port_name,
3237            pci_id,
3238            iommu,
3239        })
3240    }
3241}
3242
3243/// CLI configuration for an iommufd context.
3244///
3245/// Syntax: `id=<name>`
3246#[cfg(target_os = "linux")]
3247#[derive(Clone, Debug)]
3248pub struct IommuCli {
3249    /// Unique identifier for this iommufd context.
3250    pub id: String,
3251}
3252
3253#[cfg(target_os = "linux")]
3254impl FromStr for IommuCli {
3255    type Err = anyhow::Error;
3256
3257    fn from_str(s: &str) -> Result<Self, Self::Err> {
3258        let (key, value) = s
3259            .split_once('=')
3260            .context("expected id=<name> (e.g., id=iommu0)")?;
3261        if key != "id" {
3262            anyhow::bail!("expected 'id=<name>', got '{key}=...'");
3263        }
3264        if value.is_empty() {
3265            anyhow::bail!("iommu id cannot be empty");
3266        }
3267        Ok(IommuCli {
3268            id: value.to_string(),
3269        })
3270    }
3271}
3272
3273/// Read a environment variable that may / may-not have a target-specific
3274/// prefix. e.g: `default_value_from_arch_env("FOO")` would first try and read
3275/// from `FOO`, and if that's not found, it will try `X86_64_FOO`.
3276///
3277/// Must return an `OsString`, in order to be compatible with `clap`'s
3278/// default_value code. As such - to encode the absence of the env-var, an empty
3279/// OsString is returned.
3280fn default_value_from_arch_env(name: &str) -> OsString {
3281    let prefix = if cfg!(guest_arch = "x86_64") {
3282        "X86_64"
3283    } else if cfg!(guest_arch = "aarch64") {
3284        "AARCH64"
3285    } else {
3286        return Default::default();
3287    };
3288    let prefixed = format!("{}_{}", prefix, name);
3289    std::env::var_os(name)
3290        .or_else(|| std::env::var_os(prefixed))
3291        .unwrap_or_default()
3292}
3293
3294/// Workaround to use `Option<PathBuf>` alongside [`default_value_from_arch_env`]
3295#[derive(Clone)]
3296pub struct OptionalPathBuf(pub Option<PathBuf>);
3297
3298impl From<&std::ffi::OsStr> for OptionalPathBuf {
3299    fn from(s: &std::ffi::OsStr) -> Self {
3300        OptionalPathBuf(if s.is_empty() { None } else { Some(s.into()) })
3301    }
3302}
3303
3304#[cfg(target_os = "linux")]
3305#[derive(Clone)]
3306pub enum VhostUserDeviceTypeCli {
3307    /// Block device — config from backend via GET_CONFIG, with num_queues
3308    /// patched by the frontend.
3309    Blk {
3310        num_queues: Option<u16>,
3311        queue_size: Option<u16>,
3312    },
3313    /// Filesystem device — frontend-owned config with mount tag.
3314    Fs {
3315        tag: String,
3316        num_queues: Option<u16>,
3317        queue_size: Option<u16>,
3318    },
3319    /// Generic device identified by numeric virtio device ID.
3320    Other {
3321        device_id: u16,
3322        queue_sizes: Vec<u16>,
3323    },
3324}
3325
3326#[cfg(target_os = "linux")]
3327#[derive(Clone)]
3328pub struct VhostUserCli {
3329    pub socket_path: String,
3330    pub device_type: VhostUserDeviceTypeCli,
3331    pub pcie_port: Option<String>,
3332}
3333
3334/// Split a string on commas, but not inside `[…]` brackets.
3335///
3336/// Returns an error on mismatched brackets (unmatched `]` or unclosed `[`).
3337#[cfg(target_os = "linux")]
3338fn split_respecting_brackets(s: &str) -> anyhow::Result<Vec<&str>> {
3339    let mut result = Vec::new();
3340    let mut start = 0;
3341    let mut depth: i32 = 0;
3342    for (i, c) in s.char_indices() {
3343        match c {
3344            '[' => depth += 1,
3345            ']' => {
3346                depth -= 1;
3347                anyhow::ensure!(depth >= 0, "unmatched ']' in option string");
3348            }
3349            ',' if depth == 0 => {
3350                result.push(&s[start..i]);
3351                start = i + 1;
3352            }
3353            _ => {}
3354        }
3355    }
3356    anyhow::ensure!(depth == 0, "unclosed '[' in option string");
3357    result.push(&s[start..]);
3358    Ok(result)
3359}
3360
3361#[cfg(target_os = "linux")]
3362impl FromStr for VhostUserCli {
3363    type Err = anyhow::Error;
3364
3365    fn from_str(s: &str) -> anyhow::Result<Self> {
3366        // Split on commas, but not inside brackets (for queue_sizes=[N,N]).
3367        let parts = split_respecting_brackets(s)?;
3368        let mut parts_iter = parts.into_iter();
3369        let socket_path = parts_iter
3370            .next()
3371            .context("missing socket path")?
3372            .to_string();
3373
3374        let mut device_id: Option<u16> = None;
3375        let mut tag: Option<String> = None;
3376        let mut pcie_port: Option<String> = None;
3377        let mut type_name = None;
3378        let mut num_queues: Option<u16> = None;
3379        let mut queue_size: Option<u16> = None;
3380        let mut queue_sizes: Option<Vec<u16>> = None;
3381        for opt in parts_iter {
3382            let (key, val) = opt.split_once('=').context("expected key=value option")?;
3383            match key {
3384                "type" => {
3385                    type_name = Some(val);
3386                }
3387                "device_id" => {
3388                    device_id = Some(val.parse().context("invalid device_id")?);
3389                }
3390                "tag" => {
3391                    tag = Some(val.to_string());
3392                }
3393                "pcie_port" => {
3394                    pcie_port = Some(val.to_string());
3395                }
3396                "num_queues" => {
3397                    num_queues = Some(val.parse().context("invalid num_queues")?);
3398                }
3399                "queue_size" => {
3400                    queue_size = Some(val.parse().context("invalid queue_size")?);
3401                }
3402                "queue_sizes" => {
3403                    // Parse bracket-delimited comma-separated list: [N,N,N]
3404                    let trimmed = val
3405                        .strip_prefix('[')
3406                        .and_then(|v| v.strip_suffix(']'))
3407                        .context("queue_sizes must be bracketed: [N,N,N]")?;
3408                    let sizes: Vec<u16> = trimmed
3409                        .split(',')
3410                        .map(|s| s.parse().context("invalid queue size in queue_sizes"))
3411                        .collect::<anyhow::Result<_>>()?;
3412                    anyhow::ensure!(!sizes.is_empty(), "queue_sizes must be non-empty");
3413                    queue_sizes = Some(sizes);
3414                }
3415                other => anyhow::bail!("unknown vhost-user option: '{other}'"),
3416            }
3417        }
3418
3419        if type_name.is_some() == device_id.is_some() {
3420            anyhow::bail!("must specify type=<name> or device_id=<N>");
3421        }
3422
3423        // Build the typed device variant.
3424        let device_type = match type_name {
3425            Some("fs") => {
3426                let tag = tag.take().context("type=fs requires tag=<name>")?;
3427                VhostUserDeviceTypeCli::Fs {
3428                    tag,
3429                    num_queues: num_queues.take(),
3430                    queue_size: queue_size.take(),
3431                }
3432            }
3433            Some("blk") => VhostUserDeviceTypeCli::Blk {
3434                num_queues: num_queues.take(),
3435                queue_size: queue_size.take(),
3436            },
3437            Some(ty) => anyhow::bail!("unknown vhost-user device type: '{ty}'"),
3438            None => {
3439                let queue_sizes = queue_sizes
3440                    .take()
3441                    .context("device_id= requires queue_sizes=[N,N,...]")?;
3442                VhostUserDeviceTypeCli::Other {
3443                    device_id: device_id.unwrap(),
3444                    queue_sizes,
3445                }
3446            }
3447        };
3448
3449        if tag.is_some() {
3450            anyhow::bail!("tag= is only valid for type=fs");
3451        }
3452        if queue_sizes.is_some() {
3453            anyhow::bail!("queue_sizes= is only valid for device_id=");
3454        }
3455        if num_queues.is_some() || queue_size.is_some() {
3456            anyhow::bail!(
3457                "num_queues= and queue_size= are not valid for device_id=; use queue_sizes="
3458            );
3459        }
3460
3461        Ok(VhostUserCli {
3462            socket_path,
3463            device_type,
3464            pcie_port,
3465        })
3466    }
3467}
3468
3469#[cfg(test)]
3470mod tests {
3471    use super::*;
3472
3473    use std::path::Path;
3474
3475    #[test]
3476    fn test_parse_file_opts() {
3477        // file: prefix with create
3478        let disk = DiskCliKind::from_str("file:test.vhd;create=1G").unwrap();
3479        assert!(matches!(
3480            &disk,
3481            DiskCliKind::File { path, create_with_len: Some(len), direct: false }
3482                if path == Path::new("test.vhd") && *len == 1024 * 1024 * 1024
3483        ));
3484
3485        // bare path with create (no file: prefix)
3486        let disk = DiskCliKind::from_str("test.vhd;create=1G").unwrap();
3487        assert!(matches!(
3488            &disk,
3489            DiskCliKind::File { path, create_with_len: Some(len), direct: false }
3490                if path == Path::new("test.vhd") && *len == 1024 * 1024 * 1024
3491        ));
3492
3493        // direct flag
3494        let disk = DiskCliKind::from_str("file:/dev/sdb;direct").unwrap();
3495        assert!(matches!(
3496            &disk,
3497            DiskCliKind::File { path, create_with_len: None, direct: true }
3498                if path == Path::new("/dev/sdb")
3499        ));
3500
3501        // direct + create in either order
3502        let disk = DiskCliKind::from_str("file:disk.img;direct;create=1G").unwrap();
3503        assert!(matches!(
3504            &disk,
3505            DiskCliKind::File { path, create_with_len: Some(len), direct: true }
3506                if path == Path::new("disk.img") && *len == 1024 * 1024 * 1024
3507        ));
3508
3509        let disk = DiskCliKind::from_str("file:disk.img;create=1G;direct").unwrap();
3510        assert!(matches!(
3511            &disk,
3512            DiskCliKind::File { path, create_with_len: Some(len), direct: true }
3513                if path == Path::new("disk.img") && *len == 1024 * 1024 * 1024
3514        ));
3515
3516        // plain path, no options
3517        let disk = DiskCliKind::from_str("file:disk.img").unwrap();
3518        assert!(matches!(
3519            &disk,
3520            DiskCliKind::File { path, create_with_len: None, direct: false }
3521                if path == Path::new("disk.img")
3522        ));
3523
3524        // invalid option rejected
3525        assert!(DiskCliKind::from_str("file:disk.img;bogus").is_err());
3526
3527        // direct rejected for sql disks
3528        assert!(DiskCliKind::from_str("sql:db.sqlite;direct").is_err());
3529    }
3530
3531    #[test]
3532    fn test_parse_memory_disk() {
3533        let s = "mem:1G";
3534        let disk = DiskCliKind::from_str(s).unwrap();
3535        match disk {
3536            DiskCliKind::Memory(size) => {
3537                assert_eq!(size, 1024 * 1024 * 1024); // 1G
3538            }
3539            _ => panic!("Expected Memory variant"),
3540        }
3541    }
3542
3543    #[test]
3544    fn test_parse_pcie_disk() {
3545        assert_eq!(
3546            DiskCli::from_str("mem:1G,pcie_port=p0").unwrap().pcie_port,
3547            Some("p0".to_string())
3548        );
3549        assert_eq!(
3550            DiskCli::from_str("file:path.vhdx,pcie_port=p0")
3551                .unwrap()
3552                .pcie_port,
3553            Some("p0".to_string())
3554        );
3555        assert_eq!(
3556            DiskCli::from_str("memdiff:file:path.vhdx,pcie_port=p0")
3557                .unwrap()
3558                .pcie_port,
3559            Some("p0".to_string())
3560        );
3561
3562        // Missing port name
3563        assert!(DiskCli::from_str("file:disk.vhd,pcie_port=").is_err());
3564
3565        // Incompatible with various other disk fields
3566        assert!(DiskCli::from_str("file:disk.vhd,pcie_port=p0,vtl2").is_err());
3567        assert!(DiskCli::from_str("file:disk.vhd,pcie_port=p0,uh").is_err());
3568        assert!(DiskCli::from_str("file:disk.vhd,pcie_port=p0,uh-nvme").is_err());
3569    }
3570
3571    #[test]
3572    fn test_parse_memory_diff_disk() {
3573        let s = "memdiff:file:base.img";
3574        let disk = DiskCliKind::from_str(s).unwrap();
3575        match disk {
3576            DiskCliKind::MemoryDiff(inner) => match *inner {
3577                DiskCliKind::File {
3578                    path,
3579                    create_with_len,
3580                    ..
3581                } => {
3582                    assert_eq!(path, PathBuf::from("base.img"));
3583                    assert_eq!(create_with_len, None);
3584                }
3585                _ => panic!("Expected File variant inside MemoryDiff"),
3586            },
3587            _ => panic!("Expected MemoryDiff variant"),
3588        }
3589    }
3590
3591    #[test]
3592    fn test_parse_sqlite_disk() {
3593        let s = "sql:db.sqlite;create=2G";
3594        let disk = DiskCliKind::from_str(s).unwrap();
3595        match disk {
3596            DiskCliKind::Sqlite {
3597                path,
3598                create_with_len,
3599            } => {
3600                assert_eq!(path, PathBuf::from("db.sqlite"));
3601                assert_eq!(create_with_len, Some(2 * 1024 * 1024 * 1024));
3602            }
3603            _ => panic!("Expected Sqlite variant"),
3604        }
3605
3606        // Test without create option
3607        let s = "sql:db.sqlite";
3608        let disk = DiskCliKind::from_str(s).unwrap();
3609        match disk {
3610            DiskCliKind::Sqlite {
3611                path,
3612                create_with_len,
3613            } => {
3614                assert_eq!(path, PathBuf::from("db.sqlite"));
3615                assert_eq!(create_with_len, None);
3616            }
3617            _ => panic!("Expected Sqlite variant"),
3618        }
3619    }
3620
3621    #[test]
3622    fn test_parse_sqlite_diff_disk() {
3623        // Test with create option
3624        let s = "sqldiff:diff.sqlite;create:file:base.img";
3625        let disk = DiskCliKind::from_str(s).unwrap();
3626        match disk {
3627            DiskCliKind::SqliteDiff { path, create, disk } => {
3628                assert_eq!(path, PathBuf::from("diff.sqlite"));
3629                assert!(create);
3630                match *disk {
3631                    DiskCliKind::File {
3632                        path,
3633                        create_with_len,
3634                        ..
3635                    } => {
3636                        assert_eq!(path, PathBuf::from("base.img"));
3637                        assert_eq!(create_with_len, None);
3638                    }
3639                    _ => panic!("Expected File variant inside SqliteDiff"),
3640                }
3641            }
3642            _ => panic!("Expected SqliteDiff variant"),
3643        }
3644
3645        // Test without create option
3646        let s = "sqldiff:diff.sqlite:file:base.img";
3647        let disk = DiskCliKind::from_str(s).unwrap();
3648        match disk {
3649            DiskCliKind::SqliteDiff { path, create, disk } => {
3650                assert_eq!(path, PathBuf::from("diff.sqlite"));
3651                assert!(!create);
3652                match *disk {
3653                    DiskCliKind::File {
3654                        path,
3655                        create_with_len,
3656                        ..
3657                    } => {
3658                        assert_eq!(path, PathBuf::from("base.img"));
3659                        assert_eq!(create_with_len, None);
3660                    }
3661                    _ => panic!("Expected File variant inside SqliteDiff"),
3662                }
3663            }
3664            _ => panic!("Expected SqliteDiff variant"),
3665        }
3666    }
3667
3668    #[test]
3669    fn test_parse_autocache_sqlite_disk() {
3670        // Test with cache path provided
3671        let disk =
3672            DiskCliKind::parse_autocache(":file:disk.vhd", Ok("/tmp/cache".to_string())).unwrap();
3673        assert!(matches!(
3674            disk,
3675            DiskCliKind::AutoCacheSqlite {
3676                cache_path,
3677                key,
3678                disk: _disk,
3679            } if cache_path == "/tmp/cache" && key.is_none()
3680        ));
3681
3682        // Test with key
3683        let disk =
3684            DiskCliKind::parse_autocache("mykey:file:disk.vhd", Ok("/tmp/cache".to_string()))
3685                .unwrap();
3686        assert!(matches!(
3687            disk,
3688            DiskCliKind::AutoCacheSqlite {
3689                cache_path,
3690                key: Some(key),
3691                disk: _disk,
3692            } if cache_path == "/tmp/cache" && key == "mykey"
3693        ));
3694
3695        // Test without cache path
3696        assert!(
3697            DiskCliKind::parse_autocache(":file:disk.vhd", Err(std::env::VarError::NotPresent),)
3698                .is_err()
3699        );
3700    }
3701
3702    #[test]
3703    fn test_parse_disk_errors() {
3704        assert!(DiskCliKind::from_str("invalid:").is_err());
3705        assert!(DiskCliKind::from_str("memory:extra").is_err());
3706
3707        // Test sqlite: without environment variable
3708        assert!(DiskCliKind::from_str("sqlite:").is_err());
3709    }
3710
3711    #[test]
3712    fn test_parse_errors() {
3713        // Invalid memory size
3714        assert!(DiskCliKind::from_str("mem:invalid").is_err());
3715
3716        // Invalid syntax for SQLiteDiff
3717        assert!(DiskCliKind::from_str("sqldiff:path").is_err());
3718
3719        // Missing OPENVMM_AUTO_CACHE_PATH for AutoCacheSqlite
3720        assert!(
3721            DiskCliKind::parse_autocache("key:file:disk.vhd", Err(std::env::VarError::NotPresent),)
3722                .is_err()
3723        );
3724
3725        // Invalid blob kind
3726        assert!(DiskCliKind::from_str("blob:invalid:url").is_err());
3727
3728        // Invalid cipher
3729        assert!(DiskCliKind::from_str("crypt:invalid:key.bin:file:disk.vhd").is_err());
3730
3731        // Invalid format for crypt (missing parts)
3732        assert!(DiskCliKind::from_str("crypt:xts-aes-256:key.bin").is_err());
3733
3734        // Invalid disk kind
3735        assert!(DiskCliKind::from_str("invalid:path").is_err());
3736
3737        // Missing create size
3738        assert!(DiskCliKind::from_str("file:disk.vhd;create=").is_err());
3739    }
3740
3741    #[test]
3742    fn test_fs_args_from_str() {
3743        let args = FsArgs::from_str("tag1,/path/to/fs").unwrap();
3744        assert_eq!(args.tag, "tag1");
3745        assert_eq!(args.path, "/path/to/fs");
3746
3747        // Test error cases
3748        assert!(FsArgs::from_str("tag1").is_err());
3749        assert!(FsArgs::from_str("tag1,/path,extra").is_err());
3750    }
3751
3752    #[test]
3753    fn test_fs_args_with_options_from_str() {
3754        let args = FsArgsWithOptions::from_str("tag1,/path/to/fs,opt1,opt2").unwrap();
3755        assert_eq!(args.tag, "tag1");
3756        assert_eq!(args.path, "/path/to/fs");
3757        assert_eq!(args.options, "opt1;opt2");
3758
3759        // Test without options
3760        let args = FsArgsWithOptions::from_str("tag1,/path/to/fs").unwrap();
3761        assert_eq!(args.tag, "tag1");
3762        assert_eq!(args.path, "/path/to/fs");
3763        assert_eq!(args.options, "");
3764
3765        // Test error case
3766        assert!(FsArgsWithOptions::from_str("tag1").is_err());
3767    }
3768
3769    #[test]
3770    fn test_serial_config_from_str() {
3771        assert_eq!(
3772            SerialConfigCli::from_str("none").unwrap(),
3773            SerialConfigCli::None
3774        );
3775        assert_eq!(
3776            SerialConfigCli::from_str("console").unwrap(),
3777            SerialConfigCli::Console
3778        );
3779        assert_eq!(
3780            SerialConfigCli::from_str("stderr").unwrap(),
3781            SerialConfigCli::Stderr
3782        );
3783
3784        // Test file config
3785        let file_config = SerialConfigCli::from_str("file=/path/to/file").unwrap();
3786        if let SerialConfigCli::File(path) = file_config {
3787            assert_eq!(path.to_str().unwrap(), "/path/to/file");
3788        } else {
3789            panic!("Expected File variant");
3790        }
3791
3792        // Test term config with name, but no specific path
3793        match SerialConfigCli::from_str("term,name=MyTerm").unwrap() {
3794            SerialConfigCli::NewConsole(None, Some(name)) => {
3795                assert_eq!(name, "MyTerm");
3796            }
3797            _ => panic!("Expected NewConsole variant with name"),
3798        }
3799
3800        // Test term config without name, but no specific path
3801        match SerialConfigCli::from_str("term").unwrap() {
3802            SerialConfigCli::NewConsole(None, None) => (),
3803            _ => panic!("Expected NewConsole variant without name"),
3804        }
3805
3806        // Test term config with name
3807        match SerialConfigCli::from_str("term=/dev/pts/0,name=MyTerm").unwrap() {
3808            SerialConfigCli::NewConsole(Some(path), Some(name)) => {
3809                assert_eq!(path.to_str().unwrap(), "/dev/pts/0");
3810                assert_eq!(name, "MyTerm");
3811            }
3812            _ => panic!("Expected NewConsole variant with name"),
3813        }
3814
3815        // Test term config without name
3816        match SerialConfigCli::from_str("term=/dev/pts/0").unwrap() {
3817            SerialConfigCli::NewConsole(Some(path), None) => {
3818                assert_eq!(path.to_str().unwrap(), "/dev/pts/0");
3819            }
3820            _ => panic!("Expected NewConsole variant without name"),
3821        }
3822
3823        // Test TCP config
3824        match SerialConfigCli::from_str("listen=tcp:127.0.0.1:1234").unwrap() {
3825            SerialConfigCli::Tcp(addr) => {
3826                assert_eq!(addr.to_string(), "127.0.0.1:1234");
3827            }
3828            _ => panic!("Expected Tcp variant"),
3829        }
3830
3831        // Test pipe config
3832        match SerialConfigCli::from_str("listen=/path/to/pipe").unwrap() {
3833            SerialConfigCli::Pipe(path) => {
3834                assert_eq!(path.to_str().unwrap(), "/path/to/pipe");
3835            }
3836            _ => panic!("Expected Pipe variant"),
3837        }
3838
3839        // Test error cases
3840        assert!(SerialConfigCli::from_str("").is_err());
3841        assert!(SerialConfigCli::from_str("unknown").is_err());
3842        assert!(SerialConfigCli::from_str("file").is_err());
3843        assert!(SerialConfigCli::from_str("listen").is_err());
3844    }
3845
3846    #[test]
3847    fn test_endpoint_config_from_str() {
3848        // Test none
3849        assert!(matches!(
3850            EndpointConfigCli::from_str("none").unwrap(),
3851            EndpointConfigCli::None
3852        ));
3853
3854        // Test consomme without cidr
3855        match EndpointConfigCli::from_str("consomme").unwrap() {
3856            EndpointConfigCli::Consomme {
3857                cidr: None,
3858                host_fwd,
3859            } => assert!(host_fwd.is_empty()),
3860            _ => panic!("Expected Consomme variant without cidr"),
3861        }
3862
3863        // Test consomme with cidr
3864        match EndpointConfigCli::from_str("consomme:192.168.0.0/24").unwrap() {
3865            EndpointConfigCli::Consomme {
3866                cidr: Some(cidr),
3867                host_fwd,
3868            } => {
3869                assert_eq!(cidr, "192.168.0.0/24");
3870                assert!(host_fwd.is_empty());
3871            }
3872            _ => panic!("Expected Consomme variant with cidr"),
3873        }
3874
3875        // Test consomme with hostfwd
3876        match EndpointConfigCli::from_str("consomme:hostfwd=udp:127.0.0.1:5000-:5000").unwrap() {
3877            EndpointConfigCli::Consomme { cidr, host_fwd } => {
3878                assert!(cidr.is_none());
3879                assert_eq!(host_fwd.len(), 1);
3880                assert_eq!(host_fwd[0].protocol, HostPortProtocolCli::Udp);
3881                assert_eq!(
3882                    host_fwd[0].host_address,
3883                    Some(std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)))
3884                );
3885                assert_eq!(host_fwd[0].host_port, 5000);
3886                assert_eq!(host_fwd[0].guest_port, 5000);
3887            }
3888            _ => panic!("Expected Consomme variant with hostfwd"),
3889        }
3890
3891        // Test consomme with cidr and hostfwd
3892        match EndpointConfigCli::from_str("consomme:10.0.0.0/24,hostfwd=tcp::2222-:22").unwrap() {
3893            EndpointConfigCli::Consomme { cidr, host_fwd } => {
3894                assert_eq!(cidr.as_deref(), Some("10.0.0.0/24"));
3895                assert_eq!(host_fwd.len(), 1);
3896                assert_eq!(host_fwd[0].protocol, HostPortProtocolCli::Tcp);
3897                assert_eq!(host_fwd[0].host_port, 2222);
3898                assert_eq!(host_fwd[0].guest_port, 22);
3899            }
3900            _ => panic!("Expected Consomme variant with cidr and hostfwd"),
3901        }
3902
3903        // Test consomme with multiple hostfwd
3904        match EndpointConfigCli::from_str("consomme:hostfwd=tcp::2222-:22,hostfwd=tcp::3389-:3389")
3905            .unwrap()
3906        {
3907            EndpointConfigCli::Consomme { cidr, host_fwd } => {
3908                assert!(cidr.is_none());
3909                assert_eq!(host_fwd.len(), 2);
3910                assert_eq!(host_fwd[0].host_port, 2222);
3911                assert_eq!(host_fwd[0].guest_port, 22);
3912                assert_eq!(host_fwd[1].host_port, 3389);
3913                assert_eq!(host_fwd[1].guest_port, 3389);
3914            }
3915            _ => panic!("Expected Consomme variant with multiple hostfwd"),
3916        }
3917
3918        // Test consomme with different host and guest ports
3919        match EndpointConfigCli::from_str("consomme:hostfwd=tcp:127.0.0.1:8080-:80").unwrap() {
3920            EndpointConfigCli::Consomme { cidr, host_fwd } => {
3921                assert!(cidr.is_none());
3922                assert_eq!(host_fwd.len(), 1);
3923                assert_eq!(host_fwd[0].protocol, HostPortProtocolCli::Tcp);
3924                assert_eq!(
3925                    host_fwd[0].host_address,
3926                    Some(std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)))
3927                );
3928                assert_eq!(host_fwd[0].host_port, 8080);
3929                assert_eq!(host_fwd[0].guest_port, 80);
3930            }
3931            _ => panic!("Expected Consomme variant with host/guest port mapping"),
3932        }
3933
3934        // Test consomme with guest address (accepted but ignored by backend)
3935        match EndpointConfigCli::from_str("consomme:hostfwd=tcp::8080-10.0.0.2:80").unwrap() {
3936            EndpointConfigCli::Consomme { cidr, host_fwd } => {
3937                assert!(cidr.is_none());
3938                assert_eq!(host_fwd[0].host_port, 8080);
3939                assert_eq!(host_fwd[0].guest_port, 80);
3940            }
3941            _ => panic!("Expected Consomme variant with guest address"),
3942        }
3943
3944        // Test consomme with IPv6 host address (bracketed)
3945        match EndpointConfigCli::from_str("consomme:hostfwd=tcp:[::1]:8080-:80").unwrap() {
3946            EndpointConfigCli::Consomme { cidr, host_fwd } => {
3947                assert!(cidr.is_none());
3948                assert_eq!(host_fwd.len(), 1);
3949                assert_eq!(host_fwd[0].protocol, HostPortProtocolCli::Tcp);
3950                assert_eq!(
3951                    host_fwd[0].host_address,
3952                    Some(std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST))
3953                );
3954                assert_eq!(host_fwd[0].host_port, 8080);
3955                assert_eq!(host_fwd[0].guest_port, 80);
3956            }
3957            _ => panic!("Expected Consomme variant with IPv6 hostfwd"),
3958        }
3959
3960        // Test consomme with IPv6 guest address (bracketed)
3961        match EndpointConfigCli::from_str("consomme:hostfwd=tcp::8080-[::1]:80").unwrap() {
3962            EndpointConfigCli::Consomme { cidr, host_fwd } => {
3963                assert!(cidr.is_none());
3964                assert_eq!(host_fwd[0].host_port, 8080);
3965                assert_eq!(host_fwd[0].guest_port, 80);
3966            }
3967            _ => panic!("Expected Consomme variant with IPv6 guest address"),
3968        }
3969
3970        // Test dio without id
3971        match EndpointConfigCli::from_str("dio").unwrap() {
3972            EndpointConfigCli::Dio { id: None } => (),
3973            _ => panic!("Expected Dio variant without id"),
3974        }
3975
3976        // Test dio with id
3977        match EndpointConfigCli::from_str("dio:test_id").unwrap() {
3978            EndpointConfigCli::Dio { id: Some(id) } => {
3979                assert_eq!(id, "test_id");
3980            }
3981            _ => panic!("Expected Dio variant with id"),
3982        }
3983
3984        // Test tap
3985        match EndpointConfigCli::from_str("tap:tap0").unwrap() {
3986            EndpointConfigCli::Tap { name } => {
3987                assert_eq!(name, "tap0");
3988            }
3989            _ => panic!("Expected Tap variant"),
3990        }
3991
3992        // Test error case
3993        assert!(EndpointConfigCli::from_str("invalid").is_err());
3994    }
3995
3996    #[test]
3997    fn test_nic_config_from_str() {
3998        use openvmm_defs::config::DeviceVtl;
3999
4000        // Test basic endpoint
4001        let config = NicConfigCli::from_str("none").unwrap();
4002        assert_eq!(config.vtl, DeviceVtl::Vtl0);
4003        assert!(config.max_queues.is_none());
4004        assert!(!config.underhill);
4005        assert!(config.pcie_port.is_none());
4006        assert!(matches!(config.endpoint, EndpointConfigCli::None));
4007
4008        // Test with vtl2
4009        let config = NicConfigCli::from_str("vtl2:none").unwrap();
4010        assert_eq!(config.vtl, DeviceVtl::Vtl2);
4011        assert!(config.pcie_port.is_none());
4012        assert!(matches!(config.endpoint, EndpointConfigCli::None));
4013
4014        // Test with queues
4015        let config = NicConfigCli::from_str("queues=4:none").unwrap();
4016        assert_eq!(config.max_queues, Some(4));
4017        assert!(config.pcie_port.is_none());
4018        assert!(matches!(config.endpoint, EndpointConfigCli::None));
4019
4020        // Test with underhill
4021        let config = NicConfigCli::from_str("uh:none").unwrap();
4022        assert!(config.underhill);
4023        assert!(config.pcie_port.is_none());
4024        assert!(matches!(config.endpoint, EndpointConfigCli::None));
4025
4026        // Test with pcie_port
4027        let config = NicConfigCli::from_str("pcie_port=rp0:none").unwrap();
4028        assert_eq!(config.pcie_port.unwrap(), "rp0".to_string());
4029        assert!(matches!(config.endpoint, EndpointConfigCli::None));
4030
4031        // Test error cases
4032        assert!(NicConfigCli::from_str("queues=invalid:none").is_err());
4033        assert!(NicConfigCli::from_str("uh:vtl2:none").is_err()); // uh incompatible with vtl2
4034        assert!(NicConfigCli::from_str("pcie_port=rp0:vtl2:none").is_err());
4035        assert!(NicConfigCli::from_str("uh:pcie_port=rp0:none").is_err());
4036        assert!(NicConfigCli::from_str("pcie_port=:none").is_err());
4037        assert!(NicConfigCli::from_str("pcie_port:none").is_err());
4038    }
4039
4040    #[test]
4041    fn test_parse_pcie_port_prefix() {
4042        // Successful prefix parsing
4043        let (port, rest) = parse_pcie_port_prefix("pcie_port=rp0:tag,path");
4044        assert_eq!(port.unwrap(), "rp0");
4045        assert_eq!(rest, "tag,path");
4046
4047        // No prefix
4048        let (port, rest) = parse_pcie_port_prefix("tag,path");
4049        assert!(port.is_none());
4050        assert_eq!(rest, "tag,path");
4051
4052        // Empty port name — not parsed as a prefix
4053        let (port, rest) = parse_pcie_port_prefix("pcie_port=:tag,path");
4054        assert!(port.is_none());
4055        assert_eq!(rest, "pcie_port=:tag,path");
4056
4057        // Missing colon — not parsed as a prefix
4058        let (port, rest) = parse_pcie_port_prefix("pcie_port=rp0");
4059        assert!(port.is_none());
4060        assert_eq!(rest, "pcie_port=rp0");
4061    }
4062
4063    #[test]
4064    fn test_cxl_test_device_cli_parse_valid() {
4065        let cfg = CxlTestDeviceCli::from_str("mem:1G,pcie_port=rp0").unwrap();
4066        assert_eq!(cfg.hdm_size, 1024 * 1024 * 1024);
4067        assert_eq!(cfg.pcie_port, "rp0");
4068    }
4069
4070    #[test]
4071    fn test_cxl_test_device_cli_parse_invalid() {
4072        assert!(CxlTestDeviceCli::from_str("file:disk.img,pcie_port=rp0").is_err());
4073        assert!(CxlTestDeviceCli::from_str("mem:1G").is_err());
4074        assert!(CxlTestDeviceCli::from_str("mem:1G,pcie_port=").is_err());
4075    }
4076
4077    #[test]
4078    fn test_fs_args_pcie_port() {
4079        // Without pcie_port
4080        let args = FsArgs::from_str("myfs,/path").unwrap();
4081        assert_eq!(args.tag, "myfs");
4082        assert_eq!(args.path, "/path");
4083        assert!(args.pcie_port.is_none());
4084
4085        // With pcie_port
4086        let args = FsArgs::from_str("pcie_port=rp0:myfs,/path").unwrap();
4087        assert_eq!(args.pcie_port.unwrap(), "rp0");
4088        assert_eq!(args.tag, "myfs");
4089        assert_eq!(args.path, "/path");
4090
4091        // Error: wrong number of fields
4092        assert!(FsArgs::from_str("myfs").is_err());
4093        assert!(FsArgs::from_str("pcie_port=rp0:myfs").is_err());
4094    }
4095
4096    #[test]
4097    fn test_fs_args_with_options_pcie_port() {
4098        // Without pcie_port
4099        let args = FsArgsWithOptions::from_str("myfs,/path,uid=1000").unwrap();
4100        assert_eq!(args.tag, "myfs");
4101        assert_eq!(args.path, "/path");
4102        assert_eq!(args.options, "uid=1000");
4103        assert!(args.pcie_port.is_none());
4104
4105        // With pcie_port
4106        let args = FsArgsWithOptions::from_str("pcie_port=rp0:myfs,/path,uid=1000").unwrap();
4107        assert_eq!(args.pcie_port.unwrap(), "rp0");
4108        assert_eq!(args.tag, "myfs");
4109        assert_eq!(args.path, "/path");
4110        assert_eq!(args.options, "uid=1000");
4111
4112        // Error: missing path
4113        assert!(FsArgsWithOptions::from_str("myfs").is_err());
4114    }
4115
4116    #[test]
4117    fn test_virtio_pmem_args_pcie_port() {
4118        // Without pcie_port
4119        let args = VirtioPmemArgs::from_str("/path/to/file").unwrap();
4120        assert_eq!(args.path, "/path/to/file");
4121        assert!(args.pcie_port.is_none());
4122
4123        // With pcie_port
4124        let args = VirtioPmemArgs::from_str("pcie_port=rp0:/path/to/file").unwrap();
4125        assert_eq!(args.pcie_port.unwrap(), "rp0");
4126        assert_eq!(args.path, "/path/to/file");
4127
4128        // Error: empty path
4129        assert!(VirtioPmemArgs::from_str("").is_err());
4130        assert!(VirtioPmemArgs::from_str("pcie_port=rp0:").is_err());
4131    }
4132
4133    #[test]
4134    fn test_smt_config_from_str() {
4135        assert_eq!(SmtConfigCli::from_str("auto").unwrap(), SmtConfigCli::Auto);
4136        assert_eq!(
4137            SmtConfigCli::from_str("force").unwrap(),
4138            SmtConfigCli::Force
4139        );
4140        assert_eq!(SmtConfigCli::from_str("off").unwrap(), SmtConfigCli::Off);
4141
4142        // Test error cases
4143        assert!(SmtConfigCli::from_str("invalid").is_err());
4144        assert!(SmtConfigCli::from_str("").is_err());
4145    }
4146
4147    #[test]
4148    fn test_pcat_boot_order_from_str() {
4149        // Test single device
4150        let order = PcatBootOrderCli::from_str("optical").unwrap();
4151        assert_eq!(order.0[0], PcatBootDevice::Optical);
4152
4153        // Test multiple devices
4154        let order = PcatBootOrderCli::from_str("hdd,net").unwrap();
4155        assert_eq!(order.0[0], PcatBootDevice::HardDrive);
4156        assert_eq!(order.0[1], PcatBootDevice::Network);
4157
4158        // Test error cases
4159        assert!(PcatBootOrderCli::from_str("invalid").is_err());
4160        assert!(PcatBootOrderCli::from_str("optical,optical").is_err()); // duplicate device
4161    }
4162
4163    #[test]
4164    fn test_floppy_disk_from_str() {
4165        // Test basic disk
4166        let disk = FloppyDiskCli::from_str("file:/path/to/floppy.img").unwrap();
4167        assert!(!disk.read_only);
4168        match disk.kind {
4169            DiskCliKind::File {
4170                path,
4171                create_with_len,
4172                ..
4173            } => {
4174                assert_eq!(path.to_str().unwrap(), "/path/to/floppy.img");
4175                assert_eq!(create_with_len, None);
4176            }
4177            _ => panic!("Expected File variant"),
4178        }
4179
4180        // Test with read-only flag
4181        let disk = FloppyDiskCli::from_str("file:/path/to/floppy.img,ro").unwrap();
4182        assert!(disk.read_only);
4183
4184        // Test error cases
4185        assert!(FloppyDiskCli::from_str("").is_err());
4186        assert!(FloppyDiskCli::from_str("file:/path/to/floppy.img,invalid").is_err());
4187    }
4188
4189    #[test]
4190    fn test_pcie_root_complex_from_str() {
4191        const ONE_MB: u64 = 1024 * 1024;
4192        const ONE_GB: u64 = 1024 * ONE_MB;
4193
4194        const DEFAULT_LOW_MMIO: u32 = (64 * ONE_MB) as u32;
4195        const DEFAULT_HIGH_MMIO: u64 = ONE_GB;
4196        const DEFAULT_HDM: u64 = ONE_GB;
4197        const DEFAULT_HDM_WINDOW_RESTRICTIONS: CfmwsWindowRestrictions =
4198            CfmwsWindowRestrictions::DEVICE_COHERENT;
4199
4200        assert_eq!(
4201            PcieRootComplexCli::from_str("rc0").unwrap(),
4202            PcieRootComplexCli {
4203                name: "rc0".to_string(),
4204                segment: 0,
4205                start_bus: 0,
4206                end_bus: 255,
4207                low_mmio: DEFAULT_LOW_MMIO,
4208                high_mmio: DEFAULT_HIGH_MMIO,
4209                hdm: DEFAULT_HDM,
4210                hdm_window_restrictions: DEFAULT_HDM_WINDOW_RESTRICTIONS,
4211            }
4212        );
4213
4214        assert_eq!(
4215            PcieRootComplexCli::from_str("rc1,segment=1").unwrap(),
4216            PcieRootComplexCli {
4217                name: "rc1".to_string(),
4218                segment: 1,
4219                start_bus: 0,
4220                end_bus: 255,
4221                low_mmio: DEFAULT_LOW_MMIO,
4222                high_mmio: DEFAULT_HIGH_MMIO,
4223                hdm: DEFAULT_HDM,
4224                hdm_window_restrictions: DEFAULT_HDM_WINDOW_RESTRICTIONS,
4225            }
4226        );
4227
4228        assert_eq!(
4229            PcieRootComplexCli::from_str("rc2,start_bus=32").unwrap(),
4230            PcieRootComplexCli {
4231                name: "rc2".to_string(),
4232                segment: 0,
4233                start_bus: 32,
4234                end_bus: 255,
4235                low_mmio: DEFAULT_LOW_MMIO,
4236                high_mmio: DEFAULT_HIGH_MMIO,
4237                hdm: DEFAULT_HDM,
4238                hdm_window_restrictions: DEFAULT_HDM_WINDOW_RESTRICTIONS,
4239            }
4240        );
4241
4242        assert_eq!(
4243            PcieRootComplexCli::from_str("rc3,end_bus=31").unwrap(),
4244            PcieRootComplexCli {
4245                name: "rc3".to_string(),
4246                segment: 0,
4247                start_bus: 0,
4248                end_bus: 31,
4249                low_mmio: DEFAULT_LOW_MMIO,
4250                high_mmio: DEFAULT_HIGH_MMIO,
4251                hdm: DEFAULT_HDM,
4252                hdm_window_restrictions: DEFAULT_HDM_WINDOW_RESTRICTIONS,
4253            }
4254        );
4255
4256        assert_eq!(
4257            PcieRootComplexCli::from_str("rc4,start_bus=32,end_bus=127,high_mmio=2G").unwrap(),
4258            PcieRootComplexCli {
4259                name: "rc4".to_string(),
4260                segment: 0,
4261                start_bus: 32,
4262                end_bus: 127,
4263                low_mmio: DEFAULT_LOW_MMIO,
4264                high_mmio: 2 * ONE_GB,
4265                hdm: DEFAULT_HDM,
4266                hdm_window_restrictions: DEFAULT_HDM_WINDOW_RESTRICTIONS,
4267            }
4268        );
4269
4270        assert_eq!(
4271            PcieRootComplexCli::from_str("rc5,segment=2,start_bus=32,end_bus=127").unwrap(),
4272            PcieRootComplexCli {
4273                name: "rc5".to_string(),
4274                segment: 2,
4275                start_bus: 32,
4276                end_bus: 127,
4277                low_mmio: DEFAULT_LOW_MMIO,
4278                high_mmio: DEFAULT_HIGH_MMIO,
4279                hdm: DEFAULT_HDM,
4280                hdm_window_restrictions: DEFAULT_HDM_WINDOW_RESTRICTIONS,
4281            }
4282        );
4283
4284        assert_eq!(
4285            PcieRootComplexCli::from_str("rc6,low_mmio=1M,high_mmio=64G").unwrap(),
4286            PcieRootComplexCli {
4287                name: "rc6".to_string(),
4288                segment: 0,
4289                start_bus: 0,
4290                end_bus: 255,
4291                low_mmio: ONE_MB as u32,
4292                high_mmio: 64 * ONE_GB,
4293                hdm: DEFAULT_HDM,
4294                hdm_window_restrictions: DEFAULT_HDM_WINDOW_RESTRICTIONS,
4295            }
4296        );
4297
4298        assert_eq!(
4299            PcieRootComplexCli::from_str("rc7,hdm=2G").unwrap(),
4300            PcieRootComplexCli {
4301                name: "rc7".to_string(),
4302                segment: 0,
4303                start_bus: 0,
4304                end_bus: 255,
4305                low_mmio: DEFAULT_LOW_MMIO,
4306                high_mmio: DEFAULT_HIGH_MMIO,
4307                hdm: 2 * ONE_GB,
4308                hdm_window_restrictions: DEFAULT_HDM_WINDOW_RESTRICTIONS,
4309            }
4310        );
4311
4312        assert_eq!(
4313            PcieRootComplexCli::from_str("rc8,hdm_window_restrictions=0x21").unwrap(),
4314            PcieRootComplexCli {
4315                name: "rc8".to_string(),
4316                segment: 0,
4317                start_bus: 0,
4318                end_bus: 255,
4319                low_mmio: DEFAULT_LOW_MMIO,
4320                high_mmio: DEFAULT_HIGH_MMIO,
4321                hdm: DEFAULT_HDM,
4322                hdm_window_restrictions: CfmwsWindowRestrictions::try_from_bits(0x21).unwrap(),
4323            }
4324        );
4325
4326        // Error cases
4327        assert!(PcieRootComplexCli::from_str("").is_err());
4328        assert!(PcieRootComplexCli::from_str("poorly,").is_err());
4329        assert!(PcieRootComplexCli::from_str("configured,complex").is_err());
4330        assert!(PcieRootComplexCli::from_str("fails,start_bus=foo").is_err());
4331        assert!(PcieRootComplexCli::from_str("fails,start_bus=32,end_bus=31").is_err());
4332        assert!(PcieRootComplexCli::from_str("rc,start_bus=256").is_err());
4333        assert!(PcieRootComplexCli::from_str("rc,end_bus=256").is_err());
4334        assert!(PcieRootComplexCli::from_str("rc,low_mmio=5G").is_err());
4335        assert!(PcieRootComplexCli::from_str("rc,low_mmio=aG").is_err());
4336        assert!(PcieRootComplexCli::from_str("rc,high_mmio=bad").is_err());
4337        assert!(PcieRootComplexCli::from_str("rc,high_mmio").is_err());
4338        assert!(PcieRootComplexCli::from_str("rc,hdm=bad").is_err());
4339        assert!(PcieRootComplexCli::from_str("rc,hdm").is_err());
4340        assert!(PcieRootComplexCli::from_str("rc,hdm_window_restrictions=bad").is_err());
4341        assert!(PcieRootComplexCli::from_str("rc,hdm_window_restrictions").is_err());
4342        assert!(PcieRootComplexCli::from_str("rc,cxl").is_err());
4343    }
4344
4345    #[test]
4346    fn test_pcie_root_port_from_str() {
4347        assert_eq!(
4348            PcieRootPortCli::from_str("rc0:rc0rp0").unwrap(),
4349            PcieRootPortCli {
4350                root_complex_name: "rc0".to_string(),
4351                name: "rc0rp0".to_string(),
4352                hotplug: false,
4353                acs_capabilities_supported: None,
4354                cxl: false,
4355            }
4356        );
4357
4358        assert_eq!(
4359            PcieRootPortCli::from_str("my_rc:port2").unwrap(),
4360            PcieRootPortCli {
4361                root_complex_name: "my_rc".to_string(),
4362                name: "port2".to_string(),
4363                hotplug: false,
4364                acs_capabilities_supported: None,
4365                cxl: false,
4366            }
4367        );
4368
4369        // Test with hotplug flag
4370        assert_eq!(
4371            PcieRootPortCli::from_str("my_rc:port2,hotplug").unwrap(),
4372            PcieRootPortCli {
4373                root_complex_name: "my_rc".to_string(),
4374                name: "port2".to_string(),
4375                hotplug: true,
4376                acs_capabilities_supported: None,
4377                cxl: false,
4378            }
4379        );
4380
4381        assert_eq!(
4382            PcieRootPortCli::from_str("my_rc:port3,acs=0").unwrap(),
4383            PcieRootPortCli {
4384                root_complex_name: "my_rc".to_string(),
4385                name: "port3".to_string(),
4386                hotplug: false,
4387                acs_capabilities_supported: Some(0),
4388                cxl: false,
4389            }
4390        );
4391
4392        assert_eq!(
4393            PcieRootPortCli::from_str("my_rc:port3,acs=0x5f").unwrap(),
4394            PcieRootPortCli {
4395                root_complex_name: "my_rc".to_string(),
4396                name: "port3".to_string(),
4397                hotplug: false,
4398                acs_capabilities_supported: Some(0x005f),
4399                cxl: false,
4400            }
4401        );
4402
4403        assert_eq!(
4404            PcieRootPortCli::from_str("my_rc:port4,cxl").unwrap(),
4405            PcieRootPortCli {
4406                root_complex_name: "my_rc".to_string(),
4407                name: "port4".to_string(),
4408                hotplug: false,
4409                acs_capabilities_supported: None,
4410                cxl: true,
4411            }
4412        );
4413
4414        // Error cases
4415        assert!(PcieRootPortCli::from_str("").is_err());
4416        assert!(PcieRootPortCli::from_str("rp0").is_err());
4417        assert!(PcieRootPortCli::from_str("rp0,opt").is_err());
4418        assert!(PcieRootPortCli::from_str("rc0:rp0:rp3").is_err());
4419        assert!(PcieRootPortCli::from_str("rc0:rp0,invalid_option").is_err());
4420        assert!(PcieRootPortCli::from_str("rc0:rp0,cxl=true").is_err());
4421    }
4422
4423    #[test]
4424    fn test_pcie_switch_from_str() {
4425        assert_eq!(
4426            GenericPcieSwitchCli::from_str("rp0:switch0").unwrap(),
4427            GenericPcieSwitchCli {
4428                port_name: "rp0".to_string(),
4429                name: "switch0".to_string(),
4430                num_downstream_ports: 4,
4431                hotplug: false,
4432                acs_capabilities_supported: None,
4433            }
4434        );
4435
4436        assert_eq!(
4437            GenericPcieSwitchCli::from_str("port1:my_switch,num_downstream_ports=4").unwrap(),
4438            GenericPcieSwitchCli {
4439                port_name: "port1".to_string(),
4440                name: "my_switch".to_string(),
4441                num_downstream_ports: 4,
4442                hotplug: false,
4443                acs_capabilities_supported: None,
4444            }
4445        );
4446
4447        assert_eq!(
4448            GenericPcieSwitchCli::from_str("rp2:sw,num_downstream_ports=8").unwrap(),
4449            GenericPcieSwitchCli {
4450                port_name: "rp2".to_string(),
4451                name: "sw".to_string(),
4452                num_downstream_ports: 8,
4453                hotplug: false,
4454                acs_capabilities_supported: None,
4455            }
4456        );
4457
4458        // Test hierarchical connections
4459        assert_eq!(
4460            GenericPcieSwitchCli::from_str("switch0-downstream-1:child_switch").unwrap(),
4461            GenericPcieSwitchCli {
4462                port_name: "switch0-downstream-1".to_string(),
4463                name: "child_switch".to_string(),
4464                num_downstream_ports: 4,
4465                hotplug: false,
4466                acs_capabilities_supported: None,
4467            }
4468        );
4469
4470        // Test hotplug flag
4471        assert_eq!(
4472            GenericPcieSwitchCli::from_str("rp0:switch0,hotplug").unwrap(),
4473            GenericPcieSwitchCli {
4474                port_name: "rp0".to_string(),
4475                name: "switch0".to_string(),
4476                num_downstream_ports: 4,
4477                hotplug: true,
4478                acs_capabilities_supported: None,
4479            }
4480        );
4481
4482        // Test hotplug with num_downstream_ports
4483        assert_eq!(
4484            GenericPcieSwitchCli::from_str("rp0:switch0,num_downstream_ports=8,hotplug").unwrap(),
4485            GenericPcieSwitchCli {
4486                port_name: "rp0".to_string(),
4487                name: "switch0".to_string(),
4488                num_downstream_ports: 8,
4489                hotplug: true,
4490                acs_capabilities_supported: None,
4491            }
4492        );
4493
4494        assert_eq!(
4495            GenericPcieSwitchCli::from_str("rp0:switch0,acs=0").unwrap(),
4496            GenericPcieSwitchCli {
4497                port_name: "rp0".to_string(),
4498                name: "switch0".to_string(),
4499                num_downstream_ports: 4,
4500                hotplug: false,
4501                acs_capabilities_supported: Some(0),
4502            }
4503        );
4504
4505        assert_eq!(
4506            GenericPcieSwitchCli::from_str("rp0:switch0,acs=95").unwrap(),
4507            GenericPcieSwitchCli {
4508                port_name: "rp0".to_string(),
4509                name: "switch0".to_string(),
4510                num_downstream_ports: 4,
4511                hotplug: false,
4512                acs_capabilities_supported: Some(95),
4513            }
4514        );
4515
4516        // Error cases
4517        assert!(GenericPcieSwitchCli::from_str("").is_err());
4518        assert!(GenericPcieSwitchCli::from_str("switch0").is_err());
4519        assert!(GenericPcieSwitchCli::from_str("rp0:switch0:extra").is_err());
4520        assert!(GenericPcieSwitchCli::from_str("rp0:switch0,invalid_opt=value").is_err());
4521        assert!(GenericPcieSwitchCli::from_str("rp0:switch0,num_downstream_ports=bad").is_err());
4522        assert!(GenericPcieSwitchCli::from_str("rp0:switch0,num_downstream_ports=").is_err());
4523        assert!(GenericPcieSwitchCli::from_str("rp0:switch0,invalid_flag").is_err());
4524    }
4525
4526    #[test]
4527    fn test_pcie_remote_from_str() {
4528        // Basic port name only
4529        assert_eq!(
4530            PcieRemoteCli::from_str("rc0rp0").unwrap(),
4531            PcieRemoteCli {
4532                port_name: "rc0rp0".to_string(),
4533                socket_addr: None,
4534                hu: 0,
4535                controller: 0,
4536            }
4537        );
4538
4539        // With socket address
4540        assert_eq!(
4541            PcieRemoteCli::from_str("rc0rp0,socket=localhost:22567").unwrap(),
4542            PcieRemoteCli {
4543                port_name: "rc0rp0".to_string(),
4544                socket_addr: Some("localhost:22567".to_string()),
4545                hu: 0,
4546                controller: 0,
4547            }
4548        );
4549
4550        // With all options
4551        assert_eq!(
4552            PcieRemoteCli::from_str("myport,socket=localhost:22568,hu=1,controller=2").unwrap(),
4553            PcieRemoteCli {
4554                port_name: "myport".to_string(),
4555                socket_addr: Some("localhost:22568".to_string()),
4556                hu: 1,
4557                controller: 2,
4558            }
4559        );
4560
4561        // Only hu and controller
4562        assert_eq!(
4563            PcieRemoteCli::from_str("port0,hu=5,controller=3").unwrap(),
4564            PcieRemoteCli {
4565                port_name: "port0".to_string(),
4566                socket_addr: None,
4567                hu: 5,
4568                controller: 3,
4569            }
4570        );
4571
4572        // Error cases
4573        assert!(PcieRemoteCli::from_str("").is_err());
4574        assert!(PcieRemoteCli::from_str("port,socket=").is_err());
4575        assert!(PcieRemoteCli::from_str("port,hu=").is_err());
4576        assert!(PcieRemoteCli::from_str("port,hu=bad").is_err());
4577        assert!(PcieRemoteCli::from_str("port,controller=").is_err());
4578        assert!(PcieRemoteCli::from_str("port,controller=bad").is_err());
4579        assert!(PcieRemoteCli::from_str("port,unknown=value").is_err());
4580    }
4581
4582    #[test]
4583    fn test_parse_memory_units() {
4584        assert_eq!(parse_memory("64G").unwrap(), 64 * 1024 * 1024 * 1024);
4585        assert_eq!(parse_memory("64GB").unwrap(), 64 * 1024 * 1024 * 1024);
4586        assert_eq!(parse_memory("3MB").unwrap(), 3 * 1024 * 1024);
4587        assert_eq!(parse_memory("512KB").unwrap(), 512 * 1024);
4588        assert!(parse_memory("3MiB").is_err());
4589    }
4590
4591    #[test]
4592    fn test_memory_config_size_only() {
4593        assert_eq!(
4594            parse_memory_config("64G").unwrap(),
4595            MemoryCli {
4596                mem_size: 64 * 1024 * 1024 * 1024,
4597                shared: None,
4598                prefetch: false,
4599                transparent_hugepages: false,
4600                hugepages: false,
4601                hugepage_size: None,
4602                file: None,
4603            }
4604        );
4605    }
4606
4607    #[test]
4608    fn test_memory_config_key_value() {
4609        assert_eq!(
4610            parse_memory_config("size=2G,shared=off,prefetch=on,thp=on").unwrap(),
4611            MemoryCli {
4612                mem_size: 2 * 1024 * 1024 * 1024,
4613                shared: Some(false),
4614                prefetch: true,
4615                transparent_hugepages: true,
4616                hugepages: false,
4617                hugepage_size: None,
4618                file: None,
4619            }
4620        );
4621
4622        assert_eq!(
4623            parse_memory_config("size=4GB,hugepages=on,hugepage_size=2MB").unwrap(),
4624            MemoryCli {
4625                mem_size: 4 * 1024 * 1024 * 1024,
4626                shared: None,
4627                prefetch: false,
4628                transparent_hugepages: false,
4629                hugepages: true,
4630                hugepage_size: Some(2 * 1024 * 1024),
4631                file: None,
4632            }
4633        );
4634
4635        assert_eq!(
4636            parse_memory_config("file=/tmp/memory.bin").unwrap(),
4637            MemoryCli {
4638                mem_size: DEFAULT_MEMORY_SIZE,
4639                shared: None,
4640                prefetch: false,
4641                transparent_hugepages: false,
4642                hugepages: false,
4643                hugepage_size: None,
4644                file: Some(PathBuf::from("/tmp/memory.bin")),
4645            }
4646        );
4647    }
4648
4649    #[test]
4650    fn test_memory_config_rejects_invalid_combinations() {
4651        assert!(parse_memory_config("thp=on").is_err());
4652        assert!(parse_memory_config("size=1G,size=2G").is_err());
4653        assert!(parse_memory_config("hugepage_size=2M").is_err());
4654        assert!(parse_memory_config("hugepages=on,shared=off").is_err());
4655        assert!(parse_memory_config("hugepages=on,file=/tmp/memory.bin").is_err());
4656
4657        // Semantic validation of the hugepage size happens in the memory
4658        // builder, not in CLI parsing.
4659        assert_eq!(
4660            parse_memory_config("hugepages=on,hugepage_size=3MB")
4661                .unwrap()
4662                .hugepage_size,
4663            Some(3 * 1024 * 1024)
4664        );
4665    }
4666
4667    #[test]
4668    fn test_memory_options_merge_legacy_aliases() {
4669        let opt = Options::try_parse_from([
4670            "openvmm",
4671            "--memory",
4672            "2G",
4673            "--prefetch",
4674            "--private-memory",
4675            "--thp",
4676        ])
4677        .unwrap();
4678        opt.validate_memory_options().unwrap();
4679        assert_eq!(opt.memory_size(), 2 * 1024 * 1024 * 1024);
4680        assert!(opt.prefetch_memory());
4681        assert!(opt.private_memory());
4682        assert!(opt.transparent_hugepages());
4683    }
4684
4685    #[test]
4686    fn test_memory_options_allow_legacy_thp_with_new_private_memory() {
4687        let opt = Options::try_parse_from(["openvmm", "--memory", "shared=off", "--thp"]).unwrap();
4688        opt.validate_memory_options().unwrap();
4689        assert!(opt.private_memory());
4690        assert!(opt.transparent_hugepages());
4691    }
4692
4693    #[test]
4694    fn test_memory_options_reject_conflicting_legacy_aliases() {
4695        let opt = Options::try_parse_from(["openvmm", "--memory", "shared=on", "--private-memory"])
4696            .unwrap();
4697        assert!(opt.validate_memory_options().is_err());
4698    }
4699
4700    #[test]
4701    fn test_memory_options_reject_hugepage_legacy_conflicts() {
4702        let opt =
4703            Options::try_parse_from(["openvmm", "--memory", "hugepages=on", "--private-memory"])
4704                .unwrap();
4705        assert!(opt.validate_memory_options().is_err());
4706
4707        let opt = Options::try_parse_from([
4708            "openvmm",
4709            "--memory",
4710            "hugepages=on",
4711            "--memory-backing-file",
4712            "/tmp/memory.bin",
4713        ])
4714        .unwrap();
4715        assert!(opt.validate_memory_options().is_err());
4716    }
4717
4718    #[test]
4719    fn test_pidfile_option_parsed() {
4720        let opt = Options::try_parse_from(["openvmm", "--pidfile", "/tmp/test.pid"]).unwrap();
4721        assert_eq!(opt.pidfile, Some(PathBuf::from("/tmp/test.pid")));
4722    }
4723
4724    #[cfg(target_os = "linux")]
4725    #[test]
4726    fn test_vfio_device_cli_parse() {
4727        // Required keys only.
4728        let v = VfioDeviceCli::from_str("host=0000:01:00.0,port=rp0").unwrap();
4729        assert_eq!(v.pci_id, "0000:01:00.0");
4730        assert_eq!(v.port_name, "rp0");
4731        assert_eq!(v.iommu, None);
4732
4733        // With optional iommu= key. Keys may appear in any order.
4734        let v = VfioDeviceCli::from_str("port=rp1,iommu=iommu0,host=0000:02:00.0").unwrap();
4735        assert_eq!(v.pci_id, "0000:02:00.0");
4736        assert_eq!(v.port_name, "rp1");
4737        assert_eq!(v.iommu.as_deref(), Some("iommu0"));
4738    }
4739
4740    #[cfg(target_os = "linux")]
4741    #[test]
4742    fn test_vfio_device_cli_errors() {
4743        // Missing required keys.
4744        assert!(VfioDeviceCli::from_str("port=rp0").is_err());
4745        assert!(VfioDeviceCli::from_str("host=0000:01:00.0").is_err());
4746
4747        // Unknown key.
4748        assert!(VfioDeviceCli::from_str("host=0000:01:00.0,port=rp0,foo=bar").is_err());
4749
4750        // Duplicate keys are rejected.
4751        assert!(VfioDeviceCli::from_str("host=0000:01:00.0,host=0000:02:00.0,port=rp0").is_err());
4752        assert!(VfioDeviceCli::from_str("host=0000:01:00.0,port=rp0,port=rp1").is_err());
4753        assert!(VfioDeviceCli::from_str("host=0000:01:00.0,port=rp0,iommu=a,iommu=b").is_err());
4754
4755        // Empty values are rejected.
4756        assert!(VfioDeviceCli::from_str("host=,port=rp0").is_err());
4757        assert!(VfioDeviceCli::from_str("host=0000:01:00.0,port=").is_err());
4758        assert!(VfioDeviceCli::from_str("host=0000:01:00.0,port=rp0,iommu=").is_err());
4759
4760        // Missing '=' separator.
4761        assert!(VfioDeviceCli::from_str("host").is_err());
4762        assert!(VfioDeviceCli::from_str("host=0000:01:00.0,port=rp0,iommu").is_err());
4763
4764        // Path-traversal characters in the host BDF are rejected.
4765        assert!(VfioDeviceCli::from_str("host=../../etc/passwd,port=rp0").is_err());
4766        assert!(VfioDeviceCli::from_str("host=foo/bar,port=rp0").is_err());
4767    }
4768
4769    #[cfg(target_os = "linux")]
4770    #[test]
4771    fn test_iommu_cli_parse() {
4772        let c = IommuCli::from_str("id=iommu0").unwrap();
4773        assert_eq!(c.id, "iommu0");
4774
4775        // Wrong key.
4776        assert!(IommuCli::from_str("name=iommu0").is_err());
4777
4778        // Missing '=' separator.
4779        assert!(IommuCli::from_str("iommu0").is_err());
4780
4781        // Empty id.
4782        assert!(IommuCli::from_str("id=").is_err());
4783    }
4784
4785    #[test]
4786    fn test_nvme_controller_cli_pcie() {
4787        let c = NvmeControllerCli::from_str("id=nvme0,pcie_port=p0").unwrap();
4788        assert_eq!(c.id, "nvme0");
4789        assert_eq!(c.transport, NvmeControllerTransport::Pcie("p0".into()));
4790    }
4791
4792    #[test]
4793    fn test_nvme_controller_cli_vpci_no_guid() {
4794        let c = NvmeControllerCli::from_str("id=nvme1,vpci").unwrap();
4795        assert_eq!(c.id, "nvme1");
4796        assert!(matches!(c.transport, NvmeControllerTransport::Vpci(None)));
4797    }
4798
4799    #[test]
4800    fn test_nvme_controller_cli_vpci_with_guid() {
4801        let c = NvmeControllerCli::from_str("id=nvme2,vpci=008091f6-9688-497d-9091-af347dc9173c")
4802            .unwrap();
4803        assert_eq!(c.id, "nvme2");
4804        assert!(matches!(
4805            c.transport,
4806            NvmeControllerTransport::Vpci(Some(_))
4807        ));
4808    }
4809
4810    #[test]
4811    fn test_nvme_controller_cli_errors() {
4812        // Missing id.
4813        assert!(NvmeControllerCli::from_str("pcie_port=p0").is_err());
4814        // Missing transport.
4815        assert!(NvmeControllerCli::from_str("id=nvme0").is_err());
4816        // Both transports.
4817        assert!(NvmeControllerCli::from_str("id=nvme0,pcie_port=p0,vpci").is_err());
4818        // Unknown option.
4819        assert!(NvmeControllerCli::from_str("id=nvme0,pcie_port=p0,foo=bar").is_err());
4820        // Empty id.
4821        assert!(NvmeControllerCli::from_str("id=,pcie_port=p0").is_err());
4822        // Empty pcie_port.
4823        assert!(NvmeControllerCli::from_str("id=nvme0,pcie_port=").is_err());
4824        // Invalid GUID.
4825        assert!(NvmeControllerCli::from_str("id=nvme0,vpci=not-a-guid").is_err());
4826    }
4827
4828    #[test]
4829    fn test_disk_cli_controller() {
4830        let d = DiskCli::from_str("file:disk.vhd,on=nvme0").unwrap();
4831        assert_eq!(d.controller.as_deref(), Some("nvme0"));
4832        assert_eq!(d.nsid, None);
4833    }
4834
4835    #[test]
4836    fn test_disk_cli_controller_with_nsid() {
4837        let d = DiskCli::from_str("file:disk.vhd,on=nvme0,nsid=3").unwrap();
4838        assert_eq!(d.controller.as_deref(), Some("nvme0"));
4839        assert_eq!(d.nsid, Some(3));
4840    }
4841
4842    #[test]
4843    fn test_disk_cli_controller_errors() {
4844        // nsid without on.
4845        assert!(DiskCli::from_str("file:disk.vhd,nsid=1").is_err());
4846        // lun without on.
4847        assert!(DiskCli::from_str("file:disk.vhd,lun=0").is_err());
4848        // on with pcie_port.
4849        assert!(DiskCli::from_str("file:disk.vhd,on=nvme0,pcie_port=p0").is_err());
4850        // Empty controller name.
4851        assert!(DiskCli::from_str("file:disk.vhd,on=").is_err());
4852        // Invalid nsid.
4853        assert!(DiskCli::from_str("file:disk.vhd,on=nvme0,nsid=abc").is_err());
4854        // nsid and lun together.
4855        assert!(DiskCli::from_str("file:disk.vhd,on=c,nsid=1,lun=0").is_err());
4856    }
4857
4858    #[test]
4859    fn test_disk_cli_controller_with_lun() {
4860        let d = DiskCli::from_str("file:disk.vhd,on=scsi0,lun=3").unwrap();
4861        assert_eq!(d.controller.as_deref(), Some("scsi0"));
4862        assert_eq!(d.lun, Some(3));
4863        assert_eq!(d.nsid, None);
4864    }
4865
4866    #[test]
4867    fn test_scsi_controller_cli() {
4868        let c = ScsiControllerCli::from_str("id=scsi0").unwrap();
4869        assert_eq!(c.id, "scsi0");
4870        assert_eq!(c.sub_channels, 0);
4871    }
4872
4873    #[test]
4874    fn test_scsi_controller_cli_with_sub_channels() {
4875        let c = ScsiControllerCli::from_str("id=scsi1,sub_channels=4").unwrap();
4876        assert_eq!(c.id, "scsi1");
4877        assert_eq!(c.sub_channels, 4);
4878    }
4879
4880    #[test]
4881    fn test_scsi_controller_cli_errors() {
4882        // Missing id.
4883        assert!(ScsiControllerCli::from_str("sub_channels=4").is_err());
4884        // Empty id.
4885        assert!(ScsiControllerCli::from_str("id=").is_err());
4886        // Unknown option.
4887        assert!(ScsiControllerCli::from_str("id=scsi0,foo=bar").is_err());
4888        // Invalid sub_channels.
4889        assert!(ScsiControllerCli::from_str("id=scsi0,sub_channels=abc").is_err());
4890    }
4891
4892    #[test]
4893    fn test_disk_cli_relay() {
4894        let d = DiskCli::from_str("file:disk.vhd,on=src,relay=tgt").unwrap();
4895        assert_eq!(d.relay.as_ref().unwrap().0, "tgt");
4896        assert_eq!(d.relay.as_ref().unwrap().1, None);
4897    }
4898
4899    #[test]
4900    fn test_disk_cli_relay_with_location() {
4901        let d = DiskCli::from_str("file:disk.vhd,on=src,relay=tgt:3").unwrap();
4902        assert_eq!(d.relay.as_ref().unwrap().0, "tgt");
4903        assert_eq!(d.relay.as_ref().unwrap().1, Some(3));
4904    }
4905
4906    #[test]
4907    fn test_disk_cli_relay_errors() {
4908        // relay without on.
4909        assert!(DiskCli::from_str("file:disk.vhd,relay=tgt").is_err());
4910        // relay with uh.
4911        assert!(DiskCli::from_str("file:disk.vhd,on=src,relay=tgt,uh").is_err());
4912        // relay with invalid location.
4913        assert!(DiskCli::from_str("file:disk.vhd,on=src,relay=tgt:abc").is_err());
4914        // empty relay.
4915        assert!(DiskCli::from_str("file:disk.vhd,on=src,relay=").is_err());
4916    }
4917
4918    #[test]
4919    fn test_nvme_controller_cli_vtl2() {
4920        let c = NvmeControllerCli::from_str("id=nvme0,vpci,vtl2").unwrap();
4921        assert_eq!(c.vtl, DeviceVtl::Vtl2);
4922    }
4923
4924    #[test]
4925    fn test_scsi_controller_cli_vtl2() {
4926        let c = ScsiControllerCli::from_str("id=scsi0,vtl2").unwrap();
4927        assert_eq!(c.vtl, DeviceVtl::Vtl2);
4928    }
4929
4930    #[test]
4931    fn test_openhcl_controller_cli() {
4932        let c = OpenhclControllerCli::from_str("id=vtl0-scsi,type=scsi").unwrap();
4933        assert_eq!(c.id, "vtl0-scsi");
4934        assert_eq!(c.controller_type, OpenhclControllerType::Scsi);
4935        assert_eq!(c.guid, None);
4936    }
4937
4938    #[test]
4939    fn test_openhcl_controller_cli_nvme_with_guid() {
4940        let c = OpenhclControllerCli::from_str(
4941            "id=vtl0-nvme,type=nvme,guid=09a59b81-2bf6-4164-81d7-3a0dc977ba65",
4942        )
4943        .unwrap();
4944        assert_eq!(c.controller_type, OpenhclControllerType::Nvme);
4945        assert!(c.guid.is_some());
4946    }
4947
4948    #[test]
4949    fn test_openhcl_controller_cli_errors() {
4950        // Missing id.
4951        assert!(OpenhclControllerCli::from_str("type=scsi").is_err());
4952        // Missing type.
4953        assert!(OpenhclControllerCli::from_str("id=foo").is_err());
4954        // Invalid type.
4955        assert!(OpenhclControllerCli::from_str("id=foo,type=ide").is_err());
4956        // Invalid guid.
4957        assert!(OpenhclControllerCli::from_str("id=foo,type=scsi,guid=bad").is_err());
4958    }
4959
4960    #[test]
4961    fn test_parse_vp_list() {
4962        use super::parse_vp_list;
4963
4964        // Individual indices.
4965        assert_eq!(parse_vp_list("[0,1,2,3]").unwrap(), vec![0, 1, 2, 3]);
4966
4967        // Single index.
4968        assert_eq!(parse_vp_list("[5]").unwrap(), vec![5]);
4969
4970        // Dash range.
4971        assert_eq!(parse_vp_list("[0-3]").unwrap(), vec![0, 1, 2, 3]);
4972
4973        // Mixed indices and ranges.
4974        assert_eq!(
4975            parse_vp_list("[0,1,4-6,10]").unwrap(),
4976            vec![0, 1, 4, 5, 6, 10]
4977        );
4978
4979        // Whitespace tolerance.
4980        assert_eq!(parse_vp_list("[0, 1, 2-4]").unwrap(), vec![0, 1, 2, 3, 4]);
4981
4982        // Missing brackets.
4983        assert!(parse_vp_list("0,1,2").is_err());
4984        assert!(parse_vp_list("0-3").is_err());
4985
4986        // Inverted range.
4987        assert!(parse_vp_list("[3-0]").is_err());
4988
4989        // Non-numeric.
4990        assert!(parse_vp_list("[a,b]").is_err());
4991    }
4992
4993    #[test]
4994    fn test_split_options_brackets() {
4995        use super::split_options;
4996
4997        // No brackets — plain comma split.
4998        assert_eq!(
4999            split_options("a=1,b=2,c=3").unwrap(),
5000            vec!["a=1", "b=2", "c=3"]
5001        );
5002
5003        // Brackets protect inner commas.
5004        assert_eq!(
5005            split_options("size=2G,vps=[0,1,2]").unwrap(),
5006            vec!["size=2G", "vps=[0,1,2]"]
5007        );
5008
5009        // Brackets with ranges and trailing option.
5010        assert_eq!(
5011            split_options("size=2G,vps=[0-1,4-5],host_numa_node=0").unwrap(),
5012            vec!["size=2G", "vps=[0-1,4-5]", "host_numa_node=0"]
5013        );
5014
5015        // Unmatched brackets.
5016        assert!(split_options("vps=[0,1").is_err());
5017        assert!(split_options("vps=0,1]").is_err());
5018    }
5019
5020    #[test]
5021    fn test_parse_numa_node() {
5022        use super::parse_numa_node;
5023
5024        // Basic node with size only.
5025        let n = parse_numa_node("size=2G").unwrap();
5026        assert_eq!(n.memory.mem_size, 2 * 1024 * 1024 * 1024);
5027        assert!(n.vps.is_none());
5028        assert!(n.host_numa_node.is_none());
5029
5030        // Node with bracket VP list.
5031        let n = parse_numa_node("size=1G,vps=[0,1,2,3]").unwrap();
5032        assert_eq!(n.vps.unwrap(), vec![0, 1, 2, 3]);
5033
5034        // Node with VP range in brackets.
5035        let n = parse_numa_node("size=1G,vps=[0-3]").unwrap();
5036        assert_eq!(n.vps.unwrap(), vec![0, 1, 2, 3]);
5037
5038        // Node with host_numa_node.
5039        let n = parse_numa_node("size=1G,host_numa_node=1").unwrap();
5040        assert_eq!(n.host_numa_node, Some(1));
5041
5042        // All options together.
5043        let n = parse_numa_node("size=1G,vps=[0,1],host_numa_node=0,hugepages=on").unwrap();
5044        assert_eq!(n.vps.unwrap(), vec![0, 1]);
5045        assert_eq!(n.host_numa_node, Some(0));
5046        assert!(n.memory.hugepages);
5047
5048        // Missing size.
5049        assert!(parse_numa_node("vps=[0,1]").is_err());
5050
5051        // Bare vps without brackets.
5052        assert!(parse_numa_node("size=1G,vps=0,1").is_err());
5053
5054        // Duplicate vps.
5055        assert!(parse_numa_node("size=1G,vps=[0],vps=[1]").is_err());
5056
5057        // Empty vps=[] for memory-only node.
5058        let n = parse_numa_node("size=1G,vps=[]").unwrap();
5059        assert_eq!(n.vps.unwrap(), Vec::<u32>::new());
5060    }
5061
5062    #[test]
5063    fn test_parse_numa_distance() {
5064        use super::parse_numa_distance;
5065
5066        let d = parse_numa_distance("0:1:20").unwrap();
5067        assert_eq!(d.src, 0);
5068        assert_eq!(d.dst, 1);
5069        assert_eq!(d.distance, 20);
5070
5071        // Self-distance.
5072        let d = parse_numa_distance("0:0:10").unwrap();
5073        assert_eq!(d.distance, 10);
5074
5075        // Distance below minimum.
5076        assert!(parse_numa_distance("0:1:5").is_err());
5077
5078        // Wrong format.
5079        assert!(parse_numa_distance("0:1").is_err());
5080        assert!(parse_numa_distance("0:1:20:extra").is_err());
5081    }
5082}