diff --git a/.ci/ci.yml b/.ci/ci.yml index 8d6c431ac..7add12474 100644 --- a/.ci/ci.yml +++ b/.ci/ci.yml @@ -31,6 +31,10 @@ stages: steps: + - task: UseDotNet@2 + inputs: + useGlobalJson: true + - pwsh: | Get-ChildItem -Path env: displayName: Capture environment for build @@ -137,3 +141,10 @@ stages: jobName: TestPkgWinMacOS displayName: PowerShell Core on macOS imageName: macOS-latest + + - template: test.yml + parameters: + jobName: TestPkgWinAzAuth + displayName: AzAuth PowerShell Core on Windows + imageName: windows-latest + useAzAuth: true diff --git a/.ci/test.yml b/.ci/test.yml index 541d72542..cd8c8c88f 100644 --- a/.ci/test.yml +++ b/.ci/test.yml @@ -4,6 +4,7 @@ parameters: displayName: PowerShell Core on Windows powershellExecutable: pwsh buildDirectory: '.' + useAzAuth: false jobs: - job: ${{ parameters.jobName }} @@ -11,6 +12,15 @@ jobs: vmImage: ${{ parameters.imageName }} displayName: ${{ parameters.displayName }} steps: + - ${{ parameters.powershellExecutable }}: | + Install-Module -Name 'Microsoft.PowerShell.SecretManagement' -force -SkipPublisherCheck -AllowClobber + Install-Module -Name 'Microsoft.PowerShell.SecretStore' -force -SkipPublisherCheck -AllowClobber + $vaultPassword = ConvertTo-SecureString $("a!!"+ (Get-Random -Maximum ([int]::MaxValue))) -AsPlainText -Force + Set-SecretStoreConfiguration -Authentication None -Interaction None -Confirm:$false -Password $vaultPassword + Register-SecretVault -Name SecretStore -ModuleName Microsoft.PowerShell.SecretStore -DefaultVault + displayName: Install Secret store + condition: eq(${{ parameters.useAzAuth }}, false) + - task: DownloadBuildArtifacts@0 displayName: 'Download artifacts' inputs: @@ -38,7 +48,7 @@ jobs: - ${{ parameters.powershellExecutable }}: | $modulePath = Join-Path -Path $env:AGENT_TEMPDIRECTORY -ChildPath 'TempModules' Write-Verbose -Verbose "Install Microsoft.PowerShell.PSResourceGet to temp module path" - Save-Module -Name Microsoft.PowerShell.PSResourceGet -MinimumVersion 0.9.0-rc1 -Path $modulePath -AllowPrerelease -Force + Save-Module -Name Microsoft.PowerShell.PSResourceGet -Path $modulePath -Force -Verbose Write-Verbose -Verbose "Install Pester 4.X to temp module path" Save-Module -Name "Pester" -MaximumVersion 4.99 -Path $modulePath -Force displayName: Install Microsoft.PowerShell.PSResourceGet and Pester @@ -49,10 +59,69 @@ jobs: Write-Verbose -Verbose "Importing build utilities (buildtools.psd1)" Import-Module -Name (Join-Path -Path '${{ parameters.buildDirectory }}' -ChildPath 'buildtools.psd1') -Force # - Install-ModulePackageForTest -PackagePath "$(System.ArtifactsDirectory)" + Install-ModulePackageForTest -PackagePath "$(System.ArtifactsDirectory)" -ErrorAction stop -Verbose displayName: Install module for test from downloaded artifact workingDirectory: ${{ parameters.buildDirectory }} + - task: AzurePowerShell@5 + inputs: + azureSubscription: PSResourceGetACR + azurePowerShellVersion: LatestVersion + ScriptType: InlineScript + pwsh: true + inline: | + Write-Verbose -Verbose "Getting Azure Container Registry" + Get-AzContainerRegistry -ResourceGroupName 'PSResourceGet' -Name 'psresourcegettest' | Select-Object -Property * + Write-Verbose -Verbose "Setting up secret for Azure Container Registry" + $azt = Get-AzAccessToken + $tenantId = $azt.TenantID + Set-Secret -Name $tenantId -Secret $azt.Token -Verbose + $vstsCommandString = "vso[task.setvariable variable=TenantId]$tenantId" + Write-Host "sending " + $vstsCommandString + Write-Host "##$vstsCommandString" + displayName: 'Setup Azure Container Registry secret' + condition: eq(${{ parameters.useAzAuth }}, false) + + - powershell: | + # Set environment variable to identify in tests that secret store should not be used. + $vstsCommandString = "vso[task.setvariable variable=UsingAzAuth]true" + Write-Host "sending " + $vstsCommandString + Write-Host "##$vstsCommandString" + displayName: 'Set UsingAzAuth environment variable' + condition: eq(${{ parameters.useAzAuth }}, true) + + - task: AzurePowerShell@5 + inputs: + azureSubscription: PSResourceGetACR + azurePowerShellVersion: LatestVersion + ScriptType: InlineScript + inline: | + $acrRepositoryNamesFolder = Join-Path -Path ([Environment]::GetFolderPath([System.Environment+SpecialFolder]::LocalApplicationData)) -ChildPath 'TempModules' + Write-Verbose -Verbose "Creating new folder for acr repository names file to be placed with path: $acrRepositoryNamesFolder" + $null = New-Item -Path $acrRepositoryNamesFolder -ItemType Directory + $acrRepositoryNamesFilePath = Join-Path -Path $acrRepositoryNamesFolder -ChildPath 'ACRTestRepositoryNames.txt' + New-Item -Path $acrRepositoryNamesFilePath + displayName: 'Upload empty file for ACR functional tests to write test repository names to' + + - task: AzurePowerShell@5 + inputs: + azureSubscription: PSResourceGetACR + azurePowerShellVersion: LatestVersion + ScriptType: InlineScript + inline: | + $modulePath = Join-Path -Path $env:AGENT_TEMPDIRECTORY -ChildPath 'TempModules' + $env:PSModulePath = $modulePath + [System.IO.Path]::PathSeparator + $env:PSModulePath + Write-Verbose -Verbose "Importing build utilities (buildtools.psd1)" + Import-Module -Name (Join-Path -Path '${{ parameters.buildDirectory }}' -ChildPath 'buildtools.psd1') -Force + Invoke-ModuleTestsACR -Type Functional + env: + MAPPED_GITHUB_PAT: $(github_pat) + MAPPED_ADO_PUBLIC_PAT: $(ado_public_pat) + MAPPED_ADO_PRIVATE_PAT: $(ado_private_pat) + MAPPED_ADO_PRIVATE_REPO_URL: $(ado_private_repo_url) + displayName: 'Execute functional tests with AzAuth' + condition: eq(${{ parameters.useAzAuth }}, true) + - ${{ parameters.powershellExecutable }}: | $modulePath = Join-Path -Path $env:AGENT_TEMPDIRECTORY -ChildPath 'TempModules' $env:PSModulePath = $modulePath + [System.IO.Path]::PathSeparator + $env:PSModulePath @@ -67,5 +136,21 @@ jobs: displayName: Execute functional tests workingDirectory: ${{ parameters.buildDirectory }} errorActionPreference: continue - condition: succeededOrFailed() + condition: eq(${{ parameters.useAzAuth }}, false) + - task: AzurePowerShell@5 + inputs: + azureSubscription: PSResourceGetACR + azurePowerShellVersion: LatestVersion + ScriptType: InlineScript + inline: | + $registryName = 'psresourcegettest' + $acrRepositoryNamesFolder = Join-Path -Path ([Environment]::GetFolderPath([System.Environment+SpecialFolder]::LocalApplicationData)) -ChildPath 'TempModules' + $acrRepositoryNamesFilePath = Join-Path -Path $acrRepositoryNamesFolder -ChildPath 'ACRTestRepositoryNames.txt' + $repositoryNames = Get-Content -Path $acrRepositoryNamesFilePath + foreach ($name in $repositoryNames) + { + # Delete images in the repository (including tags, unique layers, manifests) created for ACR tests + Remove-AzContainerRegistryRepository -Name $name -RegistryName $registryName + } + displayName: 'Delete test repositories from ACR' diff --git a/.config/tsaoptions.json b/.config/tsaoptions.json new file mode 100644 index 000000000..692eaec1f --- /dev/null +++ b/.config/tsaoptions.json @@ -0,0 +1,10 @@ +{ + "instanceUrl": "https://msazure.visualstudio.com", + "projectName": "One", + "areaPath": "One\\MGMT\\Compute\\Powershell\\Powershell\\PowerShell Core", + "notificationAliases": [ + "adityap@microsoft.com", + "americks@microsoft.com", + "annavied@microsoft.com" + ] +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..686da5c9f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,199 @@ +# EditorConfig is awesome: https://EditorConfig.org +# .NET coding convention settings for EditorConfig +# https://learn.microsoft.com/visualstudio/ide/editorconfig-code-style-settings-reference +# +# This file was taken from PowerShell/PowerShell 2024-07-13 +# https://github.com/PowerShell/PowerShell/blob/master/.editorconfig + +# Top-most EditorConfig file +root = true + +[*] +charset = utf-8 +# indent_size intentionally not specified in this section +indent_style = space +insert_final_newline = true + +# Source code +[*.{cs,ps1,psd1,psm1}] +indent_size = 4 + +# Shell scripts +[*.sh] +end_of_line = lf +indent_size = 4 + +# Xml project files +[*.{csproj,resx,ps1xml}] +indent_size = 2 + +# Data serialization +[*.{json,yaml,yml}] +indent_size = 2 + +# Markdown +[*.md] +indent_size = 2 + +# Xml files +[*.{resx,ruleset,stylecop,xml,xsd,xsl}] +indent_size = 2 + +# Xml config files +[*.{props,targets,config,nuspec}] +indent_size = 2 + +[*.tsv] +indent_style = tab + +# Dotnet code style settings: +[*.cs] +# Sort using and Import directives with System.* appearing first +dotnet_sort_system_directives_first = true + +file_header_template = Copyright (c) Microsoft Corporation.\nLicensed under the MIT License. + +# Modifier preferences +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion + +# Avoid "this." and "Me." if not necessary +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion + +# Use language keywords instead of framework type names for type references +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion + +# Name all constant fields using PascalCase +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style + +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.required_modifiers = const + +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# Static fields should have s_ prefix +dotnet_naming_rule.static_fields_should_have_prefix.severity = suggestion +dotnet_naming_rule.static_fields_should_have_prefix.symbols = static_fields +dotnet_naming_rule.static_fields_should_have_prefix.style = static_prefix_style + +dotnet_naming_symbols.static_fields.applicable_kinds = field +dotnet_naming_symbols.static_fields.required_modifiers = static +dotnet_naming_symbols.static_fields.applicable_accessibilities = private, internal, private_protected + +dotnet_naming_style.static_prefix_style.required_prefix = s_ +dotnet_naming_style.static_prefix_style.capitalization = camel_case + +# Internal and private fields should be _camelCase +dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion +dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields +dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style + +dotnet_naming_symbols.private_internal_fields.applicable_kinds = field +dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal + +dotnet_naming_style.camel_case_underscore_style.required_prefix = _ +dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case + +# Suggest more modern language features when available +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_readonly_field = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_auto_properties = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion + +dotnet_code_quality_unused_parameters = non_public:suggestion + +# CSharp code style settings: +[*.cs] + +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = false +csharp_prefer_braces = true:silent + +csharp_prefer_static_local_function = true:suggestion +csharp_prefer_simple_using_statement = false:none +csharp_style_prefer_switch_expression = true:suggestion +csharp_style_prefer_range_operator = false:none +csharp_style_prefer_index_operator = false:none +csharp_style_pattern_local_over_anonymous_function = false:none + +csharp_using_directive_placement = outside_namespace:suggestion + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current + +# Only use var when it's obvious what the variable type is +csharp_style_var_for_built_in_types = false:none +csharp_style_var_when_type_is_apparent = false:none +csharp_style_var_elsewhere = false:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_local_functions = true:silent + +# Prefer method-like constructs to have a block body +csharp_style_expression_bodied_methods = false:none +csharp_style_expression_bodied_constructors = false:none +csharp_style_expression_bodied_operators = false:none + +# Prefer property-like constructs to have an expression-body +csharp_style_expression_bodied_properties = true:none +csharp_style_expression_bodied_indexers = true:none +csharp_style_expression_bodied_accessors = true:none + +# Suggest more modern language features when available +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = do_not_ignore +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Newline settings +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..1b53fe53a --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,7 @@ +# https://help.github.com/articles/about-codeowners/ + +# These owners will be the default owners for everything in +# the repo. Unless a later match takes precedence, +# the following owners will be requested for +# review when someone opens a pull request. +* @anamnavi @alerickson @adityapatwardhan @SydneyhSmith diff --git a/.github/dependabot.yml b/.github/dependabot.yml index cce6e4180..1bb37493a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,3 +14,18 @@ updates: directory: "test/perf/benchmarks" # Location of package manifests schedule: interval: "daily" + + - package-ecosystem: github-actions + directory: / + schedule: + interval: daily + + - package-ecosystem: nuget + directory: / + schedule: + interval: daily + + - package-ecosystem: nuget + directory: /test/perf/benchmarks + schedule: + interval: daily diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index d261f7cdb..ba7f2ffb2 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,62 +1,64 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. name: "CodeQL" on: push: - branches: [master] + branches: [ master ] pull_request: - # The branches below must be a subset of the branches above - branches: [master] - #schedule: - # - cron: '0 7 * * 0' + branches: [ master ] + +defaults: + run: + shell: pwsh + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + +permissions: + contents: read jobs: analyze: + permissions: + actions: read # for github/codeql-action/init to get workflow details + contents: read # for actions/checkout to fetch code + security-events: write # for github/codeql-action/analyze to upload SARIF results name: Analyze runs-on: ubuntu-latest strategy: fail-fast: false matrix: - # Override automatic language detection by changing the below list - # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] - language: ['csharp'] - # Learn more... - # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection + include: + - language: csharp + build-mode: manual + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 with: languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language + - run: | + Get-ChildItem . + name: Capture env - #- run: | - # make bootstrap - # make release + - run: | + .\build.ps1 -Clean -Build + name: Build - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 000000000..a52521621 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,22 @@ +# Dependency Review Action +# +# This Action will scan dependency manifest files that change as part of a Pull Request, +# surfacing known-vulnerable versions of the packages declared or updated in the PR. +# Once installed, if the workflow run is marked as required, +# PRs introducing known-vulnerable packages will be blocked from merging. +# +# Source repository: https://github.com/actions/dependency-review-action +name: 'Dependency Review' +on: [pull_request] + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: 'Checkout Repository' + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + - name: 'Dependency Review' + uses: actions/dependency-review-action@0efb1d1d84fc9633afcdaad14c485cbbc90ef46c # v2.5.1 diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml new file mode 100644 index 000000000..75ca49424 --- /dev/null +++ b/.github/workflows/scorecards.yml @@ -0,0 +1,71 @@ +# This workflow uses actions that are not certified by GitHub. They are provided +# by a third-party and are governed by separate terms of service, privacy +# policy, and support documentation. + +name: Scorecard supply-chain security +on: + # For Branch-Protection check. Only the default branch is supported. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection + branch_protection_rule: + # To guarantee Maintained check is occasionally updated. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained + schedule: + - cron: '20 7 * * 2' + push: + branches: ["master"] + +# Declare default permissions as read only. +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + # Needed to upload the results to code-scanning dashboard. + security-events: write + # Needed to publish results and get a badge (see publish_results below). + id-token: write + contents: read + actions: read + + steps: + - name: "Checkout code" + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + with: + persist-credentials: false + + - name: "Run analysis" + uses: ossf/scorecard-action@99c53751e09b9529366343771cc321ec74e9bd3d # v2.0.6 + with: + results_file: results.sarif + results_format: sarif + # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: + # - you want to enable the Branch-Protection check on a *public* repository, or + # - you are installing Scorecards on a *private* repository + # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. + # repo_token: ${{ secrets.SCORECARD_TOKEN }} + + # Public repositories: + # - Publish results to OpenSSF REST API for easy access by consumers + # - Allows the repository to include the Scorecard badge. + # - See https://github.com/ossf/scorecard-action#publishing-results. + # For private repositories: + # - `publish_results` will always be set to `false`, regardless + # of the value entered here. + publish_results: true + + # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF + # format to the repository Actions tab. + - name: "Upload artifact" + uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + # Upload the results to GitHub's code scanning dashboard. + - name: "Upload to code-scanning" + uses: github/codeql-action/upload-sarif@ceaec5c11a131e0d282ff3b6f095917d234caace # v2.25.3 + with: + sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index ab0db5255..e035e30c7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,8 @@ srcOld/code/bin srcOld/code/obj out test/**/obj -test/**/bin \ No newline at end of file +test/**/bin +.vs +.vscode +src/code/.vs +test/testFiles/testScripts/test.ps1 \ No newline at end of file diff --git a/.pipelines/PSResourceGet-Official.yml b/.pipelines/PSResourceGet-Official.yml new file mode 100644 index 000000000..cc51e2e78 --- /dev/null +++ b/.pipelines/PSResourceGet-Official.yml @@ -0,0 +1,326 @@ +################################################################################# +# OneBranch Pipelines # +# This pipeline was created by EasyStart from a sample located at: # +# https://aka.ms/obpipelines/easystart/samples # +# Documentation: https://aka.ms/obpipelines # +# Yaml Schema: https://aka.ms/obpipelines/yaml/schema # +# Retail Tasks: https://aka.ms/obpipelines/tasks # +# Support: https://aka.ms/onebranchsup # +################################################################################# +name: PSResourceGet-Release-$(Build.BuildId) +trigger: none # https://aka.ms/obpipelines/triggers +pr: + branches: + include: + - master + - release* +parameters: # parameters are shown up in ADO UI in a build queue time +- name: 'debug' + displayName: 'Enable debug output' + type: boolean + default: false + +variables: + - name: DOTNET_CLI_TELEMETRY_OPTOUT + value: 1 + - name: POWERSHELL_TELEMETRY_OPTOUT + value: 1 + - name: WindowsContainerImage + value: onebranch.azurecr.io/windows/ltsc2022/vse2022:latest # Docker image which is used to build the project https://aka.ms/obpipelines/containers + +resources: + repositories: + - repository: onebranchTemplates + type: git + name: OneBranch.Pipelines/GovernedTemplates + ref: refs/heads/main + +extends: + template: v2/OneBranch.Official.CrossPlat.yml@onebranchTemplates # https://aka.ms/obpipelines/templates + parameters: + featureFlags: + WindowsHostVersion: '1ESWindows2022' + customTags: 'ES365AIMigrationTooling' + release: + category: NonAzure + globalSdl: + disableLegacyManifest: true + sbom: + enabled: true + packageName: Microsoft.PowerShell.PSResourceGet + codeql: + compiled: + enabled: true + asyncSdl: # https://aka.ms/obpipelines/asyncsdl + enabled: true + forStages: [stagebuild] + credscan: + enabled: true + scanFolder: $(Build.SourcesDirectory)\PSResourceGet + binskim: + enabled: true + apiscan: + enabled: false + + stages: + - stage: stagebuild + displayName: Build and Package Microsoft.PowerShell.PSResourceGet + jobs: + - job: jobbuild + displayName: Build Microsoft.PowerShell.PSResourceGet Files + variables: # More settings at https://aka.ms/obpipelines/yaml/jobs + - name: ob_outputDirectory + value: '$(Build.ArtifactStagingDirectory)/ONEBRANCH_ARTIFACT' + - name: repoRoot + value: $(Build.SourcesDirectory)\PSResourceGet + - name: ob_sdl_tsa_configFile + value: $(Build.SourcesDirectory)\PSResourceGet\.config\tsaoptions.json + - name: signSrcPath + value: $(repoRoot)/out + - name: depsPath + value: $(signSrcPath)\Microsoft.PowerShell.PSResourceGet\Dependencies + - name: ob_sdl_sbom_enabled + value: true + - name: ob_signing_setup_enabled + value: true + #CodeQL tasks added manually to workaround signing failures + - name: ob_sdl_codeql_compiled_enabled + value: false + pool: + type: windows + steps: + - checkout: self + env: + ob_restore_phase: true # Set ob_restore_phase to run this step before '🔒 Setup Signing' step. + + - pwsh: | + if (-not (Test-Path $(repoRoot)/.config/tsaoptions.json)) { + Get-ChildItem $(Build.SourcesDirectory) -recurse -ErrorAction SilentlyContinue + throw "tsaoptions.json does not exist under $(repoRoot)/.config" + } + displayName: Test if tsaoptions.json exists + env: + ob_restore_phase: true # Set ob_restore_phase to run this step before '🔒 Setup Signing' step. + + - pwsh: | + Get-ChildItem env: + displayName: Capture Environment + env: + ob_restore_phase: true # Set ob_restore_phase to run this step before '🔒 Setup Signing' step. + + - task: UseDotNet@2 + displayName: 'Install .NET dependencies' + env: + ob_restore_phase: true # Set ob_restore_phase to run this step before '🔒 Setup Signing' step. + inputs: + packageType: 'sdk' + useGlobalJson: true + workingDirectory: $(repoRoot) + + - task: CodeQL3000Init@0 # Add CodeQL Init task right before your 'Build' step. + env: + ob_restore_phase: true # Set ob_restore_phase to run this step before '🔒 Setup Signing' step. + inputs: + Enabled: true + AnalyzeInPipeline: true + Language: csharp + + # this is installing .NET + - pwsh: | + Set-Location "$(repoRoot)" + try { ./build.ps1 -Build -Clean -BuildConfiguration Release -BuildFramework 'net472'} catch { throw $_ } + displayName: Execute build + env: + ob_restore_phase: true # Set ob_restore_phase to run this step before '🔒 Setup Signing' step. + + - task: CodeQL3000Finalize@0 # Add CodeQL Finalize task right after your 'Build' step. + condition: always() + env: + ob_restore_phase: true # Set ob_restore_phase to run this step before '🔒 Setup Signing' step. + + - task: onebranch.pipeline.signing@1 + displayName: Sign 1st party files + inputs: + command: 'sign' + signing_profile: external_distribution + files_to_sign: '**\*.ps1;**\*.psd1;**\*.psm1;**\*.ps1xml;**\Microsoft*.dll' + search_root: $(signSrcPath) + + - pwsh: | + $unsignedDepsPath = Join-Path -Path $(signSrcPath) -ChildPath "Microsoft.PowerShell.PSResourceGet" -AdditionalChildPath "UnsignedDependencies" + New-Item -Path $unsignedDepsPath -ItemType Directory -Force + + Get-ChildItem -Path $(depsPath) -Filter '*.dll' | Foreach-Object { + $sig = Get-AuthenticodeSignature -FilePath $_.FullName + if ($sig.Status -ne 'Valid' -or $sig.SignerCertificate.Subject -notlike '*Microsoft*' -or $sig.SignerCertificate.Issuer -notlike '*Microsoft Code Signing PCA*') { + # Copy for third party signing + Copy-Item -Path $_.FullName -Dest $unsignedDepsPath -Force -Verbose + } + } + displayName: Find all 3rd party files that need to be signed + + - task: onebranch.pipeline.signing@1 + displayName: Sign 3rd Party files + inputs: + command: 'sign' + signing_profile: 135020002 + files_to_sign: '*.dll' + search_root: $(signSrcPath)/Microsoft.PowerShell.PSResourceGet/UnsignedDependencies + + - pwsh: | + $newlySignedDepsPath = Join-Path -Path $(signSrcPath) -ChildPath "Microsoft.PowerShell.PSResourceGet" -AdditionalChildPath "UnsignedDependencies" + Get-ChildItem -Path $newlySignedDepsPath -Filter '*.dll' | Foreach-Object { + $sig = Get-AuthenticodeSignature -FilePath $_.FullName + if ($sig.Status -ne 'Valid' -or $sig.SignerCertificate.Subject -notlike '*Microsoft*' -or $sig.SignerCertificate.Issuer -notlike '*Microsoft Windows Production PCA*') { + Write-Error "File $($_.FileName) is not signed by Microsoft" + } + else { + Copy-Item -Path $_.FullName -Dest $(depsPath) -Force -Verbose + } + } + Remove-Item -Path $newlySignedDepsPath -Recurse -Force + displayName: Validate 3rd party files were signed + + - task: CopyFiles@2 + displayName: "Copy signed files to ob_outputDirectory - '$(ob_outputDirectory)'" + inputs: + SourceFolder: "$(signSrcPath)" + Contents: '**' + TargetFolder: $(ob_outputDirectory) + + - pwsh: | + Write-Host "Displaying contents of signSrcPath:" + Get-ChildItem $(signSrcPath) -Recurse + Write-Host "Displaying contents of ob_outputDirectory:" + Get-ChildItem $(ob_outputDirectory) -Recurse + displayName: Get contents of dirs with signed files + + - job: nupkg + dependsOn: jobbuild + displayName: Package Microsoft.PowerShell.PSResourceGet + variables: + - name: ob_outputDirectory + value: '$(Build.ArtifactStagingDirectory)/ONEBRANCH_ARTIFACT' + - name: repoRoot + value: $(Build.SourcesDirectory)\PSResourceGet + - name: ob_sdl_tsa_configFile + value: $(Build.SourcesDirectory)\PSResourceGet\.config\tsaoptions.json + # Disable because SBOM was already built in the previous job + - name: ob_sdl_sbom_enabled + value: true + - name: signOutPath + value: $(repoRoot)/signed + - name: ob_signing_setup_enabled + value: true + # This job is not compiling code, so disable codeQL + - name: ob_sdl_codeql_compiled_enabled + value: false + + pool: + type: windows + steps: + - checkout: self + + - pwsh: | + if (-not (Test-Path $(repoRoot)/.config/tsaoptions.json)) { + Get-ChildItem $(Build.SourcesDirectory) -recurse -ErrorAction SilentlyContinue + throw "tsaoptions.json does not exist under $(repoRoot)/.config" + } + displayName: Test if tsaoptions.json exists + + - task: DownloadPipelineArtifact@2 + displayName: 'Download build files' + inputs: + targetPath: $(signOutPath) + artifact: drop_stagebuild_jobbuild + + - pwsh: | + Set-Location "$(signOutPath)" + Write-Host "Contents of signOutPath:" + Get-ChildItem $(signOutPath) -Recurse + displayName: Capture artifacts directory structure + + - pwsh: | + # This need to be done before set-location so the module from PSHome is loaded + Import-Module -Name Microsoft.PowerShell.PSResourceGet -Force + + Set-Location "$(signOutPath)\Microsoft.PowerShell.PSResourceGet" + $null = New-Item -ItemType Directory -Path "$(signOutPath)\PublishedNupkg" -Force + + Register-PSResourceRepository -Name 'localRepo' -Uri "$(signOutPath)\PublishedNupkg" + Publish-PSResource -Path "$(signOutPath)\Microsoft.PowerShell.PSResourceGet" -Repository 'localRepo' -Verbose + displayName: Create nupkg for publishing + + - task: onebranch.pipeline.signing@1 + displayName: Sign nupkg + inputs: + command: 'sign' + signing_profile: external_distribution + files_to_sign: '**\*.nupkg' + search_root: "$(signOutPath)\PublishedNupkg" + + - pwsh: | + Set-Location "$(signOutPath)\PublishedNupkg" + Write-Host "Contents of signOutPath:" + Get-ChildItem "$(signOutPath)" -Recurse + displayName: Find Nupkg + + - task: CopyFiles@2 + displayName: "Copy nupkg to ob_outputDirectory - '$(ob_outputDirectory)'" + inputs: + Contents: $(signOutPath)\PublishedNupkg\Microsoft.PowerShell.PSResourceGet.*.nupkg + TargetFolder: $(ob_outputDirectory) + + - pwsh: | + Write-Host "Contents of ob_outputDirectory:" + Get-ChildItem "$(ob_outputDirectory)" -Recurse + displayName: Find Signed Nupkg + + - stage: release + displayName: Release PSResourceGet + dependsOn: stagebuild + variables: + version: $[ stageDependencies.build.main.outputs['package.version'] ] + drop: $(Pipeline.Workspace)/drop_stagebuild_nupkg + ob_release_environment: 'Production' + + jobs: + - job: validation + displayName: Manual validation + pool: + type: server + timeoutInMinutes: 1440 + steps: + - task: ManualValidation@0 + displayName: Wait 24 hours for validation + inputs: + instructions: Please validate the release + timeoutInMinutes: 1440 + + - job: PSGalleryPublish + displayName: Publish to PSGallery + dependsOn: validation + templateContext: + inputs: + - input: pipelineArtifact + artifactName: drop_stagebuild_nupkg + pool: + type: release + os: windows + variables: + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)/ONEBRANCH_ARTIFACT' + steps: + - task: PowerShell@2 + inputs: + targetType: 'inline' + script: | + Get-ChildItem "$(Pipeline.Workspace)/" -Recurse + displayName: Find signed Nupkg + + - task: NuGetCommand@2 + displayName: Push PowerShellGet module artifacts to PSGallery feed + inputs: + command: push + packagesToPush: '$(Pipeline.Workspace)\PSResourceGet\signed\PublishedNupkg\Microsoft.PowerShell.PSResourceGet.*.nupkg' + nuGetFeedType: external + publishFeedCredentials: PSGet-PSGalleryPush diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 152354da2..d2f30467c 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -3,13 +3,12 @@ // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp // List of extensions which should be recommended for users of this workspace. "recommendations": [ + "EditorConfig.EditorConfig", + "ms-dotnettools.csdevkit", "ms-dotnettools.csharp", - "ms-vscode.powershell-preview", - "patcx.vscode-nuget-gallery", - "fudge.auto-using" + "ms-vscode.powershell", + "patcx.vscode-nuget-gallery" ], // List of extensions recommended by VS Code that should not be recommended for users of this workspace. - "unwantedRecommendations": [ - "ms-vscode.powershell" - ] -} \ No newline at end of file + "unwantedRecommendations": [] +} diff --git a/.vscode/launch.json b/.vscode/launch.json index 036014deb..82c604272 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -17,7 +17,7 @@ "console": "externalTerminal", "stopAtEntry": false, "logging": { - "engineLogging": false, + "logging.diagnosticsLog.protocolMessages": false, "moduleLoad": false, "exceptions": false, "browserStdOut": false @@ -30,4 +30,4 @@ "processId": "${command:pickProcess}" } ] -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 5c9c11754..1dea6f957 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "csharp.semanticHighlighting.enabled": true, + "dotnet.automaticallyCreateSolutionInWorkspace": false, "omnisharp.enableEditorConfigSupport": true, "omnisharp.enableRoslynAnalyzers": true -} \ No newline at end of file +} diff --git a/CHANGELOG.md b/CHANGELOG.md index a3003387a..08dacccbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,400 +1 @@ -# CHANGELOG -## 1.0.0 - -### New Features -- Add `ApiVersion` parameter for `Register-PSResourceRepository` (#1431) - -### Bug Fixes -- Automatically set the ApiVersion to v2 for repositories imported from PowerShellGet (#1430) -- Bug fix ADO v2 feed installation failures (#1429) -- Bug fix Artifactory v2 endpoint failures (#1428) -- Bug fix Artifactory v3 endpoint failures (#1427) -- Bug fix `-RequiredResource` silent failures (#1426) -- Bug fix for v2 repository returning extra packages for `-Tag` based search with `-Prerelease` (#1405) - -## 0.9.0-rc1 - -### Bug Fixes -- Bug fix for using `Import-PSGetRepository` in Windows PowerShell (#1390) -- Add error handling when searching for unlisted package versions (#1386) -- Bug fix for deduplicating dependencies found from `Find-PSResource` (#1382) -- Added support for non-PowerShell Gallery v2 repositories (#1380) -- Bug fix for setting 'unknown' repository `APIVersion` (#1377) -- Bug fix for saving a script with `-IncludeXML` parameter (#1375) -- Bug fix for v3 server logic to properly parse inner @id element (#1374) -- Bug fix to write warning instead of error when package is already installed (#1367) - -## 0.5.24-beta24 - -### Bug Fixes -- Detect empty V2 server responses at ServerApiCall level instead of ResponseUtil level (#1358) -- Bug fix for finding all versions of a package returning correct results and incorrect "package not found" error (#1356) -- Bug fix for installing or saving a pkg found in lower priority repository (#1350) -- Ensure `-Prerelease` is not empty or whitespace for `Update-PSModuleManifest` (#1348) -- Bug fix for saving `Az` module dependencies (#1343) -- Bug fix for `Find-PSResource` repository looping to to return matches from all repositories (#1342) -- Update error handling for Tags, Commands, and DSCResources when searching across repositories (#1339) -- Update `Find-PSResource` looping and error handling to account for multiple package names (#1338) -- Update error handling for `Find-PSResource` using V2 server endpoint repositories (#1329) -- Bug fix for searching through multiple repositories when some repositories do not contain the specified package (#1328) -- Add parameters to `Install-PSResource` verbose message (#1327) -- Bug fix for parsing required modules when publishing (#1326) -- Bug fix for saving dependency modules in version range format (#1323) -- Bug fix for `Install-PSResource` failing to find prerelease dependencies (#1322) -- Bug fix for updating to a new version of a prerelease module (#1320) -- Fix for error message when DSCResource is not found (#1317) -- Add error handling for local repository pattern based searching (#1316) -- `Set-PSResourceRepository` run without `-ApiVersion` paramater no longer resets the property for the repository (#1310) - -## 0.5.23-beta23 - -### Breaking Changes - - -### New Features -- *-PSResourceRepository -Uri now accepting PSPaths (#1269) -- Add aliases for Install-PSResource, Find-PSResource, Update-PSResource, Publish-PSResource (#1264) -- Add custom user agent string to API calls (#1260) -- Support install for NuGet.Server application hosted feed (#1253) -- Add support for NuGet.Server application hosted feeds (#1236) -- Add Import-PSGetRepository function to import existing v2 PSRepositories into PSResourceRepositories. (#1221) -- Add 'Get-PSResource' alias to 'Get-InstalledPSResource' (#1216) -- Add -ApiVersion parameter to Set-PSResourceRepository (#1207) -- Add support for FindNameGlobbing scenarios (i.e -Name az*) for MyGet server repository (V3) (#1202) - - -### Bug Fixes -- Better error handling for scenario where repo ApiVersion is unknown and allow for PSPaths as URI for registered repositories (#1288) -- Bugfix for Uninstall should be able to remove older versions of a package that are not a dependency (#1287) -- Bugfix for Publish finding prerelease dependency versions. (#1283) -- Fix Pagination for V3 search with globbing scenarios (#1277) -- Update message for -WhatIf in Install-PSResource, Save-PSResource, and Update-PSResource (#1274) -- Bug fix for publishing with ExternalModuleDependencies (#1271) -- Support Credential Persistence for Publish-PSResource (#1268) -- Update Save-PSResource -Path param so it defaults to the current working directory (#1265) -- Update dependency error message in Publish-PSResource (#1263) -- Bug fixes for script metadata (#1259) -- Fix error message for Publish-PSResource for MyGet.org feeds (#1256) -- Bug fix for version ranges with prerelease versions not returning the correct versions (#1255) -- Bug fix for file path version must match psd1 version error when publishing (#1254) -- Bug fix for searching through local repositories with -Type parameter (#1252) -- Allow environment variables in module manifests (#1249 Thanks @ThomasNieto!) -- Updating prerelease version should update to latest prerelease version (#1238) -- Fix InstallHelper call to GetEnvironmentVariable() on Unix (#1237) -- Update build script to resolve module loading error (#1234) -- Enable UNC Paths for local repositories, source directories and destination directories (#1229 Thanks @FriedrichWeinmann!) -- Improve better error handling for -Path in Publish-PSResource (#1227) -- Bug fix for RequireLicenseAcceptance in Publish-PSResource (#1225) -- Provide clearer error handling for V3 Publish support (#1224) -- Fix bug with version parsing in Publish-PSResource (#1223) -- Improve error handling for Find-PSResource (#1222) -- Add error handling to Get-InstalledPSResource and Find-PSResource (#1217) -- Improve error handling in Uninstall-PSResource (#1215) -- Change resolved paths to use GetResolvedProviderPathFromPSPath (#1209) -- Bug fix for Get-InstalledPSResource returning type of scripts as module (#1198) - - -# CHANGELOG -## 0.5.22-beta22 - -### Breaking Changes -- PowerShellGet is now PSResourceGet! (#1164) -- Update-PSScriptFile is now Update-PSScriptFileInfo (#1140) -- New-PSScriptFile is now New-PSScriptFileInfo (#1140) -- Update-ModuleManifest is now Update-PSModuleManifest (#1139) -- -Tags parameter changed to -Tag in New-PSScriptFile, Update-PSScriptFileInfo, and Update-ModuleManifest (#1123) -- Change the type of -InputObject from PSResourceInfo to PSResourceInfo[] for Install-PSResource, Save-PSResource, and Uninstall-PSResource (#1124) - -- PSModulePath is no longer referenced when searching paths (#1154) - -### New Features -- Support for Azure Artifacts, GitHub Packages, and Artifactory (#1167, #1180, #1183) - - -### Bug Fixes -- Filter out unlisted packages (#1172, #1161) -- Add paging for V3 server requests (#1170) -- Support for floating versions (#1117) -- Update, Save, and Install with wildcard gets the latest version within specified range (#1117) -- Add positonal parameter for -Path in Publish-PSResource (#1111) -- Uninstall-PSResource -WhatIf now shows version and path of package being uninstalled (#1116) -- Find returns packages from the highest priority repository only (#1155) -- Bug fix for PSCredentialInfo constructor (#1156) -- Bug fix for Install-PSResource -NoClobber parameter (#1121) -- Save-PSResource now searches through all repos when no repo is specified (#1125) -- Caching for improved performance in Uninstall-PSResource (#1175) -- Bug fix for parsing package tags for packages that only have .nuspec from local repository (#1119) - - -## 3.0.21-beta21 - -### New Features -- Move off of NuGet client APIs for local repositories (#1065) - -### Bug Fixes -- Update properties on PSResourceInfo object (#1077) -- Rename PSScriptFileInfo and Get-PSResource cmdlets (#1071) -- fix ValueFromPipelineByPropertyName on Save, Install (#1070) -- add Help message for mandatory params across cmdlets (#1068) -- fix version range bug for Update-PSResource (#1067) -- Fix attribute bugfixes for Find and Install params (#1066) -- Correct Unexpected spelling of Unexpected (#1059) -- Resolve bug with Find-PSResource -Type Module not returning modules (#1050) -- Inject credentials to ISettings to pass them into PushRunner (#993) - -## 3.0.20-beta20 - -- Move off of NuGet client APIs and use direct REST API calls for remote repositories (#1023) - - -### Bug Fixes -- Updates to dependency installation (#1010) (#996) (#907) -- Update to retrieving all packages installed on machine (#999) -- PSResourceInfo version correctly displays 2 or 3 digit version numbers (#697) -- Using `Find-PSresource` with `-CommandName` or `-DSCResourceName` parameters returns an object with a properly expanded ParentResource member (#754) -- `Find-PSResource` no longer returns duplicate results (#755) -- `Find-PSResource` lists repository 'PSGalleryScripts' which does not exist for `Get-PSResourceRepository` (#1028) - -## 3.0.19-beta19 - -### New Features -- Add `-SkipModuleManifestValidate` parameter to `Publish-PSResource` (#904) - -### Bug Fixes -- Add new parameter sets for `-IncludeXml` and `-AsNupkg` parameters in `Install-PSResource` (#910) -- Change warning to error in `Update-PSResource` when no module is already installed (#909) -- Fix `-NoClobber` bug throwing error in `Install-PSResource` (#908) -- Remove warning when installing dependencies (#907) -- Remove Proxy parameters from `Register-PSResourceRepository` (#906) -- Remove -PassThru parameter from `Update-ModuleManifest` (#900) - -## 3.0.18-beta18 - -### New Features -- Add Get-PSScriptFileInfo cmdlet (#839) -- Allow CredentialInfo parameter to accept a hashtable (#836) - -### Bug Fixes -- Publish-PSResource now preserves folder and file structure (#882) -- Fix verbose message for untrusted repos gaining trust (#841) -- Fix for Update-PSResource attempting to reinstall latest preview version (#834) -- Add SupportsWildcards() attribute to parameters accepting wildcards (#833) -- Perform Repository trust check when installing a package (#831) -- Fix casing of `PSResource` in `Install-PSResource` (#820) -- Update .nuspec 'license' property to 'licenseUrl' (#850) - -## 3.0.17-beta17 - -### New Features -- Add -TemporaryPath parameter to Install-PSResource, Save-PSResource, and Update-PSResource (#763) -- Add String and SecureString as credential types in PSCredentialInfo (#764) -- Add a warning for when the script installation path is not in Path variable (#750) -- Expand acceptable paths for Publish-PSResource (Module root directory, module manifest file, script file)(#704) -- Add -Force parameter to Register-PSResourceRepository cmdlet, to override an existing repository (#717) - -### Bug Fixes -- Change casing of -IncludeXML to -IncludeXml (#739) -- Update priority range for PSResourceRepository to 0-100 (#741) -- Editorial pass on cmdlet reference (#743) -- Fix issue when PSScriptInfo has no empty lines (#744) -- Make ConfirmImpact low for Register-PSResourceRepository and Save-PSResource (#745) -- Fix -PassThru for Set-PSResourceRepository cmdlet to return all properties (#748) -- Rename -FilePath parameter to -Path for PSScriptFileInfo cmdlets (#765) -- Fix RequiredModules description and add Find example to docs (#769) -- Remove unneeded inheritance in InstallHelper.cs (#773) -- Make -Path a required parameter for Save-PSResource cmdlet (#780) -- Improve script validation for publishing and installing (#781) - -## 3.0.16-beta16 - -### Bug Fixes -- Update NuGet dependency packages for security vulnerabilities (#733) - -## 3.0.15-beta15 - -### New Features -- Implementation of New-ScriptFileInfo, Update-ScriptFileInfo, and Test-ScriptFileInfo cmdlets (#708) -- Implementation of Update-ModuleManifest cmdlet (#677) -- Implentation of Authenticode validation via -AuthenticodeCheck for Install-PSResource (#632) - -### Bug Fixes -- Bug fix for installing modules with manifests that contain dynamic script blocks (#681) - -## 3.0.14-beta14 - -### Bug Fixes -- Bug fix for repository store (#661) - -## 3.0.13-beta - -### New Features -- Implementation of -RequiredResourceFile and -RequiredResource parameters for Install-PSResource (#610, #592) -- Scope parameters for Get-PSResource and Uninstall-PSResource (#639) -- Support for credential persistence (#480 Thanks @cansuerdogan!) - -### Bug Fixes -- Bug fix for publishing scripts (#642) -- Bug fix for publishing modules with 'RequiredModules' specified in the module manifest (#640) - -### Changes -- 'SupportsWildcard' attribute added to Find-PSResource, Get-PSResource, Get-PSResourceRepository, Uninstall-PSResource, and Update-PSResource (#658) -- Updated help documentation (#651) -- -Repositories parameter changed to singular -Repository in Register-PSResource and Set-PSResource (#645) -- Better prerelease support for Uninstall-PSResource (#593) -- Rename PSResourceInfo's PrereleaseLabel property to match Prerelease column displayed (#591) -- Renaming of parameters -Url to -Uri (#551 Thanks @fsackur!) - -## 3.0.12-beta - -### Changes -- Support searching for all packages from a repository (i.e 'Find-PSResource -Name '*''). Note, wildcard search is not supported for AzureDevOps feed repositories and will write an error message accordingly). -- Packages found are now unique by Name,Version,Repository. -- Support searching for and returning packages found across multiple repositories when using wildcard with Repository parameter (i.e 'Find-PSResource -Name 'PackageExistingInMultipleRepos' -Repository '*'' will perform an exhaustive search). - - PSResourceInfo objects can be piped into: Install-PSResource, Uninstall-PSResource, Save-PSResource. PSRepositoryInfo objects can be piped into: Unregister-PSResourceRepository -- For more consistent pipeline support, the following cmdlets have pipeline support for the listed parameter(s): - - Find-PSResource (Name param, ValueFromPipeline) - - Get-PSResource (Name param, ValueFromPipeline) - - Install-PSResource (Name param, ValueFromPipeline) - - Publish-PSResource (None) - - Save-PSResource (Name param, ValueFromPipeline) - - Uninstall-PSResource (Name param, ValueFromPipeline) - - Update-PSResource (Name param, ValueFromPipeline) - - Get-PSResourceRepository (Name param, ValueFromPipeline) - - Set-PSResourceRepository (Name param, ValueFromPipeline) - - Register-PSResourceRepository (None) - - Unregister-PSResourceRepository (Name param, ValueFromPipelineByPropertyName) -- Implement '-Tag' parameter set for Find-PSResource (i.e 'Find-PSResource -Tag 'JSON'') -- Implement '-Type' parameter set for Find-PSResource (i.e 'Find-PSResource -Type Module') -- Implement CommandName and DSCResourceName parameter sets for Find-PSResource (i.e Find-PSResource -CommandName "Get-TargetResource"). -- Add consistent pre-release version support for cmdlets, including Uninstall-PSResource and Get-PSResource. For example, running 'Get-PSResource 'MyPackage' -Version '2.0.0-beta'' would only return MyPackage with version "2.0.0" and prerelease "beta", NOT MyPackage with version "2.0.0.0" (i.e a stable version). -- Add progress bar for installation completion for Install-PSResource, Update-PSResource and Save-PSResource. -- Implement '-Quiet' param for Install-PSResource, Save-PSResource and Update-PSResource. This suppresses the progress bar display when passed in. -- Implement '-PassThru' parameter for all appropriate cmdlets. Install-PSResource, Save-PSResource, Update-PSResource and Unregister-PSResourceRepository cmdlets now have '-PassThru' support thus completing this goal. -- Implement '-SkipDependencies' parameter for Install-PSResource, Save-PSResource, and Update-PSResource cmdlets. -- Implement '-AsNupkg' and '-IncludeXML' parameters for Save-PSResource. -- Implement '-DestinationPath' parameter for Publish-PSResource -- Add '-NoClobber' functionality to Install-PSResource. -- Add thorough error handling to Update-PSResource to cover more cases and gracefully write errors when updates can't be performed. -- Add thorough error handling to Install-PSResource to cover more cases and not fail silently when installation could not happen successfully. Also fixes bug where package would install even if it was already installed and '-Reinstall' parameter was not specified. -- Restore package if installation attempt fails when reinstalling a package. -- Fix bug with some Modules installing as Scripts. -- Fix bug with separating '$env:PSModulePath' to now work with path separators across all OS systems including Unix. -- Fix bug to register repositories with local file share paths, ensuring repositories with valid URIs can be registered. -- Revert cmdlet name 'Get-InstalledPSResource' to 'Get-PSResource' -- Remove DSCResources from PowerShellGet. -- Remove unnecessary assemblies. - -## 3.0.11-beta - -### Changes -- Graceful handling of paths that do not exist -- The repository store (PSResourceRepository.xml) is auto-generated if it does not already exist. It also automatically registers the PowerShellGallery with a default priority of 50 and a default trusted value of false. -- Better Linux support, including graceful exits when paths do not exist -- Better pipeline input support all cmdlets -- General wildcard support for all cmdlets -- WhatIf support for all cmdlets -- All cmdlets output concrete return types -- Better help documentation for all cmdlets -- Using an exact prerelease version with Find, Install, or Save no longer requires `-Prerelease` tag -- Support for finding, installing, saving, and updating PowerShell resources from Azure Artifact feeds -- Publish-PSResource now properly dispays 'Tags' in nuspec -- Find-PSResource quickly cancels transactions with 'CTRL + C' -- Register-PSRepository now handles relative paths -- Find-PSResource and Save-PSResource deduplicates dependencies -- Install-PSResource no longer creates version folder with the prerelease tag -- Update-PSResource can now update all resources, and no longer requires name param -- Save-PSResource properly handles saving scripts -- Get-InstalledPSResource uses default PowerShell paths - - -### Notes -In this release, all cmdlets have been reviewed and implementation code refactored as needed. -Cmdlets have most of their functionality, but some parameters are not yet implemented and will be added in future releases. -All tests have been reviewed and rewritten as needed. - - -## 3.0.0-beta10 -Bug Fixes -* Bug fix for -ModuleName (used with -Version) in Find-PSResource returning incorrect resource type -* Make repositories unique by name -* Add tab completion for -Name parameter in Get-PSResource, Set-PSResource, and Unregister-PSResource -* Remove credential argument from Register-PSResourceRepository -* Change returned version type from 'NuGet.Version' to 'System.Version' -* Have Install output verbose message on successful installation (error for unsuccessful installation) -* Ensure that not passing credentials does not throw an error if searching through multiple repositories -* Remove attempt to remove loaded assemblies in psm1 - -## 3.0.0-beta9 -New Features -* Add DSCResources - -Bug Fixes -* Fix bug related to finding dependencies that do not have a specified version in Find-PSResource -* Fix bug related to parsing 'RequiredModules' in .psd1 in Publish-PSResource -* Improve error handling for when repository in Publish-PSResource does not exist -* Fix for unix paths in Get-PSResource, Install-PSResource, and Uninstall-PSResource -* Add debugging statements for Get-PSResource and Install-PSResource -* Fix bug related to paths in Uninstall-PSResource - -## 3.0.0-beta8 -New Features -* Add Type parameter to Install-PSResource -* Add 'sudo' check for admin privileges in Unix in Install-PSResource - -Bug Fixes -* Fix bug with retrieving installed scripts in Get-PSResource -* Fix bug with AllUsers scope in Windows in Install-PSResource -* Fix bug with Uninstall-PSResource sometimes not fully uninstalling -* Change installed file paths to contain original version number instead of normalized version - -## 3.0.0-beta7 -New Features -* Completed functionality for Update-PSResource -* Input-Object parameter for Install-PSResource - -Bug Fixes -* Improved experience when loading module for diffent frameworks -* Bug fix for assembly loading error in Publish-PSResource -* Allow for relative paths when registering psrepository -* Improved error handling for Install-PSResource and Update-PSResource -* Remove prerelease tag from module version directory -* Fix error getting thrown from paths with incorrectly formatted module versions -* Fix module installation paths on Linux and MacOS - -## 3.0.0-beta6 -New Feature -* Implement functionality for Publish-PSResource - -## 3.0.0-beta5 -* Note: 3.0.0-beta5 was skipped due to a packaging error - -## 3.0.0-beta4 -New Feature -* Implement -Repository '*' in Find-PSResource to search through all repositories instead of prioritized repository - -Bug Fix -* Fix poor error handling for when repository is not accessible in Find-PSResource - -## 3.0.0-beta3 -New Features -* -RequiredResource parameter for Install-PSResource -* -RequiredResourceFile parameter for Install-PSResource -* -IncludeXML parameter in Save-PSResource - -Bug Fixes -* Resolved paths in Install-PSRsource and Save-PSResource -* Resolved issues with capitalization (for unix systems) in Install-PSResource and Save-PSResource - -## 3.0.0-beta2 -New Features -* Progress bar and -Quiet parameter for Install-PSResource -* -TrustRepository parameter for Install-PSResource -* -NoClobber parameter for Install-PSResource -* -AcceptLicense for Install-PSResource -* -Force parameter for Install-PSResource -* -Reinstall parameter for Install-PSResource -* Improved error handling - -## 3.0.0-beta1 -BREAKING CHANGE -* Preview version of PowerShellGet. Many features are not fully implemented yet. Please see https://devblogs.microsoft.com/powershell/powershellget-3-0-preview1 for more details. +The change logs have been split by version and moved to [CHANGELOG](./CHANGELOG). \ No newline at end of file diff --git a/CHANGELOG/1.0.md b/CHANGELOG/1.0.md new file mode 100644 index 000000000..20348dea3 --- /dev/null +++ b/CHANGELOG/1.0.md @@ -0,0 +1,504 @@ +# 1.0 Changelog + +## [1.0.6](https://github.com/PowerShell/PSResourceGet/compare/v1.0.5..v1.0.6) - 2024-10-10 + +- Bump System.Text.Json to 8.0.5 + +## [1.0.5](https://github.com/PowerShell/PSResourceGet/compare/v1.0.4.1...v1.0.5) - 2024-05-13 + +### Bug Fixes + +- Update `nuget.config` to use PowerShell packages feed (#1649) +- Refactor V2ServerAPICalls and NuGetServerAPICalls to use object-oriented query/filter builder (#1645 Thanks @sean-r-williams!) +- Fix unnecessary `and` for version globbing in V2ServerAPICalls (#1644 Thanks again @sean-r-williams!) +- Fix requiring `tags` in server response (#1627 Thanks @evelyn-bi!) +- Add 10 minute timeout to HTTPClient (#1626) +- Fix save script without `-IncludeXml` (#1609, #1614 Thanks @o-l-a-v!) +- PAT token fix to translate into HttpClient 'Basic Authorization'(#1599 Thanks @gerryleys!) +- Fix incorrect request url when installing from ADO (#1597 Thanks @antonyoni!) +- Improved exception handling (#1569) +- Ensure that .NET methods are not called in order to enable use in Constrained Language Mode (#1564) +- PSResourceGet packaging update + +## [1.0.4.1](https://github.com/PowerShell/PSResourceGet/compare/v1.0.4...v1.0.4.1) - 2024-04-05 + +- PSResourceGet packaging update + +## [1.0.4](https://github.com/PowerShell/PSResourceGet/compare/v1.0.3...v1.0.4) - 2024-04-05 + +### Patch + +- Dependency package updates + +## [1.0.3](https://github.com/PowerShell/PSResourceGet/compare/v1.0.2...v1.0.3) - 2024-03-13 + +### Bug Fixes + +- Bug fix for null package version in `Install-PSResource` + +## [1.0.2](https://github.com/PowerShell/PSResourceGet/compare/v1.0.1...v1.0.2) - 2024-02-06 + +### Bug Fixes + +- Bug fix for `Update-PSResource` not updating from correct repository (#1549) +- Bug fix for creating temp home directory on Unix (#1544) +- Bug fix for creating `InstalledScriptInfos` directory when it does not exist (#1542) +- Bug fix for `Update-ModuleManifest` throwing null pointer exception (#1538) +- Bug fix for `name` property not populating in `PSResourceInfo` object when using `Find-PSResource` with JFrog Artifactory (#1535) +- Bug fix for incorrect configuration of requests to JFrog Artifactory v2 endpoints (#1533 Thanks @sean-r-williams!) +- Bug fix for determining JFrog Artifactory repositories (#1532 Thanks @sean-r-williams!) +- Bug fix for v2 server repositories incorrectly adding script endpoint (1526) +- Bug fixes for null references (#1525) +- Typo fixes in message prompts in `Install-PSResource` (#1510 Thanks @NextGData!) +- Bug fix to add `NormalizedVersion` property to `AdditionalMetadata` only when it exists (#1503 Thanks @sean-r-williams!) +- Bug fix to verify whether `Uri` is a UNC path and set respective `ApiVersion` (#1479 Thanks @kborowinski!) + +## [1.0.1](https://github.com/PowerShell/PSResourceGet/compare/v1.0.0...v1.0.1) - 2023-11-07 + +### Bug Fixes + +- Bugfix to update Unix local user installation paths to be compatible with .NET 7 and .NET 8 (#1464) +- Bugfix for Import-PSGetRepository in Windows PowerShell (#1460) +- Bugfix for `Test-PSScriptFileInfo`` to be less sensitive to whitespace (#1457) +- Bugfix to overwrite rels/rels directory on net472 when extracting nupkg to directory (#1456) +- Bugfix to add pipeline by property name support for Name and Repository properties for Find-PSResource (#1451 Thanks @ThomasNieto!) + +## 1.0.0 - 2023-10-09 + +### New Features + +- Add `ApiVersion` parameter for `Register-PSResourceRepository` (#1431) + +### Bug Fixes + +- Automatically set the ApiVersion to v2 for repositories imported from PowerShellGet (#1430) +- Bug fix ADO v2 feed installation failures (#1429) +- Bug fix Artifactory v2 endpoint failures (#1428) +- Bug fix Artifactory v3 endpoint failures (#1427) +- Bug fix `-RequiredResource` silent failures (#1426) +- Bug fix for v2 repository returning extra packages for `-Tag` based search with `-Prerelease` (#1405) + +## 0.9.0-rc1 + +### Bug Fixes + +- Bug fix for using `Import-PSGetRepository` in Windows PowerShell (#1390) +- Add error handling when searching for unlisted package versions (#1386) +- Bug fix for deduplicating dependencies found from `Find-PSResource` (#1382) +- Added support for non-PowerShell Gallery v2 repositories (#1380) +- Bug fix for setting 'unknown' repository `APIVersion` (#1377) +- Bug fix for saving a script with `-IncludeXML` parameter (#1375) +- Bug fix for v3 server logic to properly parse inner @id element (#1374) +- Bug fix to write warning instead of error when package is already installed (#1367) + +## 0.5.24-beta24 + +### Bug Fixes + +- Detect empty V2 server responses at ServerApiCall level instead of ResponseUtil level (#1358) +- Bug fix for finding all versions of a package returning correct results and incorrect "package not found" error (#1356) +- Bug fix for installing or saving a pkg found in lower priority repository (#1350) +- Ensure `-Prerelease` is not empty or whitespace for `Update-PSModuleManifest` (#1348) +- Bug fix for saving `Az` module dependencies (#1343) +- Bug fix for `Find-PSResource` repository looping to to return matches from all repositories (#1342) +- Update error handling for Tags, Commands, and DSCResources when searching across repositories (#1339) +- Update `Find-PSResource` looping and error handling to account for multiple package names (#1338) +- Update error handling for `Find-PSResource` using V2 server endpoint repositories (#1329) +- Bug fix for searching through multiple repositories when some repositories do not contain the specified package (#1328) +- Add parameters to `Install-PSResource` verbose message (#1327) +- Bug fix for parsing required modules when publishing (#1326) +- Bug fix for saving dependency modules in version range format (#1323) +- Bug fix for `Install-PSResource` failing to find prerelease dependencies (#1322) +- Bug fix for updating to a new version of a prerelease module (#1320) +- Fix for error message when DSCResource is not found (#1317) +- Add error handling for local repository pattern based searching (#1316) +- `Set-PSResourceRepository` run without `-ApiVersion` paramater no longer resets the property for the repository (#1310) + +## 0.5.23-beta23 + +### New Features + +- *-PSResourceRepository `-Uri` now accepting PSPaths (#1269) +- Add aliases for `Install-PSResource`, `Find-PSResource`, `Update-PSResource`, `Publish-PSResource` (#1264) +- Add custom user agent string to API calls (#1260) +- Support install for NuGet.Server application hosted feed (#1253) +- Add support for NuGet.Server application hosted feeds (#1236) +- Add Import-PSGetRepository function to import existing v2 PSRepositories into PSResourceRepositories. (#1221) +- Add `Get-PSResource` alias to `Get-InstalledPSResource` (#1216) +- Add `-ApiVersion` parameter to Set-PSResourceRepository (#1207) +- Add support for FindNameGlobbing scenarios (i.e -Name az*) for MyGet server repository (V3) (#1202) + +### Bug Fixes + +- Better error handling for scenario where repo ApiVersion is unknown and allow for PSPaths as URI for registered repositories (#1288) +- Bugfix for Uninstall should be able to remove older versions of a package that are not a dependency (#1287) +- Bugfix for Publish finding prerelease dependency versions. (#1283) +- Fix Pagination for V3 search with globbing scenarios (#1277) +- Update message for `-WhatIf` in `Install-PSResource`, `Save-PSResource`, and `Update-PSResource` (#1274) +- Bug fix for publishing with ExternalModuleDependencies (#1271) +- Support Credential Persistence for `Publish-PSResource` (#1268) +- Update `Save-PSResource` `-Path` param so it defaults to the current working directory (#1265) +- Update dependency error message in Publish-PSResource (#1263) +- Bug fixes for script metadata (#1259) +- Fix error message for `Publish-PSResource` for MyGet.org feeds (#1256) +- Bug fix for version ranges with prerelease versions not returning the correct versions (#1255) +- Bug fix for file path version must match psd1 version error when publishing (#1254) +- Bug fix for searching through local repositories with `-Type` parameter (#1252) +- Allow environment variables in module manifests (#1249 Thanks @ThomasNieto!) +- Updating prerelease version should update to latest prerelease version (#1238) +- Fix InstallHelper call to GetEnvironmentVariable() on Unix (#1237) +- Update build script to resolve module loading error (#1234) +- Enable UNC Paths for local repositories, source directories and destination directories (#1229 Thanks @FriedrichWeinmann!) +- Improve better error handling for `-Path` in `Publish-PSResource` (#1227) +- Bug fix for `-RequireLicenseAcceptance` in `Publish-PSResource` (#1225) +- Provide clearer error handling for V3 Publish support (#1224) +- Fix bug with version parsing in `Publish-PSResource` (#1223) +- Improve error handling for `Find-PSResource` (#1222) +- Add error handling to `Get-InstalledPSResource` and `Find-PSResource` (#1217) +- Improve error handling in `Uninstall-PSResource` (#1215) +- Change resolved paths to use GetResolvedProviderPathFromPSPath (#1209) +- Bug fix for `Get-InstalledPSResource` returning type of scripts as module (#1198) + +## 0.5.22-beta22 + +### Breaking Changes + +- PowerShellGet is now PSResourceGet! (#1164) +- `Update-PSScriptFile` is now `Update-PSScriptFileInfo` (#1140) +- `New-PSScriptFile` is now `New-PSScriptFileInfo` (#1140) +- `Update-ModuleManifest` is now `Update-PSModuleManifest` (#1139) +- `-Tags` parameter changed to `-Tag` in `New-PSScriptFile`, `Update-PSScriptFileInfo`, and `Update-ModuleManifest` (#1123) +- Change the type of `-InputObject` from PSResourceInfo to PSResourceInfo[] for `Install-PSResource`, `Save-PSResource`, and `Uninstall-PSResource` (#1124) +- PSModulePath is no longer referenced when searching paths (#1154) + +### New Features + +- Support for Azure Artifacts, GitHub Packages, and Artifactory (#1167, #1180, #1183) + +### Bug Fixes + +- Filter out unlisted packages (#1172, #1161) +- Add paging for V3 server requests (#1170) +- Support for floating versions (#1117) +- Update, Save, and Install with wildcard gets the latest version within specified range (#1117) +- Add positonal parameter for `-Path` in `Publish-PSResource` (#1111) +- `Uninstall-PSResource` `-WhatIf` now shows version and path of package being uninstalled (#1116) +- Find returns packages from the highest priority repository only (#1155) +- Bug fix for PSCredentialInfo constructor (#1156) +- Bug fix for `Install-PSResource` `-NoClobber` parameter (#1121) +- `Save-PSResource` now searches through all repos when no repo is specified (#1125) +- Caching for improved performance in `Uninstall-PSResource` (#1175) +- Bug fix for parsing package tags for packages that only have .nuspec from local repository (#1119) + +## 3.0.21-beta21 + +### New Features + +- Move off of NuGet client APIs for local repositories (#1065) + +### Bug Fixes + +- Update properties on PSResourceInfo object (#1077) +- Rename PSScriptFileInfo and `Get-PSResource` cmdlets (#1071) +- fix ValueFromPipelineByPropertyName on Save, Install (#1070) +- add Help message for mandatory params across cmdlets (#1068) +- fix version range bug for `Update-PSResource` (#1067) +- Fix attribute bugfixes for Find and Install params (#1066) +- Correct Unexpected spelling of Unexpected (#1059) +- Resolve bug with `Find-PSResource` `-Type` Module not returning modules (#1050) +- Inject credentials to ISettings to pass them into PushRunner (#993) + +## 3.0.20-beta20 + +- Move off of NuGet client APIs and use direct REST API calls for remote repositories (#1023) + +### Bug Fixes + +- Updates to dependency installation (#1010) (#996) (#907) +- Update to retrieving all packages installed on machine (#999) +- PSResourceInfo version correctly displays 2 or 3 digit version numbers (#697) +- Using `Find-PSresource` with `-CommandName` or `-DSCResourceName` parameters returns an object with a properly expanded ParentResource member (#754) +- `Find-PSResource` no longer returns duplicate results (#755) +- `Find-PSResource` lists repository 'PSGalleryScripts' which does not exist for `Get-PSResourceRepository` (#1028) + +## 3.0.19-beta19 + +### New Features + +- Add `-SkipModuleManifestValidate` parameter to `Publish-PSResource` (#904) + +### Bug Fixes + +- Add new parameter sets for `-IncludeXml` and `-AsNupkg` parameters in `Install-PSResource` (#910) +- Change warning to error in `Update-PSResource` when no module is already installed (#909) +- Fix `-NoClobber` bug throwing error in `Install-PSResource` (#908) +- Remove warning when installing dependencies (#907) +- Remove Proxy parameters from `Register-PSResourceRepository` (#906) +- Remove `-PassThru` parameter from `Update-ModuleManifest` (#900) + +## 3.0.18-beta18 + +### New Features + +- Add `Get-PSScriptFileInfo` cmdlet (#839) +- Allow `-CredentialInfo` parameter to accept a hashtable (#836) + +### Bug Fixes + +- `Publish-PSResource` now preserves folder and file structure (#882) +- Fix verbose message for untrusted repos gaining trust (#841) +- Fix for `Update-PSResource` attempting to reinstall latest preview version (#834) +- Add SupportsWildcards() attribute to parameters accepting wildcards (#833) +- Perform Repository trust check when installing a package (#831) +- Fix casing of `PSResource` in `Install-PSResource` (#820) +- Update .nuspec 'license' property to 'licenseUrl' (#850) + +## 3.0.17-beta17 + +### New Features + +- Add `-TemporaryPath` parameter to `Install-PSResource`, `Save-PSResource`, and `Update-PSResource` (#763) +- Add String and SecureString as credential types in PSCredentialInfo (#764) +- Add a warning for when the script installation path is not in Path variable (#750) +- Expand acceptable paths for `Publish-PSResource` (Module root directory, module manifest file, script file)(#704) +- Add `-Force` parameter to `Register-PSResourceRepository` cmdlet, to override an existing repository (#717) + +### Bug Fixes + +- Change casing of `-IncludeXML` to `-IncludeXml` (#739) +- Update priority range for PSResourceRepository to 0-100 (#741) +- Editorial pass on cmdlet reference (#743) +- Fix issue when PSScriptInfo has no empty lines (#744) +- Make ConfirmImpact low for `Register-PSResourceRepository` and `Save-PSResource` (#745) +- Fix `-PassThru` for `Set-PSResourceRepository` cmdlet to return all properties (#748) +- Rename `-FilePath` parameter to `-Path` for PSScriptFileInfo cmdlets (#765) +- Fix RequiredModules description and add Find example to docs (#769) +- Remove unneeded inheritance in InstallHelper.cs (#773) +- Make `-Path` a required parameter for `Save-PSResource` cmdlet (#780) +- Improve script validation for publishing and installing (#781) + +## 3.0.16-beta16 + +### Bug Fixes + +- Update NuGet dependency packages for security vulnerabilities (#733) + +## 3.0.15-beta15 + +### New Features + +- Implementation of `New-ScriptFileInfo`, `Update-ScriptFileInfo`, and `Test-ScriptFileInfo` cmdlets (#708) +- Implementation of `Update-ModuleManifest` cmdlet (#677) +- Implentation of Authenticode validation via `-AuthenticodeCheck` for `Install-PSResource` (#632) + +### Bug Fixes + +- Bug fix for installing modules with manifests that contain dynamic script blocks (#681) + +## 3.0.14-beta14 + +### Bug Fixes + +- Bug fix for repository store (#661) + +## 3.0.13-beta + +### New Features + +- Implementation of `-RequiredResourceFile` and `-RequiredResource` parameters for `Install-PSResource` (#610, #592) +- Scope parameters for `Get-PSResource` and `Uninstall-PSResource` (#639) +- Support for credential persistence (#480 Thanks @cansuerdogan!) + +### Bug Fixes + +- Bug fix for publishing scripts (#642) +- Bug fix for publishing modules with 'RequiredModules' specified in the module manifest (#640) + +### Changes + +- 'SupportsWildcard' attribute added to `Find-PSResource`, `Get-PSResource`, `Get-PSResourceRepository`, `Uninstall-PSResource`, and `Update-PSResource` (#658) +- Updated help documentation (#651) +- -Repositories parameter changed to singular `-Repository` in `Register-PSResource` and `Set-PSResource` (#645) +- Better prerelease support for `Uninstall-PSResource` (#593) +- Rename PSResourceInfo's PrereleaseLabel property to match Prerelease column displayed (#591) +- Renaming of parameters `-Url` to `-Uri` (#551 Thanks @fsackur!) + +## 3.0.12-beta + +### Changes + +- Support searching for all packages from a repository (i.e `Find-PSResource -Name '*'`). Note, wildcard search is not supported for AzureDevOps feed repositories and will write an error message accordingly. +- Packages found are now unique by Name,Version,Repository. +- Support searching for and returning packages found across multiple repositories when using wildcard with Repository parameter (i.e `Find-PSResource -Name 'PackageExistingInMultipleRepos' -Repository '*'` will perform an exhaustive search). + - PSResourceInfo objects can be piped into: `Install-PSResource`, `Uninstall-PSResource`, `Save-PSResource`. PSRepositoryInfo objects can be piped into: `Unregister-PSResourceRepository` +- For more consistent pipeline support, the following cmdlets have pipeline support for the listed parameter(s): + - `Find-PSResource` (Name param, ValueFromPipeline) + - `Get-PSResource` (Name param, ValueFromPipeline) + - `Install-PSResource` (Name param, ValueFromPipeline) + - `Publish-PSResource` (None) + - `Save-PSResource` (Name param, ValueFromPipeline) + - `Uninstall-PSResource` (Name param, ValueFromPipeline) + - `Update-PSResource` (Name param, ValueFromPipeline) + - `Get-PSResourceRepository` (Name param, ValueFromPipeline) + - `Set-PSResourceRepository` (Name param, ValueFromPipeline) + - `Register-PSResourceRepository` (None) + - `Unregister-PSResourceRepository` (Name param, ValueFromPipelineByPropertyName) +- Implement `-Tag` parameter set for `Find-PSResource` (i.e `Find-PSResource -Tag 'JSON'`) +- Implement `-Type` parameter set for `Find-PSResource` (i.e `Find-PSResource -Type Module`) +- Implement CommandName and DSCResourceName parameter sets for `Find-PSResource` (i.e `Find-PSResource -CommandName "Get-TargetResource"`). +- Add consistent pre-release version support for cmdlets, including `Uninstall-PSResource` and `Get-PSResource`. For example, running `Get-PSResource 'MyPackage' -Version '2.0.0-beta'` would only return MyPackage with version "2.0.0" and prerelease "beta", NOT MyPackage with version "2.0.0.0" (i.e a stable version). +- Add progress bar for installation completion for `Install-PSResource`, `Update-PSResource` and `Save-PSResource`. +- Implement `-Quiet` param for `Install-PSResource`, `Save-PSResource` and `Update-PSResource`. This suppresses the progress bar display when passed in. +- Implement `-PassThru` parameter for all appropriate cmdlets. `Install-PSResource`, `Save-PSResource`, `Update-PSResource` and `Unregister-PSResourceRepository` cmdlets now have `-PassThru` support thus completing this goal. +- Implement `-SkipDependencies` parameter for `Install-PSResource`, `Save-PSResource`, and `Update-PSResource` cmdlets. +- Implement `-AsNupkg` and `-IncludeXML` parameters for `Save-PSResource`. +- Implement `-DestinationPath` parameter for `Publish-PSResource`. +- Add `-NoClobber` functionality to `Install-PSResource`. +- Add thorough error handling to `Update-PSResource` to cover more cases and gracefully write errors when updates can't be performed. +- Add thorough error handling to `Install-PSResource` to cover more cases and not fail silently when installation could not happen successfully. Also fixes bug where package would install even if it was already installed and `-Reinstall` parameter was not specified. +- Restore package if installation attempt fails when reinstalling a package. +- Fix bug with some Modules installing as Scripts. +- Fix bug with separating `$env:PSModulePath` to now work with path separators across all OS systems including Unix. +- Fix bug to register repositories with local file share paths, ensuring repositories with valid URIs can be registered. +- Revert cmdlet name `Get-InstalledPSResource` to `Get-PSResource`. +- Remove DSCResources from PowerShellGet. +- Remove unnecessary assemblies. + +## 3.0.11-beta + +### Changes + +- Graceful handling of paths that do not exist +- The repository store (PSResourceRepository.xml) is auto-generated if it does not already exist. It also automatically registers the PowerShellGallery with a default priority of 50 and a default trusted value of false. +- Better Linux support, including graceful exits when paths do not exist +- Better pipeline input support all cmdlets +- General wildcard support for all cmdlets +- WhatIf support for all cmdlets +- All cmdlets output concrete return types +- Better help documentation for all cmdlets +- Using an exact prerelease version with Find, Install, or Save no longer requires `-Prerelease` tag +- Support for finding, installing, saving, and updating PowerShell resources from Azure Artifact feeds +- `Publish-PSResource` now properly dispays 'Tags' in nuspec +- `Find-PSResource` quickly cancels transactions with 'CTRL + C' +- `Register-PSRepository` now handles relative paths +- `Find-PSResource` and `Save-PSResource` deduplicates dependencies +- `Install-PSResource` no longer creates version folder with the prerelease tag +- `Update-PSResource` can now update all resources, and no longer requires name param +- `Save-PSResource` properly handles saving scripts +- `Get-InstalledPSResource` uses default PowerShell paths + +### Notes + +In this release, all cmdlets have been reviewed and implementation code refactored as needed. +Cmdlets have most of their functionality, but some parameters are not yet implemented and will be added in future releases. +All tests have been reviewed and rewritten as needed. + +## 3.0.0-beta10 + +### Bug Fixes + +- Bug fix for `-ModuleName` (used with `-Version`) in `Find-PSResource` returning incorrect resource type +- Make repositories unique by name +- Add tab completion for `-Name` parameter in `Get-PSResource`, `Set-PSResource`, and `Unregister-PSResource` +- Remove credential argument from `Register-PSResourceRepository` +- Change returned version type from 'NuGet.Version' to 'System.Version' +- Have Install output verbose message on successful installation (error for unsuccessful installation) +- Ensure that not passing credentials does not throw an error if searching through multiple repositories +- Remove attempt to remove loaded assemblies in psm1 + +## 3.0.0-beta9 + +### New Features + +- Add DSCResources + +### Bug Fixes + +- Fix bug related to finding dependencies that do not have a specified version in `Find-PSResource` +- Fix bug related to parsing 'RequiredModules' in .psd1 in `Publish-PSResource` +- Improve error handling for when repository in `Publish-PSResource` does not exist +- Fix for unix paths in `Get-PSResource`, `Install-PSResource`, and `Uninstall-PSResource` +- Add debugging statements for `Get-PSResource` and `Install-PSResource` +- Fix bug related to paths in `Uninstall-PSResource` + +## 3.0.0-beta8 + +### New Features + +- Add `-Type` parameter to `Install-PSResource` +- Add 'sudo' check for admin privileges in Unix in `Install-PSResource` + +### Bug Fixes + +- Fix bug with retrieving installed scripts in `Get-PSResource` +- Fix bug with AllUsers scope in Windows in `Install-PSResource` +- Fix bug with `Uninstall-PSResource` sometimes not fully uninstalling +- Change installed file paths to contain original version number instead of normalized version + +## 3.0.0-beta7 + +### New Features + +- Completed functionality for `Update-PSResource` +- `Input-Object` parameter for `Install-PSResource` + +### Bug Fixes + +- Improved experience when loading module for diffent frameworks +- Bug fix for assembly loading error in `Publish-PSResource` +- Allow for relative paths when registering psrepository +- Improved error handling for `Install-PSResource` and `Update-PSResource` +- Remove prerelease tag from module version directory +- Fix error getting thrown from paths with incorrectly formatted module versions +- Fix module installation paths on Linux and MacOS + +## 3.0.0-beta6 + +### New Feature + +- Implement functionality for `Publish-PSResource` + +## 3.0.0-beta5 + +- Note: 3.0.0-beta5 was skipped due to a packaging error + +## 3.0.0-beta4 + +### New Features + +- Implement `-Repository` '*' in `Find-PSResource` to search through all repositories instead of prioritized repository + +### Bug Fix + +- Fix poor error handling for when repository is not accessible in Find-PSResource + +## 3.0.0-beta3 + +### New Features + +- `-RequiredResource` parameter for `Install-PSResource` +- `-RequiredResourceFile` parameter for `Install-PSResource` +- `-IncludeXML` parameter in `Save-PSResource` + +### Bug Fixes + +- Resolved paths in `Install-PSRsource` and `Save-PSResource` +- Resolved issues with capitalization (for unix systems) in `Install-PSResource` and `Save-PSResource` + +## 3.0.0-beta2 + +### New Features + +- Progress bar and `-Quiet` parameter for `Install-PSResource` +- `-TrustRepository` parameter for `Install-PSResource` +- `-NoClobber` parameter for `Install-PSResource` +- `-AcceptLicense` for `Install-PSResource` +- `-Force` parameter for `Install-PSResource` +- `-Reinstall` parameter for `Install-PSResource` +- Improved error handling + +## 3.0.0-beta1 + +### BREAKING CHANGE + +- Preview version of PowerShellGet. Many features are not fully implemented yet. Please see for more details. diff --git a/CHANGELOG/1.1.md b/CHANGELOG/1.1.md new file mode 100644 index 000000000..4fb078eda --- /dev/null +++ b/CHANGELOG/1.1.md @@ -0,0 +1,24 @@ +# 1.1 Changelog + +## [1.1.1](https://github.com/PowerShell/PSResourceGet/compare/v1.1.0..v1.1.1) - 2025-03-06 + +- Bugfix to retrieve all metadata properties when finding a PSResource from a ContainerRegistry repository (#1799) +- Update README.md (#1798) +- Use authentication challenge for unauthenticated ContainerRegistry repository (#1797) +- Bugfix for Install-PSResource with varying digit version against ContainerRegistry repository (#1796) +- Bugfix for updating ContainerRegistry dependency parsing logic to account for AzPreview package (#1792) +- Add wildcard support for MAR repository for FindAll and FindByName (#1786) +- Bugfix for nuspec dependency version range calculation for RequiredModules (#1784) + +## [1.1.0](https://github.com/PowerShell/PSResourceGet/compare/v1.1.0-rc3...v1.1.0) - 2025-01-09 + +### Bug Fixes + +- Bugfix for publishing .nupkg file to ContainerRegistry repository (#1763) +- Bugfix for PMPs like Artifactory needing modified filter query parameter to proxy upstream (#1761) +- Bugfix for ContainerRegistry repository to parse out dependencies from metadata (#1766) +- Bugfix for Install-PSResource Null pointer occurring when package is present only in upstream feed in ADO (#1760) +- Bugfix for local repository casing issue on Linux (#1750) +- Update README.md (#1759) +- Bug fix for case sensitive License.txt when RequireLicense is specified (#1757) +- Bug fix for broken -Quiet parameter for Save-PSResource (#1745) diff --git a/CHANGELOG/preview.md b/CHANGELOG/preview.md new file mode 100644 index 000000000..53d1677b2 --- /dev/null +++ b/CHANGELOG/preview.md @@ -0,0 +1,62 @@ +## ## [1.1.0-rc3](https://github.com/PowerShell/PSResourceGet/compare/v1.1.0-RC2...v1.1.0-rc3) - 2024-11-15 + +### Bug Fix +- Include missing commits + + +## [1.1.0-RC2](https://github.com/PowerShell/PSResourceGet/compare/v1.1.0-RC1...v1.1.0-RC2) - 2024-10-30 + +### New Features +- Full Microsoft Artifact Registry integration (#1741) + +### Bug Fixes + +- Update to use OCI v2 APIs for Container Registry (#1737) +- Bug fixes for finding and installing from local repositories on Linux machines (#1738) +- Bug fix for finding package name with 4 part version from local repositories (#1739) + +# Preview Changelog + +## [1.1.0-RC1](https://github.com/PowerShell/PSResourceGet/compare/v1.1.0-preview2...v1.1.0-RC1) - 2024-10-22 + +### New Features + +- Group Policy configurations for enabling or disabling PSResource repositories (#1730) + +### Bug Fixes + +- Fix packaging name matching when searching in local repositories (#1731) +- `Compress-PSResource` `-PassThru` now passes `FileInfo` instead of string (#1720) +- Fix for `Compress-PSResource` not properly compressing scripts (#1719) +- Add `AcceptLicense` to Save-PSResource (#1718 Thanks @o-l-a-v!) +- Better support for Azure DevOps Artifacts NuGet v2 feeds (#1713 Thanks @o-l-a-v!) +- Better handling of `-WhatIf` support in `Install-PSResource` (#1531 Thanks @o-l-a-v!) +- Fix for some nupkgs failing to extract due to empty directories (#1707 Thanks @o-l-a-v!) +- Fix for searching for `-Name *` in `Find-PSResource` (#1706 Thanks @o-l-a-v!) + +## [1.1.0-preview2](https://github.com/PowerShell/PSResourceGet/compare/v1.1.0-preview1...v1.1.0-preview2) - 2024-09-13 + +### New Features + +- New cmdlet `Compress-PSResource` which packs a package into a .nupkg and saves it to the file system (#1682, #1702) +- New `-Nupkg` parameter for `Publish-PSResource` which pushes pushes a .nupkg to a repository (#1682) +- New `-ModulePrefix` parameter for `Publish-PSResource` which adds a prefix to a module name for container registry repositories to add a module prefix.This is only used for publishing and is not part of metadata. MAR will drop the prefix when syndicating from ACR to MAR (#1694) + +### Bug Fixes + +- Add prerelease string when NormalizedVersion doesn't exist, but prelease string does (#1681 Thanks @sean-r-williams) +- Add retry logic when deleting files (#1667 Thanks @o-l-a-v!) +- Fix broken PAT token use (#1672) +- Updated error messaging for authenticode signature failures (#1701) + +## [1.1.0-preview1](https://github.com/PowerShell/PSResourceGet/compare/v1.0.3...v1.1.0-preview1) - 2024-04-01 + +### New Features + +- Support for Azure Container Registries (#1495, #1497-#1499, #1501, #1502, #1505, #1522, #1545, #1548, #1550, #1554, #1560, #1567, #1573, #1576, #1587, #1588, #1589, #1594, #1598, #1600, #1602, #1604, #1615) + +### Bug Fixes + +- Fix incorrect request URL when installing resources from ADO (#1597 Thanks @anytonyoni!) +- Fix for swallowed exceptions (#1569) +- Fix for PSResourceGet not working in Constrained Languange Mode (#1564) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 90768d129..686e5e7a0 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,8 +1,10 @@ -# Code of Conduct +# Microsoft Open Source Code of Conduct -This project has adopted the [Microsoft Open Source Code of Conduct][conduct-code]. -For more information see the [Code of Conduct FAQ][conduct-FAQ] or contact [opencode@microsoft.com][conduct-email] with any additional questions or comments. +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). -[conduct-code]: https://opensource.microsoft.com/codeofconduct/ -[conduct-FAQ]: https://opensource.microsoft.com/codeofconduct/faq/ -[conduct-email]: mailto:opencode@microsoft.com +Resources: + +- [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) +- [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) +- Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns +- Employees can reach out at [aka.ms/opensource/moderation-support](https://aka.ms/opensource/moderation-support) diff --git a/README.md b/README.md index ab4e8de0a..a0ca6c443 100644 --- a/README.md +++ b/README.md @@ -1,89 +1,95 @@ +# PSResourceGet [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/PowerShell/PSResourceGet/blob/master/LICENSE) -[![Documentation - PSResourceGet](https://img.shields.io/badge/Documentation-PowerShellGet-blue.svg)](https://docs.microsoft.com/en-us/powershell/module/powershellget/?view=powershell-7.1) +[![Documentation - PSResourceGet](https://img.shields.io/badge/Documentation-PowerShellGet-blue.svg)](https://learn.microsoft.com/powershell/module/microsoft.powershell.psresourceget) [![PowerShell Gallery - PSResourceGet](https://img.shields.io/badge/PowerShell%20Gallery-PSResourceGet-blue.svg)](https://www.powershellgallery.com/packages/Microsoft.PowerShell.PSResourceGet) [![Minimum Supported PowerShell Version](https://img.shields.io/badge/PowerShell-5.0-blue.svg)](https://github.com/PowerShell/PSResourceGet) -Important Note -============== +## Important Notes -If you were familiar with the PowerShellGet 3.0 project, we renamed the module to be PSResourceGet, for more information please read [this blog](https://devblogs.microsoft.com/powershell/powershellget-in-powershell-7-4-updates/). +> [!NOTE] +> `PSResourceGet` is short for the full name of the module, `Microsoft.PowerShell.PSResourceGet`. The full name is what is used in PowerShell and when published to the [PowerShell Gallery](https://www.powershellgallery.com/packages/Microsoft.PowerShell.PSResourceGet). -This version of PSResourceGet is currently under development and is not quite complete. -As a result, we are currently only accepting PRs for tests. -If you would like to open a PR please open an issue first so that necessary discussion can take place. -Please open an issue for any feature requests, bug reports, or questions for PSResourceGet. -Please note, the repository for PowerShellGet is available at [PowerShell/PowerShellGetv2](https://github.com/PowerShell/PowerShellGetv2). +* If you were familiar with the PowerShellGet 3.0 project, we renamed the module to be PSResourceGet, for more information please read [this blog](https://devblogs.microsoft.com/powershell/powershellget-in-powershell-7-4-updates/). +* If you would like to open a PR please open an issue first so that necessary discussion can take place. + * Please open an issue for any feature requests, bug reports, or questions for PSResourceGet. + * See the [Contributing Quickstart Guide](#contributing-quickstart-guide) section. +* Please note, the repository for PowerShellGet v2 is available at [PowerShell/PowerShellGetv2](https://github.com/PowerShell/PowerShellGetv2). +* The repository for the PowerShellGet v3, the compatibility layer between PowerShellGet v2 and PSResourceGet, is available at [PowerShell/PowerShellGet](https://github.com/PowerShell/PowerShellGet). -Introduction -============ +## Introduction PSResourceGet is a PowerShell module with commands for discovering, installing, updating and publishing the PowerShell resources like Modules, Scripts, and DSC Resources. -Documentation -============= +## Documentation -Documentation for PSResourceGet is currently under its old name PowerShellGet v3, please -[Click here](https://learn.microsoft.com/powershell/module/powershellget/?view=powershellget-3.x) -to reference the documentation. +[Click here](https://learn.microsoft.com/powershell/module/microsoft.powershell.psresourceget) to reference the documentation. -Requirements -============ +## Requirements -- PowerShell 5.0 or higher. +* PowerShell 5.0 or higher. -Get PSResourceGet Module -======================== +## Install the PSResourceGet module +* `PSResourceGet` is short for the full name `Microsoft.PowerShell.PSResourceGet`. +* It's included in PowerShell since v7.4. Please use the [PowerShell Gallery](https://www.powershellgallery.com) to get the latest version of the module. -Get PowerShellGet Source -======================== +## Contributing Quickstart Guide -#### Steps - -* Obtain the source - - Download the latest source code from the release page (https://github.com/PowerShell/PSResourceGet/releases) OR - - Clone the repository (needs git) - ```powershell - git clone https://github.com/PowerShell/PSResourceGet - ``` +### Get the source code +* Download the latest source code from the release page () OR clone the repository using git. + ```powershell + PS > cd 'C:\Repos' + PS C:\Repos> git clone https://github.com/PowerShell/PSResourceGet + ``` * Navigate to the local repository directory + ```powershell + PS C:\> cd c:\Repos\PSResourceGet + PS C:\Repos\PSResourceGet> + ``` -```powershell -PS C:\> cd c:\Repos\PSResourceGet -PS C:\Repos\PSResourceGet> -``` +### Build the project -* Build the project +Note: Please ensure you have the exact version of the .NET SDK installed. The current version can be found in the [global.json](https://github.com/PowerShell/PSResourceGet/blob/master/global.json) and installed from the [.NET website](https://dotnet.microsoft.com/en-us/download). + ```powershell + # Build for the net472 framework + PS C:\Repos\PSResourceGet> .\build.ps1 -Clean -Build -BuildConfiguration Debug -BuildFramework net472 + ``` -```powershell -# Build for the net472 framework -PS C:\Repos\PSResourceGet> .\build.ps1 -Clean -Build -BuildConfiguration Debug -BuildFramework net472 +### Run functional tests -# Build for the netstandard2.0 framework -PS C:\Repos\PSResourceGet> .\build.ps1 -Clean -Build -BuildConfiguration Debug -BuildFramework netstandard2.0 -``` - -* Publish the module to a local repository - -```powershell -PS C:\Repos\PSResourceGet> .\build.ps1 -Publish -``` +* Run all tests + ```powershell + PS C:\Repos\PSResourceGet> Invoke-Pester + ``` +* Run an individual test + ```powershell + PS C:\Repos\PSResourceGet> Invoke-Pester + ``` -* Run functional tests - -```powershell -PS C:\Repos\PSResourceGet> Invoke-PSPackageProjectTest -Type Functional -``` - -* Import the module into a new PowerShell session +### Import the built module into a new PowerShell session ```powershell # If running PowerShell 6+ +C:\> pwsh C:\> Import-Module C:\Repos\PSResourceGet\out\PSResourceGet # If running Windows PowerShell +c:\> PowerShell C:\> Import-Module C:\Repos\PSResourceGet\out\PSResourceGet\PSResourceGet.psd1 ``` +## Module Support Lifecycle +Microsoft.PowerShell.PSResourceGet follows the support lifecycle of the version of PowerShell that it ships in. +For example, PSResourceGet 1.0.x shipped in PowerShell 7.4 which is an LTS release so it will be supported for 3 years. +Preview versions of the module, or versions that ship in preview versions of PowerShell are not supported. +Versions of PSResourceGet that do not ship in a version of PowerShell will be fixed forward. + +## Code of Conduct + +Please see our [Code of Conduct](CODE_OF_CONDUCT.md) before participating in this project. + +## Security Policy + +For any security issues, please see our [Security Policy](SECURITY.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..f941d308b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,41 @@ + + +## Security + +Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin) and [PowerShell](https://github.com/PowerShell). + +If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/security.md/definition), please report it to us as described below. + +## Reporting Security Issues + +**Please do not report security vulnerabilities through public GitHub issues.** + +Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/security.md/msrc/create-report). + +If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/security.md/msrc/pgp). + +You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). + +Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: + + * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) + * Full paths of source file(s) related to the manifestation of the issue + * The location of the affected source code (tag/branch/commit or direct URL) + * Any special configuration required to reproduce the issue + * Step-by-step instructions to reproduce the issue + * Proof-of-concept or exploit code (if possible) + * Impact of the issue, including how an attacker might exploit the issue + +This information will help us triage your report more quickly. + +If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/security.md/msrc/bounty) page for more details about our active programs. + +## Preferred Languages + +We prefer all communications to be in English. + +## Policy + +Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/security.md/cvd). + + diff --git a/buildtools.psd1 b/buildtools.psd1 index 6542e455e..dffba0919 100644 --- a/buildtools.psd1 +++ b/buildtools.psd1 @@ -4,25 +4,25 @@ @{ # Script module or binary module file associated with this manifest. RootModule = '.\buildtools.psm1' - + # Version number of this module. ModuleVersion = '1.0.0' - + # Supported PSEditions CompatiblePSEditions = @('Core') - + # ID used to uniquely identify this module GUID = 'fcdd259e-1163-4da2-8bfa-ce36a839f337' - + # Author of this module Author = 'Microsoft Corporation' - + # Company or vendor of this module CompanyName = 'Microsoft Corporation' - + # Copyright statement for this module Copyright = '(c) Microsoft Corporation. All rights reserved.' - + # Description of the functionality provided by this module Description = "Build utilties." @@ -32,20 +32,20 @@ # @{ ModuleName = 'Pester'; ModuleVersion = '4.8.1' }, # @{ ModuleName = 'PSScriptAnalyzer'; ModuleVersion = '1.18.0' } #) - + # Minimum version of the PowerShell engine required by this module PowerShellVersion = '5.1' - + # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. CmdletsToExport = @() - + # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. FunctionsToExport = @( - 'Get-BuildConfiguration', 'Invoke-ModuleBuild', 'Publish-ModulePackage', 'Install-ModulePackageForTest', 'Invoke-ModuleTests') - + 'Get-BuildConfiguration', 'Invoke-ModuleBuild', 'Publish-ModulePackage', 'Install-ModulePackageForTest', 'Invoke-ModuleTests', 'Invoke-ModuleTestsACR') + # Variables to export from this module VariablesToExport = '*' - + # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. AliasesToExport = @() } diff --git a/buildtools.psm1 b/buildtools.psm1 index 9ad5ed352..9aab19832 100644 --- a/buildtools.psm1 +++ b/buildtools.psm1 @@ -120,17 +120,51 @@ function Install-ModulePackageForTest { } Write-Verbose -Verbose -Message "Installing module $($config.ModuleName) to build output path $installationPath" - Save-PSResource -Name $config.ModuleName -Repository $localRepoName -Path $installationPath -SkipDependencyCheck -Prerelease -Confirm:$false -TrustRepository + $psgetModuleBase = (get-command save-psresource).Module.ModuleBase + $psgetVersion = (get-command save-psresource).Module.Version.ToString() + $psgetPrerelease = (get-command find-psresource).module.PrivateData.PSData.Prerelease + Write-Verbose -Verbose -Message "PSResourceGet module base imported: $psgetModuleBase" + Write-Verbose -Verbose -Message "PSResourceGet version base imported: $psgetVersion" + Write-Verbose -Verbose -Message "PSResourceGet prerelease base imported: $psgetPrerelease" + #Save-PSResource -Name $config.ModuleName -Repository $localRepoName -Path $installationPath -SkipDependencyCheck -Prerelease -Confirm:$false -TrustRepository + + Register-PSRepository -Name $localRepoName -SourceLocation $packagePathWithNupkg -InstallationPolicy Trusted -Verbose + $psgetv2ModuleBase = (get-command save-module).Module.ModuleBase + $psgetv2Version = (get-command save-module).Module.Version.ToString() + $psgetv2Prerelease = (get-command save-module).module.PrivateData.PSData.Prerelease + Write-Verbose -Verbose -Message "PowerShellGet module base imported: $psgetv2ModuleBase" + Write-Verbose -Verbose -Message "PowerShellGet version base imported: $psgetv2Version" + Write-Verbose -Verbose -Message "PowerShellGet prerelease base imported: $psgetv2Prerelease" + Save-Module -Name $config.ModuleName -Repository $localRepoName -Path $installationPath -Force -Verbose -AllowPrerelease -Confirm:$false + Unregister-PSRepository -Name $localRepoName Write-Verbose -Verbose -Message "Unregistering local package repo: $localRepoName" Unregister-PSResourceRepository -Name $localRepoName -Confirm:$false } +function Invoke-ModuleTestsACR { + [CmdletBinding()] + param ( + [ValidateSet("Functional", "StaticAnalysis")] + [string[]] $Type = "Functional" + ) + + $acrTestFiles = @( + "FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1", + "InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1", + "PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1" + ) + + Invoke-ModuleTests -Type $Type -TestFilePath $acrTestFiles +} + + function Invoke-ModuleTests { [CmdletBinding()] param ( [ValidateSet("Functional", "StaticAnalysis")] - [string[]] $Type = "Functional" + [string[]] $Type = "Functional", + [string[]] $TestFilePath = "." ) Write-Verbose -Verbose -Message "Starting module Pester tests..." @@ -143,7 +177,15 @@ function Invoke-ModuleTests { $testPath = $config.TestPath Write-Verbose -Verbose $config.ModuleName $moduleToTest = Join-Path -Path $config.BuildOutputPath -ChildPath "Microsoft.PowerShell.PSResourceGet" - $command = "Import-Module -Name ${moduleToTest} -Force -Verbose; Set-Location -Path ${testPath}; Invoke-Pester -Path . -OutputFile ${testResultFileName} -Tags '${tags}' -ExcludeTag '${excludeTag}'" + + if ($TestFilePath.Count -gt 1) { + $TestFilePathJoined = $TestFilePath -join ',' + } + else { + $TestFilePathJoined = $TestFilePath + } + + $command = "Import-Module -Name ${moduleToTest} -Force -Verbose; Set-Location -Path ${testPath}; Invoke-Pester -Script ${TestFilePathJoined} -OutputFile ${testResultFileName} -Tags '${tags}' -ExcludeTag '${excludeTag}'" $pwshExePath = (Get-Process -Id $pid).Path Write-Verbose -Verbose -Message "Running Pester tests with command: $command using pwsh.exe path: $pwshExePath" diff --git a/doBuild.ps1 b/doBuild.ps1 index b62e1f74d..15293d99b 100644 --- a/doBuild.ps1 +++ b/doBuild.ps1 @@ -34,9 +34,19 @@ function DoBuild Copy-Item -Path "./LICENSE" -Dest "$BuildOutPath" # Copy notice - Write-Verbose -Verbose -Message "Copying ThirdPartyNotices.txt to '$BuildOutPath'" + Write-Verbose -Verbose -Message "Copying Notice.txt to '$BuildOutPath'" Copy-Item -Path "./Notice.txt" -Dest "$BuildOutPath" + # Copy Group Policy files + Write-Verbose -Verbose -Message "Copying InstallPSResourceGetPolicyDefinitions.ps1 to '$BuildOutPath'" + Copy-Item -Path "${SrcPath}/InstallPSResourceGetPolicyDefinitions.ps1" -Dest "$BuildOutPath" -Force + + Write-Verbose -Verbose -Message "Copying PSResourceRepository.adml to '$BuildOutPath'" + Copy-Item -Path "${SrcPath}/PSResourceRepository.adml" -Dest "$BuildOutPath" -Force + + Write-Verbose -Verbose -Message "Copying PSResourceRepository.admx to '$BuildOutPath'" + Copy-Item -Path "${SrcPath}/PSResourceRepository.admx" -Dest "$BuildOutPath" -Force + # Build and place binaries if ( Test-Path "${SrcPath}/code" ) { Write-Verbose -Verbose -Message "Building assembly and copying to '$BuildOutPath'" @@ -76,6 +86,16 @@ function DoBuild ) $depAssemblyNames = @( + 'Azure.Core' + 'Azure.Identity' + 'Microsoft.Bcl.AsyncInterfaces' + 'Microsoft.Extensions.FileProviders.Abstractions' + 'Microsoft.Extensions.FileSystemGlobbing' + 'Microsoft.Extensions.Primitives' + 'Microsoft.Identity.Client' + 'Microsoft.Identity.Client.Extensions.Msal' + 'Microsoft.IdentityModel.Abstractions' + 'Newtonsoft.Json' 'NuGet.Commands' 'NuGet.Common' 'NuGet.Configuration' @@ -87,16 +107,19 @@ function DoBuild 'NuGet.ProjectModel' 'NuGet.Protocol' 'NuGet.Versioning' - 'Newtonsoft.Json' - 'System.Text.Json' 'System.Buffers' + 'System.Diagnostics.DiagnosticSource' + 'System.IO.FileSystem.AccessControl' + 'System.Memory.Data' 'System.Memory' 'System.Numerics.Vectors' 'System.Runtime.CompilerServices.Unsafe' + 'System.Security.AccessControl' + 'System.Security.Cryptography.ProtectedData' + 'System.Security.Principal.Windows' 'System.Text.Encodings.Web' 'System.Text.Json' 'System.Threading.Tasks.Extensions' - 'Microsoft.Bcl.AsyncInterfaces' 'System.ValueTuple' ) diff --git a/global.json b/global.json new file mode 100644 index 000000000..77d62424a --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "sdk": { + "version": "8.0.406" + } +} diff --git a/nuget.config b/nuget.config index 654858614..5c704bf46 100644 --- a/nuget.config +++ b/nuget.config @@ -2,7 +2,7 @@ - + diff --git a/owners.txt b/owners.txt new file mode 100644 index 000000000..9fc01f3f2 --- /dev/null +++ b/owners.txt @@ -0,0 +1,16 @@ +; this is an example owners.txt file which can be added at any level in the repo to indicate owners of a subtree +; this file is used by ownership enforcer to determine reviewers to add to a pull request +; you can add comments using ; as prefix +; introduce each owner in a separate line with his/her alias (not email address) +; prefixing an alias with * means that the owner will not be automatically added as a reviewer to pull requests to reduce noise, but can still be manually added and can sign off if necessary +; to learn more you can read https://microsoft.sharepoint.com/teams/WAG/EngSys/EngPipeline/cdp/SitePages/Configure%20checkin%20gates.aspx +; if you do not wish to use this feature then you can delete this file +; example (pretend the following lines are not commented): +; +; developer1 +; developer2 +; *developer3 +annavied +americks +adityap +*slee diff --git a/src/InstallPSResourceGetPolicyDefinitions.ps1 b/src/InstallPSResourceGetPolicyDefinitions.ps1 new file mode 100644 index 000000000..e0f2d15d4 --- /dev/null +++ b/src/InstallPSResourceGetPolicyDefinitions.ps1 @@ -0,0 +1,88 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# +.Synopsis + Group Policy tools use administrative template files (.admx, .adml) to populate policy settings in the user interface. + This allows administrators to manage registry-based policy settings. + This script installs PSResourceGet Administrative Templates for Windows. +.Notes + The PSResourceRepository.admx and PSResourceRepository.adml files are + expected to be at the location specified by the Path parameter with default value of the location of this script. +#> +[CmdletBinding()] +param +( + [ValidateNotNullOrEmpty()] + [string] $Path = $PSScriptRoot +) +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +function Test-Elevated +{ + [CmdletBinding()] + [OutputType([bool])] + Param() + + # if the current Powershell session was called with administrator privileges, + # the Administrator Group's well-known SID will show up in the Groups for the current identity. + # Note that the SID won't show up unless the process is elevated. + return (([Security.Principal.WindowsIdentity]::GetCurrent()).Groups -contains "S-1-5-32-544") +} +$IsWindowsOs = $PSHOME.EndsWith('\WindowsPowerShell\v1.0', [System.StringComparison]::OrdinalIgnoreCase) -or $IsWindows + +if (-not $IsWindowsOs) +{ + throw 'This script must be run on Windows.' +} + +if (-not (Test-Elevated)) +{ + throw 'This script must be run from an elevated process.' +} + +if ([System.Management.Automation.Platform]::IsNanoServer) +{ + throw 'Group policy definitions are not supported on Nano Server.' +} + +$admxName = 'PSResourceRepository.admx' +$admlName = 'PSResourceRepository.adml' +$admx = Get-Item -Path (Join-Path -Path $Path -ChildPath $admxName) +$adml = Get-Item -Path (Join-Path -Path $Path -ChildPath $admlName) +$admxTargetPath = Join-Path -Path $env:WINDIR -ChildPath "PolicyDefinitions" +$admlTargetPath = Join-Path -Path $admxTargetPath -ChildPath "en-US" + +$files = @($admx, $adml) +foreach ($file in $files) +{ + if (-not (Test-Path -Path $file)) + { + throw "Could not find $($file.Name) at $Path" + } +} + +Write-Verbose "Copying $admx to $admxTargetPath" +Copy-Item -Path $admx -Destination $admxTargetPath -Force +$admxTargetFullPath = Join-Path -Path $admxTargetPath -ChildPath $admxName +if (Test-Path -Path $admxTargetFullPath) +{ + Write-Verbose "$admxName was installed successfully" +} +else +{ + Write-Error "Could not install $admxName" +} + +Write-Verbose "Copying $adml to $admlTargetPath" +Copy-Item -Path $adml -Destination $admlTargetPath -Force +$admlTargetFullPath = Join-Path -Path $admlTargetPath -ChildPath $admlName +if (Test-Path -Path $admlTargetFullPath) +{ + Write-Verbose "$admlName was installed successfully" +} +else +{ + Write-Error "Could not install $admlName" +} diff --git a/src/Microsoft.PowerShell.PSResourceGet.psd1 b/src/Microsoft.PowerShell.PSResourceGet.psd1 index e4e01cfbb..c9ca1cdd9 100644 --- a/src/Microsoft.PowerShell.PSResourceGet.psd1 +++ b/src/Microsoft.PowerShell.PSResourceGet.psd1 @@ -4,7 +4,7 @@ @{ RootModule = './Microsoft.PowerShell.PSResourceGet.dll' NestedModules = @('./Microsoft.PowerShell.PSResourceGet.psm1') - ModuleVersion = '1.0.0' + ModuleVersion = '1.1.1' CompatiblePSEditions = @('Core', 'Desktop') GUID = 'e4e0bda1-0703-44a5-b70d-8fe704cd0643' Author = 'Microsoft Corporation' @@ -16,6 +16,7 @@ CLRVersion = '4.0.0' FormatsToProcess = 'PSGet.Format.ps1xml' CmdletsToExport = @( + 'Compress-PSResource', 'Find-PSResource', 'Get-InstalledPSResource', 'Get-PSResourceRepository', @@ -45,7 +46,7 @@ 'udres') PrivateData = @{ PSData = @{ - #Prerelease = '' + # Prerelease = '' Tags = @('PackageManagement', 'PSEdition_Desktop', 'PSEdition_Core', @@ -55,130 +56,165 @@ ProjectUri = 'https://go.microsoft.com/fwlink/?LinkId=828955' LicenseUri = 'https://go.microsoft.com/fwlink/?LinkId=829061' ReleaseNotes = @' -## 1.0.0 +## 1.1.1 + +### Bug Fix +- Bugfix to retrieve all metadata properties when finding a PSResource from a ContainerRegistry repository (#1799) +- Update README.md (#1798) +- Use authentication challenge for unauthenticated ContainerRegistry repository (#1797) +- Bugfix for Install-PSResource with varying digit version against ContainerRegistry repository (#1796) +- Bugfix for updating ContainerRegistry dependency parsing logic to account for AzPreview package (#1792) +- Add wildcard support for MAR repository for FindAll and FindByName (#1786) +- Bugfix for nuspec dependency version range calculation for RequiredModules (#1784) + +## 1.1.0 + +### Bug Fix +- Bugfix for publishing .nupkg file to ContainerRegistry repository (#1763) +- Bugfix for PMPs like Artifactory needing modified filter query parameter to proxy upstream (#1761) +- Bugfix for ContainerRegistry repository to parse out dependencies from metadata (#1766) +- Bugfix for Install-PSResource Null pointer occurring when package is present only in upstream feed in ADO (#1760) +- Bugfix for local repository casing issue on Linux (#1750) +- Update README.md (#1759) +- Bug fix for case sensitive License.txt when RequireLicense is specified (#1757) +- Bug fix for broken -Quiet parameter for Save-PSResource (#1745) + +## 1.1.0-rc3 + +### Bug Fix +- Include missing commits + +## 1.1.0-RC2 ### New Features -- Add `ApiVersion` parameter for `Register-PSResourceRepository` (#1431) +- Full Microsoft Artifact Registry integration (#1741) ### Bug Fixes -- Automatically set the ApiVersion to v2 for repositories imported from PowerShellGet (#1430) -- Bug fix ADO v2 feed installation failures (#1429) -- Bug fix Artifactory v2 endpoint failures (#1428) -- Bug fix Artifactory v3 endpoint failures (#1427) -- Bug fix `-RequiredResource` silent failures (#1426) -- Bug fix for v2 repository returning extra packages for `-Tag` based search with `-Prerelease` (#1405) -## 0.9.0-rc1 +- Update to use OCI v2 APIs for Container Registry (#1737) +- Bug fixes for finding and installing from local repositories on Linux machines (#1738) +- Bug fix for finding package name with 4 part version from local repositories (#1739) + +## 1.1.0-RC1 + +### New Features + +- Group Policy configurations for enabling or disabling PSResource repositories (#1730) ### Bug Fixes -- Bug fix for using `Import-PSGetRepository` in Windows PowerShell (#1390) -- Add error handling when searching for unlisted package versions (#1386) -- Bug fix for deduplicating dependencies found from `Find-PSResource` (#1382) -- Added support for non-PowerShell Gallery v2 repositories (#1380) -- Bug fix for setting 'unknown' repository `APIVersion` (#1377) -- Bug fix for saving a script with `-IncludeXML` parameter (#1375) -- Bug fix for v3 server logic to properly parse inner @id element (#1374) -- Bug fix to write warning instead of error when package is already installed (#1367) -## 0.5.24-beta24 +- Fix packaging name matching when searching in local repositories (#1731) +- `Compress-PSResource` `-PassThru` now passes `FileInfo` instead of string (#1720) +- Fix for `Compress-PSResource` not properly compressing scripts (#1719) +- Add `AcceptLicense` to Save-PSResource (#1718 Thanks @o-l-a-v!) +- Better support for NuGet v2 feeds (#1713 Thanks @o-l-a-v!) +- Better handling of `-WhatIf` support in `Install-PSResource` (#1531 Thanks @o-l-a-v!) +- Fix for some nupkgs failing to extract due to empty directories (#1707 Thanks @o-l-a-v!) +- Fix for searching for `-Name *` in `Find-PSResource` (#1706 Thanks @o-l-a-v!) + +## 1.1.0-preview2 + +### New Features + +- New cmdlet `Compress-PSResource` which packs a package into a .nupkg and saves it to the file system (#1682, #1702) +- New `-Nupkg` parameter for `Publish-PSResource` which pushes pushes a .nupkg to a repository (#1682) +- New `-ModulePrefix` parameter for `Publish-PSResource` which adds a prefix to a module name for container registry repositories to add a module prefix.This is only used for publishing and is not part of metadata. MAR will drop the prefix when syndicating from ACR to MAR (#1694) ### Bug Fixes -- Detect empty V2 server responses at ServerApiCall level instead of ResponseUtil level (#1358) -- Bug fix for finding all versions of a package returning correct results and incorrect "package not found" error (#1356) -- Bug fix for installing or saving a pkg found in lower priority repository (#1350) -- Ensure `-Prerelease` is not empty or whitespace for `Update-PSModuleManifest` (#1348) -- Bug fix for saving `Az` module dependencies (#1343) -- Bug fix for `Find-PSResource` repository looping to to return matches from all repositories (#1342) -- Update error handling for Tags, Commands, and DSCResources when searching across repositories (#1339) -- Update `Find-PSResource` looping and error handling to account for multiple package names (#1338) -- Update error handling for `Find-PSResource` using V2 server endpoint repositories (#1329) -- Bug fix for searching through multiple repositories when some repositories do not contain the specified package (#1328) -- Add parameters to `Install-PSResource` verbose message (#1327) -- Bug fix for parsing required modules when publishing (#1326) -- Bug fix for saving dependency modules in version range format (#1323) -- Bug fix for `Install-PSResource` failing to find prerelease dependencies (#1322) -- Bug fix for updating to a new version of a prerelease module (#1320) -- Fix for error message when DSCResource is not found (#1317) -- Add error handling for local repository pattern based searching (#1316) -- `Set-PSResourceRepository` run without `-ApiVersion` paramater no longer resets the property for the repository (#1310) - - -## 0.5.23-beta23 - -### Breaking Changes + +- Add prerelease string when NormalizedVersion doesn't exist, but prelease string does (#1681 Thanks @sean-r-williams) +- Add retry logic when deleting files (#1667 Thanks @o-l-a-v!) +- Fix broken PAT token use (#1672) +- Updated error messaging for authenticode signature failures (#1701) + +## 1.1.0-preview1 ### New Features -- *-PSResourceRepository -Uri now accepting PSPaths (#1269) -- Add aliases for Install-PSResource, Find-PSResource, Update-PSResource, Publish-PSResource (#1264) -- Add custom user agent string to API calls (#1260) -- Support install for NuGet.Server application hosted feed (#1253) -- Add support for NuGet.Server application hosted feeds (#1236) -- Add Import-PSGetRepository function to import existing v2 PSRepositories into PSResourceRepositories. (#1221) -- Add 'Get-PSResource' alias to 'Get-InstalledPSResource' (#1216) -- Add -ApiVersion parameter to Set-PSResourceRepository (#1207) -- Add support for FindNameGlobbing scenarios (i.e -Name az*) for MyGet server repository (V3) (#1202) +- Support for Azure Container Registries (#1495, #1497-#1499, #1501, #1502, #1505, #1522, #1545, #1548, #1550, #1554, #1560, #1567, +#1573, #1576, #1587, #1588, #1589, #1594, #1598, #1600, #1602, #1604, #1615) ### Bug Fixes -- Better error handling for scenario where repo ApiVersion is unknown and allow for PSPaths as URI for registered repositories (#1288) -- Bugfix for Uninstall should be able to remove older versions of a package that are not a dependency (#1287) -- Bugfix for Publish finding prerelease dependency versions. (#1283) -- Fix Pagination for V3 search with globbing scenarios (#1277) -- Update message for -WhatIf in Install-PSResource, Save-PSResource, and Update-PSResource (#1274) -- Bug fix for publishing with ExternalModuleDependencies (#1271) -- Support Credential Persistence for Publish-PSResource (#1268) -- Update Save-PSResource -Path param so it defaults to the current working directory (#1265) -- Update dependency error message in Publish-PSResource (#1263) -- Bug fixes for script metadata (#1259) -- Fix error message for Publish-PSResource for MyGet.org feeds (#1256) -- Bug fix for version ranges with prerelease versions not returning the correct versions (#1255) -- Bug fix for file path version must match psd1 version error when publishing (#1254) -- Bug fix for searching through local repositories with -Type parameter (#1252) -- Allow environment variables in module manifests (#1249) -- Updating prerelease version should update to latest prerelease version (#1238) -- Fix InstallHelper call to GetEnvironmentVariable() on Unix (#1237) -- Update build script to resolve module loading error (#1234) -- Enable UNC Paths for local repositories, source directories and destination directories (#1229) -- Improve better error handling for -Path in Publish-PSResource (#1227) -- Bug fix for RequireLicenseAcceptance in Publish-PSResource (#1225) -- Provide clearer error handling for V3 Publish support (#1224) -- Fix bug with version parsing in Publish-PSResource (#1223) -- Improve error handling for Find-PSResource (#1222) -- Add error handling to Get-InstalledPSResource and Find-PSResource (#1217) -- Improve error handling in Uninstall-PSResource (#1215) -- Change resolved paths to use GetResolvedProviderPathFromPSPath (#1209) -- Bug fix for Get-InstalledPSResource returning type of scripts as module (#1198) - - -## 0.5.22-beta22 - -### Breaking Changes -- PowerShellGet is now PSResourceGet! (#1164) -- Update-PSScriptFile is now Update-PSScriptFileInfo (#1140) -- New-PSScriptFile is now New-PSScriptFileInfo (#1140) -- Update-ModuleManifest is now Update-PSModuleManifest (#1139) -- -Tags parameter changed to -Tag in New-PSScriptFile, Update-PSScriptFileInfo, and Update-ModuleManifest (#1123) -- Change the type of -InputObject from PSResource to PSResource[] for Install-PSResource, Save-PSResource, and Uninstall-PSResource (#1124) -- PSModulePath is no longer referenced when searching paths (#1154) + +- Fix incorrect request URL when installing resources from ADO (#1597 Thanks @anytonyoni!) +- Fix for swallowed exceptions (#1569) +- Fix for PSResourceGet not working in Constrained Languange Mode (#1564) + +## 1.0.6 + +- Bump System.Text.Json to 8.0.5 + +## [1.0.5](https://github.com/PowerShell/PSResourceGet/compare/v1.0.4.1...v1.0.5) - 2024-05-13 + +### Bug Fixes +- Update `nuget.config` to use PowerShell packages feed (#1649) +- Refactor V2ServerAPICalls and NuGetServerAPICalls to use object-oriented query/filter builder (#1645 Thanks @sean-r-williams!) +- Fix unnecessary `and` for version globbing in V2ServerAPICalls (#1644 Thanks again @sean-r-williams!) +- Fix requiring `tags` in server response (#1627 Thanks @evelyn-bi!) +- Add 10 minute timeout to HTTPClient (#1626) +- Fix save script without `-IncludeXml` (#1609, #1614 Thanks @o-l-a-v!) +- PAT token fix to translate into HttpClient 'Basic Authorization'(#1599 Thanks @gerryleys!) +- Fix incorrect request url when installing from ADO (#1597 Thanks @antonyoni!) +- Improved exception handling (#1569) +- Ensure that .NET methods are not called in order to enable use in Constrained Language Mode (#1564) +- PSResourceGet packaging update + +## [1.0.4.1](https://github.com/PowerShell/PSResourceGet/compare/v1.0.4...v1.0.4.1) - 2024-04-05 + +- PSResourceGet packaging update + +## [1.0.4](https://github.com/PowerShell/PSResourceGet/compare/v1.0.3...v1.0.4) - 2024-04-05 + +### Patch + +- Dependency package updates + +## 1.0.3 + +### Bug Fixes +- Bug fix for null package version in `Install-PSResource` + +## 1.0.2 + +### Bug Fixes + +- Bug fix for `Update-PSResource` not updating from correct repository (#1549) +- Bug fix for creating temp home directory on Unix (#1544) +- Bug fix for creating `InstalledScriptInfos` directory when it does not exist (#1542) +- Bug fix for `Update-ModuleManifest` throwing null pointer exception (#1538) +- Bug fix for `name` property not populating in `PSResourceInfo` object when using `Find-PSResource` with JFrog Artifactory (#1535) +- Bug fix for incorrect configuration of requests to JFrog Artifactory v2 endpoints (#1533 Thanks @sean-r-williams!) +- Bug fix for determining JFrog Artifactory repositories (#1532 Thanks @sean-r-williams!) +- Bug fix for v2 server repositories incorrectly adding script endpoint (1526) +- Bug fixes for null references (#1525) +- Typo fixes in message prompts in `Install-PSResource` (#1510 Thanks @NextGData!) +- Bug fix to add `NormalizedVersion` property to `AdditionalMetadata` only when it exists (#1503 Thanks @sean-r-williams!) +- Bug fix to verify whether `Uri` is a UNC path and set respective `ApiVersion` (#1479 Thanks @kborowinski!) + +## 1.0.1 + +### Bug Fixes + +- Bugfix to update Unix local user installation paths to be compatible with .NET 7 and .NET 8 (#1464) +- Bugfix for Import-PSGetRepository in Windows PowerShell (#1460) +- Bugfix for `Test-PSScriptFileInfo`` to be less sensitive to whitespace (#1457) +- Bugfix to overwrite rels/rels directory on net472 when extracting nupkg to directory (#1456) +- Bugfix to add pipeline by property name support for Name and Repository properties for Find-PSResource (#1451 Thanks @ThomasNieto!) + +## 1.0.0 ### New Features -- Support for Azure Artifacts, GitHub Packages, and Artifactory (#1167, #1180) +- Add `ApiVersion` parameter for `Register-PSResourceRepository` (#1431) ### Bug Fixes -- Filter out unlisted packages (#1172, #1161) -- Add paging for V3 server requests (#1170) -- Support for floating versions (#1117) -- Update, Save, and Install with wildcard gets the latest version within specified range (#1117) -- Add positonal parameter for -Path in Publish-PSResource (#1111) -- Uninstall-PSResource -WhatIf now shows version and path of package being uninstalled (#1116) -- Find returns packages from the highest priority repository only (#1155) -- Bug fix for PSCredentialInfo constructor (#1156) -- Bug fix for Install-PSResource -NoClobber parameter (#1121) -- Save-PSResource now searches through all repos when no repo is specified (#1125) -- Caching for improved performance in Uninstall-PSResource (#1175) -- Bug fix for parsing package tags from local repository (#1119) - -See change log (CHANGELOG.md) at https://github.com/PowerShell/PSResourceGet +- Automatically set the ApiVersion to v2 for repositories imported from PowerShellGet (#1430) +- Bug fix ADO v2 feed installation failures (#1429) +- Bug fix Artifactory v2 endpoint failures (#1428) +- Bug fix Artifactory v3 endpoint failures (#1427) +- Bug fix `-RequiredResource` silent failures (#1426) +- Bug fix for v2 repository returning extra packages for `-Tag` based search with `-Prerelease` (#1405) + +See change log (CHANGELOG) at https://github.com/PowerShell/PSResourceGet '@ } } diff --git a/src/Microsoft.PowerShell.PSResourceGet.psm1 b/src/Microsoft.PowerShell.PSResourceGet.psm1 index 02fe8e8d7..3e477cb1f 100644 --- a/src/Microsoft.PowerShell.PSResourceGet.psm1 +++ b/src/Microsoft.PowerShell.PSResourceGet.psm1 @@ -25,9 +25,11 @@ function Import-PSGetRepository { Microsoft.PowerShell.Utility\Write-Verbose ('Found {0} registered PowerShellGet repositories.' -f $PSGetRepositories.Count) if ($PSGetRepositories.Count) { - $repos = $PSGetRepositories.Values | + $repos = @( + $PSGetRepositories.Values | Microsoft.PowerShell.Core\Where-Object {$_.PackageManagementProvider -eq 'NuGet'-and $_.Name -ne 'PSGallery'} | Microsoft.PowerShell.Utility\Select-Object Name, Trusted, SourceLocation + ) Microsoft.PowerShell.Utility\Write-Verbose ('Selected {0} NuGet repositories.' -f $repos.Count) @@ -43,7 +45,7 @@ function Import-PSGetRepository { Trusted = $_.Trusted PassThru = $true Force = $Force - ApiVersion = 'v2' + ApiVersion = if ([Uri]::new($_.SourceLocation).Scheme -eq 'file') {'local'} else {'v2'} } Register-PSResourceRepository @registerPSResourceRepositorySplat } diff --git a/src/PSGet.Format.ps1xml b/src/PSGet.Format.ps1xml index 18140432b..e81b0728a 100644 --- a/src/PSGet.Format.ps1xml +++ b/src/PSGet.Format.ps1xml @@ -94,6 +94,7 @@ + @@ -102,6 +103,7 @@ Uri Trusted Priority + IsAllowedByPolicy diff --git a/src/PSResourceRepository.adml b/src/PSResourceRepository.adml new file mode 100644 index 000000000..5da96427f --- /dev/null +++ b/src/PSResourceRepository.adml @@ -0,0 +1,20 @@ + + + + PSResourceGet Repository Policy + This creates an allow list of repositories for PSResourceGet. + + + At least Windows 11* + PSResourceGet Repository Policy + This creates an allow list of repositories for PSResourceGet. + PSResourceGet Repository Policies + + + + Please create an allow list of repositories using a name value pair like following: Name=PSGallery;Uri=https://www.powershellgallery.com/api/v2 + + + + + diff --git a/src/PSResourceRepository.admx b/src/PSResourceRepository.admx new file mode 100644 index 000000000..6e8db3ec6 --- /dev/null +++ b/src/PSResourceRepository.admx @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/code/.vs/Microsoft.PowerShell.PSResourceGet/v17/.suo b/src/code/.vs/Microsoft.PowerShell.PSResourceGet/v17/.suo index 05b0bcf91..12eaeb3a3 100644 Binary files a/src/code/.vs/Microsoft.PowerShell.PSResourceGet/v17/.suo and b/src/code/.vs/Microsoft.PowerShell.PSResourceGet/v17/.suo differ diff --git a/src/code/CompressPSResource.cs b/src/code/CompressPSResource.cs new file mode 100644 index 000000000..b306e7d88 --- /dev/null +++ b/src/code/CompressPSResource.cs @@ -0,0 +1,84 @@ +īģŋ// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.PowerShell.PSResourceGet.UtilClasses; +using System.IO; +using System.Management.Automation; + +namespace Microsoft.PowerShell.PSResourceGet.Cmdlets +{ + /// + /// Compresses a module, script, or nupkg to a designated repository. + /// + [Cmdlet(VerbsData.Compress, + "PSResource", + SupportsShouldProcess = true)] + [Alias("cmres")] + [OutputType(typeof(FileInfo))] + public sealed class CompressPSResource : PSCmdlet + { + #region Parameters + + /// + /// Specifies the path to the resource that you want to compress. This parameter accepts the path to the folder that contains the resource. + /// Specifies a path to one or more locations. Wildcards are permitted. The default location is the current directory (.). + /// + [Parameter(Mandatory = true, Position = 0, HelpMessage = "Path to the resource to be compressed.")] + [ValidateNotNullOrEmpty] + public string Path { get; set; } + + /// + /// Specifies the path where the compressed resource (as a .nupkg file) should be saved. + /// This parameter allows you to save the package to a specified location on the local file system. + /// + [Parameter(Mandatory = true, Position = 1, HelpMessage = "Path to save the compressed resource.")] + [ValidateNotNullOrEmpty] + public string DestinationPath { get; set; } + + /// + /// When specified, passes the full path of the nupkg through the pipeline. + /// + [Parameter(Mandatory = false, HelpMessage = "Pass the full path of the nupkg through the pipeline")] + public SwitchParameter PassThru { get; set; } + + /// + /// Bypasses validating a resource module manifest before compressing. + /// + [Parameter] + public SwitchParameter SkipModuleManifestValidate { get; set; } + + #endregion + + #region Members + + private PublishHelper _publishHelper; + + #endregion + + #region Method Overrides + + protected override void BeginProcessing() + { + // Create a respository store (the PSResourceRepository.xml file) if it does not already exist + // This is to create a better experience for those who have just installed v3 and want to get up and running quickly + RepositorySettings.CheckRepositoryStore(); + + _publishHelper = new PublishHelper( + this, + Path, + DestinationPath, + PassThru, + SkipModuleManifestValidate); + + _publishHelper.CheckAllParameterPaths(); + } + + protected override void EndProcessing() + { + _publishHelper.PackResource(); + } + + #endregion + + } +} diff --git a/src/code/ContainerRegistryInfo.cs b/src/code/ContainerRegistryInfo.cs new file mode 100644 index 000000000..6d8ff5c32 --- /dev/null +++ b/src/code/ContainerRegistryInfo.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections; + +namespace Microsoft.PowerShell.PSResourceGet.UtilClasses +{ + + public sealed class ContainerRegistryInfo + { + #region Properties + + public string Name { get; } + public string Metadata { get; } + public ResourceType ResourceType { get; } + + #endregion + + + #region Constructors + + internal ContainerRegistryInfo(string name, string metadata, string resourceType) + + { + Name = name ?? string.Empty; + Metadata = metadata ?? string.Empty; + ResourceType = string.IsNullOrWhiteSpace(resourceType) ? ResourceType.None : + (ResourceType)Enum.Parse(typeof(ResourceType), resourceType, ignoreCase: true); + } + + #endregion + + #region Methods + + internal Hashtable ToHashtable() + { + Hashtable hashtable = new Hashtable + { + { "Name", Name }, + { "Metadata", Metadata }, + { "ResourceType", ResourceType } + }; + + return hashtable; + } + + #endregion + } +} diff --git a/src/code/ContainerRegistryResponseUtil.cs b/src/code/ContainerRegistryResponseUtil.cs new file mode 100644 index 000000000..1bb509d9e --- /dev/null +++ b/src/code/ContainerRegistryResponseUtil.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.PowerShell.PSResourceGet.UtilClasses; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Text.Json; + +namespace Microsoft.PowerShell.PSResourceGet.Cmdlets +{ + internal class ContainerRegistryResponseUtil : ResponseUtil + { + #region Members + + internal override PSRepositoryInfo Repository { get; set; } + + #endregion + + #region Constructor + + public ContainerRegistryResponseUtil(PSRepositoryInfo repository) : base(repository) + { + Repository = repository; + } + + #endregion + + #region Overriden Methods + + public override IEnumerable ConvertToPSResourceResult(FindResults responseResults) + { + Hashtable[] responses = responseResults.HashtableResponse; + foreach (Hashtable response in responses) + { + string responseConversionError = String.Empty; + PSResourceInfo pkg = null; + + // Hashtable should have keys for Name, Metadata, ResourceType + if (!response.ContainsKey("Name") && string.IsNullOrWhiteSpace(response["Name"].ToString())) + { + yield return new PSResourceResult(returnedObject: pkg, exception: new ConvertToPSResourceException("Error retrieving package name from response."), isTerminatingError: true); + } + + if (!response.ContainsKey("Metadata")) + { + yield return new PSResourceResult(returnedObject: pkg, exception: new ConvertToPSResourceException("Error retrieving package metadata from response."), isTerminatingError: true); + } + + ResourceType? resourceType = response.ContainsKey("ResourceType") ? response["ResourceType"] as ResourceType? : ResourceType.None; + + try + { + using (JsonDocument pkgVersionEntry = JsonDocument.Parse(response["Metadata"].ToString())) + { + PSResourceInfo.TryConvertFromContainerRegistryJson(response["Name"].ToString(), pkgVersionEntry, resourceType, out pkg, Repository, out responseConversionError); + } + } + catch (Exception e) + { + responseConversionError = e.Message; + } + + if (!String.IsNullOrEmpty(responseConversionError)) + { + yield return new PSResourceResult(returnedObject: null, new ConvertToPSResourceException(responseConversionError), isTerminatingError: false); + } + + yield return new PSResourceResult(returnedObject: pkg, exception: null, isTerminatingError: false); + } + } + + #endregion + + } +} diff --git a/src/code/ContainerRegistryServerAPICalls.cs b/src/code/ContainerRegistryServerAPICalls.cs new file mode 100644 index 000000000..785c7aeae --- /dev/null +++ b/src/code/ContainerRegistryServerAPICalls.cs @@ -0,0 +1,1856 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.PowerShell.PSResourceGet.UtilClasses; +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using NuGet.Versioning; +using System.Threading.Tasks; +using System.Net; +using System.Management.Automation; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json; +using System.Collections.ObjectModel; +using System.Net.Http.Headers; +using System.Linq; +using Microsoft.PowerShell.PSResourceGet.Cmdlets; +using System.Text; +using System.Security.Cryptography; +using System.Text.Json; + +namespace Microsoft.PowerShell.PSResourceGet +{ + internal class ContainerRegistryServerAPICalls : ServerApiCall + { + // Any interface method that is not implemented here should be processed in the parent method and then call one of the implemented + // methods below. + #region Members + + public override PSRepositoryInfo Repository { get; set; } + public String Registry { get; set; } + private readonly PSCmdlet _cmdletPassedIn; + private HttpClient _sessionClient { get; set; } + private static readonly Hashtable[] emptyHashResponses = new Hashtable[] { }; + private static FindResponseType containerRegistryFindResponseType = FindResponseType.ResponseString; + private static readonly FindResults emptyResponseResults = new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: containerRegistryFindResponseType); + + const string containerRegistryRefreshTokenTemplate = "grant_type=access_token&service={0}&tenant={1}&access_token={2}"; // 0 - registry, 1 - tenant, 2 - access token + const string containerRegistryAccessTokenTemplate = "grant_type=refresh_token&service={0}&scope=repository:*:*&scope=registry:catalog:*&refresh_token={1}"; // 0 - registry, 1 - refresh token + const string containerRegistryOAuthExchangeUrlTemplate = "https://{0}/oauth2/exchange"; // 0 - registry + const string containerRegistryOAuthTokenUrlTemplate = "https://{0}/oauth2/token"; // 0 - registry + const string containerRegistryManifestUrlTemplate = "https://{0}/v2/{1}/manifests/{2}"; // 0 - registry, 1 - repo(modulename), 2 - tag(version) + const string containerRegistryBlobDownloadUrlTemplate = "https://{0}/v2/{1}/blobs/{2}"; // 0 - registry, 1 - repo(modulename), 2 - layer digest + const string containerRegistryFindImageVersionUrlTemplate = "https://{0}/v2/{1}/tags/list"; // 0 - registry, 1 - repo(modulename) + const string containerRegistryStartUploadTemplate = "https://{0}/v2/{1}/blobs/uploads/"; // 0 - registry, 1 - packagename + const string containerRegistryEndUploadTemplate = "https://{0}{1}&digest=sha256:{2}"; // 0 - registry, 1 - location, 2 - digest + const string defaultScope = "&scope=repository:*:*&scope=registry:catalog:*"; + const string containerRegistryRepositoryListTemplate = "https://{0}/v2/_catalog"; // 0 - registry + + #endregion + + #region Constructor + + public ContainerRegistryServerAPICalls(PSRepositoryInfo repository, PSCmdlet cmdletPassedIn, NetworkCredential networkCredential, string userAgentString) : base(repository, networkCredential) + { + Repository = repository; + Registry = Repository.Uri.Host; + _cmdletPassedIn = cmdletPassedIn; + HttpClientHandler handler = new HttpClientHandler() + { + Credentials = networkCredential + }; + + _sessionClient = new HttpClient(handler); + _sessionClient.Timeout = TimeSpan.FromMinutes(10); + _sessionClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgentString); + } + + #endregion + + #region Overriden Methods + + /// + /// Find method which allows for searching for all packages from a repository and returns latest version for each. + /// + public override FindResults FindAll(bool includePrerelease, ResourceType type, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindAll()"); + var findResult = FindPackages("*", includePrerelease, out errRecord); + if (errRecord != null) + { + return emptyResponseResults; + } + + return findResult; + } + + /// + /// Find method which allows for searching for packages with tag from a repository and returns latest version for each. + /// + public override FindResults FindTags(string[] tags, bool includePrerelease, ResourceType _type, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindTags()"); + errRecord = new ErrorRecord( + new InvalidOperationException($"Find tags is not supported for the ContainerRegistry server protocol repository '{Repository.Name}'"), + "FindTagsFailure", + ErrorCategory.InvalidOperation, + this); + + return emptyResponseResults; + } + + /// + /// Find method which allows for searching for all packages that have specified Command or DSCResource name. + /// + public override FindResults FindCommandOrDscResource(string[] tags, bool includePrerelease, bool isSearchingForCommands, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindCommandOrDscResource()"); + errRecord = new ErrorRecord( + new InvalidOperationException($"Find Command or DSC Resource is not supported for the ContainerRegistry server protocol repository '{Repository.Name}'"), + "FindCommandOrDscResourceFailure", + ErrorCategory.InvalidOperation, + this); + + return emptyResponseResults; + } + + /// + /// Find method which allows for searching for single name and returns latest version. + /// Name: no wildcard support + /// Examples: Search "PowerShellGet" + /// Implementation Note: Need to filter further for latest version (prerelease or non-prerelease dependening on user preference) + /// + public override FindResults FindName(string packageName, bool includePrerelease, ResourceType type, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindName()"); + + // for FindName(), need to consider all versions (hence VersionType.VersionRange and VersionRange.All, and no requiredVersion) but only pick latest (hence getOnlyLatest: true) + Hashtable[] pkgResult = FindPackagesWithVersionHelper(packageName, VersionType.VersionRange, versionRange: VersionRange.All, requiredVersion: null, includePrerelease, getOnlyLatest: true, out errRecord); + if (errRecord != null) + { + return emptyResponseResults; + } + + return new FindResults(stringResponse: new string[] { }, hashtableResponse: pkgResult.ToArray(), responseType: containerRegistryFindResponseType); + } + + /// + /// Find method which allows for searching for single name and tag and returns latest version. + /// Name: no wildcard support + /// Examples: Search "PowerShellGet" -Tag "Provider" + /// + public override FindResults FindNameWithTag(string packageName, string[] tags, bool includePrerelease, ResourceType type, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindNameWithTag()"); + errRecord = new ErrorRecord( + new InvalidOperationException($"Find name with tag(s) is not supported for the ContainerRegistry server protocol repository '{Repository.Name}'"), + "FindNameWithTagFailure", + ErrorCategory.InvalidOperation, + this); + + return emptyResponseResults; + } + + /// + /// Find method which allows for searching for single name with wildcards and returns latest version. + /// Name: supports wildcards + /// Examples: Search "PowerShell*" + /// Implementation Note: filter additionally and verify ONLY package name was a match. + /// + public override FindResults FindNameGlobbing(string packageName, bool includePrerelease, ResourceType type, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindNameGlobbing()"); + var findResult = FindPackages(packageName, includePrerelease, out errRecord); + if (errRecord != null) + { + return emptyResponseResults; + } + + return findResult; + } + + /// + /// Find method which allows for searching for single name with wildcards and tag and returns latest version. + /// Name: supports wildcards + /// Examples: Search "PowerShell*" -Tag "Provider" + /// Implementation Note: filter additionally and verify ONLY package name was a match. + /// + public override FindResults FindNameGlobbingWithTag(string packageName, string[] tags, bool includePrerelease, ResourceType type, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindNameGlobbingWithTag()"); + errRecord = new ErrorRecord( + new InvalidOperationException($"Find name globbing with tag(s) is not supported for the ContainerRegistry server protocol repository '{Repository.Name}'"), + "FindNameGlobbingWithTagFailure", + ErrorCategory.InvalidOperation, + this); + + return emptyResponseResults; + } + + /// + /// Find method which allows for searching for single name with version range. + /// Name: no wildcard support + /// Version: supports wildcards + /// Examples: Search "PowerShellGet" "[3.0.0.0, 5.0.0.0]" + /// Search "PowerShellGet" "3.*" + /// Implementation note: Returns all versions, including prerelease ones. Later (in the API client side) we'll do filtering on the versions to satisfy what user provided. + /// + public override FindResults FindVersionGlobbing(string packageName, VersionRange versionRange, bool includePrerelease, ResourceType type, bool getOnlyLatest, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindVersionGlobbing()"); + + // for FindVersionGlobbing(), need to consider all versions that match version range criteria (hence VersionType.VersionRange and no requiredVersion) + Hashtable[] pkgResults = FindPackagesWithVersionHelper(packageName, VersionType.VersionRange, versionRange: versionRange, requiredVersion: null, includePrerelease, getOnlyLatest: false, out errRecord); + if (errRecord != null) + { + return emptyResponseResults; + } + + return new FindResults(stringResponse: new string[] { }, hashtableResponse: pkgResults.ToArray(), responseType: containerRegistryFindResponseType); + } + + /// + /// Find method which allows for searching for single name with specific version. + /// Name: no wildcard support + /// Version: no wildcard support + /// Examples: Search "PowerShellGet" "2.2.5" + /// + public override FindResults FindVersion(string packageName, string version, ResourceType type, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindVersion()"); + if (!NuGetVersion.TryParse(version, out NuGetVersion requiredVersion)) + { + errRecord = new ErrorRecord( + new ArgumentException($"Version {version} to be found is not a valid NuGet version."), + "FindNameFailure", + ErrorCategory.InvalidArgument, + this); + + return emptyResponseResults; + } + + _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{requiredVersion}'"); + bool includePrereleaseVersions = requiredVersion.IsPrerelease; + + // for FindVersion(), need to consider the specific required version (hence VersionType.SpecificVersion and no version range) + Hashtable[] pkgResult = FindPackagesWithVersionHelper(packageName, VersionType.SpecificVersion, versionRange: VersionRange.None, requiredVersion: requiredVersion, includePrereleaseVersions, getOnlyLatest: false, out errRecord); + if (errRecord != null) + { + return emptyResponseResults; + } + + return new FindResults(stringResponse: new string[] { }, hashtableResponse: pkgResult.ToArray(), responseType: containerRegistryFindResponseType); + } + + /// + /// Find method which allows for searching for single name with specific version and tag. + /// Name: no wildcard support + /// Version: no wildcard support + /// Examples: Search "PowerShellGet" "2.2.5" -Tag "Provider" + /// + public override FindResults FindVersionWithTag(string packageName, string version, string[] tags, ResourceType type, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindVersionWithTag()"); + errRecord = new ErrorRecord( + new InvalidOperationException($"Find version with tag(s) is not supported for the ContainerRegistry server protocol repository '{Repository.Name}'"), + "FindVersionWithTagFailure", + ErrorCategory.InvalidOperation, + this); + + return emptyResponseResults; + } + + /** INSTALL APIS **/ + + /// + /// Installs a specific package. + /// User may request to install package with or without providing version (as seen in examples below), but prior to calling this method the package is located and package version determined. + /// Therefore, package version should not be null in this method. + /// Name: no wildcard support. + /// Examples: Install "PowerShellGet" -Version "3.5.0-alpha" + /// Install "PowerShellGet" -Version "3.0.0" + /// + public override Stream InstallPackage(string packageName, string packageVersion, bool includePrerelease, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::InstallPackage()"); + Stream results = new MemoryStream(); + if (string.IsNullOrEmpty(packageVersion)) + { + errRecord = new ErrorRecord( + exception: new ArgumentNullException($"Package version could not be found for {packageName}"), + "PackageVersionNullOrEmptyError", + ErrorCategory.InvalidArgument, + _cmdletPassedIn); + + return results; + } + + string packageNameForInstall = PrependMARPrefix(packageName); + results = InstallVersion(packageNameForInstall, packageVersion, out errRecord); + return results; + } + + /// + /// Installs a package with version specified. + /// Version can be prerelease or stable. + /// + private Stream InstallVersion( + string packageName, + string packageVersion, + out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::InstallVersion()"); + errRecord = null; + string packageNameLowercase = packageName.ToLower(); + string accessToken = string.Empty; + string tenantID = string.Empty; + string tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + try + { + Directory.CreateDirectory(tempPath); + } + catch (Exception e) + { + errRecord = new ErrorRecord( + exception: e, + "InstallVersionTempDirCreationError", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + + return null; + } + + string containerRegistryAccessToken = GetContainerRegistryAccessToken(out errRecord); + if (errRecord != null) + { + return null; + } + + _cmdletPassedIn.WriteVerbose($"Getting manifest for {packageNameLowercase} - {packageVersion}"); + var manifest = GetContainerRegistryRepositoryManifest(packageNameLowercase, packageVersion, containerRegistryAccessToken, out errRecord); + if (errRecord != null) + { + return null; + } + string digest = GetDigestFromManifest(manifest, out errRecord); + if (errRecord != null) + { + return null; + } + + _cmdletPassedIn.WriteVerbose($"Downloading blob for {packageNameLowercase} - {packageVersion}"); + HttpContent responseContent; + try + { + responseContent = GetContainerRegistryBlobAsync(packageNameLowercase, digest, containerRegistryAccessToken).Result; + } + catch (Exception e) + { + errRecord = new ErrorRecord( + exception: e, + "InstallVersionGetContainerRegistryBlobAsyncError", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + + return null; + } + + return responseContent.ReadAsStreamAsync().Result; + } + + #endregion + + #region Authentication and Token Methods + + /// + /// Gets the access token for the container registry by following the below logic: + /// If a credential is provided when registering the repository, retrieve the token from SecretsManagement. + /// If no credential provided at registration then, check if the ACR endpoint can be accessed without a token. If not, try using Azure.Identity to get the az access token, then ACR refresh token and then ACR access token. + /// Note: Access token can be empty if the repository is unauthenticated + /// + internal string GetContainerRegistryAccessToken(out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetContainerRegistryAccessToken()"); + string accessToken = string.Empty; + string containerRegistryAccessToken = string.Empty; + string tenantID = string.Empty; + errRecord = null; + + var repositoryCredentialInfo = Repository.CredentialInfo; + if (repositoryCredentialInfo != null) + { + accessToken = Utils.GetContainerRegistryAccessTokenFromSecretManagement( + Repository.Name, + repositoryCredentialInfo, + _cmdletPassedIn); + + _cmdletPassedIn.WriteVerbose("Access token retrieved."); + + tenantID = repositoryCredentialInfo.SecretName; + } + else + { + bool isRepositoryUnauthenticated = IsContainerRegistryUnauthenticated(Repository.Uri.ToString(), out errRecord, out accessToken); + if (errRecord != null) + { + return null; + } + + if (!string.IsNullOrEmpty(accessToken)) + { + _cmdletPassedIn.WriteVerbose("Anonymous access token retrieved."); + return accessToken; + } + + if (!isRepositoryUnauthenticated) + { + accessToken = Utils.GetAzAccessToken(); + if (string.IsNullOrEmpty(accessToken)) + { + errRecord = new ErrorRecord( + new InvalidOperationException("Failed to get access token from Azure."), + "AzAccessTokenFailure", + ErrorCategory.AuthenticationError, + this); + + return null; + } + } + else + { + _cmdletPassedIn.WriteVerbose("Repository is unauthenticated"); + return null; + } + } + + var containerRegistryRefreshToken = GetContainerRegistryRefreshToken(tenantID, accessToken, out errRecord); + if (errRecord != null) + { + return null; + } + + containerRegistryAccessToken = GetContainerRegistryAccessTokenByRefreshToken(containerRegistryRefreshToken, out errRecord); + if (errRecord != null) + { + return null; + } + + return containerRegistryAccessToken; + } + + /// + /// Checks if container registry repository is unauthenticated. + /// + internal bool IsContainerRegistryUnauthenticated(string containerRegistyUrl, out ErrorRecord errRecord, out string anonymousAccessToken) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::IsContainerRegistryUnauthenticated()"); + errRecord = null; + anonymousAccessToken = string.Empty; + string endpoint = $"{containerRegistyUrl}/v2/"; + HttpResponseMessage response; + try + { + response = _sessionClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, endpoint)).Result; + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + // check if there is a auth challenge header + if (response.Headers.WwwAuthenticate.Count() > 0) + { + var authHeader = response.Headers.WwwAuthenticate.First(); + if (authHeader.Scheme == "Bearer") + { + // check if there is a realm + if (authHeader.Parameter.Contains("realm")) + { + // get the realm + var realm = authHeader.Parameter.Split(',')?.Where(x => x.Contains("realm"))?.FirstOrDefault()?.Split('=')[1]?.Trim('"'); + // get the service + var service = authHeader.Parameter.Split(',')?.Where(x => x.Contains("service"))?.FirstOrDefault()?.Split('=')[1]?.Trim('"'); + + if (string.IsNullOrEmpty(realm) || string.IsNullOrEmpty(service)) + { + errRecord = new ErrorRecord( + new InvalidOperationException("Failed to get realm or service from the auth challenge header."), + "RegistryUnauthenticationCheckError", + ErrorCategory.InvalidResult, + this); + + return false; + } + + string content = "grant_type=access_token&service=" + service + defaultScope; + var contentHeaders = new Collection> { new KeyValuePair("Content-Type", "application/x-www-form-urlencoded") }; + + // get the anonymous access token + var url = $"{realm}?service={service}{defaultScope}"; + + // we dont check the errorrecord here because we want to return false if we get a 401 and not throw an error + var results = GetHttpResponseJObjectUsingContentHeaders(url, HttpMethod.Get, content, contentHeaders, out _); + + if (results == null) + { + _cmdletPassedIn.WriteDebug("Failed to get access token from the realm. results is null."); + return false; + } + + if (results["access_token"] == null) + { + _cmdletPassedIn.WriteDebug($"Failed to get access token from the realm. access_token is null. results: {results}"); + return false; + } + + anonymousAccessToken = results["access_token"].ToString(); + _cmdletPassedIn.WriteDebug("Anonymous access token retrieved"); + return true; + } + } + } + } + } + catch (HttpRequestException hre) + { + errRecord = new ErrorRecord( + hre, + "RegistryAnonymousAcquireError", + ErrorCategory.ConnectionError, + this); + + return false; + } + catch (Exception e) + { + errRecord = new ErrorRecord( + e, + "RegistryUnauthenticationCheckError", + ErrorCategory.InvalidResult, + this); + + return false; + } + + return (response.StatusCode == HttpStatusCode.OK); + } + + /// + /// Given the access token retrieved from credentials, gets the refresh token. + /// + internal string GetContainerRegistryRefreshToken(string tenant, string accessToken, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetContainerRegistryRefreshToken()"); + string content = string.Format(containerRegistryRefreshTokenTemplate, Registry, tenant, accessToken); + var contentHeaders = new Collection> { new KeyValuePair("Content-Type", "application/x-www-form-urlencoded") }; + string exchangeUrl = string.Format(containerRegistryOAuthExchangeUrlTemplate, Registry); + var results = GetHttpResponseJObjectUsingContentHeaders(exchangeUrl, HttpMethod.Post, content, contentHeaders, out errRecord); + if (errRecord != null || results == null || results["refresh_token"] == null) + { + return string.Empty; + } + + return results["refresh_token"].ToString(); + } + + /// + /// Given the refresh token, gets the new access token with appropriate scope access permissions. + /// + internal string GetContainerRegistryAccessTokenByRefreshToken(string refreshToken, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetContainerRegistryAccessTokenByRefreshToken()"); + string content = string.Format(containerRegistryAccessTokenTemplate, Registry, refreshToken); + var contentHeaders = new Collection> { new KeyValuePair("Content-Type", "application/x-www-form-urlencoded") }; + string tokenUrl = string.Format(containerRegistryOAuthTokenUrlTemplate, Registry); + var results = GetHttpResponseJObjectUsingContentHeaders(tokenUrl, HttpMethod.Post, content, contentHeaders, out errRecord); + if (errRecord != null || results == null || results["access_token"] == null) + { + return string.Empty; + } + + return results["access_token"].ToString(); + } + + #endregion + + #region Private Methods + + /// + /// Parses package manifest JObject to find digest entry, which is the SHA needed to identify and get the package. + /// + private string GetDigestFromManifest(JObject manifest, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetDigestFromManifest()"); + errRecord = null; + string digest = String.Empty; + + if (manifest == null) + { + errRecord = new ErrorRecord( + exception: new ArgumentNullException("Manifest (passed in to determine digest) is null."), + "ManifestNullError", + ErrorCategory.InvalidArgument, + _cmdletPassedIn); + + return digest; + } + + JToken layers = manifest["layers"]; + if (layers == null || !layers.HasValues) + { + errRecord = new ErrorRecord( + exception: new ArgumentNullException("Manifest 'layers' property (passed in to determine digest) is null or does not have values."), + "ManifestLayersNullOrEmptyError", + ErrorCategory.InvalidArgument, + _cmdletPassedIn); + + return digest; + } + + foreach (JObject item in layers) + { + if (item.ContainsKey("digest")) + { + digest = item.GetValue("digest").ToString(); + break; + } + } + + return digest; + } + + /// + /// Gets the manifest for a package (ie repository in container registry terms) from the repository (ie registry in container registry terms) + /// + internal JObject GetContainerRegistryRepositoryManifest(string packageName, string version, string containerRegistryAccessToken, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetContainerRegistryRepositoryManifest()"); + // example of manifestUrl: https://psgetregistry.azurecr.io/hello-world:3.0.0 + string manifestUrl = string.Format(containerRegistryManifestUrlTemplate, Registry, packageName, version); + var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); + return GetHttpResponseJObjectUsingDefaultHeaders(manifestUrl, HttpMethod.Get, defaultHeaders, out errRecord); + } + + /// + /// Get the blob for the package (ie repository in container registry terms) from the repositroy (ie registry in container registry terms) + /// Used when installing the package + /// + internal async Task GetContainerRegistryBlobAsync(string packageName, string digest, string containerRegistryAccessToken) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetContainerRegistryBlobAsync()"); + string blobUrl = string.Format(containerRegistryBlobDownloadUrlTemplate, Registry, packageName, digest); + var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); + return await GetHttpContentResponseJObject(blobUrl, defaultHeaders); + } + + /// + /// Gets the image tags associated with the package (i.e repository in container registry terms), where the tag corresponds to the package's versions. + /// If the package version is specified search for that specific tag for the image, if the package version is "*" search for all tags for the image. + /// + internal JObject FindContainerRegistryImageTags(string packageName, string version, string containerRegistryAccessToken, out ErrorRecord errRecord) + { + /* + { + "name": "", + "tags": [ + "", + "", + "" + ] + } + */ + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindContainerRegistryImageTags()"); + string resolvedVersion = string.Equals(version, "*", StringComparison.OrdinalIgnoreCase) ? null : $"/{version}"; + string findImageUrl = string.Format(containerRegistryFindImageVersionUrlTemplate, Registry, packageName); + var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); + return GetHttpResponseJObjectUsingDefaultHeaders(findImageUrl, HttpMethod.Get, defaultHeaders, out errRecord); + } + + /// + /// Helper method to find all packages on container registry + /// + /// + /// + /// + internal JObject FindAllRepositories(string containerRegistryAccessToken, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindAllRepositories()"); + string repositoryListUrl = string.Format(containerRegistryRepositoryListTemplate, Registry); + var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); + return GetHttpResponseJObjectUsingDefaultHeaders(repositoryListUrl, HttpMethod.Get, defaultHeaders, out errRecord); + } + + /// + /// Get metadata for a package version. + /// + internal Hashtable GetContainerRegistryMetadata(string packageName, string exactTagVersion, string containerRegistryAccessToken, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetContainerRegistryMetadata()"); + Hashtable requiredVersionResponse = new Hashtable(); + + var foundTags = FindContainerRegistryManifest(packageName, exactTagVersion, containerRegistryAccessToken, out errRecord); + if (errRecord != null) + { + return requiredVersionResponse; + } + + /* Response returned looks something like: + * { + * "schemaVersion": 2, + * "config": { + * "mediaType": "application/vnd.unknown.config.v1+json", + * "digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + * "size": 0 + * }, + * "layers": [ + * { + * "mediaType": "application/vnd.oci.image.layer.nondistributable.v1.tar+gzip'", + * "digest": "sha256:7c55c7b66cb075628660d8249cc4866f16e34741c246a42ed97fb23ccd4ea956", + * "size": 3533, + * "annotations": { + * "org.opencontainers.image.title": "test_module.1.0.0.nupkg", + * "metadata": "{\"GUID\":\"45219bf4-10a4-4242-92d6-9bfcf79878fd\",\"FunctionsToExport\":[],\"CompanyName\":\"Anam\",\"CmdletsToExport\":[],\"VariablesToExport\":\"*\",\"Author\":\"Anam Navied\",\"ModuleVersion\":\"1.0.0\",\"Copyright\":\"(c) Anam Navied. All rights reserved.\",\"PrivateData\":{\"PSData\":{\"Tags\":[\"Test\",\"CommandsAndResource\",\"Tag2\"]}},\"RequiredModules\":[],\"Description\":\"This is a test module, for PSGallery team internal testing. Do not take a dependency on this package. This version contains tags for the package.\",\"AliasesToExport\":[]}" + * } + * } + * ] + * } + */ + + var serverPkgInfo = GetMetadataProperty(foundTags, packageName, out errRecord); + if (errRecord != null) + { + return requiredVersionResponse; + } + + try + { + using (JsonDocument metadataJSONDoc = JsonDocument.Parse(serverPkgInfo.Metadata)) + { + string pkgVersionString = String.Empty; + JsonElement rootDom = metadataJSONDoc.RootElement; + if (rootDom.TryGetProperty("ModuleVersion", out JsonElement pkgVersionElement)) + { + // module metadata will have "ModuleVersion" property + pkgVersionString = pkgVersionElement.ToString(); + if (rootDom.TryGetProperty("PrivateData", out JsonElement pkgPrivateDataElement) && pkgPrivateDataElement.TryGetProperty("PSData", out JsonElement pkgPSDataElement) + && pkgPSDataElement.TryGetProperty("Prerelease", out JsonElement pkgPrereleaseLabelElement) && !String.IsNullOrEmpty(pkgPrereleaseLabelElement.ToString().Trim())) + { + pkgVersionString += $"-{pkgPrereleaseLabelElement.ToString()}"; + } + } + else if (rootDom.TryGetProperty("Version", out pkgVersionElement) || rootDom.TryGetProperty("version", out pkgVersionElement)) + { + // script metadata will have "Version" property, but nupkg only based .nuspec will have lowercase "version" property and JsonElement.TryGetProperty() is case sensitive + pkgVersionString = pkgVersionElement.ToString(); + } + else + { + errRecord = new ErrorRecord( + new InvalidOrEmptyResponse($"Response does not contain 'ModuleVersion' or 'Version' property in metadata for package '{packageName}' in '{Repository.Name}'."), + "ParseMetadataFailure", + ErrorCategory.InvalidResult, + this); + + return requiredVersionResponse; + } + + if (!NuGetVersion.TryParse(pkgVersionString, out NuGetVersion pkgVersion)) + { + errRecord = new ErrorRecord( + new ArgumentException($"Version {pkgVersionString} to be parsed from metadata is not a valid NuGet version."), + "ParseMetadataFailure", + ErrorCategory.InvalidArgument, + this); + + return requiredVersionResponse; + } + + if (!NuGetVersion.TryParse(exactTagVersion, out NuGetVersion requiredVersion)) + { + errRecord = new ErrorRecord( + new ArgumentException($"Version {exactTagVersion} to be parsed from method input is not a valid NuGet version."), + "ParseMetadataFailure", + ErrorCategory.InvalidArgument, + this); + + return requiredVersionResponse; + } + + _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{pkgVersion}'"); + if (pkgVersion.ToNormalizedString() == requiredVersion.ToNormalizedString()) + { + requiredVersionResponse = serverPkgInfo.ToHashtable(); + } + } + } + catch (Exception e) + { + errRecord = new ErrorRecord( + new ArgumentException($"Error parsing server metadata: {e.Message}"), + "ParseMetadataFailure", + ErrorCategory.InvalidData, + this); + + return requiredVersionResponse; + } + + return requiredVersionResponse; + } + + /// + /// Get the manifest associated with the package version. + /// + internal JObject FindContainerRegistryManifest(string packageName, string version, string containerRegistryAccessToken, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindContainerRegistryManifest()"); + var createManifestUrl = string.Format(containerRegistryManifestUrlTemplate, Registry, packageName, version); + _cmdletPassedIn.WriteDebug($"GET manifest url: {createManifestUrl}"); + + var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); + return GetHttpResponseJObjectUsingDefaultHeaders(createManifestUrl, HttpMethod.Get, defaultHeaders, out errRecord); + } + + /// + /// Get metadata for the package by parsing its manifest. + /// + internal ContainerRegistryInfo GetMetadataProperty(JObject foundTags, string packageName, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetMetadataProperty()"); + errRecord = null; + ContainerRegistryInfo serverPkgInfo = null; + + var layers = foundTags["layers"]; + if (layers == null || layers[0] == null) + { + errRecord = new ErrorRecord( + new InvalidOrEmptyResponse($"Response does not contain 'layers' element in manifest for package '{packageName}' in '{Repository.Name}'."), + "GetMetadataPropertyLayersError", + ErrorCategory.InvalidData, + this); + + return serverPkgInfo; + } + + var annotations = layers[0]["annotations"]; + if (annotations == null) + { + errRecord = new ErrorRecord( + new InvalidOrEmptyResponse($"Response does not contain 'annotations' element in manifest for package '{packageName}' in '{Repository.Name}'."), + "GetMetadataPropertyAnnotationsError", + ErrorCategory.InvalidData, + this); + + return serverPkgInfo; + } + + // Check for package name + var pkgTitleJToken = annotations["org.opencontainers.image.title"]; + if (pkgTitleJToken == null) + { + errRecord = new ErrorRecord( + new InvalidOrEmptyResponse($"Response does not contain 'org.opencontainers.image.title' element for package '{packageName}' in '{Repository.Name}'."), + "GetMetadataPropertyOCITitleError", + ErrorCategory.InvalidData, + this); + + return serverPkgInfo; + } + + string metadataPkgName = pkgTitleJToken.ToString(); + if (string.IsNullOrWhiteSpace(metadataPkgName)) + { + errRecord = new ErrorRecord( + new InvalidOrEmptyResponse($"Response element 'org.opencontainers.image.title' is empty for package '{packageName}' in '{Repository.Name}'."), + "GetMetadataPropertyOCITitleEmptyError", + ErrorCategory.InvalidData, + this); + + return serverPkgInfo; + } + + // Check for package metadata + var pkgMetadataJToken = annotations["metadata"]; + if (pkgMetadataJToken == null) + { + errRecord = new ErrorRecord( + new InvalidOrEmptyResponse($"Response does not contain 'metadata' element in manifest for package '{packageName}' in '{Repository.Name}'."), + "GetMetadataPropertyMetadataError", + ErrorCategory.InvalidData, + this); + + return serverPkgInfo; + } + + var metadata = pkgMetadataJToken.ToString(); + + // Check for package artifact type + var resourceTypeJToken = annotations["resourceType"]; + var resourceType = resourceTypeJToken != null ? resourceTypeJToken.ToString() : "None"; + + return new ContainerRegistryInfo(metadataPkgName, metadata, resourceType); + } + + /// + /// Upload manifest for the package, used for publishing. + /// + internal async Task UploadManifest(string packageName, string packageVersion, string configPath, bool isManifest, string containerRegistryAccessToken) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::UploadManifest()"); + try + { + var createManifestUrl = string.Format(containerRegistryManifestUrlTemplate, Registry, packageName, packageVersion); + var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); + return await PutRequestAsync(createManifestUrl, configPath, isManifest, defaultHeaders); + } + catch (HttpRequestException e) + { + throw new HttpRequestException("Error occured while trying to create manifest: " + e.Message); + } + } + + internal async Task GetHttpContentResponseJObject(string url, Collection> defaultHeaders) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetHttpContentResponseJObject()"); + try + { + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, url); + SetDefaultHeaders(defaultHeaders); + return await SendContentRequestAsync(request); + } + catch (HttpRequestException e) + { + throw new HttpRequestException("Error occured while trying to retrieve response: " + e.Message); + } + } + + /// + /// Get response object when using default headers in the request. + /// + internal JObject GetHttpResponseJObjectUsingDefaultHeaders(string url, HttpMethod method, Collection> defaultHeaders, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetHttpResponseJObjectUsingDefaultHeaders()"); + try + { + errRecord = null; + HttpRequestMessage request = new HttpRequestMessage(method, url); + SetDefaultHeaders(defaultHeaders); + + return SendRequestAsync(request).GetAwaiter().GetResult(); + } + catch (ResourceNotFoundException e) + { + errRecord = new ErrorRecord( + exception: e, + "ResourceNotFound", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + } + catch (UnauthorizedException e) + { + errRecord = new ErrorRecord( + exception: e, + "UnauthorizedRequest", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + } + catch (HttpRequestException e) + { + errRecord = new ErrorRecord( + exception: e, + "HttpRequestCallFailure", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + } + catch (Exception e) + { + errRecord = new ErrorRecord( + exception: e, + "HttpRequestCallFailure", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + } + + return null; + } + + /// + /// Get response object when using content headers in the request. + /// + internal JObject GetHttpResponseJObjectUsingContentHeaders(string url, HttpMethod method, string content, Collection> contentHeaders, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetHttpResponseJObjectUsingContentHeaders()"); + errRecord = null; + try + { + HttpRequestMessage request = new HttpRequestMessage(method, url); + + if (string.IsNullOrEmpty(content)) + { + errRecord = new ErrorRecord( + exception: new ArgumentNullException($"Content is null or empty and cannot be used to make a request as its content headers."), + "RequestContentHeadersNullOrEmpty", + ErrorCategory.InvalidData, + _cmdletPassedIn); + + return null; + } + + request.Content = new StringContent(content); + request.Content.Headers.Clear(); + if (contentHeaders != null) + { + foreach (var header in contentHeaders) + { + request.Content.Headers.Add(header.Key, header.Value); + } + } + + return SendRequestAsync(request).GetAwaiter().GetResult(); + } + catch (ResourceNotFoundException e) + { + errRecord = new ErrorRecord( + exception: e, + "ResourceNotFound", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + } + catch (UnauthorizedException e) + { + errRecord = new ErrorRecord( + exception: e, + "UnauthorizedRequest", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + } + catch (HttpRequestException e) + { + errRecord = new ErrorRecord( + exception: e, + "HttpRequestCallFailure", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + } + catch (Exception e) + { + errRecord = new ErrorRecord( + exception: e, + "HttpRequestCallFailure", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + } + + return null; + } + + /// + /// Get response headers. + /// + internal async Task GetHttpResponseHeader(string url, HttpMethod method, Collection> defaultHeaders) + { + try + { + HttpRequestMessage request = new HttpRequestMessage(method, url); + SetDefaultHeaders(defaultHeaders); + return await SendRequestHeaderAsync(request); + } + catch (HttpRequestException e) + { + throw new HttpRequestException("Error occured while trying to retrieve response header: " + e.Message); + } + } + + /// + /// Set default headers for HttpClient. + /// + private void SetDefaultHeaders(Collection> defaultHeaders) + { + _sessionClient.DefaultRequestHeaders.Clear(); + if (defaultHeaders != null) + { + foreach (var header in defaultHeaders) + { + if (string.Equals(header.Key, "Authorization", StringComparison.OrdinalIgnoreCase)) + { + _sessionClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", header.Value); + } + else if (string.Equals(header.Key, "Accept", StringComparison.OrdinalIgnoreCase)) + { + _sessionClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(header.Value)); + } + else + { + _sessionClient.DefaultRequestHeaders.Add(header.Key, header.Value); + } + } + } + } + + /// + /// Sends request for content. + /// + private async Task SendContentRequestAsync(HttpRequestMessage message) + { + try + { + HttpResponseMessage response = await _sessionClient.SendAsync(message); + response.EnsureSuccessStatusCode(); + return response.Content; + } + catch (Exception e) + { + throw new SendRequestException($"Error occured while sending request to Container Registry server for content with: {e.GetType()} '{e.Message}'", e); + } + } + + /// + /// Sends HTTP request. + /// + private async Task SendRequestAsync(HttpRequestMessage message) + { + HttpResponseMessage response; + try + { + response = await _sessionClient.SendAsync(message); + } + catch (Exception e) + { + throw new SendRequestException($"Error occured while sending request to Container Registry server with: {e.GetType()} '{e.Message}'", e); + } + + switch (response.StatusCode) + { + case HttpStatusCode.OK: + break; + + case HttpStatusCode.Unauthorized: + throw new UnauthorizedException($"Response returned status code: {response.ReasonPhrase}."); + + case HttpStatusCode.NotFound: + throw new ResourceNotFoundException($"Response returned status code package: {response.ReasonPhrase}."); + + default: + throw new Exception($"Response returned error with status code {response.StatusCode}: {response.ReasonPhrase}."); + } + + return JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + } + + /// + /// Send request to get response headers. + /// + private async Task SendRequestHeaderAsync(HttpRequestMessage message) + { + try + { + HttpResponseMessage response = await _sessionClient.SendAsync(message); + response.EnsureSuccessStatusCode(); + return response.Headers; + } + catch (HttpRequestException e) + { + throw new HttpRequestException("Error occured while trying to retrieve response: " + e.Message); + } + } + + /// + /// Sends a PUT request, used for publishing to container registry. + /// + private async Task PutRequestAsync(string url, string filePath, bool isManifest, Collection> contentHeaders) + { + try + { + SetDefaultHeaders(contentHeaders); + + FileInfo fileInfo = new FileInfo(filePath); + using (FileStream fileStream = fileInfo.Open(FileMode.Open, FileAccess.Read)) + { + HttpContent httpContent = new StreamContent(fileStream); + if (isManifest) + { + httpContent.Headers.Add("Content-Type", "application/vnd.oci.image.manifest.v1+json"); + } + else + { + httpContent.Headers.Add("Content-Type", "application/octet-stream"); + } + + return await _sessionClient.PutAsync(url, httpContent); + } + } + catch (Exception e) + { + throw new SendRequestException($"Error occured while uploading module to ContainerRegistry: {e.GetType()} '{e.Message}'", e); + } + } + + /// + /// Get the default headers associated with the access token. + /// + private static Collection> GetDefaultHeaders(string containerRegistryAccessToken) + { + var defaultHeaders = new Collection>(); + + if (!string.IsNullOrEmpty(containerRegistryAccessToken)) + { + defaultHeaders.Add(new KeyValuePair("Authorization", containerRegistryAccessToken)); + } + + defaultHeaders.Add(new KeyValuePair("Accept", "application/vnd.oci.image.manifest.v1+json")); + + return defaultHeaders; + } + + #endregion + + #region Publish Methods + /// + /// Helper method that publishes a package to the container registry. + /// This gets called from Publish-PSResource. + /// + internal bool PushNupkgContainerRegistry( + string outputNupkgDir, + string packageName, + string modulePrefix, + NuGetVersion packageVersion, + ResourceType resourceType, + Hashtable parsedMetadataHash, + Hashtable dependencies, + bool isNupkgPathSpecified, + string originalNupkgPath, + out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::PushNupkgContainerRegistry()"); + + // if isNupkgPathSpecified, then we need to publish the original .nupkg file, as it may be signed + string fullNupkgFile = isNupkgPathSpecified ? originalNupkgPath : System.IO.Path.Combine(outputNupkgDir, packageName + "." + packageVersion.ToNormalizedString() + ".nupkg"); + + string pkgNameForUpload = string.IsNullOrEmpty(modulePrefix) ? packageName : modulePrefix + "/" + packageName; + string packageNameLowercase = pkgNameForUpload.ToLower(); + + // Get access token (includes refresh tokens) + _cmdletPassedIn.WriteVerbose($"Get access token for container registry server."); + var containerRegistryAccessToken = GetContainerRegistryAccessToken(out errRecord); + if (errRecord != null) + { + return false; + } + + // Upload .nupkg + _cmdletPassedIn.WriteVerbose($"Upload .nupkg file: {fullNupkgFile}"); + string nupkgDigest = UploadNupkgFile(packageNameLowercase, containerRegistryAccessToken, fullNupkgFile, out errRecord); + if (errRecord != null) + { + return false; + } + + // Create and upload an empty file-- needed by ContainerRegistry server + CreateAndUploadEmptyFile(outputNupkgDir, packageNameLowercase, containerRegistryAccessToken, out errRecord); + if (errRecord != null) + { + return false; + } + + // Create config.json file + var configFilePath = System.IO.Path.Combine(outputNupkgDir, "config.json"); + _cmdletPassedIn.WriteVerbose($"Create config.json file at path: {configFilePath}"); + string configDigest = CreateConfigFile(configFilePath, out errRecord); + if (errRecord != null) + { + return false; + } + + _cmdletPassedIn.WriteVerbose("Create package version metadata as JSON string"); + // Create module metadata string + string metadataJson = CreateMetadataContent(resourceType, parsedMetadataHash, out errRecord); + if (errRecord != null) + { + return false; + } + + // Create and upload manifest + TryCreateAndUploadManifest(fullNupkgFile, nupkgDigest, configDigest, packageName, modulePrefix, resourceType, metadataJson, configFilePath, packageVersion, containerRegistryAccessToken, out errRecord); + if (errRecord != null) + { + return false; + } + + return true; + } + + /// + /// Upload the nupkg file, by creating a digest for it and uploading as blob. + /// Note: ContainerRegistry registries will only accept a name that is all lowercase. + /// + private string UploadNupkgFile(string packageNameLowercase, string containerRegistryAccessToken, string fullNupkgFile, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::UploadNupkgFile()"); + _cmdletPassedIn.WriteVerbose("Start uploading blob"); + string nupkgDigest = string.Empty; + errRecord = null; + string moduleLocation; + try + { + moduleLocation = GetStartUploadBlobLocation(packageNameLowercase, containerRegistryAccessToken).Result; + } + catch (Exception startUploadException) + { + errRecord = new ErrorRecord( + startUploadException, + "StartUploadBlobLocationError", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + + return nupkgDigest; + } + + _cmdletPassedIn.WriteVerbose("Computing digest for .nupkg file"); + nupkgDigest = CreateDigest(fullNupkgFile, out errRecord); + if (errRecord != null) + { + return nupkgDigest; + } + + _cmdletPassedIn.WriteVerbose("Finish uploading blob"); + try + { + var responseNupkg = EndUploadBlob(moduleLocation, fullNupkgFile, nupkgDigest, isManifest: false, containerRegistryAccessToken).Result; + bool uploadSuccessful = responseNupkg.IsSuccessStatusCode; + + if (!uploadSuccessful) + { + errRecord = new ErrorRecord( + new UploadBlobException("Uploading of blob for publish failed."), + "EndUploadBlobError", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + + return nupkgDigest; + } + } + catch (Exception endUploadException) + { + errRecord = new ErrorRecord( + endUploadException, + "EndUploadBlobError", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + + return nupkgDigest; + } + + return nupkgDigest; + } + + /// + /// Uploads an empty file at the start of publish as is needed. + /// + private void CreateAndUploadEmptyFile(string outputNupkgDir, string pkgNameLower, string containerRegistryAccessToken, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::CreateAndUploadEmptyFile()"); + _cmdletPassedIn.WriteVerbose("Create an empty file"); + string emptyFileName = "empty" + Guid.NewGuid().ToString() + ".txt"; + var emptyFilePath = System.IO.Path.Combine(outputNupkgDir, emptyFileName); + + try + { + Utils.CreateFile(emptyFilePath); + + _cmdletPassedIn.WriteVerbose("Start uploading an empty file"); + string emptyLocation = GetStartUploadBlobLocation(pkgNameLower, containerRegistryAccessToken).Result; + + _cmdletPassedIn.WriteVerbose("Computing digest for empty file"); + string emptyFileDigest = CreateDigest(emptyFilePath, out errRecord); + if (errRecord != null) + { + return; + } + + _cmdletPassedIn.WriteVerbose("Finish uploading empty file"); + var emptyResponse = EndUploadBlob(emptyLocation, emptyFilePath, emptyFileDigest, false, containerRegistryAccessToken).Result; + bool uploadSuccessful = emptyResponse.IsSuccessStatusCode; + + if (!uploadSuccessful) + { + errRecord = new ErrorRecord( + new UploadBlobException($"Error occurred while uploading blob, response code was: {emptyResponse.StatusCode} with reason {emptyResponse.ReasonPhrase}"), + "UploadEmptyFileError", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + + return; + } + } + catch (Exception e) + { + errRecord = new ErrorRecord( + e, + "UploadEmptyFileError", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + + return; + } + } + + /// + /// Create config file associated with the package (i.e repository in container registry terms) as is needed for the package's manifest config layer + /// + private string CreateConfigFile(string configFilePath, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::CreateConfigFile()"); + string configFileDigest = string.Empty; + _cmdletPassedIn.WriteVerbose("Create the config file"); + while (File.Exists(configFilePath)) + { + configFilePath = Guid.NewGuid().ToString() + ".json"; + } + + try + { + Utils.CreateFile(configFilePath); + + _cmdletPassedIn.WriteVerbose("Computing digest for config"); + configFileDigest = CreateDigest(configFilePath, out errRecord); + if (errRecord != null) + { + return configFileDigest; + } + } + catch (Exception e) + { + errRecord = new ErrorRecord( + e, + "CreateConfigFileError", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + + return configFileDigest; + } + + return configFileDigest; + } + + /// + /// Create the manifest for the package and upload it + /// + private bool TryCreateAndUploadManifest(string fullNupkgFile, + string nupkgDigest, + string configDigest, + string packageName, + string modulePrefix, + ResourceType resourceType, + string metadataJson, + string configFilePath, + NuGetVersion pkgVersion, + string containerRegistryAccessToken, + out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::TryCreateAndUploadManifest()"); + errRecord = null; + + string pkgNameForUpload = string.IsNullOrEmpty(modulePrefix) ? packageName : modulePrefix + "/" + packageName; + string packageNameLowercase = pkgNameForUpload.ToLower(); + + FileInfo nupkgFile = new FileInfo(fullNupkgFile); + var fileSize = nupkgFile.Length; + var fileName = System.IO.Path.GetFileName(fullNupkgFile); + string fileContent = CreateManifestContent(nupkgDigest, configDigest, fileSize, fileName, packageName, resourceType, metadataJson); + File.WriteAllText(configFilePath, fileContent); + + _cmdletPassedIn.WriteVerbose("Create the manifest layer"); + bool manifestCreated = false; + try + { + HttpResponseMessage manifestResponse = UploadManifest(packageNameLowercase, pkgVersion.OriginalVersion, configFilePath, true, containerRegistryAccessToken).Result; + manifestCreated = manifestResponse.IsSuccessStatusCode; + } + catch (Exception e) + { + errRecord = new ErrorRecord( + new UploadBlobException($"Error occured while uploading package manifest to ContainerRegistry: {e.GetType()} '{e.Message}'", e), + "PackageManifestUploadError", + ErrorCategory.InvalidResult, + _cmdletPassedIn); + + return manifestCreated; + } + + return manifestCreated; + } + + /// + /// Create the content for the manifest for the packge. + /// + private string CreateManifestContent( + string nupkgDigest, + string configDigest, + long nupkgFileSize, + string fileName, + string packageName, + ResourceType resourceType, + string metadata) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::CreateManifestContent()"); + StringBuilder stringBuilder = new StringBuilder(); + StringWriter stringWriter = new StringWriter(stringBuilder); + JsonTextWriter jsonWriter = new JsonTextWriter(stringWriter); + + jsonWriter.Formatting = Newtonsoft.Json.Formatting.Indented; + + // start of manifest JSON object + jsonWriter.WriteStartObject(); + + jsonWriter.WritePropertyName("schemaVersion"); + jsonWriter.WriteValue(2); + jsonWriter.WritePropertyName("mediaType"); + jsonWriter.WriteValue("application/vnd.oci.image.manifest.v1+json"); + + jsonWriter.WritePropertyName("config"); + jsonWriter.WriteStartObject(); + jsonWriter.WritePropertyName("mediaType"); + jsonWriter.WriteValue("application/vnd.oci.image.config.v1+json"); + jsonWriter.WritePropertyName("digest"); + jsonWriter.WriteValue($"sha256:{configDigest}"); + jsonWriter.WritePropertyName("size"); + jsonWriter.WriteValue(0); + jsonWriter.WriteEndObject(); + + jsonWriter.WritePropertyName("layers"); + jsonWriter.WriteStartArray(); + + jsonWriter.WriteStartObject(); + jsonWriter.WritePropertyName("mediaType"); + jsonWriter.WriteValue("application/vnd.oci.image.layer.v1.tar+gzip"); + jsonWriter.WritePropertyName("digest"); + jsonWriter.WriteValue($"sha256:{nupkgDigest}"); + jsonWriter.WritePropertyName("size"); + jsonWriter.WriteValue(nupkgFileSize); + jsonWriter.WritePropertyName("annotations"); + jsonWriter.WriteStartObject(); + jsonWriter.WritePropertyName("org.opencontainers.image.title"); + jsonWriter.WriteValue(packageName); + jsonWriter.WritePropertyName("org.opencontainers.image.description"); + jsonWriter.WriteValue(fileName); + jsonWriter.WritePropertyName("metadata"); + jsonWriter.WriteValue(metadata); + jsonWriter.WritePropertyName("resourceType"); + jsonWriter.WriteValue(resourceType.ToString()); + jsonWriter.WriteEndObject(); // end of annotations object + + jsonWriter.WriteEndObject(); // end of 'layers' entry object + + jsonWriter.WriteEndArray(); // end of 'layers' array + jsonWriter.WriteEndObject(); // end of manifest JSON object + + return stringWriter.ToString(); + } + + /// + /// Create SHA256 digest that will be associated with .nupkg, config file or empty file. + /// + private string CreateDigest(string fileName, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::CreateDigest()"); + errRecord = null; + string digest = string.Empty; + FileInfo fileInfo = new FileInfo(fileName); + SHA256 mySHA256 = SHA256.Create(); + + using (FileStream fileStream = fileInfo.Open(FileMode.Open, FileAccess.Read)) + { + try + { + // Create a fileStream for the file. + // Be sure it's positioned to the beginning of the stream. + fileStream.Position = 0; + // Compute the hash of the fileStream. + byte[] hashValue = mySHA256.ComputeHash(fileStream); + StringBuilder stringBuilder = new StringBuilder(); + foreach (byte b in hashValue) + { + stringBuilder.AppendFormat("{0:x2}", b); + } + + digest = stringBuilder.ToString(); + } + catch (IOException ex) + { + errRecord = new ErrorRecord(ex, $"IOException for .nupkg file: {ex.Message}", ErrorCategory.InvalidOperation, null); + return digest; + } + catch (UnauthorizedAccessException ex) + { + errRecord = new ErrorRecord(ex, $"UnauthorizedAccessException for .nupkg file: {ex.Message}", ErrorCategory.PermissionDenied, null); + return digest; + } + catch (Exception ex) + { + errRecord = new ErrorRecord(ex, $"Exception when creating digest: {ex.Message}", ErrorCategory.PermissionDenied, null); + return digest; + } + } + + if (String.IsNullOrEmpty(digest)) + { + errRecord = new ErrorRecord(new ArgumentNullException("Digest created was null or empty."), "DigestNullOrEmptyError.", ErrorCategory.InvalidResult, null); + } + + return digest; + } + + /// + /// Create metadata for the package that will be populated in the manifest. + /// + private string CreateMetadataContent(ResourceType resourceType, Hashtable parsedMetadata, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::CreateMetadataContent()"); + errRecord = null; + string jsonString = string.Empty; + + if (parsedMetadata == null || parsedMetadata.Count == 0) + { + errRecord = new ErrorRecord( + new ArgumentException("Hashtable created from .ps1 or .psd1 containing package metadata was null or empty"), + "MetadataHashtableEmptyError", + ErrorCategory.InvalidArgument, + _cmdletPassedIn); + + return jsonString; + } + + _cmdletPassedIn.WriteVerbose("Serialize JSON into string."); + + if (parsedMetadata.ContainsKey("Version") && parsedMetadata["Version"] is NuGetVersion pkgNuGetVersion) + { + // For scripts, 'Version' entry will be present in hashtable and if it is of type NuGetVersion do not serialize NuGetVersion + // as this will populate more metadata than is needed and makes it harder to deserialize later. + // For modules, 'ModuleVersion' entry will already be present as type string which is correct. + parsedMetadata.Remove("Version"); + parsedMetadata["Version"] = pkgNuGetVersion.ToString(); + } + + try + { + jsonString = System.Text.Json.JsonSerializer.Serialize(parsedMetadata); + } + catch (Exception ex) + { + errRecord = new ErrorRecord(ex, "JsonSerializationError", ErrorCategory.InvalidResult, _cmdletPassedIn); + return jsonString; + } + + return jsonString; + } + + /// + /// Get start location when uploading blob, used during publish. + /// + internal async Task GetStartUploadBlobLocation(string packageName, string containerRegistryAccessToken) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetStartUploadBlobLocation()"); + try + { + var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); + var startUploadUrl = string.Format(containerRegistryStartUploadTemplate, Registry, packageName); + return (await GetHttpResponseHeader(startUploadUrl, HttpMethod.Post, defaultHeaders)).Location.ToString(); + } + catch (Exception e) + { + throw new UploadBlobException($"Error occured while starting to upload the blob location used for publishing to ContainerRegistry: {e.GetType()} '{e.Message}'", e); + } + } + + /// + /// Upload blob, used for publishing + /// + internal async Task EndUploadBlob(string location, string filePath, string digest, bool isManifest, string containerRegistryAccessToken) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::EndUploadBlob()"); + try + { + var endUploadUrl = string.Format(containerRegistryEndUploadTemplate, Registry, location, digest); + var defaultHeaders = GetDefaultHeaders(containerRegistryAccessToken); + return await PutRequestAsync(endUploadUrl, filePath, isManifest, defaultHeaders); + } + catch (Exception e) + { + throw new UploadBlobException($"Error occured while uploading module to ContainerRegistry: {e.GetType()} '{e.Message}'", e); + } + } + + #endregion + + #region Find Helper Methods + + /// + /// Helper method for find scenarios. + /// + private Hashtable[] FindPackagesWithVersionHelper(string packageName, VersionType versionType, VersionRange versionRange, NuGetVersion requiredVersion, bool includePrerelease, bool getOnlyLatest, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindPackagesWithVersionHelper()"); + string accessToken = string.Empty; + string tenantID = string.Empty; + string registryUrl = Repository.Uri.ToString(); + string packageNameLowercase = packageName.ToLower(); + + string packageNameForFind = PrependMARPrefix(packageNameLowercase); + string containerRegistryAccessToken = GetContainerRegistryAccessToken(out errRecord); + if (errRecord != null) + { + return emptyHashResponses; + } + + var foundTags = FindContainerRegistryImageTags(packageNameForFind, "*", containerRegistryAccessToken, out errRecord); + if (errRecord != null || foundTags == null) + { + return emptyHashResponses; + } + + List latestVersionResponse = new List(); + List allVersionsList = foundTags["tags"].ToList(); + + SortedDictionary sortedQualifyingPkgs = GetPackagesWithRequiredVersion(allVersionsList, versionType, versionRange, requiredVersion, packageNameForFind, includePrerelease, out errRecord); + if (errRecord != null) + { + return emptyHashResponses; + } + + var pkgsInDescendingOrder = sortedQualifyingPkgs.Reverse(); + + foreach (var pkgVersionTag in pkgsInDescendingOrder) + { + string exactTagVersion = pkgVersionTag.Value.ToString(); + Hashtable metadata = GetContainerRegistryMetadata(packageNameForFind, exactTagVersion, containerRegistryAccessToken, out errRecord); + if (errRecord != null || metadata.Count == 0) + { + return emptyHashResponses; + } + + latestVersionResponse.Add(metadata); + if (getOnlyLatest) + { + // getOnlyLatest will be true for FindName(), as only the latest criteria satisfying version should be returned + break; + } + } + + return latestVersionResponse.ToArray(); + } + + /// + /// Helper method used for find scenarios that resolves versions required from all versions found. + /// + private SortedDictionary GetPackagesWithRequiredVersion(List allPkgVersions, VersionType versionType, VersionRange versionRange, NuGetVersion specificVersion, string packageName, bool includePrerelease, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::GetPackagesWithRequiredVersion()"); + errRecord = null; + // we need NuGetVersion to sort versions by order, and string pkgVersionString (which is the exact tag from the server) to call GetContainerRegistryMetadata() later with exact version tag. + SortedDictionary sortedPkgs = new SortedDictionary(VersionComparer.Default); + bool isSpecificVersionSearch = versionType == VersionType.SpecificVersion; + + foreach (var pkgVersionTagInfo in allPkgVersions) + { + string pkgVersionString = pkgVersionTagInfo.ToString(); + // determine if the package version that is a repository tag is a valid NuGetVersion + if (!NuGetVersion.TryParse(pkgVersionString, out NuGetVersion pkgVersion)) + { + errRecord = new ErrorRecord( + new ArgumentException($"Version {pkgVersionString} to be parsed from metadata is not a valid NuGet version."), + "FindNameFailure", + ErrorCategory.InvalidArgument, + this); + + return null; + } + + _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{pkgVersion}'"); + + if (isSpecificVersionSearch) + { + if (pkgVersion.ToNormalizedString() == specificVersion.ToNormalizedString()) + { + // accounts for FindVersion() scenario + sortedPkgs.Add(pkgVersion, pkgVersionString); + break; + } + } + else + { + if (versionRange.Satisfies(pkgVersion) && (!pkgVersion.IsPrerelease || includePrerelease)) + { + // accounts for FindVersionGlobbing() and FindName() scenario + sortedPkgs.Add(pkgVersion, pkgVersionString); + } + } + } + + return sortedPkgs; + } + + private string PrependMARPrefix(string packageName) + { + string prefix = string.IsNullOrEmpty(InternalHooks.MARPrefix) ? PSRepositoryInfo.MARPrefix : InternalHooks.MARPrefix; + + // If the repostitory is MAR and its not a wildcard search, we need to prefix the package name with MAR prefix. + string updatedPackageName = Repository.IsMARRepository() && packageName.Trim() != "*" + ? packageName.StartsWith(prefix) ? packageName : string.Concat(prefix, packageName) + : packageName; + + return updatedPackageName; + } + + private FindResults FindPackages(string packageName, bool includePrerelease, out ErrorRecord errRecord) + { + _cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::FindPackages()"); + errRecord = null; + string containerRegistryAccessToken = GetContainerRegistryAccessToken(out errRecord); + if (errRecord != null) + { + return emptyResponseResults; + } + + var pkgResult = FindAllRepositories(containerRegistryAccessToken, out errRecord); + if (errRecord != null) + { + return emptyResponseResults; + } + + List repositoriesList = new List(); + var isMAR = Repository.IsMARRepository(); + + // Convert the list of repositories to a list of hashtables + foreach (var repository in pkgResult["repositories"].ToList()) + { + string repositoryName = repository.ToString(); + + if (isMAR && !repositoryName.StartsWith(PSRepositoryInfo.MARPrefix)) + { + continue; + } + + // This remove the 'psresource/' prefix from the repository name for comparison with wildcard. + string moduleName = repositoryName.StartsWith("psresource/") ? repositoryName.Substring(11) : repositoryName; + + WildcardPattern wildcardPattern = new WildcardPattern(packageName, WildcardOptions.IgnoreCase); + + if (!wildcardPattern.IsMatch(moduleName)) + { + continue; + } + + _cmdletPassedIn.WriteDebug($"Found repository: {repositoryName}"); + + repositoriesList.AddRange(FindPackagesWithVersionHelper(repositoryName, VersionType.VersionRange, versionRange: VersionRange.All, requiredVersion: null, includePrerelease, getOnlyLatest: true, out errRecord)); + if (errRecord != null) + { + return emptyResponseResults; + } + } + + return new FindResults(stringResponse: new string[] { }, hashtableResponse: repositoriesList.ToArray(), responseType: containerRegistryFindResponseType); + } + + #endregion + } +} diff --git a/src/code/FindHelper.cs b/src/code/FindHelper.cs index 1d3fce098..327d0e024 100644 --- a/src/code/FindHelper.cs +++ b/src/code/FindHelper.cs @@ -9,6 +9,7 @@ using System.Management.Automation; using System.Net; using System.Runtime.ExceptionServices; +using System.Text.RegularExpressions; using System.Threading; namespace Microsoft.PowerShell.PSResourceGet.Cmdlets @@ -182,9 +183,24 @@ public IEnumerable FindByResourceName( } List repositoryNamesToSearch = new List(); + for (int i = 0; i < repositoriesToSearch.Count; i++) { PSRepositoryInfo currentRepository = repositoriesToSearch[i]; + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(currentRepository.Uri); + + if (!isAllowed) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new PSInvalidOperationException($"Repository '{currentRepository.Name}' is not allowed by Group Policy."), + "RepositoryNotAllowedByGroupPolicy", + ErrorCategory.PermissionDenied, + this)); + + continue; + } + repositoryNamesToSearch.Add(currentRepository.Name); _networkCredential = Utils.SetNetworkCredential(currentRepository, _networkCredential, _cmdletPassedIn); ServerApiCall currentServer = ServerFactory.GetServer(currentRepository, _cmdletPassedIn, _networkCredential); @@ -230,7 +246,7 @@ public IEnumerable FindByResourceName( // Scenarios: Find-PSResource -Name "pkg" -Repository *Gallery -> write error if only if pkg wasn't found in any matching repositories. foreach(string pkgName in pkgsDiscovered) { - var msg = repository == null ? $"Package '{pkgName}' could not be found in any registered repositories." : + var msg = repository == null ? $"Package '{pkgName}' could not be found in any registered repositories." : $"Package '{pkgName}' could not be found in registered repositories: '{string.Join(", ", repositoryNamesToSearch)}'."; _cmdletPassedIn.WriteError(new ErrorRecord( @@ -355,6 +371,20 @@ public IEnumerable FindByCommandOrDscResource( for (int i = 0; i < repositoriesToSearch.Count; i++) { PSRepositoryInfo currentRepository = repositoriesToSearch[i]; + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(currentRepository.Uri); + + if (!isAllowed) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new PSInvalidOperationException($"Repository '{currentRepository.Name}' is not allowed by Group Policy."), + "RepositoryNotAllowedByGroupPolicy", + ErrorCategory.PermissionDenied, + this)); + + continue; + } + repositoryNamesToSearch.Add(currentRepository.Name); _networkCredential = Utils.SetNetworkCredential(currentRepository, _networkCredential, _cmdletPassedIn); ServerApiCall currentServer = ServerFactory.GetServer(currentRepository, _cmdletPassedIn, _networkCredential); @@ -393,9 +423,9 @@ public IEnumerable FindByCommandOrDscResource( if (currentResult.exception != null && !currentResult.exception.Message.Equals(string.Empty)) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"'{String.Join(", ", _tag)}' could not be found", currentResult.exception), - "FindCmdOrDscToPSResourceObjFailure", - ErrorCategory.NotSpecified, + new ResourceNotFoundException($"'{String.Join(", ", _tag)}' could not be found", currentResult.exception), + "FindCmdOrDscToPSResourceObjFailure", + ErrorCategory.NotSpecified, this); if (shouldReportErrorForEachRepo) @@ -421,7 +451,7 @@ public IEnumerable FindByCommandOrDscResource( if (!isCmdOrDSCTagFound && !shouldReportErrorForEachRepo) { string parameterName = isSearchingForCommands ? "CommandName" : "DSCResourceName"; - var msg = repository == null ? $"Package with {parameterName} '{String.Join(", ", _tag)}' could not be found in any registered repositories." : + var msg = repository == null ? $"Package with {parameterName} '{String.Join(", ", _tag)}' could not be found in any registered repositories." : $"Package with {parameterName} '{String.Join(", ", _tag)}' could not be found in registered repositories: '{string.Join(", ", repositoryNamesToSearch)}'."; _cmdletPassedIn.WriteError(new ErrorRecord( @@ -545,6 +575,20 @@ public IEnumerable FindByTag( for (int i = 0; i < repositoriesToSearch.Count; i++) { PSRepositoryInfo currentRepository = repositoriesToSearch[i]; + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(currentRepository.Uri); + + if (!isAllowed) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new PSInvalidOperationException($"Repository '{currentRepository.Name}' is not allowed by Group Policy."), + "RepositoryNotAllowedByGroupPolicy", + ErrorCategory.PermissionDenied, + this)); + + continue; + } + repositoryNamesToSearch.Add(currentRepository.Name); _networkCredential = Utils.SetNetworkCredential(currentRepository, _networkCredential, _cmdletPassedIn); ServerApiCall currentServer = ServerFactory.GetServer(currentRepository, _cmdletPassedIn, _networkCredential); @@ -591,9 +635,9 @@ public IEnumerable FindByTag( if (currentResult.exception != null && !currentResult.exception.Message.Equals(string.Empty)) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"Tags '{String.Join(", ", _tag)}' could not be found" , currentResult.exception), - "FindTagConvertToPSResourceFailure", - ErrorCategory.InvalidResult, + new ResourceNotFoundException($"Tags '{String.Join(", ", _tag)}' could not be found" , currentResult.exception), + "FindTagConvertToPSResourceFailure", + ErrorCategory.InvalidResult, this); if (shouldReportErrorForEachRepo) @@ -615,7 +659,7 @@ public IEnumerable FindByTag( if (!isTagFound && !shouldReportErrorForEachRepo) { - var msg = repository == null ? $"Package with Tags '{String.Join(", ", _tag)}' could not be found in any registered repositories." : + var msg = repository == null ? $"Package with Tags '{String.Join(", ", _tag)}' could not be found in any registered repositories." : $"Package with Tags '{String.Join(", ", _tag)}' could not be found in registered repositories: '{string.Join(", ", repositoryNamesToSearch)}'."; _cmdletPassedIn.WriteError(new ErrorRecord( @@ -665,9 +709,9 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R if (currentResult.exception != null && !currentResult.exception.Message.Equals(string.Empty)) { _cmdletPassedIn.WriteError(new ErrorRecord( - currentResult.exception, - "FindAllConvertToPSResourceFailure", - ErrorCategory.InvalidResult, + currentResult.exception, + "FindAllConvertToPSResourceFailure", + ErrorCategory.InvalidResult, this)); continue; @@ -721,9 +765,9 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R if (currentResult.exception != null && !currentResult.exception.Message.Equals(string.Empty)) { _cmdletPassedIn.WriteError(new ErrorRecord( - currentResult.exception, - "FindNameGlobbingConvertToPSResourceFailure", - ErrorCategory.InvalidResult, + currentResult.exception, + "FindNameGlobbingConvertToPSResourceFailure", + ErrorCategory.InvalidResult, this)); continue; @@ -772,9 +816,9 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R { // This scenario may occur when the package version requested is unlisted. _cmdletPassedIn.WriteError(new ErrorRecord( - new ResourceNotFoundException($"Package with name '{pkgName}'{tagsAsString} could not be found in repository '{repository.Name}'"), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with name '{pkgName}'{tagsAsString} could not be found in repository '{repository.Name}'"), + "PackageNotFound", + ErrorCategory.ObjectNotFound, this)); continue; @@ -783,9 +827,9 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R if (currentResult.exception != null && !currentResult.exception.Message.Equals(string.Empty)) { _cmdletPassedIn.WriteError(new ErrorRecord( - currentResult.exception, - "FindNameConvertToPSResourceFailure", - ErrorCategory.ObjectNotFound, + currentResult.exception, + "FindNameConvertToPSResourceFailure", + ErrorCategory.ObjectNotFound, this)); continue; @@ -804,8 +848,8 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R { _cmdletPassedIn.WriteError(new ErrorRecord( new ArgumentException("Name cannot contain or equal wildcard when using specific version."), - "InvalidWildCardUsage", - ErrorCategory.InvalidOperation, + "InvalidWildCardUsage", + ErrorCategory.InvalidOperation, this)); continue; @@ -846,9 +890,9 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R { // This scenario may occur when the package version requested is unlisted. _cmdletPassedIn.WriteError(new ErrorRecord( - new ResourceNotFoundException($"Package with name '{pkgName}', version '{_nugetVersion.ToNormalizedString()}'{tagsAsString} could not be found in repository '{repository.Name}'"), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with name '{pkgName}', version '{_nugetVersion.ToNormalizedString()}'{tagsAsString} could not be found in repository '{repository.Name}'"), + "PackageNotFound", + ErrorCategory.ObjectNotFound, this)); continue; @@ -858,8 +902,8 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R { _cmdletPassedIn.WriteError(new ErrorRecord( currentResult.exception, - "FindVersionConvertToPSResourceFailure", - ErrorCategory.ObjectNotFound, + "FindVersionConvertToPSResourceFailure", + ErrorCategory.ObjectNotFound, this)); continue; @@ -879,8 +923,8 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R { _cmdletPassedIn.WriteError(new ErrorRecord( new ArgumentException("Name cannot contain or equal wildcard when using version range"), - "InvalidWildCardUsage", - ErrorCategory.InvalidOperation, + "InvalidWildCardUsage", + ErrorCategory.InvalidOperation, this)); } else @@ -897,7 +941,7 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R { _cmdletPassedIn.WriteError(new ErrorRecord( new ArgumentException("-Tag parameter cannot be specified when using version range."), - "InvalidWildCardUsage", + "InvalidWildCardUsage", ErrorCategory.InvalidOperation, this)); @@ -925,14 +969,14 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R { _cmdletPassedIn.WriteError(new ErrorRecord( currentResult.exception, - "FindVersionGlobbingConvertToPSResourceFailure", - ErrorCategory.ObjectNotFound, + "FindVersionGlobbingConvertToPSResourceFailure", + ErrorCategory.ObjectNotFound, this)); continue; } - // Check to see if version falls within version range + // Check to see if version falls within version range PSResourceInfo foundPkg = currentResult.returnedObject; string versionStr = $"{foundPkg.Version}"; if (foundPkg.IsPrerelease) @@ -956,7 +1000,7 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R // After retrieving all packages find their dependencies if (_includeDependencies) { - if (currentServer.Repository.ApiVersion == PSRepositoryInfo.APIVersion.v3) + if (currentServer.Repository.ApiVersion == PSRepositoryInfo.APIVersion.V3) { _cmdletPassedIn.WriteWarning("Installing dependencies is not currently supported for V3 server protocol repositories. The package will be installed without installing dependencies."); yield break; @@ -1014,7 +1058,7 @@ private bool TryAddToPackagesFound(PSResourceInfo foundPkg) _packagesFound.Add(foundPkg.Name, new List { foundPkgVersion }); addedToHash = true; } - + _cmdletPassedIn.WriteDebug($"Found package '{foundPkg.Name}' version '{foundPkg.Version}'"); return addedToHash; @@ -1070,9 +1114,9 @@ internal IEnumerable FindDependencyPackages( { // This scenario may occur when the package version requested is unlisted. _cmdletPassedIn.WriteError(new ErrorRecord( - new ResourceNotFoundException($"Dependency package with name '{dep.Name}' could not be found in repository '{repository.Name}'"), - "DependencyPackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Dependency package with name '{dep.Name}' could not be found in repository '{repository.Name}'"), + "DependencyPackageNotFound", + ErrorCategory.ObjectNotFound, this)); yield return null; continue; @@ -1081,9 +1125,9 @@ internal IEnumerable FindDependencyPackages( if (currentResult.exception != null && !currentResult.exception.Message.Equals(string.Empty)) { _cmdletPassedIn.WriteError(new ErrorRecord( - new ResourceNotFoundException($"Dependency package with name '{dep.Name}' could not be found in repository '{repository.Name}'", currentResult.exception), - "DependencyPackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Dependency package with name '{dep.Name}' could not be found in repository '{repository.Name}'", currentResult.exception), + "DependencyPackageNotFound", + ErrorCategory.ObjectNotFound, this)); yield return null; continue; @@ -1130,9 +1174,9 @@ internal IEnumerable FindDependencyPackages( if (responses.IsFindResultsEmpty()) { _cmdletPassedIn.WriteError(new ErrorRecord( - new InvalidOrEmptyResponse($"Dependency package with name {dep.Name} and version range {dep.VersionRange} could not be found in repository '{repository.Name}"), - "FindDepPackagesFindVersionGlobbingFailure", - ErrorCategory.InvalidResult, + new InvalidOrEmptyResponse($"Dependency package with name {dep.Name} and version range {dep.VersionRange} could not be found in repository '{repository.Name}"), + "FindDepPackagesFindVersionGlobbingFailure", + ErrorCategory.InvalidResult, this)); yield return null; continue; @@ -1143,16 +1187,16 @@ internal IEnumerable FindDependencyPackages( if (currentResult.exception != null && !currentResult.exception.Message.Equals(string.Empty)) { _cmdletPassedIn.WriteError(new ErrorRecord( - new ResourceNotFoundException($"Dependency package with name '{dep.Name}' and version range '{dep.VersionRange}' could not be found in repository '{repository.Name}'", currentResult.exception), - "DependencyPackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Dependency package with name '{dep.Name}' and version range '{dep.VersionRange}' could not be found in repository '{repository.Name}'", currentResult.exception), + "DependencyPackageNotFound", + ErrorCategory.ObjectNotFound, this)); - + yield return null; continue; } - // Check to see if version falls within version range + // Check to see if version falls within version range PSResourceInfo foundDep = currentResult.returnedObject; string depVersionStr = $"{foundDep.Version}"; if (foundDep.IsPrerelease) { diff --git a/src/code/FindPSResource.cs b/src/code/FindPSResource.cs index f5bda3c87..d2a78b1a6 100644 --- a/src/code/FindPSResource.cs +++ b/src/code/FindPSResource.cs @@ -41,6 +41,7 @@ public sealed class FindPSResource : PSCmdlet [SupportsWildcards] [Parameter(Position = 0, ValueFromPipeline = true, + ValueFromPipelineByPropertyName = true, ParameterSetName = NameParameterSet)] [ValidateNotNullOrEmpty] public string[] Name { get; set; } @@ -91,7 +92,7 @@ public sealed class FindPSResource : PSCmdlet /// Specifies one or more repository names to search. If not specified, search will include all currently registered repositories. /// [SupportsWildcards] - [Parameter()] + [Parameter(ValueFromPipelineByPropertyName = true)] [ArgumentCompleter(typeof(RepositoryNameCompleter))] [ValidateNotNullOrEmpty] public string[] Repository { get; set; } diff --git a/src/code/GroupPolicyRepositoryEnforcement.cs b/src/code/GroupPolicyRepositoryEnforcement.cs new file mode 100644 index 000000000..ac4f7ee98 --- /dev/null +++ b/src/code/GroupPolicyRepositoryEnforcement.cs @@ -0,0 +1,226 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.PowerShell.PSResourceGet.UtilClasses; +using Microsoft.Win32; + +namespace Microsoft.PowerShell.PSResourceGet.Cmdlets +{ + /// + /// This class is used to enforce group policy for repositories. + /// + public class GroupPolicyRepositoryEnforcement + { + const string userRoot = "HKEY_CURRENT_USER"; + const string psresourcegetGPPath = @"SOFTWARE\Policies\Microsoft\PSResourceGetRepository"; + const string gpRootPath = @"Software\Microsoft\Windows\CurrentVersion\Group Policy Objects"; + + private GroupPolicyRepositoryEnforcement() + { + } + + /// + /// This method is used to see if the group policy is enabled. + /// + /// + /// True if the group policy is enabled, false otherwise. + public static bool IsGroupPolicyEnabled() + { + if (Environment.OSVersion.Platform != PlatformID.Win32NT) + { + // Always return false for non-Windows platforms and Group Policy is not available. + return false; + } + + if (InternalHooks.EnableGPRegistryHook) + { + return InternalHooks.GPEnabledStatus; + } + + var values = ReadGPFromRegistry(); + + if (values is not null && values.Count > 0) + { + return true; + } + + return false; + } + + /// + /// Get allowed list of URIs for allowed repositories. + /// + /// Array of allowed URIs. + /// Thrown when the group policy is not enabled. + public static Uri[]? GetAllowedRepositoryURIs() + { + if (Environment.OSVersion.Platform != PlatformID.Win32NT) + { + throw new PlatformNotSupportedException("Group policy is only supported on Windows."); + } + + if (InternalHooks.EnableGPRegistryHook) + { + var uri = new Uri(InternalHooks.AllowedUri); + return new Uri[] { uri }; + } + + if (!IsGroupPolicyEnabled()) + { + return null; + } + else + { + List allowedUris = new List(); + + var allowedRepositories = ReadGPFromRegistry(); + + if (allowedRepositories is not null && allowedRepositories.Count > 0) + { + foreach (var allowedRepository in allowedRepositories) + { + allowedUris.Add(allowedRepository.Value); + } + } + + return allowedUris.ToArray(); + } + } + + internal static bool IsRepositoryAllowed(Uri repositoryUri) + { + bool isAllowed = false; + + if(GroupPolicyRepositoryEnforcement.IsGroupPolicyEnabled()) + { + var allowedList = GroupPolicyRepositoryEnforcement.GetAllowedRepositoryURIs(); + + if (allowedList != null && allowedList.Length > 0) + { + isAllowed = allowedList.Any(uri => uri.Equals(repositoryUri)); + } + } + else + { + isAllowed = true; + } + + return isAllowed; + } + + private static List>? ReadGPFromRegistry() + { + List> allowedRepositories = new List>(); + + using (var key = Registry.CurrentUser.OpenSubKey(gpRootPath)) + { + if (key is null) + { + return null; + } + + var subKeys = key.GetSubKeyNames(); + + if (subKeys is null) + { + return null; + } + + foreach (var subKey in subKeys) + { + if (subKey.EndsWith("Machine")) + { + continue; + } + + using (var psrgKey = key.OpenSubKey(subKey + "\\" + psresourcegetGPPath)) + { + if (psrgKey is null) + { + // this GPO does not have PSResourceGetRepository key + continue; + } + + var valueNames = psrgKey.GetValueNames(); + + // This means it is disabled + if (valueNames is null || valueNames.Length == 0 || valueNames.Length == 1 && valueNames[0].Equals("**delvals.", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + else + { + foreach (var valueName in valueNames) + { + if (valueName.Equals("**delvals.", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var value = psrgKey.GetValue(valueName); + + if (value is null) + { + throw new InvalidOperationException("Invalid registry value."); + } + + string valueString = value.ToString(); + var kvRegValue = ConvertRegValue(valueString); + allowedRepositories.Add(kvRegValue); + } + } + } + } + } + + return allowedRepositories; + } + + private static KeyValuePair ConvertRegValue(string regValue) + { + if (string.IsNullOrEmpty(regValue)) + { + throw new ArgumentException("Registry value is empty."); + } + + var KvPairs = regValue.Split(new char[] { ';' }); + + string? nameValue = null; + string? uriValue = null; + + foreach (var kvPair in KvPairs) + { + var kv = kvPair.Split(new char[] { '=' }, 2); + + if (kv.Length != 2) + { + throw new InvalidOperationException("Invalid registry value."); + } + + if (kv[0].Equals("Name", StringComparison.OrdinalIgnoreCase)) + { + nameValue = kv[1]; + } + + if (kv[0].Equals("Uri", StringComparison.OrdinalIgnoreCase)) + { + uriValue = kv[1]; + } + } + + if (nameValue is not null && uriValue is not null) + { + return new KeyValuePair(nameValue, new Uri(uriValue)); + } + else + { + throw new InvalidOperationException("Invalid registry value."); + } + } + } +} \ No newline at end of file diff --git a/src/code/InstallHelper.cs b/src/code/InstallHelper.cs index 2e1e7cc87..3e0d8ae9a 100644 --- a/src/code/InstallHelper.cs +++ b/src/code/InstallHelper.cs @@ -8,6 +8,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.IO.Compression; using System.Linq; using System.Management.Automation; using System.Net; @@ -59,7 +60,7 @@ internal class InstallHelper public InstallHelper(PSCmdlet cmdletPassedIn, NetworkCredential networkCredential) { - CancellationTokenSource source = new CancellationTokenSource(); + CancellationTokenSource source = new(); _cancellationToken = source.Token; _cmdletPassedIn = cmdletPassedIn; _networkCredential = networkCredential; @@ -94,7 +95,7 @@ public IEnumerable BeginInstallPackages( { _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}'; " + + "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}'", string.Join(",", names), versionRange != null ? (versionRange.OriginalString != null ? versionRange.OriginalString : string.Empty) : string.Empty, @@ -182,7 +183,7 @@ private List ProcessRepositories( ScopeType scope) { _cmdletPassedIn.WriteDebug("In InstallHelper::ProcessRepositories()"); - List allPkgsInstalled = new List(); + List allPkgsInstalled = new(); if (repository != null && repository.Length != 0) { // Write error and disregard repository entries containing wildcards. @@ -261,13 +262,27 @@ private List ProcessRepositories( var noToAll = false; var findHelper = new FindHelper(_cancellationToken, _cmdletPassedIn, _networkCredential); - List repositoryNamesToSearch = new List(); + List repositoryNamesToSearch = new(); bool sourceTrusted = false; - // Loop through all the repositories provided (in priority order) until there no more packages to install. + // Loop through all the repositories provided (in priority order) until there no more packages to install. for (int i = 0; i < listOfRepositories.Count && _pkgNamesToInstall.Count > 0; i++) { PSRepositoryInfo currentRepository = listOfRepositories[i]; + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(currentRepository.Uri); + + if (!isAllowed) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new PSInvalidOperationException($"Repository '{currentRepository.Name}' is not allowed by Group Policy."), + "RepositoryNotAllowedByGroupPolicy", + ErrorCategory.PermissionDenied, + this)); + + continue; + } + string repoName = currentRepository.Name; sourceTrusted = currentRepository.Trusted || trustRepository; @@ -314,7 +329,7 @@ private List ProcessRepositories( } repositoryNamesToSearch.Add(repoName); - if ((currentRepository.ApiVersion == PSRepositoryInfo.APIVersion.v3) && (!installDepsForRepo)) + if ((currentRepository.ApiVersion == PSRepositoryInfo.APIVersion.V3) && (!installDepsForRepo)) { _cmdletPassedIn.WriteWarning("Installing dependencies is not currently supported for V3 server protocol repositories. The package will be installed without installing dependencies."); installDepsForRepo = true; @@ -329,7 +344,7 @@ private List ProcessRepositories( allPkgsInstalled.AddRange(installedPkgs); } - if (_pkgNamesToInstall.Count > 0) + if (!_cmdletPassedIn.MyInvocation.BoundParameters.ContainsKey("WhatIf") && _pkgNamesToInstall.Count > 0) { string repositoryWording = repositoryNamesToSearch.Count > 1 ? "registered repositories" : "repository"; _cmdletPassedIn.WriteError(new ErrorRecord( @@ -466,7 +481,7 @@ private void MoveFilesIntoInstallPath( // Delete the directory path before replacing it with the new module. // If deletion fails (usually due to binary file in use), then attempt restore so that the currently // installed module is not corrupted. - _cmdletPassedIn.WriteVerbose($"Attempting to delete with restore on failure.'{finalModuleVersionDir}'"); + _cmdletPassedIn.WriteVerbose($"Attempting to delete with restore on failure. '{finalModuleVersionDir}'"); Utils.DeleteDirectoryWithRestore(finalModuleVersionDir); } @@ -487,15 +502,26 @@ private void MoveFilesIntoInstallPath( } else { - var scriptXML = pkgInfo.Name + "_InstalledScriptInfo.xml"; + string scriptInfoFolderPath = Path.Combine(installPath, "InstalledScriptInfos"); + string scriptXML = pkgInfo.Name + "_InstalledScriptInfo.xml"; + string scriptXmlFilePath = Path.Combine(scriptInfoFolderPath, scriptXML); if (!_savePkg) { + // Need to ensure "InstalledScriptInfos directory exists + if (!Directory.Exists(scriptInfoFolderPath)) + + { + _cmdletPassedIn.WriteVerbose($"Created '{scriptInfoFolderPath}' path for scripts"); + Directory.CreateDirectory(scriptInfoFolderPath); + } + // Need to delete old xml files because there can only be 1 per script - _cmdletPassedIn.WriteVerbose(string.Format("Checking if path '{0}' exists: ", File.Exists(Path.Combine(installPath, "InstalledScriptInfos", scriptXML)))); - if (File.Exists(Path.Combine(installPath, "InstalledScriptInfos", scriptXML))) + _cmdletPassedIn.WriteVerbose(string.Format("Checking if path '{0}' exists: '{1}'", scriptXmlFilePath, File.Exists(scriptXmlFilePath))); + if (File.Exists(scriptXmlFilePath)) { _cmdletPassedIn.WriteVerbose("Deleting script metadata XML"); - File.Delete(Path.Combine(installPath, "InstalledScriptInfos", scriptXML)); + File.Delete(Path.Combine(scriptInfoFolderPath, scriptXML)); + } _cmdletPassedIn.WriteVerbose(string.Format("Moving '{0}' to '{1}'", Path.Combine(dirNameVersion, scriptXML), Path.Combine(installPath, "InstalledScriptInfos", scriptXML))); @@ -510,8 +536,10 @@ private void MoveFilesIntoInstallPath( } } else { - _cmdletPassedIn.WriteVerbose(string.Format("Moving '{0}' to '{1}'", Path.Combine(dirNameVersion, scriptXML), Path.Combine(installPath, scriptXML))); - Utils.MoveFiles(Path.Combine(dirNameVersion, scriptXML), Path.Combine(installPath, scriptXML)); + if (_includeXml) { + _cmdletPassedIn.WriteVerbose(string.Format("Moving '{0}' to '{1}'", Path.Combine(dirNameVersion, scriptXML), Path.Combine(installPath, scriptXML))); + Utils.MoveFiles(Path.Combine(dirNameVersion, scriptXML), Path.Combine(installPath, scriptXML)); + } } _cmdletPassedIn.WriteVerbose(string.Format("Moving '{0}' to '{1}'", scriptPath, Path.Combine(finalModuleVersionDir, pkgInfo.Name + PSScriptFileExt))); @@ -533,7 +561,7 @@ private List InstallPackages( FindHelper findHelper) { _cmdletPassedIn.WriteDebug("In InstallHelper::InstallPackages()"); - List pkgsSuccessfullyInstalled = new List(); + List pkgsSuccessfullyInstalled = new(); // Install parent package to the temp directory, // Get the dependencies from the installed package, @@ -586,7 +614,7 @@ private List InstallPackages( if (!skipDependencyCheck) { - if (currentServer.Repository.ApiVersion == PSRepositoryInfo.APIVersion.v3) + if (currentServer.Repository.ApiVersion == PSRepositoryInfo.APIVersion.V3) { _cmdletPassedIn.WriteWarning("Installing dependencies is not currently supported for V3 server protocol repositories. The package will be installed without installing dependencies."); } @@ -602,7 +630,7 @@ private List InstallPackages( depFindFailed = true; continue; } - + if (String.Equals(depPkg.Name, parentPkgObj.Name, StringComparison.OrdinalIgnoreCase)) { continue; @@ -644,7 +672,7 @@ private List InstallPackages( } // If -WhatIf is passed in, early out. - if (!_cmdletPassedIn.ShouldProcess("Exit ShouldProcess")) + if (_cmdletPassedIn.MyInvocation.BoundParameters.ContainsKey("WhatIf")) { return pkgsSuccessfullyInstalled; } @@ -671,6 +699,16 @@ private List InstallPackages( } } } + catch (Exception e) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + e, + "InstallPackageFailure", + ErrorCategory.InvalidOperation, + _cmdletPassedIn)); + + throw e; + } finally { DeleteInstallationTempPath(tempInstallPath); @@ -742,14 +780,14 @@ private Hashtable BeginPackageInstall( if (currentResult.exception != null && !currentResult.exception.Message.Equals(string.Empty)) { errRecord = new ErrorRecord( - currentResult.exception, - "FindConvertToPSResourceFailure", - ErrorCategory.ObjectNotFound, + currentResult.exception, + "FindConvertToPSResourceFailure", + ErrorCategory.ObjectNotFound, _cmdletPassedIn); } else if (searchVersionType == VersionType.VersionRange) { - // Check to see if version falls within version range + // Check to see if version falls within version range PSResourceInfo foundPkg = currentResult.returnedObject; string versionStr = $"{foundPkg.Version}"; if (foundPkg.IsPrerelease) @@ -778,6 +816,21 @@ private Hashtable BeginPackageInstall( pkgToInstall.RepositorySourceLocation = repository.Uri.ToString(); pkgToInstall.AdditionalMetadata.TryGetValue("NormalizedVersion", out string pkgVersion); + if (pkgVersion == null) { + // Not all NuGet providers (e.g. Artifactory, possibly others) send NormalizedVersion in NuGet package responses. + // If they don't, we need to manually construct the combined version+prerelease from pkgToInstall.Version and the prerelease string. + pkgVersion = pkgToInstall.Version.ToString(); + if (!String.IsNullOrEmpty(pkgToInstall.Prerelease)) { + pkgVersion += $"-{pkgToInstall.Prerelease}"; + } + } + + // For most repositories/providers the server will use the normalized version, which pkgVersion originally reflects + // However, for container registries the version must exactly match what was in the artifact manifest and then reflected in PSResourceInfo.Version.ToString() + if (currentServer.Repository.ApiVersion == PSRepositoryInfo.APIVersion.ContainerRegistry) + { + pkgVersion = String.IsNullOrEmpty(pkgToInstall.Prerelease) ? pkgToInstall.Version.ToString() : $"{pkgToInstall.Version.ToString()}-{pkgToInstall.Prerelease}"; + } // Check to see if the pkg is already installed (ie the pkg is installed and the version satisfies the version range provided via param) if (!_reinstall) @@ -786,7 +839,7 @@ private Hashtable BeginPackageInstall( if (_packagesOnMachine.Contains(currPkgNameVersion)) { _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)); @@ -936,7 +989,11 @@ private bool TryInstallToTempPath( var pkgVersion = pkgToInstall.Version.ToString(); var tempDirNameVersion = Path.Combine(tempInstallPath, pkgName.ToLower(), pkgVersion); Directory.CreateDirectory(tempDirNameVersion); - System.IO.Compression.ZipFile.ExtractToDirectory(pathToFile, tempDirNameVersion); + + if (!TryExtractToDirectory(pathToFile, tempDirNameVersion, out error)) + { + return false; + } File.Delete(pathToFile); @@ -991,7 +1048,7 @@ private bool TryInstallToTempPath( } // Accept License verification - if (!_savePkg && !CallAcceptLicense(pkgToInstall, moduleManifest, tempInstallPath, pkgVersion, out error)) + if (!CallAcceptLicense(pkgToInstall, moduleManifest, tempInstallPath, pkgVersion, out error)) { _pkgNamesToInstall.RemoveAll(x => x.Equals(pkgToInstall.Name, StringComparison.InvariantCultureIgnoreCase)); return false; @@ -1146,6 +1203,71 @@ private bool TrySaveNupkgToTempPath( } } + /// + /// Extracts files from .nupkg + /// Similar functionality as System.IO.Compression.ZipFile.ExtractToDirectory, + /// but while ExtractToDirectory cannot overwrite files, this method can. + /// + private bool TryExtractToDirectory(string zipPath, string extractPath, out ErrorRecord error) + { + error = null; + // Normalize the path + extractPath = Path.GetFullPath(extractPath); + + // Ensures that the last character on the extraction path is the directory separator char. + // Without this, a malicious zip file could try to traverse outside of the expected extraction path. + if (!extractPath.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)) + { + extractPath += Path.DirectorySeparatorChar; + } + + try + { + using (ZipArchive archive = ZipFile.OpenRead(zipPath)) + { + foreach (ZipArchiveEntry entry in archive.Entries.Where(entry => entry.CompressedLength > 0)) + { + // If a file has one or more parent directories. + if (entry.FullName.Contains(Path.DirectorySeparatorChar) || entry.FullName.Contains(Path.AltDirectorySeparatorChar)) + { + // Create the parent directories if they do not already exist + var lastPathSeparatorIdx = entry.FullName.Contains(Path.DirectorySeparatorChar) ? + entry.FullName.LastIndexOf(Path.DirectorySeparatorChar) : entry.FullName.LastIndexOf(Path.AltDirectorySeparatorChar); + var parentDirs = entry.FullName.Substring(0, lastPathSeparatorIdx); + var destinationDirectory = Path.Combine(extractPath, parentDirs); + if (!Directory.Exists(destinationDirectory)) + { + Directory.CreateDirectory(destinationDirectory); + } + } + + // Gets the full path to ensure that relative segments are removed. + string destinationPath = Path.GetFullPath(Path.Combine(extractPath, entry.FullName)); + + // Validate that the resolved output path starts with the resolved destination directory. + // For example, if a zip file contains a file entry ..\sneaky-file, and the zip file is extracted to the directory c:\output, + // then naively combining the paths would result in an output file path of c:\output\..\sneaky-file, which would cause the file to be written to c:\sneaky-file. + if (destinationPath.StartsWith(extractPath, StringComparison.Ordinal)) + { + entry.ExtractToFile(destinationPath, overwrite: true); + } + } + } + } + catch (Exception e) + { + error = new ErrorRecord( + new Exception($"Error occured while extracting .nupkg: '{e.Message}'"), + "ErrorExtractingNupkg", + ErrorCategory.OperationStopped, + _cmdletPassedIn); + + return false; + } + + return true; + } + /// /// Moves package files/directories from the temp install path into the final install path location. /// @@ -1224,11 +1346,10 @@ private bool CallAcceptLicense(PSResourceInfo p, string moduleManifest, string t _cmdletPassedIn.WriteDebug("In InstallHelper::CallAcceptLicense()"); error = null; var requireLicenseAcceptance = false; - var success = true; if (File.Exists(moduleManifest)) { - using (StreamReader sr = new StreamReader(moduleManifest)) + using (StreamReader sr = new(moduleManifest)) { var text = sr.ReadToEnd(); @@ -1236,9 +1357,9 @@ private bool CallAcceptLicense(PSResourceInfo p, string moduleManifest, string t var patternToSkip1 = "#\\s*RequireLicenseAcceptance\\s*=\\s*\\$true"; var patternToSkip2 = "\\*\\s*RequireLicenseAcceptance\\s*=\\s*\\$true"; - Regex rgx = new Regex(pattern); - Regex rgxComment1 = new Regex(patternToSkip1); - Regex rgxComment2 = new Regex(patternToSkip2); + Regex rgx = new(pattern); + Regex rgxComment1 = new(patternToSkip1); + Regex rgxComment2 = new(patternToSkip2); if (rgx.IsMatch(text) && !rgxComment1.IsMatch(text) && !rgxComment2.IsMatch(text)) { requireLicenseAcceptance = true; @@ -1252,24 +1373,46 @@ private bool CallAcceptLicense(PSResourceInfo p, string moduleManifest, string t if (!_acceptLicense) { var PkgTempInstallPath = Path.Combine(tempInstallPath, p.Name, newVersion); - var LicenseFilePath = Path.Combine(PkgTempInstallPath, "License.txt"); + if (!Directory.Exists(PkgTempInstallPath)) + { + error = new ErrorRecord( + new ArgumentException($"Package '{p.Name}' could not be installed: Temporary installation path does not exist."), + "TempPathNotFound", + ErrorCategory.ObjectNotFound, + _cmdletPassedIn); + + return false; + } + + string[] files = Directory.GetFiles(PkgTempInstallPath); + + bool foundLicense = false; + string LicenseFilePath = string.Empty; + foreach (string file in files) + { + if (string.Equals(Path.GetFileName(file), "License.txt", StringComparison.OrdinalIgnoreCase)) + { + foundLicense = true; + LicenseFilePath = Path.GetFullPath(file); + break; + } + } - if (!File.Exists(LicenseFilePath)) + if (!foundLicense) { error = new ErrorRecord( new ArgumentException($"Package '{p.Name}' could not be installed: License.txt not found. License.txt must be provided when user license acceptance is required."), "LicenseTxtNotFound", ErrorCategory.ObjectNotFound, - _cmdletPassedIn);; - success = false; + _cmdletPassedIn); - return success; + return false; } // Otherwise read LicenseFile - string licenseText = System.IO.File.ReadAllText(LicenseFilePath); - var acceptanceLicenseQuery = $"Do you accept the license terms for module '{p.Name}'."; - var message = licenseText + "`r`n" + acceptanceLicenseQuery; + string licenseText = File.ReadAllText(LicenseFilePath); + var acceptanceLicenseQuery = $"Do you accept the license terms for module '{p.Name}'?"; + var message = licenseText + "\r\n" + acceptanceLicenseQuery; var title = "License Acceptance"; var yesToAll = false; @@ -1290,12 +1433,13 @@ private bool CallAcceptLicense(PSResourceInfo p, string moduleManifest, string t "ForceAcceptLicense", ErrorCategory.InvalidArgument, _cmdletPassedIn); - success = false; + + return false; } } } - return success; + return true; } /// @@ -1309,14 +1453,14 @@ private bool DetectClobber(string pkgName, Hashtable parsedMetadataHashtable, ou // Get installed modules, then get all possible paths // selectPrereleaseOnly is false because even if Prerelease is true we want to include both stable and prerelease, would never select prerelease only. - GetHelper getHelper = new GetHelper(_cmdletPassedIn); + GetHelper getHelper = new(_cmdletPassedIn); IEnumerable pkgsAlreadyInstalled = getHelper.GetPackagesFromPath( name: new string[] { "*" }, versionRange: VersionRange.All, pathsToSearch: _pathsToSearch, selectPrereleaseOnly: false); - List listOfCmdlets = new List(); + List listOfCmdlets = new(); if (parsedMetadataHashtable.ContainsKey("CmdletsToExport")) { if (parsedMetadataHashtable["CmdletsToExport"] is object[] cmdletsToExport) @@ -1330,8 +1474,8 @@ private bool DetectClobber(string pkgName, Hashtable parsedMetadataHashtable, ou foreach (var pkg in pkgsAlreadyInstalled) { - List duplicateCmdlets = new List(); - List duplicateCmds = new List(); + List duplicateCmdlets = new(); + List duplicateCmds = new(); // See if any of the cmdlets or commands in the pkg we're trying to install exist within a package that's already installed if (pkg.Includes.Cmdlet != null && pkg.Includes.Cmdlet.Length != 0) { diff --git a/src/code/InstallPSResource.cs b/src/code/InstallPSResource.cs index a65797268..d8af185f0 100644 --- a/src/code/InstallPSResource.cs +++ b/src/code/InstallPSResource.cs @@ -17,15 +17,15 @@ namespace Microsoft.PowerShell.PSResourceGet.Cmdlets /// The Install-PSResource cmdlet installs a resource. /// It returns nothing. /// - [Cmdlet(VerbsLifecycle.Install, - "PSResource", - DefaultParameterSetName = "NameParameterSet", + [Cmdlet(VerbsLifecycle.Install, + "PSResource", + DefaultParameterSetName = "NameParameterSet", SupportsShouldProcess = true)] [Alias("isres")] public sealed class InstallPSResource : PSCmdlet { - #region Parameters + #region Parameters /// /// Specifies the exact names of resources to install from a repository. @@ -42,7 +42,7 @@ class InstallPSResource : PSCmdlet [Parameter(ParameterSetName = NameParameterSet, ValueFromPipelineByPropertyName = true)] [ValidateNotNullOrEmpty] public string Version { get; set; } - + /// /// Specifies to allow installation of prerelease versions /// @@ -53,6 +53,7 @@ class InstallPSResource : PSCmdlet /// /// Specifies the repositories from which to search for the resource to be installed. /// + [SupportsWildcards] [Parameter(ParameterSetName = NameParameterSet, ValueFromPipelineByPropertyName = true)] [Parameter(ParameterSetName = InputObjectParameterSet, ValueFromPipelineByPropertyName = true)] [ArgumentCompleter(typeof(RepositoryNameCompleter))] @@ -83,9 +84,9 @@ public string TemporaryPath set { - if (WildcardPattern.ContainsWildcardCharacters(value)) - { - throw new PSArgumentException("Wildcard characters are not allowed in the temporary path."); + if (WildcardPattern.ContainsWildcardCharacters(value)) + { + throw new PSArgumentException("Wildcard characters are not allowed in the temporary path."); } // This will throw if path cannot be resolved @@ -99,7 +100,7 @@ public string TemporaryPath /// [Parameter] public SwitchParameter TrustRepository { get; set; } - + /// /// Overwrites a previously installed resource with the same name and version. /// @@ -130,7 +131,7 @@ public string TemporaryPath /// [Parameter] public SwitchParameter SkipDependencyCheck { get; set; } - + /// /// Check validation for signed and catalog files /// @@ -287,7 +288,7 @@ protected override void ProcessRecord() pkgCredential: Credential, reqResourceParams: null); break; - + case InputObjectParameterSet: foreach (var inputObj in InputObject) { string normalizedVersionString = Utils.GetNormalizedVersionString(inputObj.Version.ToString(), inputObj.Prerelease); @@ -362,7 +363,7 @@ protected override void ProcessRecord() ErrorCategory.InvalidData, this)); } - + RequiredResourceHelper(pkgsInFile); break; @@ -379,7 +380,7 @@ protected override void ProcessRecord() } } */ - + Hashtable pkgsHash = null; try { @@ -441,7 +442,7 @@ private void RequiredResourceHelper(Hashtable reqResourceHash) { var pkgNameEmptyOrWhitespaceError = new ErrorRecord( new ArgumentException($"The package name '{pkgName}' provided cannot be an empty string or whitespace."), - "pkgNameEmptyOrWhitespaceError", + "pkgNameEmptyOrWhitespaceError", ErrorCategory.InvalidArgument, this); @@ -454,7 +455,7 @@ private void RequiredResourceHelper(Hashtable reqResourceHash) { var requiredResourceHashtableInputFormatError = new ErrorRecord( new ArgumentException($"The RequiredResource input with name '{pkgName}' does not have a valid value, the value must be a hashtable."), - "RequiredResourceHashtableInputFormatError", + "RequiredResourceHashtableInputFormatError", ErrorCategory.InvalidArgument, this); @@ -483,7 +484,7 @@ private void RequiredResourceHelper(Hashtable reqResourceHash) ThrowTerminatingError(ParameterParsingError); } } - + if (pkgParams.Scope == ScopeType.AllUsers) { _pathsToInstallPkg = Utils.GetAllInstallationPaths(this, pkgParams.Scope); @@ -513,10 +514,10 @@ private void ProcessInstallHelper(string[] pkgNames, string pkgVersion, bool pkg "NameContainsWildcard", ErrorCategory.InvalidArgument, this)); - + return; } - + foreach (string error in errorMsgs) { WriteError(new ErrorRecord( diff --git a/src/code/InternalHooks.cs b/src/code/InternalHooks.cs index 9b5ea0294..0578485ca 100644 --- a/src/code/InternalHooks.cs +++ b/src/code/InternalHooks.cs @@ -9,6 +9,14 @@ public class InternalHooks { internal static bool InvokedFromCompat; + internal static bool EnableGPRegistryHook; + + internal static bool GPEnabledStatus; + + internal static string AllowedUri; + + internal static string MARPrefix; + public static void SetTestHook(string property, object value) { var fieldInfo = typeof(InternalHooks).GetField(property, BindingFlags.Static | BindingFlags.NonPublic); diff --git a/src/code/LocalServerApiCalls.cs b/src/code/LocalServerApiCalls.cs index 2bd862be3..324db1081 100644 --- a/src/code/LocalServerApiCalls.cs +++ b/src/code/LocalServerApiCalls.cs @@ -218,6 +218,8 @@ public override FindResults FindVersionWithTag(string packageName, string versio /// /// Installs a specific package. + /// User may request to install package with or without providing version (as seen in examples below), but prior to calling this method the package is located and package version determined. + /// Therefore, package version should not be null in this method. /// Name: no wildcard support. /// Examples: Install "PowerShellGet" /// Install "PowerShellGet" -Version "3.0.0" @@ -227,12 +229,16 @@ public override Stream InstallPackage(string packageName, string packageVersion, Stream results = new MemoryStream(); if (string.IsNullOrEmpty(packageVersion)) { - results = InstallName(packageName, includePrerelease, out errRecord); - } - else { - results = InstallVersion(packageName, packageVersion, out errRecord); + errRecord = new ErrorRecord( + exception: new ArgumentNullException($"Package version could not be found for {packageName}"), + "PackageVersionNullOrEmptyError", + ErrorCategory.InvalidArgument, + _cmdletPassedIn); + + return results; } + results = InstallVersion(packageName, packageVersion, out errRecord); return results; } @@ -249,32 +255,37 @@ private FindResults FindNameHelper(string packageName, string[] tags, bool inclu FindResults findResponse = new FindResults(); errRecord = null; - WildcardPattern pkgNamePattern = new WildcardPattern($"{packageName}.*", WildcardOptions.IgnoreCase); NuGetVersion latestVersion = new NuGetVersion("0.0.0.0"); String latestVersionPath = String.Empty; string actualPkgName = packageName; + // this regex pattern matches packageName followed by a version (4 digit or 3 with prerelease word) + string regexPattern = $"{packageName}" + @"(\.\d+){1,3}(?:[a-zA-Z0-9-.]+|.\d)?\.nupkg"; + _cmdletPassedIn.WriteDebug($"package file name pattern to be searched for is: {regexPattern}"); + foreach (string path in Directory.GetFiles(Repository.Uri.LocalPath)) { string packageFullName = Path.GetFileName(path); - - if (!String.IsNullOrEmpty(packageFullName) && pkgNamePattern.IsMatch(packageFullName)) + bool isMatch = Regex.IsMatch(packageFullName, regexPattern, RegexOptions.IgnoreCase); + if (!isMatch) { - NuGetVersion nugetVersion = GetInfoFromFileName(packageFullName: packageFullName, packageName: packageName, actualName: out actualPkgName, out errRecord); - _cmdletPassedIn.WriteDebug($"Version parsed as '{nugetVersion}'"); + continue; + } - if (errRecord != null) - { - return findResponse; - } + NuGetVersion nugetVersion = GetInfoFromFileName(packageFullName: packageFullName, packageName: packageName, actualName: out actualPkgName, out errRecord); + _cmdletPassedIn.WriteDebug($"Version parsed as '{nugetVersion}'"); - if ((!nugetVersion.IsPrerelease || includePrerelease) && (nugetVersion > latestVersion)) + if (errRecord != null) + { + return findResponse; + } + + if ((!nugetVersion.IsPrerelease || includePrerelease) && (nugetVersion > latestVersion)) + { + if (nugetVersion > latestVersion) { - if (nugetVersion > latestVersion) - { - latestVersion = nugetVersion; - latestVersionPath = path; - } + latestVersion = nugetVersion; + latestVersionPath = path; } } } @@ -365,29 +376,35 @@ private FindResults FindVersionHelper(string packageName, string version, string return findResponse; } - WildcardPattern pkgNamePattern = new WildcardPattern($"{packageName}.*", WildcardOptions.IgnoreCase); + // this regex pattern matches packageName followed by the requested version + string regexPattern = $"{packageName}.{requiredVersion.ToNormalizedString()}" + @".nupkg"; + _cmdletPassedIn.WriteDebug($"pattern is: {regexPattern}"); string pkgPath = String.Empty; string actualPkgName = String.Empty; + foreach (string path in Directory.GetFiles(Repository.Uri.LocalPath)) { string packageFullName = Path.GetFileName(path); - if (!String.IsNullOrEmpty(packageFullName) && pkgNamePattern.IsMatch(packageFullName)) + bool isMatch = Regex.IsMatch(packageFullName, regexPattern, RegexOptions.IgnoreCase); + if (!isMatch) { - NuGetVersion nugetVersion = GetInfoFromFileName(packageFullName: packageFullName, packageName: packageName, actualName: out actualPkgName, out errRecord); - _cmdletPassedIn.WriteDebug($"'{packageName}' version parsed as '{nugetVersion}'"); + continue; + } - if (errRecord != null) - { - return findResponse; - } + NuGetVersion nugetVersion = GetInfoFromFileName(packageFullName: packageFullName, packageName: packageName, actualName: out actualPkgName, out errRecord); + _cmdletPassedIn.WriteDebug($"Version parsed as '{nugetVersion}'"); - if (nugetVersion == requiredVersion) - { - _cmdletPassedIn.WriteDebug("Found matching version"); - string pkgFullName = $"{actualPkgName}.{nugetVersion.ToString()}.nupkg"; - pkgPath = Path.Combine(Repository.Uri.LocalPath, pkgFullName); - break; - } + if (errRecord != null) + { + return findResponse; + } + + if (nugetVersion == requiredVersion) + { + _cmdletPassedIn.WriteDebug("Found matching version"); + string pkgFullName = $"{actualPkgName}.{nugetVersion.ToString()}.nupkg"; + pkgPath = path; + break; } } @@ -643,9 +660,10 @@ private Hashtable GetMetadataFromNupkg(string packageName, string packagePath, s _cmdletPassedIn.WriteDebug($"Extracting '{zipFilePath}' to '{tempDiscoveryPath}'"); System.IO.Compression.ZipFile.ExtractToDirectory(zipFilePath, tempDiscoveryPath); - string psd1FilePath = Path.Combine(tempDiscoveryPath, $"{packageName}.psd1"); - string ps1FilePath = Path.Combine(tempDiscoveryPath, $"{packageName}.ps1"); - string nuspecFilePath = Path.Combine(tempDiscoveryPath, $"{packageName}.nuspec"); + string psd1FilePath = String.Empty; + string ps1FilePath = String.Empty; + string nuspecFilePath = String.Empty; + Utils.GetMetadataFilesFromPath(tempDiscoveryPath, packageName, out psd1FilePath, out ps1FilePath, out nuspecFilePath, out string properCasingPkgName); List pkgTags = new List(); @@ -670,7 +688,7 @@ private Hashtable GetMetadataFromNupkg(string packageName, string packagePath, s pkgMetadata.Add("ProjectUri", projectUri); pkgMetadata.Add("IconUri", iconUri); pkgMetadata.Add("ReleaseNotes", releaseNotes); - pkgMetadata.Add("Id", packageName); + pkgMetadata.Add("Id", properCasingPkgName); pkgMetadata.Add(_fileTypeKey, Utils.MetadataFileType.ModuleManifest); pkgTags.AddRange(pkgHashTags); @@ -690,7 +708,7 @@ private Hashtable GetMetadataFromNupkg(string packageName, string packagePath, s } pkgMetadata = parsedScript.ToHashtable(); - pkgMetadata.Add("Id", packageName); + pkgMetadata.Add("Id", properCasingPkgName); pkgMetadata.Add(_fileTypeKey, Utils.MetadataFileType.ScriptFile); pkgTags.AddRange(pkgMetadata["Tags"] as string[]); @@ -876,7 +894,7 @@ private NuGetVersion GetInfoFromFileName(string packageFullName, string packageN string[] packageWithoutName = packageFullName.ToLower().Split(new string[]{ $"{packageName.ToLower()}." }, StringSplitOptions.RemoveEmptyEntries); string packageVersionAndExtension = packageWithoutName[0]; - string[] originalFileNameParts = packageFullName.Split(new string[]{ $".{packageVersionAndExtension}" }, StringSplitOptions.RemoveEmptyEntries); + string[] originalFileNameParts = packageFullName.ToLower().Split(new string[]{ $".{packageVersionAndExtension.ToLower()}" }, StringSplitOptions.RemoveEmptyEntries); actualName = String.IsNullOrEmpty(originalFileNameParts[0]) ? packageName : originalFileNameParts[0]; int extensionDot = packageVersionAndExtension.LastIndexOf('.'); string version = packageVersionAndExtension.Substring(0, extensionDot); diff --git a/src/code/Microsoft.PowerShell.PSResourceGet.csproj b/src/code/Microsoft.PowerShell.PSResourceGet.csproj index f98880c3f..1d657e1a1 100644 --- a/src/code/Microsoft.PowerShell.PSResourceGet.csproj +++ b/src/code/Microsoft.PowerShell.PSResourceGet.csproj @@ -5,25 +5,28 @@ Library Microsoft.PowerShell.PSResourceGet Microsoft.PowerShell.PSResourceGet - 1.0.0.0 - 1.0.0 - 1.0.0 + 1.1.0.1 + 1.1.0.1 + 1.1.0.1 net472;netstandard2.0 9.0 true - - - - - - + + + + + + - + + + + diff --git a/src/code/ModuleInitAndCleanup.cs b/src/code/ModuleInitAndCleanup.cs index bccae4064..28e83f284 100644 --- a/src/code/ModuleInitAndCleanup.cs +++ b/src/code/ModuleInitAndCleanup.cs @@ -13,6 +13,15 @@ public class UnsafeAssemblyHandler : IModuleAssemblyInitializer, IModuleAssembly private static readonly HashSet s_dependencies; private static readonly AssemblyLoadContextProxy s_proxy; + private static readonly HashSet NetFrameworkLoadFromPath = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "System.Runtime.CompilerServices.Unsafe", + "System.Memory", + "System.Diagnostics.DiagnosticSource", + "System.Text.Json", + "System.Security.Cryptography.ProtectedData" + }; + static UnsafeAssemblyHandler() { s_self = Assembly.GetExecutingAssembly(); @@ -52,13 +61,14 @@ private static bool IsAssemblyMatching(AssemblyName assemblyName, Assembly reque private static Assembly HandleAssemblyResolve(object sender, ResolveEventArgs args) { var requiredAssembly = new AssemblyName(args.Name); + string requiredAssemblyName = requiredAssembly.Name; - // If on .NET framework and requesting assembly is System.Memory load the version dependency folder + // If on .NET framework load specific assemblies from dependency folder if (s_proxy is null - && string.Equals(requiredAssembly.Name, "System.Runtime.CompilerServices.Unsafe")) + && NetFrameworkLoadFromPath.Contains(requiredAssemblyName)) { - var compileServiceDllPath = Path.Combine(s_dependencyFolder, "System.Runtime.CompilerServices.Unsafe.dll"); - return Assembly.LoadFrom(compileServiceDllPath); + var netFxDepDllPath = Path.Combine(s_dependencyFolder, $"{requiredAssemblyName}.dll"); + return Assembly.LoadFrom(netFxDepDllPath); } if (IsAssemblyMatching(requiredAssembly, args.RequestingAssembly)) diff --git a/src/code/NuGetServerAPICalls.cs b/src/code/NuGetServerAPICalls.cs index 7748695c6..2ecabd001 100644 --- a/src/code/NuGetServerAPICalls.cs +++ b/src/code/NuGetServerAPICalls.cs @@ -40,6 +40,7 @@ public NuGetServerAPICalls (PSRepositoryInfo repository, PSCmdlet cmdletPassedIn }; _sessionClient = new HttpClient(handler); + _sessionClient.Timeout = TimeSpan.FromMinutes(10); _sessionClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgentString); } @@ -143,9 +144,9 @@ public override FindResults FindTags(string[] tags, bool includePrerelease, Reso public override FindResults FindCommandOrDscResource(string[] tags, bool includePrerelease, bool isSearchingForCommands, out ErrorRecord errRecord) { errRecord = new ErrorRecord( - new InvalidOperationException($"Find by CommandName or DSCResource is not supported for the repository '{Repository.Name}'"), - "FindCommandOrDscResourceFailure", - ErrorCategory.InvalidOperation, + new InvalidOperationException($"Find by CommandName or DSCResource is not supported for the repository '{Repository.Name}'"), + "FindCommandOrDscResourceFailure", + ErrorCategory.InvalidOperation, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: FindResponseType); @@ -163,14 +164,20 @@ public override FindResults FindCommandOrDscResource(string[] tags, bool include public override FindResults FindName(string packageName, bool includePrerelease, ResourceType type, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In NuGetServerAPICalls::FindName()"); - // Make sure to include quotations around the package name - var prerelease = includePrerelease ? "IsAbsoluteLatestVersion" : "IsLatestVersion"; - // This should return the latest stable version or the latest prerelease version (respectively) // https://www.powershellgallery.com/api/v2/FindPackagesById()?id='PowerShellGet'&$filter=IsLatestVersion and substringof('PSModule', Tags) eq true + + // Make sure to include quotations around the package name + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "id", $"'{packageName}'" }, + }); + var filterBuilder = queryBuilder.FilterBuilder; + + filterBuilder.AddCriterion(includePrerelease ? "IsAbsoluteLatestVersion" : "IsLatestVersion"); + // We need to explicitly add 'Id eq ' whenever $filter is used, otherwise arbitrary results are returned. - string idFilterPart = $" and Id eq '{packageName}'"; - var requestUrl = $"{Repository.Uri}/FindPackagesById()?id='{packageName}'&$filter={prerelease}{idFilterPart}"; + filterBuilder.AddCriterion($"Id eq '{packageName}'"); + var requestUrl = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; string response = HttpRequestCall(requestUrl, out errRecord); return new FindResults(stringResponse: new string[]{ response }, hashtableResponse: emptyHashResponses, responseType: FindResponseType); @@ -185,20 +192,26 @@ public override FindResults FindName(string packageName, bool includePrerelease, public override FindResults FindNameWithTag(string packageName, string[] tags, bool includePrerelease, ResourceType type, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In NuGetServerAPICalls::FindNameWithTag()"); - // Make sure to include quotations around the package name - var prerelease = includePrerelease ? "IsAbsoluteLatestVersion" : "IsLatestVersion"; // This should return the latest stable version or the latest prerelease version (respectively) // https://www.powershellgallery.com/api/v2/FindPackagesById()?id='PowerShellGet'&$filter=IsLatestVersion and substringof('PSModule', Tags) eq true + + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "id", $"'{packageName}'" }, + }); + var filterBuilder = queryBuilder.FilterBuilder; + // We need to explicitly add 'Id eq ' whenever $filter is used, otherwise arbitrary results are returned. - string idFilterPart = $" and Id eq '{packageName}'"; - string tagFilterPart = String.Empty; + filterBuilder.AddCriterion($"Id eq '{packageName}'"); + + filterBuilder.AddCriterion(includePrerelease ? "IsAbsoluteLatestVersion" : "IsLatestVersion"); + foreach (string tag in tags) { - tagFilterPart += $" and substringof('{tag}', Tags) eq true"; + filterBuilder.AddCriterion($"substringof('{tag}', Tags) eq true"); } - var requestUrl = $"{Repository.Uri}/FindPackagesById()?id='{packageName}'&$filter={prerelease}{idFilterPart}{tagFilterPart}"; + var requestUrl = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; string response = HttpRequestCall(requestUrl, out errRecord); return new FindResults(stringResponse: new string[] { response }, hashtableResponse: emptyHashResponses, responseType: FindResponseType); @@ -359,9 +372,17 @@ public override FindResults FindVersion(string packageName, string version, Reso _cmdletPassedIn.WriteDebug("In NuGetServerAPICalls::FindVersion()"); // https://www.powershellgallery.com/api/v2/FindPackagesById()?id='blah'&includePrerelease=false&$filter= NormalizedVersion eq '1.1.0' and substringof('PSModule', Tags) eq true // Quotations around package name and version do not matter, same metadata gets returned. + + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "id", $"'{packageName}'" }, + }); + var filterBuilder = queryBuilder.FilterBuilder; + // We need to explicitly add 'Id eq ' whenever $filter is used, otherwise arbitrary results are returned. - string idFilterPart = $" and Id eq '{packageName}'"; - var requestUrl = $"{Repository.Uri}/FindPackagesById()?id='{packageName}'&$filter= NormalizedVersion eq '{version}'{idFilterPart}"; + filterBuilder.AddCriterion($"Id eq '{packageName}'"); + filterBuilder.AddCriterion($"NormalizedVersion eq '{packageName}'"); + + var requestUrl = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; string response = HttpRequestCall(requestUrl, out errRecord); return new FindResults(stringResponse: new string[] { response }, hashtableResponse: emptyHashResponses, responseType: FindResponseType); @@ -376,15 +397,22 @@ public override FindResults FindVersion(string packageName, string version, Reso public override FindResults FindVersionWithTag(string packageName, string version, string[] tags, ResourceType type, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In NuGetServerAPICalls::FindVersionWithTag()"); + + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "id", $"'{packageName}'" }, + }); + var filterBuilder = queryBuilder.FilterBuilder; + // We need to explicitly add 'Id eq ' whenever $filter is used, otherwise arbitrary results are returned. - string idFilterPart = $" and Id eq '{packageName}'"; - string tagFilterPart = String.Empty; + filterBuilder.AddCriterion($"Id eq '{packageName}'"); + filterBuilder.AddCriterion($"NormalizedVersion eq '{packageName}'"); + foreach (string tag in tags) { - tagFilterPart += $" and substringof('{tag}', Tags) eq true"; + filterBuilder.AddCriterion($"substringof('{tag}', Tags) eq true"); } - var requestUrl = $"{Repository.Uri}/FindPackagesById()?id='{packageName}'&$filter= NormalizedVersion eq '{version}'{idFilterPart}{tagFilterPart}"; + var requestUrl = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; string response = HttpRequestCall(requestUrl, out errRecord); return new FindResults(stringResponse: new string[] { response }, hashtableResponse: emptyHashResponses, responseType: FindResponseType); @@ -394,6 +422,8 @@ public override FindResults FindVersionWithTag(string packageName, string versio /// /// Installs a specific package. + /// User may request to install package with or without providing version (as seen in examples below), but prior to calling this method the package is located and package version determined. + /// Therefore, package version should not be null in this method. /// Name: no wildcard support. /// Examples: Install "PowerShellGet" /// Install "PowerShellGet" -Version "3.0.0" @@ -403,12 +433,16 @@ public override Stream InstallPackage(string packageName, string packageVersion, Stream results = new MemoryStream(); if (string.IsNullOrEmpty(packageVersion)) { - results = InstallName(packageName, out errRecord); - } - else { - results = InstallVersion(packageName, packageVersion, out errRecord); + errRecord = new ErrorRecord( + exception: new ArgumentNullException($"Package version could not be found for {packageName}"), + "PackageVersionNullOrEmptyError", + ErrorCategory.InvalidArgument, + _cmdletPassedIn); + + return results; } + results = InstallVersion(packageName, packageVersion, out errRecord); return results; } @@ -431,25 +465,25 @@ private string HttpRequestCall(string requestUrl, out ErrorRecord errRecord) catch (HttpRequestException e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestFallFailure", - ErrorCategory.ConnectionError, + exception: e, + "HttpRequestFallFailure", + ErrorCategory.ConnectionError, this); } catch (ArgumentNullException e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestFallFailure", + exception: e, + "HttpRequestFallFailure", ErrorCategory.ConnectionError, this); } catch (InvalidOperationException e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestFallFailure", - ErrorCategory.ConnectionError, + exception: e, + "HttpRequestFallFailure", + ErrorCategory.ConnectionError, this); } @@ -480,25 +514,25 @@ private HttpContent HttpRequestCallForContent(string requestUrl, out ErrorRecord catch (HttpRequestException e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestFailure", - ErrorCategory.ConnectionError , + exception: e, + "HttpRequestFailure", + ErrorCategory.ConnectionError , this); } catch (ArgumentNullException e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestFailure", - ErrorCategory.InvalidData, + exception: e, + "HttpRequestFailure", + ErrorCategory.InvalidData, this); } catch (InvalidOperationException e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestFailure", - ErrorCategory.InvalidOperation, + exception: e, + "HttpRequestFailure", + ErrorCategory.InvalidOperation, this); } @@ -520,9 +554,24 @@ private HttpContent HttpRequestCallForContent(string requestUrl, out ErrorRecord private string FindAllFromEndPoint(bool includePrerelease, int skip, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In NuGetServerAPICalls::FindAllFromEndPoint()"); - string paginationParam = $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=6000"; - var prereleaseFilter = includePrerelease ? "IsAbsoluteLatestVersion&includePrerelease=true" : "IsLatestVersion"; - var requestUrl = $"{Repository.Uri}/Search()?$filter={prereleaseFilter}{paginationParam}"; + + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary { + { "$inlinecount", "allpages" }, + { "$skip", skip.ToString() }, + { "$top", "6000" }, + { "$orderBy", "Id desc" }, + }); + + var filterBuilder = queryBuilder.FilterBuilder; + + if (includePrerelease) { + queryBuilder.AdditionalParameters["includePrerelease"] = "true"; + filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + } else { + filterBuilder.AddCriterion("IsLatestVersion"); + } + + var requestUrl = $"{Repository.Uri}/Search()?{queryBuilder.BuildQueryString()}"; return HttpRequestCall(requestUrl, out errRecord); } @@ -533,16 +582,29 @@ private string FindAllFromEndPoint(bool includePrerelease, int skip, out ErrorRe private string FindTagFromEndpoint(string[] tags, bool includePrerelease, int skip, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In NuGetServerAPICalls::FindTagFromEndpoint()"); - string paginationParam = $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=6000"; - var prereleaseFilter = includePrerelease ? "$filter=IsAbsoluteLatestVersion&includePrerelease=true" : "$filter=IsLatestVersion"; - string tagFilterPart = String.Empty; + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary { + { "$inlinecount", "allpages" }, + { "$skip", skip.ToString() }, + { "$top", "6000" }, + { "$orderBy", "Id desc" }, + }); + + var filterBuilder = queryBuilder.FilterBuilder; + + if (includePrerelease) { + queryBuilder.AdditionalParameters["includePrerelease"] = "true"; + filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + } else { + filterBuilder.AddCriterion("IsLatestVersion"); + } + foreach (string tag in tags) { - tagFilterPart += $" and substringof('{tag}', Tags) eq true"; + filterBuilder.AddCriterion($"substringof('{tag}', Tags) eq true"); } - var requestUrl = $"{Repository.Uri}/Search()?{prereleaseFilter}{tagFilterPart}{paginationParam}"; + var requestUrl = $"{Repository.Uri}/Search()?{queryBuilder.BuildQueryString()}"; return HttpRequestCall(requestUrl, out errRecord); } @@ -556,18 +618,31 @@ private string FindNameGlobbing(string packageName, bool includePrerelease, int // https://www.powershellgallery.com/api/v2/Search()?$filter=endswith(Id, 'Get') and startswith(Id, 'PowerShell') and IsLatestVersion (stable) // https://www.powershellgallery.com/api/v2/Search()?$filter=endswith(Id, 'Get') and IsAbsoluteLatestVersion&includePrerelease=true - string extraParam = $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=100"; - var prerelease = includePrerelease ? "IsAbsoluteLatestVersion&includePrerelease=true" : "IsLatestVersion"; - string nameFilter; + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary { + { "$inlinecount", "allpages" }, + { "$skip", skip.ToString() }, + { "$top", "100" }, + { "$orderBy", "NormalizedVersion desc" }, + }); + + var filterBuilder = queryBuilder.FilterBuilder; + + if (includePrerelease) { + queryBuilder.AdditionalParameters["includePrerelease"] = "true"; + filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + } else { + filterBuilder.AddCriterion("IsLatestVersion"); + } + var names = packageName.Split(new char[] {'*'}, StringSplitOptions.RemoveEmptyEntries); if (names.Length == 0) { errRecord = new ErrorRecord( - new ArgumentException("-Name '*' for NuGet.Server hosted feed repository is not supported"), - "FindNameGlobbingFailure", - ErrorCategory.InvalidArgument, + new ArgumentException("-Name '*' for NuGet.Server hosted feed repository is not supported"), + "FindNameGlobbingFailure", + ErrorCategory.InvalidArgument, this); return string.Empty; @@ -577,17 +652,17 @@ private string FindNameGlobbing(string packageName, bool includePrerelease, int if (packageName.StartsWith("*") && packageName.EndsWith("*")) { // *get* - nameFilter = $"substringof('{names[0]}', Id)"; + filterBuilder.AddCriterion($"substringof('{names[0]}', Id)"); } else if (packageName.EndsWith("*")) { // PowerShell* - nameFilter = $"startswith(Id, '{names[0]}')"; + filterBuilder.AddCriterion($"startswith(Id, '{names[0]}')"); } else { // *ShellGet - nameFilter = $"endswith(Id, '{names[0]}')"; + filterBuilder.AddCriterion($"endswith(Id, '{names[0]}')"); } } else if (names.Length == 2 && !packageName.StartsWith("*") && !packageName.EndsWith("*")) @@ -596,20 +671,20 @@ private string FindNameGlobbing(string packageName, bool includePrerelease, int // pow*get -> only support this // pow*get* // *pow*get - nameFilter = $"startswith(Id, '{names[0]}') and endswith(Id, '{names[1]}')"; + filterBuilder.AddCriterion($"startswith(Id, '{names[0]}') and endswith(Id, '{names[1]}')"); } else { errRecord = new ErrorRecord( - new ArgumentException("-Name with wildcards is only supported for scenarios similar to the following examples: PowerShell*, *ShellGet, *Shell*."), - "FindNameGlobbingFailure", - ErrorCategory.InvalidArgument, + new ArgumentException("-Name with wildcards is only supported for scenarios similar to the following examples: PowerShell*, *ShellGet, *Shell*."), + "FindNameGlobbingFailure", + ErrorCategory.InvalidArgument, this); return string.Empty; } - var requestUrl = $"{Repository.Uri}/Search()?$filter={nameFilter} and {prerelease}{extraParam}"; + var requestUrl = $"{Repository.Uri}/Search()?{queryBuilder.BuildQueryString()}"; return HttpRequestCall(requestUrl, out errRecord); } @@ -623,18 +698,30 @@ private string FindNameGlobbingWithTag(string packageName, string[] tags, bool i // https://www.powershellgallery.com/api/v2/Search()?$filter=endswith(Id, 'Get') and startswith(Id, 'PowerShell') and IsLatestVersion (stable) // https://www.powershellgallery.com/api/v2/Search()?$filter=endswith(Id, 'Get') and IsAbsoluteLatestVersion&includePrerelease=true - string extraParam = $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=100"; - var prerelease = includePrerelease ? "IsAbsoluteLatestVersion&includePrerelease=true" : "IsLatestVersion"; - string nameFilter; + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary { + { "$inlinecount", "allpages" }, + { "$skip", skip.ToString() }, + { "$top", "100" }, + { "$orderBy", "Id desc" }, + }); + + var filterBuilder = queryBuilder.FilterBuilder; + + if (includePrerelease) { + filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + queryBuilder.AdditionalParameters["includePrerelease"] = "true"; + } else { + filterBuilder.AddCriterion("IsLatestVersion"); + } var names = packageName.Split(new char[] {'*'}, StringSplitOptions.RemoveEmptyEntries); if (names.Length == 0) { errRecord = new ErrorRecord( - new ArgumentException("-Name '*' for NuGet.Server hosted feed repository is not supported"), - "FindNameGlobbingFailure", - ErrorCategory.InvalidArgument, + new ArgumentException("-Name '*' for NuGet.Server hosted feed repository is not supported"), + "FindNameGlobbingFailure", + ErrorCategory.InvalidArgument, this); return string.Empty; @@ -644,17 +731,17 @@ private string FindNameGlobbingWithTag(string packageName, string[] tags, bool i if (packageName.StartsWith("*") && packageName.EndsWith("*")) { // *get* - nameFilter = $"substringof('{names[0]}', Id)"; + filterBuilder.AddCriterion($"substringof('{names[0]}', Id)"); } else if (packageName.EndsWith("*")) { // PowerShell* - nameFilter = $"startswith(Id, '{names[0]}')"; + filterBuilder.AddCriterion($"startswith(Id, '{names[0]}')"); } else { // *ShellGet - nameFilter = $"endswith(Id, '{names[0]}')"; + filterBuilder.AddCriterion($"endswith(Id, '{names[0]}')"); } } else if (names.Length == 2 && !packageName.StartsWith("*") && !packageName.EndsWith("*")) @@ -663,26 +750,25 @@ private string FindNameGlobbingWithTag(string packageName, string[] tags, bool i // pow*get -> only support this // pow*get* // *pow*get - nameFilter = $"startswith(Id, '{names[0]}') and endswith(Id, '{names[1]}')"; + filterBuilder.AddCriterion($"startswith(Id, '{names[0]}') and endswith(Id, '{names[1]}')"); } else { errRecord = new ErrorRecord( - new ArgumentException("-Name with wildcards is only supported for scenarios similar to the following examples: PowerShell*, *ShellGet, *Shell*."), - "FindNameGlobbing", - ErrorCategory.InvalidArgument, + new ArgumentException("-Name with wildcards is only supported for scenarios similar to the following examples: PowerShell*, *ShellGet, *Shell*."), + "FindNameGlobbing", + ErrorCategory.InvalidArgument, this); return string.Empty; } - string tagFilterPart = String.Empty; foreach (string tag in tags) { - tagFilterPart += $" and substringof('{tag}', Tags) eq true"; + filterBuilder.AddCriterion($"substringof('{tag}', Tags) eq true"); } - var requestUrl = $"{Repository.Uri}/Search()?$filter={nameFilter}{tagFilterPart} and {prerelease}{extraParam}"; + var requestUrl = $"{Repository.Uri}/Search()?{queryBuilder.BuildQueryString()}"; return HttpRequestCall(requestUrl, out errRecord); } @@ -701,6 +787,16 @@ private string FindVersionGlobbing(string packageName, VersionRange versionRange // Current bug: Find PSGet -Version "2.0.*" -> https://www.powershellgallery.com/api/v2//FindPackagesById()?id='PowerShellGet'&includePrerelease=false&$filter= Version gt '2.0.*' and Version lt '2.1' // Make sure to include quotations around the package name + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary { + { "id", $"'{packageName}'" }, + { "$inlinecount", "allpages" }, + { "$skip", skip.ToString() }, + { "$top", getOnlyLatest ? "1" : "100" }, + { "$orderBy", "NormalizedVersion desc" }, + }); + + var filterBuilder = queryBuilder.FilterBuilder; + //and IsPrerelease eq false // ex: // (2.0.0, 3.0.0) @@ -735,42 +831,23 @@ private string FindVersionGlobbing(string packageName, VersionRange versionRange } string versionFilterParts = String.Empty; - if (!String.IsNullOrEmpty(minPart) && !String.IsNullOrEmpty(maxPart)) + if (!String.IsNullOrEmpty(minPart)) { - versionFilterParts += minPart + " and " + maxPart; + filterBuilder.AddCriterion(minPart); } - else if (!String.IsNullOrEmpty(minPart)) + if (!String.IsNullOrEmpty(maxPart)) { - versionFilterParts += minPart; + filterBuilder.AddCriterion(maxPart); } - else if (!String.IsNullOrEmpty(maxPart)) - { - versionFilterParts += maxPart; - } - - string filterQuery = "&$filter="; - filterQuery += includePrerelease ? string.Empty : "IsPrerelease eq false"; - - string andOperator = " and "; - string joiningOperator = filterQuery.EndsWith("=") ? String.Empty : andOperator; - // We need to explicitly add 'Id eq ' whenever $filter is used, otherwise arbitrary results are returned. - string idFilterPart = $"{joiningOperator}Id eq '{packageName}'"; - filterQuery += idFilterPart; - if (!String.IsNullOrEmpty(versionFilterParts)) - { - // Check if includePrerelease is true, if it is we want to add "$filter" - // Single case where version is "*" (or "[,]") and includePrerelease is true, then we do not want to add "$filter" to the requestUrl. - - // Note: could be null/empty if Version was "*" -> [,] - filterQuery += $"{andOperator}{versionFilterParts}"; + if (!includePrerelease) { + filterBuilder.AddCriterion("IsPrerelease eq false"); } - string topParam = getOnlyLatest ? "$top=1" : "$top=100"; // only need 1 package if interested in latest - string paginationParam = $"$inlinecount=allpages&$skip={skip}&{topParam}"; + // We need to explicitly add 'Id eq ' whenever $filter is used, otherwise arbitrary results are returned. + filterBuilder.AddCriterion($"Id eq '{packageName}'"); - filterQuery = filterQuery.EndsWith("=") ? string.Empty : filterQuery; - var requestUrl = $"{Repository.Uri}/FindPackagesById()?id='{packageName}'&$orderby=NormalizedVersion desc&{paginationParam}{filterQuery}"; + var requestUrl = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; return HttpRequestCall(requestUrl, out errRecord); } @@ -780,16 +857,26 @@ private string FindVersionGlobbing(string packageName, VersionRange versionRange /// Name: no wildcard support. /// Examples: Install "PowerShellGet" /// Implementation Note: {repoUri}/Packages(Id='test_local_mod')/Download - /// if prerelease, call into InstallVersion instead. + /// if prerelease, call into InstallVersion instead. /// private Stream InstallName(string packageName, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In NuGetServerAPICalls::InstallName()"); var requestUrl = $"{Repository.Uri}/Packages/(Id='{packageName}')/Download"; var response = HttpRequestCallForContent(requestUrl, out errRecord); - var responseStream = response.ReadAsStreamAsync().Result; - return responseStream; + if (response is null) + { + errRecord = new ErrorRecord( + new Exception($"No content was returned by repository '{Repository.Name}'"), + "InstallFailureContentNullNuGetServer", + ErrorCategory.InvalidResult, + this); + + return null; + } + + return response.ReadAsStreamAsync().Result; } /// @@ -799,15 +886,25 @@ private Stream InstallName(string packageName, out ErrorRecord errRecord) /// Examples: Install "PowerShellGet" -Version "3.0.0.0" /// Install "PowerShellGet" -Version "3.0.0-beta16" /// API Call: {repoUri}/Packages(Id='Castle.Core',Version='5.1.1')/Download - /// + /// private Stream InstallVersion(string packageName, string version, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In NuGetServerAPICalls::InstallVersion()"); var requestUrl = $"{Repository.Uri}/Packages(Id='{packageName}',Version='{version}')/Download"; var response = HttpRequestCallForContent(requestUrl, out errRecord); - var responseStream = response.ReadAsStreamAsync().Result; - return responseStream; + if (response is null) + { + errRecord = new ErrorRecord( + new Exception($"No content was returned by repository '{Repository.Name}'"), + "InstallFailureContentNullNuGetServer", + ErrorCategory.InvalidResult, + this); + + return null; + } + + return response.ReadAsStreamAsync().Result; } /// @@ -829,9 +926,9 @@ public int GetCountFromResponse(string httpResponse, out ErrorRecord errRecord) catch (XmlException e) { errRecord = new ErrorRecord( - exception: e, - "GetCountFromResponse", - ErrorCategory.InvalidData, + exception: e, + "GetCountFromResponse", + ErrorCategory.InvalidData, this); } if (errRecord != null) @@ -886,7 +983,7 @@ public static async Task SendRequestForContentAsync(HttpRequestMess { HttpResponseMessage response = await s_client.SendAsync(message); response.EnsureSuccessStatusCode(); - + return response.Content; } catch (HttpRequestException e) diff --git a/src/code/PSGetException.cs b/src/code/PSGetException.cs index cbfa57833..8385d1cdf 100644 --- a/src/code/PSGetException.cs +++ b/src/code/PSGetException.cs @@ -68,4 +68,28 @@ public LocalResourceNotFoundException(string message, Exception innerException = { } } + + public class ProcessDependencyException : Exception + { + public ProcessDependencyException(string message, Exception innerException = null) + : base(message) + { + } + } + + public class SendRequestException : Exception + { + public SendRequestException(string message, Exception innerException = null) + : base(message) + { + } + } + + public class UploadBlobException : Exception + { + public UploadBlobException(string message, Exception innerException = null) + : base(message) + { + } + } } diff --git a/src/code/PSRepositoryInfo.cs b/src/code/PSRepositoryInfo.cs index 10bab5baf..b74d52cff 100644 --- a/src/code/PSRepositoryInfo.cs +++ b/src/code/PSRepositoryInfo.cs @@ -11,22 +11,27 @@ namespace Microsoft.PowerShell.PSResourceGet.UtilClasses /// public sealed class PSRepositoryInfo { + #region constants + internal const string MARPrefix = "psresource/"; + #endregion + #region Enums public enum APIVersion { - unknown, - v2, - v3, - local, - nugetServer + Unknown, + V2, + V3, + Local, + NugetServer, + ContainerRegistry } #endregion #region Constructor - public PSRepositoryInfo(string name, Uri uri, int priority, bool trusted, PSCredentialInfo credentialInfo, APIVersion apiVersion) + public PSRepositoryInfo(string name, Uri uri, int priority, bool trusted, PSCredentialInfo credentialInfo, APIVersion apiVersion, bool allowed) { Name = name; Uri = uri; @@ -34,6 +39,18 @@ public PSRepositoryInfo(string name, Uri uri, int priority, bool trusted, PSCred Trusted = trusted; CredentialInfo = credentialInfo; ApiVersion = apiVersion; + IsAllowedByPolicy = allowed; + } + + #endregion + + #region Enum + + public enum RepositoryProviderType + { + None, + ACR, + AzureDevOps } #endregion @@ -61,6 +78,11 @@ public PSRepositoryInfo(string name, Uri uri, int priority, bool trusted, PSCred [ValidateRange(0, 100)] public int Priority { get; } + /// + /// the type of repository provider (eg, AzureDevOps, ContainerRegistry, etc.) + /// + public RepositoryProviderType RepositoryProvider { get; } + /// /// the credential information for repository authentication /// @@ -71,6 +93,20 @@ public PSRepositoryInfo(string name, Uri uri, int priority, bool trusted, PSCred /// public APIVersion ApiVersion { get; } + // + /// is it allowed by policy + /// + public bool IsAllowedByPolicy { get; set; } + + #endregion + + #region Methods + + internal bool IsMARRepository() + { + return (ApiVersion == APIVersion.ContainerRegistry && Uri.Host.Contains("mcr.microsoft.com")); + } + #endregion } } diff --git a/src/code/PSResourceInfo.cs b/src/code/PSResourceInfo.cs index ce217db1a..dd840b62d 100644 --- a/src/code/PSResourceInfo.cs +++ b/src/code/PSResourceInfo.cs @@ -21,7 +21,8 @@ public enum ResourceType { None, Module, - Script + Script, + Nupkg } public enum VersionType @@ -490,76 +491,79 @@ public static bool TryConvertFromXml( { Hashtable metadata = new Hashtable(StringComparer.InvariantCultureIgnoreCase); - var childNodes = entry.ChildNodes; - foreach (XmlElement child in childNodes) + var entryChildNodes = entry.ChildNodes; + foreach (XmlElement entryChild in entryChildNodes) { - var key = child.LocalName; - var value = child.InnerText; + var entryKey = entryChild.LocalName; - if (key.Equals("Title")) + // For repositories such as JFrog's Artifactory, there is no 'Id' property, just 'title' (which contains the name of the pkg). + // However, other repos, like PSGallery include the name of the pkg in the 'Id' property and leave 'title' empty. + // In JFrog's Artifactory, 'title' exists both as a child of the 'entry' node and as a child of the 'properties' node, + // though sometimes 'title' under the 'properties' node can be empty (so default to using the former). + if (entryKey.Equals("title")) { - // For repositories such as JFrog's Artifactory, there is no 'Id' property, just 'Title' (which contains the name of the pkg). - // However, other repos, like PSGallery include the name of the pkg in the 'Id' property and leave 'Title' empty. - - // First check to see that both 'Title' and 'Id' exist in the child nodes. - // If both exist, take 'Id', otherwise just take 'Title'. - bool containsID = false; - foreach (XmlElement childNode in childNodes) + metadata["Id"] = entryChild.InnerText; + } + else if (entryKey.Equals("properties")) + { + var propertyChildNodes = entryChild.ChildNodes; + foreach (XmlElement propertyChild in propertyChildNodes) { - if (childNode.LocalName == "Id") + var propertyKey = propertyChild.LocalName; + var propertyValue = propertyChild.InnerText; + + if (propertyKey.Equals("Title")) { - containsID = true; + if (!metadata.ContainsKey("Id")) + { + metadata["Id"] = propertyValue; + } } - } + if (propertyKey.Equals("Version")) + { + metadata[propertyKey] = ParseHttpVersion(propertyValue, out string prereleaseLabel); + metadata["Prerelease"] = prereleaseLabel; + } + else if (propertyKey.EndsWith("Url")) + { + metadata[propertyKey] = ParseHttpUrl(propertyValue) as Uri; + } + else if (propertyKey.Equals("Tags")) + { + metadata[propertyKey] = propertyValue.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + } + else if (propertyKey.Equals("Published")) + { + metadata[propertyKey] = ParseHttpDateTime(propertyValue); + } + else if (propertyKey.Equals("Dependencies")) + { + metadata[propertyKey] = ParseHttpDependencies(propertyValue); + } + else if (propertyKey.Equals("IsPrerelease")) + { + bool.TryParse(propertyValue, out bool isPrerelease); - if (!containsID) - { - metadata["Id"] = value; - } - } - if (key.Equals("Version")) - { - metadata[key] = ParseHttpVersion(value, out string prereleaseLabel); - metadata["Prerelease"] = prereleaseLabel; - } - else if (key.EndsWith("Url")) - { - metadata[key] = ParseHttpUrl(value) as Uri; - } - else if (key.Equals("Tags")) - { - metadata[key] = value.Split(new char[]{' '}, StringSplitOptions.RemoveEmptyEntries); - } - else if (key.Equals("Published")) - { - metadata[key] = ParseHttpDateTime(value); - } - else if (key.Equals("Dependencies")) - { - metadata[key] = ParseHttpDependencies(value); - } - else if (key.Equals("IsPrerelease")) - { - bool.TryParse(value, out bool isPrerelease); + metadata[propertyKey] = isPrerelease; + } + else if (propertyKey.Equals("NormalizedVersion")) + { + if (!NuGetVersion.TryParse(propertyValue, out NuGetVersion parsedNormalizedVersion)) + { + errorMsg = string.Format( + CultureInfo.InvariantCulture, + @"TryReadPSGetInfo: Cannot parse NormalizedVersion"); - metadata[key] = isPrerelease; - } - else if (key.Equals("NormalizedVersion")) - { - if (!NuGetVersion.TryParse(value, out NuGetVersion parsedNormalizedVersion)) - { - errorMsg = string.Format( - CultureInfo.InvariantCulture, - @"TryReadPSGetInfo: Cannot parse NormalizedVersion"); + parsedNormalizedVersion = new NuGetVersion("1.0.0.0"); + } - parsedNormalizedVersion = new NuGetVersion("1.0.0.0"); + metadata[propertyKey] = parsedNormalizedVersion; + } + else + { + metadata[propertyKey] = propertyValue; + } } - - metadata[key] = parsedNormalizedVersion; - } - else - { - metadata[key] = value; } } @@ -571,7 +575,11 @@ public static bool TryConvertFromXml( }; var additionalMetadataHashtable = new Dictionary(); - additionalMetadataHashtable.Add("NormalizedVersion", metadata["NormalizedVersion"].ToString()); + + // Only add NormalizedVersion to additionalMetadata if server response included it + if (metadata.ContainsKey("NormalizedVersion")) { + additionalMetadataHashtable.Add("NormalizedVersion", metadata["NormalizedVersion"].ToString()); + } var includes = new ResourceIncludes(resourceHashtable); @@ -796,6 +804,286 @@ public static bool TryConvertFromJson( } } + /// + /// Converts ContainerRegistry JsonDocument entry to PSResourceInfo instance + /// used for ContainerRegistry Server API call find response conversion to PSResourceInfo object + /// + public static bool TryConvertFromContainerRegistryJson( + string packageName, + JsonDocument packageMetadata, + ResourceType? resourceType, + out PSResourceInfo psGetInfo, + PSRepositoryInfo repository, + out string errorMsg) + { + psGetInfo = null; + errorMsg = String.Empty; + + if (packageMetadata == null) + { + errorMsg = "TryConvertFromContainerRegistryJson: Invalid json object. Object cannot be null."; + return false; + } + + try + { + Hashtable metadata = new Hashtable(StringComparer.InvariantCultureIgnoreCase); + JsonElement rootDom = packageMetadata.RootElement; + metadata["IsPrerelease"] = false; + metadata["Prerelease"] = String.Empty; + string versionValue = String.Empty; + Version pkgVersion = null; + + // Version + // For scripts (i.e with "Version" property) the version can contain prerelease label + // For nupkg only based packages the .nuspec's metadata attributes will be lowercase + if (rootDom.TryGetProperty("Version", out JsonElement scriptVersionElement) || rootDom.TryGetProperty("version", out scriptVersionElement)) + { + versionValue = scriptVersionElement.ToString(); + pkgVersion = ParseHttpVersion(versionValue, out string prereleaseLabel); + metadata["Version"] = pkgVersion; + metadata["Prerelease"] = prereleaseLabel; + metadata["IsPrerelease"] = !String.IsNullOrEmpty(prereleaseLabel); + } + else if(rootDom.TryGetProperty("ModuleVersion", out JsonElement moduleVersionElement)) + { + // For modules (i.e with "ModuleVersion" property) it will just contain the numerical part not prerelease label, so we must find that from PrivateData.PSData.Prerelease entry + versionValue = moduleVersionElement.ToString(); + pkgVersion = ParseHttpVersion(versionValue, out string prereleaseLabel); + metadata["Version"] = pkgVersion; + + if (rootDom.TryGetProperty("PrivateData", out JsonElement versionPrivateDataElement) && versionPrivateDataElement.TryGetProperty("PSData", out JsonElement versionPSDataElement)) + { + if (versionPSDataElement.TryGetProperty("Prerelease", out JsonElement pkgPrereleaseLabelElement) && !String.IsNullOrEmpty(pkgPrereleaseLabelElement.ToString().Trim())) + { + prereleaseLabel = pkgPrereleaseLabelElement.ToString().Trim(); + versionValue += $"-{prereleaseLabel}"; + metadata["Prerelease"] = prereleaseLabel; + metadata["IsPrerelease"] = true; + } + } + } + else + { + errorMsg = string.Format( + CultureInfo.InvariantCulture, + @"TryConvertFromContainerRegistryJson: Neither 'ModuleVersion' nor 'Version' could be found in package metadata"); + + return false; + } + + if (!NuGetVersion.TryParse(versionValue, out NuGetVersion parsedNormalizedVersion) && pkgVersion == null) + { + errorMsg = string.Format( + CultureInfo.InvariantCulture, + @"TryConvertFromContainerRegistryJson: Cannot parse NormalizedVersion or System.Version from version in metadata."); + + return false; + } + + metadata["NormalizedVersion"] = parsedNormalizedVersion.ToNormalizedString(); + + // License Url + if (rootDom.TryGetProperty("LicenseUrl", out JsonElement licenseUrlElement) || rootDom.TryGetProperty("licenseUrl", out licenseUrlElement)) + { + metadata["LicenseUrl"] = ParseHttpUrl(licenseUrlElement.ToString()) as Uri; + } + + // Project Url + if (rootDom.TryGetProperty("ProjectUrl", out JsonElement projectUrlElement) || rootDom.TryGetProperty("projectUrl", out projectUrlElement)) + { + metadata["ProjectUrl"] = ParseHttpUrl(projectUrlElement.ToString()) as Uri; + } + + // Icon Url + if (rootDom.TryGetProperty("IconUrl", out JsonElement iconUrlElement) || rootDom.TryGetProperty("iconUrl", out iconUrlElement)) + { + metadata["IconUrl"] = ParseHttpUrl(iconUrlElement.ToString()) as Uri; + } + + // Tags + if (rootDom.TryGetProperty("Tags", out JsonElement tagsElement) || rootDom.TryGetProperty("tags", out tagsElement)) + { + string[] pkgTags = Utils.EmptyStrArray; + if (tagsElement.ValueKind == JsonValueKind.Array) + { + var arrayLength = tagsElement.GetArrayLength(); + List tags = new List(arrayLength); + foreach (var tag in tagsElement.EnumerateArray()) + { + tags.Add(tag.ToString()); + } + + pkgTags = tags.ToArray(); + } + else if (tagsElement.ValueKind == JsonValueKind.String) + { + string tagStr = tagsElement.ToString(); + pkgTags = tagStr.Split(Utils.WhitespaceSeparator, StringSplitOptions.RemoveEmptyEntries); + } + + metadata["Tags"] = pkgTags; + } + + // PublishedDate + if (rootDom.TryGetProperty("Published", out JsonElement publishedElement)) + { + metadata["PublishedDate"] = ParseHttpDateTime(publishedElement.ToString()); + } + + // IsPrerelease + if (rootDom.TryGetProperty("IsPrerelease", out JsonElement isPrereleaseElement)) + { + metadata["IsPrerelease"] = isPrereleaseElement.GetBoolean(); + } + + // Author + if (rootDom.TryGetProperty("Authors", out JsonElement authorsElement) || rootDom.TryGetProperty("authors", out authorsElement) || rootDom.TryGetProperty("Author", out authorsElement)) + { + metadata["Authors"] = authorsElement.ToString(); + } + + if (rootDom.TryGetProperty("CompanyName", out JsonElement companyNameElement)) + { + metadata["CompanyName"] = companyNameElement.ToString(); + } + else + { + // if CompanyName property is not provided set it to the Author value which is often the same. + metadata["CompanyName"] = metadata["Authors"]; + } + + // Copyright + if (rootDom.TryGetProperty("Copyright", out JsonElement copyrightElement) || rootDom.TryGetProperty("copyright", out copyrightElement)) + { + metadata["Copyright"] = copyrightElement.ToString(); + } + + // Description + if (rootDom.TryGetProperty("Description", out JsonElement descriptiontElement) || rootDom.TryGetProperty("description", out descriptiontElement)) + { + metadata["Description"] = descriptiontElement.ToString(); + } + + // ReleaseNotes + if (rootDom.TryGetProperty("ReleaseNotes", out JsonElement releaseNotesElement) || rootDom.TryGetProperty("releaseNotes", out releaseNotesElement)) + { + metadata["ReleaseNotes"] = releaseNotesElement.ToString(); + } + + // Dependencies + if (rootDom.TryGetProperty("RequiredModules", out JsonElement requiredModulesElement)) + { + metadata["Dependencies"] = ParseContainerRegistryDependencies(requiredModulesElement, out errorMsg).ToArray(); + } + + if (string.Equals(packageName, "Az", StringComparison.OrdinalIgnoreCase) || string.Equals(packageName, "Azpreview", StringComparison.OrdinalIgnoreCase) || packageName.StartsWith("Az.", StringComparison.OrdinalIgnoreCase)) + { + if (rootDom.TryGetProperty("ModuleList", out JsonElement moduleListDepsElement)) + { + metadata["Dependencies"] = ParseContainerRegistryDependencies(moduleListDepsElement, out errorMsg).ToArray(); + } + else if (rootDom.TryGetProperty("PrivateData", out JsonElement depsPrivateDataElement) && depsPrivateDataElement.TryGetProperty("PSData", out JsonElement depsPSDataElement)) + { + if (depsPSDataElement.TryGetProperty("ModuleList", out JsonElement privateDataModuleListDepsElement)) + { + metadata["Dependencies"] = ParseContainerRegistryDependencies(privateDataModuleListDepsElement, out errorMsg).ToArray(); + } + } + } + + if (rootDom.TryGetProperty("PrivateData", out JsonElement privateDataElement) && privateDataElement.ValueKind == JsonValueKind.Object && privateDataElement.TryGetProperty("PSData", out JsonElement psDataElement)) + { + // some properties that may be in PrivateData.PSData: LicenseUri, ProjectUri, IconUri, ReleaseNotes + if (!metadata.ContainsKey("LicenseUrl") && psDataElement.TryGetProperty("LicenseUri", out JsonElement psDataLicenseUriElement)) + { + metadata["LicenseUrl"] = ParseHttpUrl(psDataLicenseUriElement.ToString()) as Uri; + } + + if (!metadata.ContainsKey("ProjectUrl") && psDataElement.TryGetProperty("ProjectUri", out JsonElement psDataProjectUriElement)) + { + metadata["ProjectUrl"] = ParseHttpUrl(psDataProjectUriElement.ToString()) as Uri; + } + + if (!metadata.ContainsKey("IconUrl") && psDataElement.TryGetProperty("IconUri", out JsonElement psDataIconUriElement)) + { + metadata["IconUrl"] = ParseHttpUrl(psDataIconUriElement.ToString()) as Uri; + } + + if (!metadata.ContainsKey("ReleaseNotes") && psDataElement.TryGetProperty("ReleaseNotes", out JsonElement psDataReleaseNotesElement)) + { + metadata["ReleaseNotes"] = psDataReleaseNotesElement.ToString(); + } + + if (!metadata.ContainsKey("Tags") && psDataElement.TryGetProperty("Tags", out JsonElement psDataTagsElement)) + { + string[] pkgTags = Utils.EmptyStrArray; + if (psDataTagsElement.ValueKind == JsonValueKind.Array) + { + var arrayLength = psDataTagsElement.GetArrayLength(); + List tags = new List(arrayLength); + foreach (var tag in psDataTagsElement.EnumerateArray()) + { + tags.Add(tag.ToString()); + } + + pkgTags = tags.ToArray(); + } + else if (psDataTagsElement.ValueKind == JsonValueKind.String) + { + string tagStr = psDataTagsElement.ToString(); + pkgTags = tagStr.Split(Utils.WhitespaceSeparator, StringSplitOptions.RemoveEmptyEntries); + } + + metadata["Tags"] = pkgTags; + } + } + + var additionalMetadataHashtable = new Dictionary + { + { "NormalizedVersion", metadata["NormalizedVersion"].ToString() } + }; + + psGetInfo = new PSResourceInfo( + additionalMetadata: additionalMetadataHashtable, + author: metadata["Authors"] as String, + companyName: metadata["CompanyName"] as String, + copyright: metadata["Copyright"] as String, + dependencies: metadata["Dependencies"] as Dependency[], + description: metadata["Description"] as String, + iconUri: null, + includes: null, + installedDate: null, + installedLocation: null, + isPrerelease: (bool)metadata["IsPrerelease"], + licenseUri: metadata["LicenseUrl"] as Uri, + name: packageName, + powershellGetFormatVersion: null, + prerelease: metadata["Prerelease"] as String, + projectUri: metadata["ProjectUrl"] as Uri, + publishedDate: metadata["PublishedDate"] as DateTime?, + releaseNotes: metadata["ReleaseNotes"] as String, + repository: repository.Name, + repositorySourceLocation: repository.Uri.ToString(), + tags: metadata["Tags"] as string[], + type: resourceType ?? ResourceType.None, + updatedDate: null, + version: metadata["Version"] as Version); + + return true; + + } + catch (Exception ex) + { + errorMsg = string.Format( + CultureInfo.InvariantCulture, + @"TryConvertFromContainerRegistryJson: Cannot parse PSResourceInfo from json object with error: {0}", + ex.Message); + + return false; + } + } + public static bool TryConvertFromHashtableForPsd1( Hashtable pkgMetadata, out PSResourceInfo psGetInfo, @@ -1249,7 +1537,7 @@ private static Version ParseHttpVersion(string versionString, out string prerele return new System.Version(); } - public static Uri ParseHttpUrl(string uriString) + internal static Uri ParseHttpUrl(string uriString) { Uri parsedUri; Uri.TryCreate(uriString, UriKind.Absolute, out parsedUri); @@ -1257,13 +1545,13 @@ public static Uri ParseHttpUrl(string uriString) return parsedUri; } - public static DateTime? ParseHttpDateTime(string publishedString) + internal static DateTime? ParseHttpDateTime(string publishedString) { DateTime.TryParse(publishedString, out DateTime parsedDateTime); return parsedDateTime; } - public static Dependency[] ParseHttpDependencies(string dependencyString) + internal static Dependency[] ParseHttpDependencies(string dependencyString) { /* Az.Profile:[0.1.0, ):|Az.Aks:[0.1.0, ):|Az.AnalysisServices:[0.1.0, ): @@ -1300,6 +1588,71 @@ public static Dependency[] ParseHttpDependencies(string dependencyString) return dependencyList.ToArray(); } + internal static List ParseContainerRegistryDependencies(JsonElement requiredModulesElement, out string errorMsg) + { + errorMsg = string.Empty; + List pkgDeps = new List(); + if (requiredModulesElement.ValueKind == JsonValueKind.Array) + { + foreach (var dependency in requiredModulesElement.EnumerateArray()) + { + if (dependency.ValueKind == JsonValueKind.String) + { + // Dependency name with no specified version + pkgDeps.Add(new Dependency(dependency.GetString(), VersionRange.All)); + } + else if (dependency.ValueKind == JsonValueKind.Object) + { + // Dependency hashtable + string depName = string.Empty; + VersionRange depVersionRange = VersionRange.All; + if (dependency.TryGetProperty("ModuleName", out JsonElement depNameElement)) + { + depName = depNameElement.ToString(); + } + + if (dependency.TryGetProperty("ModuleVersion", out JsonElement depModuleVersionElement)) + { + // New-ScriptFileInfo will add "RequiredVersion" value as "null" if nothing is explicitly passed in + if (!NuGetVersion.TryParse(depModuleVersionElement.ToString(), out NuGetVersion depNuGetVersion)) + { + errorMsg = string.Format("Error parsing 'ModuleVersion' property from 'RequiredModules' in metadata."); + return pkgDeps; + } + + depVersionRange = new VersionRange( + minVersion: depNuGetVersion, + includeMinVersion: true); + } + else if (dependency.TryGetProperty("RequiredVersion", out JsonElement depRequiredVersionElement)) + { + // New-ScriptFileInfo will add "RequiredVersion" value as "null" if nothing is explicitly passed in, + // Which gets translated to an empty string. + // In this case, we just want the VersionRange to be VersionRange.All + if (!string.Equals(depModuleVersionElement.ToString(), string.Empty)) + { + if (!NuGetVersion.TryParse(depRequiredVersionElement.ToString(), out NuGetVersion depNuGetVersion)) + { + errorMsg = string.Format("Error parsing 'RequiredVersion' property from 'RequiredModules' in metadata."); + return pkgDeps; + } + + depVersionRange = new VersionRange( + minVersion: depNuGetVersion, + includeMinVersion: true, + maxVersion: depNuGetVersion, + includeMaxVersion: true); + } + } + + pkgDeps.Add(new Dependency(depName, depVersionRange)); + } + } + } + + return pkgDeps; + } + private static ResourceType ParseHttpMetadataType( string[] tags, out ArrayList commandNames, diff --git a/src/code/PSScriptFileInfo.cs b/src/code/PSScriptFileInfo.cs index 87d1bb1c5..d71c34e78 100644 --- a/src/code/PSScriptFileInfo.cs +++ b/src/code/PSScriptFileInfo.cs @@ -127,7 +127,7 @@ internal static bool TryParseScriptFileContents( { string line = fileContents[i]; - if (line.StartsWith("<#PSScriptInfo")) + if (line.Trim().StartsWith("<#PSScriptInfo")) { int j = i + 1; // start at the next line // keep grabbing lines until we get to closing #> @@ -135,7 +135,7 @@ internal static bool TryParseScriptFileContents( { string blockLine = fileContents[j]; psScriptInfoCommentContent.Add(blockLine); - if (blockLine.StartsWith("#>")) + if (blockLine.Trim().StartsWith("#>")) { reachedPSScriptInfoCommentEnd = true; @@ -157,7 +157,7 @@ internal static bool TryParseScriptFileContents( return false; } } - else if (line.StartsWith("<#")) + else if (line.Trim().StartsWith("<#")) { // The next comment block must be the help comment block (containing description) // keep grabbing lines until we get to closing #> @@ -166,7 +166,7 @@ internal static bool TryParseScriptFileContents( { string blockLine = fileContents[j]; - if (blockLine.StartsWith("#>")) + if (blockLine.Trim().StartsWith("#>")) { reachedHelpInfoCommentEnd = true; i = j + 1; diff --git a/src/code/PublishHelper.cs b/src/code/PublishHelper.cs new file mode 100644 index 000000000..4cbfb0f4a --- /dev/null +++ b/src/code/PublishHelper.cs @@ -0,0 +1,1486 @@ +using Microsoft.PowerShell.PSResourceGet.UtilClasses; +using NuGet.Commands; +using NuGet.Common; +using NuGet.Configuration; +using NuGet.Packaging; +using NuGet.Versioning; +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Management.Automation; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Xml; + +namespace Microsoft.PowerShell.PSResourceGet.Cmdlets +{ + internal class PublishHelper + { + #region Enums + internal enum CallerCmdlet + { + PublishPSResource, + CompressPSResource + } + + #endregion + + #region Members + + private readonly CallerCmdlet _callerCmdlet; + private readonly PSCmdlet _cmdletPassedIn; + private readonly string _cmdOperation; + private readonly string Path; + private string DestinationPath; + private string resolvedPath; + private CancellationToken _cancellationToken; + private NuGetVersion _pkgVersion; + private string _pkgName; + private static char[] _PathSeparators = new[] { System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar }; + public const string PSDataFileExt = ".psd1"; + public const string PSScriptFileExt = ".ps1"; + public const string NupkgFileExt = ".nupkg"; + private const string PSScriptInfoCommentString = "<#PSScriptInfo"; + private string pathToScriptFileToPublish = string.Empty; + private string pathToModuleManifestToPublish = string.Empty; + private string pathToModuleDirToPublish = string.Empty; + private string pathToNupkgToPublish = string.Empty; + private ResourceType resourceType = ResourceType.None; + private NetworkCredential _networkCredential; + string userAgentString = UserAgentInfo.UserAgentString(); + private bool _isNupkgPathSpecified = false; + private Hashtable dependencies; + private Hashtable parsedMetadata; + private PSCredential Credential; + private string outputNupkgDir; + private string ApiKey; + private bool SkipModuleManifestValidate = false; + private string outputDir = string.Empty; + internal bool ScriptError = false; + internal bool ShouldProcess = true; + internal bool PassThru = false; + + #endregion + + #region Constructors + + internal PublishHelper(PSCmdlet cmdlet, string path, string destinationPath, bool passThru, bool skipModuleManifestValidate) + { + _callerCmdlet = CallerCmdlet.CompressPSResource; + _cmdOperation = "Compress"; + _cmdletPassedIn = cmdlet; + Path = path; + DestinationPath = destinationPath; + PassThru = passThru; + SkipModuleManifestValidate = skipModuleManifestValidate; + outputDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString()); + outputNupkgDir = destinationPath; + } + + internal PublishHelper(PSCmdlet cmdlet, + PSCredential credential, + string apiKey, + string path, + string destinationPath, + bool skipModuleManifestValidate, + CancellationToken cancellationToken, + bool isNupkgPathSpecified) + { + _callerCmdlet = CallerCmdlet.PublishPSResource; + _cmdOperation = "Publish"; + _cmdletPassedIn = cmdlet; + Credential = credential; + ApiKey = apiKey; + Path = path; + DestinationPath = destinationPath; + SkipModuleManifestValidate = skipModuleManifestValidate; + _cancellationToken = cancellationToken; + _isNupkgPathSpecified = isNupkgPathSpecified; + outputDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString()); + outputNupkgDir = System.IO.Path.Combine(outputDir, "nupkg"); + } + + #endregion + + #region Internal Methods + + internal void PackResource() + { + // Returns the name of the file or the name of the directory, depending on path + if (!_cmdletPassedIn.ShouldProcess(string.Format("'{0}' from the machine", resolvedPath))) + { + _cmdletPassedIn.WriteVerbose("ShouldProcess is set to false."); + ShouldProcess = false; + return; + } + + parsedMetadata = new Hashtable(StringComparer.OrdinalIgnoreCase); + if (resourceType == ResourceType.Script) + { + if (!PSScriptFileInfo.TryTestPSScriptFileInfo( + scriptFileInfoPath: pathToScriptFileToPublish, + parsedScript: out PSScriptFileInfo scriptToPublish, + out ErrorRecord[] errors, + out string[] _ + )) + { + foreach (ErrorRecord error in errors) + { + _cmdletPassedIn.WriteError(error); + } + + ScriptError = true; + + return; + } + + parsedMetadata = scriptToPublish.ToHashtable(); + + _pkgName = System.IO.Path.GetFileNameWithoutExtension(pathToScriptFileToPublish); + } + else + { + if (!string.IsNullOrEmpty(pathToModuleManifestToPublish)) + { + _pkgName = System.IO.Path.GetFileNameWithoutExtension(pathToModuleManifestToPublish); + } + else + { + // Search for module manifest + foreach (FileInfo file in new DirectoryInfo(pathToModuleDirToPublish).EnumerateFiles()) + { + if (file.Name.EndsWith(PSDataFileExt, StringComparison.OrdinalIgnoreCase)) + { + pathToModuleManifestToPublish = file.FullName; + _pkgName = System.IO.Path.GetFileNameWithoutExtension(file.Name); + + break; + } + } + } + + // Validate that there's a module manifest + if (!File.Exists(pathToModuleManifestToPublish)) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new ArgumentException($"No file with a .psd1 extension was found in '{pathToModuleManifestToPublish}'. Please specify a path to a valid module manifest."), + "moduleManifestNotFound", + ErrorCategory.ObjectNotFound, + this)); + + return; + } + + // The Test-ModuleManifest currently cannot process UNC paths. Disabling verification for now. + if ((new Uri(pathToModuleManifestToPublish)).IsUnc) + SkipModuleManifestValidate = true; + + // Validate that the module manifest has correct data + if (!SkipModuleManifestValidate && + !Utils.ValidateModuleManifest(pathToModuleManifestToPublish, out string errorMsg)) + { + _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( + new PSInvalidOperationException(errorMsg), + "InvalidModuleManifest", + ErrorCategory.InvalidOperation, + this)); + } + + if (!Utils.TryReadManifestFile( + manifestFilePath: pathToModuleManifestToPublish, + manifestInfo: out parsedMetadata, + error: out Exception manifestReadError)) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + manifestReadError, + "ManifestFileReadParseForContainerRegistryPublishError", + ErrorCategory.ReadError, + this)); + + return; + } + + } + + // Create a temp folder to push the nupkg to and delete it later + try + { + Directory.CreateDirectory(outputDir); + } + catch (Exception e) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new ArgumentException(e.Message), + "ErrorCreatingTempDir", + ErrorCategory.InvalidData, + this)); + + return; + } + + try + { + string nuspec = string.Empty; + + // Create a nuspec + try + { + nuspec = CreateNuspec( + outputDir: outputDir, + filePath: (resourceType == ResourceType.Script) ? pathToScriptFileToPublish : pathToModuleManifestToPublish, + parsedMetadataHash: parsedMetadata, + requiredModules: out dependencies); + } + catch (Exception e) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new ArgumentException($"Nuspec creation failed: {e.Message}"), + "NuspecCreationFailed", + ErrorCategory.ObjectNotFound, + this)); + + return; + } + + if (string.IsNullOrEmpty(nuspec)) + { + // nuspec creation failed. + _cmdletPassedIn.WriteVerbose("Nuspec creation failed."); + return; + } + + if (resourceType == ResourceType.Script) + { + // copy the script file to the temp directory + File.Copy(pathToScriptFileToPublish, System.IO.Path.Combine(outputDir, _pkgName + PSScriptFileExt), true); + } + else + { + try + { + // If path is pointing to a file, get the parent directory, otherwise assumption is that path is pointing to the root directory + string rootModuleDir = !string.IsNullOrEmpty(pathToModuleManifestToPublish) ? System.IO.Path.GetDirectoryName(pathToModuleManifestToPublish) : pathToModuleDirToPublish; + + // Create subdirectory structure in temp folder + foreach (string dir in Directory.GetDirectories(rootModuleDir, "*", SearchOption.AllDirectories)) + { + var dirName = dir.Substring(rootModuleDir.Length).Trim(_PathSeparators); + Directory.CreateDirectory(System.IO.Path.Combine(outputDir, dirName)); + } + + // Copy files over to temp folder + foreach (string fileNamePath in Directory.GetFiles(rootModuleDir, "*", SearchOption.AllDirectories)) + { + var fileName = fileNamePath.Substring(rootModuleDir.Length).Trim(_PathSeparators); + var newFilePath = System.IO.Path.Combine(outputDir, fileName); + + // The user may have a .nuspec defined in the module directory + // If that's the case, we will not use that file and use the .nuspec that is generated via PSGet + // The .nuspec that is already in in the output directory is the one that was generated via the CreateNuspec method + if (!File.Exists(newFilePath)) + { + File.Copy(fileNamePath, newFilePath); + } + } + } + catch (Exception e) + { + _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( + new ArgumentException("Error occured while creating directory to publish: " + e.Message), + "ErrorCreatingDirectoryToPublish", + ErrorCategory.InvalidOperation, + this)); + } + } + + if (_callerCmdlet == CallerCmdlet.CompressPSResource) + { + outputNupkgDir = DestinationPath; + } + + // pack into .nupkg + if (!PackNupkg(outputDir, outputNupkgDir, nuspec, out ErrorRecord packNupkgError)) + { + _cmdletPassedIn.WriteError(packNupkgError); + // exit out of processing + return; + } + } + catch (Exception e) + { + _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( + e, + $"{this.GetType()}Error", + ErrorCategory.NotSpecified, + this)); + } + finally + { + if(_callerCmdlet == CallerCmdlet.CompressPSResource) + { + _cmdletPassedIn.WriteVerbose(string.Format("Deleting temporary directory '{0}'", outputDir)); + Utils.DeleteDirectory(outputDir); + } + } + } + + internal void PushResource(string Repository, string modulePrefix, bool SkipDependenciesCheck, NetworkCredential _networkCrendential) + { + try + { + PSRepositoryInfo repository = RepositorySettings.Read(new[] { Repository }, out _).FirstOrDefault(); + // Find repository + if (repository == null) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new ArgumentException($"The resource repository '{Repository}' is not a registered. Please run 'Register-PSResourceRepository' in order to publish to this repository."), + "RepositoryNotFound", + ErrorCategory.ObjectNotFound, + this)); + + return; + } + else if (repository.Uri.Scheme == Uri.UriSchemeFile && !repository.Uri.IsUnc && !Directory.Exists(repository.Uri.LocalPath)) + { + // this check to ensure valid local path is not for UNC paths (which are server based, instead of Drive based) + _cmdletPassedIn.WriteError(new ErrorRecord( + new ArgumentException($"The repository '{repository.Name}' with uri: '{repository.Uri.AbsoluteUri}' is not a valid folder path which exists. If providing a file based repository, provide a repository with a path that exists."), + "repositoryPathDoesNotExist", + ErrorCategory.ObjectNotFound, + this)); + + return; + } + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(repository.Uri); + + if (!isAllowed) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new PSInvalidOperationException($"Repository '{repository.Name}' is not allowed by Group Policy."), + "RepositoryNotAllowedByGroupPolicy", + ErrorCategory.PermissionDenied, + this)); + + return; + } + + if (repository.IsMARRepository()) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new PSInvalidOperationException($"Repository '{repository.Name}' is a MAR repository and cannot be published to."), + "MARRepositoryPublishError", + ErrorCategory.PermissionDenied, + this)); + + return; + } + + _networkCredential = Utils.SetNetworkCredential(repository, _networkCredential, _cmdletPassedIn); + + // Check if dependencies already exist within the repo if: + // 1) the resource to publish has dependencies and + // 2) the -SkipDependenciesCheck flag is not passed in + if (dependencies != null && !SkipDependenciesCheck) + { + // If error gets thrown, exit process record + if (!CheckDependenciesExist(dependencies, repository.Name)) + { + return; + } + } + + // If -DestinationPath is specified then also publish the .nupkg there + if (!string.IsNullOrWhiteSpace(DestinationPath)) + { + if (!Directory.Exists(DestinationPath)) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new ArgumentException($"Destination path does not exist: '{DestinationPath}'"), + "InvalidDestinationPath", + ErrorCategory.InvalidArgument, + this)); + + return; + } + + if (!_isNupkgPathSpecified) + { + try + { + var nupkgName = _pkgName + "." + _pkgVersion.ToNormalizedString() + ".nupkg"; + var sourceFilePath = System.IO.Path.Combine(outputNupkgDir, nupkgName); + var destinationFilePath = System.IO.Path.Combine(DestinationPath, nupkgName); + + if (!File.Exists(destinationFilePath)) + { + File.Copy(sourceFilePath, destinationFilePath); + } + } + catch (Exception e) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new ArgumentException($"Error moving .nupkg into destination path '{DestinationPath}' due to: '{e.Message}'."), + "ErrorMovingNupkg", + ErrorCategory.NotSpecified, + this)); + + // exit process record + return; + } + } + } + + string repositoryUri = repository.Uri.AbsoluteUri; + + if (repository.ApiVersion == PSRepositoryInfo.APIVersion.ContainerRegistry) + { + ContainerRegistryServerAPICalls containerRegistryServer = new ContainerRegistryServerAPICalls(repository, _cmdletPassedIn, _networkCredential, userAgentString); + + if (_isNupkgPathSpecified) + { + // copy the .nupkg to a temp path (outputNupkgDir field) as we don't want to tamper with the original, possibly signed, .nupkg file + string copiedNupkgFilePath = CopyNupkgFileToTempPath(nupkgFilePath: Path, errRecord: out ErrorRecord copyErrRecord); + if (copyErrRecord != null) + { + _cmdletPassedIn.WriteError(copyErrRecord); + return; + } + + // get package info (name, version, metadata hashtable) from the copied .nupkg package and then populate appropriate fields (_pkgName, _pkgVersion, parsedMetadata) + GetPackageInfoFromNupkg(nupkgFilePath: copiedNupkgFilePath, errRecord: out ErrorRecord pkgInfoErrRecord); + if (pkgInfoErrRecord != null) + { + _cmdletPassedIn.WriteError(pkgInfoErrRecord); + return; + } + } + + if (!containerRegistryServer.PushNupkgContainerRegistry(outputNupkgDir, _pkgName, modulePrefix, _pkgVersion, resourceType, parsedMetadata, dependencies, _isNupkgPathSpecified, Path, out ErrorRecord pushNupkgContainerRegistryError)) + { + _cmdletPassedIn.WriteError(pushNupkgContainerRegistryError); + return; + } + } + else + { + if(_isNupkgPathSpecified) + { + outputNupkgDir = pathToNupkgToPublish; + } + + // This call does not throw any exceptions, but it will write unsuccessful responses to the console + if (!PushNupkg(outputNupkgDir, repository.Name, repository.Uri.ToString(), out ErrorRecord pushNupkgError)) + { + _cmdletPassedIn.WriteError(pushNupkgError); + // exit out of processing + return; + } + } + } + catch (Exception e) + { + _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( + e, + "PublishPSResourceError", + ErrorCategory.NotSpecified, + this)); + } + finally + { + // For scenarios such as Publish-PSResource -NupkgPath -Repository , the outputNupkgDir will be set to NupkgPath path, and a temp outputDir folder will not have been created and thus doesn't need to attempt to be deleted + if (Directory.Exists(outputDir)) + { + _cmdletPassedIn.WriteVerbose(string.Format("Deleting temporary directory '{0}'", outputDir)); + Utils.DeleteDirectory(outputDir); + } + } + } + + internal void CheckAllParameterPaths() + { + try + { + resolvedPath = _cmdletPassedIn.GetResolvedProviderPathFromPSPath(Path, out ProviderInfo provider).First(); + } + catch (MethodInvocationException) + { + // path does not exist + _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( + new ArgumentException($"The path to the resource to {_cmdOperation.ToLower()} does not exist, point to an existing path or file of the module or script to {_cmdOperation.ToLower()}."), + "SourcePathDoesNotExist", + ErrorCategory.InvalidArgument, + this)); + } + + // Condition 1: path is to the root directory of the module to be published + // Condition 2: path is to the .psd1 or .ps1 of the module/script to be published + if (string.IsNullOrEmpty(resolvedPath)) + { + // unsupported file path + _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( + new ArgumentException($"The path to the resource to {_cmdOperation.ToLower()} is not in the correct format or does not exist. Please provide the path of the root module " + + $"(i.e. './/') or the path to the .psd1 (i.e. './/.psd1')."), + $"Invalid{_cmdOperation}Path", + ErrorCategory.InvalidArgument, + this)); + } + else if (Directory.Exists(resolvedPath)) + { + pathToModuleDirToPublish = resolvedPath; + resourceType = ResourceType.Module; + } + else if (resolvedPath.EndsWith(PSDataFileExt, StringComparison.OrdinalIgnoreCase)) + { + pathToModuleManifestToPublish = resolvedPath; + resourceType = ResourceType.Module; + } + else if (resolvedPath.EndsWith(PSScriptFileExt, StringComparison.OrdinalIgnoreCase)) + { + pathToScriptFileToPublish = resolvedPath; + resourceType = ResourceType.Script; + } + else if (resolvedPath.EndsWith(NupkgFileExt, StringComparison.OrdinalIgnoreCase) && _isNupkgPathSpecified) + { + pathToNupkgToPublish = resolvedPath; + resourceType = ResourceType.Nupkg; + } + else + { + _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( + new ArgumentException($"The {_cmdOperation.ToLower()} path provided, '{resolvedPath}', is not a valid. Please provide a path to the root module " + + $"(i.e. './/') or path to the .psd1 (i.e. './/.psd1')."), + $"Invalid{_cmdOperation}Path", + ErrorCategory.InvalidArgument, + this)); + } + + if (!String.IsNullOrEmpty(DestinationPath)) + { + string resolvedDestinationPath = _cmdletPassedIn.GetResolvedProviderPathFromPSPath(DestinationPath, out ProviderInfo provider).First(); + + if (Directory.Exists(resolvedDestinationPath)) + { + DestinationPath = resolvedDestinationPath; + } + else + { + try + { + Directory.CreateDirectory(resolvedDestinationPath); + } + catch (Exception e) + { + _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( + new ArgumentException($"Destination path does not exist and cannot be created: {e.Message}"), + "InvalidDestinationPath", + ErrorCategory.InvalidArgument, + this)); + } + } + } + } + + #endregion + + #region Private Methods + + private bool PackNupkg(string outputDir, string outputNupkgDir, string nuspecFile, out ErrorRecord error) + { + _cmdletPassedIn.WriteDebug("In PublishHelper::PackNupkg()"); + // Pack the module or script into a nupkg given a nuspec. + var builder = new PackageBuilder(); + try + { + var runner = new PackCommandRunner( + new PackArgs + { + CurrentDirectory = outputDir, + OutputDirectory = outputNupkgDir, + Path = nuspecFile, + Exclude = System.Array.Empty(), + Symbols = false, + Logger = NullLogger.Instance + }, + MSBuildProjectFactory.ProjectCreator, + builder); + bool success = runner.RunPackageBuild(); + + if (success) + { + if (PassThru) + { + var nupkgPath = System.IO.Path.Combine(outputNupkgDir, _pkgName + "." + _pkgVersion.ToNormalizedString() + ".nupkg"); + _cmdletPassedIn.WriteObject(new FileInfo(nupkgPath)); + } + _cmdletPassedIn.WriteVerbose("Successfully packed the resource into a .nupkg"); + } + else + { + error = new ErrorRecord( + new InvalidOperationException("Not able to successfully pack the resource into a .nupkg"), + "failedToPackIntoNupkg", + ErrorCategory.ObjectNotFound, + this); + + return false; + } + } + catch (Exception e) + { + error = new ErrorRecord( + new ArgumentException($"Unexpected error packing into .nupkg: '{e.Message}'."), + "ErrorPackingIntoNupkg", + ErrorCategory.NotSpecified, + this); + + // exit process record + return false; + } + + error = null; + return true; + } + + private bool PushNupkg(string outputNupkgDir, string repoName, string repoUri, out ErrorRecord error) + { + _cmdletPassedIn.WriteDebug("In PublishPSResource::PushNupkg()"); + + string fullNupkgFile; + if (_isNupkgPathSpecified) + { + fullNupkgFile = outputNupkgDir; + } + else + { + // Push the nupkg to the appropriate repository + // Pkg version is parsed from .ps1 file or .psd1 file + fullNupkgFile = System.IO.Path.Combine(outputNupkgDir, _pkgName + "." + _pkgVersion.ToNormalizedString() + ".nupkg"); + } + + // The PSGallery uses the v2 protocol still and publishes to a slightly different endpoint: + // "https://www.powershellgallery.com/api/v2/package" + // Until the PSGallery is moved onto the NuGet v3 server protocol, we'll modify the repository uri + // to accommodate for the approprate publish location. + string publishLocation = repoUri.EndsWith("/v2", StringComparison.OrdinalIgnoreCase) ? repoUri + "/package" : repoUri; + + var settings = NuGet.Configuration.Settings.LoadDefaultSettings(null, null, null); + var success = false; + + var sourceProvider = new PackageSourceProvider(settings); + if (Credential != null || _networkCredential != null) + { + InjectCredentialsToSettings(settings, sourceProvider, publishLocation); + } + + + try + { + PushRunner.Run( + settings: Settings.LoadDefaultSettings(root: null, configFileName: null, machineWideSettings: null), + sourceProvider: sourceProvider, + packagePaths: new List { fullNupkgFile }, + source: publishLocation, + apiKey: ApiKey, + symbolSource: null, + symbolApiKey: null, + timeoutSeconds: 0, + disableBuffering: false, + noSymbols: false, + noServiceEndpoint: false, // enable server endpoint + skipDuplicate: false, // if true-- if a package and version already exists, skip it and continue with the next package in the push, if any. + logger: NullLogger.Instance // nuget logger + ).GetAwaiter().GetResult(); + } + catch (HttpRequestException e) + { + _cmdletPassedIn.WriteVerbose(string.Format("Not able to publish resource to '{0}'", repoUri)); + // look in PS repo for how httpRequestExceptions are handled + + // Unfortunately there is no response message are no status codes provided with the exception and no + var ex = new ArgumentException(String.Format("Repository '{0}': {1}", repoName, e.Message)); + if (e.Message.Contains("400")) + { + if (e.Message.Contains("Api")) + { + // For ADO repositories, public and private, when ApiKey is not provided. + error = new ErrorRecord( + new ArgumentException($"Repository '{repoName}': Please try running again with the -ApiKey parameter and specific API key for the repository specified. For Azure Devops repository, set this to an arbitrary value, for example '-ApiKey AzureDevOps'"), + "400ApiKeyError", + ErrorCategory.AuthenticationError, + this); + } + else + { + error = new ErrorRecord( + ex, + "400Error", + ErrorCategory.PermissionDenied, + this); + } + } + else if (e.Message.Contains("401")) + { + if (e.Message.Contains("API")) + { + // For PSGallery when ApiKey is not provided. + error = new ErrorRecord( + new ArgumentException($"Could not publish to repository '{repoName}'. Please try running again with the -ApiKey parameter and the API key for the repository specified. Exception: '{e.Message}'"), + "401ApiKeyError", + ErrorCategory.AuthenticationError, + this); + } + else + { + // For ADO repository feeds that are public feeds, when the credentials are incorrect. + error = new ErrorRecord(new ArgumentException($"Could not publish to repository '{repoName}'. The Credential provided was incorrect. Exception: '{e.Message}'"), + "401Error", + ErrorCategory.PermissionDenied, + this); ; + } + } + else if (e.Message.Contains("403")) + { + if (repoUri.Contains("myget.org")) + { + // For myGet.org repository feeds when the ApiKey is missing or incorrect. + error = new ErrorRecord( + new ArgumentException($"Could not publish to repository '{repoName}'. The ApiKey provided is incorrect or missing. Please try running again with the -ApiKey parameter and correct API key value for the repository. Exception: '{e.Message}'"), + "403Error", + ErrorCategory.PermissionDenied, + this); + } + else if (repoUri.Contains(".jfrog.io")) + { + // For JFrog Artifactory repository feeds when the ApiKey is provided, whether correct or incorrect, as JFrog does not require -ApiKey (but does require ApiKey to be present as password to -Credential). + error = new ErrorRecord( + new ArgumentException($"Could not publish to repository '{repoName}'. The ApiKey provided is not needed for JFrog Artifactory. Please try running again without the -ApiKey parameter but ensure that -Credential is provided with ApiKey as password. Exception: '{e.Message}'"), + "403Error", + ErrorCategory.PermissionDenied, + this); + } + else + { + error = new ErrorRecord( + ex, + "403Error", + ErrorCategory.PermissionDenied, + this); + } + } + else if (e.Message.Contains("409")) + { + error = new ErrorRecord( + ex, + "409Error", + ErrorCategory.PermissionDenied, this); + } + else + { + error = new ErrorRecord( + ex, + "HTTPRequestError", + ErrorCategory.PermissionDenied, + this); + } + + return success; + } + catch (NuGet.Protocol.Core.Types.FatalProtocolException e) + { + // for ADO repository feeds that are private feeds the error thrown is different and the 401 is in the inner exception message + if (e.InnerException.Message.Contains("401")) + { + error = new ErrorRecord( + new ArgumentException($"Could not publish to repository '{repoName}'. The Credential provided was incorrect. Exception '{e.InnerException.Message}'"), + "401FatalProtocolError", + ErrorCategory.AuthenticationError, + this); + } + else + { + error = new ErrorRecord( + new ArgumentException($"Repository '{repoName}': {e.InnerException.Message}"), + "ProtocolFailError", + ErrorCategory.ProtocolError, + this); + } + + return success; + } + catch (Exception e) + { + _cmdletPassedIn.WriteVerbose($"Not able to publish resource to '{repoUri}'"); + error = new ErrorRecord( + new ArgumentException(e.Message), + "PushNupkgError", + ErrorCategory.InvalidResult, + this); + + return success; + } + + _cmdletPassedIn.WriteVerbose(string.Format("Successfully published the resource to '{0}'", repoUri)); + error = null; + success = true; + + return success; + } + + private void InjectCredentialsToSettings(ISettings settings, IPackageSourceProvider sourceProvider, string source) + { + _cmdletPassedIn.WriteDebug("In PublishPSResource::InjectCredentialsToSettings()"); + if (Credential == null && _networkCredential == null) + { + return; + } + + var packageSource = sourceProvider.LoadPackageSources().FirstOrDefault(s => s.Source == source); + if (packageSource != null) + { + if (!packageSource.IsEnabled) + { + packageSource.IsEnabled = true; + } + } + + + var networkCred = Credential == null ? _networkCredential : Credential.GetNetworkCredential(); + string key; + + if (packageSource == null) + + { + key = "_" + Guid.NewGuid().ToString().Replace("-", ""); + settings.AddOrUpdate( + ConfigurationConstants.PackageSources, + new SourceItem(key, source)); + } + else + { + key = packageSource.Name; + } + + settings.AddOrUpdate( + ConfigurationConstants.CredentialsSectionName, + new CredentialsItem( + key, + networkCred.UserName, + networkCred.Password, + isPasswordClearText: true, + String.Empty)); + } + + private string CreateNuspec( + string outputDir, + string filePath, + Hashtable parsedMetadataHash, + out Hashtable requiredModules) + { + _cmdletPassedIn.WriteDebug("In PublishHelper::CreateNuspec()"); + + bool isModule = resourceType != ResourceType.Script; + requiredModules = new Hashtable(); + + if (parsedMetadataHash == null || parsedMetadataHash.Count == 0) + { + _cmdletPassedIn.WriteError(new ErrorRecord(new ArgumentException("Hashtable provided with package metadata was null or empty"), + "PackageMetadataHashtableNullOrEmptyError", + ErrorCategory.ReadError, + this)); + + return string.Empty; + } + + // now we have parsedMetadatahash to fill out the nuspec information + var nameSpaceUri = "http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd"; + var doc = new XmlDocument(); + + // xml declaration is recommended, but not mandatory + XmlDeclaration xmlDeclaration = doc.CreateXmlDeclaration("1.0", "utf-8", null); + XmlElement root = doc.DocumentElement; + doc.InsertBefore(xmlDeclaration, root); + + // create top-level elements + XmlElement packageElement = doc.CreateElement("package", nameSpaceUri); + XmlElement metadataElement = doc.CreateElement("metadata", nameSpaceUri); + + Dictionary metadataElementsDictionary = new Dictionary(); + + // id is mandatory + metadataElementsDictionary.Add("id", _pkgName); + + string version; + if (parsedMetadataHash.ContainsKey("moduleversion")) + { + version = parsedMetadataHash["moduleversion"].ToString(); + } + else if (parsedMetadataHash.ContainsKey("version")) + { + version = parsedMetadataHash["version"].ToString(); + } + else + { + // no version is specified for the nuspec + _cmdletPassedIn.WriteError(new ErrorRecord( + new ArgumentException("There is no package version specified. Please specify a version before publishing."), + "NoVersionFound", + ErrorCategory.InvalidArgument, + this)); + + return string.Empty; + } + + // Look for Prerelease tag and then process any Tags in PrivateData > PSData + if (isModule) + { + if (parsedMetadataHash.ContainsKey("PrivateData")) + { + if (parsedMetadataHash["PrivateData"] is Hashtable privateData && + privateData.ContainsKey("PSData")) + { + if (privateData["PSData"] is Hashtable psData) + { + if (psData.ContainsKey("prerelease") && psData["prerelease"] is string preReleaseVersion) + { + if (!string.IsNullOrEmpty(preReleaseVersion)) + { + version = string.Format(@"{0}-{1}", version, preReleaseVersion); + } + } + + if (psData.ContainsKey("licenseuri") && psData["licenseuri"] is string licenseUri) + + { + metadataElementsDictionary.Add("licenseUrl", licenseUri.Trim()); + } + + if (psData.ContainsKey("projecturi") && psData["projecturi"] is string projectUri) + { + metadataElementsDictionary.Add("projectUrl", projectUri.Trim()); + } + + if (psData.ContainsKey("iconuri") && psData["iconuri"] is string iconUri) + { + metadataElementsDictionary.Add("iconUrl", iconUri.Trim()); + } + + if (psData.ContainsKey("releasenotes")) + { + if (psData["releasenotes"] is string releaseNotes) + { + metadataElementsDictionary.Add("releaseNotes", releaseNotes.Trim()); + } + else if (psData["releasenotes"] is string[] releaseNotesArr) + { + metadataElementsDictionary.Add("releaseNotes", string.Join("\n", releaseNotesArr)); + } + } + + // defaults to false + // Value for requireAcceptLicense key needs to be a lowercase string representation of the boolean for it to be correctly parsed from psData file. + + string requireLicenseAcceptance = psData.ContainsKey("requirelicenseacceptance") ? psData["requirelicenseacceptance"].ToString().ToLower() : "false"; + + metadataElementsDictionary.Add("requireLicenseAcceptance", requireLicenseAcceptance); + + + if (psData.ContainsKey("Tags") && psData["Tags"] is Array manifestTags) + { + var tagArr = new List(); + foreach (string tag in manifestTags) + { + tagArr.Add(tag); + } + parsedMetadataHash["tags"] = string.Join(" ", tagArr.ToArray()); + } + } + } + } + } + else + { + if (parsedMetadataHash.ContainsKey("licenseuri") && parsedMetadataHash["licenseuri"] is Uri licenseUri) + + { + metadataElementsDictionary.Add("licenseUrl", licenseUri.ToString().Trim()); + } + + if (parsedMetadataHash.ContainsKey("projecturi") && parsedMetadataHash["projecturi"] is Uri projectUri) + { + metadataElementsDictionary.Add("projectUrl", projectUri.ToString().Trim()); + } + + if (parsedMetadataHash.ContainsKey("iconuri") && parsedMetadataHash["iconuri"] is Uri iconUri) + { + metadataElementsDictionary.Add("iconUrl", iconUri.ToString().Trim()); + } + + if (parsedMetadataHash.ContainsKey("releaseNotes")) + { + if (parsedMetadataHash["releasenotes"] is string releaseNotes) + { + metadataElementsDictionary.Add("releaseNotes", releaseNotes.Trim()); + } + else if (parsedMetadataHash["releasenotes"] is string[] releaseNotesArr) + { + metadataElementsDictionary.Add("releaseNotes", string.Join("\n", releaseNotesArr)); + } + } + } + + + if (NuGetVersion.TryParse(version, out _pkgVersion)) + { + metadataElementsDictionary.Add("version", _pkgVersion.ToNormalizedString()); + } + + if (parsedMetadataHash.ContainsKey("author")) + { + metadataElementsDictionary.Add("authors", parsedMetadataHash["author"].ToString().Trim()); + } + + if (parsedMetadataHash.ContainsKey("companyname")) + { + metadataElementsDictionary.Add("owners", parsedMetadataHash["companyname"].ToString().Trim()); + } + + if (parsedMetadataHash.ContainsKey("description")) + { + metadataElementsDictionary.Add("description", parsedMetadataHash["description"].ToString().Trim()); + } + + if (parsedMetadataHash.ContainsKey("copyright")) + { + metadataElementsDictionary.Add("copyright", parsedMetadataHash["copyright"].ToString().Trim()); + } + + string tags = (resourceType == ResourceType.Script) ? "PSScript" : "PSModule"; + if (parsedMetadataHash.ContainsKey("tags") && parsedMetadataHash["tags"] != null) + { + if (parsedMetadataHash["tags"] is string[]) + { + string[] tagsArr = parsedMetadataHash["tags"] as string[]; + tags += " " + String.Join(" ", tagsArr); + } + else if (parsedMetadataHash["tags"] is string) + { + tags += " " + parsedMetadataHash["tags"].ToString().Trim(); + } + } + + metadataElementsDictionary.Add("tags", tags); + + + // Example nuspec: + /* + + + + System.Management.Automation + 1.0.0 + Microsoft + Microsoft,PowerShell + false + MIT + https://licenses.nuget.org/MIT + Powershell_black_64.png + https://github.com/PowerShell/PowerShell + Example description here + Copyright (c) Microsoft Corporation. All rights reserved. + en-US + PowerShell + + + + + + + + + */ + + foreach (var key in metadataElementsDictionary.Keys) + { + if (metadataElementsDictionary.TryGetValue(key, out string elementInnerText)) + { + XmlElement element = doc.CreateElement(key, nameSpaceUri); + element.InnerText = elementInnerText; + metadataElement.AppendChild(element); + } + else + { + _cmdletPassedIn.WriteVerbose(string.Format("Creating XML element failed. Unable to get value from key '{0}'.", key)); + } + } + + requiredModules = ParseRequiredModules(parsedMetadataHash); + if (requiredModules != null) + { + XmlElement dependenciesElement = doc.CreateElement("dependencies", nameSpaceUri); + foreach (string dependencyName in requiredModules.Keys) + { + XmlElement element = doc.CreateElement("dependency", nameSpaceUri); + element.SetAttribute("id", dependencyName); + + string dependencyVersion = requiredModules[dependencyName].ToString(); + if (!string.IsNullOrEmpty(dependencyVersion)) + { + var requiredModulesVersionInfo = (Hashtable)requiredModules[dependencyName]; + string versionRange = String.Empty; + if (requiredModulesVersionInfo.ContainsKey("RequiredVersion")) + { + // For RequiredVersion, use exact version notation [x.x.x] + string requiredModulesVersion = requiredModulesVersionInfo["RequiredVersion"].ToString(); + versionRange = $"[{requiredModulesVersion}]"; + } + else if (requiredModulesVersionInfo.ContainsKey("ModuleVersion") && requiredModulesVersionInfo.ContainsKey("MaximumVersion")) + { + // Version range when both min and max specified: [min,max] + versionRange = $"[{requiredModulesVersionInfo["ModuleVersion"]}, {requiredModulesVersionInfo["MaximumVersion"]}]"; + } + else if (requiredModulesVersionInfo.ContainsKey("ModuleVersion")) + { + // Only min specified: min (which means â‰Ĩ min) + versionRange = requiredModulesVersionInfo["ModuleVersion"].ToString(); + } + else if (requiredModulesVersionInfo.ContainsKey("MaximumVersion")) + { + // Only max specified: (, max] + versionRange = $"(, {requiredModulesVersionInfo["MaximumVersion"]}]"; + } + + if (!string.IsNullOrEmpty(versionRange)) + { + element.SetAttribute("version", versionRange); + } + } + + dependenciesElement.AppendChild(element); + } + + metadataElement.AppendChild(dependenciesElement); + } + + packageElement.AppendChild(metadataElement); + doc.AppendChild(packageElement); + + var nuspecFullName = System.IO.Path.Combine(outputDir, _pkgName + ".nuspec"); + doc.Save(nuspecFullName); + + _cmdletPassedIn.WriteVerbose("The newly created nuspec is: " + nuspecFullName); + + return nuspecFullName; + } + + private Hashtable ParseRequiredModules(Hashtable parsedMetadataHash) + { + _cmdletPassedIn.WriteDebug("In PublishHelper::ParseRequiredModules()"); + + if (!parsedMetadataHash.ContainsKey("requiredmodules")) + { + return null; + } + + LanguagePrimitives.TryConvertTo(parsedMetadataHash["requiredmodules"], out object[] requiredModules); + + // Required modules can be: + // a. An array of hash tables of module name and version + // b. A single hash table of module name and version + // c. A string array of module names + // d. A single string module name + + var dependenciesHash = new Hashtable(); + foreach (var reqModule in requiredModules) + { + if (LanguagePrimitives.TryConvertTo(reqModule, out Hashtable moduleHash)) + { + string moduleName = moduleHash["ModuleName"] as string; + var versionInfo = new Hashtable(); + + // RequiredVersion cannot be used with ModuleVersion or MaximumVersion + if (moduleHash.ContainsKey("RequiredVersion")) + { + versionInfo["RequiredVersion"] = moduleHash["RequiredVersion"].ToString(); + } + else + { + // ModuleVersion and MaximumVersion can be used together + if (moduleHash.ContainsKey("ModuleVersion")) + { + versionInfo["ModuleVersion"] = moduleHash["ModuleVersion"].ToString(); + } + if (moduleHash.ContainsKey("MaximumVersion")) + { + versionInfo["MaximumVersion"] = moduleHash["MaximumVersion"].ToString(); + } + } + dependenciesHash.Add(moduleName, versionInfo); + } + else if (LanguagePrimitives.TryConvertTo(reqModule, out string moduleName)) + { + dependenciesHash.Add(moduleName, string.Empty); + } + } + + var externalModuleDeps = parsedMetadataHash.ContainsKey("ExternalModuleDependencies") ? + parsedMetadataHash["ExternalModuleDependencies"] : null; + + if (externalModuleDeps != null && LanguagePrimitives.TryConvertTo(externalModuleDeps, out string[] externalModuleNames)) + { + foreach (var extModName in externalModuleNames) + { + if (dependenciesHash.ContainsKey(extModName)) + { + dependenciesHash.Remove(extModName); + } + } + } + + return dependenciesHash; + } + + private bool CheckDependenciesExist(Hashtable dependencies, string repositoryName) + { + _cmdletPassedIn.WriteDebug("In PublishHelper::CheckDependenciesExist()"); + + // Check to see that all dependencies are in the repository + // Searches for each dependency in the repository the pkg is being pushed to, + // If the dependency is not there, error + foreach (DictionaryEntry dependency in dependencies) + { + // Need to make individual calls since we're look for exact version numbers or ranges. + var depName = dependency.Key as string; + // test version + string depVersion = dependencies[depName] as string; + depVersion = string.IsNullOrWhiteSpace(depVersion) ? "*" : depVersion; + + if (!Utils.TryGetVersionType( + version: depVersion, + nugetVersion: out NuGetVersion nugetVersion, + versionRange: out VersionRange versionRange, + versionType: out VersionType versionType, + error: out string error)) + { + _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( + new ArgumentException(error), + "IncorrectVersionFormat", + ErrorCategory.InvalidArgument, + this)); + } + + // Search for and return the dependency if it's in the repository. + FindHelper findHelper = new FindHelper(_cancellationToken, _cmdletPassedIn, _networkCredential); + + var repository = new[] { repositoryName }; + // Note: we set prerelease argument for FindByResourceName() to true because if no version is specified we want latest version (including prerelease). + // If version is specified it will get that one. There is also no way to specify a prerelease flag with RequiredModules hashtable of dependency so always try to get latest version. + var dependencyFound = findHelper.FindByResourceName(new string[] { depName }, ResourceType.Module, versionRange, nugetVersion, versionType, depVersion, prerelease: true, tag: null, repository, includeDependencies: false, suppressErrors: true); + if (dependencyFound == null || !dependencyFound.Any()) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new ArgumentException($"Dependency '{depName}' was not found in repository '{repositoryName}'. Make sure the dependency is published to the repository before {_cmdOperation.ToLower()} this module."), + "DependencyNotFound", + ErrorCategory.ObjectNotFound, + this)); + + return false; + } + + } + + return true; + } + + /// + /// This method is called by Publish-PSResource when the -NupkgPath parameter is specified + /// The method copies the .nupkg file to a temp path (populated at outputNupkgDir field) as we dont' want to extract and read original .nupkg file + /// + private string CopyNupkgFileToTempPath(string nupkgFilePath, out ErrorRecord errRecord) + { + errRecord = null; + string destinationFilePath = String.Empty; + var packageFullName = System.IO.Path.GetFileName(nupkgFilePath); + try + { + if (!Directory.Exists(outputDir)) + { + Directory.CreateDirectory(outputDir); + if (!Directory.Exists(outputNupkgDir)) + { + Directory.CreateDirectory(outputNupkgDir); + } + } + + destinationFilePath = System.IO.Path.Combine(outputNupkgDir, packageFullName); + File.Copy(Path, destinationFilePath); + } + catch (Exception e) + { + errRecord = new ErrorRecord( + new ArgumentException($"Error moving .nupkg at -NupkgPath to temp nupkg dir path '{outputNupkgDir}' due to: '{e.Message}'."), + "ErrorMovingNupkg", + ErrorCategory.NotSpecified, + this); + + // exit process record + return destinationFilePath; + } + + return destinationFilePath; + } + + /// + /// Get package info from the .nupkg file provided, inluding package name (_pkgName), package version (_pkgVersion), and metadata parsed into a hashtable (parsedMetadata) + /// + private void GetPackageInfoFromNupkg(string nupkgFilePath, out ErrorRecord errRecord) + { + errRecord = null; + Regex rx = new Regex(@"\.\d+\.", RegexOptions.Compiled | RegexOptions.IgnoreCase); + var packageFullName = System.IO.Path.GetFileName(nupkgFilePath); + MatchCollection matches = rx.Matches(packageFullName); + if (matches.Count == 0) + { + return; + } + + Match match = matches[0]; + + GroupCollection groups = match.Groups; + if (groups.Count == 0) + { + return; + } + + Capture group = groups[0]; + + string pkgFoundName = packageFullName.Substring(0, group.Index); + + string version = packageFullName.Substring(group.Index + 1, packageFullName.LastIndexOf('.') - group.Index - 1); + _cmdletPassedIn.WriteDebug($"Found package '{pkgFoundName}', version '{version}', from packageFullName '{packageFullName}' at path '{Path}'"); + + if (!NuGetVersion.TryParse(version, out NuGetVersion nugetVersion)) + { + errRecord = new ErrorRecord( + new ArgumentException($"Error parsing version '{version}' into NuGetVersion instance."), + "ErrorParsingNuGetVersion", + ErrorCategory.NotSpecified, + this); + + return; + } + + _pkgName = pkgFoundName; + _pkgVersion = nugetVersion; + parsedMetadata = GetMetadataFromNupkg(nupkgFilePath, _pkgName, out errRecord); + } + + /// + /// Extract copied .nupkg, find metadata file (either .ps1, .psd1, or .nuspec) and read metadata into a hashtable + /// + internal Hashtable GetMetadataFromNupkg(string copiedNupkgPath, string packageName, out ErrorRecord errRecord) + { + Hashtable pkgMetadata = new Hashtable(StringComparer.OrdinalIgnoreCase); + errRecord = null; + + // in temp directory create an "extract" folder to which we'll copy .nupkg to, extract contents, etc. + string nupkgDirPath = Directory.GetParent(copiedNupkgPath).FullName; //someGuid/nupkg/myPkg.nupkg -> /someGuid/nupkg + string tempPath = Directory.GetParent(nupkgDirPath).FullName; // someGuid + var extractPath = System.IO.Path.Combine(tempPath, "extract"); // someGuid/extract + + try + { + var dir = Directory.CreateDirectory(extractPath); + dir.Attributes &= ~FileAttributes.ReadOnly; + + // change extension to .zip + string zipFilePath = System.IO.Path.ChangeExtension(copiedNupkgPath, ".zip"); + File.Move(copiedNupkgPath, zipFilePath); + + // extract from .zip + _cmdletPassedIn.WriteDebug($"Extracting '{zipFilePath}' to '{extractPath}'"); + System.IO.Compression.ZipFile.ExtractToDirectory(zipFilePath, extractPath); + + string psd1FilePath = String.Empty; + string ps1FilePath = String.Empty; + string nuspecFilePath = String.Empty; + Utils.GetMetadataFilesFromPath(extractPath, packageName, out psd1FilePath, out ps1FilePath, out nuspecFilePath, out string properCasingPkgName); + + List pkgTags = new List(); + + if (File.Exists(psd1FilePath)) + { + _cmdletPassedIn.WriteDebug($"Attempting to read module manifest file '{psd1FilePath}'"); + if (!Utils.TryReadManifestFile(psd1FilePath, out pkgMetadata, out Exception readManifestError)) + { + errRecord = new ErrorRecord( + readManifestError, + "GetMetadataFromNupkgFailure", + ErrorCategory.ParserError, + this); + + return pkgMetadata; + } + } + else if (File.Exists(ps1FilePath)) + { + _cmdletPassedIn.WriteDebug($"Attempting to read script file '{ps1FilePath}'"); + if (!PSScriptFileInfo.TryTestPSScriptFileInfo(ps1FilePath, out PSScriptFileInfo parsedScript, out ErrorRecord[] errors, out string[] verboseMsgs)) + { + errRecord = new ErrorRecord( + new InvalidDataException($"PSScriptFile could not be read properly"), + "GetMetadataFromNupkgFailure", + ErrorCategory.ParserError, + this); + + return pkgMetadata; + } + + pkgMetadata = parsedScript.ToHashtable(); + } + else if (File.Exists(nuspecFilePath)) + { + _cmdletPassedIn.WriteDebug($"Attempting to read nuspec file '{nuspecFilePath}'"); + pkgMetadata = Utils.GetMetadataFromNuspec(nuspecFilePath, _cmdletPassedIn, out errRecord); + if (errRecord != null) + { + return pkgMetadata; + } + } + else + { + errRecord = new ErrorRecord( + new InvalidDataException($".nupkg package must contain either .psd1, .ps1, or .nuspec file and none were found"), + "GetMetadataFromNupkgFailure", + ErrorCategory.InvalidData, + this); + + return pkgMetadata; + } + } + catch (Exception e) + { + errRecord = new ErrorRecord( + new InvalidOperationException($"Temporary folder for installation could not be created or set due to: {e.Message}"), + "GetMetadataFromNupkgFailure", + ErrorCategory.InvalidOperation, + this); + } + finally + { + if (Directory.Exists(extractPath)) + { + Utils.DeleteDirectory(extractPath); + } + } + + return pkgMetadata; + } + + #endregion + } +} diff --git a/src/code/PublishPSResource.cs b/src/code/PublishPSResource.cs index e6447e1b3..524e86ae1 100644 --- a/src/code/PublishPSResource.cs +++ b/src/code/PublishPSResource.cs @@ -2,35 +2,29 @@ // Licensed under the MIT License. using Microsoft.PowerShell.PSResourceGet.UtilClasses; -using NuGet.Commands; -using NuGet.Common; -using NuGet.Configuration; -using NuGet.Packaging; -using NuGet.Versioning; using System; -using System.Collections; -using System.Collections.Generic; -using System.IO; using System.Linq; using System.Management.Automation; using System.Net; -using System.Net.Http; using System.Threading; -using System.Xml; namespace Microsoft.PowerShell.PSResourceGet.Cmdlets { /// /// Publishes a module, script, or nupkg to a designated repository. /// - [Cmdlet(VerbsData.Publish, - "PSResource", + [Cmdlet(VerbsData.Publish, + "PSResource", SupportsShouldProcess = true)] [Alias("pbres")] - public sealed class PublishPSResource : PSCmdlet + public sealed class PublishPSResource : PSCmdlet, IDynamicParameters { #region Parameters + private const string PathParameterSet = "PathParameterSet"; + private const string NupkgPathParameterSet = "NupkgPathParameterSet"; + private ContainerRegistryDynamicParameters _pkgPrefix; + /// /// Specifies the API key that you want to use to publish a module to the online gallery. /// @@ -50,7 +44,7 @@ public sealed class PublishPSResource : PSCmdlet /// Specifies the path to the resource that you want to publish. This parameter accepts the path to the folder that contains the resource. /// Specifies a path to one or more locations. Wildcards are permitted. The default location is the current directory (.). /// - [Parameter (Mandatory = true, Position = 0, HelpMessage = "Path to the resource to be published.")] + [Parameter (Mandatory = true, Position = 0, ParameterSetName = PathParameterSet, HelpMessage = "Path to the resource to be published.")] [ValidateNotNullOrEmpty] public string Path { get; set; } @@ -119,1048 +113,91 @@ public PSCredential ProxyCredential { } } - #endregion - - #region Members + [Parameter(Mandatory = true, ParameterSetName = NupkgPathParameterSet, HelpMessage = "Path to the resource to be published.")] + [ValidateNotNullOrEmpty] + public string NupkgPath { get; set; } - private string resolvedPath; - private CancellationToken _cancellationToken; - private NuGetVersion _pkgVersion; - private string _pkgName; - private static char[] _PathSeparators = new[] { System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar }; - public const string PSDataFileExt = ".psd1"; - public const string PSScriptFileExt = ".ps1"; - private const string PSScriptInfoCommentString = "<#PSScriptInfo"; - private string pathToScriptFileToPublish = string.Empty; - private string pathToModuleManifestToPublish = string.Empty; - private string pathToModuleDirToPublish = string.Empty; - private ResourceType resourceType = ResourceType.None; - private NetworkCredential _networkCredential; #endregion - #region Method overrides - - protected override void BeginProcessing() + #region DynamicParameters + public object GetDynamicParameters() { - _cancellationToken = new CancellationToken(); - - _networkCredential = Credential != null ? new NetworkCredential(Credential.UserName, Credential.Password) : null; - - // Create a respository story (the PSResourceRepository.xml file) if it does not already exist - // This is to create a better experience for those who have just installed v3 and want to get up and running quickly - RepositorySettings.CheckRepositoryStore(); - - try + PSRepositoryInfo repository = RepositorySettings.Read(new[] { Repository }, out string[] _).FirstOrDefault(); + if (repository is not null && repository.ApiVersion == PSRepositoryInfo.APIVersion.ContainerRegistry) { - resolvedPath = GetResolvedProviderPathFromPSPath(Path, out ProviderInfo provider).First(); - } - catch (MethodInvocationException) - { - // path does not exist - ThrowTerminatingError(new ErrorRecord( - new ArgumentException("The path to the resource to publish does not exist, point to an existing path or file of the module or script to publish."), - "SourcePathDoesNotExist", - ErrorCategory.InvalidArgument, - this)); + _pkgPrefix = new ContainerRegistryDynamicParameters(); + return _pkgPrefix; } - // Condition 1: path is to the root directory of the module to be published - // Condition 2: path is to the .psd1 or .ps1 of the module/script to be published - if (string.IsNullOrEmpty(resolvedPath)) - { - // unsupported file path - ThrowTerminatingError(new ErrorRecord( - new ArgumentException("The path to the resource to publish is not in the correct format or does not exist. Please provide the path of the root module " + - "(i.e. './/') or the path to the .psd1 (i.e. './/.psd1')."), - "InvalidPublishPath", - ErrorCategory.InvalidArgument, - this)); - } - else if (Directory.Exists(resolvedPath)) - { - pathToModuleDirToPublish = resolvedPath; - resourceType = ResourceType.Module; - } - else if (resolvedPath.EndsWith(PSDataFileExt, StringComparison.OrdinalIgnoreCase)) - { - pathToModuleManifestToPublish = resolvedPath; - resourceType = ResourceType.Module; - } - else if (resolvedPath.EndsWith(PSScriptFileExt, StringComparison.OrdinalIgnoreCase)) - { - pathToScriptFileToPublish = resolvedPath; - resourceType = ResourceType.Script; - } - else { - ThrowTerminatingError(new ErrorRecord( - new ArgumentException($"The publish path provided, '{resolvedPath}', is not a valid. Please provide a path to the root module " + - "(i.e. './/') or path to the .psd1 (i.e. './/.psd1')."), - "InvalidPublishPath", - ErrorCategory.InvalidArgument, - this)); - } - - if (!String.IsNullOrEmpty(DestinationPath)) - { - string resolvedDestinationPath = GetResolvedProviderPathFromPSPath(DestinationPath, out ProviderInfo provider).First(); - - if (Directory.Exists(resolvedDestinationPath)) - { - DestinationPath = resolvedDestinationPath; - } - else - { - try - { - Directory.CreateDirectory(resolvedDestinationPath); - } - catch (Exception e) - { - ThrowTerminatingError(new ErrorRecord( - new ArgumentException($"Destination path does not exist and cannot be created: {e.Message}"), - "InvalidDestinationPath", - ErrorCategory.InvalidArgument, - this)); - } - } - } + return null; } - protected override void EndProcessing() - { - // Returns the name of the file or the name of the directory, depending on path - if (!ShouldProcess(string.Format("Publish resource '{0}' from the machine", resolvedPath))) - { - WriteVerbose("ShouldProcess is set to false."); - return; - } - - Hashtable parsedMetadata = new Hashtable(StringComparer.OrdinalIgnoreCase); - if (resourceType == ResourceType.Script) - { - if (!PSScriptFileInfo.TryTestPSScriptFileInfo( - scriptFileInfoPath: pathToScriptFileToPublish, - parsedScript: out PSScriptFileInfo scriptToPublish, - out ErrorRecord[] errors, - out string[] _ - )) - { - foreach (ErrorRecord error in errors) - { - WriteError(error); - } - - return; - } - - parsedMetadata = scriptToPublish.ToHashtable(); - - _pkgName = System.IO.Path.GetFileNameWithoutExtension(pathToScriptFileToPublish); - } - else - { - // parsedMetadata needs to be initialized for modules, will later be passed in to create nuspec - if (!string.IsNullOrEmpty(pathToModuleManifestToPublish)) - { - _pkgName = System.IO.Path.GetFileNameWithoutExtension(pathToModuleManifestToPublish); - } - else { - // Search for module manifest - foreach (FileInfo file in new DirectoryInfo(pathToModuleDirToPublish).EnumerateFiles()) - { - if (file.Name.EndsWith(PSDataFileExt, StringComparison.OrdinalIgnoreCase)) - { - pathToModuleManifestToPublish = file.FullName; - _pkgName = System.IO.Path.GetFileNameWithoutExtension(file.Name); - - break; - } - } - } - - // Validate that there's a module manifest - if (!File.Exists(pathToModuleManifestToPublish)) - { - WriteError(new ErrorRecord( - new ArgumentException($"No file with a .psd1 extension was found in '{pathToModuleManifestToPublish}'. Please specify a path to a valid module manifest."), - "moduleManifestNotFound", - ErrorCategory.ObjectNotFound, - this)); - - return; - } - - // The Test-ModuleManifest currently cannot process UNC paths. Disabling verification for now. - if ((new Uri(pathToModuleManifestToPublish)).IsUnc) - SkipModuleManifestValidate = true; - // Validate that the module manifest has correct data - if (! SkipModuleManifestValidate && - ! Utils.ValidateModuleManifest(pathToModuleManifestToPublish, out string errorMsg)) - { - ThrowTerminatingError(new ErrorRecord( - new PSInvalidOperationException(errorMsg), - "InvalidModuleManifest", - ErrorCategory.InvalidOperation, - this)); - } - } - - // Create a temp folder to push the nupkg to and delete it later - string outputDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString()); - try - { - Directory.CreateDirectory(outputDir); - } - catch (Exception e) - { - WriteError(new ErrorRecord( - new ArgumentException(e.Message), - "ErrorCreatingTempDir", - ErrorCategory.InvalidData, - this)); - - return; - } - - try - { - // Create a nuspec - // Right now parsedMetadataHash will be empty for modules and will contain metadata for scripts - Hashtable dependencies; - string nuspec = string.Empty; - try - { - nuspec = CreateNuspec( - outputDir: outputDir, - filePath: (resourceType == ResourceType.Script) ? pathToScriptFileToPublish : pathToModuleManifestToPublish, - parsedMetadataHash: parsedMetadata, - requiredModules: out dependencies); - } - catch (Exception e) - { - WriteError(new ErrorRecord( - new ArgumentException($"Nuspec creation failed: {e.Message}"), - "NuspecCreationFailed", - ErrorCategory.ObjectNotFound, - this)); - - return; - } - - if (string.IsNullOrEmpty(nuspec)) - { - // nuspec creation failed. - WriteVerbose("Nuspec creation failed."); - return; - } - - // Find repository - PSRepositoryInfo repository = RepositorySettings.Read(new[] { Repository }, out string[] _).FirstOrDefault(); - if (repository == null) - { - WriteError(new ErrorRecord( - new ArgumentException($"The resource repository '{Repository}' is not a registered. Please run 'Register-PSResourceRepository' in order to publish to this repository."), - "RepositoryNotFound", - ErrorCategory.ObjectNotFound, - this)); - - return; - } - else if(repository.Uri.Scheme == Uri.UriSchemeFile && !repository.Uri.IsUnc && !Directory.Exists(repository.Uri.LocalPath)) - { - // this check to ensure valid local path is not for UNC paths (which are server based, instead of Drive based) - WriteError(new ErrorRecord( - new ArgumentException($"The repository '{repository.Name}' with uri: '{repository.Uri.AbsoluteUri}' is not a valid folder path which exists. If providing a file based repository, provide a repository with a path that exists."), - "repositoryPathDoesNotExist", - ErrorCategory.ObjectNotFound, - this)); - - return; - } - - _networkCredential = Utils.SetNetworkCredential(repository, _networkCredential, this); - - // Check if dependencies already exist within the repo if: - // 1) the resource to publish has dependencies and - // 2) the -SkipDependenciesCheck flag is not passed in - if (dependencies != null && !SkipDependenciesCheck) - { - // If error gets thrown, exit process record - if (!CheckDependenciesExist(dependencies, repository.Name)) - { - return; - } - } - - if (resourceType == ResourceType.Script) - { - // copy the script file to the temp directory - File.Copy(pathToScriptFileToPublish, System.IO.Path.Combine(outputDir, _pkgName + PSScriptFileExt), true); - } - else - { - try - { - // If path is pointing to a file, get the parent directory, otherwise assumption is that path is pointing to the root directory - string rootModuleDir = !string.IsNullOrEmpty(pathToModuleManifestToPublish) ? System.IO.Path.GetDirectoryName(pathToModuleManifestToPublish) : pathToModuleDirToPublish; - - // Create subdirectory structure in temp folder - foreach (string dir in Directory.GetDirectories(rootModuleDir, "*", SearchOption.AllDirectories)) - { - var dirName = dir.Substring(rootModuleDir.Length).Trim(_PathSeparators); - Directory.CreateDirectory(System.IO.Path.Combine(outputDir, dirName)); - } - - // Copy files over to temp folder - foreach (string fileNamePath in Directory.GetFiles(rootModuleDir, "*", SearchOption.AllDirectories)) - { - var fileName = fileNamePath.Substring(rootModuleDir.Length).Trim(_PathSeparators); - var newFilePath = System.IO.Path.Combine(outputDir, fileName); - - // The user may have a .nuspec defined in the module directory - // If that's the case, we will not use that file and use the .nuspec that is generated via PSGet - // The .nuspec that is already in in the output directory is the one that was generated via the CreateNuspec method - if (!File.Exists(newFilePath)) - { - File.Copy(fileNamePath, newFilePath); - } - } - } - catch (Exception e) - { - ThrowTerminatingError(new ErrorRecord( - new ArgumentException("Error occured while creating directory to publish: " + e.Message), - "ErrorCreatingDirectoryToPublish", - ErrorCategory.InvalidOperation, - this)); - } - } - - var outputNupkgDir = System.IO.Path.Combine(outputDir, "nupkg"); - - // pack into .nupkg - if (!PackNupkg(outputDir, outputNupkgDir, nuspec, out ErrorRecord packNupkgError)) - { - WriteError(packNupkgError); - // exit out of processing - return; - } - - // If -DestinationPath is specified then also publish the .nupkg there - if (!string.IsNullOrWhiteSpace(DestinationPath)) - { - if (!Directory.Exists(DestinationPath)) - { - WriteError(new ErrorRecord( - new ArgumentException($"Destination path does not exist: '{DestinationPath}'"), - "InvalidDestinationPath", - ErrorCategory.InvalidArgument, - this)); - - return; - } - - try - { - var nupkgName = _pkgName + "." + _pkgVersion.ToNormalizedString() + ".nupkg"; - File.Copy(System.IO.Path.Combine(outputNupkgDir, nupkgName), System.IO.Path.Combine(DestinationPath, nupkgName)); - } - catch (Exception e) - { - WriteError(new ErrorRecord( - new ArgumentException($"Error moving .nupkg into destination path '{DestinationPath}' due to: '{e.Message}'."), - "ErrorMovingNupkg", - ErrorCategory.NotSpecified, - this)); - - // exit process record - return; - } - } - - string repositoryUri = repository.Uri.AbsoluteUri; - - // This call does not throw any exceptions, but it will write unsuccessful responses to the console - if (!PushNupkg(outputNupkgDir, repository.Name, repositoryUri, out ErrorRecord pushNupkgError)) - { - WriteError(pushNupkgError); - // exit out of processing - return; - } - } - finally - { - WriteVerbose(string.Format("Deleting temporary directory '{0}'", outputDir)); - - Utils.DeleteDirectory(outputDir); - } - - } #endregion - #region Private methods - - private string CreateNuspec( - string outputDir, - string filePath, - Hashtable parsedMetadataHash, - out Hashtable requiredModules) - { - WriteDebug("In PublishPSResource::CreateNuspec()"); - bool isModule = resourceType != ResourceType.Script; - requiredModules = new Hashtable(); - - // A script will already have the metadata parsed into the parsedMetadatahash, - // a module will still need the module manifest to be parsed. - if (isModule) - { - // Use the parsed module manifest data as 'parsedMetadataHash' instead of the passed-in data. - if (!Utils.TryReadManifestFile( - manifestFilePath: filePath, - manifestInfo: out parsedMetadataHash, - error: out Exception manifestReadError)) - { - WriteError(new ErrorRecord( - manifestReadError, - "ManifestFileReadParseForNuspecError", - ErrorCategory.ReadError, - this)); - - return string.Empty; - } - } - - // now we have parsedMetadatahash to fill out the nuspec information - var nameSpaceUri = "http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd"; - var doc = new XmlDocument(); - - // xml declaration is recommended, but not mandatory - XmlDeclaration xmlDeclaration = doc.CreateXmlDeclaration("1.0", "utf-8", null); - XmlElement root = doc.DocumentElement; - doc.InsertBefore(xmlDeclaration, root); - - // create top-level elements - XmlElement packageElement = doc.CreateElement("package", nameSpaceUri); - XmlElement metadataElement = doc.CreateElement("metadata", nameSpaceUri); - - Dictionary metadataElementsDictionary = new Dictionary(); - - // id is mandatory - metadataElementsDictionary.Add("id", _pkgName); - - string version; - if (parsedMetadataHash.ContainsKey("moduleversion")) - { - version = parsedMetadataHash["moduleversion"].ToString(); - } - else if (parsedMetadataHash.ContainsKey("version")) - { - version = parsedMetadataHash["version"].ToString(); - } - else - { - // no version is specified for the nuspec - WriteError(new ErrorRecord( - new ArgumentException("There is no package version specified. Please specify a version before publishing."), - "NoVersionFound", - ErrorCategory.InvalidArgument, - this)); - - return string.Empty; - } - - // Look for Prerelease tag and then process any Tags in PrivateData > PSData - if (isModule) - { - if (parsedMetadataHash.ContainsKey("PrivateData")) - { - if (parsedMetadataHash["PrivateData"] is Hashtable privateData && - privateData.ContainsKey("PSData")) - { - if (privateData["PSData"] is Hashtable psData) - { - if (psData.ContainsKey("prerelease") && psData["prerelease"] is string preReleaseVersion) - { - if (!string.IsNullOrEmpty(preReleaseVersion)) - { - version = string.Format(@"{0}-{1}", version, preReleaseVersion); - } - } - - if (psData.ContainsKey("licenseuri") && psData["licenseuri"] is string licenseUri) - - { - metadataElementsDictionary.Add("licenseUrl", licenseUri.Trim()); - } - - if (psData.ContainsKey("projecturi") && psData["projecturi"] is string projectUri) - { - metadataElementsDictionary.Add("projectUrl", projectUri.Trim()); - } - - if (psData.ContainsKey("iconuri") && psData["iconuri"] is string iconUri) - { - metadataElementsDictionary.Add("iconUrl", iconUri.Trim()); - } - - if (psData.ContainsKey("releasenotes")) - { - if (psData["releasenotes"] is string releaseNotes) - { - metadataElementsDictionary.Add("releaseNotes", releaseNotes.Trim()); - } - else if (psData["releasenotes"] is string[] releaseNotesArr) - { - metadataElementsDictionary.Add("releaseNotes", string.Join("\n", releaseNotesArr)); - } - } - - // defaults to false - // Value for requireAcceptLicense key needs to be a lowercase string representation of the boolean for it to be correctly parsed from psData file. - - string requireLicenseAcceptance = psData.ContainsKey("requirelicenseacceptance") ? psData["requirelicenseacceptance"].ToString().ToLower() : "false"; - - metadataElementsDictionary.Add("requireLicenseAcceptance", requireLicenseAcceptance); - - - if (psData.ContainsKey("Tags") && psData["Tags"] is Array manifestTags) - { - var tagArr = new List(); - foreach (string tag in manifestTags) - { - tagArr.Add(tag); - } - parsedMetadataHash["tags"] = string.Join(" ", tagArr.ToArray()); - } - } - } - } - } - else - { - if (parsedMetadataHash.ContainsKey("licenseuri") && parsedMetadataHash["licenseuri"] is Uri licenseUri) - - { - metadataElementsDictionary.Add("licenseUrl", licenseUri.ToString().Trim()); - } - - if (parsedMetadataHash.ContainsKey("projecturi") && parsedMetadataHash["projecturi"] is Uri projectUri) - { - metadataElementsDictionary.Add("projectUrl", projectUri.ToString().Trim()); - } - - if (parsedMetadataHash.ContainsKey("iconuri") && parsedMetadataHash["iconuri"] is Uri iconUri) - { - metadataElementsDictionary.Add("iconUrl", iconUri.ToString().Trim()); - } - - if (parsedMetadataHash.ContainsKey("releaseNotes")) - { - if (parsedMetadataHash["releasenotes"] is string releaseNotes) - { - metadataElementsDictionary.Add("releaseNotes", releaseNotes.Trim()); - } - else if (parsedMetadataHash["releasenotes"] is string[] releaseNotesArr) - { - metadataElementsDictionary.Add("releaseNotes", string.Join("\n", releaseNotesArr)); - } - } - } - - - if (NuGetVersion.TryParse(version, out _pkgVersion)) - { - metadataElementsDictionary.Add("version", _pkgVersion.ToNormalizedString()); - } - - if (parsedMetadataHash.ContainsKey("author")) - { - metadataElementsDictionary.Add("authors", parsedMetadataHash["author"].ToString().Trim()); - } - - if (parsedMetadataHash.ContainsKey("companyname")) - { - metadataElementsDictionary.Add("owners", parsedMetadataHash["companyname"].ToString().Trim()); - } - - if (parsedMetadataHash.ContainsKey("description")) - { - metadataElementsDictionary.Add("description", parsedMetadataHash["description"].ToString().Trim()); - } - - if (parsedMetadataHash.ContainsKey("copyright")) - { - metadataElementsDictionary.Add("copyright", parsedMetadataHash["copyright"].ToString().Trim()); - } - - string tags = (resourceType == ResourceType.Script) ? "PSScript" : "PSModule"; - if (parsedMetadataHash.ContainsKey("tags") && parsedMetadataHash["tags"] != null) - { - if (parsedMetadataHash["tags"] is string[]) - { - string[] tagsArr = parsedMetadataHash["tags"] as string[]; - tags += " " + String.Join(" ", tagsArr); - } - else if (parsedMetadataHash["tags"] is string) - { - tags += " " + parsedMetadataHash["tags"].ToString().Trim(); - } - } - - metadataElementsDictionary.Add("tags", tags); - - - // Example nuspec: - /* - - - - System.Management.Automation - 1.0.0 - Microsoft - Microsoft,PowerShell - false - MIT - https://licenses.nuget.org/MIT - Powershell_black_64.png - https://github.com/PowerShell/PowerShell - Example description here - Copyright (c) Microsoft Corporation. All rights reserved. - en-US - PowerShell - - - - - - - - - */ - - foreach (var key in metadataElementsDictionary.Keys) - { - if (metadataElementsDictionary.TryGetValue(key, out string elementInnerText)) - { - XmlElement element = doc.CreateElement(key, nameSpaceUri); - element.InnerText = elementInnerText; - metadataElement.AppendChild(element); - } - else { - WriteVerbose(string.Format("Creating XML element failed. Unable to get value from key '{0}'.", key)); - } - } - - requiredModules = ParseRequiredModules(parsedMetadataHash); - if (requiredModules != null) - { - XmlElement dependenciesElement = doc.CreateElement("dependencies", nameSpaceUri); - - foreach (string dependencyName in requiredModules.Keys) - { - XmlElement element = doc.CreateElement("dependency", nameSpaceUri); - - element.SetAttribute("id", dependencyName); - string dependencyVersion = requiredModules[dependencyName].ToString(); - if (!string.IsNullOrEmpty(dependencyVersion)) - { - element.SetAttribute("version", requiredModules[dependencyName].ToString()); - } - - dependenciesElement.AppendChild(element); - } - metadataElement.AppendChild(dependenciesElement); - } - - packageElement.AppendChild(metadataElement); - doc.AppendChild(packageElement); - - var nuspecFullName = System.IO.Path.Combine(outputDir, _pkgName + ".nuspec"); - doc.Save(nuspecFullName); - - WriteVerbose("The newly created nuspec is: " + nuspecFullName); - - return nuspecFullName; - } - - private Hashtable ParseRequiredModules(Hashtable parsedMetadataHash) - { - WriteDebug("In PublishPSResource::ParseRequiredModules()"); - if (!parsedMetadataHash.ContainsKey("requiredmodules")) - { - return null; - } - LanguagePrimitives.TryConvertTo(parsedMetadataHash["requiredmodules"], out object[] requiredModules); - - // Required modules can be: - // a. An array of hash tables of module name and version - // b. A single hash table of module name and version - // c. A string array of module names - // d. A single string module name - - var dependenciesHash = new Hashtable(); - foreach (var reqModule in requiredModules) - { - if (LanguagePrimitives.TryConvertTo(reqModule, out Hashtable moduleHash)) - { - string moduleName = moduleHash["ModuleName"] as string; + #region Members - if (moduleHash.ContainsKey("ModuleVersion")) - { - dependenciesHash.Add(moduleName, moduleHash["ModuleVersion"]); - } - else if (moduleHash.ContainsKey("RequiredVersion")) - { - dependenciesHash.Add(moduleName, moduleHash["RequiredVersion"]); - } - else { - dependenciesHash.Add(moduleName, string.Empty); - } - } - else if (LanguagePrimitives.TryConvertTo(reqModule, out string moduleName)) - { - dependenciesHash.Add(moduleName, string.Empty); - } - } - var externalModuleDeps = parsedMetadataHash.ContainsKey("ExternalModuleDependencies") ? - parsedMetadataHash["ExternalModuleDependencies"] : null; + private CancellationToken _cancellationToken; + private NetworkCredential _networkCredential; + private bool _isNupkgPathSpecified = false; + private PublishHelper _publishHelper; - if (externalModuleDeps != null && LanguagePrimitives.TryConvertTo(externalModuleDeps, out string[] externalModuleNames)) - { - foreach (var extModName in externalModuleNames) - { - if (dependenciesHash.ContainsKey(extModName)) - { - dependenciesHash.Remove(extModName); - } - } - } + #endregion - return dependenciesHash; - } + #region Method overrides - private bool CheckDependenciesExist(Hashtable dependencies, string repositoryName) + protected override void BeginProcessing() { - WriteDebug("In PublishPSResource::CheckDependenciesExist()"); - // Check to see that all dependencies are in the repository - // Searches for each dependency in the repository the pkg is being pushed to, - // If the dependency is not there, error - foreach (DictionaryEntry dependency in dependencies) - { - // Need to make individual calls since we're look for exact version numbers or ranges. - var depName = dependency.Key as string; - // test version - string depVersion = dependencies[depName] as string; - depVersion = string.IsNullOrWhiteSpace(depVersion) ? "*" : depVersion; - - if (!Utils.TryGetVersionType( - version: depVersion, - nugetVersion: out NuGetVersion nugetVersion, - versionRange: out VersionRange versionRange, - versionType: out VersionType versionType, - error: out string error)) - { - ThrowTerminatingError(new ErrorRecord( - new ArgumentException(error), - "IncorrectVersionFormat", - ErrorCategory.InvalidArgument, - this)); - } - - // Search for and return the dependency if it's in the repository. - FindHelper findHelper = new FindHelper(_cancellationToken, this, _networkCredential); - - var repository = new[] { repositoryName }; - // Note: we set prerelease argument for FindByResourceName() to true because if no version is specified we want latest version (including prerelease). - // If version is specified it will get that one. There is also no way to specify a prerelease flag with RequiredModules hashtable of dependency so always try to get latest version. - var dependencyFound = findHelper.FindByResourceName(new string[] { depName }, ResourceType.Module, versionRange, nugetVersion, versionType, depVersion, prerelease: true, tag: null, repository, includeDependencies: false, suppressErrors: true); - if (dependencyFound == null || !dependencyFound.Any()) - { - WriteError(new ErrorRecord( - new ArgumentException($"Dependency '{depName}' was not found in repository '{repositoryName}'. Make sure the dependency is published to the repository before publishing this module."), - "DependencyNotFound", - ErrorCategory.ObjectNotFound, - this)); + _cancellationToken = new CancellationToken(); - return false; - } - } - return true; - } + _networkCredential = Credential != null ? new NetworkCredential(Credential.UserName, Credential.Password) : null; - private bool PackNupkg(string outputDir, string outputNupkgDir, string nuspecFile, out ErrorRecord error) - { - WriteDebug("In PublishPSResource::PackNupkg()"); - // Pack the module or script into a nupkg given a nuspec. - var builder = new PackageBuilder(); - try + if (!string.IsNullOrEmpty(NupkgPath)) { - var runner = new PackCommandRunner( - new PackArgs - { - CurrentDirectory = outputDir, - OutputDirectory = outputNupkgDir, - Path = nuspecFile, - Exclude = System.Array.Empty(), - Symbols = false, - Logger = NullLogger.Instance - }, - MSBuildProjectFactory.ProjectCreator, - builder); - bool success = runner.RunPackageBuild(); - - if (success) - { - WriteVerbose("Successfully packed the resource into a .nupkg"); - } - else - { - error = new ErrorRecord( - new InvalidOperationException("Not able to successfully pack the resource into a .nupkg"), - "failedToPackIntoNupkg", - ErrorCategory.ObjectNotFound, - this); - - return false; - } + _isNupkgPathSpecified = true; + Path = NupkgPath; } - catch (Exception e) - { - error = new ErrorRecord( - new ArgumentException($"Unexpected error packing into .nupkg: '{e.Message}'."), - "ErrorPackingIntoNupkg", - ErrorCategory.NotSpecified, - this); - // exit process record - return false; - } + // Create a respository story (the PSResourceRepository.xml file) if it does not already exist + // This is to create a better experience for those who have just installed v3 and want to get up and running quickly + RepositorySettings.CheckRepositoryStore(); - error = null; - return true; + _publishHelper = new PublishHelper( + this, + Credential, + ApiKey, + Path, + DestinationPath, + SkipModuleManifestValidate, + _cancellationToken, + _isNupkgPathSpecified); + + _publishHelper.CheckAllParameterPaths(); } - private bool PushNupkg(string outputNupkgDir, string repoName, string repoUri, out ErrorRecord error) + protected override void EndProcessing() { - WriteDebug("In PublishPSResource::PushNupkg()"); - // Push the nupkg to the appropriate repository - // Pkg version is parsed from .ps1 file or .psd1 file - var fullNupkgFile = System.IO.Path.Combine(outputNupkgDir, _pkgName + "." + _pkgVersion.ToNormalizedString() + ".nupkg"); - - // The PSGallery uses the v2 protocol still and publishes to a slightly different endpoint: - // "https://www.powershellgallery.com/api/v2/package" - // Until the PSGallery is moved onto the NuGet v3 server protocol, we'll modify the repository uri - // to accommodate for the approprate publish location. - string publishLocation = repoUri.EndsWith("/v2", StringComparison.OrdinalIgnoreCase) ? repoUri + "/package" : repoUri; - - var settings = NuGet.Configuration.Settings.LoadDefaultSettings(null, null, null); - var success = false; - - var sourceProvider = new PackageSourceProvider(settings); - if (Credential != null || _networkCredential != null) - { - InjectCredentialsToSettings(settings, sourceProvider, publishLocation); - } - - - try - { - PushRunner.Run( - settings: Settings.LoadDefaultSettings(root: null, configFileName: null, machineWideSettings: null), - sourceProvider: sourceProvider, - packagePaths: new List { fullNupkgFile }, - source: publishLocation, - apiKey: ApiKey, - symbolSource: null, - symbolApiKey: null, - timeoutSeconds: 0, - disableBuffering: false, - noSymbols: false, - noServiceEndpoint: false, // enable server endpoint - skipDuplicate: false, // if true-- if a package and version already exists, skip it and continue with the next package in the push, if any. - logger: NullLogger.Instance // nuget logger - ).GetAwaiter().GetResult(); - } - catch (HttpRequestException e) - { - WriteVerbose(string.Format("Not able to publish resource to '{0}'", repoUri)); - // look in PS repo for how httpRequestExceptions are handled - - // Unfortunately there is no response message are no status codes provided with the exception and no - var ex = new ArgumentException(String.Format("Repository '{0}': {1}", repoName, e.Message)); - if (e.Message.Contains("400")) - { - if (e.Message.Contains("Api")) - { - // For ADO repositories, public and private, when ApiKey is not provided. - error = new ErrorRecord( - new ArgumentException($"Repository '{repoName}': Please try running again with the -ApiKey parameter and specific API key for the repository specified. For Azure Devops repository, set this to an arbitrary value, for example '-ApiKey AzureDevOps'"), - "400ApiKeyError", - ErrorCategory.AuthenticationError, - this); - } - else - { - error = new ErrorRecord( - ex, - "400Error", - ErrorCategory.PermissionDenied, - this); - } - } - else if (e.Message.Contains("401")) - { - if (e.Message.Contains("API")) - { - // For PSGallery when ApiKey is not provided. - error = new ErrorRecord( - new ArgumentException($"Could not publish to repository '{repoName}'. Please try running again with the -ApiKey parameter and the API key for the repository specified. Exception: '{e.Message}'"), - "401ApiKeyError", - ErrorCategory.AuthenticationError, - this); - } - else - { - // For ADO repository feeds that are public feeds, when the credentials are incorrect. - error = new ErrorRecord(new ArgumentException($"Could not publish to repository '{repoName}'. The Credential provided was incorrect. Exception: '{e.Message}'"), - "401Error", - ErrorCategory.PermissionDenied, - this); ; - } - } - else if (e.Message.Contains("403")) - { - if (repoUri.Contains("myget.org")) - { - // For myGet.org repository feeds when the ApiKey is missing or incorrect. - error = new ErrorRecord( - new ArgumentException($"Could not publish to repository '{repoName}'. The ApiKey provided is incorrect or missing. Please try running again with the -ApiKey parameter and correct API key value for the repository. Exception: '{e.Message}'"), - "403Error", - ErrorCategory.PermissionDenied, - this); - } - else if (repoUri.Contains(".jfrog.io")) - { - // For JFrog Artifactory repository feeds when the ApiKey is provided, whether correct or incorrect, as JFrog does not require -ApiKey (but does require ApiKey to be present as password to -Credential). - error = new ErrorRecord( - new ArgumentException($"Could not publish to repository '{repoName}'. The ApiKey provided is not needed for JFrog Artifactory. Please try running again without the -ApiKey parameter but ensure that -Credential is provided with ApiKey as password. Exception: '{e.Message}'"), - "403Error", - ErrorCategory.PermissionDenied, - this); - } - else - { - error = new ErrorRecord( - ex, - "403Error", - ErrorCategory.PermissionDenied, - this); - } - } - else if (e.Message.Contains("409")) - { - error = new ErrorRecord( - ex, - "409Error", - ErrorCategory.PermissionDenied, this); - } - else - { - error = new ErrorRecord( - ex, - "HTTPRequestError", - ErrorCategory.PermissionDenied, - this); - } - - return success; - } - catch (NuGet.Protocol.Core.Types.FatalProtocolException e) - { - // for ADO repository feeds that are private feeds the error thrown is different and the 401 is in the inner exception message - if (e.InnerException.Message.Contains("401")) - { - error = new ErrorRecord( - new ArgumentException($"Could not publish to repository '{repoName}'. The Credential provided was incorrect. Exception '{e.InnerException.Message}'"), - "401FatalProtocolError", - ErrorCategory.AuthenticationError, - this); - } - else - { - error = new ErrorRecord( - new ArgumentException($"Repository '{repoName}': {e.InnerException.Message}"), - "ProtocolFailError", - ErrorCategory.ProtocolError, - this); - } - - return success; - } - catch (Exception e) + if (!_isNupkgPathSpecified) { - WriteVerbose($"Not able to publish resource to '{repoUri}'"); - error = new ErrorRecord( - new ArgumentException(e.Message), - "PushNupkgError", - ErrorCategory.InvalidResult, - this); - - return success; + _publishHelper.PackResource(); } - - WriteVerbose(string.Format("Successfully published the resource to '{0}'", repoUri)); - error = null; - success = true; - - return success; - } - - private void InjectCredentialsToSettings(ISettings settings, IPackageSourceProvider sourceProvider, string source) - { - WriteDebug("In PublishPSResource::InjectCredentialsToSettings()"); - if (Credential == null && _networkCredential == null) + if (_publishHelper.ScriptError || !_publishHelper.ShouldProcess) { return; } - var packageSource = sourceProvider.LoadPackageSources().FirstOrDefault(s => s.Source == source); - if (packageSource != null) - { - if (!packageSource.IsEnabled) - { - packageSource.IsEnabled = true; - } - } + string modulePrefix = _pkgPrefix?.ModulePrefix; + _publishHelper.PushResource(Repository, modulePrefix, SkipDependenciesCheck, _networkCredential); + } - var networkCred = Credential == null ? _networkCredential : Credential.GetNetworkCredential(); - string key; - - if (packageSource == null) + #endregion - { - key = "_" + Guid.NewGuid().ToString().Replace("-", ""); - settings.AddOrUpdate( - ConfigurationConstants.PackageSources, - new SourceItem(key, source)); - } - else - { - key = packageSource.Name; - } + } - settings.AddOrUpdate( - ConfigurationConstants.CredentialsSectionName, - new CredentialsItem( - key, - networkCred.UserName, - networkCred.Password, - isPasswordClearText: true, - String.Empty)); - } - - #endregion + public class ContainerRegistryDynamicParameters + { + [Parameter] + public string ModulePrefix { get; set; } } } diff --git a/src/code/RegisterPSResourceRepository.cs b/src/code/RegisterPSResourceRepository.cs index 5b84749fd..1a86db210 100644 --- a/src/code/RegisterPSResourceRepository.cs +++ b/src/code/RegisterPSResourceRepository.cs @@ -339,11 +339,12 @@ private PSRepositoryInfo RepoValidationHelper(Hashtable repo) if (repo.ContainsKey("ApiVersion") && (repo["ApiVersion"] == null || String.IsNullOrEmpty(repo["ApiVersion"].ToString()) || - !(repo["ApiVersion"].ToString().Equals("local") || repo["ApiVersion"].ToString().Equals("v2") || - repo["ApiVersion"].ToString().Equals("v3") || repo["ApiVersion"].ToString().Equals("nugetServer") || repo["ApiVersion"].ToString().Equals("unknown")))) + !(repo["ApiVersion"].ToString().Equals("Local", StringComparison.OrdinalIgnoreCase) || repo["ApiVersion"].ToString().Equals("V2", StringComparison.OrdinalIgnoreCase) || + repo["ApiVersion"].ToString().Equals("V3", StringComparison.OrdinalIgnoreCase) || repo["ApiVersion"].ToString().Equals("NugetServer", StringComparison.OrdinalIgnoreCase) || + repo["ApiVersion"].ToString().Equals("Unknown", StringComparison.OrdinalIgnoreCase)))) { WriteError(new ErrorRecord( - new PSInvalidOperationException("Repository ApiVersion must be either 'local', 'v2', 'v3', 'nugetServer' or 'unknown'"), + new PSInvalidOperationException("Repository ApiVersion must be either 'Local', 'V2', 'V3', 'NugetServer', 'ContainRegistry' or 'Unknown'"), "IncorrectApiVersionForRepositoriesParameterSetRegistration", ErrorCategory.InvalidArgument, this)); diff --git a/src/code/RepositorySettings.cs b/src/code/RepositorySettings.cs index 495483d61..e9f2693e2 100644 --- a/src/code/RepositorySettings.cs +++ b/src/code/RepositorySettings.cs @@ -9,6 +9,8 @@ using System.Management.Automation; using System.Xml; using System.Xml.Linq; +using Microsoft.PowerShell.PSResourceGet.Cmdlets; +using static Microsoft.PowerShell.PSResourceGet.UtilClasses.PSRepositoryInfo; namespace Microsoft.PowerShell.PSResourceGet.UtilClasses { @@ -60,7 +62,7 @@ public static void CheckRepositoryStore() // Add PSGallery to the newly created store Uri psGalleryUri = new Uri(PSGalleryRepoUri); - Add(PSGalleryRepoName, psGalleryUri, DefaultPriority, DefaultTrusted, repoCredentialInfo: null, PSRepositoryInfo.APIVersion.v2, force: false); + Add(PSGalleryRepoName, psGalleryUri, DefaultPriority, DefaultTrusted, repoCredentialInfo: null, PSRepositoryInfo.APIVersion.V2, force: false); } // Open file (which should exist now), if cannot/is corrupted then throw error @@ -278,7 +280,9 @@ public static PSRepositoryInfo Add(string repoName, Uri repoUri, int repoPriorit throw new PSInvalidOperationException(String.Format("Adding to repository store failed: {0}", e.Message)); } - return new PSRepositoryInfo(repoName, repoUri, repoPriority, repoTrusted, repoCredentialInfo, apiVersion); + bool isAllowed = GroupPolicyRepositoryEnforcement.IsGroupPolicyEnabled() ? GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(repoUri) : true; + + return new PSRepositoryInfo(repoName, repoUri, repoPriority, repoTrusted, repoCredentialInfo, apiVersion, isAllowed); } /// @@ -310,12 +314,12 @@ public static PSRepositoryInfo Update(string repoName, Uri repoUri, int repoPrio errorMsg = $"Repository element does not contain neccessary 'Priority' attribute, in file located at path: {FullRepositoryPath}. Fix this in your file and run again."; return null; } - + if (node.Attribute("Trusted") == null) { errorMsg = $"Repository element does not contain neccessary 'Trusted' attribute, in file located at path: {FullRepositoryPath}. Fix this in your file and run again."; return null; - } + } if (node.Attribute("APIVersion") == null) { @@ -340,7 +344,7 @@ public static PSRepositoryInfo Update(string repoName, Uri repoUri, int repoPrio // determine if existing repository node (which we wish to update) had Url or Uri attribute Uri thisUrl = null; - if (repoUri != null) + if (repoUri != null) { if (urlAttributeExists) { @@ -357,7 +361,7 @@ public static PSRepositoryInfo Update(string repoName, Uri repoUri, int repoPrio apiVersion = GetRepoAPIVersion(repoUri); } } - else + else { if (urlAttributeExists) { @@ -413,7 +417,7 @@ public static PSRepositoryInfo Update(string repoName, Uri repoUri, int repoPrio } // Update APIVersion if necessary - PSRepositoryInfo.APIVersion resolvedAPIVersion = PSRepositoryInfo.APIVersion.unknown; + PSRepositoryInfo.APIVersion resolvedAPIVersion = PSRepositoryInfo.APIVersion.Unknown; if (apiVersion != null) { resolvedAPIVersion = (PSRepositoryInfo.APIVersion)apiVersion; @@ -421,7 +425,7 @@ public static PSRepositoryInfo Update(string repoName, Uri repoUri, int repoPrio } else { - resolvedAPIVersion = (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), node.Attribute("APIVersion").Value); + resolvedAPIVersion = (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), node.Attribute("APIVersion").Value, ignoreCase: true); } @@ -435,12 +439,22 @@ public static PSRepositoryInfo Update(string repoName, Uri repoUri, int repoPrio node.Attribute(PSCredentialInfo.SecretNameAttribute).Value); } + if (GroupPolicyRepositoryEnforcement.IsGroupPolicyEnabled()) + { + var allowedList = GroupPolicyRepositoryEnforcement.GetAllowedRepositoryURIs(); + + } + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsGroupPolicyEnabled() ? GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(thisUrl) : true; + + RepositoryProviderType repositoryProvider= GetRepositoryProviderType(thisUrl); updatedRepo = new PSRepositoryInfo(repoName, thisUrl, Int32.Parse(node.Attribute("Priority").Value), Boolean.Parse(node.Attribute("Trusted").Value), thisCredentialInfo, - resolvedAPIVersion); + resolvedAPIVersion, + isAllowed); // Close the file root.Save(FullRepositoryPath); @@ -496,7 +510,7 @@ public static List Remove(string[] repoNames, out string[] err tempErrorList.Add(String.Format("Repository element does not contain neccessary 'Priority' attribute, in file located at path: {0}. Fix this in your file and run again.", FullRepositoryPath)); continue; } - + if (node.Attribute("Trusted") == null) { tempErrorList.Add(String.Format("Repository element does not contain neccessary 'Trusted' attribute, in file located at path: {0}. Fix this in your file and run again.", FullRepositoryPath)); @@ -515,17 +529,23 @@ public static List Remove(string[] repoNames, out string[] err if (!urlAttributeExists && !uriAttributeExists) { tempErrorList.Add(String.Format("Repository element does not contain neccessary 'Url' or equivalent 'Uri' attribute (it must contain one per Repository), in file located at path: {0}. Fix this in your file and run again.", FullRepositoryPath)); - continue; + continue; } string attributeUrlUriName = urlAttributeExists ? "Url" : "Uri"; + Uri repoUri = new Uri(node.Attribute(attributeUrlUriName).Value); + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsGroupPolicyEnabled() ? GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(repoUri) : true; + + RepositoryProviderType repositoryProvider= GetRepositoryProviderType(repoUri); removedRepos.Add( new PSRepositoryInfo(repo, new Uri(node.Attribute(attributeUrlUriName).Value), Int32.Parse(node.Attribute("Priority").Value), Boolean.Parse(node.Attribute("Trusted").Value), repoCredentialInfo, - (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), node.Attribute("APIVersion").Value))); + (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), node.Attribute("APIVersion").Value, ignoreCase: true), + isAllowed)); // Remove item from file node.Remove(); @@ -571,7 +591,7 @@ public static List Read(string[] repoNames, out string[] error tempErrorList.Add(String.Format("Repository element does not contain neccessary 'Priority' attribute, in file located at path: {0}. Fix this in your file and run again.", FullRepositoryPath)); continue; } - + if (repo.Attribute("Trusted") == null) { tempErrorList.Add(String.Format("Repository element does not contain neccessary 'Trusted' attribute, in file located at path: {0}. Fix this in your file and run again.", FullRepositoryPath)); @@ -584,7 +604,7 @@ public static List Read(string[] repoNames, out string[] error if (!urlAttributeExists && !uriAttributeExists) { tempErrorList.Add(String.Format("Repository element does not contain neccessary 'Url' or equivalent 'Uri' attribute (it must contain one), in file located at path: {0}. Fix this in your file and run again.", FullRepositoryPath)); - continue; + continue; } Uri thisUrl = null; @@ -649,12 +669,17 @@ public static List Read(string[] repoNames, out string[] error continue; } + RepositoryProviderType repositoryProvider= GetRepositoryProviderType(thisUrl); + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsGroupPolicyEnabled() ? GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(thisUrl) : true; + PSRepositoryInfo currentRepoItem = new PSRepositoryInfo(repo.Attribute("Name").Value, thisUrl, Int32.Parse(repo.Attribute("Priority").Value), Boolean.Parse(repo.Attribute("Trusted").Value), thisCredentialInfo, - (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), repo.Attribute("APIVersion").Value)); + (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), repo.Attribute("APIVersion").Value, ignoreCase: true), + isAllowed); foundRepos.Add(currentRepoItem); } @@ -673,7 +698,7 @@ public static List Read(string[] repoNames, out string[] error tempErrorList.Add(String.Format("Repository element does not contain neccessary 'Priority' attribute, in file located at path: {0}. Fix this in your file and run again.", FullRepositoryPath)); continue; } - + if (node.Attribute("Trusted") == null) { tempErrorList.Add(String.Format("Repository element does not contain neccessary 'Trusted' attribute, in file located at path: {0}. Fix this in your file and run again.", FullRepositoryPath)); @@ -688,7 +713,7 @@ public static List Read(string[] repoNames, out string[] error if (!urlAttributeExists && !uriAttributeExists) { tempErrorList.Add(String.Format("Repository element does not contain neccessary 'Url' or equivalent 'Uri' attribute (it must contain one), in file located at path: {0}. Fix this in your file and run again.", FullRepositoryPath)); - continue; + continue; } Uri thisUrl = null; @@ -752,12 +777,17 @@ public static List Read(string[] repoNames, out string[] error continue; } + RepositoryProviderType repositoryProvider= GetRepositoryProviderType(thisUrl); + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsGroupPolicyEnabled() ? GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(thisUrl) : true; + PSRepositoryInfo currentRepoItem = new PSRepositoryInfo(node.Attribute("Name").Value, thisUrl, Int32.Parse(node.Attribute("Priority").Value), Boolean.Parse(node.Attribute("Trusted").Value), thisCredentialInfo, - (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), node.Attribute("APIVersion").Value)); + (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), node.Attribute("APIVersion").Value, ignoreCase: true), + isAllowed); foundRepos.Add(currentRepoItem); } @@ -772,7 +802,7 @@ public static List Read(string[] repoNames, out string[] error errorList = tempErrorList.ToArray(); // Sort by priority, then by repo name var reposToReturn = foundRepos.OrderBy(x => x.Priority).ThenBy(x => x.Name); - + return reposToReturn.ToList(); } @@ -783,7 +813,7 @@ public static List Read(string[] repoNames, out string[] error private static XElement FindRepositoryElement(XDocument doc, string name) { return doc.Descendants("Repository").Where( - e => e.Attribute("Name") != null && + e => e.Attribute("Name") != null && string.Equals( e.Attribute("Name").Value, name, @@ -815,29 +845,49 @@ private static PSRepositoryInfo.APIVersion GetRepoAPIVersion(Uri repoUri) if (repoUri.AbsoluteUri.EndsWith("/v2", StringComparison.OrdinalIgnoreCase)) { // Scenario: V2 server protocol repositories (i.e PSGallery) - return PSRepositoryInfo.APIVersion.v2; + return PSRepositoryInfo.APIVersion.V2; } else if (repoUri.AbsoluteUri.EndsWith("/index.json", StringComparison.OrdinalIgnoreCase)) { // Scenario: V3 server protocol repositories (i.e NuGet.org, Azure Artifacts (ADO), Artifactory, Github Packages, MyGet.org) - return PSRepositoryInfo.APIVersion.v3; + return PSRepositoryInfo.APIVersion.V3; } else if (repoUri.AbsoluteUri.EndsWith("/nuget", StringComparison.OrdinalIgnoreCase)) { // Scenario: ASP.Net application feed created with NuGet.Server to host packages - return PSRepositoryInfo.APIVersion.nugetServer; + return PSRepositoryInfo.APIVersion.NugetServer; } else if (repoUri.Scheme.Equals(Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase) || repoUri.Scheme.Equals("temp", StringComparison.OrdinalIgnoreCase)) { // repositories with Uri Scheme "temp" may have PSPath Uri's like: "Temp:\repo" and we should consider them as local repositories. - return PSRepositoryInfo.APIVersion.local; + return PSRepositoryInfo.APIVersion.Local; + } + else if (repoUri.AbsoluteUri.EndsWith(".azurecr.io") || repoUri.AbsoluteUri.EndsWith(".azurecr.io/") || repoUri.AbsoluteUri.Contains("mcr.microsoft.com")) + { + return PSRepositoryInfo.APIVersion.ContainerRegistry; } else { - return PSRepositoryInfo.APIVersion.unknown; + return PSRepositoryInfo.APIVersion.Unknown; } } + private static RepositoryProviderType GetRepositoryProviderType(Uri repoUri) + { + string absoluteUri = repoUri.AbsoluteUri; + // We want to use contains instead of EndsWith to accomodate for trailing '/' + if (absoluteUri.Contains("azurecr.io") || absoluteUri.Contains("mcr.microsoft.com")){ + return RepositoryProviderType.ACR; + } + // TODO: add a regex for this match + // eg: *pkgs.*/_packaging/* + else if (absoluteUri.Contains("pkgs.")){ + return RepositoryProviderType.AzureDevOps; + } + else { + return RepositoryProviderType.None; + } + } #endregion } } diff --git a/src/code/ResponseUtilFactory.cs b/src/code/ResponseUtilFactory.cs index 3f25d63c3..c2cf56964 100644 --- a/src/code/ResponseUtilFactory.cs +++ b/src/code/ResponseUtilFactory.cs @@ -14,23 +14,27 @@ public static ResponseUtil GetResponseUtil(PSRepositoryInfo repository) switch (repoApiVersion) { - case PSRepositoryInfo.APIVersion.v2: + case PSRepositoryInfo.APIVersion.V2: currentResponseUtil = new V2ResponseUtil(repository); break; - case PSRepositoryInfo.APIVersion.v3: + case PSRepositoryInfo.APIVersion.V3: currentResponseUtil = new V3ResponseUtil(repository); break; - case PSRepositoryInfo.APIVersion.local: + case PSRepositoryInfo.APIVersion.Local: currentResponseUtil = new LocalResponseUtil(repository); break; - case PSRepositoryInfo.APIVersion.nugetServer: + case PSRepositoryInfo.APIVersion.NugetServer: currentResponseUtil = new NuGetServerResponseUtil(repository); break; - case PSRepositoryInfo.APIVersion.unknown: + case PSRepositoryInfo.APIVersion.ContainerRegistry: + currentResponseUtil = new ContainerRegistryResponseUtil(repository); + break; + + case PSRepositoryInfo.APIVersion.Unknown: break; } diff --git a/src/code/SavePSResource.cs b/src/code/SavePSResource.cs index 48d6f4891..26d481fce 100644 --- a/src/code/SavePSResource.cs +++ b/src/code/SavePSResource.cs @@ -161,7 +161,6 @@ public string TemporaryPath /// /// Check validation for signed and catalog files - /// [Parameter] public SwitchParameter AuthenticodeCheck { get; set; } @@ -169,8 +168,15 @@ public string TemporaryPath /// /// Suppresses progress information. /// + [Parameter] public SwitchParameter Quiet { get; set; } + /// + /// For modules that require a license, AcceptLicense automatically accepts the license agreement during installation. + /// + [Parameter] + public SwitchParameter AcceptLicense { get; set; } + #endregion #region Method overrides @@ -207,7 +213,8 @@ protected override void ProcessRecord() break; case InputObjectParameterSet: - foreach (var inputObj in InputObject) { + foreach (var inputObj in InputObject) + { string normalizedVersionString = Utils.GetNormalizedVersionString(inputObj.Version.ToString(), inputObj.Prerelease); ProcessSaveHelper( pkgNames: new string[] { inputObj.Name }, @@ -230,7 +237,7 @@ protected override void ProcessRecord() private void ProcessSaveHelper(string[] pkgNames, string pkgVersion, bool pkgPrerelease, string[] pkgRepository) { WriteDebug("In SavePSResource::ProcessSaveHelper()"); - var namesToSave = Utils.ProcessNameWildcards(pkgNames, removeWildcardEntries:false, out string[] errorMsgs, out bool nameContainsWildcard); + var namesToSave = Utils.ProcessNameWildcards(pkgNames, removeWildcardEntries: false, out string[] errorMsgs, out bool nameContainsWildcard); if (nameContainsWildcard) { WriteError(new ErrorRecord( @@ -238,7 +245,7 @@ private void ProcessSaveHelper(string[] pkgNames, string pkgVersion, bool pkgPre "NameContainsWildcard", ErrorCategory.InvalidArgument, this)); - + return; } @@ -276,26 +283,27 @@ private void ProcessSaveHelper(string[] pkgNames, string pkgVersion, bool pkgPre // figure out if version is a prerelease or not. // if condition is not met, prerelease is the value passed in via the parameter. - if (!string.IsNullOrEmpty(pkgVersion) && pkgVersion.Contains('-')) { + if (!string.IsNullOrEmpty(pkgVersion) && pkgVersion.Contains('-')) + { pkgPrerelease = true; } var installedPkgs = _installHelper.BeginInstallPackages( - names: namesToSave, + names: namesToSave, versionRange: versionRange, nugetVersion: nugetVersion, versionType: versionType, versionString: pkgVersion, - prerelease: pkgPrerelease, - repository: pkgRepository, - acceptLicense: true, - quiet: Quiet, - reinstall: true, - force: false, + prerelease: pkgPrerelease, + repository: pkgRepository, + acceptLicense: AcceptLicense, + quiet: Quiet, + reinstall: true, + force: false, trustRepository: TrustRepository, - noClobber: false, - asNupkg: AsNupkg, - includeXml: IncludeXml, + noClobber: false, + asNupkg: AsNupkg, + includeXml: IncludeXml, skipDependencyCheck: SkipDependencyCheck, authenticodeCheck: AuthenticodeCheck, savePkg: true, diff --git a/src/code/ServerApiCall.cs b/src/code/ServerApiCall.cs index 047d1e1e2..4580e362e 100644 --- a/src/code/ServerApiCall.cs +++ b/src/code/ServerApiCall.cs @@ -2,10 +2,12 @@ // Licensed under the MIT License. using Microsoft.PowerShell.PSResourceGet.UtilClasses; +using System; using System.IO; using System.Net.Http; using NuGet.Versioning; using System.Net; +using System.Text; using System.Runtime.ExceptionServices; using System.Management.Automation; @@ -25,12 +27,30 @@ internal abstract class ServerApiCall : IServerAPICalls public ServerApiCall(PSRepositoryInfo repository, NetworkCredential networkCredential) { this.Repository = repository; - HttpClientHandler handler = new HttpClientHandler() + + HttpClientHandler handler = new HttpClientHandler(); + bool token = false; + + if(networkCredential != null) + { + token = String.Equals("token", networkCredential.UserName) ? true : false; + }; + + if (token) { - Credentials = networkCredential + string credString = string.Format(":{0}", networkCredential.Password); + byte[] byteArray = Encoding.ASCII.GetBytes(credString); + + _sessionClient = new HttpClient(handler); + _sessionClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(byteArray)); + } else { + + handler.Credentials = networkCredential; + + _sessionClient = new HttpClient(handler); }; + _sessionClient.Timeout = TimeSpan.FromMinutes(10); - _sessionClient = new HttpClient(handler); } #endregion diff --git a/src/code/ServerFactory.cs b/src/code/ServerFactory.cs index 8be08662e..10c43b1a3 100644 --- a/src/code/ServerFactory.cs +++ b/src/code/ServerFactory.cs @@ -1,10 +1,10 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using Microsoft.PowerShell.PSResourceGet.UtilClasses; +using System.Collections; using System.Management.Automation; -using System.Management.Automation.Runspaces; using System.Net; +using Microsoft.PowerShell.PSResourceGet.UtilClasses; namespace Microsoft.PowerShell.PSResourceGet.Cmdlets { @@ -14,7 +14,7 @@ static UserAgentInfo() { using (System.Management.Automation.PowerShell ps = System.Management.Automation.PowerShell.Create(RunspaceMode.CurrentRunspace)) { - _psVersion = ps.AddScript("$PSVersionTable.PSVersion.ToString()").Invoke()[0]; + _psVersion = ps.AddScript("$PSVersionTable").Invoke()[0]["PSVersion"].ToString(); } _psResourceGetVersion = typeof(UserAgentInfo).Assembly.GetName().Version.ToString(); @@ -42,23 +42,27 @@ public static ServerApiCall GetServer(PSRepositoryInfo repository, PSCmdlet cmdl switch (repoApiVersion) { - case PSRepositoryInfo.APIVersion.v2: + case PSRepositoryInfo.APIVersion.V2: currentServer = new V2ServerAPICalls(repository, cmdletPassedIn, networkCredential, userAgentString); break; - case PSRepositoryInfo.APIVersion.v3: + case PSRepositoryInfo.APIVersion.V3: currentServer = new V3ServerAPICalls(repository, cmdletPassedIn, networkCredential, userAgentString); break; - case PSRepositoryInfo.APIVersion.local: + case PSRepositoryInfo.APIVersion.Local: currentServer = new LocalServerAPICalls(repository, cmdletPassedIn, networkCredential); break; - case PSRepositoryInfo.APIVersion.nugetServer: + case PSRepositoryInfo.APIVersion.NugetServer: currentServer = new NuGetServerAPICalls(repository, cmdletPassedIn, networkCredential, userAgentString); break; - case PSRepositoryInfo.APIVersion.unknown: + case PSRepositoryInfo.APIVersion.ContainerRegistry: + currentServer = new ContainerRegistryServerAPICalls(repository, cmdletPassedIn, networkCredential, userAgentString); + break; + + case PSRepositoryInfo.APIVersion.Unknown: break; } diff --git a/src/code/UninstallPSResource.cs b/src/code/UninstallPSResource.cs index 2769cd713..4d7862fbb 100644 --- a/src/code/UninstallPSResource.cs +++ b/src/code/UninstallPSResource.cs @@ -263,7 +263,7 @@ private bool UninstallPkgHelper(out List errRecords) /* uninstalls a module */ private bool UninstallModuleHelper(string pkgPath, string pkgName, out ErrorRecord errRecord) - { + { WriteDebug("In UninstallPSResource::UninstallModuleHelper"); errRecord = null; var successfullyUninstalledPkg = false; @@ -324,7 +324,7 @@ private bool UninstallModuleHelper(string pkgPath, string pkgName, out ErrorReco /* uninstalls a script */ private bool UninstallScriptHelper(string pkgPath, string pkgName, out ErrorRecord errRecord) - { + { WriteDebug("In UninstallPSResource::UninstallScriptHelper"); errRecord = null; var successfullyUninstalledPkg = false; @@ -375,7 +375,7 @@ private bool UninstallScriptHelper(string pkgPath, string pkgName, out ErrorReco } private bool CheckIfDependency(string pkgName, string version, out ErrorRecord errorRecord) - { + { WriteDebug("In UninstallPSResource::CheckIfDependency"); // Checking if a specific package version is a dependency anywhere // this is a primitive implementation diff --git a/src/code/UpdateModuleManifest.cs b/src/code/UpdateModuleManifest.cs index 7c2eeab05..8ba86d87a 100644 --- a/src/code/UpdateModuleManifest.cs +++ b/src/code/UpdateModuleManifest.cs @@ -4,6 +4,7 @@ using Microsoft.PowerShell.PSResourceGet.UtilClasses; using System; using System.Collections; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Management.Automation; @@ -307,6 +308,30 @@ protected override void EndProcessing() this)); } + // Due to a PowerShell New-ModuleManifest bug with the PrivateData entry when it's a nested hashtable (https://github.com/PowerShell/PowerShell/issues/5922) + // we have to handle PrivateData entry, and thus module manifest creation, differently on PSCore than on WindowsPowerShell. + ErrorRecord errorRecord = null; + if (Utils.GetIsWindowsPowerShell(this)) + { + CreateModuleManifestForWinPSHelper(parsedMetadata, resolvedManifestPath, out errorRecord); + } + else + { + CreateModuleManifestHelper(parsedMetadata, resolvedManifestPath, out errorRecord); + } + + if (errorRecord != null) + { + ThrowTerminatingError(errorRecord); + } + } + + /// + /// Handles module manifest creation for non-WindowsPowerShell platforms. + /// + private void CreateModuleManifestHelper(Hashtable parsedMetadata, string resolvedManifestPath, out ErrorRecord errorRecord) + { + errorRecord = null; // Prerelease, ReleaseNotes, Tags, ProjectUri, LicenseUri, IconUri, RequireLicenseAcceptance, // and ExternalModuleDependencies are all properties within a hashtable property called 'PSData' // which is within another hashtable property called 'PrivateData' @@ -343,60 +368,67 @@ protected override void EndProcessing() // } # End of PSData hashtable // // } # End of PrivateData hashtable - var PrivateData = parsedMetadata["PrivateData"] as Hashtable; - var PSData = PrivateData["PSData"] as Hashtable; - if (PSData.ContainsKey("Prerelease")) + Hashtable privateData = new Hashtable(); + + if (PrivateData != null && PrivateData.Count != 0) + { + privateData = PrivateData; + } + else + { + privateData = parsedMetadata["PrivateData"] as Hashtable; + } + + var psData = privateData["PSData"] as Hashtable; + + if (psData.ContainsKey("Prerelease")) { - parsedMetadata["Prerelease"] = PSData["Prerelease"]; + parsedMetadata["Prerelease"] = psData["Prerelease"]; } - if (PSData.ContainsKey("ReleaseNotes")) + if (psData.ContainsKey("ReleaseNotes")) { - parsedMetadata["ReleaseNotes"] = PSData["ReleaseNotes"]; + parsedMetadata["ReleaseNotes"] = psData["ReleaseNotes"]; } - if (PSData.ContainsKey("Tags")) + if (psData.ContainsKey("Tags")) { - parsedMetadata["Tags"] = PSData["Tags"]; + parsedMetadata["Tags"] = psData["Tags"]; } - if (PSData.ContainsKey("ProjectUri")) + if (psData.ContainsKey("ProjectUri")) { - parsedMetadata["ProjectUri"] = PSData["ProjectUri"]; + parsedMetadata["ProjectUri"] = psData["ProjectUri"]; } - if (PSData.ContainsKey("LicenseUri")) + if (psData.ContainsKey("LicenseUri")) { - parsedMetadata["LicenseUri"] = PSData["LicenseUri"]; + parsedMetadata["LicenseUri"] = psData["LicenseUri"]; } - if (PSData.ContainsKey("IconUri")) + if (psData.ContainsKey("IconUri")) { - parsedMetadata["IconUri"] = PSData["IconUri"]; + parsedMetadata["IconUri"] = psData["IconUri"]; } - if (PSData.ContainsKey("RequireLicenseAcceptance")) + if (psData.ContainsKey("RequireLicenseAcceptance")) { - parsedMetadata["RequireLicenseAcceptance"] = PSData["RequireLicenseAcceptance"]; + parsedMetadata["RequireLicenseAcceptance"] = psData["RequireLicenseAcceptance"]; } - if (PSData.ContainsKey("ExternalModuleDependencies")) + if (psData.ContainsKey("ExternalModuleDependencies")) { - parsedMetadata["ExternalModuleDependencies"] = PSData["ExternalModuleDependencies"]; + parsedMetadata["ExternalModuleDependencies"] = psData["ExternalModuleDependencies"]; } // Now we need to remove 'PSData' becaues if we leave this value in the hashtable, // New-ModuleManifest will keep this value and also attempt to create a new value for 'PSData' // and then complain that there's two keys within the PrivateData hashtable. - PrivateData.Remove("PSData"); - - // After getting the original module manifest contents, migrate all the fields to the new module manifest, - - // adding in any new values specified via cmdlet parameters. - // Set up params to pass to New-ModuleManifest module - // For now this will be parsedMetadata hashtable and we will just add to it as needed + // This is due to the issue of New-ModuleManifest when the PrivateData entry is a nested hashtable (https://github.com/PowerShell/PowerShell/issues/5922). + privateData.Remove("PSData"); + // After getting the original module manifest contents, migrate all the fields to the parsedMetadata hashtable which will be provided as params for New-ModuleManifest. if (NestedModules != null) { parsedMetadata["NestedModules"] = NestedModules; @@ -572,7 +604,7 @@ protected override void EndProcessing() parsedMetadata["Prerelease"] = Prerelease; } - if (RequireLicenseAcceptance != null) + if (RequireLicenseAcceptance != null && RequireLicenseAcceptance.IsPresent) { parsedMetadata["RequireLicenseAcceptance"] = RequireLicenseAcceptance; } @@ -590,11 +622,15 @@ protected override void EndProcessing() } catch (Exception e) { - ThrowTerminatingError(new ErrorRecord( + Utils.DeleteDirectory(tmpParentPath); + + errorRecord = new ErrorRecord( new ArgumentException(e.Message), "ErrorCreatingTempDir", ErrorCategory.InvalidData, - this)); + this); + + return; } string tmpModuleManifestPath = System.IO.Path.Combine(tmpParentPath, System.IO.Path.GetFileName(resolvedManifestPath)); @@ -605,7 +641,7 @@ protected override void EndProcessing() { try { - var results = pwsh.AddCommand("Microsoft.PowerShell.Core\\New-ModuleManifest").AddArgument(new object[] { parsedMetadata }).Invoke(); + var results = pwsh.AddCommand("Microsoft.PowerShell.Core\\New-ModuleManifest").AddParameters(parsedMetadata).Invoke(); if (pwsh.HadErrors || pwsh.Streams.Error.Count > 0) { foreach (var err in pwsh.Streams.Error) @@ -616,11 +652,13 @@ protected override void EndProcessing() } catch (Exception e) { - ThrowTerminatingError(new ErrorRecord( + errorRecord = new ErrorRecord( new ArgumentException($"Error occured while running 'New-ModuleManifest': {e.Message}"), "ErrorExecutingNewModuleManifest", ErrorCategory.InvalidArgument, - this)); + this); + + return; } } @@ -630,13 +668,545 @@ protected override void EndProcessing() WriteVerbose($"Moving '{tmpModuleManifestPath}' to '{resolvedManifestPath}'"); Utils.MoveFiles(tmpModuleManifestPath, resolvedManifestPath, overwrite: true); } + catch (Exception e) + { + errorRecord = new ErrorRecord( + e, + "CreateModuleManifestFailed", + ErrorCategory.InvalidOperation, + this); + } + finally { + // Clean up temp file if move fails + if (File.Exists(tmpModuleManifestPath)) + { + File.Delete(tmpModuleManifestPath); + } + + Utils.DeleteDirectory(tmpParentPath); + } + } + + /// + /// Handles module manifest creation for Windows PowerShell platform. + /// Since the code calls New-ModuleManifest and the Windows PowerShell version of the cmdlet did not have Prerelease, ExternalModuleDependencies and RequireLicenseAcceptance parameters, + /// we can't simply call New-ModuleManifest with all parameters. Instead, create the manifest without PrivateData parameter (and the keys usually inside it) and then update the lines for PrivateData later. + /// + private void CreateModuleManifestForWinPSHelper(Hashtable parsedMetadata, string resolvedManifestPath, out ErrorRecord errorRecord) + { + // Note on priority of values: + // If -PrivateData parameter was provided with the cmdlet & .psd1 file PrivateData already had values, the passed in -PrivateData values replace those previosuly there. + // any direct parameters supplied by the user (i.e ProjectUri) [takes priority over but in mix-and-match fashion] over -> -PrivateData parameter [takes priority over but in replacement fashion] over -> original .psd1 file's PrivateData values (complete replacement) + errorRecord = null; + string[] tags = Utils.EmptyStrArray; + Uri licenseUri = null; + Uri iconUri = null; + Uri projectUri = null; + string prerelease = String.Empty; + string releaseNotes = String.Empty; + bool? requireLicenseAcceptance = null; + string[] externalModuleDependencies = Utils.EmptyStrArray; + + Hashtable privateData = new Hashtable(); + if (PrivateData != null && PrivateData.Count != 0) + { + privateData = PrivateData; + } + else + { + privateData = parsedMetadata["PrivateData"] as Hashtable; + } + + var psData = privateData["PSData"] as Hashtable; + + if (psData.ContainsKey("Prerelease")) + { + prerelease = psData["Prerelease"] as string; + } + + if (psData.ContainsKey("ReleaseNotes")) + { + releaseNotes = psData["ReleaseNotes"] as string; + } + + if (psData.ContainsKey("Tags")) + { + tags = psData["Tags"] as string[]; + } + + if (psData.ContainsKey("ProjectUri") && psData["ProjectUri"] is string projectUriString) + { + if (!Uri.TryCreate(projectUriString, UriKind.Absolute, out projectUri)) + { + projectUri = null; + } + } + + if (psData.ContainsKey("LicenseUri") && psData["LicenseUri"] is string licenseUriString) + { + if (!Uri.TryCreate(licenseUriString, UriKind.Absolute, out licenseUri)) + { + licenseUri = null; + } + } + + if (psData.ContainsKey("IconUri") && psData["IconUri"] is string iconUriString) + { + if (!Uri.TryCreate(iconUriString, UriKind.Absolute, out iconUri)) + { + iconUri = null; + } + } + + if (psData.ContainsKey("RequireLicenseAcceptance")) + { + requireLicenseAcceptance = psData["RequireLicenseAcceptance"] as bool?; + } + + if (psData.ContainsKey("ExternalModuleDependencies")) + { + externalModuleDependencies = psData["ExternalModuleDependencies"] as string[]; + } + + // the rest of the parameters can be directly provided to New-ModuleManifest, so add it parsedMetadata hashtable used for cmdlet parameters. + if (NestedModules != null) + { + parsedMetadata["NestedModules"] = NestedModules; + } + + if (Guid != Guid.Empty) + { + parsedMetadata["Guid"] = Guid; + } + + if (!string.IsNullOrWhiteSpace(Author)) + { + parsedMetadata["Author"] = Author; + } + + if (CompanyName != null) + { + parsedMetadata["CompanyName"] = CompanyName; + } + + if (Copyright != null) + { + parsedMetadata["Copyright"] = Copyright; + } + + if (RootModule != null) + { + parsedMetadata["RootModule"] = RootModule; + } + + if (ModuleVersion != null) + { + parsedMetadata["ModuleVersion"] = ModuleVersion; + } + + if (Description != null) + { + parsedMetadata["Description"] = Description; + } + + if (ProcessorArchitecture != ProcessorArchitecture.None) + { + parsedMetadata["ProcessorArchitecture"] = ProcessorArchitecture; + } + + if (PowerShellVersion != null) + { + parsedMetadata["PowerShellVersion"] = PowerShellVersion; + } + + if (ClrVersion != null) + { + parsedMetadata["ClrVersion"] = ClrVersion; + } + + if (DotNetFrameworkVersion != null) + { + parsedMetadata["DotNetFrameworkVersion"] = DotNetFrameworkVersion; + } + + if (PowerShellHostName != null) + { + parsedMetadata["PowerShellHostName"] = PowerShellHostName; + } + + if (PowerShellHostVersion != null) + { + parsedMetadata["PowerShellHostVersion"] = PowerShellHostVersion; + } + + if (RequiredModules != null) + { + parsedMetadata["RequiredModules"] = RequiredModules; + } + + if (TypesToProcess != null) + { + parsedMetadata["TypesToProcess"] = TypesToProcess; + } + + if (FormatsToProcess != null) + { + parsedMetadata["FormatsToProcess"] = FormatsToProcess; + } + + if (ScriptsToProcess != null) + { + parsedMetadata["ScriptsToProcess"] = ScriptsToProcess; + } + + if (RequiredAssemblies != null) + { + parsedMetadata["RequiredAssemblies"] = RequiredAssemblies; + } + + if (FileList != null) + { + parsedMetadata["FileList"] = FileList; + } + + if (ModuleList != null) + { + parsedMetadata["ModuleList"] = ModuleList; + } + + if (FunctionsToExport != null) + { + parsedMetadata["FunctionsToExport"] = FunctionsToExport; + } + + if (AliasesToExport != null) + { + parsedMetadata["AliasesToExport"] = AliasesToExport; + } + + if (VariablesToExport != null) + { + parsedMetadata["VariablesToExport"] = VariablesToExport; + } + + if (CmdletsToExport != null) + { + parsedMetadata["CmdletsToExport"] = CmdletsToExport; + } + + if (DscResourcesToExport != null) + { + parsedMetadata["DscResourcesToExport"] = DscResourcesToExport; + } + + if (CompatiblePSEditions != null) + { + parsedMetadata["CompatiblePSEditions"] = CompatiblePSEditions; + } + + if (HelpInfoUri != null) + { + parsedMetadata["HelpInfoUri"] = HelpInfoUri; + } + + if (DefaultCommandPrefix != null) + { + parsedMetadata["DefaultCommandPrefix"] = DefaultCommandPrefix; + } + + // if values were passed in for these parameters, they will be prioritized over values retrieved from PrivateData + // we need to populate the local variables with their values to use for PrivateData entry creation later. + // and parameters that can be passed to New-ModuleManifest are added to the parsedMetadata hashtable. + if (Tags != null) + { + tags = Tags; + parsedMetadata["Tags"] = tags; + } + + if (LicenseUri != null) + { + licenseUri = LicenseUri; + parsedMetadata["LicenseUri"] = licenseUri; + } + + if (ProjectUri != null) + { + projectUri = ProjectUri; + parsedMetadata["ProjectUri"] = projectUri; + } + + if (IconUri != null) + { + iconUri = IconUri; + parsedMetadata["IconUri"] = iconUri; + } + + if (ReleaseNotes != null) + { + releaseNotes = ReleaseNotes; + parsedMetadata["ReleaseNotes"] = releaseNotes; + } + + // New-ModuleManifest on WinPS doesn't support parameters: Prerelease, RequireLicenseAcceptance, and ExternalModuleDependencies so we don't add those to parsedMetadata hashtable. + if (Prerelease != null) + { + prerelease = Prerelease; + } + + if (RequireLicenseAcceptance != null && RequireLicenseAcceptance.IsPresent) + { + requireLicenseAcceptance = RequireLicenseAcceptance; + } + + if (ExternalModuleDependencies != null) + { + externalModuleDependencies = ExternalModuleDependencies; + } + + // create a tmp path to create the module manifest + string tmpParentPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString()); + try + { + Directory.CreateDirectory(tmpParentPath); + } + catch (Exception e) + { + Utils.DeleteDirectory(tmpParentPath); + + errorRecord = new ErrorRecord( + new ArgumentException(e.Message), + "ErrorCreatingTempDir", + ErrorCategory.InvalidData, + this); + + return; + } + + string tmpModuleManifestPath = System.IO.Path.Combine(tmpParentPath, System.IO.Path.GetFileName(resolvedManifestPath)); + parsedMetadata["Path"] = tmpModuleManifestPath; + WriteVerbose($"Temp path created for new module manifest is: {tmpModuleManifestPath}"); + + using (System.Management.Automation.PowerShell pwsh = System.Management.Automation.PowerShell.Create()) + { + try + { + var results = pwsh.AddCommand("Microsoft.PowerShell.Core\\New-ModuleManifest").AddParameters(parsedMetadata).Invoke(); + if (pwsh.HadErrors || pwsh.Streams.Error.Count > 0) + { + foreach (var err in pwsh.Streams.Error) + { + WriteError(err); + } + } + } + catch (Exception e) + { + Utils.DeleteDirectory(tmpParentPath); + + errorRecord = new ErrorRecord( + new ArgumentException($"Error occured while running 'New-ModuleManifest': {e.Message}"), + "ErrorExecutingNewModuleManifest", + ErrorCategory.InvalidArgument, + this); + + return; + } + } + + string privateDataString = GetPrivateDataString(tags, licenseUri, projectUri, iconUri, releaseNotes, prerelease, requireLicenseAcceptance, externalModuleDependencies); + + // create new file in tmp path for updated module manifest (i.e updated with PrivateData entry) + string newTmpModuleManifestPath = System.IO.Path.Combine(tmpParentPath, "Updated" + System.IO.Path.GetFileName(resolvedManifestPath)); + if (!TryCreateNewPsd1WithUpdatedPrivateData(privateDataString, tmpModuleManifestPath, newTmpModuleManifestPath, out errorRecord)) + { + return; + } + + try + { + // Move to the new module manifest back to the original location + WriteVerbose($"Moving '{newTmpModuleManifestPath}' to '{resolvedManifestPath}'"); + Utils.MoveFiles(newTmpModuleManifestPath, resolvedManifestPath, overwrite: true); + } + catch (Exception e) + { + errorRecord = new ErrorRecord( + e, + "CreateModuleManifestForWinPSFailed", + ErrorCategory.InvalidOperation, + this); + } finally { // Clean up temp file if move fails if (File.Exists(tmpModuleManifestPath)) { File.Delete(tmpModuleManifestPath); } + + if (File.Exists(newTmpModuleManifestPath)) + { + File.Delete(newTmpModuleManifestPath); + } + + Utils.DeleteDirectory(tmpParentPath); + } + } + + /// + /// Returns string representing PrivateData entry for .psd1 file. This used for WinPS .psd1 creation as these values could not be populated otherwise. + /// + private string GetPrivateDataString(string[] tags, Uri licenseUri, Uri projectUri, Uri iconUri, string releaseNotes, string prerelease, bool? requireLicenseAcceptance, string[] externalModuleDependencies) + { + /** + Example PrivateData + + PrivateData = @{ + PSData = @{ + # Tags applied to this module. These help with module discovery in online galleries. + Tags = @('Tag1', 'Tag2') + + # A URL to the license for this module. + LicenseUri = 'https://www.licenseurl.com/' + + # A URL to the main website for this project. + ProjectUri = 'https://www.projecturi.com/' + + # A URL to an icon representing this module. + IconUri = 'https://iconuri.com/' + + # ReleaseNotes of this module. + ReleaseNotes = 'These are the release notes of this module.' + + # Prerelease string of this module. + Prerelease = 'preview' + + # Flag to indicate whether the module requires explicit user acceptance for install/update/save. + RequireLicenseAcceptance = $false + + # External dependent modules of this module + ExternalModuleDependencies = @('ModuleDep1, 'ModuleDep2') + + } # End of PSData hashtable + + } # End of PrivateData hashtable + */ + + string tagsString = string.Join(", ", tags.Select(item => "'" + item + "'")); + string tagLine = tags.Length != 0 ? $"Tags = @({tagsString})" : "# Tags = @()"; + + string licenseUriLine = licenseUri == null ? "# LicenseUri = ''" : $"LicenseUri = '{licenseUri.ToString()}'"; + string projectUriLine = projectUri == null ? "# ProjectUri = ''" : $"ProjectUri = '{projectUri.ToString()}'"; + string iconUriLine = iconUri == null ? "# IconUri = ''" : $"IconUri = '{iconUri.ToString()}'"; + + string releaseNotesLine = String.IsNullOrEmpty(releaseNotes) ? "# ReleaseNotes = ''": $"ReleaseNotes = '{releaseNotes}'"; + string prereleaseLine = String.IsNullOrEmpty(prerelease) ? "# Prerelease = ''" : $"Prerelease = '{prerelease}'"; + + string requireLicenseAcceptanceLine = requireLicenseAcceptance == null? "# RequireLicenseAcceptance = $false" : (requireLicenseAcceptance == false ? "RequireLicenseAcceptance = $false": "RequireLicenseAcceptance = $true"); + + string externalModuleDependenciesString = string.Join(", ", externalModuleDependencies.Select(item => "'" + item + "'")); + string externalModuleDependenciesLine = externalModuleDependencies.Length == 0 ? "# ExternalModuleDependencies = @()" : $"ExternalModuleDependencies = @({externalModuleDependenciesString})"; + + string initialPrivateDataString = "PrivateData = @{" + System.Environment.NewLine + "PSData = @{" + System.Environment.NewLine; + + string privateDataString = $@" + # Tags applied to this module. These help with module discovery in online galleries. + {tagLine} + + # A URL to the license for this module. + {licenseUriLine} + + # A URL to the main website for this project. + {projectUriLine} + + # A URL to an icon representing this module. + {iconUriLine} + + # ReleaseNotes of this module + {releaseNotesLine} + + # Prerelease string of this module + {prereleaseLine} + + # Flag to indicate whether the module requires explicit user acceptance for install/update/save + {requireLicenseAcceptanceLine} + + # External dependent modules of this module + {externalModuleDependenciesLine}"; + + string endingPrivateDataString = System.Environment.NewLine + "} # End of PSData hashtable" + System.Environment.NewLine + "} # End of PrivateData hashtable"; + + return initialPrivateDataString + privateDataString + endingPrivateDataString; + } + + /// + /// Replaces the default PrivateData entry in the .psd1 created with the values as parameters (either direct i.e as -Prerelease or via -PrivateData i.e PrivateData.PSData.Prerelease) + /// This used for WinPS .psd1 creation as the correct PrivateData entry could not be populated otherwise. + /// + private bool TryCreateNewPsd1WithUpdatedPrivateData(string privateDataString, string tmpModuleManifestPath, string newTmpModuleManifestPath, out ErrorRecord errorRecord) + { + errorRecord = null; + string[] psd1FileLines = File.ReadAllLines(tmpModuleManifestPath); + + int privateDataStartLine = 0; + int privateDataEndLine = 0; + + // find line that is start of PrivateData entry + for (int i = 0; i < psd1FileLines.Length; i++) + { + if (psd1FileLines[i].Trim().StartsWith("PrivateData =")){ + privateDataStartLine = i; + break; + } } + + // next find line that is end of the PrivateData entry + int leftBracket = 0; + for (int i = privateDataStartLine; i < psd1FileLines.Length; i++) + { + if (psd1FileLines[i].Contains("{")) + { + leftBracket++; + } + else if(psd1FileLines[i].Contains("}")) + { + if (leftBracket > 0) + { + leftBracket--; + } + + if (leftBracket == 0) + { + privateDataEndLine = i; + break; + } + } + } + + if (privateDataEndLine == 0) + { + errorRecord = new ErrorRecord( + new InvalidOperationException($"Could not locate/parse ending bracket for the PrivateData hashtable entry in module manifest (.psd1 file)."), + "PrivateDataEntryParsingError", + ErrorCategory.InvalidOperation, + this); + + return false; + } + + List newPsd1Lines = new List(); + for (int i = 0; i < privateDataStartLine; i++) + { + newPsd1Lines.Add(psd1FileLines[i]); + } + + newPsd1Lines.Add(privateDataString); + for (int i = privateDataEndLine+1; i < psd1FileLines.Length; i++) + { + newPsd1Lines.Add(psd1FileLines[i]); + } + + File.WriteAllLines(newTmpModuleManifestPath, newPsd1Lines); + return true; } #endregion diff --git a/src/code/UpdatePSResource.cs b/src/code/UpdatePSResource.cs index fc4401f97..86e2cf1ae 100644 --- a/src/code/UpdatePSResource.cs +++ b/src/code/UpdatePSResource.cs @@ -321,6 +321,15 @@ private string[] ProcessPackageNames( latestInstalledIsPrerelease = true; } + // Update from the repository where the package was previously installed from. + // If user explicitly specifies a repository to update from, use that instead. + if (Repository == null) + { + Repository = new String[] { installedPackages.First().Value.Repository }; + + WriteDebug($"Updating from repository '{string.Join(", ", Repository)}"); + } + // Find all packages selected for updating in provided repositories. var repositoryPackages = new Dictionary(StringComparer.InvariantCultureIgnoreCase); foreach (var foundResource in _findHelper.FindByResourceName( diff --git a/src/code/Utils.cs b/src/code/Utils.cs index 1a025ffda..89de7cd10 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -1,4 +1,3 @@ -using System.Net; // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. @@ -15,7 +14,16 @@ using System.Runtime.InteropServices; using Microsoft.PowerShell.Commands; using Microsoft.PowerShell.PSResourceGet.Cmdlets; +using System.Net; using System.Net.Http; +using System.Globalization; +using System.Security; +using Azure.Core; +using Azure.Identity; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; namespace Microsoft.PowerShell.PSResourceGet.UtilClasses { @@ -38,8 +46,9 @@ public enum MetadataFileType #region String fields public static readonly string[] EmptyStrArray = Array.Empty(); - public static readonly char[] WhitespaceSeparator = new char[]{' '}; + public static readonly char[] WhitespaceSeparator = new char[] { ' ' }; public const string PSDataFileExt = ".psd1"; + public const string PSScriptFileExt = ".ps1"; private const string ConvertJsonToHashtableScript = @" param ( [string] $json @@ -83,6 +92,12 @@ function ConvertToHash #endregion + #region Path fields + + private static string s_tempHome = null; + + #endregion + #region String methods public static string TrimQuotes(string name) @@ -120,7 +135,7 @@ public static string[] GetStringArrayFromString(string[] delimeter, string strin return stringToConvertToArray.Split(delimeter, StringSplitOptions.RemoveEmptyEntries); } - + /// /// Converts an ArrayList of object types to a string array. /// @@ -129,7 +144,7 @@ public static string[] GetStringArray(ArrayList list) if (list == null) { return null; } var strArray = new string[list.Count]; - for (int i=0; i < list.Count; i++) + for (int i = 0; i < list.Count; i++) { strArray[i] = list[i] as string; } @@ -150,7 +165,7 @@ public static string[] ProcessNameWildcards( { isContainWildcard = true; errorMsgs = errorMsgsList.ToArray(); - return new string[] {"*"}; + return new string[] { "*" }; } isContainWildcard = false; @@ -160,7 +175,7 @@ public static string[] ProcessNameWildcards( { if (removeWildcardEntries) { - // Tag // CommandName // DSCResourceName + // Tag // CommandName // DSCResourceName errorMsgsList.Add($"{name} will be discarded from the provided entries."); continue; } @@ -168,8 +183,8 @@ public static string[] ProcessNameWildcards( if (String.Equals(name, "*", StringComparison.InvariantCultureIgnoreCase)) { isContainWildcard = true; - errorMsgs = new string[] {}; - return new string[] {"*"}; + errorMsgs = new string[] { }; + return new string[] { "*" }; } if (name.Contains("?") || name.Contains("[")) @@ -251,20 +266,21 @@ public static bool TryGetVersionType( string[] versionSplit = version.Split(new string[] { "." }, StringSplitOptions.None); if (versionSplit.Length == 2 && versionSplit[1].Equals("*")) { - // eg: 2.* should translate to the version range "[2.0,2.99999]" + // eg: 2.* should translate to the version range "[2.0,2.99999]" modifiedVersion = $"[{versionSplit[0]}.0,{versionSplit[0]}.999999]"; } else if (versionSplit.Length == 3 && versionSplit[2].Equals("*")) { - // eg: 2.1.* should translate to the version range "[2.1.0,2.1.99999]" + // eg: 2.1.* should translate to the version range "[2.1.0,2.1.99999]" modifiedVersion = $"[{versionSplit[0]}.{versionSplit[1]}.0,{versionSplit[0]}.{versionSplit[1]}.999999]"; } else if (versionSplit.Length == 4 && versionSplit[3].Equals("*")) { - // eg: 2.8.8.* should translate to the version range "[2.1.3.0,2.1.3.99999]" + // eg: 2.8.8.* should translate to the version range "[2.1.3.0,2.1.3.99999]" modifiedVersion = $"[{versionSplit[0]}.{versionSplit[1]}.{versionSplit[2]}.0,{versionSplit[0]}.{versionSplit[1]}.{versionSplit[2]}.999999]"; } - else { + else + { error = "Argument for -Version parameter is not in the proper format"; return false; } @@ -300,9 +316,11 @@ public static string GetNormalizedVersionString( string versionString, string prerelease) { - // versionString may be like 1.2.0.0 or 1.2.0 + // versionString may be like 1.2.0.0 or 1.2.0 or 1.2 // prerelease may be null or "alpha1" // possible passed in examples: + // versionString: "1.2" <- container registry 2 digit version + // versionString: "1.2" prerelease: "alpha1" <- container registry 2 digit version // versionString: "1.2.0" prerelease: "alpha1" // versionString: "1.2.0" prerelease: "" <- doubtful though // versionString: "1.2.0.0" prerelease: "alpha1" @@ -315,9 +333,10 @@ public static string GetNormalizedVersionString( int numVersionDigits = versionString.Split('.').Count(); - if (numVersionDigits == 3) + if (numVersionDigits == 2 || numVersionDigits == 3) { - // versionString: "1.2.0" prerelease: "alpha1" + // versionString: "1.2.0" prerelease: "alpha1" -> 1.2.0-alpha1 + // versionString: "1.2" prerelease: "alpha1" -> 1.2-alpha1 return versionString + "-" + prerelease; } else if (numVersionDigits == 4) @@ -421,7 +440,7 @@ public static bool TryCreateValidUri( { // This is needed for a relative path Uri string. Does not throw error for an absolute path. var filePath = cmdletPassedIn.GetResolvedProviderPathFromPSPath(uriString, out ProviderInfo provider).First(); - + if (Uri.TryCreate(filePath, UriKind.Absolute, out uriResult)) { return true; @@ -458,15 +477,15 @@ public static bool TryCreateValidPSCredentialInfo( try { - if (!string.IsNullOrEmpty((string) credentialInfoCandidate.Properties[PSCredentialInfo.VaultNameAttribute]?.Value) - && !string.IsNullOrEmpty((string) credentialInfoCandidate.Properties[PSCredentialInfo.SecretNameAttribute]?.Value)) + if (!string.IsNullOrEmpty((string)credentialInfoCandidate.Properties[PSCredentialInfo.VaultNameAttribute]?.Value) + && !string.IsNullOrEmpty((string)credentialInfoCandidate.Properties[PSCredentialInfo.SecretNameAttribute]?.Value)) { PSCredential credential = null; if (credentialInfoCandidate.Properties[PSCredentialInfo.CredentialAttribute] != null) { try { - credential = (PSCredential) credentialInfoCandidate.Properties[PSCredentialInfo.CredentialAttribute].Value; + credential = (PSCredential)credentialInfoCandidate.Properties[PSCredentialInfo.CredentialAttribute].Value; } catch (Exception e) { @@ -481,8 +500,8 @@ public static bool TryCreateValidPSCredentialInfo( } repoCredentialInfo = new PSCredentialInfo( - (string) credentialInfoCandidate.Properties[PSCredentialInfo.VaultNameAttribute].Value, - (string) credentialInfoCandidate.Properties[PSCredentialInfo.SecretNameAttribute].Value, + (string)credentialInfoCandidate.Properties[PSCredentialInfo.VaultNameAttribute].Value, + (string)credentialInfoCandidate.Properties[PSCredentialInfo.SecretNameAttribute].Value, credential ); @@ -598,6 +617,10 @@ public static PSCredential GetRepositoryCredentialFromSecretManagement( { return secretCredential; } + else if (secretObject.BaseObject is SecureString secretString) + { + return new PSCredential("token", secretString); + } } cmdletPassedIn.ThrowTerminatingError( @@ -620,11 +643,98 @@ public static PSCredential GetRepositoryCredentialFromSecretManagement( "RepositoryCredentialCannotGetSecretFromVault", ErrorCategory.InvalidOperation, cmdletPassedIn)); - + return null; } } + public static string GetAzAccessToken() + { + var credOptions = new DefaultAzureCredentialOptions + { + ExcludeEnvironmentCredential = true, + ExcludeVisualStudioCodeCredential = true, + ExcludeVisualStudioCredential = true, + ExcludeWorkloadIdentityCredential = true, + ExcludeManagedIdentityCredential = true, // ManagedIdentityCredential makes the experience slow + ExcludeSharedTokenCacheCredential = true, // SharedTokenCacheCredential is not supported on macOS + ExcludeAzureCliCredential = false, + ExcludeAzurePowerShellCredential = false, + ExcludeInteractiveBrowserCredential = false + }; + + var dCred = new DefaultAzureCredential(credOptions); + var tokenRequestContext = new TokenRequestContext(new string[] { "https://management.azure.com/.default" }); + var token = dCred.GetTokenAsync(tokenRequestContext).Result; + return token.Token; + } + + public static string GetContainerRegistryAccessTokenFromSecretManagement( + string repositoryName, + PSCredentialInfo repositoryCredentialInfo, + PSCmdlet cmdletPassedIn) + { + if (!IsSecretManagementVaultAccessible(repositoryName, repositoryCredentialInfo, cmdletPassedIn)) + { + cmdletPassedIn.ThrowTerminatingError( + new ErrorRecord( + new PSInvalidOperationException($"Cannot access Microsoft.PowerShell.SecretManagement vault \"{repositoryCredentialInfo.VaultName}\" for PSResourceRepository ({repositoryName}) authentication."), + "RepositoryCredentialSecretManagementInaccessibleVault", + ErrorCategory.ResourceUnavailable, + cmdletPassedIn)); + return null; + } + + var results = PowerShellInvoker.InvokeScriptWithHost( + cmdlet: cmdletPassedIn, + script: @" + param ( + [string] $VaultName, + [string] $SecretName + ) + $module = Microsoft.PowerShell.Core\Import-Module -Name Microsoft.PowerShell.SecretManagement -PassThru + if ($null -eq $module) { + return + } + & $module ""Get-Secret"" -Name $SecretName -Vault $VaultName + ", + args: new object[] { repositoryCredentialInfo.VaultName, repositoryCredentialInfo.SecretName }, + out Exception terminatingError); + + var secretValue = (results.Count == 1) ? results[0] : null; + if (secretValue == null) + { + cmdletPassedIn.ThrowTerminatingError( + new ErrorRecord( + new PSInvalidOperationException( + message: $"Microsoft.PowerShell.SecretManagement\\Get-Secret encountered an error while reading secret \"{repositoryCredentialInfo.SecretName}\" from vault \"{repositoryCredentialInfo.VaultName}\" for PSResourceRepository ({repositoryName}) authentication.", + innerException: terminatingError), + "ContainerRegistryRepositoryCannotGetSecretFromVault", + ErrorCategory.InvalidOperation, + cmdletPassedIn)); + } + + if (secretValue is SecureString secretSecureString) + { + string password = new NetworkCredential(string.Empty, secretSecureString).Password; + return password; + } + else if (secretValue is PSCredential psCredSecret) + { + string password = new NetworkCredential(string.Empty, psCredSecret.Password).Password; + return password; + } + + cmdletPassedIn.ThrowTerminatingError( + new ErrorRecord( + new PSNotSupportedException($"Secret \"{repositoryCredentialInfo.SecretName}\" from vault \"{repositoryCredentialInfo.VaultName}\" has an invalid type. The only supported type is PSCredential."), + "ContainerRegistryRepositoryTokenIsInvalidSecretType", + ErrorCategory.InvalidType, + cmdletPassedIn)); + + return null; + } + public static void SaveRepositoryCredentialToSecretManagementVault( string repositoryName, PSCredentialInfo repositoryCredentialInfo, @@ -841,7 +951,7 @@ public static NetworkCredential SetNetworkCredential( return networkCredential; } - + #endregion #region Path methods @@ -888,7 +998,7 @@ public static string GetInstalledPackageName(string pkgPath) return new DirectoryInfo(pkgPath).Parent.Name; } - // Find all potential resource paths + // Find all potential resource paths public static List GetPathsFromEnvVarAndScope( PSCmdlet psCmdlet, ScopeType? scope) @@ -919,7 +1029,7 @@ public static List GetAllResourcePaths( ScopeType? scope = null) { List resourcePaths = GetPathsFromEnvVarAndScope(psCmdlet, scope); - + // resourcePaths should now contain, eg: // ./PowerShell/Scripts // ./PowerShell/Modules @@ -969,33 +1079,69 @@ public static List GetAllInstallationPaths( ScopeType? scope) { List installationPaths = GetPathsFromEnvVarAndScope(psCmdlet, scope); - + installationPaths = installationPaths.Distinct(StringComparer.InvariantCultureIgnoreCase).ToList(); installationPaths.ForEach(dir => psCmdlet.WriteVerbose(string.Format("All paths to search: '{0}'", dir))); return installationPaths; } + private static string GetHomeOrCreateTempHome() + { + const string tempHomeFolderName = "psresourceget-{0}-98288ff9-5712-4a14-9a11-23693b9cd91a"; + + string envHome = Environment.GetEnvironmentVariable("HOME") ?? s_tempHome; + if (envHome is not null) + { + return envHome; + } + + try + { + s_tempHome = Path.Combine(Path.GetTempPath(), string.Format(CultureInfo.CurrentCulture, tempHomeFolderName, Environment.UserName)); + Directory.CreateDirectory(s_tempHome); + } + catch (UnauthorizedAccessException) + { + // Directory creation may fail if the account doesn't have filesystem permission such as some service accounts. + // Return an empty string in this case so the process working directory will be used. + s_tempHome = string.Empty; + } + + return s_tempHome; + } + private readonly static Version PSVersion6 = new Version(6, 0); private static void GetStandardPlatformPaths( PSCmdlet psCmdlet, - out string myDocumentsPath, - out string programFilesPath) + out string localUserDir, + out string allUsersDir) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { string powerShellType = (psCmdlet.Host.Version >= PSVersion6) ? "PowerShell" : "WindowsPowerShell"; - myDocumentsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), powerShellType); - programFilesPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), powerShellType); + localUserDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), powerShellType); + allUsersDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), powerShellType); } else { // paths are the same for both Linux and macOS - myDocumentsPath = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "powershell"); - programFilesPath = System.IO.Path.Combine("/usr", "local", "share", "powershell"); + localUserDir = Path.Combine(GetHomeOrCreateTempHome(), ".local", "share", "powershell"); + // Create the default data directory if it doesn't exist. + if (!Directory.Exists(localUserDir)) + { + Directory.CreateDirectory(localUserDir); + } + + allUsersDir = System.IO.Path.Combine("/usr", "local", "share", "powershell"); } } + public static bool GetIsWindowsPowerShell(PSCmdlet psCmdlet) + { + return psCmdlet.Host.Version < PSVersion6; + } + /// /// Checks if any of the package versions are already installed and if they are removes them from the list of packages to install. /// @@ -1030,6 +1176,48 @@ internal static HashSet GetInstalledPackages(List pathsToSearch, return pkgsInstalledOnMachine; } + internal static void GetMetadataFilesFromPath(string dirPath, string packageName, out string psd1FilePath, out string ps1FilePath, out string nuspecFilePath, out string properCasingPkgName) + { + psd1FilePath = String.Empty; + ps1FilePath = String.Empty; + nuspecFilePath = String.Empty; + properCasingPkgName = packageName; + + var discoveredFiles = Directory.GetFiles(dirPath, "*.*", SearchOption.AllDirectories); + string pkgNamePattern = $"{packageName}*"; + Regex rgx = new(pkgNamePattern, RegexOptions.IgnoreCase); + foreach (var file in discoveredFiles) + { + if (rgx.IsMatch(file)) + { + string fileName = Path.GetFileName(file); + if (fileName.EndsWith("psd1")) + { + if (string.Compare($"{packageName}.psd1", fileName, StringComparison.OrdinalIgnoreCase) == 0) + { + properCasingPkgName = Path.GetFileNameWithoutExtension(file); + } + psd1FilePath = file; + } + else if (file.EndsWith("nuspec")) + { + if (string.Compare($"{packageName}.nuspec", fileName, StringComparison.OrdinalIgnoreCase) == 0) + { + properCasingPkgName = Path.GetFileNameWithoutExtension(file); + } + nuspecFilePath = file; + } + else if (file.EndsWith("ps1")) + { + if (string.Compare($"{packageName}.ps1", fileName, StringComparison.OrdinalIgnoreCase) == 0) + { + properCasingPkgName = Path.GetFileNameWithoutExtension(file); + } + ps1FilePath = file; + } + } + } + } #endregion #region PSDataFile parsing @@ -1102,7 +1290,7 @@ private static bool TryReadPSDataFile( allowedCommands: allowedCommands, allowedVariables: allowedVariables, allowEnvironmentVariables: allowEnvironmentVariables); - + // Convert contents into PSDataFile Hashtable by executing content as script. object result = scriptBlock.InvokeReturnAsIs(); if (result is PSObject psObject) @@ -1110,7 +1298,7 @@ private static bool TryReadPSDataFile( result = psObject.BaseObject; } - dataFileInfo = (Hashtable) result; + dataFileInfo = (Hashtable)result; error = null; return true; } @@ -1232,22 +1420,22 @@ public static bool TryCreateModuleSpecification( validatedModuleSpecs = Array.Empty(); List moduleSpecsList = new List(); - foreach(Hashtable moduleSpec in moduleSpecHashtables) + foreach (Hashtable moduleSpec in moduleSpecHashtables) { // ModuleSpecification(string) constructor for creating a ModuleSpecification when only ModuleName is provided. - if (!moduleSpec.ContainsKey("ModuleName") || String.IsNullOrEmpty((string) moduleSpec["ModuleName"])) + if (!moduleSpec.ContainsKey("ModuleName") || String.IsNullOrEmpty((string)moduleSpec["ModuleName"])) { errorList.Add(new ErrorRecord( - new ArgumentException($"RequiredModules Hashtable entry {moduleSpec.ToString()} is missing a key 'ModuleName' and associated value, which is required for each module specification entry"), - "NameMissingInModuleSpecification", - ErrorCategory.InvalidArgument, + new ArgumentException($"RequiredModules Hashtable entry {moduleSpec.ToString()} is missing a key 'ModuleName' and associated value, which is required for each module specification entry"), + "NameMissingInModuleSpecification", + ErrorCategory.InvalidArgument, null)); moduleSpecCreatedSuccessfully = false; continue; } // At this point it must contain ModuleName key. - string moduleSpecName = (string) moduleSpec["ModuleName"]; + string moduleSpecName = (string)moduleSpec["ModuleName"]; ModuleSpecification currentModuleSpec = null; if (!moduleSpec.ContainsKey("MaximumVersion") && !moduleSpec.ContainsKey("ModuleVersion") && !moduleSpec.ContainsKey("RequiredVersion")) { @@ -1262,9 +1450,9 @@ public static bool TryCreateModuleSpecification( else { errorList.Add(new ErrorRecord( - new ArgumentException($"ModuleSpecification object was not able to be created for {moduleSpecName}"), - "ModuleSpecificationNotCreated", - ErrorCategory.InvalidArgument, + new ArgumentException($"ModuleSpecification object was not able to be created for {moduleSpecName}"), + "ModuleSpecificationNotCreated", + ErrorCategory.InvalidArgument, null)); moduleSpecCreatedSuccessfully = false; continue; @@ -1273,17 +1461,17 @@ public static bool TryCreateModuleSpecification( else { // ModuleSpecification(Hashtable) constructor for when ModuleName + {Required,Maximum,Module}Version value is also provided. - string moduleSpecMaxVersion = moduleSpec.ContainsKey("MaximumVersion") ? (string) moduleSpec["MaximumVersion"] : String.Empty; - string moduleSpecModuleVersion = moduleSpec.ContainsKey("ModuleVersion") ? (string) moduleSpec["ModuleVersion"] : String.Empty; - string moduleSpecRequiredVersion = moduleSpec.ContainsKey("RequiredVersion") ? (string) moduleSpec["RequiredVersion"] : String.Empty; - Guid moduleSpecGuid = moduleSpec.ContainsKey("Guid") ? (Guid) moduleSpec["Guid"] : Guid.Empty; + string moduleSpecMaxVersion = moduleSpec.ContainsKey("MaximumVersion") ? (string)moduleSpec["MaximumVersion"] : String.Empty; + string moduleSpecModuleVersion = moduleSpec.ContainsKey("ModuleVersion") ? (string)moduleSpec["ModuleVersion"] : String.Empty; + string moduleSpecRequiredVersion = moduleSpec.ContainsKey("RequiredVersion") ? (string)moduleSpec["RequiredVersion"] : String.Empty; + Guid moduleSpecGuid = moduleSpec.ContainsKey("Guid") ? (Guid)moduleSpec["Guid"] : Guid.Empty; if (String.IsNullOrEmpty(moduleSpecMaxVersion) && String.IsNullOrEmpty(moduleSpecModuleVersion) && String.IsNullOrEmpty(moduleSpecRequiredVersion)) { errorList.Add(new ErrorRecord( - new ArgumentException($"ModuleSpecification hashtable requires one of the following keys: MaximumVersion, ModuleVersion, RequiredVersion and failed to be created for {moduleSpecName}"), - "MissingModuleSpecificationMember", - ErrorCategory.InvalidArgument, + new ArgumentException($"ModuleSpecification hashtable requires one of the following keys: MaximumVersion, ModuleVersion, RequiredVersion and failed to be created for {moduleSpecName}"), + "MissingModuleSpecificationMember", + ErrorCategory.InvalidArgument, null)); moduleSpecCreatedSuccessfully = false; continue; @@ -1319,9 +1507,9 @@ public static bool TryCreateModuleSpecification( catch (Exception e) { errorList.Add(new ErrorRecord( - new ArgumentException($"ModuleSpecification instance was not able to be created with hashtable constructor due to: {e.Message}"), - "ModuleSpecificationNotCreated", - ErrorCategory.InvalidArgument, + new ArgumentException($"ModuleSpecification instance was not able to be created with hashtable constructor due to: {e.Message}"), + "ModuleSpecificationNotCreated", + ErrorCategory.InvalidArgument, null)); moduleSpecCreatedSuccessfully = false; } @@ -1378,6 +1566,10 @@ public static void DeleteDirectoryWithRestore(string dirPath) ex); } } + catch (Exception e) + { + throw e; + } finally { if (Directory.Exists(tempDirPath)) @@ -1395,22 +1587,41 @@ public static void DeleteDirectoryWithRestore(string dirPath) /// public static void DeleteDirectory(string dirPath) { - foreach (var dirFilePath in Directory.GetFiles(dirPath)) + if (!Directory.Exists(dirPath)) + { + throw new Exception($"Path '{dirPath}' that was attempting to be deleted does not exist."); + } + + // Remove read only file attributes first + foreach (var dirFilePath in Directory.GetFiles(dirPath,"*",SearchOption.AllDirectories)) { if (File.GetAttributes(dirFilePath).HasFlag(FileAttributes.ReadOnly)) { - File.SetAttributes(dirFilePath, (File.GetAttributes(dirFilePath) & ~FileAttributes.ReadOnly)); + File.SetAttributes(dirFilePath, File.GetAttributes(dirFilePath) & ~FileAttributes.ReadOnly); } - - File.Delete(dirFilePath); } - - foreach (var dirSubPath in Directory.GetDirectories(dirPath)) + // Delete directory recursive, try multiple times before throwing ( #1662 ) + int maxAttempts = 5; + int msDelay = 5; + for (int attempt = 1; attempt <= maxAttempts; ++attempt) { - DeleteDirectory(dirSubPath); + try + { + Directory.Delete(dirPath,true); + return; + } + catch (Exception ex) + { + if (attempt < maxAttempts && (ex is IOException || ex is UnauthorizedAccessException)) + { + Thread.Sleep(msDelay); + } + else + { + throw; + } + } } - - Directory.Delete(dirPath); } /// @@ -1470,6 +1681,117 @@ private static void CopyDirContents( } } + public static void DeleteExtraneousFiles(PSCmdlet callingCmdlet, string pkgName, string dirNameVersion) + { + // Deleting .nupkg SHA file, .nuspec, and .nupkg after unpacking the module + var nuspecToDelete = Path.Combine(dirNameVersion, pkgName + ".nuspec"); + var contentTypesToDelete = Path.Combine(dirNameVersion, "[Content_Types].xml"); + var relsDirToDelete = Path.Combine(dirNameVersion, "_rels"); + var packageDirToDelete = Path.Combine(dirNameVersion, "package"); + + // Unforunately have to check if each file exists because it may or may not be there + if (File.Exists(nuspecToDelete)) + { + callingCmdlet.WriteVerbose(string.Format("Deleting '{0}'", nuspecToDelete)); + File.Delete(nuspecToDelete); + } + if (File.Exists(contentTypesToDelete)) + { + callingCmdlet.WriteVerbose(string.Format("Deleting '{0}'", contentTypesToDelete)); + File.Delete(contentTypesToDelete); + } + if (Directory.Exists(relsDirToDelete)) + { + callingCmdlet.WriteVerbose(string.Format("Deleting '{0}'", relsDirToDelete)); + Utils.DeleteDirectory(relsDirToDelete); + } + if (Directory.Exists(packageDirToDelete)) + { + callingCmdlet.WriteVerbose(string.Format("Deleting '{0}'", packageDirToDelete)); + Utils.DeleteDirectory(packageDirToDelete); + } + } + + public static void MoveFilesIntoInstallPath( + PSResourceInfo pkgInfo, + bool isModule, + bool isLocalRepo, + bool savePkg, + string dirNameVersion, + string tempInstallPath, + string installPath, + string newVersion, + string moduleManifestVersion, + string scriptPath, + PSCmdlet cmdletPassedIn) + { + // Creating the proper installation path depending on whether pkg is a module or script + var newPathParent = isModule ? Path.Combine(installPath, pkgInfo.Name) : installPath; + var finalModuleVersionDir = isModule ? Path.Combine(installPath, pkgInfo.Name, moduleManifestVersion) : installPath; + + // If script, just move the files over, if module, move the version directory over + var tempModuleVersionDir = (!isModule || isLocalRepo) ? dirNameVersion + : Path.Combine(tempInstallPath, pkgInfo.Name.ToLower(), newVersion); + + cmdletPassedIn.WriteVerbose(string.Format("Installation source path is: '{0}'", tempModuleVersionDir)); + cmdletPassedIn.WriteVerbose(string.Format("Installation destination path is: '{0}'", finalModuleVersionDir)); + + if (isModule) + { + // If new path does not exist + if (!Directory.Exists(newPathParent)) + { + cmdletPassedIn.WriteVerbose(string.Format("Attempting to move '{0}' to '{1}'", tempModuleVersionDir, finalModuleVersionDir)); + Directory.CreateDirectory(newPathParent); + Utils.MoveDirectory(tempModuleVersionDir, finalModuleVersionDir); + } + else + { + cmdletPassedIn.WriteVerbose(string.Format("Temporary module version directory is: '{0}'", tempModuleVersionDir)); + + if (Directory.Exists(finalModuleVersionDir)) + { + // Delete the directory path before replacing it with the new module. + // If deletion fails (usually due to binary file in use), then attempt restore so that the currently + // installed module is not corrupted. + cmdletPassedIn.WriteVerbose(string.Format("Attempting to delete with restore on failure.'{0}'", finalModuleVersionDir)); + Utils.DeleteDirectoryWithRestore(finalModuleVersionDir); + } + + cmdletPassedIn.WriteVerbose(string.Format("Attempting to move '{0}' to '{1}'", tempModuleVersionDir, finalModuleVersionDir)); + Utils.MoveDirectory(tempModuleVersionDir, finalModuleVersionDir); + } + } + else + { + if (!savePkg) + { + // Need to delete old xml files because there can only be 1 per script + var scriptXML = pkgInfo.Name + "_InstalledScriptInfo.xml"; + cmdletPassedIn.WriteVerbose(string.Format("Checking if path '{0}' exists: ", File.Exists(Path.Combine(installPath, "InstalledScriptInfos", scriptXML)))); + if (File.Exists(Path.Combine(installPath, "InstalledScriptInfos", scriptXML))) + { + cmdletPassedIn.WriteVerbose(string.Format("Deleting script metadata XML")); + File.Delete(Path.Combine(installPath, "InstalledScriptInfos", scriptXML)); + } + + cmdletPassedIn.WriteVerbose(string.Format("Moving '{0}' to '{1}'", Path.Combine(dirNameVersion, scriptXML), Path.Combine(installPath, "InstalledScriptInfos", scriptXML))); + Utils.MoveFiles(Path.Combine(dirNameVersion, scriptXML), Path.Combine(installPath, "InstalledScriptInfos", scriptXML)); + + // Need to delete old script file, if that exists + cmdletPassedIn.WriteVerbose(string.Format("Checking if path '{0}' exists: ", File.Exists(Path.Combine(finalModuleVersionDir, pkgInfo.Name + PSScriptFileExt)))); + if (File.Exists(Path.Combine(finalModuleVersionDir, pkgInfo.Name + PSScriptFileExt))) + { + cmdletPassedIn.WriteVerbose(string.Format("Deleting script file")); + File.Delete(Path.Combine(finalModuleVersionDir, pkgInfo.Name + PSScriptFileExt)); + } + } + + cmdletPassedIn.WriteVerbose(string.Format("Moving '{0}' to '{1}'", scriptPath, Path.Combine(finalModuleVersionDir, pkgInfo.Name + PSScriptFileExt))); + Utils.MoveFiles(scriptPath, Path.Combine(finalModuleVersionDir, pkgInfo.Name + PSScriptFileExt)); + } + } + private static void RestoreDirContents( string sourceDirPath, string destDirPath) @@ -1495,6 +1817,93 @@ private static void RestoreDirContents( } } + public static void CreateFile(string filePath) + { + FileStream fileStream = null; + try + { + fileStream = File.Create(filePath); + } + catch (Exception e) + { + throw new Exception($"Error creating file '{filePath}': {e.Message}"); + } + finally + { + if (fileStream != null) + { + fileStream.Close(); + } + } + } + + #endregion + + #region Nuspec file parsing methods + + public static Hashtable GetMetadataFromNuspec(string nuspecFilePath, PSCmdlet cmdletPassedIn, out ErrorRecord errorRecord) + { + Hashtable nuspecHashtable = new Hashtable(StringComparer.InvariantCultureIgnoreCase); + + XmlDocument nuspecXmlDocument = LoadXmlDocument(nuspecFilePath, cmdletPassedIn, out errorRecord); + if (errorRecord != null) + { + return nuspecHashtable; + } + + try + { + XmlNodeList elemList = nuspecXmlDocument.GetElementsByTagName("metadata"); + for(int i = 0; i < elemList.Count; i++) + { + XmlNode metadataInnerXml = elemList[i]; + + for(int j= 0; j + /// Method that loads file content into XMLDocument. Used when reading .nuspec file. + /// + public static XmlDocument LoadXmlDocument(string filePath, PSCmdlet cmdletPassedIn, out ErrorRecord errRecord) + { + errRecord = null; + XmlDocument doc = new XmlDocument(); + doc.PreserveWhitespace = true; + try { doc.Load(filePath); } + catch (Exception e) + { + errRecord = new ErrorRecord( + exception: e, + "LoadXmlDocumentFailure", + ErrorCategory.ReadError, + cmdletPassedIn); + } + + return doc; + } + #endregion } @@ -1682,9 +2091,9 @@ internal static bool CheckAuthenticodeSignature( string[] listOfExtensions = { "*.ps1", "*.psd1", "*.psm1", "*.mof", "*.cat", "*.ps1xml" }; authenticodeSignatures = cmdletPassedIn.InvokeCommand.InvokeScript( script: @"param ( - [string] $tempDirNameVersion, + [string] $tempDirNameVersion, [string[]] $listOfExtensions - ) + ) Get-ChildItem $tempDirNameVersion -Recurse -Include $listOfExtensions | Get-AuthenticodeSignature -ErrorAction SilentlyContinue", useNewScope: true, writeToPipeline: System.Management.Automation.Runspaces.PipelineResultTypes.None, @@ -1694,9 +2103,9 @@ internal static bool CheckAuthenticodeSignature( catch (Exception e) { errorRecord = new ErrorRecord( - new ArgumentException(e.Message), - "GetAuthenticodeSignatureError", - ErrorCategory.InvalidResult, + new ArgumentException(e.Message), + "GetAuthenticodeSignatureError", + ErrorCategory.InvalidResult, cmdletPassedIn); return false; @@ -1709,9 +2118,9 @@ internal static bool CheckAuthenticodeSignature( if (!signature.Status.Equals(SignatureStatus.Valid)) { errorRecord = new ErrorRecord( - new ArgumentException($"The signature for '{pkgName}' is '{signature.Status}."), - "GetAuthenticodeSignatureError", - ErrorCategory.InvalidResult, + new ArgumentException($"The signature status for '{pkgName}' file '{Path.GetFileName(signature.Path)}' is '{signature.Status}'. Status message: '{signature.StatusMessage}'"), + "GetAuthenticodeSignatureError", + ErrorCategory.InvalidResult, cmdletPassedIn); return false; @@ -1720,7 +2129,7 @@ internal static bool CheckAuthenticodeSignature( return true; } - + #endregion } diff --git a/src/code/V2QueryBuilder.cs b/src/code/V2QueryBuilder.cs new file mode 100644 index 000000000..c4891d5dd --- /dev/null +++ b/src/code/V2QueryBuilder.cs @@ -0,0 +1,224 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Configuration.Assemblies; +using System.IO; +using System.Linq; +using System.Text; +using System.Web; + +namespace Microsoft.PowerShell.PSResourceGet.Cmdlets +{ + internal class NuGetV2QueryBuilder + { + + internal Dictionary AdditionalParameters { get; private set; } + + /// + /// The filter to use when querying the NuGet API (query parameter $filter), if needed. + /// + /// + /// If no criteria are added with , the built query string will not contain a $filter parameter unless is true. + /// + internal NuGetV2FilterBuilder FilterBuilder { get; private set; } + + /// + /// Indicates whether an empty $filter parameter should be emitted if contains no criteria. + /// + internal bool ShouldEmitEmptyFilter = false; + + /// + /// The search term to pass to NuGet (searchTerm parameter), if needed. + /// + /// + /// No additional quote-encapsulation is performed on the string. A string will cause the parameter to be omitted. + /// + internal string SearchTerm; + + /// + /// Construct a new with no additional query parameters. + /// + internal NuGetV2QueryBuilder() + { + + FilterBuilder = new NuGetV2FilterBuilder(); + AdditionalParameters = new Dictionary { }; + } + + /// + /// Construct a new with a user-specified collection of query parameters. + /// + /// + /// The set of additional parameters to provide. + /// + internal NuGetV2QueryBuilder(Dictionary parameters) : this() + { + AdditionalParameters = new Dictionary(parameters); + } + + /// + /// Serialize the instance to an HTTP-compatible query string. + /// + /// + /// Query key-value pairs from will take precedence. + /// + /// + /// A containing URL-encoded query parameters separated by . No ? is prefixed at the beginning of the string. + /// + internal string BuildQueryString() + { + + var QueryParameters = HttpUtility.ParseQueryString(""); + + + if (FilterBuilder.CriteriaCount > 0 || ShouldEmitEmptyFilter) + { + QueryParameters["$filter"] = FilterBuilder.BuildFilterString(); + } + + if (SearchTerm != null) { + QueryParameters["searchTerm"] = SearchTerm; + } + + foreach (var parameter in AdditionalParameters) + { + QueryParameters[parameter.Key] = parameter.Value; + } + + return QueryParameters.ToString(); + + } + + } + + /// + /// Helper class for building NuGet v2 (OData) filter strings based on a set of criteria + /// + internal class NuGetV2FilterBuilder + { + + /// + /// Construct a new with an empty set of criteria. + /// + internal NuGetV2FilterBuilder() + { + + } + + private HashSet FilterCriteria = new HashSet(); + + /// + /// Convert the builder's provided set of filter criteria into an OData-compatible filter string. + /// + /// + /// Criteria order is not guaranteed. Filter criteria are combined with the and operator. + /// + /// + /// Filter criteria combined into a single string. + /// + /// + /// The following example will emit one of the two values: + /// + /// + /// IsPrerelease eq false and Id eq 'Microsoft.PowerShell.PSResourceGet' + /// + /// + /// Id eq 'Microsoft.PowerShell.PSResourceGet' and IsPrerelease eq false + /// + /// + /// + /// var filter = new NuGetV2FilterBuilder(); + /// filter.AddCriteria("IsPrerelease eq false"); + /// filter.AddCriteria("Id eq 'Microsoft.PowerShell.PSResourceGet'"); + /// return filter.BuildFilterString(); + /// + /// + public string BuildFilterString() + { + + if (FilterCriteria.Count == 0) + { + return ""; + } + + // Parenthesizing binary criteria (like "Id eq 'Foo'") would ideally provide better isolation/debuggability of mis-built filters. + // However, a $filter like "(IsLatestVersion)" appears to be rejected by PSGallery (possibly because grouping operators cannot be used with single unary operators). + // Parenthesizing only binary criteria requires more introspection into the underlying criteria, which we don't currently have with string-form criteria. + + // Figure out the expected size of our filter string, based on: + int ExpectedSize = FilterCriteria.Select(x => x.Length).Sum() // The length of the filter criteria themselves. + + 5 * (FilterCriteria.Count - 1); // The length of the combining string, " and ", interpolated between the filters. + + // Allocate a StringBuilder with our calculated capacity. + // This helps right-size memory allocation and reduces performance impact from resizing the builder's internal capacity. + StringBuilder sb = new StringBuilder(ExpectedSize); + + // StringBuilder.AppendJoin() is not available in .NET 4.8.1/.NET Standard 2, + // so we have to make do with repeated calls to Append(). + + + int CriteriaAdded = 0; + + foreach (string filter in FilterCriteria) + { + sb.Append(filter); + CriteriaAdded++; + if (CriteriaAdded < FilterCriteria.Count) + { + sb.Append(" and "); + } + } + + return sb.ToString(); + + } + + /// + /// Add a given OData-compatible criterion to the object's internal criteria set. + /// + /// + /// The criterion to add, e.g. IsLatestVersion or Id eq 'Foo'. + /// + /// + /// A boolean indicating whether the criterion was added to the set. false indicates the criteria set already contains the given string. + /// + /// + /// This method encapsulates over . Similar comparison and equality semantics apply. + /// + /// + /// The provided criterion string was null or empty. + /// + public bool AddCriterion(string criterion) + { + if (string.IsNullOrEmpty(criterion)) + { + throw new ArgumentException("Criteria cannot be null or empty.", nameof(criterion)); + } + else + { + return FilterCriteria.Add(criterion); + } + } + + /// + /// Remove a criterion from the instance's internal criteria set. + /// + /// + /// The criteria to remove. + /// + /// + /// true if the criterion was removed, false if it was not found. + /// + /// + /// This method encapsulates over . Similar comparison and equality semantics apply. + /// + public bool RemoveCriterion(string criterion) => FilterCriteria.Remove(criterion); + + public int CriteriaCount => FilterCriteria.Count; + + } +} \ No newline at end of file diff --git a/src/code/V2ResponseUtil.cs b/src/code/V2ResponseUtil.cs index 1584461f5..4025f0c83 100644 --- a/src/code/V2ResponseUtil.cs +++ b/src/code/V2ResponseUtil.cs @@ -2,8 +2,10 @@ // Licensed under the MIT License. using Microsoft.PowerShell.PSResourceGet.UtilClasses; +using NuGet.Versioning; using System; using System.Collections.Generic; +using System.Linq; using System.Xml; namespace Microsoft.PowerShell.PSResourceGet.Cmdlets @@ -70,17 +72,64 @@ public override IEnumerable ConvertToPSResourceResult(FindResu #region V2 Specific Methods public XmlNode[] ConvertResponseToXML(string httpResponse) { + NuGetVersion emptyVersion = new NuGetVersion("0.0.0.0"); + NuGetVersion firstVersion = emptyVersion; + NuGetVersion lastVersion = emptyVersion; + bool shouldFixOrder = true; //Create the XmlDocument. XmlDocument doc = new XmlDocument(); doc.LoadXml(httpResponse); - XmlNodeList elemList = doc.GetElementsByTagName("m:properties"); - - XmlNode[] nodes = new XmlNode[elemList.Count]; - for (int i = 0; i < elemList.Count; i++) + XmlNodeList entryNode = doc.GetElementsByTagName("entry"); + + XmlNode[] nodes = new XmlNode[entryNode.Count]; + for (int i = 0; i < entryNode.Count; i++) + { + XmlNode node = entryNode[i]; + nodes[i] = node; + var entryChildNodes = node.ChildNodes; + foreach (XmlElement childNode in entryChildNodes) + { + var entryKey = childNode.LocalName; + if (entryKey.Equals("properties")) + { + var propertyChildNodes = childNode.ChildNodes; + foreach (XmlElement propertyChild in propertyChildNodes) + { + var propertyKey = propertyChild.LocalName; + var propertyValue = propertyChild.InnerText; + if (propertyKey.Equals("NormalizedVersion")) + { + if (!NuGetVersion.TryParse(propertyValue, out NuGetVersion parsedNormalizedVersion)) + { + // if a version couldn't be parsed, keep ordering as is. + shouldFixOrder = false; + } + + if (i == 0) + { + firstVersion = parsedNormalizedVersion; + } + else + { + // later version element + lastVersion = parsedNormalizedVersion; + } + + break; // don't care about rest of the childNode's properties + } + } + + break; // don't care about rest of the childNode's keys + } + } + } + + // order the array in descending order if not already. + if (shouldFixOrder && firstVersion.CompareTo(lastVersion) < 0) { - nodes[i] = elemList[i]; + nodes = nodes.Reverse().ToArray(); } return nodes; diff --git a/src/code/V2ServerAPICalls.cs b/src/code/V2ServerAPICalls.cs index d0a766377..38b5640de 100644 --- a/src/code/V2ServerAPICalls.cs +++ b/src/code/V2ServerAPICalls.cs @@ -11,10 +11,12 @@ using System.Threading.Tasks; using System.Xml; using System.Net; +using System.Text; using System.Runtime.ExceptionServices; using System.Management.Automation; using System.Reflection; using System.Data.Common; +using System.Linq; namespace Microsoft.PowerShell.PSResourceGet.Cmdlets { @@ -41,6 +43,7 @@ internal class V2ServerAPICalls : ServerApiCall public FindResponseType v2FindResponseType = FindResponseType.ResponseString; private bool _isADORepo; private bool _isJFrogRepo; + private bool _isPSGalleryRepo; #endregion @@ -50,16 +53,35 @@ public V2ServerAPICalls (PSRepositoryInfo repository, PSCmdlet cmdletPassedIn, N { this.Repository = repository; _cmdletPassedIn = cmdletPassedIn; - HttpClientHandler handler = new HttpClientHandler() + HttpClientHandler handler = new HttpClientHandler(); + bool token = false; + + if(networkCredential != null) { - Credentials = networkCredential + token = String.Equals("token", networkCredential.UserName) ? true : false; + }; + + if (token) + { + string credString = string.Format(":{0}", networkCredential.Password); + byte[] byteArray = Encoding.ASCII.GetBytes(credString); + + _sessionClient = new HttpClient(handler); + _sessionClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(byteArray)); + + } else { + + handler.Credentials = networkCredential; + + _sessionClient = new HttpClient(handler); }; - _sessionClient = new HttpClient(handler); + _sessionClient.Timeout = TimeSpan.FromMinutes(10); _sessionClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgentString); var repoURL = repository.Uri.ToString().ToLower(); _isADORepo = repoURL.Contains("pkgs.dev.azure.com") || repoURL.Contains("pkgs.visualstudio.com"); - _isJFrogRepo = repoURL.Contains("jfrog"); + _isJFrogRepo = repoURL.Contains("jfrog") || repoURL.Contains("artifactory"); + _isPSGalleryRepo = repoURL.Contains("powershellgallery.com/api/v2"); } #endregion @@ -240,9 +262,9 @@ public override FindResults FindTags(string[] tags, bool includePrerelease, Reso if (responses.Count == 0) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"Package with Tags '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), - "PackageWithSpecifiedTagsNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with Tags '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), + "PackageWithSpecifiedTagsNotFound", + ErrorCategory.ObjectNotFound, this); } @@ -294,9 +316,9 @@ public override FindResults FindCommandOrDscResource(string[] tags, bool include { string parameterForErrorMsg = isSearchingForCommands ? "Command" : "DSC Resource"; errRecord = new ErrorRecord( - new ResourceNotFoundException($"Package with {parameterForErrorMsg} '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), - "PackageWithSpecifiedCmdOrDSCNotFound", - ErrorCategory.InvalidResult, + new ResourceNotFoundException($"Package with {parameterForErrorMsg} '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), + "PackageWithSpecifiedCmdOrDSCNotFound", + ErrorCategory.InvalidResult, this); } @@ -316,35 +338,58 @@ public override FindResults FindName(string packageName, bool includePrerelease, { _cmdletPassedIn.WriteDebug("In V2ServerAPICalls::FindName()"); // Make sure to include quotations around the package name - var prerelease = includePrerelease ? "IsAbsoluteLatestVersion" : "IsLatestVersion"; // This should return the latest stable version or the latest prerelease version (respectively) // https://www.powershellgallery.com/api/v2/FindPackagesById()?id='PowerShellGet'&$filter=IsLatestVersion and substringof('PSModule', Tags) eq true // We need to explicitly add 'Id eq ' whenever $filter is used, otherwise arbitrary results are returned. + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "$inlinecount", "allpages" }, + { "id", $"'{packageName}'" }, + }); + var filterBuilder = queryBuilder.FilterBuilder; + // If it's a JFrog repository do not include the Id filter portion since JFrog uses 'Title' instead of 'Id', // however filtering on 'and Title eq '' returns "Response status code does not indicate success: 500". - string idFilterPart = _isJFrogRepo ? "": $" and Id eq '{packageName}'"; - string typeFilterPart = GetTypeFilterForRequest(type); - var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?id='{packageName}'&$inlinecount=allpages&$filter={prerelease}{idFilterPart}{typeFilterPart}"; + if (!_isJFrogRepo) { + filterBuilder.AddCriterion($"Id eq '{packageName}'"); + } + + filterBuilder.AddCriterion(includePrerelease ? "IsAbsoluteLatestVersion eq true" : "IsLatestVersion eq true"); + if (type != ResourceType.None) { + filterBuilder.AddCriterion(GetTypeFilterForRequest(type)); + } + + var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; string response = HttpRequestCall(requestUrlV2, out errRecord); if (errRecord != null) { - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + // usually this is for errors in calling the V2 server, but for ADO V2 this error will include package not found errors which we want to deliver in a standard message + if (_isADORepo && errRecord.Exception is ResourceNotFoundException) + { + errRecord = new ErrorRecord( + new ResourceNotFoundException($"Package with name '{packageName}' could not be found in repository '{Repository.Name}'. For ADO feed, if the package is in an upstream feed make sure you are authenticated to the upstream feed.", errRecord.Exception), + "PackageNotFound", + ErrorCategory.ObjectNotFound, + this); + response = string.Empty; + } + + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); } int count = GetCountFromResponse(response, out errRecord); if (errRecord != null) { - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); } if (count == 0) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"Package with name '{packageName}' could not be found in repository '{Repository.Name}'."), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with name '{packageName}' could not be found in repository '{Repository.Name}'."), + "PackageNotFound", + ErrorCategory.ObjectNotFound, this); response = string.Empty; } @@ -362,41 +407,53 @@ public override FindResults FindNameWithTag(string packageName, string[] tags, b { _cmdletPassedIn.WriteDebug("In V2ServerAPICalls::FindNameWithTag()"); // Make sure to include quotations around the package name - var prerelease = includePrerelease ? "IsAbsoluteLatestVersion" : "IsLatestVersion"; // This should return the latest stable version or the latest prerelease version (respectively) // https://www.powershellgallery.com/api/v2/FindPackagesById()?id='PowerShellGet'&$filter=IsLatestVersion and substringof('PSModule', Tags) eq true // We need to explicitly add 'Id eq ' whenever $filter is used, otherwise arbitrary results are returned. + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "$inlinecount", "allpages" }, + { "id", $"'{packageName}'" }, + }); + var filterBuilder = queryBuilder.FilterBuilder; + // If it's a JFrog repository do not include the Id filter portion since JFrog uses 'Title' instead of 'Id', // however filtering on 'and Title eq '' returns "Response status code does not indicate success: 500". - string idFilterPart = _isJFrogRepo ? "" : $" and Id eq '{packageName}'"; - string typeFilterPart = GetTypeFilterForRequest(type); - string tagFilterPart = String.Empty; + if (!_isJFrogRepo) { + filterBuilder.AddCriterion($"Id eq '{packageName}'"); + } + + filterBuilder.AddCriterion(includePrerelease ? "IsAbsoluteLatestVersion eq true" : "IsLatestVersion eq true"); + if (type != ResourceType.None) { + filterBuilder.AddCriterion(GetTypeFilterForRequest(type)); + } + foreach (string tag in tags) { - tagFilterPart += $" and substringof('{tag}', Tags) eq true"; + filterBuilder.AddCriterion($"substringof('{tag}', Tags) eq true"); } - var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?id='{packageName}'&$inlinecount=allpages&$filter={prerelease}{idFilterPart}{typeFilterPart}{tagFilterPart}"; + var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; + string response = HttpRequestCall(requestUrlV2, out errRecord); if (errRecord != null) { - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); } int count = GetCountFromResponse(response, out errRecord); if (errRecord != null) { - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); } if (count == 0) { errRecord = new ErrorRecord( new ResourceNotFoundException($"Package with name '{packageName}' and tags '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + "PackageNotFound", + ErrorCategory.ObjectNotFound, this); response = string.Empty; } @@ -436,7 +493,7 @@ public override FindResults FindNameGlobbing(string packageName, bool includePre // If count is 0, early out as this means no packages matching search criteria were found. We want to set the responses array to empty and not set ErrorRecord (as is a globbing scenario). if (initialCount == 0) { - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); } int count = (int)Math.Ceiling((double)(initialCount / 100)); @@ -488,7 +545,7 @@ public override FindResults FindNameGlobbingWithTag(string packageName, string[] if (initialCount == 0) { - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); } int count = (int)Math.Ceiling((double)(initialCount / 100)); @@ -540,7 +597,7 @@ public override FindResults FindVersionGlobbing(string packageName, VersionRange if (initialCount == 0) { - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); } responses.Add(initialResponse); @@ -581,15 +638,39 @@ public override FindResults FindVersion(string packageName, string version, Reso // Quotations around package name and version do not matter, same metadata gets returned. // We need to explicitly add 'Id eq ' whenever $filter is used, otherwise arbitrary results are returned. + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "$inlinecount", "allpages" }, + { "id", $"'{packageName}'" }, + }); + var filterBuilder = queryBuilder.FilterBuilder; + // If it's a JFrog repository do not include the Id filter portion since JFrog uses 'Title' instead of 'Id', // however filtering on 'and Title eq '' returns "Response status code does not indicate success: 500". - string idFilterPart = _isJFrogRepo ? "" : $" and Id eq '{packageName}'"; - string typeFilterPart = GetTypeFilterForRequest(type); - var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?id='{packageName}'&$inlinecount=allpages&$filter= NormalizedVersion eq '{version}'{idFilterPart}{typeFilterPart}"; + if (!_isJFrogRepo) { + filterBuilder.AddCriterion($"Id eq '{packageName}'"); + } + + filterBuilder.AddCriterion($"NormalizedVersion eq '{version}'"); + if (type != ResourceType.None) { + filterBuilder.AddCriterion(GetTypeFilterForRequest(type)); + } + + var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; string response = HttpRequestCall(requestUrlV2, out errRecord); if (errRecord != null) { - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + // usually this is for errors in calling the V2 server, but for ADO V2 this error will include package not found errors which we want to deliver with a standard message + if (_isADORepo && errRecord.Exception is ResourceNotFoundException) + { + errRecord = new ErrorRecord( + new ResourceNotFoundException($"Package with name '{packageName}' and version '{version}' could not be found in repository '{Repository.Name}'. For ADO feed, if the package is in an upstream feed make sure you are authenticated to the upstream feed.", errRecord.Exception), + "PackageNotFound", + ErrorCategory.ObjectNotFound, + this); + response = string.Empty; + } + + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); } int count = GetCountFromResponse(response, out errRecord); @@ -597,15 +678,15 @@ public override FindResults FindVersion(string packageName, string version, Reso if (errRecord != null) { - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); } if (count == 0) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"Package with name '{packageName}', version '{version}' could not be found in repository '{Repository.Name}'."), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with name '{packageName}', version '{version}' could not be found in repository '{Repository.Name}'."), + "PackageNotFound", + ErrorCategory.ObjectNotFound, this); response = string.Empty; } @@ -622,23 +703,35 @@ public override FindResults FindVersion(string packageName, string version, Reso public override FindResults FindVersionWithTag(string packageName, string version, string[] tags, ResourceType type, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In V2ServerAPICalls::FindVersionWithTag()"); + + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "$inlinecount", "allpages" }, + { "id", $"'{packageName}'" }, + }); + var filterBuilder = queryBuilder.FilterBuilder; // We need to explicitly add 'Id eq ' whenever $filter is used, otherwise arbitrary results are returned. // If it's a JFrog repository do not include the Id filter portion since JFrog uses 'Title' instead of 'Id', // however filtering on 'and Title eq '' returns "Response status code does not indicate success: 500". - string idFilterPart = _isJFrogRepo ? "" : $" and Id eq '{packageName}'"; - string typeFilterPart = GetTypeFilterForRequest(type); - string tagFilterPart = String.Empty; + if (!_isJFrogRepo) { + filterBuilder.AddCriterion($"Id eq '{packageName}'"); + } + + filterBuilder.AddCriterion($"NormalizedVersion eq '{version}'"); + if (type != ResourceType.None) { + filterBuilder.AddCriterion(GetTypeFilterForRequest(type)); + } + foreach (string tag in tags) { - tagFilterPart += $" and substringof('{tag}', Tags) eq true"; + filterBuilder.AddCriterion($"substringof('{tag}', Tags) eq true"); } - var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?id='{packageName}'&$inlinecount=allpages&$filter= NormalizedVersion eq '{version}'{idFilterPart}{typeFilterPart}{tagFilterPart}"; + var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; string response = HttpRequestCall(requestUrlV2, out errRecord); if (errRecord != null) { - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); } int count = GetCountFromResponse(response, out errRecord); @@ -646,19 +739,19 @@ public override FindResults FindVersionWithTag(string packageName, string versio if (errRecord != null) { - return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); } if (count == 0) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"Package with name '{packageName}', version '{version}' and tags '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with name '{packageName}', version '{version}' and tags '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), + "PackageNotFound", + ErrorCategory.ObjectNotFound, this); response = string.Empty; } - + return new FindResults(stringResponse: new string[] { response }, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); } @@ -666,6 +759,8 @@ public override FindResults FindVersionWithTag(string packageName, string versio /// /// Installs a specific package. + /// User may request to install package with or without providing version (as seen in examples below), but prior to calling this method the package is located and package version determined. + /// Therefore, package version should not be null in this method. /// Name: no wildcard support. /// Examples: Install "PowerShellGet" /// Install "PowerShellGet" -Version "3.0.0" @@ -675,13 +770,16 @@ public override Stream InstallPackage(string packageName, string packageVersion, Stream results = new MemoryStream(); if (string.IsNullOrEmpty(packageVersion)) { - results = InstallName(packageName, out errRecord); - } - else - { - results = InstallVersion(packageName, packageVersion, out errRecord); + errRecord = new ErrorRecord( + exception: new ArgumentNullException($"Package version could not be found for {packageName}"), + "PackageVersionNullOrEmptyError", + ErrorCategory.InvalidArgument, + _cmdletPassedIn); + + return results; } + results = InstallVersion(packageName, packageVersion, out errRecord); return results; } @@ -704,33 +802,33 @@ private string HttpRequestCall(string requestUrlV2, out ErrorRecord errRecord) catch (ResourceNotFoundException e) { errRecord = new ErrorRecord( - exception: e, - "ResourceNotFound", - ErrorCategory.InvalidResult, + exception: e, + "ResourceNotFound", + ErrorCategory.InvalidResult, this); } catch (UnauthorizedException e) { errRecord = new ErrorRecord( - exception: e, - "UnauthorizedRequest", - ErrorCategory.InvalidResult, + exception: e, + "UnauthorizedRequest", + ErrorCategory.InvalidResult, this); } catch (HttpRequestException e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestCallFailure", - ErrorCategory.ConnectionError, + exception: e, + "HttpRequestCallFailure", + ErrorCategory.ConnectionError, this); } catch (Exception e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestCallFailure", - ErrorCategory.ConnectionError, + exception: e, + "HttpRequestCallFailure", + ErrorCategory.ConnectionError, this); } @@ -761,25 +859,25 @@ private HttpContent HttpRequestCallForContent(string requestUrlV2, out ErrorReco catch (HttpRequestException e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestFailure", - ErrorCategory.ConnectionError, + exception: e, + "HttpRequestFailure", + ErrorCategory.ConnectionError, this); } catch (ArgumentNullException e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestFailure", - ErrorCategory.InvalidData, + exception: e, + "HttpRequestFailure", + ErrorCategory.InvalidData, this); } catch (InvalidOperationException e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestFailure", - ErrorCategory.InvalidOperation, + exception: e, + "HttpRequestFailure", + ErrorCategory.InvalidOperation, this); } @@ -787,7 +885,7 @@ private HttpContent HttpRequestCallForContent(string requestUrlV2, out ErrorReco { _cmdletPassedIn.WriteDebug("Response is empty"); } - + return content; } @@ -801,11 +899,43 @@ private HttpContent HttpRequestCallForContent(string requestUrlV2, out ErrorReco private string FindAllFromTypeEndPoint(bool includePrerelease, bool isSearchingModule, int skip, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In V2ServerAPICalls::FindAllFromTypeEndPoint()"); - string typeEndpoint = isSearchingModule ? String.Empty : "/items/psscript"; - string paginationParam = $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=6000"; - var prereleaseFilter = includePrerelease ? "IsAbsoluteLatestVersion&includePrerelease=true" : "IsLatestVersion"; + string typeEndpoint = _isPSGalleryRepo && !isSearchingModule ? "/items/psscript" : String.Empty; + + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "$inlinecount", "allpages" }, + { "$skip", skip.ToString()}, + { "$top", "6000"} + }); + var filterBuilder = queryBuilder.FilterBuilder; + + if (_isPSGalleryRepo) { + queryBuilder.AdditionalParameters["$orderby"] = "Id desc"; + } + + // JFrog/Artifactory requires an empty search term to enumerate all packages in the feed + if (_isJFrogRepo) { + queryBuilder.SearchTerm = "''"; + + if (includePrerelease) { + queryBuilder.AdditionalParameters["includePrerelease"] = "true"; + // note: we add 'eq true' because some PMPs (currently we know of JFrog, but others may do this too) will proxy the query unedited to the upstream remote and if that's PSGallery, it doesn't handle IsAbsoluteLatestVersion correctly + filterBuilder.AddCriterion("IsAbsoluteLatestVersion eq true"); + } else { + // note: we add 'eq true' because some PMPs (currently we know of JFrog, but others may do this too) will proxy the query unedited to the upstream remote and if that's PSGallery, it doesn't handle IsLatestVersion correctly + filterBuilder.AddCriterion("IsLatestVersion eq true"); + } + } + else { + // For ADO, 'IsLatestVersion eq true' and 'IsAbsoluteLatestVersion eq true' in the filter create a bad request error, so we use 'IsLatestVersion' or 'IsAbsoluteLatestVersion' only + if (includePrerelease) { + queryBuilder.AdditionalParameters["includePrerelease"] = "true"; + filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + } else { + filterBuilder.AddCriterion("IsLatestVersion"); + } + } - var requestUrlV2 = $"{Repository.Uri}{typeEndpoint}/Search()?$filter={prereleaseFilter}{paginationParam}"; + var requestUrlV2 = $"{Repository.Uri}{typeEndpoint}/Search()?{queryBuilder.BuildQueryString()}"; return HttpRequestCall(requestUrlV2, out errRecord); } @@ -821,19 +951,48 @@ private string FindTagFromEndpoint(string[] tags, bool includePrerelease, bool i // type: S -> just search Scripts end point // type: DSCResource -> just search Modules // type: Command -> just search Modules - string typeEndpoint = isSearchingModule ? String.Empty : "/items/psscript"; - string paginationParam = $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=6000"; - var prereleaseFilter = includePrerelease ? "includePrerelease=true&$filter=IsAbsoluteLatestVersion" : "$filter=IsLatestVersion"; - string typeFilterPart = isSearchingModule ? $" and substringof('PSModule', Tags) eq true" : $" and substringof('PSScript', Tags) eq true"; + string typeEndpoint = _isPSGalleryRepo && !isSearchingModule ? "/items/psscript" : String.Empty; + + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "$inlinecount", "allpages" }, + { "$skip", skip.ToString()}, + { "$top", "6000"} + }); + var filterBuilder = queryBuilder.FilterBuilder; + + if (_isPSGalleryRepo) { + queryBuilder.AdditionalParameters["$orderby"] = "Id desc"; + } + + if (includePrerelease) { + queryBuilder.AdditionalParameters["includePrerelease"] = "true"; + if (_isJFrogRepo) { + // note: we add 'eq true' because some PMPs (currently we know of JFrog, but others may do this too) will proxy the query unedited to the upstream remote and if that's PSGallery, it doesn't handle IsAbsoluteLatestVersion correctly + filterBuilder.AddCriterion("IsAbsoluteLatestVersion eq true"); + } + else { + // For ADO, 'IsLatestVersion eq true' and 'IsAbsoluteLatestVersion eq true' in the filter create a bad request error, so we use 'IsLatestVersion' or 'IsAbsoluteLatestVersion' only + filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + } + } else { + if (_isJFrogRepo) { + filterBuilder.AddCriterion("IsLatestVersion eq true"); + } + else { + // For ADO, 'IsLatestVersion eq true' and 'IsAbsoluteLatestVersion eq true' in the filter create a bad request error, so we use 'IsLatestVersion' or 'IsAbsoluteLatestVersion' only + filterBuilder.AddCriterion("IsLatestVersion"); + } + } + + filterBuilder.AddCriterion($"substringof('PS{(isSearchingModule ? "Module" : "Script")}', Tags) eq true"); - string tagFilterPart = String.Empty; foreach (string tag in tags) { - tagFilterPart += $" and substringof('{tag}', Tags) eq true"; + filterBuilder.AddCriterion($"substringof('{tag}', Tags) eq true"); } - var requestUrlV2 = $"{Repository.Uri}{typeEndpoint}/Search()?{prereleaseFilter}{typeFilterPart}{tagFilterPart}{paginationParam}"; - + var requestUrlV2 = $"{Repository.Uri}{typeEndpoint}/Search()?{queryBuilder.BuildQueryString()}"; + return HttpRequestCall(requestUrlV2: requestUrlV2, out errRecord); } @@ -843,24 +1002,50 @@ private string FindTagFromEndpoint(string[] tags, bool includePrerelease, bool i private string FindCommandOrDscResource(string[] tags, bool includePrerelease, bool isSearchingForCommands, int skip, out ErrorRecord errRecord) { _cmdletPassedIn.WriteDebug("In V2ServerAPICalls::FindCommandOrDscResource()"); - // can only find from Modules endpoint - string paginationParam = $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=6000"; - var prereleaseFilter = includePrerelease ? "$filter=IsAbsoluteLatestVersion&includePrerelease=true" : "$filter=IsLatestVersion"; - var tagPrefix = isSearchingForCommands ? "PSCommand_" : "PSDscResource_"; - string tagSearchTermPart = String.Empty; - foreach (string tag in tags) - { - if (!String.IsNullOrEmpty(tagSearchTermPart)) - { - tagSearchTermPart += " "; - } + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "$inlinecount", "allpages" }, + { "$skip", skip.ToString()}, + { "$top", "6000"} + }); + var filterBuilder = queryBuilder.FilterBuilder; + + if (_isPSGalleryRepo) { + queryBuilder.AdditionalParameters["$orderby"] = "Id desc"; + } - tagSearchTermPart += $"tag:{tagPrefix}{tag}"; + if (includePrerelease) { + queryBuilder.AdditionalParameters["includePrerelease"] = "true"; + if (_isJFrogRepo) { + // note: we add 'eq true' because some PMPs (currently we know of JFrog, but others may do this too) will proxy the query unedited to the upstream remote and if that's PSGallery, it doesn't handle IsAbsoluteLatestVersion correctly + filterBuilder.AddCriterion("IsAbsoluteLatestVersion eq true"); + } + else { + // For ADO, 'IsLatestVersion eq true' and 'IsAbsoluteLatestVersion eq true' in the filter create a bad request error, so we use 'IsLatestVersion' or 'IsAbsoluteLatestVersion' only + filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + } + } else { + if (_isJFrogRepo) { + // note: we add 'eq true' because some PMPs (currently we know of JFrog, but others may do this too) will proxy the query unedited to the upstream remote and if that's PSGallery, it doesn't handle IsLatestVersion correctly + filterBuilder.AddCriterion("IsLatestVersion eq true"); + } + else { + // For ADO, 'IsLatestVersion eq true' and 'IsAbsoluteLatestVersion eq true' in the filter create a bad request error, so we use 'IsLatestVersion' or 'IsAbsoluteLatestVersion' only + filterBuilder.AddCriterion("IsLatestVersion"); + } } - var requestUrlV2 = $"{Repository.Uri}/Search()?{prereleaseFilter}&searchTerm='{tagSearchTermPart}'{paginationParam}"; - + // can only find from Modules endpoint + var tagPrefix = isSearchingForCommands ? "PSCommand_" : "PSDscResource_"; + + queryBuilder.SearchTerm = "'" + string.Join( + " ", + tags.Select(tag => $"tag:{tagPrefix}{tag}") + ) + "'"; + + + var requestUrlV2 = $"{Repository.Uri}/Search()?{queryBuilder.BuildQueryString()}"; + return HttpRequestCall(requestUrlV2, out errRecord); } @@ -873,18 +1058,46 @@ private string FindNameGlobbing(string packageName, ResourceType type, bool incl // https://www.powershellgallery.com/api/v2/Search()?$filter=endswith(Id, 'Get') and startswith(Id, 'PowerShell') and IsLatestVersion (stable) // https://www.powershellgallery.com/api/v2/Search()?$filter=endswith(Id, 'Get') and IsAbsoluteLatestVersion&includePrerelease=true - string extraParam = $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=100"; - var prerelease = includePrerelease ? "IsAbsoluteLatestVersion&includePrerelease=true" : "IsLatestVersion"; - string nameFilter; + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "$inlinecount", "allpages" }, + { "$skip", skip.ToString()}, + { "$top", "100"} + }); + var filterBuilder = queryBuilder.FilterBuilder; + + if (_isPSGalleryRepo) { + queryBuilder.AdditionalParameters["$orderby"] = "Id desc"; + } + + if (includePrerelease) { + queryBuilder.AdditionalParameters["includePrerelease"] = "true"; + if (_isJFrogRepo) { + // note: we add 'eq true' because some PMPs (currently we know of JFrog, but others may do this too) will proxy the query unedited to the upstream remote and if that's PSGallery, it doesn't handle IsAbsoluteLatestVersion correctly + filterBuilder.AddCriterion("IsAbsoluteLatestVersion eq true"); + } + else { + // For ADO, 'IsLatestVersion eq true' and 'IsAbsoluteLatestVersion eq true' in the filter create a bad request error, so we use 'IsLatestVersion' or 'IsAbsoluteLatestVersion' only + filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + } + } else { + if (_isJFrogRepo) { + // note: we add 'eq true' because some PMPs (currently we know of JFrog, but others may do this too) will proxy the query unedited to the upstream remote and if that's PSGallery, it doesn't handle IsLatestVersion correctly + filterBuilder.AddCriterion("IsLatestVersion eq true"); + } + else { + // For ADO, 'IsLatestVersion eq true' and 'IsAbsoluteLatestVersion eq true' in the filter create a bad request error, so we use 'IsLatestVersion' or 'IsAbsoluteLatestVersion' only + filterBuilder.AddCriterion("IsLatestVersion"); + } + } var names = packageName.Split(new char[] {'*'}, StringSplitOptions.RemoveEmptyEntries); if (names.Length == 0) { errRecord = new ErrorRecord( - new ArgumentException("-Name '*' for V2 server protocol repositories is not supported"), - "FindNameGlobbingFailure", - ErrorCategory.InvalidArgument, + new ArgumentException("-Name '*' for V2 server protocol repositories is not supported"), + "FindNameGlobbingFailure", + ErrorCategory.InvalidArgument, this); return string.Empty; @@ -894,17 +1107,17 @@ private string FindNameGlobbing(string packageName, ResourceType type, bool incl if (packageName.StartsWith("*") && packageName.EndsWith("*")) { // *get* - nameFilter = $"substringof('{names[0]}', Id)"; + filterBuilder.AddCriterion($"substringof('{names[0]}', Id)"); } else if (packageName.EndsWith("*")) { // PowerShell* - nameFilter = $"startswith(Id, '{names[0]}')"; + filterBuilder.AddCriterion($"startswith(Id, '{names[0]}')"); } else { // *ShellGet - nameFilter = $"endswith(Id, '{names[0]}')"; + filterBuilder.AddCriterion($"endswith(Id, '{names[0]}')"); } } else if (names.Length == 2 && !packageName.StartsWith("*") && !packageName.EndsWith("*")) @@ -913,22 +1126,34 @@ private string FindNameGlobbing(string packageName, ResourceType type, bool incl // pow*get -> only support this // pow*get* // *pow*get - nameFilter = $"startswith(Id, '{names[0]}') and endswith(Id, '{names[1]}')"; + filterBuilder.AddCriterion($"startswith(Id, '{names[0]}') and endswith(Id, '{names[1]}')"); } else { errRecord = new ErrorRecord( - new ArgumentException("-Name with wildcards is only supported for scenarios similar to the following examples: PowerShell*, *ShellGet, *Shell*."), - "FindNameGlobbingFailure", - ErrorCategory.InvalidArgument, + new ArgumentException("-Name with wildcards is only supported for scenarios similar to the following examples: PowerShell*, *ShellGet, *Shell*."), + "FindNameGlobbingFailure", + ErrorCategory.InvalidArgument, this); return string.Empty; } - string typeFilterPart = GetTypeFilterForRequest(type); - var requestUrlV2 = $"{Repository.Uri}/Search()?$filter={nameFilter}{typeFilterPart} and {prerelease}{extraParam}"; - + if (!_isPSGalleryRepo && type != ResourceType.None) + { + errRecord = new ErrorRecord( + new ArgumentException("-Name with wildcards with -Type is not supported for this repository."), + "FindNameGlobbingNotSupportedForRepo", + ErrorCategory.InvalidArgument, + this); + + return string.Empty; + } + if (type != ResourceType.None) { + filterBuilder.AddCriterion(GetTypeFilterForRequest(type)); + } + var requestUrlV2 = $"{Repository.Uri}/Search()?{queryBuilder.BuildQueryString()}"; + return HttpRequestCall(requestUrlV2, out errRecord); } @@ -941,18 +1166,57 @@ private string FindNameGlobbingWithTag(string packageName, string[] tags, Resour // https://www.powershellgallery.com/api/v2/Search()?$filter=endswith(Id, 'Get') and startswith(Id, 'PowerShell') and IsLatestVersion (stable) // https://www.powershellgallery.com/api/v2/Search()?$filter=endswith(Id, 'Get') and IsAbsoluteLatestVersion&includePrerelease=true - string extraParam = $"&$orderby=Id desc&$inlinecount=allpages&$skip={skip}&$top=100"; - var prerelease = includePrerelease ? "IsAbsoluteLatestVersion&includePrerelease=true" : "IsLatestVersion"; - string nameFilter; + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary{ + { "$inlinecount", "allpages" }, + { "$skip", skip.ToString()}, + { "$top", "100"} + }); + var filterBuilder = queryBuilder.FilterBuilder; + + if (_isPSGalleryRepo) { + queryBuilder.AdditionalParameters["$orderby"] = "Id desc"; + } + + // JFrog/Artifactory requires an empty search term to enumerate all packages in the feed + if (includePrerelease) { + queryBuilder.AdditionalParameters["includePrerelease"] = "true"; + if (_isJFrogRepo) { + // note: we add 'eq true' because some PMPs (currently we know of JFrog, but others may do this too) will proxy the query unedited to the upstream remote and if that's PSGallery, it doesn't handle IsAbsoluteLatestVersion correctly + filterBuilder.AddCriterion("IsAbsoluteLatestVersion eq true"); + } + else { + // For ADO, 'IsLatestVersion eq true' and 'IsAbsoluteLatestVersion eq true' in the filter create a bad request error, so we use 'IsLatestVersion' or 'IsAbsoluteLatestVersion' only + filterBuilder.AddCriterion("IsAbsoluteLatestVersion"); + } + } else { + if (_isJFrogRepo) { + // note: we add 'eq true' because some PMPs (currently we know of JFrog, but others may do this too) will proxy the query unedited to the upstream remote and if that's PSGallery, it doesn't handle IsLatestVersion correctly + filterBuilder.AddCriterion("IsLatestVersion eq true"); + } + else { + // For ADO, 'IsLatestVersion eq true' and 'IsAbsoluteLatestVersion eq true' in the filter create a bad request error, so we use 'IsLatestVersion' or 'IsAbsoluteLatestVersion' only + filterBuilder.AddCriterion("IsLatestVersion"); + } + } var names = packageName.Split(new char[] {'*'}, StringSplitOptions.RemoveEmptyEntries); + if (!_isPSGalleryRepo) + { + errRecord = new ErrorRecord( + new ArgumentException("Name globbing with tags is not supported for V2 server protocol repositories."), + "FindNameGlobbingAndTagFailure", + ErrorCategory.InvalidArgument, + this); + + return string.Empty; + } if (names.Length == 0) { errRecord = new ErrorRecord( - new ArgumentException("-Name '*' for V2 server protocol repositories is not supported"), - "FindNameGlobbingFailure", - ErrorCategory.InvalidArgument, + new ArgumentException("-Name '*' for V2 server protocol repositories is not supported"), + "FindNameGlobbingFailure", + ErrorCategory.InvalidArgument, this); return string.Empty; @@ -961,18 +1225,17 @@ private string FindNameGlobbingWithTag(string packageName, string[] tags, Resour { if (packageName.StartsWith("*") && packageName.EndsWith("*")) { - // *get* - nameFilter = $"substringof('{names[0]}', Id)"; + filterBuilder.AddCriterion($"substringof('{names[0]}', Id)"); } else if (packageName.EndsWith("*")) { // PowerShell* - nameFilter = $"startswith(Id, '{names[0]}')"; + filterBuilder.AddCriterion($"startswith(Id, '{names[0]}')"); } else { // *ShellGet - nameFilter = $"endswith(Id, '{names[0]}')"; + filterBuilder.AddCriterion($"endswith(Id, '{names[0]}')"); } } else if (names.Length == 2 && !packageName.StartsWith("*") && !packageName.EndsWith("*")) @@ -981,14 +1244,14 @@ private string FindNameGlobbingWithTag(string packageName, string[] tags, Resour // pow*get -> only support this // pow*get* // *pow*get - nameFilter = $"startswith(Id, '{names[0]}') and endswith(Id, '{names[1]}')"; + filterBuilder.AddCriterion($"startswith(Id, '{names[0]}') and endswith(Id, '{names[1]}')"); } else { errRecord = new ErrorRecord( - new ArgumentException("-Name with wildcards is only supported for scenarios similar to the following examples: PowerShell*, *ShellGet, *Shell*."), - "FindNameGlobbing", - ErrorCategory.InvalidArgument, + new ArgumentException("-Name with wildcards is only supported for scenarios similar to the following examples: PowerShell*, *ShellGet, *Shell*."), + "FindNameGlobbing", + ErrorCategory.InvalidArgument, this); return string.Empty; @@ -997,12 +1260,14 @@ private string FindNameGlobbingWithTag(string packageName, string[] tags, Resour string tagFilterPart = String.Empty; foreach (string tag in tags) { - tagFilterPart += $" and substringof('{tag}', Tags) eq true"; + filterBuilder.AddCriterion($"substringof('{tag}', Tags) eq true"); } - string typeFilterPart = GetTypeFilterForRequest(type); - var requestUrlV2 = $"{Repository.Uri}/Search()?$filter={nameFilter}{tagFilterPart}{typeFilterPart} and {prerelease}{extraParam}"; - + if (type != ResourceType.None) { + filterBuilder.AddCriterion(GetTypeFilterForRequest(type)); + } + var requestUrlV2 = $"{Repository.Uri}/Search()?{queryBuilder.BuildQueryString()}"; + return HttpRequestCall(requestUrlV2, out errRecord); } @@ -1041,6 +1306,15 @@ private string FindVersionGlobbing(string packageName, VersionRange versionRange string minPart = String.Empty; string maxPart = String.Empty; + var queryBuilder = new NuGetV2QueryBuilder(new Dictionary { + {"$inlinecount", "allpages"}, + {"$skip", skip.ToString()}, + {"$orderby", "NormalizedVersion desc"}, + {"id", $"'{packageName}'"} + }); + + var filterBuilder = queryBuilder.FilterBuilder; + if (versionRange.MinVersion != null) { string operation = versionRange.IsMinInclusive ? "ge" : "gt"; @@ -1054,7 +1328,7 @@ private string FindVersionGlobbing(string packageName, VersionRange versionRange // because we want to retrieve all the prerelease versions for the upper end of the range // and PSGallery views prerelease as higher than its stable. // eg 3.0.0-prerelease > 3.0.0 - // If looking for versions within '[1.9.9,1.9.9]' including prerelease values, this will change it to search for '[1.9.9,1.9.99]' + // If looking for versions within '[1.9.9,1.9.9]' including prerelease values, this will change it to search for '[1.9.9,1.9.99]' // and find any pkg versions that are 1.9.9-prerelease. string maxString = includePrerelease ? $"{versionRange.MaxVersion.Major}.{versionRange.MaxVersion.Minor}.{versionRange.MaxVersion.Patch.ToString() + "9"}" : $"{versionRange.MaxVersion.ToNormalizedString()}"; @@ -1062,76 +1336,40 @@ private string FindVersionGlobbing(string packageName, VersionRange versionRange { maxPart = String.Format(format, operation, $"'{maxVersion.ToNormalizedString()}'"); } - else { + else { maxPart = String.Format(format, operation, $"'{versionRange.MaxVersion.ToNormalizedString()}'"); } } string versionFilterParts = String.Empty; - if (!String.IsNullOrEmpty(minPart) && !String.IsNullOrEmpty(maxPart)) + if (!String.IsNullOrEmpty(minPart)) { - versionFilterParts += minPart + " and " + maxPart; + filterBuilder.AddCriterion(minPart); } - else if (!String.IsNullOrEmpty(minPart)) + if (!String.IsNullOrEmpty(maxPart)) { - versionFilterParts += minPart; + filterBuilder.AddCriterion(maxPart); } - else if (!String.IsNullOrEmpty(maxPart)) - { - versionFilterParts += maxPart; + if (!includePrerelease) { + filterBuilder.AddCriterion("IsPrerelease eq false"); } - string filterQuery = "&$filter="; - filterQuery += includePrerelease ? string.Empty : "IsPrerelease eq false"; - - string andOperator = " and "; - string joiningOperator = filterQuery.EndsWith("=") ? String.Empty : andOperator; // We need to explicitly add 'Id eq ' whenever $filter is used, otherwise arbitrary results are returned. // If it's a JFrog repository do not include the Id filter portion since JFrog uses 'Title' instead of 'Id', // however filtering on 'and Title eq '' returns "Response status code does not indicate success: 500". - string idFilterPart = $"{joiningOperator}"; - idFilterPart += _isJFrogRepo ? "" : $"Id eq '{packageName}'"; - filterQuery += idFilterPart; - filterQuery += type == ResourceType.Script ? $"{andOperator}substringof('PS{type.ToString()}', Tags) eq true" : String.Empty; - - if (!String.IsNullOrEmpty(versionFilterParts)) - { - // Check if includePrerelease is true, if it is we want to add "$filter" - // Single case where version is "*" (or "[,]") and includePrerelease is true, then we do not want to add "$filter" to the requestUrl. - - // Note: could be null/empty if Version was "*" -> [,] - filterQuery += $"{andOperator}{versionFilterParts}"; + if (!_isJFrogRepo) { + filterBuilder.AddCriterion($"Id eq '{packageName}'"); } - string paginationParam = $"$inlinecount=allpages&$skip={skip}"; - - filterQuery = filterQuery.EndsWith("=") ? string.Empty : filterQuery; - var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?id='{packageName}'&$orderby=NormalizedVersion desc&{paginationParam}{filterQuery}"; - - return HttpRequestCall(requestUrlV2, out errRecord); - } - - /// - /// Installs specific package. - /// Name: no wildcard support. - /// Examples: Install "PowerShellGet" - /// Implementation Note: if not prerelease: https://www.powershellgallery.com/api/v2/package/powershellget (Returns latest stable) - /// if prerelease, call into InstallVersion instead. - /// - private Stream InstallName(string packageName, out ErrorRecord errRecord) - { - _cmdletPassedIn.WriteDebug("In V2ServerAPICalls::InstallName()"); - var requestUrlV2 = $"{Repository.Uri}/package/{packageName}"; - var response = HttpRequestCallForContent(requestUrlV2, out errRecord); - if (errRecord != null) - { - return new MemoryStream(); + if (type == ResourceType.Script) { + filterBuilder.AddCriterion($"substringof('PS{type.ToString()}', Tags) eq true"); } - var responseStream = response.ReadAsStreamAsync().Result; - return responseStream; + var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; + + return HttpRequestCall(requestUrlV2, out errRecord); } /// @@ -1150,9 +1388,9 @@ private Stream InstallVersion(string packageName, string version, out ErrorRecor if (_isADORepo) { // eg: https://pkgs.dev.azure.com///_packaging//nuget/v2?id=test_module&version=5.0.0 - requestUrlV2 = $"{Repository.Uri}?id={packageName}&version={version}"; + requestUrlV2 = $"{Repository.Uri}?id={packageName.ToLower()}&version={version}"; } - if (_isJFrogRepo) + else if (_isJFrogRepo) { // eg: https://.jfrog.io/artifactory/api/nuget//Download/test_module/5.0.0 requestUrlV2 = $"{Repository.Uri}/Download/{packageName}/{version}"; @@ -1163,24 +1401,35 @@ private Stream InstallVersion(string packageName, string version, out ErrorRecor } var response = HttpRequestCallForContent(requestUrlV2, out errRecord); - var responseStream = response.ReadAsStreamAsync().Result; + if (errRecord != null) { return new MemoryStream(); } - return responseStream; + if (response is null) + { + errRecord = new ErrorRecord( + new Exception($"No content was returned by repository '{Repository.Name}'"), + "InstallFailureContentNullv2", + ErrorCategory.InvalidResult, + this); + + return null; + } + + return response.ReadAsStreamAsync().Result; } private string GetTypeFilterForRequest(ResourceType type) { string typeFilterPart = string.Empty; if (type == ResourceType.Script) { - typeFilterPart += $" and substringof('PS{type.ToString()}', Tags) eq true "; + typeFilterPart += $"substringof('PS{type.ToString()}', Tags) eq true"; } else if (type == ResourceType.Module) { - typeFilterPart += $" and substringof('PS{ResourceType.Script.ToString()}', Tags) eq false "; + typeFilterPart += $"substringof('PS{ResourceType.Script.ToString()}', Tags) eq false"; } return typeFilterPart; @@ -1222,7 +1471,7 @@ public int GetCountFromResponse(string httpResponse, out ErrorRecord errRecord) countSearchSucceeded = int.TryParse(node.InnerText, out count); } } - + if (!countSearchSucceeded) { // Note: not all V2 servers may have the 'count' property implemented or valid (i.e CloudSmith server), in this case try to get 'd:Id' property. @@ -1234,16 +1483,16 @@ public int GetCountFromResponse(string httpResponse, out ErrorRecord errRecord) } else { - _cmdletPassedIn.WriteDebug($"Property 'count' and 'd:Id' could not be found in response. This may indicate that the package could not be found"); + _cmdletPassedIn.WriteDebug($"Property 'count' and 'd:Id' could not be found in response. This may indicate that the package could not be found"); } } } catch (XmlException e) { errRecord = new ErrorRecord( - exception: e, - "GetCountFromResponse", - ErrorCategory.InvalidData, + exception: e, + "GetCountFromResponse", + ErrorCategory.InvalidData, this); } diff --git a/src/code/V3ServerAPICalls.cs b/src/code/V3ServerAPICalls.cs index dec0da5ea..c184426a8 100644 --- a/src/code/V3ServerAPICalls.cs +++ b/src/code/V3ServerAPICalls.cs @@ -9,6 +9,7 @@ using System.Net.Http; using System.Linq; using System.Net; +using System.Text; using System.Text.Json; using System.Threading.Tasks; using System.Collections; @@ -50,13 +51,31 @@ public V3ServerAPICalls(PSRepositoryInfo repository, PSCmdlet cmdletPassedIn, Ne { this.Repository = repository; _cmdletPassedIn = cmdletPassedIn; - HttpClientHandler handler = new HttpClientHandler() + HttpClientHandler handler = new HttpClientHandler(); + handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate; + bool token = false; + + if(networkCredential != null) { - AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, - Credentials = networkCredential + token = String.Equals("token", networkCredential.UserName) ? true : false; }; - _sessionClient = new HttpClient(handler); + if (token) + { + string credString = string.Format(":{0}", networkCredential.Password); + byte[] byteArray = Encoding.ASCII.GetBytes(credString); + + _sessionClient = new HttpClient(handler); + _sessionClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(byteArray)); + + } else { + + handler.Credentials = networkCredential; + + _sessionClient = new HttpClient(handler); + }; + + _sessionClient.Timeout = TimeSpan.FromMinutes(10); _sessionClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgentString); _isNuGetRepo = String.Equals(Repository.Uri.AbsoluteUri, nugetRepoUri, StringComparison.InvariantCultureIgnoreCase); @@ -78,8 +97,8 @@ public override FindResults FindAll(bool includePrerelease, ResourceType type, o _cmdletPassedIn.WriteDebug("In V3ServerAPICalls::FindAll()"); errRecord = new ErrorRecord( new InvalidOperationException($"Find all is not supported for the V3 server protocol repository '{Repository.Name}'"), - "FindAllFailure", - ErrorCategory.InvalidOperation, + "FindAllFailure", + ErrorCategory.InvalidOperation, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -116,9 +135,9 @@ public override FindResults FindCommandOrDscResource(string[] tags, bool include { _cmdletPassedIn.WriteDebug("In V3ServerAPICalls::FindCommandOrDscResource()"); errRecord = new ErrorRecord( - new InvalidOperationException($"Find by CommandName or DSCResource is not supported for the V3 server protocol repository '{Repository.Name}'"), - "FindCommandOrDscResourceFailure", - ErrorCategory.InvalidOperation, + new InvalidOperationException($"Find by CommandName or DSCResource is not supported for the V3 server protocol repository '{Repository.Name}'"), + "FindCommandOrDscResourceFailure", + ErrorCategory.InvalidOperation, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -162,9 +181,9 @@ public override FindResults FindNameGlobbing(string packageName, bool includePre else { errRecord = new ErrorRecord( - new InvalidOperationException($"Find with Name containing wildcards is not supported for the V3 server protocol repository '{Repository.Name}'"), - "FindNameGlobbingFailure", - ErrorCategory.InvalidOperation, + new InvalidOperationException($"Find with Name containing wildcards is not supported for the V3 server protocol repository '{Repository.Name}'"), + "FindNameGlobbingFailure", + ErrorCategory.InvalidOperation, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -185,9 +204,9 @@ public override FindResults FindNameGlobbingWithTag(string packageName, string[] else { errRecord = new ErrorRecord( - new InvalidOperationException($"Find with Name containing wildcards is not supported for the V3 server protocol repository '{Repository.Name}'"), - "FindNameGlobbingWithTagFailure", - ErrorCategory.InvalidOperation, + new InvalidOperationException($"Find with Name containing wildcards is not supported for the V3 server protocol repository '{Repository.Name}'"), + "FindNameGlobbingWithTagFailure", + ErrorCategory.InvalidOperation, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -222,9 +241,9 @@ public override FindResults FindVersionGlobbing(string packageName, VersionRange if (!rootDom.TryGetProperty(versionName, out JsonElement pkgVersionElement)) { errRecord = new ErrorRecord( - new InvalidOrEmptyResponse($"Response does not contain '{versionName}' element."), - "FindVersionGlobbingFailure", - ErrorCategory.InvalidData, + new InvalidOrEmptyResponse($"Response does not contain '{versionName}' element."), + "FindVersionGlobbingFailure", + ErrorCategory.InvalidData, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -243,9 +262,9 @@ public override FindResults FindVersionGlobbing(string packageName, VersionRange catch (Exception e) { errRecord = new ErrorRecord( - exception: e, + exception: e, "FindVersionGlobbingFailure", - ErrorCategory.InvalidResult, + ErrorCategory.InvalidResult, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -285,6 +304,8 @@ public override FindResults FindVersionWithTag(string packageName, string versio /// /// Installs a specific package. + /// User may request to install package with or without providing version (as seen in examples below), but prior to calling this method the package is located and package version determined. + /// Therefore, package version should not be null in this method. /// Name: no wildcard support. /// Examples: Install "PowerShellGet" /// Install "PowerShellGet" -Version "3.0.0" @@ -295,13 +316,16 @@ public override Stream InstallPackage(string packageName, string packageVersion, Stream results = new MemoryStream(); if (string.IsNullOrEmpty(packageVersion)) { - results = InstallName(packageName, out errRecord); - } - else - { - results = InstallVersion(packageName, packageVersion, out errRecord); + errRecord = new ErrorRecord( + exception: new ArgumentNullException($"Package version could not be found for {packageName}"), + "PackageVersionNullOrEmptyError", + ErrorCategory.InvalidArgument, + _cmdletPassedIn); + + return results; } + results = InstallVersion(packageName, packageVersion, out errRecord); return results; } @@ -322,8 +346,8 @@ private FindResults FindNameGlobbingFromNuGetRepo(string packageName, string[] t { errRecord = new ErrorRecord( new ArgumentException("-Name '*' for V3 server protocol repositories is not supported"), - "FindNameGlobbingFromNuGetRepoFailure", - ErrorCategory.InvalidArgument, + "FindNameGlobbingFromNuGetRepoFailure", + ErrorCategory.InvalidArgument, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -379,9 +403,9 @@ private FindResults FindNameGlobbingFromNuGetRepo(string packageName, string[] t if (!pkgEntry.TryGetProperty(tagsName, out JsonElement tagsItem)) { errRecord = new ErrorRecord( - new JsonParsingException("FindNameGlobbing(): Tags element could not be found in response."), - "GetEntriesFromSearchQueryResourceFailure", - ErrorCategory.InvalidResult, + new JsonParsingException("FindNameGlobbing(): Tags element could not be found in response."), + "GetEntriesFromSearchQueryResourceFailure", + ErrorCategory.InvalidResult, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -410,8 +434,8 @@ private FindResults FindNameGlobbingFromNuGetRepo(string packageName, string[] t { errRecord = new ErrorRecord( exception: e, - "GetEntriesFromSearchQueryResourceFailure", - ErrorCategory.InvalidResult, + "GetEntriesFromSearchQueryResourceFailure", + ErrorCategory.InvalidResult, this); break; @@ -439,9 +463,9 @@ private FindResults FindTagsFromNuGetRepo(string[] tags, bool includePrerelease, if (tagPkgEntries.Count == 0) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"Package with Tags '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), - "PackageWithSpecifiedTagsNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with Tags '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), + "PackageWithSpecifiedTagsNotFound", + ErrorCategory.ObjectNotFound, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -480,19 +504,19 @@ private FindResults FindNameHelper(string packageName, string[] tags, bool inclu if (!rootDom.TryGetProperty(versionName, out JsonElement pkgVersionElement)) { errRecord = new ErrorRecord( - new InvalidOrEmptyResponse($"Response does not contain '{versionName}' element for search with Name '{packageName}' in '{Repository.Name}'."), - "FindNameFailure", - ErrorCategory.InvalidResult, + new InvalidOrEmptyResponse($"Response does not contain '{versionName}' element for search with Name '{packageName}' in '{Repository.Name}'."), + "FindNameFailure", + ErrorCategory.InvalidResult, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); } - if (!rootDom.TryGetProperty(tagsName, out JsonElement tagsItem)) + if (!rootDom.TryGetProperty(tagsName, out JsonElement tagsItem) && tags.Length != 0) { errRecord = new ErrorRecord( - new InvalidOrEmptyResponse($"Response does not contain '{tagsName}' element for search with Name '{packageName}' in '{Repository.Name}'."), - "FindNameFailure", - ErrorCategory.InvalidResult, + new InvalidOrEmptyResponse($"Response does not contain '{tagsName}' element for search with Name '{packageName}' in '{Repository.Name}'."), + "FindNameFailure", + ErrorCategory.InvalidResult, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -515,9 +539,9 @@ private FindResults FindNameHelper(string packageName, string[] tags, bool inclu catch (Exception e) { errRecord = new ErrorRecord( - exception: e, - "FindNameFailure", - ErrorCategory.InvalidResult, + exception: e, + "FindNameFailure", + ErrorCategory.InvalidResult, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -527,9 +551,9 @@ private FindResults FindNameHelper(string packageName, string[] tags, bool inclu if (String.IsNullOrEmpty(latestVersionResponse)) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"Package with name '{packageName}' could not be found in repository '{Repository.Name}'."), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with name '{packageName}' could not be found in repository '{Repository.Name}'."), + "PackageNotFound", + ErrorCategory.ObjectNotFound, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -541,9 +565,9 @@ private FindResults FindNameHelper(string packageName, string[] tags, bool inclu if (errRecord == null) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"Package with name '{packageName}' and tags '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with name '{packageName}' and tags '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), + "PackageNotFound", + ErrorCategory.ObjectNotFound, this); } @@ -562,9 +586,9 @@ private FindResults FindVersionHelper(string packageName, string version, string if (!NuGetVersion.TryParse(version, out NuGetVersion requiredVersion)) { errRecord = new ErrorRecord( - new ArgumentException($"Version {version} to be found is not a valid NuGet version."), - "FindNameFailure", - ErrorCategory.InvalidArgument, + new ArgumentException($"Version {version} to be found is not a valid NuGet version."), + "FindNameFailure", + ErrorCategory.InvalidArgument, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -590,19 +614,19 @@ private FindResults FindVersionHelper(string packageName, string version, string if (!rootDom.TryGetProperty(versionName, out JsonElement pkgVersionElement)) { errRecord = new ErrorRecord( - new InvalidOrEmptyResponse($"Response does not contain '{versionName}' element for search with name '{packageName}' and version '{version}' in repository '{Repository.Name}'."), - "FindVersionFailure", - ErrorCategory.InvalidResult, + new InvalidOrEmptyResponse($"Response does not contain '{versionName}' element for search with name '{packageName}' and version '{version}' in repository '{Repository.Name}'."), + "FindVersionFailure", + ErrorCategory.InvalidResult, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); } - if (!rootDom.TryGetProperty(tagsName, out JsonElement tagsItem)) + if (!rootDom.TryGetProperty(tagsName, out JsonElement tagsItem) && tags.Length != 0) { errRecord = new ErrorRecord( - new InvalidOrEmptyResponse($"Response does not contain '{tagsName}' element for search with name '{packageName}' and version '{version}' in repository '{Repository.Name}'."), - "FindVersionFailure", - ErrorCategory.InvalidResult, + new InvalidOrEmptyResponse($"Response does not contain '{tagsName}' element for search with name '{packageName}' and version '{version}' in repository '{Repository.Name}'."), + "FindVersionFailure", + ErrorCategory.InvalidResult, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -622,9 +646,9 @@ private FindResults FindVersionHelper(string packageName, string version, string catch (Exception e) { errRecord = new ErrorRecord( - exception: e, - "FindVersionFailure", - ErrorCategory.InvalidResult, + exception: e, + "FindVersionFailure", + ErrorCategory.InvalidResult, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -634,9 +658,9 @@ private FindResults FindVersionHelper(string packageName, string version, string if (String.IsNullOrEmpty(latestVersionResponse)) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"Package with name '{packageName}', version '{version}' could not be found in repository '{Repository.Name}'."), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with name '{packageName}', version '{version}' could not be found in repository '{Repository.Name}'."), + "PackageNotFound", + ErrorCategory.ObjectNotFound, this); return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v3FindResponseType); @@ -647,9 +671,9 @@ private FindResults FindVersionHelper(string packageName, string version, string if (errRecord == null) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"FindVersion(): Package with name '{packageName}', version '{version}' and tags '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"FindVersion(): Package with name '{packageName}', version '{version}' and tags '{String.Join(", ", tags)}' could not be found in repository '{Repository.Name}'."), + "PackageNotFound", + ErrorCategory.ObjectNotFound, this); } @@ -717,9 +741,9 @@ private Stream InstallHelper(string packageName, NuGetVersion version, out Error if (versionedResponses.Length == 0) { errRecord = new ErrorRecord( - new Exception($"Package with name '{packageName}' and version '{version}' could not be found in repository '{Repository.Name}'"), - "InstallFailure", - ErrorCategory.InvalidResult, + new Exception($"Package with name '{packageName}' and version '{version}' could not be found in repository '{Repository.Name}'"), + "InstallFailure", + ErrorCategory.InvalidResult, this); return null; @@ -748,9 +772,9 @@ private Stream InstallHelper(string packageName, NuGetVersion version, out Error if (String.IsNullOrEmpty(pkgContentUrl)) { errRecord = new ErrorRecord( - new Exception($"Package with name '{packageName}' and version '{version}' could not be found in repository '{Repository.Name}'"), - "InstallFailure", - ErrorCategory.InvalidResult, + new Exception($"Package with name '{packageName}' and version '{version}' could not be found in repository '{Repository.Name}'"), + "InstallFailure", + ErrorCategory.InvalidResult, this); return null; @@ -762,9 +786,18 @@ private Stream InstallHelper(string packageName, NuGetVersion version, out Error return null; } - pkgStream = content.ReadAsStreamAsync().Result; + if (content is null) + { + errRecord = new ErrorRecord( + new Exception($"No content was returned by repository '{Repository.Name}'"), + "InstallFailureContentNullv3", + ErrorCategory.InvalidResult, + this); + + return null; + } - return pkgStream; + return content.ReadAsStreamAsync().Result; } /// @@ -860,8 +893,8 @@ private Dictionary GetResourcesFromServiceIndex(out ErrorRecord if (!resource.TryGetProperty("@type", out JsonElement typeElement)) { errRecord = new ErrorRecord( - new JsonParsingException($"@type element not found for resource in service index for repository '{Repository.Name}'"), "GetResourcesFromServiceIndexFailure", - ErrorCategory.InvalidResult, + new JsonParsingException($"@type element not found for resource in service index for repository '{Repository.Name}'"), "GetResourcesFromServiceIndexFailure", + ErrorCategory.InvalidResult, this); return new Dictionary(); @@ -870,9 +903,9 @@ private Dictionary GetResourcesFromServiceIndex(out ErrorRecord if (!resource.TryGetProperty("@id", out JsonElement idElement)) { errRecord = new ErrorRecord( - new JsonParsingException($"@id element not found for resource in service index for repository '{Repository.Name}'"), - "GetResourcesFromServiceIndexFailure", - ErrorCategory.InvalidResult, + new JsonParsingException($"@id element not found for resource in service index for repository '{Repository.Name}'"), + "GetResourcesFromServiceIndexFailure", + ErrorCategory.InvalidResult, this); return new Dictionary(); @@ -887,9 +920,9 @@ private Dictionary GetResourcesFromServiceIndex(out ErrorRecord catch (Exception e) { errRecord = new ErrorRecord( - new Exception($"Exception parsing service index JSON for respository '{Repository.Name}' with error: {e.Message}"), - "GetResourcesFromServiceIndexFailure", - ErrorCategory.InvalidResult, + new Exception($"Exception parsing service index JSON for respository '{Repository.Name}' with error: {e.Message}"), + "GetResourcesFromServiceIndexFailure", + ErrorCategory.InvalidResult, this); return new Dictionary(); @@ -941,9 +974,9 @@ private string FindRegistrationsBaseUrl(Dictionary resources, ou else { errRecord = new ErrorRecord( - new ResourceNotFoundException($"RegistrationBaseUrl resource could not be found for repository '{Repository.Name}'"), - "FindRegistrationsBaseUrlFailure", - ErrorCategory.InvalidResult, + new ResourceNotFoundException($"RegistrationBaseUrl resource could not be found for repository '{Repository.Name}'"), + "FindRegistrationsBaseUrlFailure", + ErrorCategory.InvalidResult, this); } @@ -979,9 +1012,9 @@ private string FindSearchQueryService(Dictionary resources, out else { errRecord = new ErrorRecord( - new ResourceNotFoundException($"SearchQueryService resource could not be found for Repository '{Repository.Name}'"), - "FindSearchQueryServiceFailure", - ErrorCategory.InvalidResult, + new ResourceNotFoundException($"SearchQueryService resource could not be found for Repository '{Repository.Name}'"), + "FindSearchQueryServiceFailure", + ErrorCategory.InvalidResult, this); } @@ -1005,9 +1038,9 @@ private JsonElement[] GetMetadataElementFromIdLinkElement(JsonElement idLinkElem { if (errRecord.Exception is ResourceNotFoundException) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"Package with name '{packageName}' could not be found in repository '{Repository.Name}'.", errRecord.Exception), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with name '{packageName}' could not be found in repository '{Repository.Name}'.", errRecord.Exception), + "PackageNotFound", + ErrorCategory.ObjectNotFound, this); } @@ -1022,9 +1055,9 @@ private JsonElement[] GetMetadataElementFromIdLinkElement(JsonElement idLinkElem if (!rootDom.TryGetProperty(itemsName, out JsonElement innerItemsElement)) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"'{itemsName}' element for package with name '{packageName}' could not be found in JFrog repository '{Repository.Name}'"), - "GetElementForJFrogRepoFailure", - ErrorCategory.InvalidResult, + new ResourceNotFoundException($"'{itemsName}' element for package with name '{packageName}' could not be found in JFrog repository '{Repository.Name}'"), + "GetElementForJFrogRepoFailure", + ErrorCategory.InvalidResult, this); return innerItems; @@ -1049,9 +1082,9 @@ private JsonElement[] GetMetadataElementFromIdLinkElement(JsonElement idLinkElem catch (Exception e) { errRecord = new ErrorRecord( - exception: e, - "MetadataElementForIdElementRetrievalFailure", - ErrorCategory.InvalidResult, + exception: e, + "MetadataElementForIdElementRetrievalFailure", + ErrorCategory.InvalidResult, this); } @@ -1216,7 +1249,7 @@ private string[] GetMetadataElementsFromResponse(string response, string propert return versionedPkgResponses.ToArray(); } - + /// /// Helper method iterates through the entries in the registrationsUrl for a specific package and all its versions. /// This contains an inner items element (containing the package metadata) and the packageContent element (containing URI through which the .nupkg can be downloaded) @@ -1237,9 +1270,9 @@ private string[] GetVersionedResponsesFromRegistrationsResource(string registrat if (errRecord.Exception is ResourceNotFoundException) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"Package with name '{packageName}' could not be found in repository '{Repository.Name}'.", errRecord.Exception), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with name '{packageName}' could not be found in repository '{Repository.Name}'.", errRecord.Exception), + "PackageNotFound", + ErrorCategory.ObjectNotFound, this); } @@ -1315,7 +1348,7 @@ private bool IsLatestVersionFirstForSearch(string[] versionedResponses, out Erro return latestVersionFirst; } - + string firstVersion = firstVersionElement.ToString(); if (!NuGetVersion.TryParse(firstVersion, out firstPkgVersion)) @@ -1343,7 +1376,7 @@ private bool IsLatestVersionFirstForSearch(string[] versionedResponses, out Erro return latestVersionFirst; } - + string lastVersion = lastVersionElement.ToString(); if (!NuGetVersion.TryParse(lastVersion, out lastPkgVersion)) @@ -1366,9 +1399,9 @@ private bool IsLatestVersionFirstForSearch(string[] versionedResponses, out Erro catch (Exception e) { errRecord = new ErrorRecord( - exception: e, - "LatestVersionFirstSearchFailure", - ErrorCategory.InvalidResult, + exception: e, + "LatestVersionFirstSearchFailure", + ErrorCategory.InvalidResult, this); return true; @@ -1435,11 +1468,11 @@ private bool IsRequiredTagSatisfied(JsonElement tagsElement, string[] tags, out catch (Exception e) { errRecord = new ErrorRecord( - exception: e, - "GetResponsesFromRegistrationsResourceFailure", - ErrorCategory.InvalidResult, + exception: e, + "GetResponsesFromRegistrationsResourceFailure", + ErrorCategory.InvalidResult, this); - + return false; } @@ -1498,17 +1531,17 @@ private JsonElement[] GetJsonElementArr(string request, string propertyName, out { // scenario where the feed is not active anymore, i.e confirmed for JFrogArtifactory. The default error message is not intuitive. errRecord = new ErrorRecord( - exception: new Exception($"JSON response from repository {Repository.Name} could not be parsed, likely due to the feed being inactive or invalid, with inner exception: {e.Message}"), + exception: new Exception($"JSON response from repository {Repository.Name} could not be parsed, likely due to the feed being inactive or invalid, with inner exception: {e.Message}"), "FindVersionGlobbingFailure", - ErrorCategory.InvalidResult, + ErrorCategory.InvalidResult, this); } catch (Exception e) { errRecord = new ErrorRecord( - exception: e, - "GetResponsesFromRegistrationsResourceFailure", - ErrorCategory.InvalidResult, + exception: e, + "GetResponsesFromRegistrationsResourceFailure", + ErrorCategory.InvalidResult, this); } @@ -1534,33 +1567,33 @@ private string HttpRequestCall(string requestUrlV3, out ErrorRecord errRecord) catch (ResourceNotFoundException e) { errRecord = new ErrorRecord( - exception: e, - "ResourceNotFound", - ErrorCategory.InvalidResult, + exception: e, + "ResourceNotFound", + ErrorCategory.InvalidResult, this); } catch (UnauthorizedException e) { errRecord = new ErrorRecord( - exception: e, - "UnauthorizedRequest", - ErrorCategory.InvalidResult, + exception: e, + "UnauthorizedRequest", + ErrorCategory.InvalidResult, this); } catch (HttpRequestException e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestCallFailure", - ErrorCategory.InvalidResult, + exception: e, + "HttpRequestCallFailure", + ErrorCategory.InvalidResult, this); } catch (Exception e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestCallFailure", - ErrorCategory.InvalidResult, + exception: e, + "HttpRequestCallFailure", + ErrorCategory.InvalidResult, this); } @@ -1585,13 +1618,15 @@ private HttpContent HttpRequestCallForContent(string requestUrlV3, out ErrorReco catch (Exception e) { errRecord = new ErrorRecord( - exception: e, - "HttpRequestCallForContentFailure", - ErrorCategory.InvalidResult, + exception: e, + "HttpRequestCallForContentFailure", + ErrorCategory.InvalidResult, this); + + return null; } - if (string.IsNullOrEmpty(content.ToString())) + if (string.IsNullOrEmpty(content?.ToString())) { _cmdletPassedIn.WriteDebug("Response is empty"); } diff --git a/test/FindPSResourceTests/FindPSResourceADOServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceADOServer.Tests.ps1 index 07197f426..df2632489 100644 --- a/test/FindPSResourceTests/FindPSResourceADOServer.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceADOServer.Tests.ps1 @@ -1,6 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. - +<## $modPath = "$psscriptroot/../PSGetTestUtils.psm1" Import-Module $modPath -Force -Verbose @@ -9,7 +9,7 @@ Describe 'Test HTTP Find-PSResource for ADO Server Protocol' -tags 'CI' { BeforeAll{ $testModuleName = "test_local_mod" $ADORepoName = "PSGetTestingPublicFeed" - $ADORepoUri = "https://pkgs.dev.azure.com/powershell/PowerShell/_packaging/powershell-public-test/nuget/v3/index.json" + $ADORepoUri = "https://pkgs.dev.azure.com/powershell/PowerShell/_packaging/psresourceget-public-test-ci/nuget/v3/index.json" Get-NewPSResourceRepositoryFile Register-PSResourceRepository -Name $ADORepoName -Uri $ADORepoUri } @@ -212,3 +212,4 @@ Describe 'Test HTTP Find-PSResource for ADO Server Protocol' -tags 'CI' { $err[0].FullyQualifiedErrorId | Should -BeExactly "FindAllFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } } +##> \ No newline at end of file diff --git a/test/FindPSResourceTests/FindPSResourceADOV2Server.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceADOV2Server.Tests.ps1 new file mode 100644 index 000000000..253dad68e --- /dev/null +++ b/test/FindPSResourceTests/FindPSResourceADOV2Server.Tests.ps1 @@ -0,0 +1,269 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +$modPath = "$psscriptroot/../PSGetTestUtils.psm1" +Import-Module $modPath -Force -Verbose + +$psmodulePaths = $env:PSModulePath -split ';' +Write-Verbose -Verbose "Current module search paths: $psmodulePaths" + +Describe 'Test HTTP Find-PSResource for ADO V2 Server Protocol' -tags 'CI' { + + BeforeAll{ + $testModuleName = "test_local_mod" + $ADOV2RepoName = "PSGetTestingPublicFeed" + $ADOV2RepoUri = "https://pkgs.dev.azure.com/powershell/PowerShell/_packaging/psresourceget-public-test-ci/nuget/v2" + Get-NewPSResourceRepositoryFile + Register-PSResourceRepository -Name $ADOV2RepoName -Uri $ADOV2RepoUri + } + + AfterAll { + Get-RevertPSResourceRepositoryFile + } + + It "Find resource given specific Name, Version null" { + $res = Find-PSResource -Name $testModuleName -Repository $ADOV2RepoName + $res.Name | Should -Be $testModuleName + $res.Version | Should -Be "5.0.0" + } + + It "Should not find resource given nonexistant Name" { + $res = Find-PSResource -Name NonExistantModule -Repository $ADOV2RepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + $res | Should -BeNullOrEmpty + } + + It "Find resource(s) given wildcard Name" { + # FindNameGlobbing + $foundScript = $False + $res = Find-PSResource -Name "test_*" -Repository $ADOV2RepoName + $res.Count | Should -BeGreaterThan 1 + } + + $testCases2 = @{Version="[5.0.0]"; ExpectedVersions=@("5.0.0"); Reason="validate version, exact match"}, + @{Version="5.0.0"; ExpectedVersions=@("5.0.0"); Reason="validate version, exact match without bracket syntax"}, + @{Version="[1.0.0, 5.0.0]"; ExpectedVersions=@("1.0.0", "3.0.0", "5.0.0"); Reason="validate version, exact range inclusive"}, + @{Version="(1.0.0, 5.0.0)"; ExpectedVersions=@("3.0.0"); Reason="validate version, exact range exclusive"}, + @{Version="(1.0.0,)"; ExpectedVersions=@("3.0.0", "5.0.0"); Reason="validate version, minimum version exclusive"}, + @{Version="[1.0.0,)"; ExpectedVersions=@("1.0.0", "3.0.0", "5.0.0"); Reason="validate version, minimum version inclusive"}, + @{Version="(,3.0.0)"; ExpectedVersions=@("1.0.0"); Reason="validate version, maximum version exclusive"}, + @{Version="(,3.0.0]"; ExpectedVersions=@("1.0.0", "3.0.0"); Reason="validate version, maximum version inclusive"}, + @{Version="[1.0.0, 5.0.0)"; ExpectedVersions=@("1.0.0", "3.0.0"); Reason="validate version, mixed inclusive minimum and exclusive maximum version"} + @{Version="(1.0.0, 5.0.0]"; ExpectedVersions=@("3.0.0", "5.0.0"); Reason="validate version, mixed exclusive minimum and inclusive maximum version"} + + It "Find resource when given Name to " -TestCases $testCases2{ + # FindVersionGlobbing() + param($Version, $ExpectedVersions) + $res = Find-PSResource -Name $testModuleName -Version $Version -Repository $ADOV2RepoName + $res | Should -Not -BeNullOrEmpty + foreach ($item in $res) { + $item.Name | Should -Be $testModuleName + $ExpectedVersions | Should -Contain $item.Version + } + } + + It "Find all versions of resource when given specific Name, Version not null --> '*'" { + # FindVersionGlobbing() + $res = Find-PSResource -Name $testModuleName -Version "*" -Repository $ADOV2RepoName + $res | ForEach-Object { + $_.Name | Should -Be $testModuleName + } + + $res.Count | Should -BeGreaterOrEqual 1 + } + + It "Find resource with latest (including prerelease) version given Prerelease parameter" { + # FindName() + # test_module resource's latest version is a prerelease version, before that it has a non-prerelease version + $res = Find-PSResource -Name $testModuleName -Repository $ADOV2RepoName + $res.Version | Should -Be "5.0.0" + + $resPrerelease = Find-PSResource -Name $testModuleName -Prerelease -Repository $ADOV2RepoName + $resPrerelease.Version | Should -Be "5.2.5" + $resPrerelease.Prerelease | Should -Be "alpha001" + } + + It "Find resources, including Prerelease version resources, when given Prerelease parameter" { + # FindVersionGlobbing() + $resWithoutPrerelease = Find-PSResource -Name $testModuleName -Version "*" -Repository $ADOV2RepoName + $resWithPrerelease = Find-PSResource -Name $testModuleName -Version "*" -Repository $ADOV2RepoName + $resWithPrerelease.Count | Should -BeGreaterOrEqual $resWithoutPrerelease.Count + } + +<# LATER + It "Find resource and its dependency resources with IncludeDependencies parameter" { + # FindName() with deps + $resWithoutDependencies = Find-PSResource -Name "TestModuleWithDependencyE" -Repository $ADOV2RepoName + $resWithoutDependencies.Name | Should -Be "TestModuleWithDependencyE" + $resWithoutDependencies | Should -HaveCount 1 + + # TestModuleWithDependencyE has the following dependencies: + # TestModuleWithDependencyC <= 1.0.0.0 + # TestModuleWithDependencyB >= 1.0.0.0 + # TestModuleWithDependencyD <= 1.0.0.0 + + $resWithDependencies = Find-PSResource -Name "TestModuleWithDependencyE" -IncludeDependencies -Repository $ADOV2RepoName + $resWithDependencies | Should -HaveCount 4 + + $foundParentPkgE = $false + $foundDepB = $false + $foundDepBCorrectVersion = $false + $foundDepC = $false + $foundDepCCorrectVersion = $false + $foundDepD = $false + $foundDepDCorrectVersion = $false + foreach ($pkg in $resWithDependencies) + { + if ($pkg.Name -eq "TestModuleWithDependencyE") + { + $foundParentPkgE = $true + } + elseif ($pkg.Name -eq "TestModuleWithDependencyC") + { + $foundDepC = $true + $foundDepCCorrectVersion = [System.Version]$pkg.Version -le [System.Version]"1.0" + } + elseif ($pkg.Name -eq "TestModuleWithDependencyB") + { + $foundDepB = $true + $foundDepBCorrectVersion = [System.Version]$pkg.Version -ge [System.Version]"1.0" + } + elseif ($pkg.Name -eq "TestModuleWithDependencyD") + { + $foundDepD = $true + $foundDepDCorrectVersion = [System.Version]$pkg.Version -le [System.Version]"1.0" + } + } + + $foundParentPkgE | Should -Be $true + $foundDepC | Should -Be $true + $foundDepCCorrectVersion | Should -Be $true + $foundDepB | Should -Be $true + $foundDepBCorrectVersion | Should -Be $true + $foundDepD | Should -Be $true + $foundDepDCorrectVersion | Should -Be $true + } + + It "find resource of Type script or module from PSGallery, when no Type parameter provided" { + # FindName() script + $resScript = Find-PSResource -Name $testScriptName -Repository $ADOV2RepoName + $resScript.Name | Should -Be $testScriptName + $resScriptType = Out-String -InputObject $resScript.Type + $resScriptType.Replace(",", " ").Split() | Should -Contain "Script" + + $resModule = Find-PSResource -Name $testModuleName -Repository $ADOV2RepoName + $resModule.Name | Should -Be $testModuleName + $resModuleType = Out-String -InputObject $resModule.Type + $resModuleType.Replace(",", " ").Split() | Should -Contain "Module" + } + + It "find resource of Type Script from PSGallery, when Type Script specified" { + # FindName() Type script + $resScript = Find-PSResource -Name $testScriptName -Repository $ADOV2RepoName -Type "Script" + $resScript.Name | Should -Be $testScriptName + $resScriptType = Out-String -InputObject $resScript.Type + $resScriptType.Replace(",", " ").Split() | Should -Contain "Script" + } +#> + + + It "Find all resources of Type Module when Type parameter set is used" { + $res = Find-PSResource -Name "test*" -Type Module -Repository $ADOV2RepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "FindNameGlobbingNotSupportedForRepo,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Find resource that satisfies given Name and Tag property (single tag)" { + # FindNameWithTag() + $requiredTag = "Test" + $res = Find-PSResource -Name $testModuleName -Tag $requiredTag -Repository $ADOV2RepoName + $res.Name | Should -Be $testModuleName + $res.Tags | Should -Contain $requiredTag + } + + It "Should not find resource if Name and Tag are not both satisfied (single tag)" { + # FindNameWithTag + $requiredTag = "Windows" # tag "windows" is not present for test_module package + $res = Find-PSResource -Name $testModuleName -Tag $requiredTag -Repository $ADOV2RepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Find resource that satisfies given Name and Tag property (multiple tags)" { + # FindNameWithTag() + $requiredTags = @("Test", "Tag2") + $res = Find-PSResource -Name $testModuleName -Tag $requiredTags -Repository $ADOV2RepoName + $res.Name | Should -Be $testModuleName + $res.Tags | Should -Contain $requiredTags[0] + $res.Tags | Should -Contain $requiredTags[1] + } + + It "Should not find resource if Name and Tag are not both satisfied (multiple tag)" { + # FindNameWithTag + $requiredTags = @("test", "Windows") # tag "windows" is not present for test_module package + $res = Find-PSResource -Name $testModuleName -Tag $requiredTags -Repository $ADOV2RepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Find all resources that satisfy Name pattern and have specified Tag (single tag)" { + # FindNameGlobbingWithTag() + $requiredTag = "test" + $nameWithWildcard = "test_module*" + $res = Find-PSResource -Name $nameWithWildcard -Tag $requiredTag -Repository $ADOV2RepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "FindNameGlobbingAndTagFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + + } + + It "Should not find resources if both Name pattern and Tags are not satisfied (multiple tags)" { + # FindNameGlobbingWithTag() # tag "windows" is not present for test_module package + $requiredTags = @("Test", "windows") + $res = Find-PSResource -Name $testModuleName -Tag $requiredTags -Repository $ADOV2RepoName -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + } + + It "Find resource that satisfies given Name, Version and Tag property (single tag)" { + # FindVersionWithTag() + $requiredTag = "Test" + $res = Find-PSResource -Name $testModuleName -Version "5.0.0" -Tag $requiredTag -Repository $ADOV2RepoName + $res.Name | Should -Be $testModuleName + $res.Version | Should -Be "5.0.0" + $res.Tags | Should -Contain $requiredTag + } + + It "Should not find resource if Name, Version and Tag property are not all satisfied (single tag)" { + # FindVersionWithTag() + $requiredTag = "windows" # tag "windows" is not present for test_module package + $res = Find-PSResource -Name $testModuleName -Version "5.0.0" -Tag $requiredTag -Repository $ADOV2RepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Find resource that satisfies given Name, Version and Tag property (multiple tags)" { + # FindVersionWithTag() + $requiredTags = @("Test", "Tag2") + $res = Find-PSResource -Name $testModuleName -Version "5.0.0" -Tag $requiredTags -Repository $ADOV2RepoName + $res.Name | Should -Be $testModuleName + $res.Version | Should -Be "5.0.0" + $res.Tags | Should -Contain $requiredTags[0] + $res.Tags | Should -Contain $requiredTags[1] + + } + + It "Should not find resource if Name, Version and Tag property are not all satisfied (multiple tags)" { + # FindVersionWithTag() + $requiredTags = @("test", "windows") + $res = Find-PSResource -Name $testModuleName -Version "5.0.0" -Tag $requiredTags -Repository $ADOV2RepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } +} diff --git a/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 new file mode 100644 index 000000000..5b2de751b --- /dev/null +++ b/test/FindPSResourceTests/FindPSResourceContainerRegistryServer.Tests.ps1 @@ -0,0 +1,333 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +$modPath = "$psscriptroot/../PSGetTestUtils.psm1" +Import-Module $modPath -Force -Verbose + +Describe 'Test HTTP Find-PSResource for ACR Server Protocol' -tags 'CI' { + + BeforeAll{ + $testModuleName = "test-module" + $testModuleWith2DigitVersion = "test-2DigitPkg" + $testModuleParentName = "test_parent_mod" + $testModuleDependencyName = "test_dependency_mod" + $testScriptName = "test-script" + $ACRRepoName = "ACRRepo" + $ACRRepoUri = "https://psresourcegettest.azurecr.io" + Get-NewPSResourceRepositoryFile + + $usingAzAuth = $env:USINGAZAUTH -eq 'true' + + if ($usingAzAuth) + { + Write-Verbose -Verbose "Using Az module for authentication" + Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'ContainerRegistry' -Uri $ACRRepoUri -Verbose + } + else + { + $psCredInfo = New-Object Microsoft.PowerShell.PSResourceGet.UtilClasses.PSCredentialInfo ("SecretStore", "$env:TENANTID") + Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'ContainerRegistry' -Uri $ACRRepoUri -CredentialInfo $psCredInfo -Verbose + } + } + + AfterAll { + Get-RevertPSResourceRepositoryFile + } + + It "Find resource given specific Name, Version null" { + # FindName() + $res = Find-PSResource -Name $testModuleName -Repository $ACRRepoName + $res.Name | Should -Be $testModuleName + $res.Version | Should -Be "5.0.0" + } + + It "Should not find resource given nonexistant Name" { + # FindName() + $res = Find-PSResource -Name NonExistantModule -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "ResourceNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + $res | Should -BeNullOrEmpty + } + + $testCases2 = @{Version="[5.0.0.0]"; ExpectedVersions=@("5.0.0"); Reason="validate version, exact match"}, + @{Version="5.0.0.0"; ExpectedVersions=@("5.0.0"); Reason="validate version, exact match without bracket syntax"}, + @{Version="[1.0.0.0, 5.0.0.0]"; ExpectedVersions=@("1.0.0", "3.0.0", "5.0.0"); Reason="validate version, exact range inclusive"}, + @{Version="(1.0.0.0, 5.0.0.0)"; ExpectedVersions=@("3.0.0"); Reason="validate version, exact range exclusive"}, + @{Version="(1.0.0.0,)"; ExpectedVersions=@("3.0.0", "5.0.0"); Reason="validate version, minimum version exclusive"}, + @{Version="[1.0.0.0,)"; ExpectedVersions=@("1.0.0", "3.0.0", "5.0.0"); Reason="validate version, minimum version inclusive"}, + @{Version="(,3.0.0.0)"; ExpectedVersions=@("1.0.0"); Reason="validate version, maximum version exclusive"}, + @{Version="(,3.0.0.0]"; ExpectedVersions=@("1.0.0", "3.0.0"); Reason="validate version, maximum version inclusive"}, + @{Version="[1.0.0.0, 5.0.0.0)"; ExpectedVersions=@("1.0.0", "3.0.0"); Reason="validate version, mixed inclusive minimum and exclusive maximum version"} + @{Version="(1.0.0.0, 5.0.0.0]"; ExpectedVersions=@("3.0.0", "5.0.0"); Reason="validate version, mixed exclusive minimum and inclusive maximum version"} + + It "Find resource when given Name to " -TestCases $testCases2{ + # FindVersionGlobbing() + param($Version, $ExpectedVersions) + $res = Find-PSResource -Name $testModuleName -Version $Version -Repository $ACRRepoName + $res | Should -Not -BeNullOrEmpty + foreach ($item in $res) { + $item.Name | Should -Be $testModuleName + $ExpectedVersions | Should -Contain $item.Version + } + } + + It "Find all versions of resource when given specific Name, Version not null --> '*'" { + # FindVersionGlobbing() + $res = Find-PSResource -Name $testModuleName -Version "*" -Repository $ACRRepoName + $res | Should -Not -BeNullOrEmpty + $res | ForEach-Object { + $_.Name | Should -Be $testModuleName + } + + $res.Count | Should -BeGreaterOrEqual 1 + } + + It "Find resource when version contains different number of digits than the normalized version" { + # the resource has version "1.0", but querying with any equivalent version should work + $res1DigitVersion = Find-PSResource -Name $testModuleWith2DigitVersion -Version "1" -Repository $ACRRepoName + $res1DigitVersion | Should -Not -BeNullOrEmpty + $res1DigitVersion.Version | Should -Be "1.0" + + $res2DigitVersion = Find-PSResource -Name $testModuleWith2DigitVersion -Version "1.0" -Repository $ACRRepoName + $res2DigitVersion | Should -Not -BeNullOrEmpty + $res2DigitVersion.Version | Should -Be "1.0" + + $res3DigitVersion = Find-PSResource -Name $testModuleWith2DigitVersion -Version "1.0.0" -Repository $ACRRepoName + $res3DigitVersion | Should -Not -BeNullOrEmpty + $res3DigitVersion.Version | Should -Be "1.0" + + $res4DigitVersion = Find-PSResource -Name $testModuleWith2DigitVersion -Version "1.0.0.0" -Repository $ACRRepoName + $res4DigitVersion | Should -Not -BeNullOrEmpty + $res4DigitVersion.Version | Should -Be "1.0" + } + + It "Find module and dependencies when -IncludeDependencies is specified" { + $res = Find-PSResource -Name $testModuleParentName -Repository $ACRRepoName -IncludeDependencies + $res | Should -Not -BeNullOrEmpty + $res.Name | Should -Be @($testModuleParentName, $testModuleDependencyName) + $res.Version[0].ToString() | Should -Be "1.0.0" + $res.Version[1].ToString() | Should -Be "1.0.0" + } + + It "Find resource given specific Name, Version null but allowing Prerelease" { + # FindName() + $res = Find-PSResource -Name $testModuleName -Repository $ACRRepoName -Prerelease + $res.Name | Should -Be $testModuleName + $res.Version | Should -Be "5.2.5" + $res.Prerelease | Should -Be "alpha" + } + + It "Find resource with latest (including prerelease) version given Prerelease parameter" { + # FindName() + # test_local_mod resource's latest version is a prerelease version, before that it has a non-prerelease version + $res = Find-PSResource -Name $testModuleName -Repository $ACRRepoName + $res.Version | Should -Be "5.0.0" + + $resPrerelease = Find-PSResource -Name $testModuleName -Prerelease -Repository $ACRRepoName + $resPrerelease.Version | Should -Be "5.2.5" + $resPrerelease.Prerelease | Should -Be "alpha" + } + + It "Find resources, including Prerelease version resources, when given Prerelease parameter" { + # FindVersionGlobbing() + $resWithoutPrerelease = Find-PSResource -Name $testModuleName -Version "*" -Repository $ACRRepoName + $resWithPrerelease = Find-PSResource -Name $testModuleName -Version "*" -Repository $ACRRepoName -Prerelease + $resWithPrerelease.Count | Should -BeGreaterOrEqual $resWithoutPrerelease.Count + } + + It "Should not find resource if Name, Version and Tag property are not all satisfied (single tag)" { + # FindVersionWithTag() + $requiredTag = "windows" # tag "windows" is not present for test_local_mod package + $res = Find-PSResource -Name $testModuleName -Version "5.0.0.0" -Tag $requiredTag -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "FindVersionWithTagFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Should not find resources given Tag property" { + # FindTag() + $tagToFind = "Tag2" + $res = Find-PSResource -Tag $tagToFind -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "FindTagsFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Should not find resource given CommandName" { + # FindCommandOrDSCResource() + $res = Find-PSResource -CommandName "command" -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + write-Host $($err[0].FullyQualifiedErrorId) + $err[0].FullyQualifiedErrorId | Should -BeExactly "FindCommandOrDscResourceFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Should not find resource given DscResourceName" { + # FindCommandOrDSCResource() + $res = Find-PSResource -DscResourceName "dscResource" -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "FindCommandOrDscResourceFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Should find all resources given Name '*'" { + # FindAll() + $res = Find-PSResource -Name "*" -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -Not -BeNullOrEmpty + $res.Count | Should -BeGreaterThan 0 + } + + It "Should find script given Name" { + # FindName() + $res = Find-PSResource -Name $testScriptName -Repository $ACRRepoName + $res | Should -Not -BeNullOrEmpty + $res.Name | Should -BeExactly $testScriptName + $res.Version | Should -Be "3.0.0" + $res.Type.ToString() | Should -Be "Script" + } + + It "Should find script given Name and Prerelease" { + # latest version is a prerelease version + $res = Find-PSResource -Name $testScriptName -Prerelease -Repository $ACRRepoName + $res | Should -Not -BeNullOrEmpty + $res.Name | Should -BeExactly $testScriptName + $res.Version | Should -Be "5.0.0" + $res.Prerelease | Should -Be "alpha" + $res.Type.ToString() | Should -Be "Script" + } + + It "Should find script given Name and Version" { + # FindVersion() + $res = Find-PSResource -Name $testScriptName -Version "1.0.0" -Repository $ACRRepoName + $res | Should -Not -BeNullOrEmpty + $res.Name | Should -BeExactly $testScriptName + $res.Version | Should -Be "1.0.0" + $res.Type.ToString() | Should -Be "Script" + } + + It "Should find script given Name, Version and Prerelease" { + # latest version is a prerelease version + $res = Find-PSResource -Name $testScriptName -Version "5.0.0-alpha" -Prerelease -Repository $ACRRepoName + $res | Should -Not -BeNullOrEmpty + $res.Name | Should -BeExactly $testScriptName + $res.Version | Should -Be "5.0.0" + $res.Prerelease | Should -Be "alpha" + $res.Type.ToString() | Should -Be "Script" + } + + It "Should find and return correct resource type - module" { + $res = Find-PSResource -Name $testModuleName -Repository $ACRRepoName + $res | Should -Not -BeNullOrEmpty + $res.Name | Should -BeExactly $testModuleName + $res.Version | Should -Be "5.0.0" + $res.Type.ToString() | Should -Be "Module" + } + + It "Should find and return correct resource type - script" { + $scriptName = "test-script" + $res = Find-PSResource -Name $scriptName -Repository $ACRRepoName + $res | Should -Not -BeNullOrEmpty + $res.Name | Should -BeExactly $scriptName + $res.Version | Should -Be "3.0.0" + $res.Type.ToString() | Should -Be "Script" + } + + It "Should find module with varying case sensitivity" { + $res = Find-PSResource -Name "test-camelCaseModule" -Repository $ACRRepoName + $res.Name | Should -BeExactly "test-camelCaseModule" + $res.Version | Should -Be "1.0.0" + $res.Type.ToString() | Should -Be "Module" + } + + It "Should find script with varying case sensitivity" { + $res = Find-PSResource -Name "test-camelCaseScript" -Repository $ACRRepoName + $res.Name | Should -BeExactly "test-camelCaseScript" + $res.Version | Should -Be "1.0.0" + $res.Type.ToString() | Should -Be "Script" + } + + It "Should find resource with dependency, given Name and Version" { + $res = Find-PSResource -Name "Az.Storage" -Version "8.0.0" -Repository $ACRRepoName + $res.Dependencies.Length | Should -Be 1 + $res.Dependencies[0].Name | Should -Be "Az.Accounts" + } + + It "Should find resource and its associated author, licenseUri, projectUri, releaseNotes, etc properties" { + $res = Find-PSResource -Name "Az.Storage" -Version "8.0.0" -Repository $ACRRepoName + $res.Author | Should -Be "Microsoft Corporation" + $res.CompanyName | Should -Be "Microsoft Corporation" + $res.LicenseUri | Should -Be "https://aka.ms/azps-license" + $res.ProjectUri | Should -Be "https://github.com/Azure/azure-powershell" + $res.ReleaseNotes.Length | Should -Not -Be 0 + $res.Tags.Length | Should -Be 5 + } +} + +Describe 'Test Find-PSResource for MAR Repository' -tags 'CI' { + BeforeAll { + Register-PSResourceRepository -Name "MAR" -Uri "https://mcr.microsoft.com" -ApiVersion "ContainerRegistry" + } + + AfterAll { + Unregister-PSResourceRepository -Name "MAR" + } + + It "Should find resource given specific Name, Version null" { + $res = Find-PSResource -Name "Az.Accounts" -Repository "MAR" + $res.Name | Should -Be "Az.Accounts" + $res.Version | Should -BeGreaterThan ([Version]"4.0.0") + } + + It "Should find resource and its dependency given specific Name and Version" { + $res = Find-PSResource -Name "Az.Storage" -Version "8.0.0" -Repository "MAR" + $res.Dependencies.Length | Should -Be 1 + $res.Dependencies[0].Name | Should -Be "Az.Accounts" + } + + It "Should find Azpreview resource and it's dependency given specific Name and Version" { + $res = Find-PSResource -Name "Azpreview" -Version "13.2.0" -Repository "MAR" + $res.Dependencies.Length | Should -Not -Be 0 + } + + It "Should find resource with wildcard in Name" { + $res = Find-PSResource -Name "Az.App*" -Repository "MAR" + $res | Should -Not -BeNullOrEmpty + $res.Count | Should -BeGreaterThan 1 + } + + It "Should find all resource with wildcard in Name" { + $res = Find-PSResource -Name "*" -Repository "MAR" + $res | Should -Not -BeNullOrEmpty + $res.Count | Should -BeGreaterThan 1 + } +} + +# Skip this test fo +Describe 'Test Find-PSResource for unauthenticated ACR repository' -tags 'CI' { + BeforeAll { + $skipOnWinPS = $PSVersionTable.PSVersion.Major -eq 5 + + if (-not $skipOnWinPS) { + Register-PSResourceRepository -Name "Unauthenticated" -Uri "https://psresourcegetnoauth.azurecr.io/" -ApiVersion "ContainerRegistry" + } + } + + AfterAll { + if (-not $skipOnWinPS) { + Unregister-PSResourceRepository -Name "Unauthenticated" + } + } + + It "Should find resource given specific Name, Version null" { + + if ($skipOnWinPS) { + Set-ItResult -Pending -Because "Skipping test on Windows PowerShell" + return + } + + $res = Find-PSResource -Name "hello-world" -Repository "Unauthenticated" + $res.Name | Should -Be "hello-world" + $res.Version | Should -Be "5.0.0" + } +} diff --git a/test/FindPSResourceTests/FindPSResourceLocal.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceLocal.Tests.ps1 index dda4ddf50..b89a245ee 100644 --- a/test/FindPSResourceTests/FindPSResourceLocal.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceLocal.Tests.ps1 @@ -14,11 +14,15 @@ Describe 'Test Find-PSResource for local repositories' -tags 'CI' { $localUNCRepo = 'psgettestlocal3' $testModuleName = "test_local_mod" $testModuleName2 = "test_local_mod2" + $testModuleName3 = "Test_Local_Mod3" + $similarTestModuleName = "test_local_mod.similar" $commandName = "cmd1" $dscResourceName = "dsc1" $prereleaseLabel = "" + $localNupkgRepo = "localNupkgRepo" Get-NewPSResourceRepositoryFile Register-LocalRepos + Register-LocalTestNupkgsRepo $localRepoUriAddress = Join-Path -Path $TestDrive -ChildPath "testdir" $tagsEscaped = @("'Test'", "'Tag2'", "'PSCommand_$cmdName'", "'PSDscResource_$dscName'") @@ -31,6 +35,11 @@ Describe 'Test Find-PSResource for local repositories' -tags 'CI' { New-TestModule -moduleName $testModuleName2 -repoName $localRepo -packageVersion "5.0.0" -prereleaseLabel "" -tags $tagsEscaped New-TestModule -moduleName $testModuleName2 -repoName $localRepo -packageVersion "5.2.5" -prereleaseLabel $prereleaseLabel -tags $tagsEscaped + + New-TestModule -moduleName $testModuleName3 -repoName $localRepo -packageVersion "1.0.0" -prereleaseLabel "" -tags @() + + New-TestModule -moduleName $similarTestModuleName -repoName $localRepo -packageVersion "4.0.0" -prereleaseLabel "" -tags $tagsEscaped + New-TestModule -moduleName $similarTestModuleName -repoName $localRepo -packageVersion "5.0.0" -prereleaseLabel "" -tags $tagsEscaped } AfterAll { @@ -44,6 +53,20 @@ Describe 'Test Find-PSResource for local repositories' -tags 'CI' { $res.Version | Should -Be "5.0.0" } + It "find resource given specific Name with incorrect casing (should return correct casing)" { + # FindName() + $res = Find-PSResource -Name "test_local_mod3" -Repository $localRepo + $res.Name | Should -Be $testModuleName3 + $res.Version | Should -Be "1.0.0" + } + + It "find resource given specific Name with incorrect casing and Version (should return correct casing)" { + # FindVersion() + $res = Find-PSResource -Name "test_local_mod3" -Version "1.0.0" -Repository $localRepo + $res.Name | Should -Be $testModuleName3 + $res.Version | Should -Be "1.0.0" + } + It "find resource given specific Name, Version null (module) from a UNC-based local repository" { # FindName() $res = Find-PSResource -Name $testModuleName -Repository $localUNCRepo @@ -74,6 +97,7 @@ Describe 'Test Find-PSResource for local repositories' -tags 'CI' { } It "should not find resource given nonexistant Name" { + # FindName() $res = Find-PSResource -Name NonExistantModule -Repository $localRepo -ErrorVariable err -ErrorAction SilentlyContinue $res | Should -BeNullOrEmpty $err.Count | Should -Not -Be 0 @@ -81,6 +105,17 @@ Describe 'Test Find-PSResource for local repositories' -tags 'CI' { $res | Should -BeNullOrEmpty } + It "find resource given specific Name when another package with similar name (with period) exists" { + # FindName() + $res = Find-PSResource -Name $testModuleName -Repository $localRepo + $res.Name | Should -Be $testModuleName + $res.Version | Should -Be "5.0.0" + + $res = Find-PSResource -Name $similarTestModuleName -Repository $localRepo + $res.Name | Should -Be $similarTestModuleName + $res.Version | Should -Be "5.0.0" + } + It "find resource(s) given wildcard Name" { # FindNameGlobbing $res = Find-PSResource -Name "test_local_*" -Repository $localRepo @@ -129,6 +164,22 @@ Describe 'Test Find-PSResource for local repositories' -tags 'CI' { $resPrerelease.Prerelease | Should -Be "alpha001" } + It "find resource given specific Name when another package with similar name (with period) exists" { + # FindVersion() + # Package $testModuleName version 4.0.0 does not exist + # previously if Find-PSResource -Version against local repo did not find that package's version it kept looking at + # similar named packages and would fault. This test is to ensure only the specified package and its version is checked + $res = Find-PSResource -Name $testModuleName -Version "4.0.0" -Repository $localRepo -ErrorVariable err -ErrorAction SilentlyContinue + $res | Should -BeNullOrEmpty + $err.Count | Should -Not -Be 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + $res | Should -BeNullOrEmpty + + $res = Find-PSResource -Name $similarTestModuleName -Version "4.0.0" -Repository $localRepo + $res.Name | Should -Be $similarTestModuleName + $res.Version | Should -Be "4.0.0" + } + It "find resources, including Prerelease version resources, when given Prerelease parameter" { # FindVersionGlobbing() $resWithoutPrerelease = Find-PSResource -Name $testModuleName -Version "*" -Repository $localRepo @@ -277,4 +328,14 @@ Describe 'Test Find-PSResource for local repositories' -tags 'CI' { $err.Count | Should -Not -Be 0 $err[0].FullyQualifiedErrorId | Should -BeExactly "FindTagsPackageNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } + + It "find package where prerelease label includes digits and period (i.e prerelease label is not just words)" { + $nupkgName = "WebView2.Avalonia" + $nupkgVersion = "1.0.1518.46" + $prereleaseLabel = "preview.230207.17" + $res = Find-PSResource -Name $nupkgName -Prerelease -Repository $localNupkgRepo + $res.Name | Should -Be $nupkgName + $res.Version | Should -Be $nupkgVersion + $res.Prerelease | Should -Be $prereleaseLabel + } } diff --git a/test/FindPSResourceTests/FindPSResourceRepositorySearching.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceRepositorySearching.Tests.ps1 index 4b6ddac79..405c93e61 100644 --- a/test/FindPSResourceTests/FindPSResourceRepositorySearching.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceRepositorySearching.Tests.ps1 @@ -57,7 +57,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - $pkg2 = $res[1] $pkg2.Name | Should -Be $testModuleName $pkg2.Repository | Should -Be $PSGalleryName - + $pkg3 = $res[2] $pkg3.Name | Should -Be $testModuleName $pkg3.Repository | Should -Be $NuGetGalleryName @@ -210,7 +210,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - It "should not allow for repository name with wildcard and non-wildcard name specified in same command run" { {Find-PSResource -Name "test_module" -Repository "*Gallery",$localRepoName} | Should -Throw -ErrorId "RepositoryNamesWithWildcardsAndNonWildcardUnsupported,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } - + It "not find resource and write error if resource does not exist in any pattern matching repositories (-Repository with wildcard)" { $res = Find-PSResource -Name "nonExistantPkg" -Repository "*Gallery" -ErrorVariable err -ErrorAction SilentlyContinue $res | Should -BeNullOrEmpty @@ -296,7 +296,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - $pkg3 = $res[2] $pkg3.Name | Should -Be $testModuleName $pkg3.Repository | Should -Be $PSGalleryName - + # Note Find-PSResource -Tag returns package Ids in desc order $pkg4 = $res[3] $pkg4.Name | Should -Be $testModuleName @@ -330,7 +330,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - $res = Find-PSResource -Tag "NonExistantTag" -ErrorVariable err -ErrorAction SilentlyContinue $res | Should -HaveCount 0 $err | Should -HaveCount 1 - $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageWithTagsNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageWithTagsNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } It "not find resource when it has one tag specified but not other and report error (without -Repository specified)" { @@ -380,7 +380,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - $pkg2 = $res[1] $pkg2.Name | Should -Be $testModuleName $pkg2.Repository | Should -Be $PSGalleryName - + # Note Find-PSResource -Tag returns package Ids in desc order $pkg3 = $res[2] $pkg3.Name | Should -Be $testModuleName @@ -394,7 +394,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - It "should not allow for repository name with wildcard and non-wildcard name specified in same command run" { {Find-PSResource -Tag $tag1 -Repository "*Gallery",$localRepoName} | Should -Throw -ErrorId "RepositoryNamesWithWildcardsAndNonWildcardUnsupported,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } - + It "not find resource and write error if tag does not exist for resources in any pattern matching repositories (-Repository with wildcard)" { $res = Find-PSResource -Tag "NonExistantTag" -Repository "*Gallery" -ErrorVariable err -ErrorAction SilentlyContinue $res | Should -BeNullOrEmpty @@ -451,7 +451,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - $pkg2 = $res[1] $pkg2.Name | Should -Be "anam_script" - $pkg2.Repository | Should -Be $PSGalleryName + $pkg2.Repository | Should -Be $PSGalleryName $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageWithSpecifiedTagsNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } @@ -461,7 +461,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - # $cmdNameToSearch = "Get-TargetResource" $res = Find-PSResource -CommandName $cmdName -ErrorVariable err -ErrorAction SilentlyContinue $err | Should -HaveCount 0 - $res.Count | Should -BeGreaterOrEqual 10 + $res.Count | Should -BeGreaterOrEqual 9 $pkgFoundFromLocalRepo = $false $pkgFoundFromPSGallery = $false @@ -477,7 +477,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - } } - $pkg.Names | Should -Be $cmdName + $pkg.Names | Should -Be $cmdName $pkg.ParentResource.Includes.Command | Should -Contain $cmdName $pkgFoundFromLocalRepo | Should -BeTrue $pkgFoundFromPSGallery | Should -BeTrue @@ -487,7 +487,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - $res = Find-PSResource -Command "NonExistantCommandName" -ErrorVariable err -ErrorAction SilentlyContinue $res | Should -HaveCount 0 $err | Should -HaveCount 1 - $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageWithCmdOrDscNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageWithCmdOrDscNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } It "not find resource when it has one CommandName specified but not other and report error (without -Repository specified)" { @@ -509,7 +509,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - $err | Should -HaveCount 1 $err[0].FullyQualifiedErrorId | Should -BeExactly "WildcardsUnsupportedForCommandNameorDSCResourceName,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" - $res.Count | Should -BeGreaterOrEqual 10 + $res.Count | Should -BeGreaterOrEqual 9 $pkgFoundFromLocalRepo = $false $pkgFoundFromPSGallery = $false @@ -525,7 +525,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - } } - $pkg.Names | Should -Be $cmdName + $pkg.Names | Should -Be $cmdName $pkg.ParentResource.Includes.Command | Should -Contain $cmdName $pkgFoundFromLocalRepo | Should -BeTrue $pkgFoundFromPSGallery | Should -BeTrue @@ -552,7 +552,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - } } - $pkg.Names | Should -Be $cmdName + $pkg.Names | Should -Be $cmdName $pkg.ParentResource.Includes.Command | Should -Contain $cmdName $pkgFoundFromLocalRepo | Should -BeFalse $pkgFoundFromPSGallery | Should -BeTrue @@ -561,7 +561,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - It "should not allow for repository name with wildcard and non-wildcard command name specified in same command run" { {Find-PSResource -CommandName $cmdName -Repository "*Gallery",$localRepoName} | Should -Throw -ErrorId "RepositoryNamesWithWildcardsAndNonWildcardUnsupported,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } - + It "not find resource and write error if tag does not exist for resources in any pattern matching repositories (-Repository with wildcard)" { $res = Find-PSResource -CommandName "NonExistantCommand" -Repository "*Gallery" -ErrorVariable err -ErrorAction SilentlyContinue $res | Should -BeNullOrEmpty @@ -592,7 +592,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - It "find resource given CommandName from all repositories where it exists (-Repository with multiple non-wildcard values)" { $res = Find-PSResource -CommandName $cmdName -Repository $PSGalleryName,$localRepoName - $res.Count | Should -BeGreaterOrEqual 10 + $res.Count | Should -BeGreaterOrEqual 9 $pkgFoundFromLocalRepo = $false $pkgFoundFromPSGallery = $false @@ -609,7 +609,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - } } - $pkg.Names | Should -Be $cmdName + $pkg.Names | Should -Be $cmdName $pkg.ParentResource.Includes.Command | Should -Contain $cmdName $pkgFoundFromLocalRepo | Should -BeTrue $pkgFoundFromPSGallery | Should -BeTrue @@ -636,10 +636,10 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - } } - $pkg.Names | Should -Be $cmdName2 + $pkg.Names | Should -Be $cmdName2 $pkg.ParentResource.Includes.Command | Should -Contain $cmdName2 $pkgFoundFromLocalRepo | Should -BeTrue - $pkgFoundFromPSGallery | Should -BeFalse + $pkgFoundFromPSGallery | Should -BeFalse $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageWithSpecifiedCmdOrDSCNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } @@ -681,7 +681,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - It "find resource that has DSCResourceName specified from all repositories where it exists and not write errors where it does not exist (without -Repository specified)" { $res = Find-PSResource -DscResourceName $dscName -ErrorVariable err -ErrorAction SilentlyContinue $err | Should -HaveCount 0 - $res.Count | Should -BeGreaterOrEqual 3 + $res.Count | Should -BeGreaterOrEqual 2 $pkgFoundFromLocalRepo = $false $pkgFoundFromPSGallery = $false @@ -697,7 +697,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - } } - $pkg.Names | Should -Be $dscName + $pkg.Names | Should -Be $dscName $pkg.ParentResource.Includes.DscResource | Should -Contain $dscName $pkgFoundFromLocalRepo | Should -BeTrue $pkgFoundFromPSGallery | Should -BeTrue @@ -707,7 +707,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - $res = Find-PSResource -DscResourceName "NonExistantDSCResourceName" -ErrorVariable err -ErrorAction SilentlyContinue $res | Should -HaveCount 0 $err | Should -HaveCount 1 - $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageWithCmdOrDscNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageWithCmdOrDscNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } It "not find resource when it has one DSCResourceName specified but not other and report error (without -Repository specified)" { @@ -729,7 +729,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - $err | Should -HaveCount 1 $err[0].FullyQualifiedErrorId | Should -BeExactly "WildcardsUnsupportedForCommandNameorDSCResourceName,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" - $res.Count | Should -BeGreaterOrEqual 3 + $res.Count | Should -BeGreaterOrEqual 2 $pkgFoundFromLocalRepo = $false $pkgFoundFromPSGallery = $false @@ -772,7 +772,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - } } - $pkg.Names | Should -Be $dscName + $pkg.Names | Should -Be $dscName $pkg.ParentResource.Includes.DscResource | Should -Contain $dscName $pkgFoundFromLocalRepo | Should -BeFalse $pkgFoundFromPSGallery | Should -BeTrue @@ -781,7 +781,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - It "should not allow for repository name with wildcard and non-wildcard command name specified in same command run" { {Find-PSResource -DscResourceName $dscName -Repository "*Gallery",$localRepoName} | Should -Throw -ErrorId "RepositoryNamesWithWildcardsAndNonWildcardUnsupported,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } - + It "not find resource and write error if tag does not exist for resources in any pattern matching repositories (-Repository with wildcard)" { $res = Find-PSResource -DscResourceName "NonExistantDSCResource" -Repository "*Gallery" -ErrorVariable err -ErrorAction SilentlyContinue $res | Should -BeNullOrEmpty @@ -830,7 +830,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - } } - $pkg.Names | Should -Be $dscName + $pkg.Names | Should -Be $dscName $pkg.ParentResource.Includes.DscResource | Should -Contain $dscName $pkgFoundFromLocalRepo | Should -BeTrue $pkgFoundFromPSGallery | Should -BeTrue @@ -860,7 +860,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - $pkg.Names | Should -Be $dscName2 $pkg.ParentResource.Includes.DscResource | Should -Contain $dscName2 $pkgFoundFromLocalRepo | Should -BeTrue - $pkgFoundFromPSGallery | Should -BeFalse + $pkgFoundFromPSGallery | Should -BeFalse $err[0].FullyQualifiedErrorId | Should -BeExactly "PackageWithSpecifiedCmdOrDSCNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" } @@ -891,7 +891,7 @@ Describe 'Test Find-PSResource for searching and looping through repositories' - } } - $pkg.Names | Should -Be $dscName + $pkg.Names | Should -Be $dscName $pkg.ParentResource.Includes.DscResource | Should -Contain $dscName $pkgFoundFromLocalRepo | Should -BeTrue $pkgFoundFromPSGallery | Should -BeFalse diff --git a/test/FindPSResourceTests/FindPSResourceV2Server.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceV2Server.Tests.ps1 index b345b48c2..2a01c7677 100644 --- a/test/FindPSResourceTests/FindPSResourceV2Server.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceV2Server.Tests.ps1 @@ -5,7 +5,7 @@ $modPath = "$psscriptroot/../PSGetTestUtils.psm1" Import-Module $modPath -Force -Verbose $psmodulePaths = $env:PSModulePath -split ';' -Write-Verbose -Verbose "Current module search paths: $psmodulePaths" +Write-Verbose -Verbose -Message "Current module search paths: $psmodulePaths" Describe 'Test HTTP Find-PSResource for V2 Server Protocol' -tags 'CI' { @@ -56,6 +56,11 @@ Describe 'Test HTTP Find-PSResource for V2 Server Protocol' -tags 'CI' { $foundScript | Should -BeTrue } + It "find all resources when wildcard only for Name" { + $res = Find-PSResource -Name '*' -Repository $PSGalleryName + $res.Count | Should -BeGreaterThan 0 + } + $testCases2 = @{Version="[5.0.0.0]"; ExpectedVersions=@("5.0.0.0"); Reason="validate version, exact match"}, @{Version="5.0.0.0"; ExpectedVersions=@("5.0.0.0"); Reason="validate version, exact match without bracket syntax"}, @{Version="[1.0.0.0, 5.0.0.0]"; ExpectedVersions=@("1.0.0.0", "3.0.0.0", "5.0.0.0"); Reason="validate version, exact range inclusive"}, diff --git a/test/GroupPolicyEnforcement.Tests.ps1 b/test/GroupPolicyEnforcement.Tests.ps1 new file mode 100644 index 000000000..2353056e2 --- /dev/null +++ b/test/GroupPolicyEnforcement.Tests.ps1 @@ -0,0 +1,104 @@ +# Copyright (c) Microsoft Corporation. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +$checkIfWindows = [System.Environment]::OSVersion.Platform -eq 'Win32NT' + +# Add Pester test to check the API for GroupPolicyEnforcement +Describe 'GroupPolicyEnforcement API Tests' -Tags 'CI' { + + It 'IsGroupPolicyEnabled should return the correct policy enforcement status' -Skip:(-not $checkIfWindows) { + $actualStatus = [Microsoft.PowerShell.PSResourceGet.Cmdlets.GroupPolicyRepositoryEnforcement]::IsGroupPolicyEnabled() + $actualStatus | Should -BeFalse + } + + It 'IsGroupPolicyEnabled should return false on non-windows platform' -Skip:$checkIfWindows { + [Microsoft.PowerShell.PSResourceGet.Cmdlets.GroupPolicyRepositoryEnforcement]::IsGroupPolicyEnabled() | Should -BeFalse + } + + It 'GetAllowedRepositoryURIs return null if Group Policy is not enabled' -Skip:(-not $checkIfWindows) { + [Microsoft.PowerShell.PSResourceGet.Cmdlets.GroupPolicyRepositoryEnforcement]::GetAllowedRepositoryURIs() | Should -BeNullOrEmpty + + try { + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('EnableGPRegistryHook', $true) + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('GPEnabledStatus', $true) + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('AllowedUri', "https://www.example.com/") + + $allowedReps = [Microsoft.PowerShell.PSResourceGet.Cmdlets.GroupPolicyRepositoryEnforcement]::GetAllowedRepositoryURIs() + $allowedReps.AbsoluteUri | Should -Be @("https://www.example.com/") + } + finally { + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('EnableGPRegistryHook', $false) + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('GPEnabledStatus', $false) + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('AllowedUri', $null) + } + } +} + +Describe 'GroupPolicyEnforcement Cmdlet Tests' -Tags 'CI' { + BeforeEach { + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('EnableGPRegistryHook', $true) + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('GPEnabledStatus', $true) + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('AllowedUri', "https://www.example.com/") + } + + AfterEach { + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('EnableGPRegistryHook', $false) + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('GPEnabledStatus', $false) + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('AllowedUri', $null) + } + + It 'Get-PSResourceRepository lists the allowed repository' -Skip:(-not $checkIfWindows) { + try { + Register-PSResourceRepository -Name 'Example' -Uri 'https://www.example.com/' + $psrep = Get-PSResourceRepository -Name 'Example' + $psrep | Should -Not -BeNullOrEmpty + $psrep.IsAllowedByPolicy | Should -BeTrue + } + finally { + Unregister-PSResourceRepository -Name 'Example' + } + } + + It 'Find-PSResource is blocked by policy' -Skip:(-not $checkIfWindows) { + try { + Register-PSResourceRepository -Name 'Example' -Uri 'https://www.example.com/' -ApiVersion 'v3' + { Find-PSResource -Repository PSGallery -Name 'Az.Accounts' -ErrorAction Stop } | Should -Throw "Repository 'PSGallery' is not allowed by Group Policy." + + # Allow PSGallery and it should not fail + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('AllowedUri', " https://www.powershellgallery.com/api/v2") + { Find-PSResource -Repository PSGallery -Name 'Az.Accounts' -ErrorAction Stop } | Should -Not -Throw + } + finally { + Unregister-PSResourceRepository -Name 'Example' + } + } + + It 'Install-PSResource is blocked by policy' -Skip:(-not $checkIfWindows) { + try { + Register-PSResourceRepository -Name 'Example' -Uri 'https://www.example.com/' -ApiVersion 'v3' + { Install-PSResource -Repository PSGallery -Name 'Az.Accounts' -ErrorAction Stop } | Should -Throw "Repository 'PSGallery' is not allowed by Group Policy." + + # Allow PSGallery and it should not fail + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('AllowedUri', " https://www.powershellgallery.com/api/v2") + { Install-PSResource -Repository PSGallery -Name 'Az.Accounts' -ErrorAction Stop -TrustRepository} | Should -Not -Throw + } + finally { + Unregister-PSResourceRepository -Name 'Example' + } + } + + It 'Save-PSResource is blocked by policy' -Skip:(-not $checkIfWindows) { + try { + Register-PSResourceRepository -Name 'Example' -Uri 'https://www.example.com/' -ApiVersion 'v3' + { Save-PSResource -Repository PSGallery -Name 'Az.Accounts' -ErrorAction Stop } | Should -Throw "Repository 'PSGallery' is not allowed by Group Policy." + + # Allow PSGallery and it should not fail + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('AllowedUri', " https://www.powershellgallery.com/api/v2") + { Save-PSResource -Repository PSGallery -Name 'Az.Accounts' -ErrorAction Stop -TrustRepository} | Should -Not -Throw + } + finally { + Unregister-PSResourceRepository -Name 'Example' + } + } +} diff --git a/test/InstallPSResourceTests/InstallPSResourceADOServer.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceADOServer.Tests.ps1 index 8cf65b81f..fbf32c59e 100644 --- a/test/InstallPSResourceTests/InstallPSResourceADOServer.Tests.ps1 +++ b/test/InstallPSResourceTests/InstallPSResourceADOServer.Tests.ps1 @@ -12,7 +12,7 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { $testModuleName2 = "test_local_mod2" $testScriptName = "test_ado_script" $ADORepoName = "PSGetTestingPublicFeed" - $ADORepoUri = "https://pkgs.dev.azure.com/powershell/PowerShell/_packaging/powershell-public-test/nuget/v3/index.json" + $ADORepoUri = "https://pkgs.dev.azure.com/powershell/PowerShell/_packaging/psresourceget-public-test-ci/nuget/v3/index.json" Get-NewPSResourceRepositoryFile Register-PSResourceRepository -Name $ADORepoName -Uri $ADORepoUri } @@ -54,7 +54,7 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { It "Install multiple resources by name" { $pkgNames = @($testModuleName, $testModuleName2) - Install-PSResource -Name $pkgNames -Repository $ADORepoName -TrustRepository + Install-PSResource -Name $pkgNames -Repository $ADORepoName -TrustRepository $pkg = Get-InstalledPSResource $pkgNames $pkg.Name | Should -Be $pkgNames } @@ -64,7 +64,7 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { $pkg = Get-InstalledPSResource "NonExistantModule" $pkg | Should -BeNullOrEmpty $err.Count | Should -BeGreaterThan 0 - $err[0].FullyQualifiedErrorId | Should -BeExactly "InstallPackageFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" + $err[0].FullyQualifiedErrorId | Should -BeExactly "InstallPackageFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" } # Do some version testing, but Find-PSResource should be doing thorough testing @@ -76,21 +76,21 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { } It "Should install resource given name and exact version with bracket syntax" { - Install-PSResource -Name $testModuleName -Version "[1.0.0]" -Repository $ADORepoName -TrustRepository + Install-PSResource -Name $testModuleName -Version "[1.0.0]" -Repository $ADORepoName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "1.0.0" } It "Should install resource given name and exact range inclusive [1.0.0, 5.0.0]" { - Install-PSResource -Name $testModuleName -Version "[1.0.0, 5.0.0]" -Repository $ADORepoName -TrustRepository + Install-PSResource -Name $testModuleName -Version "[1.0.0, 5.0.0]" -Repository $ADORepoName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "5.0.0" } It "Should install resource given name and exact range exclusive (1.0.0, 5.0.0)" { - Install-PSResource -Name $testModuleName -Version "(1.0.0, 5.0.0)" -Repository $ADORepoName -TrustRepository + Install-PSResource -Name $testModuleName -Version "(1.0.0, 5.0.0)" -Repository $ADORepoName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "3.0.0" @@ -118,7 +118,7 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { } It "Install resource with latest (including prerelease) version given Prerelease parameter" { - Install-PSResource -Name $testModuleName -Prerelease -Repository $ADORepoName -TrustRepository + Install-PSResource -Name $testModuleName -Prerelease -Repository $ADORepoName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "5.2.5" @@ -126,7 +126,7 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { } It "Install resource via InputObject by piping from Find-PSresource" { - Find-PSResource -Name $testModuleName -Repository $ADORepoName | Install-PSResource -TrustRepository + Find-PSResource -Name $testModuleName -Repository $ADORepoName | Install-PSResource -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "5.0.0" @@ -238,15 +238,15 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'ManualValidatio It "Install resource under AllUsers scope - Unix only" -Skip:(Get-IsWindows) { Install-PSResource -Name $testModuleName -Repository $TestGalleryName -Scope AllUsers $pkg = Get-Module $testModuleName -ListAvailable - $pkg.Name | Should -Be $testModuleName + $pkg.Name | Should -Be $testModuleName $pkg.Path.Contains("/usr/") | Should -Be $true } # This needs to be manually tested due to prompt It "Install resource that requires accept license without -AcceptLicense flag" { Install-PSResource -Name $testModuleName2 -Repository $TestGalleryName - $pkg = Get-InstalledPSResource $testModuleName2 - $pkg.Name | Should -Be $testModuleName2 + $pkg = Get-InstalledPSResource $testModuleName2 + $pkg.Name | Should -Be $testModuleName2 $pkg.Version | Should -Be "0.0.1.0" } @@ -255,7 +255,7 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'ManualValidatio Set-PSResourceRepository PoshTestGallery -Trusted:$false Install-PSResource -Name $testModuleName -Repository $TestGalleryName -confirm:$false - + $pkg = Get-Module $testModuleName -ListAvailable $pkg.Name | Should -Be $testModuleName diff --git a/test/InstallPSResourceTests/InstallPSResourceADOV2Server.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceADOV2Server.Tests.ps1 new file mode 100644 index 000000000..589bff92b --- /dev/null +++ b/test/InstallPSResourceTests/InstallPSResourceADOV2Server.Tests.ps1 @@ -0,0 +1,265 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +$ProgressPreference = "SilentlyContinue" +$modPath = "$psscriptroot/../PSGetTestUtils.psm1" +Import-Module $modPath -Force -Verbose + +Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { + + BeforeAll { + $testModuleName = "test_local_mod" + $testModuleName2 = "test_local_mod2" + $testScriptName = "test_ado_script" + $ADORepoName = "PSGetTestingPublicFeed" + $ADORepoUri = "https://pkgs.dev.azure.com/powershell/PowerShell/_packaging/psresourceget-public-test-ci/nuget/v2" + Get-NewPSResourceRepositoryFile + Register-PSResourceRepository -Name $ADORepoName -Uri $ADORepoUri + } + + AfterEach { + Uninstall-PSResource $testModuleName, $testModuleName2, $testScriptName -Version "*" -SkipDependencyCheck -ErrorAction SilentlyContinue + } + + AfterAll { + Get-RevertPSResourceRepositoryFile + } + + $testCases = @{Name="*"; ErrorId="NameContainsWildcard"}, + @{Name="Test_local_m*"; ErrorId="NameContainsWildcard"}, + @{Name="Test?local","Test[local"; ErrorId="ErrorFilteringNamesForUnsupportedWildcards"} + + It "Should not install resource with wildcard in name" -TestCases $testCases { + param($Name, $ErrorId) + Install-PSResource -Name $Name -Repository $ADORepoName -ErrorVariable err -ErrorAction SilentlyContinue + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "$ErrorId,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" + $res = Get-InstalledPSResource $testModuleName + $res | Should -BeNullOrEmpty + } + + It "Install specific module resource by name" { + Install-PSResource -Name $testModuleName -Repository $ADORepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "5.0.0" + } + + It "Install specific script resource by name" { + Install-PSResource -Name $testScriptName -Repository $ADORepoName -TrustRepository + $pkg = Get-InstalledPSResource $testScriptName + $pkg.Name | Should -Be $testScriptName + $pkg.Version | Should -Be "1.0.0" + } + + It "Install multiple resources by name" { + $pkgNames = @($testModuleName, $testModuleName2) + Install-PSResource -Name $pkgNames -Repository $ADORepoName -TrustRepository + $pkg = Get-InstalledPSResource $pkgNames + $pkg.Name | Should -Be $pkgNames + } + + It "Should not install resource given nonexistant name" { + Install-PSResource -Name "NonExistantModule" -Repository $ADORepoName -TrustRepository -ErrorVariable err -ErrorAction SilentlyContinue + $pkg = Get-InstalledPSResource "NonExistantModule" + $pkg | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "InstallPackageFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" + } + + # Do some version testing, but Find-PSResource should be doing thorough testing + It "Should install resource given name and exact version" { + Install-PSResource -Name $testModuleName -Version "1.0.0" -Repository $ADORepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "1.0.0" + } + + It "Should install resource given name and exact version with bracket syntax" { + Install-PSResource -Name $testModuleName -Version "[1.0.0]" -Repository $ADORepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "1.0.0" + } + + It "Should install resource given name and exact range inclusive [1.0.0, 5.0.0]" { + Install-PSResource -Name $testModuleName -Version "[1.0.0, 5.0.0]" -Repository $ADORepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "5.0.0" + } + + It "Should install resource given name and exact range exclusive (1.0.0, 5.0.0)" { + Install-PSResource -Name $testModuleName -Version "(1.0.0, 5.0.0)" -Repository $ADORepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "3.0.0" + } + + # TODO: Update this test and others like it that use try/catch blocks instead of Should -Throw + It "Should not install resource with incorrectly formatted version such as exclusive version (1.0.0.0)" { + $Version = "(1.0.0.0)" + try { + Install-PSResource -Name $testModuleName -Version $Version -Repository $ADORepoName -TrustRepository -ErrorAction SilentlyContinue + } + catch + {} + $Error[0].FullyQualifiedErrorId | Should -be "IncorrectVersionFormat,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" + + $res = Get-InstalledPSResource $testModuleName + $res | Should -BeNullOrEmpty + } + + It "Install resource when given Name, Version '*', should install the latest version" { + Install-PSResource -Name $testModuleName -Version "*" -Repository $ADORepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "5.0.0" + } + + It "Install resource with latest (including prerelease) version given Prerelease parameter" { + Install-PSResource -Name $testModuleName -Prerelease -Repository $ADORepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "5.2.5" + $pkg.Prerelease | Should -Be "alpha001" + } + + It "Install resource via InputObject by piping from Find-PSresource" { + Find-PSResource -Name $testModuleName -Repository $ADORepoName | Install-PSResource -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "5.0.0" + } + + It "Install resource with author and repository source location and validate properties" { + # CompanyName is not present in ADO V2 feed response properties. + Install-PSResource -Name $testModuleName -Version "5.2.5-alpha001" -Repository $ADORepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Version | Should -Be "5.2.5" + $pkg.Prerelease | Should -Be "alpha001" + + $pkg.Author | Should -Be "None" + $pkg.RepositorySourceLocation | Should -Be $ADORepoUri + } + + # Windows only + It "Install resource under CurrentUser scope - Windows only" -Skip:(!(Get-IsWindows)) { + Install-PSResource -Name $testModuleName -Repository $ADORepoName -TrustRepository -Scope CurrentUser + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.InstalledLocation.ToString().Contains("Documents") | Should -Be $true + } + + # Windows only + It "Install resource under AllUsers scope - Windows only" -Skip:(!((Get-IsWindows) -and (Test-IsAdmin))) { + Install-PSResource -Name $testModuleName -Repository $ADORepoName -TrustRepository -Scope AllUsers -Verbose + $pkg = Get-InstalledPSResource $testModuleName -Scope AllUsers + $pkg.Name | Should -Be $testModuleName + $pkg.InstalledLocation.ToString().Contains("Program Files") | Should -Be $true + } + + # Windows only + It "Install resource under no specified scope - Windows only" -Skip:(!(Get-IsWindows)) { + Install-PSResource -Name $testModuleName -Repository $ADORepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.InstalledLocation.ToString().Contains("Documents") | Should -Be $true + } + + # Unix only + # Expected path should be similar to: '/home/janelane/.local/share/powershell/Modules' + It "Install resource under CurrentUser scope - Unix only" -Skip:(Get-IsWindows) { + Install-PSResource -Name $testModuleName -Repository $ADORepoName -TrustRepository -Scope CurrentUser + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.InstalledLocation.ToString().Contains("$env:HOME/.local") | Should -Be $true + } + + # Unix only + # Expected path should be similar to: '/home/janelane/.local/share/powershell/Modules' + It "Install resource under no specified scope - Unix only" -Skip:(Get-IsWindows) { + Install-PSResource -Name $testModuleName -Repository $ADORepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.InstalledLocation.ToString().Contains("$env:HOME/.local") | Should -Be $true + } + + It "Should not install resource that is already installed" { + Install-PSResource -Name $testModuleName -Repository $ADORepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + Install-PSResource -Name $testModuleName -Repository $ADORepoName -TrustRepository -WarningVariable WarningVar -warningaction SilentlyContinue + $WarningVar | Should -Not -BeNullOrEmpty + } + + It "Reinstall resource that is already installed with -Reinstall parameter" { + Install-PSResource -Name $testModuleName -Repository $ADORepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "5.0.0" + Install-PSResource -Name $testModuleName -Repository $ADORepoName -Reinstall -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "5.0.0" + } + + It "Install PSResourceInfo object piped in" { + Find-PSResource -Name $testModuleName -Version "1.0.0.0" -Repository $ADORepoName | Install-PSResource -TrustRepository + $res = Get-InstalledPSResource -Name $testModuleName + $res.Name | Should -Be $testModuleName + $res.Version | Should -Be "1.0.0" + } + + It "Install module using -PassThru" { + $res = Install-PSResource -Name $testModuleName -Repository $ADORepoName -PassThru -TrustRepository + $res.Name | Should -Contain $testModuleName + } +} + +Describe 'Test Install-PSResource for V3Server scenarios' -tags 'ManualValidationOnly' { + + BeforeAll { + $testModuleName = "TestModule" + $testModuleName2 = "testModuleWithlicense" + Get-NewPSResourceRepositoryFile + Register-LocalRepos + } + + AfterEach { + Uninstall-PSResource $testModuleName, $testModuleName2 -SkipDependencyCheck -ErrorAction SilentlyContinue + } + + AfterAll { + Get-RevertPSResourceRepositoryFile + } + + # Unix only manual test + # Expected path should be similar to: '/usr/local/share/powershell/Modules' + It "Install resource under AllUsers scope - Unix only" -Skip:(Get-IsWindows) { + Install-PSResource -Name $testModuleName -Repository $TestGalleryName -Scope AllUsers + $pkg = Get-Module $testModuleName -ListAvailable + $pkg.Name | Should -Be $testModuleName + $pkg.Path.Contains("/usr/") | Should -Be $true + } + + # This needs to be manually tested due to prompt + It "Install resource that requires accept license without -AcceptLicense flag" { + Install-PSResource -Name $testModuleName2 -Repository $TestGalleryName + $pkg = Get-InstalledPSResource $testModuleName2 + $pkg.Name | Should -Be $testModuleName2 + $pkg.Version | Should -Be "0.0.1.0" + } + + # This needs to be manually tested due to prompt + It "Install resource should prompt 'trust repository' if repository is not trusted" { + Set-PSResourceRepository PoshTestGallery -Trusted:$false + + Install-PSResource -Name $testModuleName -Repository $TestGalleryName -confirm:$false + + $pkg = Get-Module $testModuleName -ListAvailable + $pkg.Name | Should -Be $testModuleName + + Set-PSResourceRepository PoshTestGallery -Trusted + } +} diff --git a/test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 new file mode 100644 index 000000000..5f80ace08 --- /dev/null +++ b/test/InstallPSResourceTests/InstallPSResourceContainerRegistryServer.Tests.ps1 @@ -0,0 +1,376 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +$ProgressPreference = "SilentlyContinue" +$modPath = "$psscriptroot/../PSGetTestUtils.psm1" +Import-Module $modPath -Force -Verbose + +Describe 'Test Install-PSResource for ACR scenarios' -tags 'CI' { + + BeforeAll { + $testModuleName = "test-module" + $testModuleName2 = "test-module2" + $testModuleWith2DigitVersion = "test-2DigitPkg" + $testCamelCaseModuleName = "test-camelCaseModule" + $testCamelCaseScriptName = "test-camelCaseScript" + $testModuleParentName = "test_parent_mod" + $testModuleDependencyName = "test_dependency_mod" + $testScriptName = "test-script" + $ACRRepoName = "ACRRepo" + $ACRRepoUri = "https://psresourcegettest.azurecr.io/" + Get-NewPSResourceRepositoryFile + + $usingAzAuth = $env:USINGAZAUTH -eq 'true' + + if ($usingAzAuth) + { + Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'ContainerRegistry' -Uri $ACRRepoUri -Verbose + } + else + { + $psCredInfo = New-Object Microsoft.PowerShell.PSResourceGet.UtilClasses.PSCredentialInfo ("SecretStore", "$env:TENANTID") + Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'ContainerRegistry' -Uri $ACRRepoUri -CredentialInfo $psCredInfo -Verbose + } + } + + AfterEach { + Uninstall-PSResource $testModuleName, $testModuleName2, $testCamelCaseModuleName, $testScriptName, $testCamelCaseScriptName, $testModuleWith2DigitVersion -Version "*" -SkipDependencyCheck -ErrorAction SilentlyContinue + } + + AfterAll { + Get-RevertPSResourceRepositoryFile + } + + $testCases = @{Name="*"; ErrorId="NameContainsWildcard"}, + @{Name="Test-mod*"; ErrorId="NameContainsWildcard"}, + @{Name="Test?modu","Test[module"; ErrorId="ErrorFilteringNamesForUnsupportedWildcards"} + + It "Should not install resource with wildcard in name" -TestCases $testCases { + param($Name, $ErrorId) + Install-PSResource -Name $Name -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "$ErrorId,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" + $res = Get-InstalledPSResource $testModuleName + $res | Should -BeNullOrEmpty + } + + It "Install specific module resource by name" { + Install-PSResource -Name $testModuleName -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "5.0.0" + } + + It "Install specific script resource by name" { + Install-PSResource -Name $testScriptName -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testScriptName + $pkg.Name | Should -BeExactly $testScriptName + $pkg.Version | Should -Be "3.0.0" + $pkg.Type.ToString() | Should -Be "Script" + } + + It "Install script resource by name and version" { + Install-PSResource -Name $testScriptName -Version "1.0.0" -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testScriptName + $pkg.Name | Should -Be $testScriptName + $pkg.Version | Should -BeExactly "1.0.0" + } + + It "Install resource when version contains different number of digits than the normalized version- 1 digit specified" { + # the resource has version "1.0", but querying with any equivalent version should work + Install-PSResource -Name $testModuleWith2DigitVersion -Version "1" -Repository $ACRRepoName -TrustRepository + $res = Get-InstalledPSResource -Name $testModuleWith2DigitVersion + $res | Should -Not -BeNullOrEmpty + $res.Version | Should -Be "1.0" + } + + It "Install resource when version contains different number of digits than the normalized version- 2 digits specified" { + # the resource has version "1.0", but querying with any equivalent version should work + Install-PSResource -Name $testModuleWith2DigitVersion -Version "1.0" -Repository $ACRRepoName -TrustRepository + $res = Get-InstalledPSResource -Name $testModuleWith2DigitVersion + $res | Should -Not -BeNullOrEmpty + $res.Version | Should -Be "1.0" + } + + It "Install resource when version contains different number of digits than the normalized version- 3 digits specified" { + # the resource has version "1.0", but querying with any equivalent version should work + Install-PSResource -Name $testModuleWith2DigitVersion -Version "1.0.0" -Repository $ACRRepoName -TrustRepository + $res = Get-InstalledPSResource -Name $testModuleWith2DigitVersion + $res | Should -Not -BeNullOrEmpty + $res.Version | Should -Be "1.0" + } + + It "Install resource when version contains different number of digits than the normalized version- 4 digits specified" { + # the resource has version "1.0", but querying with any equivalent version should work + Install-PSResource -Name $testModuleWith2DigitVersion -Version "1.0.0.0" -Repository $ACRRepoName -TrustRepository + $res = Get-InstalledPSResource -Name $testModuleWith2DigitVersion + $res | Should -Not -BeNullOrEmpty + $res.Version | Should -Be "1.0" + } + + It "Install resource where version specified is a prerelease version" { + # the resource has version "1.0", but querying with any equivalent version should work + Install-PSResource -Name $testModuleWith2DigitVersion -Version "1.5-alpha" -Prerelease -Repository $ACRRepoName -TrustRepository + $res = Get-InstalledPSResource -Name $testModuleWith2DigitVersion + $res | Should -Not -BeNullOrEmpty + $res.Version | Should -Be "1.5" + $res.Prerelease | Should -Be "alpha" + } + + It "Install multiple resources by name" { + $pkgNames = @($testModuleName, $testModuleName2) + Install-PSResource -Name $pkgNames -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $pkgNames + $pkg.Name | Should -Be $pkgNames + } + + It "Should not install resource given nonexistant name" { + Install-PSResource -Name "NonExistantModule" -Repository $ACRRepoName -TrustRepository -ErrorVariable err -ErrorAction SilentlyContinue + $pkg = Get-InstalledPSResource "NonExistantModule" + $pkg | Should -BeNullOrEmpty + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "ResourceNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" + } + + # Do some version testing, but Find-PSResource should be doing thorough testing + It "Should install resource given name and exact version" { + Install-PSResource -Name $testModuleName -Version "1.0.0" -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "1.0.0" + } + + It "Should install resource given name and exact version with bracket syntax" { + Install-PSResource -Name $testModuleName -Version "[1.0.0]" -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "1.0.0" + } + + It "Should install resource given name and exact range inclusive [1.0.0, 5.0.0]" { + Install-PSResource -Name $testModuleName -Version "[1.0.0, 5.0.0]" -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "5.0.0" + } + + It "Should install resource given name and exact range exclusive (1.0.0, 5.0.0)" { + Install-PSResource -Name $testModuleName -Version "(1.0.0, 5.0.0)" -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "3.0.0" + } + + # TODO: Update this test and others like it that use try/catch blocks instead of Should -Throw + It "Should not install resource with incorrectly formatted version such as exclusive version (1.0.0.0)" { + $Version = "(1.0.0.0)" + { Install-PSResource -Name $testModuleName -Version $Version -Repository $ACRRepoName -TrustRepository -ErrorAction SilentlyContinue } | Should -Throw -ErrorId "IncorrectVersionFormat,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" + + $res = Get-InstalledPSResource $testModuleName + $res | Should -BeNullOrEmpty + } + + It "Install resource when given Name, Version '*', should install the latest version" { + Install-PSResource -Name $testModuleName -Version "*" -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "5.0.0" + } + + It "Install resource with a dependency (should install both parent and dependency)" { + Install-PSResource -Name $testModuleParentName -Repository $ACRRepoName -TrustRepository + + $parentPkg = Get-InstalledPSResource $testModuleParentName + $parentPkg.Name | Should -Be $testModuleParentName + $parentPkg.Version | Should -Be "1.0.0" + $childPkg = Get-InstalledPSResource $testModuleDependencyName + $childPkg.Name | Should -Be $testModuleDependencyName + $childPkg.Version | Should -Be "1.0.0" + } + + It "Install resource with latest (including prerelease) version given Prerelease parameter" { + Install-PSResource -Name $testModuleName -Prerelease -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "5.2.5" + $pkg.Prerelease | Should -Be "alpha" + } + + It "Install resource via InputObject by piping from Find-PSresource" { + Find-PSResource -Name $testModuleName -Repository $ACRRepoName | Install-PSResource -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "5.0.0" + } + + It "Install resource with copyright, description and repository source location and validate properties" { + Install-PSResource -Name $testModuleName -Version "3.0.0" -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "3.0.0" + $pkg.Copyright | Should -Be "(c) Anam Navied. All rights reserved." + $pkg.Description | Should -Be "This is a test module, for PSGallery team internal testing. Do not take a dependency on this package. This version contains tags for the package." + $pkg.RepositorySourceLocation | Should -Be $ACRRepoUri + } + + # Windows only + It "Install resource under CurrentUser scope - Windows only" -Skip:(!(Get-IsWindows)) { + Install-PSResource -Name $testModuleName -Repository $ACRRepoName -TrustRepository -Scope CurrentUser + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.InstalledLocation.ToString().Contains("Documents") | Should -Be $true + } + + # Windows only + It "Install resource under AllUsers scope - Windows only" -Skip:(!((Get-IsWindows) -and (Test-IsAdmin))) { + Install-PSResource -Name $testModuleName -Repository $ACRRepoName -TrustRepository -Scope AllUsers -Verbose + $pkg = Get-InstalledPSResource $testModuleName -Scope AllUsers + $pkg.Name | Should -Be $testModuleName + $pkg.InstalledLocation.ToString().Contains("Program Files") | Should -Be $true + } + + # Windows only + It "Install resource under no specified scope - Windows only" -Skip:(!(Get-IsWindows)) { + Install-PSResource -Name $testModuleName -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.InstalledLocation.ToString().Contains("Documents") | Should -Be $true + } + + # Unix only + # Expected path should be similar to: '/home/janelane/.local/share/powershell/Modules' + It "Install resource under CurrentUser scope - Unix only" -Skip:(Get-IsWindows) { + Install-PSResource -Name $testModuleName -Repository $ACRRepoName -TrustRepository -Scope CurrentUser + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.InstalledLocation.ToString().Contains("$env:HOME/.local") | Should -Be $true + } + + # Unix only + # Expected path should be similar to: '/home/janelane/.local/share/powershell/Modules' + It "Install resource under no specified scope - Unix only" -Skip:(Get-IsWindows) { + Install-PSResource -Name $testModuleName -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.InstalledLocation.ToString().Contains("$env:HOME/.local") | Should -Be $true + } + + It "Should not install resource that is already installed" { + Install-PSResource -Name $testModuleName -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + Install-PSResource -Name $testModuleName -Repository $ACRRepoName -TrustRepository -WarningVariable WarningVar -warningaction SilentlyContinue + $WarningVar | Should -Not -BeNullOrEmpty + } + + It "Reinstall resource that is already installed with -Reinstall parameter" { + Install-PSResource -Name $testModuleName -Repository $ACRRepoName -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "5.0.0" + Install-PSResource -Name $testModuleName -Repository $ACRRepoName -Reinstall -TrustRepository + $pkg = Get-InstalledPSResource $testModuleName + $pkg.Name | Should -Be $testModuleName + $pkg.Version | Should -Be "5.0.0" + } + + It "Install PSResourceInfo object piped in" { + Find-PSResource -Name $testModuleName -Version "1.0.0" -Repository $ACRRepoName | Install-PSResource -TrustRepository + $res = Get-InstalledPSResource -Name $testModuleName + $res.Name | Should -Be $testModuleName + $res.Version | Should -Be "1.0.0" + } + + It "Install module using -PassThru" { + $res = Install-PSResource -Name $testModuleName -Repository $ACRRepoName -PassThru -TrustRepository + $res.Name | Should -Contain $testModuleName + } + + It "Install module with varying case sensitivity" { + Install-PSResource -Name $testCamelCaseModuleName -Repository $ACRRepoName -TrustRepository + $res = Get-InstalledPSResource -Name $testCamelCaseModuleName + $res.Name | Should -BeExactly $testCamelCaseModuleName + $res.Version | Should -Be "1.0.0" + $res.Type.ToString() | Should -Be "Module" + } + + It "Install script with varying case sensitivity" { + Install-PSResource -Name $testCamelCaseScriptName -Repository $ACRRepoName -TrustRepository + $res = Get-InstalledPSResource -Name $testCamelCaseScriptName + $res.Name | Should -BeExactly $testCamelCaseScriptName + $res.Version | Should -Be "1.0.0" + $res.Type.ToString() | Should -Be "Script" + } +} + +Describe 'Test Install-PSResource for V3Server scenarios' -tags 'ManualValidationOnly' { + + BeforeAll { + $testModuleName = "TestModule" + $testModuleName2 = "testModuleWithlicense" + Get-NewPSResourceRepositoryFile + Register-LocalRepos + } + + AfterEach { + Uninstall-PSResource $testModuleName, $testModuleName2 -SkipDependencyCheck -ErrorAction SilentlyContinue + } + + AfterAll { + Get-RevertPSResourceRepositoryFile + } + + # Unix only manual test + # Expected path should be similar to: '/usr/local/share/powershell/Modules' + It "Install resource under AllUsers scope - Unix only" -Skip:(Get-IsWindows) { + Install-PSResource -Name $testModuleName -Repository $TestGalleryName -Scope AllUsers + $pkg = Get-Module $testModuleName -ListAvailable + $pkg.Name | Should -Be $testModuleName + $pkg.Path.Contains("/usr/") | Should -Be $true + } + + # This needs to be manually tested due to prompt + It "Install resource that requires accept license without -AcceptLicense flag" { + Install-PSResource -Name $testModuleName2 -Repository $TestGalleryName + $pkg = Get-InstalledPSResource $testModuleName2 + $pkg.Name | Should -Be $testModuleName2 + $pkg.Version | Should -Be "0.0.1.0" + } + + # This needs to be manually tested due to prompt + It "Install resource should prompt 'trust repository' if repository is not trusted" { + Set-PSResourceRepository PoshTestGallery -Trusted:$false + + Install-PSResource -Name $testModuleName -Repository $TestGalleryName -confirm:$false + + $pkg = Get-Module $testModuleName -ListAvailable + $pkg.Name | Should -Be $testModuleName + + Set-PSResourceRepository PoshTestGallery -Trusted + } +} + +Describe 'Test Install-PSResource for MAR Repository' -tags 'CI' { + BeforeAll { + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook("MARPrefix", "azure-powershell/"); + Register-PSResourceRepository -Name "MAR" -Uri "https://mcr.microsoft.com" -ApiVersion "ContainerRegistry" + } + + AfterAll { + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook("MARPrefix", $null); + Unregister-PSResourceRepository -Name "MAR" + } + + It "Should find resource given specific Name, Version null" { + try { + $pkg = Install-PSResource -Name "Az.Accounts" -Repository "MAR" -PassThru -TrustRepository -Reinstall + $pkg.Name | Should -Be "Az.Accounts" + $pkg.Version | Should -Be "3.0.4" + } + finally { + if ($pkg) { + Uninstall-PSResource -Name "Az.Accounts" -Version "3.0.4" + } + } + } +} diff --git a/test/InstallPSResourceTests/InstallPSResourceGithubPackages.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceGithubPackages.Tests.ps1 index c8ed9cd96..7c4e68d6a 100644 --- a/test/InstallPSResourceTests/InstallPSResourceGithubPackages.Tests.ps1 +++ b/test/InstallPSResourceTests/InstallPSResourceGithubPackages.Tests.ps1 @@ -5,7 +5,7 @@ $ProgressPreference = "SilentlyContinue" $modPath = "$psscriptroot/../PSGetTestUtils.psm1" Import-Module $modPath -Force -Verbose -Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { +Describe 'Test Install-PSResource for GitHub packages' -tags 'CI' { BeforeAll { $testModuleName = "test_module" @@ -18,6 +18,8 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { $secureString = ConvertTo-SecureString $env:MAPPED_GITHUB_PAT -AsPlainText -Force $credential = New-Object pscredential ($env:GITHUB_USERNAME, $secureString) + + Uninstall-PSResource $testModuleName, $testScriptName -Version "*" -SkipDependencyCheck -ErrorAction SilentlyContinue } AfterEach { @@ -28,9 +30,9 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { Get-RevertPSResourceRepositoryFile } - $testCases = @{Name="*"; ErrorId="NameContainsWildcard"}, - @{Name="Test_local_m*"; ErrorId="NameContainsWildcard"}, - @{Name="Test?local","Test[local"; ErrorId="ErrorFilteringNamesForUnsupportedWildcards"} + $testCases = @{Name="*"; ErrorId="NameContainsWildcard"}, + @{Name="Test_m*"; ErrorId="NameContainsWildcard"}, + @{Name="Test?module","Test[module"; ErrorId="ErrorFilteringNamesForUnsupportedWildcards"} It "Should not install resource with wildcard in name" -TestCases $testCases { param($Name, $ErrorId) diff --git a/test/InstallPSResourceTests/InstallPSResourceLocal.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceLocal.Tests.ps1 index 7553fa066..f5ec1d02b 100644 --- a/test/InstallPSResourceTests/InstallPSResourceLocal.Tests.ps1 +++ b/test/InstallPSResourceTests/InstallPSResourceLocal.Tests.ps1 @@ -1,27 +1,31 @@ # 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 $psmodulePaths = $env:PSModulePath -split ';' -Write-Verbose -Verbose "Current module search paths: $psmodulePaths" +Write-Verbose -Verbose -Message "Current module search paths: $psmodulePaths" Describe 'Test Install-PSResource for local repositories' -tags 'CI' { - BeforeAll { $localRepo = "psgettestlocal" $localUNCRepo = "psgettestlocal3" + $localNupkgRepo = "localNupkgRepo" $testModuleName = "test_local_mod" $testModuleName2 = "test_local_mod2" $testModuleClobber = "testModuleClobber" $testModuleClobber2 = "testModuleClobber2" Get-NewPSResourceRepositoryFile Register-LocalRepos + Register-LocalTestNupkgsRepo - $prereleaseLabel = "alpha001" + $prereleaseLabel = "Alpha001" $tags = @() New-TestModule -moduleName $testModuleName -repoName $localRepo -packageVersion "1.0.0" -prereleaseLabel "" -tags $tags @@ -51,7 +55,7 @@ Describe 'Test Install-PSResource for local repositories' -tags 'CI' { $res.Version | Should -Be "5.0.0" } - It "Install resource given Name parameter from UNC repository" { + It "Install resource given Name parameter from UNC repository" { Install-PSResource -Name $testModuleName -Repository $localUNCRepo -TrustRepository $res = Get-InstalledPSResource -Name $testModuleName $res.Name | Should -Be $testModuleName @@ -67,7 +71,7 @@ Describe 'Test Install-PSResource for local repositories' -tags 'CI' { It "Install multiple resources by name" { $pkgNames = @($testModuleName, $testModuleName2) - Install-PSResource -Name $pkgNames -Repository $localRepo -TrustRepository + Install-PSResource -Name $pkgNames -Repository $localRepo -TrustRepository $pkg = Get-InstalledPSResource $pkgNames $pkg.Name | Should -Be $pkgNames } @@ -80,7 +84,7 @@ Describe 'Test Install-PSResource for local repositories' -tags 'CI' { } It "Should install resource given name and exact version with bracket syntax" { - Install-PSResource -Name $testModuleName -Version "[1.0.0.0]" -Repository $localRepo -TrustRepository + Install-PSResource -Name $testModuleName -Version "[1.0.0.0]" -Repository $localRepo -TrustRepository $res = Get-InstalledPSResource $testModuleName $res.Name | Should -Be $testModuleName $res.Version | Should -Be "1.0.0" @@ -94,7 +98,7 @@ Describe 'Test Install-PSResource for local repositories' -tags 'CI' { } It "Should install resource given name and exact range exclusive (1.0.0.0, 5.0.0.0)" { - Install-PSResource -Name $testModuleName -Version "(1.0.0.0, 5.0.0.0)" -Repository $localRepo -TrustRepository + Install-PSResource -Name $testModuleName -Version "(1.0.0.0, 5.0.0.0)" -Repository $localRepo -TrustRepository $res = Get-InstalledPSResource $testModuleName $res.Name | Should -Be $testModuleName $res.Version | Should -Be "3.0.0" @@ -107,7 +111,7 @@ Describe 'Test Install-PSResource for local repositories' -tags 'CI' { } catch {} - $Error[0].FullyQualifiedErrorId | Should -be "IncorrectVersionFormat,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" + $Error[0].FullyQualifiedErrorId | Should -Be "IncorrectVersionFormat,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" $res = Get-InstalledPSResource $testModuleName -ErrorAction SilentlyContinue $res | Should -BeNullOrEmpty @@ -127,12 +131,12 @@ Describe 'Test Install-PSResource for local repositories' -tags 'CI' { $pkg.Version | Should -Be "3.0.0" } - It "Install resource with latest (including prerelease) version given Prerelease parameter" { - Install-PSResource -Name $testModuleName -Prerelease -Repository $localRepo -TrustRepository + It "Install resource with latest (including prerelease) version given Prerelease parameter (prerelease casing should be correct)" { + Install-PSResource -Name $testModuleName -Prerelease -Repository $localRepo -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "5.2.5" - $pkg.Prerelease | Should -Be "alpha001" + $pkg.Prerelease | Should -Be "Alpha001" } It "Install resource with cmdlet names from a module already installed with -NoClobber (should not clobber)" { @@ -168,14 +172,14 @@ Describe 'Test Install-PSResource for local repositories' -tags 'CI' { } It "Install resource via InputObject by piping from Find-PSresource" { - Find-PSResource -Name $testModuleName -Repository $localRepo | Install-PSResource -TrustRepository + Find-PSResource -Name $testModuleName -Repository $localRepo | Install-PSResource -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "5.0.0" } It "Install resource via InputObject by piping from Find-PSResource" { - $modules = Find-PSResource -Name "*" -Repository $localRepo + $modules = Find-PSResource -Name "*" -Repository $localRepo $modules.Count | Should -BeGreaterThan 1 Install-PSResource -TrustRepository -InputObject $modules @@ -187,7 +191,7 @@ Describe 'Test Install-PSResource for local repositories' -tags 'CI' { It "Install resource under location specified in PSModulePath" { Install-PSResource -Name $testModuleName -Repository $localRepo -TrustRepository $pkg = Get-InstalledPSResource $testModuleName - $pkg.Name | Should -Be $testModuleName + $pkg.Name | Should -Be $testModuleName ($env:PSModulePath).Contains($pkg.InstalledLocation) } @@ -201,7 +205,7 @@ Describe 'Test Install-PSResource for local repositories' -tags 'CI' { # Windows only It "Install resource under AllUsers scope - Windows only" -Skip:(!((Get-IsWindows) -and (Test-IsAdmin))) { - Install-PSResource -Name $testModuleName -Repository $localRepo -TrustRepository -Scope AllUsers -Verbose + Install-PSResource -Name $testModuleName -Repository $localRepo -TrustRepository -Scope AllUsers $pkg = Get-InstalledPSResource $testModuleName -Scope AllUsers $pkg.Name | Should -Be $testModuleName $pkg.InstalledLocation.ToString().Contains("Program Files") | Should -Be $true @@ -209,7 +213,7 @@ Describe 'Test Install-PSResource for local repositories' -tags 'CI' { # Windows only It "Install resource under no specified scope - Windows only" -Skip:(!(Get-IsWindows)) { - Install-PSResource -Name $testModuleName -Repository $localRepo -TrustRepository + Install-PSResource -Name $testModuleName -Repository $localRepo -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.InstalledLocation.ToString().Contains("Documents") | Should -Be $true @@ -237,7 +241,7 @@ Describe 'Test Install-PSResource for local repositories' -tags 'CI' { Install-PSResource -Name $testModuleName -Repository $localRepo -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName - Install-PSResource -Name $testModuleName -Repository $localRepo -TrustRepository -WarningVariable WarningVar -warningaction SilentlyContinue + Install-PSResource -Name $testModuleName -Repository $localRepo -TrustRepository -WarningVariable WarningVar -WarningAction SilentlyContinue $WarningVar | Should -Not -BeNullOrEmpty } @@ -254,6 +258,8 @@ Describe 'Test Install-PSResource for local repositories' -tags 'CI' { It "Install module using -WhatIf, should not install the module" { Install-PSResource -Name $testModuleName -Version "1.0.0.0" -Repository $localRepo -TrustRepository -WhatIf + $? | Should -BeTrue + $res = Get-InstalledPSResource -Name $testModuleName -ErrorAction SilentlyContinue $res | Should -BeNullOrEmpty } @@ -267,11 +273,11 @@ Describe 'Test Install-PSResource for local repositories' -tags 'CI' { It "Get definition for alias 'isres'" { (Get-Alias isres).Definition | Should -BeExactly 'Install-PSResource' } - + It "Not install resource that lists dependency packages which cannot be found" { $localRepoUri = Join-Path -Path $TestDrive -ChildPath "testdir" Save-PSResource -Name "test_script" -Repository "PSGallery" -TrustRepository -Path $localRepoUri -AsNupkg -SkipDependencyCheck - Write-Host $localRepoUri + Write-Information -InformationAction Continue -MessageData $localRepoUri $res = Install-PSResource -Name "test_script" -Repository $localRepo -TrustRepository -PassThru -ErrorVariable err -ErrorAction SilentlyContinue $res | Should -BeNullOrEmpty $err.Count | Should -Not -Be 0 @@ -279,4 +285,15 @@ Describe 'Test Install-PSResource for local repositories' -tags 'CI' { $err[$i].FullyQualifiedErrorId | Should -Not -Be "System.NullReferenceException,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" } } + + It "Install .nupkg that contains directories (specific package throws errors when accessed by ZipFile.OpenRead)" { + $nupkgName = "Microsoft.Web.Webview2" + $nupkgVersion = "1.0.2792.45" + $repoPath = Get-PSResourceRepository $localNupkgRepo + $searchPkg = Find-PSResource -Name $nupkgName -Version $nupkgVersion -Repository $localNupkgRepo + Install-PSResource -Name $nupkgName -Version $nupkgVersion -Repository $localNupkgRepo -TrustRepository + $pkg = Get-InstalledPSResource $nupkgName + $pkg.Name | Should -Be $nupkgName + $pkg.Version | Should -Be $nupkgVersion + } } diff --git a/test/InstallPSResourceTests/InstallPSResourceV2Server.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceV2Server.Tests.ps1 index b64244dcd..0b03a984b 100644 --- a/test/InstallPSResourceTests/InstallPSResourceV2Server.Tests.ps1 +++ b/test/InstallPSResourceTests/InstallPSResourceV2Server.Tests.ps1 @@ -1,12 +1,15 @@ # 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 $psmodulePaths = $env:PSModulePath -split ';' -Write-Verbose -Verbose "Current module search paths: $psmodulePaths" +Write-Verbose -Verbose -Message "Current module search paths: $psmodulePaths" Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'CI' { @@ -28,17 +31,19 @@ Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'CI' { AfterEach { Uninstall-PSResource "test_module", "test_module2", "test_script", "TestModule99", "testModuleWithlicense", ` - "TestFindModule","ClobberTestModule1", "ClobberTestModule2", "PackageManagement", "TestTestScript", ` - "TestModuleWithDependency", "TestModuleWithPrereleaseDep", "PrereleaseModule" -SkipDependencyCheck -ErrorAction SilentlyContinue + "TestFindModule","ClobberTestModule1", "ClobberTestModule2", "PackageManagement", "TestTestScript", ` + "TestModuleWithDependency", "TestModuleWithPrereleaseDep", "PrereleaseModule" -SkipDependencyCheck -ErrorAction SilentlyContinue } AfterAll { Get-RevertPSResourceRepositoryFile } - $testCases = @{Name="*"; ErrorId="NameContainsWildcard"}, - @{Name="Test_Module*"; ErrorId="NameContainsWildcard"}, - @{Name="Test?Module","Test[Module"; ErrorId="ErrorFilteringNamesForUnsupportedWildcards"} + $testCases = [array]( + @{Name="*"; ErrorId="NameContainsWildcard"}, + @{Name="Test_Module*"; ErrorId="NameContainsWildcard"}, + @{Name="Test?Module","Test[Module"; ErrorId="ErrorFilteringNamesForUnsupportedWildcards"} + ) It "Should not install resource with wildcard in name" -TestCases $testCases { param($Name, $ErrorId) @@ -63,7 +68,7 @@ Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'CI' { It "Install multiple resources by name" { $pkgNames = @($testModuleName, $testModuleName2) - Install-PSResource -Name $pkgNames -Repository $PSGalleryName -TrustRepository + Install-PSResource -Name $pkgNames -Repository $PSGalleryName -TrustRepository $pkg = Get-InstalledPSResource $pkgNames $pkg.Name | Should -Be $pkgNames } @@ -73,7 +78,7 @@ Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'CI' { $pkg = Get-InstalledPSResource "NonExistantModule" $pkg.Name | Should -BeNullOrEmpty $err.Count | Should -BeGreaterThan 0 - $err[0].FullyQualifiedErrorId | Should -BeExactly "InstallPackageFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" + $err[0].FullyQualifiedErrorId | Should -BeExactly "InstallPackageFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" } # Do some version testing, but Find-PSResource should be doing thorough testing @@ -83,30 +88,30 @@ Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'CI' { $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "1.0.0.0" } - + It "Should install resource given name and exact version with bracket syntax" { - Install-PSResource -Name $testModuleName -Version "3.*" -Repository $PSGalleryName -TrustRepository + Install-PSResource -Name $testModuleName -Version "3.*" -Repository $PSGalleryName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "3.0.0.0" } It "Should install resource given name and exact version with bracket syntax" { - Install-PSResource -Name $testModuleName -Version "[1.0.0]" -Repository $PSGalleryName -TrustRepository + Install-PSResource -Name $testModuleName -Version "[1.0.0]" -Repository $PSGalleryName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "1.0.0.0" } It "Should install resource given name and exact range inclusive [1.0.0, 5.0.0]" { - Install-PSResource -Name $testModuleName -Version "[1.0.0, 5.0.0]" -Repository $PSGalleryName -TrustRepository + Install-PSResource -Name $testModuleName -Version "[1.0.0, 5.0.0]" -Repository $PSGalleryName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "5.0.0.0" } It "Should install resource given name and exact range exclusive (1.0.0, 5.0.0)" { - Install-PSResource -Name $testModuleName -Version "(1.0.0, 5.0.0)" -Repository $PSGalleryName -TrustRepository + Install-PSResource -Name $testModuleName -Version "(1.0.0, 5.0.0)" -Repository $PSGalleryName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "3.0.0.0" @@ -118,9 +123,9 @@ Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'CI' { try { Install-PSResource -Name $testModuleName -Version $Version -Repository $PSGalleryName -TrustRepository -ErrorAction SilentlyContinue } - catch - {} - $Error[0].FullyQualifiedErrorId | Should -be "IncorrectVersionFormat,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" + catch { + } + $Error[0].FullyQualifiedErrorId | Should -Be "IncorrectVersionFormat,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" $res = Get-InstalledPSResource $testModuleName $res | Should -BeNullOrEmpty @@ -134,7 +139,7 @@ Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'CI' { } It "Install resource with latest (including prerelease) version given Prerelease parameter" { - Install-PSResource -Name $testModuleName -Prerelease -Repository $PSGalleryName -TrustRepository + Install-PSResource -Name $testModuleName -Prerelease -Repository $PSGalleryName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "5.2.5" @@ -143,7 +148,7 @@ Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'CI' { It "Install a module with a dependency" { Uninstall-PSResource -Name "TestModuleWithDependency*" -Version "*" -SkipDependencyCheck -ErrorAction SilentlyContinue - Install-PSResource -Name "TestModuleWithDependencyC" -Version "5.0.0.0" -Repository $PSGalleryName -TrustRepository + Install-PSResource -Name "TestModuleWithDependencyC" -Version "5.0.0.0" -Repository $PSGalleryName -TrustRepository $pkg = Get-InstalledPSResource "TestModuleWithDependencyC" $pkg.Name | Should -Be "TestModuleWithDependencyC" @@ -159,7 +164,7 @@ Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'CI' { } It "Install a module with a prerelease dependency" { - Install-PSResource -Name "TestModuleWithPrereleaseDep" -Repository $PSGalleryName -TrustRepository + Install-PSResource -Name "TestModuleWithPrereleaseDep" -Repository $PSGalleryName -TrustRepository $pkg = Get-InstalledPSResource "TestModuleWithPrereleaseDep" $pkg.Name | Should -Be "TestModuleWithPrereleaseDep" @@ -183,16 +188,16 @@ Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'CI' { } It "Install resource via InputObject by piping from Find-PSresource" { - Find-PSResource -Name $testModuleName -Repository $PSGalleryName | Install-PSResource -TrustRepository + Find-PSResource -Name $testModuleName -Repository $PSGalleryName | Install-PSResource -TrustRepository $pkg = Get-InstalledPSResource $testModuleName - $pkg.Name | Should -Be $testModuleName + $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "5.0.0.0" } It "Install resource under specified in PSModulePath" { Install-PSResource -Name $testModuleName -Repository $PSGalleryName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName - $pkg.Name | Should -Be $testModuleName + $pkg.Name | Should -Be $testModuleName ($env:PSModulePath).Contains($pkg.InstalledLocation) } @@ -237,7 +242,7 @@ Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'CI' { # Windows only It "Install resource under no specified scope - Windows only" -Skip:(!(Get-IsWindows)) { - Install-PSResource -Name $testModuleName -Repository $PSGalleryName -TrustRepository + Install-PSResource -Name $testModuleName -Repository $PSGalleryName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.InstalledLocation.ToString().Contains("Documents") | Should -Be $true @@ -265,7 +270,7 @@ Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'CI' { Install-PSResource -Name $testModuleName -Repository $PSGalleryName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName - Install-PSResource -Name $testModuleName -Repository $PSGalleryName -TrustRepository -WarningVariable WarningVar -warningaction SilentlyContinue + Install-PSResource -Name $testModuleName -Repository $PSGalleryName -TrustRepository -WarningVariable WarningVar -WarningAction SilentlyContinue $WarningVar | Should -Not -BeNullOrEmpty } @@ -309,7 +314,7 @@ Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'CI' { # It "Install resource that requires accept license with -AcceptLicense flag" { # Install-PSResource -Name "testModuleWithlicense" -Repository $TestGalleryName -AcceptLicense # $pkg = Get-InstalledPSResource "testModuleWithlicense" - # $pkg.Name | Should -Be "testModuleWithlicense" + # $pkg.Name | Should -Be "testModuleWithlicense" # $pkg.Version | Should -Be "0.0.3.0" # } @@ -347,6 +352,7 @@ Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'CI' { It "Install module using -WhatIf, should not install the module" { Install-PSResource -Name $testModuleName -Repository $PSGalleryName -TrustRepository -WhatIf + $? | Should -BeTrue $res = Get-InstalledPSResource $testModuleName $res | Should -BeNullOrEmpty @@ -384,34 +390,32 @@ Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'CI' { It "Install modules using -RequiredResource with hashtable" { $rrHash = @{ - test_module = @{ - version = "[1.0.0,5.0.0)" - repository = $PSGalleryName + test_module = @{ + version = "[1.0.0,5.0.0)" + repository = $PSGalleryName } - test_module2 = @{ - version = "[1.0.0,3.0.0)" - repository = $PSGalleryName - prerelease = "true" + version = "[1.0.0,3.0.0)" + repository = $PSGalleryName + prerelease = "true" } - TestModule99 = @{} - } + } - Install-PSResource -RequiredResource $rrHash -TrustRepository + Install-PSResource -RequiredResource $rrHash -TrustRepository - $res1 = Get-InstalledPSResource $testModuleName - $res1.Name | Should -Be $testModuleName - $res1.Version | Should -Be "3.0.0.0" + $res1 = Get-InstalledPSResource $testModuleName + $res1.Name | Should -Be $testModuleName + $res1.Version | Should -Be "3.0.0.0" - $res2 = Get-InstalledPSResource "test_module2" -Version "2.5.0-beta" - $res2.Name | Should -Be "test_module2" - $res2.Version | Should -Be "2.5.0" - $res2.Prerelease | Should -Be "beta" + $res2 = Get-InstalledPSResource "test_module2" -Version "2.5.0-beta" + $res2.Name | Should -Be "test_module2" + $res2.Version | Should -Be "2.5.0" + $res2.Prerelease | Should -Be "beta" - $res3 = Get-InstalledPSResource $testModuleName2 - $res3.Name | Should -Be $testModuleName2 - $res3.Version | Should -Be "0.0.93" + $res3 = Get-InstalledPSResource $testModuleName2 + $res3.Name | Should -Be $testModuleName2 + $res3.Version | Should -Be "0.0.93" } It "Install modules using -RequiredResource with JSON string" { @@ -430,20 +434,20 @@ Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'CI' { } }" - Install-PSResource -RequiredResource $rrJSON -TrustRepository + Install-PSResource -RequiredResource $rrJSON -TrustRepository - $res1 = Get-InstalledPSResource $testModuleName - $res1.Name | Should -Be $testModuleName - $res1.Version | Should -Be "3.0.0.0" + $res1 = Get-InstalledPSResource $testModuleName + $res1.Name | Should -Be $testModuleName + $res1.Version | Should -Be "3.0.0.0" - $res2 = Get-InstalledPSResource "test_module2" -Version "2.5.0-beta" - $res2.Name | Should -Be "test_module2" - $res2.Version | Should -Be "2.5.0" - $res2.Prerelease | Should -Be "beta" + $res2 = Get-InstalledPSResource "test_module2" -Version "2.5.0-beta" + $res2.Name | Should -Be "test_module2" + $res2.Version | Should -Be "2.5.0" + $res2.Prerelease | Should -Be "beta" - $res3 = Get-InstalledPSResource $testModuleName2 - $res3.Name | Should -Be $testModuleName2 - $res3.Version | Should -Be "0.0.93" + $res3 = Get-InstalledPSResource $testModuleName2 + $res3.Name | Should -Be $testModuleName2 + $res3.Version | Should -Be "0.0.93" } It "Install modules using -RequiredResourceFile with PSD1 file" { @@ -485,7 +489,7 @@ Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'CI' { } # Install module 1.4.3 (is authenticode signed and has catalog file) - # Should install successfully + # Should install successfully It "Install modules with catalog file using publisher validation" -Skip:(!(Get-IsWindows)) { Install-PSResource -Name $PackageManagement -Version "1.4.3" -AuthenticodeCheck -Repository $PSGalleryName -TrustRepository @@ -509,7 +513,7 @@ Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'CI' { It "Install module that is not authenticode signed" -Skip:(!(Get-IsWindows)) { Install-PSResource -Name $testModuleName -Version "5.0.0" -AuthenticodeCheck -Repository $PSGalleryName -TrustRepository -ErrorVariable err -ErrorAction SilentlyContinue $err.Count | Should -BeGreaterThan 0 - $err[0].FullyQualifiedErrorId | Should -BeExactly "GetAuthenticodeSignatureError,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" + $err[0].FullyQualifiedErrorId | Should -BeExactly "GetAuthenticodeSignatureError,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" } # # Install 1.4.4.1 (with incorrect catalog file) @@ -532,11 +536,11 @@ Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'CI' { # Should throw and fail to install It "Install script that is not signed" -Skip:(!(Get-IsWindows)) { Install-PSResource -Name "TestTestScript" -Version "1.3.1.1" -AuthenticodeCheck -Repository $PSGalleryName -TrustRepository -ErrorVariable err -ErrorAction SilentlyContinue - write-host $err.Count + Write-Information -InformationAction Continue -MessageData $err.Count $err.Count | Should -HaveCount 1 - write-Host $err - write-Host $err[0] - $err[0].FullyQualifiedErrorId | Should -BeExactly "GetAuthenticodeSignatureError,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" + Write-Information -InformationAction Continue -MessageData $err + Write-Information -InformationAction Continue -MessageData $err[0] + $err[0].FullyQualifiedErrorId | Should -BeExactly "GetAuthenticodeSignatureError,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" } # Unix test for installing scripts @@ -557,10 +561,10 @@ Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'CI' { $res | Should -BeNullOrEmpty } - # This is testing FindVersionGlobbing when it creates the 'maxString' variable, - # specifically when the prerelease versions need to be accounted for since that's when PSResourceGet + # This is testing FindVersionGlobbing when it creates the 'maxString' variable, + # specifically when the prerelease versions need to be accounted for since that's when PSResourceGet # modifies the version range to also account for prerelease versions. - # + # It "install resource when version has a nine in the digit and -Prerelease parameter is passed in" { $moduleName = "TestModuleVersionWithNine" $version = "1.9.9" @@ -596,7 +600,7 @@ Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'ManualValidati It "Install resource under AllUsers scope - Unix only" -Skip:(Get-IsWindows) { Install-PSResource -Name $testModuleName -Repository $TestGalleryName -Scope AllUsers $pkg = Get-Module $testModuleName -ListAvailable - $pkg.Name | Should -Be $testModuleName + $pkg.Name | Should -Be $testModuleName $pkg.Path.Contains("/usr/") | Should -Be $true } @@ -604,7 +608,7 @@ Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'ManualValidati It "Install resource that requires accept license without -AcceptLicense flag" { Install-PSResource -Name $testModuleName2 -Repository $TestGalleryName $pkg = Get-InstalledPSResource $testModuleName2 - $pkg.Name | Should -Be $testModuleName2 + $pkg.Name | Should -Be $testModuleName2 $pkg.Version | Should -Be "0.0.1.0" } @@ -612,10 +616,10 @@ Describe 'Test Install-PSResource for V2 Server scenarios' -tags 'ManualValidati It "Install resource should prompt 'trust repository' if repository is not trusted" { Set-PSResourceRepository PoshTestGallery -Trusted:$false - Install-PSResource -Name $testModuleName -Repository $TestGalleryName -confirm:$false - + Install-PSResource -Name $testModuleName -Repository $TestGalleryName -Confirm:$false + $pkg = Get-Module $testModuleName -ListAvailable - $pkg.Name | Should -Be $testModuleName + $pkg.Name | Should -Be $testModuleName Set-PSResourceRepository PoshTestGallery -Trusted } diff --git a/test/InstallPSResourceTests/InstallPSResourceV3Server.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceV3Server.Tests.ps1 index 7c9ee682f..3e4f82823 100644 --- a/test/InstallPSResourceTests/InstallPSResourceV3Server.Tests.ps1 +++ b/test/InstallPSResourceTests/InstallPSResourceV3Server.Tests.ps1 @@ -1,12 +1,15 @@ # 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 $psmodulePaths = $env:PSModulePath -split ';' -Write-Verbose -Verbose "Current module search paths: $psmodulePaths" +Write-Verbose -Verbose -Message "Current module search paths: $psmodulePaths" Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { @@ -32,8 +35,8 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { } $testCases = @{Name="*"; ErrorId="NameContainsWildcard"}, - @{Name="Test_Module*"; ErrorId="NameContainsWildcard"}, - @{Name="Test?Module","Test[Module"; ErrorId="ErrorFilteringNamesForUnsupportedWildcards"} + @{Name="Test_Module*"; ErrorId="NameContainsWildcard"}, + @{Name="Test?Module","Test[Module"; ErrorId="ErrorFilteringNamesForUnsupportedWildcards"} It "Should not install resource with wildcard in name" -TestCases $testCases { param($Name, $ErrorId) @@ -60,7 +63,7 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { It "Install multiple resources by name" { $pkgNames = @($testModuleName, $testModuleName2) - Install-PSResource -Name $pkgNames -Repository $NuGetGalleryName -TrustRepository + Install-PSResource -Name $pkgNames -Repository $NuGetGalleryName -TrustRepository $pkg = Get-InstalledPSResource $pkgNames $pkg.Name | Should -Be $pkgNames } @@ -70,7 +73,15 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { $pkg = Get-InstalledPSResource "NonExistantModule" -ErrorAction SilentlyContinue $pkg.Name | Should -BeNullOrEmpty $err.Count | Should -BeGreaterThan 0 - $err[0].FullyQualifiedErrorId | Should -BeExactly "InstallPackageFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" + $err[0].FullyQualifiedErrorId | Should -BeExactly "InstallPackageFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" + } + + It "Install module using -WhatIf, should not install the module" { + Install-PSResource -Name $testModuleName -Repository $NuGetGalleryName -TrustRepository -WhatIf + $? | Should -BeTrue + + $res = Get-InstalledPSResource $testModuleName + $res | Should -BeNullOrEmpty } # Do some version testing, but Find-PSResource should be doing thorough testing @@ -82,21 +93,21 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { } It "Should install resource given name and exact version with bracket syntax" { - Install-PSResource -Name $testModuleName -Version "[1.0.0]" -Repository $NuGetGalleryName -TrustRepository + Install-PSResource -Name $testModuleName -Version "[1.0.0]" -Repository $NuGetGalleryName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "1.0.0" } It "Should install resource given name and exact range inclusive [1.0.0, 5.0.0]" { - Install-PSResource -Name $testModuleName -Version "[1.0.0, 5.0.0]" -Repository $NuGetGalleryName -TrustRepository + Install-PSResource -Name $testModuleName -Version "[1.0.0, 5.0.0]" -Repository $NuGetGalleryName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "5.0.0" } It "Should install resource given name and exact range exclusive (1.0.0, 5.0.0)" { - Install-PSResource -Name $testModuleName -Version "(1.0.0, 5.0.0)" -Repository $NuGetGalleryName -TrustRepository + Install-PSResource -Name $testModuleName -Version "(1.0.0, 5.0.0)" -Repository $NuGetGalleryName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "3.0.0" @@ -110,7 +121,7 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { } catch {} - $Error[0].FullyQualifiedErrorId | Should -be "IncorrectVersionFormat,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" + $Error[0].FullyQualifiedErrorId | Should -Be "IncorrectVersionFormat,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" $res = Get-InstalledPSResource $testModuleName -ErrorAction SilentlyContinue $res | Should -BeNullOrEmpty @@ -124,7 +135,7 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { } It "Install resource with latest (including prerelease) version given Prerelease parameter" { - Install-PSResource -Name $testModuleName -Prerelease -Repository $NuGetGalleryName -TrustRepository + Install-PSResource -Name $testModuleName -Prerelease -Repository $NuGetGalleryName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "5.2.5" @@ -132,7 +143,7 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { } It "Install resource via InputObject by piping from Find-PSresource" { - Find-PSResource -Name $testModuleName -Repository $NuGetGalleryName | Install-PSResource -TrustRepository + Find-PSResource -Name $testModuleName -Repository $NuGetGalleryName | Install-PSResource -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName $pkg.Version | Should -Be "5.0.0" @@ -203,7 +214,7 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { Install-PSResource -Name $testModuleName -Repository $NuGetGalleryName -TrustRepository $pkg = Get-InstalledPSResource $testModuleName $pkg.Name | Should -Be $testModuleName - Install-PSResource -Name $testModuleName -Repository $NuGetGalleryName -TrustRepository -WarningVariable WarningVar -warningaction SilentlyContinue + Install-PSResource -Name $testModuleName -Repository $NuGetGalleryName -TrustRepository -WarningVariable WarningVar -WarningAction SilentlyContinue $WarningVar | Should -Not -BeNullOrEmpty } @@ -265,35 +276,35 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { It "Install modules using -RequiredResource with hashtable" { $rrHash = @{ - test_module = @{ - version = "[1.0.0,5.0.0)" - repository = $NuGetGalleryName + test_module = @{ + version = "[1.0.0,5.0.0)" + repository = $NuGetGalleryName } - test_module2 = @{ - version = "[1.0.0,5.0.0]" - repository = $NuGetGalleryName - prerelease = "true" + test_module2 = @{ + version = "[1.0.0,5.0.0]" + repository = $NuGetGalleryName + prerelease = "true" } - TestModule99 = @{ + TestModule99 = @{ repository = $NuGetGalleryName } - } + } - Install-PSResource -RequiredResource $rrHash -TrustRepository + Install-PSResource -RequiredResource $rrHash -TrustRepository - $res1 = Get-InstalledPSResource $testModuleName - $res1.Name | Should -Be $testModuleName - $res1.Version | Should -Be "3.0.0" + $res1 = Get-InstalledPSResource $testModuleName + $res1.Name | Should -Be $testModuleName + $res1.Version | Should -Be "3.0.0" - $res2 = Get-InstalledPSResource $testModuleName2 - $res2.Name | Should -Be $testModuleName2 - $res2.Version | Should -Be "5.0.0" + $res2 = Get-InstalledPSResource $testModuleName2 + $res2.Name | Should -Be $testModuleName2 + $res2.Version | Should -Be "5.0.0" - $res3 = Get-InstalledPSResource "TestModule99" - $res3.Name | Should -Be "TestModule99" - $res3.Version | Should -Be "0.0.93" + $res3 = Get-InstalledPSResource "TestModule99" + $res3.Name | Should -Be "TestModule99" + $res3.Version | Should -Be "0.0.93" } It "Install modules using -RequiredResource with JSON string" { @@ -312,19 +323,19 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'CI' { } }" - Install-PSResource -RequiredResource $rrJSON -TrustRepository + Install-PSResource -RequiredResource $rrJSON -TrustRepository - $res1 = Get-InstalledPSResource $testModuleName - $res1.Name | Should -Be $testModuleName - $res1.Version | Should -Be "3.0.0" + $res1 = Get-InstalledPSResource $testModuleName + $res1.Name | Should -Be $testModuleName + $res1.Version | Should -Be "3.0.0" - $res2 = Get-InstalledPSResource $testModuleName2 - $res2.Name | Should -Be $testModuleName2 - $res2.Version | Should -Be "5.0.0.0" + $res2 = Get-InstalledPSResource $testModuleName2 + $res2.Name | Should -Be $testModuleName2 + $res2.Version | Should -Be "5.0.0.0" - $res3 = Get-InstalledPSResource "testModule99" - $res3.Name | Should -Be "testModule99" - $res3.Version | Should -Be "0.0.93" + $res3 = Get-InstalledPSResource "testModule99" + $res3.Name | Should -Be "testModule99" + $res3.Version | Should -Be "0.0.93" } It "Install modules using -RequiredResourceFile with PSD1 file" { @@ -388,15 +399,15 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'ManualValidatio It "Install resource under AllUsers scope - Unix only" -Skip:(Get-IsWindows) { Install-PSResource -Name $testModuleName -Repository $TestGalleryName -Scope AllUsers $pkg = Get-Module $testModuleName -ListAvailable - $pkg.Name | Should -Be $testModuleName + $pkg.Name | Should -Be $testModuleName $pkg.Path.Contains("/usr/") | Should -Be $true } # This needs to be manually tested due to prompt It "Install resource that requires accept license without -AcceptLicense flag" { Install-PSResource -Name $testModuleName2 -Repository $TestGalleryName - $pkg = Get-InstalledPSResource $testModuleName2 - $pkg.Name | Should -Be $testModuleName2 + $pkg = Get-InstalledPSResource $testModuleName2 + $pkg.Name | Should -Be $testModuleName2 $pkg.Version | Should -Be "2.0.0" } @@ -404,8 +415,8 @@ Describe 'Test Install-PSResource for V3Server scenarios' -tags 'ManualValidatio It "Install resource should prompt 'trust repository' if repository is not trusted" { Set-PSResourceRepository PoshTestGallery -Trusted:$false - Install-PSResource -Name $testModuleName -Repository $TestGalleryName -confirm:$false - + Install-PSResource -Name $testModuleName -Repository $TestGalleryName -Confirm:$false + $pkg = Get-Module $testModuleName -ListAvailable $pkg.Name | Should -Be $testModuleName diff --git a/test/PSGetTestUtils.psm1 b/test/PSGetTestUtils.psm1 index 1d5c51ed6..6a384c17c 100644 --- a/test/PSGetTestUtils.psm1 +++ b/test/PSGetTestUtils.psm1 @@ -273,6 +273,26 @@ function Register-LocalRepos { Write-Verbose "registered psgettestlocal, psgettestlocal2, psgettestlocal3, psgettestlocal4" } +function Register-LocalTestNupkgsRepo { + # Path to folder, within our test folder, where we store special case modules, scripts and nupkgs used for testing + $testDir = (get-item $psscriptroot).FullName + $testFilesFolderPath = Join-Path $testDir -ChildPath "testFiles" + + # Path to specifically to that invalid test nupkgs folder + $testNupkgsFolderPath = Join-Path $testFilesFolderPath -ChildPath "testNupkgs" + Write-Verbose -Verbose "testNupkgsFolderPath: $testNupkgsFolderPath" + + $repoUriAddress = $testNupkgsFolderPath + $localRepoParams = @{ + Name = "localNupkgRepo" + Uri = $repoUriAddress + Priority = 70 + Trusted = $false + } + + Register-PSResourceRepository @localRepoParams +} + function Register-PSGallery { $PSGalleryRepoParams = @{ Name = $script:PSGalleryName @@ -747,3 +767,20 @@ function CheckForExpectedPSGetInfo $psGetInfo.UpdatedDate.Year | Should -BeExactly 1 $psGetInfo.Version | Should -Be "1.1.0" } + +function Set-TestACRRepositories +{ + Param( + [string[]] + $repositoryNames + ) + + $acrRepositoryNamesFolder = Join-Path -Path ([Environment]::GetFolderPath([System.Environment+SpecialFolder]::LocalApplicationData)) -ChildPath 'TempModules' + $acrRepositoryNamesFilePath = Join-Path -Path $acrRepositoryNamesFolder -ChildPath 'ACRTestRepositoryNames.txt' + $fileExists = Test-Path -Path $acrRepositoryNamesFilePath + + if ($fileExists) + { + $repositoryNames | Out-File -FilePath $acrRepositoryNamesFilePath + } +} diff --git a/test/PSScriptFileInfoTests/GetPSScriptFileInfo.Tests.ps1 b/test/PSScriptFileInfoTests/GetPSScriptFileInfo.Tests.ps1 index b521ba470..9e46e5000 100644 --- a/test/PSScriptFileInfoTests/GetPSScriptFileInfo.Tests.ps1 +++ b/test/PSScriptFileInfoTests/GetPSScriptFileInfo.Tests.ps1 @@ -3,11 +3,6 @@ $modPath = "$psscriptroot/../PSGetTestUtils.psm1" Import-Module $modPath -Force -Verbose -# Explicitly import build module because in CI PowerShell can autoload PSGetv2 -# This ensures the build module is always being tested -$buildModule = "$psscriptroot/../../out/PSResourceGet" -Import-Module $buildModule -Force -Verbose - $testDir = (get-item $psscriptroot).parent.FullName Describe "Test Get-PSScriptFileInfo" -tags 'CI' { diff --git a/test/PSScriptFileInfoTests/TestPSScriptFile.Tests.ps1 b/test/PSScriptFileInfoTests/TestPSScriptFile.Tests.ps1 index b667e317e..47da1dfea 100644 --- a/test/PSScriptFileInfoTests/TestPSScriptFile.Tests.ps1 +++ b/test/PSScriptFileInfoTests/TestPSScriptFile.Tests.ps1 @@ -88,4 +88,11 @@ Describe "Test Test-PSScriptFileInfo" -tags 'CI' { Test-PSScriptFileInfo $scriptFilePath | Should -Be $true } + + It "determine script with whitespace before closing comment is valid" { + $scriptName = "ScriptWithWhitespaceBeforeClosingComment.ps1" + $scriptFilePath = Join-Path $script:testScriptsFolderPath -ChildPath $scriptName + + Test-PSScriptFileInfo $scriptFilePath | Should -Be $true + } } diff --git a/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 b/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 new file mode 100644 index 000000000..28f74a742 --- /dev/null +++ b/test/PublishPSResourceTests/CompressPSResource.Tests.ps1 @@ -0,0 +1,422 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +$modPath = "$psscriptroot/../PSGetTestUtils.psm1" +Import-Module $modPath -Force -Verbose + +$testDir = (get-item $psscriptroot).parent.FullName + +function CreateTestModule +{ + param ( + [string] $Path = "$TestDrive", + [string] $ModuleName = 'TestModule' + ) + + $modulePath = Join-Path -Path $Path -ChildPath $ModuleName + $moduleMan = Join-Path $modulePath -ChildPath ($ModuleName + '.psd1') + $moduleSrc = Join-Path $modulePath -ChildPath ($ModuleName + '.psm1') + + if ( Test-Path -Path $modulePath) { + Remove-Item -Path $modulePath -Recurse -Force + } + + $null = New-Item -Path $modulePath -ItemType Directory -Force + + @' + @{{ + RootModule = "{0}.psm1" + ModuleVersion = '1.0.0' + Author = 'None' + Description = 'None' + GUID = '0c2829fc-b165-4d72-9038-ae3a71a755c1' + FunctionsToExport = @('Test1') + RequiredModules = @('NonExistentModule') + }} +'@ -f $ModuleName | Out-File -FilePath $moduleMan + + @' + function Test1 { + Write-Output 'Hello from Test1' + } +'@ | Out-File -FilePath $moduleSrc +} + +function CompressExpandRetrieveNuspec +{ + param( + [string]$PublishModuleBase, + [string]$PublishModuleName, + [string]$ModuleVersion, + [string]$RepositoryPath, + [string]$ModuleBasePath, + [string]$TestDrive, + [object[]]$RequiredModules, + [switch]$SkipModuleManifestValidate + ) + + $testFile = Join-Path -Path "TestSubDirectory" -ChildPath "TestSubDirFile.ps1" + $null = New-ModuleManifest -Path (Join-Path -Path $PublishModuleBase -ChildPath "$PublishModuleName.psd1") -ModuleVersion $version -Description "$PublishModuleName module" -RequiredModules $RequiredModules + $null = New-Item -Path (Join-Path -Path $PublishModuleBase -ChildPath $testFile) -Force + + $null = Compress-PSResource -Path $PublishModuleBase -DestinationPath $repositoryPath -SkipModuleManifestValidate:$SkipModuleManifestValidate + + # Must change .nupkg to .zip so that Expand-Archive can work on Windows PowerShell + $nupkgPath = Join-Path -Path $RepositoryPath -ChildPath "$PublishModuleName.$version.nupkg" + $zipPath = Join-Path -Path $RepositoryPath -ChildPath "$PublishModuleName.$version.zip" + Rename-Item -Path $nupkgPath -NewName $zipPath + $unzippedPath = Join-Path -Path $TestDrive -ChildPath "$PublishModuleName" + $null = New-Item $unzippedPath -Itemtype directory -Force + $null = Expand-Archive -Path $zipPath -DestinationPath $unzippedPath + + $nuspecPath = Join-Path -Path $unzippedPath -ChildPath "$PublishModuleName.nuspec" + $nuspecxml = [xml](Get-Content $nuspecPath) + $null = Remove-Item $unzippedPath -Force -Recurse + return $nuspecxml +} + +Describe "Test Compress-PSResource" -tags 'CI' { + BeforeAll { + Get-NewPSResourceRepositoryFile + + # Register temporary repositories + $tmpRepoPath = Join-Path -Path $TestDrive -ChildPath "tmpRepoPath" + New-Item $tmpRepoPath -Itemtype directory -Force + $testRepository = "testRepository" + Register-PSResourceRepository -Name $testRepository -Uri $tmpRepoPath -Priority 1 -ErrorAction SilentlyContinue + $script:repositoryPath = [IO.Path]::GetFullPath((get-psresourcerepository "testRepository").Uri.AbsolutePath) + + $tmpRepoPath2 = Join-Path -Path $TestDrive -ChildPath "tmpRepoPath2" + New-Item $tmpRepoPath2 -Itemtype directory -Force + $testRepository2 = "testRepository2" + Register-PSResourceRepository -Name $testRepository2 -Uri $tmpRepoPath2 -ErrorAction SilentlyContinue + $script:repositoryPath2 = [IO.Path]::GetFullPath((get-psresourcerepository "testRepository2").Uri.AbsolutePath) + + # Create module + $script:tmpModulesPath = Join-Path -Path $TestDrive -ChildPath "tmpModulesPath" + $script:PublishModuleName = "PSGetTestModule" + $script:PublishModuleBase = Join-Path $script:tmpModulesPath -ChildPath $script:PublishModuleName + if(!(Test-Path $script:PublishModuleBase)) + { + New-Item -Path $script:PublishModuleBase -ItemType Directory -Force + } + $script:PublishModuleBaseUNC = $script:PublishModuleBase -Replace '^(.):', '\\localhost\$1$' + + #Create dependency module + $script:DependencyModuleName = "PackageManagement" + $script:DependencyModuleBase = Join-Path $script:tmpModulesPath -ChildPath $script:DependencyModuleName + if(!(Test-Path $script:DependencyModuleBase)) + { + New-Item -Path $script:DependencyModuleBase -ItemType Directory -Force + } + + # Create temp destination path + $script:destinationPath = [IO.Path]::GetFullPath((Join-Path -Path $TestDrive -ChildPath "tmpDestinationPath")) + New-Item $script:destinationPath -ItemType directory -Force + + #Create folder where we shall place all script files to be published for these tests + $script:tmpScriptsFolderPath = Join-Path -Path $TestDrive -ChildPath "tmpScriptsPath" + if(!(Test-Path $script:tmpScriptsFolderPath)) + { + New-Item -Path $script:tmpScriptsFolderPath -ItemType Directory -Force + } + + # Path to folder, within our test folder, where we store invalid module and script files used for testing + $script:testFilesFolderPath = Join-Path $testDir -ChildPath "testFiles" + + # Path to specifically to that invalid test modules folder + $script:testModulesFolderPath = Join-Path $script:testFilesFolderPath -ChildPath "testModules" + + # Path to specifically to that invalid test scripts folder + $script:testScriptsFolderPath = Join-Path $script:testFilesFolderPath -ChildPath "testScripts" + + # Create test module with missing required module + CreateTestModule -Path $TestDrive -ModuleName 'ModuleWithMissingRequiredModule' + } + AfterAll { + Get-RevertPSResourceRepositoryFile + } + AfterEach { + # Delete all contents of the repository without deleting the repository directory itself + $pkgsToDelete = Join-Path -Path "$script:repositoryPath" -ChildPath "*" + Remove-Item $pkgsToDelete -Recurse + + $pkgsToDelete = Join-Path -Path "$script:repositoryPath2" -ChildPath "*" + Remove-Item $pkgsToDelete -Recurse + + $pkgsToDelete = Join-Path -Path $script:PublishModuleBase -ChildPath "*" + Remove-Item $pkgsToDelete -Recurse -ErrorAction SilentlyContinue + } + + It "Compress-PSResource compresses a module into a nupkg and saves it to the DestinationPath" { + $version = "1.0.0" + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + + Compress-PSResource -Path $script:PublishModuleBase -DestinationPath $script:repositoryPath + + $expectedPath = Join-Path -Path $script:repositoryPath -ChildPath "$script:PublishModuleName.$version.nupkg" + (Get-ChildItem $script:repositoryPath).FullName | Should -Be $expectedPath + } + + It "Compress a module using -Path positional parameter and -Destination positional parameter" { + $version = "1.0.0" + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + + Compress-PSResource $script:PublishModuleBase $script:repositoryPath + + $expectedPath = Join-Path -Path $script:repositoryPath -ChildPath "$script:PublishModuleName.$version.nupkg" + (Get-ChildItem $script:repositoryPath).FullName | Should -Be $expectedPath + } + + It "Compress-PSResource compresses a module and preserves file structure" { + $version = "1.0.0" + $testFile = Join-Path -Path "TestSubDirectory" -ChildPath "TestSubDirFile.ps1" + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + New-Item -Path (Join-Path -Path $script:PublishModuleBase -ChildPath $testFile) -Force + + Compress-PSResource -Path $script:PublishModuleBase -DestinationPath $script:repositoryPath + + # Must change .nupkg to .zip so that Expand-Archive can work on Windows PowerShell + $nupkgPath = Join-Path -Path $script:repositoryPath -ChildPath "$script:PublishModuleName.$version.nupkg" + $zipPath = Join-Path -Path $script:repositoryPath -ChildPath "$script:PublishModuleName.$version.zip" + Rename-Item -Path $nupkgPath -NewName $zipPath + $unzippedPath = Join-Path -Path $TestDrive -ChildPath "$script:PublishModuleName" + New-Item $unzippedPath -Itemtype directory -Force + Expand-Archive -Path $zipPath -DestinationPath $unzippedPath + + Test-Path -Path (Join-Path -Path $unzippedPath -ChildPath $testFile) | Should -Be $True + $null = Remove-Item $unzippedPath -Force -Recurse + } + + It "Compresses a script" { + $scriptName = "PSGetTestScript" + $scriptVersion = "1.0.0" + + $params = @{ + Version = $scriptVersion + GUID = [guid]::NewGuid() + Author = 'Jane' + CompanyName = 'Microsoft Corporation' + Copyright = '(c) 2020 Microsoft Corporation. All rights reserved.' + Description = "Description for the $scriptName script" + LicenseUri = "https://$scriptName.com/license" + IconUri = "https://$scriptName.com/icon" + ProjectUri = "https://$scriptName.com" + Tags = @('Tag1','Tag2', "Tag-$scriptName-$scriptVersion") + ReleaseNotes = "$scriptName release notes" + } + + $scriptPath = (Join-Path -Path $script:tmpScriptsFolderPath -ChildPath "$scriptName.ps1") + New-PSScriptFileInfo @params -Path $scriptPath + + Compress-PSResource -Path $scriptPath -DestinationPath $script:repositoryPath + + $expectedPath = Join-Path -Path $script:repositoryPath -ChildPath "$scriptName.$scriptVersion.nupkg" + (Get-ChildItem $script:repositoryPath).FullName | Should -Be $expectedPath + } + + It "Compress-PSResource -DestinationPath works for relative paths" { + $version = "1.0.0" + $relativePath = ".\RelativeTestModule" + $relativeDestination = ".\RelativeDestination" + + # Create relative paths + New-Item -Path $relativePath -ItemType Directory -Force + New-Item -Path $relativeDestination -ItemType Directory -Force + + # Create module manifest in the relative path + New-ModuleManifest -Path (Join-Path -Path $relativePath -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + + # Compress using relative paths + Compress-PSResource -Path $relativePath -DestinationPath $relativeDestination + + $expectedPath = Join-Path -Path $relativeDestination -ChildPath "$script:PublishModuleName.$version.nupkg" + $fileExists = Test-Path -Path $expectedPath + $fileExists | Should -Be $True + + # Cleanup + Remove-Item -Path $relativePath -Recurse -Force + Remove-Item -Path $relativeDestination -Recurse -Force + } + + It "Compress-PSResource -PassThru returns a FileInfo object with the correct path" { + $version = "1.0.0" + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + + $fileInfoObject = Compress-PSResource -Path $script:PublishModuleBase -DestinationPath $script:repositoryPath -PassThru + + $expectedPath = Join-Path -Path $script:repositoryPath -ChildPath "$script:PublishModuleName.$version.nupkg" + $fileInfoObject | Should -BeOfType 'System.IO.FileSystemInfo' + $fileInfoObject.FullName | Should -Be $expectedPath + $fileInfoObject.Extension | Should -Be '.nupkg' + $fileInfoObject.Name | Should -Be "$script:PublishModuleName.$version.nupkg" + } + + It "Compress-PSResource creates nuspec dependecy version range when RequiredVersion is in RequiredModules section" { + $version = "1.0.0" + $requiredModules = @( + @{ + 'ModuleName' = 'PSGetTestRequiredModule' + 'GUID' = (New-Guid).Guid + 'RequiredVersion' = '2.0.0' + } + ) + $compressParams = @{ + 'PublishModuleBase' = $script:PublishModuleBase + 'PublishModuleName' = $script:PublishModuleName + 'ModuleVersion' = $version + 'RepositoryPath' = $script:repositoryPath + 'TestDrive' = $TestDrive + 'RequiredModules' = $requiredModules + 'SkipModuleManifestValidate' = $true + } + $nuspecxml = CompressExpandRetrieveNuspec @compressParams + # removing spaces as the nuget packaging is formatting the version range and adding spaces even when the original nuspec file doesn't have spaces. + # e.g (,2.0.0] is being formatted to (, 2.0.0] + $nuspecxml.package.metadata.dependencies.dependency.version.replace(' ', '') | Should -BeExactly '[2.0.0]' + } + + It "Compress-PSResource creates nuspec dependecy version range when ModuleVersion is in RequiredModules section" { + $version = "1.0.0" + $requiredModules = @( + @{ + 'ModuleName' = 'PSGetTestRequiredModule' + 'GUID' = (New-Guid).Guid + 'ModuleVersion' = '2.0.0' + } + ) + $compressParams = @{ + 'PublishModuleBase' = $script:PublishModuleBase + 'PublishModuleName' = $script:PublishModuleName + 'ModuleVersion' = $version + 'RepositoryPath' = $script:repositoryPath + 'TestDrive' = $TestDrive + 'RequiredModules' = $requiredModules + 'SkipModuleManifestValidate' = $true + } + $nuspecxml = CompressExpandRetrieveNuspec @compressParams + $nuspecxml.package.metadata.dependencies.dependency.version.replace(' ', '') | Should -BeExactly '2.0.0' + } + + It "Compress-PSResource creates nuspec dependecy version range when MaximumVersion is in RequiredModules section" { + $version = "1.0.0" + $requiredModules = @( + @{ + 'ModuleName' = 'PSGetTestRequiredModule' + 'GUID' = (New-Guid).Guid + 'MaximumVersion' = '2.0.0' + } + ) + $compressParams = @{ + 'PublishModuleBase' = $script:PublishModuleBase + 'PublishModuleName' = $script:PublishModuleName + 'ModuleVersion' = $version + 'RepositoryPath' = $script:repositoryPath + 'TestDrive' = $TestDrive + 'RequiredModules' = $requiredModules + 'SkipModuleManifestValidate' = $true + } + $nuspecxml = CompressExpandRetrieveNuspec @compressParams + $nuspecxml.package.metadata.dependencies.dependency.version.replace(' ', '') | Should -BeExactly '(,2.0.0]' + } + + It "Compress-PSResource creates nuspec dependecy version range when ModuleVersion and MaximumVersion are in RequiredModules section" { + $version = "1.0.0" + $requiredModules = @( + @{ + 'ModuleName' = 'PSGetTestRequiredModule' + 'GUID' = (New-Guid).Guid + 'ModuleVersion' = '1.0.0' + 'MaximumVersion' = '2.0.0' + } + ) + $compressParams = @{ + 'PublishModuleBase' = $script:PublishModuleBase + 'PublishModuleName' = $script:PublishModuleName + 'ModuleVersion' = $version + 'RepositoryPath' = $script:repositoryPath + 'TestDrive' = $TestDrive + 'RequiredModules' = $requiredModules + 'SkipModuleManifestValidate' = $true + } + $nuspecxml = CompressExpandRetrieveNuspec @compressParams + $nuspecxml.package.metadata.dependencies.dependency.version.replace(' ', '') | Should -BeExactly '[1.0.0,2.0.0]' + } + + It "Compress-PSResource creates nuspec dependecy version range when there are multiple modules in RequiredModules section" { + $version = "1.0.0" + $requiredModules = @( + @{ + 'ModuleName' = 'PSGetTestRequiredModuleRequiredVersion' + 'GUID' = (New-Guid).Guid + 'RequiredVersion' = '1.0.0' + }, + @{ + 'ModuleName' = 'PSGetTestRequiredModuleModuleVersion' + 'GUID' = (New-Guid).Guid + 'ModuleVersion' = '2.0.0' + }, + @{ + 'ModuleName' = 'PSGetTestRequiredModuleMaximumVersion' + 'GUID' = (New-Guid).Guid + 'MaximumVersion' = '3.0.0' + }, + @{ + 'ModuleName' = 'PSGetTestRequiredModuleModuleAndMaximumVersion' + 'GUID' = (New-Guid).Guid + 'ModuleVersion' = '4.0.0' + 'MaximumVersion' = '5.0.0' + } + ) + $compressParams = @{ + 'PublishModuleBase' = $script:PublishModuleBase + 'PublishModuleName' = $script:PublishModuleName + 'ModuleVersion' = $version + 'RepositoryPath' = $script:repositoryPath + 'TestDrive' = $TestDrive + 'RequiredModules' = $requiredModules + 'SkipModuleManifestValidate' = $true + } + $nuspecxml = CompressExpandRetrieveNuspec @compressParams + foreach ($dependency in $nuspecxml.package.metadata.dependencies.dependency) { + switch ($dependency.id) { + "PSGetTestRequiredModuleRequiredVersion" { + $dependency.version.replace(' ', '') | Should -BeExactly '[1.0.0]' + } + "PSGetTestRequiredModuleModuleVersion" { + $dependency.version.replace(' ', '') | Should -BeExactly '2.0.0' + } + "PSGetTestRequiredModuleMaximumVersion" { + $dependency.version.replace(' ', '') | Should -BeExactly '(,3.0.0]' + } + "PSGetTestRequiredModuleModuleAndMaximumVersion" { + $dependency.version.replace(' ', '') | Should -BeExactly '[4.0.0,5.0.0]' + } + } + } + } + +<# Test for Signing the nupkg. Signing doesn't work + It "Compressed Module is able to be signed with a certificate" { + $version = "1.0.0" + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + + Compress-PSResource -Path $script:PublishModuleBase -DestinationPath $script:repositoryPath2 + + $expectedPath = Join-Path -Path $script:repositoryPath2 -ChildPath "$script:PublishModuleName.$version.nupkg" + (Get-ChildItem $script:repositoryPath2).FullName | Should -Be $expectedPath + + # create test cert + # Create a self-signed certificate for code signing + $testCert = New-SelfSignedCertificate -Subject "CN=NuGet Test Developer, OU=Use for testing purposes ONLY" -FriendlyName "NuGetTestDeveloper" -Type CodeSigning -KeyUsage DigitalSignature -KeyLength 2048 -KeyAlgorithm RSA -HashAlgorithm SHA256 -Provider "Microsoft Enhanced RSA and AES Cryptographic Provider" -CertStoreLocation "Cert:\CurrentUser\My" + + # sign the nupkg + $nupkgPath = Join-Path -Path $script:repositoryPath2 -ChildPath "$script:PublishModuleName.$version.nupkg" + Set-AuthenticodeSignature -FilePath $nupkgPath -Certificate $testCert + + # Verify the file was signed + $signature = Get-AuthenticodeSignature -FilePath $nupkgPath + $signature.Status | Should -Be 'Valid' + } + #> +} diff --git a/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 index 35ba14881..84e941dfa 100644 --- a/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 +++ b/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 @@ -270,16 +270,20 @@ Describe "Test Publish-PSResource" -tags 'CI' { $dependencyVersion = "2.0.0" New-ModuleManifest -Path (Join-Path -Path $script:DependencyModuleBase -ChildPath "$script:DependencyModuleName.psd1") -ModuleVersion $dependencyVersion -Description "$script:DependencyModuleName module" - Publish-PSResource -Path $script:DependencyModuleBase + Publish-PSResource -Path $script:DependencyModuleBase -Repository $testRepository2 + $pkg1 = Find-PSResource $script:DependencyModuleName -Repository $testRepository2 + $pkg1 | Should -Not -BeNullOrEmpty + $pkg1.Version | Should -Be $dependencyVersion # Create module to test $version = "1.0.0" New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" -RequiredModules @(@{ModuleName = 'PackageManagement'; ModuleVersion = '2.0.0' }) - Publish-PSResource -Path $script:PublishModuleBase + Publish-PSResource -Path $script:PublishModuleBase -Repository $testRepository2 - $nupkg = Get-ChildItem $script:repositoryPath | select-object -Last 1 - $nupkg.Name | Should -Be "$script:PublishModuleName.$version.nupkg" + $pkg2 = Find-PSResource $script:DependencyModuleName -Repository $testRepository2 + $pkg2 | Should -Not -BeNullOrEmpty + $pkg2.Version | Should -Be $dependencyVersion } It "Publish a module with a dependency that is not published, should throw" { @@ -290,7 +294,6 @@ Describe "Test Publish-PSResource" -tags 'CI' { {Publish-PSResource -Path $script:PublishModuleBase -ErrorAction Stop} | Should -Throw -ErrorId "DependencyNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" } - It "Publish a module with -SkipDependenciesCheck" { $version = "1.0.0" $dependencyVersion = "2.0.0" @@ -321,8 +324,23 @@ Describe "Test Publish-PSResource" -tags 'CI' { Test-Path -Path (Join-Path -Path $unzippedPath -ChildPath $testFile) | Should -Be $True } + It "Publish a module with -NupkgPath" { + $version = "1.0.0" + # Make a nupkg + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + Compress-PSResource -Path $script:PublishModuleBase -DestinationPath $script:destinationPath + $expectedPath = Join-Path -Path $script:destinationPath -ChildPath "$script:PublishModuleName.$version.nupkg" + (Get-ChildItem $script:destinationPath).FullName | Should -Be $expectedPath + + # Pass the nupkg via -NupkgPath + Publish-PSResource -NupkgPath $expectedPath -Repository $testRepository2 + $expectedPath = Join-Path -Path $script:repositoryPath2 -ChildPath "$script:PublishModuleName.$version.nupkg" + (Get-ChildItem $script:repositoryPath2).FullName | Should -Be $expectedPath + } + <# The following tests are related to passing in parameters to customize a nuspec. # These parameters are not going in the current release, but is open for discussion to include in the future. + It "Publish a module with -Nuspec" { $version = "1.0.0" New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" -NestedModules "$script:PublishModuleName.psm1" @@ -433,8 +451,8 @@ Describe "Test Publish-PSResource" -tags 'CI' { It "Publish a module to PSGallery using incorrect API key, should throw" { $version = "1.0.0" New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" - - Publish-PSResource -Path $script:PublishModuleBase -Repository PSGallery -APIKey "123456789" -ErrorAction SilentlyContinue + $APIKey = New-Guid + Publish-PSResource -Path $script:PublishModuleBase -Repository PSGallery -APIKey $APIKey -ErrorAction SilentlyContinue $Error[0].FullyQualifiedErrorId | Should -be "403Error,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" } @@ -671,7 +689,7 @@ Describe "Test Publish-PSResource" -tags 'CI' { $expectedPath = Join-Path -Path $script:repositoryPath2 -ChildPath "$ParentModuleName.$ParentVersion.nupkg" (Get-ChildItem $script:repositoryPath2).FullName | Should -Contain $expectedPath } - +<# It "Publish a module with required modules (both in string format and hashtable format)" { # look at functions in test utils for creating a module with prerelease $ModuleName = "ParentModule" @@ -706,4 +724,5 @@ Describe "Test Publish-PSResource" -tags 'CI' { $expectedPath = Join-Path -Path $script:repositoryPath2 -ChildPath "$ModuleName.$ModuleVersion.nupkg" (Get-ChildItem $script:repositoryPath2).FullName | Should -Contain $expectedPath } +#> } diff --git a/test/PublishPSResourceTests/PublishPSResourceADOServer.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResourceADOServer.Tests.ps1 index 10e6b2f23..e5984b7f3 100644 --- a/test/PublishPSResourceTests/PublishPSResourceADOServer.Tests.ps1 +++ b/test/PublishPSResourceTests/PublishPSResourceADOServer.Tests.ps1 @@ -48,7 +48,7 @@ Describe "Test Publish-PSResource" -tags 'CI' { $testModuleName = "test_local_mod" $ADOPublicRepoName = "PSGetTestingPublicFeed" - $ADOPublicRepoUri = "https://pkgs.dev.azure.com/powershell/PowerShell/_packaging/powershell-public-test/nuget/v3/index.json" + $ADOPublicRepoUri = "https://pkgs.dev.azure.com/powershell/PowerShell/_packaging/psresourceget-public-test-ci/nuget/v3/index.json" Register-PSResourceRepository -Name $ADOPublicRepoName -Uri $ADOPublicRepoUri $ADOPrivateRepoName = "PSGetTestFeedWithPrivateAccess" @@ -106,6 +106,6 @@ Describe "Test Publish-PSResource" -tags 'CI' { Publish-PSResource -Path $script:PublishModuleBase -Repository $ADOPrivateRepoName -Credential $incorrectRepoCred -ErrorAction SilentlyContinue - $Error[0].FullyQualifiedErrorId | Should -be "401FatalProtocolError,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" + $Error[0].FullyQualifiedErrorId | Should -be ("401FatalProtocolError,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" -or "ProtocolFailError,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource") } } diff --git a/test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 new file mode 100644 index 000000000..af57385a1 --- /dev/null +++ b/test/PublishPSResourceTests/PublishPSResourceContainerRegistryServer.Tests.ps1 @@ -0,0 +1,574 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +$modPath = "$psscriptroot/../PSGetTestUtils.psm1" +Import-Module $modPath -Force -Verbose + +function CreateTestModule +{ + param ( + [string] $Path = "$TestDrive", + [string] $ModuleName = 'temp-testmodule' + ) + + $modulePath = Join-Path -Path $Path -ChildPath $ModuleName + $moduleMan = Join-Path $modulePath -ChildPath ($ModuleName + '.psd1') + $moduleSrc = Join-Path $modulePath -ChildPath ($ModuleName + '.psm1') + + if ( Test-Path -Path $modulePath) { + Remove-Item -Path $modulePath -Recurse -Force + } + + $null = New-Item -Path $modulePath -ItemType Directory -Force + + @' + @{{ + RootModule = "{0}.psm1" + ModuleVersion = '1.0.0' + Author = 'None' + Description = 'None' + GUID = '0c2829fc-b165-4d72-9038-ae3a71a755c1' + FunctionsToExport = @('Test1') + RequiredModules = @('NonExistentModule') + }} +'@ -f $ModuleName | Out-File -FilePath $moduleMan + + @' + function Test1 { + Write-Output 'Hello from Test1' + } +'@ | Out-File -FilePath $moduleSrc +} + +Describe "Test Publish-PSResource" -tags 'CI' { + BeforeAll { + $script:testDir = (get-item $psscriptroot).parent.FullName + Get-NewPSResourceRepositoryFile + + # Register repositories + $ACRRepoName = "ACRRepo" + $ACRRepoUri = "https://psresourcegettest.azurecr.io" + + $usingAzAuth = $env:USINGAZAUTH -eq 'true' + + if ($usingAzAuth) + { + Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'ContainerRegistry' -Uri $ACRRepoUri -Verbose + } + else + { + $psCredInfo = New-Object Microsoft.PowerShell.PSResourceGet.UtilClasses.PSCredentialInfo ("SecretStore", "$env:TENANTID") + Register-PSResourceRepository -Name $ACRRepoName -ApiVersion 'ContainerRegistry' -Uri $ACRRepoUri -CredentialInfo $psCredInfo -Verbose + } + + # Create module + $script:tmpModulesPath = Join-Path -Path $TestDrive -ChildPath "tmpModulesPath" + $script:PublishModuleName = "temp-testmodule" + [System.Guid]::NewGuid(); + $script:PublishModuleBase = Join-Path $script:tmpModulesPath -ChildPath $script:PublishModuleName + if(!(Test-Path $script:PublishModuleBase)) + { + New-Item -Path $script:PublishModuleBase -ItemType Directory -Force + } + $script:PublishModuleBaseUNC = $script:PublishModuleBase -Replace '^(.):', '\\localhost\$1$' + + # create names of other modules and scripts that will be referenced in test + $script:ModuleWithoutRequiredModuleName = "temp-testmodulewithoutrequiredmodule-" + [System.Guid]::NewGuid() + $script:ScriptName = "temp-testscript" + [System.Guid]::NewGuid() + $script:ScriptWithExternalDeps = "temp-testscriptwithexternaldeps" + [System.Guid]::NewGuid() + $script:ScriptWithoutEmptyLinesInMetadata = "temp-scriptwithoutemptylinesinmetadata" + [System.Guid]::NewGuid() + $script:ScriptWithoutEmptyLinesBetweenCommentBlocks = "temp-scriptwithoutemptylinesbetweencommentblocks" + [System.Guid]::NewGuid() + + # Create temp destination path + $script:destinationPath = [IO.Path]::GetFullPath((Join-Path -Path $TestDrive -ChildPath "tmpDestinationPath")) + $null = New-Item $script:destinationPath -ItemType directory -Force + + #Create folder where we shall place all script files to be published for these tests + $script:tmpScriptsFolderPath = Join-Path -Path $TestDrive -ChildPath "tmpScriptsPath" + if(!(Test-Path $script:tmpScriptsFolderPath)) + { + $null = New-Item -Path $script:tmpScriptsFolderPath -ItemType Directory -Force + } + + # Path to folder, within our test folder, where we store invalid module and script files used for testing + $script:testFilesFolderPath = Join-Path $script:testDir -ChildPath "testFiles" + + # Path to specifically to that invalid test modules folder + $script:testModulesFolderPath = Join-Path $script:testFilesFolderPath -ChildPath "testModules" + + # Path to specifically to that invalid test scripts folder + $script:testScriptsFolderPath = Join-Path $script:testFilesFolderPath -ChildPath "testScripts" + + # Path to specifically to that invalid test nupkgs folder + $script:testNupkgsFolderPath = Join-Path $script:testFilesFolderPath -ChildPath "testNupkgs" + } + AfterEach { + if(!(Test-Path $script:PublishModuleBase)) + { + Remove-Item -Path $script:PublishModuleBase -Recurse -Force + } + } + AfterAll { + Get-RevertPSResourceRepositoryFile + + # Note: all repository names provided as test packages for ACR, must have lower cased names, otherwise the Az cmdlets will not be able to properly find and delete it. + $acrRepositoryNames = @($script:PublishModuleName, $script:ModuleWithoutRequiredModuleName, $script:ScriptName, $script:ScriptWithExternalDeps, $script:ScriptWithoutEmptyLinesInMetadata, $script:ScriptWithoutEmptyLinesBetweenCommentBlocks) + Set-TestACRRepositories $acrRepositoryNames + } + + It "Publish module with required module not installed on the local machine using -SkipModuleManifestValidate" { + CreateTestModule -Path $TestDrive -ModuleName $script:ModuleWithoutRequiredModuleName + + # Skip the module manifest validation test, which fails from the missing manifest required module. + $testModulePath = Join-Path -Path $TestDrive -ChildPath $script:ModuleWithoutRequiredModuleName + Publish-PSResource -Path $testModulePath -Repository $ACRRepoName -Confirm:$false -SkipDependenciesCheck -SkipModuleManifestValidate + + $results = Find-PSResource -Name $script:ModuleWithoutRequiredModuleName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:ModuleWithoutRequiredModuleName + $results[0].Version | Should -Be "1.0.0" + } + + It "Publish a module with -Path pointing to a module directory (parent directory has same name)" { + $version = "1.0.0" + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + + Publish-PSResource -Path $script:PublishModuleBase -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + } + + It "Publish a module with -Path pointing to a module directory (parent directory has different name)" { + $version = "2.0.0" + $newModuleRoot = Join-Path -Path $script:PublishModuleBase -ChildPath "NewTestParentDirectory" + New-Item -Path $newModuleRoot -ItemType Directory -Force + New-ModuleManifest -Path (Join-Path -Path $newModuleRoot -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + + Publish-PSResource -Path $newModuleRoot -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + } + + It "Publish a module with -Path pointing to a .psd1 (parent directory has same name)" { + $version = "3.0.0" + $manifestPath = Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1" + New-ModuleManifest -Path $manifestPath -ModuleVersion $version -Description "$script:PublishModuleName module" + + Publish-PSResource -Path $manifestPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + } + + It "Publish a module with -Path pointing to a .psd1 (parent directory has different name)" { + $version = "4.0.0" + $newModuleRoot = Join-Path -Path $script:PublishModuleBase -ChildPath "NewTestParentDirectory" + New-Item -Path $newModuleRoot -ItemType Directory -Force + $manifestPath = Join-Path -Path $newModuleRoot -ChildPath "$script:PublishModuleName.psd1" + New-ModuleManifest -Path $manifestPath -ModuleVersion $version -Description "$script:PublishModuleName module" + + Publish-PSResource -Path $manifestPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + } + + It "Publish a module with -Path pointing to a module directory (parent directory has same name) on a network share" { + $version = "5.0.0" + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBaseUNC -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + + Publish-PSResource -Path $script:PublishModuleBaseUNC -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + } + + It "Publish a module with -Path pointing to a module directory (parent directory has different name) on a network share" { + $version = "6.0.0" + $newModuleRoot = Join-Path -Path $script:PublishModuleBaseUNC -ChildPath "NewTestParentDirectory" + New-Item -Path $newModuleRoot -ItemType Directory -Force + New-ModuleManifest -Path (Join-Path -Path $newModuleRoot -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + + Publish-PSResource -Path $newModuleRoot -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + } + + It "Publish a module with -Path pointing to a .psd1 (parent directory has same name) on a network share" { + $version = "7.0.0" + $manifestPath = Join-Path -Path $script:PublishModuleBaseUNC -ChildPath "$script:PublishModuleName.psd1" + New-ModuleManifest -Path $manifestPath -ModuleVersion $version -Description "$script:PublishModuleName module" + + Publish-PSResource -Path $manifestPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + } + + It "Publish a module with -Path pointing to a .psd1 (parent directory has different name) on a network share" { + $version = "8.0.0" + $newModuleRoot = Join-Path -Path $script:PublishModuleBaseUNC -ChildPath "NewTestParentDirectory" + New-Item -Path $newModuleRoot -ItemType Directory -Force + $manifestPath = Join-Path -Path $newModuleRoot -ChildPath "$script:PublishModuleName.psd1" + New-ModuleManifest -Path $manifestPath -ModuleVersion $version -Description "$script:PublishModuleName module" + + Publish-PSResource -Path $manifestPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + } + + It "Publish a module and preserve file structure" { + $version = "9.0.0" + $testFile = Join-Path -Path "TestSubDirectory" -ChildPath "TestSubDirFile.ps1" + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + New-Item -Path (Join-Path -Path $script:PublishModuleBase -ChildPath $testFile) -Force + + Publish-PSResource -Path $script:PublishModuleBase -Repository $ACRRepoName -ErrorAction Stop + + Save-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName -AsNupkg -Path $TestDrive -TrustRepository + # Must change .nupkg to .zip so that Expand-Archive can work on Windows PowerShell + $nupkgPath = Join-Path -Path $TestDrive -ChildPath "$script:PublishModuleName.$version.nupkg" + $zipPath = Join-Path -Path $TestDrive -ChildPath "$script:PublishModuleName.$version.zip" + Rename-Item -Path $nupkgPath -NewName $zipPath + $unzippedPath = Join-Path -Path $TestDrive -ChildPath "$script:PublishModuleName" + New-Item $unzippedPath -Itemtype directory -Force + Expand-Archive -Path $zipPath -DestinationPath $unzippedPath + + Test-Path -Path (Join-Path -Path $unzippedPath -ChildPath $testFile) | Should -Be $True + } + + It "Publish a module with -Path -Repository and -DestinationPath" { + $version = "10.0.0" + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + + Publish-PSResource -Path $script:PublishModuleBase -Repository $ACRRepoName -DestinationPath $script:destinationPath + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName -Version $version + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + + $expectedPath = Join-Path -Path $script:destinationPath -ChildPath "$script:PublishModuleName.$version.nupkg" + Test-Path $expectedPath | Should -Be $true + } + + It "Publish a module with one dependency" { + $version = "11.0.0" + $dependencyName = 'test_dependency_mod' + $dependencyVersion = '1.0.0' + + # New-ModuleManifest requires that the module be installed before it can be added as a dependency + Install-PSResource -Name $dependencyName -Version $dependencyVersion -Repository $ACRRepoName -TrustRepository + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" -RequiredModules @(@{ ModuleName = $dependencyName; ModuleVersion = $dependencyVersion }) + + Publish-PSResource -Path $script:PublishModuleBase -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName -Version $version + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + $results[0].Dependencies.Name | Should -Be $dependencyName + $results[0].Dependencies.VersionRange.MinVersion.ToString() | Should -Be $dependencyVersion + } + + It "Publish a module with multiple dependencies" { + $version = "12.0.0" + $dependency1Name = 'test_dependency_mod2' + $dependency2Name = 'test_dependency_mod' + $dependency2Version = '1.0.0' + + # New-ModuleManifest requires that the module be installed before it can be added as a dependency + Install-PSResource -Name $dependency1Name -Repository $ACRRepoName -TrustRepository -Verbose -Reinstall + Install-PSResource -Name $dependency2Name -Version $dependency2Version -Repository $ACRRepoName -TrustRepository -Reinstall + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" -RequiredModules @( $dependency1Name , @{ ModuleName = $dependency2Name; ModuleVersion = $dependency2Version }) + + Publish-PSResource -Path $script:PublishModuleBase -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName -Version $version + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + $results[0].Dependencies.Name | Should -Be $dependency1Name, $dependency2Name + $results[0].Dependencies.VersionRange.MinVersion.OriginalVersion.ToString() | Should -Be $dependency2Version + } + + It "Publish a module and clean up properly when file in module is readonly" { + $version = "13.0.0" + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + + # Create a readonly file that will throw access denied error if deletion is attempted + $file = Join-Path -Path $script:PublishModuleBase -ChildPath "inaccessiblefile.txt" + New-Item $file -Itemtype file -Force + Set-ItemProperty -Path $file -Name IsReadOnly -Value $true + + Publish-PSResource -Path $script:PublishModuleBase -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName -Version $version + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + } + + It "Publish a module when the .psd1 version and the path version are different" { + $incorrectVersion = "15.2.4" + $correctVersion = "14.0.0" + $versionBase = (Join-Path -Path $script:PublishModuleBase -ChildPath $incorrectVersion) + New-Item -Path $versionBase -ItemType Directory -Force + $modManifestPath = (Join-Path -Path $versionBase -ChildPath "$script:PublishModuleName.psd1") + New-ModuleManifest -Path $modManifestPath -ModuleVersion $correctVersion -Description "$script:PublishModuleName module" + + Publish-PSResource -Path $modManifestPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:PublishModuleName -Repository $ACRRepoName -Version $correctVersion + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $correctVersion + } + + It "Publish a script"{ + $scriptVersion = "1.0.0" + $params = @{ + Version = $scriptVersion + GUID = [guid]::NewGuid() + Author = 'Jane' + CompanyName = 'Microsoft Corporation' + Copyright = '(c) 2024 Microsoft Corporation. All rights reserved.' + Description = "Description for the $script:ScriptName script" + LicenseUri = "https://$script:ScriptName.com/license" + IconUri = "https://$script:ScriptName.com/icon" + ProjectUri = "https://$script:ScriptName.com" + Tags = @('Tag1','Tag2', "Tag-$script:ScriptName-$scriptVersion") + ReleaseNotes = "$script:ScriptName release notes" + } + + $scriptPath = (Join-Path -Path $script:tmpScriptsFolderPath -ChildPath "$script:ScriptName.ps1") + New-PSScriptFileInfo @params -Path $scriptPath + + Publish-PSResource -Path $scriptPath -Repository $ACRRepoName + + $result = Find-PSResource -Name $script:ScriptName -Repository $ACRRepoName + $result | Should -Not -BeNullOrEmpty + $result.Name | Should -Be $script:ScriptName + $result.Version | Should -Be $scriptVersion + } + + It "Should publish a script without lines in between comment blocks locally" { + $scriptName = "ScriptWithoutEmptyLinesBetweenCommentBlocks" + $scriptVersion = "1.0" + $scriptSrcPath = Join-Path -Path $script:testScriptsFolderPath -ChildPath "$scriptName.ps1" + $scriptDestPath = Join-Path -Path $script:tmpScriptsFolderPath -ChildPath "$script:ScriptWithoutEmptyLinesBetweenCommentBlocks.ps1" + Copy-Item -Path $scriptSrcPath -Destination $scriptDestPath + + Publish-PSResource -Path $scriptDestPath -Repository $ACRRepoName + + $result = Find-PSResource -Name $script:ScriptWithoutEmptyLinesBetweenCommentBlocks -Repository $ACRRepoName + $result | Should -Not -BeNullOrEmpty + $result.Name | Should -Be $script:ScriptWithoutEmptyLinesBetweenCommentBlocks + $result.Version | Should -Be $scriptVersion + } + + It "Should publish a script without lines in help block locally" { + $scriptName = "ScriptWithoutEmptyLinesInMetadata" + $scriptVersion = "1.0" + $scriptSrcPath = Join-Path -Path $script:testScriptsFolderPath -ChildPath "$scriptName.ps1" + $scriptDestPath = Join-Path -Path $script:tmpScriptsFolderPath -ChildPath "$script:ScriptWithoutEmptyLinesInMetadata.ps1" + Copy-Item -Path $scriptSrcPath -Destination $scriptDestPath + + Publish-PSResource -Path $scriptDestPath -Repository $ACRRepoName + + $result = Find-PSResource -Name $script:ScriptWithoutEmptyLinesInMetadata -Repository $ACRRepoName + $result | Should -Not -BeNullOrEmpty + $result.Name | Should -Be $script:ScriptWithoutEmptyLinesInMetadata + $result.Version | Should -Be $scriptVersion + } + + It "Should publish a script with ExternalModuleDependencies that are not published" { + $scriptVersion = "1.0.0" + $scriptPath = Join-Path -Path $script:tmpScriptsFolderPath -ChildPath "$script:ScriptWithExternalDeps.ps1" + New-PSScriptFileInfo -Description 'test' -Version $scriptVersion -RequiredModules @{ModuleName='testModule'} -ExternalModuleDependencies 'testModule' -Path $scriptPath -Force + + Publish-PSResource -Path $scriptPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $script:ScriptWithExternalDeps -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:ScriptWithExternalDeps + $results[0].Version | Should -Be $scriptVersion + } + + It "Should write error and not publish script when Author property is missing" { + $scriptName = "InvalidScriptMissingAuthor.ps1" + + $scriptFilePath = Join-Path $script:testScriptsFolderPath -ChildPath $scriptName + Publish-PSResource -Path $scriptFilePath -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "psScriptMissingAuthor,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" + + Find-PSResource -Name $scriptName -Repository $ACRRepoName -ErrorVariable findErr -ErrorAction SilentlyContinue + $findErr.Count | Should -BeGreaterThan 0 + $findErr[0].FullyQualifiedErrorId | Should -BeExactly "ResourceNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Should write error and not publish script when Version property is missing" { + $scriptName = "InvalidScriptMissingVersion.ps1" + + $scriptFilePath = Join-Path $script:testScriptsFolderPath -ChildPath $scriptName + Publish-PSResource -Path $scriptFilePath -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "psScriptMissingVersion,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" + + Find-PSResource -Name $scriptName -Repository $ACRRepoName -ErrorVariable findErr -ErrorAction SilentlyContinue + $findErr.Count | Should -BeGreaterThan 0 + $findErr[0].FullyQualifiedErrorId | Should -BeExactly "ResourceNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Should write error and not publish script when Guid property is missing" { + $scriptName = "InvalidScriptMissingGuid.ps1" + + $scriptFilePath = Join-Path $script:testScriptsFolderPath -ChildPath $scriptName + Publish-PSResource -Path $scriptFilePath -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "psScriptMissingGuid,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" + + Find-PSResource -Name $scriptName -Repository $ACRRepoName -ErrorVariable findErr -ErrorAction SilentlyContinue + $findErr.Count | Should -BeGreaterThan 0 + $findErr[0].FullyQualifiedErrorId | Should -BeExactly "ResourceNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Should write error and not publish script when Description property is missing" { + $scriptName = "InvalidScriptMissingDescription.ps1" + + $scriptFilePath = Join-Path $script:testScriptsFolderPath -ChildPath $scriptName + Publish-PSResource -Path $scriptFilePath -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "PSScriptInfoMissingDescription,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" + + Find-PSResource -Name $scriptName -Repository $ACRRepoName -ErrorVariable findErr -ErrorAction SilentlyContinue + $findErr.Count | Should -BeGreaterThan 0 + $findErr[0].FullyQualifiedErrorId | Should -BeExactly "ResourceNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Should write error and not publish script when Description block altogether is missing" { + # we expect .ps1 files to have a separate comment block for .DESCRIPTION property, not to be included in the PSScriptInfo commment block + $scriptName = "InvalidScriptMissingDescriptionCommentBlock.ps1" + + $scriptFilePath = Join-Path $script:testScriptsFolderPath -ChildPath $scriptName + Publish-PSResource -Path $scriptFilePath -Repository $ACRRepoName -ErrorVariable err -ErrorAction SilentlyContinue + $err.Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -BeExactly "missingHelpInfoCommentError,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" + + Find-PSResource -Name $scriptName -Repository $ACRRepoName -ErrorVariable findErr -ErrorAction SilentlyContinue + $findErr.Count | Should -BeGreaterThan 0 + $findErr[0].FullyQualifiedErrorId | Should -BeExactly "ResourceNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource" + } + + It "Publish a module with that has an invalid version format, should throw" { + $moduleName = "incorrectmoduleversion" + $incorrectmoduleversion = Join-Path -Path $script:testModulesFolderPath -ChildPath $moduleName + + { Publish-PSResource -Path $incorrectmoduleversion -Repository $ACRRepoName -ErrorAction Stop } | Should -Throw -ErrorId "InvalidModuleManifest,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" + } + + It "Publish a module with a dependency that has an invalid version format, should throw" { + $moduleName = "incorrectdepmoduleversion" + $incorrectdepmoduleversion = Join-Path -Path $script:testModulesFolderPath -ChildPath $moduleName + + { Publish-PSResource -Path $incorrectdepmoduleversion -Repository $ACRRepoName -ErrorAction Stop } | Should -Throw -ErrorId "InvalidModuleManifest,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" + } + + It "Publish a module with using an invalid file path (path to .psm1), should throw" { + $fileName = "$script:PublishModuleName.psm1" + $psm1Path = Join-Path -Path $script:PublishModuleBase -ChildPath $fileName + $null = New-Item -Path $psm1Path -ItemType File -Force + + {Publish-PSResource -Path $psm1Path -Repository $ACRRepoName -ErrorAction Stop} | Should -Throw -ErrorId "InvalidPublishPath,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" + } + + It "Publish a module with -ModulePrefix" { + $version = "1.0.0" + $modulePrefix = "unlisted" + New-ModuleManifest -Path (Join-Path -Path $script:PublishModuleBase -ChildPath "$script:PublishModuleName.psd1") -ModuleVersion $version -Description "$script:PublishModuleName module" + + Publish-PSResource -Path $script:PublishModuleBase -Repository $ACRRepoName -ModulePrefix $modulePrefix + + $results = Find-PSResource -Name "$modulePrefix/$script:PublishModuleName" -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $script:PublishModuleName + $results[0].Version | Should -Be $version + } + + It "Publish a package given NupkgPath to a package with .psd1" { + $packageName = "temp-testmodule-nupkgpath" + $version = "1.0.0.0" + $nupkgPath = Join-Path -Path $script:testNupkgsFolderPath -ChildPath "$packageName.1.0.0.nupkg" + Publish-PSResource -NupkgPath $nupkgPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $packageName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $packageName + $results[0].Version | Should -Be $version + } + + It "Publish a package given NupkgPath to a package with .ps1" { + $packageName = "temp-testscript-nupkgpath" + $version = "1.0.0.0" + $nupkgPath = Join-Path -Path $script:testNupkgsFolderPath -ChildPath "$packageName.1.0.0.nupkg" + Publish-PSResource -NupkgPath $nupkgPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $packageName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $packageName + $results[0].Version | Should -Be $version + } + + It "Publish a package given NupkgPath to a package with .nuspec" { + $packageName = "temp-testnupkg-nupkgpath" + $version = "1.0.0" + $nupkgPath = Join-Path -Path $script:testNupkgsFolderPath -ChildPath "$packageName.1.0.0.nupkg" + Publish-PSResource -NupkgPath $nupkgPath -Repository $ACRRepoName + + $results = Find-PSResource -Name $packageName -Repository $ACRRepoName + $results | Should -Not -BeNullOrEmpty + $results[0].Name | Should -Be $packageName + $results[0].Version | Should -Be $version + } +} + +Describe 'Test Publish-PSResource for MAR Repository' -tags 'CI' { + BeforeAll { + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook("MARPrefix", "azure-powershell/"); + Register-PSResourceRepository -Name "MAR" -Uri "https://mcr.microsoft.com" -ApiVersion "ContainerRegistry" + } + + AfterAll { + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook("MARPrefix", $null); + Unregister-PSResourceRepository -Name "MAR" + } + + It "Should find resource given specific Name, Version null" { + $fileName = "NonExistent.psd1" + $modulePath = New-Item -Path "$TestDrive\NonExistent" -ItemType Directory -Force + $psd1Path = Join-Path -Path $modulePath -ChildPath $fileName + New-ModuleManifest -Path $psd1Path -ModuleVersion "1.0.0" -Description "NonExistent module" + + { Publish-PSResource -Path $modulePath -Repository "MAR" -ErrorAction Stop } | Should -Throw -ErrorId "MARRepositoryPublishError,Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource" + } +} diff --git a/test/ResourceRepositoryTests/RegisterPSResourceRepository.Tests.ps1 b/test/ResourceRepositoryTests/RegisterPSResourceRepository.Tests.ps1 index c49db4e15..fcaa9dcda 100644 --- a/test/ResourceRepositoryTests/RegisterPSResourceRepository.Tests.ps1 +++ b/test/ResourceRepositoryTests/RegisterPSResourceRepository.Tests.ps1 @@ -403,4 +403,15 @@ Describe "Test Register-PSResourceRepository" -tags 'CI' { $res.Uri.LocalPath | Should -Contain $tmpDir1Path $res.ApiVersion | Should -Be 'v2' } + + It "should register container registry repository with correct ApiVersion" { + $ContainerRegistryName = "ACRRepo" + $ContainerRegistryUri = "https://psresourcegettest.azurecr.io/" + Register-PSResourceRepository -Name $ContainerRegistryName -Uri $ContainerRegistryUri + $res = Get-PSResourceRepository -Name $ContainerRegistryName + + $res.Name | Should -Be $ContainerRegistryName + $res.Uri.AbsoluteUri | Should -Contain $ContainerRegistryUri + $res.ApiVersion | Should -Be 'ContainerRegistry' + } } diff --git a/test/SavePSResourceTests/SavePSResourceLocalTests.ps1 b/test/SavePSResourceTests/SavePSResourceLocalTests.ps1 index ba94dc71e..36eb5330c 100644 --- a/test/SavePSResourceTests/SavePSResourceLocalTests.ps1 +++ b/test/SavePSResourceTests/SavePSResourceLocalTests.ps1 @@ -198,4 +198,10 @@ Describe 'Test Save-PSResource for local repositories' -tags 'CI' { $err.Count | Should -Not -BeNullOrEmpty $err[0].FullyQualifiedErrorId | Should -BeExactly "InstallPackageFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.SavePSResource" } + + It "Save module using -Quiet" { + $res = Save-PSResource -Name $moduleName -Version "1.0.0" -Repository $localRepo -Path $SaveDir -PassThru -TrustRepository -Quiet + $res.Name | Should -Be $moduleName + $res.Version | Should -Be "1.0.0" + } } diff --git a/test/SavePSResourceTests/SavePSResourceV2Tests.ps1 b/test/SavePSResourceTests/SavePSResourceV2Tests.ps1 index 4d15e5b3b..f00656b06 100644 --- a/test/SavePSResourceTests/SavePSResourceV2Tests.ps1 +++ b/test/SavePSResourceTests/SavePSResourceV2Tests.ps1 @@ -30,14 +30,14 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { It "Save specific module resource by name" { Save-PSResource -Name $testModuleName -Repository $PSGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty (Get-ChildItem $pkgDir.FullName) | Should -HaveCount 1 } It "Save specific script resource by name" { Save-PSResource -Name $testScriptName -Repository $PSGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq "test_script.ps1" + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ "test_script.ps1" $pkgDir | Should -Not -BeNullOrEmpty (Get-ChildItem $pkgDir.FullName) | Should -HaveCount 1 } @@ -53,7 +53,7 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { It "Should not save resource given nonexistant name" { Save-PSResource -Name NonExistentModule -Repository $PSGalleryName -Path $SaveDir -ErrorVariable err -ErrorAction SilentlyContinue -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq "NonExistentModule" + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ "NonExistentModule" $pkgDir.Name | Should -BeNullOrEmpty } @@ -65,7 +65,7 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { It "Should save resource given name and exact version" { Save-PSResource -Name $testModuleName -Version "1.0.0" -Repository $PSGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem $pkgDir.FullName $pkgDirVersion.Name | Should -Be "1.0.0.0" @@ -73,7 +73,7 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { It "Should save resource given name and version '3.*'" { Save-PSResource -Name $testModuleName -Version "3.*" -Repository $PSGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem -Path $pkgDir.FullName $pkgDirVersion.Name | Should -Be "3.0.0.0" @@ -81,7 +81,7 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { It "Should save resource given name and exact version with bracket syntax" { Save-PSResource -Name $testModuleName -Version "[1.0.0]" -Repository $PSGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem -Path $pkgDir.FullName $pkgDirVersion.Name | Should -Be "1.0.0.0" @@ -89,7 +89,7 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { It "Should save resource given name and exact range inclusive [1.0.0, 3.0.0]" { Save-PSResource -Name $testModuleName -Version "[1.0.0, 3.0.0]" -Repository $PSGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem -Path $pkgDir.FullName $pkgDirVersion.Name | Should -Be "3.0.0.0" @@ -97,7 +97,7 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { It "Should save resource given name and exact range exclusive (1.0.0, 5.0.0)" { Save-PSResource -Name $testModuleName -Version "(1.0.0, 5.0.0)" -Repository $PSGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem -Path $pkgDir.FullName $pkgDirVersion.Name | Should -Be "3.0.0.0" @@ -111,21 +111,21 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { catch {} - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -BeNullOrEmpty $Error.Count | Should -BeGreaterThan 0 - $Error[0].FullyQualifiedErrorId | Should -Be "IncorrectVersionFormat,Microsoft.PowerShell.PSResourceGet.Cmdlets.SavePSResource" + $Error[0].FullyQualifiedErrorId | Should -Be "IncorrectVersionFormat,Microsoft.PowerShell.PSResourceGet.Cmdlets.SavePSResource" } It "Save resource with latest (including prerelease) version given Prerelease parameter" { Save-PSResource -Name $testModuleName -Prerelease -Repository $PSGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem -Path $pkgDir.FullName $pkgDirVersion.Name | Should -Be "5.2.5" } - It "Save a module with a dependency" { + It "Save a module with a dependency" { Save-PSResource -Name "TestModuleWithDependencyE" -Version "1.0.0.0" -Repository $PSGalleryName -Path $SaveDir -TrustRepository $pkgDirs = Get-ChildItem -Path $SaveDir | Where-Object { $_.Name -eq "TestModuleWithDependencyE" -or $_.Name -eq "TestModuleWithDependencyC" -or $_.Name -eq "TestModuleWithDependencyB" -or $_.Name -eq "TestModuleWithDependencyD"} $pkgDirs.Count | Should -BeGreaterThan 1 @@ -146,24 +146,24 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { ### the input object is of type string (ie "true"). It "Save PSResourceInfo object piped in for prerelease version object" -Pending { Find-PSResource -Name $testModuleName -Version "5.2.5-alpha001" -Repository $PSGalleryName | Save-PSResource -Path $SaveDir -TrustRepository -Verbose - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty - (Get-ChildItem -Path $pkgDir.FullName) | Should -HaveCount 1 + (Get-ChildItem -Path $pkgDir.FullName) | Should -HaveCount 1 } It "Save module as a nupkg" { Save-PSResource -Name $testModuleName -Version "1.0.0" -Repository $PSGalleryName -Path $SaveDir -AsNupkg -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq "test_module.1.0.0.nupkg" + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ "test_module.1.0.0.nupkg" $pkgDir | Should -Not -BeNullOrEmpty } It "Save module and include XML metadata file" { Save-PSResource -Name $testModuleName -Version "1.0.0" -Repository $PSGalleryName -Path $SaveDir -IncludeXml -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem -Path $pkgDir.FullName $pkgDirVersion.Name | Should -Be "1.0.0.0" - $xmlFile = Get-ChildItem -Path $pkgDirVersion.FullName | Where-Object Name -eq "PSGetModuleInfo.xml" + $xmlFile = Get-ChildItem -Path $pkgDirVersion.FullName | Where-Object Name -EQ "PSGetModuleInfo.xml" $xmlFile | Should -Not -BeNullOrEmpty } @@ -173,12 +173,19 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { $res.Version | Should -Be "1.0.0.0" } + It "Save script without using -IncludeXML" { + Save-PSResource -Name $testScriptName -Repository $PSGalleryName -Path $SaveDir -TrustRepository | Should -Not -Throw + + $SavedScriptFile = Join-Path -Path $SaveDir -ChildPath "$testScriptName.ps1" + Test-Path -Path $SavedScriptFile -PathType 'Leaf' | Should -BeTrue + } + It "Save script using -IncludeXML" { - Save-PSResource -Name $testScriptName -Repository $PSGalleryName -Path $SaveDir -TrustRepository + Save-PSResource -Name $testScriptName -Repository $PSGalleryName -Path $SaveDir -TrustRepository -IncludeXml - $scriptXML = $testScriptNamen + "_InstalledScriptInfo.xml" - $savedScriptFile = Get-ChildItem -Path $SaveDir | Where-Object Name -eq "test_script.ps1" - $savedScriptXML = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $scriptXML + $scriptXML = $testScriptName + "_InstalledScriptInfo.xml" + $savedScriptFile = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ "test_script.ps1" + $savedScriptXML = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $scriptXML $savedScriptFile | Should -Not -BeNullOrEmpty (Get-ChildItem $savedScriptFile.FullName) | Should -HaveCount 1 $savedScriptXML | Should -Not -BeNullOrEmpty @@ -192,4 +199,13 @@ Describe 'Test HTTP Save-PSResource for V2 Server Protocol' -tags 'CI' { $err.Count | Should -BeGreaterThan 0 $err[0].FullyQualifiedErrorId | Should -BeExactly "InstallPackageFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.SavePSResource" } -} \ No newline at end of file + + # Save resource that requires license + It "Save resource that requires accept license with -AcceptLicense flag" { + Save-PSResource -Repository $TestGalleryName -TrustRepository -Path $SaveDir ` + -Name $testModuleName2 -AcceptLicense + $pkg = Get-InstalledPSResource -Path $SaveDir -Name $testModuleName2 + $pkg.Name | Should -Be $testModuleName2 + $pkg.Version | Should -Be "0.0.1.0" + } +} diff --git a/test/SavePSResourceTests/SavePSResourceV3Tests.ps1 b/test/SavePSResourceTests/SavePSResourceV3Tests.ps1 index c82a11b54..cb414ce50 100644 --- a/test/SavePSResourceTests/SavePSResourceV3Tests.ps1 +++ b/test/SavePSResourceTests/SavePSResourceV3Tests.ps1 @@ -28,7 +28,7 @@ Describe 'Test HTTP Save-PSResource for V3 Server Protocol' -tags 'CI' { It "Save specific module resource by name" { Save-PSResource -Name $testModuleName -Repository $NuGetGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty (Get-ChildItem $pkgDir.FullName) | Should -HaveCount 1 } @@ -44,7 +44,7 @@ Describe 'Test HTTP Save-PSResource for V3 Server Protocol' -tags 'CI' { It "Should not save resource given nonexistant name" { Save-PSResource -Name NonExistentModule -Repository $NuGetGalleryName -Path $SaveDir -ErrorVariable err -ErrorAction SilentlyContinue -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq "NonExistentModule" + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ "NonExistentModule" $pkgDir.Name | Should -BeNullOrEmpty } @@ -56,7 +56,7 @@ Describe 'Test HTTP Save-PSResource for V3 Server Protocol' -tags 'CI' { It "Should save resource given name and exact version" { Save-PSResource -Name $testModuleName -Version "1.0.0" -Repository $NuGetGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem $pkgDir.FullName $pkgDirVersion.Name | Should -Be "1.0.0" @@ -64,7 +64,7 @@ Describe 'Test HTTP Save-PSResource for V3 Server Protocol' -tags 'CI' { It "Should save resource given name and exact version with bracket syntax" { Save-PSResource -Name $testModuleName -Version "[1.0.0]" -Repository $NuGetGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem -Path $pkgDir.FullName $pkgDirVersion.Name | Should -Be "1.0.0" @@ -72,7 +72,7 @@ Describe 'Test HTTP Save-PSResource for V3 Server Protocol' -tags 'CI' { It "Should save resource given name and exact range inclusive [1.0.0, 3.0.0]" { Save-PSResource -Name $testModuleName -Version "[1.0.0, 3.0.0]" -Repository $NuGetGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem -Path $pkgDir.FullName $pkgDirVersion.Name | Should -Be "3.0.0" @@ -80,7 +80,7 @@ Describe 'Test HTTP Save-PSResource for V3 Server Protocol' -tags 'CI' { It "Should save resource given name and exact range exclusive (1.0.0, 5.0.0)" { Save-PSResource -Name $testModuleName -Version "(1.0.0, 5.0.0)" -Repository $NuGetGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem -Path $pkgDir.FullName $pkgDirVersion.Name | Should -Be "3.0.0" @@ -94,15 +94,15 @@ Describe 'Test HTTP Save-PSResource for V3 Server Protocol' -tags 'CI' { catch {} - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -BeNullOrEmpty $Error.Count | Should -BeGreaterThan 0 - $Error[0].FullyQualifiedErrorId | Should -Be "IncorrectVersionFormat,Microsoft.PowerShell.PSResourceGet.Cmdlets.SavePSResource" + $Error[0].FullyQualifiedErrorId | Should -Be "IncorrectVersionFormat,Microsoft.PowerShell.PSResourceGet.Cmdlets.SavePSResource" } It "Save resource with latest (including prerelease) version given Prerelease parameter" { Save-PSResource -Name $testModuleName -Prerelease -Repository $NuGetGalleryName -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem -Path $pkgDir.FullName $pkgDirVersion.Name | Should -Be "5.2.5" @@ -112,24 +112,24 @@ Describe 'Test HTTP Save-PSResource for V3 Server Protocol' -tags 'CI' { ### the input object is of type string (ie "true"). It "Save PSResourceInfo object piped in for prerelease version object" -Pending{ Find-PSResource -Name $testModuleName -Version "5.2.5-alpha001" -Repository $NuGetGalleryName | Save-PSResource -Path $SaveDir -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty - (Get-ChildItem -Path $pkgDir.FullName) | Should -HaveCount 1 + (Get-ChildItem -Path $pkgDir.FullName) | Should -HaveCount 1 } It "Save module as a nupkg" { Save-PSResource -Name $testModuleName -Version "1.0.0" -Repository $NuGetGalleryName -Path $SaveDir -AsNupkg -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq "test_module.1.0.0.nupkg" + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ "test_module.1.0.0.nupkg" $pkgDir | Should -Not -BeNullOrEmpty } It "Save module and include XML metadata file" { Save-PSResource -Name $testModuleName -Version "1.0.0" -Repository $NuGetGalleryName -Path $SaveDir -IncludeXml -TrustRepository - $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -eq $testModuleName + $pkgDir = Get-ChildItem -Path $SaveDir | Where-Object Name -EQ $testModuleName $pkgDir | Should -Not -BeNullOrEmpty $pkgDirVersion = Get-ChildItem -Path $pkgDir.FullName $pkgDirVersion.Name | Should -Be "1.0.0" - $xmlFile = Get-ChildItem -Path $pkgDirVersion.FullName | Where-Object Name -eq "PSGetModuleInfo.xml" + $xmlFile = Get-ChildItem -Path $pkgDirVersion.FullName | Where-Object Name -EQ "PSGetModuleInfo.xml" $xmlFile | Should -Not -BeNullOrEmpty } @@ -146,4 +146,13 @@ Describe 'Test HTTP Save-PSResource for V3 Server Protocol' -tags 'CI' { $err.Count | Should -BeGreaterThan 0 $err[0].FullyQualifiedErrorId | Should -BeExactly "InstallPackageFailure,Microsoft.PowerShell.PSResourceGet.Cmdlets.SavePSResource" } -} \ No newline at end of file + + # Save resource that requires license + It "Install resource that requires accept license with -AcceptLicense flag" { + Save-PSResource -Repository $NuGetGalleryName -TrustRepository -Path $SaveDir ` + -Name "test_module_with_license" -AcceptLicense + $pkg = Get-InstalledPSResource -Path $SaveDir "test_module_with_license" + $pkg.Name | Should -Be "test_module_with_license" + $pkg.Version | Should -Be "2.0.0" + } +} diff --git a/test/UpdateModuleManifest/UpdateModuleManifest.Tests.ps1 b/test/UpdateModuleManifest/UpdateModuleManifest.Tests.ps1 index cc1776025..d20d25076 100644 --- a/test/UpdateModuleManifest/UpdateModuleManifest.Tests.ps1 +++ b/test/UpdateModuleManifest/UpdateModuleManifest.Tests.ps1 @@ -3,12 +3,8 @@ $modPath = "$psscriptroot/../PSGetTestUtils.psm1" Import-Module $modPath -Force -Verbose -# Explicitly import build module because in CI PowerShell can autoload PSGetv2 -# This ensures the build module is always being tested -$buildModule = "$psscriptroot/../../out/PSResourceGet" -Import-Module $buildModule -Force -Verbose -Describe 'Test Update-PSModuleManifest' { +Describe 'Test Update-PSModuleManifest' -tags 'CI' { BeforeEach { # Create temp module manifest to be updated @@ -102,7 +98,7 @@ Describe 'Test Update-PSModuleManifest' { $ModuleVersion = "1.0.0" $Prerelease = " " New-ModuleManifest -Path $script:testManifestPath -Description $Description -ModuleVersion $ModuleVersion - {Update-PSModuleManifest -Path $script:testManifestPath -Prerelease $Prerelease} | Should -Throw -ErrorId "PrereleaseValueCannotOrBeWhiteSpace,Microsoft.PowerShell.PSResourceGet.Cmdlets.InstallPSResource" + {Update-PSModuleManifest -Path $script:testManifestPath -Prerelease $Prerelease} | Should -Throw -ErrorId "PrereleaseValueCannotBeWhiteSpace,Microsoft.PowerShell.PSResourceGet.Cmdlets.UpdateModuleManifest" } It "Update module manifest given ReleaseNotes parameter" { @@ -112,6 +108,8 @@ Describe 'Test Update-PSModuleManifest' { Update-PSModuleManifest -Path $script:testManifestPath -ReleaseNotes $ReleaseNotes $results = Test-ModuleManifest -Path $script:testManifestPath + Write-Verbose -Verbose "release notes are: $($results.PrivateData.PSData.ReleaseNotes)" + Write-Verbose -Verbose "release notes should be: $ReleaseNotes" $results.PrivateData.PSData.ReleaseNotes | Should -Be $ReleaseNotes } @@ -422,6 +420,8 @@ Describe 'Test Update-PSModuleManifest' { $results = Test-ModuleManifest -Path $script:testManifestPath $results.Author | Should -Be $Author + Write-Verbose -Verbose "Project Uri was: $($results.PrivateData.PSData.ProjectUri)" + Write-Verbose -Verbose "Project Uri should be: $ProjectUri" $results.PrivateData.PSData.ProjectUri | Should -Be $ProjectUri $results.PrivateData.PSData.Prerelease | Should -Be $Prerelease } diff --git a/test/UpdatePSResourceTests/UpdatePSResourceLocalTests.ps1 b/test/UpdatePSResourceTests/UpdatePSResourceLocalTests.ps1 index da417d267..f5334be83 100644 --- a/test/UpdatePSResourceTests/UpdatePSResourceLocalTests.ps1 +++ b/test/UpdatePSResourceTests/UpdatePSResourceLocalTests.ps1 @@ -10,6 +10,7 @@ Describe 'Test Update-PSResource for local repositories' -tags 'CI' { BeforeAll { $localRepo = "psgettestlocal" + $localRepo2 = "psgettestlocal2" $moduleName = "test_local_mod" $moduleName2 = "test_local_mod2" Get-NewPSResourceRepositoryFile @@ -20,6 +21,9 @@ Describe 'Test Update-PSResource for local repositories' -tags 'CI' { Get-ModuleResourcePublishedToLocalRepoTestDrive $moduleName $localRepo "5.0.0" Get-ModuleResourcePublishedToLocalRepoTestDrive $moduleName2 $localRepo "1.0.0" Get-ModuleResourcePublishedToLocalRepoTestDrive $moduleName2 $localRepo "5.0.0" + + Get-ModuleResourcePublishedToLocalRepoTestDrive $moduleName $localRepo2 "1.0.0" + Get-ModuleResourcePublishedToLocalRepoTestDrive $moduleName $localRepo2 "5.0.0" } AfterEach { @@ -48,6 +52,31 @@ Describe 'Test Update-PSResource for local repositories' -tags 'CI' { $isPkgUpdated | Should -Be $true } + It "Update resource from the repository which package was previously from" { + Install-PSResource -Name $moduleName -Version "1.0.0" -Repository $localRepo2 -TrustRepository + + Update-PSResource -Name $moduleName -TrustRepository + $res = Get-InstalledPSResource -Name $moduleName + + $isPkgUpdated = $false + $isCorrectRepo = $false + foreach ($pkg in $res) + { + if ([System.Version]$pkg.Version -gt [System.Version]"1.0.0") + { + $isPkgUpdated = $true + + if ($pkg.Repository -eq $localRepo2) + { + $isCorrectRepo = $true + } + } + } + + $isPkgUpdated | Should -Be $true + $isCorrectRepo | Should -Be $true + } + It "Update resources installed given Name (with wildcard) parameter" { Install-PSResource -Name $moduleName -Version "1.0.0" -Repository $localRepo -TrustRepository Install-PSResource -Name $moduleName2 -Version "1.0.0" -Repository $localRepo -TrustRepository diff --git a/test/perf/benchmarks/benchmarks.csproj b/test/perf/benchmarks/benchmarks.csproj index b7f2a4bf9..d34e3f9b2 100644 --- a/test/perf/benchmarks/benchmarks.csproj +++ b/test/perf/benchmarks/benchmarks.csproj @@ -1,25 +1,25 @@ - - - net6.0 - Exe - - - AnyCPU - pdbonly - true - true - true - Release - false - - - - - - - - - - - - + + + net6.0 + Exe + + + AnyCPU + pdbonly + true + true + true + Release + false + + + + + + + + + + + + diff --git a/test/testFiles/testNupkgs/microsoft.web.webview2.1.0.2792.45.nupkg b/test/testFiles/testNupkgs/microsoft.web.webview2.1.0.2792.45.nupkg new file mode 100644 index 000000000..d047ae55d Binary files /dev/null and b/test/testFiles/testNupkgs/microsoft.web.webview2.1.0.2792.45.nupkg differ diff --git a/test/testFiles/testNupkgs/temp-testmodule-nupkgpath.1.0.0.nupkg b/test/testFiles/testNupkgs/temp-testmodule-nupkgpath.1.0.0.nupkg new file mode 100644 index 000000000..a0f7d11d8 Binary files /dev/null and b/test/testFiles/testNupkgs/temp-testmodule-nupkgpath.1.0.0.nupkg differ diff --git a/test/testFiles/testNupkgs/temp-testnupkg-nupkgpath.1.0.0.nupkg b/test/testFiles/testNupkgs/temp-testnupkg-nupkgpath.1.0.0.nupkg new file mode 100644 index 000000000..370ba0068 Binary files /dev/null and b/test/testFiles/testNupkgs/temp-testnupkg-nupkgpath.1.0.0.nupkg differ diff --git a/test/testFiles/testNupkgs/temp-testscript-nupkgpath.1.0.0.nupkg b/test/testFiles/testNupkgs/temp-testscript-nupkgpath.1.0.0.nupkg new file mode 100644 index 000000000..e3adbf814 Binary files /dev/null and b/test/testFiles/testNupkgs/temp-testscript-nupkgpath.1.0.0.nupkg differ diff --git a/test/testFiles/testNupkgs/webview2.avalonia.1.0.1518.46-preview.230207.17.nupkg b/test/testFiles/testNupkgs/webview2.avalonia.1.0.1518.46-preview.230207.17.nupkg new file mode 100644 index 000000000..e47c80773 Binary files /dev/null and b/test/testFiles/testNupkgs/webview2.avalonia.1.0.1518.46-preview.230207.17.nupkg differ diff --git a/test/testFiles/testScripts/ScriptWithWhitespaceBeforeClosingComment.ps1 b/test/testFiles/testScripts/ScriptWithWhitespaceBeforeClosingComment.ps1 new file mode 100644 index 000000000..c0e06af8b --- /dev/null +++ b/test/testFiles/testScripts/ScriptWithWhitespaceBeforeClosingComment.ps1 @@ -0,0 +1,42 @@ + +<#PSScriptInfo + +.VERSION 1.0 + +.GUID 3951be04-bd06-4337-8dc3-a620bf539fbd + +.AUTHOR annavied + +.COMPANYNAME + +.COPYRIGHT + +.TAGS + +.LICENSEURI + +.PROJECTURI + +.ICONURI + +.EXTERNALMODULEDEPENDENCIES + +.REQUIREDSCRIPTS + +.EXTERNALSCRIPTDEPENDENCIES + +.RELEASENOTES + + +.PRIVATEDATA + +#> + +<# + +.DESCRIPTION + this is a test for a script that will be published remotely + + #> +Param() +