From 7caf6b0c8089424ed2f59b3ae7ce4225c397b14f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enes=20Ya=C4=9F=C4=B1z?= Date: Wed, 21 Oct 2026 21:53:52 +0300 Subject: [PATCH] feat(maven): add Java packages from Maven Central as a registry Versions come from maven-metadata.xml and are ordered by Maven's ComparableVersion rules; the repository is read from the POM and its parents, and release notes come from GitHub. OSV, deps.dev and Socket cover Maven, and the Maven registry row is enabled on start-up. Co-Authored-By: Claude Opus 5.5 --- .../PkgDiff.API/Extensions/DatabaseSeeder.cs | 46 ++- ...OutboundHttpServiceCollectionExtensions.cs | 17 + .../Extensions/ServiceCollectionExtensions.cs | 2 + .../Interfaces/IMavenService.cs | 10 + .../Versioning/MavenVersion.cs | 243 ++++++++++++ .../Versioning/PackageVersion.cs | 13 +- .../Versioning/VersionScheme.cs | 5 +- .../Services/GitHubScraperService.cs | 15 +- .../Services/HttpClientNames.cs | 2 + .../Services/ListedVersionsRegistryClient.cs | 7 +- .../Services/MavenRegistryClient.cs | 54 +++ .../Services/MavenService.cs | 358 ++++++++++++++++++ .../Services/OsvClient.cs | 1 + .../Services/PackageHealthClient.cs | 1 + .../Services/SocketBehaviorClient.cs | 7 +- .../PkgDiff.Shared/Constants/RegistryNames.cs | 3 +- .../Validation/PackageIdRules.cs | 5 + .../Registries/RegistryClientTests.cs | 14 +- .../TestDoubles/TestRegistries.cs | 6 +- .../Validation/PackageInputValidationTests.cs | 8 +- .../src/app/core/config/app.config.ts | 2 +- .../app/core/utils/install-command.utils.ts | 7 + .../src/app/core/utils/release-link.utils.ts | 2 +- src/frontend/src/app/features/home/home.html | 4 +- src/frontend/src/app/features/home/home.ts | 5 +- .../src/app/features/not-found/not-found.ts | 2 +- .../src/app/features/package/package.ts | 2 + .../app/features/packages/packages-list.ts | 6 +- .../src/app/features/privacy/privacy.html | 2 +- .../src/app/features/terms/terms.html | 4 +- src/frontend/src/index.html | 21 +- src/frontend/ssr-smoke.test.mjs | 4 +- 32 files changed, 817 insertions(+), 61 deletions(-) create mode 100644 src/backend/PkgDiff.Application/Interfaces/IMavenService.cs create mode 100644 src/backend/PkgDiff.Application/Versioning/MavenVersion.cs create mode 100644 src/backend/PkgDiff.Infrastructure/Services/MavenRegistryClient.cs create mode 100644 src/backend/PkgDiff.Infrastructure/Services/MavenService.cs diff --git a/src/backend/PkgDiff.API/Extensions/DatabaseSeeder.cs b/src/backend/PkgDiff.API/Extensions/DatabaseSeeder.cs index 26435c8..f21315e 100644 --- a/src/backend/PkgDiff.API/Extensions/DatabaseSeeder.cs +++ b/src/backend/PkgDiff.API/Extensions/DatabaseSeeder.cs @@ -136,7 +136,7 @@ public static async Task SeedDataAsync(IServiceProvider serviceProvider) new Registry { Value = "nuget", Label = "NuGet", Icon = "/icons/nuget.svg", Disabled = false, DisplayOrder = 1 }, new Registry { Value = "npm", Label = "npm", Icon = "/icons/npm.svg", Disabled = false, DisplayOrder = 2 }, new Registry { Value = "pypi", Label = "PyPI", Icon = "/icons/pypi.svg", Disabled = false, DisplayOrder = 3 }, - new Registry { Value = "maven", Label = "Maven", Icon = "/icons/maven.svg", Disabled = true, DisplayOrder = 4 } + new Registry { Value = "maven", Label = "Maven", Icon = "/icons/maven.svg", Disabled = false, DisplayOrder = 4 } }; await context.Registries.AddRangeAsync(registries); @@ -154,21 +154,9 @@ public static async Task SeedDataAsync(IServiceProvider serviceProvider) } } - var packagistSpellings = PkgDiff.Shared.Constants.RegistryNames.SpellingsOf(PkgDiff.Shared.Constants.RegistryNames.Packagist); - if (!await context.Registries.AnyAsync(r => packagistSpellings.Contains(r.Value.ToLower()))) - { - var displayOrder = (await context.Registries.MaxAsync(r => (int?)r.DisplayOrder) ?? 0) + 1; - context.Registries.Add(new Registry - { - Value = PkgDiff.Shared.Constants.RegistryNames.Packagist, - Label = "Packagist", - Icon = "/icons/packagist.svg", - Disabled = false, - DisplayOrder = displayOrder - }); - await context.SaveChangesAsync(); - logger.LogInformation("Registry {Registry} added", PkgDiff.Shared.Constants.RegistryNames.Packagist); - } + await EnsureRegistryAsync(context, logger, PkgDiff.Shared.Constants.RegistryNames.Packagist, "Packagist", "/icons/packagist.svg", enable: false); + // The Maven row was seeded disabled while Maven had no client + await EnsureRegistryAsync(context, logger, PkgDiff.Shared.Constants.RegistryNames.Maven, "Maven", "/icons/maven.svg", enable: true); var expectedPatterns = new[] { @@ -236,6 +224,32 @@ public static async Task SeedDataAsync(IServiceProvider serviceProvider) logger.LogInformation("Version exclude patterns seeded: {Added} added, {Updated} updated", addedCount, updatedCount); } } + + private static async Task EnsureRegistryAsync(ApplicationDbContext context, ILogger logger, string value, string label, string icon, bool enable) + { + var spellings = PkgDiff.Shared.Constants.RegistryNames.SpellingsOf(value); + var existing = await context.Registries.FirstOrDefaultAsync(r => spellings.Contains(r.Value.ToLower())); + if (existing is null) + { + var displayOrder = (await context.Registries.MaxAsync(r => (int?)r.DisplayOrder) ?? 0) + 1; + context.Registries.Add(new Registry + { + Value = value, + Label = label, + Icon = icon, + Disabled = false, + DisplayOrder = displayOrder + }); + await context.SaveChangesAsync(); + logger.LogInformation("Registry {Registry} added", value); + } + else if (enable && existing.Disabled) + { + existing.Disabled = false; + await context.SaveChangesAsync(); + logger.LogInformation("Registry {Registry} enabled", value); + } + } // Bootstrap only: once any Admin exists, pkgdiff_admin is never re-promoted or recreated public static async Task SeedAdminUserAsync(UserManager userManager, string? adminPassword, ILogger logger) { diff --git a/src/backend/PkgDiff.API/Extensions/OutboundHttpServiceCollectionExtensions.cs b/src/backend/PkgDiff.API/Extensions/OutboundHttpServiceCollectionExtensions.cs index 24f66ff..92d6b0c 100644 --- a/src/backend/PkgDiff.API/Extensions/OutboundHttpServiceCollectionExtensions.cs +++ b/src/backend/PkgDiff.API/Extensions/OutboundHttpServiceCollectionExtensions.cs @@ -61,6 +61,23 @@ public static IServiceCollection AddPkgDiffOutboundHttp(this IServiceCollection client.DefaultRequestHeaders.Add("User-Agent", "PkgDiff/1.0 (+https://pkgdiff.dev)"); }); + services.AddHttpClient(HttpClientNames.MavenCentral, client => + { + client.BaseAddress = new Uri("https://repo1.maven.org/maven2/"); + client.Timeout = FixedHostTimeout; + client.MaxResponseContentBufferSize = RegistryMaxResponseBytes; + client.DefaultRequestHeaders.Add("User-Agent", "PkgDiff/1.0 (+https://pkgdiff.dev)"); + }); + + // Only publish dates come from the search API, so a slow answer must not hold up the package page + services.AddHttpClient(HttpClientNames.MavenSearch, client => + { + client.BaseAddress = new Uri("https://search.maven.org/"); + client.Timeout = TimeSpan.FromSeconds(10); + client.MaxResponseContentBufferSize = RegistryMaxResponseBytes; + client.DefaultRequestHeaders.Add("User-Agent", "PkgDiff/1.0 (+https://pkgdiff.dev)"); + }); + services.AddHttpClient(HttpClientNames.PubDev, client => { client.BaseAddress = new Uri("https://pub.dev/"); diff --git a/src/backend/PkgDiff.API/Extensions/ServiceCollectionExtensions.cs b/src/backend/PkgDiff.API/Extensions/ServiceCollectionExtensions.cs index 025a161..6f055b7 100644 --- a/src/backend/PkgDiff.API/Extensions/ServiceCollectionExtensions.cs +++ b/src/backend/PkgDiff.API/Extensions/ServiceCollectionExtensions.cs @@ -23,6 +23,7 @@ public static IServiceCollection AddBusinessServices(this IServiceCollection ser services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // IPubDevService is the PubDev typed client registered by AddPkgDiffOutboundHttp services.AddScoped(); services.AddSingleton(); @@ -46,6 +47,7 @@ public static IServiceCollection AddBusinessServices(this IServiceCollection ser services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/backend/PkgDiff.Application/Interfaces/IMavenService.cs b/src/backend/PkgDiff.Application/Interfaces/IMavenService.cs new file mode 100644 index 0000000..1a57aea --- /dev/null +++ b/src/backend/PkgDiff.Application/Interfaces/IMavenService.cs @@ -0,0 +1,10 @@ +using PkgDiff.Shared.DTOs; + +namespace PkgDiff.Application.Interfaces; + +public interface IMavenService +{ + Task> GetPackageVersionsAsync(string packageId, bool includePrerelease = false); + Task GetPackagePublishedDateAsync(string packageId, string version); + Task GetPackageMetadataAsync(string packageId); +} diff --git a/src/backend/PkgDiff.Application/Versioning/MavenVersion.cs b/src/backend/PkgDiff.Application/Versioning/MavenVersion.cs new file mode 100644 index 0000000..725e9fa --- /dev/null +++ b/src/backend/PkgDiff.Application/Versioning/MavenVersion.cs @@ -0,0 +1,243 @@ +namespace PkgDiff.Application.Versioning; + +// A Maven Central version ordered as Maven's ComparableVersion does it: +// alpha < beta < milestone < rc < snapshot < release (ga, final) < sp < any other qualifier +public sealed class MavenVersion : IComparable, IComparable +{ + private const int MaxLength = 100; + + private static readonly string[] Qualifiers = { "alpha", "beta", "milestone", "rc", "snapshot", "", "sp" }; + + private static readonly Dictionary Aliases = new(StringComparer.Ordinal) + { + ["ga"] = "", + ["final"] = "", + ["release"] = "", + ["cr"] = "rc" + }; + + private static readonly HashSet PrereleaseWords = new(StringComparer.Ordinal) + { + "alpha", "beta", "milestone", "rc", "snapshot", "preview", "pre", "ea", "dev", "incubating" + }; + + private readonly ListItem _items; + + private MavenVersion(ListItem items, bool isPrerelease) + { + _items = items; + IsPrerelease = isPrerelease; + } + + public bool IsPrerelease { get; } + + public static bool TryParse(string? value, out MavenVersion version) + { + version = null!; + if (string.IsNullOrWhiteSpace(value) || value.Length > MaxLength) + return false; + + value = value.Trim(); + if (!char.IsAsciiDigit(value[0]) || value.Any(c => !char.IsAsciiLetterOrDigit(c) && c is not ('.' or '-' or '_' or '+'))) + return false; + + var items = Parse(value.ToLowerInvariant(), out var isPrerelease); + version = new MavenVersion(items, isPrerelease); + return true; + } + + public int CompareTo(MavenVersion? other) => other is null ? 1 : _items.CompareTo(other._items); + + public int CompareTo(object? obj) => CompareTo(obj as MavenVersion); + + private static ListItem Parse(string version, out bool isPrerelease) + { + isPrerelease = false; + var root = new ListItem(); + var list = root; + var stack = new Stack(); + stack.Push(list); + + var isDigit = false; + var start = 0; + + for (var i = 0; i < version.Length; i++) + { + var c = version[i]; + if (c == '.') + { + list.Add(i == start ? IntItem.Zero : ParseItem(isDigit, version[start..i], ref isPrerelease)); + start = i + 1; + } + else if (c is '-' or '_' or '+') + { + list.Add(i == start ? IntItem.Zero : ParseItem(isDigit, version[start..i], ref isPrerelease)); + start = i + 1; + list.Add(list = new ListItem()); + stack.Push(list); + } + else if (char.IsAsciiDigit(c)) + { + if (!isDigit && i > start) + { + // "m1" is milestone 1: a single letter right before a digit is a short qualifier + list.Add(StringItem.Of(version[start..i], followedByDigit: true, ref isPrerelease)); + start = i; + list.Add(list = new ListItem()); + stack.Push(list); + } + isDigit = true; + } + else + { + if (isDigit && i > start) + { + list.Add(ParseItem(true, version[start..i], ref isPrerelease)); + start = i; + list.Add(list = new ListItem()); + stack.Push(list); + } + isDigit = false; + } + } + + if (version.Length > start) + list.Add(ParseItem(isDigit, version[start..], ref isPrerelease)); + + while (stack.Count > 0) + stack.Pop().Normalize(); + + return root; + } + + private static Item ParseItem(bool isDigit, string text, ref bool isPrerelease) + { + if (isDigit) + { + var trimmed = text.TrimStart('0'); + return new IntItem(trimmed.Length == 0 ? "0" : trimmed); + } + return StringItem.Of(text, followedByDigit: false, ref isPrerelease); + } + + private abstract class Item + { + public abstract bool IsNull { get; } + + public abstract int CompareTo(Item? other); + } + + private sealed class IntItem : Item + { + public static readonly IntItem Zero = new("0"); + + // Digits without leading zeros, so longer means larger and equal length compares as text + private readonly string _digits; + + public IntItem(string digits) => _digits = digits; + + public override bool IsNull => _digits == "0"; + + public override int CompareTo(Item? other) => other switch + { + null => IsNull ? 0 : 1, + IntItem number => _digits.Length != number._digits.Length + ? _digits.Length.CompareTo(number._digits.Length) + : string.CompareOrdinal(_digits, number._digits), + _ => 1 + }; + } + + private sealed class StringItem : Item + { + private readonly string _value; + + private StringItem(string value) => _value = value; + + public static StringItem Of(string value, bool followedByDigit, ref bool isPrerelease) + { + if (followedByDigit && value.Length == 1) + { + value = value[0] switch + { + 'a' => "alpha", + 'b' => "beta", + 'm' => "milestone", + _ => value + }; + } + + if (Aliases.TryGetValue(value, out var alias)) + value = alias; + + if (PrereleaseWords.Contains(value)) + isPrerelease = true; + + return new StringItem(value); + } + + public override bool IsNull => Comparable(_value) == Comparable(string.Empty); + + public override int CompareTo(Item? other) => other switch + { + null => string.CompareOrdinal(Comparable(_value), Comparable(string.Empty)), + StringItem text => string.CompareOrdinal(Comparable(_value), Comparable(text._value)), + _ => -1 + }; + + // Known qualifiers by their rank; any other comes after all of them, in text order + private static string Comparable(string qualifier) + { + var index = Array.IndexOf(Qualifiers, qualifier); + return index >= 0 ? index.ToString() : $"{Qualifiers.Length}-{qualifier}"; + } + } + + private sealed class ListItem : Item + { + private readonly List _items = new(); + + public void Add(Item item) => _items.Add(item); + + public override bool IsNull => _items.Count == 0; + + // 1.0.0 is 1, and 1-ga is 1: trailing zeros and release words do not count + public void Normalize() + { + for (var i = _items.Count - 1; i >= 0; i--) + { + var item = _items[i]; + if (item.IsNull) + _items.RemoveAt(i); + else if (item is not ListItem) + break; + } + } + + public override int CompareTo(Item? other) + { + switch (other) + { + case null: + return _items.Count == 0 ? 0 : _items[0].CompareTo(null); + case IntItem: + return -1; + case StringItem: + return 1; + } + + var right = ((ListItem)other)._items; + for (var i = 0; i < Math.Max(_items.Count, right.Count); i++) + { + var l = i < _items.Count ? _items[i] : null; + var r = i < right.Count ? right[i] : null; + var result = l is null + ? (r is null ? 0 : -r.CompareTo(null)) + : l.CompareTo(r); + if (result != 0) + return result; + } + return 0; + } + } +} diff --git a/src/backend/PkgDiff.Application/Versioning/PackageVersion.cs b/src/backend/PkgDiff.Application/Versioning/PackageVersion.cs index 4b2213e..00cfbd1 100644 --- a/src/backend/PkgDiff.Application/Versioning/PackageVersion.cs +++ b/src/backend/PkgDiff.Application/Versioning/PackageVersion.cs @@ -5,7 +5,7 @@ namespace PkgDiff.Application.Versioning; public sealed class PackageVersion : IComparable, IEquatable { - // A Pep440Version, ComposerVersion or NuGetVersion; versions of one package always share the kind + // A Pep440Version, ComposerVersion, MavenVersion or NuGetVersion; versions of one package always share the kind private readonly IComparable _key; private PackageVersion(string value, bool isPrerelease, IComparable key) @@ -15,7 +15,7 @@ private PackageVersion(string value, bool isPrerelease, IComparable key) _key = key; } - // The spelling the registry is asked with: its own for npm, pub.dev, PyPI and Packagist, NuGetVersion.ToString() for NuGet + // The spelling the registry is asked with: its own for npm, pub.dev, PyPI, Packagist and Maven, NuGetVersion.ToString() for NuGet public string Value { get; } public bool IsPrerelease { get; } @@ -37,6 +37,15 @@ public static bool TryParse(VersionScheme scheme, string? value, [NotNullWhen(tr return true; } + if (scheme == VersionScheme.Maven) + { + if (!MavenVersion.TryParse(value, out var maven)) + return false; + + version = new PackageVersion(value, maven.IsPrerelease, maven); + return true; + } + if (scheme == VersionScheme.Composer) { if (!ComposerVersion.TryParse(value, out var composer)) diff --git a/src/backend/PkgDiff.Application/Versioning/VersionScheme.cs b/src/backend/PkgDiff.Application/Versioning/VersionScheme.cs index dd65a99..c64885c 100644 --- a/src/backend/PkgDiff.Application/Versioning/VersionScheme.cs +++ b/src/backend/PkgDiff.Application/Versioning/VersionScheme.cs @@ -12,5 +12,8 @@ public enum VersionScheme Pep440, // Composer's rules: Packagist - Composer + Composer, + + // Maven's ComparableVersion: Maven Central + Maven } diff --git a/src/backend/PkgDiff.Infrastructure/Services/GitHubScraperService.cs b/src/backend/PkgDiff.Infrastructure/Services/GitHubScraperService.cs index e9f09be..fbc5a5c 100644 --- a/src/backend/PkgDiff.Infrastructure/Services/GitHubScraperService.cs +++ b/src/backend/PkgDiff.Infrastructure/Services/GitHubScraperService.cs @@ -276,6 +276,11 @@ public async Task> GetAllReleasesAsync(string githubR { _logger.LogDebug("[GitHubScraper] Match found with 'v' prefix for version v{Version}", cleanVersion); } + else if (ReleaseSuffix().Replace(cleanVersion, string.Empty) is var core && core != cleanVersion + && (allReleases.TryGetValue(core, out notes) || allReleases.TryGetValue($"v{core}", out notes))) + { + _logger.LogDebug("[GitHubScraper] Match found for {Version} without its release suffix", cleanVersion); + } else { var fuzzyMatch = allReleases.FirstOrDefault(kvp => @@ -403,9 +408,9 @@ private static IEnumerable TagCandidates(string? packageId, string versi yield break; var names = new List { packageId }; - var slash = packageId.LastIndexOf('/'); - if (slash >= 0 && slash < packageId.Length - 1) - names.Add(packageId[(slash + 1)..]); + var separator = packageId.LastIndexOfAny(new[] { '/', ':' }); + if (separator >= 0 && separator < packageId.Length - 1) + names.Add(packageId[(separator + 1)..]); foreach (var name in names) { @@ -424,4 +429,8 @@ private static IEnumerable TagCandidates(string? packageId, string versi [System.Text.RegularExpressions.GeneratedRegex(@"^[vV]?\d")] private static partial System.Text.RegularExpressions.Regex PlainVersionTag(); + + // 6.4.0.Final is tagged 6.4.0 + [System.Text.RegularExpressions.GeneratedRegex(@"[.-](?:final|release|ga)$", System.Text.RegularExpressions.RegexOptions.IgnoreCase)] + private static partial System.Text.RegularExpressions.Regex ReleaseSuffix(); } diff --git a/src/backend/PkgDiff.Infrastructure/Services/HttpClientNames.cs b/src/backend/PkgDiff.Infrastructure/Services/HttpClientNames.cs index ff46fd6..4b8c531 100644 --- a/src/backend/PkgDiff.Infrastructure/Services/HttpClientNames.cs +++ b/src/backend/PkgDiff.Infrastructure/Services/HttpClientNames.cs @@ -8,6 +8,8 @@ public static class HttpClientNames public const string PyPIRegistry = "PyPIRegistry"; public const string PubDev = "PubDev"; public const string PackagistRepository = "PackagistRepository"; + public const string MavenCentral = "MavenCentral"; + public const string MavenSearch = "MavenSearch"; public const string HealthProbe = "HealthProbe"; public const string Osv = "Osv"; public const string DepsDev = "DepsDev"; diff --git a/src/backend/PkgDiff.Infrastructure/Services/ListedVersionsRegistryClient.cs b/src/backend/PkgDiff.Infrastructure/Services/ListedVersionsRegistryClient.cs index 5bb5066..0bfde83 100644 --- a/src/backend/PkgDiff.Infrastructure/Services/ListedVersionsRegistryClient.cs +++ b/src/backend/PkgDiff.Infrastructure/Services/ListedVersionsRegistryClient.cs @@ -48,7 +48,7 @@ public async Task> GetVersionsAsync(string package var excludePatterns = await _versionFilterService.GetActiveExcludePatternsAsync(); return versions - .Where(v => !_versionFilterService.ShouldExcludeVersion(v.Value, excludePatterns)) + .Where(v => !_versionFilterService.ShouldExcludeVersion(FilteredSpelling(v.Value), excludePatterns)) .ToList(); } @@ -60,13 +60,16 @@ public async Task GetMetadataAsync(string packageId) var metadata = await FetchMetadataAsync(packageId); var excludePatterns = await _versionFilterService.GetActiveExcludePatternsAsync(); metadata.Versions = metadata.Versions - .Where(v => !_versionFilterService.ShouldExcludeVersion(v.Version, excludePatterns)) + .Where(v => !_versionFilterService.ShouldExcludeVersion(FilteredSpelling(v.Version), excludePatterns)) .ToList(); return metadata; } protected abstract Task FetchMetadataAsync(string packageId); + // The text the exclude patterns are matched against + protected virtual string FilteredSpelling(string version) => version; + public abstract Task GetOwnReleaseNotesAsync(string packageId, string version); public abstract IReleaseNotesScrapingStrategy GetScrapingStrategy(string? githubRepoUrl); diff --git a/src/backend/PkgDiff.Infrastructure/Services/MavenRegistryClient.cs b/src/backend/PkgDiff.Infrastructure/Services/MavenRegistryClient.cs new file mode 100644 index 0000000..3534247 --- /dev/null +++ b/src/backend/PkgDiff.Infrastructure/Services/MavenRegistryClient.cs @@ -0,0 +1,54 @@ +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using PkgDiff.Application.Interfaces; +using PkgDiff.Application.Versioning; +using PkgDiff.Domain.Interfaces; +using PkgDiff.Shared.Constants; +using PkgDiff.Shared.DTOs; + +namespace PkgDiff.Infrastructure.Services; + +public partial class MavenRegistryClient : ListedVersionsRegistryClient +{ + private readonly IMavenService _mavenService; + private readonly GitHubFirstScrapingStrategy _githubFirstStrategy; + + public MavenRegistryClient( + IMavenService mavenService, + GitHubFirstScrapingStrategy githubFirstStrategy, + IVersionFilterService versionFilterService, + ILogger logger) + : base(versionFilterService, logger) + { + _mavenService = mavenService; + _githubFirstStrategy = githubFirstStrategy; + } + + public override string Id => RegistryNames.Maven; + + public override string SourceLabel => "Maven Central"; + + public override VersionScheme VersionScheme => VersionScheme.Maven; + + public override bool PublishesReleaseNotes => false; + + protected override Task> ListVersionsAsync(string packageId, bool includePrerelease) => + _mavenService.GetPackageVersionsAsync(packageId, includePrerelease); + + public override Task GetPublishedDateAsync(string packageId, string version) => + _mavenService.GetPackagePublishedDateAsync(packageId, version); + + protected override Task FetchMetadataAsync(string packageId) => + _mavenService.GetPackageMetadataAsync(packageId); + + public override Task GetOwnReleaseNotesAsync(string packageId, string version) => + Task.FromResult(null); + + public override IReleaseNotesScrapingStrategy GetScrapingStrategy(string? githubRepoUrl) => _githubFirstStrategy; + + // 6.4.0.Final and 5.3.0.RELEASE are releases; the "final" and "release" patterns must not hide them + protected override string FilteredSpelling(string version) => ReleaseSuffix().Replace(version, string.Empty); + + [GeneratedRegex(@"[.-](?:final|release|ga)$", RegexOptions.IgnoreCase)] + private static partial Regex ReleaseSuffix(); +} diff --git a/src/backend/PkgDiff.Infrastructure/Services/MavenService.cs b/src/backend/PkgDiff.Infrastructure/Services/MavenService.cs new file mode 100644 index 0000000..63febee --- /dev/null +++ b/src/backend/PkgDiff.Infrastructure/Services/MavenService.cs @@ -0,0 +1,358 @@ +using System.Collections.Concurrent; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Extensions.Logging; +using PkgDiff.Application.Interfaces; +using PkgDiff.Application.Versioning; +using PkgDiff.Shared.Constants; +using PkgDiff.Shared.DTOs; +using PkgDiff.Shared.Validation; + +namespace PkgDiff.Infrastructure.Services; + +// Maven Central: versions from maven-metadata.xml, dates from the Central search API, the repository from the POM +public partial class MavenService : IMavenService +{ + private const VersionScheme Scheme = VersionScheme.Maven; + private const int MaxParents = 4; + private const int SearchPageSize = 200; + private const int MaxSearchPages = 5; + + private static readonly string[] NotRepositoryOwners = { "sponsors", "orgs", "users", "apps", "marketplace", "settings" }; + + private readonly HttpClient _repository; + private readonly HttpClient _search; + private readonly ILogger _logger; + + // An analysis asks for many versions of one artifact; each file is read once per scope + private readonly ConcurrentDictionary?>> _versions = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary>> _dates = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary> _poms = new(StringComparer.Ordinal); + + public MavenService(IHttpClientFactory httpClientFactory, ILogger logger) + { + _repository = httpClientFactory.CreateClient(HttpClientNames.MavenCentral); + _search = httpClientFactory.CreateClient(HttpClientNames.MavenSearch); + _logger = logger; + } + + public async Task> GetPackageVersionsAsync(string packageId, bool includePrerelease = false) + { + var versions = await GetVersionsAsync(packageId); + if (versions is null) + return Enumerable.Empty(); + + return versions + .Where(v => includePrerelease || !v.IsPrerelease) + .Select(v => v.Value) + .ToList(); + } + + public async Task GetPackagePublishedDateAsync(string packageId, string version) + { + if (Coordinates(packageId) is not { } coordinates) + return null; + + var dates = await GetDatesAsync(packageId); + if (dates.TryGetValue(version, out var date)) + return date; + + try + { + using var request = new HttpRequestMessage(HttpMethod.Head, PomPath(coordinates.Group, coordinates.Artifact, version)); + using var response = await _repository.SendAsync(request); + return response.IsSuccessStatusCode ? response.Content.Headers.LastModified?.UtcDateTime : null; + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + _logger.LogWarning(ex, "Maven Central date lookup failed for {PackageId} {Version}", packageId, version); + return null; + } + } + + public async Task GetPackageMetadataAsync(string packageId) + { + var versions = await GetVersionsAsync(packageId); + if (versions is null || versions.Count == 0 || Coordinates(packageId) is not { } coordinates) + throw new ArgumentException($"Package '{packageId}' not found on Maven Central."); + + var stable = versions.Where(v => !v.IsPrerelease).ToList(); + var latest = stable.Count > 0 ? stable[^1] : versions[^1]; + var datesTask = GetDatesAsync(packageId); + + var pom = await GetPomAsync(coordinates.Group, coordinates.Artifact, latest.Value); + var chain = pom is null ? new List() : await ParentChainAsync(pom, coordinates.Group); + var properties = PropertiesOf(chain, coordinates.Group, coordinates.Artifact, latest.Value); + var repository = RepositoryOf(chain, coordinates.Group, properties); + var dates = await datesTask; + + var homepage = Interpolate(Text(pom, "url"), properties); + return new PackageMetadata + { + PackageId = packageId, + LatestVersion = latest.Value, + Description = Interpolate(Text(pom, "description"), properties)?.Trim() ?? Interpolate(Text(pom, "name"), properties) ?? string.Empty, + Authors = AuthorsOf(chain, properties) ?? "Unknown", + ProjectUrl = IsWebAddress(homepage) ? homepage : repository, + GitHubRepositoryUrl = repository, + Versions = stable + .AsEnumerable() + .Reverse() + .Select(v => new VersionSummary + { + Version = v.Value, + PublishedDate = dates.TryGetValue(v.Value, out var date) ? date : null, + IsPrerelease = false + }) + .ToList() + }; + } + + private Task?> GetVersionsAsync(string packageId) => + _versions.GetOrAdd(packageId, FetchVersionsAsync); + + // Ascending by Maven's ordering; 1.0 and 1.0.0 are one version, the first listed spelling kept + private async Task?> FetchVersionsAsync(string packageId) + { + if (Coordinates(packageId) is not { } coordinates) + return null; + + var metadata = await LoadXmlAsync($"{ArtifactPath(coordinates.Group, coordinates.Artifact)}/maven-metadata.xml", packageId); + if (metadata is null) + return null; + + var listed = metadata.Descendants() + .Where(e => e.Name.LocalName == "version" && e.Parent?.Name.LocalName == "versions") + .Select(e => e.Value.Trim()) + .Where(v => v.Length > 0) + .ToList(); + + var versions = PackageVersions.ParseAll(Scheme, listed, includePrerelease: true, out var skipped); + if (skipped.Count > 0) + { + _logger.LogWarning( + "Skipped {Count} versions of {PackageId} that Maven cannot order: {Versions}", + skipped.Count, packageId, string.Join(", ", skipped.Take(10))); + } + + return versions.Where((v, i) => i == 0 || !v.Equals(versions[i - 1])).ToList(); + } + + private Task> GetDatesAsync(string packageId) => + _dates.GetOrAdd(packageId, FetchDatesAsync); + + private async Task> FetchDatesAsync(string packageId) + { + var dates = new Dictionary(StringComparer.Ordinal); + if (Coordinates(packageId) is not { } coordinates) + return dates; + + var query = Uri.EscapeDataString($"g:\"{coordinates.Group}\" AND a:\"{coordinates.Artifact}\""); + try + { + for (var page = 0; page < MaxSearchPages; page++) + { + var answer = await _search.GetFromJsonAsync( + $"solrsearch/select?q={query}&core=gav&rows={SearchPageSize}&start={page * SearchPageSize}&wt=json"); + if (!answer.TryGetProperty("response", out var response) + || !response.TryGetProperty("docs", out var docs) + || docs.ValueKind != JsonValueKind.Array) + break; + + foreach (var doc in docs.EnumerateArray()) + { + if (doc.TryGetProperty("v", out var v) && v.ValueKind == JsonValueKind.String + && doc.TryGetProperty("timestamp", out var timestamp) && timestamp.TryGetInt64(out var milliseconds)) + dates.TryAdd(v.GetString()!, DateTimeOffset.FromUnixTimeMilliseconds(milliseconds).UtcDateTime); + } + + if (docs.GetArrayLength() < SearchPageSize) + break; + } + } + catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException or NotSupportedException) + { + // Dates are a nicety: the versions still list without them + _logger.LogWarning(ex, "Maven Central search failed for {PackageId}", packageId); + } + return dates; + } + + private Task GetPomAsync(string group, string artifact, string version) => + _poms.GetOrAdd($"{group}:{artifact}:{version}", _ => LoadXmlAsync(PomPath(group, artifact, version), $"{group}:{artifact}")); + + // The POM first, then each parent it inherits from + private async Task> ParentChainAsync(XElement pom, string group) + { + var chain = new List { pom }; + var current = pom; + for (var i = 0; i < MaxParents; i++) + { + var parent = Child(current, "parent"); + var parentGroup = Text(parent, "groupId"); + var parentArtifact = Text(parent, "artifactId"); + var parentVersion = Text(parent, "version"); + if (parentGroup is null || parentArtifact is null || parentVersion is null + || !PackageIdRules.IsValidPackageId(RegistryNames.Maven, $"{parentGroup}:{parentArtifact}") + || !PackageIdRules.IsValidVersion(parentVersion)) + break; + + var next = await GetPomAsync(parentGroup, parentArtifact, parentVersion); + if (next is null) + break; + chain.Add(next); + current = next; + } + return chain; + } + + // Values are inherited, the child's winning; project.* always names the artifact itself + private static Dictionary PropertiesOf(List chain, string group, string artifact, string version) + { + var properties = new Dictionary(StringComparer.Ordinal); + foreach (var pom in chain.AsEnumerable().Reverse()) + { + foreach (var property in Child(pom, "properties")?.Elements() ?? Enumerable.Empty()) + properties[property.Name.LocalName] = property.Value.Trim(); + } + + foreach (var prefix in new[] { "project.", "pom.", "" }) + { + properties[prefix + "groupId"] = group; + properties[prefix + "artifactId"] = artifact; + properties[prefix + "version"] = version; + } + if (chain.Count > 0 && Text(chain[0], "name") is { } name) + properties["project.name"] = name; + return properties; + } + + // A parent's address counts only when it is of the same group or is filled in with this artifact's values + private static string? RepositoryOf(List chain, string group, Dictionary properties) + { + for (var i = 0; i < chain.Count; i++) + { + var pom = chain[i]; + var inherited = i > 0; + var sameGroup = (Text(pom, "groupId") ?? Text(Child(pom, "parent"), "groupId")) == group; + var scm = Child(pom, "scm"); + var candidates = new[] + { + Text(scm, "url"), Text(scm, "connection"), Text(scm, "developerConnection"), + Text(pom, "url"), Text(Child(pom, "issueManagement"), "url") + }; + + foreach (var candidate in candidates) + { + if (candidate is null || (inherited && !sameGroup && !candidate.Contains("${"))) + continue; + if (GitHubRepository(Interpolate(candidate, properties)) is { } repository) + return repository; + } + } + return null; + } + + private static string? AuthorsOf(List chain, Dictionary properties) + { + foreach (var pom in chain) + { + var names = Child(pom, "developers")?.Elements() + .Select(d => Text(d, "name") ?? Text(d, "id")) + .Where(n => !string.IsNullOrWhiteSpace(n)) + .Take(5) + .ToList(); + if (names is { Count: > 0 }) + return Interpolate(string.Join(", ", names), properties); + + if (Interpolate(Text(Child(pom, "organization"), "name"), properties) is { Length: > 0 } organization) + return organization; + } + return null; + } + + private static string? GitHubRepository(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return null; + + var match = GitHubAddress().Match(text); + if (!match.Success || NotRepositoryOwners.Contains(match.Groups["owner"].Value, StringComparer.OrdinalIgnoreCase)) + return null; + + var repo = match.Groups["repo"].Value; + if (repo.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + repo = repo[..^4]; + return repo.Length == 0 ? null : $"https://github.com/{match.Groups["owner"].Value}/{repo}"; + } + + private static string? Interpolate(string? text, Dictionary properties) + { + if (text is null) + return null; + + for (var pass = 0; pass < 3 && text.Contains("${"); pass++) + text = Placeholder().Replace(text, m => properties.TryGetValue(m.Groups["name"].Value, out var value) ? value : m.Value); + return text.Trim(); + } + + private static bool IsWebAddress(string? url) => + Uri.TryCreate(url, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp); + + private async Task LoadXmlAsync(string path, string packageId) + { + try + { + using var response = await _repository.GetAsync(path); + if (!response.IsSuccessStatusCode) + return null; + + await using var stream = await response.Content.ReadAsStreamAsync(); + // No DTD: a POM needs none, and one could expand entities + using var reader = XmlReader.Create(stream, new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + Async = true + }); + return (await XDocument.LoadAsync(reader, LoadOptions.None, CancellationToken.None)).Root; + } + catch (Exception ex) when (ex is HttpRequestException or XmlException or TaskCanceledException) + { + _logger.LogWarning(ex, "Maven Central lookup failed for {PackageId}", packageId); + return null; + } + } + + private static (string Group, string Artifact)? Coordinates(string packageId) + { + if (!PackageIdRules.IsValidPackageId(RegistryNames.Maven, packageId)) + return null; + var colon = packageId.IndexOf(':'); + return (packageId[..colon], packageId[(colon + 1)..]); + } + + private static string ArtifactPath(string group, string artifact) => + string.Join('/', group.Split('.').Select(PackageUrlEncoder.EscapeSegment)) + "/" + PackageUrlEncoder.EscapeSegment(artifact); + + private static string PomPath(string group, string artifact, string version) + { + var escapedVersion = PackageUrlEncoder.EscapeSegment(version); + return $"{ArtifactPath(group, artifact)}/{escapedVersion}/{PackageUrlEncoder.EscapeSegment(artifact)}-{escapedVersion}.pom"; + } + + private static XElement? Child(XElement? element, string name) => + element?.Elements().FirstOrDefault(e => e.Name.LocalName == name); + + private static string? Text(XElement? element, string name) => + Child(element, name)?.Value.Trim() is { Length: > 0 } value ? value : null; + + [GeneratedRegex(@"github\.com[/:](?[A-Za-z0-9_.-]+)/(?[A-Za-z0-9_.-]+)", RegexOptions.IgnoreCase)] + private static partial Regex GitHubAddress(); + + [GeneratedRegex(@"\$\{(?[A-Za-z0-9_.-]+)\}")] + private static partial Regex Placeholder(); +} diff --git a/src/backend/PkgDiff.Infrastructure/Services/OsvClient.cs b/src/backend/PkgDiff.Infrastructure/Services/OsvClient.cs index 2aa8bf2..0a77298 100644 --- a/src/backend/PkgDiff.Infrastructure/Services/OsvClient.cs +++ b/src/backend/PkgDiff.Infrastructure/Services/OsvClient.cs @@ -225,6 +225,7 @@ private static string SeverityOf(Vulnerability vuln) RegistryNames.PyPI => "PyPI", RegistryNames.PubDev => "Pub", RegistryNames.Packagist => "Packagist", + RegistryNames.Maven => "Maven", _ => null }; diff --git a/src/backend/PkgDiff.Infrastructure/Services/PackageHealthClient.cs b/src/backend/PkgDiff.Infrastructure/Services/PackageHealthClient.cs index 0050433..ec135b9 100644 --- a/src/backend/PkgDiff.Infrastructure/Services/PackageHealthClient.cs +++ b/src/backend/PkgDiff.Infrastructure/Services/PackageHealthClient.cs @@ -179,6 +179,7 @@ private static bool HasAny(JsonElement element, string property) => RegistryNames.Npm => "NPM", RegistryNames.PyPI => "PYPI", RegistryNames.NuGet => "NUGET", + RegistryNames.Maven => "MAVEN", _ => null }; diff --git a/src/backend/PkgDiff.Infrastructure/Services/SocketBehaviorClient.cs b/src/backend/PkgDiff.Infrastructure/Services/SocketBehaviorClient.cs index f30b678..898b805 100644 --- a/src/backend/PkgDiff.Infrastructure/Services/SocketBehaviorClient.cs +++ b/src/backend/PkgDiff.Infrastructure/Services/SocketBehaviorClient.cs @@ -10,7 +10,7 @@ namespace PkgDiff.Infrastructure.Services; -// Socket.dev behaviour analysis for npm, PyPI and NuGet; off unless enabled with a token and org. +// Socket.dev behaviour analysis for npm, PyPI, NuGet and Maven; off unless enabled with a token and org. // The free tier allows about five batch calls an hour, so calls are batched and capped. public partial class SocketBehaviorClient : IBehaviorClient { @@ -145,10 +145,11 @@ private static List Alerts(JsonElement item) RegistryNames.Npm => "npm", RegistryNames.PyPI => "pypi", RegistryNames.NuGet => "nuget", + RegistryNames.Maven => "maven", _ => null }; - // pkg:npm/%40scope/name@1.0.0, pkg:pypi/normalized-name@1.0, pkg:nuget/Id@1.0.0 + // pkg:npm/%40scope/name@1.0.0, pkg:pypi/normalized-name@1.0, pkg:nuget/Id@1.0.0, pkg:maven/group/artifact@1.0 private static string Purl(string type, string registry, string packageId, string version) { var name = RegistryNames.Canonical(registry) switch @@ -156,6 +157,8 @@ private static string Purl(string type, string registry, string packageId, strin RegistryNames.Npm when packageId.StartsWith('@') && packageId.Contains('/') => "%40" + Uri.EscapeDataString(packageId[1..packageId.IndexOf('/')]) + "/" + Uri.EscapeDataString(packageId[(packageId.IndexOf('/') + 1)..]), RegistryNames.PyPI => Uri.EscapeDataString(PyPINormalization().Replace(packageId.ToLowerInvariant(), "-")), + RegistryNames.Maven when packageId.Contains(':') => + Uri.EscapeDataString(packageId[..packageId.IndexOf(':')]) + "/" + Uri.EscapeDataString(packageId[(packageId.IndexOf(':') + 1)..]), _ => Uri.EscapeDataString(packageId) }; return new StringBuilder("pkg:").Append(type).Append('/').Append(name).Append('@').Append(Uri.EscapeDataString(version)).ToString(); diff --git a/src/backend/PkgDiff.Shared/Constants/RegistryNames.cs b/src/backend/PkgDiff.Shared/Constants/RegistryNames.cs index 7148498..075cf07 100644 --- a/src/backend/PkgDiff.Shared/Constants/RegistryNames.cs +++ b/src/backend/PkgDiff.Shared/Constants/RegistryNames.cs @@ -7,8 +7,9 @@ public static class RegistryNames public const string PyPI = "pypi"; public const string PubDev = "pub.dev"; public const string Packagist = "packagist"; + public const string Maven = "maven"; - public static readonly IReadOnlyList All = new[] { NuGet, Npm, PyPI, PubDev, Packagist }; + public static readonly IReadOnlyList All = new[] { NuGet, Npm, PyPI, PubDev, Packagist, Maven }; private static readonly IReadOnlyDictionary Aliases = new Dictionary(StringComparer.OrdinalIgnoreCase) diff --git a/src/backend/PkgDiff.Shared/Validation/PackageIdRules.cs b/src/backend/PkgDiff.Shared/Validation/PackageIdRules.cs index 852cc79..5cb6ba8 100644 --- a/src/backend/PkgDiff.Shared/Validation/PackageIdRules.cs +++ b/src/backend/PkgDiff.Shared/Validation/PackageIdRules.cs @@ -27,8 +27,13 @@ public static class PackageIdRules private static readonly Regex PackagistId = new( @"^[a-z0-9]+(?:[_.-][a-z0-9]+)*/[a-z0-9]+(?:(?:[_.]|--?)[a-z0-9]+)*\z", RegexOptions.CultureInvariant, MatchTimeout); + // groupId:artifactId; the group's dots become folders, so none of its parts may be empty + private static readonly Regex MavenId = new( + @"^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*:[A-Za-z0-9_][A-Za-z0-9_.-]*\z", RegexOptions.CultureInvariant, MatchTimeout); + private static readonly IReadOnlyDictionary Grammars = new Dictionary { + [RegistryNames.Maven] = MavenId, [RegistryNames.NuGet] = NuGetId, [RegistryNames.Npm] = NpmId, [RegistryNames.PyPI] = PyPIId, diff --git a/src/backend/PkgDiff.Tests/Registries/RegistryClientTests.cs b/src/backend/PkgDiff.Tests/Registries/RegistryClientTests.cs index 17979c6..5b303ae 100644 --- a/src/backend/PkgDiff.Tests/Registries/RegistryClientTests.cs +++ b/src/backend/PkgDiff.Tests/Registries/RegistryClientTests.cs @@ -23,6 +23,7 @@ public class RegistryClientTests [InlineData("pub", "pub.dev", "PubDev", VersionScheme.SemVer, true, true)] [InlineData("pub.dev", "pub.dev", "PubDev", VersionScheme.SemVer, true, true)] [InlineData("composer", "packagist", "Packagist", VersionScheme.Composer, true, false)] + [InlineData("Maven", "maven", "Maven Central", VersionScheme.Maven, true, false)] public void Every_spelling_of_a_registry_reaches_its_one_client( string spelling, string id, string label, VersionScheme scheme, bool includesStart, bool publishesNotes) { @@ -36,7 +37,7 @@ public void Every_spelling_of_a_registry_reaches_its_one_client( } [Theory] - [InlineData("maven")] + [InlineData("cargo")] [InlineData("")] [InlineData("nuget ")] public void A_registry_without_a_client_is_a_caller_fault_not_a_nuget_lookup(string registry) @@ -66,13 +67,18 @@ public void Every_registry_name_has_a_client_and_a_package_id_grammar() { Assert.Equal(name, factory.GetClient(name).Id); Assert.Equal(name, RegistryNames.Canonical(name.ToUpperInvariant())); - var sample = name == RegistryNames.Packagist ? "abc/def" : "abc"; + var sample = name switch + { + RegistryNames.Packagist => "abc/def", + RegistryNames.Maven => "abc:def", + _ => "abc" + }; Assert.True(PkgDiff.Shared.Validation.PackageIdRules.IsValidPackageId(name, sample)); }); Assert.Equal(new[] { "pub.dev", "pub" }, RegistryNames.SpellingsOf("PUB")); Assert.Equal(new[] { "npm" }, RegistryNames.SpellingsOf("npm")); - Assert.Equal(new[] { "maven" }, RegistryNames.SpellingsOf("Maven")); - Assert.Null(RegistryNames.Canonical("maven")); + Assert.Equal(new[] { "cargo" }, RegistryNames.SpellingsOf("Cargo")); + Assert.Null(RegistryNames.Canonical("cargo")); } [Fact] diff --git a/src/backend/PkgDiff.Tests/TestDoubles/TestRegistries.cs b/src/backend/PkgDiff.Tests/TestDoubles/TestRegistries.cs index 11f2b77..8b7b989 100644 --- a/src/backend/PkgDiff.Tests/TestDoubles/TestRegistries.cs +++ b/src/backend/PkgDiff.Tests/TestDoubles/TestRegistries.cs @@ -19,7 +19,8 @@ public static RegistryServiceFactory Create( GitHubFirstScrapingStrategy? githubFirst = null, NuGetFirstScrapingStrategy? nugetFirst = null, PubDevScrapingStrategy? pubDevStrategy = null, - IPackagistService? packagist = null) + IPackagistService? packagist = null, + IMavenService? maven = null) { versionFilter ??= new VersionFilterService( new FakeUnitOfWork(), new MemoryCache(new MemoryCacheOptions()), NullLogger.Instance); @@ -30,7 +31,8 @@ public static RegistryServiceFactory Create( new NpmRegistryClient(npm!, githubFirst!, versionFilter, NullLogger.Instance), new PyPIRegistryClient(pypi!, githubFirst!, versionFilter, NullLogger.Instance), new PubDevRegistryClient(pubDev!, pubDevStrategy!, versionFilter, NullLogger.Instance), - new PackagistRegistryClient(packagist!, githubFirst!, versionFilter, NullLogger.Instance) + new PackagistRegistryClient(packagist!, githubFirst!, versionFilter, NullLogger.Instance), + new MavenRegistryClient(maven!, githubFirst!, versionFilter, NullLogger.Instance) ]); } } diff --git a/src/backend/PkgDiff.Tests/Validation/PackageInputValidationTests.cs b/src/backend/PkgDiff.Tests/Validation/PackageInputValidationTests.cs index 26d07c7..bc97796 100644 --- a/src/backend/PkgDiff.Tests/Validation/PackageInputValidationTests.cs +++ b/src/backend/PkgDiff.Tests/Validation/PackageInputValidationTests.cs @@ -31,7 +31,7 @@ public void IsKnownRegistry_AllowlistedKey_ReturnsTrue(string registry) } [Theory] - [InlineData("maven")] + [InlineData("cargo")] [InlineData("")] [InlineData(null)] [InlineData("npm ")] @@ -140,7 +140,7 @@ public void IsValidPackageId_InvalidPubName_ReturnsFalse(string registry, string [Fact] public void IsValidPackageId_UnknownRegistry_ReturnsFalse() { - Assert.False(PackageIdRules.IsValidPackageId("maven", "junit")); + Assert.False(PackageIdRules.IsValidPackageId("cargo", "serde")); } [Theory] @@ -191,7 +191,7 @@ public void AnalyzeRequest_PathTraversalId_IsInvalid() [Fact] public void AnalyzeRequest_UnknownRegistry_IsInvalid() { - var request = new AnalyzeRequest { Registry = "maven", PackageId = "x" }; + var request = new AnalyzeRequest { Registry = "cargo", PackageId = "x" }; var results = ValidateModel(request); @@ -303,7 +303,7 @@ public async Task Filter_DoubleEncodedId_IsNotDecodedTwice() [Fact] public async Task Filter_UnknownRegistry_ReturnsBadRequest() { - var arguments = new Dictionary { ["registry"] = "maven", ["packageId"] = "junit" }; + var arguments = new Dictionary { ["registry"] = "cargo", ["packageId"] = "serde" }; var (context, nextCalled) = await RunFilterAsync(new FakeUnitOfWork(), arguments); diff --git a/src/frontend/src/app/core/config/app.config.ts b/src/frontend/src/app/core/config/app.config.ts index 51b1a3e..011e1fd 100644 --- a/src/frontend/src/app/core/config/app.config.ts +++ b/src/frontend/src/app/core/config/app.config.ts @@ -3,7 +3,7 @@ import { environment } from '../../../environments/environment'; export const APP_CONFIG = { name: 'pkgdiff', shortName: 'pkgdiff', - description: 'Compare and analyze package versions across NuGet, npm, PyPI, Pub and Packagist registries. Track breaking changes, view detailed release notes, and understand version differences with our free online diff tool.', + description: 'Compare and analyze package versions across NuGet, npm, PyPI, Maven, Pub and Packagist registries. Track breaking changes, view detailed release notes, and understand version differences with our free online diff tool.', version: '1.0.0', contact: { diff --git a/src/frontend/src/app/core/utils/install-command.utils.ts b/src/frontend/src/app/core/utils/install-command.utils.ts index 91f8843..3a60c2d 100644 --- a/src/frontend/src/app/core/utils/install-command.utils.ts +++ b/src/frontend/src/app/core/utils/install-command.utils.ts @@ -26,6 +26,13 @@ export function installSnippets(registry: string, packageId: string, version: st { label: 'dart pub', code: `dart pub add ${packageId}:${version}` }, { label: 'pubspec.yaml', code: `${packageId}: ${version}` }, ]; + case 'maven': { + const [groupId, artifactId] = packageId.split(':'); + return [ + { label: 'Maven', code: `\n ${groupId}\n ${artifactId}\n ${version}\n` }, + { label: 'Gradle', code: `implementation("${packageId}:${version}")` }, + ]; + } case 'packagist': case 'composer': return [ diff --git a/src/frontend/src/app/core/utils/release-link.utils.ts b/src/frontend/src/app/core/utils/release-link.utils.ts index bf95728..ec56dfc 100644 --- a/src/frontend/src/app/core/utils/release-link.utils.ts +++ b/src/frontend/src/app/core/utils/release-link.utils.ts @@ -21,7 +21,7 @@ export function getReleaseLink( case 'pypi': return `https://pypi.org/project/${packageId}/${version}/`; case 'maven': - return `https://central.sonatype.com/artifact/${packageId}/${version}`; + return `https://central.sonatype.com/artifact/${packageId.replace(':', '/')}/${version}`; case 'pub': case 'pub.dev': return `https://pub.dev/packages/${packageId}/versions/${version}`; diff --git a/src/frontend/src/app/features/home/home.html b/src/frontend/src/app/features/home/home.html index 8cba28a..5fb86ef 100644 --- a/src/frontend/src/app/features/home/home.html +++ b/src/frontend/src/app/features/home/home.html @@ -1,7 +1,7 @@

- Compare package versions across NuGet, npm, PyPI, Pub & Packagist. Track breaking changes, analyze release notes, and + Compare package versions across NuGet, npm, PyPI, Maven, Pub & Packagist. Track breaking changes, analyze release notes, and understand version differences.

@@ -157,7 +157,7 @@

Track Breaking

4 Registries Supported

- Works with NuGet (.NET), npm (JavaScript), PyPI (Python), Pub (Dart / Flutter), and Packagist (PHP). + Works with NuGet (.NET), npm (JavaScript), PyPI (Python), Maven (Java), Pub (Dart / Flutter), and Packagist (PHP).

diff --git a/src/frontend/src/app/features/home/home.ts b/src/frontend/src/app/features/home/home.ts index 64ab2ec..728b23d 100644 --- a/src/frontend/src/app/features/home/home.ts +++ b/src/frontend/src/app/features/home/home.ts @@ -21,6 +21,7 @@ const EXAMPLES: Record = { pub: ['riverpod', 'lints', 'code_builder'], 'pub.dev': ['riverpod', 'lints', 'code_builder'], packagist: ['laravel/framework', 'symfony/console', 'guzzlehttp/guzzle'], + maven: ['com.google.guava:guava', 'com.fasterxml.jackson.core:jackson-databind', 'com.squareup.okhttp3:okhttp'], }; @Component({ @@ -58,8 +59,8 @@ export class HomeComponent { constructor() { this.seo.updateMeta({ - title: 'pkgdiff — Compare NuGet, npm, PyPI, Pub & Packagist Packages', - description: 'Free tool to compare package versions across NuGet, npm, PyPI, Pub & Packagist. Track breaking changes, view release notes, and analyze version differences between any two versions.', + title: 'pkgdiff — Compare NuGet, npm, PyPI, Maven, Pub & Packagist Packages', + description: 'Free tool to compare package versions across NuGet, npm, PyPI, Maven, Pub & Packagist. Track breaking changes, view release notes, and analyze version differences between any two versions.', url: '/', }); diff --git a/src/frontend/src/app/features/not-found/not-found.ts b/src/frontend/src/app/features/not-found/not-found.ts index 4596e79..0761085 100644 --- a/src/frontend/src/app/features/not-found/not-found.ts +++ b/src/frontend/src/app/features/not-found/not-found.ts @@ -15,6 +15,6 @@ export class NotFoundComponent { inject(HttpStatusService).notFound(); inject(SeoService).setNotFound( '404 - Page Not Found | pkgdiff', - 'The requested page could not be found. Return to pkgdiff to compare package versions across NuGet, npm, PyPI, Pub and Packagist registries.'); + 'The requested page could not be found. Return to pkgdiff to compare package versions across NuGet, npm, PyPI, Maven, Pub and Packagist registries.'); } } diff --git a/src/frontend/src/app/features/package/package.ts b/src/frontend/src/app/features/package/package.ts index b8625cf..ade7aac 100644 --- a/src/frontend/src/app/features/package/package.ts +++ b/src/frontend/src/app/features/package/package.ts @@ -158,6 +158,8 @@ export class PackageComponent { case 'pub': case 'pub.dev': return 'border-purple-300 dark:border-purple-700 text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-900/20'; + case 'maven': + return 'border-rose-300 dark:border-rose-700 text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-900/20'; case 'packagist': case 'composer': return 'border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20'; diff --git a/src/frontend/src/app/features/packages/packages-list.ts b/src/frontend/src/app/features/packages/packages-list.ts index d7ec504..6c6094a 100644 --- a/src/frontend/src/app/features/packages/packages-list.ts +++ b/src/frontend/src/app/features/packages/packages-list.ts @@ -93,8 +93,8 @@ export class PackagesListComponent { constructor() { this.seo.updateMeta({ - title: 'Browse Packages — Compare Versions Across NuGet, npm, PyPI, Pub & Packagist | pkgdiff', - description: 'Browse and compare packages from NuGet, npm, PyPI, Pub and Packagist registries. Search through thousands of packages, view detailed version history, track breaking changes, and analyze comprehensive release notes.', + title: 'Browse Packages — Compare Versions Across NuGet, npm, PyPI, Maven, Pub & Packagist | pkgdiff', + description: 'Browse and compare packages from NuGet, npm, PyPI, Maven, Pub and Packagist registries. Search through thousands of packages, view detailed version history, track breaking changes, and analyze comprehensive release notes.', url: '/packages', }); @@ -173,6 +173,8 @@ export class PackagesListComponent { case 'pub': case 'pub.dev': return 'border-purple-300 dark:border-purple-700 text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-900/20'; + case 'maven': + return 'border-rose-300 dark:border-rose-700 text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-900/20'; case 'packagist': case 'composer': return 'border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20'; diff --git a/src/frontend/src/app/features/privacy/privacy.html b/src/frontend/src/app/features/privacy/privacy.html index 5c36688..7f0c262 100644 --- a/src/frontend/src/app/features/privacy/privacy.html +++ b/src/frontend/src/app/features/privacy/privacy.html @@ -158,7 +158,7 @@

6. Service Commission's Standard Contractual Clauses.

- To build release notes, our server fetches public data from NuGet.org, npmjs.com, PyPI, pub.dev, Packagist and + To build release notes, our server fetches public data from NuGet.org, npmjs.com, PyPI, Maven Central, pub.dev, Packagist and GitHub, and security data from sources such as OSV and deps.dev. These requests come from our server and contain package names and versions only — never your IP address or any other data about you.

diff --git a/src/frontend/src/app/features/terms/terms.html b/src/frontend/src/app/features/terms/terms.html index 372be10..52fe80f 100644 --- a/src/frontend/src/app/features/terms/terms.html +++ b/src/frontend/src/app/features/terms/terms.html @@ -21,7 +21,7 @@

1. Accepta

2. Description of Service

{{ appName }} is a free, open-source tool that allows users to compare package versions across - registries (NuGet, npm, PyPI, pub.dev and Packagist), view release notes, and analyze differences between versions. + registries (NuGet, npm, PyPI, Maven, pub.dev and Packagist), view release notes, and analyze differences between versions. The Service retrieves publicly available data from package registries and GitHub repositories.

@@ -42,7 +42,7 @@

3. Accepta

4. Data & Content

The release notes, version information, and package metadata displayed on {{ appName }} are sourced - from third-party registries (NuGet.org, npmjs.com, PyPI, pub.dev and Packagist) and GitHub. We do not claim + from third-party registries (NuGet.org, npmjs.com, PyPI, Maven Central, pub.dev and Packagist) and GitHub. We do not claim ownership of this content; it belongs to its authors and is shown with a link to its source. The accuracy, completeness, and timeliness of this data depend on the upstream sources. {{ appName }} is not responsible for errors or omissions in third-party data. If you own content shown here and want it corrected or removed, contact us and we will act on it promptly. diff --git a/src/frontend/src/index.html b/src/frontend/src/index.html index 28959db..6d94933 100644 --- a/src/frontend/src/index.html +++ b/src/frontend/src/index.html @@ -15,11 +15,11 @@ })(); - pkgdiff — Compare NuGet, npm, PyPI, Pub & Packagist Packages + pkgdiff — Compare NuGet, npm, PyPI, Maven, Pub & Packagist Packages + content="Free tool to compare package versions across NuGet, npm, PyPI, Maven, Pub & Packagist. Track breaking changes, view release notes, and analyze version differences between any two versions."> @@ -35,9 +35,9 @@ + content="pkgdiff — Compare NuGet, npm, PyPI, Maven, Pub & Packagist Packages"> + content="Free tool to compare package versions across NuGet, npm, PyPI, Maven, Pub & Packagist. Track breaking changes, view release notes, and analyze version differences between any two versions."> @@ -45,7 +45,7 @@ - + @@ -53,11 +53,11 @@ + content="pkgdiff — Compare NuGet, npm, PyPI, Maven, Pub & Packagist Packages"> + content="Free tool to compare package versions across NuGet, npm, PyPI, Maven, Pub & Packagist. Track breaking changes, view release notes, and analyze version differences between any two versions."> - + @@ -76,7 +76,7 @@ "name": "pkgdiff", "alternateName": "Package Diff", "url": "https://pkgdiff.dev", - "description": "Free tool to compare package versions across NuGet, npm, PyPI, Pub & Packagist. Track breaking changes, view release notes, and analyze version differences between any two versions.", + "description": "Free tool to compare package versions across NuGet, npm, PyPI, Maven, Pub & Packagist. Track breaking changes, view release notes, and analyze version differences between any two versions.", "applicationCategory": "DeveloperApplication", "operatingSystem": "Web", "browserRequirements": "Requires JavaScript. Requires HTML5.", @@ -91,6 +91,7 @@ "Compare npm package versions", "Compare PyPI package versions", "Compare pub.dev package versions", + "Compare Maven package versions", "Compare Packagist package versions", "View release notes and changelogs", "Track version history", @@ -127,7 +128,7 @@ style="font-family: Inter, sans-serif; max-width: 720px; margin: 0 auto; padding: 40px 16px; font-size: 16px; line-height: 1.6; color: #111418;">

pkgdiff — Compare Package Versions Online

- Compare and analyze package versions across NuGet, npm, PyPI, Pub and Packagist registries. Track breaking changes, + Compare and analyze package versions across NuGet, npm, PyPI, Maven, Pub and Packagist registries. Track breaking changes, view detailed release notes, and understand version differences with our free online diff tool. Perfect for developers upgrading dependencies and managing package updates. diff --git a/src/frontend/ssr-smoke.test.mjs b/src/frontend/ssr-smoke.test.mjs index 3b7e8a2..d6a92eb 100644 --- a/src/frontend/ssr-smoke.test.mjs +++ b/src/frontend/ssr-smoke.test.mjs @@ -121,9 +121,9 @@ test('the home page arrives rendered, with its meta and ready to be hydrated', a const { status, html } = await page('/'); assert.equal(status, 200); - assert.match(html, /]*>\s*Compare package versions across NuGet, npm, PyPI, Pub & Packagist/); + assert.match(html, /]*>\s*Compare package versions across NuGet, npm, PyPI, Maven, Pub & Packagist/); assert.match(html, /What you can do with pkgdiff/); - assert.match(html, /pkgdiff — Compare NuGet, npm, PyPI, Pub & Packagist Packages<\/title>/); + assert.match(html, /<title>pkgdiff — Compare NuGet, npm, PyPI, Maven, Pub & Packagist Packages<\/title>/); assert.equal(count(html, /<link rel="canonical"/g), 1); assert.match(html, /<link rel="canonical" href="https:\/\/pkgdiff\.dev\/"/); assert.match(html, /ng-server-context="ssr"/);