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