Skip to content

Commit 44c656f

Browse files
authored
✨Install-Time GUID Mismatch Detection
ModuleFast will now check at install time if the manifest GUID does not match what was in the spec. We unfortunately cannot do this earlier in the process without fully downloading the modules, as Nuget v3 doesn't have a standard location to store the GUID for PowerShell modules in the metadata. There could potentially be a tag like "GUID:XXX-YYY-ZZZ" used for this purpose in the future. A warning was also added against using GUID to match in general. Fixes #43
1 parent 92b6592 commit 44c656f

File tree

2 files changed

+82
-39
lines changed

2 files changed

+82
-39
lines changed

ModuleFast.psm1

Lines changed: 66 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@ function Get-ModuleFastPlan {
445445
THIS COMMAND IS DEPRECATED AND WILL NOT RECEIVE PARAMETER UPDATES. Please use Install-ModuleFast -Plan instead.
446446
#>
447447
[CmdletBinding()]
448-
[OutputType([ModuleFastInfo])]
448+
[OutputType([ModuleFastInfo[]])]
449449
param(
450450
#The module(s) to install. This can be a string, a ModuleSpecification, a hashtable with nuget version style (e.g. @{Name='test';Version='1.0'}), a hashtable with ModuleSpecification style (e.g. @{Name='test';RequiredVersion='1.0'}),
451451
[Alias('Name')]
@@ -543,6 +543,10 @@ function Get-ModuleFastPlan {
543543
throw 'Failed to find Module Specification for completed task. This is a bug.'
544544
}
545545

546+
if ($currentModuleSpec.Guid -ne [Guid]::Empty) {
547+
Write-Warning "${currentModuleSpec}: A GUID constraint was found in the module spec. ModuleSpec will currently only verify GUIDs after the module has been installed, so a plan may not be accurate. It is not recommended to match modules by GUID in ModuleFast."
548+
}
549+
546550
Write-Debug "${currentModuleSpec}: Processing Response"
547551
# We use GetAwaiter so we get proper error messages back, as things such as network errors might occur here.
548552
try {
@@ -598,7 +602,6 @@ function Get-ModuleFastPlan {
598602
}
599603

600604
if ($currentModuleSpec.SatisfiedBy($candidate)) {
601-
#TODO: If the found version still matches an installed version, we should skip it
602605
Write-Debug "${ModuleSpec}: Found satisfying version $candidate in the inlined index."
603606
$matchingEntry = $entries | Where-Object version -EQ $candidate
604607
if ($matchingEntry.count -gt 1) { throw 'Multiple matching Entries found for a specific version. This is a bug and should not happen' }
@@ -683,6 +686,9 @@ function Get-ModuleFastPlan {
683686
$selectedEntry.version,
684687
$selectedEntry.PackageContent
685688
)
689+
if ($moduleSpec.Guid -and $moduleSpec.Guid -ne [Guid]::Empty) {
690+
$selectedModule.Guid = $moduleSpec.Guid
691+
}
686692

687693
#If -Update was specified, we need to re-check that none of the selected modules are already installed.
688694
#TODO: Persist state of the local modules found to this point so we don't have to recheck.
@@ -907,34 +913,52 @@ function Install-ModuleFastHelper {
907913
[ValidateNotNullOrEmpty()]$stream = $USING:stream,
908914
[ValidateNotNullOrEmpty()]$context = $USING:context
909915
)
910-
$installPath = $context.InstallPath
911-
$installIndicatorPath = Join-Path $installPath '.incomplete'
916+
process {
917+
$installPath = $context.InstallPath
918+
$installIndicatorPath = Join-Path $installPath '.incomplete'
919+
920+
if (Test-Path $installIndicatorPath) {
921+
#FIXME: Output inside a threadjob is not surfaced to the user.
922+
Write-Warning "$($context.Module): Incomplete installation found at $installPath. Will delete and retry."
923+
Remove-Item $installPath -Recurse -Force
924+
}
925+
926+
if (-not (Test-Path $context.InstallPath)) {
927+
New-Item -Path $context.InstallPath -ItemType Directory -Force | Out-Null
928+
}
929+
930+
New-Item -ItemType File -Path $installIndicatorPath -Force | Out-Null
931+
932+
$zip = [IO.Compression.ZipArchive]::new($stream, 'Read')
933+
[IO.Compression.ZipFileExtensions]::ExtractToDirectory($zip, $installPath)
934+
935+
if ($context.Module.Guid -and $context.Module.Guid -ne [Guid]::Empty) {
936+
Write-Debug "$($context.Module): GUID was specified in Module. Verifying manifest"
937+
$manifestPath = Join-Path $installPath "$($context.Module.Name).psd1"
938+
#FIXME: This should be using Import-ModuleManifest but it needs to be brought in via the ThreadJob context. This will fail if the module has a dynamic manifest.
939+
$manifest = Import-PowerShellDataFile $manifestPath
940+
if ($manifest.Guid -ne $context.Module.Guid) {
941+
Remove-Item $installPath -Force -Recurse
942+
throw [InvalidOperationException]"$($context.Module): The installed package GUID does not match what was in the Module Spec. Expected $($context.Module.Guid) but found $($manifest.Guid) in $($manifestPath). Deleting this module, please check that your GUID specification is correct, or otherwise investigate why the GUID is different."
943+
}
944+
}
912945

913-
if (Test-Path $installIndicatorPath) {
914946
#FIXME: Output inside a threadjob is not surfaced to the user.
915-
Write-Warning "$($context.Module): Incomplete installation found at $installPath. Will delete and retry."
916-
Remove-Item $installPath -Recurse -Force
947+
Write-Debug "Cleanup Nuget Files in $installPath"
948+
if (-not $installPath) { throw 'ModuleDestination was not set. This is a bug, report it' }
949+
Get-ChildItem -Path $installPath | Where-Object {
950+
$_.Name -in '_rels', 'package', '[Content_Types].xml' -or
951+
$_.Name.EndsWith('.nuspec')
952+
} | Remove-Item -Force -Recurse
953+
954+
Remove-Item $installIndicatorPath -Force
955+
return $context
917956
}
918957

919-
if (-not (Test-Path $context.InstallPath)) {
920-
New-Item -Path $context.InstallPath -ItemType Directory -Force | Out-Null
958+
CLEAN {
959+
if ($zip) {$zip.Dispose()}
960+
if ($stream) {$stream.Dispose()}
921961
}
922-
923-
New-Item -ItemType File -Path $installIndicatorPath -Force | Out-Null
924-
925-
$zip = [IO.Compression.ZipArchive]::new($stream, 'Read')
926-
[IO.Compression.ZipFileExtensions]::ExtractToDirectory($zip, $installPath)
927-
#FIXME: Output inside a threadjob is not surfaced to the user.
928-
Write-Debug "Cleanup Nuget Files in $installPath"
929-
if (-not $installPath) { throw 'ModuleDestination was not set. This is a bug, report it' }
930-
Get-ChildItem -Path $installPath | Where-Object {
931-
$_.Name -in '_rels', 'package', '[Content_Types].xml' -or
932-
$_.Name.EndsWith('.nuspec')
933-
} | Remove-Item -Force -Recurse
934-
($zip).Dispose()
935-
($stream).Dispose()
936-
Remove-Item $installIndicatorPath -Force
937-
return $context
938962
}
939963
$installJob
940964
}
@@ -957,6 +981,12 @@ function Install-ModuleFastHelper {
957981
return $installedModules
958982
}
959983
}
984+
CLEAN {
985+
$cancelTokenSource.Dispose()
986+
if ($installJobs) {
987+
$installJobs | Remove-Job -Force
988+
}
989+
}
960990
}
961991

962992
function Import-ModuleManifest {
@@ -1014,6 +1044,7 @@ class ModuleFastInfo: IComparable {
10141044
[uri]$Location
10151045
#TODO: This should be a getter
10161046
[boolean]$IsLocal
1047+
[Guid]$Guid = [Guid]::Empty
10171048

10181049
ModuleFastInfo([string]$Name, [NuGetVersion]$ModuleVersion, [Uri]$Location) {
10191050
$this.Name = $Name
@@ -1228,10 +1259,6 @@ class ModuleFastSpec {
12281259
$this._Name = $TrimmedName
12291260
$this._VersionRange = $Range ?? [VersionRange]::new()
12301261
$this._Guid = $Guid ?? [Guid]::Empty
1231-
# TODO: Fix this check logic
1232-
# if ($this.Guid -ne [Guid]::Empty -and -not $this.Required) {
1233-
# throw 'Cannot specify Guid unless min and max are the same. If you see this, it is probably a bug'
1234-
# }
12351262
}
12361263

12371264
hidden Initialize([ModuleSpecification]$ModuleSpec) {
@@ -1530,10 +1557,7 @@ function Find-LocalModule {
15301557
return
15311558
}
15321559

1533-
#We want to minimize reading the manifest files, so we will do a fast file-based search first and then do a more detailed inspection on high confidence candidate(s). Any module in any folder path that satisfies the spec will be sufficient, we don't care about finding the "latest" version, so we will return the first module that satisfies the spec.
1534-
1535-
#We will store potential candidates in this list, with their evaluated "guessed" version based on the folder name and the path. The first items added to the list should be the highest likelihood candidates in Path priority order, so no sorting should be necessary.
1536-
1560+
#We want to minimize reading the manifest files, so we will do a fast file-based search first and then do a more detailed inspection on high confidence candidate(s). Any module in any folder path that satisfies the spec will be sufficient, we don't care about finding the "latest" version, so we will return the first module that satisfies the spec. We will store potential candidates in this list, with their evaluated "guessed" version based on the folder name and the path. The first items added to the list should be the highest likelihood candidates in Path priority order, so no sorting should be necessary.
15371561

15381562
foreach ($modulePath in $modulePaths) {
15391563
[List[[Tuple[Version, string]]]]$candidatePaths = @()
@@ -1639,6 +1663,10 @@ function Find-LocalModule {
16391663

16401664
#Read the manifest so we can compare prerelease info. If this matches, we have a valid candidate and don't need to check anything further.
16411665
$manifestCandidate = ConvertFrom-ModuleManifest $versionedManifestPath[0]
1666+
if ($ModuleSpec.Guid -and $ModuleSpec.Guid -ne [Guid]::Empty -and $manifestCandidate.Guid -ne $ModuleSpec.Guid) {
1667+
Write-Warning "${ModuleSpec}: A locally installed module $folder that matches the module spec but the manifest GUID $($manifestCandidate.Guid) does not match the expected GUID $($ModuleSpec.Guid) in the spec. Verify your specification is correct otherwise investigate this module for why the GUID does not match."
1668+
continue
1669+
}
16421670
$candidateVersion = $manifestCandidate.ModuleVersion
16431671

16441672
if ($ModuleSpec.SatisfiedBy($candidateVersion)) {
@@ -1842,7 +1870,11 @@ filter ConvertFrom-ModuleManifest {
18421870
$manifestData.PrivateData.PSData.Prerelease
18431871
)
18441872

1845-
return [ModuleFastInfo]::new($ManifestName, $manifestVersion, $ManifestPath)
1873+
$moduleFastInfo = [ModuleFastInfo]::new($ManifestName, $manifestVersion, $ManifestPath)
1874+
if ($manifestVersion.Guid) {
1875+
$moduleFastInfo.Guid = $manifestVersion.Guid
1876+
}
1877+
return $moduleFastInfo
18461878
}
18471879

18481880
#endregion Helpers

ModuleFast.tests.ps1

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,7 @@ Describe 'Install-ModuleFast' -Tag 'E2E' {
475475
}
476476
It 'Only installs once when Update is specified and latest has not changed for multiple modules' {
477477
Install-ModuleFast @imfParams 'Az.Compute', 'Az.CosmosDB' -Update
478-
Install-ModuleFast @imfParams 'Az.Compute', 'Az.CosmosDB' -Update -WhatIf
478+
Install-ModuleFast @imfParams 'Az.Compute', 'Az.CosmosDB' -Update -Plan
479479
| Should -BeNullOrEmpty
480480
}
481481

@@ -534,14 +534,14 @@ Describe 'Install-ModuleFast' -Tag 'E2E' {
534534
}
535535
It 'Doesnt install prerelease if same-version Prerelease already installed' {
536536
Install-ModuleFast @imfParams 'PrereleaseTest=0.0.1-prerelease'
537-
$plan = Install-ModuleFast @imfParams 'PrereleaseTest=0.0.1-prerelease' -WhatIf
537+
$plan = Install-ModuleFast @imfParams 'PrereleaseTest=0.0.1-prerelease' -Plan
538538
$plan | Should -BeNullOrEmpty
539539
}
540540

541541
It 'Installs from <Name> SpecFile' {
542542
$SCRIPT:Mocks = Resolve-Path "$PSScriptRoot/Test/Mocks"
543543
$specFilePath = Join-Path $Mocks $File
544-
$modulesToInstall = Install-ModuleFast @imfParams -Path $specFilePath -WhatIf
544+
$modulesToInstall = Install-ModuleFast @imfParams -Path $specFilePath -Plan
545545
#TODO: Verify individual modules and versions
546546
$modulesToInstall | Should -Not -BeNullOrEmpty
547547
} -TestCases @(
@@ -609,9 +609,20 @@ Describe 'Install-ModuleFast' -Tag 'E2E' {
609609
}
610610
)
611611
}" | Out-File $scriptPath
612-
$modules = Install-ModuleFast @imfParams -Path $scriptPath -WhatIf
612+
$modules = Install-ModuleFast @imfParams -Path $scriptPath -Plan
613613
$modules.count | Should -Be 2
614614
}
615+
It 'Resolves GUID with version range' {
616+
$scriptPath = Join-Path $testDrive 'testscript.ps1'
617+
"#requires -Module @{ModuleName='PreReleaseTest';Guid='7c279caf-00bc-40ae-a1ed-184ad07be1b0';ModuleVersion='0.0.1';MaximumVersion='0.0.2'}" | Out-File $scriptPath
618+
$actual = Install-ModuleFast @imfParams -WarningAction SilentlyContinue -Path $scriptPath -PassThru
619+
$actual.Name | Should -Be 'PrereleaseTest'
620+
$actual.ModuleVersion | Should -Be '0.0.1'
621+
}
622+
It 'Errors if GUID spec is different than installed module' {
623+
{ Install-ModuleFast @imfParams -WarningAction SilentlyContinue -Specification "@{ModuleName='PreReleaseTest';Guid='3cb1a381-5d96-4b56-843e-dd97cf4c6545';ModuleVersion='0.0.1';MaximumVersion='0.0.2'}" -PassThru }
624+
| Should -Throw '*Expected 3cb1a381-5d96-4b56-843e-dd97cf4c6545 but found 7c279caf-00bc-40ae-a1ed-184ad07be1b0*'
625+
}
615626

616627
It 'Writes a CI File' {
617628
Set-Location $testDrive
@@ -653,7 +664,7 @@ Describe 'Install-ModuleFast' -Tag 'E2E' {
653664
Describe 'GitHub Packages' {
654665
It 'Gets Specific Module' {
655666
$credential = [PSCredential]::new('Pester', (Get-Secret -Name 'ReadOnlyPackagesGithubPAT'))
656-
$actual = Install-ModuleFast @imfParams -Specification 'PrereleaseTest=0.0.1' -Source 'https://nuget.pkg.github.com/justingrote/index.json' -Credential $credential -WhatIf
667+
$actual = Install-ModuleFast @imfParams -Specification 'PrereleaseTest=0.0.1' -Source 'https://nuget.pkg.github.com/justingrote/index.json' -Credential $credential -Plan
657668
$actual.Name | Should -Be 'PrereleaseTest'
658669
$actual.ModuleVersion -as 'NuGet.Versioning.NuGetVersion' | Should -Be '0.0.1'
659670
}

0 commit comments

Comments
 (0)