diff --git a/Directory.Packages.props b/Directory.Packages.props index 9709d33..ee2f768 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,9 +7,6 @@ - - - @@ -17,14 +14,12 @@ - - - + \ No newline at end of file diff --git a/source/Directory.Build.props b/source/Directory.Build.props index 9f1eeeb..cc41fe5 100644 --- a/source/Directory.Build.props +++ b/source/Directory.Build.props @@ -5,7 +5,7 @@ true - 1.0.0-beta.31 + 1.0.0-beta.32 Steven T. Cramer https://github.com/TimeWarpEngineering/timewarp-amuru Unlicense diff --git a/source/timewarp-amuru/global-usings.cs b/source/timewarp-amuru/global-usings.cs index 6043cf5..3036cb6 100644 --- a/source/timewarp-amuru/global-usings.cs +++ b/source/timewarp-amuru/global-usings.cs @@ -5,9 +5,6 @@ global using CliWrap; global using CliWrap.Buffered; global using CliWrap.EventStream; -global using NuGet.Common; -global using NuGet.Configuration; -global using NuGet.Protocol.Core.Types; global using NuGet.Versioning; // global using StreamJsonRpc; global using System; @@ -21,4 +18,4 @@ global using System.Text.Json.Serialization; global using System.Threading; global using System.Xml.Linq; -global using TimeWarp.Terminal; \ No newline at end of file +global using TimeWarp.Terminal; diff --git a/source/timewarp-amuru/nu-get/nuget-package-service.cs b/source/timewarp-amuru/nu-get/nuget-package-service.cs index cfb595a..272d732 100644 --- a/source/timewarp-amuru/nu-get/nuget-package-service.cs +++ b/source/timewarp-amuru/nu-get/nuget-package-service.cs @@ -1,59 +1,37 @@ #region Purpose -// Implementation of NuGet package operations using NuGet Protocol API +// Implementation of NuGet package operations using NuGet registration API #endregion #region Design -// Uses FindPackageByIdResource instead of PackageMetadataResource because: -// - GetAllVersionsAsync returns all versions including prerelease in one call -// - Simpler API surface - no need for multiple queries -// - Aggregate versions from all enabled NuGet sources for comprehensive results -// - SourceRepository instances are cached via NuGetSourceCache to avoid repeated init -// - Removed CLI-based approach (ParseSearchResult) for correctness and performance: -// dotnet package search only returns latest version, Protocol gives all versions +// Uses nuget.org's registration index to avoid the NuGet.Protocol dependency chain. +// Keeps NuGet.Versioning for NuGet-compatible parsing, normalization, and comparison. +// Registration metadata excludes unlisted packages and matches package update semantics. +// The service targets nuget.org package version checks, not authenticated/custom feeds. #endregion namespace TimeWarp.Amuru; public sealed class NuGetPackageService : INuGetPackageService { - private readonly NuGetSourceCache SourceCache; + private const string RegistrationBaseUrl = "https://api.nuget.org/v3/registration5-gz-semver2"; + private static readonly HttpClient HttpClient = new + ( + new HttpClientHandler + { + AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate, + CheckCertificateRevocationList = true + } + ); public NuGetPackageService() - : this(new NuGetSourceCache()) - { - } - - internal NuGetPackageService(NuGetSourceCache sourceCache) { - SourceCache = sourceCache; } public async Task SearchAsync(string packageId, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(packageId); - SourceRepository repository = SourceCache.GetOrCreate("https://api.nuget.org/v3/index.json"); - - FindPackageByIdResource? resource = await repository.GetResourceAsync(cancellationToken); - if (resource == null) - { - return null; - } - - using SourceCacheContext cacheContext = new(); - #pragma warning disable IDE0007 - IEnumerable versions = await resource.GetAllVersionsAsync -#pragma warning restore IDE0007 - ( - packageId, - cacheContext, - NullLogger.Instance, - cancellationToken - ); - -#pragma warning disable IDE0007 - List versionList = versions.ToList(); -#pragma warning restore IDE0007 + List? versionList = await GetVersionsAsync(packageId, cancellationToken); if (versionList.Count == 0) { return null; @@ -73,24 +51,7 @@ internal NuGetPackageService(NuGetSourceCache sourceCache) { ArgumentException.ThrowIfNullOrWhiteSpace(packageId); - SourceRepository repository = SourceCache.GetOrCreate("https://api.nuget.org/v3/index.json"); - - FindPackageByIdResource? resource = await repository.GetResourceAsync(cancellationToken); - if (resource == null) - { - return null; - } - - using SourceCacheContext cacheContext = new(); - #pragma warning disable IDE0007 - IEnumerable versions = await resource.GetAllVersionsAsync -#pragma warning restore IDE0007 - ( - packageId, - cacheContext, - NullLogger.Instance, - cancellationToken - ); + List? versions = await GetVersionsAsync(packageId, cancellationToken); NuGetVersion? stableVersion = null; NuGetVersion? prereleaseVersion = null; @@ -189,4 +150,114 @@ public string GetUpdateType(string currentVersion, string latestVersion) if (latest.Minor > current.Minor) return "minor"; return "patch"; } -} \ No newline at end of file + + private static async Task> GetVersionsAsync(string packageId, CancellationToken cancellationToken) + { + string lowerPackageId = packageId.ToLowerInvariant(); + string escapedPackageId = Uri.EscapeDataString(lowerPackageId); + string url = $"{RegistrationBaseUrl}/{escapedPackageId}/index.json"; + + using HttpRequestMessage request = new(HttpMethod.Get, url); + using HttpResponseMessage response = await HttpClient.SendAsync(request, cancellationToken); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return []; + } + + response.EnsureSuccessStatusCode(); + + using JsonDocument document = await ReadJsonDocumentAsync(response.Content, cancellationToken); + + if (!document.RootElement.TryGetProperty("items", out JsonElement pagesElement) || + pagesElement.ValueKind != JsonValueKind.Array) + { + return []; + } + + List versions = []; + foreach (JsonElement pageElement in pagesElement.EnumerateArray()) + { + await AddPageVersionsAsync(pageElement, versions, cancellationToken); + } + + return versions; + } + + private static async Task AddPageVersionsAsync + ( + JsonElement pageElement, + List versions, + CancellationToken cancellationToken + ) + { + if (!pageElement.TryGetProperty("items", out JsonElement itemsElement)) + { + if (!pageElement.TryGetProperty("@id", out JsonElement pageUrlElement)) + { + return; + } + + string? pageUrl = pageUrlElement.GetString(); + if (string.IsNullOrWhiteSpace(pageUrl)) + { + return; + } + + using HttpRequestMessage request = new(HttpMethod.Get, pageUrl); + using HttpResponseMessage response = await HttpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + using JsonDocument pageDocument = await ReadJsonDocumentAsync(response.Content, cancellationToken); + + if (!pageDocument.RootElement.TryGetProperty("items", out itemsElement)) + { + return; + } + } + + AddLeafVersions(itemsElement, versions); + } + + private static void AddLeafVersions(JsonElement itemsElement, List versions) + { + if (itemsElement.ValueKind != JsonValueKind.Array) + { + return; + } + + foreach (JsonElement itemElement in itemsElement.EnumerateArray()) + { + if (!itemElement.TryGetProperty("catalogEntry", out JsonElement catalogEntryElement) || + !catalogEntryElement.TryGetProperty("version", out JsonElement versionElement)) + { + continue; + } + + string? version = versionElement.GetString(); + if (version != null && NuGetVersion.TryParse(version, out NuGetVersion? parsedVersion)) + { + versions.Add(parsedVersion); + } + } + } + + private static async Task ReadJsonDocumentAsync(HttpContent content, CancellationToken cancellationToken) + { + byte[] bytes = await content.ReadAsByteArrayAsync(cancellationToken); + if (bytes.Length >= 2 && bytes[0] == 0x1F && bytes[1] == 0x8B) + { + using MemoryStream compressedStream = new(bytes); + using System.IO.Compression.GZipStream gzipStream = new + ( + compressedStream, + System.IO.Compression.CompressionMode.Decompress + ); + using MemoryStream decompressedStream = new(); + await gzipStream.CopyToAsync(decompressedStream, cancellationToken); + return JsonDocument.Parse(decompressedStream.ToArray()); + } + + return JsonDocument.Parse(bytes); + } +} diff --git a/source/timewarp-amuru/nu-get/nuget-source-cache.cs b/source/timewarp-amuru/nu-get/nuget-source-cache.cs deleted file mode 100644 index 6128f85..0000000 --- a/source/timewarp-amuru/nu-get/nuget-source-cache.cs +++ /dev/null @@ -1,38 +0,0 @@ -#region Purpose -// Cache for NuGet SourceRepository instances per source URL -#endregion - -#region Design -// SourceRepository creation involves network calls and resource initialization. -// Caching avoids repeated initialization when querying the same feed multiple times. -// Uses ConcurrentDictionary for thread-safe access without explicit locking. -#endregion - -namespace TimeWarp.Amuru; - -public sealed class NuGetSourceCache -{ - private readonly ConcurrentDictionary Repositories = new(StringComparer.OrdinalIgnoreCase); - - #pragma warning disable CA1054 - public SourceRepository GetOrCreate(string sourceUrl) -#pragma warning restore CA1054 - { - ArgumentException.ThrowIfNullOrWhiteSpace(sourceUrl); - - return Repositories.GetOrAdd - ( - sourceUrl, - static url => - { - PackageSource packageSource = new(url); - return Repository.CreateSource(Repository.Provider.GetCoreV3(), packageSource); - } - ); - } - - public void Clear() - { - Repositories.Clear(); - } -} \ No newline at end of file diff --git a/source/timewarp-amuru/timewarp-amuru.csproj b/source/timewarp-amuru/timewarp-amuru.csproj index 53ae516..89b15f9 100644 --- a/source/timewarp-amuru/timewarp-amuru.csproj +++ b/source/timewarp-amuru/timewarp-amuru.csproj @@ -10,9 +10,6 @@ - - -