From c1a646d84bae7f6eb94371a707be79b6eed02975 Mon Sep 17 00:00:00 2001 From: devlooped-bot Date: Sun, 30 Jun 2024 00:18:14 +0000 Subject: [PATCH] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Bump=20files=20with=20dotn?= =?UTF-8?q?et-file=20sync=20#=20devlooped/oss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename sample assemblies for nicer display https://github.com/devlooped/oss/commit/93df7c7 - Introduce lazy-init of sponsoring status, simplify diagnostics https://github.com/devlooped/oss/commit/5009784 - Make sure Funding class is available to intellisense https://github.com/devlooped/oss/commit/5813f21 - Add support and showcase determining install time https://github.com/devlooped/oss/commit/717ddb1 - Fix path to jwk.ps1 alongside the SponsorLink.targets https://github.com/devlooped/oss/commit/c4830fc - Add our implementation of JWT manifest reading and reporting https://github.com/devlooped/oss/commit/a0ae727 - Minimal docs on consuming https://github.com/devlooped/oss/commit/827a1d1 - Make sure we report only once per product for entire solution https://github.com/devlooped/oss/commit/4b7f922 - Improve versioning of sample package https://github.com/devlooped/oss/commit/3b943f5 - Update to newest JsonWebTokens https://github.com/devlooped/oss/commit/068140b - Add nullable and generated code annotations https://github.com/devlooped/oss/commit/b2a11fa - Dynamically fetch devlooped JWK from github https://github.com/devlooped/oss/commit/55124bc - Remove dependency on ThisAssembly https://github.com/devlooped/oss/commit/c879f25 - Replace JWT package in tests targets too https://github.com/devlooped/oss/commit/ba1310c - Whitespace and formatting https://github.com/devlooped/oss/commit/d74f511 - Simplify and unify manifest reading implementation https://github.com/devlooped/oss/commit/4fca946 - Fix formatting/whitespace https://github.com/devlooped/oss/commit/7febebc - Remove unused tracing overloads https://github.com/devlooped/oss/commit/08a8488 - Fix dependabot group for tests https://github.com/devlooped/oss/commit/49661db - Make sure build runs before pack https://github.com/devlooped/oss/commit/ede013a - Switch to PackOnBuild=true and remove pack step https://github.com/devlooped/oss/commit/6e7a3ab - Upload binlog artifact on debug runs https://github.com/devlooped/oss/commit/a67ae78 - Set env:gh_token if present as secret https://github.com/devlooped/oss/commit/97ebd18 - Update to checkout@v4 https://github.com/devlooped/oss/commit/5fb1723 - Cleanup build and publish to use VersionLabel https://github.com/devlooped/oss/commit/14deaea - Update dotnet-file.yml with fix to create pull request action https://github.com/devlooped/oss/commit/11a331d - Don't add random wait on manual dotnet-file runs https://github.com/devlooped/oss/commit/7afe350 - Update .gitignore with BenchmarkDotNet artifacts default path https://github.com/devlooped/oss/commit/e20e906 - Remove whitespace and add results to ignore https://github.com/devlooped/oss/commit/ef852e7 - Only ignore App folder directly under the root https://github.com/devlooped/oss/commit/02811fa - NoTargets/Traversal SDKs now support central package versions too https://github.com/devlooped/oss/commit/afca922 - Enable floating versions for central packages by default https://github.com/devlooped/oss/commit/b1d14c6 - Add static usings to allow unprefixed ThrowXxxx https://github.com/devlooped/oss/commit/6dfe21f - Add compatibility for non-SDK projects without InitializeSourceControlInformation target https://github.com/devlooped/oss/commit/6e96c59 - Set Version from VersionLabel if it's a refs/tags/ https://github.com/devlooped/oss/commit/57653a2 - Bump create-pr dependency to avoid error with existing PRs https://github.com/devlooped/oss/commit/11a8757 - Only commit markdown files when resolving includes https://github.com/devlooped/oss/commit/2c10a83 # devlooped/.github - Update sponsorlink.jwt without the older 'pub' claim https://github.com/devlooped/.github/commit/51e44c3 - Fix missing bracket in link https://github.com/devlooped/.github/commit/4d38bd1 - Rename community files https://github.com/devlooped/.github/commit/80e81d2 - Update readme.md https://github.com/devlooped/.github/commit/55d9272 - Update discord invite to devlooped server https://github.com/devlooped/.github/commit/a3ffa5d - Fix typo https://github.com/devlooped/.github/commit/e17634b - Try adding anchor to stats https://github.com/devlooped/.github/commit/390e4a5 - Try profile anchors https://github.com/devlooped/.github/commit/27d7604 - Add SponsorLink section, improve a bit the badges and stats https://github.com/devlooped/.github/commit/d6f9b01 - Update readme.md https://github.com/devlooped/.github/commit/5c0ed40 - Update readme.md https://github.com/devlooped/.github/commit/82c9a83 - Update readme.md https://github.com/devlooped/.github/commit/b319295 - Update readme.md https://github.com/devlooped/.github/commit/ecb79e3 - Update readme.md for renamed sponsors > sponsor https://github.com/devlooped/.github/commit/4bc89bc - Add status badges on sponsorlink usage https://github.com/devlooped/.github/commit/1bdc06a - Update readme.md https://github.com/devlooped/.github/commit/b53e2d3 --- .github/CODE_OF_CONDUCT.md | 6 - .github/CONTRIBUTING.md | 35 -- .github/SECURITY.md | 9 - .github/SUPPORT.md | 9 - .github/dependabot.yml | 2 +- .github/workflows/build.yml | 21 +- .github/workflows/changelog.yml | 2 +- .github/workflows/dotnet-file.yml | 5 +- .github/workflows/includes.yml | 5 +- .github/workflows/publish.yml | 18 +- .github/workflows/sponsor.yml | 2 +- .gitignore | 4 +- .netconfig | 5 - profile/readme.md | 86 +++-- readme.md | 10 +- sponsorlink.jwt | 1 + src/Directory.Build.props | 19 +- src/SponsorLink/Analyzer/Analyzer.csproj | 37 ++ .../Analyzer/Properties/launchSettings.json | 11 + .../Analyzer/StatusReportingAnalyzer.cs | 45 +++ .../Analyzer/StatusReportingGenerator.cs | 20 + .../buildTransitive/SponsorableLib.targets | 7 + src/SponsorLink/Directory.Build.props | 47 +++ src/SponsorLink/Directory.Build.targets | 8 + src/SponsorLink/Library/Library.csproj | 25 ++ src/SponsorLink/Library/MyClass.cs | 5 + src/SponsorLink/Library/Resources.resx | 123 ++++++ src/SponsorLink/Library/readme.md | 5 + src/SponsorLink/SponsorLink.Tests.targets | 38 ++ src/SponsorLink/SponsorLink.targets | 191 ++++++++++ .../SponsorLink/AppDomainDictionary.cs | 36 ++ .../SponsorLink/DiagnosticsManager.cs | 226 +++++++++++ src/SponsorLink/SponsorLink/ManifestStatus.cs | 25 ++ src/SponsorLink/SponsorLink/Resources.es.resx | 163 ++++++++ src/SponsorLink/SponsorLink/Resources.resx | 164 ++++++++ src/SponsorLink/SponsorLink/SponsorLink.cs | 207 +++++++++++ .../SponsorLink/SponsorLink.csproj | 79 ++++ .../SponsorLink/SponsorLinkAnalyzer.cs | 89 +++++ src/SponsorLink/SponsorLink/SponsorStatus.cs | 25 ++ .../SponsorLink/SponsorableLib.targets | 60 +++ src/SponsorLink/SponsorLink/Tracing.cs | 49 +++ .../Devlooped.Sponsors.targets | 118 ++++++ src/SponsorLink/SponsorLink/sponsorable.md | 5 + src/SponsorLink/SponsorLinkAnalyzer.sln | 43 +++ src/SponsorLink/Tests/.netconfig | 17 + src/SponsorLink/Tests/Attributes.cs | 59 +++ src/SponsorLink/Tests/Extensions.cs | 56 +++ src/SponsorLink/Tests/JsonOptions.cs | 70 ++++ src/SponsorLink/Tests/Resources.Designer.cs | 63 ++++ src/SponsorLink/Tests/Resources.resx | 101 +++++ src/SponsorLink/Tests/Sample.cs | 69 ++++ src/SponsorLink/Tests/SponsorLinkTests.cs | 126 +++++++ src/SponsorLink/Tests/SponsorableManifest.cs | 351 ++++++++++++++++++ src/SponsorLink/Tests/Tests.csproj | 69 ++++ src/SponsorLink/jwk.ps1 | 1 + src/SponsorLink/readme.md | 34 ++ src/nuget.config | 32 -- 57 files changed, 2986 insertions(+), 152 deletions(-) delete mode 100644 .github/CODE_OF_CONDUCT.md delete mode 100644 .github/CONTRIBUTING.md delete mode 100644 .github/SECURITY.md delete mode 100644 .github/SUPPORT.md create mode 100644 sponsorlink.jwt create mode 100644 src/SponsorLink/Analyzer/Analyzer.csproj create mode 100644 src/SponsorLink/Analyzer/Properties/launchSettings.json create mode 100644 src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs create mode 100644 src/SponsorLink/Analyzer/StatusReportingGenerator.cs create mode 100644 src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets create mode 100644 src/SponsorLink/Directory.Build.props create mode 100644 src/SponsorLink/Directory.Build.targets create mode 100644 src/SponsorLink/Library/Library.csproj create mode 100644 src/SponsorLink/Library/MyClass.cs create mode 100644 src/SponsorLink/Library/Resources.resx create mode 100644 src/SponsorLink/Library/readme.md create mode 100644 src/SponsorLink/SponsorLink.Tests.targets create mode 100644 src/SponsorLink/SponsorLink.targets create mode 100644 src/SponsorLink/SponsorLink/AppDomainDictionary.cs create mode 100644 src/SponsorLink/SponsorLink/DiagnosticsManager.cs create mode 100644 src/SponsorLink/SponsorLink/ManifestStatus.cs create mode 100644 src/SponsorLink/SponsorLink/Resources.es.resx create mode 100644 src/SponsorLink/SponsorLink/Resources.resx create mode 100644 src/SponsorLink/SponsorLink/SponsorLink.cs create mode 100644 src/SponsorLink/SponsorLink/SponsorLink.csproj create mode 100644 src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs create mode 100644 src/SponsorLink/SponsorLink/SponsorStatus.cs create mode 100644 src/SponsorLink/SponsorLink/SponsorableLib.targets create mode 100644 src/SponsorLink/SponsorLink/Tracing.cs create mode 100644 src/SponsorLink/SponsorLink/buildTransitive/Devlooped.Sponsors.targets create mode 100644 src/SponsorLink/SponsorLink/sponsorable.md create mode 100644 src/SponsorLink/SponsorLinkAnalyzer.sln create mode 100644 src/SponsorLink/Tests/.netconfig create mode 100644 src/SponsorLink/Tests/Attributes.cs create mode 100644 src/SponsorLink/Tests/Extensions.cs create mode 100644 src/SponsorLink/Tests/JsonOptions.cs create mode 100644 src/SponsorLink/Tests/Resources.Designer.cs create mode 100644 src/SponsorLink/Tests/Resources.resx create mode 100644 src/SponsorLink/Tests/Sample.cs create mode 100644 src/SponsorLink/Tests/SponsorLinkTests.cs create mode 100644 src/SponsorLink/Tests/SponsorableManifest.cs create mode 100644 src/SponsorLink/Tests/Tests.csproj create mode 100644 src/SponsorLink/jwk.ps1 create mode 100644 src/SponsorLink/readme.md delete mode 100644 src/nuget.config diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md deleted file mode 100644 index 775f221..0000000 --- a/.github/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,6 +0,0 @@ -# Code of Conduct - -This project has adopted the code of conduct defined by the Contributor Covenant -to clarify expected behavior in our community. - -For more information, see the [.NET Foundation Code of Conduct](https://dotnetfoundation.org/code-of-conduct). diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md deleted file mode 100644 index f577cde..0000000 --- a/.github/CONTRIBUTING.md +++ /dev/null @@ -1,35 +0,0 @@ -# Contributing - -You can contribute to the project with issues and PRs. -Simply filing issues for problems you encounter is a great way to contribute. Contributing implementations is greatly appreciated. - -## Before you file a bug... -* Is this a question, or are you looking for help? Ask it in the project's **Discussions** tab instead, if available. - -* Did you search the issues list to see if someone already reported it? _When looking for duplicates, make sure to look through open **and** closed issues._ -* Did you create a simple repro for the problem? _We won't look at your bug until you provide a repro project or complete repro steps._ - -## Before you submit a PR... - -* Did you ensure there is a corresponding issue labelled as 🟢 help wanted? - If not, please open one to start the discussion. -* Does the code follow existing coding styles? (spaces, comments, no regions, etc.)? - We enforce the basic styles by running `dotnet format` on the repository root. - You can do the same before submitting a PR to speed up the process. -* Did you write unit tests? - Typically required if there are other unit tests for existing or related code. - -## Looking for something to work on? - -Look at the repo issues labelled as 🟢 help wanted and 🟣 good first issue to find -something to contribute! - -# Contributor License Agreement - -You must sign the [Contribution License Agreement (CLA)](https://cla-assistant.io/devlooped/) before your PR will be merged. -This is a one-time requirement for projects in the Devlooped organization. - You can read more about [Contribution License Agreements (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) on Wikipedia. - - You don't have to do this up-front. You can simply clone, fork, and submit your pull-request as usual. - When your pull-request is created, it is classified by a CLA bot, which will let - you know if signing is pending. \ No newline at end of file diff --git a/.github/SECURITY.md b/.github/SECURITY.md deleted file mode 100644 index 01b219a..0000000 --- a/.github/SECURITY.md +++ /dev/null @@ -1,9 +0,0 @@ -# Security Policy - -## Reporting a Vulnerability - -Security issues and bugs should be reported privately by emailing security@devlooped.com. -You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your -original message and ping [@devlooped](https://twitter.com/devlooped) on Twitter. - -Please do not open issues for anything you think might have a security implication. \ No newline at end of file diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md deleted file mode 100644 index cfde22d..0000000 --- a/.github/SUPPORT.md +++ /dev/null @@ -1,9 +0,0 @@ -# Support - -Support is typically available thorugh GitHub Discussions and Issues. - -**Found a bug? File an issue.** If you found something that isn't working as expected and you can reproduce it, go to the repo Issues tab and file an issue. - -**Want a new feature? Start a discussion.** It's always good to start a discussion (Discussion tab in the project, Ideas category) to explain the problem you're trying to solve before jumping in with the pull request. We may already have a solution in place or have an idea on ways to solve that issue. After the details are nailed down, we will migrate the discussion to a proper Issue (with the `enhancement`) label. At that point, we'd love to see your PRs :). - -Consider [sponsoring](https://github.com/sponsors/devlooped) the project to get priority support and many other perks! diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ed06dbe..c95eb73 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -29,7 +29,7 @@ updates: - "Microsoft.AspNetCore*" Tests: patterns: - - "Microsoft.NET.Tests*" + - "Microsoft.NET.Test*" - "xunit*" - "coverlet*" ThisAssembly: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0480456..c671ecc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,9 +17,12 @@ on: env: DOTNET_NOLOGO: true + PackOnBuild: true + GeneratePackageOnBuild: true VersionPrefix: 42.42.${{ github.run_number }} VersionLabel: ${{ github.ref }} - + GH_TOKEN: ${{ secrets.GH_TOKEN }} + defaults: run: shell: bash @@ -31,7 +34,7 @@ jobs: matrix: ${{ steps.lookup.outputs.matrix }} steps: - name: 🤘 checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: 🔎 lookup id: lookup @@ -50,13 +53,13 @@ jobs: os: ${{ fromJSON(needs.os-matrix.outputs.matrix) }} steps: - name: 🤘 checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 0 - name: 🙏 build - run: dotnet build -m:1 + run: dotnet build -m:1 -bl:build.binlog - name: ⚙ GNU grep if: matrix.os == 'macOS-latest' @@ -67,8 +70,12 @@ jobs: - name: 🧪 test uses: ./.github/workflows/test - - name: 📦 pack - run: dotnet pack -m:1 + - name: 🐛 logs + uses: actions/upload-artifact@v3 + if: runner.debug && always() + with: + name: logs + path: '*.binlog' # Only push CI package to sleet feed if building on ubuntu (fastest) - name: 🚀 sleet @@ -83,7 +90,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 🤘 checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 0 diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index b120b73..ca50e5a 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -17,7 +17,7 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} - name: 🤘 checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: fetch-depth: 0 ref: main diff --git a/.github/workflows/dotnet-file.yml b/.github/workflows/dotnet-file.yml index 818aa2c..95f6228 100644 --- a/.github/workflows/dotnet-file.yml +++ b/.github/workflows/dotnet-file.yml @@ -24,7 +24,7 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} - name: 🤘 checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: fetch-depth: 0 ref: main @@ -32,6 +32,7 @@ jobs: - name: ⌛ rate shell: pwsh + if: github.event_name != 'workflow_dispatch' run: | # add random sleep since we run on fixed schedule sleep (get-random -max 60) @@ -70,7 +71,7 @@ jobs: validate: false - name: ✍ pull request - uses: peter-evans/create-pull-request@v4 + uses: peter-evans/create-pull-request@v6 with: base: main branch: dotnet-file-sync diff --git a/.github/workflows/includes.yml b/.github/workflows/includes.yml index bb1a90b..9cdae21 100644 --- a/.github/workflows/includes.yml +++ b/.github/workflows/includes.yml @@ -21,7 +21,7 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} - name: 🤘 checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: token: ${{ env.GH_TOKEN }} @@ -29,8 +29,9 @@ jobs: uses: devlooped/actions-includes@v1 - name: ✍ pull request - uses: peter-evans/create-pull-request@v4 + uses: peter-evans/create-pull-request@v6 with: + add-paths: '**/*.md' base: main branch: markdown-includes delete-branch: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bd83ada..a086072 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,25 +10,33 @@ on: env: DOTNET_NOLOGO: true Configuration: Release - + PackOnBuild: true + GeneratePackageOnBuild: true + VersionLabel: ${{ github.ref }} + GH_TOKEN: ${{ secrets.GH_TOKEN }} + jobs: publish: runs-on: ubuntu-latest steps: - name: 🤘 checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 0 - name: 🙏 build - run: dotnet build -m:1 -p:version=${GITHUB_REF#refs/*/v} + run: dotnet build -m:1 -bl:build.binlog - name: 🧪 test uses: ./.github/workflows/test - - name: 📦 pack - run: dotnet pack -m:1 -p:version=${GITHUB_REF#refs/*/v} + - name: 🐛 logs + uses: actions/upload-artifact@v3 + if: runner.debug && always() + with: + name: logs + path: '*.binlog' - name: 🚀 nuget run: dotnet nuget push ./bin/**/*.nupkg -s https://api.nuget.org/v3/index.json -k ${{secrets.NUGET_API_KEY}} --skip-duplicate diff --git a/.github/workflows/sponsor.yml b/.github/workflows/sponsor.yml index 9e47191..1d484d3 100644 --- a/.github/workflows/sponsor.yml +++ b/.github/workflows/sponsor.yml @@ -15,7 +15,7 @@ jobs: steps: - name: 🤘 checkout if: env.token != '' - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: 💜 sponsor if: env.token != '' diff --git a/.gitignore b/.gitignore index a438e35..6639458 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,11 @@ bin -app obj artifacts pack TestResults +results +BenchmarkDotNet.Artifacts +/app .vs .vscode .idea diff --git a/.netconfig b/.netconfig index 32fe484..cb801ea 100644 --- a/.netconfig +++ b/.netconfig @@ -150,11 +150,6 @@ sha = c97cdeb04769df3f397d9e70b345d4549628c388 etag = fc8101fd914820db3e6b42d608dc46aefbc60c830ac721ed3917daead3727dbd weak -[file ".github/SUPPORT.md"] - url = https://github.com/devlooped/.github/blob/main/.github/SUPPORT.md - sha = c97cdeb04769df3f397d9e70b345d4549628c388 - etag = 2d47e2fc4fdfa3515203d452674566e3df507461f9938f451a06d79deb687d24 - weak [file ".github/workflows/sponsor.yml"] url = https://github.com/devlooped/oss/blob/main/.github/workflows/sponsor.yml sha = 8990ebb36199046e0b8098bad9e46dcef739c56e diff --git a/profile/readme.md b/profile/readme.md index 9b2881d..80675a9 100644 --- a/profile/readme.md +++ b/profile/readme.md @@ -1,17 +1,18 @@ -

Hi 👋, I'm kzu

-

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

-

A hardcode .NET/C# developer from Argentina

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

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

-

- - - - - -

-

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