Skip to content

Commit 8901abe

Browse files
committed
Improve VirtualBox descriptor parsing
1 parent b19a789 commit 8901abe

15 files changed

Lines changed: 676 additions & 446 deletions

File tree

Lines changed: 111 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,125 @@
11
from __future__ import annotations
22

3+
from datetime import datetime
34
from typing import TYPE_CHECKING, TextIO
5+
from uuid import UUID
46

57
from defusedxml import ElementTree
68

79
if TYPE_CHECKING:
8-
from collections.abc import Iterator
910
from xml.etree.ElementTree import Element
1011

12+
NS = "{http://www.virtualbox.org/}"
1113

12-
class VBox:
13-
VBOX_XML_NAMESPACE = "{http://www.virtualbox.org/}"
1414

15+
class VBox:
1516
def __init__(self, fh: TextIO):
1617
self._xml: Element = ElementTree.fromstring(fh.read())
18+
if self._xml.tag != f"{NS}VirtualBox":
19+
raise ValueError("Invalid VirtualBox XML descriptor: root element is not VirtualBox")
20+
21+
self.machine = self._xml.find(f"./{NS}Machine")
22+
if self.machine is None:
23+
raise ValueError("Invalid VirtualBox XML descriptor: no Machine element found")
24+
25+
self.media: dict[str, HardDisk] = {}
26+
27+
stack = [(None, element) for element in self.machine.find(f"./{NS}MediaRegistry/{NS}HardDisks")]
28+
while stack:
29+
parent, element = stack.pop()
30+
hdd = HardDisk(self, element, parent)
31+
32+
self.media[hdd.uuid] = hdd
33+
stack.extend([(hdd, child) for child in list(element)])
34+
35+
if (element := self.machine.find(f"./{NS}Hardware")) is None:
36+
raise ValueError("Invalid VirtualBox XML descriptor: no Hardware element found")
37+
self.hardware = Hardware(self, element)
38+
39+
@property
40+
def uuid(self) -> UUID | None:
41+
"""The VM UUID."""
42+
if self.machine is not None:
43+
return UUID(self.machine.get("uuid").strip("{}"))
44+
return None
45+
46+
@property
47+
def current_snapshot(self) -> Snapshot | None:
48+
"""The current snapshot."""
49+
if self.machine is not None and (value := self.machine.get("currentSnapshot")) is not None:
50+
current_snapshot = UUID(value.strip("{}"))
51+
for snapshot in self.snapshots:
52+
if snapshot.uuid == current_snapshot:
53+
return snapshot
54+
return None
55+
56+
@property
57+
def name(self) -> str | None:
58+
"""The VM name."""
59+
if self.machine is not None:
60+
return self.machine.get("name")
61+
return None
62+
63+
@property
64+
def snapshots(self) -> list[Snapshot]:
65+
"""All snapshots."""
66+
# Snapshots have a weird structure, since we don't do much with it for now, just get them flatly
67+
# TODO: Implement proper snapshot tree structure
68+
return [Snapshot(self, element) for element in self.machine.findall(f".//{NS}Snapshot")]
69+
70+
71+
class HardDisk:
72+
def __init__(self, vbox: VBox, element: Element, parent: HardDisk | None = None):
73+
self.vbox = vbox
74+
self.element = element
75+
self.parent = parent
76+
77+
@property
78+
def uuid(self) -> UUID:
79+
"""The disk UUID."""
80+
return UUID(self.element.get("uuid").strip("{}"))
81+
82+
@property
83+
def location(self) -> str:
84+
"""The disk location."""
85+
return self.element.get("location")
86+
87+
88+
class Snapshot:
89+
def __init__(self, vbox: VBox, element: Element):
90+
self.vbox = vbox
91+
self.element = element
92+
93+
@property
94+
def uuid(self) -> UUID:
95+
"""The snapshot UUID."""
96+
return UUID(self.element.get("uuid").strip("{}"))
97+
98+
@property
99+
def name(self) -> str:
100+
"""The snapshot name."""
101+
return self.element.get("name")
102+
103+
@property
104+
def ts(self) -> datetime:
105+
"""The snapshot timestamp."""
106+
return datetime.strptime(self.element.get("timeStamp"), "%Y-%m-%dT%H:%M:%S%z")
107+
108+
@property
109+
def hardware(self) -> Hardware:
110+
"""The snapshot hardware state."""
111+
return Hardware(self.vbox, self.element.find(f"./{NS}Hardware"))
112+
113+
114+
class Hardware:
115+
def __init__(self, vbox: VBox, element: Element):
116+
self.vbox = vbox
117+
self.element = element
17118

18-
def disks(self) -> Iterator[str]:
19-
for hdd_elem in self._xml.findall(f".//{self.VBOX_XML_NAMESPACE}HardDisk[@location][@type='Normal']"):
20-
# Allow format specifier to be case-insensitive (i.e. VDI, vdi)
21-
if (format := hdd_elem.get("format")) and format.lower() == "vdi":
22-
yield hdd_elem.attrib["location"]
119+
@property
120+
def disks(self) -> list[HardDisk]:
121+
"""All attached hard disks."""
122+
images = self.element.findall(
123+
f"./{NS}StorageControllers/{NS}StorageController/{NS}AttachedDevice[@type='HardDisk']/{NS}Image"
124+
)
125+
return [self.vbox.media[UUID(image.get("uuid").strip("{}"))] for image in images]
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
<?xml version="1.0"?>
2+
<!--
3+
** DO NOT EDIT THIS FILE.
4+
** If you make changes to this file while any VirtualBox related application
5+
** is running, your changes will be overwritten later, without taking effect.
6+
** Use VBoxManage or the VirtualBox Manager GUI to make changes.
7+
**
8+
** Written by VirtualBox 7.2.4_RPMFUSION (r170995)
9+
-->
10+
<VirtualBox xmlns="http://www.virtualbox.org/" version="1.19-linux">
11+
<Machine uuid="{a6277950-3d1b-45d3-b2fd-dc1f385027e1}" name="GOAD-DC01" OSType="Windows2016_64" currentSnapshot="{264ccd7e-9ffd-45ba-bd0e-4e1968d3355a}" snapshotFolder="Snapshots" lastStateChange="2025-12-19T21:00:43Z">
12+
<MediaRegistry>
13+
<HardDisks>
14+
<HardDisk uuid="{0898f36f-01c3-4b43-8aeb-36ba7adaef95}" location="WindowsServer2019-disk001.vmdk" format="VMDK" type="Normal">
15+
<HardDisk uuid="{3c72ec80-dc73-4448-a63e-97970cdd87e5}" location="Snapshots/{3c72ec80-dc73-4448-a63e-97970cdd87e5}.vmdk" format="VMDK">
16+
<HardDisk uuid="{e6800503-8273-4f16-b584-c7ba2c1df698}" location="Snapshots/{e6800503-8273-4f16-b584-c7ba2c1df698}.vmdk" format="VMDK"/>
17+
</HardDisk>
18+
<!--
19+
** I ignored the big warning above and edited this file anyway. Yes, I'm kind of a rebel.
20+
** Don't tell VirtualBox about it, they get upset.
21+
** Manually add a "tree" of snapshots for testing purposes.
22+
-->
23+
<HardDisk uuid="{264ccd7e-9ffd-45ba-bd0e-4e1968d3355a}" location="Snapshots/{264ccd7e-9ffd-45ba-bd0e-4e1968d3355a}.vmdk" format="VMDK">
24+
<HardDisk uuid="{95b1572d-c893-48f0-9bc9-6a01a0fd2cb6}" location="Snapshots/{95b1572d-c893-48f0-9bc9-6a01a0fd2cb6}.vmdk" format="VMDK"/>
25+
</HardDisk>
26+
</HardDisk>
27+
</HardDisks>
28+
</MediaRegistry>
29+
<Snapshot uuid="{95b1572d-c893-48f0-9bc9-6a01a0fd2cb6}" name="push_1766163705_7292" timeStamp="2025-12-19T17:01:46Z">
30+
<Hardware>
31+
<Memory RAMSize="3000"/>
32+
<Boot>
33+
<Order position="1" device="HardDisk"/>
34+
<Order position="2" device="DVD"/>
35+
<Order position="3" device="None"/>
36+
<Order position="4" device="None"/>
37+
</Boot>
38+
<Display VRAMSize="128"/>
39+
<RemoteDisplay enabled="true">
40+
<VRDEProperties>
41+
<Property name="TCP/Address" value="127.0.0.1"/>
42+
<Property name="TCP/Ports" value="5902"/>
43+
</VRDEProperties>
44+
</RemoteDisplay>
45+
<Firmware/>
46+
<BIOS>
47+
<IOAPIC enabled="true"/>
48+
<SmbiosUuidLittleEndian enabled="true"/>
49+
</BIOS>
50+
<Network>
51+
<Adapter slot="0" enabled="true" MACAddress="0800277AA2FC" type="82540EM">
52+
<NAT localhost-reachable="true">
53+
<DNS use-proxy="true"/>
54+
<Forwarding name="ssh" proto="1" hostip="127.0.0.1" hostport="2222" guestport="22"/>
55+
<Forwarding name="winrm" proto="1" hostip="127.0.0.1" hostport="55985" guestport="5985"/>
56+
<Forwarding name="winrm-ssl" proto="1" hostip="127.0.0.1" hostport="55986" guestport="5986"/>
57+
</NAT>
58+
</Adapter>
59+
<Adapter slot="1" enabled="true" MACAddress="0800278CC821" type="82540EM">
60+
<DisabledModes>
61+
<NAT localhost-reachable="true"/>
62+
<InternalNetwork name="intnet"/>
63+
<NATNetwork name="NatNetwork"/>
64+
</DisabledModes>
65+
<HostOnlyInterface name="vboxnet0"/>
66+
</Adapter>
67+
</Network>
68+
<AudioAdapter driver="ALSA"/>
69+
<Clipboard mode="Bidirectional"/>
70+
<StorageControllers>
71+
<StorageController name="IDE Controller" type="PIIX4" PortCount="2" useHostIOCache="true" Bootable="true">
72+
<AttachedDevice type="HardDisk" hotpluggable="false" port="0" device="0">
73+
<Image uuid="{0898f36f-01c3-4b43-8aeb-36ba7adaef95}"/>
74+
</AttachedDevice>
75+
</StorageController>
76+
</StorageControllers>
77+
<RTC localOrUTC="UTC"/>
78+
<CPU count="2">
79+
<HardwareVirtExLargePages enabled="false"/>
80+
<PAE enabled="true"/>
81+
<LongMode enabled="true"/>
82+
</CPU>
83+
</Hardware>
84+
<Snapshots>
85+
<Snapshot uuid="{264ccd7e-9ffd-45ba-bd0e-4e1968d3355a}" name="push_1766170151_8843" timeStamp="2025-12-19T18:49:11Z">
86+
<Hardware>
87+
<Memory RAMSize="3000"/>
88+
<Boot>
89+
<Order position="1" device="HardDisk"/>
90+
<Order position="2" device="DVD"/>
91+
<Order position="3" device="None"/>
92+
<Order position="4" device="None"/>
93+
</Boot>
94+
<Display VRAMSize="128"/>
95+
<RemoteDisplay enabled="true">
96+
<VRDEProperties>
97+
<Property name="TCP/Address" value="127.0.0.1"/>
98+
<Property name="TCP/Ports" value="5902"/>
99+
</VRDEProperties>
100+
</RemoteDisplay>
101+
<Firmware/>
102+
<BIOS>
103+
<IOAPIC enabled="true"/>
104+
<SmbiosUuidLittleEndian enabled="true"/>
105+
</BIOS>
106+
<Network>
107+
<Adapter slot="0" enabled="true" MACAddress="0800277AA2FC" type="82540EM">
108+
<NAT localhost-reachable="true">
109+
<DNS use-proxy="true"/>
110+
<Forwarding name="ssh" proto="1" hostip="127.0.0.1" hostport="2222" guestport="22"/>
111+
<Forwarding name="winrm" proto="1" hostip="127.0.0.1" hostport="55985" guestport="5985"/>
112+
<Forwarding name="winrm-ssl" proto="1" hostip="127.0.0.1" hostport="55986" guestport="5986"/>
113+
</NAT>
114+
</Adapter>
115+
<Adapter slot="1" enabled="true" MACAddress="0800278CC821" type="82540EM">
116+
<DisabledModes>
117+
<NAT localhost-reachable="true"/>
118+
<InternalNetwork name="intnet"/>
119+
<NATNetwork name="NatNetwork"/>
120+
</DisabledModes>
121+
<HostOnlyInterface name="vboxnet0"/>
122+
</Adapter>
123+
</Network>
124+
<AudioAdapter driver="ALSA"/>
125+
<Clipboard mode="Bidirectional"/>
126+
<StorageControllers>
127+
<StorageController name="IDE Controller" type="PIIX4" PortCount="2" useHostIOCache="true" Bootable="true">
128+
<AttachedDevice type="HardDisk" hotpluggable="false" port="0" device="0">
129+
<Image uuid="{3c72ec80-dc73-4448-a63e-97970cdd87e5}"/>
130+
</AttachedDevice>
131+
</StorageController>
132+
</StorageControllers>
133+
<RTC localOrUTC="UTC"/>
134+
<CPU count="2">
135+
<HardwareVirtExLargePages enabled="false"/>
136+
<PAE enabled="true"/>
137+
<LongMode enabled="true"/>
138+
</CPU>
139+
</Hardware>
140+
</Snapshot>
141+
</Snapshots>
142+
</Snapshot>
143+
<Hardware>
144+
<Memory RAMSize="3000"/>
145+
<Boot>
146+
<Order position="1" device="HardDisk"/>
147+
<Order position="2" device="DVD"/>
148+
<Order position="3" device="None"/>
149+
<Order position="4" device="None"/>
150+
</Boot>
151+
<Display VRAMSize="128"/>
152+
<RemoteDisplay enabled="true">
153+
<VRDEProperties>
154+
<Property name="TCP/Address" value="127.0.0.1"/>
155+
<Property name="TCP/Ports" value="5902"/>
156+
</VRDEProperties>
157+
</RemoteDisplay>
158+
<Firmware/>
159+
<BIOS>
160+
<IOAPIC enabled="true"/>
161+
<SmbiosUuidLittleEndian enabled="true"/>
162+
</BIOS>
163+
<Network>
164+
<Adapter slot="0" enabled="true" MACAddress="0800277AA2FC" type="82540EM">
165+
<NAT localhost-reachable="true">
166+
<DNS use-proxy="true"/>
167+
<Forwarding name="ssh" proto="1" hostip="127.0.0.1" hostport="2222" guestport="22"/>
168+
<Forwarding name="winrm" proto="1" hostip="127.0.0.1" hostport="55985" guestport="5985"/>
169+
<Forwarding name="winrm-ssl" proto="1" hostip="127.0.0.1" hostport="55986" guestport="5986"/>
170+
</NAT>
171+
</Adapter>
172+
<Adapter slot="1" enabled="true" MACAddress="0800278CC821" type="82540EM">
173+
<DisabledModes>
174+
<NAT localhost-reachable="true"/>
175+
<InternalNetwork name="intnet"/>
176+
<NATNetwork name="NatNetwork"/>
177+
</DisabledModes>
178+
<HostOnlyInterface name="vboxnet0"/>
179+
</Adapter>
180+
</Network>
181+
<AudioAdapter driver="ALSA"/>
182+
<Clipboard mode="Bidirectional"/>
183+
<StorageControllers>
184+
<StorageController name="IDE Controller" type="PIIX4" PortCount="2" useHostIOCache="true" Bootable="true">
185+
<AttachedDevice type="HardDisk" hotpluggable="false" port="0" device="0">
186+
<Image uuid="{e6800503-8273-4f16-b584-c7ba2c1df698}"/>
187+
</AttachedDevice>
188+
</StorageController>
189+
</StorageControllers>
190+
<RTC localOrUTC="UTC"/>
191+
<CPU count="2">
192+
<HardwareVirtExLargePages enabled="false"/>
193+
<PAE enabled="true"/>
194+
<LongMode enabled="true"/>
195+
</CPU>
196+
</Hardware>
197+
<Groups>
198+
<Group name="/GOAD"/>
199+
</Groups>
200+
</Machine>
201+
</VirtualBox>

tests/_util.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
6+
def absolute_path(filename: str) -> Path:
7+
return Path(__file__).parent.joinpath(filename).resolve()

0 commit comments

Comments
 (0)