From 1da72e180790993abaa88498171088e95bb63d57 Mon Sep 17 00:00:00 2001 From: devlooped-bot Date: Sun, 16 Jun 2024 04:06:06 +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 - Add nullable and generated code annotations https://github.com/devlooped/oss/commit/b2a11fa - Revert back to dotnet-sponsor https://github.com/devlooped/oss/commit/8d29f01 - Add copylocal behavior when merging assemblies https://github.com/devlooped/oss/commit/7cda4a1 - Add our implementation of JWT manifest reading and reporting https://github.com/devlooped/oss/commit/a0ae727 - Simplify and unify manifest reading implementation https://github.com/devlooped/oss/commit/4fca946 - Minimal docs on consuming https://github.com/devlooped/oss/commit/827a1d1 - Integrate more seamlessly with the existing workflows https://github.com/devlooped/oss/commit/e732f6a - Whitespace and formatting https://github.com/devlooped/oss/commit/d74f511 - 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 - Don't add random wait on manual dotnet-file runs https://github.com/devlooped/oss/commit/7afe350 - 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 - Only commit markdown files when resolving includes https://github.com/devlooped/oss/commit/2c10a83 --- .github/workflows/build.yml | 10 +- .github/workflows/changelog.yml | 2 +- .github/workflows/dotnet-file.yml | 3 +- .github/workflows/includes.yml | 3 +- .github/workflows/publish.yml | 5 +- .github/workflows/sponsor.yml | 2 +- .netconfig | 203 +++++++++++- readme.md | 4 - src/Directory.Build.props | 10 +- src/SponsorLink/Analyzer/Analyzer.csproj | 32 ++ .../Analyzer/StatusReportingAnalyzer.cs | 26 ++ .../buildTransitive/SponsorableLib.targets | 3 + src/SponsorLink/Directory.Build.props | 43 +++ src/SponsorLink/Directory.Build.targets | 8 + src/SponsorLink/Library/Library.csproj | 31 ++ src/SponsorLink/Library/MyClass.cs | 5 + src/SponsorLink/Library/Resources.resx | 123 +++++++ src/SponsorLink/SponsorLink.targets | 141 ++++++++ .../SponsorLink/AppDomainDictionary.cs | 36 ++ .../SponsorLink/DiagnosticsManager.cs | 138 ++++++++ src/SponsorLink/SponsorLink/ManifestStatus.cs | 25 ++ src/SponsorLink/SponsorLink/SponsorLink.cs | 169 ++++++++++ .../SponsorLink/SponsorLink.csproj | 46 +++ .../SponsorLink/SponsorLink.es.resx | 163 +++++++++ src/SponsorLink/SponsorLink/SponsorLink.resx | 164 ++++++++++ .../SponsorLink/SponsorLinkAnalyzer.cs | 126 +++++++ src/SponsorLink/SponsorLink/SponsorStatus.cs | 25 ++ .../SponsorLink/SponsorableLib.targets | 60 ++++ src/SponsorLink/SponsorLink/ThisAssembly.cs | 31 ++ src/SponsorLink/SponsorLink/Tracing.cs | 53 +++ .../Devlooped.Sponsors.targets | 99 ++++++ src/SponsorLink/SponsorLink/devlooped.pub.jwk | 5 + 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/Sample.cs | 59 ++++ src/SponsorLink/Tests/SponsorLinkTests.cs | 126 +++++++ src/SponsorLink/Tests/SponsorableManifest.cs | 309 ++++++++++++++++++ src/SponsorLink/Tests/Tests.csproj | 42 +++ src/SponsorLink/readme.md | 34 ++ 43 files changed, 2571 insertions(+), 30 deletions(-) create mode 100644 src/SponsorLink/Analyzer/Analyzer.csproj 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/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/SponsorLink.cs create mode 100644 src/SponsorLink/SponsorLink/SponsorLink.csproj create mode 100644 src/SponsorLink/SponsorLink/SponsorLink.es.resx create mode 100644 src/SponsorLink/SponsorLink/SponsorLink.resx 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/ThisAssembly.cs create mode 100644 src/SponsorLink/SponsorLink/Tracing.cs create mode 100644 src/SponsorLink/SponsorLink/buildTransitive/Devlooped.Sponsors.targets create mode 100644 src/SponsorLink/SponsorLink/devlooped.pub.jwk 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/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/readme.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 366cfd9..c671ecc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,10 +17,10 @@ on: env: DOTNET_NOLOGO: true - VersionPrefix: 42.42.${{ github.run_number }} - VersionLabel: ${{ github.ref }} PackOnBuild: true GeneratePackageOnBuild: true + VersionPrefix: 42.42.${{ github.run_number }} + VersionLabel: ${{ github.ref }} GH_TOKEN: ${{ secrets.GH_TOKEN }} defaults: @@ -34,7 +34,7 @@ jobs: matrix: ${{ steps.lookup.outputs.matrix }} steps: - name: 🤘 checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: 🔎 lookup id: lookup @@ -53,7 +53,7 @@ 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 @@ -90,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 60471ee..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) diff --git a/.github/workflows/includes.yml b/.github/workflows/includes.yml index 14629a2..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 }} @@ -31,6 +31,7 @@ jobs: - name: ✍ pull request 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 417026d..a086072 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,6 +12,7 @@ env: Configuration: Release PackOnBuild: true GeneratePackageOnBuild: true + VersionLabel: ${{ github.ref }} GH_TOKEN: ${{ secrets.GH_TOKEN }} jobs: @@ -19,13 +20,13 @@ jobs: 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} -bl:build.binlog + run: dotnet build -m:1 -bl:build.binlog - name: 🧪 test uses: ./.github/workflows/test 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/.netconfig b/.netconfig index 811e889..50b1ff4 100644 --- a/.netconfig +++ b/.netconfig @@ -27,23 +27,23 @@ weak [file ".github/workflows/build.yml"] url = https://github.com/devlooped/oss/blob/main/.github/workflows/build.yml - sha = 97ebd189e44da857e53e4d98af2727dd9dcb0740 - etag = f02775f5cdf8ba06238500e56bc25ca1e0aa329db4931f230c45bd1d7de1696d + 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 = 11a331d50be517c29df5ab1358ef354d8871d59a - etag = de115efac25b63d561d8b2ffcba3e7710bfef7bfefecb811ea943b6f2bd8de96 + sha = 7afe350f7e80a230e922db026d4e1198ba15cae1 + etag = 65e9794df6caff779eb989c8f71ddf4d4109b24a75af79e4f8d0fe6ba7bd9702 weak [file ".github/workflows/publish.yml"] url = https://github.com/devlooped/oss/blob/main/.github/workflows/publish.yml - sha = 97ebd189e44da857e53e4d98af2727dd9dcb0740 - etag = 04a0796e61476765c0a233806e85fb3f461d7cf099a14c91e6c5abcab938062f + sha = 14deaea5cecc64df51781d29891a2f67caf8be16 + etag = 4e9a9885a28ce867fd6139e1ae23735ad0073775145af96ff7d96d047d750973 weak [file ".gitignore"] url = https://github.com/devlooped/oss/blob/main/.gitignore @@ -72,8 +72,8 @@ weak [file "src/Directory.Build.props"] url = https://github.com/devlooped/oss/blob/main/src/Directory.Build.props - sha = 6dfe21fbd4a8390448958c714f8e9006fc4ac3ca - etag = de7c6b643bac2fc6651fa08f69d628cbbe12e7050829b981ac771e1b9ccccd89 + sha = 14deaea5cecc64df51781d29891a2f67caf8be16 + etag = f177eb767aaa6a347da43ff7ff419c9a0736c562cb171e17ded8007a1945a8b0 weak [file "src/Directory.Build.targets"] url = https://github.com/devlooped/oss/blob/main/src/Directory.Build.targets @@ -110,13 +110,13 @@ weak [file ".github/workflows/includes.yml"] url = https://github.com/devlooped/oss/blob/main/.github/workflows/includes.yml - sha = 11a8757c0012f0d454370823e1cda51398f4337e - etag = 73426a1361c9d298b5794c99d774043b8e9425bda759bb769fdaa025636d6e3c + sha = 5fb172362c767bef7c36478f1a6bdc264723f8f9 + etag = e5ee22e115c925fb85ec931cda3ac811fcc453c03904554fa3f573935b221d34 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/workflows/test/action.yml"] url = https://github.com/devlooped/oss/blob/main/.github/workflows/test/action.yml @@ -126,3 +126,178 @@ [file "src/nuget.config"] url = https://github.com/devlooped/oss/blob/main/src/nuget.config skip +[file "src/SponsorLink/Analyzer/Analyzer.csproj"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Analyzer/Analyzer.csproj + sha = 7cda4a18313b0b38b26c0152e1007cdbb9b6ba3a + etag = d9444fa36daa8f4ff8f06fc2f9f600dbd8032f25ff58542d3b96676e0305677e + 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 = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 23d4cd16294974d85164fc26d6a7e2ae52698f23a62463db5025d69d9c166dc5 + 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 = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 6823e1e914ecedd174276e3d53517cc0b332bb47c56402a9512cfa6aeeeb067e + 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 = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 3720f8ae0605aa64df8f6c1d9769969162175b79c93a21024653f210a42348e6 + 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/SponsorLink.targets"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink.targets + sha = 7cda4a18313b0b38b26c0152e1007cdbb9b6ba3a + etag = d725bd9cfa33f35224e91748f64237e4dc66270f7e5ec7c835b78164531ae3db + 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 = b2a11faac6c1c300bce8c1d45f95b585c19f2953 + etag = 9f289f45169f35916fff1857840d4118ed134215639d6dae9016dc62004291a5 + 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/SponsorLink.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/SponsorLink.cs + sha = 4fca946c3201d90d30e2183f699c850dcc1bf8d5 + etag = 96e1b1b28bfb2372bd5ffcc6bdef65ee926822b3489ce65be4e5a400884dce21 + weak +[file "src/SponsorLink/SponsorLink/SponsorLink.csproj"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/SponsorLink.csproj + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = ffaea0b580d8dccd672e749a5efd11fda318c484ca4a34428ff81524ec80ec4b + weak +[file "src/SponsorLink/SponsorLink/SponsorLink.es.resx"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/SponsorLink.es.resx + sha = 8d29f013595aab6d965f1b91eb8b0ecaa0246366 + etag = 7f623861748c4e7f534fa2118eb66ad3056fd5b28cd5d1d19e9348d0236ffe29 + weak +[file "src/SponsorLink/SponsorLink/SponsorLink.resx"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/SponsorLink.resx + sha = 8d29f013595aab6d965f1b91eb8b0ecaa0246366 + etag = 47a7b843e07a8ec858579f435b59548056b14d00d6653dc41ef5807613925f31 + weak +[file "src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs + sha = b2a11faac6c1c300bce8c1d45f95b585c19f2953 + etag = fc96f7f5642cbf69b35b7e8de1756822580315f0cee61e47da3b2b1b03f68e1a + 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/ThisAssembly.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/ThisAssembly.cs + sha = b2a11faac6c1c300bce8c1d45f95b585c19f2953 + etag = 978269025f58e2bae872af25fdfd94659e234e8365e3014c18b1b20fdcd155bf + 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 = 8d29f013595aab6d965f1b91eb8b0ecaa0246366 + etag = c7abdd1f840a32948afa15fce5ef5c22f1a29fb8394d265b7c9cbf646a2030bf + weak +[file "src/SponsorLink/SponsorLink/devlooped.pub.jwk"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/devlooped.pub.jwk + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = cf884781ff88b4d096841e3169282762a898b2050c9b5dac0013bc15bdbee267 + 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/Sample.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Tests/Sample.cs + sha = e732f6a2c44a2f7940a1868a92cd66523f74ed24 + etag = db968d1d665b77a17e13bc7ca3d43ea65ed05cbebc18669f1b607ebe0e38a59a + 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 = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 237409e155202ec1b845593195d30057a949b2b18ae46a575e4cf480e4e2c8fe + weak +[file "src/SponsorLink/readme.md"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/readme.md + sha = 827a1d18bf0245978d81bcd3d52e9e6f1584d1ef + etag = 079b4aedba2aa9851e609b569f25c55db8d5922e3dbb1adc22611ce4d6cfe465 + weak diff --git a/readme.md b/readme.md index 97a8e6a..c9d5f33 100644 --- a/readme.md +++ b/readme.md @@ -104,14 +104,12 @@ OPTIONS: [![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) -[![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,14 +119,12 @@ OPTIONS: [![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) [![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) -[![Michael Staib](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/michaelstaib.png "Michael Staib")](https://github.com/michaelstaib) [![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/src/Directory.Build.props b/src/Directory.Build.props index bba6ab7..1648dcd 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -118,6 +118,8 @@ <_VersionLabel>$(VersionLabel.Replace('refs/heads/', '')) + <_VersionLabel>$(_VersionLabel.Replace('refs/tags/v', '')) + <_VersionLabel Condition="$(_VersionLabel.Contains('refs/pull/'))">$(VersionLabel.TrimEnd('.0123456789')) @@ -128,7 +130,9 @@ <_VersionLabel>$(_VersionLabel.Replace('/', '-')) - $(_VersionLabel) + $(_VersionLabel) + + $(_VersionLabel) @@ -150,4 +154,8 @@ + + + diff --git a/src/SponsorLink/Analyzer/Analyzer.csproj b/src/SponsorLink/Analyzer/Analyzer.csproj new file mode 100644 index 0000000..963c77b --- /dev/null +++ b/src/SponsorLink/Analyzer/Analyzer.csproj @@ -0,0 +1,32 @@ + + + + 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/StatusReportingAnalyzer.cs b/src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs new file mode 100644 index 0000000..e21acb7 --- /dev/null +++ b/src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs @@ -0,0 +1,26 @@ +using System.Collections.Immutable; +using Devlooped.Sponsors; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using static Devlooped.Sponsors.SponsorLink; +using static ThisAssembly.Constants; + +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..c0a3e42 --- /dev/null +++ b/src/SponsorLink/Directory.Build.props @@ -0,0 +1,43 @@ + + + + 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) + + + 42.42.$([System.Math]::Floor($([MSBuild]::Divide($([System.DateTime]::Now.TimeOfDay.TotalSeconds), 10)))) + + 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..f351273 --- /dev/null +++ b/src/SponsorLink/Library/Library.csproj @@ -0,0 +1,31 @@ + + + + netstandard2.0 + true + SponsorableLib + Sample library incorporating SponsorLink checks + true + + + + + + + + + + + + + + MSBuild:Compile + $(IntermediateOutputPath)\$([MSBuild]::ValueOrDefault('%(RelativeDir)', '').Replace('\', '.').Replace('/', '.'))%(Filename).g$(DefaultLanguageSourceExtension) + $(Language) + $(RootNamespace) + $(RootNamespace).$([MSBuild]::ValueOrDefault('%(RelativeDir)', '').Replace('\', '.').Replace('/', '.').TrimEnd('.')) + %(Filename) + + + + 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/SponsorLink.targets b/src/SponsorLink/SponsorLink.targets new file mode 100644 index 0000000..de93845 --- /dev/null +++ b/src/SponsorLink/SponsorLink.targets @@ -0,0 +1,141 @@ + + + + + + + true + + true + + true + + + $([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)SponsorLink/devlooped.pub.jwk')) + + + $(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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + $([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)"', ' ') + + + + + + + + + + + + + + + + + + + \ 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..49143d9 --- /dev/null +++ b/src/SponsorLink/SponsorLink/DiagnosticsManager.cs @@ -0,0 +1,138 @@ +// +#nullable enable +using System; +using System.Collections.Concurrent; +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 + { + get => 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", + ThisAssembly.Strings.Sponsor.Title, + ThisAssembly.Strings.Sponsor.MessageFormat, + "SponsorLink", + DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: ThisAssembly.Strings.Sponsor.Description, + helpLinkUri: "https://github.com/devlooped#sponsorlink", + "DoesNotSupportF1Help"); + + static DiagnosticDescriptor CreateUnknown(string[] sponsorable, string product, string prefix) => new( + $"{prefix}101", + ThisAssembly.Strings.Unknown.Title, + ThisAssembly.Strings.Unknown.MessageFormat, + "SponsorLink", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: ThisAssembly.Strings.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", + ThisAssembly.Strings.Expiring.Title, + ThisAssembly.Strings.Expiring.MessageFormat, + "SponsorLink", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: ThisAssembly.Strings.Expiring.Description(string.Join(" ", sponsorable)), + helpLinkUri: "https://github.com/devlooped#autosync", + "DoesNotSupportF1Help", WellKnownDiagnosticTags.NotConfigurable); + + static DiagnosticDescriptor CreateExpired(string[] sponsorable, string prefix) => new( + $"{prefix}104", + ThisAssembly.Strings.Expired.Title, + ThisAssembly.Strings.Expired.MessageFormat, + "SponsorLink", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: ThisAssembly.Strings.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/SponsorLink.cs b/src/SponsorLink/SponsorLink/SponsorLink.cs new file mode 100644 index 0000000..a5e5beb --- /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.jwk) || 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..4b00feb --- /dev/null +++ b/src/SponsorLink/SponsorLink/SponsorLink.csproj @@ -0,0 +1,46 @@ + + + + netstandard2.0 + SponsorLink + disable + false + + + + $([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)devlooped.pub.jwk')) + + $(Product) + + $([System.Text.RegularExpressions.Regex]::Replace("$(FundingProduct)", "[^A-Z]", "")) + + 21 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SponsorLink/SponsorLink/SponsorLink.es.resx b/src/SponsorLink/SponsorLink/SponsorLink.es.resx new file mode 100644 index 0000000..1266993 --- /dev/null +++ b/src/SponsorLink/SponsorLink/SponsorLink.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 {links} y ejecutando posteriormente 'sponsor sync {spaced}'. + + + Por favor considere apoyar {0} patrocinando @{1} 🙏 + + + Estado de patrocinio desconocido + + + Funcionalidades exclusivas para patrocinadores pueden no estar disponibles. Ejecuta 'sponsor sync {spaced}' 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 {spaced}' 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/SponsorLink.resx b/src/SponsorLink/SponsorLink/SponsorLink.resx new file mode 100644 index 0000000..67000f3 --- /dev/null +++ b/src/SponsorLink/SponsorLink/SponsorLink.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 {links} and running 'sponsor sync {spaced}' afterwards. + Unknown sponsor description + + + Please consider supporting {0} by sponsoring @{1} 🙏 + + + Unknown sponsor status + + + Sponsor-only features may be disabled. Please run 'sponsor sync {spaced}' 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 {spaced}' 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/SponsorLinkAnalyzer.cs b/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs new file mode 100644 index 0000000..2e97528 --- /dev/null +++ b/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs @@ -0,0 +1,126 @@ +// +#nullable enable +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Linq; +using Humanizer; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using static Devlooped.Sponsors.SponsorLink; +using static ThisAssembly.Constants; + +namespace Devlooped.Sponsors; + +/// +/// Links the sponsor status for the current compilation. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)] +public class SponsorLinkAnalyzer : DiagnosticAnalyzer +{ + static readonly int graceDays = int.Parse(Funding.Grace); + 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 => + { + if (Diagnostics.Pop(Funding.Product) is Diagnostic diagnostic) + { + ctx.ReportDiagnostic(diagnostic); + } + else + { + // This should never happen and would be a bug. + Debug.Assert(true, "We should have provided a diagnostic of some kind for " + Funding.Product); + // We'll report it as unknown as a fallback for now. + ctx.ReportDiagnostic(Diagnostic.Create(descriptors[SponsorStatus.Unknown], null, + properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Unknown)), + Funding.Product, Sponsorables.Keys.Humanize(ThisAssembly.Strings.Or))); + } + }); + } + }); +#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(ThisAssembly.Strings.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(graceDays) < 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/ThisAssembly.cs b/src/SponsorLink/SponsorLink/ThisAssembly.cs new file mode 100644 index 0000000..89f2316 --- /dev/null +++ b/src/SponsorLink/SponsorLink/ThisAssembly.cs @@ -0,0 +1,31 @@ +// +partial class ThisAssembly +{ + partial class Strings + { + partial class Unknown + { + public static string MessageFormat => GetResourceManager("Devlooped.SponsorLink").GetString("Unknown_Message"); + } + + partial class Expiring + { + public static string MessageFormat => GetResourceManager("Devlooped.SponsorLink").GetString("Expiring_Message"); + } + + partial class Expired + { + public static string MessageFormat => GetResourceManager("Devlooped.SponsorLink").GetString("Expired_Message"); + } + + partial class Grace + { + public static string MessageFormat => GetResourceManager("Devlooped.SponsorLink").GetString("Grace_Message"); + } + + partial class Sponsor + { + public static string MessageFormat => GetResourceManager("Devlooped.SponsorLink").GetString("Sponsor_Message"); + } + } +} \ 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..ab9a670 --- /dev/null +++ b/src/SponsorLink/SponsorLink/buildTransitive/Devlooped.Sponsors.targets @@ -0,0 +1,99 @@ + + + + + $([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/devlooped.pub.jwk b/src/SponsorLink/SponsorLink/devlooped.pub.jwk new file mode 100644 index 0000000..cdf45c2 --- /dev/null +++ b/src/SponsorLink/SponsorLink/devlooped.pub.jwk @@ -0,0 +1,5 @@ +{ + "e": "AQAB", + "kty": "RSA", + "n": "5inhv8QymaDBOihNi1eY-6-hcIB5qSONFZxbxxXAyOtxAdjFCPM-94gIZqM9CDrX3pyg1lTJfml_a_FZSU9dB1ii5mSX_mNHBFXn1_l_gi1ErdbkIF5YbW6oxWFxf3G5mwVXwnPfxHTyQdmWQ3YJR-A3EB4kaFwLqA6Ha5lb2ObGpMTQJNakD4oTAGDhqHMGhu6PupGq5ie4qZcQ7N8ANw8xH7nicTkbqEhQABHWOTmLBWq5f5F6RYGF8P7cl0IWl_w4YcIZkGm2vX2fi26F9F60cU1v13GZEVDTXpJ9kzvYeM9sYk6fWaoyY2jhE51qbv0B0u6hScZiLREtm3n7ClJbIGXhkUppFS2JlNaX3rgQ6t-4LK8gUTyLt3zDs2H8OZyCwlCpfmGmdsUMkm1xX6t2r-95U3zywynxoWZfjBCJf41leM9OMKYwNWZ6LQMyo83HWw1PBIrX4ZLClFwqBcSYsXDyT8_ZLd1cdYmPfmtllIXxZhLClwT5qbCWv73V" +} \ 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/Sample.cs b/src/SponsorLink/Tests/Sample.cs new file mode 100644 index 0000000..6249e62 --- /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..f753aad --- /dev/null +++ b/src/SponsorLink/Tests/Tests.csproj @@ -0,0 +1,42 @@ + + + + net8.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + %(GitRoot.FullPath) + + + + + + + + + + \ 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