From 097f0ca290fffd74e831835e070ce0213f1ff173 Mon Sep 17 00:00:00 2001 From: devlooped-bot Date: Sun, 30 Jun 2024 01:06:42 +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 - Fix path to jwk.ps1 alongside the SponsorLink.targets https://github.com/devlooped/oss/commit/c4830fc - Introduce lazy-init of sponsoring status, simplify diagnostics https://github.com/devlooped/oss/commit/5009784 - Dynamically fetch devlooped JWK from github https://github.com/devlooped/oss/commit/55124bc - Replace JWT package in tests targets too https://github.com/devlooped/oss/commit/ba1310c - Remove dependency on ThisAssembly https://github.com/devlooped/oss/commit/c879f25 - SponsorLink code should be checked as regular code https://github.com/devlooped/oss/commit/e81ab75 - Switch to the dotnet global tool version of SL CLI https://github.com/devlooped/oss/commit/cff07df - Switch to renamed sponsorlink > sponsor https://github.com/devlooped/oss/commit/d5efe5e - Bump to renamed tool https://github.com/devlooped/oss/commit/b8fd87b - Revert back to dotnet-sponsor https://github.com/devlooped/oss/commit/8d29f01 - Rename sample assemblies for nicer display https://github.com/devlooped/oss/commit/93df7c7 - Switch to built-in item metadata for manifest analyzer files https://github.com/devlooped/oss/commit/49c9a38 - Add support and showcase determining install time https://github.com/devlooped/oss/commit/717ddb1 - Set Version from VersionLabel if it's a refs/tags/ https://github.com/devlooped/oss/commit/57653a2 - Cleanup build and publish to use VersionLabel https://github.com/devlooped/oss/commit/14deaea - Make sure we report only once per product for entire solution https://github.com/devlooped/oss/commit/4b7f922 - Update to newest JsonWebTokens https://github.com/devlooped/oss/commit/068140b - SponsorLink-enabled analyzers need copylocal https://github.com/devlooped/oss/commit/7593657 - Extend grace period to unknown status too https://github.com/devlooped/oss/commit/9f918ec - Add SponsorLinkImported so we can skip imports https://github.com/devlooped/oss/commit/c81f532 - Make sure Funding class is available to intellisense https://github.com/devlooped/oss/commit/5813f21 - Fix formatting/whitespace https://github.com/devlooped/oss/commit/7febebc - Minor code simplification https://github.com/devlooped/oss/commit/cf154d5 - Improve versioning of sample package https://github.com/devlooped/oss/commit/3b943f5 - Fix roles checking from new identity-based token handler https://github.com/devlooped/oss/commit/6eecf46 - Change debug traces location to the well-known location of .sponsorlink https://github.com/devlooped/oss/commit/1019e2a - Remove unused tracing overloads https://github.com/devlooped/oss/commit/08a8488 --- .editorconfig | 3 - .netconfig | 5 - src/Directory.Build.props | 6 +- src/SponsorLink/Analyzer/Analyzer.csproj | 5 + .../Analyzer/StatusReportingAnalyzer.cs | 33 ++- .../Analyzer/StatusReportingGenerator.cs | 20 ++ .../buildTransitive/SponsorableLib.targets | 4 + src/SponsorLink/Directory.Build.props | 6 +- src/SponsorLink/Library/Library.csproj | 16 +- src/SponsorLink/Library/readme.md | 5 + src/SponsorLink/SponsorLink.Tests.targets | 38 ++++ src/SponsorLink/SponsorLink.targets | 86 ++++++-- .../SponsorLink/DiagnosticsManager.cs | 196 +++++++++++++----- ...{SponsorLink.es.resx => Resources.es.resx} | 10 +- .../{SponsorLink.resx => Resources.resx} | 8 +- src/SponsorLink/SponsorLink/SponsorLink.cs | 98 ++++++--- .../SponsorLink/SponsorLink.csproj | 57 +++-- .../SponsorLink/SponsorLinkAnalyzer.cs | 117 ++++------- src/SponsorLink/SponsorLink/ThisAssembly.cs | 31 --- src/SponsorLink/SponsorLink/Tracing.cs | 12 +- .../Devlooped.Sponsors.targets | 29 ++- src/SponsorLink/SponsorLink/devlooped.pub.jwk | 5 - src/SponsorLink/Tests/.netconfig | 16 +- src/SponsorLink/Tests/Extensions.cs | 13 ++ src/SponsorLink/Tests/JsonOptions.cs | 4 +- src/SponsorLink/Tests/Resources.Designer.cs | 63 ++++++ src/SponsorLink/Tests/Resources.resx | 101 +++++++++ src/SponsorLink/Tests/Sample.cs | 12 +- src/SponsorLink/Tests/SponsorableManifest.cs | 190 ++++++++++------- src/SponsorLink/Tests/Tests.csproj | 33 ++- src/SponsorLink/jwk.ps1 | 1 + 31 files changed, 858 insertions(+), 365 deletions(-) create mode 100644 src/SponsorLink/Analyzer/StatusReportingGenerator.cs create mode 100644 src/SponsorLink/Library/readme.md create mode 100644 src/SponsorLink/SponsorLink.Tests.targets rename src/SponsorLink/SponsorLink/{SponsorLink.es.resx => Resources.es.resx} (92%) rename src/SponsorLink/SponsorLink/{SponsorLink.resx => Resources.resx} (94%) delete mode 100644 src/SponsorLink/SponsorLink/ThisAssembly.cs delete mode 100644 src/SponsorLink/SponsorLink/devlooped.pub.jwk create mode 100644 src/SponsorLink/Tests/Resources.Designer.cs create mode 100644 src/SponsorLink/Tests/Resources.resx create mode 100644 src/SponsorLink/jwk.ps1 diff --git a/.editorconfig b/.editorconfig index e17d14e..4cab270 100644 --- a/.editorconfig +++ b/.editorconfig @@ -107,6 +107,3 @@ dotnet_analyzer_diagnostic.category-Style.severity = none # VSTHRD200: Use "Async" suffix for async methods dotnet_diagnostic.VSTHRD200.severity = none - -[**/*SponsorLink*/**] -generated_code = true \ No newline at end of file diff --git a/.netconfig b/.netconfig index 75e23bf..c732478 100644 --- a/.netconfig +++ b/.netconfig @@ -240,11 +240,6 @@ sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d etag = 72ec691a085dc34f946627f7038a82569e44f0b63a9f4a7bd60f0f7b52fd198f weak -[file "src/SponsorLink/SponsorLink/devlooped.pub.jwk"] - url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/devlooped.pub.jwk - sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d - etag = cf884781ff88b4d096841e3169282762a898b2050c9b5dac0013bc15bdbee267 - weak [file "src/SponsorLink/SponsorLink/sponsorable.md"] url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/sponsorable.md sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 50fc169..1648dcd 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -118,6 +118,8 @@ <_VersionLabel>$(VersionLabel.Replace('refs/heads/', '')) + <_VersionLabel>$(_VersionLabel.Replace('refs/tags/v', '')) + <_VersionLabel Condition="$(_VersionLabel.Contains('refs/pull/'))">$(VersionLabel.TrimEnd('.0123456789')) @@ -128,7 +130,9 @@ <_VersionLabel>$(_VersionLabel.Replace('/', '-')) - $(_VersionLabel) + $(_VersionLabel) + + $(_VersionLabel) diff --git a/src/SponsorLink/Analyzer/Analyzer.csproj b/src/SponsorLink/Analyzer/Analyzer.csproj index 963c77b..f65390a 100644 --- a/src/SponsorLink/Analyzer/Analyzer.csproj +++ b/src/SponsorLink/Analyzer/Analyzer.csproj @@ -1,6 +1,7 @@  + SponsorableLib.Analyzers netstandard2.0 true analyzers/dotnet/roslyn4.0 @@ -29,4 +30,8 @@ + + + + \ No newline at end of file diff --git a/src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs b/src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs index e21acb7..d7a1fd7 100644 --- a/src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs +++ b/src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs @@ -1,26 +1,45 @@ -using System.Collections.Immutable; +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; -using static ThisAssembly.Constants; namespace Analyzer; -[DiagnosticAnalyzer(LanguageNames.CSharp)] +[DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)] public class StatusReportingAnalyzer : DiagnosticAnalyzer { - public override ImmutableArray SupportedDiagnostics => ImmutableArray.Empty; + 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.RegisterCodeBlockAction(c => + context.RegisterCompilationAction(c => { - var status = Diagnostics.GetStatus(Funding.Product); - Tracing.Trace($"Status: {status}"); + 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 index fd1e6e4..37585e8 100644 --- a/src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets +++ b/src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets @@ -1,3 +1,7 @@ + + + + \ No newline at end of file diff --git a/src/SponsorLink/Directory.Build.props b/src/SponsorLink/Directory.Build.props index c0a3e42..8afa061 100644 --- a/src/SponsorLink/Directory.Build.props +++ b/src/SponsorLink/Directory.Build.props @@ -15,7 +15,11 @@ - 42.42.$([System.Math]::Floor($([MSBuild]::Divide($([System.DateTime]::Now.TimeOfDay.TotalSeconds), 10)))) + $([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/Library/Library.csproj b/src/SponsorLink/Library/Library.csproj index f351273..6e79399 100644 --- a/src/SponsorLink/Library/Library.csproj +++ b/src/SponsorLink/Library/Library.csproj @@ -1,11 +1,13 @@  + SponsorableLib netstandard2.0 true SponsorableLib Sample library incorporating SponsorLink checks true + true @@ -16,16 +18,8 @@ - - - - MSBuild:Compile - $(IntermediateOutputPath)\$([MSBuild]::ValueOrDefault('%(RelativeDir)', '').Replace('\', '.').Replace('/', '.'))%(Filename).g$(DefaultLanguageSourceExtension) - $(Language) - $(RootNamespace) - $(RootNamespace).$([MSBuild]::ValueOrDefault('%(RelativeDir)', '').Replace('\', '.').Replace('/', '.').TrimEnd('.')) - %(Filename) - - + + + diff --git a/src/SponsorLink/Library/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 index de93845..4678d5d 100644 --- a/src/SponsorLink/SponsorLink.targets +++ b/src/SponsorLink/SponsorLink.targets @@ -7,27 +7,19 @@ true true - - true - - - $([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)SponsorLink/devlooped.pub.jwk')) + + true + + CoreResGen;$(CoreCompileDependsOn) $(Product) $([System.Text.RegularExpressions.Regex]::Replace("$(FundingProduct)", "[^A-Z]", "")) - - 21 + + 15 - - - - - - - + ManifestResourceName="Devlooped.Sponsors.%(Filename)"/> + @@ -74,17 +71,38 @@ - + - + + + + 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 + } +} + + + + + + + + $(ILRepackArgs) "/lib:$(NetstandardDirectory)" --> - + @@ -138,4 +156,36 @@ + + + + + + + + + + + + + $([System.IO.File]::ReadAllText('$(MSBuildProjectDirectory)\$(BaseIntermediateOutputPath)devlooped.jwk')) + + + + + + + + + + + + + + + + true + + \ No newline at end of file diff --git a/src/SponsorLink/SponsorLink/DiagnosticsManager.cs b/src/SponsorLink/SponsorLink/DiagnosticsManager.cs index 49143d9..96e7e14 100644 --- a/src/SponsorLink/SponsorLink/DiagnosticsManager.cs +++ b/src/SponsorLink/SponsorLink/DiagnosticsManager.cs @@ -2,8 +2,19 @@ #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; @@ -13,67 +24,64 @@ namespace Devlooped.Sponsors; /// class DiagnosticsManager { - /// - /// Acceses the diagnostics dictionary for the current . - /// - ConcurrentDictionary Diagnostics - { - get => AppDomainDictionary.Get>(nameof(Diagnostics)); - } + static readonly Guid appDomainDiagnosticsKey = new(0x8d0e2670, 0xe6c4, 0x45c8, 0x81, 0xba, 0x5a, 0x36, 0x81, 0xd3, 0x65, 0x3e); - /// - /// Creates a descriptor from well-known diagnostic kinds. - /// - /// The names of the sponsorable accounts that can be funded for the given product. - /// The product or project developed by the sponsorable(s). - /// Custom prefix to use for diagnostic IDs. - /// The kind of status diagnostic to create. - /// The given . - /// The is not one of the known ones. - public DiagnosticDescriptor GetDescriptor(string[] sponsorable, string product, string prefix, SponsorStatus status) => status switch + public static Dictionary KnownDescriptors { get; } = new() { - SponsorStatus.Unknown => CreateUnknown(sponsorable, product, prefix), - SponsorStatus.Sponsor => CreateSponsor(sponsorable, prefix), - SponsorStatus.Expiring => CreateExpiring(sponsorable, prefix), - SponsorStatus.Expired => CreateExpired(sponsorable, prefix), - _ => throw new NotImplementedException(), + // 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) }, }; /// - /// Pushes a diagnostic for the given product. If an existing one exists, it is replaced. + /// Acceses the diagnostics dictionary for the current . /// - /// The same diagnostic that was pushed, for chained invocations. - public Diagnostic Push(string product, Diagnostic diagnostic) - { - // Directly sets, since we only expect to get one warning per sponsorable+product - // combination. - Diagnostics[product] = diagnostic; - return diagnostic; - } + 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 Diagnostic? Pop(string product) + public void ReportOnce(Action report, string product = Funding.Product) { - Diagnostics.TryRemove(product, out var diagnostic); - return diagnostic; + 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. + /// 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). /// - /// The product to check status for. /// Optional that was reported, if any. - public SponsorStatus? GetStatus(string product) + 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(product, out var diagnostic) && + if (Diagnostics.TryGetValue(Funding.Product, out var diagnostic) && diagnostic.Properties.TryGetValue(nameof(SponsorStatus), out var value)) { // Switch on value matching DiagnosticKind names @@ -90,49 +98,129 @@ public Diagnostic Push(string product, Diagnostic diagnostic) return null; } - static DiagnosticDescriptor CreateSponsor(string[] sponsorable, string prefix) => new( + /// + /// 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", - ThisAssembly.Strings.Sponsor.Title, - ThisAssembly.Strings.Sponsor.MessageFormat, + Resources.Sponsor_Title, + Resources.Sponsor_Message, "SponsorLink", DiagnosticSeverity.Info, isEnabledByDefault: true, - description: ThisAssembly.Strings.Sponsor.Description, + description: Resources.Sponsor_Description, helpLinkUri: "https://github.com/devlooped#sponsorlink", "DoesNotSupportF1Help"); - static DiagnosticDescriptor CreateUnknown(string[] sponsorable, string product, string prefix) => new( + internal static DiagnosticDescriptor CreateUnknown(string[] sponsorable, string product, string prefix) => new( $"{prefix}101", - ThisAssembly.Strings.Unknown.Title, - ThisAssembly.Strings.Unknown.MessageFormat, + Resources.Unknown_Title, + Resources.Unknown_Message, "SponsorLink", DiagnosticSeverity.Warning, isEnabledByDefault: true, - description: ThisAssembly.Strings.Unknown.Description( + 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); - static DiagnosticDescriptor CreateExpiring(string[] sponsorable, string prefix) => new( + internal static DiagnosticDescriptor CreateExpiring(string[] sponsorable, string prefix) => new( $"{prefix}103", - ThisAssembly.Strings.Expiring.Title, - ThisAssembly.Strings.Expiring.MessageFormat, + Resources.Expiring_Title, + Resources.Expiring_Message, "SponsorLink", DiagnosticSeverity.Warning, isEnabledByDefault: true, - description: ThisAssembly.Strings.Expiring.Description(string.Join(" ", sponsorable)), + description: string.Format(CultureInfo.CurrentCulture, Resources.Expiring_Description, string.Join(" ", sponsorable)), helpLinkUri: "https://github.com/devlooped#autosync", "DoesNotSupportF1Help", WellKnownDiagnosticTags.NotConfigurable); - static DiagnosticDescriptor CreateExpired(string[] sponsorable, string prefix) => new( + internal static DiagnosticDescriptor CreateExpired(string[] sponsorable, string prefix) => new( $"{prefix}104", - ThisAssembly.Strings.Expired.Title, - ThisAssembly.Strings.Expired.MessageFormat, + Resources.Expired_Title, + Resources.Expired_Message, "SponsorLink", DiagnosticSeverity.Warning, isEnabledByDefault: true, - description: ThisAssembly.Strings.Expired.Description(string.Join(" ", sponsorable)), + 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/SponsorLink.es.resx b/src/SponsorLink/SponsorLink/Resources.es.resx similarity index 92% rename from src/SponsorLink/SponsorLink/SponsorLink.es.resx rename to src/SponsorLink/SponsorLink/Resources.es.resx index d8794ca..ec1b5c1 100644 --- a/src/SponsorLink/SponsorLink/SponsorLink.es.resx +++ b/src/SponsorLink/SponsorLink/Resources.es.resx @@ -119,16 +119,16 @@ Patrocinar los proyectos en que dependes asegura que se mantengan activos, y que recibas el apoyo que necesitas. También es muy económico y está disponible en todo el mundo! -Por favor considera apoyar el proyecto patrocinando en {links} y ejecutando posteriormente 'gh sponsors sync {spaced}'. +Por favor considera apoyar el proyecto patrocinando en {0} y ejecutando posteriormente 'sponsor sync {1}'. - No se pudo determinar el estado de su patrocinio. Funcionalidades exclusivas para patrocinadores pueden no estar disponibles. + Por favor considere apoyar {0} patrocinando @{1} 🙏 Estado de patrocinio desconocido - Funcionalidades exclusivas para patrocinadores pueden no estar disponibles. Ejecuta 'gh sponsors sync {spaced}' y, opcionalmente, habilita la sincronización automática. + 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. @@ -140,13 +140,13 @@ Por favor considera apoyar el proyecto patrocinando en {links} y ejecutando post Eres un verdadero héroe. Tu patrocinio ayuda a mantener el proyecto vivo y próspero 🙏. - Gracias por apoyar a {0} con tu patrocinio de {1} 💟! + 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 'gh sponsors sync {spaced}' y, opcionalmente, habilita la sincronización automática. + 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. diff --git a/src/SponsorLink/SponsorLink/SponsorLink.resx b/src/SponsorLink/SponsorLink/Resources.resx similarity index 94% rename from src/SponsorLink/SponsorLink/SponsorLink.resx rename to src/SponsorLink/SponsorLink/Resources.resx index b8cdd5e..e12a0e5 100644 --- a/src/SponsorLink/SponsorLink/SponsorLink.resx +++ b/src/SponsorLink/SponsorLink/Resources.resx @@ -119,17 +119,17 @@ Sponsoring projects you depend on ensures they remain active, and that you get the support you need. It's also super affordable and available worldwide! -Please consider supporting the project by sponsoring at {links} and running 'gh sponsors sync {spaced}' afterwards. +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} 🙏 + Please consider supporting {0} by sponsoring @{1} 🙏 Unknown sponsor status - Sponsor-only features may be disabled. Please run 'gh sponsors sync {spaced}' and optionally enable automatic sync. + 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. @@ -147,7 +147,7 @@ Please consider supporting the project by sponsoring at {links} and running 'gh You are a sponsor of the project, you rock 💟! - Sponsor status has expired and you are in the grace period. Please run 'gh sponsors sync {spaced}' and optionally enable automatic sync. + 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. diff --git a/src/SponsorLink/SponsorLink/SponsorLink.cs b/src/SponsorLink/SponsorLink/SponsorLink.cs index a5e5beb..b3b1cf3 100644 --- a/src/SponsorLink/SponsorLink/SponsorLink.cs +++ b/src/SponsorLink/SponsorLink/SponsorLink.cs @@ -2,11 +2,15 @@ #nullable enable using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; -using System.IdentityModel.Tokens.Jwt; +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; @@ -59,6 +63,32 @@ static partial class SponsorLink .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. /// @@ -82,15 +112,15 @@ public static bool TryRead([NotNullWhen(true)] out ClaimsPrincipal? principal, I foreach (var value in values) { - if (string.IsNullOrWhiteSpace(value.jwk) || string.IsNullOrEmpty(value.jwk)) + if (string.IsNullOrWhiteSpace(value.jwt) || string.IsNullOrEmpty(value.jwk)) continue; - if (Validate(value.jwt, value.jwk, out var token, out var claims, false) == ManifestStatus.Valid && claims != null) + if (Validate(value.jwt, value.jwk, out var token, out var identity, false) == ManifestStatus.Valid && identity != null) { if (principal == null) - principal = claims; + principal = new JwtRolesPrincipal(identity); else - principal.AddIdentities(claims.Identities); + principal.AddIdentity(identity); } } @@ -103,13 +133,13 @@ public static bool TryRead([NotNullWhen(true)] out ClaimsPrincipal? principal, I /// 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 . + /// The associated claims, only when return value is not . /// Whether to check for expiration. /// The status of the validation. - public static ManifestStatus Validate(string jwt, string jwk, out SecurityToken? token, out ClaimsPrincipal? principal, bool validateExpiration) + public static ManifestStatus Validate(string jwt, string jwk, out SecurityToken? token, out ClaimsIdentity? identity, bool validateExpiration) { token = default; - principal = default; + identity = default; SecurityKey key; try @@ -121,7 +151,7 @@ public static ManifestStatus Validate(string jwt, string jwk, out SecurityToken? return ManifestStatus.Unknown; } - var handler = new JwtSecurityTokenHandler { MapInboundClaims = false }; + var handler = new JsonWebTokenHandler { MapInboundClaims = false }; if (!handler.CanReadToken(jwt)) return ManifestStatus.Unknown; @@ -138,32 +168,40 @@ public static ManifestStatus Validate(string jwt, string jwk, out SecurityToken? NameClaimType = "sub", }; - try + var result = handler.ValidateTokenAsync(jwt, validation).Result; + if (result.Exception != null) { - principal = handler.ValidateToken(jwt, validation, out token); - if (validateExpiration && token.ValidTo == DateTime.MinValue) + 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; + } + } - // The sponsorable manifest does not have an expiration time. - if (validateExpiration && token.ValidTo < DateTimeOffset.UtcNow) - return ManifestStatus.Expired; + token = result.SecurityToken; + identity = new ClaimsIdentity(result.ClaimsIdentity.Claims, "JWT"); - return ManifestStatus.Valid; - } - catch (SecurityTokenInvalidSignatureException) - { - var jwtToken = handler.ReadJwtToken(jwt); - token = jwtToken; - principal = new ClaimsPrincipal(new ClaimsIdentity(jwtToken.Claims)); - return ManifestStatus.Invalid; - } - catch (SecurityTokenException) - { - var jwtToken = handler.ReadJwtToken(jwt); - token = jwtToken; - principal = new ClaimsPrincipal(new ClaimsIdentity(jwtToken.Claims)); + 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 index 4b00feb..740b146 100644 --- a/src/SponsorLink/SponsorLink/SponsorLink.csproj +++ b/src/SponsorLink/SponsorLink/SponsorLink.csproj @@ -5,10 +5,10 @@ SponsorLink disable false + CoreResGen;$(CoreCompileDependsOn) - $([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)devlooped.pub.jwk')) $(Product) @@ -24,23 +24,56 @@ - - - - - + - - + + - - - - + + + + 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 index 2e97528..0cf507f 100644 --- a/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs +++ b/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs @@ -1,16 +1,12 @@ // #nullable enable using System; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Diagnostics; using System.IO; using System.Linq; -using Humanizer; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; using static Devlooped.Sponsors.SponsorLink; -using static ThisAssembly.Constants; namespace Devlooped.Sponsors; @@ -20,19 +16,7 @@ namespace Devlooped.Sponsors; [DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)] public class SponsorLinkAnalyzer : DiagnosticAnalyzer { - static readonly int graceDays = int.Parse(Funding.Grace); - static readonly Dictionary descriptors = new() - { - // Requires: - // - // - { SponsorStatus.Unknown, Diagnostics.GetDescriptor([.. Sponsorables.Keys], Funding.Product, Funding.Prefix, SponsorStatus.Unknown) }, - { SponsorStatus.Sponsor, Diagnostics.GetDescriptor([.. Sponsorables.Keys], Funding.Product, Funding.Prefix, SponsorStatus.Sponsor) }, - { SponsorStatus.Expiring, Diagnostics.GetDescriptor([.. Sponsorables.Keys], Funding.Product, Funding.Prefix, SponsorStatus.Expiring) }, - { SponsorStatus.Expired, Diagnostics.GetDescriptor([.. Sponsorables.Keys], Funding.Product, Funding.Prefix, SponsorStatus.Expired) }, - }; - - public override ImmutableArray SupportedDiagnostics { get; } = descriptors.Values.ToImmutableArray(); + public override ImmutableArray SupportedDiagnostics { get; } = DiagnosticsManager.KnownDescriptors.Values.ToImmutableArray(); #pragma warning disable RS1026 // Enable concurrent execution public override void Initialize(AnalysisContext context) @@ -50,77 +34,56 @@ public override void Initialize(AnalysisContext context) // analyzers can report the same diagnostic and we want to avoid duplicates. context.RegisterCompilationStartAction(ctx => { - var manifests = ctx.Options.AdditionalFiles - .Where(x => - ctx.Options.AnalyzerConfigOptionsProvider.GetOptions(x).TryGetValue("build_metadata.AdditionalFiles.SourceItemType", out var itemType) && - itemType == "SponsorManifest" && - Sponsorables.ContainsKey(Path.GetFileNameWithoutExtension(x.Path))) - .ToImmutableArray(); - // Setting the status early allows other analyzers to potentially check for it. - var status = SetStatus(manifests); + var status = Diagnostics.GetOrSetStatus(() => ctx.Options); + // Never report any diagnostic unless we're in an editor. if (IsEditor) { - // NOTE: even if we don't report the diagnostic, we still set the status so other analyzers can use it. ctx.RegisterCompilationEndAction(ctx => { - if (Diagnostics.Pop(Funding.Product) is Diagnostic diagnostic) + // 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); - } - else - { - // This should never happen and would be a bug. - Debug.Assert(true, "We should have provided a diagnostic of some kind for " + Funding.Product); - // We'll report it as unknown as a fallback for now. - ctx.ReportDiagnostic(Diagnostic.Create(descriptors[SponsorStatus.Unknown], null, - properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Unknown)), - Funding.Product, Sponsorables.Keys.Humanize(ThisAssembly.Strings.Or))); - } + }); }); } }); #pragma warning restore RS1013 // Start action has no registered non-end actions } - - SponsorStatus SetStatus(ImmutableArray manifests) - { - if (!SponsorLink.TryRead(out var claims, manifests.Select(text => - (text.GetText()?.ToString() ?? "", Sponsorables[Path.GetFileNameWithoutExtension(text.Path)]))) || - claims.GetExpiration() is not DateTime exp) - { - // report unknown, either unparsed manifest or one with no expiration (which we never emit). - Diagnostics.Push(Funding.Product, Diagnostic.Create(descriptors[SponsorStatus.Unknown], null, - properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Unknown)), - Funding.Product, Sponsorables.Keys.Humanize(ThisAssembly.Strings.Or))); - return SponsorStatus.Unknown; - } - else if (exp < DateTime.Now) - { - // report expired or expiring soon if still within the configured days of grace period - if (exp.AddDays(graceDays) < DateTime.Now) - { - // report expiring soon - Diagnostics.Push(Funding.Product, Diagnostic.Create(descriptors[SponsorStatus.Expiring], null, - properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Expiring)))); - return SponsorStatus.Expiring; - } - else - { - // report expired - Diagnostics.Push(Funding.Product, Diagnostic.Create(descriptors[SponsorStatus.Expired], null, - properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Expired)))); - return SponsorStatus.Expired; - } - } - else - { - // report sponsor - Diagnostics.Push(Funding.Product, Diagnostic.Create(descriptors[SponsorStatus.Sponsor], null, - properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Sponsor)), - Funding.Product)); - return SponsorStatus.Sponsor; - } - } } diff --git a/src/SponsorLink/SponsorLink/ThisAssembly.cs b/src/SponsorLink/SponsorLink/ThisAssembly.cs deleted file mode 100644 index 89f2316..0000000 --- a/src/SponsorLink/SponsorLink/ThisAssembly.cs +++ /dev/null @@ -1,31 +0,0 @@ -// -partial class ThisAssembly -{ - partial class Strings - { - partial class Unknown - { - public static string MessageFormat => GetResourceManager("Devlooped.SponsorLink").GetString("Unknown_Message"); - } - - partial class Expiring - { - public static string MessageFormat => GetResourceManager("Devlooped.SponsorLink").GetString("Expiring_Message"); - } - - partial class Expired - { - public static string MessageFormat => GetResourceManager("Devlooped.SponsorLink").GetString("Expired_Message"); - } - - partial class Grace - { - public static string MessageFormat => GetResourceManager("Devlooped.SponsorLink").GetString("Grace_Message"); - } - - partial class Sponsor - { - public static string MessageFormat => GetResourceManager("Devlooped.SponsorLink").GetString("Sponsor_Message"); - } - } -} \ No newline at end of file diff --git a/src/SponsorLink/SponsorLink/Tracing.cs b/src/SponsorLink/SponsorLink/Tracing.cs index 9201796..ad5d9b3 100644 --- a/src/SponsorLink/SponsorLink/Tracing.cs +++ b/src/SponsorLink/SponsorLink/Tracing.cs @@ -10,12 +10,6 @@ namespace Devlooped.Sponsors; static class Tracing { - public static void Trace(string message, object? value, [CallerArgumentExpression("value")] string? expression = null, [CallerFilePath] string? filePath = null, [CallerLineNumber] int lineNumber = 0) - => Trace($"{message}: {value} ({expression})", filePath, lineNumber); - - public static void Trace(object? value, [CallerArgumentExpression("value")] string? expression = null, [CallerFilePath] string? filePath = null, [CallerLineNumber] int lineNumber = 0) - => Trace($"{value} ({expression})", filePath, lineNumber); - public static void Trace([CallerMemberName] string? message = null, [CallerFilePath] string? filePath = null, [CallerLineNumber] int lineNumber = 0) { var trace = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SPONSORLINK_TRACE")); @@ -33,14 +27,16 @@ public static void Trace([CallerMemberName] string? message = null, [CallerFileP .AppendLine($" -> {filePath}({lineNumber})") .ToString(); - var dir = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData, Environment.SpecialFolderOption.Create); + 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, "SponsorLink.log"), line); + File.AppendAllText(Path.Combine(dir, "trace.log"), line); Debugger.Log(0, "SponsorLink", line); return; } diff --git a/src/SponsorLink/SponsorLink/buildTransitive/Devlooped.Sponsors.targets b/src/SponsorLink/SponsorLink/buildTransitive/Devlooped.Sponsors.targets index 471f37f..9f843e2 100644 --- a/src/SponsorLink/SponsorLink/buildTransitive/Devlooped.Sponsors.targets +++ b/src/SponsorLink/SponsorLink/buildTransitive/Devlooped.Sponsors.targets @@ -20,25 +20,29 @@ - + + + + - SL_CollectDependencies + SL_CollectDependencies;SL_CollectSponsorableAnalyzer $(SLDependsOn);SL_CheckAutoSync;SL_ReadAutoSyncEnabled;SL_SyncSponsors - + + @@ -47,6 +51,18 @@ + + + %(SponsorablePackageId.Identity) + + + + + + + @@ -84,10 +100,13 @@ It's possible that some manifests will need interactive sync, and we'll render the messages in that case. Note that since running this requires autosync=true, we can safely assume the user - has already run `gh sponsors [...] -autosync` at least once to turn it on. Otherwise, + has already run `sponsorlink [...] -autosync` at least once to turn it on. Otherwise, this target won't run at all. + Note that since we don't specify -f (force), we only sync if the local manifest is expired, + so as not to slow the build unnecessarily. Analyzer checking for the manifest will still + check the validity of the manifest using the embedded key. --> - + diff --git a/src/SponsorLink/SponsorLink/devlooped.pub.jwk b/src/SponsorLink/SponsorLink/devlooped.pub.jwk deleted file mode 100644 index cdf45c2..0000000 --- a/src/SponsorLink/SponsorLink/devlooped.pub.jwk +++ /dev/null @@ -1,5 +0,0 @@ -{ - "e": "AQAB", - "kty": "RSA", - "n": "5inhv8QymaDBOihNi1eY-6-hcIB5qSONFZxbxxXAyOtxAdjFCPM-94gIZqM9CDrX3pyg1lTJfml_a_FZSU9dB1ii5mSX_mNHBFXn1_l_gi1ErdbkIF5YbW6oxWFxf3G5mwVXwnPfxHTyQdmWQ3YJR-A3EB4kaFwLqA6Ha5lb2ObGpMTQJNakD4oTAGDhqHMGhu6PupGq5ie4qZcQ7N8ANw8xH7nicTkbqEhQABHWOTmLBWq5f5F6RYGF8P7cl0IWl_w4YcIZkGm2vX2fi26F9F60cU1v13GZEVDTXpJ9kzvYeM9sYk6fWaoyY2jhE51qbv0B0u6hScZiLREtm3n7ClJbIGXhkUppFS2JlNaX3rgQ6t-4LK8gUTyLt3zDs2H8OZyCwlCpfmGmdsUMkm1xX6t2r-95U3zywynxoWZfjBCJf41leM9OMKYwNWZ6LQMyo83HWw1PBIrX4ZLClFwqBcSYsXDyT8_ZLd1cdYmPfmtllIXxZhLClwT5qbCWv73V" -} \ No newline at end of file diff --git a/src/SponsorLink/Tests/.netconfig b/src/SponsorLink/Tests/.netconfig index 3b3bd0d..092c205 100644 --- a/src/SponsorLink/Tests/.netconfig +++ b/src/SponsorLink/Tests/.netconfig @@ -1,15 +1,17 @@ +[config] + root = true [file "SponsorableManifest.cs"] url = https://github.com/devlooped/SponsorLink/blob/main/src/Core/SponsorableManifest.cs - sha = 976ecefc44d87217e04933d9cd7f6b950468410b - etag = e0c95e7fc6c0499dbc8c5cd28aa9a6a5a49c9d0ad41fe028a5a085aca7e00eaf + sha = 5a4cad3a084f53afe34a6b75e4f3a084a0f1bf9e + etag = 9a07c856d06e0cde629fce3ec014f64f9adfd5ae5805a35acf623eba0ee045c1 weak [file "JsonOptions.cs"] url = https://github.com/devlooped/SponsorLink/blob/main/src/Core/JsonOptions.cs - sha = 79dc56ce45fc36df49e1c4f8875e93c297edc383 - etag = 6e9a1b12757a97491441b9534ced4e5dac6d9d6334008fa0cd20575650bbd935 + sha = 80ea1bfe47049ef6c6ed4f424dcf7febb729cbba + etag = 17799725ad9b24eb5998365962c30b9a487bddadca37c616e35b76b8c9eb161a weak [file "Extensions.cs"] url = https://github.com/devlooped/SponsorLink/blob/main/src/Core/Extensions.cs - sha = d204b667eace818934c49e09b5b08ea82aef87fa - etag = f68e11894103f8748ce290c29927bf1e4f749e743ae33d5350e72ed22c15d245 - weak + sha = c455f6fa1a4d404181d076d7f3362345c8ed7df2 + etag = 9e51b7e6540fae140490a5283b1e67ce071bd18a267bc2ae0b35c7248261aed1 + weak \ No newline at end of file diff --git a/src/SponsorLink/Tests/Extensions.cs b/src/SponsorLink/Tests/Extensions.cs index 75a78b4..4063f78 100644 --- a/src/SponsorLink/Tests/Extensions.cs +++ b/src/SponsorLink/Tests/Extensions.cs @@ -1,6 +1,8 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; +using System.Security.Cryptography; using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; namespace Devlooped.Sponsors; @@ -23,6 +25,17 @@ public static HashCode AddRange(this HashCode hash, IEnumerable items) 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. diff --git a/src/SponsorLink/Tests/JsonOptions.cs b/src/SponsorLink/Tests/JsonOptions.cs index c816eba..b2349b0 100644 --- a/src/SponsorLink/Tests/JsonOptions.cs +++ b/src/SponsorLink/Tests/JsonOptions.cs @@ -1,6 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Globalization; +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; 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 index 6249e62..3ea4a32 100644 --- a/src/SponsorLink/Tests/Sample.cs +++ b/src/SponsorLink/Tests/Sample.cs @@ -4,6 +4,7 @@ using System.Runtime.CompilerServices; using System.Security.Cryptography; using Analyzer::Devlooped.Sponsors; +using Microsoft.CodeAnalysis; using Xunit; using Xunit.Abstractions; @@ -29,7 +30,7 @@ public void Test(string culture, SponsorStatus kind) Thread.CurrentThread.CurrentCulture = Thread.CurrentThread.CurrentUICulture = culture == "" ? CultureInfo.InvariantCulture : CultureInfo.GetCultureInfo(culture); - var diag = new DiagnosticsManager().GetDescriptor(["foo"], "bar", "FB", kind); + var diag = GetDescriptor(["foo"], "bar", "FB", kind); output.WriteLine(diag.Title.ToString()); output.WriteLine(diag.MessageFormat.ToString()); @@ -56,4 +57,13 @@ public void RenderSponsorables() }); } } + + 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/SponsorableManifest.cs b/src/SponsorLink/Tests/SponsorableManifest.cs index 5ae6e3f..d65d0fb 100644 --- a/src/SponsorLink/Tests/SponsorableManifest.cs +++ b/src/SponsorLink/Tests/SponsorableManifest.cs @@ -1,8 +1,8 @@ using System.Diagnostics.CodeAnalysis; -using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Security.Cryptography; using System.Text.Json; +using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; namespace Devlooped.Sponsors; @@ -42,25 +42,24 @@ public enum Status public static SponsorableManifest Create(Uri issuer, Uri[] audience, string clientId) { var rsa = RSA.Create(3072); - var pub = Convert.ToBase64String(rsa.ExportRSAPublicKey()); - - return new SponsorableManifest(issuer, audience, clientId, new RsaSecurityKey(rsa), pub); + 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. - using (http ??= new HttpClient()) + try { - var response = await http.GetAsync(url); + 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 var missingClaim)) + if (!TryRead(jwt, out var manifest, out _)) return (Status.Invalid, default); // Manifest audience should match the sponsorable account to avoid weird issues? @@ -69,6 +68,11 @@ public static SponsorableManifest Create(Uri issuer, Uri[] audience, string clie return (Status.OK, manifest); } + finally + { + if (disposeHttp) + http?.Dispose(); + } } /// @@ -80,14 +84,18 @@ public static SponsorableManifest Create(Uri issuer, Uri[] audience, string clie /// A validated manifest. public static bool TryRead(string jwt, [NotNullWhen(true)] out SponsorableManifest? manifest, out string? missingClaim) { - var handler = new JwtSecurityTokenHandler { MapInboundClaims = false }; + var handler = new JsonWebTokenHandler + { + MapInboundClaims = false, + SetDefaultTimesOnTokenCreation = false, + }; missingClaim = null; manifest = default; if (!handler.CanReadToken(jwt)) return false; - var token = handler.ReadJwtToken(jwt); + var token = handler.ReadJsonWebToken(jwt); var issuer = token.Issuer; if (token.Audiences.FirstOrDefault(x => x.StartsWith("https://github.com/")) is null) @@ -102,12 +110,6 @@ public static bool TryRead(string jwt, [NotNullWhen(true)] out SponsorableManife return false; } - if (token.Claims.FirstOrDefault(c => c.Type == "pub")?.Value is not string pub) - { - missingClaim = "pub"; - return false; - } - if (token.Claims.FirstOrDefault(c => c.Type == "sub_jwk")?.Value is not string jwk) { missingClaim = "sub_jwk"; @@ -115,20 +117,26 @@ public static bool TryRead(string jwt, [NotNullWhen(true)] out SponsorableManife } var key = new JsonWebKeySet { Keys = { JsonWebKey.Create(jwk) } }.GetSigningKeys().First(); - manifest = new SponsorableManifest(new Uri(issuer), token.Audiences.Select(x => new Uri(x)).ToArray(), clientId, key, pub); + manifest = new SponsorableManifest(new Uri(issuer), token.Audiences.Select(x => new Uri(x)).ToArray(), clientId, key); return true; } - public SponsorableManifest(Uri issuer, Uri[] audience, string clientId, SecurityKey publicKey, string publicRsaKey) + int hashcode; + string clientId; + string issuer; + + public SponsorableManifest(Uri issuer, Uri[] audience, string clientId, SecurityKey publicKey) { - Issuer = issuer.AbsoluteUri; + this.clientId = clientId; + this.issuer = issuer.AbsoluteUri; Audience = audience.Select(a => a.AbsoluteUri.TrimEnd('/')).ToArray(); - ClientId = clientId; SecurityKey = publicKey; - PublicKey = publicRsaKey; Sponsorable = audience.Where(x => x.Host == "github.com").Select(x => x.Segments.LastOrDefault()?.TrimEnd('/')).FirstOrDefault() ?? throw new ArgumentException("At least one of the intended audience must be a GitHub sponsors URL."); + + // Force hash code to be computed + ClientId = clientId; } /// @@ -149,23 +157,29 @@ public string ToJwt(SigningCredentials? signing = default) jwk = JsonWebKeyConverter.ConvertFromRSASecurityKey(new RsaSecurityKey(rsa.Rsa.ExportParameters(false))); } - var token = new JwtSecurityToken( - claims: - new[] { new Claim(JwtRegisteredClaimNames.Iss, Issuer) } - .Concat(Audience.Select(x => new Claim(JwtRegisteredClaimNames.Aud, x))) - .Concat( - [ - // See https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.6 - new(JwtRegisteredClaimNames.Iat, Math.Truncate((DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds).ToString()), - new("client_id", ClientId), - // non-standard claim containing the base64-encoded public key - new("pub", PublicKey), - // standard claim, serialized as a JSON string, not an encoded JSON object - new("sub_jwk", JsonSerializer.Serialize(jwk, JsonOptions.JsonWebKey), JsonClaimValueTypes.Json), - ]), - signingCredentials: signing); - - return new JwtSecurityTokenHandler().WriteToken(token); + 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, + }); } /// @@ -178,7 +192,7 @@ public string Sign(IEnumerable claims, RsaSecurityKey? key = default, Tim { var rsa = key ?? SecurityKey as RsaSecurityKey; if (rsa?.PrivateKeyStatus != PrivateKeyStatus.Exists) - throw new NotSupportedException("No private key found to sign the manifest."); + throw new NotSupportedException("No private key found or specified to sign the manifest."); var signing = new SigningCredentials(rsa, SecurityAlgorithms.RsaSha256); @@ -195,11 +209,9 @@ public string Sign(IEnumerable claims, RsaSecurityKey? key = default, Tim 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(); - // See https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.6 - tokenClaims.Add(new(JwtRegisteredClaimNames.Iat, Math.Truncate((DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds).ToString())); - if (tokenClaims.Find(c => c.Type == JwtRegisteredClaimNames.Iss) is { } issuer) { if (issuer.Value != Issuer) @@ -228,38 +240,55 @@ public string Sign(IEnumerable claims, RsaSecurityKey? key = default, Tim tokenClaims.Insert(1, new(JwtRegisteredClaimNames.Aud, audience)); } - // The other claims (client_id, pub, sub_jwk) claims are mostly for the SL manifest itself, - // not for the user, so for now we don't add them. - // Don't allow mismatches of public manifest key and the one used to sign, to avoid // weird run-time errors verifiying manifests that were signed with a different key. - var pubKey = Convert.ToBase64String(rsa.Rsa.ExportRSAPublicKey()); - if (pubKey != PublicKey) + if (!rsa.ThumbprintEquals(SecurityKey)) throw new ArgumentException($"Cannot sign with a private key that does not match the manifest public key."); - var jwt = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( - claims: tokenClaims, - expires: expirationDate, - signingCredentials: signing - )); - - return jwt; + return new JsonWebTokenHandler + { + MapInboundClaims = false, + SetDefaultTimesOnTokenCreation = false, + }.CreateToken(new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(tokenClaims), + IssuedAt = DateTime.UtcNow, + Expires = expirationDate, + SigningCredentials = signing, + }); } - public ClaimsPrincipal Validate(string jwt, out SecurityToken? token) => new JwtSecurityTokenHandler().ValidateToken(jwt, new TokenValidationParameters + public ClaimsIdentity Validate(string jwt, out SecurityToken? token) { - RequireExpirationTime = true, - // NOTE: setting this to false allows checking sponsorships even when the manifest is expired. - // This might be useful if package authors want to extend the manifest lifetime beyond the default - // 30 days and issue a warning on expiration, rather than an error and a forced sync. - // If this is not set (or true), a SecurityTokenExpiredException exception will be thrown. - ValidateLifetime = false, - RequireAudience = true, - // At least one of the audiences must match the manifest audiences - AudienceValidator = (audiences, _, _) => Audience.Intersect(audiences.Select(x => x.TrimEnd('/'))).Any(), - ValidIssuer = Issuer, - IssuerSigningKey = SecurityKey, - }, out token); + 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. @@ -272,7 +301,16 @@ public string Sign(IEnumerable claims, RsaSecurityKey? key = default, Tim /// /// See https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.1 /// - public string Issuer { get; } + 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. @@ -289,12 +327,16 @@ public string Sign(IEnumerable claims, RsaSecurityKey? key = default, Tim /// /// See https://www.rfc-editor.org/rfc/rfc8693.html#name-client_id-client-identifier /// - public string ClientId { get; internal set; } - - /// - /// Public key that can be used to verify JWT signatures. - /// - public string PublicKey { get; } + public 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. @@ -302,7 +344,7 @@ public string Sign(IEnumerable claims, RsaSecurityKey? key = default, Tim public SecurityKey SecurityKey { get; } /// - public override int GetHashCode() => new HashCode().Add(Issuer, ClientId, PublicKey).AddRange(Audience).ToHashCode(); + 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 index f753aad..5082c97 100644 --- a/src/SponsorLink/Tests/Tests.csproj +++ b/src/SponsorLink/Tests/Tests.csproj @@ -2,14 +2,14 @@ net8.0 + true - - + @@ -20,7 +20,22 @@ - + + + + + + True + True + Resources.resx + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + @@ -37,6 +52,18 @@ + + + + + + + + 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