diff --git a/.netconfig b/.netconfig
index a98f7e1..50257dd 100644
--- a/.netconfig
+++ b/.netconfig
@@ -137,3 +137,306 @@
etag = 013a47739e348f06891f37c45164478cca149854e6cd5c5158e6f073f852b61a
weak
+[file "src/SponsorLink"]
+ url = https://github.com/devlooped/SponsorLink/tree/main/samples/dotnet/
+[file "src/SponsorLink/Analyzer/Analyzer.csproj"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Analyzer/Analyzer.csproj
+ sha = e55425333883c4470d745f8fee70bdf204c292ee
+
+ etag = 8aa140018fcfbd889c11da36c8c21b5cfb5730c07aa3317d734b118cfa60b416
+ weak
+[file "src/SponsorLink/Analyzer/GraceApiAnalyzer.cs"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Analyzer/GraceApiAnalyzer.cs
+ sha = 4638da914b0527c156227f3705ca60a85c1871e4
+
+ etag = 6603b004f41e023d03b86f175d9fc4e0a462d1b2519406e46b4831e36c378e6f
+ weak
+[file "src/SponsorLink/Analyzer/Properties/launchSettings.json"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Analyzer/Properties/launchSettings.json
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = 6c59ab4d008e3221e316c9e3b6e0da155b892680d48cdc400a39d53cb9a12aac
+ weak
+[file "src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Analyzer/StatusReportingAnalyzer.cs
+ sha = eceeb2c5596285c95db4d1a031cc36238a7cd22d
+
+ etag = db37e051eeea1a0e368ccc8bfdf59c373486a583c57ad8301d6be9ab21da4e0d
+ weak
+[file "src/SponsorLink/Analyzer/StatusReportingGenerator.cs"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Analyzer/StatusReportingGenerator.cs
+ sha = 08d80dd734525b1e6f46adbffd2aab77d73afb71
+
+ etag = 09f466f0a23877a980ec01a7b15330c6c36c44960028188d826a8ef48f8756aa
+ weak
+[file "src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Analyzer/buildTransitive/SponsorableLib.targets
+ sha = eceeb2c5596285c95db4d1a031cc36238a7cd22d
+
+ etag = 727bd941b7a8be190c7f17a41c791ef2248be5e25a36460a0457bc080a7d4503
+ weak
+[file "src/SponsorLink/Directory.Build.props"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Directory.Build.props
+ sha = 7b5109b5b5a53a2cc16759b776c4a092aec5ca57
+
+ etag = 5d4e433c71291ea953d328aa26b2d93cdf4708271f0eb024138ba2e0db93ab15
+ weak
+[file "src/SponsorLink/Directory.Build.targets"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Directory.Build.targets
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = 9938f29c3573bf8bdb9686e1d9884dee177256b1d5dd7ee41472dd64bfbdd92d
+ weak
+[file "src/SponsorLink/Library/Library.csproj"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Library/Library.csproj
+ sha = f74ea7a8c7f81c5bceefb3ed7ef4249b1d8574a3
+
+ etag = 592707adba548606ec50ced6e424be4cbfe34f18bf01555a19b29fa61efa416a
+ weak
+[file "src/SponsorLink/Library/MyClass.cs"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Library/MyClass.cs
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = b5b3ccd6cd14bb90dd9702b9d7e52cc22c11e601c039617738d688f9fd45d49b
+ weak
+[file "src/SponsorLink/Library/Resources.resx"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Library/Resources.resx
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = aff6051733d22982e761f2b414173aafeab40e0a76a142e2b33025dced213eb2
+ weak
+[file "src/SponsorLink/Library/readme.md"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Library/readme.md
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = 5002ac8c5bbeee60c13937a32c1b6c1a5dbf0065617c8f2550e6eca6fded256d
+ weak
+[file "src/SponsorLink/SponsorLink.Analyzer.Tests.targets"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/SponsorLink.Analyzer.Tests.targets
+ sha = df44ccc14cc11b5674c55aca9ba8596bdbcf8438
+
+
+
+ etag = a3e9cbcc227dd56a7bed236eaded136f1b80f9f36a4fabce8be695ee844bf881
+ weak
+[file "src/SponsorLink/SponsorLink.Analyzer.targets"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/SponsorLink.Analyzer.targets
+ sha = fb82cf346cea86140a51ae49b9bc730d72f7c7ac
+
+
+
+ etag = 284f794d03adabf10ac5e25ef87d257821a82eac112efe65d6fe23d675f9af7f
+ weak
+[file "src/SponsorLink/SponsorLink/AnalyzerOptionsExtensions.cs"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/SponsorLink/AnalyzerOptionsExtensions.cs
+ sha = 38a11504cc9cbd994fb7380fd580102e7514b3b5
+
+ etag = 9d0e3495b4db00915f79f7e0549b20f2ffff38865741a69810251550686102cc
+ weak
+[file "src/SponsorLink/SponsorLink/AppDomainDictionary.cs"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/SponsorLink/AppDomainDictionary.cs
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = 4a70f86e73f951bca95618c221d821e38a31ef9092af4ac61447eab845671a28
+ weak
+[file "src/SponsorLink/SponsorLink/DiagnosticsManager.cs"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/SponsorLink/DiagnosticsManager.cs
+ sha = 29921560c73bb91c2a21a21800daf0b250773598
+
+ etag = a5d79dbc0ed9fac4fb1879fb3790b9ebab18e47c14c454554ce9f53f21487bb5
+ weak
+[file "src/SponsorLink/SponsorLink/ManifestStatus.cs"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/SponsorLink/ManifestStatus.cs
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = e46848f83c0436ba33a1c09a4060ad627a74db41bab66bb37ca40fce8a6532a7
+ weak
+[file "src/SponsorLink/SponsorLink/Resources.es-AR.resx"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/SponsorLink/Resources.es-AR.resx
+ sha = 586398c3e650495f36601ecc8983a14ed745e058
+
+ etag = 1d6ca61601815a20581fc13f9efdad151ee0e5cf952318723265d5c183d3e1cc
+ weak
+[file "src/SponsorLink/SponsorLink/Resources.es.resx"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/SponsorLink/Resources.es.resx
+ sha = 29921560c73bb91c2a21a21800daf0b250773598
+
+ etag = feb9dc86e4d9c0c4a294cd6e03c5b914943e8d206b88a125abd1b0f882ddb247
+ weak
+[file "src/SponsorLink/SponsorLink/Resources.resx"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/SponsorLink/Resources.resx
+ sha = 29921560c73bb91c2a21a21800daf0b250773598
+
+ etag = 7665a3be17cd224b1c413ade6a9c1c5a822dace1e7f9daae33a2e52d8bca15bb
+ weak
+[file "src/SponsorLink/SponsorLink/SponsorLink.cs"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/SponsorLink/SponsorLink.cs
+ sha = 3f72a9fd35274a659dd380a7d5b747d71b9732a1
+
+
+ etag = 616598e0ecb6d2ce97660aa6ac049e2a31a1c953669743b7b612b61d40c37706
+ weak
+[file "src/SponsorLink/SponsorLink/SponsorLink.csproj"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/SponsorLink/SponsorLink.csproj
+ sha = 0d22f1ee7d7afc93e11060887de0e1773884978e
+
+
+ etag = dbf30ffb9baa63e45a4c821bc1433e4289b9af84855c2a306eaa116874a1c9f2
+ weak
+[file "src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/SponsorLink/SponsorLinkAnalyzer.cs
+ sha = 46e9abe02e5a6abadda66ef050ddc5b9859aa2b8
+
+ etag = 062a02b6eb45e5e49cc73c77c25d66bf2695fc365e13ce7dc39f813a030fc370
+ weak
+[file "src/SponsorLink/SponsorLink/SponsorStatus.cs"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/SponsorLink/SponsorStatus.cs
+ sha = 29921560c73bb91c2a21a21800daf0b250773598
+
+ etag = 419a823edb42d9175ae96d66a8b0191d8fc91921268c2a5340cf8d34519d4535
+ weak
+[file "src/SponsorLink/SponsorLink/SponsorableLib.targets"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/SponsorLink/SponsorableLib.targets
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = 2f923a97081481a6a264d63c8ff70ce5ba65c3dbaf7ea078cbe1388fb0868e1c
+ weak
+[file "src/SponsorLink/SponsorLink/Tracing.cs"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/SponsorLink/Tracing.cs
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = 29d6c0362f4c47eedfebea5018d563adb04a8f7b30da87495c5c8a4561e2c4ed
+ weak
+[file "src/SponsorLink/SponsorLink/buildTransitive/Devlooped.Sponsors.targets"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/SponsorLink/buildTransitive/Devlooped.Sponsors.targets
+ sha = d7090c1dbcb20c68b99486a6dc53d86b8d9b06bb
+
+ etag = e992b97517c9bcc6c9e927832bc13fac3036fa6d4ecaad893caf320b3c582aee
+ weak
+[file "src/SponsorLink/SponsorLink/sponsorable.md"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/SponsorLink/sponsorable.md
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = 9c275d50705a2e661f0f86f1ae5e555c0033a05e86e12f936283a5b5ef47ae77
+ weak
+[file "src/SponsorLink/SponsorLinkAnalyzer.sln"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/SponsorLinkAnalyzer.sln
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = fc2928c9b303d81ff23891ee791a859b794d9f2d4b9f4e81b9ed15e5b74db487
+ weak
+[file "src/SponsorLink/Tests/.netconfig"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Tests/.netconfig
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = 0323e19eb4582113dd409853ba83e9845069bf35733ed84a0bdc9fb6990502a9
+ weak
+[file "src/SponsorLink/Tests/AnalyzerTests.cs"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Tests/AnalyzerTests.cs
+ sha = 29921560c73bb91c2a21a21800daf0b250773598
+
+ etag = 219df696a47a58d9de377166c87fbb199c84c33d3b7a0f7ae349543df050a583
+ weak
+[file "src/SponsorLink/Tests/Attributes.cs"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Tests/Attributes.cs
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = 1d7c17a2c9424db73746112c338a39e0000134ac878b398e2aa88f7ea5c0c488
+ weak
+[file "src/SponsorLink/Tests/Extensions.cs"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Tests/Extensions.cs
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = 9e51b7e6540fae140490a5283b1e67ce071bd18a267bc2ae0b35c7248261aed1
+ weak
+[file "src/SponsorLink/Tests/JsonOptions.cs"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Tests/JsonOptions.cs
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = 17799725ad9b24eb5998365962c30b9a487bddadca37c616e35b76b8c9eb161a
+ weak
+[file "src/SponsorLink/Tests/Resources.resx"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Tests/Resources.resx
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = 13d1bb8b0de32a8c9b5dbdc806a036ed89d423cd7c0be187b8c56055c9bf7783
+ weak
+[file "src/SponsorLink/Tests/Sample.cs"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Tests/Sample.cs
+ sha = ca82a9d6298a933192c5dfd2c5881ebadb85d0fe
+
+ etag = 1875555adb7eab21acf1e730b6baeb8c095d9f6f9f07303a87ad9c16e0f6490d
+ weak
+[file "src/SponsorLink/Tests/SponsorLinkTests.cs"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Tests/SponsorLinkTests.cs
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = 1fa41250bd984e8aa840a966d34ce0e94f2111d1422d7f50b864c38364fcf4a4
+ weak
+[file "src/SponsorLink/Tests/SponsorableManifest.cs"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Tests/SponsorableManifest.cs
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = eb2292c6d7bf53a56acbb73d7c89ccc78fd8bec2e2198d70e36da93c01d36374
+ weak
+[file "src/SponsorLink/Tests/Tests.csproj"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Tests/Tests.csproj
+ sha = 0d22f1ee7d7afc93e11060887de0e1773884978e
+
+
+ etag = 5db4da024e4ecfb90be14feb4db952efa2109ee2ec84e715921291808d57b749
+ weak
+[file "src/SponsorLink/Tests/keys/kzu.key"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Tests/keys/kzu.key
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = bd8f5b16d248829e9cf4d8695677b2b7c09607d2b50b1cda05dbaa48c2a3fe04
+ weak
+[file "src/SponsorLink/Tests/keys/kzu.key.jwk"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Tests/keys/kzu.key.jwk
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = dca60d636ab866adf211662a5aa597e4d1f477a280f6ee82cd7f7b390535a458
+ weak
+[file "src/SponsorLink/Tests/keys/kzu.key.txt"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Tests/keys/kzu.key.txt
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = 7553487806f6dbd219b4dbda5d6fb097b8047a1d1856255a339e049c7496da43
+ weak
+[file "src/SponsorLink/Tests/keys/kzu.pub"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Tests/keys/kzu.pub
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = 75c544bb911372c909a58d6d07e89abe776ef618861f6d580915b0e79c6bb2fe
+ weak
+[file "src/SponsorLink/Tests/keys/kzu.pub.jwk"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Tests/keys/kzu.pub.jwk
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = 9a2829bf01fe53089c0f4ff46f5bca60955338bbfc7a2354482cde05dc750806
+ weak
+[file "src/SponsorLink/Tests/keys/kzu.pub.txt"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Tests/keys/kzu.pub.txt
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = 6308869899eb7efeee34dc4daa71ee04a06f21cc09199beb74a78af8e213f576
+ weak
+[file "src/SponsorLink/Tests/keys/sponsorlink.jwt"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/Tests/keys/sponsorlink.jwt
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = af05cc803434a0e22b67521be8bb66676c5c0ca0795afb4430bd26751ce307e1
+ weak
+[file "src/SponsorLink/jwk.ps1"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/jwk.ps1
+ sha = f47528874a6d9192b5546f84b455f5ccc474a707
+
+ etag = f399e05ecb56adaf41d2545171f299a319142b17dd09fc38e452ca8c5d13bd0d
+ weak
+[file "src/SponsorLink/readme.md"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/readme.md
+ sha = 7407f5b3461213ae764f53ee93651a34487e458c
+
+ etag = 50937c64732bb2b97ddc67cc7b7b2d091c51390c9f5f2b5fdcfe9f1becb5d838
+ weak
diff --git a/DependencyInjection.sln b/DependencyInjection.sln
index a8dd488..690fe17 100644
--- a/DependencyInjection.sln
+++ b/DependencyInjection.sln
@@ -9,14 +9,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DependencyInjection.Tests",
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodeAnalysis.Tests", "src\CodeAnalysis.Tests\CodeAnalysis.Tests.csproj", "{E512DEBA-FB35-47FD-AF25-3BAECCF667B1}"
EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{3C5A7AC8-E8CC-40D6-B472-A693F742152A}"
-EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Library1", "src\Samples\Library1\Library1.csproj", "{2BFDB11F-9503-4898-B348-BBE053A166B4}"
-EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Library2", "src\Samples\Library2\Library2.csproj", "{EB3F2D78-0F2C-4A1B-BD5E-E31299B67601}"
-EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsoleApp", "src\Samples\ConsoleApp\ConsoleApp.csproj", "{26EF99DB-D846-4F65-929D-D7E3E820423A}"
-EndProject
Project("{13B669BE-BB05-4DDF-9536-439F39A36129}") = "Attributed", "src\Attributed\Attributed.msbuildproj", "{1E68517F-34E7-415E-91B1-857802ED5592}"
EndProject
Global
@@ -37,18 +29,6 @@ Global
{E512DEBA-FB35-47FD-AF25-3BAECCF667B1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E512DEBA-FB35-47FD-AF25-3BAECCF667B1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E512DEBA-FB35-47FD-AF25-3BAECCF667B1}.Release|Any CPU.Build.0 = Release|Any CPU
- {2BFDB11F-9503-4898-B348-BBE053A166B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {2BFDB11F-9503-4898-B348-BBE053A166B4}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {2BFDB11F-9503-4898-B348-BBE053A166B4}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {2BFDB11F-9503-4898-B348-BBE053A166B4}.Release|Any CPU.Build.0 = Release|Any CPU
- {EB3F2D78-0F2C-4A1B-BD5E-E31299B67601}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {EB3F2D78-0F2C-4A1B-BD5E-E31299B67601}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {EB3F2D78-0F2C-4A1B-BD5E-E31299B67601}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {EB3F2D78-0F2C-4A1B-BD5E-E31299B67601}.Release|Any CPU.Build.0 = Release|Any CPU
- {26EF99DB-D846-4F65-929D-D7E3E820423A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {26EF99DB-D846-4F65-929D-D7E3E820423A}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {26EF99DB-D846-4F65-929D-D7E3E820423A}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {26EF99DB-D846-4F65-929D-D7E3E820423A}.Release|Any CPU.Build.0 = Release|Any CPU
{1E68517F-34E7-415E-91B1-857802ED5592}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1E68517F-34E7-415E-91B1-857802ED5592}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1E68517F-34E7-415E-91B1-857802ED5592}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -57,11 +37,6 @@ Global
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
- GlobalSection(NestedProjects) = preSolution
- {2BFDB11F-9503-4898-B348-BBE053A166B4} = {3C5A7AC8-E8CC-40D6-B472-A693F742152A}
- {EB3F2D78-0F2C-4A1B-BD5E-E31299B67601} = {3C5A7AC8-E8CC-40D6-B472-A693F742152A}
- {26EF99DB-D846-4F65-929D-D7E3E820423A} = {3C5A7AC8-E8CC-40D6-B472-A693F742152A}
- EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {0B87CEC2-BB4D-4DAC-9C64-EB24C968C5D8}
EndGlobalSection
diff --git a/src/CodeAnalysis.Tests/AddServicesAnalyzerTests.cs b/src/CodeAnalysis.Tests/AddServicesAnalyzerTests.cs
index 4a41709..1068b90 100644
--- a/src/CodeAnalysis.Tests/AddServicesAnalyzerTests.cs
+++ b/src/CodeAnalysis.Tests/AddServicesAnalyzerTests.cs
@@ -55,7 +55,7 @@ public static void Main()
.AddPackages(ImmutableArray.Create(
new PackageIdentity("Microsoft.Extensions.DependencyInjection", "8.0.0")))
},
- };
+ }.WithPreprocessorSymbols();
//var expected = Verifier.Diagnostic(AddServicesAnalyzer.NoAddServicesCall).WithLocation(0);
@@ -101,7 +101,7 @@ public static void Main()
.AddPackages(ImmutableArray.Create(
new PackageIdentity("Microsoft.Extensions.DependencyInjection", "8.0.0")))
},
- };
+ }.WithPreprocessorSymbols();
await test.RunAsync();
}
@@ -142,7 +142,7 @@ public static void Main()
.AddPackages(ImmutableArray.Create(
new PackageIdentity("Microsoft.Extensions.DependencyInjection", "8.0.0")))
},
- };
+ }.WithPreprocessorSymbols();
var expected = Verifier.Diagnostic(AddServicesAnalyzer.NoAddServicesCall).WithLocation(0);
test.ExpectedDiagnostics.Add(expected);
@@ -155,6 +155,11 @@ public async Task WarnIfAddServicesMissingMultipleLocations()
{
var test = new AnalyzerTest
{
+ // Make sure compilation defines the constant/symbol 'DDI_ADDSERVICES'
+ // so the generator can detect the presence of the extension method.
+ OptionsTransforms = {
+ (options) => options
+ },
TestBehaviors = TestBehaviors.SkipGeneratedSourcesCheck,
TestCode = """
using System;
@@ -189,7 +194,7 @@ public static void Main()
.AddPackages(ImmutableArray.Create(
new PackageIdentity("Microsoft.Extensions.DependencyInjection", "8.0.0")))
},
- };
+ }.WithPreprocessorSymbols();
var expected = Verifier.Diagnostic(AddServicesAnalyzer.NoAddServicesCall).WithLocation(0);
test.ExpectedDiagnostics.Add(expected);
diff --git a/src/CodeAnalysis.Tests/CodeAnalysis.Tests.csproj b/src/CodeAnalysis.Tests/CodeAnalysis.Tests.csproj
index 01035b1..93b8b20 100644
--- a/src/CodeAnalysis.Tests/CodeAnalysis.Tests.csproj
+++ b/src/CodeAnalysis.Tests/CodeAnalysis.Tests.csproj
@@ -1,19 +1,23 @@
- net6.0
+ net8.0
Preview
-
-
+
-
-
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
-
+
diff --git a/src/CodeAnalysis.Tests/ConventionAnalyzerTests.cs b/src/CodeAnalysis.Tests/ConventionAnalyzerTests.cs
index 1672063..9ee32d9 100644
--- a/src/CodeAnalysis.Tests/ConventionAnalyzerTests.cs
+++ b/src/CodeAnalysis.Tests/ConventionAnalyzerTests.cs
@@ -54,7 +54,7 @@ public static void Main()
.AddPackages(ImmutableArray.Create(
new PackageIdentity("Microsoft.Extensions.DependencyInjection", "8.0.0")))
},
- };
+ }.WithPreprocessorSymbols();
var expected = Verifier.Diagnostic(ConventionsAnalyzer.AssignableTypeOfRequired).WithLocation(0);
test.ExpectedDiagnostics.Add(expected);
@@ -98,7 +98,7 @@ public static void Main()
.AddPackages(ImmutableArray.Create(
new PackageIdentity("Microsoft.Extensions.DependencyInjection", "8.0.0")))
},
- };
+ }.WithPreprocessorSymbols();
//var expected = Verifier.Diagnostic(ConventionsAnalyzer.AssignableTypeOfRequired).WithLocation(0);
//test.ExpectedDiagnostics.Add(expected);
@@ -145,7 +145,7 @@ public static void Main()
.AddPackages(ImmutableArray.Create(
new PackageIdentity("Microsoft.Extensions.DependencyInjection", "8.0.0")))
},
- };
+ }.WithPreprocessorSymbols();
var expected = Verifier.Diagnostic(ConventionsAnalyzer.OpenGenericType).WithLocation(0);
test.ExpectedDiagnostics.Add(expected);
diff --git a/src/CodeAnalysis.Tests/TestExtensions.cs b/src/CodeAnalysis.Tests/TestExtensions.cs
new file mode 100644
index 0000000..e050e70
--- /dev/null
+++ b/src/CodeAnalysis.Tests/TestExtensions.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Testing;
+
+namespace Tests.CodeAnalysis;
+
+public static class TestExtensions
+{
+ public static TAnalyzerTest WithPreprocessorSymbols(this TAnalyzerTest test, params string[] symbols)
+ where TAnalyzerTest : AnalyzerTest
+ {
+ test.OptionsTransforms.Add(options =>
+ {
+ return options;
+ });
+
+ test.SolutionTransforms.Add((solution, projectId) =>
+ {
+ var project = solution.GetProject(projectId);
+ var parseOptions = (CSharpParseOptions)project!.ParseOptions!;
+
+ parseOptions = parseOptions.WithPreprocessorSymbols(
+ symbols.Length > 0 ? symbols : ["DDI_ADDSERVICE", "DDI_ADDSERVICES"]);
+
+ return solution.WithProjectParseOptions(projectId, parseOptions);
+ });
+
+ return test;
+ }
+}
diff --git a/src/DependencyInjection.Tests/DependencyInjection.Tests.csproj b/src/DependencyInjection.Tests/DependencyInjection.Tests.csproj
index ef52c01..bbeb3ef 100644
--- a/src/DependencyInjection.Tests/DependencyInjection.Tests.csproj
+++ b/src/DependencyInjection.Tests/DependencyInjection.Tests.csproj
@@ -3,7 +3,7 @@
- net6.0
+ net8.0
true
Tests
@@ -28,6 +28,16 @@
+
+
+
+
+
+ true
+
+
+
+
diff --git a/src/DependencyInjection/DependencyInjection.csproj b/src/DependencyInjection/DependencyInjection.csproj
index 4446986..50742b4 100644
--- a/src/DependencyInjection/DependencyInjection.csproj
+++ b/src/DependencyInjection/DependencyInjection.csproj
@@ -12,20 +12,30 @@
analyzers/dotnet
true
true
- $(DefineConstants);DDI_ADDSERVICE
+ $(DefineConstants);DDI_ADDSERVICE;DDI_ADDSERVICES
false
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+ $(MSBuildThisFileDirectory)..\SponsorLink\SponsorLink.Analyzer.targets
+
+ $(PackageId)
+
+
@@ -36,14 +46,6 @@
-
-
-
-
-
-
-
-
diff --git a/src/DependencyInjection/Devlooped.Extensions.DependencyInjection.targets b/src/DependencyInjection/Devlooped.Extensions.DependencyInjection.targets
index c533eef..e2489cb 100644
--- a/src/DependencyInjection/Devlooped.Extensions.DependencyInjection.targets
+++ b/src/DependencyInjection/Devlooped.Extensions.DependencyInjection.targets
@@ -1,4 +1,5 @@
+
@@ -7,6 +8,11 @@
$(DefineConstants);DDI_ADDSERVICES
+
+
+
+
+
$(DefineConstants);DDI_ADDSERVICE
diff --git a/src/DependencyInjection/IncrementalGenerator.cs b/src/DependencyInjection/IncrementalGenerator.cs
index 3f1dc36..60a58a2 100644
--- a/src/DependencyInjection/IncrementalGenerator.cs
+++ b/src/DependencyInjection/IncrementalGenerator.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
+using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
@@ -290,6 +291,7 @@ void AddPartial(string methodName, SourceProductionContext ctx, (ImmutableArray<
builder.AppendLine(
$$"""
+ #if DDI_ADDSERVICES
using Microsoft.Extensions.DependencyInjection.Extensions;
using System;
@@ -309,6 +311,7 @@ static partial class {{className}}
}
}
}
+ #endif
""");
ctx.AddSource(methodName + ".g", builder.ToString().Replace("\r\n", "\n").Replace("\n", Environment.NewLine));
diff --git a/src/DependencyInjection/StaticGenerator.cs b/src/DependencyInjection/StaticGenerator.cs
index 9720c5b..1c816a0 100644
--- a/src/DependencyInjection/StaticGenerator.cs
+++ b/src/DependencyInjection/StaticGenerator.cs
@@ -1,5 +1,11 @@
-using Microsoft.CodeAnalysis;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Text;
+using Devlooped.Sponsors;
+using Microsoft.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
+using static Devlooped.Sponsors.SponsorLink;
namespace Devlooped.Extensions.DependencyInjection;
@@ -30,8 +36,93 @@ public void Execute(GeneratorExecutionContext context)
var className = context.AnalyzerConfigOptions.GlobalOptions.TryGetValue("build_property.AddServicesClassName", out value) && !string.IsNullOrEmpty(value) ?
value : DefaultAddServicesClass;
- context.AddSource(DefaultAddServicesClass + ".g", ThisAssembly.Resources.AddServicesNoReflectionExtension.Text
- .Replace("namespace " + DefaultNamespace, "namespace " + rootNs)
- .Replace(DefaultAddServicesClass, className));
+ var code = ThisAssembly.Resources.AddServicesNoReflectionExtension.Text
+ .Replace("namespace " + DefaultNamespace, "namespace " + rootNs)
+ .Replace(DefaultAddServicesClass, className);
+
+ if (IsEditor)
+ {
+ var status = Diagnostics.GetOrSetStatus(context.GetStatusOptions());
+ string? remarks = default;
+ string? warn = default;
+
+ if (status == SponsorStatus.Unknown || status == SponsorStatus.Expired)
+ {
+ warn =
+ $"""
+ [Obsolete("{string.Format(CultureInfo.CurrentCulture, Resources.Editor_Disabled, Funding.Product, Funding.HelpUrl)}", false
+ #if NET6_0_OR_GREATER
+ , UrlFormat = "{Funding.HelpUrl}"
+ #endif
+ )]
+ """;
+
+ remarks = Resources.Editor_DisabledRemarks;
+ }
+ else if (status == SponsorStatus.Grace && Diagnostics.TryGet() is { } grace && grace.Properties.TryGetValue(nameof(SponsorStatus.Grace), out var days))
+ {
+ remarks = string.Format(CultureInfo.CurrentCulture, Resources.Editor_GraceRemarks, days);
+ }
+
+ if (remarks != null)
+ {
+ // Remove /// and /// LINES from the remarks string
+ var builder = new StringBuilder();
+ foreach (var line in ReadLines(remarks))
+ {
+ if (line.EndsWith("/// ") || line.EndsWith("/// "))
+ continue;
+ if (line.TrimStart() is { Length: > 0 } trimmed && trimmed.StartsWith("///"))
+ builder.AppendLine(trimmed);
+ }
+ remarks = builder.AppendLine("///").ToString();
+ }
+
+ if (remarks != null || warn != null)
+ {
+ var builder = new StringBuilder();
+ foreach (var line in ReadLines(code))
+ {
+ if (remarks != null && line.EndsWith("/// "))
+ {
+ builder.AppendLine(line);
+ // trim the remarks line to remove leading spaces and
+ // replace them with the indenting from the target code line
+ var indent = line.IndexOf("/// ");
+ foreach (var rline in ReadLines(remarks))
+ {
+ builder.Append(new string(' ', indent)).AppendLine(rline);
+ }
+ }
+ else if (warn != null && line.EndsWith("[DDIAddServices]"))
+ {
+ builder.AppendLine(line);
+ // trim the remarks line to remove leading spaces and
+ // replace them with the indenting from the target code line
+ var indent = line.IndexOf("[DDIAddServices]");
+ // append indentation and the warning, also splitting lines and trimming start
+ foreach (var wline in ReadLines(warn))
+ {
+ builder.Append(new string(' ', indent)).AppendLine(wline.TrimStart());
+ }
+ }
+ else
+ {
+ builder.AppendLine(line);
+ }
+ }
+ code = builder.ToString();
+ }
+ }
+
+ context.AddSource(DefaultAddServicesClass + ".g", code);
+ }
+
+ static IEnumerable ReadLines(string text)
+ {
+ using var reader = new StringReader(text);
+ string? line;
+ while ((line = reader.ReadLine()) != null)
+ yield return line;
}
}
diff --git a/src/Samples/ConsoleApp/ConsoleApp.csproj b/src/Samples/ConsoleApp/ConsoleApp.csproj
index 32cbdf0..40c131b 100644
--- a/src/Samples/ConsoleApp/ConsoleApp.csproj
+++ b/src/Samples/ConsoleApp/ConsoleApp.csproj
@@ -1,25 +1,20 @@
-
Exe
net6.0
true
-
- true
- false
true
+
-
-
diff --git a/src/Samples/DISample.sln b/src/Samples/DISample.sln
new file mode 100644
index 0000000..2641bbe
--- /dev/null
+++ b/src/Samples/DISample.sln
@@ -0,0 +1,44 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.4.32916.344
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{3C5A7AC8-E8CC-40D6-B472-A693F742152A}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Library1", "Library1\Library1.csproj", "{2BFDB11F-9503-4898-B348-BBE053A166B4}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsoleApp", "ConsoleApp\ConsoleApp.csproj", "{26EF99DB-D846-4F65-929D-D7E3E820423A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Library2", "Library2\Library2.csproj", "{628BF7AC-CADB-E845-D0A3-82D0FCBCA4DC}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {2BFDB11F-9503-4898-B348-BBE053A166B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2BFDB11F-9503-4898-B348-BBE053A166B4}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2BFDB11F-9503-4898-B348-BBE053A166B4}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2BFDB11F-9503-4898-B348-BBE053A166B4}.Release|Any CPU.Build.0 = Release|Any CPU
+ {26EF99DB-D846-4F65-929D-D7E3E820423A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {26EF99DB-D846-4F65-929D-D7E3E820423A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {26EF99DB-D846-4F65-929D-D7E3E820423A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {26EF99DB-D846-4F65-929D-D7E3E820423A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {628BF7AC-CADB-E845-D0A3-82D0FCBCA4DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {628BF7AC-CADB-E845-D0A3-82D0FCBCA4DC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {628BF7AC-CADB-E845-D0A3-82D0FCBCA4DC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {628BF7AC-CADB-E845-D0A3-82D0FCBCA4DC}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {2BFDB11F-9503-4898-B348-BBE053A166B4} = {3C5A7AC8-E8CC-40D6-B472-A693F742152A}
+ {26EF99DB-D846-4F65-929D-D7E3E820423A} = {3C5A7AC8-E8CC-40D6-B472-A693F742152A}
+ {628BF7AC-CADB-E845-D0A3-82D0FCBCA4DC} = {3C5A7AC8-E8CC-40D6-B472-A693F742152A}
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {0B87CEC2-BB4D-4DAC-9C64-EB24C968C5D8}
+ EndGlobalSection
+EndGlobal
diff --git a/src/Samples/Directory.Build.props b/src/Samples/Directory.Build.props
new file mode 100644
index 0000000..11f86cd
--- /dev/null
+++ b/src/Samples/Directory.Build.props
@@ -0,0 +1,10 @@
+
+
+
+ false
+ true
+ https://api.nuget.org/v3/index.json;$(MSBuildThisFileDirectory)../../bin;https://pkg.kzu.app/index.json
+
+
+
+
diff --git a/src/Samples/Library1/Library1.csproj b/src/Samples/Library1/Library1.csproj
index ba2a740..19c6bc1 100644
--- a/src/Samples/Library1/Library1.csproj
+++ b/src/Samples/Library1/Library1.csproj
@@ -1,23 +1,13 @@
-
netstandard2.0
- false
- true
-
- DDI_ADDSERVICE
-
+
-
-
-
-
-
diff --git a/src/Samples/Library2/Library2.csproj b/src/Samples/Library2/Library2.csproj
index 6cb17ab..8385702 100644
--- a/src/Samples/Library2/Library2.csproj
+++ b/src/Samples/Library2/Library2.csproj
@@ -2,7 +2,6 @@
netstandard2.0
- false
@@ -10,8 +9,4 @@
-
-
-
-
diff --git a/src/SponsorLink/Analyzer/Analyzer.csproj b/src/SponsorLink/Analyzer/Analyzer.csproj
new file mode 100644
index 0000000..ef41b20
--- /dev/null
+++ b/src/SponsorLink/Analyzer/Analyzer.csproj
@@ -0,0 +1,45 @@
+
+
+
+ SponsorableLib.Analyzers
+ netstandard2.0
+ true
+ analyzers/dotnet/roslyn4.0
+ true
+ false
+ true
+ $(MSBuildThisFileDirectory)..\SponsorLink.Analyzer.targets
+ disable
+ SponsorableLib
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)..\Tests\keys\kzu.pub.jwk'))
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/SponsorLink/Analyzer/GraceApiAnalyzer.cs b/src/SponsorLink/Analyzer/GraceApiAnalyzer.cs
new file mode 100644
index 0000000..73b1ab9
--- /dev/null
+++ b/src/SponsorLink/Analyzer/GraceApiAnalyzer.cs
@@ -0,0 +1,61 @@
+using System.Collections.Immutable;
+using System.Linq;
+using Devlooped.Sponsors;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Diagnostics;
+using static Devlooped.Sponsors.SponsorLink;
+
+namespace Analyzer;
+
+///
+/// Links the sponsor status for the current compilation.
+///
+[DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)]
+public class GraceApiAnalyzer : DiagnosticAnalyzer
+{
+ public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(
+ new DiagnosticDescriptor(
+ "SL010", "Grace API usage", "Reports info for APIs that are in grace period", "Sponsors",
+ DiagnosticSeverity.Info, true, helpLinkUri: Funding.HelpUrl),
+ new DiagnosticDescriptor(
+ "SL011", "Report Sponsoring Status", "Fake to get it to call us", "Sponsors",
+ DiagnosticSeverity.Warning, true)
+ );
+
+#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);
+ // Report info grace and expiring diagnostics.
+ context.RegisterSyntaxNodeAction(AnalyzeNode, SyntaxKind.InvocationExpression);
+ context.RegisterSyntaxNodeAction(AnalyzeNode, SyntaxKind.SimpleMemberAccessExpression);
+ }
+
+ void AnalyzeNode(SyntaxNodeAnalysisContext context)
+ {
+ var status = Diagnostics.GetOrSetStatus(() => context.Options);
+ if (status != SponsorStatus.Grace)
+ return;
+
+ ReportGraceSymbol(context, context.Node.GetLocation(), context.SemanticModel.GetSymbolInfo(context.Node).Symbol);
+ }
+
+ void ReportGraceSymbol(SyntaxNodeAnalysisContext context, Location location, ISymbol? symbol)
+ {
+ if (symbol != null &&
+ symbol.GetAttributes().Any(attr =>
+ attr.AttributeClass?.ToDisplayString() == "System.ComponentModel.CategoryAttribute" &&
+ attr.ConstructorArguments.Any(arg => arg.Value as string == "Sponsored")))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ SupportedDiagnostics[0],
+ location));
+ }
+ }
+}
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..1f02282
--- /dev/null
+++ b/src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs
@@ -0,0 +1,57 @@
+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 Microsoft.CodeAnalysis.Text;
+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.Info, true),
+ new DiagnosticDescriptor(
+ "SL002", "Report Sponsoring Status", "Fake to get it to call us", "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);
+
+ var location = Location.None;
+ if (c.Options.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue("build_property.MSBuildProjectFullPath", out var value))
+ location = Location.Create(value, new TextSpan(), new LinePositionSpan());
+
+ c.ReportDiagnostic(Diagnostic.Create(SupportedDiagnostics[0], location, status.ToString()));
+
+ 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..8ba7031
--- /dev/null
+++ b/src/SponsorLink/Analyzer/StatusReportingGenerator.cs
@@ -0,0 +1,26 @@
+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(
+ // this is required to ensure status is registered properly independently
+ // of analyzer runs.
+ context.GetStatusOptions(),
+ (spc, source) =>
+ {
+ var status = Diagnostics.GetOrSetStatus(source);
+ spc.AddSource("StatusReporting.cs",
+ $"""
+ // Status: {status}
+ // DesignTimeBuild: {source.GlobalOptions.IsDesignTimeBuild()}
+ """);
+ });
+ }
+}
diff --git a/src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets b/src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets
new file mode 100644
index 0000000..bb1b113
--- /dev/null
+++ b/src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ 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..191107d
--- /dev/null
+++ b/src/SponsorLink/Directory.Build.props
@@ -0,0 +1,27 @@
+
+
+
+ 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..3ad022a
--- /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.Analyzer.Tests.targets b/src/SponsorLink/SponsorLink.Analyzer.Tests.targets
new file mode 100644
index 0000000..7959b8d
--- /dev/null
+++ b/src/SponsorLink/SponsorLink.Analyzer.Tests.targets
@@ -0,0 +1,50 @@
+
+
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $([MSBuild]::ValueOrDefault('%(FullPath)', '').Replace('net6.0', 'netstandard2.0').Replace('net8.0', 'netstandard2.0').Replace('netcoreapp3.1', 'netstandard2.0'))
+
+
+
+
+
+
+
diff --git a/src/SponsorLink/SponsorLink.Analyzer.targets b/src/SponsorLink/SponsorLink.Analyzer.targets
new file mode 100644
index 0000000..9aae475
--- /dev/null
+++ b/src/SponsorLink/SponsorLink.Analyzer.targets
@@ -0,0 +1,238 @@
+
+
+
+
+
+
+ true
+
+ true
+ false
+
+ true
+
+ CoreResGen;$(CoreCompileDependsOn)
+
+
+ $(Product)
+ $(PackageId)
+
+ $([System.Text.RegularExpressions.Regex]::Replace("$(FundingProduct)", "[^A-Z]", ""))
+
+ 15
+
+ https://github.com/devlooped#sponsorlink
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+ SponsorLink\%(RecursiveDir)%(Filename)%(Extension)
+
+
+ SponsorLink\%(RecursiveDir)%(Filename)%(Extension)
+
+
+ SponsorLink\%(RecursiveDir)%(Filename)%(Extension)
+
+
+ SponsorLink\%(PackagePath)
+
+
+
+
+
+ false
+
+
+ false
+
+
+ false
+
+
+ false
+
+
+
+
+ true
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $(FundingProduct)
+
+
+
+
+
+
+ <_FundingAnalyzerPackageId Include="@(FundingAnalyzerPackageId -> '"%(Identity)"')" />
+
+
+ <_FundingPackageIds>@(_FundingAnalyzerPackageId, ',')
+
+
+
+ using System.Collections.Generic%3B
+
+namespace Devlooped.Sponsors%3B
+
+partial class SponsorLink
+{
+ public partial class Funding
+ {
+ public static HashSet<string> PackageIds { get%3B } = [$(_FundingPackageIds)]%3B
+ public const string Product = "$(FundingProduct)"%3B
+ public const string Prefix = "$(FundingPrefix)"%3B
+ public const string HelpUrl = "$(FundingHelpUrl)"%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
+
+
+
diff --git a/src/SponsorLink/SponsorLink/AnalyzerOptionsExtensions.cs b/src/SponsorLink/SponsorLink/AnalyzerOptionsExtensions.cs
new file mode 100644
index 0000000..d8c29c6
--- /dev/null
+++ b/src/SponsorLink/SponsorLink/AnalyzerOptionsExtensions.cs
@@ -0,0 +1,18 @@
+using Microsoft.CodeAnalysis.Diagnostics;
+
+static class AnalyzerOptionsExtensions
+{
+ ///
+ /// Gets whether the current build is a design-time build.
+ ///
+ public static bool IsDesignTimeBuild(this AnalyzerConfigOptionsProvider options) =>
+ options.GlobalOptions.TryGetValue("build_property.DesignTimeBuild", out var value) &&
+ bool.TryParse(value, out var isDesignTime) && isDesignTime;
+
+ ///
+ /// Gets whether the current build is a design-time build.
+ ///
+ public static bool IsDesignTimeBuild(this AnalyzerConfigOptions options) =>
+ options.TryGetValue("build_property.DesignTimeBuild", out var value) &&
+ bool.TryParse(value, out var isDesignTime) && isDesignTime;
+}
\ 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..b2d56f8
--- /dev/null
+++ b/src/SponsorLink/SponsorLink/DiagnosticsManager.cs
@@ -0,0 +1,302 @@
+//
+#nullable enable
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using Humanizer;
+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.Grace, CreateGrace([.. Sponsorables.Keys], Funding.Product, Funding.Prefix) },
+ { SponsorStatus.User, CreateSponsor([.. Sponsorables.Keys], Funding.Prefix) },
+ { SponsorStatus.Contributor, CreateContributor([.. Sponsorables.Keys], Funding.Prefix, hidden: true) },
+ // NOTE: similar to contributor, we don't show OSS author membership in the IDE.
+ { SponsorStatus.OpenSource, CreateOpenSource([.. Sponsorables.Keys], Funding.Prefix) },
+ // NOTE: organization is a special case of sponsor, but we report it as hidden since the user isn't directly involved.
+ { SponsorStatus.Organization, CreateSponsor([.. Sponsorables.Keys], Funding.Prefix, hidden: true) },
+ // NOTE: similar to organization, we don't show team membership in the IDE.
+ { SponsorStatus.Team, CreateContributor([.. Sponsorables.Keys], Funding.Prefix, hidden: true) },
+ { 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());
+
+ ///
+ /// 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.
+ ///
+ /// The SponsorLinkAnalyzer.GetOrSetStatus 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.
+ ///
+ public SponsorStatus? GetStatus()
+ => Diagnostics.TryGetValue(Funding.Product, out var diagnostic) ? GetStatus(diagnostic) : null;
+
+ ///
+ /// Gets the status of the , or sets it from
+ /// the given set of if not already set.
+ ///
+ public SponsorStatus GetOrSetStatus(ImmutableArray manifests, AnalyzerConfigOptionsProvider options)
+ => GetOrSetStatus(() => manifests, () => options.GlobalOptions);
+
+ ///
+ /// Gets the status of the , or sets it from
+ /// the given set of if not already set.
+ ///
+ public SponsorStatus GetOrSetStatus(StatusOptions options)
+ => GetOrSetStatus(() => options.AdditionalFiles, () => options.GlobalOptions);
+
+ ///
+ /// Gets the status of the , or sets it from
+ /// the given analyzer if not already set.
+ ///
+ public SponsorStatus GetOrSetStatus(Func options)
+ => GetOrSetStatus(() => options().GetSponsorAdditionalFiles(), () => options()?.AnalyzerConfigOptionsProvider.GlobalOptions);
+
+ ///
+ /// Attemps to get the 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? TryGet(string product = Funding.Product)
+ {
+ // Don't pop grace diagnostics, as we report them more than once.
+ if (GetStatus() == SponsorStatus.Grace && Diagnostics.TryGetValue(product, out var grace))
+ return grace;
+
+ if (Diagnostics.TryRemove(product, out var diagnostic) &&
+ GetStatus(diagnostic) != SponsorStatus.Grace)
+ {
+ return diagnostic;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Pushes a diagnostic for the given product.
+ ///
+ SponsorStatus Push(Diagnostic diagnostic, SponsorStatus status, string product = Funding.Product)
+ {
+ // We only expect to get one warning per sponsorable+product
+ // combination, and first one to set wins.
+ Diagnostics.TryAdd(product, diagnostic);
+ return status;
+ }
+
+ SponsorStatus GetOrSetStatus(Func> getAdditionalFiles, Func getGlobalOptions)
+ {
+ if (GetStatus() is { } status)
+ return status;
+
+ if (!SponsorLink.TryRead(out var claims, getAdditionalFiles().Where(x => x.Path.EndsWith(".jwt")).Select(text =>
+ (text.GetText()?.ToString() ?? "", Sponsorables[Path.GetFileNameWithoutExtension(text.Path)]))) ||
+ claims.GetExpiration() is not DateTime exp)
+ {
+ var noGrace = getGlobalOptions() is { } globalOptions &&
+ globalOptions.TryGetValue("build_property.SponsorLinkNoInstallGrace", out var value) &&
+ bool.TryParse(value, out var skipCheck) && skipCheck;
+
+ if (noGrace != true)
+ {
+ // Consider grace period if we can find the install time.
+ var installed = getAdditionalFiles()
+ .Where(x => x.Path.EndsWith(".dll"))
+ .Select(x => File.GetLastWriteTime(x.Path))
+ .OrderByDescending(x => x)
+ .FirstOrDefault();
+
+ if (installed != default && ((DateTime.Now - installed).TotalDays <= Funding.Grace))
+ {
+ // get days until grace expiration
+ var days = Math.Abs((int)(installed.Date.AddDays(Funding.Grace) - DateTime.Now.Date).TotalDays);
+ // report unknown, either unparsed manifest or one with no expiration (which we never emit).
+ return Push(Diagnostic.Create(KnownDescriptors[SponsorStatus.Grace], null,
+ effectiveSeverity: DiagnosticSeverity.Info,
+ additionalLocations: null,
+ properties: ImmutableDictionary.Create()
+ .Add(nameof(SponsorStatus), nameof(SponsorStatus.Grace))
+ .Add(nameof(SponsorStatus.Grace), days.ToString()),
+ days, Funding.Product, Sponsorables.Keys.Humanize(Resources.Or)),
+ SponsorStatus.Grace);
+ }
+ }
+
+ // report unknown, either unparsed manifest or one with no expiration (which we never emit).
+ return Push(Diagnostic.Create(KnownDescriptors[SponsorStatus.Unknown], null,
+ properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Unknown)),
+ Funding.Product, Sponsorables.Keys.Select(x => "@" + x).Humanize(Resources.Or)),
+ SponsorStatus.Unknown);
+ }
+ else if (exp < DateTime.Now)
+ {
+ var days = Math.Abs((int)(exp.AddDays(Funding.Grace) - DateTime.Now).TotalDays);
+ // report expired or expiring soon if still within the configured days of grace period
+ if (exp.AddDays(Funding.Grace) < DateTime.Now)
+ {
+ // report expiring soon
+ return Push(Diagnostic.Create(KnownDescriptors[SponsorStatus.Expiring], null,
+ properties: ImmutableDictionary.Create()
+ .Add(nameof(SponsorStatus), nameof(SponsorStatus.Expiring))
+ .Add(nameof(SponsorStatus.Expiring), days.ToString())),
+ SponsorStatus.Expiring);
+ }
+ else
+ {
+ // report expired
+ return Push(Diagnostic.Create(KnownDescriptors[SponsorStatus.Expired], null,
+ properties: ImmutableDictionary.Create()
+ .Add(nameof(SponsorStatus), nameof(SponsorStatus.Expired))
+ // add how many days ago expiration happened
+ .Add(nameof(SponsorStatus.Expired), days.ToString())),
+ SponsorStatus.Expired);
+ }
+ }
+ else
+ {
+ status =
+ claims.IsInRole("team") ?
+ SponsorStatus.Team :
+ claims.IsInRole("user") ?
+ SponsorStatus.User :
+ claims.IsInRole("contrib") ?
+ SponsorStatus.Contributor :
+ claims.IsInRole("org") ?
+ SponsorStatus.Organization :
+ claims.IsInRole("oss") ?
+ SponsorStatus.OpenSource :
+ SponsorStatus.Unknown;
+
+ if (KnownDescriptors.TryGetValue(status, out var descriptor))
+ return Push(Diagnostic.Create(descriptor, null,
+ properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), status.ToString()),
+ Funding.Product), status);
+
+ return status;
+ }
+ }
+
+ SponsorStatus? GetStatus(Diagnostic? diagnostic) => diagnostic?.Properties.TryGetValue(nameof(SponsorStatus), out var value) == true
+ ? value switch
+ {
+ nameof(SponsorStatus.Grace) => SponsorStatus.Grace,
+ nameof(SponsorStatus.Unknown) => SponsorStatus.Unknown,
+ nameof(SponsorStatus.User) => SponsorStatus.User,
+ nameof(SponsorStatus.Expiring) => SponsorStatus.Expiring,
+ nameof(SponsorStatus.Expired) => SponsorStatus.Expired,
+ _ => null,
+ }
+ : null;
+
+ internal static DiagnosticDescriptor CreateUnknown(string[] sponsorable, string product, string prefix) => new(
+ $"{prefix}100",
+ Resources.Unknown_Title,
+ Resources.Unknown_Message,
+ "SponsorLink",
+ DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: string.Format(CultureInfo.CurrentCulture, Resources.Unknown_Description,
+ string.Join(", ", sponsorable.Select(x => $"https://github.com/sponsors/{x}")),
+ string.Join(" ", sponsorable.Select(x => "@" + x))),
+ helpLinkUri: Funding.HelpUrl,
+ WellKnownDiagnosticTags.NotConfigurable, "CompilationEnd");
+
+ internal static DiagnosticDescriptor CreateGrace(string[] sponsorable, string product, string prefix) => new(
+ $"{prefix}101",
+ Resources.Grace_Title,
+ Resources.Grace_Message,
+ "SponsorLink",
+ DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: string.Format(CultureInfo.CurrentCulture, Resources.Grace_Description,
+ string.Join(", ", sponsorable.Select(x => $"https://github.com/sponsors/{x}")),
+ string.Join(" ", sponsorable.Select(x => "@" + x))),
+ helpLinkUri: Funding.HelpUrl,
+ WellKnownDiagnosticTags.NotConfigurable);
+
+ internal static DiagnosticDescriptor CreateExpiring(string[] sponsorable, string prefix) => new(
+ $"{prefix}102",
+ 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, "CompilationEnd");
+
+ internal static DiagnosticDescriptor CreateExpired(string[] sponsorable, string prefix) => new(
+ $"{prefix}103",
+ 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, "CompilationEnd");
+
+ internal static DiagnosticDescriptor CreateSponsor(string[] sponsorable, string prefix, bool hidden = false) => new(
+ $"{prefix}110",
+ Resources.Sponsor_Title,
+ Resources.Sponsor_Message,
+ "SponsorLink",
+ hidden ? DiagnosticSeverity.Hidden : DiagnosticSeverity.Info,
+ isEnabledByDefault: true,
+ description: Resources.Sponsor_Description,
+ helpLinkUri: Funding.HelpUrl,
+ "DoesNotSupportF1Help", "CompilationEnd");
+
+ internal static DiagnosticDescriptor CreateContributor(string[] sponsorable, string prefix, bool hidden = false) => new(
+ $"{prefix}111",
+ Resources.Contributor_Title,
+ Resources.Contributor_Message,
+ "SponsorLink",
+ hidden ? DiagnosticSeverity.Hidden : DiagnosticSeverity.Info,
+ isEnabledByDefault: true,
+ description: Resources.Contributor_Description,
+ helpLinkUri: Funding.HelpUrl,
+ "DoesNotSupportF1Help", "CompilationEnd");
+
+ internal static DiagnosticDescriptor CreateOpenSource(string[] sponsorable, string prefix, bool hidden = false) => new(
+ $"{prefix}112",
+ Resources.OpenSource_Title,
+ Resources.OpenSource_Message,
+ "SponsorLink",
+ hidden ? DiagnosticSeverity.Hidden : DiagnosticSeverity.Info,
+ isEnabledByDefault: true,
+ description: Resources.OpenSource_Description,
+ helpLinkUri: Funding.HelpUrl,
+ "DoesNotSupportF1Help", "CompilationEnd");
+}
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-AR.resx b/src/SponsorLink/SponsorLink/Resources.es-AR.resx
new file mode 100644
index 0000000..094761f
--- /dev/null
+++ b/src/SponsorLink/SponsorLink/Resources.es-AR.resx
@@ -0,0 +1,214 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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 dependés asegura que se mantengan activos, y que recibas el apoyo que necesitás. También es muy económico y está disponible en todo el mundo!
+Por favor considerá apoyar el proyecto patrocinando en {0} y ejecutando posteriormente 'sponsor sync {1}'.
+
+
+ Por favor considerá apoyar {0} patrocinando {1} 🙏
+
+
+ Estado de patrocinio desconocido
+
+
+ Funcionalidades exclusivas para patrocinadores pueden no estar disponibles. Ejecutá '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
+
+
+ Sos un verdadero héroe. Tu patrocinio ayuda a mantener el proyecto vivo y próspero 🙏.
+
+
+ Gracias por apoyar a {0} con tu patrocinio 💟!
+
+
+ Sos un patrocinador del proyecto, sos lo máximo 💟!
+
+
+ El estado de patrocino ha expirado y estás en un período de gracia. Ejecutá 'sponsor sync {0}' y, opcionalmente, habilitá 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
+
+
+ Gracias por ser parte del equipo por tu contribución 🙏.
+
+
+ Gracias por ser parte del equipo {0} con tu contribución 💟!
+
+
+ Sos un contribuidor al proyecto, sos groso 💟!
+
+
+ Patrocinar los proyectos en que dependés asegura que se mantengan activos, y que recibas el apoyo que necesitás. También es muy económico y está disponible en todo el mundo!
+Por favor considerá apoyar el proyecto patrocinando en {0} y ejecutando posteriormente 'sponsor sync {1}'.
+
+
+ El período de prueba finaliza en {0} día(s). Disfrutá y por favor considerá apoyar {1} patrocinando {2} 🙏
+
+
+ Estado de patrocinio desconocido, período de prueba
+
+
+ /// <remarks>
+/// GitHub Sponsors es una excelente manera de apoyar proyectos de código abierto, y está disponible en la mayor parte del mundo.
+/// Sos considerado sponsor si:
+/// - Bancás directamente a Devlooped 💪.
+/// - Contribuiste con código a cualquiera de los proyectos de Devlooped.
+/// - Pertenecés a una organización de GitHub que está patrocinando a Devlooped.
+/// - Contribuiste a nugets activos y populares que son open-source.
+///
+/// En este caso, simplemente ejecutá:
+/// > dotnet tool install -g dotnet-sponsor; sponsor sync devlooped
+///
+/// Y el atributo [Obsolete] vuela de una. Gracias totales! 🙏
+/// </remarks>
+
+
+ /// <remarks>
+/// Esta API requiere patrocinio. Tu período de yapa termina en {0} día(s).
+///
+/// GitHub Sponsors es una excelente manera de apoyar proyectos de código abierto, y está disponible en la mayor parte del mundo.
+/// Sos considerado sponsor si:
+/// - Bancás directamente a Devlooped 💪.
+/// - Contribuiste con código a cualquiera de los proyectos de Devlooped.
+/// - Pertenecés a una organización de GitHub que está patrocinando a Devlooped.
+/// - Contribuiste a nugets activos y populares que son open-source.
+///
+/// En este caso, simplemente ejecutá:
+/// > dotnet tool install -g dotnet-sponsor; sponsor sync devlooped
+///
+/// ¡Gracias totales! 🙏
+/// </remarks>
+
+
\ No newline at end of file
diff --git a/src/SponsorLink/SponsorLink/Resources.es.resx b/src/SponsorLink/SponsorLink/Resources.es.resx
new file mode 100644
index 0000000..b9ff562
--- /dev/null
+++ b/src/SponsorLink/SponsorLink/Resources.es.resx
@@ -0,0 +1,224 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
+ Gracias por ser parte del equipo por tu contribución 🙏.
+
+
+ Gracias por ser parte del equipo {0} con tu contribución 💟!
+
+
+ Eres un contribuidor al proyecto, eres lo máximo 💟!
+
+
+ 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}'.
+
+
+ El período de prueba finaliza en {0} día(s). Disfrute y por favor considere apoyar {1} patrocinando {2} 🙏
+
+
+ Estado de patrocinio desconocido, período de prueba
+
+
+ /// <remarks>
+/// GitHub Sponsors es una excelente manera de apoyar proyectos de código abierto, y está disponible en la mayor parte del mundo.
+/// Se te considera un patrocinador si:
+/// - Estás patrocinando directamente a Devlooped.
+/// - Has contribuido con código a cualquiera de los proyectos de Devlooped.
+/// - Perteneces a una organización de GitHub que está patrocinando a Devlooped.
+/// - Has contribuido a nugets activos y populares que son de código abierto.
+///
+/// Si es así, simplemente ejecuta:
+/// > dotnet tool install -g dotnet-sponsor; sponsor sync devlooped
+///
+/// Posteriormente, el atributo [Obsolete] será eliminado.
+/// ¡Gracias! 🙏
+/// </remarks>
+
+
+ /// <remarks>
+/// Esta API requiere patrocinio. Su período de gracia termina en {0} día(s).
+///
+/// GitHub Sponsors es una excelente manera de apoyar proyectos de código abierto, y está disponible en la mayor parte del mundo.
+/// Se te considera un patrocinador si:
+/// - Estás patrocinando directamente a Devlooped.
+/// - Has contribuido con código a cualquiera de los proyectos de Devlooped.
+/// - Perteneces a una organización de GitHub que está patrocinando a Devlooped.
+/// - Has contribuido a packetes en nuget.org activos y populares que son de código abierto
+///
+/// Si es así, simplemente ejecuta:
+/// > dotnet tool install -g dotnet-sponsor; sponsor sync devlooped
+///
+/// ¡Gracias! 🙏
+/// </remarks>
+
+
+ Gracias por ser parte de la comunidad de código abierto con tus contribuciones 🙏.
+
+
+ Gracias por ser autor de código abierto 💟!
+
+
+ Sos un autor de código abierto, eres lo máximo 💟!
+
+
\ 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..7b1dfa9
--- /dev/null
+++ b/src/SponsorLink/SponsorLink/Resources.resx
@@ -0,0 +1,228 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
+ Thanks for being part of the team with your contributions 🙏.
+
+
+ Thank you for being part of team {0} with your contributions 💟!
+
+
+ You are a contributor to the project, you rock 💟!
+
+
+ Editor usage of {0} requires an active sponsorship. Learn more at {1}.
+
+
+ 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.
+
+
+ Grace period ends in {0} days. Enjoy and please consider supporting {1} by sponsoring {2} 🙏
+
+
+ Unknown sponsor status, grace period
+
+
+ /// <remarks>
+/// GitHub Sponsors is a great way to support open source projects, and it's available throughout most of the world.
+/// You are considered a sponsor if:
+/// - You are directly sponsoring Devlooped
+/// - You contributed code to any of Devlooped's projects.
+/// - You belong to a GitHub organization that is sponsoring Devlooped.
+/// - You contributed to active and popular nuget packages that are OSS.
+///
+/// If so, just run:
+/// > dotnet tool install -g dotnet-sponsor; sponsor sync devlooped
+///
+/// Subsequently, the [Obsolete] attribute will be removed.
+/// Thanks! 🙏
+/// </remarks>
+
+
+ /// <remarks>
+/// This is a sponsored API. Your grace period will expire in {0} day(s).
+///
+/// GitHub Sponsors is a great way to support open source projects, and it's available throughout most of the world.
+/// You are considered a sponsor if:
+/// - You are directly sponsoring Devlooped
+/// - You contributed code to any of Devlooped's projects.
+/// - You belong to a GitHub organization that is sponsoring Devlooped.
+/// - You contributed to active and popular nuget packages that are OSS.
+///
+/// If so, just run:
+/// > dotnet tool install -g dotnet-sponsor; sponsor sync devlooped
+///
+/// Thanks! 🙏
+/// </remarks>
+
+
+ Thanks for being part of the open source community with your contributions 🙏.
+
+
+ Thank you for being an open source author 💟!
+
+
+ You are a an open source author, you rock 💟!
+
+
\ 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..eec50c8
--- /dev/null
+++ b/src/SponsorLink/SponsorLink/SponsorLink.cs
@@ -0,0 +1,246 @@
+//
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Diagnostics;
+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 record StatusOptions(ImmutableArray AdditionalFiles, AnalyzerConfigOptions GlobalOptions);
+
+ ///
+ /// Statically cached dictionary of sponsorable accounts and their public key (in JWK format),
+ /// retrieved from assembly metadata attributes starting with "Funding.GitHub.".
+ ///
+ 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;
+
+ ///
+ /// A unique session ID associated with the current IDE or process running the analyzer.
+ ///
+ public static string SessionId =>
+ IsVisualStudio ? Environment.GetEnvironmentVariable("ServiceHubLogSessionKey") :
+ IsRider ? Environment.GetEnvironmentVariable("RESHARPER_FUS_SESSION") :
+ Process.GetCurrentProcess().Id.ToString();
+
+ ///
+ /// 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 necessary additional files to determine status.
+ ///
+ public static ImmutableArray GetSponsorAdditionalFiles(this AnalyzerOptions? options)
+ => options == null ? ImmutableArray.Create() : options.AdditionalFiles
+ .Where(x => x.IsSponsorManifest(options.AnalyzerConfigOptionsProvider) || x.IsSponsorableAnalyzer(options.AnalyzerConfigOptionsProvider))
+ .ToImmutableArray();
+
+ ///
+ /// Gets all sponsor manifests from the provided analyzer options.
+ ///
+ public static IncrementalValueProvider> GetSponsorAdditionalFiles(this IncrementalGeneratorInitializationContext context)
+ => context.AdditionalTextsProvider.Combine(context.AnalyzerConfigOptionsProvider)
+ .Where(source =>
+ {
+ var (text, provider) = source;
+ return text.IsSponsorManifest(provider) || text.IsSponsorableAnalyzer(provider);
+ })
+ .Select((source, c) => source.Left)
+ .Collect();
+
+ ///
+ /// Gets the status options for use within an incremental generator, to avoid depending on
+ /// analyzer runs. Used in combination with .
+ ///
+ public static IncrementalValueProvider GetStatusOptions(this IncrementalGeneratorInitializationContext context)
+ => context.GetSponsorAdditionalFiles().Combine(context.AnalyzerConfigOptionsProvider)
+ .Select((source, _) => new StatusOptions(source.Left, source.Right.GlobalOptions));
+
+ ///
+ /// Gets the status options for use within a source generator, to avoid depending on
+ /// analyzer runs. Used in combination with .
+ ///
+ public static StatusOptions GetStatusOptions(this GeneratorExecutionContext context)
+ => new StatusOptions(
+ context.AdditionalFiles.Where(x => x.IsSponsorManifest(context.AnalyzerConfigOptions) || x.IsSponsorableAnalyzer(context.AnalyzerConfigOptions)).ToImmutableArray(),
+ context.AnalyzerConfigOptions.GlobalOptions);
+
+ static bool IsSponsorManifest(this AdditionalText text, AnalyzerConfigOptionsProvider provider)
+ => provider.GetOptions(text).TryGetValue("build_metadata.SponsorManifest.ItemType", out var itemType) &&
+ itemType == "SponsorManifest" &&
+ Sponsorables.ContainsKey(Path.GetFileNameWithoutExtension(text.Path));
+
+ static bool IsSponsorableAnalyzer(this AdditionalText text, AnalyzerConfigOptionsProvider provider)
+ => provider.GetOptions(text) is { } options &&
+ options.TryGetValue("build_metadata.Analyzer.ItemType", out var itemType) &&
+ options.TryGetValue("build_metadata.Analyzer.NuGetPackageId", out var packageId) &&
+ itemType == "Analyzer" &&
+ Funding.PackageIds.Contains(packageId);
+
+ ///
+ /// 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..cf62d1b
--- /dev/null
+++ b/src/SponsorLink/SponsorLink/SponsorLink.csproj
@@ -0,0 +1,100 @@
+
+
+
+ netstandard2.0
+ SponsorLink
+ disable
+ false
+ CoreResGen;$(CoreCompileDependsOn)
+ SponsorLink
+
+
+
+
+ $(Product)
+ $(PackageId)
+
+ $([System.Text.RegularExpressions.Regex]::Replace("$(FundingProduct)", "[^A-Z]", ""))
+
+ 21
+
+ https://github.com/devlooped#sponsorlink
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $(FundingProduct)
+
+
+
+
+
+
+ <_FundingAnalyzerPackageId Include="@(FundingAnalyzerPackageId -> '"%(Identity)"')" />
+
+
+ <_FundingPackageIds>@(_FundingAnalyzerPackageId, ',')
+
+
+
+ using System.Collections.Generic%3B
+
+namespace Devlooped.Sponsors%3B
+
+partial class SponsorLink
+{
+ public partial class Funding
+ {
+ public static HashSet<string> PackageIds { get%3B } = [$(_FundingPackageIds)]%3B
+ public const string Product = "$(FundingProduct)"%3B
+ public const string Prefix = "$(FundingPrefix)"%3B
+ public const string HelpUrl = "$(FundingHelpUrl)"%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..9caa9a2
--- /dev/null
+++ b/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs
@@ -0,0 +1,71 @@
+//
+#nullable enable
+using System.Collections.Immutable;
+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)
+ {
+ // 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.
+ ctx.RegisterCompilationEndAction(ctx =>
+ {
+ // We'd never report Info/hero link if users opted out of it.
+ if (status.IsSponsor() &&
+ ctx.Options.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue("build_property.SponsorLinkHero", out var slHero) &&
+ bool.TryParse(slHero, out var isHero) && isHero)
+ return;
+
+ // Only report if the package is directly referenced in the project for
+ // any of the funding packages we monitor (i.e. we could have one or more
+ // metapackages we also consider "direct references).
+ // See SL_CollectDependencies in buildTransitive\Devlooped.Sponsors.targets
+ foreach (var prop in Funding.PackageIds.Select(id => id.Replace('.', '_')))
+ {
+ if (ctx.Options.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue("build_property." + prop, out var package) &&
+ package?.Length > 0 &&
+ Diagnostics.TryGet() is { } diagnostic)
+ {
+ ctx.ReportDiagnostic(diagnostic);
+ break;
+ }
+ }
+ });
+ }
+ });
+#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..d0fe800
--- /dev/null
+++ b/src/SponsorLink/SponsorLink/SponsorStatus.cs
@@ -0,0 +1,57 @@
+//
+namespace Devlooped.Sponsors;
+
+public static class SponsorStatusExtensions
+{
+ ///
+ /// Whether represents a sponsor (directly or indirectly).
+ ///
+ public static bool IsSponsor(this SponsorStatus status)
+ => status == SponsorStatus.User ||
+ status == SponsorStatus.Team ||
+ status == SponsorStatus.Contributor ||
+ status == SponsorStatus.Organization;
+}
+
+///
+/// The determined sponsoring status.
+///
+public enum SponsorStatus
+{
+ ///
+ /// Sponsorship status is unknown.
+ ///
+ Unknown,
+ ///
+ /// Sponsorship status is unknown, but within the grace period.
+ ///
+ Grace,
+ ///
+ /// 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 personally sponsoring.
+ ///
+ User,
+ ///
+ /// The user is a team member.
+ ///
+ Team,
+ ///
+ /// The user is a contributor.
+ ///
+ Contributor,
+ ///
+ /// The user is a member of a contributing organization.
+ ///
+ Organization,
+ ///
+ /// The user is a OSS author.
+ ///
+ OpenSource,
+}
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..0bc5a45
--- /dev/null
+++ b/src/SponsorLink/SponsorLink/buildTransitive/Devlooped.Sponsors.targets
@@ -0,0 +1,126 @@
+
+
+
+
+ $([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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $([MSBuild]::ValueOrDefault('%(_RestoreGraphEntry.Id)', '').Replace('.', '_'))
+
+
+
+
+
+
+
+
+
+
+ %(FundingPackageId.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/AnalyzerTests.cs b/src/SponsorLink/Tests/AnalyzerTests.cs
new file mode 100644
index 0000000..4424b14
--- /dev/null
+++ b/src/SponsorLink/Tests/AnalyzerTests.cs
@@ -0,0 +1,279 @@
+extern alias Analyzer;
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Data;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Security.Claims;
+using System.Text;
+using System.Threading.Tasks;
+using Analyzer::Devlooped.Sponsors;
+using Devlooped.Sponsors;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Text;
+using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.Tokens;
+using Xunit;
+
+namespace Tests;
+
+public class AnalyzerTests : IDisposable
+{
+ static readonly SponsorableManifest sponsorable = new(
+ new Uri("https://sponsorlink.devlooped.com"),
+ [new Uri("https://github.com/sponsors/devlooped"), new Uri("https://github.com/sponsors/kzu")],
+ "a82350fb2bae407b3021",
+ new JsonWebKey(ThisAssembly.Resources.keys.kzu_key.Text));
+
+ public AnalyzerTests()
+ {
+ // Simulate being a VS IDE for analyzers to actually run.
+ if (Environment.GetEnvironmentVariable("VSAPPIDNAME") == null)
+ Environment.SetEnvironmentVariable("VSAPPIDNAME", "test");
+ }
+
+ void IDisposable.Dispose()
+ {
+ if (Environment.GetEnvironmentVariable("VSAPPIDNAME") == "test")
+ Environment.SetEnvironmentVariable("VSAPPIDNAME", null);
+ }
+
+ [Fact]
+ public async Task WhenNoAdditionalFiles_ThenReportsUnknown()
+ {
+ var compilation = CSharpCompilation.Create("test", [CSharpSyntaxTree.ParseText("//")])
+ .WithAnalyzers([new SponsorLinkAnalyzer()],
+ new AnalyzerOptions([], new TestAnalyzerConfigOptionsProvider(new())
+ {
+ // Force reporting without wait period
+ { "build_property.SponsorLinkNoInstallGrace", "true" },
+ // Simulate directly referenced package
+ { "build_property.SponsorableLib", "1.0.0" },
+ }));
+
+ var diagnostics = await compilation.GetAnalyzerDiagnosticsAsync();
+
+ Assert.NotEmpty(diagnostics);
+
+ var diagnostic = diagnostics.Single(x => x.Properties.TryGetValue(nameof(SponsorStatus), out var value));
+
+ Assert.True(diagnostic.Properties.TryGetValue(nameof(SponsorStatus), out var value));
+ var status = Enum.Parse(value);
+
+ Assert.Equal(SponsorStatus.Unknown, status);
+ }
+
+ [Fact]
+ public async Task WhenUnknownAndGrace_ThenDoesNotReport()
+ {
+ // simulate an analyzer file with the right metadata, which is recent and therefore
+ // within the grace period
+ var dll = Path.Combine(GetTempPath(), "FakeAnalyzer.dll");
+ File.WriteAllText(dll, "");
+
+ var compilation = CSharpCompilation.Create("test", [CSharpSyntaxTree.ParseText("//")])
+ .WithAnalyzers([new SponsorLinkAnalyzer()], new AnalyzerOptions([new AdditionalTextFile(dll)], new TestAnalyzerConfigOptionsProvider(new())
+ {
+ { "build_metadata.Analyzer.ItemType", "Analyzer" },
+ { "build_metadata.Analyzer.NuGetPackageId", "SponsorableLib" }
+ }));
+
+ var diagnostics = await compilation.GetAnalyzerDiagnosticsAsync();
+
+ Assert.Empty(diagnostics);
+ }
+
+ [Fact]
+ public async Task WhenUnknownAndNoGraceOption_ThenReportsUnknown()
+ {
+ // simulate an analyzer file with the right metadata, which is recent and therefore
+ // within the grace period
+ var dll = Path.Combine(GetTempPath(), "FakeAnalyzer.dll");
+ File.WriteAllText(dll, "");
+
+ var compilation = CSharpCompilation.Create("test", [CSharpSyntaxTree.ParseText("//")])
+ .WithAnalyzers([new SponsorLinkAnalyzer()], new AnalyzerOptions([new AdditionalTextFile(dll)], new TestAnalyzerConfigOptionsProvider(new())
+ {
+ { "build_property.SponsorLinkNoInstallGrace", "true" },
+ { "build_metadata.Analyzer.ItemType", "Analyzer" },
+ { "build_metadata.Analyzer.NuGetPackageId", "SponsorableLib" },
+ // Simulate directly referenced package
+ { "build_property.SponsorableLib", "1.0.0" },
+ }));
+
+ var diagnostics = await compilation.GetAnalyzerDiagnosticsAsync();
+
+ Assert.NotEmpty(diagnostics);
+
+ var diagnostic = diagnostics.Single(x => x.Properties.TryGetValue(nameof(SponsorStatus), out var value));
+
+ Assert.True(diagnostic.Properties.TryGetValue(nameof(SponsorStatus), out var value));
+ var status = Enum.Parse(value);
+
+ Assert.Equal(SponsorStatus.Unknown, status);
+ }
+
+ [Fact]
+ public async Task WhenUnknownAndGraceExpired_ThenReportsUnknown()
+ {
+ // simulate an analyzer file with the right metadata, which is recent and therefore
+ // within the grace period
+ var dll = Path.Combine(GetTempPath(), "FakeAnalyzer.dll");
+ File.WriteAllText(dll, "");
+ File.SetLastWriteTimeUtc(dll, DateTime.UtcNow - TimeSpan.FromDays(30));
+
+ var compilation = CSharpCompilation.Create("test", [CSharpSyntaxTree.ParseText("//")])
+ .WithAnalyzers([new SponsorLinkAnalyzer()], new AnalyzerOptions([new AdditionalTextFile(dll)], new TestAnalyzerConfigOptionsProvider(new())
+ {
+ { "build_metadata.Analyzer.ItemType", "Analyzer" },
+ { "build_metadata.Analyzer.NuGetPackageId", "SponsorableLib" },
+ // Simulate directly referenced package
+ { "build_property.SponsorableLib", "1.0.0" },
+ }));
+
+ var diagnostics = await compilation.GetAnalyzerDiagnosticsAsync();
+
+ Assert.NotEmpty(diagnostics);
+
+ var diagnostic = diagnostics.Single(x => x.Properties.TryGetValue(nameof(SponsorStatus), out var value));
+
+ Assert.True(diagnostic.Properties.TryGetValue(nameof(SponsorStatus), out var value));
+ var status = Enum.Parse(value);
+
+ Assert.Equal(SponsorStatus.Unknown, status);
+ }
+
+ [Theory]
+ [InlineData("user", SponsorStatus.User)]
+ [InlineData("org", SponsorStatus.Organization)]
+ [InlineData("contrib", SponsorStatus.Contributor)]
+ [InlineData("team", SponsorStatus.Team)]
+ // team trumps everything (since team members will typically also be contributors)
+ [InlineData("user,contrib,team", SponsorStatus.Team)]
+ // user trumps others
+ [InlineData("user,org,contrib", SponsorStatus.User)]
+ // contrib trumps org
+ [InlineData("org,contrib", SponsorStatus.Contributor)]
+ // team trumps contrib (since team members will typically also be contributors
+ [InlineData("contrib,team", SponsorStatus.Team)]
+ [InlineData("contrib,oss", SponsorStatus.Contributor)]
+ [InlineData("user,oss", SponsorStatus.User)]
+ [InlineData("org,oss", SponsorStatus.Organization)]
+ [InlineData("oss", SponsorStatus.OpenSource)]
+ public async Task WhenSponsoringRole_ThenEnsureStatus(string roles, SponsorStatus status)
+ {
+ var sponsor = sponsorable.Sign(roles.Split(',').Select(x => new Claim("roles", x)), expiration: TimeSpan.FromMinutes(5));
+ var jwt = Path.Combine(GetTempPath(), "kzu.jwt");
+ File.WriteAllText(jwt, sponsor, Encoding.UTF8);
+
+ var compilation = CSharpCompilation.Create("test", [CSharpSyntaxTree.ParseText("//")])
+ .WithAnalyzers([new SponsorLinkAnalyzer()], new AnalyzerOptions([new AdditionalTextFile(jwt)], new TestAnalyzerConfigOptionsProvider(new())
+ {
+ { "build_metadata.SponsorManifest.ItemType", "SponsorManifest" },
+ // Simulate directly referenced package
+ { "build_property.SponsorableLib", "1.0.0" },
+ }));
+
+ var diagnostics = await compilation.GetAnalyzerDiagnosticsAsync();
+
+ Assert.NotEmpty(diagnostics);
+
+ var diagnostic = diagnostics.Single(x => x.Properties.TryGetValue(nameof(SponsorStatus), out var value));
+
+ Assert.True(diagnostic.Properties.TryGetValue(nameof(SponsorStatus), out var value));
+ var actual = Enum.Parse(value);
+
+ Assert.Equal(status, actual);
+ }
+
+ [Fact]
+ public async Task WhenMultipleAnalyzers_ThenReportsOnce()
+ {
+ var sponsor = sponsorable.Sign([new("roles", "user")], expiration: TimeSpan.FromMinutes(5));
+ var jwt = Path.Combine(GetTempPath(), "kzu.jwt");
+ File.WriteAllText(jwt, sponsor, Encoding.UTF8);
+
+ var compilation = CSharpCompilation.Create("test", [CSharpSyntaxTree.ParseText("//")])
+ .WithAnalyzers([new SponsorLinkAnalyzer(), new SponsorLinkAnalyzer()],
+ new AnalyzerOptions([new AdditionalTextFile(jwt)], new TestAnalyzerConfigOptionsProvider(new())
+ {
+ // Force reporting without wait period
+ { "build_property.SponsorLinkNoInstallGrace", "true" },
+ // Simulate directly referenced package
+ { "build_property.SponsorableLib", "1.0.0" },
+ { "build_property.SponsorLink", "1.0.0" },
+ { "build_metadata.SponsorManifest.ItemType", "SponsorManifest" }
+ }));
+
+ var diagnostics = (await compilation.GetAnalyzerDiagnosticsAsync())
+ .Where(x => x.Properties.TryGetValue(nameof(SponsorStatus), out var _));
+
+ Assert.NotEmpty(diagnostics);
+ Assert.Single(diagnostics.Where(x => x.Properties.TryGetValue(nameof(SponsorStatus), out var value)));
+ }
+
+ [Fact]
+ public async Task WhenAnalyzerNotDirectlyReferenced_ThenDoesNotReport()
+ {
+ var compilation = CSharpCompilation.Create("test", [CSharpSyntaxTree.ParseText("//")])
+ .WithAnalyzers([new SponsorLinkAnalyzer()],
+ new AnalyzerOptions([], new TestAnalyzerConfigOptionsProvider(new())
+ {
+ // Force reporting if necessary without wait period
+ { "build_property.SponsorLinkNoInstallGrace", "true" },
+ // Directly referenced package would result in a compiler visible property like:
+ //{ "build_property.SponsorableLib", "1.0.0" },
+ }));
+
+ var diagnostics = await compilation.GetAnalyzerDiagnosticsAsync();
+
+ Assert.Empty(diagnostics);
+ }
+
+ string GetTempPath([CallerMemberName] string? test = default)
+ {
+ var path = Path.Combine(Path.GetTempPath(), test ?? nameof(AnalyzerTests));
+ Directory.CreateDirectory(path);
+ return path;
+ }
+
+ class AdditionalTextFile(string path) : AdditionalText
+ {
+ public override string Path => path;
+ public override SourceText GetText(CancellationToken cancellationToken) => SourceText.From(File.ReadAllText(Path), Encoding.UTF8);
+ }
+
+ class TestAnalyzerConfigOptionsProvider(Dictionary options) : AnalyzerConfigOptionsProvider, IDictionary
+ {
+ AnalyzerConfigOptions analyzerOptions = new TestAnalyzerConfigOptions(options);
+ public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => analyzerOptions;
+
+ public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => analyzerOptions;
+ public void Add(string key, string value) => options.Add(key, value);
+ public bool ContainsKey(string key) => options.ContainsKey(key);
+ public bool Remove(string key) => options.Remove(key);
+ public bool TryGetValue(string key, [MaybeNullWhen(false)] out string value) => options.TryGetValue(key, out value);
+ public void Add(KeyValuePair item) => ((ICollection>)options).Add(item);
+ public void Clear() => ((ICollection>)options).Clear();
+ public bool Contains(KeyValuePair item) => ((ICollection>)options).Contains(item);
+ public void CopyTo(KeyValuePair[] array, int arrayIndex) => ((ICollection>)options).CopyTo(array, arrayIndex);
+ public bool Remove(KeyValuePair item) => ((ICollection>)options).Remove(item);
+ public IEnumerator> GetEnumerator() => ((IEnumerable>)options).GetEnumerator();
+ IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)options).GetEnumerator();
+ public override AnalyzerConfigOptions GlobalOptions => analyzerOptions;
+ public ICollection Keys => options.Keys;
+ public ICollection Values => options.Values;
+ public int Count => ((ICollection>)options).Count;
+ public bool IsReadOnly => ((ICollection>)options).IsReadOnly;
+ public string this[string key] { get => options[key]; set => options[key] = value; }
+
+ class TestAnalyzerConfigOptions(Dictionary options) : AnalyzerConfigOptions
+ {
+ public override bool TryGetValue(string key, out string value) => options.TryGetValue(key, out value);
+ }
+ }
+}
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.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..8ef1ae8
--- /dev/null
+++ b/src/SponsorLink/Tests/Sample.cs
@@ -0,0 +1,78 @@
+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.User)]
+ [InlineData("es-AR", SponsorStatus.Contributor)]
+ [InlineData("es", SponsorStatus.Unknown)]
+ [InlineData("es", SponsorStatus.Expiring)]
+ [InlineData("es", SponsorStatus.Expired)]
+ [InlineData("es", SponsorStatus.User)]
+ [InlineData("es", SponsorStatus.Contributor)]
+ [InlineData("en", SponsorStatus.Unknown)]
+ [InlineData("en", SponsorStatus.Expiring)]
+ [InlineData("en", SponsorStatus.Expired)]
+ [InlineData("en", SponsorStatus.User)]
+ [InlineData("en", SponsorStatus.Contributor)]
+ [InlineData("", SponsorStatus.Unknown)]
+ [InlineData("", SponsorStatus.Expiring)]
+ [InlineData("", SponsorStatus.Expired)]
+ [InlineData("", SponsorStatus.User)]
+ [InlineData("", SponsorStatus.Contributor)]
+ 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.Expiring => DiagnosticsManager.CreateExpiring(sponsorable, prefix),
+ SponsorStatus.Expired => DiagnosticsManager.CreateExpired(sponsorable, prefix),
+ SponsorStatus.User => DiagnosticsManager.CreateSponsor(sponsorable, prefix),
+ SponsorStatus.Contributor => DiagnosticsManager.CreateContributor(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..907fc10
--- /dev/null
+++ b/src/SponsorLink/Tests/SponsorableManifest.cs
@@ -0,0 +1,357 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Security.Claims;
+using System.Security.Cryptography;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+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)
+ {
+ var key = new RsaSecurityKey(rsa);
+ if (key.PrivateKeyStatus != PrivateKeyStatus.Exists)
+ throw new NotSupportedException("No private key found or specified to sign the manifest.");
+
+ // 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 Sign(claims, key, expiration);
+ }
+
+ ///
+ /// Sign the JWT claims, optionally overriding the used for signing.
+ ///
+ public string Sign(IEnumerable claims, SecurityKey? key = default, TimeSpan? expiration = default)
+ {
+ var credentials = new SigningCredentials(key ?? SecurityKey, 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));
+ }
+
+ return new JsonWebTokenHandler
+ {
+ MapInboundClaims = false,
+ SetDefaultTimesOnTokenCreation = false,
+ }.CreateToken(new SecurityTokenDescriptor
+ {
+ Subject = new ClaimsIdentity(tokenClaims),
+ IssuedAt = DateTime.UtcNow,
+ Expires = expirationDate,
+ SigningCredentials = credentials,
+ });
+ }
+
+ 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 = 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..a56aa30
--- /dev/null
+++ b/src/SponsorLink/Tests/Tests.csproj
@@ -0,0 +1,75 @@
+
+
+
+ net8.0
+ true
+ CS8981;$(NoWarn)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %(GitRoot.FullPath)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/SponsorLink/Tests/keys/kzu.key b/src/SponsorLink/Tests/keys/kzu.key
new file mode 100644
index 0000000..cddc6c6
Binary files /dev/null and b/src/SponsorLink/Tests/keys/kzu.key differ
diff --git a/src/SponsorLink/Tests/keys/kzu.key.jwk b/src/SponsorLink/Tests/keys/kzu.key.jwk
new file mode 100644
index 0000000..3589e3d
--- /dev/null
+++ b/src/SponsorLink/Tests/keys/kzu.key.jwk
@@ -0,0 +1,11 @@
+{
+ "d": "OmDrKyd0ap7Az2V09C8E6aASK0nnXHGUdTIymmU1tGoxaWpkZ4gLGaYDp4L5fKc-AaqqD3PjvfJvEWfXLqJtEWfUl4gahWrgUkmuzPyAVFFioIzeGIvTGELsR6lTRke0IB2kvfvS7hRgX8Py8ohCAGHiidmoec2SyKkEg0aPdxrIKV8hx5ybC_D_4zRKn0GuVwATIeVZzPpTcyJX_sn4NHDOqut0Xg02iHhMKpF850BSoC97xGMlcSjLocFSTwI63msz6jWQ-6LRVXsfRr2mqakAvsPpqEQ3Ytk9Ud9xW0ctuAWyo6UXev5w2XEL8cSXm33-57fi3ekC_jGCqW0KfAU4Cr2UbuTC0cv8Vv0F4Xm5FizolmuSBFOvf55-eqsjmpwf9hftYAiIlFF-49-P0DpeJejSeoL06BE3e3_IVu3g3HNnSWVUOLJ5Uk5FQ-ieHhf-r2Tq5qZ8_-losHekQbCxCMY2isc-r6V6BMnVL_9kWPxpXwhjKrYxNFZEXUJ1",
+ "dp": "HjCs_QF1Hn1SGS2OqZzYhGhNk4PTw9Hs97E7g3pb4liY0uECYOYp1RNoMyzvNBZVwlxhpeTTS299yPoXeYmseXfLtcjfIVi6mSWS_u27Hd0zfSdaPDOXyyK-mZfIV7Q76RTost0QY3LA0ciJbj3gJqpl38dhuNQ8h9Yqt-TFyb3CUaM3A_JUNKOTce8qnkLrasytPEuSroOBT8bgCWJIjw_mXWMGcoqRFWHw9Nyp9mIyvtPjUQ9ig3bGSP_-3LZf",
+ "dq": "IP6EsAZ_6psFdlQrvnugYFs91fEP5QfBzNHmbfmsPRVX4QM5B3L6klQyJsLqvPfF1Xu17ZffLFkNBKuiphIcLPo0yZTJG9Y7S8gLuPAmrH-ndfxG-bQ8Yt0ZB1pA77ILIS8bUTKrMqAWS-VcaxcSCIyhSusLEWYYDi3PEzB375OUw4aXIk3ob8bePG7UqFSL6qmDPgkGLTxkY9m5dEiOshHygtVY-H_jjOIawliEPgmgAr2M-zlXiphovDyAT0PV",
+ "e": "AQAB",
+ "kty": "RSA",
+ "n": "yP71VgOgHDtGbxdyN31mIFFITmGYEk2cwepKbyqKTbTYXF1OXaMoP5n3mfwqwzUQmEAsrclAigPcK4GIy5WWlc5YujIxKauJjsKe0FBxMnFp9o1UcBUHfgDJjaAKieQxb44717b1MwCcflEGnCGTXkntdr45y9Gi1D9-oBw5zIVZekgMP55XxmKvkJd1k-bYWSv-QFG2JJwRIGwr29Jr62juCsLB7Tg83ZGKCa22Y_7lQcezxRRD5OrGWhf3gTYArbrEzbYy653zbHfbOCJeVBe_bXDkR74yG3mmq_Ne0qhNk6wXuX-NrKEvdPxRSRBF7C465fcVY9PM6eTqEPQwKDiarHpU1NTwUetzb-YKry-h678RJWMhC7I9lzzWVobbC0YVKG7XpeVqBB4u7Q6cGo5Xkf19VldkIxQMu9sFeuHGDSoiCLqmRmwNn9GsMV77oZWr-OPrxEdZzL9BcI4fMJMz7YdiIu-qbIp_vqatbalfNasumf8RgtPOkR2vgc59",
+ "p": "6JTf8Qb0iHRL6MIIs7MlkEKBNpnAu_Nie4HTqhxy2wfE4cBr6QZ98iJniXffDIjq_GxVpw9K-Bv2gTcNrlzOiBaLf3X2Itfice_Qd-luhNbnXVfiA5sg6dZ2wbBuue5ann5iJ_TIbxO4CLUiqQp0PCReUPzTQhzesHxM2-dBC9AYDl7P6p1FF53Hh_Knx9UywhoPvNtoCJy35-5rj0ghgPYz289dbOBccZnvabRueOr_wpHGMKaznqiDMrcFSZ07",
+ "q": "3TvrN8R9imw6E6JkVQ4PtveE0vkvkSWHUpn9KwKFIJJiwL_HSS4z_8IYR1_0Q1OgK5-z-QcXhq9P7jTjz02I2uwWhP3RZQf99RZACfMaeIs8O2V-I89WdlJYOerzAelW4nYw7zyeVoT5c5osicGWfSmWslLRjA1yx7x1KA_MCU_KIEBlpe1RgEUYPET3OtvPKFIVQYoJfQC5PFlmrC-kgHZMSpdHjWgWi5gPn0fIBCKFsXcPrt2n_lKKGc4lFOen",
+ "qi": "m-tgdFqO1Ax3C00oe7kdkYLHMD56wkGARdqPCqS5IGhFVKCOA8U6O_s5bSL4r0TzPE0KrJ4A5QJEwjbH4bXssPaaAlv1ZdWjn8YMQCYFolg_pgUWYYI5vNxG1gIsLGXPTfE8a6SObkJ2Q9VC5ZZp14r4lPvJhwFICIGSRBKcvS-gO_gqB3LKuG9TQBi-CE4DHDLJwsCbEBR8Ber45oTqvG7hphpOhBHsFZ8_6f3Reg_sK1BCz9HFCx8hhi8rBfUp"
+}
\ No newline at end of file
diff --git a/src/SponsorLink/Tests/keys/kzu.key.txt b/src/SponsorLink/Tests/keys/kzu.key.txt
new file mode 100644
index 0000000..5fe8758
--- /dev/null
+++ b/src/SponsorLink/Tests/keys/kzu.key.txt
@@ -0,0 +1 @@
+MIIG4wIBAAKCAYEAyP71VgOgHDtGbxdyN31mIFFITmGYEk2cwepKbyqKTbTYXF1OXaMoP5n3mfwqwzUQmEAsrclAigPcK4GIy5WWlc5YujIxKauJjsKe0FBxMnFp9o1UcBUHfgDJjaAKieQxb44717b1MwCcflEGnCGTXkntdr45y9Gi1D9+oBw5zIVZekgMP55XxmKvkJd1k+bYWSv+QFG2JJwRIGwr29Jr62juCsLB7Tg83ZGKCa22Y/7lQcezxRRD5OrGWhf3gTYArbrEzbYy653zbHfbOCJeVBe/bXDkR74yG3mmq/Ne0qhNk6wXuX+NrKEvdPxRSRBF7C465fcVY9PM6eTqEPQwKDiarHpU1NTwUetzb+YKry+h678RJWMhC7I9lzzWVobbC0YVKG7XpeVqBB4u7Q6cGo5Xkf19VldkIxQMu9sFeuHGDSoiCLqmRmwNn9GsMV77oZWr+OPrxEdZzL9BcI4fMJMz7YdiIu+qbIp/vqatbalfNasumf8RgtPOkR2vgc59AgMBAAECggGAOmDrKyd0ap7Az2V09C8E6aASK0nnXHGUdTIymmU1tGoxaWpkZ4gLGaYDp4L5fKc+AaqqD3PjvfJvEWfXLqJtEWfUl4gahWrgUkmuzPyAVFFioIzeGIvTGELsR6lTRke0IB2kvfvS7hRgX8Py8ohCAGHiidmoec2SyKkEg0aPdxrIKV8hx5ybC/D/4zRKn0GuVwATIeVZzPpTcyJX/sn4NHDOqut0Xg02iHhMKpF850BSoC97xGMlcSjLocFSTwI63msz6jWQ+6LRVXsfRr2mqakAvsPpqEQ3Ytk9Ud9xW0ctuAWyo6UXev5w2XEL8cSXm33+57fi3ekC/jGCqW0KfAU4Cr2UbuTC0cv8Vv0F4Xm5FizolmuSBFOvf55+eqsjmpwf9hftYAiIlFF+49+P0DpeJejSeoL06BE3e3/IVu3g3HNnSWVUOLJ5Uk5FQ+ieHhf+r2Tq5qZ8/+losHekQbCxCMY2isc+r6V6BMnVL/9kWPxpXwhjKrYxNFZEXUJ1AoHBAOiU3/EG9Ih0S+jCCLOzJZBCgTaZwLvzYnuB06occtsHxOHAa+kGffIiZ4l33wyI6vxsVacPSvgb9oE3Da5czogWi3919iLX4nHv0HfpboTW511X4gObIOnWdsGwbrnuWp5+Yif0yG8TuAi1IqkKdDwkXlD800Ic3rB8TNvnQQvQGA5ez+qdRRedx4fyp8fVMsIaD7zbaAict+fua49IIYD2M9vPXWzgXHGZ72m0bnjq/8KRxjCms56ogzK3BUmdOwKBwQDdO+s3xH2KbDoTomRVDg+294TS+S+RJYdSmf0rAoUgkmLAv8dJLjP/whhHX/RDU6Arn7P5BxeGr0/uNOPPTYja7BaE/dFlB/31FkAJ8xp4izw7ZX4jz1Z2Ulg56vMB6VbidjDvPJ5WhPlzmiyJwZZ9KZayUtGMDXLHvHUoD8wJT8ogQGWl7VGARRg8RPc6288oUhVBigl9ALk8WWasL6SAdkxKl0eNaBaLmA+fR8gEIoWxdw+u3af+UooZziUU56cCgcAeMKz9AXUefVIZLY6pnNiEaE2Tg9PD0ez3sTuDelviWJjS4QJg5inVE2gzLO80FlXCXGGl5NNLb33I+hd5iax5d8u1yN8hWLqZJZL+7bsd3TN9J1o8M5fLIr6Zl8hXtDvpFOiy3RBjcsDRyIluPeAmqmXfx2G41DyH1iq35MXJvcJRozcD8lQ0o5Nx7yqeQutqzK08S5Kug4FPxuAJYkiPD+ZdYwZyipEVYfD03Kn2YjK+0+NRD2KDdsZI//7ctl8CgcAg/oSwBn/qmwV2VCu+e6BgWz3V8Q/lB8HM0eZt+aw9FVfhAzkHcvqSVDImwuq898XVe7Xtl98sWQ0Eq6KmEhws+jTJlMkb1jtLyAu48Casf6d1/Eb5tDxi3RkHWkDvsgshLxtRMqsyoBZL5VxrFxIIjKFK6wsRZhgOLc8TMHfvk5TDhpciTehvxt48btSoVIvqqYM+CQYtPGRj2bl0SI6yEfKC1Vj4f+OM4hrCWIQ+CaACvYz7OVeKmGi8PIBPQ9UCgcEAm+tgdFqO1Ax3C00oe7kdkYLHMD56wkGARdqPCqS5IGhFVKCOA8U6O/s5bSL4r0TzPE0KrJ4A5QJEwjbH4bXssPaaAlv1ZdWjn8YMQCYFolg/pgUWYYI5vNxG1gIsLGXPTfE8a6SObkJ2Q9VC5ZZp14r4lPvJhwFICIGSRBKcvS+gO/gqB3LKuG9TQBi+CE4DHDLJwsCbEBR8Ber45oTqvG7hphpOhBHsFZ8/6f3Reg/sK1BCz9HFCx8hhi8rBfUp
\ No newline at end of file
diff --git a/src/SponsorLink/Tests/keys/kzu.pub b/src/SponsorLink/Tests/keys/kzu.pub
new file mode 100644
index 0000000..5594797
Binary files /dev/null and b/src/SponsorLink/Tests/keys/kzu.pub differ
diff --git a/src/SponsorLink/Tests/keys/kzu.pub.jwk b/src/SponsorLink/Tests/keys/kzu.pub.jwk
new file mode 100644
index 0000000..b4bfb31
--- /dev/null
+++ b/src/SponsorLink/Tests/keys/kzu.pub.jwk
@@ -0,0 +1,5 @@
+{
+ "e": "AQAB",
+ "kty": "RSA",
+ "n": "yP71VgOgHDtGbxdyN31mIFFITmGYEk2cwepKbyqKTbTYXF1OXaMoP5n3mfwqwzUQmEAsrclAigPcK4GIy5WWlc5YujIxKauJjsKe0FBxMnFp9o1UcBUHfgDJjaAKieQxb44717b1MwCcflEGnCGTXkntdr45y9Gi1D9-oBw5zIVZekgMP55XxmKvkJd1k-bYWSv-QFG2JJwRIGwr29Jr62juCsLB7Tg83ZGKCa22Y_7lQcezxRRD5OrGWhf3gTYArbrEzbYy653zbHfbOCJeVBe_bXDkR74yG3mmq_Ne0qhNk6wXuX-NrKEvdPxRSRBF7C465fcVY9PM6eTqEPQwKDiarHpU1NTwUetzb-YKry-h678RJWMhC7I9lzzWVobbC0YVKG7XpeVqBB4u7Q6cGo5Xkf19VldkIxQMu9sFeuHGDSoiCLqmRmwNn9GsMV77oZWr-OPrxEdZzL9BcI4fMJMz7YdiIu-qbIp_vqatbalfNasumf8RgtPOkR2vgc59"
+}
\ No newline at end of file
diff --git a/src/SponsorLink/Tests/keys/kzu.pub.txt b/src/SponsorLink/Tests/keys/kzu.pub.txt
new file mode 100644
index 0000000..729ecd5
--- /dev/null
+++ b/src/SponsorLink/Tests/keys/kzu.pub.txt
@@ -0,0 +1 @@
+MIIBigKCAYEAyP71VgOgHDtGbxdyN31mIFFITmGYEk2cwepKbyqKTbTYXF1OXaMoP5n3mfwqwzUQmEAsrclAigPcK4GIy5WWlc5YujIxKauJjsKe0FBxMnFp9o1UcBUHfgDJjaAKieQxb44717b1MwCcflEGnCGTXkntdr45y9Gi1D9+oBw5zIVZekgMP55XxmKvkJd1k+bYWSv+QFG2JJwRIGwr29Jr62juCsLB7Tg83ZGKCa22Y/7lQcezxRRD5OrGWhf3gTYArbrEzbYy653zbHfbOCJeVBe/bXDkR74yG3mmq/Ne0qhNk6wXuX+NrKEvdPxRSRBF7C465fcVY9PM6eTqEPQwKDiarHpU1NTwUetzb+YKry+h678RJWMhC7I9lzzWVobbC0YVKG7XpeVqBB4u7Q6cGo5Xkf19VldkIxQMu9sFeuHGDSoiCLqmRmwNn9GsMV77oZWr+OPrxEdZzL9BcI4fMJMz7YdiIu+qbIp/vqatbalfNasumf8RgtPOkR2vgc59AgMBAAE=
\ No newline at end of file
diff --git a/src/SponsorLink/Tests/keys/sponsorlink.jwt b/src/SponsorLink/Tests/keys/sponsorlink.jwt
new file mode 100644
index 0000000..b53fe62
--- /dev/null
+++ b/src/SponsorLink/Tests/keys/sponsorlink.jwt
@@ -0,0 +1 @@
+eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MTk4NjgyMzAsImlzcyI6Imh0dHBzOi8vc3BvbnNvcmxpbmsuZGV2bG9vcGVkLmNvbS8iLCJhdWQiOlsiaHR0cHM6Ly9naXRodWIuY29tL3Nwb25zb3JzL2t6dSIsImh0dHBzOi8vZ2l0aHViLmNvbS9zcG9uc29ycy9kZXZsb29wZWQiXSwiY2xpZW50X2lkIjoiYTgyMzUwZmIyYmFlNDA3YjMwMjEiLCJzdWJfandrIjp7ImUiOiJBUUFCIiwia3R5IjoiUlNBIiwibiI6InlQNzFWZ09nSER0R2J4ZHlOMzFtSUZGSVRtR1lFazJjd2VwS2J5cUtUYlRZWEYxT1hhTW9QNW4zbWZ3cXd6VVFtRUFzcmNsQWlnUGNLNEdJeTVXV2xjNVl1akl4S2F1SmpzS2UwRkJ4TW5GcDlvMVVjQlVIZmdESmphQUtpZVF4YjQ0NzE3YjFNd0NjZmxFR25DR1RYa250ZHI0NXk5R2kxRDktb0J3NXpJVlpla2dNUDU1WHhtS3ZrSmQxay1iWVdTdi1RRkcySkp3UklHd3IyOUpyNjJqdUNzTEI3VGc4M1pHS0NhMjJZXzdsUWNlenhSUkQ1T3JHV2hmM2dUWUFyYnJFemJZeTY1M3piSGZiT0NKZVZCZV9iWERrUjc0eUczbW1xX05lMHFoTms2d1h1WC1OcktFdmRQeFJTUkJGN0M0NjVmY1ZZOVBNNmVUcUVQUXdLRGlhckhwVTFOVHdVZXR6Yi1ZS3J5LWg2NzhSSldNaEM3STlsenpXVm9iYkMwWVZLRzdYcGVWcUJCNHU3UTZjR281WGtmMTlWbGRrSXhRTXU5c0ZldUhHRFNvaUNMcW1SbXdObjlHc01WNzdvWldyLU9QcnhFZFp6TDlCY0k0Zk1KTXo3WWRpSXUtcWJJcF92cWF0YmFsZk5hc3VtZjhSZ3RQT2tSMnZnYzU5In19.er4apYbEjHVKlQ_aMXoRhHYeR8N-3uIrCk3HX8UuZO7mb0CaS94-422EI3z5O9vRvckcGkNVoiSIX0ykZqUMHTZxBae-QZc1u_rhdBOChoaxWqpUiPXLZ5-yi7mcRwqg2DOUb2eHTNfRjwJ-0tjL1R1TqZw9d8Bgku1zw2ZTuJl_WsBRHKHTD_s5KyCP5yhSOUumrsf3nXYrc20fJ7ql0FsL0MP66utJk7TFYHGhQV3cfcXYqFEpv-k6tqB9k3Syc0UnepmQT0Y3dtcBzQzCOzfKQ8bdaAXVHjfp4VvXBluHmh9lP6TeZmpvlmQDFvyk0kp1diTbo9pqmX_llNDWNxBdvaSZGa7RZMG_dE2WJGtQNu0C_sbEZDPZsKncxdtm-j-6Y7GRqx7uxe4Py8tAZ7SxjiPgD64jf9KF2OT6f6drVtzohVzYCs6-vhcXzC2sQvd_gQ-SoFNTa1MEcMgGbL-fFWUC7-7bQV1DlSg2YFwrxEIwbM-gHpLZHyyJLvYD
\ 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..ca6d5e3
--- /dev/null
+++ b/src/SponsorLink/readme.md
@@ -0,0 +1,38 @@
+# SponsorLink .NET Analyzer Sample
+
+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 can include all the necessary files by using the [dotnet-file](https://github.com/devlooped/dotnet-file)
+tool and sync all files to a folder, such as:
+
+```shell
+dotnet file add https://github.com/devlooped/SponsorLink/tree/main/samples/dotnet/ 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.Analyzer.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