diff --git a/.azure-pipelines/ci-build.yml b/.azure-pipelines/ci-build.yml
index fb7413240..5e8a40e98 100644
--- a/.azure-pipelines/ci-build.yml
+++ b/.azure-pipelines/ci-build.yml
@@ -21,6 +21,8 @@ pr:
variables:
buildPlatform: 'Any CPU'
buildConfiguration: 'Release'
+ NuGetOrganizationName: 'openapinet'
+ privateFeedBaseUrl: 'https://microsoftgraph.pkgs.visualstudio.com/0985d294-5762-4bc2-a565-161ef349ca3e/_packaging/GraphDeveloperExperiences_Public'
ProductBinPath: '$(Build.SourcesDirectory)\src\Microsoft.OpenApi\bin\$(BuildConfiguration)'
REGISTRY: 'msgraphprodregistry.azurecr.io'
IMAGE_NAME: 'public/openapi/hidi'
@@ -69,7 +71,7 @@ extends:
- task: UseDotNet@2
displayName: 'Use .NET 10'
inputs:
- version: 10.x
+ useGlobalJson: true
# Install the nuget tool.
- task: NuGetToolInstaller@1
@@ -88,7 +90,7 @@ extends:
-
+
"@ | Set-Content -Path "$(Build.SourcesDirectory)/nuget.config" -Encoding UTF8
@@ -100,14 +102,18 @@ extends:
arguments: '--configuration $(BuildConfiguration) --no-incremental'
# Run the Unit test
- - task: DotNetCoreCLI@2
+ - pwsh: |
+ dotnet test "$(Build.SourcesDirectory)\Microsoft.OpenApi.slnx" `
+ --configuration $(BuildConfiguration) `
+ --no-build `
+ -- `
+ --report-azdo `
+ --publish-azdo-test-results `
+ --results-directory="$(Agent.TempDirectory)\TestResults" `
+ --minimum-expected-tests 1
displayName: 'test'
- inputs:
- command: test
- projects: '$(Build.SourcesDirectory)\Microsoft.OpenApi.slnx'
- arguments: '--configuration $(BuildConfiguration) --no-build'
- - task: EsrpCodeSigning@5
+ - task: EsrpCodeSigning@6
displayName: 'ESRP CodeSigning binaries'
inputs:
ConnectedServiceName: 'Federated DevX ESRP Managed Identity Connection'
@@ -173,7 +179,7 @@ extends:
- pwsh: dotnet pack $(Build.SourcesDirectory)/src/Microsoft.OpenApi.Hidi/Microsoft.OpenApi.Hidi.csproj -o $(Build.ArtifactStagingDirectory) --configuration $(BuildConfiguration) --no-build --include-symbols --include-source /p:SymbolPackageFormat=snupkg
displayName: 'pack Hidi'
- - task: EsrpCodeSigning@5
+ - task: EsrpCodeSigning@6
displayName: 'ESRP CodeSigning Nuget Packages'
inputs:
ConnectedServiceName: 'Federated DevX ESRP Managed Identity Connection'
@@ -212,7 +218,7 @@ extends:
displayName: publish Hidi as executable
inputs:
command: 'publish'
- arguments: -c Release --runtime win-x64 /p:PublishSingleFile=true /p:PackAsTool=false --self-contained --output $(Build.ArtifactStagingDirectory)/Microsoft.OpenApi.Hidi
+ arguments: -c Release --runtime win-x64 -p:RestoreConfigFile=$(Build.SourcesDirectory)\nuget.config /p:PublishSingleFile=true /p:PackAsTool=false --self-contained --output $(Build.ArtifactStagingDirectory)/Microsoft.OpenApi.Hidi
projects: 'src/Microsoft.OpenApi.Hidi/Microsoft.OpenApi.Hidi.csproj'
publishWebProjects: False
zipAfterPublish: false
@@ -222,7 +228,16 @@ extends:
inputs:
targetFolder: $(Build.ArtifactStagingDirectory)/Nugets
sourceFolder: $(Build.ArtifactStagingDirectory)
- content: '*.nupkg'
+ Contents: |
+ *.nupkg
+ *.snupkg
+
+ - task: CopyFiles@2
+ displayName: 'Include version-check script in Nugets artifact'
+ inputs:
+ SourceFolder: '$(Build.SourcesDirectory)/scripts'
+ Contents: 'check-nuget-package-published.ps1'
+ TargetFolder: '$(Build.ArtifactStagingDirectory)/Nugets/scripts'
# Copy repository files to be used in the deploy stage
- task: CopyFiles@2
@@ -261,13 +276,46 @@ extends:
pool:
vmImage: ubuntu-latest
steps:
- - task: 1ES.PublishNuget@1
- displayName: 'NuGet push'
+ - task: PowerShell@2
+ displayName: 'Check whether NuGet package version already published (idempotent)'
+ inputs:
+ targetType: filePath
+ filePath: '$(Pipeline.Workspace)/scripts/check-nuget-package-published.ps1'
+ arguments: '-PackageId "Microsoft.OpenApi.Hidi" -PackageDirectory "$(Pipeline.Workspace)" -NuGetServiceIndexUrl "$(privateFeedBaseUrl)/nuget/v3/index.json"'
+ pwsh: true
+ env:
+ FEED_ACCESS_TOKEN: $(System.AccessToken)
+ - task: CopyFiles@2
+ displayName: 'Stage Hidi NuGet packages for ESRP release'
+ condition: and(succeeded(), ne(variables['nugetAlreadyPublished'], 'true'))
+ inputs:
+ SourceFolder: '$(Pipeline.Workspace)'
+ Contents: |
+ Microsoft.OpenApi.Hidi.*.nupkg
+ Microsoft.OpenApi.Hidi.*.snupkg
+ TargetFolder: '$(Pipeline.Workspace)/nuget-packages/$(NuGetOrganizationName)/hidi'
+ CleanTargetFolder: true
+ - task: EsrpRelease@14
+ displayName: 'ESRP Release - Hidi NuGet'
+ condition: and(succeeded(), ne(variables['nugetAlreadyPublished'], 'true'))
inputs:
- packagesToPush: '$(Pipeline.Workspace)/Microsoft.OpenApi.Hidi.*.nupkg'
- packageParentPath: '$(Pipeline.Workspace)'
- nuGetFeedType: external
- publishFeedCredentials: 'OpenAPI Nuget Connection'
+ connectedservicename: 'Federated DevX ESRP Managed Identity Connection'
+ usemanagedidentity: false
+ keyvaultname: 'akv-prod-eastus'
+ authcertname: 'ReferenceLibraryPrivateCert'
+ signcertname: 'ReferencePackagePublisherCertificate'
+ clientid: '65035b7f-7357-4f29-bf25-c5ee5c3949f8'
+ intent: 'packagedistribution'
+ contenttype: 'NuGet'
+ organizationname: '$(NuGetOrganizationName)'
+ contentsource: 'Folder'
+ folderlocation: '$(Pipeline.Workspace)/nuget-packages/$(NuGetOrganizationName)/hidi'
+ waitforreleasecompletion: true
+ owners: 'christiano@microsoft.com,ramsess@microsoft.com,gavinbarron@microsoft.com,jingjingjia@microsoft.com,peombwa@microsoft.com,treicys@microsoft.com'
+ approvers: 'christiano@microsoft.com,ramsess@microsoft.com,gavinbarron@microsoft.com,jingjingjia@microsoft.com,peombwa@microsoft.com,treicys@microsoft.com'
+ serviceendpointurl: 'https://api.esrp.microsoft.com/'
+ mainpublisher: 'ESRPRELPACMAN'
+ domaintenantid: 'cdc5aeea-15c5-4db6-b079-fcadd2505dc2'
- deployment: deploy_lib
condition: and(contains(variables['build.SourceBranch'], 'refs/tags/v'), succeeded())
@@ -286,21 +334,48 @@ extends:
pool:
vmImage: ubuntu-latest
steps:
- - pwsh: |
- $fileNames = "$(Pipeline.Workspace)/Microsoft.OpenApi.Hidi.*.nupkg", "$(Pipeline.Workspace)/Microsoft.OpenApi.YamlReader.*.nupkg", "$(Pipeline.Workspace)/Microsoft.OpenApi.Workbench.*.nupkg"
- foreach($fileName in $fileNames) {
- if(Test-Path $fileName) {
- Remove-Item $fileName -Verbose
- }
- }
- displayName: remove other nupkgs to avoid duplication
- - task: 1ES.PublishNuget@1
- displayName: 'NuGet push'
+ - task: PowerShell@2
+ displayName: 'Check whether NuGet package version already published (idempotent)'
inputs:
- packagesToPush: '$(Pipeline.Workspace)/Microsoft.OpenApi.*.nupkg'
- packageParentPath: '$(Pipeline.Workspace)'
- nuGetFeedType: external
- publishFeedCredentials: 'OpenAPI Nuget Connection'
+ targetType: filePath
+ filePath: '$(Pipeline.Workspace)/scripts/check-nuget-package-published.ps1'
+ arguments: '-PackageId "Microsoft.OpenApi" -PackageDirectory "$(Pipeline.Workspace)" -NuGetServiceIndexUrl "$(privateFeedBaseUrl)/nuget/v3/index.json"'
+ pwsh: true
+ env:
+ FEED_ACCESS_TOKEN: $(System.AccessToken)
+ - task: CopyFiles@2
+ displayName: 'Stage OpenAPI NuGet packages for ESRP release'
+ condition: and(succeeded(), ne(variables['nugetAlreadyPublished'], 'true'))
+ inputs:
+ SourceFolder: '$(Pipeline.Workspace)'
+ Contents: |
+ Microsoft.OpenApi.*.nupkg
+ Microsoft.OpenApi.*.snupkg
+ !Microsoft.OpenApi.Hidi.*
+ !Microsoft.OpenApi.YamlReader.*
+ TargetFolder: '$(Pipeline.Workspace)/nuget-packages/$(NuGetOrganizationName)/openapi'
+ CleanTargetFolder: true
+ - task: EsrpRelease@14
+ displayName: 'ESRP Release - OpenAPI NuGet'
+ condition: and(succeeded(), ne(variables['nugetAlreadyPublished'], 'true'))
+ inputs:
+ connectedservicename: 'Federated DevX ESRP Managed Identity Connection'
+ usemanagedidentity: false
+ keyvaultname: 'akv-prod-eastus'
+ authcertname: 'ReferenceLibraryPrivateCert'
+ signcertname: 'ReferencePackagePublisherCertificate'
+ clientid: '65035b7f-7357-4f29-bf25-c5ee5c3949f8'
+ intent: 'packagedistribution'
+ contenttype: 'NuGet'
+ organizationname: '$(NuGetOrganizationName)'
+ contentsource: 'Folder'
+ folderlocation: '$(Pipeline.Workspace)/nuget-packages/$(NuGetOrganizationName)/openapi'
+ waitforreleasecompletion: true
+ owners: 'christiano@microsoft.com,ramsess@microsoft.com,gavinbarron@microsoft.com,jingjingjia@microsoft.com,peombwa@microsoft.com,treicys@microsoft.com'
+ approvers: 'christiano@microsoft.com,ramsess@microsoft.com,gavinbarron@microsoft.com,jingjingjia@microsoft.com,peombwa@microsoft.com,treicys@microsoft.com'
+ serviceendpointurl: 'https://api.esrp.microsoft.com/'
+ mainpublisher: 'ESRPRELPACMAN'
+ domaintenantid: 'cdc5aeea-15c5-4db6-b079-fcadd2505dc2'
- deployment: deploy_yaml_reader
condition: and(contains(variables['build.SourceBranch'], 'refs/tags/v'), succeeded())
@@ -319,13 +394,46 @@ extends:
pool:
vmImage: ubuntu-latest
steps:
- - task: 1ES.PublishNuget@1
- displayName: 'NuGet push'
+ - task: PowerShell@2
+ displayName: 'Check whether NuGet package version already published (idempotent)'
+ inputs:
+ targetType: filePath
+ filePath: '$(Pipeline.Workspace)/scripts/check-nuget-package-published.ps1'
+ arguments: '-PackageId "Microsoft.OpenApi.YamlReader" -PackageDirectory "$(Pipeline.Workspace)" -NuGetServiceIndexUrl "$(privateFeedBaseUrl)/nuget/v3/index.json"'
+ pwsh: true
+ env:
+ FEED_ACCESS_TOKEN: $(System.AccessToken)
+ - task: CopyFiles@2
+ displayName: 'Stage YAML reader NuGet packages for ESRP release'
+ condition: and(succeeded(), ne(variables['nugetAlreadyPublished'], 'true'))
inputs:
- packagesToPush: '$(Pipeline.Workspace)/Microsoft.OpenApi.YamlReader.*.nupkg'
- packageParentPath: '$(Pipeline.Workspace)'
- nuGetFeedType: external
- publishFeedCredentials: 'OpenAPI Nuget Connection'
+ SourceFolder: '$(Pipeline.Workspace)'
+ Contents: |
+ Microsoft.OpenApi.YamlReader.*.nupkg
+ Microsoft.OpenApi.YamlReader.*.snupkg
+ TargetFolder: '$(Pipeline.Workspace)/nuget-packages/$(NuGetOrganizationName)/yaml-reader'
+ CleanTargetFolder: true
+ - task: EsrpRelease@14
+ displayName: 'ESRP Release - YAML reader NuGet'
+ condition: and(succeeded(), ne(variables['nugetAlreadyPublished'], 'true'))
+ inputs:
+ connectedservicename: 'Federated DevX ESRP Managed Identity Connection'
+ usemanagedidentity: false
+ keyvaultname: 'akv-prod-eastus'
+ authcertname: 'ReferenceLibraryPrivateCert'
+ signcertname: 'ReferencePackagePublisherCertificate'
+ clientid: '65035b7f-7357-4f29-bf25-c5ee5c3949f8'
+ intent: 'packagedistribution'
+ contenttype: 'NuGet'
+ organizationname: '$(NuGetOrganizationName)'
+ contentsource: 'Folder'
+ folderlocation: '$(Pipeline.Workspace)/nuget-packages/$(NuGetOrganizationName)/yaml-reader'
+ waitforreleasecompletion: true
+ owners: 'christiano@microsoft.com,ramsess@microsoft.com,gavinbarron@microsoft.com,jingjingjia@microsoft.com,peombwa@microsoft.com,treicys@microsoft.com'
+ approvers: 'christiano@microsoft.com,ramsess@microsoft.com,gavinbarron@microsoft.com,jingjingjia@microsoft.com,peombwa@microsoft.com,treicys@microsoft.com'
+ serviceendpointurl: 'https://api.esrp.microsoft.com/'
+ mainpublisher: 'ESRPRELPACMAN'
+ domaintenantid: 'cdc5aeea-15c5-4db6-b079-fcadd2505dc2'
- deployment: create_github_release
condition: and(contains(variables['build.SourceBranch'], 'refs/tags/v'), succeeded())
@@ -455,7 +563,33 @@ extends:
displayName: 'Get current date'
name: setdate
condition: eq(variables['Build.SourceBranch'], variables['PREVIEW_BRANCH'])
-
+
+ # Keep feed credentials out of the Docker build context and image layers.
+ - pwsh: |
+ if ([string]::IsNullOrWhiteSpace($env:FEED_ACCESS_TOKEN)) {
+ throw "No Azure Artifacts access token available for the Docker build."
+ }
+ $feedAccessToken = [System.Security.SecurityElement]::Escape($env:FEED_ACCESS_TOKEN)
+ @"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ "@ | Set-Content -Path "$(Agent.TempDirectory)/hidi-docker.nuget.config" -Encoding UTF8
+ displayName: 'Create Docker NuGet config (central feed)'
+ env:
+ FEED_ACCESS_TOKEN: $(System.AccessToken)
+
- script: |
docker run --privileged --rm msgraphprodregistry.azurecr.io/tonistiigi/binfmt --install all
displayName: "Enable multi-platform builds"
@@ -474,6 +608,7 @@ extends:
# Using quotes around tags to prevent flag interpretation
docker buildx build \
--platform linux/amd64,linux/arm64/v8 \
+ --secret id=nuget_config,src="$(Agent.TempDirectory)/hidi-docker.nuget.config" \
--push \
-t "$(REGISTRY)/$(IMAGE_NAME):nightly" \
-t "$(REGISTRY)/$(IMAGE_NAME):${VERSION}.${BUILDDATE}${RUNNUMBER}" \
@@ -486,6 +621,7 @@ extends:
echo "Building Docker image for release..."
docker buildx build\
--platform linux/amd64,linux/arm64/v8 \
+ --secret id=nuget_config,src="$(Agent.TempDirectory)/hidi-docker.nuget.config" \
--push \
-t "$(REGISTRY)/$(IMAGE_NAME):latest" \
-t "$(REGISTRY)/$(IMAGE_NAME):${VERSION}" \
@@ -493,6 +629,14 @@ extends:
displayName: 'Build and Push Release Image'
condition: contains(variables['Build.SourceBranch'], 'refs/tags/v')
+ - pwsh: |
+ $configPath = "$(Agent.TempDirectory)/hidi-docker.nuget.config"
+ if (Test-Path $configPath) {
+ Remove-Item $configPath -Force
+ }
+ displayName: 'Remove Docker NuGet config'
+ condition: always()
+
# once the nuget has been released, fill this form to get the public documentation updated.
# https://dev.azure.com/msft-skilling/Content/_workitems/create/User%20Story?templateId=39fb91e3-64a2-4c8a-83db-b2bdf3603dd3&ownerId=c4a28f90-17ae-4384-b514-7273392b082b
# https://learn.microsoft.com/en-us/dotnet/api/microsoft.openapi
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index db2d8ebfb..22ae8e30f 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -5,6 +5,10 @@ updates:
open-pull-requests-limit: 10
schedule:
interval: daily
+ groups:
+ codeql:
+ patterns:
+ - github/codeql-action*
cooldown:
default-days: 7
- package-ecosystem: nuget
@@ -16,9 +20,12 @@ updates:
MicrosoftExtensions:
patterns:
- Microsoft.Extensions.*
- coverlet:
+ testing:
patterns:
- coverlet.*
+ - Microsoft.NET.Test.Sdk
+ - Microsoft.Testing.*
+ - xunit.*
cooldown:
default-days: 7
- package-ecosystem: dotnet-sdk
diff --git a/.github/workflows/auto-merge-dependabot.yml b/.github/workflows/auto-merge-dependabot.yml
index 9d9039433..f6dafa170 100644
--- a/.github/workflows/auto-merge-dependabot.yml
+++ b/.github/workflows/auto-merge-dependabot.yml
@@ -19,14 +19,14 @@ jobs:
steps:
- name: Dependabot metadata
id: metadata
- uses: dependabot/fetch-metadata@v3.1.0
+ uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Enable auto-merge for Dependabot PRs
# Only if version bump is not a major version change
if: ${{steps.metadata.outputs.update-type != 'version-update:semver-major'}}
- run: gh pr merge --auto --merge "$PR_URL"
+ run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{github.event.pull_request.html_url}}
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml
index d274b00c2..4ae9e6ae0 100644
--- a/.github/workflows/ci-cd.yml
+++ b/.github/workflows/ci-cd.yml
@@ -18,18 +18,18 @@ jobs:
GITHUB_RUN_NUMBER: ${{ github.run_number }}
steps:
- name: Setup .NET 8
- uses: actions/setup-dotnet@v6
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 8.x
- name: Setup .NET 10
- uses: actions/setup-dotnet@v6
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 10.x
- name: Checkout repository
id: checkout_repo
- uses: actions/checkout@v7
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
token: ${{ secrets.GITHUB_TOKEN }}
@@ -45,7 +45,7 @@ jobs:
id: run_unit_tests
shell: pwsh
run: |
- dotnet test Microsoft.OpenApi.slnx -c Release --no-build -v n --collect:"XPlat Code Coverage"
+ dotnet test --solution Microsoft.OpenApi.slnx -c Release -v n --results-directory=./TestResults --coverlet --coverlet-output-format cobertura --report-gh
- name: Install report generator
shell: pwsh
@@ -55,7 +55,7 @@ jobs:
- name: Generate coverage report
shell: pwsh
run: |
- reportgenerator -reports:**/coverage.cobertura.xml -targetdir:./reports/coverage -reporttypes:"Html;MarkdownSummaryGithub;Cobertura"
+ reportgenerator -reports:./TestResults/**/coverage.cobertura.*.xml -targetdir:./reports/coverage -reporttypes:"Html;MarkdownSummaryGithub;Cobertura"
- name: Add coverage to job summary
shell: bash
@@ -64,14 +64,14 @@ jobs:
- name: Upload coverage report
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]') || (github.event_name != 'pull_request' && github.ref_name == github.event.repository.default_branch)
- uses: actions/upload-code-coverage@v1.4.1
+ uses: actions/upload-code-coverage@2b21a77928be8d5168c2b9581a67f2adbebacc52 # v1.4.4
with:
file: ./reports/coverage/Cobertura.xml
language: CSharp
label: code-coverage/dotnet
- name: Upload coverage artifact
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage
path: reports/coverage
@@ -80,15 +80,15 @@ jobs:
name: Validate Project for Trimming
runs-on: windows-latest
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup .NET 8
- uses: actions/setup-dotnet@v6
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 8.x
- name: Setup .NET 10
- uses: actions/setup-dotnet@v6
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 10.x
@@ -102,15 +102,15 @@ jobs:
needs: [ci]
steps:
- name: Checkout repository
- uses: actions/checkout@v7
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup .NET 8
- uses: actions/setup-dotnet@v6
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 8.x
- name: Setup .NET 10
- uses: actions/setup-dotnet@v6
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 10.x
@@ -125,7 +125,7 @@ jobs:
working-directory: ./performance/benchmark
- name: Publish benchmark results
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
if-no-files-found: error
name: benchmark-results
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index aba7add18..b2311adf8 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -20,21 +20,21 @@ jobs:
steps:
- name: Checkout repository
id: checkout_repo
- uses: actions/checkout@v7
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup .NET 8
- uses: actions/setup-dotnet@v6
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 8.0.x
- name: Setup .NET 10
- uses: actions/setup-dotnet@v6
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 10.0.x
- name: Initialize CodeQL
id: init_codeql
- uses: github/codeql-action/init@v4.37.5
+ uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
queries: security-and-quality
@@ -54,6 +54,6 @@ jobs:
- name: Perform CodeQL Analysis
id: analyze_codeql
- uses: github/codeql-action/analyze@v4.37.5
+ uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
# Built with ❤ by [Pipeline Foundation](https://pipeline.foundation)
\ No newline at end of file
diff --git a/.github/workflows/promote-shipped-apis.yml b/.github/workflows/promote-shipped-apis.yml
index b3c7e8b6d..825b8944f 100644
--- a/.github/workflows/promote-shipped-apis.yml
+++ b/.github/workflows/promote-shipped-apis.yml
@@ -7,6 +7,10 @@ on:
- support/v2
workflow_dispatch:
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: false
+
jobs:
promote-apis:
runs-on: ubuntu-latest
@@ -16,13 +20,22 @@ jobs:
steps:
- name: Generate GitHub App token
id: app-token
- uses: actions/create-github-app-token@v3
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.RELEASE_PLEASE_TOKEN_PROVIDER_APP_ID }}
private-key: ${{ secrets.RELEASE_PLEASE_TOKEN_PROVIDER_PEM }}
+ permission-contents: write
+ permission-pull-requests: write
+
+ - name: Get GitHub App user ID
+ id: get-user-id
+ shell: bash
+ env:
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
- name: Checkout code
- uses: actions/checkout@v7
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
@@ -32,9 +45,9 @@ jobs:
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
- git config --global user.name "github-actions[bot]"
- git config --global user.email "github-actions[bot]@users.noreply.github.com"
- git config --global url."https://$($env:GH_TOKEN)@github.com/".insteadOf "https://github.com/"
+ git config --global user.name "${{ steps.app-token.outputs.app-slug }}[bot]"
+ git config --global user.email "${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com"
+ git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/"
- name: Check for existing PR
id: check_pr
diff --git a/.github/workflows/release-please-gha.yml b/.github/workflows/release-please-gha.yml
index 48e87c4b5..74a6233fb 100644
--- a/.github/workflows/release-please-gha.yml
+++ b/.github/workflows/release-please-gha.yml
@@ -34,7 +34,7 @@ jobs:
if: needs.check-secret.outputs.has-token == 'true'
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Validate PublicAPI.Unshipped.txt files are empty
shell: pwsh
@@ -62,13 +62,13 @@ jobs:
- name: Generate GitHub App token
id: app-token
- uses: actions/create-github-app-token@v3
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.RELEASE_PLEASE_TOKEN_PROVIDER_APP_ID }}
private-key: ${{ secrets.RELEASE_PLEASE_TOKEN_PROVIDER_PEM }}
- name: Release Please
- uses: googleapis/release-please-action@v5
+ uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
with:
token: ${{ steps.app-token.outputs.token }}
config-file: release-please-config.json
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
index afd0135b0..a6954d70d 100644
--- a/.github/workflows/sonarcloud.yml
+++ b/.github/workflows/sonarcloud.yml
@@ -35,23 +35,23 @@ jobs:
runs-on: windows-latest
steps:
- name: Set up JDK 17
- uses: actions/setup-java@v5.7.0
+ uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
- distribution: 'adopt'
+ distribution: 'temurin'
java-version: 17
- name: Setup .NET 8
- uses: actions/setup-dotnet@v6
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 8.0.x
- name: Setup .NET 10
- uses: actions/setup-dotnet@v6
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 10.0.x
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Cache SonarCloud packages
- uses: actions/cache@v6
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar
@@ -61,12 +61,10 @@ jobs:
- name: Build and analyze
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
- CollectCoverage: true
- CoverletOutputFormat: 'opencover' # https://github.com/microsoft/vstest/issues/4014#issuecomment-1307913682
shell: pwsh
run: |
- dotnet tool run dotnet-sonarscanner begin /k:"microsoft_OpenAPI.NET" /o:"microsoft" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="test/**/coverage.opencover.xml"
+ dotnet tool run dotnet-sonarscanner begin /k:"microsoft_OpenAPI.NET" /o:"microsoft" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover*.xml"
dotnet workload restore
dotnet build
- dotnet test Microsoft.OpenApi.slnx --no-build --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=opencover
- dotnet tool run dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
\ No newline at end of file
+ dotnet test --solution Microsoft.OpenApi.slnx --verbosity normal --coverlet --coverlet-output-format opencover --report-gh
+ dotnet tool run dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
diff --git a/.gitignore b/.gitignore
index 258fee87a..09353aba3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -127,7 +127,7 @@ nCrunchTemp_*
*.mm.*
AutoTest.Net/
-# Web workbench (sass)
+# Sass cache
.sass-cache/
# Installshield output folder
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 9127b1bd5..f7929cb4c 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "3.9.0"
+ ".": "3.10.2"
}
\ No newline at end of file
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 66912fac4..ad2bdf11d 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -49,23 +49,7 @@
"console": "internalConsole",
"stopAtEntry": false,
"requireExactSource": false,
- },
- {
- // Use IntelliSense to find out which attributes exist for C# debugging
- // Use hover for the description of the existing attributes
- // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/main/debugger-launchjson.md
- "name": "Launch Workbench",
- "type": "coreclr",
- "request": "launch",
- "preLaunchTask": "build",
- // If you have changed target frameworks, make sure to update the program path.
- "program": "${workspaceFolder}/src/Microsoft.OpenApi.WorkBench/bin/Debug/net8.0-windows/Microsoft.OpenApi.Workbench.exe",
- "args": [],
- "cwd": "${workspaceFolder}/src/Microsoft.OpenApi.Workbench",
- // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
- "console": "internalConsole",
- "stopAtEntry": false
- },
+ },
{
"name": ".NET Core Attach",
"type": "coreclr",
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 186b10bea..619467ff3 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -6,6 +6,7 @@
},
"cSpell.words": [
"csdl",
- "Hidi"
+ "Hidi",
+ "Xunit"
]
}
\ No newline at end of file
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index e68597ddc..c61ccd31c 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -43,12 +43,6 @@
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
- },
- {
- "label": "workbench",
- "type": "shell",
- "command": "src/Microsoft.OpenApi.WorkBench/bin/Debug/Microsoft.OpenApi.WorkBench.exe",
- "problemMatcher": []
}
]
}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 558c2a93f..0077ae140 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,32 @@
# Changelog
+## [3.10.2](https://github.com/microsoft/OpenAPI.NET/compare/v3.10.1...v3.10.2) (2026-08-20)
+
+
+### Bug Fixes
+
+* duplicate schema example when serializing to v2 ([#3045](https://github.com/microsoft/OpenAPI.NET/issues/3045)) ([50f5a2b](https://github.com/microsoft/OpenAPI.NET/commit/50f5a2bb119089bb48987494d906cb1a95cddbd1))
+
+## [3.10.1](https://github.com/microsoft/OpenAPI.NET/compare/v3.10.0...v3.10.1) (2026-08-19)
+
+
+### Bug Fixes
+
+* circular ref guard ([#3033](https://github.com/microsoft/OpenAPI.NET/issues/3033)) ([8018d40](https://github.com/microsoft/OpenAPI.NET/commit/8018d40aac5fc6ed5e022e6203ee917ecf36472c))
+* harden yaml parsing ([#3027](https://github.com/microsoft/OpenAPI.NET/issues/3027)) ([e0d5e9d](https://github.com/microsoft/OpenAPI.NET/commit/e0d5e9d105ad1a7b29ff6c628793614f5610f048))
+
+## [3.10.0](https://github.com/microsoft/OpenAPI.NET/compare/v3.9.0...v3.10.0) (2026-08-11)
+
+
+### Features
+
+* do not ignore multiple types when serializing to 3.0 ([#2960](https://github.com/microsoft/OpenAPI.NET/issues/2960)) ([ebaf27a](https://github.com/microsoft/OpenAPI.NET/commit/ebaf27ac46f8820e7b817cc33e693632c2fc908a))
+
+
+### Bug Fixes
+
+* bound YAML anchor/alias expansion to prevent OOM (billion laughs) ([#3000](https://github.com/microsoft/OpenAPI.NET/issues/3000)) ([2179326](https://github.com/microsoft/OpenAPI.NET/commit/21793261d6860fb6e607b71fd95c0bd320724764))
+
## [3.9.0](https://github.com/microsoft/OpenAPI.NET/compare/v3.8.0...v3.9.0) (2026-07-15)
diff --git a/Directory.Build.props b/Directory.Build.props
index aec9507b2..1971fa2c6 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -8,17 +8,19 @@
https://github.com/Microsoft/OpenAPI.NET
https://github.com/microsoft/OpenAPI.NET/releases
true
+ OpenSource
+ 2027-09
http://go.microsoft.com/fwlink/?LinkID=288890
https://github.com/Microsoft/OpenAPI.NET
© Microsoft Corporation. All rights reserved.
OpenAPI .NET
- 3.9.0
+ 3.10.2
true
-
+
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 25f1ec589..4e932b975 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,11 +1,13 @@
-FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build-env
WORKDIR /app
COPY ./src ./hidi/src
COPY ./Directory.Build.props ./hidi/Directory.Build.props
COPY ./README.md ./hidi/README.md
WORKDIR /app/hidi
-RUN dotnet publish ./src/Microsoft.OpenApi.Hidi/Microsoft.OpenApi.Hidi.csproj -c Release
+# CI supplies the private feed config as a secret; local builds use default NuGet sources.
+RUN --mount=type=secret,id=nuget_config,target=/app/hidi/NuGet.Config \
+ dotnet publish ./src/Microsoft.OpenApi.Hidi/Microsoft.OpenApi.Hidi.csproj -c Release
FROM mcr.microsoft.com/dotnet/runtime:8.0-jammy-chiseled AS runtime
WORKDIR /app
diff --git a/Microsoft.OpenApi.slnx b/Microsoft.OpenApi.slnx
index f764776bf..8356bb428 100644
--- a/Microsoft.OpenApi.slnx
+++ b/Microsoft.OpenApi.slnx
@@ -9,7 +9,6 @@
-
diff --git a/README.md b/README.md
index 3c77f909f..8622d64d8 100644
--- a/README.md
+++ b/README.md
@@ -91,22 +91,6 @@ In order to test the validity of an OpenApi document, we avail the following too
A commandline tool for validating and transforming OpenAPI descriptions. [Installation guidelines and documentation](https://github.com/microsoft/OpenAPI.NET/blob/main/src/Microsoft.OpenApi.Hidi/readme.md)
-- Microsoft.OpenApi.Workbench
-
- A workbench tool consisting of a GUI where you can test and convert OpenAPI descriptions in both JSON and YAML from v2-->v3 and vice versa.
-
- #### Installation guidelines:
- 1. Clone the repo locally by running this command:
- `git clone https://github.com/microsoft/OpenAPI.NET.git`
- 2. Open the solution file `(.slnx)` in the root of the project with Visual Studio
- 3. Navigate to the `src/Microsoft.OpenApi.Workbench` directory and set it as the startup project
- 4. Run the project and you'll see a GUI pop up resembling the one below:
-
-
- 
-
- 5. Copy and paste your OpenAPI descriptions in the **Input Content** window or paste the path to the descriptions file in the **Input File** textbox and click on `Convert` to render the results.
-
# Contributing
This project welcomes contributions and suggestions. Most contributions require you to agree to a
diff --git a/agents.md b/agents.md
new file mode 100644
index 000000000..abeed736e
--- /dev/null
+++ b/agents.md
@@ -0,0 +1,7 @@
+# Regex handling
+
+- Analyze regex patterns for matching complexity and excessive backtracking, including on long and near-matching inputs. Prefer equivalent patterns with less backtracking when available, while preserving matching semantics and target-framework compatibility.
+- For fixed patterns on modern targets, use source-generated regexes with explicit match timeouts (`GeneratedRegex` under `NET8_0_OR_GREATER`).
+- Use conditional compilation to provide a regular `Regex` with the same pattern and an explicit match timeout for older targets. Do not duplicate regex validation with a manually maintained character scanner.
+- Older-runtime regex matching may still time out under load because timeouts use wall-clock time. If consumers encounter this limitation, recommend upgrading to a modern runtime that uses the source-generated implementation.
+- Keep shared patterns in constants and reference those constants in validation diagnostics and tests.
diff --git a/docs/images/workbench.png b/docs/images/workbench.png
deleted file mode 100644
index 898fe9b5b..000000000
Binary files a/docs/images/workbench.png and /dev/null differ
diff --git a/global.json b/global.json
index d0c1ec64c..7494875e1 100644
--- a/global.json
+++ b/global.json
@@ -1,5 +1,8 @@
{
"sdk": {
- "version": "10.0.302"
+ "version": "10.0.401"
+ },
+ "test": {
+ "runner": "Microsoft.Testing.Platform"
}
}
\ No newline at end of file
diff --git a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report-github.md b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report-github.md
index 6ee697332..95726f79a 100644
--- a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report-github.md
+++ b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report-github.md
@@ -1,20 +1,20 @@
```
BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
-INTEL XEON PLATINUM 8573C 2.30GHz, 1 CPU, 4 logical and 2 physical cores
-.NET SDK 10.0.302
- [Host] : .NET 8.0.29 (8.0.29, 8.0.2926.32403), X64 RyuJIT x86-64-v4
- ShortRun : .NET 8.0.29 (8.0.29, 8.0.2926.32403), X64 RyuJIT x86-64-v4
+AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores
+.NET SDK 10.0.400
+ [Host] : .NET 8.0.30 (8.0.30, 8.0.3026.36720), X64 RyuJIT x86-64-v3
+ ShortRun : .NET 8.0.30 (8.0.30, 8.0.3026.36720), X64 RyuJIT x86-64-v3
Job=ShortRun IterationCount=3 LaunchCount=1
WarmupCount=3
```
-| Method | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated |
-|------------- |---------------:|--------------:|-------------:|----------:|----------:|----------:|-------------:|
-| PetStoreYaml | 477.6 μs | 237.53 μs | 13.02 μs | 3.9063 | - | - | 375.67 KB |
-| PetStoreJson | 193.3 μs | 29.03 μs | 1.59 μs | 1.9531 | - | - | 209.67 KB |
-| GHESYaml | 878,325.2 μs | 555,688.50 μs | 30,459.16 μs | 4000.0000 | 3000.0000 | 1000.0000 | 310814.2 KB |
-| GHESJson | 225,445.4 μs | 35,058.33 μs | 1,921.67 μs | 1000.0000 | - | - | 140426.65 KB |
-| GHESNextYaml | 1,254,063.7 μs | 245,210.30 μs | 13,440.80 μs | 8000.0000 | 6000.0000 | 2000.0000 | 512928.9 KB |
-| GHESNextJson | 588,121.5 μs | 83,680.85 μs | 4,586.83 μs | 5000.0000 | 4000.0000 | 1000.0000 | 344754.7 KB |
+| Method | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated |
+|------------- |---------------:|--------------:|-------------:|-----------:|-----------:|----------:|-------------:|
+| PetStoreYaml | 569.0 μs | 108.17 μs | 5.93 μs | 19.5313 | - | - | 327.8 KB |
+| PetStoreJson | 250.8 μs | 20.85 μs | 1.14 μs | 11.7188 | 1.9531 | - | 209.67 KB |
+| GHESYaml | 831,169.7 μs | 217,393.05 μs | 11,916.05 μs | 18000.0000 | 10000.0000 | 2000.0000 | 267570.8 KB |
+| GHESJson | 367,711.4 μs | 135,955.63 μs | 7,452.19 μs | 9000.0000 | 8000.0000 | 2000.0000 | 140917.06 KB |
+| GHESNextYaml | 1,040,243.2 μs | 167,595.29 μs | 9,186.46 μs | 30000.0000 | 11000.0000 | 2000.0000 | 469507.05 KB |
+| GHESNextJson | 615,173.6 μs | 143,051.39 μs | 7,841.13 μs | 22000.0000 | 10000.0000 | 2000.0000 | 345247.91 KB |
diff --git a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.csv b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.csv
index c5aeb2762..bb632a223 100644
--- a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.csv
+++ b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.csv
@@ -1,7 +1,7 @@
Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,LargeAddressAware,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,Mean,Error,StdDev,Gen0,Gen1,Gen2,Allocated
-PetStoreYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,477.6 μs,237.53 μs,13.02 μs,3.9063,0.0000,0.0000,375.67 KB
-PetStoreJson,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,193.3 μs,29.03 μs,1.59 μs,1.9531,0.0000,0.0000,209.67 KB
-GHESYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"878,325.2 μs","555,688.50 μs","30,459.16 μs",4000.0000,3000.0000,1000.0000,310814.2 KB
-GHESJson,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"225,445.4 μs","35,058.33 μs","1,921.67 μs",1000.0000,0.0000,0.0000,140426.65 KB
-GHESNextYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"1,254,063.7 μs","245,210.30 μs","13,440.80 μs",8000.0000,6000.0000,2000.0000,512928.9 KB
-GHESNextJson,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"588,121.5 μs","83,680.85 μs","4,586.83 μs",5000.0000,4000.0000,1000.0000,344754.7 KB
+PetStoreYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,569.0 μs,108.17 μs,5.93 μs,19.5313,0.0000,0.0000,327.8 KB
+PetStoreJson,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,250.8 μs,20.85 μs,1.14 μs,11.7188,1.9531,0.0000,209.67 KB
+GHESYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"831,169.7 μs","217,393.05 μs","11,916.05 μs",18000.0000,10000.0000,2000.0000,267570.8 KB
+GHESJson,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"367,711.4 μs","135,955.63 μs","7,452.19 μs",9000.0000,8000.0000,2000.0000,140917.06 KB
+GHESNextYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"1,040,243.2 μs","167,595.29 μs","9,186.46 μs",30000.0000,11000.0000,2000.0000,469507.05 KB
+GHESNextJson,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"615,173.6 μs","143,051.39 μs","7,841.13 μs",22000.0000,10000.0000,2000.0000,345247.91 KB
diff --git a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.html b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.html
index f015841f0..c9feca148 100644
--- a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.html
+++ b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.html
@@ -2,7 +2,7 @@
-performance.Descriptions-20260807-144733
+performance.Descriptions-20260819-014451
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/Microsoft.OpenApi.Workbench/Themes/Metro/Metro.MSControls.Toolkit.Implicit.xaml b/src/Microsoft.OpenApi.Workbench/Themes/Metro/Metro.MSControls.Toolkit.Implicit.xaml
deleted file mode 100644
index cb48bd5be..000000000
--- a/src/Microsoft.OpenApi.Workbench/Themes/Metro/Metro.MSControls.Toolkit.Implicit.xaml
+++ /dev/null
@@ -1,939 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/Microsoft.OpenApi.Workbench/Themes/Metro/Styles.Shared.xaml b/src/Microsoft.OpenApi.Workbench/Themes/Metro/Styles.Shared.xaml
deleted file mode 100644
index 783d05199..000000000
--- a/src/Microsoft.OpenApi.Workbench/Themes/Metro/Styles.Shared.xaml
+++ /dev/null
@@ -1,697 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/Microsoft.OpenApi.Workbench/Themes/Metro/Styles.WPF.xaml b/src/Microsoft.OpenApi.Workbench/Themes/Metro/Styles.WPF.xaml
deleted file mode 100644
index 988e787b8..000000000
--- a/src/Microsoft.OpenApi.Workbench/Themes/Metro/Styles.WPF.xaml
+++ /dev/null
@@ -1,830 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Visible
-
-
-
-
-
-
-
-
-
-
- Visible
-
-
-
-
-
-
-
-
-
-
-
-
-
- Visible
-
-
-
-
-
-
-
-
-
-
- Visible
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/Microsoft.OpenApi.Workbench/Themes/Metro/Theme.Colors.xaml b/src/Microsoft.OpenApi.Workbench/Themes/Metro/Theme.Colors.xaml
deleted file mode 100644
index 02babe07a..000000000
--- a/src/Microsoft.OpenApi.Workbench/Themes/Metro/Theme.Colors.xaml
+++ /dev/null
@@ -1,89 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
- #FF282828
- #FF3F3F3F
- #FF565656
- #FF858585
- #FFB9B9B9
- #FFD7D7D7
- #FFE7E7E7
- #FFF4F4F4
- #FFF9F9F9
- #FFFFFFFF
-
-
- #E5FFFFFF
- #BFFFFFFF
- #99FFFFFF
- #72FFFFFF
- #4CFFFFFF
- #00FFFFFF
-
-
- #72000000
- #4C000000
- #26000000
- #00000000
- #66E2E2E2
-
-
- #FF0086AF
- #FF00AADE
- #FF80D5EF
- #FFB2E1EF
- #2600AADE
-
-
- #FFD0284C
- #FFF55E7F
- #FFFFCAD5
-
-
- #FF006481
- #FF8A9B0F
- #FF3E4700
- #FFF14D0F
- #FF8D2E00
- #FF81106B
- #FF410135
- #FFFCA910
- #FF8D4902
- #FF037A54
- #FF003F2A
- #FF154D85
- #FF02284D
- #FF543511
- #FF211303
- #FF89806D
- #FF393225
- #FF58458B
- #FF211347
- #7FB9B9B9
- #33565656
- #7F3F3F3F
- #FF686868
- #8000AADE
- #CC3F3F3F
-
-
-
- #FF0092BE
- #FF00AADE
- #FF2BB9E5
- #FF55C8EB
- #FF80D7F2
-
-
\ No newline at end of file
diff --git a/src/Microsoft.OpenApi.YamlReader/Microsoft.OpenApi.YamlReader.csproj b/src/Microsoft.OpenApi.YamlReader/Microsoft.OpenApi.YamlReader.csproj
index 8e7dcfde9..5a04ce302 100644
--- a/src/Microsoft.OpenApi.YamlReader/Microsoft.OpenApi.YamlReader.csproj
+++ b/src/Microsoft.OpenApi.YamlReader/Microsoft.OpenApi.YamlReader.csproj
@@ -28,20 +28,12 @@
-
- runtime; build; native; contentfiles; analyzers; buildtransitive
- all
-
+
-
- runtime; build; native; contentfiles; analyzers; buildtransitive
- all
-
+
-
-
-
-
+
+
@@ -71,4 +63,4 @@
-
\ No newline at end of file
+
diff --git a/src/Microsoft.OpenApi.YamlReader/OpenApiReaderSettingsExtensions.cs b/src/Microsoft.OpenApi.YamlReader/OpenApiReaderSettingsExtensions.cs
index ee5add0a0..006d1fb3d 100644
--- a/src/Microsoft.OpenApi.YamlReader/OpenApiReaderSettingsExtensions.cs
+++ b/src/Microsoft.OpenApi.YamlReader/OpenApiReaderSettingsExtensions.cs
@@ -1,4 +1,5 @@
-using Microsoft.OpenApi.YamlReader;
+using System;
+using Microsoft.OpenApi.YamlReader;
namespace Microsoft.OpenApi.Reader;
@@ -17,4 +18,18 @@ public static void AddYamlReader(this OpenApiReaderSettings settings)
settings.TryAddReader(OpenApiConstants.Yaml, yamlReader);
settings.TryAddReader(OpenApiConstants.Yml, yamlReader);
}
+
+ ///
+ /// Adds a YAML reader for the specified format using per-reader resource limits.
+ ///
+ /// The settings to add the reader to.
+ /// The YAML reader settings.
+ public static void AddYamlReader(this OpenApiReaderSettings settings, OpenApiYamlReaderSettings yamlSettings)
+ {
+ if (settings is null) throw new ArgumentNullException(nameof(settings));
+ if (yamlSettings is null) throw new ArgumentNullException(nameof(yamlSettings));
+ var yamlReader = new OpenApiYamlReader(yamlSettings);
+ settings.TryAddReader(OpenApiConstants.Yaml, yamlReader);
+ settings.TryAddReader(OpenApiConstants.Yml, yamlReader);
+ }
}
diff --git a/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReader.cs b/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReader.cs
index cea996152..13f17d77d 100644
--- a/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReader.cs
+++ b/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReader.cs
@@ -7,9 +7,8 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.OpenApi.Reader;
-using SharpYaml.Serialization;
+using SharpYaml;
using System;
-using System.Linq;
using System.Text;
namespace Microsoft.OpenApi.YamlReader
@@ -17,10 +16,46 @@ namespace Microsoft.OpenApi.YamlReader
///
/// Reader for parsing YAML files into an OpenAPI document.
///
+ ///
+ /// Input is converted directly from SharpYaml parser events so resource limits are enforced
+ /// before SharpYaml's recursive YAML model loader can compose or expand the document.
+ ///
public class OpenApiYamlReader : IOpenApiReader
{
private const int copyBufferSize = 4096;
private static readonly OpenApiJsonReader _jsonReader = new();
+ private readonly OpenApiYamlReaderSettings _yamlSettings;
+
+ ///
+ /// Initializes a YAML reader using the current legacy global conversion limits.
+ ///
+ public OpenApiYamlReader()
+ : this(new()
+ {
+ MaxDepth = YamlConverter.MaxDepth,
+ MaxNodeCount = YamlConverter.MaxNodeCount,
+ MaxAliasExpansionNodeCount = YamlConverter.MaxAliasExpansionNodeCount,
+ })
+ {
+ }
+
+ ///
+ /// Initializes a YAML reader with immutable per-reader resource limits.
+ ///
+ /// The YAML reader settings.
+ public OpenApiYamlReader(OpenApiYamlReaderSettings settings)
+ {
+ if (settings is null) throw new ArgumentNullException(nameof(settings));
+ settings.Validate();
+ _yamlSettings = new()
+ {
+ MaxDepth = settings.MaxDepth,
+ MaxNodeCount = settings.MaxNodeCount,
+ MaxAliasExpansionNodeCount = settings.MaxAliasExpansionNodeCount,
+ MaxInputByteCount = settings.MaxInputByteCount,
+ MaxScalarLength = settings.MaxScalarLength,
+ };
+ }
///
public async Task ReadAsync(Stream input,
@@ -29,16 +64,33 @@ public async Task ReadAsync(Stream input,
CancellationToken cancellationToken = default)
{
if (input is null) throw new ArgumentNullException(nameof(input));
+ if (settings is null) throw new ArgumentNullException(nameof(settings));
if (input is MemoryStream memoryStream)
{
- return UpdateFormat(Read(memoryStream, location, settings));
+ return ReadCore(memoryStream, location, settings, cancellationToken);
}
else
{
using var preparedStream = new MemoryStream();
- await input.CopyToAsync(preparedStream, copyBufferSize, cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await CopyToMemoryStreamAsync(
+ input,
+ preparedStream,
+ _yamlSettings.MaxInputByteCount,
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (OpenApiReaderException ex)
+ {
+ return new()
+ {
+ Document = null,
+ Diagnostic = CreateDiagnostic(new(ex)),
+ };
+ }
+
preparedStream.Position = 0;
- return UpdateFormat(Read(preparedStream, location, settings));
+ return ReadCore(preparedStream, location, settings, cancellationToken);
}
}
@@ -46,14 +98,22 @@ public async Task ReadAsync(Stream input,
public ReadResult Read(MemoryStream input,
Uri location,
OpenApiReaderSettings settings)
+ => ReadCore(input, location, settings, CancellationToken.None);
+
+ private ReadResult ReadCore(MemoryStream input,
+ Uri location,
+ OpenApiReaderSettings settings,
+ CancellationToken cancellationToken)
{
if (input is null) throw new ArgumentNullException(nameof(input));
if (settings is null) throw new ArgumentNullException(nameof(settings));
+ cancellationToken.ThrowIfCancellationRequested();
JsonNode jsonNode;
// Parse the YAML text in the stream into a sequence of JsonNodes
try
{
+ EnsureInputWithinLimit(input, _yamlSettings.MaxInputByteCount);
#if NET
// this represents net core, net5 and up
using var stream = new StreamReader(input, default, true, -1, settings.LeaveStreamOpen);
@@ -61,33 +121,65 @@ public ReadResult Read(MemoryStream input,
// the implementation differs and results in a null reference exception in NETFX
using var stream = new StreamReader(input, Encoding.UTF8, true, 4096, settings.LeaveStreamOpen);
#endif
- jsonNode = LoadJsonNodesFromYamlDocument(stream);
+ jsonNode = LoadJsonNodesFromYamlDocument(stream, cancellationToken);
}
catch (JsonException ex)
{
- var diagnostic = new OpenApiDiagnostic();
- diagnostic.Errors.Add(new($"#line={ex.LineNumber}", ex.Message));
- diagnostic.Format = OpenApiConstants.Yaml;
return new()
{
Document = null,
- Diagnostic = diagnostic,
+ Diagnostic = CreateDiagnostic(new($"#line={ex.LineNumber}", ex.Message)),
};
}
catch (OpenApiReaderException ex)
{
- var diagnostic = new OpenApiDiagnostic();
- diagnostic.Errors.Add(new(ex));
- diagnostic.Format = OpenApiConstants.Yaml;
return new()
{
Document = null,
- Diagnostic = diagnostic,
+ Diagnostic = CreateDiagnostic(new(ex)),
};
}
+ cancellationToken.ThrowIfCancellationRequested();
return UpdateFormat(Read(jsonNode, location, settings));
}
+
+ private static async Task CopyToMemoryStreamAsync(
+ Stream input,
+ MemoryStream output,
+ uint maxInputByteCount,
+ CancellationToken cancellationToken)
+ {
+ var buffer = new byte[copyBufferSize];
+ long totalBytesRead = 0;
+ int bytesRead;
+ while ((bytesRead = await input.ReadAsync(
+ buffer,
+ 0,
+ buffer.Length,
+ cancellationToken).ConfigureAwait(false)) > 0)
+ {
+ if (bytesRead > (long)maxInputByteCount - totalBytesRead)
+ {
+ throw CreateInputLimitException(maxInputByteCount);
+ }
+
+ await output.WriteAsync(buffer, 0, bytesRead, cancellationToken).ConfigureAwait(false);
+ totalBytesRead += bytesRead;
+ }
+ }
+
+ private static void EnsureInputWithinLimit(MemoryStream input, uint maxInputByteCount)
+ {
+ if (input.Length - input.Position > maxInputByteCount)
+ {
+ throw CreateInputLimitException(maxInputByteCount);
+ }
+ }
+
+ private static OpenApiReaderException CreateInputLimitException(uint maxInputByteCount)
+ => new($"The YAML input exceeds the maximum supported size of {maxInputByteCount} bytes.");
+
private static ReadResult UpdateFormat(ReadResult result)
{
result.Diagnostic ??= new OpenApiDiagnostic();
@@ -114,13 +206,22 @@ public static ReadResult Read(JsonNode jsonNode, Uri location, OpenApiReaderSett
// Parse the YAML
try
{
- using var stream = new StreamReader(input);
- jsonNode = LoadJsonNodesFromYamlDocument(stream);
+ EnsureInputWithinLimit(input, _yamlSettings.MaxInputByteCount);
+#if NET
+ using var stream = new StreamReader(input, default, true, -1, settings?.LeaveStreamOpen ?? false);
+#else
+ using var stream = new StreamReader(input, Encoding.UTF8, true, 4096, settings?.LeaveStreamOpen ?? false);
+#endif
+ jsonNode = LoadJsonNodesFromYamlDocument(stream, CancellationToken.None);
}
catch (JsonException ex)
{
- diagnostic = new();
- diagnostic.Errors.Add(new($"#line={ex.LineNumber}", ex.Message));
+ diagnostic = CreateDiagnostic(new($"#line={ex.LineNumber}", ex.Message));
+ return default;
+ }
+ catch (OpenApiReaderException ex)
+ {
+ diagnostic = CreateDiagnostic(new(ex));
return default;
}
@@ -134,20 +235,34 @@ public static ReadResult Read(JsonNode jsonNode, Uri location, OpenApiReaderSett
}
///
- /// Helper method to turn streams into a sequence of JsonNodes
+ /// Converts the first YAML document in a stream into a JSON node.
///
/// Stream containing YAML formatted text
- /// Instance of a YamlDocument
- static JsonNode LoadJsonNodesFromYamlDocument(TextReader input)
+ /// Propagates notification that parsing should be cancelled.
+ /// The converted JSON node.
+ private JsonNode LoadJsonNodesFromYamlDocument(TextReader input, CancellationToken cancellationToken)
{
- var yamlStream = new YamlStream();
- yamlStream.Load(input);
- if (yamlStream.Documents.Any() && yamlStream.Documents[0].ToJsonNode() is { } jsonNode)
+ try
{
- return jsonNode;
+ return new YamlJsonParser(_yamlSettings).Parse(input, cancellationToken);
}
+ catch (YamlException ex)
+ {
+ var location = ex.Start.Line >= 0
+ ? $" at line {ex.Start.Line + 1}, column {ex.Start.Column + 1}"
+ : string.Empty;
+ throw new OpenApiReaderException($"Unable to parse the YAML document{location}: {ex.Message}", ex);
+ }
+ }
- throw new InvalidOperationException("No documents found in the YAML stream.");
+ private static OpenApiDiagnostic CreateDiagnostic(OpenApiError error)
+ {
+ var diagnostic = new OpenApiDiagnostic
+ {
+ Format = OpenApiConstants.Yaml,
+ };
+ diagnostic.Errors.Add(error);
+ return diagnostic;
}
}
}
diff --git a/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReaderSettings.cs b/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReaderSettings.cs
new file mode 100644
index 000000000..b525f65c6
--- /dev/null
+++ b/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReaderSettings.cs
@@ -0,0 +1,73 @@
+using System;
+
+namespace Microsoft.OpenApi.YamlReader;
+
+///
+/// Configures resource limits for an .
+///
+public sealed class OpenApiYamlReaderSettings
+{
+ ///
+ /// Default maximum number of input bytes read from a single YAML document (128 MiB).
+ /// Bounds the buffered copy of a non-seekable stream, so an endless or oversized response body
+ /// cannot exhaust memory before parsing begins.
+ ///
+ public const uint DefaultMaxInputByteCount = 128 * 1024 * 1024;
+
+ ///
+ /// Default maximum length of a single YAML scalar value (65,536 UTF-16 code units).
+ /// Bounds the cost of any one key, string, number, date or block literal. For reference, the
+ /// longest scalar in the Microsoft Graph beta description is 1,833 code units, so this leaves
+ /// substantial headroom for legitimate documents.
+ ///
+ public const uint DefaultMaxScalarLength = 64 * 1024;
+
+ ///
+ /// Gets or sets the maximum YAML nesting depth.
+ /// Defaults to and cannot exceed
+ /// .
+ ///
+ public uint MaxDepth { get; set; } = YamlConverter.DefaultMaxDepth;
+
+ ///
+ /// Gets or sets the maximum number of JSON nodes materialized from one YAML document.
+ /// Defaults to and cannot exceed
+ /// .
+ ///
+ public uint MaxNodeCount { get; set; } = YamlConverter.DefaultMaxNodeCount;
+
+ ///
+ /// Gets or sets the maximum number of JSON nodes materialized specifically from aliases.
+ /// Defaults to .
+ ///
+ public uint MaxAliasExpansionNodeCount { get; set; } = YamlConverter.DefaultMaxAliasExpansionNodeCount;
+
+ ///
+ /// Gets or sets the maximum number of input bytes read from one YAML document.
+ /// Defaults to .
+ ///
+ public uint MaxInputByteCount { get; set; } = DefaultMaxInputByteCount;
+
+ ///
+ /// Gets or sets the maximum length of one YAML scalar value.
+ /// Defaults to .
+ ///
+ public uint MaxScalarLength { get; set; } = DefaultMaxScalarLength;
+
+ internal void Validate()
+ {
+ YamlConverter.ValidateMaxDepth(MaxDepth, nameof(MaxDepth));
+ YamlConverter.ValidateMaxNodeCount(MaxNodeCount, nameof(MaxNodeCount));
+ ValidatePositive(MaxAliasExpansionNodeCount, nameof(MaxAliasExpansionNodeCount));
+ ValidatePositive(MaxInputByteCount, nameof(MaxInputByteCount));
+ ValidatePositive(MaxScalarLength, nameof(MaxScalarLength));
+ }
+
+ private static void ValidatePositive(uint value, string parameterName)
+ {
+ if (value == 0)
+ {
+ throw new ArgumentOutOfRangeException(parameterName, $"{parameterName} must be greater than zero.");
+ }
+ }
+}
diff --git a/src/Microsoft.OpenApi.YamlReader/PublicAPI.Shipped.txt b/src/Microsoft.OpenApi.YamlReader/PublicAPI.Shipped.txt
index 1d7c628ca..4c258a43e 100644
--- a/src/Microsoft.OpenApi.YamlReader/PublicAPI.Shipped.txt
+++ b/src/Microsoft.OpenApi.YamlReader/PublicAPI.Shipped.txt
@@ -21,3 +21,24 @@ static Microsoft.OpenApi.YamlReader.YamlConverter.MaxDepth.get -> uint
static Microsoft.OpenApi.YamlReader.YamlConverter.MaxDepth.set -> void
static Microsoft.OpenApi.YamlReader.YamlConverter.MaxNodeCount.get -> uint
static Microsoft.OpenApi.YamlReader.YamlConverter.MaxNodeCount.set -> void
+Microsoft.OpenApi.YamlReader.OpenApiYamlReader.OpenApiYamlReader(Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings! settings) -> void
+Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings
+Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxAliasExpansionNodeCount.get -> uint
+Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxAliasExpansionNodeCount.set -> void
+Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxDepth.get -> uint
+Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxDepth.set -> void
+Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxInputByteCount.get -> uint
+Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxInputByteCount.set -> void
+Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxNodeCount.get -> uint
+Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxNodeCount.set -> void
+Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxScalarLength.get -> uint
+Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxScalarLength.set -> void
+Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.OpenApiYamlReaderSettings() -> void
+const Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.DefaultMaxInputByteCount = 134217728 -> uint
+const Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.DefaultMaxScalarLength = 65536 -> uint
+const Microsoft.OpenApi.YamlReader.YamlConverter.DefaultMaxAliasExpansionNodeCount = 5000 -> uint
+const Microsoft.OpenApi.YamlReader.YamlConverter.MaximumAllowedDepth = 256 -> uint
+const Microsoft.OpenApi.YamlReader.YamlConverter.MaximumAllowedNodeCount = 10000000 -> uint
+static Microsoft.OpenApi.Reader.OpenApiReaderSettingsExtensions.AddYamlReader(this Microsoft.OpenApi.Reader.OpenApiReaderSettings! settings, Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings! yamlSettings) -> void
+static Microsoft.OpenApi.YamlReader.YamlConverter.MaxAliasExpansionNodeCount.get -> uint
+static Microsoft.OpenApi.YamlReader.YamlConverter.MaxAliasExpansionNodeCount.set -> void
diff --git a/src/Microsoft.OpenApi.YamlReader/YamlConversionBudget.cs b/src/Microsoft.OpenApi.YamlReader/YamlConversionBudget.cs
new file mode 100644
index 000000000..4b3ed75d9
--- /dev/null
+++ b/src/Microsoft.OpenApi.YamlReader/YamlConversionBudget.cs
@@ -0,0 +1,125 @@
+namespace Microsoft.OpenApi.YamlReader;
+
+///
+/// Tracks the resource budget consumed while materializing a single YAML document.
+///
+///
+///
+/// A budget instance is scoped to one document and is not thread safe. Callers charge the budget
+/// before allocating, so a document that would breach a limit is rejected without the
+/// allocation ever happening.
+///
+///
+/// Every limit breach throws , which the reader converts into an
+/// OpenApiDiagnostic. That is the whole point of this type: hostile input produces a reportable
+/// diagnostic rather than an unrecoverable process failure.
+///
+///
+internal sealed class YamlConversionBudget
+{
+ private readonly uint _maxDepth;
+ private readonly uint _maxNodeCount;
+ private readonly uint _maxAliasExpansionNodeCount;
+ private uint _nodeCount;
+ private uint _aliasExpansionNodeCount;
+
+ ///
+ /// Initializes a budget for a single document.
+ ///
+ /// Maximum nesting depth. Bounds stack and structural growth.
+ /// Maximum total nodes materialized from the document.
+ ///
+ /// Maximum nodes materialized specifically by expanding aliases. This is the anti-amplification
+ /// limit and is deliberately far smaller than : a large document is
+ /// legitimate, but a small document that expands into a large one is not.
+ ///
+ public YamlConversionBudget(uint maxDepth, uint maxNodeCount, uint maxAliasExpansionNodeCount)
+ {
+ _maxDepth = maxDepth;
+ _maxNodeCount = maxNodeCount;
+ _maxAliasExpansionNodeCount = maxAliasExpansionNodeCount;
+ }
+
+ ///
+ /// Charges one node at the supplied depth.
+ ///
+ /// Zero-based nesting depth of the node being materialized.
+ /// The depth or total node limit would be exceeded.
+ public void EnterNode(uint depth)
+ {
+ ValidateDepth(depth);
+ AddNodes(1);
+ }
+
+ ///
+ /// Charges the full cost of expanding an alias, against both the alias budget and the total budget.
+ ///
+ /// Zero-based nesting depth at which the alias appears.
+ /// Number of nodes the alias will materialize when cloned.
+ ///
+ /// Height of the subtree the alias will materialize, where a scalar has height 1.
+ ///
+ /// The depth, alias, or total node limit would be exceeded.
+ ///
+ /// Must be called before the clone is taken. Charging afterwards would allow the very allocation
+ /// this limit exists to prevent.
+ ///
+ public void EnterAlias(uint depth, uint expandedNodeCount, uint expandedHeight)
+ {
+ ValidateDepth(depth);
+
+ // The alias site clears the depth check on its own, but expanding it grafts an entire
+ // subtree at this position. Without charging the grafted height, an anchor defined at a
+ // legal depth can be replayed from another legal depth to produce a tree deeper than the
+ // limit. The underlying YAML parser cannot catch this either, because it sees an alias as
+ // a single event and never re-walks the anchored content.
+ if (expandedHeight > _maxDepth - depth)
+ {
+ throw new OpenApiReaderException($"The YAML document expands an alias to more than the maximum supported nesting depth of {_maxDepth}.");
+ }
+
+ if (expandedNodeCount > _maxAliasExpansionNodeCount - _aliasExpansionNodeCount)
+ {
+ throw new OpenApiReaderException($"The YAML document expands aliases to more than the maximum supported number of nodes ({_maxAliasExpansionNodeCount}).");
+ }
+
+ _aliasExpansionNodeCount += expandedNodeCount;
+ AddNodes(expandedNodeCount);
+ }
+
+ ///
+ /// Validates that a node at is within the depth limit.
+ ///
+ ///
+ /// is zero-based, so a node at that depth occupies level
+ /// depth + 1. Rejecting depth >= _maxDepth therefore admits exactly
+ /// _maxDepth levels, matching the limit enforced by the underlying YAML parser.
+ /// The comparison avoids arithmetic so it cannot overflow.
+ ///
+ private void ValidateDepth(uint depth)
+ {
+ if (depth >= _maxDepth)
+ {
+ throw new OpenApiReaderException($"The YAML document exceeds the maximum supported nesting depth of {_maxDepth}.");
+ }
+ }
+
+ ///
+ /// Charges nodes against the total node budget.
+ ///
+ ///
+ /// The remaining headroom is compared as count > _maxNodeCount - _nodeCount rather than
+ /// _nodeCount + count > _maxNodeCount. Both operands are unsigned, so the latter form could
+ /// wrap and silently admit an over-budget document; the invariant _nodeCount <= _maxNodeCount
+ /// makes the subtraction used here safe from underflow.
+ ///
+ private void AddNodes(uint count)
+ {
+ if (count > _maxNodeCount - _nodeCount)
+ {
+ throw new OpenApiReaderException($"The YAML document expands to more than the maximum supported number of nodes ({_maxNodeCount}). This may indicate a YAML anchor/alias expansion attack.");
+ }
+
+ _nodeCount += count;
+ }
+}
diff --git a/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs b/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs
index 3a09ff876..aa73618f5 100644
--- a/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs
+++ b/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
+using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using SharpYaml;
@@ -12,6 +13,10 @@ namespace Microsoft.OpenApi.YamlReader
///
/// Provides extensions to convert YAML models to JSON models.
///
+ ///
+ /// These limits apply after a SharpYaml model exists. Use
+ /// for untrusted input so limits are enforced before SharpYaml model loading.
+ ///
public static class YamlConverter
{
///
@@ -28,25 +33,38 @@ public static class YamlConverter
///
public const uint DefaultMaxNodeCount = 5_000_000;
+ ///
+ /// Default maximum number of JSON nodes that may be materialized from YAML aliases.
+ ///
+ public const uint DefaultMaxAliasExpansionNodeCount = 5_000;
+
+ ///
+ /// Maximum configurable YAML nesting depth.
+ ///
+ public const uint MaximumAllowedDepth = 256;
+
+ ///
+ /// Maximum configurable number of JSON nodes that may be materialized from a single YAML document.
+ /// Bounds the running node totals so they cannot overflow while accumulating, which would surface as an
+ /// instead of a reportable diagnostic.
+ ///
+ public const uint MaximumAllowedNodeCount = 10_000_000;
+
private static uint _maxDepth = DefaultMaxDepth;
private static uint _maxNodeCount = DefaultMaxNodeCount;
+ private static uint _maxAliasExpansionNodeCount = DefaultMaxAliasExpansionNodeCount;
///
/// Gets or sets the maximum nesting depth allowed when converting a YAML node graph into JSON nodes.
- /// Defaults to . Raise this if legitimate deeply nested documents are
- /// being rejected, or lower it to fail faster when only shallow documents are expected.
+ /// Defaults to and cannot exceed the library's safe depth ceiling.
///
- /// Thrown when set to zero.
+ /// Thrown when set outside the supported range.
public static uint MaxDepth
{
get => _maxDepth;
set
{
- if (value == 0)
- {
- throw new ArgumentOutOfRangeException(nameof(value), "MaxDepth must be greater than zero.");
- }
-
+ ValidateMaxDepth(value, nameof(value));
_maxDepth = value;
}
}
@@ -55,50 +73,51 @@ public static uint MaxDepth
/// Gets or sets the maximum number of JSON nodes that may be materialized from a single YAML document.
/// Defaults to , guarding against YAML anchor/alias expansion
/// ("billion laughs") attacks. Raise this if legitimate large documents are being rejected, or lower
- /// it to fail faster when only small documents are expected.
+ /// it to fail faster when only small documents are expected. Cannot exceed
+ /// .
///
- /// Thrown when set to zero.
+ /// Thrown when set outside the supported range.
public static uint MaxNodeCount
{
get => _maxNodeCount;
set
{
- if (value == 0)
- {
- throw new ArgumentOutOfRangeException(nameof(value), "MaxNodeCount must be greater than zero.");
- }
-
+ ValidateMaxNodeCount(value, nameof(value));
_maxNodeCount = value;
}
}
///
- /// Tracks and enforces resource limits while converting a YAML node graph into JSON nodes,
- /// failing fast when a hostile document would otherwise exhaust memory or the stack.
+ /// Gets or sets the maximum number of JSON nodes that may be materialized from YAML aliases.
///
- private sealed class YamlConversionBudget
+ /// Thrown when set to zero.
+ public static uint MaxAliasExpansionNodeCount
{
- private readonly uint _maxDepth;
- private readonly uint _maxNodeCount;
- private uint _nodeCount;
-
- public YamlConversionBudget(uint maxDepth, uint maxNodeCount)
+ get => _maxAliasExpansionNodeCount;
+ set
{
- _maxDepth = maxDepth;
- _maxNodeCount = maxNodeCount;
+ if (value == 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(value), "MaxAliasExpansionNodeCount must be greater than zero.");
+ }
+
+ _maxAliasExpansionNodeCount = value;
}
+ }
- public void EnterNode(uint depth)
+ internal static void ValidateMaxDepth(uint value, string parameterName)
+ {
+ if (value == 0 || value > MaximumAllowedDepth)
{
- if (depth > _maxDepth)
- {
- throw new OpenApiReaderException($"The YAML document exceeds the maximum supported nesting depth of {_maxDepth}.");
- }
+ throw new ArgumentOutOfRangeException(parameterName, $"MaxDepth must be between 1 and {MaximumAllowedDepth}.");
+ }
+ }
- if (++_nodeCount > _maxNodeCount)
- {
- throw new OpenApiReaderException($"The YAML document expands to more than the maximum supported number of nodes ({_maxNodeCount}). This may indicate a YAML anchor/alias expansion (billion laughs) attack.");
- }
+ internal static void ValidateMaxNodeCount(uint value, string parameterName)
+ {
+ if (value == 0 || value > MaximumAllowedNodeCount)
+ {
+ throw new ArgumentOutOfRangeException(parameterName, $"MaxNodeCount must be between 1 and {MaximumAllowedNodeCount}.");
}
}
@@ -130,19 +149,7 @@ public static JsonNode ToJsonNode(this YamlDocument yaml)
/// Thrown for YAML that is not compatible with JSON.
public static JsonNode ToJsonNode(this YamlNode yaml)
{
- return yaml.ToJsonNode(new YamlConversionBudget(MaxDepth, MaxNodeCount), 0);
- }
-
- private static JsonNode ToJsonNode(this YamlNode yaml, YamlConversionBudget budget, uint depth)
- {
- budget.EnterNode(depth);
- return yaml switch
- {
- YamlMappingNode map => map.ToJsonObject(budget, depth),
- YamlSequenceNode seq => seq.ToJsonArray(budget, depth),
- YamlScalarNode scalar => scalar.ToJsonValue(),
- _ => throw new NotSupportedException("This yaml isn't convertible to JSON")
- };
+ return CreateConversionContext().Convert(yaml, 0).Node;
}
///
@@ -173,19 +180,7 @@ public static YamlNode ToYamlNode(this JsonNode json)
///
public static JsonObject ToJsonObject(this YamlMappingNode yaml)
{
- return yaml.ToJsonObject(new YamlConversionBudget(MaxDepth, MaxNodeCount), 0);
- }
-
- private static JsonObject ToJsonObject(this YamlMappingNode yaml, YamlConversionBudget budget, uint depth)
- {
- var node = new JsonObject();
- foreach (var keyValuePair in yaml)
- {
- var key = ((YamlScalarNode)keyValuePair.Key).Value!;
- node[key] = keyValuePair.Value.ToJsonNode(budget, depth + 1);
- }
-
- return node;
+ return (JsonObject)CreateConversionContext().Convert(yaml, 0).Node;
}
private static YamlMappingNode ToYamlMapping(this JsonObject obj)
@@ -203,18 +198,7 @@ private static YamlMappingNode ToYamlMapping(this JsonObject obj)
///
public static JsonArray ToJsonArray(this YamlSequenceNode yaml)
{
- return yaml.ToJsonArray(new YamlConversionBudget(MaxDepth, MaxNodeCount), 0);
- }
-
- private static JsonArray ToJsonArray(this YamlSequenceNode yaml, YamlConversionBudget budget, uint depth)
- {
- var node = new JsonArray();
- foreach (var value in yaml)
- {
- node.Add(value.ToJsonNode(budget, depth + 1));
- }
-
- return node;
+ return (JsonArray)CreateConversionContext().Convert(yaml, 0).Node;
}
private static YamlSequenceNode ToYamlSequence(this JsonArray arr)
@@ -230,19 +214,150 @@ private static YamlSequenceNode ToYamlSequence(this JsonArray arr)
"NULL"
};
- private static JsonValue ToJsonValue(this YamlScalarNode yaml)
+ private static YamlConversionContext CreateConversionContext()
{
- return yaml.Style switch
+ var maxDepth = MaxDepth;
+ var maxNodeCount = MaxNodeCount;
+ var maxAliasExpansionNodeCount = MaxAliasExpansionNodeCount;
+ ValidateMaxDepth(maxDepth, nameof(MaxDepth));
+ return new(
+ new YamlConversionBudget(maxDepth, maxNodeCount, maxAliasExpansionNodeCount));
+ }
+
+ internal static JsonValue ToJsonValue(string? value, ScalarStyle style)
+ {
+ return style switch
{
- ScalarStyle.Plain when decimal.TryParse(yaml.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) => JsonValue.Create(d),
- ScalarStyle.Plain when bool.TryParse(yaml.Value, out var b) => JsonValue.Create(b),
- ScalarStyle.Plain when YamlNullRepresentations.Contains(yaml.Value) => (JsonValue)JsonNullSentinel.JsonNull.DeepClone(),
- ScalarStyle.Plain => JsonValue.Create(yaml.Value),
- ScalarStyle.SingleQuoted or ScalarStyle.DoubleQuoted or ScalarStyle.Literal or ScalarStyle.Folded or ScalarStyle.Any => JsonValue.Create(yaml.Value),
- _ => throw new ArgumentOutOfRangeException(nameof(yaml)),
+ ScalarStyle.Plain when decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) => JsonValue.Create(d),
+ ScalarStyle.Plain when bool.TryParse(value, out var b) => JsonValue.Create(b),
+ ScalarStyle.Plain when value is not null && YamlNullRepresentations.Contains(value) => (JsonValue)JsonNullSentinel.JsonNull.DeepClone(),
+ ScalarStyle.Plain => JsonValue.Create(value ?? string.Empty),
+ ScalarStyle.SingleQuoted or ScalarStyle.DoubleQuoted or ScalarStyle.Literal or ScalarStyle.Folded or ScalarStyle.Any => JsonValue.Create(value ?? string.Empty),
+ _ => throw new ArgumentOutOfRangeException(nameof(style)),
};
}
+ private sealed class YamlConversionContext
+ {
+ private readonly YamlConversionBudget _budget;
+ private readonly Dictionary _completed = new(ReferenceEqualityComparer.Instance);
+ private readonly HashSet _active = new(ReferenceEqualityComparer.Instance);
+
+ public YamlConversionContext(YamlConversionBudget budget)
+ {
+ _budget = budget;
+ }
+
+ public MaterializedNode Convert(YamlNode yaml, uint depth)
+ {
+ try
+ {
+ RuntimeHelpers.EnsureSufficientExecutionStack();
+ }
+ catch (InsufficientExecutionStackException ex)
+ {
+ throw new OpenApiReaderException("The YAML node graph is too deeply nested to convert safely.", ex);
+ }
+
+ if (_active.Contains(yaml))
+ {
+ throw new OpenApiReaderException("The YAML node graph contains a cycle.");
+ }
+
+ if (_completed.TryGetValue(yaml, out var completed))
+ {
+ _budget.EnterAlias(depth, completed.NodeCount, completed.Height);
+ return new(completed.Node.DeepClone(), completed.NodeCount, completed.Height);
+ }
+
+ _budget.EnterNode(depth);
+ _active.Add(yaml);
+ try
+ {
+ var materialized = yaml switch
+ {
+ YamlMappingNode map => ConvertMapping(map, depth),
+ YamlSequenceNode sequence => ConvertSequence(sequence, depth),
+ YamlScalarNode scalar => new MaterializedNode(ToJsonValue(scalar.Value, scalar.Style), 1, 1),
+ _ => throw new NotSupportedException("This yaml isn't convertible to JSON")
+ };
+ _completed.Add(yaml, materialized);
+ return materialized;
+ }
+ finally
+ {
+ _active.Remove(yaml);
+ }
+ }
+
+ private MaterializedNode ConvertMapping(YamlMappingNode yaml, uint depth)
+ {
+ var node = new JsonObject();
+ uint nodeCount = 1;
+ uint maxChildHeight = 0;
+ foreach (var keyValuePair in yaml)
+ {
+ if (keyValuePair.Key is not YamlScalarNode scalarKey || scalarKey.Value is null)
+ {
+ throw new OpenApiReaderException("YAML mapping keys must be scalar values.");
+ }
+
+ if (node.ContainsKey(scalarKey.Value))
+ {
+ throw new OpenApiReaderException($"The YAML mapping contains the duplicate key '{scalarKey.Value}'.");
+ }
+
+ var child = Convert(keyValuePair.Value, depth + 1);
+ node.Add(scalarKey.Value, child.Node);
+ nodeCount = checked(nodeCount + child.NodeCount);
+ maxChildHeight = Math.Max(maxChildHeight, child.Height);
+ }
+
+ return new(node, nodeCount, maxChildHeight + 1);
+ }
+
+ private MaterializedNode ConvertSequence(YamlSequenceNode yaml, uint depth)
+ {
+ var node = new JsonArray();
+ uint nodeCount = 1;
+ uint maxChildHeight = 0;
+ foreach (var value in yaml)
+ {
+ var child = Convert(value, depth + 1);
+ node.Add(child.Node);
+ nodeCount = checked(nodeCount + child.NodeCount);
+ maxChildHeight = Math.Max(maxChildHeight, child.Height);
+ }
+
+ return new(node, nodeCount, maxChildHeight + 1);
+ }
+ }
+
+ private sealed class MaterializedNode
+ {
+ public MaterializedNode(JsonNode node, uint nodeCount, uint height)
+ {
+ Node = node;
+ NodeCount = nodeCount;
+ Height = height;
+ }
+
+ public JsonNode Node { get; }
+ public uint NodeCount { get; }
+
+ /// Number of levels in this subtree, where a scalar has height 1.
+ public uint Height { get; }
+ }
+
+ private sealed class ReferenceEqualityComparer : IEqualityComparer where T : class
+ {
+ public static ReferenceEqualityComparer Instance { get; } = new();
+
+ public bool Equals(T? x, T? y) => ReferenceEquals(x, y);
+
+ public int GetHashCode(T obj) => RuntimeHelpers.GetHashCode(obj);
+ }
+
private static bool NeedsQuoting(string value) =>
string.IsNullOrEmpty(value) ||
decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out _) ||
diff --git a/src/Microsoft.OpenApi.YamlReader/YamlJsonParser.cs b/src/Microsoft.OpenApi.YamlReader/YamlJsonParser.cs
new file mode 100644
index 000000000..c7a3aab90
--- /dev/null
+++ b/src/Microsoft.OpenApi.YamlReader/YamlJsonParser.cs
@@ -0,0 +1,291 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text.Json.Nodes;
+using System.Threading;
+using SharpYaml;
+using SharpYaml.Events;
+
+namespace Microsoft.OpenApi.YamlReader;
+
+///
+/// Iteratively materializes the first YAML document from parser events under resource limits.
+///
+internal sealed class YamlJsonParser
+{
+ private const int LookAheadBufferCapacity = 8;
+
+ private readonly YamlConversionBudget _budget;
+ private readonly Dictionary _anchors = new(StringComparer.Ordinal);
+ private readonly HashSet _activeAnchors = new(StringComparer.Ordinal);
+ private readonly Stack _containers = new();
+ private readonly uint _maxScalarLength;
+ private readonly uint _maxDepth;
+ private JsonNode? _root;
+
+ public YamlJsonParser(OpenApiYamlReaderSettings settings)
+ {
+ _budget = new(settings.MaxDepth, settings.MaxNodeCount, settings.MaxAliasExpansionNodeCount);
+ _maxScalarLength = settings.MaxScalarLength;
+ _maxDepth = settings.MaxDepth;
+ }
+
+ public JsonNode Parse(TextReader input, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var cancellationReader = new CancellationTokenTextReader(input, cancellationToken);
+
+ // SharpYaml applies its own nesting limit, defaulting to 64. Passing the configured limit
+ // keeps the two enforcement points in agreement; leaving it unset would silently cap every
+ // reader at 64 regardless of MaxDepth, making values above the default a no-op.
+ var parser = new Parser(
+ new LookAheadBuffer(cancellationReader, LookAheadBufferCapacity),
+ (int)_maxDepth);
+ var documentStarted = false;
+
+ while (true)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!parser.MoveNext())
+ {
+ break;
+ }
+
+ switch (parser.Current)
+ {
+ case StreamStart:
+ break;
+ case DocumentStart:
+ documentStarted = true;
+ break;
+ case MappingStart mappingStart:
+ StartContainer(new JsonObject(), mappingStart.Anchor);
+ break;
+ case SequenceStart sequenceStart:
+ StartContainer(new JsonArray(), sequenceStart.Anchor);
+ break;
+ case Scalar scalar:
+ AddScalar(scalar, cancellationToken);
+ break;
+ case AnchorAlias alias:
+ AddAlias(alias, cancellationToken);
+ break;
+ case MappingEnd:
+ case SequenceEnd:
+ EndContainer();
+ break;
+ case DocumentEnd:
+ return _root ?? throw new OpenApiReaderException("No content found in the YAML document.");
+ case StreamEnd:
+ if (documentStarted)
+ {
+ return _root ?? throw new OpenApiReaderException("No content found in the YAML document.");
+ }
+
+ throw new OpenApiReaderException("No documents found in the YAML stream.");
+ default:
+ throw new OpenApiReaderException(
+ $"Unsupported YAML parser event '{parser.Current?.GetType().Name ?? ""}'.");
+ }
+ }
+
+ throw new OpenApiReaderException("No documents found in the YAML stream.");
+ }
+
+ private void StartContainer(JsonNode container, string? anchor)
+ {
+ _budget.EnterNode((uint)_containers.Count);
+ RegisterActiveAnchor(anchor);
+ _containers.Push(new(container, anchor));
+ }
+
+ private void AddScalar(Scalar scalar, CancellationToken cancellationToken)
+ {
+ if (scalar.Value is { } value && value.Length > _maxScalarLength)
+ {
+ throw new OpenApiReaderException(
+ $"The YAML scalar exceeds the maximum supported length of {_maxScalarLength} characters.");
+ }
+
+ _budget.EnterNode((uint)_containers.Count);
+ cancellationToken.ThrowIfCancellationRequested();
+ var materialized = new MaterializedNode(
+ YamlConverter.ToJsonValue(scalar.Value, scalar.Style),
+ 1,
+ 1,
+ scalar.Value);
+
+ RegisterCompletedAnchor(scalar.Anchor, materialized);
+ AddNode(materialized);
+ }
+
+ private void AddAlias(AnchorAlias alias, CancellationToken cancellationToken)
+ {
+ if (_activeAnchors.Contains(alias.Value))
+ {
+ throw new OpenApiReaderException($"The YAML alias '*{alias.Value}' forms a cycle.");
+ }
+
+ if (!_anchors.TryGetValue(alias.Value, out var anchor))
+ {
+ throw new OpenApiReaderException($"The YAML alias '*{alias.Value}' refers to an unknown anchor.");
+ }
+
+ _budget.EnterAlias((uint)_containers.Count, anchor.NodeCount, anchor.Height);
+ cancellationToken.ThrowIfCancellationRequested();
+ AddNode(new(anchor.Node.DeepClone(), anchor.NodeCount, anchor.Height, anchor.MappingKey));
+ }
+
+ private void EndContainer()
+ {
+ if (_containers.Count == 0)
+ {
+ throw new OpenApiReaderException("The YAML document contains an unexpected container terminator.");
+ }
+
+ var frame = _containers.Pop();
+ if (frame.PendingKey is not null)
+ {
+ throw new OpenApiReaderException("The YAML mapping contains a key without a value.");
+ }
+
+ var materialized = new MaterializedNode(frame.Container, frame.NodeCount, frame.MaxChildHeight + 1, null);
+ if (frame.Anchor is not null)
+ {
+ _activeAnchors.Remove(frame.Anchor);
+ _anchors.Add(frame.Anchor, materialized);
+ }
+
+ AddNode(materialized);
+ }
+
+ private void AddNode(MaterializedNode materialized)
+ {
+ if (_containers.Count == 0)
+ {
+ if (_root is not null)
+ {
+ throw new OpenApiReaderException("The YAML document contains more than one root node.");
+ }
+
+ _root = materialized.Node;
+ return;
+ }
+
+ var frame = _containers.Peek();
+ switch (frame.Container)
+ {
+ case JsonArray array:
+ array.Add(materialized.Node);
+ frame.NodeCount = checked(frame.NodeCount + materialized.NodeCount);
+ frame.MaxChildHeight = Math.Max(frame.MaxChildHeight, materialized.Height);
+ break;
+ case JsonObject when frame.PendingKey is null:
+ frame.PendingKey = materialized.MappingKey
+ ?? throw new OpenApiReaderException("YAML mapping keys must be scalar values.");
+ break;
+ case JsonObject map:
+ if (map.ContainsKey(frame.PendingKey))
+ {
+ throw new OpenApiReaderException($"The YAML mapping contains the duplicate key '{frame.PendingKey}'.");
+ }
+
+ map.Add(frame.PendingKey, materialized.Node);
+ frame.PendingKey = null;
+ frame.NodeCount = checked(frame.NodeCount + materialized.NodeCount);
+ frame.MaxChildHeight = Math.Max(frame.MaxChildHeight, materialized.Height);
+ break;
+ }
+ }
+
+ private void RegisterActiveAnchor(string? anchor)
+ {
+ if (anchor is null || anchor.Length == 0)
+ {
+ return;
+ }
+
+ if (_anchors.ContainsKey(anchor) || !_activeAnchors.Add(anchor))
+ {
+ throw new OpenApiReaderException($"The YAML document contains the duplicate anchor '&{anchor}'.");
+ }
+ }
+
+ private void RegisterCompletedAnchor(string? anchor, MaterializedNode materialized)
+ {
+ if (anchor is null || anchor.Length == 0)
+ {
+ return;
+ }
+
+ if (_anchors.ContainsKey(anchor) || _activeAnchors.Contains(anchor))
+ {
+ throw new OpenApiReaderException($"The YAML document contains the duplicate anchor '&{anchor}'.");
+ }
+
+ _anchors.Add(anchor, materialized);
+ }
+
+ private sealed class ContainerFrame(JsonNode container, string? anchor)
+ {
+ public JsonNode Container { get; } = container;
+ public string? Anchor { get; } = anchor;
+ public string? PendingKey { get; set; }
+ public uint NodeCount { get; set; } = 1;
+
+ /// Height of the tallest child added so far; 0 while the container is empty.
+ public uint MaxChildHeight { get; set; }
+ }
+
+ private sealed class MaterializedNode
+ {
+ public MaterializedNode(JsonNode node, uint nodeCount, uint height, string? mappingKey)
+ {
+ Node = node;
+ NodeCount = nodeCount;
+ Height = height;
+ MappingKey = mappingKey;
+ }
+
+ public JsonNode Node { get; }
+ public uint NodeCount { get; }
+
+ /// Number of levels in this subtree, where a scalar has height 1.
+ public uint Height { get; }
+
+ public string? MappingKey { get; }
+ }
+
+ private sealed class CancellationTokenTextReader(TextReader innerReader, CancellationToken cancellationToken) : TextReader
+ {
+ public override int Peek()
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return innerReader.Peek();
+ }
+
+ public override int Read()
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return innerReader.Read();
+ }
+
+ public override int Read(char[] buffer, int index, int count)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return innerReader.Read(buffer, index, count);
+ }
+
+ public override string? ReadLine()
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return base.ReadLine();
+ }
+
+ public override string ReadToEnd()
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return base.ReadToEnd();
+ }
+ }
+}
diff --git a/src/Microsoft.OpenApi/Expressions/CompositeExpression.cs b/src/Microsoft.OpenApi/Expressions/CompositeExpression.cs
index cac554318..db2a97caf 100644
--- a/src/Microsoft.OpenApi/Expressions/CompositeExpression.cs
+++ b/src/Microsoft.OpenApi/Expressions/CompositeExpression.cs
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
@@ -10,10 +11,17 @@ namespace Microsoft.OpenApi
///
/// String literal with embedded expressions
///
- public class CompositeExpression : RuntimeExpression
+ public partial class CompositeExpression : RuntimeExpression
{
private readonly string template;
- private readonly Regex expressionPattern = new(@"{(?\$[^}]*)");
+ private const string ExpressionPattern = @"{(?\$[^}]*)";
+
+#if NET8_0_OR_GREATER
+ [GeneratedRegex(ExpressionPattern, RegexOptions.None, matchTimeoutMilliseconds: 100)]
+ private static partial Regex ExpressionRegex();
+#else
+ private static readonly Regex ExpressionRegex = new(ExpressionPattern, RegexOptions.None, TimeSpan.FromMilliseconds(100));
+#endif
///
/// Expressions embedded into string literal
@@ -24,12 +32,17 @@ public class CompositeExpression : RuntimeExpression
/// Create a composite expression from a string literal with an embedded expression
///
///
+ /// Extracting embedded expressions exceeds the regex match timeout.
public CompositeExpression(string expression)
{
template = expression;
// Extract subexpressions and convert to RuntimeExpressions
- var matches = expressionPattern.Matches(expression);
+#if NET8_0_OR_GREATER
+ var matches = ExpressionRegex().Matches(expression);
+#else
+ var matches = ExpressionRegex.Matches(expression);
+#endif
foreach (var item in matches.Cast())
{
diff --git a/src/Microsoft.OpenApi/Microsoft.OpenApi.csproj b/src/Microsoft.OpenApi/Microsoft.OpenApi.csproj
index 81729fa20..8e1ebb555 100644
--- a/src/Microsoft.OpenApi/Microsoft.OpenApi.csproj
+++ b/src/Microsoft.OpenApi/Microsoft.OpenApi.csproj
@@ -24,13 +24,10 @@
true
-
- runtime; build; native; contentfiles; analyzers; buildtransitive
- all
-
-
-
-
+
+
+
+
diff --git a/src/Microsoft.OpenApi/Models/OpenApiDocument.cs b/src/Microsoft.OpenApi/Models/OpenApiDocument.cs
index 68259a618..03086600d 100644
--- a/src/Microsoft.OpenApi/Models/OpenApiDocument.cs
+++ b/src/Microsoft.OpenApi/Models/OpenApiDocument.cs
@@ -857,6 +857,34 @@ static bool AddToDictionary(IDictionary dict, string key
// Register only if it was actually added to the collection
return added && (Workspace?.RegisterComponentForDocument(this, componentToRegister, id) ?? false);
}
+
+ ///
+ /// Finds an operation in the document by its operation ID.
+ ///
+ /// The operation ID to search for.
+ /// The matching , or if not found.
+ public OpenApiOperation? GetOperationById(string operationId)
+ {
+ Utils.CheckArgumentNullOrEmpty(operationId);
+
+ return GetOperationByIdFromPathItems(Paths, operationId) ??
+ (Webhooks is not null ?
+ GetOperationByIdFromPathItems(Webhooks, operationId) : null);
+ }
+
+ private static OpenApiOperation? GetOperationByIdFromPathItems(IDictionary pathItems, string operationId)
+ {
+ foreach (var pathItem in pathItems.Values)
+ {
+ if (pathItem.Operations is null) continue;
+ foreach (var operation in pathItem.Operations.Values)
+ {
+ if (string.Equals(operation.OperationId, operationId, StringComparison.Ordinal))
+ return operation;
+ }
+ }
+ return null;
+ }
}
internal class FindSchemaReferences : OpenApiVisitorBase
diff --git a/src/Microsoft.OpenApi/Models/OpenApiResponse.cs b/src/Microsoft.OpenApi/Models/OpenApiResponse.cs
index 4ee39336e..6bd02af25 100644
--- a/src/Microsoft.OpenApi/Models/OpenApiResponse.cs
+++ b/src/Microsoft.OpenApi/Models/OpenApiResponse.cs
@@ -73,7 +73,7 @@ public virtual void SerializeAsV3(IOpenApiWriter writer)
SerializeInternal(writer, OpenApiSpecVersion.OpenApi3_0, (writer, element) => element.SerializeAsV3(writer));
}
- private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version,
+ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version,
Action callback)
{
Utils.CheckArgumentNull(writer);
@@ -87,7 +87,7 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version
}
// description
- writer.WriteRequiredProperty(OpenApiConstants.Description, Description);
+ writer.WriteProperty(OpenApiConstants.Description, Description);
// headers
writer.WriteOptionalMap(OpenApiConstants.Headers, Headers, callback);
@@ -120,7 +120,7 @@ public virtual void SerializeAsV2(IOpenApiWriter writer)
writer.WriteStartObject();
// description
- writer.WriteRequiredProperty(OpenApiConstants.Description, Description);
+ writer.WriteProperty(OpenApiConstants.Description, Description);
var extensionsClone = Extensions is not null ? new Dictionary(Extensions) : null;
@@ -177,7 +177,7 @@ public virtual void SerializeAsV2(IOpenApiWriter writer)
// so remove it from the cloned collection so we don't write it again.
extensionsClone?.Remove(key);
}
- }
+ }
}
}
diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs
index 34592d0e6..79bd88dd5 100644
--- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs
+++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs
@@ -964,10 +964,7 @@ private void SerializeAsV2(
writer.WriteOptionalObject(OpenApiConstants.ExternalDocs, ExternalDocs, (w, s) => s.SerializeAsV2(w));
// example
-#pragma warning disable CS0618
writer.WriteOptionalObject(OpenApiConstants.Example, GetCompatibilityExample(), (w, e) => w.WriteAny(e));
- writer.WriteOptionalObject(OpenApiConstants.Example, Example, (w, e) => w.WriteAny(e));
-#pragma warning restore CS0618
// x-nullable extension
SerializeNullable(writer, OpenApiSpecVersion.OpenApi2_0);
diff --git a/src/Microsoft.OpenApi/Models/References/BaseOpenApiReferenceHolder.cs b/src/Microsoft.OpenApi/Models/References/BaseOpenApiReferenceHolder.cs
index ed2936bd4..2af750476 100644
--- a/src/Microsoft.OpenApi/Models/References/BaseOpenApiReferenceHolder.cs
+++ b/src/Microsoft.OpenApi/Models/References/BaseOpenApiReferenceHolder.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Runtime.CompilerServices;
namespace Microsoft.OpenApi;
///
@@ -10,6 +11,11 @@ namespace Microsoft.OpenApi;
/// The type for the reference holding the additional fields and annotations
public abstract class BaseOpenApiReferenceHolder : IOpenApiReferenceHolder where T : class, IOpenApiReferenceable, U where U : IOpenApiReferenceable, IOpenApiSerializable where V : BaseOpenApiReference, new()
{
+ [ThreadStatic]
+ private static HashSet>? t_activeReferenceAccesses;
+ [ThreadStatic]
+ private static HashSet>? t_activeTargetActions;
+
///
public virtual U? Target
{
@@ -19,28 +25,129 @@ public virtual U? Target
return Reference.HostDocument.ResolveReferenceTo(Reference, this as IOpenApiSchema);
}
}
+
+ ///
+ /// Gets a value from the resolved target while detecting cycles in delegated member access.
+ ///
+ /// The type of value to get from the target.
+ /// Selects the value from the resolved target.
+ /// The selected value, or the default value when the target cannot be resolved.
+ ///
+ /// The guard remains active while reads the target member. This covers
+ /// the complete delegated call chain without changing the immediate-resolution semantics of
+ /// or walking an acyclic chain more than once.
+ ///
+ private protected TResult GetFromTarget(Func selector)
+ {
+ Utils.CheckArgumentNull(selector);
+ return ExecuteWithReferenceAccessGuard(this, () =>
+ {
+ return Target is { } target ? selector(target) : default!;
+ });
+ }
+
+ ///
+ /// Executes an action against the resolved target while detecting cycles in delegated access.
+ ///
+ /// The action to execute against the resolved target.
+ private protected void ApplyToTarget(Action action)
+ {
+ Utils.CheckArgumentNull(action);
+ ExecuteWithTargetActionGuard