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 + } + } +}