Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions src/SharedAssets/Assets/Utilities/unigetui_ps_operation.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#Requires -Version 5
# Controlled launcher for UniGetUI PowerShell package operations.
#
# Invoked as: powershell.exe -NoProfile -ExecutionPolicy Bypass -File <this> <mode> <command> [args...]
#
# This script deliberately declares NO param() block. Without one, PowerShell binds every
# argument positionally into $args and performs no parameter-name binding at all, so a data
# argument that happens to look like "-Mode" or "-Command" cannot be smuggled into a control
# value. The arguments after <command> are splatted, which passes them to the cmdlet as data
# and never re-parses them as script.

$ErrorActionPreference = 'Continue'
$ConfirmPreference = 'None'

if ($args.Count -lt 2)
{
[Console]::Error.WriteLine('UniGetUI: the operation launcher requires a mode and a command.')
exit 2
}

$mode = [string]$args[0]
$command = [string]$args[1]

# Windows PowerShell 5.x defaults to TLS 1.0/1.1, which the PowerShell Gallery rejects.
if ($mode -eq 'tls12')
{
try
{
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
}
catch
{
[Console]::Error.WriteLine('UniGetUI: could not select TLS 1.2.')
}
}

$commandInfo = $null
try
{
$commandInfo = Get-Command -Name $command -ErrorAction Stop
}
catch
{
# Left null: without metadata nothing is coerced, and the call below reports the real error.
}

# Whether $name names a switch on the command about to be invoked. Only a switch may have its
# value coerced, because an ordinary string parameter can legitimately be given the text "$false"
# - a repository named that, for instance - and must reach the cmdlet unchanged.
function Test-IsSwitchParameter([string]$name)
{
if ($null -eq $commandInfo)
{
return $false
}

$parameter = $commandInfo.Parameters[$name]
if ($null -eq $parameter)
{
$parameter = $commandInfo.Parameters.Values |
Where-Object { $_.Aliases -contains $name } |
Select-Object -First 1
}

if ($null -eq $parameter)
{
return $false
}

return $parameter.ParameterType -eq [System.Management.Automation.SwitchParameter]
}

$named = @{}
$rest = @()

for ($i = 2; $i -lt $args.Count; $i++)
{
$item = [string]$args[$i]

# powershell.exe splits "-Switch:$false" into "-Switch" and the literal text "$false" before
# this script runs, and splatting cannot bind that text to a switch. Such a pair is turned
# into a real boolean and bound by name through a hashtable, which does accept one.
if ($item -match '^-([A-Za-z][A-Za-z0-9_]*)$' -and ($i + 1) -lt $args.Count)
{
$switchName = $Matches[1]
$next = [string]$args[$i + 1]

if (($next -eq '$false' -or $next -eq '$true') -and (Test-IsSwitchParameter $switchName))
{
$named[$switchName] = ($next -eq '$true')
Comment thread
GabrielDuf marked this conversation as resolved.
$i++
continue
}
}

$rest += $args[$i]
}

# A terminating error, such as a parameter that the cmdlet does not accept, would otherwise be
# written to the error stream and leave this script to exit 0, reporting a failed operation as a
# success. Running under -Command used to fail the process for us, so it is done explicitly here.
try
{
& $command @named @rest
Comment thread
GabrielDuf marked this conversation as resolved.
$succeeded = $?
}
catch
{
# The whole record, not just the message: the caller matches on the error id to decide
# whether to retry elevated or without -Scope, and that id is only in the full record.
Write-Error -ErrorRecord $_
exit 1
}

# Running under -Command also failed the process when the command reported failure without
# throwing. Not every caller binds the error variable below - PowerShell 7 operations and source
# operations do not - so without this a failed operation would be reported as a success.
if (-not $succeeded)
{
exit 1
}

# PowerShellGet reports some failures as non-terminating errors that leave $? true, so the caller
# binds -ErrorVariable to this name and it is checked as well.
if ($UniGetUIOperationError)
{
exit 1
}
Comment thread
GabrielDuf marked this conversation as resolved.
13 changes: 11 additions & 2 deletions src/UniGetUI.Avalonia/Infrastructure/ManualInstallHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,17 @@ internal static class ManualInstallHelper
{
if (package is null || package.Source.IsVirtualManager) return null;
var options = await InstallOptionsFactory.LoadApplicableAsync(package);
var args = await Task.Run(() => package.Manager.OperationHelper.GetParameters(package, options, operation));
return package.Manager.Properties.ExecutableFriendlyName + " " + string.Join(' ', args);
try
{
var args = await Task.Run(() =>
package.Manager.OperationHelper.GetStandaloneParameters(package, options, operation));
return package.Manager.Properties.ExecutableFriendlyName + " " + string.Join(' ', args);
}
catch (InvalidOperationException ex)
{
Logger.Warn($"[ManualInstallHelper] No command line for {package.Id}: {ex.Message}");
return null;
}
}

/// <summary>Entry point for the "Manual install/update/uninstall" menu and toolbar actions.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -507,8 +507,17 @@ public async Task<string> BuildCurrentCommandAsync()
var snap = SnapshotOptions();
var op = CurrentOp();
var applied = await InstallOptionsFactory.LoadApplicableAsync(_package, overridePackageOptions: snap);
var args = await Task.Run(() => _package.Manager.OperationHelper.GetParameters(_package, applied, op));
return _package.Manager.Properties.ExecutableFriendlyName + " " + string.Join(' ', args);
try
{
var args = await Task.Run(() =>
_package.Manager.OperationHelper.GetStandaloneParameters(_package, applied, op));
return _package.Manager.Properties.ExecutableFriendlyName + " " + string.Join(' ', args);
}
catch (InvalidOperationException ex)
{
Logger.Warn($"[InstallOptionsViewModel] No command line for {_package.Id}: {ex.Message}");
return "";
}
}

private void Refresh() { if (_uiLoaded) _ = RefreshCommandPreviewAsync(); }
Expand Down
90 changes: 46 additions & 44 deletions src/UniGetUI.Avalonia/Views/SoftwarePages/PackageBundlesPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using UniGetUI.Interface.Enums;
using UniGetUI.Interface.Telemetry;
using UniGetUI.PackageEngine;
using UniGetUI.PackageEngine.Classes.Manager.Classes;
using UniGetUI.PackageEngine.Classes.Serializable;
using UniGetUI.PackageEngine.Enums;
using UniGetUI.PackageEngine.Interfaces;
Expand Down Expand Up @@ -433,25 +434,15 @@ public static async Task<string> CreateBundle(IReadOnlyList<IPackage> unsortedPa
?? throw new JsonException("Could not parse JSON object")));

var report = new BundleReport { IsEmpty = true };
bool allowCLI = SecureSettings.Get(SecureSettings.K.AllowCLIArguments)
&& SecureSettings.Get(SecureSettings.K.AllowImportingCLIArguments);
bool allowPrePost = SecureSettings.Get(SecureSettings.K.AllowPrePostOpCommand)
&& SecureSettings.Get(SecureSettings.K.AllowImportPrePostOpCommands);
bool allowCLI = BundleImportFilter.CliArgumentsAllowed();
bool allowPrePost = BundleImportFilter.PrePostCommandsAllowed();

var packages = new List<IPackage>();
foreach (var pkg in deserializedData.packages)
{
var opts = pkg.InstallationOptions;
ReportList(ref report, pkg.Id, opts.CustomParameters_Install, "Custom install arguments", allowCLI);
ReportList(ref report, pkg.Id, opts.CustomParameters_Update, "Custom update arguments", allowCLI);
ReportList(ref report, pkg.Id, opts.CustomParameters_Uninstall, "Custom uninstall arguments", allowCLI);
opts.PreInstallCommand = ReportStr(ref report, pkg.Id, opts.PreInstallCommand, "Pre-install command", allowPrePost);
opts.PostInstallCommand = ReportStr(ref report, pkg.Id, opts.PostInstallCommand, "Post-install command", allowPrePost);
opts.PreUpdateCommand = ReportStr(ref report, pkg.Id, opts.PreUpdateCommand, "Pre-update command", allowPrePost);
opts.PostUpdateCommand = ReportStr(ref report, pkg.Id, opts.PostUpdateCommand, "Post-update command", allowPrePost);
opts.PreUninstallCommand = ReportStr(ref report, pkg.Id, opts.PreUninstallCommand, "Pre-uninstall command", allowPrePost);
opts.PostUninstallCommand = ReportStr(ref report, pkg.Id, opts.PostUninstallCommand, "Post-uninstall command", allowPrePost);
pkg.InstallationOptions = opts;
pkg.InstallationOptions = BundleImportFilter.Apply(
ref report, pkg.Id, pkg.InstallationOptions, allowCLI, allowPrePost,
ResolveManagerForImport(pkg.ManagerName)?.CommandLineIsShellInterpreted ?? false);
packages.Add(DeserializePackage(pkg));
}

Expand All @@ -463,16 +454,26 @@ public static async Task<string> CreateBundle(IReadOnlyList<IPackage> unsortedPa
return (deserializedData.export_version, report);
}

// ─── Deserialization helpers ──────────────────────────────────────────────
public static IPackage DeserializePackage(SerializablePackage raw)
private static IPackageManager? ResolveManagerForImport(string managerName)
{
IPackageManager? manager = null;
foreach (var m in PEInterface.Managers)
foreach (var manager in PEInterface.Managers)
{
if (m.Id == raw.ManagerName || m.Name == raw.ManagerName || m.DisplayName == raw.ManagerName)
{ manager = m; break; }
if (
manager.Id == managerName
|| manager.Name == managerName
|| manager.DisplayName == managerName
)
return manager;
}

return null;
}

// ─── Deserialization helpers ──────────────────────────────────────────────
public static IPackage DeserializePackage(SerializablePackage raw)
{
IPackageManager? manager = ResolveManagerForImport(raw.ManagerName);

IManagerSource? source;
if (manager?.Capabilities.SupportsCustomSources == true)
{
Expand All @@ -492,25 +493,6 @@ public static IPackage DeserializePackage(SerializablePackage raw)
public static IPackage DeserializeIncompatiblePackage(SerializableIncompatiblePackage raw, IManagerSource source)
=> new InvalidImportedPackage(raw, source);

// ─── Security report helpers ──────────────────────────────────────────────
private static void ReportList(ref BundleReport report, string id, List<string> values, string label, bool allowed)
{
if (!values.Any(x => x.Any())) return;
if (!report.Contents.ContainsKey(id)) report.Contents[id] = [];
report.Contents[id].Add(new BundleReportEntry($"{label}: [{string.Join(", ", values)}]", allowed));
report.IsEmpty = false;
if (!allowed) values.Clear();
}

private static string ReportStr(ref BundleReport report, string id, string value, string label, bool allowed)
{
if (!value.Any()) return value;
if (!report.Contents.ContainsKey(id)) report.Contents[id] = [];
report.Contents[id].Add(new BundleReportEntry($"{label}: {value}", allowed));
report.IsEmpty = false;
return allowed ? value : "";
}

// ─── Batch script export ──────────────────────────────────────────────────
private async Task CreateBatchScriptAsync()
{
Expand Down Expand Up @@ -539,16 +521,36 @@ private async Task CreateBatchScriptAsync()
{
if (p is not ImportedPackage pkg) continue;

// Resolved before anything is appended: a package whose command line is refused
// must contribute nothing at all, or its imported pre-install command would still
// run from the script even though the install itself was dropped.
IReadOnlyList<string> param;
try
{
param = pkg.Manager.OperationHelper.GetStandaloneParameters(
pkg, pkg.installation_options, OperationType.Install);
}
catch (InvalidOperationException ex)
{
Logger.Warn($"Skipping {pkg.Id} in the exported script: {ex.Message}");
continue;
}

packages.Add(pkg.Name + " from " + pkg.Manager.DisplayName);

foreach (var process in pkg.installation_options.KillBeforeOperation)
commands.Add($"taskkill /im \"{process}\"" + (forceKill ? " /f" : ""));
{
string safeProcess = new string(
process.Where(c => c is not '"' && !char.IsControl(c)).ToArray()
);
Comment thread
GabrielDuf marked this conversation as resolved.
Outdated
if (safeProcess.Length is 0)
continue;
commands.Add($"taskkill /im \"{safeProcess}\"" + (forceKill ? " /f" : ""));
}

if (pkg.installation_options.PreInstallCommand != "")
commands.Add(pkg.installation_options.PreInstallCommand);

var param = pkg.Manager.OperationHelper.GetParameters(
pkg, pkg.installation_options, OperationType.Install);
commands.Add($"{pkg.Manager.Properties.ExecutableFriendlyName} {string.Join(' ', param)}");

if (pkg.installation_options.PostInstallCommand != "")
Expand Down Expand Up @@ -604,7 +606,7 @@ private static string GenerateCommandString(IReadOnlyList<string> names, IReadOn
if ($args[0] -ne "/DisablePausePrompts") { pause }
Write-Host ""
Write-Host "This script will attempt to install the following packages:"
{{string.Join('\n', names.Select(x => $"Write-Host \" - {x}\""))}}
{{string.Join('\n', names.Select(x => $"Write-Host {CoreTools.EscapePowerShellSingleQuoted($" - {x}")}"))}}
Write-Host ""
if ($args[0] -ne "/DisablePausePrompts") { pause }
Clear-Host
Expand Down
14 changes: 14 additions & 0 deletions src/UniGetUI.Core.Data/CoreData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,20 @@ private static int GetCodePage()
? Path.Join(Environment.SystemDirectory, "windowspowershell\\v1.0\\powershell.exe")
: "pwsh";

/// <summary>
/// The controlled launcher script that runs PowerShell package operations. It lives next to
/// the application binary so it carries the same integrity as the executable itself, and it
/// is invoked with -File so the operation parameters bind as data instead of being
/// reassembled into a script body the way -Command does.
/// </summary>
public static string PowerShellOperationLauncher =>
Path.Join(
UniGetUIExecutableDirectory,
"Assets",
"Utilities",
"unigetui_ps_operation.ps1"
);

private static string GetLocalDataRoot()
{
string localApplicationData = Environment.GetFolderPath(
Expand Down
Loading
Loading