diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f9c7266..42e98155 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,24 @@ jobs: - name: Build and Test run: ./Build.ps1 shell: pwsh + - name: Get package version + id: version + shell: pwsh + run: | + $pkg = Get-ChildItem ./artifacts -Filter "MediatR.*.nupkg" | Select-Object -First 1 + $version = $pkg.BaseName -replace '^MediatR\.', '' + "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + - name: Generate SBOM + shell: pwsh + run: | + dotnet tool install --global Microsoft.Sbom.DotNetTool + sbom-tool generate ` + -b ./artifacts ` + -bc ./src/MediatR ` + -pn MediatR ` + -pv ${{ steps.version.outputs.version }} ` + -ps "Lucky Penny Software LLC" ` + -nsb https://mediatr.io - name: Push to MyGet if: github.ref == 'refs/heads/main' env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8abbcfff..6c4fde59 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,6 +42,24 @@ jobs: - name: Build and Test run: ./Build.ps1 shell: pwsh + - name: Get package version + id: version + shell: pwsh + run: | + $pkg = Get-ChildItem ./artifacts -Filter "MediatR.*.nupkg" | Select-Object -First 1 + $version = $pkg.BaseName -replace '^MediatR\.', '' + "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + - name: Generate SBOM + shell: pwsh + run: | + dotnet tool install --global Microsoft.Sbom.DotNetTool + sbom-tool generate ` + -b ./artifacts ` + -bc ./src/MediatR ` + -pn MediatR ` + -pv ${{ steps.version.outputs.version }} ` + -ps "Lucky Penny Software LLC" ` + -nsb https://mediatr.io - name: Sign packages run: |- foreach ($f in Get-ChildItem "./artifacts" -Filter "*.nupkg") { diff --git a/src/MediatR/Licensing/BuildInfo.cs b/src/MediatR/Licensing/BuildInfo.cs new file mode 100644 index 00000000..79e82ec6 --- /dev/null +++ b/src/MediatR/Licensing/BuildInfo.cs @@ -0,0 +1,27 @@ +using System; +using System.Linq; +using System.Reflection; + +namespace MediatR.Licensing; + +internal static class BuildInfo +{ + public static DateTimeOffset? BuildDate { get; } = GetBuildDate(); + + private static DateTimeOffset? GetBuildDate() + { + var assembly = typeof(BuildInfo).Assembly; + + var buildDateAttribute = assembly + .GetCustomAttributes() + .FirstOrDefault(a => a.Key == "BuildDateUtc"); + + if (buildDateAttribute?.Value != null && + DateTimeOffset.TryParse(buildDateAttribute.Value, out var buildDate)) + { + return buildDate; + } + + return null; + } +} diff --git a/src/MediatR/Licensing/License.cs b/src/MediatR/Licensing/License.cs index 8848be66..e7165339 100644 --- a/src/MediatR/Licensing/License.cs +++ b/src/MediatR/Licensing/License.cs @@ -39,10 +39,13 @@ public License(ClaimsPrincipal claims) } if (Enum.TryParse(claims.FindFirst("type")?.Value, out var productType)) - { + { ProductType = productType; } + var perpetualValue = claims.FindFirst("perpetual")?.Value; + IsPerpetual = perpetualValue?.ToLowerInvariant() is "true" or "1"; + IsConfigured = AccountId != null && CustomerId != null && SubscriptionId != null @@ -59,6 +62,7 @@ public License(ClaimsPrincipal claims) public DateTimeOffset? ExpirationDate { get; } public Edition? Edition { get; } public ProductType? ProductType { get; } - + public bool IsPerpetual { get; } + public bool IsConfigured { get; } } \ No newline at end of file diff --git a/src/MediatR/Licensing/LicenseValidator.cs b/src/MediatR/Licensing/LicenseValidator.cs index ae56499a..1fa8dc06 100644 --- a/src/MediatR/Licensing/LicenseValidator.cs +++ b/src/MediatR/Licensing/LicenseValidator.cs @@ -7,9 +7,17 @@ namespace MediatR.Licensing; internal class LicenseValidator { private readonly ILogger _logger; + private readonly DateTimeOffset? _buildDate; - public LicenseValidator(ILoggerFactory loggerFactory) - => _logger = loggerFactory.CreateLogger("LuckyPennySoftware.MediatR.License"); + public LicenseValidator(ILoggerFactory loggerFactory) : this(loggerFactory, BuildInfo.BuildDate) + { + } + + public LicenseValidator(ILoggerFactory loggerFactory, DateTimeOffset? buildDate) + { + _logger = loggerFactory.CreateLogger("LuckyPennySoftware.MediatR.License"); + _buildDate = buildDate; + } public void Validate(License license) { @@ -31,7 +39,31 @@ public void Validate(License license) var diff = DateTime.UtcNow.Date.Subtract(license.ExpirationDate!.Value.Date).TotalDays; if (diff > 0) { - errors.Add($"Your license for the Lucky Penny software MediatR expired {diff} days ago."); + // If perpetual, check if build date is before expiration + if (license.IsPerpetual && _buildDate.HasValue) + { + var buildDateDiff = _buildDate.Value.Date.Subtract(license.ExpirationDate.Value.Date).TotalDays; + if (buildDateDiff <= 0) + { + _logger.LogInformation( + "Your license for the Lucky Penny software MediatR expired {expiredDaysAgo} days ago, but perpetual licensing is active because the build date ({buildDate:O}) is before the license expiration date ({licenseExpiration:O}).", + diff, _buildDate, license.ExpirationDate); + // Don't add to errors - perpetual fallback applies + } + else + { + errors.Add($"Your license for the Lucky Penny software MediatR expired {diff} days ago."); + } + } + else + { + if (license.IsPerpetual) + { + _logger.LogWarning( + "Your license for the Lucky Penny software MediatR has perpetual licensing enabled, but the build date could not be determined. Perpetual licensing cannot be applied. Please ensure the assembly metadata is correctly embedded at build time."); + } + errors.Add($"Your license for the Lucky Penny software MediatR expired {diff} days ago."); + } } if (license.ProductType!.Value != ProductType.MediatR diff --git a/src/MediatR/MediatR.csproj b/src/MediatR/MediatR.csproj index bddc5e2f..439dfd20 100644 --- a/src/MediatR/MediatR.csproj +++ b/src/MediatR/MediatR.csproj @@ -29,6 +29,19 @@ $(TargetFrameworks);net462 + + + + + + $([System.DateTime]::UtcNow.ToString("O")) + + + + + + + diff --git a/test/MediatR.Tests/Licensing/LicenseValidatorTests.cs b/test/MediatR.Tests/Licensing/LicenseValidatorTests.cs index ac5cb9ea..ac00be39 100644 --- a/test/MediatR.Tests/Licensing/LicenseValidatorTests.cs +++ b/test/MediatR.Tests/Licensing/LicenseValidatorTests.cs @@ -114,6 +114,122 @@ public void Should_return_invalid_when_expired() .ShouldContain(log => log.Level == LogLevel.Error); } + [Fact] + public void Should_allow_perpetual_license_when_build_date_before_expiration() + { + var factory = new LoggerFactory(); + var provider = new FakeLoggerProvider(); + factory.AddProvider(provider); + + var buildDate = DateTimeOffset.UtcNow.AddDays(-30); + var licenseValidator = new LicenseValidator(factory, buildDate); + var license = new License( + new Claim("account_id", Guid.NewGuid().ToString()), + new Claim("customer_id", Guid.NewGuid().ToString()), + new Claim("sub_id", Guid.NewGuid().ToString()), + new Claim("iat", DateTimeOffset.UtcNow.AddYears(-1).ToUnixTimeSeconds().ToString()), + new Claim("exp", DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeSeconds().ToString()), + new Claim("edition", nameof(Edition.Professional)), + new Claim("type", nameof(ProductType.MediatR)), + new Claim("perpetual", "true")); + + license.IsConfigured.ShouldBeTrue(); + license.IsPerpetual.ShouldBeTrue(); + + licenseValidator.Validate(license); + + var logMessages = provider.Collector.GetSnapshot(); + logMessages.ShouldNotContain(log => log.Level == LogLevel.Error); + logMessages.ShouldContain(log => log.Level == LogLevel.Information && + log.Message.Contains("perpetual")); + } + + [Fact] + public void Should_reject_perpetual_license_when_build_date_after_expiration() + { + var factory = new LoggerFactory(); + var provider = new FakeLoggerProvider(); + factory.AddProvider(provider); + + var buildDate = DateTimeOffset.UtcNow.AddDays(-5); // Build date in past, after expiration + var licenseValidator = new LicenseValidator(factory, buildDate); + var license = new License( + new Claim("account_id", Guid.NewGuid().ToString()), + new Claim("customer_id", Guid.NewGuid().ToString()), + new Claim("sub_id", Guid.NewGuid().ToString()), + new Claim("iat", DateTimeOffset.UtcNow.AddYears(-1).ToUnixTimeSeconds().ToString()), + new Claim("exp", DateTimeOffset.UtcNow.AddDays(-10).ToUnixTimeSeconds().ToString()), + new Claim("edition", nameof(Edition.Professional)), + new Claim("type", nameof(ProductType.MediatR)), + new Claim("perpetual", "true")); + + license.IsConfigured.ShouldBeTrue(); + license.IsPerpetual.ShouldBeTrue(); + + licenseValidator.Validate(license); + + var logMessages = provider.Collector.GetSnapshot(); + logMessages.ShouldContain(log => log.Level == LogLevel.Error && + log.Message.Contains("expired")); + } + + [Fact] + public void Should_handle_missing_perpetual_claim() + { + var factory = new LoggerFactory(); + var provider = new FakeLoggerProvider(); + factory.AddProvider(provider); + + var licenseValidator = new LicenseValidator(factory); + var license = new License( + new Claim("account_id", Guid.NewGuid().ToString()), + new Claim("customer_id", Guid.NewGuid().ToString()), + new Claim("sub_id", Guid.NewGuid().ToString()), + new Claim("iat", DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeSeconds().ToString()), + new Claim("exp", DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeSeconds().ToString()), + new Claim("edition", nameof(Edition.Community)), + new Claim("type", nameof(ProductType.Bundle))); + + license.IsConfigured.ShouldBeTrue(); + license.IsPerpetual.ShouldBeFalse(); + + licenseValidator.Validate(license); + + var logMessages = provider.Collector.GetSnapshot(); + logMessages.ShouldNotContain(log => log.Level == LogLevel.Error + || log.Level == LogLevel.Warning + || log.Level == LogLevel.Critical); + } + + [Fact] + public void Should_fall_back_to_expiration_error_when_perpetual_and_build_date_is_null() + { + var factory = new LoggerFactory(); + var provider = new FakeLoggerProvider(); + factory.AddProvider(provider); + + var licenseValidator = new LicenseValidator(factory, (DateTimeOffset?)null); + var license = new License( + new Claim("account_id", Guid.NewGuid().ToString()), + new Claim("customer_id", Guid.NewGuid().ToString()), + new Claim("sub_id", Guid.NewGuid().ToString()), + new Claim("iat", DateTimeOffset.UtcNow.AddDays(-10).ToUnixTimeSeconds().ToString()), + new Claim("exp", DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeSeconds().ToString()), + new Claim("edition", nameof(Edition.Community)), + new Claim("type", nameof(ProductType.Bundle)), + new Claim("perpetual", "true")); + + license.IsConfigured.ShouldBeTrue(); + license.IsPerpetual.ShouldBeTrue(); + + // Validator was created with null buildDate - perpetual licensing cannot be applied. + licenseValidator.Validate(license); + + var logMessages = provider.Collector.GetSnapshot(); + logMessages.ShouldContain(log => log.Level == LogLevel.Warning && log.Message.Contains("perpetual")); + logMessages.ShouldContain(log => log.Level == LogLevel.Error); + } + [Fact(Skip = "Needs license")] public void Should_return_valid_for_actual_valid_license() {