diff --git a/.github/workflows/os-matrix.json b/.github/workflows/os-matrix.json
new file mode 100644
index 0000000..adbf722
--- /dev/null
+++ b/.github/workflows/os-matrix.json
@@ -0,0 +1 @@
+[ "ubuntu-latest", "macOS-latest", "windows-latest" ]
\ No newline at end of file
diff --git a/.github/workflows/sponsor.yml b/.github/workflows/sponsor.yml
deleted file mode 100644
index 1d484d3..0000000
--- a/.github/workflows/sponsor.yml
+++ /dev/null
@@ -1,24 +0,0 @@
-name: sponsor 💜
-on:
- issues:
- types: [opened, edited, reopened]
- pull_request:
- types: [opened, edited, synchronize, reopened]
-
-jobs:
- sponsor:
- runs-on: ubuntu-latest
- continue-on-error: true
- env:
- token: ${{ secrets.GH_TOKEN }}
- if: ${{ !endsWith(github.event.sender.login, '[bot]') && !endsWith(github.event.sender.login, 'bot') }}
- steps:
- - name: 🤘 checkout
- if: env.token != ''
- uses: actions/checkout@v4
-
- - name: 💜 sponsor
- if: env.token != ''
- uses: devlooped/actions-sponsor@main
- with:
- token: ${{ env.token }}
diff --git a/.netconfig b/.netconfig
index fe19480..0721182 100644
--- a/.netconfig
+++ b/.netconfig
@@ -301,11 +301,6 @@
sha = c879f25bf483086725c8a29f104555644e6ee542
etag = fcb46a86511cb7996e8dcd1f4e283cea9cd51610b094ac49a7396301730814b0
weak
-[file "src/SponsorLink/Tests/Resources.Designer.cs"]
- url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Tests/Resources.Designer.cs
- sha = c879f25bf483086725c8a29f104555644e6ee542
- etag = 69404ac09238930893fdbc225ae7839b14957e129b4c05f1ef0e7afcc4c91d63
- weak
[file "src/SponsorLink/Tests/Resources.resx"]
url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Tests/Resources.resx
sha = c879f25bf483086725c8a29f104555644e6ee542
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 1648dcd..381c383 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -46,8 +46,6 @@
Release
- true
- false
Latest
diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets
index 0cb1e4e..20d7f0b 100644
--- a/src/Directory.Build.targets
+++ b/src/Directory.Build.targets
@@ -4,6 +4,13 @@
CI;$(DefineConstants)
+
+
+
+ false
+ false
+ true
+
true
diff --git a/src/SponsorLink/Analyzer/Analyzer.csproj b/src/SponsorLink/Analyzer/Analyzer.csproj
index f65390a..b274f5e 100644
--- a/src/SponsorLink/Analyzer/Analyzer.csproj
+++ b/src/SponsorLink/Analyzer/Analyzer.csproj
@@ -6,9 +6,11 @@
true
analyzers/dotnet/roslyn4.0
true
+ false
+ true
$(MSBuildThisFileDirectory)..\SponsorLink.targets
- true
disable
+ SponsorableLib
@@ -22,16 +24,23 @@
-
-
-
-
-
+
+
+
+
+
+ $([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)..\Tests\keys\kzu.pub.jwk'))
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs b/src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs
index ad82ed3..d7a1fd7 100644
--- a/src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs
+++ b/src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs
@@ -1,25 +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;
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..a1437f9
--- /dev/null
+++ b/src/SponsorLink/Analyzer/StatusReportingGenerator.cs
@@ -0,0 +1,23 @@
+using Devlooped.Sponsors;
+using Microsoft.CodeAnalysis;
+using static Devlooped.Sponsors.SponsorLink;
+
+namespace Analyzer;
+
+[Generator]
+public class StatusReportingGenerator : IIncrementalGenerator
+{
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ context.RegisterSourceOutput(
+ // this is required to ensure status is registered properly independently
+ // of analyzer runs.
+ context.GetSponsorAdditionalFiles().Combine(context.AnalyzerConfigOptionsProvider),
+ (spc, source) =>
+ {
+ var (manifests, options) = source;
+ var status = Diagnostics.GetOrSetStatus(manifests, options);
+ spc.AddSource("StatusReporting.cs", $"// Status: {status}");
+ });
+ }
+}
diff --git a/src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets b/src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets
index fd1e6e4..8c0f8ef 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/Library/Library.csproj b/src/SponsorLink/Library/Library.csproj
index f363648..6e79399 100644
--- a/src/SponsorLink/Library/Library.csproj
+++ b/src/SponsorLink/Library/Library.csproj
@@ -18,4 +18,8 @@
+
+
+
+
diff --git a/src/SponsorLink/SponsorLink.Tests.targets b/src/SponsorLink/SponsorLink.Tests.targets
index ffc7586..1ca1eb6 100644
--- a/src/SponsorLink/SponsorLink.Tests.targets
+++ b/src/SponsorLink/SponsorLink.Tests.targets
@@ -7,12 +7,12 @@
-
+
-
+
diff --git a/src/SponsorLink/SponsorLink.targets b/src/SponsorLink/SponsorLink.targets
index 6de86fb..72304cb 100644
--- a/src/SponsorLink/SponsorLink.targets
+++ b/src/SponsorLink/SponsorLink.targets
@@ -6,7 +6,8 @@
true
- true
+ true
+ false
true
@@ -14,10 +15,11 @@
$(Product)
+ $(PackageId)
$([System.Text.RegularExpressions.Regex]::Replace("$(FundingProduct)", "[^A-Z]", ""))
-
- 21
+
+ 15
@@ -68,28 +70,38 @@
+
+ true
+ false
+
+
-
+
-
+
-
+
-
+
-
+
+
+
+ $(FundingProduct)
namespace Devlooped.Sponsors%3B
partial class SponsorLink
{
public partial class Funding
{
+ public const string PackageId = "$(FundingPackageId)"%3B
public const string Product = "$(FundingProduct)"%3B
public const string Prefix = "$(FundingPrefix)"%3B
public const int Grace = $(FundingGrace)%3B
@@ -183,4 +195,9 @@ partial class SponsorLink
+
+
+ true
+
+
\ No newline at end of file
diff --git a/src/SponsorLink/SponsorLink/DiagnosticsManager.cs b/src/SponsorLink/SponsorLink/DiagnosticsManager.cs
index c22ecc8..9320ad2 100644
--- a/src/SponsorLink/SponsorLink/DiagnosticsManager.cs
+++ b/src/SponsorLink/SponsorLink/DiagnosticsManager.cs
@@ -2,9 +2,20 @@
#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.Runtime.InteropServices;
+using System.Threading;
using Humanizer;
+using Humanizer.Localisation;
using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using static Devlooped.Sponsors.SponsorLink;
namespace Devlooped.Sponsors;
@@ -14,93 +25,211 @@ namespace Devlooped.Sponsors;
///
class DiagnosticsManager
{
+ static readonly Guid appDomainDiagnosticsKey = new(0x8d0e2670, 0xe6c4, 0x45c8, 0x81, 0xba, 0x5a, 0x36, 0x81, 0xd3, 0x65, 0x3e);
+
+ public static Dictionary KnownDescriptors { get; } = new()
+ {
+ // Requires:
+ //
+ //
+ { SponsorStatus.Unknown, CreateUnknown([.. Sponsorables.Keys], Funding.Product, Funding.Prefix) },
+ { SponsorStatus.Sponsor, CreateSponsor([.. Sponsorables.Keys], Funding.Prefix) },
+ { SponsorStatus.Expiring, CreateExpiring([.. Sponsorables.Keys], Funding.Prefix) },
+ { SponsorStatus.Expired, CreateExpired([.. Sponsorables.Keys], Funding.Prefix) },
+ };
+
///
/// Acceses the diagnostics dictionary for the current .
///
ConcurrentDictionary Diagnostics
- => AppDomainDictionary.Get>(nameof(Diagnostics));
+ => AppDomainDictionary.Get>(appDomainDiagnosticsKey.ToString());
///
- /// Creates a descriptor from well-known diagnostic kinds.
+ /// Attemps to remove a diagnostic for the given product.
///
- /// 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
+ /// The product diagnostic that might have been pushed previously.
+ /// The removed diagnostic, or if none was previously pushed.
+ public void ReportOnce(Action report, string product = Funding.Product)
{
- SponsorStatus.Unknown => CreateUnknown(sponsorable, product, prefix),
- SponsorStatus.Sponsor => CreateSponsor(sponsorable, prefix),
- SponsorStatus.Expiring => CreateExpiring(sponsorable, prefix),
- SponsorStatus.Expired => CreateExpired(sponsorable, prefix),
- _ => throw new NotImplementedException(),
- };
+ if (Diagnostics.TryRemove(product, out var diagnostic) &&
+ GetStatus(diagnostic) != SponsorStatus.Grace)
+ {
+ // 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 = CreateOrOpenMemoryMappedFile(id, 1);
+ using var accessor = mmf.CreateViewAccessor();
+ if (accessor.ReadByte(0) == 0)
+ {
+ accessor.Write(0, (byte)1);
+ report(diagnostic);
+ Tracing.Trace($"👈{diagnostic.Severity.ToString().ToLowerInvariant()}:{Process.GetCurrentProcess().Id}:{Process.GetCurrentProcess().ProcessName}:{product}:{diagnostic.Id}");
+ }
+ }
+ }
///
- /// Pushes a diagnostic for the given product. If an existing one exists, it is replaced.
+ /// 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 same diagnostic that was pushed, for chained invocations.
- public Diagnostic Push(string product, Diagnostic diagnostic)
+ /// Optional that was reported, if any.
+ ///
+ /// The SponsorLinkAnalyzer.GetOrSetStatus uses diagnostic properties to store the
+ /// kind of diagnostic as a simple string instead of the enum. We do this so that
+ /// multiple analyzers or versions even across multiple products, which all would
+ /// have their own enum, can still share the same diagnostic kind.
+ ///
+ public SponsorStatus? GetStatus()
+ => Diagnostics.TryGetValue(Funding.Product, out var diagnostic) ? GetStatus(diagnostic) : null;
+
+ ///
+ /// Gets the status of the , or sets it from
+ /// the given set of if not already set.
+ ///
+ public SponsorStatus GetOrSetStatus(ImmutableArray manifests, AnalyzerConfigOptionsProvider options)
+ => GetOrSetStatus(() => manifests, () => options.GlobalOptions);
+
+ ///
+ /// Gets the status of the , or sets it from
+ /// the given analyzer if not already set.
+ ///
+ public SponsorStatus GetOrSetStatus(Func options)
+ => GetOrSetStatus(() => options().GetSponsorAdditionalFiles(), () => options()?.AnalyzerConfigOptionsProvider.GlobalOptions);
+
+ SponsorStatus GetOrSetStatus(Func> getAdditionalFiles, Func getGlobalOptions)
{
- // Directly sets, since we only expect to get one warning per sponsorable+product
- // combination.
- Diagnostics[product] = diagnostic;
- return diagnostic;
+ if (GetStatus() is { } status)
+ return status;
+
+ if (!SponsorLink.TryRead(out var claims, getAdditionalFiles().Where(x => x.Path.EndsWith(".jwt")).Select(text =>
+ (text.GetText()?.ToString() ?? "", Sponsorables[Path.GetFileNameWithoutExtension(text.Path)]))) ||
+ claims.GetExpiration() is not DateTime exp)
+ {
+ var noGrace = getGlobalOptions() is { } globalOptions &&
+ globalOptions.TryGetValue("build_property.SponsorLinkNoInstallGrace", out var value) &&
+ bool.TryParse(value, out var skipCheck) && skipCheck;
+
+ if (noGrace != true)
+ {
+ // Consider grace period if we can find the install time.
+ var installed = getAdditionalFiles()
+ .Where(x => x.Path.EndsWith(".dll"))
+ .Select(x => File.GetLastWriteTime(x.Path))
+ .OrderByDescending(x => x)
+ .FirstOrDefault();
+
+ if (installed != default && ((DateTime.Now - installed).TotalDays <= Funding.Grace))
+ {
+ // 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.Grace)),
+ Funding.Product, Sponsorables.Keys.Humanize(Resources.Or)));
+ return SponsorStatus.Grace;
+ }
+ }
+
+ // 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;
+ }
}
///
- /// Attemps to remove a diagnostic for the given product.
+ /// Pushes 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)
+ /// The same diagnostic that was pushed, for chained invocations.
+ Diagnostic Push(Diagnostic diagnostic, string product = Funding.Product)
{
- Diagnostics.TryRemove(product, out var diagnostic);
+ // 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 = CreateOrOpenMemoryMappedFile(id, 1);
+ using var accessor = mmf.CreateViewAccessor();
+ accessor.Write(0, (byte)0);
+ Tracing.Trace($"👉{diagnostic.Severity.ToString().ToLowerInvariant()}:{Process.GetCurrentProcess().Id}:{Process.GetCurrentProcess().ProcessName}:{product}:{diagnostic.Id}");
+ }
+
return diagnostic;
}
- ///
- /// Gets the status of the given product based on a previously stored diagnostic.
- ///
- /// The product to check status for.
- /// Optional that was reported, if any.
- public SponsorStatus? GetStatus(string product)
+ SponsorStatus? GetStatus(Diagnostic? diagnostic) => diagnostic?.Properties.TryGetValue(nameof(SponsorStatus), out var value) == true
+ ? value switch
+ {
+ nameof(SponsorStatus.Grace) => SponsorStatus.Grace,
+ nameof(SponsorStatus.Unknown) => SponsorStatus.Unknown,
+ nameof(SponsorStatus.Sponsor) => SponsorStatus.Sponsor,
+ nameof(SponsorStatus.Expiring) => SponsorStatus.Expiring,
+ nameof(SponsorStatus.Expired) => SponsorStatus.Expired,
+ _ => null,
+ }
+ : null;
+
+ static MemoryMappedFile CreateOrOpenMemoryMappedFile(string mapName, int capacity)
{
- // 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) &&
- diagnostic.Properties.TryGetValue(nameof(SponsorStatus), out var value))
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
- // Switch on value matching DiagnosticKind names
- return value switch
- {
- nameof(SponsorStatus.Unknown) => SponsorStatus.Unknown,
- nameof(SponsorStatus.Sponsor) => SponsorStatus.Sponsor,
- nameof(SponsorStatus.Expiring) => SponsorStatus.Expiring,
- nameof(SponsorStatus.Expired) => SponsorStatus.Expired,
- _ => null,
- };
+ return MemoryMappedFile.CreateOrOpen(mapName, capacity);
}
+ else
+ {
+ // On Linux, use a file-based memory-mapped file
+ string filePath = $"/tmp/{mapName}";
+ using (var fs = new FileStream(filePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite))
+ fs.Write(new byte[capacity], 0, capacity);
- return null;
+ return MemoryMappedFile.CreateFromFile(filePath, FileMode.OpenOrCreate);
+ }
}
- static DiagnosticDescriptor CreateSponsor(string[] sponsorable, string prefix) => new(
- $"{prefix}100",
- Resources.Sponsor_Title,
- Resources.Sponsor_Message,
- "SponsorLink",
- DiagnosticSeverity.Info,
- isEnabledByDefault: true,
- description: Resources.Sponsor_Description,
- helpLinkUri: "https://github.com/devlooped#sponsorlink",
- "DoesNotSupportF1Help");
+ internal static DiagnosticDescriptor CreateSponsor(string[] sponsorable, string prefix) => new(
+ $"{prefix}100",
+ Resources.Sponsor_Title,
+ Resources.Sponsor_Message,
+ "SponsorLink",
+ DiagnosticSeverity.Info,
+ isEnabledByDefault: true,
+ description: Resources.Sponsor_Description,
+ helpLinkUri: "https://github.com/devlooped#sponsorlink",
+ "DoesNotSupportF1Help");
- static DiagnosticDescriptor CreateUnknown(string[] sponsorable, string product, string prefix) => new(
+ internal static DiagnosticDescriptor CreateUnknown(string[] sponsorable, string product, string prefix) => new(
$"{prefix}101",
Resources.Unknown_Title,
Resources.Unknown_Message,
@@ -113,7 +242,7 @@ public Diagnostic Push(string product, Diagnostic diagnostic)
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",
Resources.Expiring_Title,
Resources.Expiring_Message,
@@ -124,7 +253,7 @@ public Diagnostic Push(string product, Diagnostic diagnostic)
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",
Resources.Expired_Title,
Resources.Expired_Message,
diff --git a/src/SponsorLink/SponsorLink/SponsorLink.cs b/src/SponsorLink/SponsorLink/SponsorLink.cs
index f3d8328..1efc487 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,39 @@ static partial class SponsorLink
.Select(DateTimeOffset.FromUnixTimeSeconds)
.Max().DateTime is var exp && exp == DateTime.MinValue ? null : exp;
+ ///
+ /// Gets all necessary additional files to determine status.
+ ///
+ public static ImmutableArray GetSponsorAdditionalFiles(this AnalyzerOptions? options)
+ => options == null ? ImmutableArray.Create() : options.AdditionalFiles
+ .Where(x => x.IsSponsorManifest(options.AnalyzerConfigOptionsProvider) || x.IsSponsorableAnalyzer(options.AnalyzerConfigOptionsProvider))
+ .ToImmutableArray();
+
+ ///
+ /// Gets all sponsor manifests from the provided analyzer options.
+ ///
+ public static IncrementalValueProvider> GetSponsorAdditionalFiles(this IncrementalGeneratorInitializationContext context)
+ => context.AdditionalTextsProvider.Combine(context.AnalyzerConfigOptionsProvider)
+ .Where(source =>
+ {
+ var (text, provider) = source;
+ return text.IsSponsorManifest(provider) || text.IsSponsorableAnalyzer(provider);
+ })
+ .Select((source, c) => source.Left)
+ .Collect();
+
+ static bool IsSponsorManifest(this AdditionalText text, AnalyzerConfigOptionsProvider provider)
+ => provider.GetOptions(text).TryGetValue("build_metadata.SponsorManifest.ItemType", out var itemType) &&
+ itemType == "SponsorManifest" &&
+ Sponsorables.ContainsKey(Path.GetFileNameWithoutExtension(text.Path));
+
+ static bool IsSponsorableAnalyzer(this AdditionalText text, AnalyzerConfigOptionsProvider provider)
+ => provider.GetOptions(text) is { } options &&
+ options.TryGetValue("build_metadata.Analyzer.ItemType", out var itemType) &&
+ options.TryGetValue("build_metadata.Analyzer.NuGetPackageId", out var packageId) &&
+ itemType == "Analyzer" &&
+ packageId == Funding.PackageId;
+
///
/// Reads all manifests, validating their signatures.
///
@@ -85,12 +122,12 @@ public static bool TryRead([NotNullWhen(true)] out ClaimsPrincipal? principal, I
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 +140,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 +158,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 +175,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 824353d..6479792 100644
--- a/src/SponsorLink/SponsorLink/SponsorLink.csproj
+++ b/src/SponsorLink/SponsorLink/SponsorLink.csproj
@@ -6,11 +6,13 @@
disable
false
CoreResGen;$(CoreCompileDependsOn)
+ SponsorLink
$(Product)
+ $(PackageId)
$([System.Text.RegularExpressions.Regex]::Replace("$(FundingProduct)", "[^A-Z]", ""))
@@ -24,7 +26,7 @@
-
+
@@ -37,13 +39,18 @@
+
+
+ $(FundingProduct)
namespace Devlooped.Sponsors%3B
partial class SponsorLink
{
public partial class Funding
{
+ public const string PackageId = "$(FundingPackageId)"%3B
public const string Product = "$(FundingProduct)"%3B
public const string Prefix = "$(FundingPrefix)"%3B
public const int Grace = $(FundingGrace)%3B
diff --git a/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs b/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs
index 2bf1783..dcdf2e9 100644
--- a/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs
+++ b/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs
@@ -1,13 +1,6 @@
//
#nullable enable
-using System;
-using System.Collections.Generic;
using System.Collections.Immutable;
-using System.Diagnostics;
-using System.IO;
-using System.Linq;
-using Humanizer;
-using Humanizer.Localisation;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using static Devlooped.Sponsors.SponsorLink;
@@ -20,18 +13,7 @@ namespace Devlooped.Sponsors;
[DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)]
public class SponsorLinkAnalyzer : DiagnosticAnalyzer
{
- 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)
@@ -49,71 +31,19 @@ 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.
+ // NOTE: for multiple projects with the same product name, we only report one diagnostic,
+ // so it's expected to NOT get a diagnostic back. Also, we don't want to report
+ // multiple diagnostics for each project in a solution that uses the same product.
ctx.RegisterCompilationEndAction(ctx =>
- {
- // 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.
- if (Diagnostics.Pop(Funding.Product) is Diagnostic diagnostic)
- {
- ctx.ReportDiagnostic(diagnostic);
- }
- });
+ Diagnostics.ReportOnce(diagnostic => ctx.ReportDiagnostic(diagnostic)));
}
});
#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(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
- 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/SponsorStatus.cs b/src/SponsorLink/SponsorLink/SponsorStatus.cs
index 6cdbc90..97b344e 100644
--- a/src/SponsorLink/SponsorLink/SponsorStatus.cs
+++ b/src/SponsorLink/SponsorLink/SponsorStatus.cs
@@ -11,6 +11,10 @@ public enum SponsorStatus
///
Unknown,
///
+ /// Sponsorship status is unknown, but within the grace period.
+ ///
+ Grace,
+ ///
/// The sponsors manifest is expired but within the grace period.
///
Expiring,
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 de0563e..6e4492a 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 @@
+
+
+ %(FundingPackageId.Identity)
+
+
+
+
+
+
+
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/AnalyzerTests.cs b/src/SponsorLink/Tests/AnalyzerTests.cs
new file mode 100644
index 0000000..daed0fb
--- /dev/null
+++ b/src/SponsorLink/Tests/AnalyzerTests.cs
@@ -0,0 +1,223 @@
+extern alias Analyzer;
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Threading.Tasks;
+using Analyzer::Devlooped.Sponsors;
+using Devlooped.Sponsors;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Text;
+using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.Tokens;
+using Xunit;
+
+namespace Tests;
+
+public class AnalyzerTests : IDisposable
+{
+ static readonly SponsorableManifest sponsorable = new(
+ new Uri("https://sponsorlink.devlooped.com"),
+ [new Uri("https://github.com/sponsors/devlooped"), new Uri("https://github.com/sponsors/kzu")],
+ "a82350fb2bae407b3021",
+ new JsonWebKey(ThisAssembly.Resources.keys.kzu_key.Text));
+
+ public AnalyzerTests()
+ {
+ // Simulate being a VS IDE for analyzers to actually run.
+ if (Environment.GetEnvironmentVariable("VSAPPIDNAME") == null)
+ Environment.SetEnvironmentVariable("VSAPPIDNAME", "test");
+ }
+
+ void IDisposable.Dispose()
+ {
+ if (Environment.GetEnvironmentVariable("VSAPPIDNAME") == "test")
+ Environment.SetEnvironmentVariable("VSAPPIDNAME", null);
+ }
+
+ [Fact]
+ public async Task WhenNoAdditionalFiles_ThenReportsUnknown()
+ {
+ var compilation = CSharpCompilation.Create("test", [CSharpSyntaxTree.ParseText("//")])
+ .WithAnalyzers([new SponsorLinkAnalyzer()]);
+
+ var diagnostics = await compilation.GetAnalyzerDiagnosticsAsync();
+
+ Assert.NotEmpty(diagnostics);
+
+ var diagnostic = diagnostics.Single(x => x.Properties.TryGetValue(nameof(SponsorStatus), out var value));
+
+ Assert.True(diagnostic.Properties.TryGetValue(nameof(SponsorStatus), out var value));
+ var status = Enum.Parse(value);
+
+ Assert.Equal(SponsorStatus.Unknown, status);
+ }
+
+ [Fact]
+ public async Task WhenUnknownAndGrace_ThenDoesNotReport()
+ {
+ // simulate an analyzer file with the right metadata, which is recent and therefore
+ // within the grace period
+ var dll = Path.Combine(GetTempPath(), "FakeAnalyzer.dll");
+ File.WriteAllText(dll, "");
+
+ var compilation = CSharpCompilation.Create("test", [CSharpSyntaxTree.ParseText("//")])
+ .WithAnalyzers([new SponsorLinkAnalyzer()], new AnalyzerOptions([new AdditionalTextFile(dll)], new TestAnalyzerConfigOptionsProvider(new())
+ {
+ { "build_metadata.Analyzer.ItemType", "Analyzer" },
+ { "build_metadata.Analyzer.NuGetPackageId", "SponsorableLib" }
+ }));
+
+ var diagnostics = await compilation.GetAnalyzerDiagnosticsAsync();
+
+ Assert.Empty(diagnostics);
+ }
+
+ [Fact]
+ public async Task WhenUnknownAndNoGraceOption_ThenReportsUnknown()
+ {
+ // simulate an analyzer file with the right metadata, which is recent and therefore
+ // within the grace period
+ var dll = Path.Combine(GetTempPath(), "FakeAnalyzer.dll");
+ File.WriteAllText(dll, "");
+
+ var compilation = CSharpCompilation.Create("test", [CSharpSyntaxTree.ParseText("//")])
+ .WithAnalyzers([new SponsorLinkAnalyzer()], new AnalyzerOptions([new AdditionalTextFile(dll)], new TestAnalyzerConfigOptionsProvider(new())
+ {
+ { "build_property.SponsorLinkNoInstallGrace", "true" },
+ { "build_metadata.Analyzer.ItemType", "Analyzer" },
+ { "build_metadata.Analyzer.NuGetPackageId", "SponsorableLib" }
+ }));
+
+ var diagnostics = await compilation.GetAnalyzerDiagnosticsAsync();
+
+ Assert.NotEmpty(diagnostics);
+
+ var diagnostic = diagnostics.Single(x => x.Properties.TryGetValue(nameof(SponsorStatus), out var value));
+
+ Assert.True(diagnostic.Properties.TryGetValue(nameof(SponsorStatus), out var value));
+ var status = Enum.Parse(value);
+
+ Assert.Equal(SponsorStatus.Unknown, status);
+ }
+
+ [Fact]
+ public async Task WhenUnknownAndGraceExpired_ThenReportsUnknown()
+ {
+ // simulate an analyzer file with the right metadata, which is recent and therefore
+ // within the grace period
+ var dll = Path.Combine(GetTempPath(), "FakeAnalyzer.dll");
+ File.WriteAllText(dll, "");
+ File.SetLastWriteTimeUtc(dll, DateTime.UtcNow - TimeSpan.FromDays(30));
+
+ var compilation = CSharpCompilation.Create("test", [CSharpSyntaxTree.ParseText("//")])
+ .WithAnalyzers([new SponsorLinkAnalyzer()], new AnalyzerOptions([new AdditionalTextFile(dll)], new TestAnalyzerConfigOptionsProvider(new())
+ {
+ { "build_metadata.Analyzer.ItemType", "Analyzer" },
+ { "build_metadata.Analyzer.NuGetPackageId", "SponsorableLib" }
+ }));
+
+ var diagnostics = await compilation.GetAnalyzerDiagnosticsAsync();
+
+ Assert.NotEmpty(diagnostics);
+
+ var diagnostic = diagnostics.Single(x => x.Properties.TryGetValue(nameof(SponsorStatus), out var value));
+
+ Assert.True(diagnostic.Properties.TryGetValue(nameof(SponsorStatus), out var value));
+ var status = Enum.Parse(value);
+
+ Assert.Equal(SponsorStatus.Unknown, status);
+ }
+
+ [Fact]
+ public async Task WhenSponsoring_ThenReportsSponsor()
+ {
+ var sponsor = sponsorable.Sign([], expiration: TimeSpan.FromMinutes(5));
+ var jwt = Path.Combine(GetTempPath(), "kzu.jwt");
+ File.WriteAllText(jwt, sponsor, Encoding.UTF8);
+
+ var compilation = CSharpCompilation.Create("test", [CSharpSyntaxTree.ParseText("//")])
+ .WithAnalyzers([new SponsorLinkAnalyzer()], new AnalyzerOptions([new AdditionalTextFile(jwt)], new TestAnalyzerConfigOptionsProvider(new())
+ {
+ { "build_metadata.SponsorManifest.ItemType", "SponsorManifest" }
+ }));
+
+ var diagnostics = await compilation.GetAnalyzerDiagnosticsAsync();
+
+ Assert.NotEmpty(diagnostics);
+
+ var diagnostic = diagnostics.Single(x => x.Properties.TryGetValue(nameof(SponsorStatus), out var value));
+
+ Assert.True(diagnostic.Properties.TryGetValue(nameof(SponsorStatus), out var value));
+ var status = Enum.Parse(value);
+
+ Assert.Equal(SponsorStatus.Sponsor, status);
+ }
+
+ [Fact]
+ public async Task WhenMultipleAnalyzers_ThenReportsOnce()
+ {
+ var compilation = CSharpCompilation.Create("test", [CSharpSyntaxTree.ParseText("//")])
+ .WithAnalyzers([new SponsorLinkAnalyzer(), new SponsorLinkAnalyzer()]);
+
+ var diagnostics = await compilation.GetAnalyzerDiagnosticsAsync();
+
+ Assert.NotEmpty(diagnostics);
+
+ var diagnostic = diagnostics.Single(x => x.Properties.TryGetValue(nameof(SponsorStatus), out var value));
+
+ Assert.True(diagnostic.Properties.TryGetValue(nameof(SponsorStatus), out var value));
+ var status = Enum.Parse(value);
+
+ Assert.Equal(SponsorStatus.Unknown, status);
+ }
+
+
+ string GetTempPath([CallerMemberName] string? test = default)
+ {
+ var path = Path.Combine(Path.GetTempPath(), test ?? nameof(AnalyzerTests));
+ Directory.CreateDirectory(path);
+ return path;
+ }
+
+ class AdditionalTextFile(string path) : AdditionalText
+ {
+ public override string Path => path;
+ public override SourceText GetText(CancellationToken cancellationToken) => SourceText.From(File.ReadAllText(Path), Encoding.UTF8);
+ }
+
+ class TestAnalyzerConfigOptionsProvider(Dictionary options) : AnalyzerConfigOptionsProvider, IDictionary
+ {
+ AnalyzerConfigOptions analyzerOptions = new TestAnalyzerConfigOptions(options);
+ public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => analyzerOptions;
+
+ public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => analyzerOptions;
+ public void Add(string key, string value) => options.Add(key, value);
+ public bool ContainsKey(string key) => options.ContainsKey(key);
+ public bool Remove(string key) => options.Remove(key);
+ public bool TryGetValue(string key, [MaybeNullWhen(false)] out string value) => options.TryGetValue(key, out value);
+ public void Add(KeyValuePair item) => ((ICollection>)options).Add(item);
+ public void Clear() => ((ICollection>)options).Clear();
+ public bool Contains(KeyValuePair item) => ((ICollection>)options).Contains(item);
+ public void CopyTo(KeyValuePair[] array, int arrayIndex) => ((ICollection>)options).CopyTo(array, arrayIndex);
+ public bool Remove(KeyValuePair item) => ((ICollection>)options).Remove(item);
+ public IEnumerator> GetEnumerator() => ((IEnumerable>)options).GetEnumerator();
+ IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)options).GetEnumerator();
+ public override AnalyzerConfigOptions GlobalOptions => analyzerOptions;
+ public ICollection Keys => options.Keys;
+ public ICollection Values => options.Values;
+ public int Count => ((ICollection>)options).Count;
+ public bool IsReadOnly => ((ICollection>)options).IsReadOnly;
+ public string this[string key] { get => options[key]; set => options[key] = value; }
+
+ class TestAnalyzerConfigOptions(Dictionary options) : AnalyzerConfigOptions
+ {
+ public override bool TryGetValue(string key, out string value) => options.TryGetValue(key, out value);
+ }
+ }
+}
diff --git a/src/SponsorLink/Tests/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
deleted file mode 100644
index 7824a60..0000000
--- a/src/SponsorLink/Tests/Resources.Designer.cs
+++ /dev/null
@@ -1,63 +0,0 @@
-//------------------------------------------------------------------------------
-//
-// 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/Sample.cs b/src/SponsorLink/Tests/Sample.cs
index 897c91c..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());
@@ -40,7 +41,7 @@ public void Test(string culture, SponsorStatus kind)
public void RenderSponsorables()
{
Assert.NotEmpty(SponsorLink.Sponsorables);
-
+
foreach (var pair in SponsorLink.Sponsorables)
{
output.WriteLine($"{pair.Key} = {pair.Value}");
@@ -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..907fc10 100644
--- a/src/SponsorLink/Tests/SponsorableManifest.cs
+++ b/src/SponsorLink/Tests/SponsorableManifest.cs
@@ -1,8 +1,9 @@
using System.Diagnostics.CodeAnalysis;
-using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
namespace Devlooped.Sponsors;
@@ -42,25 +43,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 +69,11 @@ public static SponsorableManifest Create(Uri issuer, Uri[] audience, string clie
return (Status.OK, manifest);
}
+ finally
+ {
+ if (disposeHttp)
+ http?.Dispose();
+ }
}
///
@@ -80,14 +85,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 +111,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 +118,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,38 +158,54 @@ 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,
+ });
}
///
/// Sign the JWT claims with the provided RSA key.
///
public string Sign(IEnumerable claims, RSA rsa, TimeSpan? expiration = default)
- => Sign(claims, new RsaSecurityKey(rsa), expiration);
-
- public string Sign(IEnumerable claims, RsaSecurityKey? key = default, TimeSpan? expiration = default)
{
- var rsa = key ?? SecurityKey as RsaSecurityKey;
- if (rsa?.PrivateKeyStatus != PrivateKeyStatus.Exists)
- throw new NotSupportedException("No private key found to sign the manifest.");
+ var key = new RsaSecurityKey(rsa);
+ if (key.PrivateKeyStatus != PrivateKeyStatus.Exists)
+ throw new NotSupportedException("No private key found or specified to sign the manifest.");
+
+ // Don't allow mismatches of public manifest key and the one used to sign, to avoid
+ // weird run-time errors verifiying manifests that were signed with a different key.
+ if (!rsa.ThumbprintEquals(SecurityKey))
+ throw new ArgumentException($"Cannot sign with a private key that does not match the manifest public key.");
+
+ return Sign(claims, key, expiration);
+ }
- var signing = new SigningCredentials(rsa, SecurityAlgorithms.RsaSha256);
+ ///
+ /// Sign the JWT claims, optionally overriding the used for signing.
+ ///
+ public string Sign(IEnumerable claims, SecurityKey? key = default, TimeSpan? expiration = default)
+ {
+ var credentials = new SigningCredentials(key ?? SecurityKey, SecurityAlgorithms.RsaSha256);
var expirationDate = expiration != null ?
DateTime.UtcNow.Add(expiration.Value) :
@@ -195,11 +220,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 +251,50 @@ 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)
- 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 = credentials,
+ });
}
- 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 +307,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 +333,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 = 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 +350,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 0585911..fd98b13 100644
--- a/src/SponsorLink/Tests/Tests.csproj
+++ b/src/SponsorLink/Tests/Tests.csproj
@@ -2,41 +2,45 @@
net8.0
+ true
+ CS8981;$(NoWarn)
-
-
+
+
+
-
-
- True
- True
- Resources.resx
-
+
+
+
+
+
-
-
- ResXFileCodeGenerator
- Resources.Designer.cs
-
-
+
+
+
+
+
+
+
+
@@ -52,6 +56,19 @@
+
+
+
+
+
+
+
+
+ true
+
+
+
+
\ No newline at end of file
diff --git a/src/SponsorLink/Tests/keys/kzu.key b/src/SponsorLink/Tests/keys/kzu.key
new file mode 100644
index 0000000..cddc6c6
Binary files /dev/null and b/src/SponsorLink/Tests/keys/kzu.key differ
diff --git a/src/SponsorLink/Tests/keys/kzu.key.jwk b/src/SponsorLink/Tests/keys/kzu.key.jwk
new file mode 100644
index 0000000..3589e3d
--- /dev/null
+++ b/src/SponsorLink/Tests/keys/kzu.key.jwk
@@ -0,0 +1,11 @@
+{
+ "d": "OmDrKyd0ap7Az2V09C8E6aASK0nnXHGUdTIymmU1tGoxaWpkZ4gLGaYDp4L5fKc-AaqqD3PjvfJvEWfXLqJtEWfUl4gahWrgUkmuzPyAVFFioIzeGIvTGELsR6lTRke0IB2kvfvS7hRgX8Py8ohCAGHiidmoec2SyKkEg0aPdxrIKV8hx5ybC_D_4zRKn0GuVwATIeVZzPpTcyJX_sn4NHDOqut0Xg02iHhMKpF850BSoC97xGMlcSjLocFSTwI63msz6jWQ-6LRVXsfRr2mqakAvsPpqEQ3Ytk9Ud9xW0ctuAWyo6UXev5w2XEL8cSXm33-57fi3ekC_jGCqW0KfAU4Cr2UbuTC0cv8Vv0F4Xm5FizolmuSBFOvf55-eqsjmpwf9hftYAiIlFF-49-P0DpeJejSeoL06BE3e3_IVu3g3HNnSWVUOLJ5Uk5FQ-ieHhf-r2Tq5qZ8_-losHekQbCxCMY2isc-r6V6BMnVL_9kWPxpXwhjKrYxNFZEXUJ1",
+ "dp": "HjCs_QF1Hn1SGS2OqZzYhGhNk4PTw9Hs97E7g3pb4liY0uECYOYp1RNoMyzvNBZVwlxhpeTTS299yPoXeYmseXfLtcjfIVi6mSWS_u27Hd0zfSdaPDOXyyK-mZfIV7Q76RTost0QY3LA0ciJbj3gJqpl38dhuNQ8h9Yqt-TFyb3CUaM3A_JUNKOTce8qnkLrasytPEuSroOBT8bgCWJIjw_mXWMGcoqRFWHw9Nyp9mIyvtPjUQ9ig3bGSP_-3LZf",
+ "dq": "IP6EsAZ_6psFdlQrvnugYFs91fEP5QfBzNHmbfmsPRVX4QM5B3L6klQyJsLqvPfF1Xu17ZffLFkNBKuiphIcLPo0yZTJG9Y7S8gLuPAmrH-ndfxG-bQ8Yt0ZB1pA77ILIS8bUTKrMqAWS-VcaxcSCIyhSusLEWYYDi3PEzB375OUw4aXIk3ob8bePG7UqFSL6qmDPgkGLTxkY9m5dEiOshHygtVY-H_jjOIawliEPgmgAr2M-zlXiphovDyAT0PV",
+ "e": "AQAB",
+ "kty": "RSA",
+ "n": "yP71VgOgHDtGbxdyN31mIFFITmGYEk2cwepKbyqKTbTYXF1OXaMoP5n3mfwqwzUQmEAsrclAigPcK4GIy5WWlc5YujIxKauJjsKe0FBxMnFp9o1UcBUHfgDJjaAKieQxb44717b1MwCcflEGnCGTXkntdr45y9Gi1D9-oBw5zIVZekgMP55XxmKvkJd1k-bYWSv-QFG2JJwRIGwr29Jr62juCsLB7Tg83ZGKCa22Y_7lQcezxRRD5OrGWhf3gTYArbrEzbYy653zbHfbOCJeVBe_bXDkR74yG3mmq_Ne0qhNk6wXuX-NrKEvdPxRSRBF7C465fcVY9PM6eTqEPQwKDiarHpU1NTwUetzb-YKry-h678RJWMhC7I9lzzWVobbC0YVKG7XpeVqBB4u7Q6cGo5Xkf19VldkIxQMu9sFeuHGDSoiCLqmRmwNn9GsMV77oZWr-OPrxEdZzL9BcI4fMJMz7YdiIu-qbIp_vqatbalfNasumf8RgtPOkR2vgc59",
+ "p": "6JTf8Qb0iHRL6MIIs7MlkEKBNpnAu_Nie4HTqhxy2wfE4cBr6QZ98iJniXffDIjq_GxVpw9K-Bv2gTcNrlzOiBaLf3X2Itfice_Qd-luhNbnXVfiA5sg6dZ2wbBuue5ann5iJ_TIbxO4CLUiqQp0PCReUPzTQhzesHxM2-dBC9AYDl7P6p1FF53Hh_Knx9UywhoPvNtoCJy35-5rj0ghgPYz289dbOBccZnvabRueOr_wpHGMKaznqiDMrcFSZ07",
+ "q": "3TvrN8R9imw6E6JkVQ4PtveE0vkvkSWHUpn9KwKFIJJiwL_HSS4z_8IYR1_0Q1OgK5-z-QcXhq9P7jTjz02I2uwWhP3RZQf99RZACfMaeIs8O2V-I89WdlJYOerzAelW4nYw7zyeVoT5c5osicGWfSmWslLRjA1yx7x1KA_MCU_KIEBlpe1RgEUYPET3OtvPKFIVQYoJfQC5PFlmrC-kgHZMSpdHjWgWi5gPn0fIBCKFsXcPrt2n_lKKGc4lFOen",
+ "qi": "m-tgdFqO1Ax3C00oe7kdkYLHMD56wkGARdqPCqS5IGhFVKCOA8U6O_s5bSL4r0TzPE0KrJ4A5QJEwjbH4bXssPaaAlv1ZdWjn8YMQCYFolg_pgUWYYI5vNxG1gIsLGXPTfE8a6SObkJ2Q9VC5ZZp14r4lPvJhwFICIGSRBKcvS-gO_gqB3LKuG9TQBi-CE4DHDLJwsCbEBR8Ber45oTqvG7hphpOhBHsFZ8_6f3Reg_sK1BCz9HFCx8hhi8rBfUp"
+}
\ No newline at end of file
diff --git a/src/SponsorLink/Tests/keys/kzu.key.txt b/src/SponsorLink/Tests/keys/kzu.key.txt
new file mode 100644
index 0000000..5fe8758
--- /dev/null
+++ b/src/SponsorLink/Tests/keys/kzu.key.txt
@@ -0,0 +1 @@
+MIIG4wIBAAKCAYEAyP71VgOgHDtGbxdyN31mIFFITmGYEk2cwepKbyqKTbTYXF1OXaMoP5n3mfwqwzUQmEAsrclAigPcK4GIy5WWlc5YujIxKauJjsKe0FBxMnFp9o1UcBUHfgDJjaAKieQxb44717b1MwCcflEGnCGTXkntdr45y9Gi1D9+oBw5zIVZekgMP55XxmKvkJd1k+bYWSv+QFG2JJwRIGwr29Jr62juCsLB7Tg83ZGKCa22Y/7lQcezxRRD5OrGWhf3gTYArbrEzbYy653zbHfbOCJeVBe/bXDkR74yG3mmq/Ne0qhNk6wXuX+NrKEvdPxRSRBF7C465fcVY9PM6eTqEPQwKDiarHpU1NTwUetzb+YKry+h678RJWMhC7I9lzzWVobbC0YVKG7XpeVqBB4u7Q6cGo5Xkf19VldkIxQMu9sFeuHGDSoiCLqmRmwNn9GsMV77oZWr+OPrxEdZzL9BcI4fMJMz7YdiIu+qbIp/vqatbalfNasumf8RgtPOkR2vgc59AgMBAAECggGAOmDrKyd0ap7Az2V09C8E6aASK0nnXHGUdTIymmU1tGoxaWpkZ4gLGaYDp4L5fKc+AaqqD3PjvfJvEWfXLqJtEWfUl4gahWrgUkmuzPyAVFFioIzeGIvTGELsR6lTRke0IB2kvfvS7hRgX8Py8ohCAGHiidmoec2SyKkEg0aPdxrIKV8hx5ybC/D/4zRKn0GuVwATIeVZzPpTcyJX/sn4NHDOqut0Xg02iHhMKpF850BSoC97xGMlcSjLocFSTwI63msz6jWQ+6LRVXsfRr2mqakAvsPpqEQ3Ytk9Ud9xW0ctuAWyo6UXev5w2XEL8cSXm33+57fi3ekC/jGCqW0KfAU4Cr2UbuTC0cv8Vv0F4Xm5FizolmuSBFOvf55+eqsjmpwf9hftYAiIlFF+49+P0DpeJejSeoL06BE3e3/IVu3g3HNnSWVUOLJ5Uk5FQ+ieHhf+r2Tq5qZ8/+losHekQbCxCMY2isc+r6V6BMnVL/9kWPxpXwhjKrYxNFZEXUJ1AoHBAOiU3/EG9Ih0S+jCCLOzJZBCgTaZwLvzYnuB06occtsHxOHAa+kGffIiZ4l33wyI6vxsVacPSvgb9oE3Da5czogWi3919iLX4nHv0HfpboTW511X4gObIOnWdsGwbrnuWp5+Yif0yG8TuAi1IqkKdDwkXlD800Ic3rB8TNvnQQvQGA5ez+qdRRedx4fyp8fVMsIaD7zbaAict+fua49IIYD2M9vPXWzgXHGZ72m0bnjq/8KRxjCms56ogzK3BUmdOwKBwQDdO+s3xH2KbDoTomRVDg+294TS+S+RJYdSmf0rAoUgkmLAv8dJLjP/whhHX/RDU6Arn7P5BxeGr0/uNOPPTYja7BaE/dFlB/31FkAJ8xp4izw7ZX4jz1Z2Ulg56vMB6VbidjDvPJ5WhPlzmiyJwZZ9KZayUtGMDXLHvHUoD8wJT8ogQGWl7VGARRg8RPc6288oUhVBigl9ALk8WWasL6SAdkxKl0eNaBaLmA+fR8gEIoWxdw+u3af+UooZziUU56cCgcAeMKz9AXUefVIZLY6pnNiEaE2Tg9PD0ez3sTuDelviWJjS4QJg5inVE2gzLO80FlXCXGGl5NNLb33I+hd5iax5d8u1yN8hWLqZJZL+7bsd3TN9J1o8M5fLIr6Zl8hXtDvpFOiy3RBjcsDRyIluPeAmqmXfx2G41DyH1iq35MXJvcJRozcD8lQ0o5Nx7yqeQutqzK08S5Kug4FPxuAJYkiPD+ZdYwZyipEVYfD03Kn2YjK+0+NRD2KDdsZI//7ctl8CgcAg/oSwBn/qmwV2VCu+e6BgWz3V8Q/lB8HM0eZt+aw9FVfhAzkHcvqSVDImwuq898XVe7Xtl98sWQ0Eq6KmEhws+jTJlMkb1jtLyAu48Casf6d1/Eb5tDxi3RkHWkDvsgshLxtRMqsyoBZL5VxrFxIIjKFK6wsRZhgOLc8TMHfvk5TDhpciTehvxt48btSoVIvqqYM+CQYtPGRj2bl0SI6yEfKC1Vj4f+OM4hrCWIQ+CaACvYz7OVeKmGi8PIBPQ9UCgcEAm+tgdFqO1Ax3C00oe7kdkYLHMD56wkGARdqPCqS5IGhFVKCOA8U6O/s5bSL4r0TzPE0KrJ4A5QJEwjbH4bXssPaaAlv1ZdWjn8YMQCYFolg/pgUWYYI5vNxG1gIsLGXPTfE8a6SObkJ2Q9VC5ZZp14r4lPvJhwFICIGSRBKcvS+gO/gqB3LKuG9TQBi+CE4DHDLJwsCbEBR8Ber45oTqvG7hphpOhBHsFZ8/6f3Reg/sK1BCz9HFCx8hhi8rBfUp
\ No newline at end of file
diff --git a/src/SponsorLink/Tests/keys/kzu.pub b/src/SponsorLink/Tests/keys/kzu.pub
new file mode 100644
index 0000000..5594797
Binary files /dev/null and b/src/SponsorLink/Tests/keys/kzu.pub differ
diff --git a/src/SponsorLink/Tests/keys/kzu.pub.jwk b/src/SponsorLink/Tests/keys/kzu.pub.jwk
new file mode 100644
index 0000000..b4bfb31
--- /dev/null
+++ b/src/SponsorLink/Tests/keys/kzu.pub.jwk
@@ -0,0 +1,5 @@
+{
+ "e": "AQAB",
+ "kty": "RSA",
+ "n": "yP71VgOgHDtGbxdyN31mIFFITmGYEk2cwepKbyqKTbTYXF1OXaMoP5n3mfwqwzUQmEAsrclAigPcK4GIy5WWlc5YujIxKauJjsKe0FBxMnFp9o1UcBUHfgDJjaAKieQxb44717b1MwCcflEGnCGTXkntdr45y9Gi1D9-oBw5zIVZekgMP55XxmKvkJd1k-bYWSv-QFG2JJwRIGwr29Jr62juCsLB7Tg83ZGKCa22Y_7lQcezxRRD5OrGWhf3gTYArbrEzbYy653zbHfbOCJeVBe_bXDkR74yG3mmq_Ne0qhNk6wXuX-NrKEvdPxRSRBF7C465fcVY9PM6eTqEPQwKDiarHpU1NTwUetzb-YKry-h678RJWMhC7I9lzzWVobbC0YVKG7XpeVqBB4u7Q6cGo5Xkf19VldkIxQMu9sFeuHGDSoiCLqmRmwNn9GsMV77oZWr-OPrxEdZzL9BcI4fMJMz7YdiIu-qbIp_vqatbalfNasumf8RgtPOkR2vgc59"
+}
\ No newline at end of file
diff --git a/src/SponsorLink/Tests/keys/kzu.pub.txt b/src/SponsorLink/Tests/keys/kzu.pub.txt
new file mode 100644
index 0000000..729ecd5
--- /dev/null
+++ b/src/SponsorLink/Tests/keys/kzu.pub.txt
@@ -0,0 +1 @@
+MIIBigKCAYEAyP71VgOgHDtGbxdyN31mIFFITmGYEk2cwepKbyqKTbTYXF1OXaMoP5n3mfwqwzUQmEAsrclAigPcK4GIy5WWlc5YujIxKauJjsKe0FBxMnFp9o1UcBUHfgDJjaAKieQxb44717b1MwCcflEGnCGTXkntdr45y9Gi1D9+oBw5zIVZekgMP55XxmKvkJd1k+bYWSv+QFG2JJwRIGwr29Jr62juCsLB7Tg83ZGKCa22Y/7lQcezxRRD5OrGWhf3gTYArbrEzbYy653zbHfbOCJeVBe/bXDkR74yG3mmq/Ne0qhNk6wXuX+NrKEvdPxRSRBF7C465fcVY9PM6eTqEPQwKDiarHpU1NTwUetzb+YKry+h678RJWMhC7I9lzzWVobbC0YVKG7XpeVqBB4u7Q6cGo5Xkf19VldkIxQMu9sFeuHGDSoiCLqmRmwNn9GsMV77oZWr+OPrxEdZzL9BcI4fMJMz7YdiIu+qbIp/vqatbalfNasumf8RgtPOkR2vgc59AgMBAAE=
\ No newline at end of file
diff --git a/src/SponsorLink/Tests/keys/sponsorlink.jwt b/src/SponsorLink/Tests/keys/sponsorlink.jwt
new file mode 100644
index 0000000..b53fe62
--- /dev/null
+++ b/src/SponsorLink/Tests/keys/sponsorlink.jwt
@@ -0,0 +1 @@
+eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MTk4NjgyMzAsImlzcyI6Imh0dHBzOi8vc3BvbnNvcmxpbmsuZGV2bG9vcGVkLmNvbS8iLCJhdWQiOlsiaHR0cHM6Ly9naXRodWIuY29tL3Nwb25zb3JzL2t6dSIsImh0dHBzOi8vZ2l0aHViLmNvbS9zcG9uc29ycy9kZXZsb29wZWQiXSwiY2xpZW50X2lkIjoiYTgyMzUwZmIyYmFlNDA3YjMwMjEiLCJzdWJfandrIjp7ImUiOiJBUUFCIiwia3R5IjoiUlNBIiwibiI6InlQNzFWZ09nSER0R2J4ZHlOMzFtSUZGSVRtR1lFazJjd2VwS2J5cUtUYlRZWEYxT1hhTW9QNW4zbWZ3cXd6VVFtRUFzcmNsQWlnUGNLNEdJeTVXV2xjNVl1akl4S2F1SmpzS2UwRkJ4TW5GcDlvMVVjQlVIZmdESmphQUtpZVF4YjQ0NzE3YjFNd0NjZmxFR25DR1RYa250ZHI0NXk5R2kxRDktb0J3NXpJVlpla2dNUDU1WHhtS3ZrSmQxay1iWVdTdi1RRkcySkp3UklHd3IyOUpyNjJqdUNzTEI3VGc4M1pHS0NhMjJZXzdsUWNlenhSUkQ1T3JHV2hmM2dUWUFyYnJFemJZeTY1M3piSGZiT0NKZVZCZV9iWERrUjc0eUczbW1xX05lMHFoTms2d1h1WC1OcktFdmRQeFJTUkJGN0M0NjVmY1ZZOVBNNmVUcUVQUXdLRGlhckhwVTFOVHdVZXR6Yi1ZS3J5LWg2NzhSSldNaEM3STlsenpXVm9iYkMwWVZLRzdYcGVWcUJCNHU3UTZjR281WGtmMTlWbGRrSXhRTXU5c0ZldUhHRFNvaUNMcW1SbXdObjlHc01WNzdvWldyLU9QcnhFZFp6TDlCY0k0Zk1KTXo3WWRpSXUtcWJJcF92cWF0YmFsZk5hc3VtZjhSZ3RQT2tSMnZnYzU5In19.er4apYbEjHVKlQ_aMXoRhHYeR8N-3uIrCk3HX8UuZO7mb0CaS94-422EI3z5O9vRvckcGkNVoiSIX0ykZqUMHTZxBae-QZc1u_rhdBOChoaxWqpUiPXLZ5-yi7mcRwqg2DOUb2eHTNfRjwJ-0tjL1R1TqZw9d8Bgku1zw2ZTuJl_WsBRHKHTD_s5KyCP5yhSOUumrsf3nXYrc20fJ7ql0FsL0MP66utJk7TFYHGhQV3cfcXYqFEpv-k6tqB9k3Syc0UnepmQT0Y3dtcBzQzCOzfKQ8bdaAXVHjfp4VvXBluHmh9lP6TeZmpvlmQDFvyk0kp1diTbo9pqmX_llNDWNxBdvaSZGa7RZMG_dE2WJGtQNu0C_sbEZDPZsKncxdtm-j-6Y7GRqx7uxe4Py8tAZ7SxjiPgD64jf9KF2OT6f6drVtzohVzYCs6-vhcXzC2sQvd_gQ-SoFNTa1MEcMgGbL-fFWUC7-7bQV1DlSg2YFwrxEIwbM-gHpLZHyyJLvYD
\ No newline at end of file