diff --git a/src/code/UninstallPSResource.cs b/src/code/UninstallPSResource.cs index b7d79f258..97939df7c 100644 --- a/src/code/UninstallPSResource.cs +++ b/src/code/UninstallPSResource.cs @@ -472,9 +472,16 @@ private bool CheckIfDependency(string pkgName, string version, out ErrorRecord e else { if (System.Version.TryParse(version, out systemVersion) && pkgToUninstall.Version.CompareTo(systemVersion) == 0) { - // The required version OR module version is the version we're attempting to uninstall. - parentsOfDependency.Add(parentPkg.Name); - dependencyExists = true; + // The version we're attempting to uninstall matches the version (or minimum version) required by the + // parent package. If the parent package specified a minimum version (i.e. 'ModuleVersion' and/or + // 'MaximumVersion' in its manifest's RequiredModules entry, as opposed to an exact 'RequiredVersion'), + // then another installed version satisfying that range would also fulfill the dependency requirement, + // so we should not block uninstalling this version in that case. + if (!IsAnotherInstalledVersionSatisfyingRequirement(parentPkg, pkgName, systemVersion)) + { + parentsOfDependency.Add(parentPkg.Name); + dependencyExists = true; + } } } } @@ -497,6 +504,132 @@ private bool CheckIfDependency(string pkgName, string version, out ErrorRecord e return false; } + /// + /// Determines whether another already-installed version of pkgName (other than versionToUninstall) also + /// satisfies the dependency requirement that parentPkg's manifest declares for pkgName. This is used to + /// avoid blocking uninstallation of a specific version when the parent package's requirement is a minimum + /// version (i.e. specified via 'ModuleVersion' and, optionally, 'MaximumVersion' in the RequiredModules + /// manifest entry) rather than an exact 'RequiredVersion', since a newer (or otherwise in-range) installed + /// version would still fulfill the dependency. + /// + private bool IsAnotherInstalledVersionSatisfyingRequirement(PSModuleInfo parentPkg, string pkgName, Version versionToUninstall) + { + Hashtable requiredModuleEntry = GetRawRequiredModuleEntry(parentPkg, pkgName); + if (requiredModuleEntry == null || requiredModuleEntry.ContainsKey("RequiredVersion")) + { + // Either we could not determine the requirement details, or the parent package requires an exact + // version. In both cases fall back to the original (safe) behavior of treating this as a blocking + // dependency, since only the exact version being uninstalled can satisfy the requirement. + return false; + } + + System.Version minVersion = null; + System.Version maxVersion = null; + if (requiredModuleEntry["ModuleVersion"] != null) + { + System.Version.TryParse(requiredModuleEntry["ModuleVersion"].ToString(), out minVersion); + } + + if (requiredModuleEntry["MaximumVersion"] != null) + { + System.Version.TryParse(requiredModuleEntry["MaximumVersion"].ToString(), out maxVersion); + } + + if (minVersion == null && maxVersion == null) + { + // No usable version range information, cannot safely determine another version satisfies. + return false; + } + + _pwsh ??= System.Management.Automation.PowerShell.Create(); + _pwsh.Commands.Clear(); + _pwsh.Streams.ClearStreams(); + + Collection pkgVersions; + try + { + pkgVersions = _pwsh.AddCommand("Microsoft.PowerShell.Core\\Get-Module").AddParameters( + new Hashtable() { + { "Name", pkgName }, + { "ListAvailable", true } + }).Invoke(); + } + catch (Exception) + { + return false; + } + + foreach (PSModuleInfo installedPkg in pkgVersions) + { + if (installedPkg.Version == null || installedPkg.Version.CompareTo(versionToUninstall) == 0) + { + // This is the version being uninstalled, skip it. + continue; + } + + bool satisfiesMin = minVersion == null || installedPkg.Version.CompareTo(minVersion) >= 0; + bool satisfiesMax = maxVersion == null || installedPkg.Version.CompareTo(maxVersion) <= 0; + if (satisfiesMin && satisfiesMax) + { + return true; + } + } + + return false; + } + + /// + /// Reads the parent package's module manifest to find the raw RequiredModules entry (as a Hashtable) + /// corresponding to pkgName. Returns null if the manifest cannot be read, or if the entry is not a + /// Hashtable (e.g. RequiredModules specified as just a module name string, meaning any version works). + /// + private Hashtable GetRawRequiredModuleEntry(PSModuleInfo parentPkg, string pkgName) + { + if (parentPkg == null || string.IsNullOrEmpty(parentPkg.Path) || !File.Exists(parentPkg.Path)) + { + return null; + } + + _pwsh ??= System.Management.Automation.PowerShell.Create(); + _pwsh.Commands.Clear(); + _pwsh.Streams.ClearStreams(); + + Collection manifestResults; + try + { + manifestResults = _pwsh.AddCommand("Import-PowerShellDataFile").AddParameter("Path", parentPkg.Path).Invoke(); + } + catch (Exception) + { + return null; + } + + if (manifestResults == null || manifestResults.Count == 0 || !manifestResults[0].ContainsKey("RequiredModules")) + { + return null; + } + + // RequiredModules is normally an object[] but PowerShell may surface it as another IEnumerable + // (e.g. ArrayList) depending on how the data file was authored, so handle any enumerable here. + if (!(manifestResults[0]["RequiredModules"] is System.Collections.IEnumerable requiredModulesRaw) || + manifestResults[0]["RequiredModules"] is string) + { + return null; + } + + foreach (object entry in requiredModulesRaw) + { + if (entry is Hashtable entryHash && + entryHash["ModuleName"] is string moduleName && + string.Equals(moduleName, pkgName, StringComparison.InvariantCultureIgnoreCase)) + { + return entryHash; + } + } + + return null; + } + #endregion } } diff --git a/test/PSGetTestUtils.psm1 b/test/PSGetTestUtils.psm1 index 44202a58d..407f54f56 100644 --- a/test/PSGetTestUtils.psm1 +++ b/test/PSGetTestUtils.psm1 @@ -498,7 +498,10 @@ function New-TestModule $dscResourceToExport = "Test-ModuleDSCResource", [string[]] - $tags + $tags, + + [string] + $requiredModulesEntry = "@()" ) $modulePath = Join-Path -Path $path -ChildPath $ModuleName @@ -525,7 +528,7 @@ function New-TestModule CmdletsToExport = @('{1}', '{2}') DscResourcesToExport = @('{3}') FunctionsToExport = @() - RequiredModules = @() + RequiredModules = {6} PrivateData = @{{ PSData = @{{ {4} @@ -533,7 +536,7 @@ function New-TestModule }} }} }} -'@ -f $packageVersion, $cmdletToExport, $cmdletToExport2, $dscResourceToExport, $prereleaseEntry, $tagsEntry | Out-File -FilePath $moduleMan +'@ -f $packageVersion, $cmdletToExport, $cmdletToExport2, $dscResourceToExport, $prereleaseEntry, $tagsEntry, $requiredModulesEntry | Out-File -FilePath $moduleMan Publish-PSResource -Path $modulePath -Repository $repoName } diff --git a/test/UninstallPSResourceTests/UninstallPSResource.Tests.ps1 b/test/UninstallPSResourceTests/UninstallPSResource.Tests.ps1 index 45b9310a1..751060c79 100644 --- a/test/UninstallPSResourceTests/UninstallPSResource.Tests.ps1 +++ b/test/UninstallPSResourceTests/UninstallPSResource.Tests.ps1 @@ -400,3 +400,49 @@ Describe 'Test Uninstall-PSResource for Modules' -tags 'CI' { (Get-Alias usres).Definition | Should -BeExactly 'Uninstall-PSResource' } } + +Describe 'Test Uninstall-PSResource dependency check with minimum version requirements' -tags 'CI' { + + BeforeAll { + $localRepo = "psgettestlocal" + $dependencyModuleName = "DependencyMinVersionChild" + $parentModuleName = "DependencyMinVersionParent" + Get-NewPSResourceRepositoryFile + Register-LocalRepos + + New-TestModule -moduleName $dependencyModuleName -repoName $localRepo -packageVersion "2.0.0" -prereleaseLabel "" + New-TestModule -moduleName $dependencyModuleName -repoName $localRepo -packageVersion "3.0.0" -prereleaseLabel "" + New-TestModule -moduleName $parentModuleName -repoName $localRepo -packageVersion "1.0.0" -prereleaseLabel "" ` + -requiredModulesEntry "@(@{ ModuleName = '$dependencyModuleName'; ModuleVersion = '2.0.0' })" + } + + BeforeEach { + Uninstall-PSResource -Name $dependencyModuleName, $parentModuleName -Version "*" -ErrorAction SilentlyContinue -SkipDependencyCheck + Install-PSResource -Name $dependencyModuleName -Version "2.0.0" -Repository $localRepo -TrustRepository + Install-PSResource -Name $dependencyModuleName -Version "3.0.0" -Repository $localRepo -TrustRepository + Install-PSResource -Name $parentModuleName -Repository $localRepo -TrustRepository -SkipDependencyCheck + } + + AfterAll { + Uninstall-PSResource -Name $dependencyModuleName, $parentModuleName -Version "*" -ErrorAction SilentlyContinue -SkipDependencyCheck + Get-RevertPSResourceRepositoryFile + } + + It "Uninstall a dependency's minimum required version when a newer installed version still satisfies the requirement" { + Uninstall-PSResource -Name $dependencyModuleName -Version "2.0.0" -ErrorAction SilentlyContinue -ErrorVariable err + $err.Count | Should -Be 0 + + Get-InstalledPSResource -Name $dependencyModuleName -Version "2.0.0" | Should -BeNullOrEmpty + (Get-InstalledPSResource -Name $dependencyModuleName -Version "3.0.0").Version | Should -Be "3.0.0" + } + + It "Should not uninstall a dependency's minimum required version when no other installed version satisfies the requirement" { + Uninstall-PSResource -Name $dependencyModuleName -Version "3.0.0" -SkipDependencyCheck + + Uninstall-PSResource -Name $dependencyModuleName -Version "2.0.0" -ErrorAction SilentlyContinue -ErrorVariable err + $err.Count | Should -Not -Be 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly 'UninstallPSResourcePackageIsADependency,Microsoft.PowerShell.PSResourceGet.Cmdlets.UninstallPSResource' + + (Get-InstalledPSResource -Name $dependencyModuleName -Version "2.0.0") | Should -Not -BeNullOrEmpty + } +}