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/.github/workflows/sponsor.yml b/.github/workflows/sponsor.yml
index 1d484d3..c6a6f92 100644
--- a/.github/workflows/sponsor.yml
+++ b/.github/workflows/sponsor.yml
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
continue-on-error: true
env:
- token: ${{ secrets.GH_TOKEN }}
+ token: ${{ secrets.DEVLOOPED_TOKEN }}
if: ${{ !endsWith(github.event.sender.login, '[bot]') && !endsWith(github.event.sender.login, 'bot') }}
steps:
- name: 🤘 checkout
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