Skip to content

Commit 4deca6e

Browse files
authored
Merge pull request #14 from pkgdiff-dev/gsd/quick-261021-packagist-registry
feat(packagist): add PHP packages from Packagist as a fifth registry
2 parents 035e882 + 81ce92d commit 4deca6e

34 files changed

Lines changed: 621 additions & 43 deletions

File tree

‎src/backend/PkgDiff.API/Extensions/DatabaseSeeder.cs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,22 @@ public static async Task SeedDataAsync(IServiceProvider serviceProvider)
154154
}
155155
}
156156

157+
var packagistSpellings = PkgDiff.Shared.Constants.RegistryNames.SpellingsOf(PkgDiff.Shared.Constants.RegistryNames.Packagist);
158+
if (!await context.Registries.AnyAsync(r => packagistSpellings.Contains(r.Value.ToLower())))
159+
{
160+
var displayOrder = (await context.Registries.MaxAsync(r => (int?)r.DisplayOrder) ?? 0) + 1;
161+
context.Registries.Add(new Registry
162+
{
163+
Value = PkgDiff.Shared.Constants.RegistryNames.Packagist,
164+
Label = "Packagist",
165+
Icon = "/icons/packagist.svg",
166+
Disabled = false,
167+
DisplayOrder = displayOrder
168+
});
169+
await context.SaveChangesAsync();
170+
logger.LogInformation("Registry {Registry} added", PkgDiff.Shared.Constants.RegistryNames.Packagist);
171+
}
172+
157173
var expectedPatterns = new[]
158174
{
159175
new { Id = 1, Pattern = "dev", Description = "Development builds (e.g., 1.0.0-dev-123)" },

‎src/backend/PkgDiff.API/Extensions/OutboundHttpServiceCollectionExtensions.cs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ public static IServiceCollection AddPkgDiffOutboundHttp(this IServiceCollection
5252
client.MaxResponseContentBufferSize = RegistryMaxResponseBytes;
5353
});
5454

55+
// Packagist asks API clients to name themselves with a way to reach them
56+
services.AddHttpClient(HttpClientNames.PackagistRepository, client =>
57+
{
58+
client.BaseAddress = new Uri("https://repo.packagist.org/");
59+
client.Timeout = FixedHostTimeout;
60+
client.MaxResponseContentBufferSize = RegistryMaxResponseBytes;
61+
client.DefaultRequestHeaders.Add("User-Agent", "PkgDiff/1.0 (+https://pkgdiff.dev)");
62+
});
63+
5564
services.AddHttpClient<IPubDevService, PubDevService>(HttpClientNames.PubDev, client =>
5665
{
5766
client.BaseAddress = new Uri("https://pub.dev/");

‎src/backend/PkgDiff.API/Extensions/ServiceCollectionExtensions.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public static IServiceCollection AddBusinessServices(this IServiceCollection ser
2222
services.AddScoped<INugetService, NugetService>();
2323
services.AddScoped<INpmService, NpmService>();
2424
services.AddScoped<IPyPIService, PyPIService>();
25+
services.AddScoped<IPackagistService, PackagistService>();
2526
// IPubDevService is the PubDev typed client registered by AddPkgDiffOutboundHttp
2627
services.AddScoped<IScraperService, ScraperService>();
2728
services.AddSingleton<GitHubScrapeCache>();
@@ -44,6 +45,7 @@ public static IServiceCollection AddBusinessServices(this IServiceCollection ser
4445
services.AddScoped<IRegistryClient, NpmRegistryClient>();
4546
services.AddScoped<IRegistryClient, PyPIRegistryClient>();
4647
services.AddScoped<IRegistryClient, PubDevRegistryClient>();
48+
services.AddScoped<IRegistryClient, PackagistRegistryClient>();
4749

4850
services.AddScoped<IRegistryServiceFactory, RegistryServiceFactory>();
4951
services.AddScoped<IPackageCacheService, PackageCacheService>();
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using PkgDiff.Shared.DTOs;
2+
3+
namespace PkgDiff.Application.Interfaces;
4+
5+
public interface IPackagistService
6+
{
7+
Task<IEnumerable<string>> GetPackageVersionsAsync(string packageId, bool includePrerelease = false);
8+
Task<DateTime?> GetPackagePublishedDateAsync(string packageId, string version);
9+
Task<PackageMetadata> GetPackageMetadataAsync(string packageId);
10+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
using System.Text.RegularExpressions;
2+
3+
namespace PkgDiff.Application.Versioning;
4+
5+
// A Packagist version as Composer orders it: dev < alpha < beta < RC < stable < patch
6+
public sealed class ComposerVersion : IComparable<ComposerVersion>, IComparable
7+
{
8+
private const int MaxLength = 100;
9+
private const int MaxReleaseSegments = 4;
10+
private const int StableRank = 4;
11+
12+
private static readonly Regex Pattern = new(
13+
@"^v?
14+
(?<release>[0-9]+(?:\.[0-9]+)*)
15+
(?:[._-]?(?<stability>stable|beta|b|rc|alpha|a|patch|pl|p)(?<number>[.-]?[0-9]+(?:[.-][0-9]+)*)?)?
16+
(?<dev>[.-]?dev)?
17+
(?:\+[0-9A-Za-z.-]+)?$",
18+
RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace | RegexOptions.CultureInvariant,
19+
TimeSpan.FromMilliseconds(250));
20+
21+
private readonly long[] _release;
22+
private readonly int _rank;
23+
private readonly long[] _number;
24+
private readonly bool _dev;
25+
26+
private ComposerVersion(long[] release, int rank, long[] number, bool dev)
27+
{
28+
_release = release;
29+
_rank = rank;
30+
_number = number;
31+
_dev = dev;
32+
}
33+
34+
public bool IsPrerelease => _rank < StableRank || _dev;
35+
36+
public static bool TryParse(string? value, out ComposerVersion version)
37+
{
38+
version = null!;
39+
if (string.IsNullOrWhiteSpace(value) || value.Length > MaxLength)
40+
return false;
41+
42+
Match match;
43+
try
44+
{
45+
match = Pattern.Match(value.Trim());
46+
}
47+
catch (RegexMatchTimeoutException)
48+
{
49+
return false;
50+
}
51+
52+
if (!match.Success)
53+
return false;
54+
55+
var segments = match.Groups["release"].Value.Split('.');
56+
if (segments.Length > MaxReleaseSegments || !TryNumbers(segments, out var release))
57+
return false;
58+
59+
var number = Array.Empty<long>();
60+
if (match.Groups["number"].Success
61+
&& !TryNumbers(match.Groups["number"].Value.Split(new[] { '.', '-' }, StringSplitOptions.RemoveEmptyEntries), out number))
62+
return false;
63+
64+
var dev = match.Groups["dev"].Success;
65+
var rank = match.Groups["stability"].Success
66+
? RankOf(match.Groups["stability"].Value)
67+
: dev ? 0 : StableRank;
68+
69+
version = new ComposerVersion(release, rank, number, dev && match.Groups["stability"].Success);
70+
return true;
71+
}
72+
73+
public int CompareTo(ComposerVersion? other)
74+
{
75+
if (other is null)
76+
return 1;
77+
78+
var result = CompareNumbers(_release, other._release);
79+
if (result != 0)
80+
return result;
81+
82+
result = _rank.CompareTo(other._rank);
83+
if (result != 0)
84+
return result;
85+
86+
result = CompareNumbers(_number, other._number);
87+
if (result != 0)
88+
return result;
89+
90+
// 1.0.0-beta1-dev comes before 1.0.0-beta1
91+
return other._dev.CompareTo(_dev);
92+
}
93+
94+
public int CompareTo(object? obj) => CompareTo(obj as ComposerVersion);
95+
96+
private static int RankOf(string stability) => stability.ToLowerInvariant() switch
97+
{
98+
"alpha" or "a" => 1,
99+
"beta" or "b" => 2,
100+
"rc" => 3,
101+
"stable" => StableRank,
102+
_ => 5
103+
};
104+
105+
private static bool TryNumbers(string[] parts, out long[] numbers)
106+
{
107+
numbers = new long[parts.Length];
108+
for (var i = 0; i < parts.Length; i++)
109+
{
110+
if (!long.TryParse(parts[i], out numbers[i]))
111+
return false;
112+
}
113+
return true;
114+
}
115+
116+
// Trailing zeros do not count: 1.0 and 1.0.0.0 are the same release
117+
private static int CompareNumbers(long[] left, long[] right)
118+
{
119+
var length = Math.Max(left.Length, right.Length);
120+
for (var i = 0; i < length; i++)
121+
{
122+
var result = (i < left.Length ? left[i] : 0).CompareTo(i < right.Length ? right[i] : 0);
123+
if (result != 0)
124+
return result;
125+
}
126+
return 0;
127+
}
128+
}

‎src/backend/PkgDiff.Application/Versioning/PackageVersion.cs‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ namespace PkgDiff.Application.Versioning;
55

66
public sealed class PackageVersion : IComparable<PackageVersion>, IEquatable<PackageVersion>
77
{
8-
// Either a Pep440Version or a NuGetVersion; versions of one package always share the kind
8+
// A Pep440Version, ComposerVersion or NuGetVersion; versions of one package always share the kind
99
private readonly IComparable _key;
1010

1111
private PackageVersion(string value, bool isPrerelease, IComparable key)
@@ -15,7 +15,7 @@ private PackageVersion(string value, bool isPrerelease, IComparable key)
1515
_key = key;
1616
}
1717

18-
// The spelling the registry is asked with: its own for npm, pub.dev and PyPI, NuGetVersion.ToString() for NuGet
18+
// The spelling the registry is asked with: its own for npm, pub.dev, PyPI and Packagist, NuGetVersion.ToString() for NuGet
1919
public string Value { get; }
2020

2121
public bool IsPrerelease { get; }
@@ -37,6 +37,15 @@ public static bool TryParse(VersionScheme scheme, string? value, [NotNullWhen(tr
3737
return true;
3838
}
3939

40+
if (scheme == VersionScheme.Composer)
41+
{
42+
if (!ComposerVersion.TryParse(value, out var composer))
43+
return false;
44+
45+
version = new PackageVersion(value, composer.IsPrerelease, composer);
46+
return true;
47+
}
48+
4049
if (!NuGetVersion.TryParse(value, out var semantic))
4150
return false;
4251

‎src/backend/PkgDiff.Application/Versioning/VersionScheme.cs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,8 @@ public enum VersionScheme
99
SemVer,
1010

1111
// PEP 440: PyPI
12-
Pep440
12+
Pep440,
13+
14+
// Composer's rules: Packagist
15+
Composer
1316
}

‎src/backend/PkgDiff.Infrastructure/Services/HttpClientNames.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ public static class HttpClientNames
77
public const string NpmRegistry = "NpmRegistry";
88
public const string PyPIRegistry = "PyPIRegistry";
99
public const string PubDev = "PubDev";
10+
public const string PackagistRepository = "PackagistRepository";
1011
public const string HealthProbe = "HealthProbe";
1112
public const string Osv = "Osv";
1213
public const string DepsDev = "DepsDev";

‎src/backend/PkgDiff.Infrastructure/Services/ManifestParser.cs‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public ManifestParseResult Parse(string registry, string content, string? fileNa
3131
RegistryNames.NuGet => ParseNuGet(content),
3232
RegistryNames.PyPI => ParsePyPI(content, fileName),
3333
RegistryNames.PubDev => ParsePub(content),
34+
RegistryNames.Packagist => ParseComposer(content),
3435
_ => throw new ArgumentException("Unknown registry.")
3536
};
3637
result.Registry = canonical;
@@ -587,6 +588,86 @@ private static AuditDependency FromPubConstraint(string name, string? requiremen
587588
};
588589
}
589590

591+
private static ManifestParseResult ParseComposer(string content)
592+
{
593+
var root = ParseJson(content, "a composer.json or composer.lock");
594+
595+
if (root.TryGetProperty("packages", out var packages) && packages.ValueKind == JsonValueKind.Array)
596+
{
597+
var lockResult = new ManifestParseResult { Format = "composer.lock" };
598+
ReadComposerLock(packages, null, lockResult);
599+
if (root.TryGetProperty("packages-dev", out var devPackages) && devPackages.ValueKind == JsonValueKind.Array)
600+
ReadComposerLock(devPackages, Dev, lockResult);
601+
return lockResult;
602+
}
603+
604+
if (!root.TryGetProperty("require", out _) && !root.TryGetProperty("require-dev", out _))
605+
throw new ArgumentException("This JSON has neither require nor packages: paste a composer.json or a composer.lock.");
606+
607+
var result = new ManifestParseResult { Format = "composer.json" };
608+
foreach (var (section, scope) in new[] { ("require", (string?)null), ("require-dev", Dev) })
609+
{
610+
if (!root.TryGetProperty(section, out var dependencies) || dependencies.ValueKind != JsonValueKind.Object)
611+
continue;
612+
foreach (var dependency in dependencies.EnumerateObject())
613+
{
614+
// php, ext-*, lib-* and composer-plugin-api name the platform, not a package
615+
if (!dependency.Name.Contains('/'))
616+
continue;
617+
var requirement = dependency.Value.ValueKind == JsonValueKind.String ? dependency.Value.GetString() : null;
618+
result.Dependencies.Add(FromComposerConstraint(dependency.Name.ToLowerInvariant(), requirement, scope, result.Warnings));
619+
}
620+
}
621+
return result;
622+
}
623+
624+
private static void ReadComposerLock(JsonElement packages, string? scope, ManifestParseResult result)
625+
{
626+
foreach (var package in packages.EnumerateArray())
627+
{
628+
var name = Text(package, "name");
629+
var version = Text(package, "version");
630+
if (name is null || !name.Contains('/'))
631+
continue;
632+
if (version is null || version.StartsWith("dev-", StringComparison.OrdinalIgnoreCase))
633+
{
634+
result.Warnings.Add($"{name}: \"{version}\" is a branch, not a release; its latest version is checked.");
635+
result.Dependencies.Add(new AuditDependency { Name = name.ToLowerInvariant(), Requirement = version, Scope = scope });
636+
continue;
637+
}
638+
result.Dependencies.Add(Exact(name.ToLowerInvariant(), StripComposerV(version), scope));
639+
}
640+
}
641+
642+
private static AuditDependency FromComposerConstraint(string name, string? requirement, string? scope, List<string> warnings)
643+
{
644+
var value = requirement?.Trim() ?? string.Empty;
645+
if (value.Length == 0 || value == "*")
646+
return new AuditDependency { Name = name, Requirement = requirement, Scope = scope };
647+
648+
if (value.Contains("dev-", StringComparison.OrdinalIgnoreCase))
649+
{
650+
warnings.Add($"{name}: \"{value}\" is a branch, not a release; its latest version is checked.");
651+
return new AuditDependency { Name = name, Requirement = requirement, Scope = scope };
652+
}
653+
654+
// "^1.0 || ^2.0" allows several ranges; the first one is checked, without its @stability flag
655+
var first = (value.Split('|', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? value).Trim().Split('@')[0];
656+
var exact = SemVerExact().IsMatch(first);
657+
var match = SemVerStart().Match(first);
658+
return new AuditDependency
659+
{
660+
Name = name,
661+
Requirement = requirement,
662+
Scope = scope,
663+
IsExact = exact,
664+
Version = match.Success ? Complete(match.Value) : null
665+
};
666+
}
667+
668+
private static string StripComposerV(string version) =>
669+
version.Length > 1 && (version[0] == 'v' || version[0] == 'V') && char.IsDigit(version[1]) ? version[1..] : version;
670+
590671
private static JsonElement ParseJson(string content, string expected)
591672
{
592673
try

‎src/backend/PkgDiff.Infrastructure/Services/OsvClient.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ private static string SeverityOf(Vulnerability vuln)
224224
RegistryNames.Npm => "npm",
225225
RegistryNames.PyPI => "PyPI",
226226
RegistryNames.PubDev => "Pub",
227+
RegistryNames.Packagist => "Packagist",
227228
_ => null
228229
};
229230

0 commit comments

Comments
 (0)