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