From 0cd9e572c87ebb6ef64d721c510951f8fe4a88eb Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Wed, 23 Sep 2026 09:00:12 -0400 Subject: [PATCH 1/2] Serve NuGet sources that are local folders or network shares Fixes #5426 --- AGENTS.md | 10 + src/UniGetUI.Core.Tools/Tools.cs | 6 + .../Helpers/ChocolateySourceHelper.cs | 24 +- .../BaseNuGet.cs | 146 ++++++ .../BaseNuGetDetailsHelper.cs | 109 ++++ .../Internal/NuGetLocalFeed.cs | 351 +++++++++++++ .../DownloadOperation.cs | 34 +- .../ChocolateyManagerTests.cs | 88 ++++ .../DownloadOperationProgressTests.cs | 59 +++ .../Chocolatey/source-list-output.txt | 3 + .../Builders/LocalNuGetFeedBuilder.cs | 80 +++ .../NuGetLocalFeedTests.cs | 470 ++++++++++++++++++ 12 files changed, 1363 insertions(+), 17 deletions(-) create mode 100644 src/UniGetUI.PackageEngine.Managers.Generic.NuGet/Internal/NuGetLocalFeed.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/Infrastructure/Builders/LocalNuGetFeedBuilder.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/NuGetLocalFeedTests.cs diff --git a/AGENTS.md b/AGENTS.md index aebc6e914d..cdefe20fd4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,12 +43,22 @@ The constructor sets `Capabilities`, `Properties`, and wires the helpers. See `s These four managers share `BaseNuGet` / `BaseNuGetDetailsHelper`, which talk to a NuGet feed over HTTP. Each source picks its protocol independently, in `NuGetV3ServiceIndex.GetServiceIndexUrl`: +- A source URL with the `file` scheme - what `new Uri()` produces for a local folder or a UNC + share such as `C:\packages` or `\\server\share` - is a **local folder feed**, served by + `NuGetLocalFeed`. There is no HTTP endpoint to call, so search, details, icons, versions and + updates read the `.nupkg` files and their embedded `.nuspec` straight from disk. This check + runs before the V3 one, everywhere. - A source URL whose path ends in `index.json`, or whose last path segment is `v3`, is a **NuGet V3** feed. Its service index is fetched once per session and cached. - Every other source URL is treated as a **V2/OData** feed and keeps the legacy code path. Detection is purely by URL shape, so it costs no probe request and no V2 feed changes behaviour. +A local folder feed is scanned at most three directories deep, which covers both the flat layout +and the `//..nupkg` layout, and each parsed manifest is cached against +its file's size and write time. Installers on such a feed are copied from disk instead of being +downloaded (`DownloadOperation`). + ### V3 resources used | Resource `@type` | Used for | diff --git a/src/UniGetUI.Core.Tools/Tools.cs b/src/UniGetUI.Core.Tools/Tools.cs index 074ae393f8..b59e47109f 100644 --- a/src/UniGetUI.Core.Tools/Tools.cs +++ b/src/UniGetUI.Core.Tools/Tools.cs @@ -376,6 +376,9 @@ public static long GetFileSizeAsLong(Uri? url) try { + if (url.IsFile) + return new FileInfo(url.LocalPath).Length; + using HttpClient client = new(CoreTools.GenericHttpClientParameters); using var request = new HttpRequestMessage(HttpMethod.Head, url); using HttpResponseMessage response = client.Send(request); @@ -394,6 +397,9 @@ public static string GetFileName(Uri url) { try { + if (url.IsFile) + return Path.GetFileName(url.LocalPath); + var handler = CoreTools.GenericHttpClientParameters; handler.AllowAutoRedirect = false; using HttpClient client = new(handler); diff --git a/src/UniGetUI.PackageEngine.Managers.Chocolatey/Helpers/ChocolateySourceHelper.cs b/src/UniGetUI.PackageEngine.Managers.Chocolatey/Helpers/ChocolateySourceHelper.cs index d12ea975e1..727af25699 100644 --- a/src/UniGetUI.PackageEngine.Managers.Chocolatey/Helpers/ChocolateySourceHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Chocolatey/Helpers/ChocolateySourceHelper.cs @@ -9,6 +9,8 @@ namespace UniGetUI.PackageEngine.Managers.ChocolateyManager { internal sealed class ChocolateySourceHelper : BaseSourceHelper { + private const string AuthenticatedMarker = "(Authenticated)"; + public ChocolateySourceHelper(Chocolatey manager) : base(manager) { } @@ -106,10 +108,11 @@ internal IReadOnlyList ParseSources(IEnumerable lines) if (line.Contains(" - ") && line.Contains("| ")) { - string[] parts = line.Trim().Split('|')[0].Trim().Split(" - "); + string[] parts = line.Trim().Split('|')[0].Trim().Split(" - ", 2); + string url = ExtractSourceUrl(parts[1]); if ( - parts[1].Trim() == "https://community.chocolatey.org/api/v2/" - || parts[1].Trim() == "https://chocolatey.org/api/v2/" + url == "https://community.chocolatey.org/api/v2/" + || url == "https://chocolatey.org/api/v2/" ) { sources.Add( @@ -123,11 +126,7 @@ internal IReadOnlyList ParseSources(IEnumerable lines) else { sources.Add( - new ManagerSource( - Manager, - parts[0].Trim(), - new Uri(parts[1].Split(" ")[0].Trim()) - ) + new ManagerSource(Manager, parts[0].Trim(), new Uri(url)) ); } } @@ -140,5 +139,14 @@ internal IReadOnlyList ParseSources(IEnumerable lines) return sources; } + + private static string ExtractSourceUrl(string value) + { + string url = value.Trim(); + if (url.EndsWith(AuthenticatedMarker, StringComparison.Ordinal)) + url = url[..^AuthenticatedMarker.Length].TrimEnd(); + + return url; + } } } diff --git a/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/BaseNuGet.cs b/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/BaseNuGet.cs index 481ea4e381..4119167b95 100644 --- a/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/BaseNuGet.cs +++ b/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/BaseNuGet.cs @@ -116,6 +116,14 @@ protected sealed override IReadOnlyList FindPackages_UnSafe(string quer { try { + if (NuGetLocalFeed.TryGetDirectory(source, out string localDirectory)) + { + Packages.AddRange( + FindPackagesLocal(source, localDirectory, query, canPrerelease, logger) + ); + continue; + } + if (NuGetV3ServiceIndex.IsV3Source(source)) { Packages.AddRange(FindPackagesV3(source, query, canPrerelease, logger)); @@ -244,6 +252,130 @@ protected sealed override IReadOnlyList FindPackages_UnSafe(string quer return Packages; } + internal IReadOnlyList FindPackagesLocal( + IManagerSource source, + string directory, + string query, + bool canPrerelease, + INativeTaskLogger logger + ) + { + logger.Log( + $"Begin local folder package search for query={query} on source {source.Name} " + + $"at Directory={directory} of manager {Name}" + ); + + Dictionary latest = new(StringComparer.OrdinalIgnoreCase); + foreach (LocalNuGetPackage candidate in NuGetLocalFeed.Enumerate(directory)) + { + if (candidate.IsPreRelease && !canPrerelease) + continue; + + if (!NuGetLocalFeed.MatchesQuery(candidate, query, UseSubstringSearch)) + continue; + + if ( + latest.TryGetValue(candidate.Id, out LocalNuGetPackage? current) + && !IsNewerVersion(candidate.Version, current.Version) + ) + continue; + + latest[candidate.Id] = candidate; + } + + List packages = []; + foreach (LocalNuGetPackage found in latest.Values) + { + logger.Log( + $"Found package {found.Id} version {found.Version} on source {source.Name}" + ); + + packages.Add( + new Package( + CoreTools.FormatAsName(found.Id), + found.Id, + found.Version, + source, + this + ) + ); + } + + return packages; + } + + internal IReadOnlyList GetAvailableUpdatesLocal( + IManagerSource source, + string directory, + IReadOnlyList installedPackages, + bool canPrerelease, + INativeTaskLogger logger + ) + { + Dictionary> availableById = new( + StringComparer.OrdinalIgnoreCase + ); + + foreach (LocalNuGetPackage candidate in NuGetLocalFeed.Enumerate(directory)) + { + if (candidate.IsPreRelease && !canPrerelease) + continue; + + if (!availableById.TryGetValue(candidate.Id, out List? entries)) + availableById[candidate.Id] = entries = []; + + entries.Add(candidate); + } + + var installed = new Dictionary(); + foreach (IPackage package in installedPackages) + installed[package.Id.ToLower()] = (package.Id, package.VersionString); + + var scopeMap = BuildInstalledScopeMap(installedPackages); + List packages = []; + + foreach ((string id, string installedVersion) in installed.Values) + { + if (!availableById.TryGetValue(id, out List? entries)) + continue; + + string? newest = null; + foreach (LocalNuGetPackage candidate in entries) + { + if (!IsNewerVersion(candidate.Version, installedVersion)) + continue; + + if (newest is null || IsNewerVersion(candidate.Version, newest)) + newest = candidate.Version; + } + + if (newest is null) + continue; + + logger.Log($"Found package {id} version {newest} on source {source.Name}"); + + packages.Add( + new Package( + CoreTools.FormatAsName(id), + id, + installedVersion, + newest, + source, + this, + new OverridenInstallationOptions(scopeMap.GetValueOrDefault(id.ToLower())) + ) + ); + } + + return packages; + } + + private bool IsNewerVersion(string candidate, string current) => + CompareVersions(candidate, current) is { } comparison + ? comparison > 0 + : CoreTools.VersionStringToStruct(candidate) + > CoreTools.VersionStringToStruct(current); + internal IReadOnlyList FindPackagesV3( IManagerSource source, string query, @@ -324,6 +456,20 @@ protected override IReadOnlyList GetAvailableUpdates_UnSafe() { try { + if (NuGetLocalFeed.TryGetDirectory(pair.Key, out string localDirectory)) + { + Packages.AddRange( + GetAvailableUpdatesLocal( + pair.Key, + localDirectory, + pair.Value, + canPrerelease, + logger + ) + ); + continue; + } + if (NuGetV3ServiceIndex.IsV3Source(pair.Key)) { var v3Updates = GetAvailableUpdatesV3( diff --git a/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/BaseNuGetDetailsHelper.cs b/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/BaseNuGetDetailsHelper.cs index 7c96e2a491..9d79333589 100644 --- a/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/BaseNuGetDetailsHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/BaseNuGetDetailsHelper.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.RegularExpressions; using UniGetUI.Core.Data; using UniGetUI.Core.IconEngine; @@ -21,6 +22,12 @@ protected override void GetDetails_UnSafe(IPackageDetails details) var logger = Manager.TaskLogger.CreateNew(LoggableTaskType.LoadPackageDetails); try { + if (NuGetLocalFeed.TryGetDirectory(details.Package.Source, out string directory)) + { + logger.Close(GetDetailsLocal(details, directory, logger) ? 0 : 1); + return; + } + if (NuGetV3ServiceIndex.IsV3Source(details.Package.Source)) { logger.Close(GetDetailsV3(details, logger) ? 0 : 1); @@ -230,6 +237,72 @@ Match match in Regex.Matches( } } + private static bool GetDetailsLocal( + IPackageDetails details, + string directory, + INativeTaskLogger logger + ) + { + IPackage package = details.Package; + LocalNuGetPackage? local = NuGetLocalFeed.Find( + directory, + package.Id, + package.VersionString + ); + + if (local is null) + { + logger.Error( + $"No package file for {package.Id} version {package.VersionString} was found " + + $"on source {package.Source.Name} at Directory={directory}" + ); + return false; + } + + Uri packageFile = new(local.FilePath); + details.ManifestUrl = packageFile; + details.InstallerUrl = packageFile; + details.InstallerSize = local.Size; + details.InstallerType = CoreTools.Translate("NuPkg (zipped manifest)"); + details.Description = FirstNonEmpty(local.Description, local.Summary); + details.ReleaseNotes = local.ReleaseNotes; + details.License = local.License; + details.UpdateDate = local.LastWriteTimeUtc.ToString("u", CultureInfo.InvariantCulture); + details.Tags = + local.Tags?.Split( + [' ', ',', ';', '\t', '\n', '\r'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries + ) ?? []; + + string? authors = FirstNonEmpty(local.Authors, local.Owners); + if (authors is not null) + { + details.Author = authors; + details.Publisher = authors; + } + + if (Uri.TryCreate(local.ProjectUrl, UriKind.Absolute, out Uri? projectUrl)) + details.HomepageUrl = projectUrl; + + if (Uri.TryCreate(local.LicenseUrl, UriKind.Absolute, out Uri? licenseUrl)) + details.LicenseUrl = licenseUrl; + + details.Dependencies.Clear(); + foreach (LocalNuGetDependency dependency in local.Dependencies) + { + details.Dependencies.Add( + new() + { + Name = dependency.Id, + Version = FormatDependencyRange(dependency.Range), + Mandatory = true, + } + ); + } + + return true; + } + private static bool GetDetailsV3(IPackageDetails details, INativeTaskLogger logger) { IPackage package = details.Package; @@ -369,6 +442,9 @@ internal static string FormatDependencyRange(string? range) protected override CacheableIcon? GetIcon_UnSafe(IPackage package) { + if (NuGetLocalFeed.TryGetDirectory(package.Source, out string directory)) + return GetIconLocal(package, directory); + if (NuGetV3ServiceIndex.IsV3Source(package.Source)) return GetIconV3(package); @@ -399,6 +475,22 @@ internal static string FormatDependencyRange(string? range) ); } + private static CacheableIcon? GetIconLocal(IPackage package, string directory) + { + LocalNuGetPackage? local = NuGetLocalFeed.Find( + directory, + package.Id, + package.VersionString + ); + + if (local is null) + return null; + + return Uri.TryCreate(local.IconUrl, UriKind.Absolute, out Uri? iconUrl) + ? new CacheableIcon(iconUrl, package.VersionString) + : null; + } + private static CacheableIcon? GetIconV3(IPackage package) { long hash = package.GetVersionedHash(); @@ -447,6 +539,23 @@ protected override IReadOnlyList GetScreenshots_UnSafe(IPackage package) protected override IReadOnlyList GetInstallableVersions_UnSafe(IPackage package) { + if (NuGetLocalFeed.TryGetDirectory(package.Source, out string directory)) + { + try + { + return NuGetLocalFeed.GetVersionsDescending(directory, package.Id); + } + catch (Exception e) + { + Logger.Warn( + $"Could not list the versions of package {package.Id} on the local " + + $"folder feed at Directory={directory}" + ); + Logger.Warn(e); + return []; + } + } + if (NuGetV3ServiceIndex.IsV3Source(package.Source)) { NuGetV3ServiceIndex? index = NuGetV3ServiceIndex.Resolve(package.Source); diff --git a/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/Internal/NuGetLocalFeed.cs b/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/Internal/NuGetLocalFeed.cs new file mode 100644 index 0000000000..1abcab0be6 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/Internal/NuGetLocalFeed.cs @@ -0,0 +1,351 @@ +using System.Collections.Concurrent; +using System.IO.Compression; +using System.Xml.Linq; +using UniGetUI.Core.Logging; +using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine.Interfaces; + +namespace UniGetUI.PackageEngine.Managers.Generic.NuGet.Internal +{ + internal readonly record struct LocalNuGetDependency(string Id, string Range); + + internal sealed class LocalNuGetPackage + { + public required string Id { get; init; } + public required string Version { get; init; } + public required string FilePath { get; init; } + public required long Size { get; init; } + public required DateTime LastWriteTimeUtc { get; init; } + public required bool IsPreRelease { get; init; } + public string? Title { get; init; } + public string? Description { get; init; } + public string? Summary { get; init; } + public string? Authors { get; init; } + public string? Owners { get; init; } + public string? ProjectUrl { get; init; } + public string? LicenseUrl { get; init; } + public string? License { get; init; } + public string? IconUrl { get; init; } + public string? ReleaseNotes { get; init; } + public string? Tags { get; init; } + public IReadOnlyList Dependencies { get; init; } = []; + } + + internal static class NuGetLocalFeed + { + private const int MaxRecursionDepth = 3; + + private readonly record struct CacheEntry( + long Size, + DateTime LastWriteTimeUtc, + LocalNuGetPackage Package + ); + + private static readonly ConcurrentDictionary ParsedPackages = + new(StringComparer.OrdinalIgnoreCase); + + public static bool IsLocalSource(IManagerSource? source) => TryGetDirectory(source, out _); + + public static bool TryGetDirectory(IManagerSource? source, out string directory) + { + directory = string.Empty; + + if (source?.Url is not { IsAbsoluteUri: true, IsFile: true } url) + return false; + + directory = url.LocalPath; + return directory.Length > 0; + } + + internal static void ClearCache() => ParsedPackages.Clear(); + + public static IReadOnlyList Enumerate(string directory) + { + EnumerationOptions options = new() + { + RecurseSubdirectories = true, + MaxRecursionDepth = MaxRecursionDepth, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.Hidden | FileAttributes.System, + MatchCasing = MatchCasing.CaseInsensitive, + }; + + List packages = []; + foreach (string file in Directory.EnumerateFiles(directory, "*.nupkg", options)) + { + if (!Path.GetExtension(file).Equals(".nupkg", StringComparison.OrdinalIgnoreCase)) + continue; + + if (Load(file) is { } package) + packages.Add(package); + } + + return packages; + } + + public static LocalNuGetPackage? Find(string directory, string packageId, string version) + { + LocalNuGetPackage? equivalent = null; + SemanticVersion.TryParse( + version, + SemVerLabels.CaseInsensitive, + out SemanticVersion wanted + ); + + foreach (LocalNuGetPackage package in Enumerate(directory)) + { + if (!package.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)) + continue; + + if (package.Version.Equals(version, StringComparison.OrdinalIgnoreCase)) + return package; + + if ( + equivalent is null + && wanted.IsValid + && SemanticVersion.TryParse( + package.Version, + SemVerLabels.CaseInsensitive, + out SemanticVersion parsed + ) + && parsed == wanted + ) + equivalent = package; + } + + return equivalent; + } + + public static IReadOnlyList GetVersionsDescending( + string directory, + string packageId + ) + { + List<(SemanticVersion Parsed, string Raw)> versions = []; + HashSet alreadyAdded = new(StringComparer.OrdinalIgnoreCase); + + foreach (LocalNuGetPackage package in Enumerate(directory)) + { + if (!package.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)) + continue; + + if (!alreadyAdded.Add(package.Version)) + continue; + + versions.Add( + SemanticVersion.TryParse( + package.Version, + SemVerLabels.CaseInsensitive, + out SemanticVersion parsed + ) + ? (parsed, package.Version) + : (SemanticVersion.Invalid(package.Version), package.Version) + ); + } + + versions.Sort((left, right) => right.Parsed.CompareTo(left.Parsed)); + return versions.Select(entry => entry.Raw).ToArray(); + } + + public static bool MatchesQuery(LocalNuGetPackage package, string query, bool idOnly) + { + if (string.IsNullOrWhiteSpace(query)) + return true; + + string term = query.Trim(); + if (Contains(package.Id, term)) + return true; + + if (idOnly) + return false; + + return Contains(package.Title, term) + || Contains(package.Tags, term) + || Contains(package.Summary, term) + || Contains(package.Description, term) + || Contains(package.Authors, term); + } + + private static bool Contains(string? value, string term) => + value is not null && value.Contains(term, StringComparison.OrdinalIgnoreCase); + + private static LocalNuGetPackage? Load(string file) + { + long size; + DateTime lastWriteTimeUtc; + + try + { + FileInfo info = new(file); + size = info.Length; + lastWriteTimeUtc = info.LastWriteTimeUtc; + } + catch (Exception e) + { + Logger.Warn($"Could not read the NuGet package file at {file}"); + Logger.Warn(e); + return null; + } + + if ( + ParsedPackages.TryGetValue(file, out CacheEntry cached) + && cached.Size == size + && cached.LastWriteTimeUtc == lastWriteTimeUtc + ) + return cached.Package; + + try + { + using FileStream stream = File.OpenRead(file); + using ZipArchive archive = new(stream, ZipArchiveMode.Read); + + ZipArchiveEntry? nuspec = null; + foreach (ZipArchiveEntry entry in archive.Entries) + { + if (entry.FullName.Contains('/') || entry.FullName.Contains('\\')) + continue; + + if (!entry.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)) + continue; + + nuspec = entry; + break; + } + + if (nuspec is null) + { + Logger.Warn($"The NuGet package at {file} carries no .nuspec manifest"); + return null; + } + + using Stream manifest = nuspec.Open(); + LocalNuGetPackage? package = ParseNuspec(manifest, file, size, lastWriteTimeUtc); + + if (package is null) + { + Logger.Warn( + $"The .nuspec manifest of the NuGet package at {file} declares no id or version" + ); + return null; + } + + ParsedPackages[file] = new CacheEntry(size, lastWriteTimeUtc, package); + return package; + } + catch (Exception e) + { + Logger.Warn($"Could not read the NuGet package at {file}"); + Logger.Warn(e); + return null; + } + } + + internal static LocalNuGetPackage? ParseNuspec( + Stream manifest, + string file, + long size, + DateTime lastWriteTimeUtc + ) + { + XElement? metadata = XDocument + .Load(manifest) + .Root?.Elements() + .FirstOrDefault(element => element.Name.LocalName is "metadata"); + + if (metadata is null) + return null; + + string? id = Value(metadata, "id"); + string? version = Value(metadata, "version"); + + if (id is null || version is null) + return null; + + return new LocalNuGetPackage + { + Id = id, + Version = version, + FilePath = file, + Size = size, + LastWriteTimeUtc = lastWriteTimeUtc, + IsPreRelease = + SemanticVersion.TryParse( + version, + SemVerLabels.CaseInsensitive, + out SemanticVersion parsed + ) && parsed.IsPreRelease, + Title = Value(metadata, "title"), + Description = Value(metadata, "description"), + Summary = Value(metadata, "summary"), + Authors = Value(metadata, "authors"), + Owners = Value(metadata, "owners"), + ProjectUrl = Value(metadata, "projectUrl"), + LicenseUrl = Value(metadata, "licenseUrl"), + License = ReadLicenseExpression(metadata), + IconUrl = Value(metadata, "iconUrl"), + ReleaseNotes = Value(metadata, "releaseNotes"), + Tags = Value(metadata, "tags"), + Dependencies = ReadDependencies(metadata), + }; + } + + private static string? ReadLicenseExpression(XElement metadata) + { + XElement? license = metadata + .Elements() + .FirstOrDefault(element => element.Name.LocalName is "license"); + + if (license is null) + return null; + + string? type = license.Attribute("type")?.Value; + if (type is not null && !type.Equals("expression", StringComparison.OrdinalIgnoreCase)) + return null; + + string value = license.Value.Trim(); + return value.Length is 0 ? null : value; + } + + private static IReadOnlyList ReadDependencies(XElement metadata) + { + XElement? dependencies = metadata + .Elements() + .FirstOrDefault(element => element.Name.LocalName is "dependencies"); + + if (dependencies is null) + return []; + + List parsed = []; + HashSet alreadyAdded = new(StringComparer.OrdinalIgnoreCase); + + foreach (XElement dependency in dependencies.Descendants()) + { + if (dependency.Name.LocalName is not "dependency") + continue; + + string? dependencyId = dependency.Attribute("id")?.Value; + if (string.IsNullOrWhiteSpace(dependencyId) || !alreadyAdded.Add(dependencyId)) + continue; + + parsed.Add( + new LocalNuGetDependency( + dependencyId, + dependency.Attribute("version")?.Value ?? string.Empty + ) + ); + } + + return parsed; + } + + private static string? Value(XElement metadata, string name) + { + string? value = metadata + .Elements() + .FirstOrDefault(element => element.Name.LocalName == name) + ?.Value.Trim(); + + return string.IsNullOrEmpty(value) ? null : value; + } + } +} diff --git a/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs b/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs index 48d66b959e..17eb934d3b 100644 --- a/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs +++ b/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs @@ -94,17 +94,33 @@ protected override async Task PerformOperation() } Line($"Download URL found at {downloadUrl} ", LineType.Information); - using var httpClient = CreateHttpClient(); - using var response = await httpClient.GetAsync( - downloadUrl, - HttpCompletionOption.ResponseHeadersRead, - CancellationToken - ); - response.EnsureSuccessStatusCode(); + using HttpClient? httpClient = downloadUrl.IsFile ? null : CreateHttpClient(); + using HttpResponseMessage? response = + httpClient is null + ? null + : await httpClient.GetAsync( + downloadUrl, + HttpCompletionOption.ResponseHeadersRead, + CancellationToken + ); + response?.EnsureSuccessStatusCode(); + + long totalBytes; + Stream sourceStream; + if (response is null) + { + FileInfo sourceFile = new(downloadUrl.LocalPath); + totalBytes = sourceFile.Length; + sourceStream = sourceFile.OpenRead(); + } + else + { + totalBytes = response.Content.Headers.ContentLength ?? -1L; + sourceStream = await response.Content.ReadAsStreamAsync(CancellationToken); + } - var totalBytes = response.Content.Headers.ContentLength ?? -1L; var canReportProgress = totalBytes > 0; - await using (var contentStream = await response.Content.ReadAsStreamAsync(CancellationToken)) + await using (var contentStream = sourceStream) await using (var fileStream = new FileStream( downloadLocation, FileMode.Create, diff --git a/src/UniGetUI.PackageEngine.Tests/ChocolateyManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/ChocolateyManagerTests.cs index 5163429101..e3439fc6a5 100644 --- a/src/UniGetUI.PackageEngine.Tests/ChocolateyManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/ChocolateyManagerTests.cs @@ -4,6 +4,9 @@ using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Managers.Choco; using UniGetUI.PackageEngine.Managers.ChocolateyManager; +using UniGetUI.PackageEngine.Classes.Manager.Providers; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.PackageClasses; using UniGetUI.PackageEngine.Serializable; using UniGetUI.PackageEngine.Structs; using UniGetUI.PackageEngine.Tests.Infrastructure.Assertions; @@ -135,6 +138,26 @@ public void ParseSourcesNormalizesCommunityFeedsAndPreservesCustomFeeds() { Assert.Equal("internal repo", source.Name); Assert.Equal(new Uri("https://packages.example.test/api/v2/"), source.Url); + }, + source => + { + Assert.Equal("local folder", source.Name); + Assert.True(source.Url.IsFile); + Assert.Equal(@"C:\Shared Packages", source.Url.LocalPath); + }, + source => + { + Assert.Equal("network share", source.Name); + Assert.True(source.Url.IsUnc); + Assert.Equal(@"\\files\nuget\server", source.Url.LocalPath); + }, + source => + { + Assert.Equal("private feed", source.Name); + Assert.Equal( + new Uri("https://packages.example.test/private/api/v2/"), + source.Url + ); } ); } @@ -541,6 +564,71 @@ public void RemoveStaleLegacyInstallVariableLeavesAReloadedProcessValueAlone() } } + [Fact] + public void SearchesALocalFolderSourceReportedByChocoSourceList() + { + using var feed = new LocalNuGetFeedBuilder(); + feed.WritePackage("Contoso.Internal", "1.0.0"); + feed.WritePackage("Contoso.Internal", "2.0.0"); + + var helper = Assert.IsType(new Chocolatey().SourcesHelper); + var source = Assert.Single( + helper.ParseSources( + [ + $"internal feed - {feed.Directory} | Priority 0|Bypass Proxy - false|" + + "Self-Service - false|Admin Only - false.", + ] + ) + ); + + Assert.True(source.Url.IsFile); + Assert.Equal(feed.Directory, source.Url.LocalPath); + + var manager = new LocalSourceChocolatey([source]); + var found = manager.FindLocalPackages("contoso"); + + Assert.Equal("Contoso.Internal", Assert.Single(found).Id); + Assert.Equal("2.0.0", found[0].VersionString); + Assert.Same(source, found[0].Source); + } + + private sealed class LocalSourceChocolatey : Chocolatey + { + public LocalSourceChocolatey(IReadOnlyList sources) + { + SourcesHelper = new StubSourceHelper(this, sources); + } + + public IReadOnlyList FindLocalPackages(string query) => + FindPackages_UnSafe(query); + } + + private sealed class StubSourceHelper( + IPackageManager manager, + IReadOnlyList sources + ) : BaseSourceHelper(manager) + { + public override IReadOnlyList GetSources() => sources; + + public override string[] GetAddSourceParameters(IManagerSource source) => []; + + public override string[] GetRemoveSourceParameters(IManagerSource source) => []; + + protected override OperationVeredict _getAddSourceOperationVeredict( + IManagerSource source, + int ReturnCode, + string[] Output + ) => OperationVeredict.Success; + + protected override OperationVeredict _getRemoveSourceOperationVeredict( + IManagerSource source, + int ReturnCode, + string[] Output + ) => OperationVeredict.Success; + + protected override IReadOnlyList GetSources_UnSafe() => sources; + } + private static string[] ReadFixtureLines(string relativePath) { return PackageEngineFixtureFiles.ReadAllText(relativePath).Replace("\r\n", "\n").Split('\n'); diff --git a/src/UniGetUI.PackageEngine.Tests/DownloadOperationProgressTests.cs b/src/UniGetUI.PackageEngine.Tests/DownloadOperationProgressTests.cs index ee06d8201c..f33126c55f 100644 --- a/src/UniGetUI.PackageEngine.Tests/DownloadOperationProgressTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/DownloadOperationProgressTests.cs @@ -223,4 +223,63 @@ await operation.InvokePerformOperationForTests() File.Delete(downloadPath); } } + + [Fact] + public async Task LocalFeedInstallers_AreCopiedFromDiskWithoutHttp() + { + byte[] payload = new byte[64 * 1024]; + new Random(23).NextBytes(payload); + + string sourcePath = Path.Join( + Path.GetTempPath(), + $"unigetui-local-source-{Guid.NewGuid():N}.nupkg" + ); + string downloadPath = Path.Join( + Path.GetTempPath(), + $"unigetui-local-copy-{Guid.NewGuid():N}.nupkg" + ); + File.WriteAllBytes(sourcePath, payload); + + var manager = new PackageManagerBuilder() + .ConfigureDetails(helper => + { + helper.PopulateDetails = details => + { + details.InstallerUrl = new Uri(sourcePath); + details.InstallerType = "nupkg"; + }; + }) + .Build(); + IPackage package = new PackageBuilder().WithManager(manager).Build(); + + try + { + using var operation = new ProbeDownloadOperation( + package, + downloadPath, + new UnreachableHandler() + ); + + Assert.Equal( + OperationVeredict.Success, + await operation.InvokePerformOperationForTests() + ); + Assert.Equal(payload, File.ReadAllBytes(downloadPath)); + Assert.Equal(100, Math.Round(operation.CurrentProgress.Percentage!.Value)); + } + finally + { + File.Delete(sourcePath); + if (File.Exists(downloadPath)) + File.Delete(downloadPath); + } + } + + private sealed class UnreachableHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) => throw new InvalidOperationException("No HTTP request was expected"); + } } diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Chocolatey/source-list-output.txt b/src/UniGetUI.PackageEngine.Tests/Fixtures/Chocolatey/source-list-output.txt index b28ad329d8..fd968b335f 100644 --- a/src/UniGetUI.PackageEngine.Tests/Fixtures/Chocolatey/source-list-output.txt +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Chocolatey/source-list-output.txt @@ -2,4 +2,7 @@ Chocolatey v2.4.3 community - https://community.chocolatey.org/api/v2/ | Priority 0|Bypass Proxy - false|Self-Service - false|Admin Only - false legacy community - https://chocolatey.org/api/v2/ | Priority 0|Bypass Proxy - false|Self-Service - false|Admin Only - false internal repo - https://packages.example.test/api/v2/ | Priority 10|Bypass Proxy - false|Self-Service - false|Admin Only - false +local folder - C:\Shared Packages | Priority 0|Bypass Proxy - false|Self-Service - false|Admin Only - false. +network share - \\files\nuget\server | Priority 0|Bypass Proxy - false|Self-Service - false|Admin Only - false. +private feed - https://packages.example.test/private/api/v2/ (Authenticated)| Priority 0|Bypass Proxy - false|Self-Service - false|Admin Only - false. invalid source line diff --git a/src/UniGetUI.PackageEngine.Tests/Infrastructure/Builders/LocalNuGetFeedBuilder.cs b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Builders/LocalNuGetFeedBuilder.cs new file mode 100644 index 0000000000..39083bb38f --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Builders/LocalNuGetFeedBuilder.cs @@ -0,0 +1,80 @@ +using System.IO.Compression; +using System.Text; +using UniGetUI.PackageEngine.Managers.Generic.NuGet.Internal; + +namespace UniGetUI.PackageEngine.Tests.Infrastructure.Builders; + +public sealed class LocalNuGetFeedBuilder : IDisposable +{ + public LocalNuGetFeedBuilder(string? folderName = null) + { + Directory = Path.Join(Path.GetTempPath(), folderName ?? Path.GetRandomFileName()); + System.IO.Directory.CreateDirectory(Directory); + NuGetLocalFeed.ClearCache(); + } + + public string Directory { get; } + + public string WritePackage( + string id, + string version, + string? folder = null, + string description = "A package", + string authors = "Example Ltd", + string tags = "tooling", + string? iconUrl = null, + string? dependencyId = null + ) + { + string target = folder is null ? Directory : Path.Join(Directory, folder); + System.IO.Directory.CreateDirectory(target); + + string file = Path.Join(target, $"{id}.{version}.nupkg"); + string nuspec = $""" + + + + {id} + {version} + {id} + {authors} + {description} + {tags} + https://example.test/package + MIT + {(iconUrl is null ? string.Empty : $"{iconUrl}")} + {( + dependencyId is null + ? string.Empty + : $""" + + + + + + """ + )} + + + """; + + using (FileStream stream = File.Create(file)) + using (ZipArchive archive = new(stream, ZipArchiveMode.Create)) + { + using Stream entry = archive.CreateEntry($"{id}.nuspec").Open(); + entry.Write(Encoding.UTF8.GetBytes(nuspec)); + } + + NuGetLocalFeed.ClearCache(); + return file; + } + + public void Dispose() + { + try + { + System.IO.Directory.Delete(Directory, true); + } + catch (IOException) { } + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/NuGetLocalFeedTests.cs b/src/UniGetUI.PackageEngine.Tests/NuGetLocalFeedTests.cs new file mode 100644 index 0000000000..bca8f00e6d --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/NuGetLocalFeedTests.cs @@ -0,0 +1,470 @@ +using UniGetUI.Core.IconEngine; +using UniGetUI.PackageEngine.Classes.Manager; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.ManagerClasses.Classes; +using UniGetUI.PackageEngine.ManagerClasses.Manager; +using UniGetUI.PackageEngine.Managers.Generic.NuGet.Internal; +using UniGetUI.PackageEngine.Managers.PowerShellManager; +using UniGetUI.PackageEngine.PackageClasses; +using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; + +namespace UniGetUI.PackageEngine.Tests; + +public sealed class NuGetLocalFeedTests +{ + [Theory] + [InlineData("https://community.chocolatey.org/api/v2/", false)] + [InlineData("https://packages.example.test/api/v3/index.json", false)] + [InlineData("file://Data/nuget/server/", true)] + [InlineData("file:///C:/packages", true)] + public void TryGetDirectoryOnlyAcceptsFileSystemSources(string url, bool expected) + { + var manager = new PackageManagerBuilder().Build(); + var source = new ManagerSource(manager, "test", new Uri(url)); + + Assert.Equal(expected, NuGetLocalFeed.TryGetDirectory(source, out _)); + Assert.Equal(expected, NuGetLocalFeed.IsLocalSource(source)); + } + + [Fact] + public void TryGetDirectoryReturnsTheFolderBehindTheSourceUrl() + { + using var feed = new LocalFeed(); + var manager = feed.CreateManager(); + + Assert.True( + NuGetLocalFeed.TryGetDirectory(manager.Properties.DefaultSource, out string directory) + ); + Assert.Equal(feed.Directory, directory.TrimEnd(Path.DirectorySeparatorChar)); + } + + [Fact] + public void FindPackagesKeepsTheLatestStableVersionOfEachPackage() + { + using var feed = new LocalFeed(); + feed.WritePackage("Contoso.Tool", "1.0.0"); + feed.WritePackage("Contoso.Tool", "2.0.0"); + feed.WritePackage("Contoso.Tool", "2.1.0-beta.1"); + feed.WritePackage("Fabrikam.Tool", "1.0.0"); + + var manager = feed.CreateManager(); + var packages = feed.Find(manager, "contoso", canPrerelease: false); + + Assert.Single(packages); + Assert.Equal("Contoso.Tool", packages[0].Id); + Assert.Equal("2.0.0", packages[0].VersionString); + Assert.Same(manager.Properties.DefaultSource, packages[0].Source); + Assert.Same(manager, packages[0].Manager); + } + + [Fact] + public void FindPackagesOffersPreReleasesOnlyWhenTheyAreAllowed() + { + using var feed = new LocalFeed(); + feed.WritePackage("Contoso.Tool", "2.0.0"); + feed.WritePackage("Contoso.Tool", "2.1.0-beta.1"); + + var manager = feed.CreateManager(); + + Assert.Equal("2.0.0", feed.Find(manager, "contoso", false)[0].VersionString); + Assert.Equal("2.1.0-beta.1", feed.Find(manager, "contoso", true)[0].VersionString); + } + + [Fact] + public void FindPackagesReadsTheVersionFolderLayout() + { + using var feed = new LocalFeed(); + feed.WritePackage("Contoso.Tool", "1.5.0", folder: Path.Join("contoso.tool", "1.5.0")); + + var manager = feed.CreateManager(); + var packages = feed.Find(manager, "contoso", canPrerelease: false); + + Assert.Single(packages); + Assert.Equal("1.5.0", packages[0].VersionString); + } + + [Fact] + public void FindPackagesMatchesMetadataUnlessTheManagerSearchesIdsOnly() + { + using var feed = new LocalFeed(); + feed.WritePackage("Contoso.Tool", "1.0.0", description: "A deployment helper"); + + Assert.Single(feed.Find(feed.CreateManager(), "deployment", false)); + Assert.Empty(feed.Find(feed.CreateManager(idOnlySearch: true), "deployment", false)); + Assert.Single(feed.Find(feed.CreateManager(idOnlySearch: true), "contoso", false)); + } + + [Fact] + public void FindPackagesIgnoresFilesThatAreNotReadableNuGetPackages() + { + using var feed = new LocalFeed(); + feed.WritePackage("Contoso.Tool", "1.0.0"); + File.WriteAllText(Path.Join(feed.Directory, "broken.nupkg"), "not a zip archive"); + File.WriteAllText(Path.Join(feed.Directory, "notes.txt"), "ignored"); + + var packages = feed.Find(feed.CreateManager(), "", canPrerelease: false); + + Assert.Single(packages); + Assert.Equal("Contoso.Tool", packages[0].Id); + } + + [Fact] + public void FindPackagesPicksUpAPackageFileThatWasReplaced() + { + using var feed = new LocalFeed(); + string file = feed.WritePackage("Contoso.Tool", "1.0.0"); + + var manager = feed.CreateManager(); + Assert.Equal("1.0.0", feed.Find(manager, "contoso", false)[0].VersionString); + + File.Delete(file); + feed.WritePackage("Contoso.Tool", "1.0.0", description: "A much longer description"); + + Assert.Equal("1.0.0", feed.Find(manager, "much longer", false)[0].VersionString); + } + + [Fact] + public void GetAvailableUpdatesOnlyOffersNewerVersions() + { + using var feed = new LocalFeed(); + feed.WritePackage("Contoso.Tool", "1.0.0"); + feed.WritePackage("Contoso.Tool", "2.0.0"); + feed.WritePackage("Fabrikam.Tool", "1.0.0"); + + var manager = feed.CreateManager(); + var source = manager.Properties.DefaultSource; + IPackage outdated = new PackageBuilder() + .WithManager(manager) + .WithSource(source) + .WithId("Contoso.Tool") + .WithVersion("1.0.0") + .Build(); + IPackage current = new PackageBuilder() + .WithManager(manager) + .WithSource(source) + .WithId("Fabrikam.Tool") + .WithVersion("1.0.0") + .Build(); + + var updates = manager.GetAvailableUpdatesLocal( + source, + feed.Directory, + [outdated, current], + canPrerelease: false, + Logger(manager) + ); + + Assert.Single(updates); + Assert.Equal("Contoso.Tool", updates[0].Id); + Assert.Equal("1.0.0", updates[0].VersionString); + Assert.Equal("2.0.0", updates[0].NewVersionString); + } + + [Fact] + public void GetAvailableUpdatesOffersOneUpdatePerIdWhenAPackageIsInstalledTwice() + { + using var feed = new LocalFeed(); + feed.WritePackage("Contoso.Tool", "2.0.0"); + + var manager = feed.CreateManager(); + var source = manager.Properties.DefaultSource; + IPackage currentUser = new PackageBuilder() + .WithManager(manager) + .WithSource(source) + .WithId("Contoso.Tool") + .WithVersion("1.0.0") + .Build(); + IPackage allUsers = new PackageBuilder() + .WithManager(manager) + .WithSource(source) + .WithId("contoso.tool") + .WithVersion("1.0.0") + .Build(); + + var updates = manager.GetAvailableUpdatesLocal( + source, + feed.Directory, + [currentUser, allUsers], + canPrerelease: false, + Logger(manager) + ); + + Assert.Equal("2.0.0", Assert.Single(updates).NewVersionString); + } + + [Fact] + public void GetAvailableUpdatesSkipsPreReleasesUnlessTheyAreAllowed() + { + using var feed = new LocalFeed(); + feed.WritePackage("Contoso.Tool", "2.0.0-beta.1"); + + var manager = feed.CreateManager(); + var source = manager.Properties.DefaultSource; + IPackage installed = new PackageBuilder() + .WithManager(manager) + .WithSource(source) + .WithId("Contoso.Tool") + .WithVersion("1.0.0") + .Build(); + + Assert.Empty( + manager.GetAvailableUpdatesLocal( + source, + feed.Directory, + [installed], + false, + Logger(manager) + ) + ); + Assert.Single( + manager.GetAvailableUpdatesLocal( + source, + feed.Directory, + [installed], + true, + Logger(manager) + ) + ); + } + + [Fact] + public void GetDetailsReadsTheEmbeddedNuspec() + { + using var feed = new LocalFeed(); + string file = feed.WritePackage( + "Contoso.Tool", + "2.0.0", + description: "A deployment helper", + authors: "Contoso Ltd", + tags: "deployment tooling", + dependencyId: "Fabrikam.Core" + ); + + var manager = feed.CreateManager(); + var package = new PackageBuilder() + .WithManager(manager) + .WithSource(manager.Properties.DefaultSource) + .WithId("Contoso.Tool") + .WithVersion("2.0.0") + .Build(); + var details = new PackageDetailsBuilder().Build(package); + + manager.ExposedDetailsHelper.LoadDetails(details); + + Assert.Equal("A deployment helper", details.Description); + Assert.Equal("Contoso Ltd", details.Author); + Assert.Equal("Contoso Ltd", details.Publisher); + Assert.Equal("MIT", details.License); + Assert.Equal("https://example.test/package", details.HomepageUrl?.AbsoluteUri); + Assert.Equal(new Uri(file), details.InstallerUrl); + Assert.Equal(new Uri(file), details.ManifestUrl); + Assert.Equal(new FileInfo(file).Length, details.InstallerSize); + Assert.Equal(["deployment", "tooling"], details.Tags); + Assert.Equal("Fabrikam.Core", Assert.Single(details.Dependencies).Name); + Assert.Equal("1.2.0", details.Dependencies[0].Version); + } + + [Fact] + public void GetDetailsReportsAPackageFileThatIsNotOnTheFeedAnymore() + { + using var feed = new LocalFeed(); + var manager = feed.CreateManager(); + var package = new PackageBuilder() + .WithManager(manager) + .WithSource(manager.Properties.DefaultSource) + .WithId("Contoso.Tool") + .WithVersion("2.0.0") + .Build(); + var details = new PackageDetailsBuilder().Build(package); + + manager.ExposedDetailsHelper.LoadDetails(details); + + Assert.Null(details.Description); + Assert.Null(details.InstallerUrl); + } + + [Fact] + public void GetIconUsesTheIconUrlOfTheNuspec() + { + using var feed = new LocalFeed(); + feed.WritePackage("Contoso.Tool", "2.0.0", iconUrl: "https://example.test/icon.png"); + feed.WritePackage("Fabrikam.Tool", "2.0.0"); + + var manager = feed.CreateManager(); + CacheableIcon? icon = manager.ExposedDetailsHelper.LoadIcon( + new PackageBuilder() + .WithManager(manager) + .WithSource(manager.Properties.DefaultSource) + .WithId("Contoso.Tool") + .WithVersion("2.0.0") + .Build() + ); + + Assert.Equal("https://example.test/icon.png", icon?.Url?.AbsoluteUri); + Assert.Null( + manager.ExposedDetailsHelper.LoadIcon( + new PackageBuilder() + .WithManager(manager) + .WithSource(manager.Properties.DefaultSource) + .WithId("Fabrikam.Tool") + .WithVersion("2.0.0") + .Build() + ) + ); + } + + [Fact] + public void GetInstallableVersionsListsTheFolderContentsNewestFirst() + { + using var feed = new LocalFeed(); + feed.WritePackage("Contoso.Tool", "1.0.0"); + feed.WritePackage("Contoso.Tool", "2.0.0-beta.1"); + feed.WritePackage("Contoso.Tool", "2.0.0"); + feed.WritePackage("Fabrikam.Tool", "3.0.0"); + + var manager = feed.CreateManager(); + var versions = manager.ExposedDetailsHelper.LoadVersions( + new PackageBuilder() + .WithManager(manager) + .WithSource(manager.Properties.DefaultSource) + .WithId("Contoso.Tool") + .WithVersion("1.0.0") + .Build() + ); + + Assert.Equal(["2.0.0", "2.0.0-beta.1", "1.0.0"], versions); + } + + [Fact] + public void GetInstallableVersionsReturnsNothingWhenTheFolderIsGone() + { + var manager = new TestNuGetManager( + Path.Join(Path.GetTempPath(), Path.GetRandomFileName()), + idOnlySearch: false + ); + + Assert.Empty( + manager.ExposedDetailsHelper.LoadVersions( + new PackageBuilder() + .WithManager(manager) + .WithSource(manager.Properties.DefaultSource) + .WithId("Contoso.Tool") + .WithVersion("1.0.0") + .Build() + ) + ); + } + + private static INativeTaskLogger Logger(BaseNuGet manager) => + manager.TaskLogger.CreateNew(LoggableTaskType.FindPackages); + + private sealed class LocalFeed : IDisposable + { + private readonly LocalNuGetFeedBuilder _feed = new(); + + public string Directory => _feed.Directory; + + public TestNuGetManager CreateManager(bool idOnlySearch = false) => + new(Directory, idOnlySearch); + + public IReadOnlyList Find( + TestNuGetManager manager, + string query, + bool canPrerelease + ) => + manager.FindPackagesLocal( + manager.Properties.DefaultSource, + Directory, + query, + canPrerelease, + Logger(manager) + ); + + public string WritePackage( + string id, + string version, + string? folder = null, + string description = "A package", + string authors = "Example Ltd", + string tags = "tooling", + string? iconUrl = null, + string? dependencyId = null + ) => + _feed.WritePackage( + id, + version, + folder, + description, + authors, + tags, + iconUrl, + dependencyId + ); + + public void Dispose() => _feed.Dispose(); + } + + private sealed class TestNuGetManager : BaseNuGet + { + private readonly bool _idOnlySearch; + + public TestNuGetManager(string directory, bool idOnlySearch) + { + _idOnlySearch = idOnlySearch; + + Capabilities = new ManagerCapabilities + { + SupportsCustomVersions = true, + SupportsCustomPackageIcons = true, + CanListDependencies = true, + }; + + Properties = new ManagerProperties + { + Id = "test-nuget", + Name = "TestNuGet", + DefaultSource = new ManagerSource(this, "local", new Uri(directory)), + }; + + ExposedDetailsHelper = new TestNuGetDetailsHelper(this); + DetailsHelper = ExposedDetailsHelper; + } + + public TestNuGetDetailsHelper ExposedDetailsHelper { get; } + + protected override bool UseSubstringSearch => _idOnlySearch; + + protected override IReadOnlyList _getInstalledPackages_UnSafe() => []; + + public override IReadOnlyList FindCandidateExecutableFiles() => []; + + protected override void _loadManagerExecutableFile( + out bool found, + out string executablePath, + out string callArgs + ) + { + found = false; + executablePath = string.Empty; + callArgs = string.Empty; + } + + protected override void _loadManagerVersion(out string version) => version = "0.0.0"; + } + + private sealed class TestNuGetDetailsHelper : BaseNuGetDetailsHelper + { + public TestNuGetDetailsHelper(BaseNuGet manager) + : base(manager) { } + + protected override string? GetInstallLocation_UnSafe(IPackage package) => null; + + public void LoadDetails(IPackageDetails details) => GetDetails_UnSafe(details); + + public CacheableIcon? LoadIcon(IPackage package) => GetIcon_UnSafe(package); + + public IReadOnlyList LoadVersions(IPackage package) => + GetInstallableVersions_UnSafe(package); + } +} From c8900a726b9e1058359955d18406228e75dd97d8 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Wed, 23 Sep 2026 09:33:17 -0400 Subject: [PATCH 2/2] Bound what a local NuGet feed is trusted to hand back --- AGENTS.md | 8 +- .../BaseNuGetDetailsHelper.cs | 14 +- .../Internal/NuGetLocalFeed.cs | 148 +++++++++++++++++- .../DownloadOperation.cs | 26 +++ .../DownloadOperationProgressTests.cs | 52 ++++++ .../Builders/LocalNuGetFeedBuilder.cs | 28 +++- .../NuGetLocalFeedTests.cs | 135 +++++++++++++++- 7 files changed, 400 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cdefe20fd4..a028ecbd1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,13 @@ Detection is purely by URL shape, so it costs no probe request and no V2 feed ch A local folder feed is scanned at most three directories deep, which covers both the flat layout and the `//..nupkg` layout, and each parsed manifest is cached against its file's size and write time. Installers on such a feed are copied from disk instead of being -downloaded (`DownloadOperation`). +downloaded (`DownloadOperation`), which refuses a destination that is the package file itself. + +Every `.nupkg` in the folder is opened during a search, so its contents are treated as untrusted: +a manifest is rejected above 4 MiB (checked against the declared size *and* while decompressing, +since the declared one can lie) and parsed with DTD processing prohibited, and an embedded +`` is extracted under the same bounds into the package's icon cache directory, named only +from the package version plus an allow-listed extension so a crafted entry path cannot escape it. ### V3 resources used diff --git a/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/BaseNuGetDetailsHelper.cs b/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/BaseNuGetDetailsHelper.cs index 9d79333589..0d864ce47f 100644 --- a/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/BaseNuGetDetailsHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/BaseNuGetDetailsHelper.cs @@ -475,7 +475,7 @@ internal static string FormatDependencyRange(string? range) ); } - private static CacheableIcon? GetIconLocal(IPackage package, string directory) + private CacheableIcon? GetIconLocal(IPackage package, string directory) { LocalNuGetPackage? local = NuGetLocalFeed.Find( directory, @@ -486,9 +486,15 @@ internal static string FormatDependencyRange(string? range) if (local is null) return null; - return Uri.TryCreate(local.IconUrl, UriKind.Absolute, out Uri? iconUrl) - ? new CacheableIcon(iconUrl, package.VersionString) - : null; + if (Uri.TryCreate(local.IconUrl, UriKind.Absolute, out Uri? iconUrl)) + return new CacheableIcon(iconUrl, package.VersionString); + + string? extracted = NuGetLocalFeed.ExtractIcon( + local, + IconCacheEngine.GetIconCacheDirectory(Manager.Name, package.Id) + ); + + return extracted is null ? null : new CacheableIcon(extracted); } private static CacheableIcon? GetIconV3(IPackage package) diff --git a/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/Internal/NuGetLocalFeed.cs b/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/Internal/NuGetLocalFeed.cs index 1abcab0be6..46ea106f07 100644 --- a/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/Internal/NuGetLocalFeed.cs +++ b/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/Internal/NuGetLocalFeed.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.IO.Compression; +using System.Xml; using System.Xml.Linq; using UniGetUI.Core.Logging; using UniGetUI.Core.Tools; @@ -26,6 +27,7 @@ internal sealed class LocalNuGetPackage public string? LicenseUrl { get; init; } public string? License { get; init; } public string? IconUrl { get; init; } + public string? IconFile { get; init; } public string? ReleaseNotes { get; init; } public string? Tags { get; init; } public IReadOnlyList Dependencies { get; init; } = []; @@ -34,6 +36,8 @@ internal sealed class LocalNuGetPackage internal static class NuGetLocalFeed { private const int MaxRecursionDepth = 3; + private const int MaxNuspecBytes = 4 * 1024 * 1024; + private const long MaxIconBytes = 8 * 1024 * 1024; private readonly record struct CacheEntry( long Size, @@ -169,6 +173,114 @@ public static bool MatchesQuery(LocalNuGetPackage package, string query, bool id private static bool Contains(string? value, string term) => value is not null && value.Contains(term, StringComparison.OrdinalIgnoreCase); + private static readonly string[] IconExtensions = + [ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".bmp", + ".webp", + ".ico", + ".svg", + ]; + + public static string? ExtractIcon(LocalNuGetPackage package, string targetDirectory) + { + if (package.IconFile is not { Length: > 0 } iconFile) + return null; + + string extension = Path.GetExtension(iconFile); + if (!IconExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + extension = ".png"; + + string target = Path.Join( + targetDirectory, + $"localfeed-{CoreTools.MakeValidFileName(package.Version)}{extension}" + ); + + try + { + if ( + File.Exists(target) + && File.GetLastWriteTimeUtc(target) >= package.LastWriteTimeUtc + ) + return target; + + string wanted = iconFile.Replace('\\', '/').TrimStart('/'); + using FileStream stream = File.OpenRead(package.FilePath); + using ZipArchive archive = new(stream, ZipArchiveMode.Read); + + ZipArchiveEntry? entry = archive.Entries.FirstOrDefault(candidate => + candidate.FullName.Replace('\\', '/') + .Equals(wanted, StringComparison.OrdinalIgnoreCase) + ); + + if (entry is null) + { + Logger.Warn( + $"The NuGet package at {package.FilePath} declares the icon {iconFile}, " + + "which the archive does not contain" + ); + return null; + } + + if (entry.Length > MaxIconBytes) + { + Logger.Warn( + $"The icon of the NuGet package at {package.FilePath} declares " + + $"{entry.Length} bytes, over the {MaxIconBytes} byte limit" + ); + return null; + } + + using Stream compressed = entry.Open(); + using MemoryStream? icon = ReadBounded(compressed, MaxIconBytes); + + if (icon is null) + { + Logger.Warn( + $"The icon of the NuGet package at {package.FilePath} expands past " + + $"the {MaxIconBytes} byte limit" + ); + return null; + } + + Directory.CreateDirectory(targetDirectory); + File.WriteAllBytes(target, icon.ToArray()); + return target; + } + catch (Exception e) + { + Logger.Warn($"Could not extract the icon of the NuGet package at {package.FilePath}"); + Logger.Warn(e); + return null; + } + } + + private static MemoryStream? ReadBounded(Stream source, long limit) + { + MemoryStream buffer = new(); + byte[] chunk = new byte[81920]; + long total = 0; + int read; + + while ((read = source.Read(chunk, 0, chunk.Length)) > 0) + { + total += read; + if (total > limit) + { + buffer.Dispose(); + return null; + } + + buffer.Write(chunk, 0, read); + } + + buffer.Position = 0; + return buffer; + } + private static LocalNuGetPackage? Load(string file) { long size; @@ -218,7 +330,27 @@ private static bool Contains(string? value, string term) => return null; } - using Stream manifest = nuspec.Open(); + if (nuspec.Length > MaxNuspecBytes) + { + Logger.Warn( + $"The .nuspec manifest of the NuGet package at {file} declares " + + $"{nuspec.Length} bytes, over the {MaxNuspecBytes} byte limit" + ); + return null; + } + + using Stream compressed = nuspec.Open(); + using MemoryStream? manifest = ReadBounded(compressed, MaxNuspecBytes); + + if (manifest is null) + { + Logger.Warn( + $"The .nuspec manifest of the NuGet package at {file} expands past " + + $"the {MaxNuspecBytes} byte limit" + ); + return null; + } + LocalNuGetPackage? package = ParseNuspec(manifest, file, size, lastWriteTimeUtc); if (package is null) @@ -247,8 +379,19 @@ private static bool Contains(string? value, string term) => DateTime lastWriteTimeUtc ) { + XmlReaderSettings settings = new() + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + MaxCharactersInDocument = MaxNuspecBytes, + IgnoreComments = true, + IgnoreProcessingInstructions = true, + CloseInput = false, + }; + + using XmlReader reader = XmlReader.Create(manifest, settings); XElement? metadata = XDocument - .Load(manifest) + .Load(reader) .Root?.Elements() .FirstOrDefault(element => element.Name.LocalName is "metadata"); @@ -283,6 +426,7 @@ out SemanticVersion parsed LicenseUrl = Value(metadata, "licenseUrl"), License = ReadLicenseExpression(metadata), IconUrl = Value(metadata, "iconUrl"), + IconFile = Value(metadata, "icon"), ReleaseNotes = Value(metadata, "releaseNotes"), Tags = Value(metadata, "tags"), Dependencies = ReadDependencies(metadata), diff --git a/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs b/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs index 17eb934d3b..e9c436152c 100644 --- a/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs +++ b/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs @@ -54,6 +54,22 @@ protected override void ApplyRetryAction(string retryMode) { } protected virtual HttpClient CreateHttpClient() => new(CoreTools.GenericHttpClientParameters); + internal static bool IsSameFile(string source, string destination) + { + try + { + return string.Equals( + Path.GetFullPath(source), + Path.GetFullPath(destination), + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal + ); + } + catch (Exception) + { + return false; + } + } + protected override async Task PerformOperation() { bool downloadFileCreated = false; @@ -93,6 +109,16 @@ protected override async Task PerformOperation() downloadLocation = Path.Join(downloadLocation, fileName); } + if (downloadUrl.IsFile && IsSameFile(downloadUrl.LocalPath, downloadLocation)) + { + Line( + $"The chosen location {downloadLocation} is the package file itself, " + + "please choose a different destination", + LineType.Error + ); + return OperationVeredict.Failure; + } + Line($"Download URL found at {downloadUrl} ", LineType.Information); using HttpClient? httpClient = downloadUrl.IsFile ? null : CreateHttpClient(); using HttpResponseMessage? response = diff --git a/src/UniGetUI.PackageEngine.Tests/DownloadOperationProgressTests.cs b/src/UniGetUI.PackageEngine.Tests/DownloadOperationProgressTests.cs index f33126c55f..456442d60e 100644 --- a/src/UniGetUI.PackageEngine.Tests/DownloadOperationProgressTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/DownloadOperationProgressTests.cs @@ -275,6 +275,58 @@ await operation.InvokePerformOperationForTests() } } + [Fact] + public async Task LocalFeedInstallers_RefuseToOverwriteTheSourceFile() + { + byte[] payload = new byte[2048]; + new Random(31).NextBytes(payload); + + string sourcePath = Path.Join( + Path.GetTempPath(), + $"unigetui-same-file-{Guid.NewGuid():N}.nupkg" + ); + File.WriteAllBytes(sourcePath, payload); + + var manager = new PackageManagerBuilder() + .ConfigureDetails(helper => + { + helper.PopulateDetails = details => + { + details.InstallerUrl = new Uri(sourcePath); + details.InstallerType = "nupkg"; + }; + }) + .Build(); + IPackage package = new PackageBuilder().WithManager(manager).Build(); + + try + { + using var operation = new ProbeDownloadOperation( + package, + sourcePath, + new UnreachableHandler() + ); + + Assert.Equal( + OperationVeredict.Failure, + await operation.InvokePerformOperationForTests() + ); + Assert.Equal(payload, File.ReadAllBytes(sourcePath)); + Assert.Contains( + operation.GetOutput(), + line => line.Item1.Contains("is the package file itself") + ); + Assert.DoesNotContain( + operation.GetOutput(), + line => line.Item1.Contains("System.IO.IOException") + ); + } + finally + { + File.Delete(sourcePath); + } + } + private sealed class UnreachableHandler : HttpMessageHandler { protected override Task SendAsync( diff --git a/src/UniGetUI.PackageEngine.Tests/Infrastructure/Builders/LocalNuGetFeedBuilder.cs b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Builders/LocalNuGetFeedBuilder.cs index 39083bb38f..c5865dd762 100644 --- a/src/UniGetUI.PackageEngine.Tests/Infrastructure/Builders/LocalNuGetFeedBuilder.cs +++ b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Builders/LocalNuGetFeedBuilder.cs @@ -23,7 +23,9 @@ public string WritePackage( string authors = "Example Ltd", string tags = "tooling", string? iconUrl = null, - string? dependencyId = null + string? dependencyId = null, + string? iconFile = null, + byte[]? iconBytes = null ) { string target = folder is null ? Directory : Path.Join(Directory, folder); @@ -43,6 +45,7 @@ public string WritePackage( https://example.test/package MIT {(iconUrl is null ? string.Empty : $"{iconUrl}")} + {(iconFile is null ? string.Empty : $"{iconFile}")} {( dependencyId is null ? string.Empty @@ -61,7 +64,28 @@ dependencyId is null using (FileStream stream = File.Create(file)) using (ZipArchive archive = new(stream, ZipArchiveMode.Create)) { - using Stream entry = archive.CreateEntry($"{id}.nuspec").Open(); + using (Stream entry = archive.CreateEntry($"{id}.nuspec").Open()) + entry.Write(Encoding.UTF8.GetBytes(nuspec)); + + if (iconFile is not null && iconBytes is not null) + { + using Stream icon = archive.CreateEntry(iconFile).Open(); + icon.Write(iconBytes); + } + } + + NuGetLocalFeed.ClearCache(); + return file; + } + + public string WriteRawPackage(string fileName, string nuspec) + { + string file = Path.Join(Directory, fileName); + + using (FileStream stream = File.Create(file)) + using (ZipArchive archive = new(stream, ZipArchiveMode.Create)) + { + using Stream entry = archive.CreateEntry("package.nuspec").Open(); entry.Write(Encoding.UTF8.GetBytes(nuspec)); } diff --git a/src/UniGetUI.PackageEngine.Tests/NuGetLocalFeedTests.cs b/src/UniGetUI.PackageEngine.Tests/NuGetLocalFeedTests.cs index bca8f00e6d..c2a9b78cd6 100644 --- a/src/UniGetUI.PackageEngine.Tests/NuGetLocalFeedTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/NuGetLocalFeedTests.cs @@ -314,6 +314,130 @@ public void GetIconUsesTheIconUrlOfTheNuspec() ); } + [Fact] + public void FindPackagesSkipsAManifestThatExpandsPastTheSizeLimit() + { + using var feed = new LocalFeed(); + feed.WritePackage("Contoso.Tool", "1.0.0"); + feed.WriteRawPackage( + "bomb.1.0.0.nupkg", + $""" + + + + Contoso.Bomb + 1.0.0 + {new string('A', 5 * 1024 * 1024)} + + + """ + ); + + var packages = feed.Find(feed.CreateManager(), "contoso", canPrerelease: false); + + Assert.Equal("Contoso.Tool", Assert.Single(packages).Id); + } + + [Fact] + public void FindPackagesRejectsAManifestThatDeclaresADocumentTypeDefinition() + { + using var feed = new LocalFeed(); + feed.WriteRawPackage( + "entities.1.0.0.nupkg", + """ + + + + ]> + + + Contoso.Entities + 1.0.0 + &lol2; + + + """ + ); + + Assert.Empty(feed.Find(feed.CreateManager(), "contoso", canPrerelease: false)); + } + + [Fact] + public void GetIconExtractsAnIconEmbeddedInThePackage() + { + byte[] iconBytes = [137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3, 4]; + + using var feed = new LocalFeed(); + feed.WritePackage( + "Contoso.Tool", + "2.0.0", + iconFile: "images/icon.png", + iconBytes: iconBytes + ); + + var manager = feed.CreateManager(); + CacheableIcon? icon = manager.ExposedDetailsHelper.LoadIcon( + new PackageBuilder() + .WithManager(manager) + .WithSource(manager.Properties.DefaultSource) + .WithId("Contoso.Tool") + .WithVersion("2.0.0") + .Build() + ); + + Assert.NotNull(icon); + Assert.True(icon.Value.IsLocalPath); + Assert.Equal(iconBytes, File.ReadAllBytes(icon.Value.LocalPath)); + Assert.Equal(".png", Path.GetExtension(icon.Value.LocalPath)); + } + + [Fact] + public void GetIconPrefersTheIconUrlOverAnEmbeddedIcon() + { + using var feed = new LocalFeed(); + feed.WritePackage( + "Contoso.Tool", + "2.0.0", + iconUrl: "https://example.test/icon.png", + iconFile: "images/icon.png", + iconBytes: [1, 2, 3] + ); + + var manager = feed.CreateManager(); + CacheableIcon? icon = manager.ExposedDetailsHelper.LoadIcon( + new PackageBuilder() + .WithManager(manager) + .WithSource(manager.Properties.DefaultSource) + .WithId("Contoso.Tool") + .WithVersion("2.0.0") + .Build() + ); + + Assert.False(icon?.IsLocalPath); + Assert.Equal("https://example.test/icon.png", icon?.Url.AbsoluteUri); + } + + [Fact] + public void GetIconIgnoresAnEmbeddedIconThatIsNotInTheArchive() + { + using var feed = new LocalFeed(); + feed.WritePackage("Contoso.Tool", "2.0.0", iconFile: "images/missing.png"); + + var manager = feed.CreateManager(); + + Assert.Null( + manager.ExposedDetailsHelper.LoadIcon( + new PackageBuilder() + .WithManager(manager) + .WithSource(manager.Properties.DefaultSource) + .WithId("Contoso.Tool") + .WithVersion("2.0.0") + .Build() + ) + ); + } + [Fact] public void GetInstallableVersionsListsTheFolderContentsNewestFirst() { @@ -389,7 +513,9 @@ public string WritePackage( string authors = "Example Ltd", string tags = "tooling", string? iconUrl = null, - string? dependencyId = null + string? dependencyId = null, + string? iconFile = null, + byte[]? iconBytes = null ) => _feed.WritePackage( id, @@ -399,9 +525,14 @@ public string WritePackage( authors, tags, iconUrl, - dependencyId + dependencyId, + iconFile, + iconBytes ); + public string WriteRawPackage(string fileName, string nuspec) => + _feed.WriteRawPackage(fileName, nuspec); + public void Dispose() => _feed.Dispose(); }