fix: remediate scan-4 findings F058, F062, F063, F071, F072, F073, F074, F075

F058 (critical): Replace while(true) infinite-loop task polling with
TaskService.WaitForTask in 5 container snapshot and storage cmdlets.

F073+F047 (high): Migrate net9.0 → net10.0 across both source .csproj
files, build.yml, publish.yml, and test helper.

F071 (medium): Add Uri.EscapeDataString() to all inline URL path
segments in ~16 cmdlets that bypass service classes (D003).

F062+F063 (medium): Add ConfirmImpact.High to Restart-PveContainer
and Suspend-PveContainer (D006).

F075 (medium): Generate markdown help docs for 89 cmdlets that were
missing documentation (170 total, up from 81).

F072 (low): Remove unused System.Text.Json dependency from Core.csproj.
F074 (low): Raise publish smoke-test threshold from 60 to 150.
F065 (low): Add .github/ISSUE_TEMPLATE/config.yml.
F066 (low): Add CODEOWNERS.

Also fix _TestHelper.ps1 net9.0 → net10.0 framework reference.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clint Branham
2026-03-23 09:54:28 -05:00
parent 0fa92d689f
commit 94424367bf
114 changed files with 14072 additions and 143 deletions
+2 -4
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard2.0;net9.0;net48</TargetFrameworks>
<TargetFrameworks>netstandard2.0;net10.0;net48</TargetFrameworks>
<LangVersion>10.0</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>PSProxmoxVE.Core</RootNamespace>
@@ -18,18 +18,16 @@
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<PackageReference Include="SharpCompress" Version="0.38.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net48'">
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<PackageReference Include="SharpCompress" Version="0.38.0" />
<Reference Include="System.Net.Http" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="SharpCompress" Version="0.38.0" />
</ItemGroup>
@@ -1,3 +1,4 @@
using System;
using System.Management.Automation;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Client;
@@ -33,7 +34,7 @@ namespace PSProxmoxVE.Cmdlets.CloudInit
using var client = new PveHttpClient(session);
WriteVerbose($"Getting cloud-init config for VM {VmId}...");
var json = client.GetAsync($"nodes/{Node}/qemu/{VmId}/config").GetAwaiter().GetResult();
var json = client.GetAsync($"nodes/{Uri.EscapeDataString(Node)}/qemu/{VmId}/config").GetAwaiter().GetResult();
var root = JObject.Parse(json);
var data = root["data"];
@@ -4,6 +4,7 @@ using System.Management.Automation;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Containers
{
@@ -54,7 +55,7 @@ namespace PSProxmoxVE.Cmdlets.Containers
};
if (!string.IsNullOrEmpty(Description)) data["description"] = Description!;
var json = client.PostAsync($"nodes/{Node}/lxc/{VmId}/snapshot", data).GetAwaiter().GetResult();
var json = client.PostAsync($"nodes/{Uri.EscapeDataString(Node)}/lxc/{VmId}/snapshot", data).GetAwaiter().GetResult();
var root = JObject.Parse(json);
var upid = root["data"]?.ToString() ?? string.Empty;
@@ -62,25 +63,11 @@ namespace PSProxmoxVE.Cmdlets.Containers
if (Wait.IsPresent && !string.IsNullOrEmpty(upid))
{
task = WaitForTask(client, Node, upid);
var taskService = new TaskService();
task = taskService.WaitForTask(session, Node, upid);
}
WriteObject(task);
}
private static PveTask WaitForTask(PveHttpClient client, string node, string upid)
{
var encodedUpid = Uri.EscapeDataString(upid);
var statusResource = $"nodes/{node}/tasks/{encodedUpid}/status";
while (true)
{
System.Threading.Thread.Sleep(2000);
var statusJson = client.GetAsync(statusResource).GetAwaiter().GetResult();
var statusRoot = JObject.Parse(statusJson);
var d = statusRoot["data"];
if (d?["status"]?.ToString() == "stopped")
return new PveTask { Upid = upid, Node = node, Status = "stopped", ExitStatus = d["exitstatus"]?.ToString() };
}
}
}
}
@@ -3,6 +3,7 @@ using System.Management.Automation;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Containers
{
@@ -46,7 +47,7 @@ namespace PSProxmoxVE.Cmdlets.Containers
WriteVerbose($"Removing snapshot '{Name}' from container {VmId}...");
using var client = new PveHttpClient(session);
var json = client.DeleteAsync($"nodes/{Node}/lxc/{VmId}/snapshot/{Name}").GetAwaiter().GetResult();
var json = client.DeleteAsync($"nodes/{Uri.EscapeDataString(Node)}/lxc/{VmId}/snapshot/{Uri.EscapeDataString(Name)}").GetAwaiter().GetResult();
var root = JObject.Parse(json);
var upid = root["data"]?.ToString() ?? string.Empty;
@@ -54,25 +55,11 @@ namespace PSProxmoxVE.Cmdlets.Containers
if (Wait.IsPresent && !string.IsNullOrEmpty(upid))
{
task = WaitForTask(client, Node, upid);
var taskService = new TaskService();
task = taskService.WaitForTask(session, Node, upid);
}
WriteObject(task);
}
private static PveTask WaitForTask(PveHttpClient client, string node, string upid)
{
var encodedUpid = Uri.EscapeDataString(upid);
var statusResource = $"nodes/{node}/tasks/{encodedUpid}/status";
while (true)
{
System.Threading.Thread.Sleep(2000);
var statusJson = client.GetAsync(statusResource).GetAwaiter().GetResult();
var statusRoot = JObject.Parse(statusJson);
var d = statusRoot["data"];
if (d?["status"]?.ToString() == "stopped")
return new PveTask { Upid = upid, Node = node, Status = "stopped", ExitStatus = d["exitstatus"]?.ToString() };
}
}
}
}
@@ -12,7 +12,7 @@ namespace PSProxmoxVE.Cmdlets.Containers
/// Use -Wait to block until both tasks complete.
/// </para>
/// </summary>
[Cmdlet(VerbsLifecycle.Restart, "PveContainer", SupportsShouldProcess = true)]
[Cmdlet(VerbsLifecycle.Restart, "PveContainer", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)]
[OutputType(typeof(PveTask))]
public sealed class RestartPveContainerCmdlet : PveCmdletBase
{
@@ -3,6 +3,7 @@ using System.Management.Automation;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Containers
{
@@ -49,7 +50,7 @@ namespace PSProxmoxVE.Cmdlets.Containers
WriteVerbose($"Restoring snapshot '{Name}' on container {VmId}...");
using var client = new PveHttpClient(session);
var json = client.PostAsync($"nodes/{Node}/lxc/{VmId}/snapshot/{Name}/rollback").GetAwaiter().GetResult();
var json = client.PostAsync($"nodes/{Uri.EscapeDataString(Node)}/lxc/{VmId}/snapshot/{Uri.EscapeDataString(Name)}/rollback").GetAwaiter().GetResult();
var root = JObject.Parse(json);
var upid = root["data"]?.ToString() ?? string.Empty;
@@ -57,25 +58,11 @@ namespace PSProxmoxVE.Cmdlets.Containers
if (Wait.IsPresent && !string.IsNullOrEmpty(upid))
{
task = WaitForTask(client, Node, upid);
var taskService = new TaskService();
task = taskService.WaitForTask(session, Node, upid);
}
WriteObject(task);
}
private static PveTask WaitForTask(PveHttpClient client, string node, string upid)
{
var encodedUpid = Uri.EscapeDataString(upid);
var statusResource = $"nodes/{node}/tasks/{encodedUpid}/status";
while (true)
{
System.Threading.Thread.Sleep(2000);
var statusJson = client.GetAsync(statusResource).GetAwaiter().GetResult();
var statusRoot = JObject.Parse(statusJson);
var d = statusRoot["data"];
if (d?["status"]?.ToString() == "stopped")
return new PveTask { Upid = upid, Node = node, Status = "stopped", ExitStatus = d["exitstatus"]?.ToString() };
}
}
}
}
@@ -11,7 +11,7 @@ namespace PSProxmoxVE.Cmdlets.Containers
/// Use -Wait to block until the suspend task completes.
/// </para>
/// </summary>
[Cmdlet(VerbsLifecycle.Suspend, "PveContainer", SupportsShouldProcess = true)]
[Cmdlet(VerbsLifecycle.Suspend, "PveContainer", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)]
[OutputType(typeof(PveTask))]
public sealed class SuspendPveContainerCmdlet : PveCmdletBase
{
@@ -40,7 +40,7 @@ namespace PSProxmoxVE.Cmdlets.Network
using var client = new PveHttpClient(session);
WriteVerbose($"Getting network interfaces on node '{Node}'...");
var resource = $"nodes/{Node}/network";
var resource = $"nodes/{Uri.EscapeDataString(Node)}/network";
if (!string.IsNullOrEmpty(Type))
resource += $"?type={Uri.EscapeDataString(Type)}";
@@ -1,3 +1,4 @@
using System;
using System.Management.Automation;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Client;
@@ -35,7 +36,7 @@ namespace PSProxmoxVE.Cmdlets.Network
using var client = new PveHttpClient(session);
WriteVerbose($"Applying network configuration on node '{Node}'...");
var json = client.PutAsync($"nodes/{Node}/network").GetAwaiter().GetResult();
var json = client.PutAsync($"nodes/{Uri.EscapeDataString(Node)}/network").GetAwaiter().GetResult();
var root = JObject.Parse(json);
var upid = root["data"]?.ToString() ?? string.Empty;
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Management.Automation;
using PSProxmoxVE.Core.Client;
@@ -90,7 +91,7 @@ namespace PSProxmoxVE.Cmdlets.Network
if (Autostart.IsPresent) data["autostart"] = "1";
if (!string.IsNullOrEmpty(Comments)) data["comments"] = Comments!;
client.PostAsync($"nodes/{Node}/network", data).GetAwaiter().GetResult();
client.PostAsync($"nodes/{Uri.EscapeDataString(Node)}/network", data).GetAwaiter().GetResult();
}
}
}
@@ -1,3 +1,4 @@
using System;
using System.Management.Automation;
using PSProxmoxVE.Core.Client;
@@ -31,7 +32,7 @@ namespace PSProxmoxVE.Cmdlets.Network
using var client = new PveHttpClient(session);
WriteVerbose($"Removing network interface '{Iface}' on node '{Node}'...");
client.DeleteAsync($"nodes/{Node}/network/{Iface}").GetAwaiter().GetResult();
client.DeleteAsync($"nodes/{Uri.EscapeDataString(Node)}/network/{Uri.EscapeDataString(Iface)}").GetAwaiter().GetResult();
}
}
}
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Management.Automation;
using PSProxmoxVE.Core.Client;
@@ -97,7 +98,7 @@ namespace PSProxmoxVE.Cmdlets.Network
if (Autostart.IsPresent) data["autostart"] = "1";
if (!string.IsNullOrEmpty(Comments)) data["comments"] = Comments!;
client.PutAsync($"nodes/{Node}/network/{Iface}", data).GetAwaiter().GetResult();
client.PutAsync($"nodes/{Uri.EscapeDataString(Node)}/network/{Uri.EscapeDataString(Iface)}", data).GetAwaiter().GetResult();
}
}
}
+2 -2
View File
@@ -126,8 +126,8 @@ namespace PSProxmoxVE.Cmdlets
// because it returns qmpstatus (needed for paused state detection — PVE reports
// status=running but qmpstatus=paused for suspended VMs).
var statusResource = isContainer
? $"nodes/{node}/lxc/{vmid}/status/current"
: $"nodes/{node}/qemu/{vmid}/status/current";
? $"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}/status/current"
: $"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/status/current";
var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
using var pollClient = new PveHttpClient(session);
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Management.Automation;
using Newtonsoft.Json.Linq;
@@ -59,7 +60,7 @@ namespace PSProxmoxVE.Cmdlets.Snapshots
if (!string.IsNullOrEmpty(Description)) data["description"] = Description!;
if (IncludeVmState.IsPresent) data["vmstate"] = "1";
var json = client.PostAsync($"nodes/{Node}/qemu/{VmId}/snapshot", data).GetAwaiter().GetResult();
var json = client.PostAsync($"nodes/{Uri.EscapeDataString(Node)}/qemu/{VmId}/snapshot", data).GetAwaiter().GetResult();
var root = JObject.Parse(json);
var upid = root["data"]?.ToString() ?? string.Empty;
@@ -1,3 +1,4 @@
using System;
using System.Management.Automation;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Client;
@@ -46,7 +47,7 @@ namespace PSProxmoxVE.Cmdlets.Snapshots
WriteVerbose($"Removing snapshot '{Name}' from VM {VmId}...");
using var client = new PveHttpClient(session);
var json = client.DeleteAsync($"nodes/{Node}/qemu/{VmId}/snapshot/{Name}").GetAwaiter().GetResult();
var json = client.DeleteAsync($"nodes/{Uri.EscapeDataString(Node)}/qemu/{VmId}/snapshot/{Uri.EscapeDataString(Name)}").GetAwaiter().GetResult();
var root = JObject.Parse(json);
var upid = root["data"]?.ToString() ?? string.Empty;
@@ -1,3 +1,4 @@
using System;
using System.Management.Automation;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Client;
@@ -49,7 +50,7 @@ namespace PSProxmoxVE.Cmdlets.Snapshots
WriteVerbose($"Restoring snapshot '{Name}' on VM {VmId}...");
using var client = new PveHttpClient(session);
var json = client.PostAsync($"nodes/{Node}/qemu/{VmId}/snapshot/{Name}/rollback").GetAwaiter().GetResult();
var json = client.PostAsync($"nodes/{Uri.EscapeDataString(Node)}/qemu/{VmId}/snapshot/{Uri.EscapeDataString(Name)}/rollback").GetAwaiter().GetResult();
var root = JObject.Parse(json);
var upid = root["data"]?.ToString() ?? string.Empty;
@@ -4,6 +4,7 @@ using System.Management.Automation;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Storage
{
@@ -53,7 +54,7 @@ namespace PSProxmoxVE.Cmdlets.Storage
using var client = new PveHttpClient(session);
WriteVerbose($"Downloading '{Url}' to {Node}/{Storage}...");
var resource = $"nodes/{Node}/storage/{Storage}/download-url";
var resource = $"nodes/{Uri.EscapeDataString(Node)}/storage/{Uri.EscapeDataString(Storage)}/download-url";
var data = new Dictionary<string, string>
{
["url"] = Url,
@@ -69,37 +70,11 @@ namespace PSProxmoxVE.Cmdlets.Storage
if (Wait.IsPresent && !string.IsNullOrEmpty(upid))
{
task = WaitForTask(client, Node, upid);
var taskService = new TaskService();
task = taskService.WaitForTask(session, Node, upid);
}
WriteObject(task);
}
private static PveTask WaitForTask(PveHttpClient client, string node, string upid)
{
var encodedUpid = Uri.EscapeDataString(upid);
var statusResource = $"nodes/{node}/tasks/{encodedUpid}/status";
while (true)
{
System.Threading.Thread.Sleep(2000);
var statusJson = client.GetAsync(statusResource).GetAwaiter().GetResult();
var statusRoot = JObject.Parse(statusJson);
var data = statusRoot["data"];
var status = data?["status"]?.ToString();
var exitStatus = data?["exitstatus"]?.ToString();
if (status == "stopped")
{
return new PveTask
{
Upid = upid,
Node = node,
Status = status,
ExitStatus = exitStatus
};
}
}
}
}
}
@@ -4,6 +4,7 @@ using System.Management.Automation;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Storage
{
@@ -77,7 +78,7 @@ namespace PSProxmoxVE.Cmdlets.Storage
using var client = new PveHttpClient(session);
WriteVerbose($"Uploading {fileName} to {Node}/{Storage} (content={ContentType})...");
var resource = $"nodes/{Node}/storage/{Storage}/upload";
var resource = $"nodes/{Uri.EscapeDataString(Node)}/storage/{Uri.EscapeDataString(Storage)}/upload";
var totalBytes = new System.IO.FileInfo(Path).Length;
var activityId = 1;
@@ -129,38 +130,12 @@ namespace PSProxmoxVE.Cmdlets.Storage
if (Wait.IsPresent && !string.IsNullOrEmpty(upid))
{
task = WaitForTask(client, Node, upid);
var taskService = new TaskService();
task = taskService.WaitForTask(session, Node, upid);
}
WriteObject(task);
}
private static PveTask WaitForTask(PveHttpClient client, string node, string upid)
{
var encodedUpid = Uri.EscapeDataString(upid);
var statusResource = $"nodes/{node}/tasks/{encodedUpid}/status";
while (true)
{
System.Threading.Thread.Sleep(2000);
var statusJson = client.GetAsync(statusResource).GetAwaiter().GetResult();
var statusRoot = JObject.Parse(statusJson);
var data = statusRoot["data"];
var status = data?["status"]?.ToString();
var exitStatus = data?["exitstatus"]?.ToString();
if (status == "stopped")
{
return new PveTask
{
Upid = upid,
Node = node,
Status = status,
ExitStatus = exitStatus
};
}
}
}
}
/// <summary>Validates that a file path exists on disk.</summary>
@@ -53,7 +53,7 @@ namespace PSProxmoxVE.Cmdlets.Tasks
var deadline = Timeout.HasValue ? DateTime.UtcNow + Timeout.Value : DateTime.MaxValue;
var encodedUpid = Uri.EscapeDataString(Upid);
var statusResource = $"nodes/{Node}/tasks/{encodedUpid}/status";
var statusResource = $"nodes/{Uri.EscapeDataString(Node)}/tasks/{encodedUpid}/status";
// Derive a short human-readable description from the UPID for progress display
var taskDesc = Upid.Length > 50 ? Upid.Substring(0, 47) + "..." : Upid;
+3 -3
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard2.0;net9.0;net48</TargetFrameworks>
<TargetFrameworks>netstandard2.0;net10.0;net48</TargetFrameworks>
<LangVersion>10.0</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>PSProxmoxVE</RootNamespace>
@@ -18,8 +18,8 @@
<PackageReference Include="PowerShellStandard.Library" Version="5.1.1" PrivateAssets="all" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
<PackageReference Include="System.Management.Automation" Version="7.4.0" PrivateAssets="all" />
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageReference Include="System.Management.Automation" Version="7.5.0" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>