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..801c4d9 100644
--- a/.github/workflows/sponsor.yml
+++ b/.github/workflows/sponsor.yml
@@ -9,14 +9,58 @@ jobs:
sponsor:
runs-on: ubuntu-latest
continue-on-error: true
- env:
- token: ${{ secrets.GH_TOKEN }}
- if: ${{ !endsWith(github.event.sender.login, '[bot]') && !endsWith(github.event.sender.login, 'bot') }}
+ if: ${{ !endsWith(github.event.sender.login, '[bot]') && github.event.sender.login != github.repository_owner }}
steps:
- name: 🤘 checkout
- if: env.token != ''
- uses: actions/checkout@v2
-
+ uses: actions/checkout@v4
+
+ - name: ⚙ install
+ run: dotnet tool update -g dotnet-sponsor --prerelease
+
+ - name: 💻 setup
+ run: |
+ sponsor --version
+ pushd ~
+ git config -f .sponsorlink/.netconfig sponsorlink.id devlooped.sponsors.ci
+
+ - name: 🧪 run
+ shell: pwsh
+ env:
+ SPONSORABLE: ${{ secrets.GITHUB_TOKEN }}
+ TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ $env:TOKEN | sponsor sync $env:SPONSORABLE --tos --with-token
+ if ($LASTEXITCODE -eq -5) {
+ throw "Can not determine sponsorship with the provided token"
+ } elseif ($LASTEXITCODE -eq -6) {
+ Write-Output "User is not sponsoring, skipping"
+ exit 0
+ } elseif ($LASTEXITCODE -eq -3) {
+ Write-Output "$env:SPONSORABLE is not set up for SponsorLink"
+ exit 0
+ } elseif ($LASTEXITCODE -eq -4) {
+ Write-Output "$env:SPONSORABLE SponsorLink manifest is invalid"
+ exit 0
+ } elseif ($LASTEXITCODE -ne -0) {
+ Write-Output "Could not determine sponsor status"
+ exit $LASTEXITCODE
+ }
+
+ $roles = cat ~/.sponsorlink/github/$env:SPONSORABLE.jwt | jq -R 'split(".") | .[1] | @base64d | fromjson | .roles[]'
+ if (($roles | jq 'select(. == "team")' -r) -eq "team") {
+ Write-Output "User is a team member, skipping"
+ exit 0
+ } elseif (($roles | jq 'select(. == "contrib")' -r) -eq "contrib") {
+ Write-Output "User is a contributor!"
+ } else {
+ Write-Output "User is a sponsor"
+ if (($roles | jq 'select(. == "org")' -r) -eq "org") {
+ Write-Output " (indirectly as a sponsoring organization member)"
+ } elseif (($roles | jq 'select(. == "user")' -r) -eq "user") {
+ Write-Output " (as a direct sponsor)"
+ }
+ }
+
- name: 💜 sponsor
if: env.token != ''
uses: devlooped/actions-sponsor@main
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 70f4631..2e317f0 100644
--- a/.netconfig
+++ b/.netconfig
@@ -124,11 +124,6 @@
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/pages.yml"]
url = https://github.com/clarius/pages/blob/main/.github/workflows/pages.yml
sha = afcb0421af6507eba5ceba913b8fc37261efc085
diff --git a/SponsorLink.sln b/SponsorLink.sln
new file mode 100644
index 0000000..d4eab56
--- /dev/null
+++ b/SponsorLink.sln
@@ -0,0 +1,43 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.11.34909.67
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SponsorLink", "src\SponsorLink\SponsorLink\SponsorLink.csproj", "{1E1D01A2-D202-4FAB-B21B-AF21B1C37163}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Analyzer", "src\SponsorLink\Analyzer\Analyzer.csproj", "{87B3A42C-FFA7-49CF-8F3A-656A6D213246}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Library", "src\SponsorLink\Library\Library.csproj", "{23371E8B-2401-42A1-9A01-4720D8388105}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "src\SponsorLink\Tests\Tests.csproj", "{A86B253A-340E-4B82-8207-336BF65F36C8}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {1E1D01A2-D202-4FAB-B21B-AF21B1C37163}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1E1D01A2-D202-4FAB-B21B-AF21B1C37163}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1E1D01A2-D202-4FAB-B21B-AF21B1C37163}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1E1D01A2-D202-4FAB-B21B-AF21B1C37163}.Release|Any CPU.Build.0 = Release|Any CPU
+ {87B3A42C-FFA7-49CF-8F3A-656A6D213246}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {87B3A42C-FFA7-49CF-8F3A-656A6D213246}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {87B3A42C-FFA7-49CF-8F3A-656A6D213246}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {87B3A42C-FFA7-49CF-8F3A-656A6D213246}.Release|Any CPU.Build.0 = Release|Any CPU
+ {23371E8B-2401-42A1-9A01-4720D8388105}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {23371E8B-2401-42A1-9A01-4720D8388105}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {23371E8B-2401-42A1-9A01-4720D8388105}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {23371E8B-2401-42A1-9A01-4720D8388105}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A86B253A-340E-4B82-8207-336BF65F36C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A86B253A-340E-4B82-8207-336BF65F36C8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A86B253A-340E-4B82-8207-336BF65F36C8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A86B253A-340E-4B82-8207-336BF65F36C8}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {36BC3C24-D4E3-4EB0-A910-4BE4BD8FE01F}
+ EndGlobalSection
+EndGlobal
diff --git a/readme.md b/readme.md
index e484254..cdfbe05 100644
--- a/readme.md
+++ b/readme.md
@@ -185,40 +185,35 @@ Note that at the visitor level, both hierarchies are treated uniformly, since th
[![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)
-[![Rich Lee](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/richlee.png "Rich Lee")](https://github.com/richlee)
-[![](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/nietras.png "")](https://github.com/nietras)
[![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)
[![Oleg Kyrylchuk](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/okyrylchuk.png "Oleg Kyrylchuk")](https://github.com/okyrylchuk)
-[![Mariusz Kogut](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/MariuszKogut.png "Mariusz Kogut")](https://github.com/MariuszKogut)
[![Charley Wu](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/akunzai.png "Charley Wu")](https://github.com/akunzai)
[![Jakob Tikjøb Andersen](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/jakobt.png "Jakob Tikjøb Andersen")](https://github.com/jakobt)
[![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)
-[![Blauhaus Technology (Pty) Ltd](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/BlauhausTechnology.png "Blauhaus Technology (Pty) Ltd")](https://github.com/BlauhausTechnology)
[![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)
-[![](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/wjgthb.png "")](https://github.com/wjgthb)
+[![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/src/Directory.Build.props b/src/Directory.Build.props
index 4527560..381c383 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
@@ -45,8 +46,6 @@
Release
- true
- false
Latest
@@ -117,6 +116,8 @@
<_VersionLabel>$(VersionLabel.Replace('refs/heads/', ''))
+ <_VersionLabel>$(_VersionLabel.Replace('refs/tags/v', ''))
+
<_VersionLabel Condition="$(_VersionLabel.Contains('refs/pull/'))">$(VersionLabel.TrimEnd('.0123456789'))
@@ -127,7 +128,9 @@
<_VersionLabel>$(_VersionLabel.Replace('/', '-'))
- $(_VersionLabel)
+ $(_VersionLabel)
+
+ $(_VersionLabel)
@@ -141,6 +144,16 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets
index 0cb1e4e..6acb20f 100644
--- a/src/Directory.Build.targets
+++ b/src/Directory.Build.targets
@@ -4,6 +4,13 @@
CI;$(DefineConstants)
+
+
+
+ false
+ false
+ true
+
true
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..d7a1fd7
--- /dev/null
+++ b/src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Collections.Immutable;
+using System.IO;
+using System.Linq;
+using Devlooped.Sponsors;
+using Humanizer;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using static Devlooped.Sponsors.SponsorLink;
+
+namespace Analyzer;
+
+[DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)]
+public class StatusReportingAnalyzer : DiagnosticAnalyzer
+{
+ public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(new DiagnosticDescriptor(
+ "SL001", "Report Sponsoring Status", "Reports sponsoring status determined by SponsorLink", "Sponsors",
+ DiagnosticSeverity.Warning, true));
+
+ public override void Initialize(AnalysisContext context)
+ {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+
+ context.RegisterCompilationAction(c =>
+ {
+ var installed = c.Options.AdditionalFiles.Where(x =>
+ {
+ var options = c.Options.AnalyzerConfigOptionsProvider.GetOptions(x);
+ // In release builds, we'll have a single such item, since we IL-merge the analyzer.
+ return options.TryGetValue("build_metadata.Analyzer.ItemType", out var itemType) &&
+ options.TryGetValue("build_metadata.Analyzer.NuGetPackageId", out var packageId) &&
+ itemType == "Analyzer" &&
+ packageId == "SponsorableLib";
+ }).Select(x => File.GetLastWriteTime(x.Path)).OrderByDescending(x => x).FirstOrDefault();
+
+ var status = Diagnostics.GetOrSetStatus(() => c.Options);
+
+ if (installed != default)
+ Tracing.Trace($"Status: {status}, Installed: {(DateTime.Now - installed).Humanize()} ago");
+ else
+ Tracing.Trace($"Status: {status}, unknown install time");
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/SponsorLink/Analyzer/StatusReportingGenerator.cs b/src/SponsorLink/Analyzer/StatusReportingGenerator.cs
new file mode 100644
index 0000000..0a13b1c
--- /dev/null
+++ b/src/SponsorLink/Analyzer/StatusReportingGenerator.cs
@@ -0,0 +1,20 @@
+using Devlooped.Sponsors;
+using Microsoft.CodeAnalysis;
+using static Devlooped.Sponsors.SponsorLink;
+
+namespace Analyzer;
+
+[Generator]
+public class StatusReportingGenerator : IIncrementalGenerator
+{
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ context.RegisterSourceOutput(
+ context.GetSponsorManifests(),
+ (spc, source) =>
+ {
+ var status = Diagnostics.GetOrSetStatus(source);
+ spc.AddSource("StatusReporting.cs", $"// Status: {status}");
+ });
+ }
+}
diff --git a/src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets b/src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets
new file mode 100644
index 0000000..37585e8
--- /dev/null
+++ b/src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ 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..6e79399
--- /dev/null
+++ b/src/SponsorLink/Library/Library.csproj
@@ -0,0 +1,25 @@
+
+
+
+ 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..1ca1eb6
--- /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..4678d5d
--- /dev/null
+++ b/src/SponsorLink/SponsorLink.targets
@@ -0,0 +1,191 @@
+
+
+
+
+
+
+ true
+
+ true
+
+ true
+
+ CoreResGen;$(CoreCompileDependsOn)
+
+
+ $(Product)
+
+ $([System.Text.RegularExpressions.Regex]::Replace("$(FundingProduct)", "[^A-Z]", ""))
+
+ 15
+
+
+
+
+
+
+
+
+
+
+
+ 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'))
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+
\ 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..96e7e14
--- /dev/null
+++ b/src/SponsorLink/SponsorLink/DiagnosticsManager.cs
@@ -0,0 +1,226 @@
+//
+#nullable enable
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.IO.MemoryMappedFiles;
+using System.Linq;
+using System.Threading;
+using Humanizer;
+using Humanizer.Localisation;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using static Devlooped.Sponsors.SponsorLink;
+
+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
+{
+ static readonly Guid appDomainDiagnosticsKey = new(0x8d0e2670, 0xe6c4, 0x45c8, 0x81, 0xba, 0x5a, 0x36, 0x81, 0xd3, 0x65, 0x3e);
+
+ public static Dictionary KnownDescriptors { get; } = new()
+ {
+ // Requires:
+ //
+ //
+ { SponsorStatus.Unknown, CreateUnknown([.. Sponsorables.Keys], Funding.Product, Funding.Prefix) },
+ { SponsorStatus.Sponsor, CreateSponsor([.. Sponsorables.Keys], Funding.Prefix) },
+ { SponsorStatus.Expiring, CreateExpiring([.. Sponsorables.Keys], Funding.Prefix) },
+ { SponsorStatus.Expired, CreateExpired([.. Sponsorables.Keys], Funding.Prefix) },
+ };
+
+ ///
+ /// Acceses the diagnostics dictionary for the current .
+ ///
+ ConcurrentDictionary Diagnostics
+ => AppDomainDictionary.Get>(appDomainDiagnosticsKey.ToString());
+
+ ///
+ /// 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 void ReportOnce(Action report, string product = Funding.Product)
+ {
+ if (Diagnostics.TryRemove(product, out var diagnostic))
+ {
+ // Ensure only one such diagnostic is reported per product for the entire process,
+ // so that we can avoid polluting the error list with duplicates across multiple projects.
+ var id = string.Concat(Process.GetCurrentProcess().Id, product, diagnostic.Id);
+ using var mutex = new Mutex(false, "mutex" + id);
+ mutex.WaitOne();
+ using var mmf = MemoryMappedFile.CreateOrOpen(id, 1);
+ using var accessor = mmf.CreateViewAccessor();
+ if (accessor.ReadByte(0) == 0)
+ {
+ accessor.Write(0, 1);
+ report(diagnostic);
+ Tracing.Trace($"👈{diagnostic.Severity.ToString().ToLowerInvariant()}:{Process.GetCurrentProcess().Id}:{Process.GetCurrentProcess().ProcessName}:{product}:{diagnostic.Id}");
+ }
+ }
+ }
+
+ ///
+ /// Gets the status of the given product based on a previously stored diagnostic.
+ /// To ensure the value is always set before returning, use .
+ /// This method is safe to use (and would get a non-null value) in analyzers that run after CompilationStartAction(see
+ /// https://github.com/dotnet/roslyn/blob/main/docs/analyzers/Analyzer%20Actions%20Semantics.md under Ordering of actions).
+ ///
+ /// Optional that was reported, if any.
+ public SponsorStatus? GetStatus()
+ {
+ // 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(Funding.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;
+ }
+
+ ///
+ /// Gets the status of the , or sets it from
+ /// the given set of if not already set.
+ ///
+ public SponsorStatus GetOrSetStatus(ImmutableArray manifests)
+ => GetOrSetStatus(() => manifests);
+
+ ///
+ /// Gets the status of the , or sets it from
+ /// the given analyzer if not already set.
+ ///
+ public SponsorStatus GetOrSetStatus(Func options)
+ => GetOrSetStatus(() => options().GetSponsorManifests());
+
+ SponsorStatus GetOrSetStatus(Func> getManifests)
+ {
+ if (GetStatus() is { } status)
+ return status;
+
+ if (!SponsorLink.TryRead(out var claims, getManifests().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).
+ Push(Diagnostic.Create(KnownDescriptors[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
+ Push(Diagnostic.Create(KnownDescriptors[SponsorStatus.Expiring], null,
+ properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Expiring))));
+ return SponsorStatus.Expiring;
+ }
+ else
+ {
+ // report expired
+ Push(Diagnostic.Create(KnownDescriptors[SponsorStatus.Expired], null,
+ properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Expired))));
+ return SponsorStatus.Expired;
+ }
+ }
+ else
+ {
+ // report sponsor
+ Push(Diagnostic.Create(KnownDescriptors[SponsorStatus.Sponsor], null,
+ properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Sponsor)),
+ Funding.Product));
+ return SponsorStatus.Sponsor;
+ }
+ }
+
+ ///
+ /// Pushes a diagnostic for the given product.
+ ///
+ /// The same diagnostic that was pushed, for chained invocations.
+ Diagnostic Push(Diagnostic diagnostic, string product = Funding.Product)
+ {
+ // We only expect to get one warning per sponsorable+product
+ // combination, and first one to set wins.
+ if (Diagnostics.TryAdd(product, diagnostic))
+ {
+ // Reset the process-wide flag for this diagnostic.
+ var id = string.Concat(Process.GetCurrentProcess().Id, product, diagnostic.Id);
+ using var mutex = new Mutex(false, "mutex" + id);
+ mutex.WaitOne();
+ using var mmf = MemoryMappedFile.CreateOrOpen(id, 1);
+ using var accessor = mmf.CreateViewAccessor();
+ accessor.Write(0, 0);
+ Tracing.Trace($"👉{diagnostic.Severity.ToString().ToLowerInvariant()}:{Process.GetCurrentProcess().Id}:{Process.GetCurrentProcess().ProcessName}:{product}:{diagnostic.Id}");
+ }
+
+ return diagnostic;
+ }
+
+ internal 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");
+
+ internal 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);
+
+ internal 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);
+
+ internal 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..b3b1cf3
--- /dev/null
+++ b/src/SponsorLink/SponsorLink/SponsorLink.cs
@@ -0,0 +1,207 @@
+//
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Security.Claims;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.IdentityModel.JsonWebTokens;
+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;
+
+ ///
+ /// Gets all sponsor manifests from the provided analyzer options.
+ ///
+ public static ImmutableArray GetSponsorManifests(this AnalyzerOptions? options)
+ => options == null ? ImmutableArray.Create() : options.AdditionalFiles
+ .Where(x =>
+ options.AnalyzerConfigOptionsProvider.GetOptions(x).TryGetValue("build_metadata.SponsorManifest.ItemType", out var itemType) &&
+ itemType == "SponsorManifest" &&
+ Sponsorables.ContainsKey(Path.GetFileNameWithoutExtension(x.Path)))
+ .ToImmutableArray();
+
+ ///
+ /// Gets all sponsor manifests from the provided analyzer options.
+ ///
+ public static IncrementalValueProvider> GetSponsorManifests(this IncrementalGeneratorInitializationContext context)
+ => context.AdditionalTextsProvider.Combine(context.AnalyzerConfigOptionsProvider)
+ .Where(source =>
+ {
+ var (text, options) = source;
+ return options.GetOptions(text).TryGetValue("build_metadata.SponsorManifest.ItemType", out var itemType) &&
+ itemType == "SponsorManifest" &&
+ Sponsorables.ContainsKey(Path.GetFileNameWithoutExtension(text.Path));
+ })
+ .Select((source, c) => source.Left)
+ .Collect();
+
+ ///
+ /// 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 identity, false) == ManifestStatus.Valid && identity != null)
+ {
+ if (principal == null)
+ principal = new JwtRolesPrincipal(identity);
+ else
+ principal.AddIdentity(identity);
+ }
+ }
+
+ 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 ClaimsIdentity? identity, bool validateExpiration)
+ {
+ token = default;
+ identity = default;
+
+ SecurityKey key;
+ try
+ {
+ key = JsonWebKey.Create(jwk);
+ }
+ catch (ArgumentException)
+ {
+ return ManifestStatus.Unknown;
+ }
+
+ var handler = new JsonWebTokenHandler { 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",
+ };
+
+ var result = handler.ValidateTokenAsync(jwt, validation).Result;
+ if (result.Exception != null)
+ {
+ if (result.Exception is SecurityTokenInvalidSignatureException)
+ {
+ var jwtToken = handler.ReadJsonWebToken(jwt);
+ token = jwtToken;
+ identity = new ClaimsIdentity(jwtToken.Claims);
+ return ManifestStatus.Invalid;
+ }
+ else
+ {
+ var jwtToken = handler.ReadJsonWebToken(jwt);
+ token = jwtToken;
+ identity = new ClaimsIdentity(jwtToken.Claims);
+ return ManifestStatus.Invalid;
+ }
+ }
+
+ token = result.SecurityToken;
+ identity = new ClaimsIdentity(result.ClaimsIdentity.Claims, "JWT");
+
+ 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;
+ }
+
+ class JwtRolesPrincipal(ClaimsIdentity identity) : ClaimsPrincipal([identity])
+ {
+ public override bool IsInRole(string role) => HasClaim("roles", role) || base.IsInRole(role);
+ }
+}
diff --git a/src/SponsorLink/SponsorLink/SponsorLink.csproj b/src/SponsorLink/SponsorLink/SponsorLink.csproj
new file mode 100644
index 0000000..740b146
--- /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..0cf507f
--- /dev/null
+++ b/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs
@@ -0,0 +1,89 @@
+//
+#nullable enable
+using System;
+using System.Collections.Immutable;
+using System.IO;
+using System.Linq;
+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
+{
+ public override ImmutableArray SupportedDiagnostics { get; } = DiagnosticsManager.KnownDescriptors.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 =>
+ {
+ // Setting the status early allows other analyzers to potentially check for it.
+ var status = Diagnostics.GetOrSetStatus(() => ctx.Options);
+
+ // Never report any diagnostic unless we're in an editor.
+ if (IsEditor)
+ {
+ 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.
+ Diagnostics.ReportOnce(diagnostic =>
+ {
+ // For unknown (never sync'ed), only report if install grace period is over
+ if (status == SponsorStatus.Unknown)
+ {
+ var noGrace = ctx.Options.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue("build_property.SponsorLinkNoInstallGrace", out var value) &&
+ bool.TryParse(value, out var skipCheck) && skipCheck;
+
+ // NOTE: we'll always report if noGrace is set to true, regardless of install time, for
+ // testing purposes. This can be achieved via MSBuild with:
+ //
+ // true
+ //
+ //
+ //
+ //
+ if (noGrace == false)
+ {
+ var installed = ctx.Options.AdditionalFiles.Where(x =>
+ {
+ var options = ctx.Options.AnalyzerConfigOptionsProvider.GetOptions(x);
+ // In release builds, we'll have a single such item, since we IL-merge the analyzer.
+ return options.TryGetValue("build_metadata.Analyzer.ItemType", out var itemType) &&
+ options.TryGetValue("build_metadata.Analyzer.NuGetPackageId", out var packageId) &&
+ itemType == "Analyzer" &&
+ packageId == Funding.Product;
+ }).Select(x => File.GetLastWriteTime(x.Path)).OrderByDescending(x => x).FirstOrDefault();
+
+ // NOTE: if we can't determine install time, we'll always report.
+ if (installed != default && installed.AddDays(Funding.Grace) > DateTime.Now)
+ return;
+ }
+ }
+
+ ctx.ReportDiagnostic(diagnostic);
+ });
+ });
+ }
+ });
+#pragma warning restore RS1013 // Start action has no registered non-end actions
+ }
+}
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..ad5d9b3
--- /dev/null
+++ b/src/SponsorLink/SponsorLink/Tracing.cs
@@ -0,0 +1,49 @@
+//
+#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([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 = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".sponsorlink");
+ Directory.CreateDirectory(dir);
+
+ var tries = 0;
+ // Best-effort only
+ while (tries < 10)
+ {
+ try
+ {
+ File.AppendAllText(Path.Combine(dir, "trace.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..9f843e2
--- /dev/null
+++ b/src/SponsorLink/SponsorLink/buildTransitive/Devlooped.Sponsors.targets
@@ -0,0 +1,118 @@
+
+
+
+
+ $([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;SL_CollectSponsorableAnalyzer
+ $(SLDependsOn);SL_CheckAutoSync;SL_ReadAutoSyncEnabled;SL_SyncSponsors
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %(SponsorablePackageId.Identity)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %(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..092c205
--- /dev/null
+++ b/src/SponsorLink/Tests/.netconfig
@@ -0,0 +1,17 @@
+[config]
+ root = true
+[file "SponsorableManifest.cs"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/src/Core/SponsorableManifest.cs
+ sha = 5a4cad3a084f53afe34a6b75e4f3a084a0f1bf9e
+ etag = 9a07c856d06e0cde629fce3ec014f64f9adfd5ae5805a35acf623eba0ee045c1
+ weak
+[file "JsonOptions.cs"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/src/Core/JsonOptions.cs
+ sha = 80ea1bfe47049ef6c6ed4f424dcf7febb729cbba
+ etag = 17799725ad9b24eb5998365962c30b9a487bddadca37c616e35b76b8c9eb161a
+ weak
+[file "Extensions.cs"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/src/Core/Extensions.cs
+ sha = c455f6fa1a4d404181d076d7f3362345c8ed7df2
+ etag = 9e51b7e6540fae140490a5283b1e67ce071bd18a267bc2ae0b35c7248261aed1
+ weak
\ No newline at end of file
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..4063f78
--- /dev/null
+++ b/src/SponsorLink/Tests/Extensions.cs
@@ -0,0 +1,56 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.CompilerServices;
+using System.Security.Cryptography;
+using Microsoft.Extensions.Logging;
+using Microsoft.IdentityModel.Tokens;
+
+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 bool ThumbprintEquals(this SecurityKey key, RSA rsa) => key.ThumbprintEquals(new RsaSecurityKey(rsa));
+
+ public static bool ThumbprintEquals(this RSA rsa, SecurityKey key) => key.ThumbprintEquals(rsa);
+
+ public static bool ThumbprintEquals(this SecurityKey first, SecurityKey second)
+ {
+ var expectedKey = JsonWebKeyConverter.ConvertFromSecurityKey(second);
+ var actualKey = JsonWebKeyConverter.ConvertFromSecurityKey(first);
+ return expectedKey.ComputeJwkThumbprint().AsSpan().SequenceEqual(actualKey.ComputeJwkThumbprint());
+ }
+
+ 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..b2349b0
--- /dev/null
+++ b/src/SponsorLink/Tests/JsonOptions.cs
@@ -0,0 +1,70 @@
+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..3ea4a32
--- /dev/null
+++ b/src/SponsorLink/Tests/Sample.cs
@@ -0,0 +1,69 @@
+extern alias Analyzer;
+using System;
+using System.Globalization;
+using System.Runtime.CompilerServices;
+using System.Security.Cryptography;
+using Analyzer::Devlooped.Sponsors;
+using Microsoft.CodeAnalysis;
+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 = 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),
+ });
+ }
+ }
+
+ DiagnosticDescriptor GetDescriptor(string[] sponsorable, string product, string prefix, SponsorStatus status) => status switch
+ {
+ SponsorStatus.Unknown => DiagnosticsManager.CreateUnknown(sponsorable, product, prefix),
+ SponsorStatus.Sponsor => DiagnosticsManager.CreateSponsor(sponsorable, prefix),
+ SponsorStatus.Expiring => DiagnosticsManager.CreateExpiring(sponsorable, prefix),
+ SponsorStatus.Expired => DiagnosticsManager.CreateExpired(sponsorable, prefix),
+ _ => throw new NotImplementedException(),
+ };
+}
\ 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..d65d0fb
--- /dev/null
+++ b/src/SponsorLink/Tests/SponsorableManifest.cs
@@ -0,0 +1,351 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Security.Claims;
+using System.Security.Cryptography;
+using System.Text.Json;
+using Microsoft.IdentityModel.JsonWebTokens;
+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);
+ return new SponsorableManifest(issuer, audience, clientId, new RsaSecurityKey(rsa));
+ }
+
+ 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";
+ var disposeHttp = http == null;
+
+ // Manifest should be public, so no need for any special HTTP client.
+ try
+ {
+ var response = await (http ?? new HttpClient()).GetAsync(url);
+ if (!response.IsSuccessStatusCode)
+ return (Status.NotFound, default);
+
+ var jwt = await response.Content.ReadAsStringAsync();
+ if (!TryRead(jwt, out var manifest, out _))
+ 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);
+ }
+ finally
+ {
+ if (disposeHttp)
+ http?.Dispose();
+ }
+ }
+
+ ///
+ /// 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 JsonWebTokenHandler
+ {
+ MapInboundClaims = false,
+ SetDefaultTimesOnTokenCreation = false,
+ };
+ missingClaim = null;
+ manifest = default;
+
+ if (!handler.CanReadToken(jwt))
+ return false;
+
+ var token = handler.ReadJsonWebToken(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 == "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);
+
+ return true;
+ }
+
+ int hashcode;
+ string clientId;
+ string issuer;
+
+ public SponsorableManifest(Uri issuer, Uri[] audience, string clientId, SecurityKey publicKey)
+ {
+ this.clientId = clientId;
+ this.issuer = issuer.AbsoluteUri;
+ Audience = audience.Select(a => a.AbsoluteUri.TrimEnd('/')).ToArray();
+ SecurityKey = publicKey;
+ 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.");
+
+ // Force hash code to be computed
+ ClientId = clientId;
+ }
+
+ ///
+ /// 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 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("client_id", ClientId),
+ // standard claim, serialized as a JSON string, not an encoded JSON object
+ new("sub_jwk", JsonSerializer.Serialize(jwk, JsonOptions.JsonWebKey), JsonClaimValueTypes.Json),
+ ]);
+
+ var handler = new JsonWebTokenHandler
+ {
+ MapInboundClaims = false,
+ SetDefaultTimesOnTokenCreation = false,
+ };
+
+ return handler.CreateToken(new SecurityTokenDescriptor
+ {
+ IssuedAt = DateTime.UtcNow,
+ Subject = new ClaimsIdentity(claims),
+ SigningCredentials = signing,
+ });
+ }
+
+ ///
+ /// 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 or specified 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);
+
+ // Removed as we set IssuedAt = DateTime.UtcNow
+ var tokenClaims = claims.Where(x => x.Type != JwtRegisteredClaimNames.Iat && x.Type != JwtRegisteredClaimNames.Exp).ToList();
+
+ 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));
+ }
+
+ // 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.
+ if (!rsa.ThumbprintEquals(SecurityKey))
+ throw new ArgumentException($"Cannot sign with a private key that does not match the manifest public key.");
+
+ return new JsonWebTokenHandler
+ {
+ MapInboundClaims = false,
+ SetDefaultTimesOnTokenCreation = false,
+ }.CreateToken(new SecurityTokenDescriptor
+ {
+ Subject = new ClaimsIdentity(tokenClaims),
+ IssuedAt = DateTime.UtcNow,
+ Expires = expirationDate,
+ SigningCredentials = signing,
+ });
+ }
+
+ public ClaimsIdentity Validate(string jwt, out SecurityToken? token)
+ {
+ var validation = 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(),
+ // We don't validate the issuer in debug builds, to allow testing with localhost-run backend.
+#if DEBUG
+ ValidateIssuer = false,
+#else
+ ValidIssuer = Issuer,
+#endif
+ IssuerSigningKey = SecurityKey,
+ };
+
+ var result = new JsonWebTokenHandler
+ {
+ MapInboundClaims = false,
+ SetDefaultTimesOnTokenCreation = false,
+ }.ValidateTokenAsync(jwt, validation).Result;
+
+ token = result.SecurityToken;
+ return result.ClaimsIdentity;
+ }
+
+ ///
+ /// 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 => issuer;
+ internal set
+ {
+ issuer = value;
+ var thumb = JsonWebKeyConverter.ConvertFromSecurityKey(SecurityKey).ComputeJwkThumbprint();
+ hashcode = new HashCode().Add(Issuer, ClientId, Convert.ToBase64String(thumb)).AddRange(Audience).ToHashCode();
+ }
+ }
+
+ ///
+ /// 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 => clientId;
+ internal set
+ {
+ clientId = value;
+ var thumb = JsonWebKeyConverter.ConvertFromSecurityKey(SecurityKey).ComputeJwkThumbprint();
+ hashcode = new HashCode().Add(Issuer, ClientId, Convert.ToBase64String(thumb)).AddRange(Audience).ToHashCode();
+ }
+ }
+
+ ///
+ /// Public key in a format that can be used to verify JWT signatures.
+ ///
+ public SecurityKey SecurityKey { get; }
+
+ ///
+ public override int GetHashCode() => hashcode;
+
+ ///
+ 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..5082c97
--- /dev/null
+++ b/src/SponsorLink/Tests/Tests.csproj
@@ -0,0 +1,69 @@
+
+
+
+ net8.0
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ True
+ True
+ Resources.resx
+
+
+
+
+
+ ResXFileCodeGenerator
+ Resources.Designer.cs
+
+
+
+
+
+
+
+
+
+ %(GitRoot.FullPath)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+
+
+
\ 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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-