Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
214 changes: 212 additions & 2 deletions src/code/InstallHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License.

using Microsoft.PowerShell.PSResourceGet.UtilClasses;
using NuGet.Frameworks;
using NuGet.Versioning;
using System;
using System.Collections;
Expand Down Expand Up @@ -48,6 +49,9 @@ internal class InstallHelper
private bool _noClobber;
private bool _authenticodeCheck;
private bool _savePkg;
private bool _skipRuntimeFiltering;
private string _runtimeIdentifier;
private string _targetFramework;
List<string> _pathsToSearch;
List<string> _pkgNamesToInstall;
private string _tmpPath;
Expand Down Expand Up @@ -91,7 +95,10 @@ public IEnumerable<PSResourceInfo> BeginInstallPackages(
List<string> pathsToInstallPkg,
ScopeType? scope,
string tmpPath,
HashSet<string> pkgsInstalled)
HashSet<string> pkgsInstalled,
bool skipRuntimeFiltering = false,
string runtimeIdentifier = null,
string targetFramework = null)
{
_cmdletPassedIn.WriteDebug("In InstallHelper::BeginInstallPackages()");
_cmdletPassedIn.WriteDebug(string.Format("Parameters passed in >>> Name: '{0}'; VersionRange: '{1}'; NuGetVersion: '{2}'; VersionType: '{3}'; Version: '{4}'; Prerelease: '{5}'; Repository: '{6}'; " +
Expand Down Expand Up @@ -133,6 +140,9 @@ public IEnumerable<PSResourceInfo> BeginInstallPackages(
_asNupkg = asNupkg;
_includeXml = includeXml;
_savePkg = savePkg;
_skipRuntimeFiltering = skipRuntimeFiltering;
_runtimeIdentifier = runtimeIdentifier;
_targetFramework = targetFramework;
_pathsToInstallPkg = pathsToInstallPkg;
_tmpPath = tmpPath ?? Path.GetTempPath();

Expand Down Expand Up @@ -1161,9 +1171,12 @@ private bool TrySaveNupkgToTempPath(
}

/// <summary>
/// Extracts files from .nupkg
/// Extracts files from .nupkg with platform-aware filtering.
/// Similar functionality as System.IO.Compression.ZipFile.ExtractToDirectory,
/// but while ExtractToDirectory cannot overwrite files, this method can.
/// Additionally filters:
/// - runtimes/{rid}/ entries based on the current platform's RID (unless _skipRuntimeFiltering is true)
/// - lib/{tfm}/ entries to only extract the best matching Target Framework Moniker
/// </summary>
private bool TryExtractToDirectory(string zipPath, string extractPath, out ErrorRecord error)
{
Expand All @@ -1182,8 +1195,50 @@ private bool TryExtractToDirectory(string zipPath, string extractPath, out Error
{
using (ZipArchive archive = ZipFile.OpenRead(zipPath))
{
// Determine best TFM for lib/ folder filtering
// If user specified -TargetFramework, use that; otherwise auto-detect
NuGetFramework bestLibFramework;
if (!string.IsNullOrEmpty(_targetFramework))
{
bestLibFramework = NuGetFramework.ParseFolder(_targetFramework);
if (bestLibFramework == null || bestLibFramework.IsUnsupported)
{
_cmdletPassedIn.WriteDebug($"Could not parse specified TargetFramework '{_targetFramework}', falling back to auto-detection.");
bestLibFramework = GetBestLibFramework(archive);
}
else
{
_cmdletPassedIn.WriteDebug($"Using user-specified TargetFramework: {bestLibFramework.GetShortFolderName()}");
}
}
else
{
bestLibFramework = GetBestLibFramework(archive);
}

foreach (ZipArchiveEntry entry in archive.Entries.Where(entry => entry.CompressedLength > 0))
{
// RID filtering: skip runtimes/ entries for incompatible platforms
if (!_skipRuntimeFiltering)
{
bool includeEntry = !string.IsNullOrEmpty(_runtimeIdentifier)
? RuntimePackageHelper.ShouldIncludeEntry(entry.FullName, _runtimeIdentifier)
: RuntimePackageHelper.ShouldIncludeEntry(entry.FullName);

if (!includeEntry)
{
_cmdletPassedIn.WriteDebug($"Skipping runtime entry not matching target platform: {entry.FullName}");
continue;
}
}

// TFM filtering: for lib/ entries, only extract the best matching TFM
if (bestLibFramework != null && !ShouldIncludeLibEntry(entry.FullName, bestLibFramework))
{
_cmdletPassedIn.WriteDebug($"Skipping lib entry not matching target framework: {entry.FullName}");
continue;
}

// If a file has one or more parent directories.
if (entry.FullName.Contains(Path.DirectorySeparatorChar) || entry.FullName.Contains(Path.AltDirectorySeparatorChar))
{
Expand Down Expand Up @@ -1225,6 +1280,161 @@ private bool TryExtractToDirectory(string zipPath, string extractPath, out Error
return true;
}

/// <summary>
/// Determines the best matching Target Framework Moniker (TFM) from the lib/ folder entries in a zip archive.
/// Uses NuGet.Frameworks.FrameworkReducer to select the nearest compatible framework.
/// </summary>
/// <param name="archive">The zip archive to analyze.</param>
/// <returns>The best matching NuGetFramework, or null if no lib/ folders exist or no match is found.</returns>
private NuGetFramework GetBestLibFramework(ZipArchive archive)
{
// Collect all TFMs from lib/ folder entries
var libFrameworks = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (ZipArchiveEntry entry in archive.Entries)
{
string normalizedName = entry.FullName.Replace('\\', '/');
if (normalizedName.StartsWith("lib/", StringComparison.OrdinalIgnoreCase))
{
string[] segments = normalizedName.Split('/');
if (segments.Length >= 3 && !string.IsNullOrEmpty(segments[1]))
{
libFrameworks.Add(segments[1]);
}
}
}

if (libFrameworks.Count <= 1)
{
// Zero or one TFM — no filtering needed
return null;
}

try
{
// Detect the current runtime's target framework
NuGetFramework currentFramework = GetCurrentFramework();

// Parse all discovered TFMs
var parsedFrameworks = new List<NuGetFramework>();
foreach (string tfm in libFrameworks)
{
NuGetFramework parsed = NuGetFramework.ParseFolder(tfm);
if (parsed != null && !parsed.IsUnsupported)
{
parsedFrameworks.Add(parsed);
}
}

if (parsedFrameworks.Count == 0)
{
return null;
}

// Use FrameworkReducer to find the best match
var reducer = new FrameworkReducer();
NuGetFramework bestMatch = reducer.GetNearest(currentFramework, parsedFrameworks);

if (bestMatch != null)
{
_cmdletPassedIn.WriteDebug($"Selected best matching TFM: {bestMatch.GetShortFolderName()} (from {string.Join(", ", libFrameworks)})");
}

return bestMatch;
}
catch (Exception e)
{
_cmdletPassedIn.WriteDebug($"TFM selection failed, extracting all lib/ folders: {e.Message}");
return null;
}
}

/// <summary>
/// Determines if a zip entry from the lib/ folder should be included based on the best matching TFM.
/// Non-lib entries are always included.
/// </summary>
/// <param name="entryFullName">The full name of the zip entry.</param>
/// <param name="bestFramework">The best matching framework from GetBestLibFramework.</param>
/// <returns>True if the entry should be extracted.</returns>
private static bool ShouldIncludeLibEntry(string entryFullName, NuGetFramework bestFramework)
{
string normalizedName = entryFullName.Replace('\\', '/');

// Only filter entries inside lib/
if (!normalizedName.StartsWith("lib/", StringComparison.OrdinalIgnoreCase))
{
return true;
}

string[] segments = normalizedName.Split('/');
if (segments.Length < 3 || string.IsNullOrEmpty(segments[1]))
{
// lib/ root files (uncommon) — include them
return true;
}

string entryTfm = segments[1];
NuGetFramework entryFramework = NuGetFramework.ParseFolder(entryTfm);

if (entryFramework == null || entryFramework.IsUnsupported)
{
// Can't parse TFM, include to be safe
return true;
}

// Only include entries matching the best framework
return entryFramework.Equals(bestFramework);
}

/// <summary>
/// Gets the NuGetFramework for the current runtime environment.
/// Since this assembly is compiled as net472, it must detect the actual host runtime
/// by parsing RuntimeInformation.FrameworkDescription rather than using Environment.Version
/// (which returns 4.0.30319.x even when running on .NET 8+ via compatibility shims).
/// </summary>
private static NuGetFramework GetCurrentFramework()
{
string runtimeDescription = RuntimeInformation.FrameworkDescription;

if (runtimeDescription.StartsWith(".NET Framework", StringComparison.OrdinalIgnoreCase))
{
// Windows PowerShell 5.1 — .NET Framework 4.x
return NuGetFramework.ParseFolder("net472");
}

// PowerShell 7+ on .NET Core/.NET 5+
// RuntimeInformation.FrameworkDescription format examples:
// ".NET Core 3.1.0" -> netcoreapp3.1
// ".NET 6.0.5" -> net6.0
// ".NET 8.0.1" -> net8.0
// ".NET 9.0.0" -> net9.0
try
{
string versionPart = runtimeDescription;

// Strip prefix to get just the version number
if (versionPart.StartsWith(".NET Core ", StringComparison.OrdinalIgnoreCase))
{
versionPart = versionPart.Substring(".NET Core ".Length);
}
else if (versionPart.StartsWith(".NET ", StringComparison.OrdinalIgnoreCase))
{
versionPart = versionPart.Substring(".NET ".Length);
}

if (Version.TryParse(versionPart, out Version parsedVersion))
{
return new NuGetFramework(".NETCoreApp", new Version(parsedVersion.Major, parsedVersion.Minor));
}
}
catch
{
// Fall through to default
}

// Fallback: default to netstandard2.0 which is broadly compatible
return NuGetFramework.ParseFolder("netstandard2.0");
}

/// <summary>
/// Moves package files/directories from the temp install path into the final install path location.
/// </summary>
Expand Down
33 changes: 32 additions & 1 deletion src/code/InstallPSResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,34 @@ public string TemporaryPath
[Parameter]
public SwitchParameter AuthenticodeCheck { get; set; }

/// <summary>
/// Skips platform-specific runtime asset filtering during installation.
/// When specified, all runtime assets for all platforms will be installed (original behavior).
/// By default, only runtime assets compatible with the current platform are installed.
/// </summary>
[Parameter]
public SwitchParameter SkipRuntimeFiltering { get; set; }

/// <summary>
/// Specifies the Runtime Identifier (RID) to filter platform-specific assets for.
/// When specified, only runtime assets matching this RID are installed instead of the auto-detected platform.
/// Use this for cross-platform deployment scenarios (e.g., preparing a Linux package from Windows).
/// Valid values follow the .NET RID catalog: win-x64, linux-x64, osx-arm64, etc.
/// </summary>
[Parameter]
[ValidateNotNullOrEmpty]
public string RuntimeIdentifier { get; set; }

/// <summary>
/// Specifies the Target Framework Moniker (TFM) to select for lib/ folder filtering.
/// When specified, only lib/ assets matching this TFM are installed instead of the auto-detected framework.
/// Use this for cross-platform deployment scenarios (e.g., preparing a .NET 6 package from a .NET 8 host).
/// Valid values follow NuGet TFM format: net472, netstandard2.0, net6.0, net8.0, etc.
/// </summary>
[Parameter]
[ValidateNotNullOrEmpty]
public string TargetFramework { get; set; }

/// <summary>
/// Passes the resource installed to the console.
/// </summary>
Expand Down Expand Up @@ -597,7 +625,10 @@ private void ProcessInstallHelper(string[] pkgNames, string pkgVersion, bool pkg
pathsToInstallPkg: _pathsToInstallPkg,
scope: scope,
tmpPath: _tmpPath,
pkgsInstalled: _packagesOnMachine);
pkgsInstalled: _packagesOnMachine,
skipRuntimeFiltering: SkipRuntimeFiltering,
runtimeIdentifier: RuntimeIdentifier,
targetFramework: TargetFramework);

if (PassThru)
{
Expand Down
Loading
Loading