Merge main into fix/http-client-timeout

Resolves findings.json conflict — F086 (from #60, merged into main) and
F087 (this branch) both append to the trailing findings array. Kept both
entries, in numeric order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clint Branham
2026-05-20 18:17:20 -05:00
7 changed files with 338 additions and 11 deletions
@@ -0,0 +1,79 @@
using System;
using System.Globalization;
using System.Text.RegularExpressions;
namespace PSProxmoxVE.Core.Utilities
{
/// <summary>
/// Parses storage size strings (e.g. "32G", "1T", "60") and normalizes them
/// to a bare integer count of gibibytes for use in PVE disk specs.
/// </summary>
/// <remarks>
/// PVE accepts a size suffix on file-backed storages (NFS, directory) but parses
/// the value after the colon as a volume name on LVM-backed storages — so
/// <c>local-lvm:32G</c> fails with "unable to parse lvm volume name '32G'" while
/// <c>local-lvm:32</c> works on every storage type. Cmdlets that build disk specs
/// must normalize size inputs through this helper before joining with the storage.
/// </remarks>
public static class SizeParser
{
private static readonly Regex Pattern = new Regex(
@"^\s*(?<num>\d+)\s*(?<unit>[A-Za-z]*)\s*$",
RegexOptions.Compiled);
/// <summary>
/// Parses a size string and returns the value as a bare integer count of GiB.
/// Accepts values like "60", "60G", "60GB" (= 60), "1T", "1TB" (= 1024).
/// Sub-GB units are rejected because PVE disk allocation is GB-granular.
/// </summary>
/// <param name="value">The size string supplied by the user.</param>
/// <param name="parameterName">Parameter name used in the error message.</param>
/// <returns>The size in whole GiB as a string, suitable for direct use in disk specs.</returns>
/// <exception cref="ArgumentException">The input cannot be parsed or uses an unsupported unit.</exception>
public static string NormalizeToGibibytes(string value, string parameterName = "size")
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException($"{parameterName} must not be null or empty.", parameterName);
var match = Pattern.Match(value);
if (!match.Success)
throw new ArgumentException(
$"{parameterName} '{value}' is not a valid size. Expected a positive integer optionally suffixed with G, GB, T, or TB (e.g. '32G', '1T', '60').",
parameterName);
if (!long.TryParse(match.Groups["num"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var num) || num <= 0)
throw new ArgumentException(
$"{parameterName} '{value}' must be a positive integer.",
parameterName);
var unit = match.Groups["unit"].Value.ToUpperInvariant();
long gib;
switch (unit)
{
case "":
case "G":
case "GB":
case "GIB":
gib = num;
break;
case "T":
case "TB":
case "TIB":
try { gib = checked(num * 1024L); }
catch (OverflowException)
{
throw new ArgumentException(
$"{parameterName} '{value}' is too large to represent in GiB.",
parameterName);
}
break;
default:
throw new ArgumentException(
$"{parameterName} '{value}' uses unsupported unit '{unit}'. Use G, GB, T, or TB. Sub-GB units (M, MB, K, KB) are not supported by PVE disk allocation.",
parameterName);
}
return gib.ToString(CultureInfo.InvariantCulture);
}
}
}
@@ -6,6 +6,7 @@ using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Services;
using PSProxmoxVE.Core.Utilities;
namespace PSProxmoxVE.Cmdlets.Containers
{
@@ -59,9 +60,13 @@ namespace PSProxmoxVE.Cmdlets.Containers
public int? Cores { get; set; }
/// <summary>
/// <para type="description">Size of the root filesystem (e.g., "8G").</para>
/// <para type="description">
/// Size of the root filesystem. Accepts a bare integer in GiB ("8") or a value
/// suffixed with G/GB/T/TB (case-insensitive); the value is normalized to a
/// bare GiB count before being sent to the API.
/// </para>
/// </summary>
[Parameter(Mandatory = false, HelpMessage = "Size of the root filesystem (e.g. 8G).")]
[Parameter(Mandatory = false, HelpMessage = "Size of the root filesystem in GiB (e.g. 8 or 8G).")]
public string? RootFsSize { get; set; }
/// <summary>
@@ -125,6 +130,13 @@ namespace PSProxmoxVE.Cmdlets.Containers
protected override void ProcessRecord()
{
// Validate -RootFsSize before ShouldProcess so typos like "512M" are rejected
// even with -WhatIf, and so the error is raised regardless of whether
// -RootFsStorage is also supplied.
string? rootFsSizeGib = null;
if (!string.IsNullOrEmpty(RootFsSize))
rootFsSizeGib = SizeParser.NormalizeToGibibytes(RootFsSize!, nameof(RootFsSize));
if (!ShouldProcess($"Container on node '{Node}'", "New-PveContainer"))
return;
@@ -160,8 +172,8 @@ namespace PSProxmoxVE.Cmdlets.Containers
if (!string.IsNullOrEmpty(RootFsStorage))
{
var rootFsValue = RootFsStorage!;
if (!string.IsNullOrEmpty(RootFsSize))
rootFsValue += $":{RootFsSize}";
if (rootFsSizeGib != null)
rootFsValue += $":{rootFsSizeGib}";
config["rootfs"] = rootFsValue;
}
+16 -4
View File
@@ -4,6 +4,7 @@ using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Services;
using PSProxmoxVE.Core.Utilities;
namespace PSProxmoxVE.Cmdlets.Vms
{
@@ -74,9 +75,13 @@ namespace PSProxmoxVE.Cmdlets.Vms
public string? Machine { get; set; }
/// <summary>
/// <para type="description">Size of the primary disk (e.g., "32G").</para>
/// <para type="description">
/// Size of the primary disk. Accepts a bare integer in GiB ("32") or a value
/// suffixed with G/GB/T/TB (case-insensitive); the value is normalized to a
/// bare GiB count before being sent to the API.
/// </para>
/// </summary>
[Parameter(Mandatory = false, HelpMessage = "Size of the primary disk (e.g. 32G).")]
[Parameter(Mandatory = false, HelpMessage = "Size of the primary disk in GiB (e.g. 32 or 32G).")]
public string? DiskSize { get; set; }
/// <summary>
@@ -123,6 +128,13 @@ namespace PSProxmoxVE.Cmdlets.Vms
protected override void ProcessRecord()
{
// Validate -DiskSize before ShouldProcess so typos like "512M" are rejected
// even with -WhatIf, and so the error is raised regardless of whether
// -DiskStorage is also supplied.
string? diskSizeGib = null;
if (!string.IsNullOrEmpty(DiskSize))
diskSizeGib = SizeParser.NormalizeToGibibytes(DiskSize!, nameof(DiskSize));
if (!ShouldProcess($"VM on node '{Node}'", "New-PveVm"))
return;
@@ -161,9 +173,9 @@ namespace PSProxmoxVE.Cmdlets.Vms
if (!string.IsNullOrEmpty(OsType))
config["ostype"] = OsType!;
if (!string.IsNullOrEmpty(DiskStorage) && !string.IsNullOrEmpty(DiskSize))
if (!string.IsNullOrEmpty(DiskStorage) && diskSizeGib != null)
{
var diskValue = $"{DiskStorage}:{DiskSize}";
var diskValue = $"{DiskStorage}:{diskSizeGib}";
if (!string.IsNullOrEmpty(DiskFormat))
diskValue += $",format={DiskFormat}";
config["virtio0"] = diskValue;