diff --git a/src/code/ArgumentCompleter.cs b/src/code/ArgumentCompleter.cs index 1aaf1da10..211b8beae 100644 --- a/src/code/ArgumentCompleter.cs +++ b/src/code/ArgumentCompleter.cs @@ -7,6 +7,44 @@ using System.Management.Automation; using System.Management.Automation.Language; +internal class RuntimeIdentifierCompleter : IArgumentCompleter +{ + private static readonly string[] s_knownRids = + { + "win-x64", + "win-x86", + "win-arm64", + "linux-x64", + "linux-arm64", + "linux-arm", + "linux-musl-x64", + "linux-musl-arm64", + "osx-x64", + "osx-arm64", + }; + + public IEnumerable CompleteArgument( + string commandName, + string parameterName, + string wordToComplete, + CommandAst commandAst, + IDictionary fakeBoundParameters) + { + wordToComplete = Utils.TrimQuotes(wordToComplete); + var wordToCompletePattern = WildcardPattern.Get( + pattern: string.IsNullOrWhiteSpace(wordToComplete) ? "*" : wordToComplete + "*", + options: WildcardOptions.IgnoreCase); + + foreach (string rid in s_knownRids) + { + if (wordToCompletePattern.IsMatch(rid)) + { + yield return new CompletionResult(rid); + } + } + } +} + internal class RepositoryNameCompleter : IArgumentCompleter { public IEnumerable CompleteArgument( diff --git a/src/code/InstallHelper.cs b/src/code/InstallHelper.cs index 45b400489..122a856ca 100644 --- a/src/code/InstallHelper.cs +++ b/src/code/InstallHelper.cs @@ -53,6 +53,8 @@ internal class InstallHelper private bool _noClobber; private bool _authenticodeCheck; private bool _savePkg; + private bool _filterRuntimeAssets; + private string _runtimeIdentifier; List _pathsToSearch; List _pkgNamesToInstall; private string _tmpPath; @@ -99,12 +101,14 @@ public IEnumerable BeginInstallPackages( List pathsToInstallPkg, ScopeType? scope, string tmpPath, - HashSet pkgsInstalled) + HashSet pkgsInstalled, + bool filterRuntimeAssets = false, + string runtimeIdentifier = 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}'; " + "AcceptLicense: '{7}'; Quiet: '{8}'; Reinstall: '{9}'; TrustRepository: '{10}'; NoClobber: '{11}'; AsNupkg: '{12}'; IncludeXml '{13}'; SavePackage '{14}'; TemporaryPath '{15}'; SkipDependencyCheck: '{16}'; " + - "AuthenticodeCheck: '{17}'; PathsToInstallPkg: '{18}'; Scope '{19}'", + "AuthenticodeCheck: '{17}'; PathsToInstallPkg: '{18}'; Scope '{19}'; FilterRuntimeAssets: '{20}'; RuntimeIdentifier: '{21}'", string.Join(",", names), versionRange != null ? (versionRange.OriginalString != null ? versionRange.OriginalString : string.Empty) : string.Empty, nugetVersion != null ? nugetVersion.ToString() : string.Empty, @@ -124,7 +128,9 @@ public IEnumerable BeginInstallPackages( skipDependencyCheck, authenticodeCheck, pathsToInstallPkg != null ? string.Join(",", pathsToInstallPkg) : string.Empty, - scope?.ToString() ?? string.Empty)); + scope?.ToString() ?? string.Empty, + filterRuntimeAssets, + runtimeIdentifier ?? string.Empty)); _versionRange = versionRange; _nugetVersion = nugetVersion; @@ -141,6 +147,8 @@ public IEnumerable BeginInstallPackages( _asNupkg = asNupkg; _includeXml = includeXml; _savePkg = savePkg; + _filterRuntimeAssets = filterRuntimeAssets; + _runtimeIdentifier = runtimeIdentifier; _pathsToInstallPkg = pathsToInstallPkg; _tmpPath = tmpPath ?? Path.GetTempPath(); @@ -402,7 +410,8 @@ private void MoveFilesIntoInstallPath( string installPath, string newVersion, string moduleManifestVersion, - string scriptPath) + string scriptPath, + bool mergeRuntimeContent) { _cmdletPassedIn.WriteDebug("In InstallHelper::MoveFilesIntoInstallPath()"); // Creating the proper installation path depending on whether pkg is a module or script @@ -425,6 +434,13 @@ private void MoveFilesIntoInstallPath( Directory.CreateDirectory(newPathParent); Utils.MoveDirectory(tempModuleVersionDir, finalModuleVersionDir); } + else if (mergeRuntimeContent && Directory.Exists(finalModuleVersionDir)) + { + _cmdletPassedIn.WriteVerbose( + $"Merging runtime content from '{tempModuleVersionDir}' into '{finalModuleVersionDir}'"); + Utils.MergeDirContents(tempModuleVersionDir, finalModuleVersionDir); + Utils.DeleteDirectory(tempModuleVersionDir); + } else { _cmdletPassedIn.WriteVerbose($"Temporary module version directory is: '{tempModuleVersionDir}'"); @@ -555,15 +571,27 @@ private List InstallPackages( continue; } + bool mergeRuntimeContent = false; + // Check to see if the pkg is already installed (unless -Reinstall was specified). if (!_reinstall && _packagesOnMachine.Contains($"{pkgToInstall.Name}{pkgVersion}")) { - _cmdletPassedIn.WriteWarning($"Resource '{pkgToInstall.Name}' with version '{pkgVersion}' is already installed. If you would like to reinstall, please run the cmdlet again with the -Reinstall parameter"); + if (_filterRuntimeAssets && !string.IsNullOrEmpty(_runtimeIdentifier)) + { + _cmdletPassedIn.WriteVerbose( + $"Resource '{pkgToInstall.Name}' with version '{pkgVersion}' is already installed. " + + $"Proceeding to merge runtime content for RuntimeIdentifier '{_runtimeIdentifier}'."); + mergeRuntimeContent = true; + } + else + { + _cmdletPassedIn.WriteWarning($"Resource '{pkgToInstall.Name}' with version '{pkgVersion}' is already installed. If you would like to reinstall, please run the cmdlet again with the -Reinstall parameter"); - // Remove from tracking list of packages to install. - _pkgNamesToInstall.RemoveAll(x => x.Equals(pkgToInstall.Name, StringComparison.InvariantCultureIgnoreCase)); + // Remove from tracking list of packages to install. + _pkgNamesToInstall.RemoveAll(x => x.Equals(pkgToInstall.Name, StringComparison.InvariantCultureIgnoreCase)); - continue; + continue; + } } // ShouldProcess gate (handles -WhatIf / -Confirm) on the pipeline thread. @@ -579,7 +607,8 @@ private List InstallPackages( { PkgToInstall = pkgToInstall, PkgVersion = pkgVersion, - TempInstallPath = CreateInstallationTempPath() + TempInstallPath = CreateInstallationTempPath(), + MergeRuntimeContent = mergeRuntimeContent, }); } @@ -632,7 +661,14 @@ private List InstallPackages( // Parent package and dependencies are now installed to temp directory. // Try to move all package directories from temp directory to final destination. - if (!TryMoveInstallContent(workItem.TempInstallPath, scope, workItem.PackagesHash)) + string mergePackageName = workItem.MergeRuntimeContent + ? workItem.PkgToInstall.Name + : null; + if (!TryMoveInstallContent( + workItem.TempInstallPath, + scope, + workItem.PackagesHash, + mergePackageName)) { _cmdletPassedIn.WriteError(new ErrorRecord( new InvalidOperationException(), @@ -683,6 +719,7 @@ private sealed class ParentInstallWorkItem public string TempInstallPath; public ConcurrentDictionary PackagesHash; public bool Succeeded; + public bool MergeRuntimeContent; public readonly ConcurrentQueue ErrorMsgs = new(); public readonly ConcurrentQueue WarningMsgs = new(); public readonly ConcurrentQueue DebugMsgs = new(); @@ -1272,9 +1309,10 @@ private bool TrySaveNupkgToTempPath( } /// - /// Extracts files from .nupkg + /// Extracts files from .nupkg. /// Similar functionality as System.IO.Compression.ZipFile.ExtractToDirectory, /// but while ExtractToDirectory cannot overwrite files, this method can. + /// Install-PSResource additionally filters root-level RID folders. /// /// /// This method may run on a background thread (via Parallel.ForEach), so it must not call @@ -1304,6 +1342,22 @@ private bool TryExtractToDirectory( { foreach (ZipArchiveEntry entry in archive.Entries.Where(entry => entry.CompressedLength > 0)) { + if (_filterRuntimeAssets) + { + bool includeEntry = string.IsNullOrEmpty(_runtimeIdentifier) + ? RuntimePackageHelper.ShouldIncludeEntry(entry.FullName) + : RuntimePackageHelper.ShouldIncludeEntry(entry.FullName, _runtimeIdentifier); + + if (!includeEntry) + { + string targetRid = _runtimeIdentifier ?? + RuntimeIdentifierHelper.GetCurrentRuntimeIdentifier(); + debugMsgs.Enqueue( + $"Skipping runtime entry not compatible with RuntimeIdentifier '{targetRid}': {entry.FullName}"); + continue; + } + } + // If a file has one or more parent directories. if (entry.FullName.Contains(Path.DirectorySeparatorChar) || entry.FullName.Contains(Path.AltDirectorySeparatorChar)) { @@ -1329,6 +1383,23 @@ private bool TryExtractToDirectory( entry.ExtractToFile(destinationPath, overwrite: true); } } + + if (_filterRuntimeAssets && !string.IsNullOrEmpty(_runtimeIdentifier)) + { + IReadOnlyList availableRids = + RuntimePackageHelper.GetAvailableRidsFromArchive(archive); + bool hasMatchingRid = availableRids.Any( + rid => RuntimeIdentifierHelper.IsCompatibleRid(rid, _runtimeIdentifier)); + + if (availableRids.Count > 0 && !hasMatchingRid) + { + string available = string.Join(", ", availableRids); + warningMsgs.Enqueue( + $"The specified RuntimeIdentifier '{_runtimeIdentifier}' was not found in this package. " + + $"No matching root-level runtime assets were installed. " + + $"Available root-level RIDs: {available}"); + } + } } } catch (Exception e) @@ -1348,7 +1419,11 @@ private bool TryExtractToDirectory( /// /// Moves package files/directories from the temp install path into the final install path location. /// - private bool TryMoveInstallContent(string tempInstallPath, ScopeType scope, ConcurrentDictionary packagesHash) + private bool TryMoveInstallContent( + string tempInstallPath, + ScopeType scope, + ConcurrentDictionary packagesHash, + string mergePackageName) { _cmdletPassedIn.WriteDebug("In InstallHelper::TryMoveInstallContent()"); foreach (string pkgName in packagesHash.Keys) @@ -1361,6 +1436,8 @@ private bool TryMoveInstallContent(string tempInstallPath, ScopeType scope, Conc string pkgVersion = pkgInfo["pkgVersion"] as string; string scriptPath = pkgInfo["scriptPath"] as string; string installPath = pkgInfo["installPath"] as string; + bool mergeRuntimeContent = !string.IsNullOrEmpty(mergePackageName) && + string.Equals(pkgName, mergePackageName, StringComparison.OrdinalIgnoreCase); try { @@ -1373,7 +1450,8 @@ private bool TryMoveInstallContent(string tempInstallPath, ScopeType scope, Conc installPath, newVersion: pkgVersion, // would not have prerelease label in this string moduleManifestVersion: pkgVersion, - scriptPath); + scriptPath, + mergeRuntimeContent); _cmdletPassedIn.WriteVerbose($"Successfully installed package '{pkgName}' to location '{installPath}'"); diff --git a/src/code/InstallPSResource.cs b/src/code/InstallPSResource.cs index 7beb61a56..ae4731797 100644 --- a/src/code/InstallPSResource.cs +++ b/src/code/InstallPSResource.cs @@ -138,6 +138,16 @@ public string TemporaryPath [Parameter] public SwitchParameter AuthenticodeCheck { get; set; } + /// + /// Specifies the Runtime Identifier (RID) used to select root-level runtime asset folders. + /// When omitted, the RID for the current process is used. Specify a RID to prepare a + /// package for a different platform. + /// + [Parameter] + [ValidateNotNullOrEmpty] + [ArgumentCompleter(typeof(RuntimeIdentifierCompleter))] + public string RuntimeIdentifier { get; set; } + /// /// Passes the resource installed to the console. /// @@ -597,7 +607,9 @@ private void ProcessInstallHelper(string[] pkgNames, string pkgVersion, bool pkg pathsToInstallPkg: _pathsToInstallPkg, scope: scope, tmpPath: _tmpPath, - pkgsInstalled: _packagesOnMachine); + pkgsInstalled: _packagesOnMachine, + filterRuntimeAssets: true, + runtimeIdentifier: RuntimeIdentifier); if (PassThru) { diff --git a/src/code/InternalHooks.cs b/src/code/InternalHooks.cs index a86ab21d5..766bec947 100644 --- a/src/code/InternalHooks.cs +++ b/src/code/InternalHooks.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System.Collections.Generic; using System.Reflection; namespace Microsoft.PowerShell.PSResourceGet.UtilClasses @@ -43,5 +44,60 @@ public static string GetUserString() { return Microsoft.PowerShell.PSResourceGet.Cmdlets.UserAgentInfo.UserAgentString(); } + + public static string GetCurrentRuntimeIdentifier() + { + return RuntimeIdentifierHelper.GetCurrentRuntimeIdentifier(); + } + + public static IReadOnlyList GetCompatibleRuntimeIdentifiers() + { + return RuntimeIdentifierHelper.GetCompatibleRuntimeIdentifiers(); + } + + public static IReadOnlyList GetCompatibleRuntimeIdentifiersFor(string rid) + { + return RuntimeIdentifierHelper.GetCompatibleRuntimeIdentifiers(rid); + } + + public static bool IsCompatibleRid(string rid) + { + return RuntimeIdentifierHelper.IsCompatibleRid(rid); + } + + public static bool IsCompatibleRidWith(string candidateRid, string targetRid) + { + return RuntimeIdentifierHelper.IsCompatibleRid(candidateRid, targetRid); + } + + public static bool IsRidFolder(string folderName) + { + return RuntimePackageHelper.IsRidFolder(folderName); + } + + public static bool IsRuntimeAssetEntry(string entryFullName) + { + return RuntimePackageHelper.IsRuntimeAssetEntry(entryFullName); + } + + public static string GetRidFromRuntimeAssetEntry(string entryFullName) + { + return RuntimePackageHelper.GetRidFromRuntimeAssetEntry(entryFullName); + } + + public static bool ShouldIncludeRuntimeAssetEntry(string entryFullName) + { + return RuntimePackageHelper.ShouldIncludeEntry(entryFullName); + } + + public static bool ShouldIncludeRuntimeAssetEntryForRid(string entryFullName, string targetRid) + { + return RuntimePackageHelper.ShouldIncludeEntry(entryFullName, targetRid); + } + + public static IReadOnlyList GetAvailableRidsFromZipFile(string zipPath) + { + return RuntimePackageHelper.GetAvailableRidsFromZipFile(zipPath); + } } } diff --git a/src/code/RuntimeIdentifierHelper.cs b/src/code/RuntimeIdentifierHelper.cs new file mode 100644 index 000000000..2c6981ffd --- /dev/null +++ b/src/code/RuntimeIdentifierHelper.cs @@ -0,0 +1,309 @@ +// 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; +using System.Security; + +namespace Microsoft.PowerShell.PSResourceGet.UtilClasses +{ + /// + /// Detects the current Runtime Identifier (RID) and evaluates RID compatibility. + /// + internal static class RuntimeIdentifierHelper + { + private static readonly object s_lock = new(); + private static string s_currentRid; + private static List s_compatibleRids; + + /// + /// Gets the portable RID for the current process. + /// + public static string GetCurrentRuntimeIdentifier() + { + if (s_currentRid != null) + { + return s_currentRid; + } + + lock (s_lock) + { + if (s_currentRid == null) + { + s_currentRid = DetectRuntimeIdentifier(); + } + + return s_currentRid; + } + } + + /// + /// Gets compatible RIDs for the current process, ordered from most to least specific. + /// + public static IReadOnlyList GetCompatibleRuntimeIdentifiers() + { + if (s_compatibleRids != null) + { + return s_compatibleRids; + } + + lock (s_lock) + { + if (s_compatibleRids == null) + { + s_compatibleRids = BuildCompatibleRidList(GetCurrentRuntimeIdentifier()); + } + + return s_compatibleRids; + } + } + + /// + /// Gets compatible RIDs for the specified RID, ordered from most 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); + } + + /// + /// Determines whether a package RID is compatible with the current process RID. + /// + public static bool IsCompatibleRid(string candidateRid) + { + return IsCompatibleRid(candidateRid, GetCurrentRuntimeIdentifier()); + } + + /// + /// Determines whether a package RID is compatible with the specified target RID. + /// + public static bool IsCompatibleRid(string candidateRid, string targetRid) + { + if (string.IsNullOrWhiteSpace(candidateRid) || string.IsNullOrWhiteSpace(targetRid)) + { + return false; + } + + if (ContainsRid(BuildCompatibleRidList(targetRid), candidateRid)) + { + return true; + } + + // PowerShell modules sometimes use version-specific folders such as win10-x64 + // while the host reports the portable win-x64 RID. + return ContainsRid(BuildCompatibleRidList(candidateRid), targetRid); + } + + private static string DetectRuntimeIdentifier() + { + string architecture = GetArchitectureString(); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return $"win-{architecture}"; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return IsMuslBasedLinux() ? $"linux-musl-{architecture}" : $"linux-{architecture}"; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return $"osx-{architecture}"; + } + + return $"unix-{architecture}"; + } + + private static string GetArchitectureString() + { + return RuntimeInformation.ProcessArchitecture switch + { + Architecture.X64 => "x64", + Architecture.X86 => "x86", + Architecture.Arm => "arm", + Architecture.Arm64 => "arm64", + _ => RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(), + }; + } + + private static bool IsMuslBasedLinux() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return false; + } + + try + { + const string osReleasePath = "/etc/os-release"; + if (File.Exists(osReleasePath)) + { + string content = File.ReadAllText(osReleasePath); + if (content.IndexOf("alpine", StringComparison.OrdinalIgnoreCase) >= 0) + { + return true; + } + } + + return Directory.Exists("/lib") && + Directory.GetFiles("/lib", "libc.musl-*.so*").Length > 0; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + catch (SecurityException) + { + return false; + } + } + + private static List BuildCompatibleRidList(string primaryRid) + { + var compatibleRids = new List(); + AddRid(compatibleRids, primaryRid); + + if (primaryRid.StartsWith("win", StringComparison.OrdinalIgnoreCase)) + { + string architecture = ExtractArchitecture(primaryRid); + if (architecture != null) + { + AddRid(compatibleRids, $"win-{architecture}"); + AddRid(compatibleRids, "win"); + } + + AddRid(compatibleRids, "any"); + } + else if (primaryRid.StartsWith("linux-musl", StringComparison.OrdinalIgnoreCase)) + { + string architecture = ExtractArchitecture(primaryRid); + if (architecture != null) + { + AddRid(compatibleRids, $"linux-{architecture}"); + } + + AddRid(compatibleRids, "linux"); + AddRid(compatibleRids, "unix"); + AddRid(compatibleRids, "any"); + } + else if (primaryRid.StartsWith("linux", StringComparison.OrdinalIgnoreCase)) + { + string architecture = ExtractArchitecture(primaryRid); + if (string.Equals(architecture, "armel", StringComparison.OrdinalIgnoreCase)) + { + AddRid(compatibleRids, "linux-arm"); + } + + AddRid(compatibleRids, "linux"); + AddRid(compatibleRids, "unix"); + AddRid(compatibleRids, "any"); + } + else if (primaryRid.StartsWith("maccatalyst", StringComparison.OrdinalIgnoreCase)) + { + string architecture = ExtractArchitecture(primaryRid); + if (architecture != null) + { + AddRid(compatibleRids, $"osx-{architecture}"); + } + + AddRid(compatibleRids, "osx"); + AddRid(compatibleRids, "unix"); + AddRid(compatibleRids, "any"); + } + else if (primaryRid.StartsWith("osx", StringComparison.OrdinalIgnoreCase)) + { + string architecture = ExtractArchitecture(primaryRid); + if (architecture != null) + { + AddRid(compatibleRids, $"osx-{architecture}"); + } + + AddRid(compatibleRids, "osx"); + AddRid(compatibleRids, "unix"); + AddRid(compatibleRids, "any"); + } + else if (primaryRid.StartsWith("browser-wasm", StringComparison.OrdinalIgnoreCase)) + { + AddRid(compatibleRids, "any"); + } + else if (primaryRid.StartsWith("unix", StringComparison.OrdinalIgnoreCase)) + { + AddRid(compatibleRids, "any"); + } + else + { + AddRid(compatibleRids, "any"); + } + + return compatibleRids; + } + + private static string ExtractArchitecture(string rid) + { + string[] knownArchitectures = + { + "loongarch64", + "ppc64le", + "mips64", + "s390x", + "arm64", + "armel", + "wasm", + "arm", + "x64", + "x86", + }; + + string[] parts = rid.Split('-'); + if (parts.Length < 2) + { + return null; + } + + string lastPart = parts[parts.Length - 1]; + foreach (string architecture in knownArchitectures) + { + if (string.Equals(lastPart, architecture, StringComparison.OrdinalIgnoreCase)) + { + return architecture; + } + } + + return null; + } + + private static bool ContainsRid(IEnumerable rids, string rid) + { + foreach (string candidate in rids) + { + if (string.Equals(candidate, rid, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static void AddRid(List rids, string rid) + { + if (!ContainsRid(rids, rid)) + { + rids.Add(rid); + } + } + } +} diff --git a/src/code/RuntimePackageHelper.cs b/src/code/RuntimePackageHelper.cs new file mode 100644 index 000000000..2ad0e9327 --- /dev/null +++ b/src/code/RuntimePackageHelper.cs @@ -0,0 +1,196 @@ +// 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 +{ + /// + /// Filters runtime assets stored in root-level RID folders used by PowerShell packages. + /// + internal static class RuntimePackageHelper + { + private const char ZipPathSeparator = '/'; + + private static readonly string[] s_knownOsPrefixes = + { + "win", + "linux", + "osx", + "unix", + "maccatalyst", + "browser", + }; + + private static readonly string[] s_knownArchitectures = + { + "loongarch64", + "ppc64le", + "mips64", + "s390x", + "arm64", + "armel", + "wasm", + "arm", + "x64", + "x86", + }; + + private static readonly string[] s_genericRids = + { + "any", + "win", + "linux", + "osx", + "unix", + }; + + /// + /// Determines whether a folder name resembles a supported Runtime Identifier. + /// + public static bool IsRidFolder(string folderName) + { + if (string.IsNullOrEmpty(folderName)) + { + return false; + } + + foreach (string genericRid in s_genericRids) + { + if (string.Equals(folderName, genericRid, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + if (!folderName.Contains("-")) + { + return false; + } + + bool startsWithKnownOs = false; + foreach (string prefix in s_knownOsPrefixes) + { + if (folderName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && + folderName.Length > prefix.Length) + { + char nextCharacter = folderName[prefix.Length]; + if (nextCharacter == '-' || nextCharacter == '.' || char.IsDigit(nextCharacter)) + { + startsWithKnownOs = true; + break; + } + } + } + + if (!startsWithKnownOs) + { + return false; + } + + string[] parts = folderName.Split('-'); + string lastPart = parts[parts.Length - 1]; + foreach (string architecture in s_knownArchitectures) + { + if (string.Equals(lastPart, architecture, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// + /// Determines whether an archive entry is under a root-level RID folder. + /// Standard NuGet runtimes/{rid}/ entries are intentionally not included. + /// + public static bool IsRuntimeAssetEntry(string entryFullName) + { + return !string.IsNullOrEmpty(GetRidFromRuntimeAssetEntry(entryFullName)); + } + + /// + /// Gets the RID from a root-level RID folder entry. + /// + public static string GetRidFromRuntimeAssetEntry(string entryFullName) + { + if (string.IsNullOrEmpty(entryFullName)) + { + return null; + } + + string normalizedPath = entryFullName.Replace('\\', ZipPathSeparator); + string[] parts = normalizedPath.Split(ZipPathSeparator); + + return parts.Length >= 2 && IsRidFolder(parts[0]) ? parts[0] : null; + } + + /// + /// Determines whether an archive entry should be extracted for the current process RID. + /// + public static bool ShouldIncludeEntry(string entryFullName) + { + string entryRid = GetRidFromRuntimeAssetEntry(entryFullName); + return string.IsNullOrEmpty(entryRid) || RuntimeIdentifierHelper.IsCompatibleRid(entryRid); + } + + /// + /// Determines whether an archive entry should be extracted for an explicit target RID. + /// + public static bool ShouldIncludeEntry(string entryFullName, string targetRid) + { + string entryRid = GetRidFromRuntimeAssetEntry(entryFullName); + return string.IsNullOrEmpty(entryRid) || + RuntimeIdentifierHelper.IsCompatibleRid(entryRid, targetRid); + } + + /// + /// Gets the unique root-level RIDs present in an 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 = GetRidFromRuntimeAssetEntry(entry.FullName); + if (!string.IsNullOrEmpty(rid)) + { + rids.Add(rid); + } + } + + return rids.OrderBy(rid => rid, StringComparer.OrdinalIgnoreCase).ToList(); + } + + /// + /// Gets the unique root-level RIDs present in a zip file. + /// + 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); + } + } + } +} diff --git a/src/code/Utils.cs b/src/code/Utils.cs index 3e05f1089..3bf3ef716 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -1947,6 +1947,33 @@ public static void MoveDirectory( DeleteDirectory(sourceDirPath); } + /// + /// Copies missing files and directories from a source directory into an existing destination. + /// Existing files are preserved. + /// + public static void MergeDirContents(string sourceDirPath, string destDirPath) + { + if (!Directory.Exists(destDirPath)) + { + Directory.CreateDirectory(destDirPath); + } + + foreach (string filePath in Directory.GetFiles(sourceDirPath)) + { + string destFilePath = Path.Combine(destDirPath, Path.GetFileName(filePath)); + if (!File.Exists(destFilePath)) + { + File.Copy(filePath, destFilePath); + } + } + + foreach (string sourceSubDirPath in Directory.GetDirectories(sourceDirPath)) + { + string destSubDirPath = Path.Combine(destDirPath, Path.GetFileName(sourceSubDirPath)); + MergeDirContents(sourceSubDirPath, destSubDirPath); + } + } + private static void CopyDirContents( string sourceDirPath, string destDirPath, diff --git a/test/RIDFilteringTests/InstallPSResourceRuntimeIdentifier.Tests.ps1 b/test/RIDFilteringTests/InstallPSResourceRuntimeIdentifier.Tests.ps1 new file mode 100644 index 000000000..a788e2154 --- /dev/null +++ b/test/RIDFilteringTests/InstallPSResourceRuntimeIdentifier.Tests.ps1 @@ -0,0 +1,275 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] +Param() + +$ProgressPreference = 'SilentlyContinue' +$modPath = "$PSScriptRoot/../PSGetTestUtils.psm1" +Import-Module $modPath -Force -Verbose + +Describe 'Install-PSResource RuntimeIdentifier filtering' -Tags 'CI' { + BeforeAll { + Add-Type -AssemblyName System.IO.Compression.FileSystem + + function New-RidTestPackage { + param( + [Parameter(Mandatory)] + [string] $Name, + + [Parameter(Mandatory)] + [string] $Version, + + [Parameter(Mandatory)] + [string] $OutputDirectory, + + [string[]] $RootRuntimeIdentifiers = @(), + + [string[]] $StandardRuntimeIdentifiers = @(), + + [string[]] $LibTfms = @() + ) + + $packageSource = Join-Path ([System.IO.Path]::GetTempPath()) ([Guid]::NewGuid()) + $null = New-Item $packageSource -ItemType Directory -Force + + try { + foreach ($rid in $RootRuntimeIdentifiers) { + $ridPath = Join-Path $packageSource $rid + $null = New-Item $ridPath -ItemType Directory -Force + Set-Content -Path (Join-Path $ridPath "native_$rid.txt") -Value $rid + } + + foreach ($rid in $StandardRuntimeIdentifiers) { + $runtimesPath = Join-Path $packageSource 'runtimes' + $ridPath = Join-Path $runtimesPath $rid + $nativePath = Join-Path $ridPath 'native' + $null = New-Item $nativePath -ItemType Directory -Force + Set-Content -Path (Join-Path $nativePath "standard_$rid.txt") -Value $rid + } + + foreach ($tfm in $LibTfms) { + $libPath = Join-Path (Join-Path $packageSource 'lib') $tfm + $null = New-Item $libPath -ItemType Directory -Force + Set-Content -Path (Join-Path $libPath "$Name.dll") -Value $tfm + } + + Set-Content -Path (Join-Path $packageSource 'common.txt') -Value 'common-content' + Set-Content -Path (Join-Path $packageSource "$Name.psm1") -Value '' + + $manifest = @" +@{ + RootModule = '$Name.psm1' + ModuleVersion = '$Version' + GUID = '$([Guid]::NewGuid())' + Author = 'PSResourceGet Tests' + Description = 'RID filtering test package' + FunctionsToExport = @() + CmdletsToExport = @() +} +"@ + Set-Content -Path (Join-Path $packageSource "$Name.psd1") -Value $manifest + + $nuspec = @" + + + + $Name + $Version + PSResourceGet Tests + RID filtering test package + PSModule test + + +"@ + Set-Content -Path (Join-Path $packageSource "$Name.nuspec") -Value $nuspec + + $packagePath = Join-Path $OutputDirectory "$Name.$Version.nupkg" + if (Test-Path $packagePath) { + Remove-Item $packagePath -Force + } + + [System.IO.Compression.ZipFile]::CreateFromDirectory($packageSource, $packagePath) + } + finally { + Remove-Item $packageSource -Recurse -Force -ErrorAction SilentlyContinue + } + } + + function Get-VersionInstallPath { + param( + [Parameter(Mandatory)] + [object] $Package + ) + + $basePath = $Package.InstalledLocation + $version = $Package.Version.ToString() + $namePath = Join-Path $basePath $Package.Name + $versionPath = Join-Path $namePath $version + + if (Test-Path $versionPath) { + return $versionPath + } + + if ($basePath -match "$([regex]::Escape($Package.Name))[\\/]$([regex]::Escape($version))$") { + return $basePath + } + + if (Test-Path $namePath) { + return $namePath + } + + return $basePath + } + + Get-NewPSResourceRepositoryFile + + $script:InternalHooks = [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks] + $script:currentRid = $InternalHooks::GetCurrentRuntimeIdentifier() + $script:foreignRid = if ($currentRid -match '^win') { + 'linux-x64' + } + elseif ($currentRid -match '^linux') { + 'osx-arm64' + } + else { + 'win-x64' + } + + $script:repoName = 'RidFilteringTestRepo' + $script:repoPath = Join-Path $TestDrive 'rid-repository' + $null = New-Item $repoPath -ItemType Directory -Force + Register-PSResourceRepository -Name $repoName -Uri $repoPath -Trusted -Force + + $script:version = '1.0.0' + $script:filterPackage = 'TestRidFilterModule' + $script:mergePackage = 'TestRidMergeModule' + $script:freshPackage = 'TestRidFreshModule' + $script:warningPackage = 'TestRidWarningModule' + $script:packageNames = @( + $filterPackage, + $mergePackage, + $freshPackage, + $warningPackage + ) + + New-RidTestPackage -Name $filterPackage -Version $version -OutputDirectory $repoPath ` + -RootRuntimeIdentifiers @($currentRid, $foreignRid) ` + -StandardRuntimeIdentifiers @($currentRid, $foreignRid) ` + -LibTfms @('net472', 'net8.0') + + New-RidTestPackage -Name $mergePackage -Version $version -OutputDirectory $repoPath ` + -RootRuntimeIdentifiers @($currentRid, $foreignRid) + + New-RidTestPackage -Name $freshPackage -Version $version -OutputDirectory $repoPath ` + -RootRuntimeIdentifiers @($currentRid, $foreignRid) + + New-RidTestPackage -Name $warningPackage -Version $version -OutputDirectory $repoPath ` + -RootRuntimeIdentifiers @($currentRid) + } + + AfterEach { + foreach ($packageName in $script:packageNames) { + Uninstall-PSResource -Name $packageName -Version '*' -SkipDependencyCheck ` + -ErrorAction SilentlyContinue + } + + if ($script:staleFreshPath -and (Test-Path $script:staleFreshPath)) { + Remove-Item $script:staleFreshPath -Recurse -Force + } + + $script:staleFreshPath = $null + } + + AfterAll { + Unregister-PSResourceRepository -Name $script:repoName -ErrorAction SilentlyContinue + Get-RevertPSResourceRepositoryFile + } + + It 'automatically filters root-level RID folders and leaves other layouts unchanged' { + Install-PSResource -Name $filterPackage -Version $version -Repository $repoName ` + -TrustRepository + + $installed = Get-InstalledPSResource -Name $filterPackage + $installPath = Get-VersionInstallPath $installed + + Test-Path (Join-Path $installPath $currentRid) | Should -BeTrue + Test-Path (Join-Path $installPath $foreignRid) | Should -BeFalse + Test-Path (Join-Path (Join-Path $installPath 'runtimes') $currentRid) | Should -BeTrue + Test-Path (Join-Path (Join-Path $installPath 'runtimes') $foreignRid) | Should -BeTrue + Test-Path (Join-Path (Join-Path $installPath 'lib') 'net472') | Should -BeTrue + Test-Path (Join-Path (Join-Path $installPath 'lib') 'net8.0') | Should -BeTrue + Test-Path (Join-Path $installPath 'common.txt') | Should -BeTrue + } + + It 'selects a foreign root-level RID when RuntimeIdentifier is explicit' { + Install-PSResource -Name $filterPackage -Version $version -Repository $repoName ` + -TrustRepository -RuntimeIdentifier $foreignRid + + $installed = Get-InstalledPSResource -Name $filterPackage + $installPath = Get-VersionInstallPath $installed + + Test-Path (Join-Path $installPath $foreignRid) | Should -BeTrue + Test-Path (Join-Path $installPath $currentRid) | Should -BeFalse + } + + It 'completes common RuntimeIdentifier values' { + $command = 'Install-PSResource -RuntimeIdentifier lin' + $completion = TabExpansion2 $command $command.Length + + $completion.CompletionMatches.CompletionText | Should -Contain 'linux-x64' + $completion.CompletionMatches.CompletionText | Should -Contain 'linux-arm64' + } + + It 'merges an explicit RID into an existing version without overwriting existing content' { + Install-PSResource -Name $mergePackage -Version $version -Repository $repoName ` + -TrustRepository + + $installed = Get-InstalledPSResource -Name $mergePackage + $installPath = Get-VersionInstallPath $installed + $preservedFile = Join-Path $installPath 'preserve-me.txt' + Set-Content -Path $preservedFile -Value 'preserve' + + Install-PSResource -Name $mergePackage -Version $version -Repository $repoName ` + -TrustRepository -RuntimeIdentifier $foreignRid + + Test-Path (Join-Path $installPath $currentRid) | Should -BeTrue + Test-Path (Join-Path $installPath $foreignRid) | Should -BeTrue + Get-Content $preservedFile | Should -Be 'preserve' + } + + It 'keeps merge state scoped to the already-installed parent work item' { + Install-PSResource -Name $mergePackage -Version $version -Repository $repoName ` + -TrustRepository + + $installedMergePackage = Get-InstalledPSResource -Name $mergePackage + $mergeInstallPath = Get-VersionInstallPath $installedMergePackage + $moduleInstallRoot = Split-Path (Split-Path $mergeInstallPath -Parent) -Parent + $script:staleFreshPath = Join-Path (Join-Path $moduleInstallRoot $freshPackage) $version + $null = New-Item $staleFreshPath -ItemType Directory -Force + Set-Content -Path (Join-Path $staleFreshPath 'stale.txt') -Value 'stale' + + Install-PSResource -Name @($mergePackage, $freshPackage) -Version $version ` + -Repository $repoName -TrustRepository -RuntimeIdentifier $foreignRid + + $installedFreshPackage = Get-InstalledPSResource -Name $freshPackage + $freshInstallPath = Get-VersionInstallPath $installedFreshPackage + + Test-Path (Join-Path $mergeInstallPath $currentRid) | Should -BeTrue + Test-Path (Join-Path $mergeInstallPath $foreignRid) | Should -BeTrue + Test-Path (Join-Path $freshInstallPath $foreignRid) | Should -BeTrue + Test-Path (Join-Path $freshInstallPath 'stale.txt') | Should -BeFalse + } + + It 'warns when an explicit RuntimeIdentifier has no compatible root-level folder' { + $warnings = $null + Install-PSResource -Name $warningPackage -Version $version -Repository $repoName ` + -TrustRepository -RuntimeIdentifier $foreignRid -WarningVariable warnings + + $warningText = $warnings | Out-String + $warningText | Should -Match ([regex]::Escape($foreignRid)) + $warningText | Should -Match 'not found' + $warningText | Should -Match ([regex]::Escape($currentRid)) + } +} diff --git a/test/RIDFilteringTests/RuntimeIdentifierHelper.Tests.ps1 b/test/RIDFilteringTests/RuntimeIdentifierHelper.Tests.ps1 new file mode 100644 index 000000000..7863fc71e --- /dev/null +++ b/test/RIDFilteringTests/RuntimeIdentifierHelper.Tests.ps1 @@ -0,0 +1,105 @@ +# 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' -Tags 'CI' { + BeforeAll { + $InternalHooks = [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks] + } + + Context 'Current runtime detection' { + It 'returns a stable RID for the current process' { + $firstRid = $InternalHooks::GetCurrentRuntimeIdentifier() + $secondRid = $InternalHooks::GetCurrentRuntimeIdentifier() + + $firstRid | Should -Not -BeNullOrEmpty + $firstRid | Should -Be $secondRid + $firstRid | Should -Match '-(x64|x86|arm64|arm|s390x|ppc64le|loongarch64)$' + } + + It 'returns the expected operating system prefix' { + $rid = $InternalHooks::GetCurrentRuntimeIdentifier() + + if ($PSVersionTable.PSVersion.Major -eq 5 -or $IsWindows) { + $rid | Should -Match '^win-' + } + elseif ($IsLinux) { + $rid | Should -Match '^linux(-musl)?-' + } + elseif ($IsMacOS) { + $rid | Should -Match '^osx-' + } + } + } + + Context 'Compatibility chains' { + It 'builds the Windows portable fallback chain' { + $rids = $InternalHooks::GetCompatibleRuntimeIdentifiersFor('win10-x64') + + $rids[0] | Should -Be 'win10-x64' + $rids | Should -Contain 'win-x64' + $rids | Should -Contain 'win' + $rids | Should -Contain 'any' + } + + It 'builds the Linux musl fallback chain' { + $rids = $InternalHooks::GetCompatibleRuntimeIdentifiersFor('linux-musl-x64') + + $rids[0] | Should -Be 'linux-musl-x64' + $rids | Should -Contain 'linux-x64' + $rids | Should -Contain 'linux' + $rids | Should -Contain 'unix' + $rids | Should -Contain 'any' + } + + It 'builds the macOS version fallback chain' { + $rids = $InternalHooks::GetCompatibleRuntimeIdentifiersFor('osx.12-arm64') + + $rids[0] | Should -Be 'osx.12-arm64' + $rids | Should -Contain 'osx-arm64' + $rids | Should -Contain 'osx' + $rids | Should -Contain 'unix' + $rids | Should -Contain 'any' + } + + It 'includes the current RID first' { + $currentRid = $InternalHooks::GetCurrentRuntimeIdentifier() + $rids = $InternalHooks::GetCompatibleRuntimeIdentifiers() + + $rids[0] | Should -Be $currentRid + } + } + + Context 'Compatibility checks' { + It 'accepts the current process RID' { + $currentRid = $InternalHooks::GetCurrentRuntimeIdentifier() + + $InternalHooks::IsCompatibleRid($currentRid) | Should -BeTrue + } + + It 'accepts portable and version-specific RIDs in the same Windows family' { + $InternalHooks::IsCompatibleRidWith('win-x64', 'win10-x64') | Should -BeTrue + $InternalHooks::IsCompatibleRidWith('win10-x64', 'win-x64') | Should -BeTrue + } + + It 'accepts a fallback RID for a musl target' { + $InternalHooks::IsCompatibleRidWith('linux-x64', 'linux-musl-x64') | Should -BeTrue + } + + It 'rejects RIDs from a different operating system family' { + $InternalHooks::IsCompatibleRidWith('linux-x64', 'win-x64') | Should -BeFalse + $InternalHooks::IsCompatibleRidWith('osx-arm64', 'linux-arm64') | Should -BeFalse + } + + It 'rejects empty RID values' { + $InternalHooks::IsCompatibleRidWith($null, 'win-x64') | Should -BeFalse + $InternalHooks::IsCompatibleRidWith('win-x64', '') | Should -BeFalse + } + } +} diff --git a/test/RIDFilteringTests/RuntimePackageHelper.Tests.ps1 b/test/RIDFilteringTests/RuntimePackageHelper.Tests.ps1 new file mode 100644 index 000000000..3880d540a --- /dev/null +++ b/test/RIDFilteringTests/RuntimePackageHelper.Tests.ps1 @@ -0,0 +1,119 @@ +# 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' -Tags 'CI' { + BeforeAll { + Add-Type -AssemblyName System.IO.Compression.FileSystem + $InternalHooks = [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks] + } + + Context 'Root-level RID detection' { + It 'recognizes supported root-level RID folders' { + $InternalHooks::IsRidFolder('win-x64') | Should -BeTrue + $InternalHooks::IsRidFolder('linux-musl-arm64') | Should -BeTrue + $InternalHooks::IsRidFolder('osx.12-arm64') | Should -BeTrue + $InternalHooks::IsRidFolder('win') | Should -BeTrue + $InternalHooks::IsRidFolder('any') | Should -BeTrue + } + + It 'rejects non-RID folder names' { + $InternalHooks::IsRidFolder('lib') | Should -BeFalse + $InternalHooks::IsRidFolder('runtimes') | Should -BeFalse + $InternalHooks::IsRidFolder('content-x64') | Should -BeFalse + $InternalHooks::IsRidFolder('windows-x64') | Should -BeFalse + } + + It 'recognizes only root-level RID entries' { + $InternalHooks::IsRuntimeAssetEntry('win-x64/native/module.dll') | Should -BeTrue + $InternalHooks::IsRuntimeAssetEntry('linux-arm64/libmodule.so') | Should -BeTrue + $InternalHooks::IsRuntimeAssetEntry('runtimes/win-x64/native/module.dll') | Should -BeFalse + $InternalHooks::IsRuntimeAssetEntry('lib/net8.0/module.dll') | Should -BeFalse + } + + It 'extracts a RID only from a root-level entry' { + $InternalHooks::GetRidFromRuntimeAssetEntry('win-x64/native/module.dll') | + Should -Be 'win-x64' + $InternalHooks::GetRidFromRuntimeAssetEntry('runtimes/win-x64/native/module.dll') | + Should -BeNullOrEmpty + } + } + + Context 'Entry filtering' { + It 'includes non-runtime content and standard NuGet runtime layouts' { + $InternalHooks::ShouldIncludeRuntimeAssetEntry('module.psd1') | Should -BeTrue + $InternalHooks::ShouldIncludeRuntimeAssetEntry('lib/net8.0/module.dll') | Should -BeTrue + $InternalHooks::ShouldIncludeRuntimeAssetEntry('runtimes/linux-x64/native/module.so') | + Should -BeTrue + } + + It 'includes the root-level folder for the current RID' { + $currentRid = $InternalHooks::GetCurrentRuntimeIdentifier() + + $InternalHooks::ShouldIncludeRuntimeAssetEntry("$currentRid/native/module.dll") | + Should -BeTrue + } + + It 'honors an explicit cross-platform target RID' { + $InternalHooks::ShouldIncludeRuntimeAssetEntryForRid( + 'linux-x64/native/module.so', + 'linux-x64') | Should -BeTrue + $InternalHooks::ShouldIncludeRuntimeAssetEntryForRid( + 'win-x64/native/module.dll', + 'linux-x64') | Should -BeFalse + } + + It 'honors generic fallback RID folders' { + $InternalHooks::ShouldIncludeRuntimeAssetEntryForRid( + 'linux/native/module.so', + 'linux-x64') | Should -BeTrue + $InternalHooks::ShouldIncludeRuntimeAssetEntryForRid( + 'any/native/module.bin', + 'linux-x64') | Should -BeTrue + $InternalHooks::ShouldIncludeRuntimeAssetEntryForRid( + 'win/native/module.dll', + 'linux-x64') | Should -BeFalse + } + } + + Context 'Available RID discovery' { + BeforeAll { + $zipSource = Join-Path $TestDrive 'rid-source' + $null = New-Item $zipSource -ItemType Directory -Force + + foreach ($rid in @('win-x64', 'linux-x64', 'osx-arm64')) { + $ridPath = Join-Path $zipSource $rid + $null = New-Item $ridPath -ItemType Directory -Force + Set-Content -Path (Join-Path $ridPath 'asset.txt') -Value $rid + } + + $standardRuntimePath = Join-Path (Join-Path $zipSource 'runtimes') 'linux-arm64' + $null = New-Item $standardRuntimePath -ItemType Directory -Force + Set-Content -Path (Join-Path $standardRuntimePath 'asset.txt') -Value 'standard-layout' + + $script:zipPath = Join-Path $TestDrive 'runtime-assets.zip' + [System.IO.Compression.ZipFile]::CreateFromDirectory($zipSource, $zipPath) + } + + It 'lists only root-level RIDs' { + $rids = $InternalHooks::GetAvailableRidsFromZipFile($zipPath) + + $rids.Count | Should -Be 3 + $rids | Should -Contain 'win-x64' + $rids | Should -Contain 'linux-x64' + $rids | Should -Contain 'osx-arm64' + $rids | Should -Not -Contain 'linux-arm64' + } + + It 'throws when the zip file does not exist' { + $missingPath = Join-Path $TestDrive 'missing.zip' + { $InternalHooks::GetAvailableRidsFromZipFile($missingPath) } | Should -Throw + } + } +}