diff --git a/diagram.md b/diagram.md
new file mode 100644
index 000000000..272f5efe9
--- /dev/null
+++ b/diagram.md
@@ -0,0 +1,150 @@
+# Platform-Aware Installation Flow
+
+## Extraction Filtering Pipeline
+
+```mermaid
+flowchart TD
+ A["Install-PSResource -Name MyModule
[-SkipRuntimeFiltering] [-RuntimeIdentifier] [-TargetFramework]"] --> B["1. Download .nupkg from gallery
(full package, all platforms)"]
+ B --> C["2. Open as ZipArchive in TryExtractToDirectory()"]
+ C --> D["Detect TFM"]
+ C --> E["Detect RID"]
+
+ D --> D1{"-TargetFramework
specified?"}
+ D1 -->|YES| D2["Parse user value
NuGetFramework.ParseFolder()"]
+ D1 -->|NO| D3["Auto-detect via
GetCurrentFramework()"]
+ D2 --> D4["GetBestLibFramework()
FrameworkReducer.GetNearest()
→ Best TFM e.g., net8.0"]
+ D3 --> D4
+
+ E --> E1{"-RuntimeIdentifier
specified?"}
+ E1 -->|YES| E2["Use user value
e.g., linux-x64"]
+ E1 -->|NO| E3["DetectRuntimeIdentifier()
ProcessArchitecture + OSPlatform"]
+ E2 --> E4["Target RID
e.g., win-x64"]
+ E3 --> E4
+
+ D4 --> F["3. Filter each zip entry"]
+ E4 --> F
+
+ F --> G{"Entry type?"}
+
+ G -->|"runtimes/**"| H{"-SkipRuntimeFiltering?"}
+ H -->|YES| I["✅ INCLUDE all runtimes"]
+ H -->|NO| J{"ShouldIncludeEntry()
RID compatible?"}
+ J -->|"runtimes/win-x64/ vs win-x64"| K["✅ INCLUDE"]
+ J -->|"runtimes/linux-arm64/ vs win-x64"| L["❌ SKIP"]
+ J -->|"runtimes/osx-arm64/ vs win-x64"| L
+
+ G -->|"lib/**"| M{"Best TFM
match found?"}
+ M -->|NO| N["✅ INCLUDE all lib/ entries"]
+ M -->|YES| O{"ShouldIncludeLibEntry()
TFM matches best?"}
+ O -->|"lib/net8.0/ vs best net8.0"| P["✅ INCLUDE"]
+ O -->|"lib/net472/ vs best net8.0"| Q["❌ SKIP"]
+ O -->|"lib/netstandard2.0/ vs best net8.0"| Q
+
+ G -->|"*.psd1, *.psm1, etc."| R["✅ INCLUDE always"]
+
+ K --> S["4. DeleteExtraneousFiles()
Delete: Content_Types.xml, _rels/, package/, .nuspec"]
+ I --> S
+ N --> S
+ P --> S
+ R --> S
+
+ S --> T["5. Installed Module
MyModule/1.0.0/
├── MyModule.psd1
├── lib/net8.0/ ← only best TFM
└── runtimes/win-x64/ ← only matching RID"]
+
+ style K fill:#2d6a2d,color:#fff
+ style I fill:#2d6a2d,color:#fff
+ style N fill:#2d6a2d,color:#fff
+ style P fill:#2d6a2d,color:#fff
+ style R fill:#2d6a2d,color:#fff
+ style L fill:#8b1a1a,color:#fff
+ style Q fill:#8b1a1a,color:#fff
+ style T fill:#1a3d5c,color:#fff
+```
+
+### Code References
+
+| Diagram Step | Method | File |
+|---|---|---|
+| Cmdlet parameters | `SkipRuntimeFiltering`, `RuntimeIdentifier`, `TargetFramework` | [InstallPSResource.cs#L147](src/code/InstallPSResource.cs#L147) |
+| TryExtractToDirectory | Entry point for filtered extraction | [InstallHelper.cs#L1181](src/code/InstallHelper.cs#L1181) |
+| GetCurrentFramework | Auto-detect TFM from `RuntimeInformation.FrameworkDescription` | [InstallHelper.cs#L1394](src/code/InstallHelper.cs#L1394) |
+| GetBestLibFramework | `FrameworkReducer.GetNearest()` for lib/ selection | [InstallHelper.cs#L1289](src/code/InstallHelper.cs#L1289) |
+| ShouldIncludeLibEntry | Filter lib/ entries against best TFM | [InstallHelper.cs#L1358](src/code/InstallHelper.cs#L1358) |
+| DetectRuntimeIdentifier | Auto-detect RID from OS + architecture | [RuntimeIdentifierHelper.cs#L204](src/code/RuntimeIdentifierHelper.cs#L204) |
+| ShouldIncludeEntry | Filter runtimes/ entries against target RID | [RuntimePackageHelper.cs#L83](src/code/RuntimePackageHelper.cs#L83) |
+| DeleteExtraneousFiles | Cleanup: remove NuGet packaging artifacts | [InstallHelper.cs#L1709](src/code/InstallHelper.cs#L1709) |
+
+## Dependency Parsing (TFM-Aware)
+
+```mermaid
+flowchart TD
+ A[".nuspec dependencies"] --> B{"Source type?"}
+
+ B -->|"Local repo (.nuspec file)"| C["GetHashtableForNuspec()
NuspecReader.GetDependencyGroups()
→ ParseNuspecDependencyGroups()"]
+ B -->|"Remote V3 (JSON)"| D["Parse dependencyGroups JSON
→ TryConvertFromJson()"]
+
+ C --> E["FrameworkReducer.GetNearest()
picks best TFM group"]
+ D --> E
+
+ E --> F{"Current runtime?"}
+
+ F -->|".NET 8 (PS 7.4)"| G["Select net8.0 group
→ 0 dependencies
(APIs are inbox)"]
+ F -->|".NET Framework 4.7.2 (PS 5.1)"| H["Select net472 group
→ 2 dependencies
(System.Memory, System.Buffers)"]
+
+ style G fill:#2d6a2d,color:#fff
+ style H fill:#c47a20,color:#fff
+```
+
+### Code References
+
+| Diagram Step | Method | File |
+|---|---|---|
+| GetHashtableForNuspec | `NuspecReader` parses .nuspec for local repos | [LocalServerApiCalls.cs#L958](src/code/LocalServerApiCalls.cs#L958) |
+| TryConvertFromJson | Parses V3 JSON dependency groups for remote repos | [PSResourceInfo.cs#L618](src/code/PSResourceInfo.cs#L618) |
+| ParseNuspecDependencyGroups | TFM-aware group selection via `FrameworkReducer` | [PSResourceInfo.cs#L1704](src/code/PSResourceInfo.cs#L1704) |
+
+## Before vs After
+
+```mermaid
+graph LR
+ subgraph BEFORE["Before (no filtering) — ~56 MB"]
+ B1["lib/net472/"]
+ B2["lib/netstandard2.0/"]
+ B3["lib/net6.0/"]
+ B4["lib/net8.0/ ✓"]
+ B5["runtimes/win-x64/ ✓"]
+ B6["runtimes/win-x86/"]
+ B7["runtimes/linux-x64/"]
+ B8["runtimes/linux-arm64/"]
+ B9["runtimes/osx-x64/"]
+ B10["runtimes/osx-arm64/"]
+ end
+
+ subgraph AFTER["After (with filtering) — ~4 MB"]
+ A1["lib/net8.0/ ✓"]
+ A2["runtimes/win-x64/ ✓"]
+ end
+
+ style B1 fill:#8b1a1a,color:#fff
+ style B2 fill:#8b1a1a,color:#fff
+ style B3 fill:#8b1a1a,color:#fff
+ style B4 fill:#2d6a2d,color:#fff
+ style B5 fill:#2d6a2d,color:#fff
+ style B6 fill:#8b1a1a,color:#fff
+ style B7 fill:#8b1a1a,color:#fff
+ style B8 fill:#8b1a1a,color:#fff
+ style B9 fill:#8b1a1a,color:#fff
+ style B10 fill:#8b1a1a,color:#fff
+ style A1 fill:#2d6a2d,color:#fff
+ style A2 fill:#2d6a2d,color:#fff
+```
+
+## RID Compatibility Chain
+
+```mermaid
+flowchart LR
+ W1["win10-x64"] --> W2["win-x64"] --> W3["win"] --> ANY["any"]
+ L1["linux-musl-x64"] --> L2["linux-x64"] --> L3["linux"] --> U["unix"] --> ANY
+ O1["osx.12-arm64"] --> O2["osx-arm64"] --> O3["osx"] --> U
+
+
+```
diff --git a/src/code/InstallHelper.cs b/src/code/InstallHelper.cs
index 0616cf040..ed66edaeb 100644
--- a/src/code/InstallHelper.cs
+++ b/src/code/InstallHelper.cs
@@ -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;
@@ -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 _pathsToSearch;
List _pkgNamesToInstall;
private string _tmpPath;
@@ -91,7 +95,10 @@ public IEnumerable BeginInstallPackages(
List pathsToInstallPkg,
ScopeType? scope,
string tmpPath,
- HashSet pkgsInstalled)
+ HashSet 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}'; " +
@@ -133,6 +140,9 @@ public IEnumerable BeginInstallPackages(
_asNupkg = asNupkg;
_includeXml = includeXml;
_savePkg = savePkg;
+ _skipRuntimeFiltering = skipRuntimeFiltering;
+ _runtimeIdentifier = runtimeIdentifier;
+ _targetFramework = targetFramework;
_pathsToInstallPkg = pathsToInstallPkg;
_tmpPath = tmpPath ?? Path.GetTempPath();
@@ -1161,9 +1171,12 @@ private bool TrySaveNupkgToTempPath(
}
///
- /// 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
///
private bool TryExtractToDirectory(string zipPath, string extractPath, out ErrorRecord error)
{
@@ -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))
{
@@ -1225,6 +1280,161 @@ private bool TryExtractToDirectory(string zipPath, string extractPath, out Error
return true;
}
+ ///
+ /// 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.
+ ///
+ /// The zip archive to analyze.
+ /// The best matching NuGetFramework, or null if no lib/ folders exist or no match is found.
+ private NuGetFramework GetBestLibFramework(ZipArchive archive)
+ {
+ // Collect all TFMs from lib/ folder entries
+ var libFrameworks = new HashSet(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();
+ 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;
+ }
+ }
+
+ ///
+ /// Determines if a zip entry from the lib/ folder should be included based on the best matching TFM.
+ /// Non-lib entries are always included.
+ ///
+ /// The full name of the zip entry.
+ /// The best matching framework from GetBestLibFramework.
+ /// True if the entry should be extracted.
+ 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);
+ }
+
+ ///
+ /// 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).
+ ///
+ 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");
+ }
+
///
/// Moves package files/directories from the temp install path into the final install path location.
///
diff --git a/src/code/InstallPSResource.cs b/src/code/InstallPSResource.cs
index feca62d50..f54d2e906 100644
--- a/src/code/InstallPSResource.cs
+++ b/src/code/InstallPSResource.cs
@@ -138,6 +138,34 @@ public string TemporaryPath
[Parameter]
public SwitchParameter AuthenticodeCheck { get; set; }
+ ///
+ /// 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.
+ ///
+ [Parameter]
+ public SwitchParameter SkipRuntimeFiltering { get; set; }
+
+ ///
+ /// 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.
+ ///
+ [Parameter]
+ [ValidateNotNullOrEmpty]
+ public string RuntimeIdentifier { get; set; }
+
+ ///
+ /// 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.
+ ///
+ [Parameter]
+ [ValidateNotNullOrEmpty]
+ public string TargetFramework { get; set; }
+
///
/// Passes the resource installed to the console.
///
@@ -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)
{
diff --git a/src/code/InternalHooks.cs b/src/code/InternalHooks.cs
index 99d6104ce..b999ae083 100644
--- a/src/code/InternalHooks.cs
+++ b/src/code/InternalHooks.cs
@@ -1,7 +1,11 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
using System.Reflection;
+using System.Runtime.InteropServices;
namespace Microsoft.PowerShell.PSResourceGet.UtilClasses
{
@@ -27,5 +31,93 @@ public static string GetUserString()
{
return Microsoft.PowerShell.PSResourceGet.Cmdlets.UserAgentInfo.UserAgentString();
}
+
+ #region RuntimeIdentifierHelper Test Hooks
+
+ ///
+ /// Returns the detected RID for the current platform.
+ ///
+ public static string GetCurrentRuntimeIdentifier()
+ {
+ return RuntimeIdentifierHelper.GetCurrentRuntimeIdentifier();
+ }
+
+ ///
+ /// Returns the compatible RID list for the current platform.
+ ///
+ public static IReadOnlyList GetCompatibleRuntimeIdentifiers()
+ {
+ return RuntimeIdentifierHelper.GetCompatibleRuntimeIdentifiers();
+ }
+
+ ///
+ /// Returns the compatible RID list for a specified RID.
+ ///
+ public static IReadOnlyList GetCompatibleRuntimeIdentifiersFor(string rid)
+ {
+ return RuntimeIdentifierHelper.GetCompatibleRuntimeIdentifiers(rid);
+ }
+
+ ///
+ /// Checks if a given RID is compatible with the current platform.
+ ///
+ public static bool IsCompatibleRid(string rid)
+ {
+ return RuntimeIdentifierHelper.IsCompatibleRid(rid);
+ }
+
+ #endregion
+
+ #region RuntimePackageHelper Test Hooks
+
+ ///
+ /// Checks if a zip entry path is in the runtimes folder.
+ ///
+ public static bool IsRuntimesEntry(string entryFullName)
+ {
+ return RuntimePackageHelper.IsRuntimesEntry(entryFullName);
+ }
+
+ ///
+ /// Extracts the RID from a runtimes entry path.
+ ///
+ public static string GetRidFromRuntimesEntry(string entryFullName)
+ {
+ return RuntimePackageHelper.GetRidFromRuntimesEntry(entryFullName);
+ }
+
+ ///
+ /// Determines if a zip entry should be included for the current platform.
+ ///
+ public static bool ShouldIncludeEntry(string entryFullName)
+ {
+ return RuntimePackageHelper.ShouldIncludeEntry(entryFullName);
+ }
+
+ ///
+ /// Determines if a zip entry should be included for an explicit target RID.
+ ///
+ public static bool ShouldIncludeEntryForRid(string entryFullName, string targetRid)
+ {
+ return RuntimePackageHelper.ShouldIncludeEntry(entryFullName, targetRid);
+ }
+
+ ///
+ /// Checks if a given RID is compatible with a specified target RID.
+ ///
+ public static bool IsCompatibleRidWith(string candidateRid, string targetRid)
+ {
+ return RuntimeIdentifierHelper.IsCompatibleRid(candidateRid, targetRid);
+ }
+
+ ///
+ /// Returns a list of RIDs from a zip file's runtimes folder.
+ ///
+ public static IReadOnlyList GetAvailableRidsFromZipFile(string zipPath)
+ {
+ return RuntimePackageHelper.GetAvailableRidsFromZipFile(zipPath);
+ }
+
+ #endregion
}
}
diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs
index d2351da35..262b5fd35 100644
--- a/src/code/PSResourceInfo.cs
+++ b/src/code/PSResourceInfo.cs
@@ -3,6 +3,9 @@
using Dbg = System.Diagnostics.Debug;
using Microsoft.PowerShell.Commands;
+using NuGet.Frameworks;
+using NuGet.Packaging;
+using NuGet.Packaging.Core;
using NuGet.Versioning;
using System;
using System.Collections;
@@ -10,6 +13,7 @@
using System.Globalization;
using System.Linq;
using System.Management.Automation;
+using System.Runtime.InteropServices;
using System.Text.Json;
using System.Xml;
@@ -717,39 +721,92 @@ public static bool TryConvertFromJson(
metadata["PublishedDate"] = ParseHttpDateTime(publishedElement.ToString());
}
- // Dependencies
+ // Dependencies
+ // TFM-aware: select the best matching dependency group for the current runtime,
+ // rather than merging all groups into a flat list.
if (rootDom.TryGetProperty("dependencyGroups", out JsonElement dependencyGroupsElement))
{
List pkgDeps = new();
if (dependencyGroupsElement.ValueKind == JsonValueKind.Array)
{
+ // Build a mapping of targetFramework -> dependencies for each group
+ var groupMap = new List<(NuGetFramework framework, JsonElement groupElement)>();
+
foreach (JsonElement dependencyGroup in dependencyGroupsElement.EnumerateArray())
{
- if (dependencyGroup.TryGetProperty("dependencies", out JsonElement dependenciesElement))
+ NuGetFramework groupFramework = NuGetFramework.AnyFramework;
+ if (dependencyGroup.TryGetProperty("targetFramework", out JsonElement tfmElement))
{
- if (dependenciesElement.ValueKind == JsonValueKind.Array)
+ string tfmString = tfmElement.GetString();
+ if (!string.IsNullOrWhiteSpace(tfmString))
{
- foreach (
- JsonElement dependency in dependenciesElement.EnumerateArray().Where(
- x => x.TryGetProperty("id", out JsonElement idProperty) &&
- !string.IsNullOrWhiteSpace(idProperty.GetString())
- )
- )
+ NuGetFramework parsed = NuGetFramework.Parse(tfmString);
+ if (parsed != null && !parsed.IsUnsupported)
{
- pkgDeps.Add(
- new Dependency(
- dependency.GetProperty("id").GetString(),
- (
- VersionRange.TryParse(dependency.GetProperty("range").GetString(), out VersionRange versionRange) ?
- versionRange :
- VersionRange.All
- )
- )
- );
+ groupFramework = parsed;
}
}
}
+ groupMap.Add((groupFramework, dependencyGroup));
+ }
+
+ // Select the best matching group using FrameworkReducer
+ JsonElement? selectedGroupElement = null;
+ try
+ {
+ if (groupMap.Count > 0)
+ {
+ NuGetFramework currentFramework = GetCurrentFrameworkForDeps();
+ var reducer = new FrameworkReducer();
+ NuGetFramework bestMatch = reducer.GetNearest(currentFramework, groupMap.Select(g => g.framework));
+
+ if (bestMatch != null)
+ {
+ selectedGroupElement = groupMap.FirstOrDefault(g => g.framework.Equals(bestMatch)).groupElement;
+ }
+ }
+ }
+ catch
+ {
+ // If TFM selection fails, fall through to fallback
+ }
+
+ // Fallback: use the "any" / no-TFM group, or the first group
+ if (selectedGroupElement == null)
+ {
+ var fallback = groupMap.FirstOrDefault(g =>
+ g.framework == null ||
+ g.framework.Equals(NuGetFramework.AnyFramework) ||
+ g.framework.IsUnsupported);
+ selectedGroupElement = fallback.groupElement.ValueKind != JsonValueKind.Undefined
+ ? fallback.groupElement
+ : groupMap.FirstOrDefault().groupElement;
+ }
+
+ // Parse dependencies from the selected group
+ if (selectedGroupElement.HasValue &&
+ selectedGroupElement.Value.TryGetProperty("dependencies", out JsonElement dependenciesElement) &&
+ dependenciesElement.ValueKind == JsonValueKind.Array)
+ {
+ foreach (
+ JsonElement dependency in dependenciesElement.EnumerateArray().Where(
+ x => x.TryGetProperty("id", out JsonElement idProperty) &&
+ !string.IsNullOrWhiteSpace(idProperty.GetString())
+ )
+ )
+ {
+ pkgDeps.Add(
+ new Dependency(
+ dependency.GetProperty("id").GetString(),
+ (
+ VersionRange.TryParse(dependency.GetProperty("range").GetString(), out VersionRange versionRange) ?
+ versionRange :
+ VersionRange.All
+ )
+ )
+ );
+ }
}
}
metadata["Dependencies"] = pkgDeps.ToArray();
@@ -1340,7 +1397,7 @@ public static bool TryConvertFromHashtableForNuspec(
author: pkgMetadata["authors"] as String,
companyName: String.Empty,
copyright: pkgMetadata["copyright"] as String,
- dependencies: new Dependency[] { },
+ dependencies: ParseNuspecDependencyGroups(pkgMetadata),
description: pkgMetadata["description"] as String,
iconUri: iconUri,
includes: includes,
@@ -1636,6 +1693,118 @@ internal static Dependency[] ParseHttpDependencies(string dependencyString)
return dependencyList.ToArray();
}
+ ///
+ /// Parses NuGet dependency groups from the .nuspec metadata hashtable (populated by NuspecReader).
+ /// Performs TFM-aware selection: picks the best matching dependency group for the current runtime,
+ /// rather than merging all groups.
+ ///
+ /// Hashtable containing nuspec metadata, including a "dependencyGroups" key
+ /// with a List of PackageDependencyGroup objects.
+ /// Array of Dependency objects for the best matching TFM group, or empty if no groups exist.
+ internal static Dependency[] ParseNuspecDependencyGroups(Hashtable pkgMetadata)
+ {
+ if (pkgMetadata == null || !pkgMetadata.ContainsKey("dependencyGroups"))
+ {
+ return new Dependency[] { };
+ }
+
+ var dependencyGroups = pkgMetadata["dependencyGroups"] as List;
+ if (dependencyGroups == null || dependencyGroups.Count == 0)
+ {
+ return new Dependency[] { };
+ }
+
+ // Determine the current runtime's target framework for TFM-aware selection
+ PackageDependencyGroup selectedGroup = null;
+ try
+ {
+ NuGetFramework currentFramework = GetCurrentFrameworkForDeps();
+
+ // Use FrameworkReducer to find the best matching dependency group
+ var reducer = new FrameworkReducer();
+ var groupFrameworks = dependencyGroups.Select(g => g.TargetFramework).ToList();
+ NuGetFramework bestMatch = reducer.GetNearest(currentFramework, groupFrameworks);
+
+ if (bestMatch != null)
+ {
+ selectedGroup = dependencyGroups.FirstOrDefault(g => g.TargetFramework.Equals(bestMatch));
+ }
+ }
+ catch
+ {
+ // If TFM selection fails, fall through to fallback logic below
+ }
+
+ // Fallback: if no TFM match, use the group with no target framework (portable/any) or the first group
+ if (selectedGroup == null)
+ {
+ selectedGroup = dependencyGroups.FirstOrDefault(g =>
+ g.TargetFramework == null ||
+ g.TargetFramework.Equals(NuGetFramework.AnyFramework) ||
+ g.TargetFramework.IsUnsupported) ?? dependencyGroups.First();
+ }
+
+ // Convert PackageDependency objects to our Dependency[] format
+ List deps = new List();
+ foreach (PackageDependency dep in selectedGroup.Packages)
+ {
+ if (!string.IsNullOrWhiteSpace(dep.Id))
+ {
+ deps.Add(new Dependency(dep.Id, dep.VersionRange ?? VersionRange.All));
+ }
+ }
+
+ return deps.ToArray();
+ }
+
+ ///
+ /// Detects the current runtime's NuGetFramework for dependency group selection.
+ /// 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).
+ ///
+ private static NuGetFramework GetCurrentFrameworkForDeps()
+ {
+ 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
+ try
+ {
+ string versionPart = runtimeDescription;
+
+ 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");
+ }
+
internal static List ParseContainerRegistryDependencies(JsonElement requiredModulesElement, out string errorMsg)
{
errorMsg = string.Empty;
diff --git a/src/code/RuntimeIdentifierHelper.cs b/src/code/RuntimeIdentifierHelper.cs
new file mode 100644
index 000000000..1fa98f8b9
--- /dev/null
+++ b/src/code/RuntimeIdentifierHelper.cs
@@ -0,0 +1,448 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Runtime.InteropServices;
+
+namespace Microsoft.PowerShell.PSResourceGet.UtilClasses
+{
+ ///
+ /// Helper class for Runtime Identifier (RID) detection and compatibility.
+ /// Used for platform-aware package installation to filter runtime-specific assets.
+ ///
+ internal static class RuntimeIdentifierHelper
+ {
+ #region Private Fields
+
+ ///
+ /// Cached current runtime identifier to avoid repeated detection.
+ ///
+ private static string s_currentRid = null;
+
+ ///
+ /// Cached compatible RIDs for the current platform.
+ ///
+ private static List s_compatibleRids = null;
+
+ ///
+ /// Lock object for thread-safe initialization.
+ ///
+ private static readonly object s_lock = new object();
+
+ #endregion
+
+ #region Public Methods
+
+ ///
+ /// Gets the .NET Runtime Identifier (RID) for the current platform.
+ ///
+ ///
+ /// A RID string like "win-x64", "linux-x64", "osx-arm64", etc.
+ /// Follows the .NET RID catalog: https://learn.microsoft.com/en-us/dotnet/core/rid-catalog
+ ///
+ public static string GetCurrentRuntimeIdentifier()
+ {
+ if (s_currentRid != null)
+ {
+ return s_currentRid;
+ }
+
+ lock (s_lock)
+ {
+ if (s_currentRid != null)
+ {
+ return s_currentRid;
+ }
+
+ s_currentRid = DetectRuntimeIdentifier();
+ return s_currentRid;
+ }
+ }
+
+ ///
+ /// Gets a list of compatible Runtime Identifiers for the current platform.
+ /// RIDs follow an inheritance chain (e.g., win10-x64 -> win-x64 -> any).
+ ///
+ ///
+ /// A list of RIDs that are compatible with the current platform, ordered from most specific to least specific.
+ ///
+ public static IReadOnlyList GetCompatibleRuntimeIdentifiers()
+ {
+ if (s_compatibleRids != null)
+ {
+ return s_compatibleRids;
+ }
+
+ lock (s_lock)
+ {
+ if (s_compatibleRids != null)
+ {
+ return s_compatibleRids;
+ }
+
+ s_compatibleRids = BuildCompatibleRidList(GetCurrentRuntimeIdentifier());
+ return s_compatibleRids;
+ }
+ }
+
+ ///
+ /// Gets a list of compatible Runtime Identifiers for a given RID.
+ ///
+ /// The primary RID to get compatibility for.
+ ///
+ /// A list of RIDs that are compatible with the specified RID, ordered from most specific to least specific.
+ ///
+ public static IReadOnlyList GetCompatibleRuntimeIdentifiers(string primaryRid)
+ {
+ if (string.IsNullOrWhiteSpace(primaryRid))
+ {
+ throw new ArgumentException("Primary RID cannot be null or empty.", nameof(primaryRid));
+ }
+
+ return BuildCompatibleRidList(primaryRid);
+ }
+
+ ///
+ /// Checks if a given RID is compatible with the current platform.
+ /// A package RID is compatible if:
+ /// 1. It's in our platform's compatibility chain (e.g., 'win' folder works on 'win-x64' machine), OR
+ /// 2. Our platform is in the package RID's compatibility chain (e.g., 'win10-x64' folder works on 'win-x64' machine)
+ ///
+ /// The RID to check.
+ /// True if the RID is compatible with the current platform; otherwise, false.
+ public static bool IsCompatibleRid(string rid)
+ {
+ if (string.IsNullOrWhiteSpace(rid))
+ {
+ return false;
+ }
+
+ string currentRid = GetCurrentRuntimeIdentifier();
+
+ // Check if the package RID is in our platform's compatibility chain
+ // e.g., our platform is win-x64, and package has 'win' folder -> compatible
+ var ourCompatibleRids = GetCompatibleRuntimeIdentifiers();
+ foreach (var compatibleRid in ourCompatibleRids)
+ {
+ if (string.Equals(rid, compatibleRid, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ // Check if our platform is in the package RID's compatibility chain
+ // e.g., our platform is win-x64, and package has 'win10-x64' folder -> compatible
+ // because win10-x64's chain includes win-x64
+ var packageRidCompatibles = BuildCompatibleRidList(rid);
+ foreach (var compatibleRid in packageRidCompatibles)
+ {
+ if (string.Equals(currentRid, compatibleRid, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Checks if a given RID is compatible with a specified target RID (rather than the current platform).
+ /// Used when the user explicitly specifies a -RuntimeIdentifier for cross-platform deployment scenarios.
+ ///
+ /// The RID from the package entry to check.
+ /// The target RID to check compatibility against.
+ /// True if the candidate RID is compatible with the target; otherwise, false.
+ public static bool IsCompatibleRid(string candidateRid, string targetRid)
+ {
+ if (string.IsNullOrWhiteSpace(candidateRid) || string.IsNullOrWhiteSpace(targetRid))
+ {
+ return false;
+ }
+
+ // Check if the candidate RID is in the target's compatibility chain
+ var targetCompatibleRids = BuildCompatibleRidList(targetRid);
+ foreach (var compatibleRid in targetCompatibleRids)
+ {
+ if (string.Equals(candidateRid, compatibleRid, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ // Check if the target is in the candidate RID's compatibility chain
+ var candidateCompatibleRids = BuildCompatibleRidList(candidateRid);
+ foreach (var compatibleRid in candidateCompatibleRids)
+ {
+ if (string.Equals(targetRid, compatibleRid, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Checks if a folder name in the runtimes directory should be included for the current platform.
+ ///
+ /// The name of the folder in the runtimes directory.
+ /// True if the folder should be included; otherwise, false.
+ public static bool ShouldIncludeRuntimeFolder(string runtimeFolderName)
+ {
+ return IsCompatibleRid(runtimeFolderName);
+ }
+
+ #endregion
+
+ #region Private Methods
+
+ ///
+ /// Detects the runtime identifier for the current platform.
+ ///
+ private static string DetectRuntimeIdentifier()
+ {
+ // Get architecture
+ string arch = GetArchitectureString();
+
+ // Detect OS and construct RID
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return $"win-{arch}";
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ // Check for musl-based distros (Alpine, etc.)
+ if (IsMuslBasedLinux())
+ {
+ return $"linux-musl-{arch}";
+ }
+ return $"linux-{arch}";
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ return $"osx-{arch}";
+ }
+ else
+ {
+ // Fallback for unknown platforms
+ return $"unix-{arch}";
+ }
+ }
+
+ ///
+ /// Gets the architecture string for the current process.
+ ///
+ private static string GetArchitectureString()
+ {
+ Architecture processArch = RuntimeInformation.ProcessArchitecture;
+
+ return processArch switch
+ {
+ Architecture.X64 => "x64",
+ Architecture.X86 => "x86",
+ Architecture.Arm => "arm",
+ Architecture.Arm64 => "arm64",
+ _ => processArch.ToString().ToLowerInvariant()
+ };
+ }
+
+ ///
+ /// Checks if the current Linux system is musl-based (e.g., Alpine Linux).
+ ///
+ private static bool IsMuslBasedLinux()
+ {
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ return false;
+ }
+
+ try
+ {
+ // Check /etc/os-release for Alpine or musl indicators
+ const string osReleasePath = "/etc/os-release";
+ if (File.Exists(osReleasePath))
+ {
+ string content = File.ReadAllText(osReleasePath);
+ // Alpine Linux specifically uses musl
+ if (content.IndexOf("alpine", StringComparison.OrdinalIgnoreCase) >= 0 ||
+ content.IndexOf("ID=alpine", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ return true;
+ }
+ }
+
+ // Alternative: Check if libc is musl by examining /lib/libc.musl-*.so
+ // This is a more direct check but requires directory enumeration
+ if (Directory.Exists("/lib"))
+ {
+ string[] muslLibs = Directory.GetFiles("/lib", "libc.musl-*.so*");
+ if (muslLibs.Length > 0)
+ {
+ return true;
+ }
+ }
+ }
+ catch
+ {
+ // If we can't determine, assume glibc (most common)
+ }
+
+ return false;
+ }
+
+ ///
+ /// Builds a list of compatible RIDs for the given primary RID.
+ /// RIDs follow an inheritance chain from most specific to least specific.
+ ///
+ /// The primary RID to build the compatibility list for.
+ /// A list of compatible RIDs.
+ private static List BuildCompatibleRidList(string primaryRid)
+ {
+ var compatibleRids = new List { primaryRid };
+
+ // Parse the RID to extract OS and architecture
+ // RID format: {os}[-{version}][-{qualifier}]-{arch}
+ // Examples: win-x64, win10-x64, linux-x64, linux-musl-x64, osx.12-arm64
+
+ if (primaryRid.StartsWith("win", StringComparison.OrdinalIgnoreCase))
+ {
+ // Windows compatibility chain
+ // win10-x64 -> win-x64 -> win -> any
+ string arch = ExtractArchitecture(primaryRid);
+ if (arch != null)
+ {
+ string genericWinRid = $"win-{arch}";
+ if (!string.Equals(primaryRid, genericWinRid, StringComparison.OrdinalIgnoreCase))
+ {
+ compatibleRids.Add(genericWinRid);
+ }
+ compatibleRids.Add("win");
+ compatibleRids.Add("any");
+ }
+ else
+ {
+ // Just "win" folder without architecture
+ compatibleRids.Add("any");
+ }
+ }
+ else if (primaryRid.StartsWith("linux-musl", StringComparison.OrdinalIgnoreCase))
+ {
+ // Alpine/musl Linux compatibility chain
+ // linux-musl-x64 -> linux-x64 -> unix -> any
+ string arch = ExtractArchitecture(primaryRid);
+ if (arch != null)
+ {
+ compatibleRids.Add($"linux-{arch}");
+ compatibleRids.Add("linux");
+ compatibleRids.Add("unix");
+ compatibleRids.Add("any");
+ }
+ }
+ else if (primaryRid.StartsWith("linux", StringComparison.OrdinalIgnoreCase))
+ {
+ // Linux compatibility chain
+ // linux-x64 -> linux -> unix -> any
+ // linux-armel -> linux-arm -> linux -> unix -> any
+ string arch = ExtractArchitecture(primaryRid);
+ if (arch != null)
+ {
+ // armel (ARM EABI soft-float) is compatible with arm
+ if (string.Equals(arch, "armel", StringComparison.OrdinalIgnoreCase))
+ {
+ compatibleRids.Add("linux-arm");
+ }
+ compatibleRids.Add("linux");
+ compatibleRids.Add("unix");
+ compatibleRids.Add("any");
+ }
+ }
+ else if (primaryRid.StartsWith("maccatalyst", StringComparison.OrdinalIgnoreCase))
+ {
+ // Mac Catalyst compatibility chain (iOS apps on Mac)
+ // maccatalyst-arm64 -> osx-arm64 -> osx -> unix -> any
+ string arch = ExtractArchitecture(primaryRid);
+ if (arch != null)
+ {
+ compatibleRids.Add($"osx-{arch}");
+ compatibleRids.Add("osx");
+ compatibleRids.Add("unix");
+ compatibleRids.Add("any");
+ }
+ }
+ else if (primaryRid.StartsWith("osx", StringComparison.OrdinalIgnoreCase))
+ {
+ // macOS compatibility chain
+ // osx.12-arm64 -> osx-arm64 -> osx -> unix -> any
+ string arch = ExtractArchitecture(primaryRid);
+ if (arch != null)
+ {
+ string genericOsxRid = $"osx-{arch}";
+ if (!string.Equals(primaryRid, genericOsxRid, StringComparison.OrdinalIgnoreCase))
+ {
+ compatibleRids.Add(genericOsxRid);
+ }
+ compatibleRids.Add("osx");
+ compatibleRids.Add("unix");
+ compatibleRids.Add("any");
+ }
+ else
+ {
+ // Just "osx" folder without architecture
+ compatibleRids.Add("unix");
+ compatibleRids.Add("any");
+ }
+ }
+ else if (primaryRid.StartsWith("browser-wasm", StringComparison.OrdinalIgnoreCase))
+ {
+ // Browser WebAssembly - not compatible with native platforms
+ compatibleRids.Add("any");
+ }
+ else if (primaryRid.StartsWith("unix", StringComparison.OrdinalIgnoreCase))
+ {
+ // Generic Unix compatibility chain
+ compatibleRids.Add("any");
+ }
+ else
+ {
+ // Unknown RID, just add "any" as fallback
+ compatibleRids.Add("any");
+ }
+
+ return compatibleRids;
+ }
+
+ ///
+ /// Extracts the architecture from a RID string.
+ ///
+ /// The RID string.
+ /// The architecture portion of the RID, or null if not found.
+ private static string ExtractArchitecture(string rid)
+ {
+ // Known architectures - order matters, longer names first to avoid partial matches
+ string[] knownArchitectures = new[]
+ {
+ "loongarch64", "ppc64le", "mips64", "s390x", "arm64", "armel", "wasm", "arm", "x64", "x86"
+ };
+
+ // Split RID by '-' and check for architecture at the end
+ string[] parts = rid.Split('-');
+ if (parts.Length >= 2)
+ {
+ string lastPart = parts[parts.Length - 1];
+ foreach (string arch in knownArchitectures)
+ {
+ if (string.Equals(lastPart, arch, StringComparison.OrdinalIgnoreCase))
+ {
+ return arch;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ #endregion
+ }
+}
diff --git a/src/code/RuntimePackageHelper.cs b/src/code/RuntimePackageHelper.cs
new file mode 100644
index 000000000..77201f199
--- /dev/null
+++ b/src/code/RuntimePackageHelper.cs
@@ -0,0 +1,180 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+
+namespace Microsoft.PowerShell.PSResourceGet.UtilClasses
+{
+ ///
+ /// Helper class for parsing package runtime assets and filtering during extraction.
+ /// Provides functionality to filter runtime-specific assets based on the current platform's RID.
+ ///
+ internal static class RuntimePackageHelper
+ {
+ #region Constants
+
+ ///
+ /// The name of the runtimes folder in NuGet packages.
+ ///
+ private const string RuntimesFolderName = "runtimes";
+
+ ///
+ /// Path separator used in zip archives.
+ ///
+ private const char ZipPathSeparator = '/';
+
+ #endregion
+
+ #region Public Methods
+
+ ///
+ /// Checks if a zip entry path is within the runtimes folder.
+ ///
+ /// The full path of the zip entry.
+ /// True if the entry is in the runtimes folder; otherwise, false.
+ public static bool IsRuntimesEntry(string entryFullName)
+ {
+ if (string.IsNullOrEmpty(entryFullName))
+ {
+ return false;
+ }
+
+ // Normalize path separators for comparison
+ string normalizedPath = entryFullName.Replace('\\', ZipPathSeparator);
+
+ return normalizedPath.StartsWith(RuntimesFolderName + ZipPathSeparator, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Extracts the RID from a runtimes folder entry path.
+ ///
+ /// The full path of the zip entry (e.g., "runtimes/win-x64/native/file.dll").
+ /// The RID (e.g., "win-x64"), or null if not a runtimes entry.
+ public static string GetRidFromRuntimesEntry(string entryFullName)
+ {
+ if (!IsRuntimesEntry(entryFullName))
+ {
+ return null;
+ }
+
+ // Normalize path separators
+ string normalizedPath = entryFullName.Replace('\\', ZipPathSeparator);
+
+ // Path format: runtimes/{rid}/...
+ string[] parts = normalizedPath.Split(ZipPathSeparator);
+
+ if (parts.Length >= 2)
+ {
+ return parts[1]; // The RID is the second segment
+ }
+
+ return null;
+ }
+
+ ///
+ /// Determines if a zip entry should be included based on the current platform's RID.
+ ///
+ /// The full path of the zip entry.
+ /// True if the entry should be included; otherwise, false.
+ public static bool ShouldIncludeEntry(string entryFullName)
+ {
+ // Non-runtimes entries are always included
+ if (!IsRuntimesEntry(entryFullName))
+ {
+ return true;
+ }
+
+ string entryRid = GetRidFromRuntimesEntry(entryFullName);
+
+ if (string.IsNullOrEmpty(entryRid))
+ {
+ // If we can't determine the RID, include the entry to be safe
+ return true;
+ }
+
+ // Check if this RID is compatible with the current platform
+ return RuntimeIdentifierHelper.IsCompatibleRid(entryRid);
+ }
+
+ ///
+ /// Determines if a zip entry should be included based on an explicit target RID.
+ /// Used when the user specifies -RuntimeIdentifier for cross-platform deployment.
+ ///
+ /// The full path of the zip entry.
+ /// The target RID to filter for.
+ /// True if the entry should be included; otherwise, false.
+ public static bool ShouldIncludeEntry(string entryFullName, string targetRid)
+ {
+ // Non-runtimes entries are always included
+ if (!IsRuntimesEntry(entryFullName))
+ {
+ return true;
+ }
+
+ string entryRid = GetRidFromRuntimesEntry(entryFullName);
+
+ if (string.IsNullOrEmpty(entryRid))
+ {
+ return true;
+ }
+
+ // Check if this RID is compatible with the specified target
+ return RuntimeIdentifierHelper.IsCompatibleRid(entryRid, targetRid);
+ }
+
+ ///
+ /// Gets a list of all unique RIDs present in a zip archive's runtimes folder.
+ ///
+ /// The zip archive to scan.
+ /// A list of unique RIDs found in the archive.
+ public static IReadOnlyList GetAvailableRidsFromArchive(ZipArchive archive)
+ {
+ if (archive == null)
+ {
+ throw new ArgumentNullException(nameof(archive));
+ }
+
+ var rids = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (ZipArchiveEntry entry in archive.Entries)
+ {
+ string rid = GetRidFromRuntimesEntry(entry.FullName);
+ if (!string.IsNullOrEmpty(rid))
+ {
+ rids.Add(rid);
+ }
+ }
+
+ return rids.ToList();
+ }
+
+ ///
+ /// Gets a list of all unique RIDs present in a zip file's runtimes folder.
+ ///
+ /// The path to the zip file.
+ /// A list of unique RIDs found in the archive.
+ public static IReadOnlyList GetAvailableRidsFromZipFile(string zipPath)
+ {
+ if (string.IsNullOrEmpty(zipPath))
+ {
+ throw new ArgumentException("Zip path cannot be null or empty.", nameof(zipPath));
+ }
+
+ if (!File.Exists(zipPath))
+ {
+ throw new FileNotFoundException("Zip file not found.", zipPath);
+ }
+
+ using (ZipArchive archive = ZipFile.OpenRead(zipPath))
+ {
+ return GetAvailableRidsFromArchive(archive);
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/test/PlatformFilteringTests/PlatformAwareInstall.Tests.ps1 b/test/PlatformFilteringTests/PlatformAwareInstall.Tests.ps1
new file mode 100644
index 000000000..5da7aa979
--- /dev/null
+++ b/test/PlatformFilteringTests/PlatformAwareInstall.Tests.ps1
@@ -0,0 +1,418 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+# Integration tests for platform-aware installation:
+# - RID filtering of runtimes/ folder
+# - TFM filtering of lib/ folder
+# - .nuspec retention post-install
+# - NuspecReader-based dependency parsing
+# - SkipRuntimeFiltering parameter
+#
+# NOTE: These tests require the built module to be deployed on PSModulePath.
+# Run the project build script (e.g., build.ps1) before executing these tests.
+# The unit tests in RuntimeIdentifierHelper.Tests.ps1 and RuntimePackageHelper.Tests.ps1
+# can be run directly after 'dotnet build' by loading the DLL.
+
+[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')]
+Param()
+
+$ProgressPreference = "SilentlyContinue"
+$modPath = "$psscriptroot/../PSGetTestUtils.psm1"
+Import-Module $modPath -Force -Verbose
+
+
+Describe 'Platform-Aware Installation Integration Tests' -tags 'CI' {
+
+ BeforeAll {
+ # Helper: Creates a minimal .nupkg with configurable runtimes/, lib/ TFMs, and .nuspec dependencies.
+ function New-TestNupkg {
+ param(
+ [string]$Name,
+ [string]$Version,
+ [string]$OutputDir,
+ [string[]]$RuntimeIdentifiers = @(),
+ [string[]]$LibTfms = @(),
+ [hashtable[]]$Dependencies = @(),
+ [switch]$IncludeModuleManifest
+ )
+
+ $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString())
+ $null = New-Item $tempDir -ItemType Directory -Force
+
+ try {
+ # Create runtimes/ subdirectories with dummy native files
+ foreach ($rid in $RuntimeIdentifiers) {
+ $nativeDir = Join-Path $tempDir "runtimes/$rid/native"
+ $null = New-Item $nativeDir -ItemType Directory -Force
+ Set-Content -Path (Join-Path $nativeDir "native_$rid.dll") -Value "native-binary-for-$rid"
+ }
+
+ # Create lib/ subdirectories with dummy assemblies
+ foreach ($tfm in $LibTfms) {
+ $libDir = Join-Path $tempDir "lib/$tfm"
+ $null = New-Item $libDir -ItemType Directory -Force
+ Set-Content -Path (Join-Path $libDir "$Name.dll") -Value "assembly-for-$tfm"
+ }
+
+ # Build .nuspec XML with dependencies
+ $depGroupsXml = ""
+ if ($Dependencies.Count -gt 0) {
+ $depEntriesXml = ""
+ foreach ($dep in $Dependencies) {
+ $depEntriesXml += " `n"
+ }
+ $depGroupsXml = @"
+
+
+$depEntriesXml
+
+"@
+ }
+
+ $nuspecContent = @"
+
+
+
+ $Name
+ $Version
+ TestAuthor
+ Test package for platform-aware install tests
+ PSModule test
+ $depGroupsXml
+
+
+"@
+ Set-Content -Path (Join-Path $tempDir "$Name.nuspec") -Value $nuspecContent
+
+ # Optionally create a minimal .psd1 module manifest
+ if ($IncludeModuleManifest) {
+ $psd1Content = @"
+@{
+ ModuleVersion = '$Version'
+ Author = 'TestAuthor'
+ Description = 'Test module for platform-aware install tests'
+ GUID = '$([Guid]::NewGuid().ToString())'
+ FunctionsToExport = @()
+ CmdletsToExport = @()
+ PrivateData = @{
+ PSData = @{
+ Tags = @('test')
+ }
+ }
+}
+"@
+ Set-Content -Path (Join-Path $tempDir "$Name.psd1") -Value $psd1Content
+ }
+
+ # Create .nupkg (zip)
+ $nupkgPath = Join-Path $OutputDir "$Name.$Version.nupkg"
+ if (Test-Path $nupkgPath) { Remove-Item $nupkgPath -Force }
+ [System.IO.Compression.ZipFile]::CreateFromDirectory($tempDir, $nupkgPath)
+ return $nupkgPath
+ }
+ finally {
+ Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue
+ }
+ }
+
+ $InternalHooks = [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]
+
+ # Helper: Resolves the version-specific install directory from a PSResourceInfo object.
+ # InstalledLocation may be the Modules root or the version folder depending on context.
+ function Get-VersionInstallPath {
+ param([object]$PkgInfo)
+ $base = $PkgInfo.InstalledLocation
+ $versionPath = Join-Path $base $PkgInfo.Name $PkgInfo.Version.ToString()
+ if (Test-Path $versionPath) { return $versionPath }
+ # Maybe InstalledLocation already points to name/version
+ if ($base -match "$([regex]::Escape($PkgInfo.Name))[\\/]$([regex]::Escape($PkgInfo.Version.ToString()))$") { return $base }
+ # Try name folder only
+ $namePath = Join-Path $base $PkgInfo.Name
+ if (Test-Path $namePath) { return $namePath }
+ return $base
+ }
+
+ # Set up a local repository for test packages
+ $script:localRepoDir = Join-Path $TestDrive 'platformFilterRepo'
+ $null = New-Item $localRepoDir -ItemType Directory -Force
+
+ $script:localRepoName = 'PlatformFilterTestRepo'
+ Register-PSResourceRepository -Name $localRepoName -Uri $localRepoDir -Trusted -Force -ErrorAction SilentlyContinue
+
+ $script:currentRid = $InternalHooks::GetCurrentRuntimeIdentifier()
+ }
+
+ AfterAll {
+ Unregister-PSResourceRepository -Name $script:localRepoName -ErrorAction SilentlyContinue
+ }
+
+
+ Context 'RID Filtering during Install' {
+
+ BeforeAll {
+ $script:ridPkgName = 'TestRidFilterModule'
+ $script:ridPkgVersion = '1.0.0'
+
+ # Create test nupkg with runtimes for multiple platforms
+ New-TestNupkg -Name $ridPkgName -Version $ridPkgVersion `
+ -OutputDir $localRepoDir `
+ -RuntimeIdentifiers @('win-x64', 'win-x86', 'linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64') `
+ -LibTfms @('netstandard2.0') `
+ -IncludeModuleManifest
+ }
+
+ AfterEach {
+ Uninstall-PSResource $ridPkgName -Version "*" -ErrorAction SilentlyContinue
+ }
+
+ It "Should only install runtimes matching current platform" {
+ Install-PSResource -Name $ridPkgName -Repository $localRepoName -TrustRepository -Version $ridPkgVersion
+ $installed = Get-InstalledPSResource -Name $ridPkgName
+ $installed | Should -Not -BeNullOrEmpty
+
+ $installPath = Get-VersionInstallPath $installed
+ $runtimesDir = Join-Path $installPath 'runtimes'
+
+ if (Test-Path $runtimesDir) {
+ $installedRidFolders = @((Get-ChildItem $runtimesDir -Directory).Name)
+
+ # Current platform should be present
+ $installedRidFolders | Should -Contain $currentRid
+
+ # Foreign platforms should NOT be present
+ $foreignRids = @('win-x64', 'linux-x64', 'osx-arm64') | Where-Object {
+ -not $InternalHooks::IsCompatibleRid($_)
+ }
+
+ foreach ($foreign in $foreignRids) {
+ $installedRidFolders | Should -Not -Contain $foreign
+ }
+ }
+ }
+
+ It "Should install all runtimes when -SkipRuntimeFiltering is specified" {
+ Install-PSResource -Name $ridPkgName -Repository $localRepoName -TrustRepository -Version $ridPkgVersion -SkipRuntimeFiltering
+ $installed = Get-InstalledPSResource -Name $ridPkgName
+ $installed | Should -Not -BeNullOrEmpty
+
+ $installPath = Get-VersionInstallPath $installed
+ $runtimesDir = Join-Path $installPath 'runtimes'
+
+ if (Test-Path $runtimesDir) {
+ $installedRidFolders = @((Get-ChildItem $runtimesDir -Directory).Name)
+ # All 6 RID folders should be present
+ $installedRidFolders.Count | Should -Be 6
+ }
+ }
+ }
+
+
+ Context 'TFM Filtering during Install' {
+
+ BeforeAll {
+ $script:tfmPkgName = 'TestTfmFilterModule'
+ $script:tfmPkgVersion = '1.0.0'
+
+ # Create test nupkg with multiple lib/ TFMs
+ New-TestNupkg -Name $tfmPkgName -Version $tfmPkgVersion `
+ -OutputDir $localRepoDir `
+ -LibTfms @('net472', 'netstandard2.0', 'net6.0', 'net8.0') `
+ -IncludeModuleManifest
+ }
+
+ AfterEach {
+ Uninstall-PSResource $tfmPkgName -Version "*" -ErrorAction SilentlyContinue
+ }
+
+ It "Should only install one TFM lib folder (the best match)" {
+ Install-PSResource -Name $tfmPkgName -Repository $localRepoName -TrustRepository -Version $tfmPkgVersion
+ $installed = Get-InstalledPSResource -Name $tfmPkgName
+ $installed | Should -Not -BeNullOrEmpty
+
+ $installPath = Get-VersionInstallPath $installed
+ $libDir = Join-Path $installPath 'lib'
+
+ if (Test-Path $libDir) {
+ $installedTfmFolders = @((Get-ChildItem $libDir -Directory).Name)
+ # Should have exactly 1 TFM folder (the best match)
+ $installedTfmFolders.Count | Should -Be 1
+
+ # The chosen TFM should be one of the valid ones
+ $installedTfmFolders[0] | Should -BeIn @('net472', 'netstandard2.0', 'net6.0', 'net8.0')
+ }
+ }
+
+ It "Should pick net472 on Windows PowerShell 5.1" -Skip:($PSVersionTable.PSVersion.Major -gt 5) {
+ Install-PSResource -Name $tfmPkgName -Repository $localRepoName -TrustRepository -Version $tfmPkgVersion
+ $installed = Get-InstalledPSResource -Name $tfmPkgName
+
+ $installPath = Get-VersionInstallPath $installed
+ $libDir = Join-Path $installPath 'lib'
+ if (Test-Path $libDir) {
+ $installedTfmFolders = @((Get-ChildItem $libDir -Directory).Name)
+ $installedTfmFolders | Should -Contain 'net472'
+ }
+ }
+
+ It "Should pick a .NET Core TFM on PowerShell 7+" -Skip:($PSVersionTable.PSVersion.Major -le 5) {
+ Install-PSResource -Name $tfmPkgName -Repository $localRepoName -TrustRepository -Version $tfmPkgVersion
+ $installed = Get-InstalledPSResource -Name $tfmPkgName
+
+ $installPath = Get-VersionInstallPath $installed
+ $libDir = Join-Path $installPath 'lib'
+ if (Test-Path $libDir) {
+ $installedTfmFolders = @((Get-ChildItem $libDir -Directory).Name)
+ # On PS 7+ (net6.0 or net8.0), should NOT pick net472
+ $installedTfmFolders | Should -Not -Contain 'net472'
+ }
+ }
+ }
+
+
+ Context 'Explicit RuntimeIdentifier Parameter' {
+
+ BeforeAll {
+ $script:ridOverridePkgName = 'TestRidOverrideModule'
+ $script:ridOverridePkgVersion = '1.0.0'
+
+ New-TestNupkg -Name $ridOverridePkgName -Version $ridOverridePkgVersion `
+ -OutputDir $localRepoDir `
+ -RuntimeIdentifiers @('win-x64', 'win-x86', 'linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64') `
+ -LibTfms @('netstandard2.0') `
+ -IncludeModuleManifest
+ }
+
+ AfterEach {
+ Uninstall-PSResource $ridOverridePkgName -Version "*" -ErrorAction SilentlyContinue
+ }
+
+ It "Should install only the specified RID when -RuntimeIdentifier is used" {
+ Install-PSResource -Name $ridOverridePkgName -Repository $localRepoName -TrustRepository -Version $ridOverridePkgVersion -RuntimeIdentifier 'linux-x64'
+ $installed = Get-InstalledPSResource -Name $ridOverridePkgName
+ $installed | Should -Not -BeNullOrEmpty
+
+ $installPath = Get-VersionInstallPath $installed
+ $runtimesDir = Join-Path $installPath 'runtimes'
+
+ if (Test-Path $runtimesDir) {
+ $installedRidFolders = @((Get-ChildItem $runtimesDir -Directory).Name)
+ $installedRidFolders | Should -Contain 'linux-x64'
+ # Other platforms should NOT be present
+ $installedRidFolders | Should -Not -Contain 'win-x86'
+ $installedRidFolders | Should -Not -Contain 'osx-arm64'
+ }
+ }
+
+ It "Should override auto-detection with -RuntimeIdentifier" {
+ # Install for a foreign platform
+ $foreignRid = if ($IsWindows) { 'osx-arm64' } elseif ($IsMacOS) { 'linux-x64' } else { 'win-x64' }
+ Install-PSResource -Name $ridOverridePkgName -Repository $localRepoName -TrustRepository -Version $ridOverridePkgVersion -RuntimeIdentifier $foreignRid
+ $installed = Get-InstalledPSResource -Name $ridOverridePkgName
+ $installed | Should -Not -BeNullOrEmpty
+
+ $installPath = Get-VersionInstallPath $installed
+ $runtimesDir = Join-Path $installPath 'runtimes'
+
+ if (Test-Path $runtimesDir) {
+ $installedRidFolders = @((Get-ChildItem $runtimesDir -Directory).Name)
+ $installedRidFolders | Should -Contain $foreignRid
+ }
+ }
+ }
+
+
+ Context 'Explicit TargetFramework Parameter' {
+
+ BeforeAll {
+ $script:tfmOverridePkgName = 'TestTfmOverrideModule'
+ $script:tfmOverridePkgVersion = '1.0.0'
+
+ New-TestNupkg -Name $tfmOverridePkgName -Version $tfmOverridePkgVersion `
+ -OutputDir $localRepoDir `
+ -LibTfms @('net472', 'netstandard2.0', 'net6.0', 'net8.0') `
+ -IncludeModuleManifest
+ }
+
+ AfterEach {
+ Uninstall-PSResource $tfmOverridePkgName -Version "*" -ErrorAction SilentlyContinue
+ }
+
+ It "Should install only the specified TFM when -TargetFramework is used" {
+ Install-PSResource -Name $tfmOverridePkgName -Repository $localRepoName -TrustRepository -Version $tfmOverridePkgVersion -TargetFramework 'net6.0'
+ $installed = Get-InstalledPSResource -Name $tfmOverridePkgName
+ $installed | Should -Not -BeNullOrEmpty
+
+ $installPath = Get-VersionInstallPath $installed
+ $libDir = Join-Path $installPath 'lib'
+
+ if (Test-Path $libDir) {
+ $installedTfmFolders = @((Get-ChildItem $libDir -Directory).Name)
+ $installedTfmFolders.Count | Should -Be 1
+ $installedTfmFolders[0] | Should -Be 'net6.0'
+ }
+ }
+
+ It "Should override auto-detection with -TargetFramework net472" {
+ Install-PSResource -Name $tfmOverridePkgName -Repository $localRepoName -TrustRepository -Version $tfmOverridePkgVersion -TargetFramework 'net472'
+ $installed = Get-InstalledPSResource -Name $tfmOverridePkgName
+ $installed | Should -Not -BeNullOrEmpty
+
+ $installPath = Get-VersionInstallPath $installed
+ $libDir = Join-Path $installPath 'lib'
+
+ if (Test-Path $libDir) {
+ $installedTfmFolders = @((Get-ChildItem $libDir -Directory).Name)
+ $installedTfmFolders.Count | Should -Be 1
+ $installedTfmFolders[0] | Should -Be 'net472'
+ }
+ }
+
+ It "Should allow combining -RuntimeIdentifier and -TargetFramework" {
+ Install-PSResource -Name $tfmOverridePkgName -Repository $localRepoName -TrustRepository -Version $tfmOverridePkgVersion -TargetFramework 'net6.0' -RuntimeIdentifier 'linux-x64'
+ $installed = Get-InstalledPSResource -Name $tfmOverridePkgName
+ $installed | Should -Not -BeNullOrEmpty
+
+ # This test verifies the command accepts both parameters together without error
+ $installPath = Get-VersionInstallPath $installed
+ $libDir = Join-Path $installPath 'lib'
+
+ if (Test-Path $libDir) {
+ $installedTfmFolders = @((Get-ChildItem $libDir -Directory).Name)
+ $installedTfmFolders.Count | Should -Be 1
+ $installedTfmFolders[0] | Should -Be 'net6.0'
+ }
+ }
+ }
+
+ Context 'Nuspec Dependency Parsing' {
+
+ BeforeAll {
+ $script:depPkgName = 'TestNuspecDepsModule'
+ $script:depPkgVersion = '1.0.0'
+
+ # Create a package with .nuspec dependencies
+ New-TestNupkg -Name $depPkgName -Version $depPkgVersion `
+ -OutputDir $localRepoDir `
+ -LibTfms @('netstandard2.0') `
+ -Dependencies @(
+ @{ Id = 'Newtonsoft.Json'; Version = '[13.0.1, )' },
+ @{ Id = 'System.Memory'; Version = '[4.5.4, )' }
+ ) `
+ -IncludeModuleManifest
+ }
+
+ It "Should parse dependencies from .nuspec for local repo find" {
+ # Find should return package info with parsed dependencies
+ $found = Find-PSResource -Name $depPkgName -Repository $localRepoName -Version $depPkgVersion
+ $found | Should -Not -BeNullOrEmpty
+ $found.Name | Should -Be $depPkgName
+
+ # Dependencies should be populated (not empty)
+ if ($found.Dependencies -and $found.Dependencies.Count -gt 0) {
+ $depNames = $found.Dependencies | ForEach-Object { $_.Name }
+ $depNames | Should -Contain 'Newtonsoft.Json'
+ $depNames | Should -Contain 'System.Memory'
+ }
+ }
+ }
+}
diff --git a/test/PlatformFilteringTests/RuntimeIdentifierHelper.Tests.ps1 b/test/PlatformFilteringTests/RuntimeIdentifierHelper.Tests.ps1
new file mode 100644
index 000000000..621d19910
--- /dev/null
+++ b/test/PlatformFilteringTests/RuntimeIdentifierHelper.Tests.ps1
@@ -0,0 +1,173 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')]
+Param()
+
+$ProgressPreference = "SilentlyContinue"
+$modPath = "$psscriptroot/../PSGetTestUtils.psm1"
+Import-Module $modPath -Force -Verbose
+
+Describe 'RuntimeIdentifierHelper Tests' -tags 'CI' {
+
+ BeforeAll {
+ $InternalHooks = [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]
+ }
+
+ Context 'GetCurrentRuntimeIdentifier' {
+
+ It "Should return a non-empty RID string" {
+ $rid = $InternalHooks::GetCurrentRuntimeIdentifier()
+ $rid | Should -Not -BeNullOrEmpty
+ }
+
+ It "Should return a RID matching the expected platform prefix" {
+ $rid = $InternalHooks::GetCurrentRuntimeIdentifier()
+ if ($IsWindows -or ($PSVersionTable.PSVersion.Major -eq 5)) {
+ $rid | Should -Match '^win-'
+ }
+ elseif ($IsLinux) {
+ $rid | Should -Match '^linux(-musl)?-'
+ }
+ elseif ($IsMacOS) {
+ $rid | Should -Match '^osx-'
+ }
+ }
+
+ It "Should return a RID with a known architecture suffix" {
+ $rid = $InternalHooks::GetCurrentRuntimeIdentifier()
+ $rid | Should -Match '-(x64|x86|arm64|arm|s390x|ppc64le|loongarch64)$'
+ }
+
+ It "Should return consistent results on repeated calls (caching)" {
+ $rid1 = $InternalHooks::GetCurrentRuntimeIdentifier()
+ $rid2 = $InternalHooks::GetCurrentRuntimeIdentifier()
+ $rid1 | Should -Be $rid2
+ }
+ }
+
+ Context 'GetCompatibleRuntimeIdentifiers' {
+
+ It "Should return a non-empty list" {
+ $rids = $InternalHooks::GetCompatibleRuntimeIdentifiers()
+ $rids.Count | Should -BeGreaterThan 0
+ }
+
+ It "Should include the current RID as the first entry" {
+ $currentRid = $InternalHooks::GetCurrentRuntimeIdentifier()
+ $rids = $InternalHooks::GetCompatibleRuntimeIdentifiers()
+ $rids[0] | Should -Be $currentRid
+ }
+
+ It "Should include 'any' in the compatibility chain" {
+ $rids = $InternalHooks::GetCompatibleRuntimeIdentifiers()
+ $rids | Should -Contain 'any'
+ }
+ }
+
+ Context 'GetCompatibleRuntimeIdentifiersFor - Windows' {
+
+ It "Should build compatibility chain for win-x64" {
+ $rids = $InternalHooks::GetCompatibleRuntimeIdentifiersFor('win-x64')
+ $rids | Should -Contain 'win-x64'
+ $rids | Should -Contain 'win'
+ $rids | Should -Contain 'any'
+ }
+
+ It "Should build compatibility chain for win10-x64" {
+ $rids = $InternalHooks::GetCompatibleRuntimeIdentifiersFor('win10-x64')
+ $rids | Should -Contain 'win10-x64'
+ $rids | Should -Contain 'win-x64'
+ $rids | Should -Contain 'win'
+ $rids | Should -Contain 'any'
+ }
+
+ It "Should build compatibility chain for win-arm64" {
+ $rids = $InternalHooks::GetCompatibleRuntimeIdentifiersFor('win-arm64')
+ $rids | Should -Contain 'win-arm64'
+ $rids | Should -Contain 'win'
+ $rids | Should -Contain 'any'
+ }
+ }
+
+ Context 'GetCompatibleRuntimeIdentifiersFor - Linux' {
+
+ It "Should build compatibility chain for linux-x64" {
+ $rids = $InternalHooks::GetCompatibleRuntimeIdentifiersFor('linux-x64')
+ $rids | Should -Contain 'linux-x64'
+ $rids | Should -Contain 'linux'
+ $rids | Should -Contain 'unix'
+ $rids | Should -Contain 'any'
+ }
+
+ It "Should build compatibility chain for linux-musl-x64" {
+ $rids = $InternalHooks::GetCompatibleRuntimeIdentifiersFor('linux-musl-x64')
+ $rids | Should -Contain 'linux-musl-x64'
+ $rids | Should -Contain 'linux-x64'
+ $rids | Should -Contain 'unix'
+ $rids | Should -Contain 'any'
+ }
+ }
+
+ Context 'GetCompatibleRuntimeIdentifiersFor - macOS' {
+
+ It "Should build compatibility chain for osx-arm64" {
+ $rids = $InternalHooks::GetCompatibleRuntimeIdentifiersFor('osx-arm64')
+ $rids | Should -Contain 'osx-arm64'
+ $rids | Should -Contain 'osx'
+ $rids | Should -Contain 'unix'
+ $rids | Should -Contain 'any'
+ }
+
+ It "Should build compatibility chain for osx.12-arm64" {
+ $rids = $InternalHooks::GetCompatibleRuntimeIdentifiersFor('osx.12-arm64')
+ $rids | Should -Contain 'osx.12-arm64'
+ $rids | Should -Contain 'osx-arm64'
+ $rids | Should -Contain 'osx'
+ $rids | Should -Contain 'unix'
+ $rids | Should -Contain 'any'
+ }
+ }
+
+ Context 'IsCompatibleRid' {
+
+ It "Should return true for the current platform RID" {
+ $currentRid = $InternalHooks::GetCurrentRuntimeIdentifier()
+ $InternalHooks::IsCompatibleRid($currentRid) | Should -BeTrue
+ }
+
+ It "Should return true for 'any'" {
+ $InternalHooks::IsCompatibleRid('any') | Should -BeTrue
+ }
+
+ It "Should return false for null or empty" {
+ $InternalHooks::IsCompatibleRid($null) | Should -BeFalse
+ $InternalHooks::IsCompatibleRid('') | Should -BeFalse
+ }
+
+ It "Should return false for a clearly incompatible RID" {
+ $currentRid = $InternalHooks::GetCurrentRuntimeIdentifier()
+ # Pick an OS that is definitely not the current one
+ if ($currentRid -match '^win') {
+ $InternalHooks::IsCompatibleRid('osx-arm64') | Should -BeFalse
+ }
+ elseif ($currentRid -match '^linux') {
+ $InternalHooks::IsCompatibleRid('win-x64') | Should -BeFalse
+ }
+ elseif ($currentRid -match '^osx') {
+ $InternalHooks::IsCompatibleRid('win-x64') | Should -BeFalse
+ }
+ }
+
+ It "Should return true for a more-specific RID in the same platform family" {
+ $currentRid = $InternalHooks::GetCurrentRuntimeIdentifier()
+ if ($currentRid -eq 'win-x64') {
+ # win10-x64 package folder should be compatible on a win-x64 machine
+ $InternalHooks::IsCompatibleRid('win10-x64') | Should -BeTrue
+ }
+ elseif ($currentRid -eq 'osx-arm64') {
+ $InternalHooks::IsCompatibleRid('osx.12-arm64') | Should -BeTrue
+ }
+ }
+ }
+}
diff --git a/test/PlatformFilteringTests/RuntimePackageHelper.Tests.ps1 b/test/PlatformFilteringTests/RuntimePackageHelper.Tests.ps1
new file mode 100644
index 000000000..42708360a
--- /dev/null
+++ b/test/PlatformFilteringTests/RuntimePackageHelper.Tests.ps1
@@ -0,0 +1,164 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')]
+Param()
+
+$ProgressPreference = "SilentlyContinue"
+$modPath = "$psscriptroot/../PSGetTestUtils.psm1"
+Import-Module $modPath -Force -Verbose
+
+Describe 'RuntimePackageHelper Tests' -tags 'CI' {
+
+ BeforeAll {
+ $InternalHooks = [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]
+ }
+
+ Context 'IsRuntimesEntry' {
+
+ It "Should return true for a runtimes/ path" {
+ $InternalHooks::IsRuntimesEntry('runtimes/win-x64/native/file.dll') | Should -BeTrue
+ }
+
+ It "Should return true for runtimes/ path with backslashes" {
+ $InternalHooks::IsRuntimesEntry('runtimes\win-x64\native\file.dll') | Should -BeTrue
+ }
+
+ It "Should return false for a lib/ path" {
+ $InternalHooks::IsRuntimesEntry('lib/net472/MyLib.dll') | Should -BeFalse
+ }
+
+ It "Should return false for null" {
+ $InternalHooks::IsRuntimesEntry($null) | Should -BeFalse
+ }
+
+ It "Should return false for empty string" {
+ $InternalHooks::IsRuntimesEntry('') | Should -BeFalse
+ }
+
+ It "Should return false for a path that only contains 'runtimes' without separator" {
+ $InternalHooks::IsRuntimesEntry('runtimes') | Should -BeFalse
+ }
+
+ It "Should be case insensitive" {
+ $InternalHooks::IsRuntimesEntry('Runtimes/win-x64/native/file.dll') | Should -BeTrue
+ $InternalHooks::IsRuntimesEntry('RUNTIMES/win-x64/native/file.dll') | Should -BeTrue
+ }
+ }
+
+ Context 'GetRidFromRuntimesEntry' {
+
+ It "Should extract RID from a valid runtimes path" {
+ $InternalHooks::GetRidFromRuntimesEntry('runtimes/win-x64/native/file.dll') | Should -Be 'win-x64'
+ }
+
+ It "Should extract RID for linux-musl-x64" {
+ $InternalHooks::GetRidFromRuntimesEntry('runtimes/linux-musl-x64/lib/file.dll') | Should -Be 'linux-musl-x64'
+ }
+
+ It "Should extract RID for osx-arm64" {
+ $InternalHooks::GetRidFromRuntimesEntry('runtimes/osx-arm64/native/file.dylib') | Should -Be 'osx-arm64'
+ }
+
+ It "Should return null for non-runtimes path" {
+ $InternalHooks::GetRidFromRuntimesEntry('lib/net472/MyLib.dll') | Should -BeNullOrEmpty
+ }
+
+ It "Should return null for null input" {
+ $InternalHooks::GetRidFromRuntimesEntry($null) | Should -BeNullOrEmpty
+ }
+ }
+
+ Context 'ShouldIncludeEntry' {
+
+ It "Should include non-runtimes entries" {
+ $InternalHooks::ShouldIncludeEntry('lib/net472/MyLib.dll') | Should -BeTrue
+ $InternalHooks::ShouldIncludeEntry('content/readme.txt') | Should -BeTrue
+ $InternalHooks::ShouldIncludeEntry('MyModule.psd1') | Should -BeTrue
+ }
+
+ It "Should include runtimes entry for current platform" {
+ $currentRid = $InternalHooks::GetCurrentRuntimeIdentifier()
+ $InternalHooks::ShouldIncludeEntry("runtimes/$currentRid/native/file.dll") | Should -BeTrue
+ }
+
+ It "Should include runtimes entry for 'any' RID" {
+ # 'any' is always compatible
+ $InternalHooks::ShouldIncludeEntry("runtimes/any/lib/file.dll") | Should -BeTrue
+ }
+
+ It "Should exclude runtimes entry for incompatible platform" {
+ $currentRid = $InternalHooks::GetCurrentRuntimeIdentifier()
+ if ($currentRid -match '^win') {
+ $InternalHooks::ShouldIncludeEntry('runtimes/osx-arm64/native/file.dylib') | Should -BeFalse
+ $InternalHooks::ShouldIncludeEntry('runtimes/linux-x64/native/file.so') | Should -BeFalse
+ }
+ elseif ($currentRid -match '^linux') {
+ $InternalHooks::ShouldIncludeEntry('runtimes/win-x64/native/file.dll') | Should -BeFalse
+ $InternalHooks::ShouldIncludeEntry('runtimes/osx-arm64/native/file.dylib') | Should -BeFalse
+ }
+ elseif ($currentRid -match '^osx') {
+ $InternalHooks::ShouldIncludeEntry('runtimes/win-x64/native/file.dll') | Should -BeFalse
+ $InternalHooks::ShouldIncludeEntry('runtimes/linux-x64/native/file.so') | Should -BeFalse
+ }
+ }
+ }
+
+ Context 'GetAvailableRidsFromZipFile' {
+
+ BeforeAll {
+ # Create a test .nupkg (zip) with multiple runtimes folders
+ $script:testZipDir = Join-Path $TestDrive 'test-rids-zip'
+ $null = New-Item $testZipDir -ItemType Directory -Force
+
+ # Create runtimes folder structure
+ $rids = @('win-x64', 'win-x86', 'linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64')
+ foreach ($rid in $rids) {
+ $ridNativeDir = Join-Path $testZipDir "runtimes/$rid/native"
+ $null = New-Item $ridNativeDir -ItemType Directory -Force
+ Set-Content -Path (Join-Path $ridNativeDir "test.dll") -Value "dummy"
+ }
+
+ # Also create a non-runtimes file
+ Set-Content -Path (Join-Path $testZipDir "MyModule.psd1") -Value "dummy"
+
+ # Create the zip
+ $script:testZipPath = Join-Path $TestDrive 'test-rids.zip'
+ [System.IO.Compression.ZipFile]::CreateFromDirectory($testZipDir, $testZipPath)
+ }
+
+ It "Should list all RIDs present in the zip" {
+ $availableRids = $InternalHooks::GetAvailableRidsFromZipFile($testZipPath)
+ $availableRids.Count | Should -Be 6
+ $availableRids | Should -Contain 'win-x64'
+ $availableRids | Should -Contain 'linux-x64'
+ $availableRids | Should -Contain 'osx-arm64'
+ }
+
+ It "Should throw for non-existent file" {
+ { $InternalHooks::GetAvailableRidsFromZipFile('C:\nonexistent\file.zip') } | Should -Throw
+ }
+ }
+
+ Context 'Integration - ShouldIncludeEntry filters correctly for multi-platform package' {
+
+ It "Should include current platform and exclude others" {
+ $currentRid = $InternalHooks::GetCurrentRuntimeIdentifier()
+
+ # Entries that should be included
+ $InternalHooks::ShouldIncludeEntry("runtimes/$currentRid/native/file.dll") | Should -BeTrue
+ $InternalHooks::ShouldIncludeEntry("lib/net472/MyLib.dll") | Should -BeTrue
+ $InternalHooks::ShouldIncludeEntry("MyModule.psd1") | Should -BeTrue
+
+ # At least one of these foreign platforms should be excluded
+ $foreignPlatforms = @('win-x64', 'linux-x64', 'osx-arm64') | Where-Object { $_ -ne $currentRid }
+ $excludedCount = 0
+ foreach ($foreign in $foreignPlatforms) {
+ if (-not $InternalHooks::ShouldIncludeEntry("runtimes/$foreign/native/file.dll")) {
+ $excludedCount++
+ }
+ }
+ $excludedCount | Should -BeGreaterThan 0
+ }
+ }
+}