refactor: move OVA/OVF archive I/O out of the OvfMetadata model into OvfReader (#222)

OvfMetadata.cs opened files and drove SharpCompress.Readers.ReaderFactory
inside a Models/ type, the only SharpCompress consumer in the codebase.
Move TAR extraction and OVF XML parsing to a new static
PSProxmoxVE.Core.Utilities.OvfReader (ReadOva, internal ParseOvf) and
leave OvfMetadata as a data type: properties plus the pure MapNicModel
helper.

The move is behavior-preserving: bus-type inference, href validation,
DtdProcessing.Prohibit, and disk-slot handling are unchanged.

OvfMetadata.FromOva is removed rather than kept as an [Obsolete]
forwarder — PowerShell ignores ObsoleteAttribute at invocation, the only
other caller is a non-code-owned integration test, and ADR 0028's
deprecation-path bar is for parameter-binding surface, not a Core
static method reached by fully-qualified type name. The integration
test now calls OvfReader.ReadOva directly.

Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
This commit is contained in:
goodolclint-claude[bot]
2026-09-03 17:31:10 +00:00
committed by GitHub
parent 142f18af1d
commit 22b65ffd3d
6 changed files with 551 additions and 452 deletions
@@ -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
/// <summary>OS type hint from the OVF OperatingSystemSection.</summary>
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);
/// <summary>
/// Parses an OVA file (TAR archive) and extracts OVF metadata.
/// </summary>
/// <param name="ovaPath">Path to the OVA file.</param>
/// <returns>Parsed OVF metadata.</returns>
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);
}
/// <summary>
/// Extracts the .ovf XML content from a TAR archive using SharpCompress.
/// </summary>
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;
}
/// <summary>
/// Parses OVF XML and extracts VM metadata.
/// </summary>
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<string, string>(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<string, string>(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<string, string> diskFileMap,
Dictionary<string, string> 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;
}
/// <summary>
/// Maps an OVF ResourceSubType for a NIC to a PVE network model string.
/// </summary>
@@ -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";
}
}
}
+356
View File
@@ -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
{
/// <summary>
/// Reads OVA archives and OVF descriptors into <see cref="OvfMetadata"/>.
/// </summary>
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);
/// <summary>
/// Parses an OVA file (TAR archive) and extracts OVF metadata.
/// </summary>
/// <param name="ovaPath">Path to the OVA file.</param>
/// <returns>Parsed OVF metadata.</returns>
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);
}
/// <summary>
/// Extracts the .ovf XML content from a TAR archive using SharpCompress.
/// </summary>
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;
}
/// <summary>
/// Parses OVF XML and extracts VM metadata.
/// </summary>
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<string, string>(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<string, string>(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<string, string> diskFileMap,
Dictionary<string, string> 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";
}
}
}
@@ -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)
{
@@ -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 =
"<Envelope xmlns=\"http://schemas.dmtf.org/ovf/envelope/1\" " +
"xmlns:ovf=\"http://schemas.dmtf.org/ovf/envelope/1\" " +
"xmlns:rasd=\"http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData\" " +
"xmlns:vssd=\"http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_VirtualSystemSettingData\">";
private static string BuildDescriptor(string href, int controllerResourceType)
{
return
OvfHeader +
"<References><File ovf:id=\"file1\" ovf:href=\"" + href + "\"/></References>" +
"<DiskSection><Disk ovf:diskId=\"vmdisk1\" ovf:fileRef=\"file1\"/></DiskSection>" +
"<VirtualSystem ovf:id=\"test-vm\">" +
"<VirtualHardwareSection>" +
"<Item>" +
"<rasd:InstanceID>1</rasd:InstanceID>" +
"<rasd:ResourceType>" + controllerResourceType + "</rasd:ResourceType>" +
"</Item>" +
"<Item>" +
"<rasd:InstanceID>2</rasd:InstanceID>" +
"<rasd:ResourceType>17</rasd:ResourceType>" +
"<rasd:Parent>1</rasd:Parent>" +
"<rasd:HostResource>ovf:/disk/vmdisk1</rasd:HostResource>" +
"</Item>" +
"</VirtualHardwareSection>" +
"</VirtualSystem>" +
"</Envelope>";
}
[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<InvalidDataException>(() => OvfMetadata.ParseOvfXml(xml));
}
[Fact]
public void ParseOvfXml_HrefWithPathTraversal_Throws()
{
var xml = BuildDescriptor("../x.vmdk", 6);
Assert.Throws<InvalidDataException>(() => OvfMetadata.ParseOvfXml(xml));
}
[Fact]
public void ParseOvfXml_HrefIsDotDot_Throws()
{
var xml = BuildDescriptor("..", 6);
Assert.Throws<InvalidDataException>(() => OvfMetadata.ParseOvfXml(xml));
}
[Fact]
public void ParseOvfXml_HrefIsDot_Throws()
{
var xml = BuildDescriptor(".", 6);
Assert.Throws<InvalidDataException>(() => OvfMetadata.ParseOvfXml(xml));
}
[Fact]
public void ParseOvfXml_DescriptorWithInternalEntity_Throws()
{
var xml =
"<?xml version=\"1.0\"?>" +
"<!DOCTYPE Envelope [<!ENTITY boom \"boom\">]>" +
OvfHeader +
"<VirtualSystem ovf:id=\"&boom;\"/>" +
"</Envelope>";
Assert.ThrowsAny<XmlException>(() => OvfMetadata.ParseOvfXml(xml));
}
}
}
@@ -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 =
"<Envelope xmlns=\"http://schemas.dmtf.org/ovf/envelope/1\" " +
"xmlns:ovf=\"http://schemas.dmtf.org/ovf/envelope/1\" " +
"xmlns:rasd=\"http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData\" " +
"xmlns:vssd=\"http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_VirtualSystemSettingData\">";
private static string BuildDescriptor(string href, int controllerResourceType)
{
return
OvfHeader +
"<References><File ovf:id=\"file1\" ovf:href=\"" + href + "\"/></References>" +
"<DiskSection><Disk ovf:diskId=\"vmdisk1\" ovf:fileRef=\"file1\"/></DiskSection>" +
"<VirtualSystem ovf:id=\"test-vm\">" +
"<VirtualHardwareSection>" +
"<Item>" +
"<rasd:InstanceID>1</rasd:InstanceID>" +
"<rasd:ResourceType>" + controllerResourceType + "</rasd:ResourceType>" +
"</Item>" +
"<Item>" +
"<rasd:InstanceID>2</rasd:InstanceID>" +
"<rasd:ResourceType>17</rasd:ResourceType>" +
"<rasd:Parent>1</rasd:Parent>" +
"<rasd:HostResource>ovf:/disk/vmdisk1</rasd:HostResource>" +
"</Item>" +
"</VirtualHardwareSection>" +
"</VirtualSystem>" +
"</Envelope>";
}
// 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<System.InvalidOperationException>(() => 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<InvalidDataException>(() => OvfReader.ParseOvf(xml));
}
[Fact]
public void ParseOvf_HrefWithPathTraversal_Throws()
{
var xml = BuildDescriptor("../x.vmdk", 6);
Assert.Throws<InvalidDataException>(() => OvfReader.ParseOvf(xml));
}
[Fact]
public void ParseOvf_HrefIsDotDot_Throws()
{
var xml = BuildDescriptor("..", 6);
Assert.Throws<InvalidDataException>(() => OvfReader.ParseOvf(xml));
}
[Fact]
public void ParseOvf_HrefIsDot_Throws()
{
var xml = BuildDescriptor(".", 6);
Assert.Throws<InvalidDataException>(() => OvfReader.ParseOvf(xml));
}
[Fact]
public void ParseOvf_DescriptorWithInternalEntity_Throws()
{
var xml =
"<?xml version=\"1.0\"?>" +
"<!DOCTYPE Envelope [<!ENTITY boom \"boom\">]>" +
OvfHeader +
"<VirtualSystem ovf:id=\"&boom;\"/>" +
"</Envelope>";
Assert.ThrowsAny<XmlException>(() => OvfReader.ParseOvf(xml));
}
}
}
@@ -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