diff --git a/src/code/InstallHelper.cs b/src/code/InstallHelper.cs index 0616cf040..dc52f91fb 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,8 @@ internal class InstallHelper private bool _noClobber; private bool _authenticodeCheck; private bool _savePkg; + private string _runtimeIdentifier; + private string _targetFramework; List _pathsToSearch; List _pkgNamesToInstall; private string _tmpPath; @@ -91,7 +94,9 @@ public IEnumerable BeginInstallPackages( List pathsToInstallPkg, ScopeType? scope, string tmpPath, - HashSet pkgsInstalled) + HashSet pkgsInstalled, + 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 +138,8 @@ public IEnumerable BeginInstallPackages( _asNupkg = asNupkg; _includeXml = includeXml; _savePkg = savePkg; + _runtimeIdentifier = runtimeIdentifier; + _targetFramework = targetFramework; _pathsToInstallPkg = pathsToInstallPkg; _tmpPath = tmpPath ?? Path.GetTempPath(); @@ -1161,9 +1168,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: + /// - Root-level RID folder entries (e.g., win-x64/) based on the current platform's RID + /// - lib/{tfm}/ entries to only extract the best matching Target Framework Moniker /// private bool TryExtractToDirectory(string zipPath, string extractPath, out ErrorRecord error) { @@ -1182,8 +1192,56 @@ 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); + } + + // Warn if TFM filtering is skipping assemblies for a different .NET lineage + // (e.g., installing on PS7/.NET 8 but package also has net472 for WinPS 5.1) + if (bestLibFramework != null) + { + WarnIfCrossLineageTfmSkipped(archive, bestLibFramework); + } + foreach (ZipArchiveEntry entry in archive.Entries.Where(entry => entry.CompressedLength > 0)) { + // RID filtering: skip entries under incompatible platform RID folders + { + 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 +1283,222 @@ 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"); + } + + /// + /// Emits a warning if TFM filtering will skip assemblies for a different .NET lineage. + /// For example, when installing on PowerShell 7 (.NET Core), if the package also contains + /// .NET Framework assemblies (net472), those will be skipped — which means the module + /// won't work if later used on Windows PowerShell 5.1. + /// + private void WarnIfCrossLineageTfmSkipped(ZipArchive archive, NuGetFramework bestFramework) + { + try + { + bool bestIsNetCore = bestFramework.Framework.Equals(".NETCoreApp", StringComparison.OrdinalIgnoreCase); + bool bestIsNetFramework = bestFramework.Framework.Equals(".NETFramework", StringComparison.OrdinalIgnoreCase); + + // Only warn when we're on one lineage and skipping the other + if (!bestIsNetCore && !bestIsNetFramework) + { + return; + } + + // Scan lib/ folders for a TFM from the other lineage + var skippedLineageTfms = 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])) + { + NuGetFramework entryFw = NuGetFramework.ParseFolder(segments[1]); + if (entryFw != null && !entryFw.IsUnsupported && !entryFw.Equals(bestFramework)) + { + bool entryIsNetCore = entryFw.Framework.Equals(".NETCoreApp", StringComparison.OrdinalIgnoreCase); + bool entryIsNetFramework = entryFw.Framework.Equals(".NETFramework", StringComparison.OrdinalIgnoreCase); + + // Detect cross-lineage: we're on Core and skipping Framework, or vice versa + if ((bestIsNetCore && entryIsNetFramework) || (bestIsNetFramework && entryIsNetCore)) + { + skippedLineageTfms.Add(segments[1]); + } + } + } + } + } + + if (skippedLineageTfms.Count > 0) + { + string skippedList = string.Join(", ", skippedLineageTfms); + string otherHost = bestIsNetCore ? "Windows PowerShell 5.1" : "PowerShell 7+"; + _cmdletPassedIn.WriteWarning( + $"This package contains assemblies for {skippedList} which were not installed because " + + $"the current runtime selected {bestFramework.GetShortFolderName()}. " + + $"If you also use this module on {otherHost}, install it separately from that host."); + } + } + catch + { + // Non-critical warning — don't let it fail the install + } + } + /// /// 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..d45799983 100644 --- a/src/code/InstallPSResource.cs +++ b/src/code/InstallPSResource.cs @@ -138,6 +138,26 @@ public string TemporaryPath [Parameter] public SwitchParameter AuthenticodeCheck { 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 +617,9 @@ private void ProcessInstallHelper(string[] pkgNames, string pkgVersion, bool pkg pathsToInstallPkg: _pathsToInstallPkg, scope: scope, tmpPath: _tmpPath, - pkgsInstalled: _packagesOnMachine); + pkgsInstalled: _packagesOnMachine, + runtimeIdentifier: RuntimeIdentifier, + targetFramework: TargetFramework); if (PassThru) { diff --git a/src/code/InternalHooks.cs b/src/code/InternalHooks.cs index 99d6104ce..7f2e73048 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,101 @@ 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 folder name looks like a .NET Runtime Identifier. + /// + public static bool IsRidFolder(string folderName) + { + return RuntimePackageHelper.IsRidFolder(folderName); + } + + /// + /// Checks if a zip entry path contains runtime-specific assets. + /// + 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..a74cb82d9 --- /dev/null +++ b/src/code/RuntimePackageHelper.cs @@ -0,0 +1,231 @@ +// 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. + /// Detects root-level RID folders (e.g., win-x64/native.dll) used by PowerShell modules + /// with platform-specific native dependencies. + /// + internal static class RuntimePackageHelper + { + #region Constants + + /// + /// Path separator used in zip archives. + /// + private const char ZipPathSeparator = '/'; + + /// + /// Known OS prefixes used in .NET Runtime Identifiers. + /// + private static readonly string[] s_knownOsPrefixes = new[] + { + "win", "linux", "osx", "unix", "maccatalyst", "browser" + }; + + /// + /// Known architectures used in .NET Runtime Identifiers. + /// + private static readonly string[] s_knownArchitectures = new[] + { + "loongarch64", "ppc64le", "mips64", "s390x", "arm64", "armel", "wasm", "arm", "x64", "x86" + }; + + #endregion + + #region Public Methods + + /// + /// Checks if a folder name looks like a .NET Runtime Identifier. + /// Matches patterns like: win-x64, linux-arm64, osx-arm64, linux-musl-x64, etc. + /// + /// The folder name to check. + /// True if the folder name matches a RID pattern; otherwise, false. + public static bool IsRidFolder(string folderName) + { + if (string.IsNullOrEmpty(folderName) || !folderName.Contains("-")) + { + return false; + } + + // Must start with a known OS prefix + bool startsWithKnownOs = false; + foreach (string prefix in s_knownOsPrefixes) + { + if (folderName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + startsWithKnownOs = true; + break; + } + } + + if (!startsWithKnownOs) + { + return false; + } + + // Must end with a known architecture + string[] parts = folderName.Split('-'); + string lastPart = parts[parts.Length - 1]; + foreach (string arch in s_knownArchitectures) + { + if (string.Equals(lastPart, arch, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// + /// Checks if a zip entry path is under a root-level RID folder. + /// Detects entries like win-x64/native.dll, linux-arm64/libfoo.so, etc. + /// + /// The full path of the zip entry. + /// True if the entry is under a RID folder; otherwise, false. + public static bool IsRuntimesEntry(string entryFullName) + { + if (string.IsNullOrEmpty(entryFullName)) + { + return false; + } + + string normalizedPath = entryFullName.Replace('\\', ZipPathSeparator); + string[] segments = normalizedPath.Split(ZipPathSeparator); + + // Pattern: {rid}/... (root-level RID folders like win-x64/native.dll) + return segments.Length >= 2 && IsRidFolder(segments[0]); + } + + /// + /// Extracts the RID from a root-level RID folder entry path. + /// + /// The full path of the zip entry (e.g., "win-x64/native.dll"). + /// The RID (e.g., "win-x64"), or null if not under a RID folder. + public static string GetRidFromRuntimesEntry(string entryFullName) + { + if (string.IsNullOrEmpty(entryFullName)) + { + return null; + } + + string normalizedPath = entryFullName.Replace('\\', ZipPathSeparator); + string[] parts = normalizedPath.Split(ZipPathSeparator); + + if (parts.Length >= 2 && IsRidFolder(parts[0])) + { + return parts[0]; + } + + 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) + { + if (!IsRuntimesEntry(entryFullName)) + { + return true; + } + + string entryRid = GetRidFromRuntimesEntry(entryFullName); + + if (string.IsNullOrEmpty(entryRid)) + { + return true; + } + + 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) + { + if (!IsRuntimesEntry(entryFullName)) + { + return true; + } + + string entryRid = GetRidFromRuntimesEntry(entryFullName); + + if (string.IsNullOrEmpty(entryRid)) + { + return true; + } + + return RuntimeIdentifierHelper.IsCompatibleRid(entryRid, targetRid); + } + + /// + /// Gets a list of all unique RIDs present in a zip archive. + /// Detects both runtimes/{rid}/ and root-level {rid}/ patterns. + /// + /// 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. + /// + /// 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..4a0b84969 --- /dev/null +++ b/test/PlatformFilteringTests/PlatformAwareInstall.Tests.ps1 @@ -0,0 +1,399 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Integration tests for platform-aware installation: +# - RID filtering of root-level RID folders +# - TFM filtering of lib/ folder +# - NuspecReader-based dependency parsing +# +# 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 RID subdirectories with dummy native files at package root + foreach ($rid in $RuntimeIdentifiers) { + $ridDir = Join-Path $tempDir $rid + $null = New-Item $ridDir -ItemType Directory -Force + Set-Content -Path (Join-Path $ridDir "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 RID folders at package root + 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 matching RID folders" { + Install-PSResource -Name $ridPkgName -Repository $localRepoName -TrustRepository -Version $ridPkgVersion + $installed = Get-InstalledPSResource -Name $ridPkgName + $installed | Should -Not -BeNullOrEmpty + + $installPath = Get-VersionInstallPath $installed + + # Current platform RID folder should exist + $currentRidDir = Join-Path $installPath $currentRid + Test-Path $currentRidDir | Should -BeTrue + + # Foreign platform folders should NOT exist + $foreignRids = @('win-x64', 'linux-x64', 'osx-arm64') | Where-Object { + -not $InternalHooks::IsCompatibleRid($_) + } + + foreach ($foreign in $foreignRids) { + Test-Path (Join-Path $installPath $foreign) | Should -BeFalse + } + } + + It "Should install only the specified RID folder with -RuntimeIdentifier" { + Install-PSResource -Name $ridPkgName -Repository $localRepoName -TrustRepository -Version $ridPkgVersion -RuntimeIdentifier 'linux-x64' + $installed = Get-InstalledPSResource -Name $ridPkgName + $installed | Should -Not -BeNullOrEmpty + + $installPath = Get-VersionInstallPath $installed + + Test-Path (Join-Path $installPath 'linux-x64') | Should -BeTrue + Test-Path (Join-Path $installPath 'win-x86') | Should -BeFalse + Test-Path (Join-Path $installPath 'osx-arm64') | Should -BeFalse + } + } + + + 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 + + Test-Path (Join-Path $installPath 'linux-x64') | Should -BeTrue + Test-Path (Join-Path $installPath 'win-x86') | Should -BeFalse + Test-Path (Join-Path $installPath 'osx-arm64') | Should -BeFalse + } + + 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 + Test-Path (Join-Path $installPath $foreignRid) | Should -BeTrue + } + } + + + 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 + } + } +}