diff --git a/src/PSProxmoxVE.Core/Models/Vms/OvfMetadata.cs b/src/PSProxmoxVE.Core/Models/Vms/OvfMetadata.cs
index 4df6967..5ab818b 100644
--- a/src/PSProxmoxVE.Core/Models/Vms/OvfMetadata.cs
+++ b/src/PSProxmoxVE.Core/Models/Vms/OvfMetadata.cs
@@ -1,9 +1,4 @@
-using System;
using System.Collections.Generic;
-using System.IO;
-using System.Text;
-using System.Text.RegularExpressions;
-using System.Xml;
namespace PSProxmoxVE.Core.Models.Vms
{
@@ -57,294 +52,6 @@ namespace PSProxmoxVE.Core.Models.Vms
/// OS type hint from the OVF OperatingSystemSection.
public string OsTypeHint { get; set; } = string.Empty;
- // OVF namespace constants
- private const string OvfNs = "http://schemas.dmtf.org/ovf/envelope/1";
- private const string RasdNs = "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData";
- private const string VssdNs = "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_VirtualSystemSettingData";
-
- // ovf:href is used as a path segment in a PVE property string (import-from=storage:import/ova/href);
- // PVE property strings are comma-separated, so ',' and any path separator must be rejected.
- // \A/\z (not ^/$) so a trailing newline cannot sneak past the anchor under .NET's default regex options.
- private static readonly Regex ValidHrefPattern = new Regex(@"\A[A-Za-z0-9._-]+\z", RegexOptions.Compiled);
-
- ///
- /// Parses an OVA file (TAR archive) and extracts OVF metadata.
- ///
- /// Path to the OVA file.
- /// Parsed OVF metadata.
- public static OvfMetadata FromOva(string ovaPath)
- {
- if (string.IsNullOrWhiteSpace(ovaPath))
- throw new ArgumentException("OVA path must not be null or empty.", nameof(ovaPath));
- if (!File.Exists(ovaPath))
- throw new FileNotFoundException("OVA file not found.", ovaPath);
-
- var ovfXml = ExtractOvfFromTar(ovaPath);
- if (ovfXml == null)
- throw new InvalidOperationException("No .ovf file found inside the OVA archive.");
-
- return ParseOvfXml(ovfXml);
- }
-
- ///
- /// Extracts the .ovf XML content from a TAR archive using SharpCompress.
- ///
- private static string? ExtractOvfFromTar(string tarPath)
- {
- using var stream = File.OpenRead(tarPath);
- using var reader = SharpCompress.Readers.ReaderFactory.OpenReader(stream, new SharpCompress.Readers.ReaderOptions());
-
- while (reader.MoveToNextEntry())
- {
- if (!reader.Entry.IsDirectory &&
- reader.Entry.Key != null &&
- reader.Entry.Key.EndsWith(".ovf", StringComparison.OrdinalIgnoreCase))
- {
- using var entryStream = reader.OpenEntryStream();
- using var sr = new StreamReader(entryStream, Encoding.UTF8);
- return sr.ReadToEnd();
- }
- }
-
- return null;
- }
-
- ///
- /// Parses OVF XML and extracts VM metadata.
- ///
- internal static OvfMetadata ParseOvfXml(string xml)
- {
- var doc = new XmlDocument();
- var readerSettings = new XmlReaderSettings
- {
- DtdProcessing = DtdProcessing.Prohibit,
- XmlResolver = null
- };
- using (var stringReader = new StringReader(xml))
- using (var xmlReader = XmlReader.Create(stringReader, readerSettings))
- {
- doc.Load(xmlReader);
- }
-
- var nsm = new XmlNamespaceManager(doc.NameTable);
- nsm.AddNamespace("ovf", OvfNs);
- nsm.AddNamespace("rasd", RasdNs);
- nsm.AddNamespace("vssd", VssdNs);
-
- var metadata = new OvfMetadata();
-
- // Extract VM name from VirtualSystem
- var vsNode = doc.SelectSingleNode("//ovf:VirtualSystem", nsm);
- if (vsNode != null)
- {
- // Try ovf:id attribute first, then Name element
- var idAttr = vsNode.Attributes?["ovf:id"];
- if (idAttr != null && !string.IsNullOrEmpty(idAttr.Value))
- {
- metadata.Name = idAttr.Value;
- }
- }
-
- // Try VirtualSystemIdentifier from VirtualSystemSettingData
- var vsId = doc.SelectSingleNode("//vssd:VirtualSystemIdentifier", nsm);
- if (vsId != null && !string.IsNullOrEmpty(vsId.InnerText))
- {
- metadata.Name = vsId.InnerText;
- }
-
- // Extract OS type hint from OperatingSystemSection
- var osSection = doc.SelectSingleNode("//ovf:OperatingSystemSection", nsm);
- if (osSection != null)
- {
- var osTypeAttr = osSection.Attributes?["ovf:id"];
- var description = osSection.SelectSingleNode("ovf:Description", nsm);
- var osDesc = description?.InnerText ?? osTypeAttr?.Value ?? string.Empty;
- metadata.OsTypeHint = MapOsType(osDesc);
- }
-
- // Build file reference map: fileRef -> fileName
- var fileRefs = new Dictionary(StringComparer.OrdinalIgnoreCase);
- var fileNodes = doc.SelectNodes("//ovf:References/ovf:File", nsm);
- if (fileNodes != null)
- {
- foreach (XmlNode fileNode in fileNodes)
- {
- var id = fileNode.Attributes?["ovf:id"]?.Value;
- var href = fileNode.Attributes?["ovf:href"]?.Value;
- if (id != null && href != null)
- {
- if (!ValidHrefPattern.IsMatch(href) || href == "." || href == "..")
- throw new InvalidDataException($"OVF descriptor references a disallowed file name: '{href}'.");
- fileRefs[id] = href;
- }
- }
- }
-
- // Build disk reference map: diskId -> fileRef
- var diskFileMap = new Dictionary(StringComparer.OrdinalIgnoreCase);
- var diskNodes = doc.SelectNodes("//ovf:DiskSection/ovf:Disk", nsm);
- if (diskNodes != null)
- {
- foreach (XmlNode diskNode in diskNodes)
- {
- var diskId = diskNode.Attributes?["ovf:diskId"]?.Value;
- var fileRef = diskNode.Attributes?["ovf:fileRef"]?.Value;
- if (diskId != null && fileRef != null)
- {
- diskFileMap[diskId] = fileRef;
- }
- }
- }
-
- // Parse hardware items
- var items = doc.SelectNodes("//ovf:VirtualHardwareSection/ovf:Item", nsm);
- if (items != null)
- {
- foreach (XmlNode item in items)
- {
- var resourceTypeNode = item.SelectSingleNode("rasd:ResourceType", nsm);
- if (resourceTypeNode == null) continue;
-
- if (!int.TryParse(resourceTypeNode.InnerText.Trim(), out int resourceType))
- continue;
-
- switch (resourceType)
- {
- case 3: // Processor
- var vcpuNode = item.SelectSingleNode("rasd:VirtualQuantity", nsm);
- if (vcpuNode != null && int.TryParse(vcpuNode.InnerText.Trim(), out int vcpus))
- metadata.CpuCount = vcpus;
- break;
-
- case 4: // Memory
- var memNode = item.SelectSingleNode("rasd:VirtualQuantity", nsm);
- var unitsNode = item.SelectSingleNode("rasd:AllocationUnits", nsm);
- if (memNode != null && long.TryParse(memNode.InnerText.Trim(), out long memVal))
- {
- var units = unitsNode?.InnerText?.Trim() ?? "byte * 2^20";
- metadata.MemoryMB = ConvertToMB(memVal, units);
- }
- break;
-
- case 5: // IDE Controller
- case 6: // Parallel SCSI HBA
- case 20: // Other storage device (VMware's SATA AHCI controller)
- // Controllers themselves don't produce disk entries; skip.
- break;
-
- case 17: // Disk Drive
- var diskRef = ExtractDiskReference(item, nsm, diskFileMap, fileRefs);
- if (diskRef != null)
- {
- // Determine bus type from parent controller
- diskRef.BusType = DetermineBusType(item, items, nsm);
- metadata.Disks.Add(diskRef);
- }
- break;
-
- case 10: // Ethernet Adapter
- var adapterName = item.SelectSingleNode("rasd:ElementName", nsm)?.InnerText
- ?? item.SelectSingleNode("rasd:Caption", nsm)?.InnerText
- ?? "Network adapter";
- var connection = item.SelectSingleNode("rasd:Connection", nsm)?.InnerText ?? string.Empty;
- var nicSubType = item.SelectSingleNode("rasd:ResourceSubType", nsm)?.InnerText ?? string.Empty;
- metadata.NetworkAdapters.Add(new OvfNetworkAdapter
- {
- AdapterName = adapterName,
- ConnectionName = connection,
- ResourceSubType = nicSubType
- });
- break;
- }
- }
- }
-
- return metadata;
- }
-
- private static OvfDiskReference? ExtractDiskReference(
- XmlNode item,
- XmlNamespaceManager nsm,
- Dictionary diskFileMap,
- Dictionary fileRefs)
- {
- // The HostResource element typically contains a reference like "ovf:/disk/vmdisk1"
- var hostResource = item.SelectSingleNode("rasd:HostResource", nsm)?.InnerText ?? string.Empty;
- string? diskId = null;
-
- if (hostResource.Contains("/disk/"))
- {
- var idx = hostResource.LastIndexOf("/disk/", StringComparison.Ordinal);
- diskId = hostResource.Substring(idx + 6);
- }
- else if (hostResource.Contains("disk/"))
- {
- var idx = hostResource.LastIndexOf("disk/", StringComparison.Ordinal);
- diskId = hostResource.Substring(idx + 5);
- }
-
- if (diskId != null && diskFileMap.TryGetValue(diskId, out var fileRef) && fileRefs.TryGetValue(fileRef, out var fileName))
- {
- return new OvfDiskReference { FileName = fileName };
- }
-
- return null;
- }
-
- private static string DetermineBusType(XmlNode diskItem, XmlNodeList allItems, XmlNamespaceManager nsm)
- {
- // Look at the Parent element to find which controller this disk is attached to
- var parentNode = diskItem.SelectSingleNode("rasd:Parent", nsm);
- if (parentNode == null)
- return "scsi"; // default
-
- var parentId = parentNode.InnerText.Trim();
-
- foreach (XmlNode item in allItems)
- {
- var instanceId = item.SelectSingleNode("rasd:InstanceID", nsm)?.InnerText?.Trim();
- if (instanceId != parentId) continue;
-
- var resourceTypeNode = item.SelectSingleNode("rasd:ResourceType", nsm);
- if (resourceTypeNode == null) continue;
-
- if (int.TryParse(resourceTypeNode.InnerText.Trim(), out int rt))
- {
- switch (rt)
- {
- case 5: return "ide";
- case 6: return "scsi";
- case 20: return "sata";
- }
- }
- break;
- }
-
- return "scsi"; // default fallback
- }
-
- private static int ConvertToMB(long value, string allocationUnits)
- {
- // Common OVF allocation units:
- // "byte * 2^20" = MiB
- // "byte * 2^30" = GiB
- // "byte * 2^10" = KiB
- // "MegaBytes" or "MB"
- var lower = allocationUnits.ToLowerInvariant();
-
- if (lower.Contains("2^30") || lower.Contains("gib") || lower.Contains("gigabyte"))
- return (int)(value * 1024);
- if (lower.Contains("2^20") || lower.Contains("mib") || lower.Contains("megabyte") || lower.Contains("mb"))
- return (int)value;
- if (lower.Contains("2^10") || lower.Contains("kib") || lower.Contains("kilobyte") || lower.Contains("kb"))
- return (int)(value / 1024);
- if (lower.Contains("byte"))
- return (int)(value / (1024 * 1024));
-
- // Default: assume MiB
- return (int)value;
- }
-
///
/// Maps an OVF ResourceSubType for a NIC to a PVE network model string.
///
@@ -371,58 +78,5 @@ namespace PSProxmoxVE.Core.Models.Vms
// Unknown model — default to e1000 (widely compatible, no driver install needed)
return "e1000";
}
-
- private static string MapOsType(string osDescription)
- {
- if (string.IsNullOrEmpty(osDescription))
- return "other";
-
- var lower = osDescription.ToLowerInvariant();
-
- // Windows variants
- if (lower.Contains("windows 11") || lower.Contains("win11"))
- return "win11";
- if (lower.Contains("windows 10") || lower.Contains("win10"))
- return "win10";
- if (lower.Contains("windows server 2022") || lower.Contains("2022"))
- return "win11";
- if (lower.Contains("windows server 2019") || lower.Contains("2019"))
- return "win10";
- if (lower.Contains("windows server 2016") || lower.Contains("2016"))
- return "win10";
- if (lower.Contains("windows 8") || lower.Contains("win8"))
- return "win8";
- if (lower.Contains("windows 7") || lower.Contains("win7"))
- return "win7";
- if (lower.Contains("windows"))
- return "win10";
-
- // Linux variants
- if (lower.Contains("linux") || lower.Contains("ubuntu") || lower.Contains("debian") ||
- lower.Contains("centos") || lower.Contains("rhel") || lower.Contains("red hat") ||
- lower.Contains("fedora") || lower.Contains("suse") || lower.Contains("alma") ||
- lower.Contains("rocky"))
- return "l26";
-
- // FreeBSD
- if (lower.Contains("freebsd"))
- return "l26";
-
- // Solaris
- if (lower.Contains("solaris"))
- return "solaris";
-
- // Try numeric OVF OS ID
- if (int.TryParse(osDescription, out int osId))
- {
- // Common CIM OS IDs
- if (osId >= 56 && osId <= 70) return "win10"; // Various Windows
- if (osId >= 93 && osId <= 113) return "l26"; // Various Linux
- if (osId == 36) return "l26"; // Linux
- if (osId == 101 || osId == 106) return "l26"; // Linux 64-bit
- }
-
- return "other";
- }
}
}
diff --git a/src/PSProxmoxVE.Core/Utilities/OvfReader.cs b/src/PSProxmoxVE.Core/Utilities/OvfReader.cs
new file mode 100644
index 0000000..6b8d8e1
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Utilities/OvfReader.cs
@@ -0,0 +1,356 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Xml;
+using PSProxmoxVE.Core.Models.Vms;
+
+namespace PSProxmoxVE.Core.Utilities
+{
+ ///
+ /// Reads OVA archives and OVF descriptors into .
+ ///
+ public static class OvfReader
+ {
+ private const string OvfNs = "http://schemas.dmtf.org/ovf/envelope/1";
+ private const string RasdNs = "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData";
+ private const string VssdNs = "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_VirtualSystemSettingData";
+
+ // ovf:href is used as a path segment in a PVE property string (import-from=storage:import/ova/href);
+ // PVE property strings are comma-separated, so ',' and any path separator must be rejected.
+ // \A/\z (not ^/$) so a trailing newline cannot sneak past the anchor under .NET's default regex options.
+ private static readonly Regex ValidHrefPattern = new Regex(@"\A[A-Za-z0-9._-]+\z", RegexOptions.Compiled);
+
+ ///
+ /// Parses an OVA file (TAR archive) and extracts OVF metadata.
+ ///
+ /// Path to the OVA file.
+ /// Parsed OVF metadata.
+ public static OvfMetadata ReadOva(string ovaPath)
+ {
+ if (string.IsNullOrWhiteSpace(ovaPath))
+ throw new ArgumentException("OVA path must not be null or empty.", nameof(ovaPath));
+ if (!File.Exists(ovaPath))
+ throw new FileNotFoundException("OVA file not found.", ovaPath);
+
+ var ovfXml = ExtractOvfFromTar(ovaPath);
+ if (ovfXml == null)
+ throw new InvalidOperationException("No .ovf file found inside the OVA archive.");
+
+ return ParseOvf(ovfXml);
+ }
+
+ ///
+ /// Extracts the .ovf XML content from a TAR archive using SharpCompress.
+ ///
+ private static string? ExtractOvfFromTar(string tarPath)
+ {
+ using var stream = File.OpenRead(tarPath);
+ using var reader = SharpCompress.Readers.ReaderFactory.OpenReader(stream, new SharpCompress.Readers.ReaderOptions());
+
+ while (reader.MoveToNextEntry())
+ {
+ if (!reader.Entry.IsDirectory &&
+ reader.Entry.Key != null &&
+ reader.Entry.Key.EndsWith(".ovf", StringComparison.OrdinalIgnoreCase))
+ {
+ using var entryStream = reader.OpenEntryStream();
+ using var sr = new StreamReader(entryStream, Encoding.UTF8);
+ return sr.ReadToEnd();
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Parses OVF XML and extracts VM metadata.
+ ///
+ internal static OvfMetadata ParseOvf(string xml)
+ {
+ var doc = new XmlDocument();
+ var readerSettings = new XmlReaderSettings
+ {
+ DtdProcessing = DtdProcessing.Prohibit,
+ XmlResolver = null
+ };
+ using (var stringReader = new StringReader(xml))
+ using (var xmlReader = XmlReader.Create(stringReader, readerSettings))
+ {
+ doc.Load(xmlReader);
+ }
+
+ var nsm = new XmlNamespaceManager(doc.NameTable);
+ nsm.AddNamespace("ovf", OvfNs);
+ nsm.AddNamespace("rasd", RasdNs);
+ nsm.AddNamespace("vssd", VssdNs);
+
+ var metadata = new OvfMetadata();
+
+ // Extract VM name from VirtualSystem
+ var vsNode = doc.SelectSingleNode("//ovf:VirtualSystem", nsm);
+ if (vsNode != null)
+ {
+ // Try ovf:id attribute first, then Name element
+ var idAttr = vsNode.Attributes?["ovf:id"];
+ if (idAttr != null && !string.IsNullOrEmpty(idAttr.Value))
+ {
+ metadata.Name = idAttr.Value;
+ }
+ }
+
+ // Try VirtualSystemIdentifier from VirtualSystemSettingData
+ var vsId = doc.SelectSingleNode("//vssd:VirtualSystemIdentifier", nsm);
+ if (vsId != null && !string.IsNullOrEmpty(vsId.InnerText))
+ {
+ metadata.Name = vsId.InnerText;
+ }
+
+ // Extract OS type hint from OperatingSystemSection
+ var osSection = doc.SelectSingleNode("//ovf:OperatingSystemSection", nsm);
+ if (osSection != null)
+ {
+ var osTypeAttr = osSection.Attributes?["ovf:id"];
+ var description = osSection.SelectSingleNode("ovf:Description", nsm);
+ var osDesc = description?.InnerText ?? osTypeAttr?.Value ?? string.Empty;
+ metadata.OsTypeHint = MapOsType(osDesc);
+ }
+
+ // Build file reference map: fileRef -> fileName
+ var fileRefs = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var fileNodes = doc.SelectNodes("//ovf:References/ovf:File", nsm);
+ if (fileNodes != null)
+ {
+ foreach (XmlNode fileNode in fileNodes)
+ {
+ var id = fileNode.Attributes?["ovf:id"]?.Value;
+ var href = fileNode.Attributes?["ovf:href"]?.Value;
+ if (id != null && href != null)
+ {
+ if (!ValidHrefPattern.IsMatch(href) || href == "." || href == "..")
+ throw new InvalidDataException($"OVF descriptor references a disallowed file name: '{href}'.");
+ fileRefs[id] = href;
+ }
+ }
+ }
+
+ // Build disk reference map: diskId -> fileRef
+ var diskFileMap = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var diskNodes = doc.SelectNodes("//ovf:DiskSection/ovf:Disk", nsm);
+ if (diskNodes != null)
+ {
+ foreach (XmlNode diskNode in diskNodes)
+ {
+ var diskId = diskNode.Attributes?["ovf:diskId"]?.Value;
+ var fileRef = diskNode.Attributes?["ovf:fileRef"]?.Value;
+ if (diskId != null && fileRef != null)
+ {
+ diskFileMap[diskId] = fileRef;
+ }
+ }
+ }
+
+ // Parse hardware items
+ var items = doc.SelectNodes("//ovf:VirtualHardwareSection/ovf:Item", nsm);
+ if (items != null)
+ {
+ foreach (XmlNode item in items)
+ {
+ var resourceTypeNode = item.SelectSingleNode("rasd:ResourceType", nsm);
+ if (resourceTypeNode == null) continue;
+
+ if (!int.TryParse(resourceTypeNode.InnerText.Trim(), out int resourceType))
+ continue;
+
+ switch (resourceType)
+ {
+ case 3: // Processor
+ var vcpuNode = item.SelectSingleNode("rasd:VirtualQuantity", nsm);
+ if (vcpuNode != null && int.TryParse(vcpuNode.InnerText.Trim(), out int vcpus))
+ metadata.CpuCount = vcpus;
+ break;
+
+ case 4: // Memory
+ var memNode = item.SelectSingleNode("rasd:VirtualQuantity", nsm);
+ var unitsNode = item.SelectSingleNode("rasd:AllocationUnits", nsm);
+ if (memNode != null && long.TryParse(memNode.InnerText.Trim(), out long memVal))
+ {
+ var units = unitsNode?.InnerText?.Trim() ?? "byte * 2^20";
+ metadata.MemoryMB = ConvertToMB(memVal, units);
+ }
+ break;
+
+ case 5: // IDE Controller
+ case 6: // Parallel SCSI HBA
+ case 20: // Other storage device (VMware's SATA AHCI controller)
+ // Controllers themselves don't produce disk entries; skip.
+ break;
+
+ case 17: // Disk Drive
+ var diskRef = ExtractDiskReference(item, nsm, diskFileMap, fileRefs);
+ if (diskRef != null)
+ {
+ // Determine bus type from parent controller
+ diskRef.BusType = DetermineBusType(item, items, nsm);
+ metadata.Disks.Add(diskRef);
+ }
+ break;
+
+ case 10: // Ethernet Adapter
+ var adapterName = item.SelectSingleNode("rasd:ElementName", nsm)?.InnerText
+ ?? item.SelectSingleNode("rasd:Caption", nsm)?.InnerText
+ ?? "Network adapter";
+ var connection = item.SelectSingleNode("rasd:Connection", nsm)?.InnerText ?? string.Empty;
+ var nicSubType = item.SelectSingleNode("rasd:ResourceSubType", nsm)?.InnerText ?? string.Empty;
+ metadata.NetworkAdapters.Add(new OvfNetworkAdapter
+ {
+ AdapterName = adapterName,
+ ConnectionName = connection,
+ ResourceSubType = nicSubType
+ });
+ break;
+ }
+ }
+ }
+
+ return metadata;
+ }
+
+ private static OvfDiskReference? ExtractDiskReference(
+ XmlNode item,
+ XmlNamespaceManager nsm,
+ Dictionary diskFileMap,
+ Dictionary fileRefs)
+ {
+ // The HostResource element typically contains a reference like "ovf:/disk/vmdisk1"
+ var hostResource = item.SelectSingleNode("rasd:HostResource", nsm)?.InnerText ?? string.Empty;
+ string? diskId = null;
+
+ if (hostResource.Contains("/disk/"))
+ {
+ var idx = hostResource.LastIndexOf("/disk/", StringComparison.Ordinal);
+ diskId = hostResource.Substring(idx + 6);
+ }
+ else if (hostResource.Contains("disk/"))
+ {
+ var idx = hostResource.LastIndexOf("disk/", StringComparison.Ordinal);
+ diskId = hostResource.Substring(idx + 5);
+ }
+
+ if (diskId != null && diskFileMap.TryGetValue(diskId, out var fileRef) && fileRefs.TryGetValue(fileRef, out var fileName))
+ {
+ return new OvfDiskReference { FileName = fileName };
+ }
+
+ return null;
+ }
+
+ private static string DetermineBusType(XmlNode diskItem, XmlNodeList allItems, XmlNamespaceManager nsm)
+ {
+ // Look at the Parent element to find which controller this disk is attached to
+ var parentNode = diskItem.SelectSingleNode("rasd:Parent", nsm);
+ if (parentNode == null)
+ return "scsi"; // default
+
+ var parentId = parentNode.InnerText.Trim();
+
+ foreach (XmlNode item in allItems)
+ {
+ var instanceId = item.SelectSingleNode("rasd:InstanceID", nsm)?.InnerText?.Trim();
+ if (instanceId != parentId) continue;
+
+ var resourceTypeNode = item.SelectSingleNode("rasd:ResourceType", nsm);
+ if (resourceTypeNode == null) continue;
+
+ if (int.TryParse(resourceTypeNode.InnerText.Trim(), out int rt))
+ {
+ switch (rt)
+ {
+ case 5: return "ide";
+ case 6: return "scsi";
+ case 20: return "sata";
+ }
+ }
+ break;
+ }
+
+ return "scsi"; // default fallback
+ }
+
+ private static int ConvertToMB(long value, string allocationUnits)
+ {
+ // Common OVF allocation units:
+ // "byte * 2^20" = MiB
+ // "byte * 2^30" = GiB
+ // "byte * 2^10" = KiB
+ // "MegaBytes" or "MB"
+ var lower = allocationUnits.ToLowerInvariant();
+
+ if (lower.Contains("2^30") || lower.Contains("gib") || lower.Contains("gigabyte"))
+ return (int)(value * 1024);
+ if (lower.Contains("2^20") || lower.Contains("mib") || lower.Contains("megabyte") || lower.Contains("mb"))
+ return (int)value;
+ if (lower.Contains("2^10") || lower.Contains("kib") || lower.Contains("kilobyte") || lower.Contains("kb"))
+ return (int)(value / 1024);
+ if (lower.Contains("byte"))
+ return (int)(value / (1024 * 1024));
+
+ // Default: assume MiB
+ return (int)value;
+ }
+
+ private static string MapOsType(string osDescription)
+ {
+ if (string.IsNullOrEmpty(osDescription))
+ return "other";
+
+ var lower = osDescription.ToLowerInvariant();
+
+ // Windows variants
+ if (lower.Contains("windows 11") || lower.Contains("win11"))
+ return "win11";
+ if (lower.Contains("windows 10") || lower.Contains("win10"))
+ return "win10";
+ if (lower.Contains("windows server 2022") || lower.Contains("2022"))
+ return "win11";
+ if (lower.Contains("windows server 2019") || lower.Contains("2019"))
+ return "win10";
+ if (lower.Contains("windows server 2016") || lower.Contains("2016"))
+ return "win10";
+ if (lower.Contains("windows 8") || lower.Contains("win8"))
+ return "win8";
+ if (lower.Contains("windows 7") || lower.Contains("win7"))
+ return "win7";
+ if (lower.Contains("windows"))
+ return "win10";
+
+ // Linux variants
+ if (lower.Contains("linux") || lower.Contains("ubuntu") || lower.Contains("debian") ||
+ lower.Contains("centos") || lower.Contains("rhel") || lower.Contains("red hat") ||
+ lower.Contains("fedora") || lower.Contains("suse") || lower.Contains("alma") ||
+ lower.Contains("rocky"))
+ return "l26";
+
+ // FreeBSD
+ if (lower.Contains("freebsd"))
+ return "l26";
+
+ // Solaris
+ if (lower.Contains("solaris"))
+ return "solaris";
+
+ // Try numeric OVF OS ID
+ if (int.TryParse(osDescription, out int osId))
+ {
+ // Common CIM OS IDs
+ if (osId >= 56 && osId <= 70) return "win10"; // Various Windows
+ if (osId >= 93 && osId <= 113) return "l26"; // Various Linux
+ if (osId == 36) return "l26"; // Linux
+ if (osId == 101 || osId == 106) return "l26"; // Linux 64-bit
+ }
+
+ return "other";
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Vms/ImportPveOvaCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Vms/ImportPveOvaCmdlet.cs
index 6c9ca18..770a6b1 100644
--- a/src/PSProxmoxVE/Cmdlets/Vms/ImportPveOvaCmdlet.cs
+++ b/src/PSProxmoxVE/Cmdlets/Vms/ImportPveOvaCmdlet.cs
@@ -4,6 +4,7 @@ using System.IO;
using System.Management.Automation;
using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Services;
+using PSProxmoxVE.Core.Utilities;
namespace PSProxmoxVE.Cmdlets.Vms
{
@@ -108,7 +109,7 @@ namespace PSProxmoxVE.Cmdlets.Vms
OvfMetadata metadata;
try
{
- metadata = OvfMetadata.FromOva(Path);
+ metadata = OvfReader.ReadOva(Path);
}
catch (Exception ex)
{
diff --git a/tests/PSProxmoxVE.Core.Tests/Models/OvfMetadataTests.cs b/tests/PSProxmoxVE.Core.Tests/Models/OvfMetadataTests.cs
deleted file mode 100644
index 1211c30..0000000
--- a/tests/PSProxmoxVE.Core.Tests/Models/OvfMetadataTests.cs
+++ /dev/null
@@ -1,104 +0,0 @@
-using System.IO;
-using System.Xml;
-using Xunit;
-using PSProxmoxVE.Core.Models.Vms;
-
-namespace PSProxmoxVE.Core.Tests.Models
-{
- public class OvfMetadataTests
- {
- private const string OvfHeader =
- "";
-
- private static string BuildDescriptor(string href, int controllerResourceType)
- {
- return
- OvfHeader +
- "" +
- "" +
- "" +
- "" +
- "- " +
- "1" +
- "" + controllerResourceType + "" +
- "
" +
- "- " +
- "2" +
- "17" +
- "1" +
- "ovf:/disk/vmdisk1" +
- "
" +
- "" +
- "" +
- "";
- }
-
- [Fact]
- public void ParseOvfXml_VMwareScsiController_ResourceType6_MapsToScsi()
- {
- var xml = BuildDescriptor("disk1.vmdk", 6);
- var metadata = OvfMetadata.ParseOvfXml(xml);
-
- Assert.Single(metadata.Disks);
- Assert.Equal("scsi", metadata.Disks[0].BusType);
- }
-
- [Fact]
- public void ParseOvfXml_VMwareSataController_ResourceType20_MapsToSata()
- {
- var xml = BuildDescriptor("disk1.vmdk", 20);
- var metadata = OvfMetadata.ParseOvfXml(xml);
-
- Assert.Single(metadata.Disks);
- Assert.Equal("sata", metadata.Disks[0].BusType);
- }
-
- [Fact]
- public void ParseOvfXml_HrefWithEmbeddedProperty_Throws()
- {
- var xml = BuildDescriptor("d.vmdk,cache=unsafe", 6);
-
- Assert.Throws(() => OvfMetadata.ParseOvfXml(xml));
- }
-
- [Fact]
- public void ParseOvfXml_HrefWithPathTraversal_Throws()
- {
- var xml = BuildDescriptor("../x.vmdk", 6);
-
- Assert.Throws(() => OvfMetadata.ParseOvfXml(xml));
- }
-
- [Fact]
- public void ParseOvfXml_HrefIsDotDot_Throws()
- {
- var xml = BuildDescriptor("..", 6);
-
- Assert.Throws(() => OvfMetadata.ParseOvfXml(xml));
- }
-
- [Fact]
- public void ParseOvfXml_HrefIsDot_Throws()
- {
- var xml = BuildDescriptor(".", 6);
-
- Assert.Throws(() => OvfMetadata.ParseOvfXml(xml));
- }
-
- [Fact]
- public void ParseOvfXml_DescriptorWithInternalEntity_Throws()
- {
- var xml =
- "" +
- "]>" +
- OvfHeader +
- "" +
- "";
-
- Assert.ThrowsAny(() => OvfMetadata.ParseOvfXml(xml));
- }
- }
-}
diff --git a/tests/PSProxmoxVE.Core.Tests/Utilities/OvfReaderTests.cs b/tests/PSProxmoxVE.Core.Tests/Utilities/OvfReaderTests.cs
new file mode 100644
index 0000000..3e3c4ea
--- /dev/null
+++ b/tests/PSProxmoxVE.Core.Tests/Utilities/OvfReaderTests.cs
@@ -0,0 +1,192 @@
+using System.IO;
+using System.Text;
+using System.Xml;
+using Xunit;
+using PSProxmoxVE.Core.Utilities;
+
+namespace PSProxmoxVE.Core.Tests.Utilities
+{
+ public class OvfReaderTests
+ {
+ private const string OvfHeader =
+ "";
+
+ private static string BuildDescriptor(string href, int controllerResourceType)
+ {
+ return
+ OvfHeader +
+ "" +
+ "" +
+ "" +
+ "" +
+ "- " +
+ "1" +
+ "" + controllerResourceType + "" +
+ "
" +
+ "- " +
+ "2" +
+ "17" +
+ "1" +
+ "ovf:/disk/vmdisk1" +
+ "
" +
+ "" +
+ "" +
+ "";
+ }
+
+ // Minimal POSIX ustar header: 512-byte fixed-size header fields,
+ // followed by the file content padded to a 512-byte boundary,
+ // followed by two all-zero 512-byte end-of-archive blocks.
+ private static byte[] BuildTarWithEntry(string entryName, byte[] content)
+ {
+ using var ms = new MemoryStream();
+ var header = new byte[512];
+ var nameBytes = Encoding.ASCII.GetBytes(entryName);
+ System.Array.Copy(nameBytes, header, nameBytes.Length);
+ WriteOctalField(header, 100, 8, 0x1A4); // mode 0644
+ WriteOctalField(header, 108, 8, 0); // uid
+ WriteOctalField(header, 116, 8, 0); // gid
+ WriteOctalField(header, 124, 12, content.Length);
+ WriteOctalField(header, 136, 12, 0); // mtime
+ for (int i = 148; i < 156; i++) header[i] = (byte)' ';
+ header[156] = (byte)'0'; // regular file
+ var magic = Encoding.ASCII.GetBytes("ustar\0");
+ System.Array.Copy(magic, 0, header, 257, magic.Length);
+ header[263] = (byte)'0';
+ header[264] = (byte)'0';
+
+ int checksum = 0;
+ foreach (var b in header) checksum += b;
+ var chkBytes = Encoding.ASCII.GetBytes(System.Convert.ToString(checksum, 8).PadLeft(6, '0') + "\0 ");
+ System.Array.Copy(chkBytes, 0, header, 148, chkBytes.Length);
+
+ ms.Write(header, 0, header.Length);
+ ms.Write(content, 0, content.Length);
+ var pad = (512 - content.Length % 512) % 512;
+ if (pad > 0) ms.Write(new byte[pad], 0, pad);
+ ms.Write(new byte[1024], 0, 1024); // two end-of-archive zero blocks
+
+ return ms.ToArray();
+ }
+
+ private static void WriteOctalField(byte[] header, int offset, int length, long value)
+ {
+ var octal = System.Convert.ToString(value, 8);
+ if (value < 0 || octal.Length > length - 1)
+ throw new System.ArgumentOutOfRangeException(nameof(value), value, "Does not fit the octal field.");
+ var bytes = Encoding.ASCII.GetBytes(octal.PadLeft(length - 1, '0'));
+ System.Array.Copy(bytes, 0, header, offset, bytes.Length);
+ header[offset + length - 1] = 0;
+ }
+
+ [Fact]
+ public void ReadOva_TarWithOvfEntry_ParsesMetadata()
+ {
+ var xml = BuildDescriptor("disk1.vmdk", 6);
+ var tarBytes = BuildTarWithEntry("appliance.ovf", Encoding.UTF8.GetBytes(xml));
+
+ var tempPath = Path.GetTempFileName();
+ try
+ {
+ File.WriteAllBytes(tempPath, tarBytes);
+
+ var metadata = OvfReader.ReadOva(tempPath);
+
+ Assert.Equal("test-vm", metadata.Name);
+ Assert.Single(metadata.Disks);
+ Assert.Equal("disk1.vmdk", metadata.Disks[0].FileName);
+ Assert.Equal("scsi", metadata.Disks[0].BusType);
+ }
+ finally
+ {
+ File.Delete(tempPath);
+ }
+ }
+
+ [Fact]
+ public void ReadOva_TarWithNoOvfEntry_Throws()
+ {
+ var tarBytes = BuildTarWithEntry("readme.txt", Encoding.UTF8.GetBytes("not an ovf"));
+
+ var tempPath = Path.GetTempFileName();
+ try
+ {
+ File.WriteAllBytes(tempPath, tarBytes);
+
+ Assert.Throws(() => OvfReader.ReadOva(tempPath));
+ }
+ finally
+ {
+ File.Delete(tempPath);
+ }
+ }
+
+ [Fact]
+ public void ParseOvf_VMwareScsiController_ResourceType6_MapsToScsi()
+ {
+ var xml = BuildDescriptor("disk1.vmdk", 6);
+ var metadata = OvfReader.ParseOvf(xml);
+
+ Assert.Single(metadata.Disks);
+ Assert.Equal("scsi", metadata.Disks[0].BusType);
+ }
+
+ [Fact]
+ public void ParseOvf_VMwareSataController_ResourceType20_MapsToSata()
+ {
+ var xml = BuildDescriptor("disk1.vmdk", 20);
+ var metadata = OvfReader.ParseOvf(xml);
+
+ Assert.Single(metadata.Disks);
+ Assert.Equal("sata", metadata.Disks[0].BusType);
+ }
+
+ [Fact]
+ public void ParseOvf_HrefWithEmbeddedProperty_Throws()
+ {
+ var xml = BuildDescriptor("d.vmdk,cache=unsafe", 6);
+
+ Assert.Throws(() => OvfReader.ParseOvf(xml));
+ }
+
+ [Fact]
+ public void ParseOvf_HrefWithPathTraversal_Throws()
+ {
+ var xml = BuildDescriptor("../x.vmdk", 6);
+
+ Assert.Throws(() => OvfReader.ParseOvf(xml));
+ }
+
+ [Fact]
+ public void ParseOvf_HrefIsDotDot_Throws()
+ {
+ var xml = BuildDescriptor("..", 6);
+
+ Assert.Throws(() => OvfReader.ParseOvf(xml));
+ }
+
+ [Fact]
+ public void ParseOvf_HrefIsDot_Throws()
+ {
+ var xml = BuildDescriptor(".", 6);
+
+ Assert.Throws(() => OvfReader.ParseOvf(xml));
+ }
+
+ [Fact]
+ public void ParseOvf_DescriptorWithInternalEntity_Throws()
+ {
+ var xml =
+ "" +
+ "]>" +
+ OvfHeader +
+ "" +
+ "";
+
+ Assert.ThrowsAny(() => OvfReader.ParseOvf(xml));
+ }
+ }
+}
diff --git a/tests/PSProxmoxVE.Tests/Integration/12_OVA.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/12_OVA.Tests.ps1
index c5003d7..ea1fc82 100644
--- a/tests/PSProxmoxVE.Tests/Integration/12_OVA.Tests.ps1
+++ b/tests/PSProxmoxVE.Tests/Integration/12_OVA.Tests.ps1
@@ -31,7 +31,7 @@ Describe 'OVA Import — Integration' -Tag 'Integration' {
return
}
- $metadata = [PSProxmoxVE.Core.Models.Vms.OvfMetadata]::FromOva($script:OvaPath)
+ $metadata = [PSProxmoxVE.Core.Utilities.OvfReader]::ReadOva($script:OvaPath)
$metadata | Should -Not -BeNullOrEmpty
$metadata.Name | Should -Not -BeNullOrEmpty
$metadata.CpuCount | Should -BeGreaterThan 0