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