diff --git a/.netconfig b/.netconfig index 9a0062b..217542f 100644 --- a/.netconfig +++ b/.netconfig @@ -155,3 +155,299 @@ 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 = efdaa7c93cca3bed49aafdca4a1f29e85126c94f + + etag = 3a15ca6d1eec97f5280de063a3463dd24eafe47a8a9ac193bc0ecb2603643b6e + 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 = f47528874a6d9192b5546f84b455f5ccc474a707 + + etag = 06a4ffe0d8f24f795a484b1e86bcbad538437d819c41dd1ae0e5184a1e4a7d31 + 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 = 058dbbc3582042d7fdcaf5741db59d2b46ea1222 + + etag = 2d8bac60892b5565e3419b1fa775c5b977af202304ef3c25a4c6d04c7ac9faf1 + weak +[file "src/SponsorLink/SponsorLink.Analyzer.targets"] + url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/SponsorLink.Analyzer.targets + sha = efdaa7c93cca3bed49aafdca4a1f29e85126c94f + + etag = 96480c337e10cdca35957d41b651879b146da30a6e6db234f2bb6f09e88e3a7f + 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 = efda32a6eaaeffa485fb480e31adfd2f8130bd48 + + etag = 555128f4279241225b4dc8bb2ec8835894edfe9f6b9223399ee8cb3dc7adaf06 + weak +[file "src/SponsorLink/SponsorLink/SponsorLink.csproj"] + url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/SponsorLink/SponsorLink.csproj + sha = 46e9abe02e5a6abadda66ef050ddc5b9859aa2b8 + + etag = 9674d70c0042b3b973846d5252d6861a981e1e7b84bc806a8d20698ba0c27481 + 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 = eceeb2c5596285c95db4d1a031cc36238a7cd22d + + etag = ce125080f1a10d94caecf0c93840a3e2878bf73b83526aaa9a569f91414cf2be + 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/CloudStorageAccount.sln b/CloudStorageAccount.sln index 5cecd4c..007dd0b 100644 --- a/CloudStorageAccount.sln +++ b/CloudStorageAccount.sln @@ -7,13 +7,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CloudStorageAccount", "src\ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CloudStorageAccountTests", "src\CloudStorageAccountTests\CloudStorageAccountTests.csproj", "{3D75F7F3-DDF9-4BD5-80BF-7A102667DC34}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{C05DD3A9-4F0C-4165-9675-41334E9BC0C0}" - ProjectSection(SolutionItems) = preProject - readme.md = readme.md - EndProjectSection -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CloudStorageAccount.Source", "src\CloudStorageAccount.Source\CloudStorageAccount.Source.csproj", "{29911CE6-86EE-41F6-AB64-1C26DAEC2061}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudStorageAccount.Analyzers", "src\CloudStorageAccount.Analyzers\CloudStorageAccount.Analyzers.csproj", "{C8CEDE7C-DC79-4A5B-BAD0-3A22031526EA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -32,6 +29,10 @@ Global {29911CE6-86EE-41F6-AB64-1C26DAEC2061}.Debug|Any CPU.Build.0 = Debug|Any CPU {29911CE6-86EE-41F6-AB64-1C26DAEC2061}.Release|Any CPU.ActiveCfg = Release|Any CPU {29911CE6-86EE-41F6-AB64-1C26DAEC2061}.Release|Any CPU.Build.0 = Release|Any CPU + {C8CEDE7C-DC79-4A5B-BAD0-3A22031526EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C8CEDE7C-DC79-4A5B-BAD0-3A22031526EA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C8CEDE7C-DC79-4A5B-BAD0-3A22031526EA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C8CEDE7C-DC79-4A5B-BAD0-3A22031526EA}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/CloudStorageAccount.Analyzers/CloudStorageAccount.Analyzers.csproj b/src/CloudStorageAccount.Analyzers/CloudStorageAccount.Analyzers.csproj index ff2ca01..b3fe074 100644 --- a/src/CloudStorageAccount.Analyzers/CloudStorageAccount.Analyzers.csproj +++ b/src/CloudStorageAccount.Analyzers/CloudStorageAccount.Analyzers.csproj @@ -1,17 +1,32 @@ - + - Devlooped.CloudStorageAccount.CodeAnalysis + Devlooped.CloudStorageAccount.Analyzers netstandard2.0 analyzers/dotnet/roslyn4.0 false + false - - + + - + + + + + + + $(MSBuildThisFileDirectory)..\SponsorLink\SponsorLink.Analyzer.targets + + $(Product) + + + + + + diff --git a/src/CloudStorageAccount.Analyzers/Devlooped.CloudStorageAccount.targets b/src/CloudStorageAccount.Analyzers/Devlooped.CloudStorageAccount.targets new file mode 100644 index 0000000..e55fcce --- /dev/null +++ b/src/CloudStorageAccount.Analyzers/Devlooped.CloudStorageAccount.targets @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/CloudStorageAccount.Source/CloudStorageAccount.Source.csproj b/src/CloudStorageAccount.Source/CloudStorageAccount.Source.csproj index 95b215e..ba497a9 100644 --- a/src/CloudStorageAccount.Source/CloudStorageAccount.Source.csproj +++ b/src/CloudStorageAccount.Source/CloudStorageAccount.Source.csproj @@ -1,15 +1,11 @@ - + - Devlooped netstandard2.0 Devlooped.CloudStorageAccount.Source Devlooped.CloudStorageAccount.Source - CloudStorageAccount for Azure Storage v12+ - https://clarius.org/CloudStorageAccount false true - readme.md @@ -18,22 +14,16 @@ - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + - + - - %(Filename)%(Extension) - diff --git a/src/CloudStorageAccount.Source/Devlooped.CloudStorageAccount.Source.targets b/src/CloudStorageAccount.Source/Devlooped.CloudStorageAccount.Source.targets index 566eb72..b2acd2d 100644 --- a/src/CloudStorageAccount.Source/Devlooped.CloudStorageAccount.Source.targets +++ b/src/CloudStorageAccount.Source/Devlooped.CloudStorageAccount.Source.targets @@ -1,10 +1,12 @@  + + false Devlooped\CloudStorageAccount\%(Filename)%(Extension) - + \ No newline at end of file diff --git a/src/CloudStorageAccount/CloudStorageAccount.csproj b/src/CloudStorageAccount/CloudStorageAccount.csproj index 113ea24..6b5d7ce 100644 --- a/src/CloudStorageAccount/CloudStorageAccount.csproj +++ b/src/CloudStorageAccount/CloudStorageAccount.csproj @@ -1,12 +1,9 @@ - + - Devlooped netstandard2.0 Devlooped.CloudStorageAccount Devlooped.CloudStorageAccount - CloudStorageAccount for Azure Storage v12+ - https://clarius.org/CloudStorageAccount @@ -23,7 +20,7 @@ - + diff --git a/src/Directory.props b/src/Directory.props new file mode 100644 index 0000000..fbdd79d --- /dev/null +++ b/src/Directory.props @@ -0,0 +1,10 @@ + + + Devlooped.CloudStorageAccount + CloudStorageAccount for Azure Storage v12+ + https://clarius.org/CloudStorageAccount + + Devlooped + true + + \ No newline at end of file diff --git a/src/Directory.targets b/src/Directory.targets new file mode 100644 index 0000000..46e9698 --- /dev/null +++ b/src/Directory.targets @@ -0,0 +1,62 @@ + + + + + $(Description) + + > This project uses SponsorLink and may issue IDE-only warnings if no active sponsorship is detected. + > Learn more at https://github.com/devlooped#sponsorlink. + + $(PackFolder.StartsWith('analyzers/')) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/SponsorLink/Analyzer/Analyzer.csproj b/src/SponsorLink/Analyzer/Analyzer.csproj new file mode 100644 index 0000000..4623a1b --- /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..6e79399 --- /dev/null +++ b/src/SponsorLink/Library/Library.csproj @@ -0,0 +1,25 @@ + + + + SponsorableLib + netstandard2.0 + true + SponsorableLib + Sample library incorporating SponsorLink checks + true + true + + + + + + + + + + + + + + + diff --git a/src/SponsorLink/Library/MyClass.cs b/src/SponsorLink/Library/MyClass.cs new file mode 100644 index 0000000..7b7f6f5 --- /dev/null +++ b/src/SponsorLink/Library/MyClass.cs @@ -0,0 +1,5 @@ +namespace SponsorableLib; + +public class MyClass +{ +} diff --git a/src/SponsorLink/Library/Resources.resx b/src/SponsorLink/Library/Resources.resx new file mode 100644 index 0000000..636fedc --- /dev/null +++ b/src/SponsorLink/Library/Resources.resx @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Bar + + \ No newline at end of file diff --git a/src/SponsorLink/Library/readme.md b/src/SponsorLink/Library/readme.md new file mode 100644 index 0000000..ba4ce37 --- /dev/null +++ b/src/SponsorLink/Library/readme.md @@ -0,0 +1,5 @@ +# Sponsorable Library + +Example of a library that is available for sponsorship and leverages +[SponsorLink](https://github.com/devlooped/SponsorLink) to remind users +in an IDE (VS/Rider). diff --git a/src/SponsorLink/SponsorLink.Analyzer.Tests.targets b/src/SponsorLink/SponsorLink.Analyzer.Tests.targets new file mode 100644 index 0000000..b2e6e7b --- /dev/null +++ b/src/SponsorLink/SponsorLink.Analyzer.Tests.targets @@ -0,0 +1,39 @@ + + + + + true + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SponsorLink/SponsorLink.Analyzer.targets b/src/SponsorLink/SponsorLink.Analyzer.targets new file mode 100644 index 0000000..3296c6b --- /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..eecd218 --- /dev/null +++ b/src/SponsorLink/SponsorLink/SponsorLink.cs @@ -0,0 +1,237 @@ +// +#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)); + + 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..d63116a --- /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..42e4d75 --- /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