diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json index 9f7e6d6655..7f6935a78c 100644 --- a/src/Languages/lang_en.json +++ b/src/Languages/lang_en.json @@ -1743,5 +1743,9 @@ "{0} packages cannot finish until your computer restarts.": "{0} packages cannot finish until your computer restarts.", "Your computer will restart now. Save your work and close any open applications first.": "Your computer will restart now. Save your work and close any open applications first.", "The computer could not be restarted": "The computer could not be restarted", - "UniGetUI could not start the restart. Please restart your computer manually.": "UniGetUI could not start the restart. Please restart your computer manually." + "UniGetUI could not start the restart. Please restart your computer manually.": "UniGetUI could not start the restart. Please restart your computer manually.", + "No installer is available for {0}": "No installer is available for {0}", + "{0} did not report a download address for this package. It may not be offered for your system, or it may no longer exist on {1}.": "{0} did not report a download address for this package. It may not be offered for your system, or it may no longer exist on {1}.", + "Some packages cannot be downloaded": "Some packages cannot be downloaded", + "{0} of the {1} selected packages were skipped, because their package manager cannot download installers or could not be found.": "{0} of the {1} selected packages were skipped, because their package manager cannot download installers or could not be found." } diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaPackageOperationHelper.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaPackageOperationHelper.cs index 7b501c4ad8..b3446e852f 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaPackageOperationHelper.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaPackageOperationHelper.cs @@ -117,6 +117,16 @@ public static async Task AskLocationAndDownloadAsync(IPackage? package, TEL_Inst if (package.Details.InstallerUrl is null) { Logger.Warn($"No installer URL found for {package.Id}"); + win.ShowBanner( + CoreTools.Translate("No installer is available for {0}", package.Name), + CoreTools.Translate( + "{0} did not report a download address for this package. It may not be " + + "offered for your system, or it may no longer exist on {1}.", + package.Manager.DisplayName, + package.Source.AsString_DisplayName + ), + MainWindow.RuntimeNotificationLevel.Error + ); return; } @@ -160,10 +170,25 @@ public static async Task DownloadSelectedAsync(IEnumerable packages, T { if (MainWindow.Instance is not { } win) return; - var eligible = packages + var selected = packages.ToList(); + var eligible = selected .Where(p => !p.Source.IsVirtualManager && p.Manager.Capabilities.CanDownloadInstaller) .ToList(); + if (eligible.Count < selected.Count) + { + win.ShowBanner( + CoreTools.Translate("Some packages cannot be downloaded"), + CoreTools.Translate( + "{0} of the {1} selected packages were skipped, because their package " + + "manager cannot download installers or could not be found.", + selected.Count - eligible.Count, + selected.Count + ), + MainWindow.RuntimeNotificationLevel.Error + ); + } + if (eligible.Count == 0) return; var folders = await win.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions diff --git a/src/UniGetUI.PackageEngine.Interfaces/IPackageDetails.cs b/src/UniGetUI.PackageEngine.Interfaces/IPackageDetails.cs index 607d4b35c0..1f1c1a753c 100644 --- a/src/UniGetUI.PackageEngine.Interfaces/IPackageDetails.cs +++ b/src/UniGetUI.PackageEngine.Interfaces/IPackageDetails.cs @@ -43,6 +43,12 @@ public interface IPackageDetails /// public Uri? LicenseUrl { get; set; } + /// + /// The version of the package the installer URL points at, as reported by the + /// loaded manifest. Null when the manager does not report it. + /// + public string? Version { get; set; } + /// /// A URL pointing to the installer of the package /// diff --git a/src/UniGetUI.PackageEngine.Managers.Bun/Helpers/BunPkgDetailsHelper.cs b/src/UniGetUI.PackageEngine.Managers.Bun/Helpers/BunPkgDetailsHelper.cs index 59de3d4ddb..a5ad4978c4 100644 --- a/src/UniGetUI.PackageEngine.Managers.Bun/Helpers/BunPkgDetailsHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Bun/Helpers/BunPkgDetailsHelper.cs @@ -47,6 +47,7 @@ protected override void GetDetails_UnSafe(IPackageDetails details) logger.AddToStdOut(strContents); JsonObject? contents = JsonNode.Parse(strContents) as JsonObject; + details.Version = contents?["version"]?.ToString(); details.License = contents?["license"]?.ToString(); details.Description = contents?["description"]?.ToString(); diff --git a/src/UniGetUI.PackageEngine.Managers.Cargo/Helpers/CargoPkgDetailsHelper.cs b/src/UniGetUI.PackageEngine.Managers.Cargo/Helpers/CargoPkgDetailsHelper.cs index 90b95a5cb2..ff0817db25 100644 --- a/src/UniGetUI.PackageEngine.Managers.Cargo/Helpers/CargoPkgDetailsHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Cargo/Helpers/CargoPkgDetailsHelper.cs @@ -44,12 +44,14 @@ protected override void GetDetails_UnSafe(IPackageDetails details) var categories = manifest.categories?.Select(c => c.category) ?? []; details.Tags = [.. keywords, .. categories]; - var versionData = manifest.versions.FirstOrDefault(v => - v.num == details.Package.VersionString - ); + var versionData = details.Package.HasConcreteVersion + ? manifest.versions.FirstOrDefault(v => v.num == details.Package.VersionString) + : manifest.versions.FirstOrDefault(v => v.num == manifest.crate.max_stable_version) + ?? manifest.versions.FirstOrDefault(v => v.num == manifest.crate.newest_version); if (versionData is not null) { + details.Version = versionData.num; details.Author = versionData.published_by?.name; details.License = versionData.license; details.InstallerUrl = new Uri( diff --git a/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/BaseNuGetDetailsHelper.cs b/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/BaseNuGetDetailsHelper.cs index 0d864ce47f..9ee72bc180 100644 --- a/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/BaseNuGetDetailsHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/BaseNuGetDetailsHelper.cs @@ -22,21 +22,25 @@ protected override void GetDetails_UnSafe(IPackageDetails details) var logger = Manager.TaskLogger.CreateNew(LoggableTaskType.LoadPackageDetails); try { + string version = ResolveDetailsVersion(details.Package); + details.Version = version; + if (NuGetLocalFeed.TryGetDirectory(details.Package.Source, out string directory)) { - logger.Close(GetDetailsLocal(details, directory, logger) ? 0 : 1); + logger.Close(GetDetailsLocal(details, directory, version, logger) ? 0 : 1); return; } if (NuGetV3ServiceIndex.IsV3Source(details.Package.Source)) { - logger.Close(GetDetailsV3(details, logger) ? 0 : 1); + logger.Close(GetDetailsV3(details, version, logger) ? 0 : 1); return; } - details.ManifestUrl = NuGetManifestLoader.GetManifestUrl(details.Package); + details.ManifestUrl = NuGetManifestLoader.GetManifestUrl(details.Package, version); string? PackageManifestContents = NuGetManifestLoader.GetManifestContent( - details.Package + details.Package, + version ); logger.Log(PackageManifestContents); @@ -240,20 +244,17 @@ Match match in Regex.Matches( private static bool GetDetailsLocal( IPackageDetails details, string directory, + string version, INativeTaskLogger logger ) { IPackage package = details.Package; - LocalNuGetPackage? local = NuGetLocalFeed.Find( - directory, - package.Id, - package.VersionString - ); + LocalNuGetPackage? local = NuGetLocalFeed.Find(directory, package.Id, version); if (local is null) { logger.Error( - $"No package file for {package.Id} version {package.VersionString} was found " + $"No package file for {package.Id} version {version} was found " + $"on source {package.Source.Name} at Directory={directory}" ); return false; @@ -303,7 +304,11 @@ INativeTaskLogger logger return true; } - private static bool GetDetailsV3(IPackageDetails details, INativeTaskLogger logger) + private static bool GetDetailsV3( + IPackageDetails details, + string version, + INativeTaskLogger logger + ) { IPackage package = details.Package; NuGetV3ServiceIndex? index = NuGetV3ServiceIndex.Resolve(package.Source); @@ -317,10 +322,10 @@ private static bool GetDetailsV3(IPackageDetails details, INativeTaskLogger logg } details.ManifestUrl = - NuGetV3Client.GetRegistrationLeafUrl(index, package.Id, package.VersionString) - ?? NuGetV3Client.GetNuspecUrl(index, package.Id, package.VersionString); + NuGetV3Client.GetRegistrationLeafUrl(index, package.Id, version) + ?? NuGetV3Client.GetNuspecUrl(index, package.Id, version); - V3CatalogEntry? entry = GetOrFetchCatalogEntry(package, index); + V3CatalogEntry? entry = GetOrFetchCatalogEntry(package, index, version); if (entry is null) { logger.Error( @@ -356,7 +361,7 @@ private static bool GetDetailsV3(IPackageDetails details, INativeTaskLogger logg Uri? installerUrl = Uri.TryCreate(entry.PackageContent, UriKind.Absolute, out Uri? packageContent) ? packageContent - : NuGetV3Client.GetPackageContentUrl(index, package.Id, package.VersionString); + : NuGetV3Client.GetPackageContentUrl(index, package.Id, version); details.InstallerUrl = installerUrl; if (entry.PackageSize > 0) @@ -392,10 +397,12 @@ private static bool GetDetailsV3(IPackageDetails details, INativeTaskLogger logg private static V3CatalogEntry? GetOrFetchCatalogEntry( IPackage package, - NuGetV3ServiceIndex index + NuGetV3ServiceIndex index, + string? version = null ) { - long hash = package.GetVersionedHash(); + version ??= package.VersionString; + long hash = NuGetManifestLoader.GetCacheKey(package, version); if (BaseNuGet.V3Entries.TryGetValue(hash, out V3CatalogEntry? cached)) { Logger.Debug( @@ -404,11 +411,7 @@ NuGetV3ServiceIndex index return cached; } - V3CatalogEntry? entry = NuGetV3Client.GetCatalogEntry( - index, - package.Id, - package.VersionString - ); + V3CatalogEntry? entry = NuGetV3Client.GetCatalogEntry(index, package.Id, version); if (entry is not null) BaseNuGet.V3Entries[hash] = entry; @@ -416,6 +419,29 @@ NuGetV3ServiceIndex index return entry; } + private string ResolveDetailsVersion(IPackage package) + { + if (package.HasConcreteVersion) + return package.VersionString; + + try + { + IReadOnlyList versions = GetInstallableVersions_UnSafe(package); + return NuGetV3Client.SelectHighestVersion(versions, includePreRelease: false) + ?? NuGetV3Client.SelectHighestVersion(versions, includePreRelease: true) + ?? package.VersionString; + } + catch (Exception ex) + { + Logger.Warn( + $"Could not resolve the newest version of package {package.Id} on manager " + + $"{package.Manager.Name}, the listed version will be used instead" + ); + Logger.Warn(ex); + return package.VersionString; + } + } + private static string? FirstNonEmpty(params string?[] values) { foreach (string? value in values) diff --git a/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/Internal/NuGetManifestLoader.cs b/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/Internal/NuGetManifestLoader.cs index 6cbd0f9f2c..b3d3532bd8 100644 --- a/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/Internal/NuGetManifestLoader.cs +++ b/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/Internal/NuGetManifestLoader.cs @@ -8,15 +8,32 @@ namespace UniGetUI.PackageEngine.Managers.Generic.NuGet.Internal { internal static class NuGetManifestLoader { + /// + /// Cache key for the manifest and catalog caches. Reproduces the shape of + /// IPackage.GetVersionedHash() so that an entry cached for a concrete package is + /// reused when another package resolves to that same version, and so that a package + /// whose listed version is a placeholder does not share one slot across versions. + /// + /// A valid Package object + /// The version to key on; the package's own when omitted + /// A cache key + public static long GetCacheKey(IPackage package, string? version = null) + { + return CoreTools.HashStringAsLong( + $"{package.Manager.Name}\\{package.Source.AsString_DisplayName}\\{package.Id}\\{version ?? package.VersionString}" + ); + } + /// /// Returns the URL to the manifest of a NuGet-based package /// /// A valid Package object + /// The version to address; the package's own when omitted /// A Uri object - public static Uri GetManifestUrl(IPackage package) + public static Uri GetManifestUrl(IPackage package, string? version = null) { return new Uri( - $"{package.Source.Url}/Packages(Id='{package.Id}',Version='{package.VersionString}')" + $"{package.Source.Url}/Packages(Id='{package.Id}',Version='{version ?? package.VersionString}')" ); } @@ -53,10 +70,13 @@ public static Uri GetNuPkgUrl(IPackage package) /// Returns the contents of the manifest of a NuGet-based package /// /// The package for which to obtain the manifest + /// The version to fetch; the package's own when omitted /// A string containing the contents of the manifest - public static string? GetManifestContent(IPackage package) + public static string? GetManifestContent(IPackage package, string? version = null) { - if (BaseNuGet.Manifests.TryGetValue(package.GetVersionedHash(), out string? manifest)) + version ??= package.VersionString; + long cacheKey = GetCacheKey(package, version); + if (BaseNuGet.Manifests.TryGetValue(cacheKey, out string? manifest)) { Logger.Debug( $"Loading cached NuGet manifest for package {package.Id} on manager {package.Manager.Name}" @@ -64,7 +84,7 @@ public static Uri GetNuPkgUrl(IPackage package) return manifest; } - string PackageManifestUrl = GetManifestUrl(package).ToString(); + string PackageManifestUrl = GetManifestUrl(package, version).ToString(); try { @@ -77,17 +97,17 @@ public static Uri GetNuPkgUrl(IPackage package) ); using HttpResponseMessage initialResponse = client.Send(initialRequest); - if (!initialResponse.IsSuccessStatusCode && package.VersionString.EndsWith(".0")) + if (!initialResponse.IsSuccessStatusCode && version.EndsWith(".0")) { using var fallbackRequest = new HttpRequestMessage( HttpMethod.Get, new Uri(PackageManifestUrl.ToString().Replace(".0')", "')")) ); using HttpResponseMessage fallbackResponse = client.Send(fallbackRequest); - return CacheManifestContent(package, PackageManifestUrl, fallbackResponse); + return CacheManifestContent(cacheKey, package, PackageManifestUrl, fallbackResponse); } - return CacheManifestContent(package, PackageManifestUrl, initialResponse); + return CacheManifestContent(cacheKey, package, PackageManifestUrl, initialResponse); } } catch (Exception e) @@ -101,6 +121,7 @@ public static Uri GetNuPkgUrl(IPackage package) } private static string? CacheManifestContent( + long cacheKey, IPackage package, string packageManifestUrl, HttpResponseMessage response @@ -115,7 +136,7 @@ HttpResponseMessage response } string packageManifestContent = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); - BaseNuGet.Manifests[package.GetVersionedHash()] = packageManifestContent; + BaseNuGet.Manifests[cacheKey] = packageManifestContent; return packageManifestContent; } } diff --git a/src/UniGetUI.PackageEngine.Managers.Npm/Helpers/NpmPkgDetailsHelper.cs b/src/UniGetUI.PackageEngine.Managers.Npm/Helpers/NpmPkgDetailsHelper.cs index fa6b4632c4..a58188cbaa 100644 --- a/src/UniGetUI.PackageEngine.Managers.Npm/Helpers/NpmPkgDetailsHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Npm/Helpers/NpmPkgDetailsHelper.cs @@ -60,6 +60,7 @@ protected override void GetDetails_UnSafe(IPackageDetails details) logger.AddToStdOut(strContents); JsonObject? contents = JsonNode.Parse(strContents) as JsonObject; + details.Version = contents?["version"]?.ToString(); details.License = contents?["license"]?.ToString(); details.Description = contents?["description"]?.ToString(); diff --git a/src/UniGetUI.PackageEngine.Managers.Pip/Helpers/PipPkgDetailsHelper.cs b/src/UniGetUI.PackageEngine.Managers.Pip/Helpers/PipPkgDetailsHelper.cs index 70b753a66a..57f8e9d4fd 100644 --- a/src/UniGetUI.PackageEngine.Managers.Pip/Helpers/PipPkgDetailsHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Pip/Helpers/PipPkgDetailsHelper.cs @@ -35,6 +35,7 @@ protected override void GetDetails_UnSafe(IPackageDetails details) if (contents?["info"] is JsonObject info) { + details.Version = info["version"]?.ToString(); details.Description = info["summary"]?.ToString(); details.Author = info["author"]?.ToString(); details.Publisher = info["maintainer"]?.ToString(); diff --git a/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopPkgDetailsHelper.cs b/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopPkgDetailsHelper.cs index f286aba1c4..9e3140036b 100644 --- a/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopPkgDetailsHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopPkgDetailsHelper.cs @@ -98,6 +98,8 @@ protected override void GetDetails_UnSafe(IPackageDetails details) details.Description = contents["description"]?.ToString(); } + details.Version = contents["version"]?.ToString(); + // Load installer type if (contents["innsetup"]?.ToString() == "true") details.InstallerType = "Inno Setup (" + CoreTools.Translate("extracted") + ")"; diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs index f506da1368..ae556152cf 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs @@ -406,6 +406,7 @@ internal static void ApplyShowResult( Manifest manifest = result.Manifest; Installer? installer = result.SelectedInstaller ?? manifest.Installers.FirstOrDefault(); + SetIfPresent(value => details.Version = value, manifest.Version); SetIfMissing(value => details.Author = value, details.Author, manifest.Author); SetIfMissing( value => details.Description = value, diff --git a/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs b/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs index 2c27c7fa78..e226f649fe 100644 --- a/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs +++ b/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs @@ -79,6 +79,22 @@ internal static bool ComesFromTheSourceFolder(Uri installerUrl, IManagerSource s } } + private async Task AlreadyDownloaded(string path, PublishedInstallerHash expectedHash) + { + try + { + return await expectedHash.MatchesAsync(path, CancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Line( + $"The existing file {path} could not be checked against the published hash: {ex.Message}", + LineType.Information + ); + return false; + } + } + internal static bool IsSameFile(string source, string destination) { try @@ -155,6 +171,37 @@ protected override async Task PerformOperation() return OperationVeredict.Failure; } + if (File.Exists(downloadLocation) + && PublishedInstallerHash.TryParse( + _package.Details.InstallerHash, + out PublishedInstallerHash expectedHash + )) + { + Line( + $"A file already exists at {downloadLocation}, checking whether it is this installer...", + LineType.Information + ); + if (await AlreadyDownloaded(downloadLocation, expectedHash)) + { + long existingSize = new FileInfo(downloadLocation).Length; + ReportProgress( + OperationProgress.FromDownload((ulong)existingSize, (ulong)existingSize) + ); + Line( + $"The file {downloadLocation} matches the {expectedHash.Algorithm} hash " + + "published for this installer, the download was skipped", + LineType.Information + ); + return OperationVeredict.Success; + } + + Line( + $"The file {downloadLocation} does not match the {expectedHash.Algorithm} hash " + + "published for this installer, it will be downloaded again", + LineType.Information + ); + } + Line($"Download URL found at {downloadUrl} ", LineType.Information); using HttpClient? httpClient = downloadUrl.IsFile ? null : CreateHttpClient(); using HttpResponseMessage? response = diff --git a/src/UniGetUI.PackageEngine.Operations/PublishedInstallerHash.cs b/src/UniGetUI.PackageEngine.Operations/PublishedInstallerHash.cs new file mode 100644 index 0000000000..30e0e198bf --- /dev/null +++ b/src/UniGetUI.PackageEngine.Operations/PublishedInstallerHash.cs @@ -0,0 +1,136 @@ +using System.Security.Cryptography; + +namespace UniGetUI.PackageEngine.Operations; + +internal enum PublishedHashAlgorithm +{ + Md5, + Sha1, + Sha256, + Sha384, + Sha512, +} + +internal readonly record struct PublishedInstallerHash( + PublishedHashAlgorithm Algorithm, + byte[] Digest +) +{ + private static readonly Dictionary _algorithmsByName = new( + StringComparer.OrdinalIgnoreCase + ) + { + ["md5"] = PublishedHashAlgorithm.Md5, + ["sha1"] = PublishedHashAlgorithm.Sha1, + ["sha256"] = PublishedHashAlgorithm.Sha256, + ["sha384"] = PublishedHashAlgorithm.Sha384, + ["sha512"] = PublishedHashAlgorithm.Sha512, + }; + + private static readonly Dictionary _algorithmsByDigestLength = + new() + { + [16] = PublishedHashAlgorithm.Md5, + [20] = PublishedHashAlgorithm.Sha1, + [32] = PublishedHashAlgorithm.Sha256, + [48] = PublishedHashAlgorithm.Sha384, + [64] = PublishedHashAlgorithm.Sha512, + }; + + public static bool TryParse(string? publishedHash, out PublishedInstallerHash parsed) + { + foreach ( + string candidate in (publishedHash ?? "").Split( + ' ', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries + ) + ) + { + if (TryParseSingle(candidate, out parsed)) + return true; + } + + parsed = default; + return false; + } + + private static bool TryParseSingle(string candidate, out PublishedInstallerHash parsed) + { + int separator = candidate.IndexOfAny([':', '-']); + if ( + separator > 0 + && _algorithmsByName.TryGetValue( + candidate[..separator], + out PublishedHashAlgorithm named + ) + ) + { + return TryBuild(named, candidate[(separator + 1)..], out parsed); + } + + return TryBuild(null, candidate, out parsed); + } + + private static bool TryBuild( + PublishedHashAlgorithm? expected, + string value, + out PublishedInstallerHash parsed + ) + { + parsed = default; + if (!TryDecode(value, out byte[]? digest)) + return false; + + if (!_algorithmsByDigestLength.TryGetValue(digest.Length, out PublishedHashAlgorithm sized)) + return false; + + if (expected is { } named && named != sized) + return false; + + parsed = new PublishedInstallerHash(sized, digest); + return true; + } + + private static bool TryDecode(string value, out byte[] digest) + { + string hex = value.Replace(" ", "").Replace("-", ""); + if (hex.Length > 0 && hex.Length % 2 == 0 && hex.All(Uri.IsHexDigit)) + { + digest = Convert.FromHexString(hex); + return true; + } + + Span buffer = stackalloc byte[64]; + if (Convert.TryFromBase64String(value, buffer, out int written)) + { + digest = buffer[..written].ToArray(); + return true; + } + + digest = []; + return false; + } + + public async Task MatchesAsync(string filePath, CancellationToken token) + { + await using FileStream stream = new( + filePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 8192, + useAsync: true + ); + + byte[] actual = Algorithm switch + { + PublishedHashAlgorithm.Md5 => await MD5.HashDataAsync(stream, token), + PublishedHashAlgorithm.Sha1 => await SHA1.HashDataAsync(stream, token), + PublishedHashAlgorithm.Sha384 => await SHA384.HashDataAsync(stream, token), + PublishedHashAlgorithm.Sha512 => await SHA512.HashDataAsync(stream, token), + _ => await SHA256.HashDataAsync(stream, token), + }; + + return CryptographicOperations.FixedTimeEquals(actual, Digest); + } +} diff --git a/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/PackageDetails.cs b/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/PackageDetails.cs index 2c7a3f70c9..b105439498 100644 --- a/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/PackageDetails.cs +++ b/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/PackageDetails.cs @@ -49,6 +49,12 @@ public class PackageDetails : IPackageDetails /// public Uri? LicenseUrl { get; set; } + /// + /// The version of the package the installer URL points at, as reported by the + /// loaded manifest. Null when the manager does not report it. + /// + public string? Version { get; set; } + /// /// A URL pointing to the installer of the package /// diff --git a/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Package.cs b/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Package.cs index a8e9c544d1..b022daba6d 100644 --- a/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Package.cs +++ b/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Package.cs @@ -334,8 +334,15 @@ public virtual bool NewerVersionIsInstalled() private string ResolveInstallerVersion() { + string listedVersion = HasConcreteVersion ? VersionString : ""; + string resolvedVersion = + Details.Version is { } reported && reported.Any(char.IsDigit) ? reported : ""; + if (Manager.InstallerUrlFollowsPackageVersion) - return VersionString; + return listedVersion.Length > 0 ? listedVersion : resolvedVersion; + + if (resolvedVersion.Length > 0) + return resolvedVersion; if (IsUpgradable) return NewVersionString; @@ -346,23 +353,24 @@ private string ResolveInstallerVersion() if (GetAvailablePackage() is { } available) return available.VersionString; - return VersionString; + return listedVersion; } public async Task GetInstallerFileName() { var scheme = InstallerFileNaming.ResolveScheme(); - string version = scheme is InstallerNameScheme.PublisherName - ? "" - : ResolveInstallerVersion(); + bool versioned = scheme is not InstallerNameScheme.PublisherName; if (Manager.Name.StartsWith("PowerShell") || Manager.Name.StartsWith(".NET")) { + if (versioned && !HasConcreteVersion && !Details.IsPopulated) + await Details.Load(); + return InstallerFileNaming.Build( $"{Id}.nupkg", Name, Id, - version, + versioned ? ResolveInstallerVersion() : "", "nupkg", scheme ); @@ -377,7 +385,7 @@ private string ResolveInstallerVersion() await CoreTools.GetFileNameAsync(Details.InstallerUrl), Name, Id, - version, + versioned ? ResolveInstallerVersion() : "", Details.InstallerType, scheme ); diff --git a/src/UniGetUI.PackageEngine.Tests/DownloadOperationProgressTests.cs b/src/UniGetUI.PackageEngine.Tests/DownloadOperationProgressTests.cs index f051181fcf..5fa635b090 100644 --- a/src/UniGetUI.PackageEngine.Tests/DownloadOperationProgressTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/DownloadOperationProgressTests.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Net; +using System.Security.Cryptography; using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Interfaces; using UniGetUI.PackageEngine.Operations; @@ -401,6 +402,132 @@ await operation.InvokePerformOperationForTests() } } + private static IPackage CreatePackageWithHash(string installerHash) + { + var manager = new PackageManagerBuilder() + .ConfigureDetails(helper => + { + helper.PopulateDetails = details => + { + details.InstallerUrl = new Uri("http://127.0.0.1/payload.bin"); + details.InstallerType = "exe"; + details.InstallerHash = installerHash; + }; + }) + .Build(); + return new PackageBuilder().WithManager(manager).Build(); + } + + [Fact] + public async Task AnExistingFileMatchingThePublishedHashIsNotDownloadedAgain() + { + byte[] payload = new byte[64 * 1024]; + new Random(53).NextBytes(payload); + + IPackage package = CreatePackageWithHash( + Convert.ToHexString(SHA256.HashData(payload)).ToLowerInvariant() + ); + string downloadPath = Path.Join( + Path.GetTempPath(), + $"unigetui-already-there-{Guid.NewGuid():N}.bin" + ); + File.WriteAllBytes(downloadPath, payload); + + try + { + using var operation = new ProbeDownloadOperation( + package, + downloadPath, + new UnreachableHandler() + ); + + Assert.Equal( + OperationVeredict.Success, + await operation.InvokePerformOperationForTests() + ); + Assert.Equal(payload, File.ReadAllBytes(downloadPath)); + Assert.Contains( + operation.GetOutput(), + line => line.Item1.Contains("the download was skipped") + ); + Assert.Equal(100, Math.Round(operation.CurrentProgress.Percentage!.Value)); + } + finally + { + File.Delete(downloadPath); + } + } + + [Fact] + public async Task AnExistingFileThatDoesNotMatchThePublishedHashIsDownloadedAgain() + { + byte[] payload = new byte[64 * 1024]; + new Random(59).NextBytes(payload); + byte[] stale = new byte[64 * 1024]; + new Random(61).NextBytes(stale); + + IPackage package = CreatePackageWithHash(Convert.ToHexString(SHA256.HashData(payload))); + var handler = new FakeDownloadHandler(payload, 4096, null, TimeSpan.Zero, null); + string downloadPath = Path.Join( + Path.GetTempPath(), + $"unigetui-stale-{Guid.NewGuid():N}.bin" + ); + File.WriteAllBytes(downloadPath, stale); + + try + { + using var operation = new ProbeDownloadOperation(package, downloadPath, handler); + + Assert.Equal( + OperationVeredict.Success, + await operation.InvokePerformOperationForTests() + ); + Assert.Equal(payload, File.ReadAllBytes(downloadPath)); + } + finally + { + File.Delete(downloadPath); + } + } + + [Fact] + public async Task AnIntegrityHashFromANonSha256ManagerAlsoSkipsTheDownload() + { + byte[] payload = new byte[64 * 1024]; + new Random(67).NextBytes(payload); + + IPackage package = CreatePackageWithHash( + "sha512-" + Convert.ToBase64String(SHA512.HashData(payload)) + ); + string downloadPath = Path.Join( + Path.GetTempPath(), + $"unigetui-integrity-{Guid.NewGuid():N}.bin" + ); + File.WriteAllBytes(downloadPath, payload); + + try + { + using var operation = new ProbeDownloadOperation( + package, + downloadPath, + new UnreachableHandler() + ); + + Assert.Equal( + OperationVeredict.Success, + await operation.InvokePerformOperationForTests() + ); + Assert.Contains( + operation.GetOutput(), + line => line.Item1.Contains("the download was skipped") + ); + } + finally + { + File.Delete(downloadPath); + } + } + private sealed class UnreachableHandler : HttpMessageHandler { protected override Task SendAsync( diff --git a/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageDetailsHelper.cs b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageDetailsHelper.cs index b2b951b37a..749486207b 100644 --- a/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageDetailsHelper.cs +++ b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageDetailsHelper.cs @@ -56,6 +56,7 @@ private static void Copy(IPackageDetails source, IPackageDetails target) target.HomepageUrl = source.HomepageUrl; target.License = source.License; target.LicenseUrl = source.LicenseUrl; + target.Version = source.Version; target.InstallerUrl = source.InstallerUrl; target.InstallerHash = source.InstallerHash; target.InstallerType = source.InstallerType; diff --git a/src/UniGetUI.PackageEngine.Tests/InstallerFileNameTests.cs b/src/UniGetUI.PackageEngine.Tests/InstallerFileNameTests.cs index 84dcbcce9a..09ab0aa473 100644 --- a/src/UniGetUI.PackageEngine.Tests/InstallerFileNameTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/InstallerFileNameTests.cs @@ -2,6 +2,8 @@ using UniGetUI.Core.SettingsEngine; using UniGetUI.Core.SettingsEngine.SecureSettings; using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine.Classes.Serializable; +using UniGetUI.PackageEngine.PackageClasses; using UniGetUI.PackageEngine.PackageLoader; using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; using UniGetUI.PackageEngine.Tests.Infrastructure.Fakes; @@ -38,13 +40,47 @@ public void Dispose() Directory.Delete(_testRoot, recursive: true); } - private static TestPackageManager BuildManager(bool installerUrlFollowsPackageVersion) => + private static TestPackageManager BuildManager( + bool installerUrlFollowsPackageVersion, + string? manifestVersion = null + ) => new PackageManagerBuilder() .WithName("PowerShellTest") + .ConfigureDetails(helper => + { + helper.PopulateDetails = details => details.Version = manifestVersion; + }) .ConfigureManager(manager => manager.SetInstallerUrlFollowsPackageVersion(installerUrlFollowsPackageVersion)) .Build(); + private TestPackageManager BuildManagerReportingManifestVersion(string? manifestVersion) => + new PackageManagerBuilder() + .ConfigureDetails(helper => + { + helper.PopulateDetails = details => + { + details.InstallerUrl = new Uri(Path.Combine(_testRoot, "ContosoSetup.exe")); + details.InstallerType = "exe"; + details.Version = manifestVersion; + }; + }) + .Build(); + + private static ImportedPackage BuildImportedPackage(TestPackageManager manager) => + new( + new SerializablePackage + { + Id = "Contoso.Tool", + Name = "Contoso Tool", + Version = "1.0.0", + ManagerName = manager.Name, + Source = manager.DefaultSource.Name, + }, + manager, + manager.DefaultSource + ); + private static void InitializeLoaders() { _ = new DiscoverablePackagesLoader([]); @@ -153,6 +189,98 @@ public async Task ListedVersionIsUsedWhenNoEquivalentIsLoaded() Assert.Equal("Contoso Tool_1.0.0.nupkg", await package.GetInstallerFileName()); } + [Fact] + public async Task TheVersionReportedByTheLoadedManifestIsPreferred() + { + var manager = BuildManagerReportingManifestVersion("5.0.0"); + InitializeLoaders(); + + var package = new PackageBuilder() + .WithManager(manager) + .WithName("Contoso Tool") + .WithId("Contoso.Tool") + .WithVersion("1.0.0") + .WithNewVersion("2.0.0") + .Build(); + + Assert.Equal("Contoso Tool_5.0.0.exe", await package.GetInstallerFileName()); + } + + [Fact] + public async Task ImportedPackagesAreNamedAfterTheVersionTheManifestOffers() + { + var manager = BuildManagerReportingManifestVersion("7.2.1"); + InitializeLoaders(); + + Assert.Equal( + "Contoso Tool_7.2.1.exe", + await BuildImportedPackage(manager).GetInstallerFileName() + ); + } + + [Fact] + public async Task APlaceholderManifestVersionDoesNotSuppressTheFallbackChain() + { + var manager = BuildManagerReportingManifestVersion("Unknown"); + InitializeLoaders(); + + var package = new PackageBuilder() + .WithManager(manager) + .WithName("Contoso Tool") + .WithId("Contoso.Tool") + .WithVersion("1.0.0") + .WithNewVersion("2.0.0") + .Build(); + + Assert.Equal("Contoso Tool_2.0.0.exe", await package.GetInstallerFileName()); + } + + [Fact] + public async Task ImportedPackagesNeverCarryTheLatestPlaceholderAsAVersion() + { + var manager = BuildManagerReportingManifestVersion(null); + InitializeLoaders(); + + Assert.Equal( + "Contoso Tool.exe", + await BuildImportedPackage(manager).GetInstallerFileName() + ); + } + + [Fact] + public async Task AnUnpinnedImportIsNamedAfterTheVersionThePinnedUrlResolvedTo() + { + var manager = BuildManager( + installerUrlFollowsPackageVersion: true, + manifestVersion: "9.1.0" + ); + InitializeLoaders(); + + Assert.Equal( + "Contoso Tool_9.1.0.nupkg", + await BuildImportedPackage(manager).GetInstallerFileName() + ); + } + + [Fact] + public async Task APinnedVersionStillWinsWhenTheManagerPinsTheInstallerUrl() + { + var manager = BuildManager( + installerUrlFollowsPackageVersion: true, + manifestVersion: "9.1.0" + ); + InitializeLoaders(); + + var package = new PackageBuilder() + .WithManager(manager) + .WithName("Contoso Tool") + .WithId("Contoso.Tool") + .WithVersion("1.0.0") + .Build(); + + Assert.Equal("Contoso Tool_1.0.0.nupkg", await package.GetInstallerFileName()); + } + [Fact] public async Task PublisherNameSchemeIgnoresTheResolvedVersion() { diff --git a/src/UniGetUI.PackageEngine.Tests/NuGetManifestLoaderTests.cs b/src/UniGetUI.PackageEngine.Tests/NuGetManifestLoaderTests.cs index 576f7ba3bb..a958d14e1d 100644 --- a/src/UniGetUI.PackageEngine.Tests/NuGetManifestLoaderTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/NuGetManifestLoaderTests.cs @@ -33,6 +33,27 @@ public void GetManifestUrlAndNuPkgUrl_UsePackageSourceAndVersion() ); } + [Fact] + public void GetCacheKey_MatchesTheVersionedHashForThePackagesOwnVersion() + { + var manager = new PackageManagerBuilder().Build(); + var package = new PackageBuilder() + .WithManager(manager) + .WithId("Contoso.Tool") + .WithVersion("1.2.3") + .Build(); + + Assert.Equal(package.GetVersionedHash(), NuGetManifestLoader.GetCacheKey(package)); + Assert.Equal( + package.GetVersionedHash(), + NuGetManifestLoader.GetCacheKey(package, "1.2.3") + ); + Assert.NotEqual( + package.GetVersionedHash(), + NuGetManifestLoader.GetCacheKey(package, "2.0.0") + ); + } + [Fact] public void GetManifestContent_UsesCachedManifestWhenAvailable() { diff --git a/src/UniGetUI.PackageEngine.Tests/NuGetV3ClientTests.cs b/src/UniGetUI.PackageEngine.Tests/NuGetV3ClientTests.cs index a5aea4d3ce..fe0424976b 100644 --- a/src/UniGetUI.PackageEngine.Tests/NuGetV3ClientTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/NuGetV3ClientTests.cs @@ -1252,6 +1252,29 @@ public void VersionSelectionUsesNuGetsCaseInsensitiveLabelOrdering() Assert.Equal("1.0.0-Z", NuGetV3Client.SelectHighestVersion(["1.0.0-a", "1.0.0-Z"], true)); } + [Fact] + public void SelectHighestVersion_PrefersAStableReleaseOverAHigherPreRelease() + { + Assert.Equal( + "3.0.0", + NuGetV3Client.SelectHighestVersion( + ["2.9.0", "3.0.0", "3.1.0-preview1"], + includePreRelease: false + ) + ); + Assert.Equal( + "1.0.1", + NuGetV3Client.SelectHighestVersion(["1.0.1-beta2", "1.0.1"], includePreRelease: false) + ); + Assert.Null( + NuGetV3Client.SelectHighestVersion(["1.0.0-alpha"], includePreRelease: false) + ); + Assert.Equal( + "1.0.0-alpha", + NuGetV3Client.SelectHighestVersion(["1.0.0-alpha"], includePreRelease: true) + ); + } + [Fact] public void TheClientAndTheManagerAgreeOnLabelOrdering() { diff --git a/src/UniGetUI.PackageEngine.Tests/PublishedInstallerHashTests.cs b/src/UniGetUI.PackageEngine.Tests/PublishedInstallerHashTests.cs new file mode 100644 index 0000000000..66266adaf0 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/PublishedInstallerHashTests.cs @@ -0,0 +1,95 @@ +using System.Security.Cryptography; +using UniGetUI.PackageEngine.Operations; + +namespace UniGetUI.PackageEngine.Tests; + +public sealed class PublishedInstallerHashTests : IDisposable +{ + private readonly string _filePath; + private readonly byte[] _payload; + + public PublishedInstallerHashTests() + { + _payload = new byte[8192]; + new Random(97).NextBytes(_payload); + _filePath = Path.Join(Path.GetTempPath(), $"unigetui-hash-{Guid.NewGuid():N}.bin"); + File.WriteAllBytes(_filePath, _payload); + } + + public void Dispose() + { + if (File.Exists(_filePath)) + File.Delete(_filePath); + } + + private async Task Matches(string publishedHash) + { + Assert.True( + PublishedInstallerHash.TryParse(publishedHash, out PublishedInstallerHash parsed), + $"could not parse {publishedHash}" + ); + return await parsed.MatchesAsync(_filePath, CancellationToken.None); + } + + [Fact] + public async Task WinGetPublishesUppercaseHexSha256() + => Assert.True(await Matches(Convert.ToHexString(SHA256.HashData(_payload)))); + + [Fact] + public async Task PipAndCargoPublishLowercaseHexSha256() + => Assert.True( + await Matches(Convert.ToHexString(SHA256.HashData(_payload)).ToLowerInvariant()) + ); + + [Fact] + public async Task NpmAndBunPublishSubresourceIntegrity() + => Assert.True( + await Matches("sha512-" + Convert.ToBase64String(SHA512.HashData(_payload))) + ); + + [Fact] + public async Task TheNuGetFamilyPublishesBase64Sha512() + => Assert.True(await Matches(Convert.ToBase64String(SHA512.HashData(_payload)))); + + [Fact] + public async Task ScoopPublishesPrefixedHashes() + { + Assert.True( + await Matches("sha256:" + Convert.ToHexString(SHA256.HashData(_payload))) + ); + Assert.True(await Matches("md5:" + Convert.ToHexString(MD5.HashData(_payload)))); + Assert.True(await Matches(Convert.ToHexString(SHA1.HashData(_payload)))); + } + + [Fact] + public async Task IntegrityListsAreAcceptedThroughTheirFirstUsableEntry() + => Assert.True( + await Matches( + "sha512-" + Convert.ToBase64String(SHA512.HashData(_payload)) + + " sha256-" + Convert.ToBase64String(SHA256.HashData(_payload)) + ) + ); + + [Fact] + public async Task ADigestOfTheWrongContentDoesNotMatch() + => Assert.False(await Matches(Convert.ToHexString(SHA256.HashData([1, 2, 3, 4])))); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-a-hash")] + [InlineData("0f6ec2")] + [InlineData("whirlpool:0f6ec2eda1f8c5dc4c267ee761c0dad8")] + public void UnusableHashesAreRejected(string? publishedHash) + => Assert.False(PublishedInstallerHash.TryParse(publishedHash, out _)); + + [Fact] + public void AnAlgorithmThatDisagreesWithItsDigestLengthIsRejected() + => Assert.False( + PublishedInstallerHash.TryParse( + "sha256-" + Convert.ToBase64String(SHA512.HashData(_payload)), + out _ + ) + ); +}