From 88f8772cf4c325973f7c7d259d517d9e698cb654 Mon Sep 17 00:00:00 2001 From: devlooped-bot Date: Thu, 20 Jun 2024 00:16:33 +0000 Subject: [PATCH] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Bump=20files=20with=20dotn?= =?UTF-8?q?et-file=20sync=20#=20devlooped/oss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename sample assemblies for nicer display https://github.com/devlooped/oss/commit/93df7c7 - Add our implementation of JWT manifest reading and reporting https://github.com/devlooped/oss/commit/a0ae727 - Fix path to jwk.ps1 alongside the SponsorLink.targets https://github.com/devlooped/oss/commit/c4830fc - Minor code simplification https://github.com/devlooped/oss/commit/cf154d5 - Remove dependency on ThisAssembly https://github.com/devlooped/oss/commit/c879f25 - Minimal docs on consuming https://github.com/devlooped/oss/commit/827a1d1 - Add nullable and generated code annotations https://github.com/devlooped/oss/commit/b2a11fa - Improve versioning of sample package https://github.com/devlooped/oss/commit/3b943f5 - Whitespace and formatting https://github.com/devlooped/oss/commit/d74f511 - Dynamically fetch devlooped JWK from github https://github.com/devlooped/oss/commit/55124bc - Fix scenario where multiple packages share product name https://github.com/devlooped/oss/commit/23f83bd - Add targets for inclusion from tests https://github.com/devlooped/oss/commit/81ba912 - Simplify and unify manifest reading implementation https://github.com/devlooped/oss/commit/4fca946 - SponsorLink-enabled analyzers need copylocal https://github.com/devlooped/oss/commit/7593657 - Fix dependabot group for tests https://github.com/devlooped/oss/commit/49661db - Make sure build runs before pack https://github.com/devlooped/oss/commit/ede013a - Switch to PackOnBuild=true and remove pack step https://github.com/devlooped/oss/commit/6e7a3ab - Upload binlog artifact on debug runs https://github.com/devlooped/oss/commit/a67ae78 - Set env:gh_token if present as secret https://github.com/devlooped/oss/commit/97ebd18 - Update to checkout@v4 https://github.com/devlooped/oss/commit/5fb1723 - Cleanup build and publish to use VersionLabel https://github.com/devlooped/oss/commit/14deaea - Update dotnet-file.yml with fix to create pull request action https://github.com/devlooped/oss/commit/11a331d - Don't add random wait on manual dotnet-file runs https://github.com/devlooped/oss/commit/7afe350 - Update .gitignore with BenchmarkDotNet artifacts default path https://github.com/devlooped/oss/commit/e20e906 - Remove whitespace and add results to ignore https://github.com/devlooped/oss/commit/ef852e7 - Only ignore App folder directly under the root https://github.com/devlooped/oss/commit/02811fa - NoTargets/Traversal SDKs now support central package versions too https://github.com/devlooped/oss/commit/afca922 - Enable floating versions for central packages by default https://github.com/devlooped/oss/commit/b1d14c6 - Add static usings to allow unprefixed ThrowXxxx https://github.com/devlooped/oss/commit/6dfe21f - Add compatibility for non-SDK projects without InitializeSourceControlInformation target https://github.com/devlooped/oss/commit/6e96c59 - Set Version from VersionLabel if it's a refs/tags/ https://github.com/devlooped/oss/commit/57653a2 - Bump create-pr dependency to avoid error with existing PRs https://github.com/devlooped/oss/commit/11a8757 - Only commit markdown files when resolving includes https://github.com/devlooped/oss/commit/2c10a83 # devlooped/.github --- .github/CODE_OF_CONDUCT.md | 6 - .github/CONTRIBUTING.md | 35 -- .github/SECURITY.md | 9 - .github/SUPPORT.md | 9 - .github/dependabot.yml | 2 +- .github/workflows/build.yml | 21 +- .github/workflows/changelog.yml | 2 +- .github/workflows/dotnet-file.yml | 5 +- .github/workflows/includes.yml | 5 +- .github/workflows/publish.yml | 18 +- .github/workflows/sponsor.yml | 2 +- .gitignore | 4 +- .netconfig | 284 +++++++++++++--- profile/readme.md | 78 +++-- readme.md | 10 +- sponsorlink.jwt | 1 + src/Directory.Build.props | 19 +- src/SponsorLink/Analyzer/Analyzer.csproj | 37 +++ .../Analyzer/Properties/launchSettings.json | 11 + .../Analyzer/StatusReportingAnalyzer.cs | 25 ++ .../buildTransitive/SponsorableLib.targets | 3 + src/SponsorLink/Directory.Build.props | 47 +++ src/SponsorLink/Directory.Build.targets | 8 + src/SponsorLink/Library/Library.csproj | 21 ++ src/SponsorLink/Library/MyClass.cs | 5 + src/SponsorLink/Library/Resources.resx | 123 +++++++ src/SponsorLink/Library/readme.md | 5 + src/SponsorLink/SponsorLink.Tests.targets | 38 +++ src/SponsorLink/SponsorLink.targets | 186 +++++++++++ .../SponsorLink/AppDomainDictionary.cs | 36 ++ .../SponsorLink/DiagnosticsManager.cs | 137 ++++++++ src/SponsorLink/SponsorLink/ManifestStatus.cs | 25 ++ src/SponsorLink/SponsorLink/Resources.es.resx | 163 +++++++++ src/SponsorLink/SponsorLink/Resources.resx | 164 ++++++++++ src/SponsorLink/SponsorLink/SponsorLink.cs | 169 ++++++++++ .../SponsorLink/SponsorLink.csproj | 79 +++++ .../SponsorLink/SponsorLinkAnalyzer.cs | 119 +++++++ src/SponsorLink/SponsorLink/SponsorStatus.cs | 25 ++ .../SponsorLink/SponsorableLib.targets | 60 ++++ src/SponsorLink/SponsorLink/Tracing.cs | 53 +++ .../Devlooped.Sponsors.targets | 102 ++++++ src/SponsorLink/SponsorLink/sponsorable.md | 5 + src/SponsorLink/SponsorLinkAnalyzer.sln | 43 +++ src/SponsorLink/Tests/.netconfig | 15 + src/SponsorLink/Tests/Attributes.cs | 59 ++++ src/SponsorLink/Tests/Extensions.cs | 43 +++ src/SponsorLink/Tests/JsonOptions.cs | 72 ++++ src/SponsorLink/Tests/Resources.Designer.cs | 63 ++++ src/SponsorLink/Tests/Resources.resx | 101 ++++++ src/SponsorLink/Tests/Sample.cs | 59 ++++ src/SponsorLink/Tests/SponsorLinkTests.cs | 126 +++++++ src/SponsorLink/Tests/SponsorableManifest.cs | 309 ++++++++++++++++++ src/SponsorLink/Tests/Tests.csproj | 57 ++++ src/SponsorLink/jwk.ps1 | 1 + src/SponsorLink/readme.md | 34 ++ src/nuget.config | 32 -- 56 files changed, 2976 insertions(+), 194 deletions(-) delete mode 100644 .github/CODE_OF_CONDUCT.md delete mode 100644 .github/CONTRIBUTING.md delete mode 100644 .github/SECURITY.md delete mode 100644 .github/SUPPORT.md create mode 100644 sponsorlink.jwt create mode 100644 src/SponsorLink/Analyzer/Analyzer.csproj create mode 100644 src/SponsorLink/Analyzer/Properties/launchSettings.json create mode 100644 src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs create mode 100644 src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets create mode 100644 src/SponsorLink/Directory.Build.props create mode 100644 src/SponsorLink/Directory.Build.targets create mode 100644 src/SponsorLink/Library/Library.csproj create mode 100644 src/SponsorLink/Library/MyClass.cs create mode 100644 src/SponsorLink/Library/Resources.resx create mode 100644 src/SponsorLink/Library/readme.md create mode 100644 src/SponsorLink/SponsorLink.Tests.targets create mode 100644 src/SponsorLink/SponsorLink.targets create mode 100644 src/SponsorLink/SponsorLink/AppDomainDictionary.cs create mode 100644 src/SponsorLink/SponsorLink/DiagnosticsManager.cs create mode 100644 src/SponsorLink/SponsorLink/ManifestStatus.cs create mode 100644 src/SponsorLink/SponsorLink/Resources.es.resx create mode 100644 src/SponsorLink/SponsorLink/Resources.resx create mode 100644 src/SponsorLink/SponsorLink/SponsorLink.cs create mode 100644 src/SponsorLink/SponsorLink/SponsorLink.csproj create mode 100644 src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs create mode 100644 src/SponsorLink/SponsorLink/SponsorStatus.cs create mode 100644 src/SponsorLink/SponsorLink/SponsorableLib.targets create mode 100644 src/SponsorLink/SponsorLink/Tracing.cs create mode 100644 src/SponsorLink/SponsorLink/buildTransitive/Devlooped.Sponsors.targets create mode 100644 src/SponsorLink/SponsorLink/sponsorable.md create mode 100644 src/SponsorLink/SponsorLinkAnalyzer.sln create mode 100644 src/SponsorLink/Tests/.netconfig create mode 100644 src/SponsorLink/Tests/Attributes.cs create mode 100644 src/SponsorLink/Tests/Extensions.cs create mode 100644 src/SponsorLink/Tests/JsonOptions.cs create mode 100644 src/SponsorLink/Tests/Resources.Designer.cs create mode 100644 src/SponsorLink/Tests/Resources.resx create mode 100644 src/SponsorLink/Tests/Sample.cs create mode 100644 src/SponsorLink/Tests/SponsorLinkTests.cs create mode 100644 src/SponsorLink/Tests/SponsorableManifest.cs create mode 100644 src/SponsorLink/Tests/Tests.csproj create mode 100644 src/SponsorLink/jwk.ps1 create mode 100644 src/SponsorLink/readme.md delete mode 100644 src/nuget.config diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md deleted file mode 100644 index 775f221..0000000 --- a/.github/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,6 +0,0 @@ -# Code of Conduct - -This project has adopted the code of conduct defined by the Contributor Covenant -to clarify expected behavior in our community. - -For more information, see the [.NET Foundation Code of Conduct](https://dotnetfoundation.org/code-of-conduct). diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md deleted file mode 100644 index f577cde..0000000 --- a/.github/CONTRIBUTING.md +++ /dev/null @@ -1,35 +0,0 @@ -# Contributing - -You can contribute to the project with issues and PRs. -Simply filing issues for problems you encounter is a great way to contribute. Contributing implementations is greatly appreciated. - -## Before you file a bug... -* Is this a question, or are you looking for help? Ask it in the project's **Discussions** tab instead, if available. - -* Did you search the issues list to see if someone already reported it? _When looking for duplicates, make sure to look through open **and** closed issues._ -* Did you create a simple repro for the problem? _We won't look at your bug until you provide a repro project or complete repro steps._ - -## Before you submit a PR... - -* Did you ensure there is a corresponding issue labelled as 🟢 help wanted? - If not, please open one to start the discussion. -* Does the code follow existing coding styles? (spaces, comments, no regions, etc.)? - We enforce the basic styles by running `dotnet format` on the repository root. - You can do the same before submitting a PR to speed up the process. -* Did you write unit tests? - Typically required if there are other unit tests for existing or related code. - -## Looking for something to work on? - -Look at the repo issues labelled as 🟢 help wanted and 🟣 good first issue to find -something to contribute! - -# Contributor License Agreement - -You must sign the [Contribution License Agreement (CLA)](https://cla-assistant.io/devlooped/) before your PR will be merged. -This is a one-time requirement for projects in the Devlooped organization. - You can read more about [Contribution License Agreements (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) on Wikipedia. - - You don't have to do this up-front. You can simply clone, fork, and submit your pull-request as usual. - When your pull-request is created, it is classified by a CLA bot, which will let - you know if signing is pending. \ No newline at end of file diff --git a/.github/SECURITY.md b/.github/SECURITY.md deleted file mode 100644 index 01b219a..0000000 --- a/.github/SECURITY.md +++ /dev/null @@ -1,9 +0,0 @@ -# Security Policy - -## Reporting a Vulnerability - -Security issues and bugs should be reported privately by emailing security@devlooped.com. -You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your -original message and ping [@devlooped](https://twitter.com/devlooped) on Twitter. - -Please do not open issues for anything you think might have a security implication. \ No newline at end of file diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md deleted file mode 100644 index cfde22d..0000000 --- a/.github/SUPPORT.md +++ /dev/null @@ -1,9 +0,0 @@ -# Support - -Support is typically available thorugh GitHub Discussions and Issues. - -**Found a bug? File an issue.** If you found something that isn't working as expected and you can reproduce it, go to the repo Issues tab and file an issue. - -**Want a new feature? Start a discussion.** It's always good to start a discussion (Discussion tab in the project, Ideas category) to explain the problem you're trying to solve before jumping in with the pull request. We may already have a solution in place or have an idea on ways to solve that issue. After the details are nailed down, we will migrate the discussion to a proper Issue (with the `enhancement`) label. At that point, we'd love to see your PRs :). - -Consider [sponsoring](https://github.com/sponsors/devlooped) the project to get priority support and many other perks! diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ed06dbe..c95eb73 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -29,7 +29,7 @@ updates: - "Microsoft.AspNetCore*" Tests: patterns: - - "Microsoft.NET.Tests*" + - "Microsoft.NET.Test*" - "xunit*" - "coverlet*" ThisAssembly: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0480456..c671ecc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,9 +17,12 @@ on: env: DOTNET_NOLOGO: true + PackOnBuild: true + GeneratePackageOnBuild: true VersionPrefix: 42.42.${{ github.run_number }} VersionLabel: ${{ github.ref }} - + GH_TOKEN: ${{ secrets.GH_TOKEN }} + defaults: run: shell: bash @@ -31,7 +34,7 @@ jobs: matrix: ${{ steps.lookup.outputs.matrix }} steps: - name: 🤘 checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: 🔎 lookup id: lookup @@ -50,13 +53,13 @@ jobs: os: ${{ fromJSON(needs.os-matrix.outputs.matrix) }} steps: - name: 🤘 checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 0 - name: 🙏 build - run: dotnet build -m:1 + run: dotnet build -m:1 -bl:build.binlog - name: ⚙ GNU grep if: matrix.os == 'macOS-latest' @@ -67,8 +70,12 @@ jobs: - name: 🧪 test uses: ./.github/workflows/test - - name: 📦 pack - run: dotnet pack -m:1 + - name: 🐛 logs + uses: actions/upload-artifact@v3 + if: runner.debug && always() + with: + name: logs + path: '*.binlog' # Only push CI package to sleet feed if building on ubuntu (fastest) - name: 🚀 sleet @@ -83,7 +90,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 🤘 checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 0 diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index b120b73..ca50e5a 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -17,7 +17,7 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} - name: 🤘 checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: fetch-depth: 0 ref: main diff --git a/.github/workflows/dotnet-file.yml b/.github/workflows/dotnet-file.yml index 818aa2c..95f6228 100644 --- a/.github/workflows/dotnet-file.yml +++ b/.github/workflows/dotnet-file.yml @@ -24,7 +24,7 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} - name: 🤘 checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: fetch-depth: 0 ref: main @@ -32,6 +32,7 @@ jobs: - name: ⌛ rate shell: pwsh + if: github.event_name != 'workflow_dispatch' run: | # add random sleep since we run on fixed schedule sleep (get-random -max 60) @@ -70,7 +71,7 @@ jobs: validate: false - name: ✍ pull request - uses: peter-evans/create-pull-request@v4 + uses: peter-evans/create-pull-request@v6 with: base: main branch: dotnet-file-sync diff --git a/.github/workflows/includes.yml b/.github/workflows/includes.yml index bb1a90b..9cdae21 100644 --- a/.github/workflows/includes.yml +++ b/.github/workflows/includes.yml @@ -21,7 +21,7 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} - name: 🤘 checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: token: ${{ env.GH_TOKEN }} @@ -29,8 +29,9 @@ jobs: uses: devlooped/actions-includes@v1 - name: ✍ pull request - uses: peter-evans/create-pull-request@v4 + uses: peter-evans/create-pull-request@v6 with: + add-paths: '**/*.md' base: main branch: markdown-includes delete-branch: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bd83ada..a086072 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,25 +10,33 @@ on: env: DOTNET_NOLOGO: true Configuration: Release - + PackOnBuild: true + GeneratePackageOnBuild: true + VersionLabel: ${{ github.ref }} + GH_TOKEN: ${{ secrets.GH_TOKEN }} + jobs: publish: runs-on: ubuntu-latest steps: - name: 🤘 checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 0 - name: 🙏 build - run: dotnet build -m:1 -p:version=${GITHUB_REF#refs/*/v} + run: dotnet build -m:1 -bl:build.binlog - name: 🧪 test uses: ./.github/workflows/test - - name: 📦 pack - run: dotnet pack -m:1 -p:version=${GITHUB_REF#refs/*/v} + - name: 🐛 logs + uses: actions/upload-artifact@v3 + if: runner.debug && always() + with: + name: logs + path: '*.binlog' - name: 🚀 nuget run: dotnet nuget push ./bin/**/*.nupkg -s https://api.nuget.org/v3/index.json -k ${{secrets.NUGET_API_KEY}} --skip-duplicate diff --git a/.github/workflows/sponsor.yml b/.github/workflows/sponsor.yml index 9e47191..1d484d3 100644 --- a/.github/workflows/sponsor.yml +++ b/.github/workflows/sponsor.yml @@ -15,7 +15,7 @@ jobs: steps: - name: 🤘 checkout if: env.token != '' - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: 💜 sponsor if: env.token != '' diff --git a/.gitignore b/.gitignore index a438e35..6639458 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,11 @@ bin -app obj artifacts pack TestResults +results +BenchmarkDotNet.Artifacts +/app .vs .vscode .idea diff --git a/.netconfig b/.netconfig index 32fe484..af3fb40 100644 --- a/.netconfig +++ b/.netconfig @@ -42,33 +42,33 @@ weak [file ".github/dependabot.yml"] url = https://github.com/devlooped/oss/blob/main/.github/dependabot.yml - sha = 35ca3f3405452465058d89005f8a88a65847c377 - etag = f8080f8f04d87529e90d9a66751d304a7141196fb9734aa2d110784e52e66898 + sha = 49661dbf0720cde93eb5569be7523b5912351560 + etag = c147ea2f3431ca0338c315c4a45b56ee233c4d30f8d6ab698d0e1980a257fd6a weak [file ".github/workflows/build.yml"] url = https://github.com/devlooped/oss/blob/main/.github/workflows/build.yml - sha = 13d67e2cf3f786c8189364fd29332aaa7dc575dc - etag = c616df0877fba60002ccfc0397e9f731ddb22acbbb195a0598fedd4cac5f3135 + sha = 14deaea5cecc64df51781d29891a2f67caf8be16 + etag = d9fa5d91dc601f10d19099abb55c86df065cd1c23b1f6fab98ad883cb443bf5c weak [file ".github/workflows/changelog.yml"] url = https://github.com/devlooped/oss/blob/main/.github/workflows/changelog.yml - sha = a4b66eb5f4dfb9704502f19f59ba33cb4855188c - etag = 54c0b571648b1055beb3ddac180b34e93a9869b9f0277de306901b2c1dbe0b2c + sha = 5fb172362c767bef7c36478f1a6bdc264723f8f9 + etag = ad1efa56d6024ee1add2bcda81a7e4e38d0e9069473c6ff70374d5ce06af1f5a weak [file ".github/workflows/dotnet-file.yml"] url = https://github.com/devlooped/oss/blob/main/.github/workflows/dotnet-file.yml - sha = f08c3f28e46e28eb31e70846d65e57aa9553ce56 - etag = 567444486383d032c1c5fbc538f07e860f92b1d08c66ac6ffb1db64ca539251c + sha = 7afe350f7e80a230e922db026d4e1198ba15cae1 + etag = 65e9794df6caff779eb989c8f71ddf4d4109b24a75af79e4f8d0fe6ba7bd9702 weak [file ".github/workflows/publish.yml"] url = https://github.com/devlooped/oss/blob/main/.github/workflows/publish.yml - sha = d3022567c9ef2bc9461511e53b8abe065afdf03b - etag = 58601b5a71c805647ab26e84053acdfb8d174eaa93330487af8a5503753c5707 + sha = 14deaea5cecc64df51781d29891a2f67caf8be16 + etag = 4e9a9885a28ce867fd6139e1ae23735ad0073775145af96ff7d96d047d750973 weak [file ".gitignore"] url = https://github.com/devlooped/oss/blob/main/.gitignore - sha = 4bd702593c10df189cd4a0f6e6fb72e55de02198 - etag = f11dcc8c057bd2f526aa9c7090c9568d1656fafaaedc6719da42a00283018ffa + sha = 02811fa23b0a102b9b33048335d41e515bf75737 + etag = a9c37ae312afac14b78436a7d018af4483d88736b5f780576f2c5a0b3f14998c weak [file "Directory.Build.rsp"] url = https://github.com/devlooped/oss/blob/main/Directory.Build.rsp @@ -92,8 +92,8 @@ weak [file "src/Directory.Build.props"] url = https://github.com/devlooped/oss/blob/main/src/Directory.Build.props - sha = 1bf1eacc7ac3920d52c8e7045bfa34abc7c05302 - etag = e9fd8ef4740d5559fa495580b04ec3437b99edbfb8452813907337f9374c1282 + sha = 14deaea5cecc64df51781d29891a2f67caf8be16 + etag = f177eb767aaa6a347da43ff7ff419c9a0736c562cb171e17ded8007a1945a8b0 weak [file "src/Directory.Build.targets"] url = https://github.com/devlooped/oss/blob/main/src/Directory.Build.targets @@ -110,15 +110,10 @@ sha = 9a1b07589b9bde93bc12528e9325712a32dec418 etag = b54216ac431a83ce5477828d391f02046527e7f6fffd21da1d03324d352c3efb weak -[file "src/nuget.config"] - url = https://github.com/devlooped/oss/blob/main/src/nuget.config - sha = b2fa09bd9db6de89e37a8ba6705b5659e435dafd - etag = eb2d09e546aa1e11c0b464d9ed6ab2d3c028a1d86c3ac40a318053625fb72819 - weak [file ".github/workflows/includes.yml"] url = https://github.com/devlooped/oss/blob/main/.github/workflows/includes.yml - sha = ac753b791d03997eb655efb26ae141b51addd1c0 - etag = fcd94a08ac9ebc0e8351deac4e7f085cf8ef67816cc50006e068f44166096eb8 + sha = 5fb172362c767bef7c36478f1a6bdc264723f8f9 + etag = e5ee22e115c925fb85ec931cda3ac811fcc453c03904554fa3f573935b221d34 weak [file ".github/workflows/combine-prs.yml"] url = https://github.com/devlooped/oss/blob/main/.github/workflows/combine-prs.yml @@ -135,38 +130,233 @@ sha = 055a8b7c94b74ae139cce919d60b83976d2a9942 etag = ddb17acb5872e9e69a76f9dec0ca590f25382caa2ccf750df058dcabb674db2b weak -[file ".github/CODE_OF_CONDUCT.md"] - url = https://github.com/devlooped/.github/blob/main/.github/CODE_OF_CONDUCT.md - sha = c97cdeb04769df3f397d9e70b345d4549628c388 - etag = 4857c01bb695f09bf6912d778951c3065d9dd565e5de3d0827f40432d0e4c613 - weak -[file ".github/CONTRIBUTING.md"] - url = https://github.com/devlooped/.github/blob/main/.github/CONTRIBUTING.md - sha = 1dc5908c7711c17c1fe06abd191f2c09f37ac56a - etag = b2bc4ee0e910ea60c3daa4e7d391e6260b5644d3e3c2a19145525c8db3f454fd - weak -[file ".github/SECURITY.md"] - url = https://github.com/devlooped/.github/blob/main/.github/SECURITY.md - sha = c97cdeb04769df3f397d9e70b345d4549628c388 - etag = fc8101fd914820db3e6b42d608dc46aefbc60c830ac721ed3917daead3727dbd - weak -[file ".github/SUPPORT.md"] - url = https://github.com/devlooped/.github/blob/main/.github/SUPPORT.md - sha = c97cdeb04769df3f397d9e70b345d4549628c388 - etag = 2d47e2fc4fdfa3515203d452674566e3df507461f9938f451a06d79deb687d24 - weak [file ".github/workflows/sponsor.yml"] url = https://github.com/devlooped/oss/blob/main/.github/workflows/sponsor.yml - sha = 8990ebb36199046e0b8098bad9e46dcef739c56e - etag = e1dc114d2e8b57d50649989d32dbf0c9080ec77da3738a4cc79e9256d6ca5d3e + sha = 5fb172362c767bef7c36478f1a6bdc264723f8f9 + etag = 0849ee61af6daee29615f9632173b4e82da5bfa9d78ff28907e9408bd5acde4d weak [file ".github/ISSUE_TEMPLATE/bug.md"] url = https://github.com/devlooped/.github/blob/main/.github/ISSUE_TEMPLATE/bug.md - sha = 50faf1212e849f3ebd675be93a6c2d9917faa6d0 - etag = 6d3c88228649aa36431b5a8017baa46cd5d1584542fbc87e0d907b17dd882899 + sha = 80e81d21c020841dfc8678f218d42ddaffad78db + etag = 6ab86c474f24c915681abf4dca6c5becf3a211ab09d11e290385890dadd6f97f weak [file "profile/readme.md"] url = https://github.com/devlooped/.github/blob/main/profile/readme.md - sha = 7676dd2cb4bc284b699dfd7e0888c4c8fe28de9a - etag = d055b4cb45f2c3a5c553869d317cdb90de91c4bccb2b967ce4a59e50ba2e8335 + sha = 4bc89bc70d81f43ef63890f270bce0c75d3e1617 + etag = 6a1b260ab2254cee61f5806d4e19c3d40905b14d48208921e07cfe7010dcc97e + weak +[file "src/SponsorLink/Analyzer/Analyzer.csproj"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Analyzer/Analyzer.csproj + sha = 93df7c7ec34f83ae58efbf213624d5ea31fe3c41 + etag = f76e33fde812244a275b95c8815101f6f87d144a5305a2c1f0f631f770d91920 + weak +[file "src/SponsorLink/Analyzer/Properties/launchSettings.json"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Analyzer/Properties/launchSettings.json + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 6c59ab4d008e3221e316c9e3b6e0da155b892680d48cdc400a39d53cb9a12aac + weak +[file "src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs + sha = c879f25bf483086725c8a29f104555644e6ee542 + etag = cde10b763b87a3987e86cca2292c9afc7637d2113b9921e79492b6a31620bbb4 + weak +[file "src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 332060de0945590d7c41cd237c250b8186acd6fc2045cc85a890368c74fdf473 + weak +[file "src/SponsorLink/Directory.Build.props"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Directory.Build.props + sha = 3b943f5aa59f33141d1c0fffcb215446d594ad53 + etag = 0c7737411744012078642dbfc174af3f2ac7dc9f7b8ea4423981ae38753a5be4 + weak +[file "src/SponsorLink/Directory.Build.targets"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Directory.Build.targets + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 9938f29c3573bf8bdb9686e1d9884dee177256b1d5dd7ee41472dd64bfbdd92d + weak +[file "src/SponsorLink/Library/Library.csproj"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Library/Library.csproj + sha = 93df7c7ec34f83ae58efbf213624d5ea31fe3c41 + etag = 56233a536fb38edd75f66f6a9a9e6044eb227a0b58fb791495ff88e43649feb7 + weak +[file "src/SponsorLink/Library/MyClass.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Library/MyClass.cs + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = b5b3ccd6cd14bb90dd9702b9d7e52cc22c11e601c039617738d688f9fd45d49b + weak +[file "src/SponsorLink/Library/Resources.resx"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Library/Resources.resx + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = aff6051733d22982e761f2b414173aafeab40e0a76a142e2b33025dced213eb2 + weak +[file "src/SponsorLink/Library/readme.md"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Library/readme.md + sha = 55124bc610b2dcad9efb343bdffc79c959170593 + etag = 5002ac8c5bbeee60c13937a32c1b6c1a5dbf0065617c8f2550e6eca6fded256d + weak +[file "src/SponsorLink/SponsorLink.Tests.targets"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink.Tests.targets + sha = 81ba912310dd4b723c7a0103a76cb71b183983b1 + etag = cf6deba5b5cdadb5b2ea6b8533331da49afd3c841db2932a45618627ffc4ff9a + weak +[file "src/SponsorLink/SponsorLink.targets"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink.targets + sha = 759365751e6529049a3df5701f85aecb51189289 + etag = 6e3955b7233c5c2000b9adf1bb281e74e7fb08813e17b3ef10fd8b5d50f9fb4d + weak +[file "src/SponsorLink/SponsorLink/AppDomainDictionary.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/AppDomainDictionary.cs + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 4a70f86e73f951bca95618c221d821e38a31ef9092af4ac61447eab845671a28 + weak +[file "src/SponsorLink/SponsorLink/DiagnosticsManager.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/DiagnosticsManager.cs + sha = cf154d5d9c2ac3dad56e95da04effdad64409471 + etag = 7ac9738f71cafd15dbb347bc9d83468b0691d0b0888cc82e35c161fd1f2d48eb + weak +[file "src/SponsorLink/SponsorLink/ManifestStatus.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/ManifestStatus.cs + sha = b2a11faac6c1c300bce8c1d45f95b585c19f2953 + etag = e46848f83c0436ba33a1c09a4060ad627a74db41bab66bb37ca40fce8a6532a7 + weak +[file "src/SponsorLink/SponsorLink/Resources.es.resx"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/Resources.es.resx + sha = c879f25bf483086725c8a29f104555644e6ee542 + etag = c0a05bb5efedf8e30a73ab96678579ad33832e4a4aec75d3b596b47f248c23f5 + weak +[file "src/SponsorLink/SponsorLink/Resources.resx"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/Resources.resx + sha = c879f25bf483086725c8a29f104555644e6ee542 + etag = fcb46a86511cb7996e8dcd1f4e283cea9cd51610b094ac49a7396301730814b0 + weak +[file "src/SponsorLink/SponsorLink/SponsorLink.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/SponsorLink.cs + sha = 55124bc610b2dcad9efb343bdffc79c959170593 + etag = 28178198489bf9b72f8a400563950194a06f7ce55ff4a016535eb1be35fa70b8 + weak +[file "src/SponsorLink/SponsorLink/SponsorLink.csproj"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/SponsorLink.csproj + sha = c879f25bf483086725c8a29f104555644e6ee542 + etag = 997b08082f85a491be7a68805d7811e65e1474a6e7d49cbe927617f7035d21e1 + weak +[file "src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs + sha = 23f83bd6b1f0fe13ac02bf464377f576652fec97 + etag = 5f9823d1bf83f7d28e5809e0a08d942fb2c444f4653ca5b035d500ebef2ead15 + weak +[file "src/SponsorLink/SponsorLink/SponsorStatus.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/SponsorStatus.cs + sha = 4fca946c3201d90d30e2183f699c850dcc1bf8d5 + etag = 9a5f6f35c38c34b77796925d80addc998e204bc112fcd5fc124030060390e7c2 + weak +[file "src/SponsorLink/SponsorLink/SponsorableLib.targets"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/SponsorableLib.targets + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 2f923a97081481a6a264d63c8ff70ce5ba65c3dbaf7ea078cbe1388fb0868e1c + weak +[file "src/SponsorLink/SponsorLink/Tracing.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/Tracing.cs + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 22e32872cafd080bcd5ac9084355578ef70910c8e494602ead365139dcbf40c0 + weak +[file "src/SponsorLink/SponsorLink/buildTransitive/Devlooped.Sponsors.targets"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/buildTransitive/Devlooped.Sponsors.targets + sha = 55124bc610b2dcad9efb343bdffc79c959170593 + etag = 46842d44ece3d55285bc30a6b22ac21c1c35d3b0c451aa5285d4ca4564b8698c + weak +[file "src/SponsorLink/SponsorLink/sponsorable.md"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/sponsorable.md + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 9c275d50705a2e661f0f86f1ae5e555c0033a05e86e12f936283a5b5ef47ae77 + weak +[file "src/SponsorLink/SponsorLinkAnalyzer.sln"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLinkAnalyzer.sln + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = fc2928c9b303d81ff23891ee791a859b794d9f2d4b9f4e81b9ed15e5b74db487 + weak +[file "src/SponsorLink/Tests/.netconfig"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Tests/.netconfig + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 089a26cdb722d57014c8b8104cc6f4e770868efdc49ae3119eebc873f00a316e + weak +[file "src/SponsorLink/Tests/Attributes.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Tests/Attributes.cs + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 1d7c17a2c9424db73746112c338a39e0000134ac878b398e2aa88f7ea5c0c488 + weak +[file "src/SponsorLink/Tests/Extensions.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Tests/Extensions.cs + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = f68e11894103f8748ce290c29927bf1e4f749e743ae33d5350e72ed22c15d245 + weak +[file "src/SponsorLink/Tests/JsonOptions.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Tests/JsonOptions.cs + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 6e9a1b12757a97491441b9534ced4e5dac6d9d6334008fa0cd20575650bbd935 + weak +[file "src/SponsorLink/Tests/Resources.Designer.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Tests/Resources.Designer.cs + sha = c879f25bf483086725c8a29f104555644e6ee542 + etag = 69404ac09238930893fdbc225ae7839b14957e129b4c05f1ef0e7afcc4c91d63 + weak +[file "src/SponsorLink/Tests/Resources.resx"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Tests/Resources.resx + sha = c879f25bf483086725c8a29f104555644e6ee542 + etag = 13d1bb8b0de32a8c9b5dbdc806a036ed89d423cd7c0be187b8c56055c9bf7783 + weak +[file "src/SponsorLink/Tests/Sample.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Tests/Sample.cs + sha = c879f25bf483086725c8a29f104555644e6ee542 + etag = c4ed1e041d1ec816710757790aaa6688e3756870cfd98ba7e6c7b5103ce3a9ba + weak +[file "src/SponsorLink/Tests/SponsorLinkTests.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Tests/SponsorLinkTests.cs + sha = d74f5111504a0fae6e5a1e68ca92bf7afddb3254 + etag = 1fa41250bd984e8aa840a966d34ce0e94f2111d1422d7f50b864c38364fcf4a4 + weak +[file "src/SponsorLink/Tests/SponsorableManifest.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Tests/SponsorableManifest.cs + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = e0c95e7fc6c0499dbc8c5cd28aa9a6a5a49c9d0ad41fe028a5a085aca7e00eaf + weak +[file "src/SponsorLink/Tests/Tests.csproj"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Tests/Tests.csproj + sha = c879f25bf483086725c8a29f104555644e6ee542 + etag = 31d33feb5860cd6df71ee2d6f3ca6d8fdc9e6535bea8caa97300421c0502246e + weak +[file "src/SponsorLink/jwk.ps1"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/jwk.ps1 + sha = c4830fc3b1aa78ec98d1d2ea4fed86ef0b7b803c + etag = f399e05ecb56adaf41d2545171f299a319142b17dd09fc38e452ca8c5d13bd0d + weak +[file "src/SponsorLink/readme.md"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/readme.md + sha = 827a1d18bf0245978d81bcd3d52e9e6f1584d1ef + etag = 079b4aedba2aa9851e609b569f25c55db8d5922e3dbb1adc22611ce4d6cfe465 + weak +[file ".github/code_of_conduct.md"] + url = https://github.com/devlooped/.github/blob/main/.github/code_of_conduct.md + sha = 4d38bd1a1662e69a131dfe2e860a0a832ea89db4 + etag = fcc35d9f63ba728070d34229be6f8ca403f4cde03e1da183f95e530bf8c632a0 + weak +[file ".github/contributing.md"] + url = https://github.com/devlooped/.github/blob/main/.github/contributing.md + sha = 80e81d21c020841dfc8678f218d42ddaffad78db + etag = b966999d5ed1927c5139d04bbd057546d0f3c2e8226c10a71c4cab78b92c3796 + weak +[file ".github/security.md"] + url = https://github.com/devlooped/.github/blob/main/.github/security.md + sha = 80e81d21c020841dfc8678f218d42ddaffad78db + etag = fc8101fd914820db3e6b42d608dc46aefbc60c830ac721ed3917daead3727dbd + weak +[file ".github/support.md"] + url = https://github.com/devlooped/.github/blob/main/.github/support.md + sha = 80e81d21c020841dfc8678f218d42ddaffad78db + etag = 2d47e2fc4fdfa3515203d452674566e3df507461f9938f451a06d79deb687d24 + weak +[file "sponsorlink.jwt"] + url = https://github.com/devlooped/.github/blob/main/sponsorlink.jwt + sha = 51e44c3926b1816c04d34aef0ae541138350ca60 + etag = b69d75dd622e327b9cf433c8d5399da24f96607bb87758514b9cb0142d6f67ea weak diff --git a/profile/readme.md b/profile/readme.md index 9b2881d..da0e587 100644 --- a/profile/readme.md +++ b/profile/readme.md @@ -1,17 +1,18 @@ -

Hi 👋, I'm kzu

-

sounds like 'kah-zu' rather than 'kzoo' :)

-

A hardcode .NET/C# developer from Argentina

+Hello there! This is [@kzu](https://github.com/kzu)'s (sounds like 'kah-zu' rather than 'kzoo' 😉) organization account +where I publish projects I think might be useful to others and not just personal experiments. -[![X](https://img.shields.io/badge/X-@kzu-blue?logo=twitter&logoColor=1D9BF0&labelColor=black)](https://x.com/kzu) -[![Discord](https://img.shields.io/badge/discord-danielkzu-7289DA.svg?logo=discord&logoColor=white)](https://discord.gg/8PtpGdu) -[![LinkedIn](https://img.shields.io/badge/LinkedIn-danielkzu-0A66C2.svg?logo=linkedin&logoColor=0A66C2&labelColor=silver)]([https://discord.gg/8PtpGdu](https://linkedin.com/in/danielcazzulino)) +This is also the organization account you can actually [sponsor](https://github.com/sponsors/devlooped) +to support my ongoing opensource work. -This is my sponsor account so you can contribute to my ongoing opensource work, no minimum amount required! +## Stats -[![Sponsor me 💜](https://raw.githubusercontent.com/devlooped/sponsors/main/sponsor.png#gh-light-mode-only "Sponsor me 💜")](https://github.com/sponsors/devlooped#gh-light-mode-only) -[![Sponsor me 💜](https://raw.githubusercontent.com/devlooped/sponsors/main/sponsor-dark.png#gh-dark-mode-only "Sponsor me 💜")](https://github.com/sponsors/devlooped#gh-dark-mode-only) +[![GitHub Sponsors](https://img.shields.io/github/sponsors/devlooped?style=social&logo=githubsponsors)](https://github.com/sponsors/devlooped) +[![GitHub followers](https://img.shields.io/github/followers/kzu?logo=GitHub&label=@kzu%20followers)](https://github.com/kzu) +[![GitHub stars](https://img.shields.io/github/stars/kzu?logo=GitHub&label=@kzu%20stars&affiliations=OWNER,COLLABORATOR&color=FFC83D)](https://github.com/kzu) +[![GitHub followers](https://img.shields.io/github/followers/devlooped?logo=GitHub&label=@devlooped%20followers)](https://github.com/devlooped) +[![GitHub Org's stars](https://img.shields.io/github/stars/devlooped?logo=GitHub&label=@devlooped%20stars)](https://github.com/devlooped) -Some fancy stats about my favorite hobbie (coding on GitHub, of course!): +Some fancy stats about my favorite hobby (coding on GitHub, of course!):

@@ -37,23 +38,44 @@ Some fancy stats about my favorite hobbie (coding on GitHub, of course!):

-

- - - - - -

-

+ -And since I'm a total fan of [Shields.io](https://shields.io/) badges, some additional GitHub stats in badge format: +## SponsorLink + +I created [SponsorLink](https://www.devlooped.com/SponsorLink/) as a mechanism to remind users that they can +sponsor my projects if they find them useful. It also allows attribution on the dev machine of a sponsorship +to potentially unlock additional functionality (or just remove the reminder and thank instead!). SponsorLink +never issues any messages outside of IDE usage, so it will never disrupt your CI/CD workflows or CLI builds. + +If you arrived here from an IDE and are interested in sponsoring, the (one-time) steps are: + +1. Select your [sponsor](https://github.com/sponsors/devlooped) tier 🙏 +1. Install the [sponsor dotnet global tool](https://nuget.org/packages/dotnet-sponsor) by running + ```shell + dotnet tool install -g dotnet-sponsor + ``` +4. Sync your sponsorship status by running + ```shell + sponsor sync devlooped + ``` + +Feel free to dive deeper into the [technical details](https://www.devlooped.com/SponsorLink/github.html) of how +this works. You can also implement SponsorLink yourself with minimal effort for your own projects. + +### Implicit or Indirect Sponsorships + +If you have ever sent a PR that was merged into any repository owned by [@devlooped](https://github.com/devlooped), +you are considered a sponsor already! Contributing your time and code is the most awesome way to support a project 🫶. + +If you belong to an organization that sponsors [@devlooped](https://github.com/sponsors/devlooped), then you are +an indirect sponsor too! This allows organizations to support projects their employees love and streamline invoicing. + +
+ +### AutoSync + +Sponsorships are renewed monthly (even if paid anually), so your dev machine manifest needs monthly renewal too. +You can simplify this process by enabling [autosync](https://www.devlooped.com/SponsorLink/github.html#auto-sync) +so that the IDE tooling can automatically do this for you by checking at most once a day for expiration and running +the same command you'd have to run manually: `sponsor sync devlooped`. -![GitHub Sponsors](https://img.shields.io/github/sponsors/devlooped?color=C65197&label=@devlooped%20sponsors) -![GitHub followers](https://img.shields.io/github/followers/kzu?logo=GitHub&label=@kzu%20followers) -![GitHub stars](https://img.shields.io/github/stars/kzu?logo=GitHub&label=@kzu%20stars&affiliations=OWNER,COLLABORATOR&color=FFC83D) -[![GitHub followers](https://img.shields.io/github/followers/devlooped?logo=GitHub&label=@devlooped%20followers)](https://github.com/devlooped) -[![GitHub Org's stars](https://img.shields.io/github/stars/devlooped?logo=GitHub&label=@devlooped%20stars)](https://github.com/devlooped) diff --git a/readme.md b/readme.md index 7348c97..161cb59 100644 --- a/readme.md +++ b/readme.md @@ -97,21 +97,18 @@ The versioning scheme for packages is: [![Stephen Shaw](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/decriptor.png "Stephen Shaw")](https://github.com/decriptor) [![Torutek](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/torutek-gh.png "Torutek")](https://github.com/torutek-gh) [![DRIVE.NET, Inc.](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/drivenet.png "DRIVE.NET, Inc.")](https://github.com/drivenet) -[![Daniel Gnägi](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/dgnaegi.png "Daniel Gnägi")](https://github.com/dgnaegi) [![Ashley Medway](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/AshleyMedway.png "Ashley Medway")](https://github.com/AshleyMedway) [![Keith Pickford](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/Keflon.png "Keith Pickford")](https://github.com/Keflon) [![Thomas Bolon](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/tbolon.png "Thomas Bolon")](https://github.com/tbolon) [![Kori Francis](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/kfrancis.png "Kori Francis")](https://github.com/kfrancis) [![Toni Wenzel](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/twenzel.png "Toni Wenzel")](https://github.com/twenzel) [![Giorgi Dalakishvili](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/Giorgi.png "Giorgi Dalakishvili")](https://github.com/Giorgi) -[![Mike James](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/MikeCodesDotNET.png "Mike James")](https://github.com/MikeCodesDotNET) +[![Uno Platform](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/unoplatform.png "Uno Platform")](https://github.com/unoplatform) [![Dan Siegel](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/dansiegel.png "Dan Siegel")](https://github.com/dansiegel) [![Reuben Swartz](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/rbnswartz.png "Reuben Swartz")](https://github.com/rbnswartz) [![Jacob Foshee](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/jfoshee.png "Jacob Foshee")](https://github.com/jfoshee) [![](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/Mrxx99.png "")](https://github.com/Mrxx99) [![Eric Johnson](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/eajhnsn1.png "Eric Johnson")](https://github.com/eajhnsn1) -[![Norman Mackay](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/mackayn.png "Norman Mackay")](https://github.com/mackayn) -[![Certify The Web](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/certifytheweb.png "Certify The Web")](https://github.com/certifytheweb) [![Ix Technologies B.V.](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/IxTechnologies.png "Ix Technologies B.V.")](https://github.com/IxTechnologies) [![David JENNI](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/davidjenni.png "David JENNI")](https://github.com/davidjenni) [![Jonathan ](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/Jonathan-Hickey.png "Jonathan ")](https://github.com/Jonathan-Hickey) @@ -121,13 +118,14 @@ The versioning scheme for packages is: [![Seann Alexander](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/seanalexander.png "Seann Alexander")](https://github.com/seanalexander) [![Tino Hager](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/tinohager.png "Tino Hager")](https://github.com/tinohager) [![Mark Seemann](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/ploeh.png "Mark Seemann")](https://github.com/ploeh) -[![Angelo Belchior](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/angelobelchior.png "Angelo Belchior")](https://github.com/angelobelchior) [![Ken Bonny](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/KenBonny.png "Ken Bonny")](https://github.com/KenBonny) [![Simon Cropp](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/SimonCropp.png "Simon Cropp")](https://github.com/SimonCropp) [![agileworks-eu](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/agileworks-eu.png "agileworks-eu")](https://github.com/agileworks-eu) -[![](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/sorahex.png "")](https://github.com/sorahex) +[![sorahex](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/sorahex.png "sorahex")](https://github.com/sorahex) [![Zheyu Shen](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/arsdragonfly.png "Zheyu Shen")](https://github.com/arsdragonfly) [![Vezel](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/vezel-dev.png "Vezel")](https://github.com/vezel-dev) +[![ChilliCream](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/ChilliCream.png "ChilliCream")](https://github.com/ChilliCream) +[![4OTC](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/4OTC.png "4OTC")](https://github.com/4OTC) diff --git a/sponsorlink.jwt b/sponsorlink.jwt new file mode 100644 index 0000000..06b1c8e --- /dev/null +++ b/sponsorlink.jwt @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3Nwb25zb3JsaW5rLmRldmxvb3BlZC5jb20vIiwiYXVkIjoiaHR0cHM6Ly9naXRodWIuY29tL3Nwb25zb3JzL2Rldmxvb3BlZCIsImlhdCI6IjE3MTgyMjQ4NzMiLCJjbGllbnRfaWQiOiJhODIzNTBmYjJiYWU0MDdiMzAyMSIsInN1Yl9qd2siOnsiZSI6IkFRQUIiLCJrdHkiOiJSU0EiLCJuIjoiNWluaHY4UXltYURCT2loTmkxZVktNi1oY0lCNXFTT05GWnhieHhYQXlPdHhBZGpGQ1BNLTk0Z0lacU05Q0RyWDNweWcxbFRKZm1sX2FfRlpTVTlkQjFpaTVtU1hfbU5IQkZYbjFfbF9naTFFcmRia0lGNVliVzZveFdGeGYzRzVtd1ZYd25QZnhIVHlRZG1XUTNZSlItQTNFQjRrYUZ3THFBNkhhNWxiMk9iR3BNVFFKTmFrRDRvVEFHRGhxSE1HaHU2UHVwR3E1aWU0cVpjUTdOOEFOdzh4SDduaWNUa2JxRWhRQUJIV09UbUxCV3E1ZjVGNlJZR0Y4UDdjbDBJV2xfdzRZY0laa0dtMnZYMmZpMjZGOUY2MGNVMXYxM0daRVZEVFhwSjlrenZZZU05c1lrNmZXYW95WTJqaEU1MXFidjBCMHU2aFNjWmlMUkV0bTNuN0NsSmJJR1hoa1VwcEZTMkpsTmFYM3JnUTZ0LTRMSzhnVVR5THQzekRzMkg4T1p5Q3dsQ3BmbUdtZHNVTWttMXhYNnQyci05NVUzenl3eW54b1daZmpCQ0pmNDFsZU05T01LWXdOV1o2TFFNeW84M0hXdzFQQklyWDRaTENsRndxQmNTWXNYRHlUOF9aTGQxY2RZbVBmbXRsbElYeFpoTENsd1Q1cWJDV3Y3M1YifX0.bTH1YjSuhaR0FGsGGcovzXpbT23pRdgZHiwjstlJ7FIyv_6UJ7SsR2x3K5Om8M1C7GaCQWdI_Hu92-0elLHG8yMeD-XbTtYD2z7td1DjmYV7mFNArKOKrg5pjNthrMP8U2yklC44bXcUOjWxHa5amT75Gr7L7mx5Evoe69yoE30ZhW1FgWJgQOHLvEGVyeEEXN96DzU3Ng5GqZZVNAs1zMd6RcraurmY_fr7FAMOlrCCnf6XmFxM8N0paJMWqBkgaO7h4bgzqVjGgUc1l6YKTR-zyLySOh4dcMbxLpAI6gwNImOVAtpC0cO8uL1bXQM86xrmYEDZ8HgwcRnrFtKZ3WQ7PoVs77fjiv8lChgDTEJWalWI7nGapkQDVA2-Hn1Ex_XiXW_KIuUrR7Y-Zt4f6GBiSIJmRL4s4YcgboEW61Lto0h5k_AZ5S2kL-OD4Qx2rudhsEEi2QP1oUW4aLTpmTHy5RAgLD-sGYnSZKDSaOBJ5aOUSilHEbBxy4_yeJgY diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 4527560..1648dcd 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -37,7 +37,8 @@ $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\bin')) - true + true + true true @@ -117,6 +118,8 @@ <_VersionLabel>$(VersionLabel.Replace('refs/heads/', '')) + <_VersionLabel>$(_VersionLabel.Replace('refs/tags/v', '')) + <_VersionLabel Condition="$(_VersionLabel.Contains('refs/pull/'))">$(VersionLabel.TrimEnd('.0123456789')) @@ -127,7 +130,9 @@ <_VersionLabel>$(_VersionLabel.Replace('/', '-')) - $(_VersionLabel) + $(_VersionLabel) + + $(_VersionLabel) @@ -141,6 +146,16 @@ + + + + + + + + + diff --git a/src/SponsorLink/Analyzer/Analyzer.csproj b/src/SponsorLink/Analyzer/Analyzer.csproj new file mode 100644 index 0000000..f65390a --- /dev/null +++ b/src/SponsorLink/Analyzer/Analyzer.csproj @@ -0,0 +1,37 @@ + + + + SponsorableLib.Analyzers + netstandard2.0 + true + analyzers/dotnet/roslyn4.0 + true + $(MSBuildThisFileDirectory)..\SponsorLink.targets + true + disable + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/SponsorLink/Analyzer/Properties/launchSettings.json b/src/SponsorLink/Analyzer/Properties/launchSettings.json new file mode 100644 index 0000000..de45107 --- /dev/null +++ b/src/SponsorLink/Analyzer/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "SponsorableLib": { + "commandName": "DebugRoslynComponent", + "targetProject": "..\\Tests\\Tests.csproj", + "environmentVariables": { + "SPONSORLINK_TRACE": "true" + } + } + } +} \ No newline at end of file diff --git a/src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs b/src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs new file mode 100644 index 0000000..ad82ed3 --- /dev/null +++ b/src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs @@ -0,0 +1,25 @@ +using System.Collections.Immutable; +using Devlooped.Sponsors; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using static Devlooped.Sponsors.SponsorLink; + +namespace Analyzer; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class StatusReportingAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Empty; + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCodeBlockAction(c => + { + var status = Diagnostics.GetStatus(Funding.Product); + Tracing.Trace($"Status: {status}"); + }); + } +} \ No newline at end of file diff --git a/src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets b/src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets new file mode 100644 index 0000000..fd1e6e4 --- /dev/null +++ b/src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/SponsorLink/Directory.Build.props b/src/SponsorLink/Directory.Build.props new file mode 100644 index 0000000..8afa061 --- /dev/null +++ b/src/SponsorLink/Directory.Build.props @@ -0,0 +1,47 @@ + + + + false + latest + true + annotations + true + + false + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)bin')) + + https://pkg.kzu.app/index.json;https://api.nuget.org/v3/index.json + $(PackageOutputPath);$(RestoreSources) + + + $([System.DateTime]::Parse("2024-03-15")) + $([System.DateTime]::UtcNow.Subtract($(Epoc)).TotalDays) + $([System.Math]::Truncate($(TotalDays))) + $([System.Math]::Floor($([MSBuild]::Divide($([System.DateTime]::UtcNow.TimeOfDay.TotalSeconds), 10)))) + 42.$(Days).$(Seconds) + + SponsorableLib + + + + + + + + + + + + diff --git a/src/SponsorLink/Directory.Build.targets b/src/SponsorLink/Directory.Build.targets new file mode 100644 index 0000000..4ce4c80 --- /dev/null +++ b/src/SponsorLink/Directory.Build.targets @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/src/SponsorLink/Library/Library.csproj b/src/SponsorLink/Library/Library.csproj new file mode 100644 index 0000000..f363648 --- /dev/null +++ b/src/SponsorLink/Library/Library.csproj @@ -0,0 +1,21 @@ + + + + SponsorableLib + netstandard2.0 + true + SponsorableLib + Sample library incorporating SponsorLink checks + true + true + + + + + + + + + + + diff --git a/src/SponsorLink/Library/MyClass.cs b/src/SponsorLink/Library/MyClass.cs new file mode 100644 index 0000000..7b7f6f5 --- /dev/null +++ b/src/SponsorLink/Library/MyClass.cs @@ -0,0 +1,5 @@ +namespace SponsorableLib; + +public class MyClass +{ +} diff --git a/src/SponsorLink/Library/Resources.resx b/src/SponsorLink/Library/Resources.resx new file mode 100644 index 0000000..636fedc --- /dev/null +++ b/src/SponsorLink/Library/Resources.resx @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Bar + + \ No newline at end of file diff --git a/src/SponsorLink/Library/readme.md b/src/SponsorLink/Library/readme.md new file mode 100644 index 0000000..ba4ce37 --- /dev/null +++ b/src/SponsorLink/Library/readme.md @@ -0,0 +1,5 @@ +# Sponsorable Library + +Example of a library that is available for sponsorship and leverages +[SponsorLink](https://github.com/devlooped/SponsorLink) to remind users +in an IDE (VS/Rider). diff --git a/src/SponsorLink/SponsorLink.Tests.targets b/src/SponsorLink/SponsorLink.Tests.targets new file mode 100644 index 0000000..ffc7586 --- /dev/null +++ b/src/SponsorLink/SponsorLink.Tests.targets @@ -0,0 +1,38 @@ + + + + + true + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/SponsorLink/SponsorLink.targets b/src/SponsorLink/SponsorLink.targets new file mode 100644 index 0000000..6de86fb --- /dev/null +++ b/src/SponsorLink/SponsorLink.targets @@ -0,0 +1,186 @@ + + + + + + + true + + true + + true + + CoreResGen;$(CoreCompileDependsOn) + + + $(Product) + + $([System.Text.RegularExpressions.Regex]::Replace("$(FundingProduct)", "[^A-Z]", "")) + + 21 + + + + + + + + + + + + SponsorLink\%(RecursiveDir)%(Filename)%(Extension) + + + SponsorLink\%(RecursiveDir)%(Filename)%(Extension) + + + SponsorLink\%(RecursiveDir)%(Filename)%(Extension) + + + SponsorLink\%(PackagePath) + + + + + + false + + + false + + + false + + + false + + + + + + + + + + + + + + + + + + + + namespace Devlooped.Sponsors%3B + +partial class SponsorLink +{ + public partial class Funding + { + public const string Product = "$(FundingProduct)"%3B + public const string Prefix = "$(FundingPrefix)"%3B + public const int Grace = $(FundingGrace)%3B + } +} + + + + + + + + + + + + + + + + + + + + + + $([System.IO.Path]::GetFullPath($([System.IO.Path]::Combine('$(MSBuildProjectDirectory)','$(AssemblyOriginatorKeyFile)')))) + /keyfile:"$(AbsoluteAssemblyOriginatorKeyFile)" /delaysign + $(ILRepackArgs) /internalize + $(ILRepackArgs) /union + + $(ILRepackArgs) @(LibDir -> '/lib:"%(Identity)."', ' ') + $(ILRepackArgs) /out:"@(IntermediateAssembly -> '%(FullPath)')" + $(ILRepackArgs) "@(IntermediateAssembly -> '%(FullPath)')" + $(ILRepackArgs) @(MergedAssemblies -> '"%(FullPath)"', ' ') + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + $([System.IO.File]::ReadAllText('$(MSBuildProjectDirectory)\$(BaseIntermediateOutputPath)devlooped.jwk')) + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/SponsorLink/SponsorLink/AppDomainDictionary.cs b/src/SponsorLink/SponsorLink/AppDomainDictionary.cs new file mode 100644 index 0000000..05cc949 --- /dev/null +++ b/src/SponsorLink/SponsorLink/AppDomainDictionary.cs @@ -0,0 +1,36 @@ +// +#nullable enable +using System; + +namespace Devlooped.Sponsors; + +/// +/// A helper class to store and retrieve values from the current +/// as typed named values. +/// +/// +/// This allows tools that run within the same app domain to share state, such as +/// MSBuild tasks or Roslyn analyzers. +/// +static class AppDomainDictionary +{ + /// + /// Gets the value associated with the specified name, or creates a new one if it doesn't exist. + /// + public static TValue Get(string name) where TValue : notnull, new() + { + var data = AppDomain.CurrentDomain.GetData(name); + if (data is TValue firstTry) + return firstTry; + + lock (AppDomain.CurrentDomain) + { + if (AppDomain.CurrentDomain.GetData(name) is TValue secondTry) + return secondTry; + + var newValue = new TValue(); + AppDomain.CurrentDomain.SetData(name, newValue); + return newValue; + } + } +} \ No newline at end of file diff --git a/src/SponsorLink/SponsorLink/DiagnosticsManager.cs b/src/SponsorLink/SponsorLink/DiagnosticsManager.cs new file mode 100644 index 0000000..c22ecc8 --- /dev/null +++ b/src/SponsorLink/SponsorLink/DiagnosticsManager.cs @@ -0,0 +1,137 @@ +// +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Globalization; +using Humanizer; +using Microsoft.CodeAnalysis; + +namespace Devlooped.Sponsors; + +/// +/// Manages diagnostics for the SponsorLink analyzer so that there are no duplicates +/// when multiple projects share the same product name (i.e. ThisAssembly). +/// +class DiagnosticsManager +{ + /// + /// Acceses the diagnostics dictionary for the current . + /// + ConcurrentDictionary Diagnostics + => AppDomainDictionary.Get>(nameof(Diagnostics)); + + /// + /// Creates a descriptor from well-known diagnostic kinds. + /// + /// The names of the sponsorable accounts that can be funded for the given product. + /// The product or project developed by the sponsorable(s). + /// Custom prefix to use for diagnostic IDs. + /// The kind of status diagnostic to create. + /// The given . + /// The is not one of the known ones. + public DiagnosticDescriptor GetDescriptor(string[] sponsorable, string product, string prefix, SponsorStatus status) => status switch + { + SponsorStatus.Unknown => CreateUnknown(sponsorable, product, prefix), + SponsorStatus.Sponsor => CreateSponsor(sponsorable, prefix), + SponsorStatus.Expiring => CreateExpiring(sponsorable, prefix), + SponsorStatus.Expired => CreateExpired(sponsorable, prefix), + _ => throw new NotImplementedException(), + }; + + /// + /// Pushes a diagnostic for the given product. If an existing one exists, it is replaced. + /// + /// The same diagnostic that was pushed, for chained invocations. + public Diagnostic Push(string product, Diagnostic diagnostic) + { + // Directly sets, since we only expect to get one warning per sponsorable+product + // combination. + Diagnostics[product] = diagnostic; + return diagnostic; + } + + /// + /// Attemps to remove a diagnostic for the given product. + /// + /// The product diagnostic that might have been pushed previously. + /// The removed diagnostic, or if none was previously pushed. + public Diagnostic? Pop(string product) + { + Diagnostics.TryRemove(product, out var diagnostic); + return diagnostic; + } + + /// + /// Gets the status of the given product based on a previously stored diagnostic. + /// + /// The product to check status for. + /// Optional that was reported, if any. + public SponsorStatus? GetStatus(string product) + { + // NOTE: the SponsorLinkAnalyzer.SetStatus uses diagnostic properties to store the + // kind of diagnostic as a simple string instead of the enum. We do this so that + // multiple analyzers or versions even across multiple products, which all would + // have their own enum, can still share the same diagnostic kind. + if (Diagnostics.TryGetValue(product, out var diagnostic) && + diagnostic.Properties.TryGetValue(nameof(SponsorStatus), out var value)) + { + // Switch on value matching DiagnosticKind names + return value switch + { + nameof(SponsorStatus.Unknown) => SponsorStatus.Unknown, + nameof(SponsorStatus.Sponsor) => SponsorStatus.Sponsor, + nameof(SponsorStatus.Expiring) => SponsorStatus.Expiring, + nameof(SponsorStatus.Expired) => SponsorStatus.Expired, + _ => null, + }; + } + + return null; + } + + static DiagnosticDescriptor CreateSponsor(string[] sponsorable, string prefix) => new( + $"{prefix}100", + Resources.Sponsor_Title, + Resources.Sponsor_Message, + "SponsorLink", + DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: Resources.Sponsor_Description, + helpLinkUri: "https://github.com/devlooped#sponsorlink", + "DoesNotSupportF1Help"); + + static DiagnosticDescriptor CreateUnknown(string[] sponsorable, string product, string prefix) => new( + $"{prefix}101", + Resources.Unknown_Title, + Resources.Unknown_Message, + "SponsorLink", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: string.Format(CultureInfo.CurrentCulture, Resources.Unknown_Description, + sponsorable.Humanize(x => $"https://github.com/sponsors/{x}"), + string.Join(" ", sponsorable)), + helpLinkUri: "https://github.com/devlooped#sponsorlink", + WellKnownDiagnosticTags.NotConfigurable); + + static DiagnosticDescriptor CreateExpiring(string[] sponsorable, string prefix) => new( + $"{prefix}103", + Resources.Expiring_Title, + Resources.Expiring_Message, + "SponsorLink", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: string.Format(CultureInfo.CurrentCulture, Resources.Expiring_Description, string.Join(" ", sponsorable)), + helpLinkUri: "https://github.com/devlooped#autosync", + "DoesNotSupportF1Help", WellKnownDiagnosticTags.NotConfigurable); + + static DiagnosticDescriptor CreateExpired(string[] sponsorable, string prefix) => new( + $"{prefix}104", + Resources.Expired_Title, + Resources.Expired_Message, + "SponsorLink", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: string.Format(CultureInfo.CurrentCulture, Resources.Expired_Description, string.Join(" ", sponsorable)), + helpLinkUri: "https://github.com/devlooped#autosync", + "DoesNotSupportF1Help", WellKnownDiagnosticTags.NotConfigurable); +} diff --git a/src/SponsorLink/SponsorLink/ManifestStatus.cs b/src/SponsorLink/SponsorLink/ManifestStatus.cs new file mode 100644 index 0000000..0960e5a --- /dev/null +++ b/src/SponsorLink/SponsorLink/ManifestStatus.cs @@ -0,0 +1,25 @@ +// +namespace Devlooped.Sponsors; + +/// +/// The resulting status from validation. +/// +public enum ManifestStatus +{ + /// + /// The manifest couldn't be read at all. + /// + Unknown, + /// + /// The manifest was read and is valid (not expired and properly signed). + /// + Valid, + /// + /// The manifest was read but has expired. + /// + Expired, + /// + /// The manifest was read, but its signature is invalid. + /// + Invalid, +} diff --git a/src/SponsorLink/SponsorLink/Resources.es.resx b/src/SponsorLink/SponsorLink/Resources.es.resx new file mode 100644 index 0000000..ec1b5c1 --- /dev/null +++ b/src/SponsorLink/SponsorLink/Resources.es.resx @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Patrocinar los proyectos en que dependes asegura que se mantengan activos, y que recibas el apoyo que necesitas. También es muy económico y está disponible en todo el mundo! +Por favor considera apoyar el proyecto patrocinando en {0} y ejecutando posteriormente 'sponsor sync {1}'. + + + Por favor considere apoyar {0} patrocinando @{1} 🙏 + + + Estado de patrocinio desconocido + + + Funcionalidades exclusivas para patrocinadores pueden no estar disponibles. Ejecuta 'sponsor sync {0}' y, opcionalmente, habilita la sincronización automática. + + + El estado de patrocino ha expirado y la sincronización automática no está habilitada. + + + El estado de patrocino ha expirado + + + Eres un verdadero héroe. Tu patrocinio ayuda a mantener el proyecto vivo y próspero 🙏. + + + Gracias por apoyar a {0} con tu patrocinio 💟! + + + Eres un patrocinador del proyecto, eres lo máximo 💟! + + + El estado de patrocino ha expirado y estás en un período de gracia. Ejecuta 'sponsor sync {0}' y, opcionalmente, habilita la sincronización automática. + + + El estado de patrocino necesita actualización periódica y la sincronización automática no está habilitada. + + + El estado de patrocino ha expirado y el período de gracia terminará pronto + + + y + + + o + + \ No newline at end of file diff --git a/src/SponsorLink/SponsorLink/Resources.resx b/src/SponsorLink/SponsorLink/Resources.resx new file mode 100644 index 0000000..e12a0e5 --- /dev/null +++ b/src/SponsorLink/SponsorLink/Resources.resx @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Sponsoring projects you depend on ensures they remain active, and that you get the support you need. It's also super affordable and available worldwide! +Please consider supporting the project by sponsoring at {0} and running 'sponsor sync {1}' afterwards. + Unknown sponsor description + + + Please consider supporting {0} by sponsoring @{1} 🙏 + + + Unknown sponsor status + + + Sponsor-only features may be disabled. Please run 'sponsor sync {0}' and optionally enable automatic sync. + + + Sponsor status has expired and automatic sync has not been enabled. + + + Sponsor status expired + + + You are a true hero. Your sponsorship helps keep the project alive and thriving 🙏. + + + Thank you for supporting {0} with your sponsorship 💟! + + + You are a sponsor of the project, you rock 💟! + + + Sponsor status has expired and you are in the grace period. Please run 'sponsor sync {0}' and optionally enable automatic sync. + + + Sponsor status needs periodic updating and automatic sync has not been enabled. + + + Sponsor status expired, grace period ending soon + + + and + + + or + + \ No newline at end of file diff --git a/src/SponsorLink/SponsorLink/SponsorLink.cs b/src/SponsorLink/SponsorLink/SponsorLink.cs new file mode 100644 index 0000000..f3d8328 --- /dev/null +++ b/src/SponsorLink/SponsorLink/SponsorLink.cs @@ -0,0 +1,169 @@ +// +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Reflection; +using System.Security.Claims; +using Microsoft.IdentityModel.Tokens; + +namespace Devlooped.Sponsors; + +static partial class SponsorLink +{ + public static Dictionary Sponsorables { get; } = typeof(SponsorLink).Assembly + .GetCustomAttributes() + .Where(x => x.Key.StartsWith("Funding.GitHub.")) + .Select(x => new { Key = x.Key[15..], x.Value }) + .ToDictionary(x => x.Key, x => x.Value); + + /// + /// Whether the current process is running in an IDE, either + /// or . + /// + public static bool IsEditor => IsVisualStudio || IsRider; + + /// + /// Whether the current process is running as part of an active Visual Studio instance. + /// + public static bool IsVisualStudio => + Environment.GetEnvironmentVariable("ServiceHubLogSessionKey") != null || + Environment.GetEnvironmentVariable("VSAPPIDNAME") != null; + + /// + /// Whether the current process is running as part of an active Rider instance. + /// + public static bool IsRider => + Environment.GetEnvironmentVariable("RESHARPER_FUS_SESSION") != null || + Environment.GetEnvironmentVariable("IDEA_INITIAL_DIRECTORY") != null; + + /// + /// Manages the sharing and reporting of diagnostics across the source generator + /// and the diagnostic analyzer, to avoid doing the online check more than once. + /// + public static DiagnosticsManager Diagnostics { get; } = new(); + + /// + /// Gets the expiration date from the principal, if any. + /// + /// + /// Whichever "exp" claim is the latest, or if none found. + /// + public static DateTime? GetExpiration(this ClaimsPrincipal principal) + // get all "exp" claims, parse them and return the latest one or null if none found + => principal.FindAll("exp") + .Select(c => c.Value) + .Select(long.Parse) + .Select(DateTimeOffset.FromUnixTimeSeconds) + .Max().DateTime is var exp && exp == DateTime.MinValue ? null : exp; + + /// + /// Reads all manifests, validating their signatures. + /// + /// The combined principal with all identities (and their claims) from each provided and valid JWT + /// The tokens to read and their corresponding JWK for signature verification. + /// if at least one manifest can be successfully read and is valid. + /// otherwise. + public static bool TryRead([NotNullWhen(true)] out ClaimsPrincipal? principal, params (string jwt, string jwk)[] values) + => TryRead(out principal, values.AsEnumerable()); + + /// + /// Reads all manifests, validating their signatures. + /// + /// The combined principal with all identities (and their claims) from each provided and valid JWT + /// The tokens to read and their corresponding JWK for signature verification. + /// if at least one manifest can be successfully read and is valid. + /// otherwise. + public static bool TryRead([NotNullWhen(true)] out ClaimsPrincipal? principal, IEnumerable<(string jwt, string jwk)> values) + { + principal = null; + + foreach (var value in values) + { + if (string.IsNullOrWhiteSpace(value.jwt) || string.IsNullOrEmpty(value.jwk)) + continue; + + if (Validate(value.jwt, value.jwk, out var token, out var claims, false) == ManifestStatus.Valid && claims != null) + { + if (principal == null) + principal = claims; + else + principal.AddIdentities(claims.Identities); + } + } + + return principal != null; + } + + /// + /// Validates the manifest signature and optional expiration. + /// + /// The JWT to validate. + /// The key to validate the manifest signature with. + /// Except when returning , returns the security token read from the JWT, even if signature check failed. + /// The associated claims, only when return value is not . + /// Whether to check for expiration. + /// The status of the validation. + public static ManifestStatus Validate(string jwt, string jwk, out SecurityToken? token, out ClaimsPrincipal? principal, bool validateExpiration) + { + token = default; + principal = default; + + SecurityKey key; + try + { + key = JsonWebKey.Create(jwk); + } + catch (ArgumentException) + { + return ManifestStatus.Unknown; + } + + var handler = new JwtSecurityTokenHandler { MapInboundClaims = false }; + + if (!handler.CanReadToken(jwt)) + return ManifestStatus.Unknown; + + var validation = new TokenValidationParameters + { + RequireExpirationTime = false, + ValidateLifetime = false, + ValidateAudience = false, + ValidateIssuer = false, + ValidateIssuerSigningKey = true, + IssuerSigningKey = key, + RoleClaimType = "roles", + NameClaimType = "sub", + }; + + try + { + principal = handler.ValidateToken(jwt, validation, out token); + if (validateExpiration && token.ValidTo == DateTime.MinValue) + return ManifestStatus.Invalid; + + // The sponsorable manifest does not have an expiration time. + if (validateExpiration && token.ValidTo < DateTimeOffset.UtcNow) + return ManifestStatus.Expired; + + return ManifestStatus.Valid; + } + catch (SecurityTokenInvalidSignatureException) + { + var jwtToken = handler.ReadJwtToken(jwt); + token = jwtToken; + principal = new ClaimsPrincipal(new ClaimsIdentity(jwtToken.Claims)); + return ManifestStatus.Invalid; + } + catch (SecurityTokenException) + { + var jwtToken = handler.ReadJwtToken(jwt); + token = jwtToken; + principal = new ClaimsPrincipal(new ClaimsIdentity(jwtToken.Claims)); + return ManifestStatus.Invalid; + } + } + +} diff --git a/src/SponsorLink/SponsorLink/SponsorLink.csproj b/src/SponsorLink/SponsorLink/SponsorLink.csproj new file mode 100644 index 0000000..824353d --- /dev/null +++ b/src/SponsorLink/SponsorLink/SponsorLink.csproj @@ -0,0 +1,79 @@ + + + + netstandard2.0 + SponsorLink + disable + false + CoreResGen;$(CoreCompileDependsOn) + + + + + $(Product) + + $([System.Text.RegularExpressions.Regex]::Replace("$(FundingProduct)", "[^A-Z]", "")) + + 21 + + + + + + + + + + + + + + + + + + + + + + + + namespace Devlooped.Sponsors%3B + +partial class SponsorLink +{ + public partial class Funding + { + public const string Product = "$(FundingProduct)"%3B + public const string Prefix = "$(FundingPrefix)"%3B + public const int Grace = $(FundingGrace)%3B + } +} + + + + + + + + + + + + + + + + + + + + + $([System.IO.File]::ReadAllText('$(MSBuildProjectDirectory)\$(BaseIntermediateOutputPath)devlooped.jwk')) + + + + + + + diff --git a/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs b/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs new file mode 100644 index 0000000..2bf1783 --- /dev/null +++ b/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs @@ -0,0 +1,119 @@ +// +#nullable enable +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Linq; +using Humanizer; +using Humanizer.Localisation; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using static Devlooped.Sponsors.SponsorLink; + +namespace Devlooped.Sponsors; + +/// +/// Links the sponsor status for the current compilation. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)] +public class SponsorLinkAnalyzer : DiagnosticAnalyzer +{ + static readonly Dictionary descriptors = new() + { + // Requires: + // + // + { SponsorStatus.Unknown, Diagnostics.GetDescriptor([.. Sponsorables.Keys], Funding.Product, Funding.Prefix, SponsorStatus.Unknown) }, + { SponsorStatus.Sponsor, Diagnostics.GetDescriptor([.. Sponsorables.Keys], Funding.Product, Funding.Prefix, SponsorStatus.Sponsor) }, + { SponsorStatus.Expiring, Diagnostics.GetDescriptor([.. Sponsorables.Keys], Funding.Product, Funding.Prefix, SponsorStatus.Expiring) }, + { SponsorStatus.Expired, Diagnostics.GetDescriptor([.. Sponsorables.Keys], Funding.Product, Funding.Prefix, SponsorStatus.Expired) }, + }; + + public override ImmutableArray SupportedDiagnostics { get; } = descriptors.Values.ToImmutableArray(); + +#pragma warning disable RS1026 // Enable concurrent execution + public override void Initialize(AnalysisContext context) +#pragma warning restore RS1026 // Enable concurrent execution + { +#if !DEBUG + // Only enable concurrent execution in release builds, otherwise debugging is quite annoying. + context.EnableConcurrentExecution(); +#endif + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + +#pragma warning disable RS1013 // Start action has no registered non-end actions + // We do this so that the status is set at compilation start so we can use it + // across all other analyzers. We report only on finish because multiple + // analyzers can report the same diagnostic and we want to avoid duplicates. + context.RegisterCompilationStartAction(ctx => + { + var manifests = ctx.Options.AdditionalFiles + .Where(x => + ctx.Options.AnalyzerConfigOptionsProvider.GetOptions(x).TryGetValue("build_metadata.AdditionalFiles.SourceItemType", out var itemType) && + itemType == "SponsorManifest" && + Sponsorables.ContainsKey(Path.GetFileNameWithoutExtension(x.Path))) + .ToImmutableArray(); + + // Setting the status early allows other analyzers to potentially check for it. + var status = SetStatus(manifests); + // Never report any diagnostic unless we're in an editor. + if (IsEditor) + { + // NOTE: even if we don't report the diagnostic, we still set the status so other analyzers can use it. + ctx.RegisterCompilationEndAction(ctx => + { + // NOTE: for multiple projects with the same product name, we only report one diagnostic, + // so it's expected to NOT get a diagnostic back. Also, we don't want to report + // multiple diagnostics for each project in a solution that uses the same product. + if (Diagnostics.Pop(Funding.Product) is Diagnostic diagnostic) + { + ctx.ReportDiagnostic(diagnostic); + } + }); + } + }); +#pragma warning restore RS1013 // Start action has no registered non-end actions + } + + SponsorStatus SetStatus(ImmutableArray manifests) + { + if (!SponsorLink.TryRead(out var claims, manifests.Select(text => + (text.GetText()?.ToString() ?? "", Sponsorables[Path.GetFileNameWithoutExtension(text.Path)]))) || + claims.GetExpiration() is not DateTime exp) + { + // report unknown, either unparsed manifest or one with no expiration (which we never emit). + Diagnostics.Push(Funding.Product, Diagnostic.Create(descriptors[SponsorStatus.Unknown], null, + properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Unknown)), + Funding.Product, Sponsorables.Keys.Humanize(Resources.Or))); + return SponsorStatus.Unknown; + } + else if (exp < DateTime.Now) + { + // report expired or expiring soon if still within the configured days of grace period + if (exp.AddDays(Funding.Grace) < DateTime.Now) + { + // report expiring soon + Diagnostics.Push(Funding.Product, Diagnostic.Create(descriptors[SponsorStatus.Expiring], null, + properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Expiring)))); + return SponsorStatus.Expiring; + } + else + { + // report expired + Diagnostics.Push(Funding.Product, Diagnostic.Create(descriptors[SponsorStatus.Expired], null, + properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Expired)))); + return SponsorStatus.Expired; + } + } + else + { + // report sponsor + Diagnostics.Push(Funding.Product, Diagnostic.Create(descriptors[SponsorStatus.Sponsor], null, + properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Sponsor)), + Funding.Product)); + return SponsorStatus.Sponsor; + } + } +} diff --git a/src/SponsorLink/SponsorLink/SponsorStatus.cs b/src/SponsorLink/SponsorLink/SponsorStatus.cs new file mode 100644 index 0000000..6cdbc90 --- /dev/null +++ b/src/SponsorLink/SponsorLink/SponsorStatus.cs @@ -0,0 +1,25 @@ +// +namespace Devlooped.Sponsors; + +/// +/// The determined sponsoring status. +/// +public enum SponsorStatus +{ + /// + /// Sponsorship status is unknown. + /// + Unknown, + /// + /// The sponsors manifest is expired but within the grace period. + /// + Expiring, + /// + /// The sponsors manifest is expired and outside the grace period. + /// + Expired, + /// + /// The user is sponsoring. + /// + Sponsor, +} diff --git a/src/SponsorLink/SponsorLink/SponsorableLib.targets b/src/SponsorLink/SponsorLink/SponsorableLib.targets new file mode 100644 index 0000000..8311ca6 --- /dev/null +++ b/src/SponsorLink/SponsorLink/SponsorableLib.targets @@ -0,0 +1,60 @@ + + + + + $([System.IO.Path]::GetFullPath($(MSBuildThisFileDirectory)sponsorable.md)) + + + + + + + + + + $(WarningsNotAsErrors);LIB001;LIB002;LIB003;LIB004;LIB005 + + $(BaseIntermediateOutputPath)autosync.stamp + + $(HOME) + $(USERPROFILE) + + true + $([System.IO.Path]::GetFullPath('$(UserProfileHome)/.sponsorlink')) + + + + + + + + + + + + + + + + + + + + + + + + + + %(GitRoot.FullPath) + + + + + + + + \ No newline at end of file diff --git a/src/SponsorLink/SponsorLink/Tracing.cs b/src/SponsorLink/SponsorLink/Tracing.cs new file mode 100644 index 0000000..9201796 --- /dev/null +++ b/src/SponsorLink/SponsorLink/Tracing.cs @@ -0,0 +1,53 @@ +// +#nullable enable +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text; + +namespace Devlooped.Sponsors; + +static class Tracing +{ + public static void Trace(string message, object? value, [CallerArgumentExpression("value")] string? expression = null, [CallerFilePath] string? filePath = null, [CallerLineNumber] int lineNumber = 0) + => Trace($"{message}: {value} ({expression})", filePath, lineNumber); + + public static void Trace(object? value, [CallerArgumentExpression("value")] string? expression = null, [CallerFilePath] string? filePath = null, [CallerLineNumber] int lineNumber = 0) + => Trace($"{value} ({expression})", filePath, lineNumber); + + public static void Trace([CallerMemberName] string? message = null, [CallerFilePath] string? filePath = null, [CallerLineNumber] int lineNumber = 0) + { + var trace = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SPONSORLINK_TRACE")); +#if DEBUG + trace = true; +#endif + + if (!trace) + return; + + var line = new StringBuilder() + .Append($"[{DateTime.Now:O}]") + .Append($"[{Process.GetCurrentProcess().ProcessName}:{Process.GetCurrentProcess().Id}]") + .Append($" {message} ") + .AppendLine($" -> {filePath}({lineNumber})") + .ToString(); + + var dir = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData, Environment.SpecialFolderOption.Create); + var tries = 0; + // Best-effort only + while (tries < 10) + { + try + { + File.AppendAllText(Path.Combine(dir, "SponsorLink.log"), line); + Debugger.Log(0, "SponsorLink", line); + return; + } + catch (IOException) + { + tries++; + } + } + } +} diff --git a/src/SponsorLink/SponsorLink/buildTransitive/Devlooped.Sponsors.targets b/src/SponsorLink/SponsorLink/buildTransitive/Devlooped.Sponsors.targets new file mode 100644 index 0000000..de0563e --- /dev/null +++ b/src/SponsorLink/SponsorLink/buildTransitive/Devlooped.Sponsors.targets @@ -0,0 +1,102 @@ + + + + + $([System.DateTime]::Now.ToString("yyyy-MM-yy")) + + $(BaseIntermediateOutputPath)autosync-$(Today).stamp + + $(BaseIntermediateOutputPath)autosync.stamp + + $(HOME) + $(USERPROFILE) + + $([System.IO.Path]::GetFullPath('$(UserProfileHome)/.sponsorlink')) + + $([System.IO.Path]::Combine('$(SponsorLinkHome)', '.netconfig')) + + + + + + + + + + + + + SL_CollectDependencies + $(SLDependsOn);SL_CheckAutoSync;SL_ReadAutoSyncEnabled;SL_SyncSponsors + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + %(SLConfigAutoSync.Identity) + true + false + + + + + + + + $([System.IO.File]::ReadAllText($(AutoSyncStampFile)).Trim()) + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/SponsorLink/SponsorLink/sponsorable.md b/src/SponsorLink/SponsorLink/sponsorable.md new file mode 100644 index 0000000..c023c25 --- /dev/null +++ b/src/SponsorLink/SponsorLink/sponsorable.md @@ -0,0 +1,5 @@ +# Why Sponsor + +Well, why not? It's super cheap :) + +This could even be partially auto-generated from FUNDING.yml and what-not. \ No newline at end of file diff --git a/src/SponsorLink/SponsorLinkAnalyzer.sln b/src/SponsorLink/SponsorLinkAnalyzer.sln new file mode 100644 index 0000000..be206b1 --- /dev/null +++ b/src/SponsorLink/SponsorLinkAnalyzer.sln @@ -0,0 +1,43 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.34928.147 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Analyzer", "Analyzer\Analyzer.csproj", "{584984D6-926B-423D-9416-519613423BAE}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Library", "Library\Library.csproj", "{598CD398-A172-492C-8367-827D43276029}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "Tests\Tests.csproj", "{EA02494C-6ED4-47A0-8D43-20F50BE8554F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SponsorLink", "SponsorLink\SponsorLink.csproj", "{B91C7E99-3D2E-4FDF-B017-9123E810197F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {584984D6-926B-423D-9416-519613423BAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {584984D6-926B-423D-9416-519613423BAE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {584984D6-926B-423D-9416-519613423BAE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {584984D6-926B-423D-9416-519613423BAE}.Release|Any CPU.Build.0 = Release|Any CPU + {598CD398-A172-492C-8367-827D43276029}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {598CD398-A172-492C-8367-827D43276029}.Debug|Any CPU.Build.0 = Debug|Any CPU + {598CD398-A172-492C-8367-827D43276029}.Release|Any CPU.ActiveCfg = Release|Any CPU + {598CD398-A172-492C-8367-827D43276029}.Release|Any CPU.Build.0 = Release|Any CPU + {EA02494C-6ED4-47A0-8D43-20F50BE8554F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EA02494C-6ED4-47A0-8D43-20F50BE8554F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EA02494C-6ED4-47A0-8D43-20F50BE8554F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EA02494C-6ED4-47A0-8D43-20F50BE8554F}.Release|Any CPU.Build.0 = Release|Any CPU + {B91C7E99-3D2E-4FDF-B017-9123E810197F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B91C7E99-3D2E-4FDF-B017-9123E810197F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B91C7E99-3D2E-4FDF-B017-9123E810197F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B91C7E99-3D2E-4FDF-B017-9123E810197F}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {1DDA0EFF-BEF6-49BB-8AA8-D71FE1CD3E6F} + EndGlobalSection +EndGlobal diff --git a/src/SponsorLink/Tests/.netconfig b/src/SponsorLink/Tests/.netconfig new file mode 100644 index 0000000..3b3bd0d --- /dev/null +++ b/src/SponsorLink/Tests/.netconfig @@ -0,0 +1,15 @@ +[file "SponsorableManifest.cs"] + url = https://github.com/devlooped/SponsorLink/blob/main/src/Core/SponsorableManifest.cs + sha = 976ecefc44d87217e04933d9cd7f6b950468410b + etag = e0c95e7fc6c0499dbc8c5cd28aa9a6a5a49c9d0ad41fe028a5a085aca7e00eaf + weak +[file "JsonOptions.cs"] + url = https://github.com/devlooped/SponsorLink/blob/main/src/Core/JsonOptions.cs + sha = 79dc56ce45fc36df49e1c4f8875e93c297edc383 + etag = 6e9a1b12757a97491441b9534ced4e5dac6d9d6334008fa0cd20575650bbd935 + weak +[file "Extensions.cs"] + url = https://github.com/devlooped/SponsorLink/blob/main/src/Core/Extensions.cs + sha = d204b667eace818934c49e09b5b08ea82aef87fa + etag = f68e11894103f8748ce290c29927bf1e4f749e743ae33d5350e72ed22c15d245 + weak diff --git a/src/SponsorLink/Tests/Attributes.cs b/src/SponsorLink/Tests/Attributes.cs new file mode 100644 index 0000000..aa5f48d --- /dev/null +++ b/src/SponsorLink/Tests/Attributes.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.Configuration; +using Xunit; + +public class SecretsFactAttribute : FactAttribute +{ + public SecretsFactAttribute(params string[] secrets) + { + var configuration = new ConfigurationBuilder() + .AddUserSecrets() + .Build(); + + var missing = new HashSet(); + + foreach (var secret in secrets) + { + if (string.IsNullOrEmpty(configuration[secret])) + missing.Add(secret); + } + + if (missing.Count > 0) + Skip = "Missing user secrets: " + string.Join(',', missing); + } +} + +public class LocalFactAttribute : SecretsFactAttribute +{ + public LocalFactAttribute(params string[] secrets) : base(secrets) + { + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))) + Skip = "Non-CI test"; + } +} + +public class CIFactAttribute : FactAttribute +{ + public CIFactAttribute() + { + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))) + Skip = "CI-only test"; + } +} + +public class LocalTheoryAttribute : TheoryAttribute +{ + public LocalTheoryAttribute() + { + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))) + Skip = "Non-CI test"; + } +} + +public class CITheoryAttribute : TheoryAttribute +{ + public CITheoryAttribute() + { + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))) + Skip = "CI-only test"; + } +} \ No newline at end of file diff --git a/src/SponsorLink/Tests/Extensions.cs b/src/SponsorLink/Tests/Extensions.cs new file mode 100644 index 0000000..75a78b4 --- /dev/null +++ b/src/SponsorLink/Tests/Extensions.cs @@ -0,0 +1,43 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Logging; + +namespace Devlooped.Sponsors; + +static class Extensions +{ + public static HashCode Add(this HashCode hash, params object[] items) + { + foreach (var item in items) + hash.Add(item); + + return hash; + } + + + public static HashCode AddRange(this HashCode hash, IEnumerable items) + { + foreach (var item in items) + hash.Add(item); + + return hash; + } + + public static Array Cast(this Array array, Type elementType) + { + //Convert the object list to the destination array type. + var result = Array.CreateInstance(elementType, array.Length); + Array.Copy(array, result, array.Length); + return result; + } + + public static void Assert(this ILogger logger, [DoesNotReturnIf(false)] bool condition, [CallerArgumentExpression(nameof(condition))] string? message = default, params object?[] args) + { + if (!condition) + { + //Debug.Assert(condition, message); + logger.LogError(message, args); + throw new InvalidOperationException(message); + } + } +} diff --git a/src/SponsorLink/Tests/JsonOptions.cs b/src/SponsorLink/Tests/JsonOptions.cs new file mode 100644 index 0000000..c816eba --- /dev/null +++ b/src/SponsorLink/Tests/JsonOptions.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Microsoft.IdentityModel.Tokens; + +namespace Devlooped.Sponsors; + +static partial class JsonOptions +{ + public static JsonSerializerOptions Default { get; } = +#if NET6_0_OR_GREATER + new(JsonSerializerDefaults.Web) +#else + new() +#endif + { + AllowTrailingCommas = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + ReadCommentHandling = JsonCommentHandling.Skip, +#if NET6_0_OR_GREATER + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault | JsonIgnoreCondition.WhenWritingNull, +#endif + WriteIndented = true, + Converters = + { + new JsonStringEnumConverter(allowIntegerValues: false), +#if NET6_0_OR_GREATER + new DateOnlyJsonConverter() +#endif + } + }; + + public static JsonSerializerOptions JsonWebKey { get; } = new(JsonSerializerOptions.Default) + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault | JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver + { + Modifiers = + { + info => + { + if (info.Type != typeof(JsonWebKey)) + return; + + foreach (var prop in info.Properties) + { + // Don't serialize empty lists, makes for more concise JWKs + prop.ShouldSerialize = (obj, value) => + value is not null && + (value is not IList list || list.Count > 0); + } + } + } + } + }; + + +#if NET6_0_OR_GREATER + public class DateOnlyJsonConverter : JsonConverter + { + public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => DateOnly.Parse(reader.GetString()?[..10] ?? "", CultureInfo.InvariantCulture); + + public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString("O", CultureInfo.InvariantCulture)); + } +#endif +} diff --git a/src/SponsorLink/Tests/Resources.Designer.cs b/src/SponsorLink/Tests/Resources.Designer.cs new file mode 100644 index 0000000..7824a60 --- /dev/null +++ b/src/SponsorLink/Tests/Resources.Designer.cs @@ -0,0 +1,63 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Tests { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Tests.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + } +} diff --git a/src/SponsorLink/Tests/Resources.resx b/src/SponsorLink/Tests/Resources.resx new file mode 100644 index 0000000..4fdb1b6 --- /dev/null +++ b/src/SponsorLink/Tests/Resources.resx @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/src/SponsorLink/Tests/Sample.cs b/src/SponsorLink/Tests/Sample.cs new file mode 100644 index 0000000..897c91c --- /dev/null +++ b/src/SponsorLink/Tests/Sample.cs @@ -0,0 +1,59 @@ +extern alias Analyzer; +using System; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using Analyzer::Devlooped.Sponsors; +using Xunit; +using Xunit.Abstractions; + +namespace Tests; + +public class Sample(ITestOutputHelper output) +{ + [Theory] + [InlineData("es-AR", SponsorStatus.Unknown)] + [InlineData("es-AR", SponsorStatus.Expiring)] + [InlineData("es-AR", SponsorStatus.Expired)] + [InlineData("es-AR", SponsorStatus.Sponsor)] + [InlineData("en", SponsorStatus.Unknown)] + [InlineData("en", SponsorStatus.Expiring)] + [InlineData("en", SponsorStatus.Expired)] + [InlineData("en", SponsorStatus.Sponsor)] + [InlineData("", SponsorStatus.Unknown)] + [InlineData("", SponsorStatus.Expiring)] + [InlineData("", SponsorStatus.Expired)] + [InlineData("", SponsorStatus.Sponsor)] + public void Test(string culture, SponsorStatus kind) + { + Thread.CurrentThread.CurrentCulture = Thread.CurrentThread.CurrentUICulture = + culture == "" ? CultureInfo.InvariantCulture : CultureInfo.GetCultureInfo(culture); + + var diag = new DiagnosticsManager().GetDescriptor(["foo"], "bar", "FB", kind); + + output.WriteLine(diag.Title.ToString()); + output.WriteLine(diag.MessageFormat.ToString()); + output.WriteLine(diag.Description.ToString()); + } + + [Fact] + public void RenderSponsorables() + { + Assert.NotEmpty(SponsorLink.Sponsorables); + + foreach (var pair in SponsorLink.Sponsorables) + { + output.WriteLine($"{pair.Key} = {pair.Value}"); + // Read the JWK + var jsonWebKey = Microsoft.IdentityModel.Tokens.JsonWebKey.Create(pair.Value); + + Assert.NotNull(jsonWebKey); + + using var key = RSA.Create(new RSAParameters + { + Modulus = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.DecodeBytes(jsonWebKey.N), + Exponent = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.DecodeBytes(jsonWebKey.E), + }); + } + } +} \ No newline at end of file diff --git a/src/SponsorLink/Tests/SponsorLinkTests.cs b/src/SponsorLink/Tests/SponsorLinkTests.cs new file mode 100644 index 0000000..7625e2c --- /dev/null +++ b/src/SponsorLink/Tests/SponsorLinkTests.cs @@ -0,0 +1,126 @@ +extern alias Analyzer; +using System.Security.Cryptography; +using System.Text.Json; +using Analyzer::Devlooped.Sponsors; +using Devlooped.Sponsors; +using Microsoft.IdentityModel.Tokens; +using Xunit; + +namespace Devlooped.Tests; + +public class SponsorLinkTests +{ + // We need to convert to jwk string since the analyzer project has merged the JWT assembly and types. + public static string ToJwk(SecurityKey key) + => JsonSerializer.Serialize( + JsonWebKeyConverter.ConvertFromSecurityKey(key), + JsonOptions.JsonWebKey); + + [Fact] + public void ValidateSponsorable() + { + var manifest = SponsorableManifest.Create(new Uri("https://foo.com"), [new Uri("https://github.com/sponsors/bar")], "ASDF1234"); + var jwt = manifest.ToJwt(); + var jwk = ToJwk(manifest.SecurityKey); + + // NOTE: sponsorable manifest doesn't have expiration date. + var status = SponsorLink.Validate(jwt, jwk, out var token, out var principal, false); + + Assert.Equal(ManifestStatus.Valid, status); + } + + [Fact] + public void ValidateWrongKey() + { + var manifest = SponsorableManifest.Create(new Uri("https://foo.com"), [new Uri("https://github.com/sponsors/bar")], "ASDF1234"); + var jwt = manifest.ToJwt(); + var jwk = ToJwk(new RsaSecurityKey(RSA.Create())); + + var status = SponsorLink.Validate(jwt, jwk, out var token, out var principal, false); + + Assert.Equal(ManifestStatus.Invalid, status); + + // We should still be a able to read the data, knowing it may have been tampered with. + Assert.NotNull(principal); + Assert.NotNull(token); + } + + [Fact] + public void ValidateExpiredSponsor() + { + var manifest = SponsorableManifest.Create(new Uri("https://foo.com"), [new Uri("https://github.com/sponsors/bar")], "ASDF1234"); + var jwk = ToJwk(manifest.SecurityKey); + var sponsor = manifest.Sign([], expiration: TimeSpan.Zero); + + // Will be expired after this. + Thread.Sleep(1000); + + var status = SponsorLink.Validate(sponsor, jwk, out var token, out var principal, true); + + Assert.Equal(ManifestStatus.Expired, status); + + // We should still be a able to read the data, even if expired (but not tampered with). + Assert.NotNull(principal); + Assert.NotNull(token); + } + + [Fact] + public void ValidateUnknownFormat() + { + var manifest = SponsorableManifest.Create(new Uri("https://foo.com"), [new Uri("https://github.com/sponsors/bar")], "ASDF1234"); + var jwk = ToJwk(manifest.SecurityKey); + + var status = SponsorLink.Validate("asdfasdf", jwk, out var token, out var principal, false); + + Assert.Equal(ManifestStatus.Unknown, status); + + // Nothing could be read at all. + Assert.Null(principal); + Assert.Null(token); + } + + [Fact] + public void TryRead() + { + var fooSponsorable = SponsorableManifest.Create(new Uri("https://foo.com"), [new Uri("https://github.com/sponsors/foo")], "ASDF1234"); + var barSponsorable = SponsorableManifest.Create(new Uri("https://bar.com"), [new Uri("https://github.com/sponsors/bar")], "GHJK5678"); + + // Org sponsor and member of team + var fooSponsor = fooSponsorable.Sign([new("sub", "kzu"), new("email", "me@foo.com"), new("roles", "org"), new("roles", "team")], expiration: TimeSpan.FromDays(30)); + // Org + personal sponsor + var barSponsor = barSponsorable.Sign([new("sub", "kzu"), new("email", "me@bar.com"), new("roles", "org"), new("roles", "user")], expiration: TimeSpan.FromDays(30)); + + Assert.True(SponsorLink.TryRead(out var principal, [(fooSponsor, ToJwk(fooSponsorable.SecurityKey)), (barSponsor, ToJwk(barSponsorable.SecurityKey))])); + + // Can check role across both JWTs + Assert.True(principal.IsInRole("org")); + Assert.True(principal.IsInRole("team")); + Assert.True(principal.IsInRole("user")); + + Assert.True(principal.HasClaim("sub", "kzu")); + Assert.True(principal.HasClaim("email", "me@foo.com")); + Assert.True(principal.HasClaim("email", "me@bar.com")); + } + + [LocalFact] + public void ValidateCachedManifest() + { + var path = Environment.ExpandEnvironmentVariables("%userprofile%\\.sponsorlink\\github\\devlooped.jwt"); + if (!File.Exists(path)) + return; + + var jwt = File.ReadAllText(path); + + var status = SponsorLink.Validate(jwt, + """ + { + "e": "AQAB", + "kty": "RSA", + "n": "5inhv8QymaDBOihNi1eY-6-hcIB5qSONFZxbxxXAyOtxAdjFCPM-94gIZqM9CDrX3pyg1lTJfml_a_FZSU9dB1ii5mSX_mNHBFXn1_l_gi1ErdbkIF5YbW6oxWFxf3G5mwVXwnPfxHTyQdmWQ3YJR-A3EB4kaFwLqA6Ha5lb2ObGpMTQJNakD4oTAGDhqHMGhu6PupGq5ie4qZcQ7N8ANw8xH7nicTkbqEhQABHWOTmLBWq5f5F6RYGF8P7cl0IWl_w4YcIZkGm2vX2fi26F9F60cU1v13GZEVDTXpJ9kzvYeM9sYk6fWaoyY2jhE51qbv0B0u6hScZiLREtm3n7ClJbIGXhkUppFS2JlNaX3rgQ6t-4LK8gUTyLt3zDs2H8OZyCwlCpfmGmdsUMkm1xX6t2r-95U3zywynxoWZfjBCJf41leM9OMKYwNWZ6LQMyo83HWw1PBIrX4ZLClFwqBcSYsXDyT8_ZLd1cdYmPfmtllIXxZhLClwT5qbCWv73V" + } + """ + , out var token, out var principal, false); + + Assert.Equal(ManifestStatus.Valid, status); + } +} diff --git a/src/SponsorLink/Tests/SponsorableManifest.cs b/src/SponsorLink/Tests/SponsorableManifest.cs new file mode 100644 index 0000000..5ae6e3f --- /dev/null +++ b/src/SponsorLink/Tests/SponsorableManifest.cs @@ -0,0 +1,309 @@ +using System.Diagnostics.CodeAnalysis; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.IdentityModel.Tokens; + +namespace Devlooped.Sponsors; + +/// +/// The serializable manifest of a sponsorable user, as persisted +/// in the .github/sponsorlink.jwt file. +/// +public class SponsorableManifest +{ + /// + /// Overall manifest status. + /// + public enum Status + { + /// + /// SponsorLink manifest is invalid. + /// + Invalid, + /// + /// The manifest has an audience that doesn't match the sponsorable account. + /// + AccountMismatch, + /// + /// SponsorLink manifest not found for the given account, so it's not supported. + /// + NotFound, + /// + /// Manifest was successfully fetched and validated. + /// + OK, + } + + /// + /// Creates a new manifest with a new RSA key pair. + /// + public static SponsorableManifest Create(Uri issuer, Uri[] audience, string clientId) + { + var rsa = RSA.Create(3072); + var pub = Convert.ToBase64String(rsa.ExportRSAPublicKey()); + + return new SponsorableManifest(issuer, audience, clientId, new RsaSecurityKey(rsa), pub); + } + + public static async Task<(Status, SponsorableManifest?)> FetchAsync(string sponsorable, string? branch, HttpClient? http = default) + { + // Try to detect sponsorlink manifest in the sponsorable .github repo + var url = $"https://github.com/{sponsorable}/.github/raw/{branch ?? "main"}/sponsorlink.jwt"; + + // Manifest should be public, so no need for any special HTTP client. + using (http ??= new HttpClient()) + { + var response = await http.GetAsync(url); + if (!response.IsSuccessStatusCode) + return (Status.NotFound, default); + + var jwt = await response.Content.ReadAsStringAsync(); + if (!TryRead(jwt, out var manifest, out var missingClaim)) + return (Status.Invalid, default); + + // Manifest audience should match the sponsorable account to avoid weird issues? + if (sponsorable != manifest.Sponsorable) + return (Status.AccountMismatch, default); + + return (Status.OK, manifest); + } + } + + /// + /// Parses a JWT into a . + /// + /// The JWT containing the sponsorable information. + /// The parsed manifest, if not required claims are missing. + /// The missing required claim, if any. + /// A validated manifest. + public static bool TryRead(string jwt, [NotNullWhen(true)] out SponsorableManifest? manifest, out string? missingClaim) + { + var handler = new JwtSecurityTokenHandler { MapInboundClaims = false }; + missingClaim = null; + manifest = default; + + if (!handler.CanReadToken(jwt)) + return false; + + var token = handler.ReadJwtToken(jwt); + var issuer = token.Issuer; + + if (token.Audiences.FirstOrDefault(x => x.StartsWith("https://github.com/")) is null) + { + missingClaim = "aud"; + return false; + } + + if (token.Claims.FirstOrDefault(c => c.Type == "client_id")?.Value is not string clientId) + { + missingClaim = "client_id"; + return false; + } + + if (token.Claims.FirstOrDefault(c => c.Type == "pub")?.Value is not string pub) + { + missingClaim = "pub"; + return false; + } + + if (token.Claims.FirstOrDefault(c => c.Type == "sub_jwk")?.Value is not string jwk) + { + missingClaim = "sub_jwk"; + return false; + } + + var key = new JsonWebKeySet { Keys = { JsonWebKey.Create(jwk) } }.GetSigningKeys().First(); + manifest = new SponsorableManifest(new Uri(issuer), token.Audiences.Select(x => new Uri(x)).ToArray(), clientId, key, pub); + + return true; + } + + public SponsorableManifest(Uri issuer, Uri[] audience, string clientId, SecurityKey publicKey, string publicRsaKey) + { + Issuer = issuer.AbsoluteUri; + Audience = audience.Select(a => a.AbsoluteUri.TrimEnd('/')).ToArray(); + ClientId = clientId; + SecurityKey = publicKey; + PublicKey = publicRsaKey; + Sponsorable = audience.Where(x => x.Host == "github.com").Select(x => x.Segments.LastOrDefault()?.TrimEnd('/')).FirstOrDefault() ?? + throw new ArgumentException("At least one of the intended audience must be a GitHub sponsors URL."); + } + + /// + /// Converts (and optionally signs) the manifest into a JWT. Never exports the private key. + /// + /// Optional credentials when signing the resulting manifest. Defaults to the if it has a private key. + /// The JWT manifest. + public string ToJwt(SigningCredentials? signing = default) + { + var jwk = JsonWebKeyConverter.ConvertFromSecurityKey(SecurityKey); + + // Automatically sign if the manifest was created with a private key + if (SecurityKey is RsaSecurityKey rsa && rsa.PrivateKeyStatus == PrivateKeyStatus.Exists) + { + signing ??= new SigningCredentials(rsa, SecurityAlgorithms.RsaSha256); + + // Ensure we never serialize the private key + jwk = JsonWebKeyConverter.ConvertFromRSASecurityKey(new RsaSecurityKey(rsa.Rsa.ExportParameters(false))); + } + + var token = new JwtSecurityToken( + claims: + new[] { new Claim(JwtRegisteredClaimNames.Iss, Issuer) } + .Concat(Audience.Select(x => new Claim(JwtRegisteredClaimNames.Aud, x))) + .Concat( + [ + // See https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.6 + new(JwtRegisteredClaimNames.Iat, Math.Truncate((DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds).ToString()), + new("client_id", ClientId), + // non-standard claim containing the base64-encoded public key + new("pub", PublicKey), + // standard claim, serialized as a JSON string, not an encoded JSON object + new("sub_jwk", JsonSerializer.Serialize(jwk, JsonOptions.JsonWebKey), JsonClaimValueTypes.Json), + ]), + signingCredentials: signing); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + /// + /// Sign the JWT claims with the provided RSA key. + /// + public string Sign(IEnumerable claims, RSA rsa, TimeSpan? expiration = default) + => Sign(claims, new RsaSecurityKey(rsa), expiration); + + public string Sign(IEnumerable claims, RsaSecurityKey? key = default, TimeSpan? expiration = default) + { + var rsa = key ?? SecurityKey as RsaSecurityKey; + if (rsa?.PrivateKeyStatus != PrivateKeyStatus.Exists) + throw new NotSupportedException("No private key found to sign the manifest."); + + var signing = new SigningCredentials(rsa, SecurityAlgorithms.RsaSha256); + + var expirationDate = expiration != null ? + DateTime.UtcNow.Add(expiration.Value) : + // Expire the first day of the next month + new DateTime( + DateTime.UtcNow.AddMonths(1).Year, + DateTime.UtcNow.AddMonths(1).Month, 1, + // Use current time so they don't expire all at the same time + DateTime.UtcNow.Hour, + DateTime.UtcNow.Minute, + DateTime.UtcNow.Second, + DateTime.UtcNow.Millisecond, + DateTimeKind.Utc); + + var tokenClaims = claims.Where(x => x.Type != JwtRegisteredClaimNames.Iat && x.Type != JwtRegisteredClaimNames.Exp).ToList(); + + // See https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.6 + tokenClaims.Add(new(JwtRegisteredClaimNames.Iat, Math.Truncate((DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds).ToString())); + + if (tokenClaims.Find(c => c.Type == JwtRegisteredClaimNames.Iss) is { } issuer) + { + if (issuer.Value != Issuer) + throw new ArgumentException($"The received claims contain an incompatible 'iss' claim. If present, the claim must contain the value '{Issuer}' but was '{issuer.Value}'."); + } + else + { + tokenClaims.Insert(0, new(JwtRegisteredClaimNames.Iss, Issuer)); + } + + if (tokenClaims.Find(c => c.Type == "client_id") is { } clientId) + { + if (clientId.Value != ClientId) + throw new ArgumentException($"The received claims contain an incompatible 'client_id' claim. If present, the claim must contain the value '{ClientId}' but was '{clientId.Value}'."); + } + else + { + tokenClaims.Add(new("client_id", ClientId)); + } + + // Avoid duplicating audience claims + foreach (var audience in Audience) + { + // Always compare ignoring trailing / + if (tokenClaims.Find(c => c.Type == JwtRegisteredClaimNames.Aud && c.Value.TrimEnd('/') == audience.TrimEnd('/')) == null) + tokenClaims.Insert(1, new(JwtRegisteredClaimNames.Aud, audience)); + } + + // The other claims (client_id, pub, sub_jwk) claims are mostly for the SL manifest itself, + // not for the user, so for now we don't add them. + + // Don't allow mismatches of public manifest key and the one used to sign, to avoid + // weird run-time errors verifiying manifests that were signed with a different key. + var pubKey = Convert.ToBase64String(rsa.Rsa.ExportRSAPublicKey()); + if (pubKey != PublicKey) + throw new ArgumentException($"Cannot sign with a private key that does not match the manifest public key."); + + var jwt = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( + claims: tokenClaims, + expires: expirationDate, + signingCredentials: signing + )); + + return jwt; + } + + public ClaimsPrincipal Validate(string jwt, out SecurityToken? token) => new JwtSecurityTokenHandler().ValidateToken(jwt, new TokenValidationParameters + { + RequireExpirationTime = true, + // NOTE: setting this to false allows checking sponsorships even when the manifest is expired. + // This might be useful if package authors want to extend the manifest lifetime beyond the default + // 30 days and issue a warning on expiration, rather than an error and a forced sync. + // If this is not set (or true), a SecurityTokenExpiredException exception will be thrown. + ValidateLifetime = false, + RequireAudience = true, + // At least one of the audiences must match the manifest audiences + AudienceValidator = (audiences, _, _) => Audience.Intersect(audiences.Select(x => x.TrimEnd('/'))).Any(), + ValidIssuer = Issuer, + IssuerSigningKey = SecurityKey, + }, out token); + + /// + /// Gets the GitHub sponsorable account. + /// + public string Sponsorable { get; } + + /// + /// The web endpoint that issues signed JWT to authenticated users. + /// + /// + /// See https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.1 + /// + public string Issuer { get; } + + /// + /// The audience for the JWT, which includes the sponsorable account and potentially other sponsoring platforms. + /// + /// + /// See https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.3 + /// + public string[] Audience { get; } + + /// + /// The OAuth client ID (i.e. GitHub OAuth App ID) that is used to + /// authenticate the user. + /// + /// + /// See https://www.rfc-editor.org/rfc/rfc8693.html#name-client_id-client-identifier + /// + public string ClientId { get; internal set; } + + /// + /// Public key that can be used to verify JWT signatures. + /// + public string PublicKey { get; } + + /// + /// Public key in a format that can be used to verify JWT signatures. + /// + public SecurityKey SecurityKey { get; } + + /// + public override int GetHashCode() => new HashCode().Add(Issuer, ClientId, PublicKey).AddRange(Audience).ToHashCode(); + + /// + public override bool Equals(object? obj) => obj is SponsorableManifest other && GetHashCode() == other.GetHashCode(); +} diff --git a/src/SponsorLink/Tests/Tests.csproj b/src/SponsorLink/Tests/Tests.csproj new file mode 100644 index 0000000..0585911 --- /dev/null +++ b/src/SponsorLink/Tests/Tests.csproj @@ -0,0 +1,57 @@ + + + + net8.0 + + + + + + + + + + + + + + + + + + + + + + + + True + True + Resources.resx + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + + + + + + %(GitRoot.FullPath) + + + + + + + + + + \ No newline at end of file diff --git a/src/SponsorLink/jwk.ps1 b/src/SponsorLink/jwk.ps1 new file mode 100644 index 0000000..c66f56f --- /dev/null +++ b/src/SponsorLink/jwk.ps1 @@ -0,0 +1 @@ +curl https://raw.githubusercontent.com/devlooped/.github/main/sponsorlink.jwt --silent | jq -R 'split(".") | .[1] | @base64d | fromjson' | jq '.sub_jwk' \ No newline at end of file diff --git a/src/SponsorLink/readme.md b/src/SponsorLink/readme.md new file mode 100644 index 0000000..cb651a1 --- /dev/null +++ b/src/SponsorLink/readme.md @@ -0,0 +1,34 @@ +# SponsorLink .NET Analyzer + +This is one opinionated implementation of [SponsorLink](https://devlooped.com/SponsorLink) +for .NET projects leveraging Roslyn analyzers. + +It is intended for use by [devlooped](https://github.com/devlooped) projects, but can be +used as a template for other sponsorables as well. Supporting arbitrary sponsoring scenarios +is out of scope though, since we just use GitHub sponsors for now. + +## Usage + +A project initializing from this template repo via [dotnet-file](https://github.com/devlooped/dotnet-file) +will have all the sources cloned under `src\SponsorLink`. + +Including the analyzer and targets in a project involves two steps. + +1. Create an analyzer project and add the following property: + +```xml + + ... + $(MSBuildThisFileDirectory)..\SponsorLink\SponsorLink.targets + +``` + +2. Add a `buildTransitive\[PackageId].targets` file with the following import: + +```xml + + + +``` + +As long as NuGetizer is used, the right packaging will be done automatically. \ No newline at end of file diff --git a/src/nuget.config b/src/nuget.config deleted file mode 100644 index ef2b768..0000000 --- a/src/nuget.config +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -