From 601f37f095e1107b8585138acf8e26b3cf1cd1f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Wed, 21 Oct 2026 16:45:46 -0400 Subject: [PATCH 1/3] Replace Cargo extension dependencies with managed installation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .gitignore | 3 + docs/CLI.md | 49 +++ scripts/CargoSmoke/CargoSmoke.csproj | 19 + scripts/CargoSmoke/Program.cs | 164 +++++++ src/UniGetUI.Core.Data/Licenses.cs | 12 +- .../OverridenInstallationOptions.cs | 4 +- .../IManagedPackageOperationHelper.cs | 16 + .../Cargo.cs | 172 ++++---- .../CargoArchive.cs | 116 +++++ .../CargoBinaryResolver.cs | 282 ++++++++++++ .../CargoConfiguration.cs | 81 ++++ .../CargoHttp.cs | 89 ++++ .../CargoInstallTracker.cs | 366 ++++++++++++++++ .../CargoJson.cs | 7 + .../CargoListParsing.cs | 116 +---- .../CargoManagedInstaller.cs | 188 ++++++++ .../CargoTrackingLock.cs | 92 ++++ .../CratesIOClient.cs | 59 ++- .../Helpers/CargoPkgDetailsHelper.cs | 19 +- .../Helpers/CargoPkgOperationHelper.cs | 180 +++----- .../InternalsVisibleTo.cs | 1 + .../Tomlyn.LICENSE.txt | 23 + ...iGetUI.PackageEngine.Managers.Cargo.csproj | 4 + .../PackageOperations.cs | 26 +- .../CargoArchiveTests.cs | 162 +++++++ .../CargoBinDirectoryTests.cs | 16 +- .../CargoBinaryResolverTests.cs | 304 +++++++++++++ .../CargoClientTests.cs | 73 ++++ .../CargoConfigurationTests.cs | 94 ++++ .../CargoDetailsProvenanceTests.cs | 187 ++++++++ .../CargoHttpTests.cs | 110 +++++ .../CargoInstallTrackerTests.cs | 361 ++++++++++++++++ .../CargoInteroperabilityTests.cs | 166 +++++++ .../CargoListParsingTests.cs | 253 ++++------- .../CargoManagedInstallerTests.cs | 221 ++++++++++ .../CargoManagedOperationRoutingTests.cs | 162 +++++++ .../CargoOperationHelperTests.cs | 407 ++++++++---------- .../Helpers/CargoTestFixtures.cs | 191 ++++++++ 38 files changed, 4061 insertions(+), 734 deletions(-) create mode 100644 scripts/CargoSmoke/CargoSmoke.csproj create mode 100644 scripts/CargoSmoke/Program.cs create mode 100644 src/UniGetUI.PackageEngine.Interfaces/ManagerHelpers/IManagedPackageOperationHelper.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Cargo/CargoArchive.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Cargo/CargoBinaryResolver.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Cargo/CargoConfiguration.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Cargo/CargoHttp.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Cargo/CargoInstallTracker.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Cargo/CargoManagedInstaller.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Cargo/CargoTrackingLock.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Cargo/Tomlyn.LICENSE.txt create mode 100644 src/UniGetUI.PackageEngine.Tests/CargoArchiveTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/CargoBinaryResolverTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/CargoConfigurationTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/CargoDetailsProvenanceTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/CargoHttpTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/CargoInstallTrackerTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/CargoInteroperabilityTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/CargoManagedInstallerTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/CargoManagedOperationRoutingTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/Infrastructure/Helpers/CargoTestFixtures.cs diff --git a/.gitignore b/.gitignore index ee7a94371c..3e313b8fb0 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,9 @@ src/UniGetU*/outpublish src/UniGetUI.Pinget.Cli/bin src/UniGetUI.Pinget.Cli/obj +scripts/CargoSmoke/bin/ +scripts/CargoSmoke/obj/ + src/ExternalLibraries.*/bin src/ExternalLibraries.*/obj src/ExternalLibraries.*/out diff --git a/docs/CLI.md b/docs/CLI.md index ad3d3b1dea..541690728b 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -104,6 +104,55 @@ Related environment variables: ## Command reference +### Cargo prerequisites and installation behavior + +The Cargo manager requires Cargo, not `cargo-binstall` or `cargo-update`. UniGetUI never +installs or invokes those extensions. Existing installations of those tools remain ordinary +Cargo packages and are not removed. + +Installed packages are listed with `cargo install --list`, independently of network access. +UniGetUI checks crates.io versions itself, excluding yanked and prerelease updates by default. +Git, local-path, and other-registry packages remain listed with their source identity; they +are not matched against crates.io for updates. Their details, version catalogs, and source +downloads are not substituted with potentially unrelated same-name crates.io packages. + +Local, non-elevated installations first attempt a publisher-provided binary for the exact +selected version and Cargo host target. The C# installer supports ZIP, TAR, TAR.GZ/TGZ, and +explicitly configured raw executables. It reads supported publisher manifest metadata or +unambiguous version-specific GitHub release assets. It requires a supported SHA-256/SHA-512 +checksum (including GitHub's published asset digest); unsupported signature-only metadata +uses source compilation. No cargo-quickinstall or other third-party build host is used. +Checksums of crates.io source archives are never reused as binary checksums. + +When a compatible verified binary is unavailable, the operation log explains the fallback +to `cargo install --locked` for the same version. Custom Cargo/build options and elevated +operations also use Cargo. Compilation still requires a working Rust compiler, linker, +and any crate-specific native dependencies. A checksum mismatch, unsafe archive, or +cancellation is a failure/cancellation, not permission to silently choose another installer. +Integrity skipping and binstall-specific custom arguments are not supported. + +Custom installation locations use Cargo's **install-root** semantics: executables go into +`\bin`, with Cargo's tracking files beside that directory. This differs from binstall's +former `--install-path`, which selected the binary directory directly; review saved custom +locations when migrating. Without a custom root, `CARGO_INSTALL_ROOT`, `install.root` in the +Cargo home configuration, `CARGO_HOME`, and the user-default Cargo home determine the root +in that order. Like Cargo itself, the default installed listing does not scan arbitrary +other custom roots. + +Uninstallation remains `cargo uninstall`. Command previews, manual-install commands, and +exported install scripts use ordinary Cargo source commands; the managed binary optimization +runs only inside UniGetUI's operation pipeline. Broker-owned operations stay with the Agent: +they never fall back to a local installer after policy denial or broker failure. The Agent's +own Cargo implementation determines its execution prerequisites and binary/source behavior; +client-side helper removal does not change an externally installed Agent. + +The standalone NativeAOT interoperability check is +`dotnet publish scripts\CargoSmoke\CargoSmoke.csproj -c Release -r win-x64 --self-contained true /p:Platform=x64`, +followed by `.\scripts\CargoSmoke\bin\x64\Release\net10.0\win-x64\publish\UniGetUI.Cargo.Smoke.exe`. +It uses fixture downloads and an isolated temporary install root, verifies real Cargo +listing/uninstallation, and treats trim/AOT warnings as errors. Use the matching RID on +other hosts; this does not substitute for their native lock-contention tests. + ### Core | Command | Required options | Optional options | Notes | diff --git a/scripts/CargoSmoke/CargoSmoke.csproj b/scripts/CargoSmoke/CargoSmoke.csproj new file mode 100644 index 0000000000..40e9d19bd7 --- /dev/null +++ b/scripts/CargoSmoke/CargoSmoke.csproj @@ -0,0 +1,19 @@ + + + Exe + UniGetUI.Cargo.Smoke + net10.0 + enable + enable + true + true + false + true + true + false + false + + + + + diff --git a/scripts/CargoSmoke/Program.cs b/scripts/CargoSmoke/Program.cs new file mode 100644 index 0000000000..3732feb8d8 --- /dev/null +++ b/scripts/CargoSmoke/Program.cs @@ -0,0 +1,164 @@ +using System.Buffers.Binary; +using System.Diagnostics; +using System.Formats.Tar; +using System.IO.Compression; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using UniGetUI.PackageEngine.Managers.CargoManager; + +string root = Path.Join(Path.GetTempPath(), "unigetui-cargo-smoke-" + Guid.NewGuid().ToString("N")); +Directory.CreateDirectory(root); +Directory.CreateDirectory(Path.Join(root, "cargo-home")); +try +{ + string architecture = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "aarch64" : "x86_64"; + string target = architecture + (OperatingSystem.IsWindows() ? "-pc-windows-msvc" + : OperatingSystem.IsMacOS() ? "-apple-darwin" : "-unknown-linux-gnu"); + byte[] executable = new byte[128]; + if (OperatingSystem.IsWindows()) + { + executable[0] = (byte)'M'; + executable[1] = (byte)'Z'; + BinaryPrimitives.WriteInt32LittleEndian(executable.AsSpan(0x3C), 64); + BinaryPrimitives.WriteUInt32LittleEndian(executable.AsSpan(64), 0x4550); + BinaryPrimitives.WriteUInt16LittleEndian(executable.AsSpan(68), architecture == "aarch64" ? (ushort)0xAA64 : (ushort)0x8664); + } + else if (OperatingSystem.IsLinux()) + { + executable[0] = 0x7F; + executable[1] = (byte)'E'; + executable[2] = (byte)'L'; + executable[3] = (byte)'F'; + executable[4] = 2; + executable[5] = 1; + BinaryPrimitives.WriteUInt16LittleEndian(executable.AsSpan(18), architecture == "aarch64" ? (ushort)183 : (ushort)62); + } + else + { + BinaryPrimitives.WriteUInt32LittleEndian(executable, 0xFEEDFACF); + BinaryPrimitives.WriteUInt32LittleEndian(executable.AsSpan(4), architecture == "aarch64" ? 0x100000CU : 0x1000007U); + } + byte[] binaryArchive; + using (MemoryStream output = new()) + { + using (ZipArchive zip = new(output, ZipArchiveMode.Create, leaveOpen: true)) + using (Stream entry = zip.CreateEntry("smoke" + (OperatingSystem.IsWindows() ? ".exe" : "")).Open()) + entry.Write(executable); + binaryArchive = output.ToArray(); + } + byte[] sourceArchive; + using (MemoryStream output = new()) + { + using (GZipStream gzip = new(output, CompressionLevel.SmallestSize, leaveOpen: true)) + using (TarWriter tar = new(gzip)) + { + var manifestEntry = new PaxTarEntry(TarEntryType.RegularFile, "unigetui-smoke/ignored"); + manifestEntry.Name = "unigetui-smoke-1.0.0/Cargo.toml"; + manifestEntry.DataStream = new MemoryStream(Encoding.UTF8.GetBytes(""" + [package] + name = "unigetui-smoke" + version = "1.0.0" + [package.metadata.binstall] + pkg-url = "https://publisher.example/{version}/smoke.zip" + pkg-fmt = "zip" + """)); + tar.WriteEntry(manifestEntry); + } + sourceArchive = output.ToArray(); + } + var version = new CargoManifestVersion + { + num = "1.0.0", + checksum = Convert.ToHexString(SHA256.HashData(sourceArchive)), + dl_path = "/crates/unigetui-smoke/1.0.0/download", + bin_names = ["smoke"], + }; + var manifest = new CargoManifest + { + crate = new CargoManifestCrate + { + name = "unigetui-smoke", + max_stable_version = "1.0.0", + max_version = "1.0.0", + newest_version = "1.0.0", + }, + versions = [version], + }; + CargoHttp.TestHandlerFactory = () => new FixtureHandler(sourceArchive, binaryArchive); + using CargoHttp http = new(); + CargoBinaryPlan plan = await new CargoBinaryResolver(http).ResolveAsync( + "unigetui-smoke", "1.0.0", target, manifest, Console.WriteLine, CancellationToken.None) + ?? throw new InvalidOperationException("Published binary resolution unexpectedly selected fallback."); + CargoBinaryResolver.VerifyHash(binaryArchive, plan.Hash); + var binaries = CargoManagedInstaller.ExtractBinaries(binaryArchive, plan, CancellationToken.None); + await CargoInstallTracker.InstallAsync(root, plan, binaries, CancellationToken.None); + string listing = await Cargo("install", "--list", "--root", root); + if (!listing.Contains("unigetui-smoke v1.0.0:", StringComparison.Ordinal)) + throw new InvalidOperationException("Real Cargo did not recognize native installation tracking."); + string fixture = Path.Join(root, "source"); + Directory.CreateDirectory(Path.Join(fixture, "src")); + File.WriteAllText(Path.Join(fixture, "Cargo.toml"), """ + [package] + name = "unigetui-smoke" + version = "1.0.1" + edition = "2021" + [[bin]] + name = "smoke" + path = "src/main.rs" + """); + File.WriteAllText(Path.Join(fixture, "src", "main.rs"), """fn main() { println!("cargo-smoke"); }"""); + await Cargo("generate-lockfile", "--manifest-path", Path.Join(fixture, "Cargo.toml"), "--offline"); + await Cargo("install", "--path", fixture, "--root", root, "--target", target, "--locked", "--offline"); + if (!(await Cargo("install", "--list", "--root", root)).Contains("unigetui-smoke v1.0.1", StringComparison.Ordinal)) + throw new InvalidOperationException("Cargo could not upgrade the native-installed package from source."); + await Cargo("uninstall", "--root", root, "unigetui-smoke"); + if ((await Cargo("install", "--list", "--root", root)).Contains("unigetui-smoke", StringComparison.Ordinal)) + throw new InvalidOperationException("Real Cargo did not remove the native-installed package."); + Console.WriteLine("PASS: published native resolver, TOML/JSON, integrity, extraction, tracking, Cargo list/source upgrade/uninstall."); +} +finally +{ + CargoHttp.TestHandlerFactory = null; + Directory.Delete(root, recursive: true); +} + +async Task Cargo(params string[] arguments) +{ + using Process process = new(); + process.StartInfo = new ProcessStartInfo("cargo") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + process.StartInfo.Environment["CARGO_HOME"] = Path.Join(root, "cargo-home"); + foreach (string argument in arguments) + process.StartInfo.ArgumentList.Add(argument); + process.Start(); + Task stdout = process.StandardOutput.ReadToEndAsync(); + Task stderr = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + if (process.ExitCode != 0) + throw new InvalidOperationException("Cargo smoke operation failed: " + await stderr); + return await stdout; +} + +sealed class FixtureHandler(byte[] source, byte[] binary) : HttpMessageHandler +{ + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + byte[]? content = request.RequestUri?.AbsoluteUri switch + { + "https://crates.io/api/v1/crates/unigetui-smoke/1.0.0/download" => source, + "https://publisher.example/1.0.0/smoke.zip.sha256" => + Encoding.ASCII.GetBytes(Convert.ToHexString(SHA256.HashData(binary)) + " smoke.zip"), + _ => null, + }; + return Task.FromResult(new HttpResponseMessage(content is null ? HttpStatusCode.NotFound : HttpStatusCode.OK) + { + Content = new ByteArrayContent(content ?? []), + }); + } +} diff --git a/src/UniGetUI.Core.Data/Licenses.cs b/src/UniGetUI.Core.Data/Licenses.cs index 4474dd8647..48d47823e7 100644 --- a/src/UniGetUI.Core.Data/Licenses.cs +++ b/src/UniGetUI.Core.Data/Licenses.cs @@ -12,6 +12,7 @@ public static class LicenseData { "Windows App Sdk", "MIT" }, { "PhotoSauce.MagicScaler", "MIT" }, { "YamlDotNet", "MIT" }, + { "Tomlyn", "BSD-2-Clause" }, { "InnoDependencyInstaller", "CPOL 1.02" }, // Package managers and related { "WinGet", "MIT" }, @@ -24,8 +25,6 @@ public static class LicenseData { "PowerShell Gallery", "Unknown" }, { ".NET SDK", "MIT" }, { "Cargo", "MIT" }, - { "cargo-binstall", "GPL-3.0-only" }, - { "cargo-update", "MIT" }, { "vcpkg", "MIT" }, // Other { "GSudo", "MIT" }, @@ -59,6 +58,7 @@ public static class LicenseData "YamlDotNet", new Uri("https://github.com/aaubry/YamlDotNet/blob/master/LICENSE.txt") }, + { "Tomlyn", new Uri("https://github.com/xoofx/tomlyn/blob/main/license.txt") }, { "InnoDependencyInstaller", new Uri( @@ -84,11 +84,6 @@ public static class LicenseData { ".NET SDK", new Uri("https://github.com/dotnet/sdk/blob/main/LICENSE.TXT") }, { "PowerShell Gallery", new Uri("https://www.powershellgallery.com/") }, { "Cargo", new Uri("https://github.com/rust-lang/cargo/blob/master/LICENSE-MIT") }, - { "cargo-binstall", new Uri("https://spdx.org/licenses/GPL-3.0-only.html") }, - { - "cargo-update", - new Uri("https://github.com/nabijaczleweli/cargo-update/blob/master/LICENSE") - }, { "vcpkg", new Uri("https://github.com/microsoft/vcpkg/blob/master/LICENSE.txt") }, // Other { "GSudo", new Uri("https://github.com/gerardog/gsudo/blob/master/LICENSE.txt") }, @@ -110,6 +105,7 @@ public static class LicenseData { "Windows App Sdk", new Uri("https://github.com/microsoft/WindowsAppSDK/") }, { "PhotoSauce.MagicScaler", new Uri("https://github.com/saucecontrol/PhotoSauce/") }, { "YamlDotNet", new Uri("https://github.com/aaubry/YamlDotNet/") }, + { "Tomlyn", new Uri("https://github.com/xoofx/tomlyn/") }, { "InnoDependencyInstaller", new Uri("https://github.com/DomGries/InnoDependencyInstaller") @@ -128,8 +124,6 @@ public static class LicenseData { ".NET SDK", new Uri("https://dotnet.microsoft.com/") }, { "PowerShell Gallery", new Uri("https://www.powershellgallery.com/") }, { "Cargo", new Uri("https://github.com/rust-lang/cargo") }, - { "cargo-binstall", new Uri("https://github.com/cargo-bins/cargo-binstall") }, - { "cargo-update", new Uri("https://github.com/nabijaczleweli/cargo-update/") }, { "vcpkg", new Uri("https://github.com/microsoft/vcpkg") }, // Other { "GSudo", new Uri("https://github.com/gerardog/gsudo/") }, diff --git a/src/UniGetUI.PackageEngine.Enums/OverridenInstallationOptions.cs b/src/UniGetUI.PackageEngine.Enums/OverridenInstallationOptions.cs index 5b7a01178d..979c9e2b0e 100644 --- a/src/UniGetUI.PackageEngine.Enums/OverridenInstallationOptions.cs +++ b/src/UniGetUI.PackageEngine.Enums/OverridenInstallationOptions.cs @@ -11,8 +11,6 @@ public struct OverridenInstallationOptions public bool Pip_BreakSystemPackages = false; public bool WinGet_DropArchAndScope = false; public bool WinGet_UseLocalIdentifier = false; - public bool Cargo_DoNotUseBinstall = false; - public bool Cargo_CustomInstallPathRequested = false; public OverridenInstallationOptions(string? scope = null, bool? runAsAdministrator = null) { @@ -22,6 +20,6 @@ public OverridenInstallationOptions(string? scope = null, bool? runAsAdministrat public override string ToString() { - return $""; + return $""; } } diff --git a/src/UniGetUI.PackageEngine.Interfaces/ManagerHelpers/IManagedPackageOperationHelper.cs b/src/UniGetUI.PackageEngine.Interfaces/ManagerHelpers/IManagedPackageOperationHelper.cs new file mode 100644 index 0000000000..6846d8030b --- /dev/null +++ b/src/UniGetUI.PackageEngine.Interfaces/ManagerHelpers/IManagedPackageOperationHelper.cs @@ -0,0 +1,16 @@ +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Serializable; + +namespace UniGetUI.PackageEngine.Interfaces.ManagerProviders; + +/// An optional local execution path; null selects the ordinary manager process. +public interface IManagedPackageOperationHelper +{ + Task TryPerformAsync( + IPackage package, + InstallOptions options, + OperationType operation, + Action log, + Action progress, + CancellationToken cancellationToken); +} diff --git a/src/UniGetUI.PackageEngine.Managers.Cargo/Cargo.cs b/src/UniGetUI.PackageEngine.Managers.Cargo/Cargo.cs index 5dd97d8354..950f6c55e1 100644 --- a/src/UniGetUI.PackageEngine.Managers.Cargo/Cargo.cs +++ b/src/UniGetUI.PackageEngine.Managers.Cargo/Cargo.cs @@ -38,14 +38,6 @@ string userProfileDirectory return directories; } - internal static bool IsCargoBinaryPresent(string binaryName) => - CoreTools.Which(binaryName).Item1 - || GetCargoBinDirectories( - Environment.GetEnvironmentVariable, - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) - ) - .Any(directory => IsExecutableFile(Path.Join(directory, binaryName))); - private static bool IsExecutableFile(string path) { if (!File.Exists(path)) @@ -77,51 +69,17 @@ private static bool IsExecutableFile(string path) public Cargo() { - string cargoCommand = OperatingSystem.IsWindows() ? "cargo.exe" : "cargo"; - string cargoUpdateBinary = OperatingSystem.IsWindows() - ? "cargo-install-update.exe" - : "cargo-install-update"; - string cargoBinstallBinary = OperatingSystem.IsWindows() - ? "cargo-binstall.exe" - : "cargo-binstall"; - - string CargoPath() => - Status.ExecutablePath is { Length: > 0 } path ? path : cargoCommand; - - Dependencies = - [ - // Cargo-binstall is required to install and update cargo binaries - new ManagerDependency( - "cargo-binstall", - cargoCommand, - "install cargo-binstall --locked", - "cargo install cargo-binstall --locked", - async () => await Task.Run(() => IsCargoBinaryPresent(cargoBinstallBinary)), - () => (CargoPath(), "install cargo-binstall --locked") - ), - // cargo-update is required to check for installed and upgradable packages - new ManagerDependency( - "cargo-update", - cargoCommand, - "install cargo-update --locked", - "cargo install cargo-update --locked", - async () => await Task.Run(() => IsCargoBinaryPresent(cargoUpdateBinary)), - () => IsCargoBinaryPresent(cargoBinstallBinary) - ? (CargoPath(), "binstall --no-confirm cargo-update") - : (CargoPath(), "install cargo-update --locked") - ), - ]; - Capabilities = new ManagerCapabilities { CanRunAsAdmin = true, - CanSkipIntegrityChecks = true, + CanSkipIntegrityChecks = false, SupportsCustomVersions = true, SupportsCustomLocations = true, CanDownloadInstaller = true, SupportsProxy = ProxySupport.Partially, SupportsProxyAuth = true, KnowsPackageReleaseDate = PackageReleaseDateSupport.Yes, + SerializesOperations = true, }; var cratesIo = new ManagerSource(this, "crates.io", new Uri("https://index.crates.io/")); @@ -136,9 +94,9 @@ string CargoPath() => IconId = IconType.Rust, ColorIconId = "cargo_color", ExecutableFriendlyName = "cargo.exe", - InstallVerb = "binstall", + InstallVerb = "install", UninstallVerb = "uninstall", - UpdateVerb = "binstall", + UpdateVerb = "install", DefaultSource = cratesIo, KnownSources = [cratesIo], }; @@ -176,8 +134,6 @@ protected override IReadOnlyList FindPackages_UnSafe(string query) for (int i = 0; i < Packages.Count; i++) { - DateTime startTime = DateTime.Now; - var package = Packages[i]; try { @@ -195,12 +151,6 @@ protected override IReadOnlyList FindPackages_UnSafe(string query) BinPackages.Add(package); } - if (i + 1 == Packages.Count) - break; - // Crates.io requires no more than one request per second - Task.Delay(Math.Max(0, 1000 - (int)(DateTime.Now - startTime).TotalMilliseconds)) - .GetAwaiter() - .GetResult(); } logger.Close(p.ExitCode); @@ -218,11 +168,16 @@ protected override IReadOnlyList GetInstalledPackages_UnSafe() return GetPackages(LoggableTaskType.ListInstalledPackages); } - public readonly bool HasBinstall = - IsCargoBinaryPresent(OperatingSystem.IsWindows() ? "cargo-binstall.exe" : "cargo-binstall"); - - public override IReadOnlyList FindCandidateExecutableFiles() => - CoreTools.WhichMultiple(OperatingSystem.IsWindows() ? "cargo.exe" : "cargo"); + public override IReadOnlyList FindCandidateExecutableFiles() + { + string name = OperatingSystem.IsWindows() ? "cargo.exe" : "cargo"; + return CoreTools.WhichMultiple(name) + .Concat(GetCargoBinDirectories(Environment.GetEnvironmentVariable, + CargoConfiguration.WorkingDirectory).Select(directory => Path.Join(directory, name)) + .Where(IsExecutableFile)) + .Distinct(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal) + .ToArray(); + } protected override void _loadManagerExecutableFile( out bool found, @@ -257,8 +212,11 @@ protected override void _loadManagerVersion(out string version) Logger.Error("cargo version error: " + error); } - public void InvalidateInstalledCache() => + public void InvalidateInstalledCache() + { TaskRecycler>.RemoveFromCache(GetInstalledCommandOutput); + CratesIOClient.InvalidateCache(); + } private IReadOnlyList GetPackages(LoggableTaskType taskType) { @@ -267,19 +225,32 @@ private IReadOnlyList GetPackages(LoggableTaskType taskType) foreach (var entry in entries) { var name = CoreTools.FormatAsName(entry.Id); + var source = DefaultSource; + if (!entry.IsCratesIo && entry.SourceDescription is { } description) + { + string location = description.StartsWith("registry+", StringComparison.Ordinal) + ? description["registry+".Length..] + : description.StartsWith("git+", StringComparison.Ordinal) + ? description["git+".Length..] : description; + if (!Uri.TryCreate(location, UriKind.Absolute, out Uri? sourceUrl)) + throw new InvalidDataException($"Unrecognized Cargo package source: {description}"); + source = new ManagerSource(this, description, sourceUrl); + } if (taskType is LoggableTaskType.ListUpdates) { - if ( - entry.NeedsUpdate - && entry.LatestVersion is { Length: > 0 } latestVersion - && latestVersion != entry.InstalledVersion - ) + if (!entry.IsCratesIo) + { + Logger.Debug($"Skipping crates.io update detection for {entry.Id} from {entry.SourceDescription}."); + continue; + } + var (_, manifest) = CratesIOClient.GetManifest(entry.Id); + if (CratesIOClient.SelectUpdate(manifest, entry.InstalledVersion) is { } update) Packages.Add( new Package( name, entry.Id, entry.InstalledVersion, - latestVersion, + update.num, DefaultSource, this ) @@ -287,7 +258,7 @@ private IReadOnlyList GetPackages(LoggableTaskType taskType) } else if (taskType is LoggableTaskType.ListInstalledPackages) Packages.Add( - new Package(name, entry.Id, entry.InstalledVersion, DefaultSource, this) + new Package(name, entry.Id, entry.InstalledVersion, source, this) ); } return Packages; @@ -296,10 +267,12 @@ private IReadOnlyList GetPackages(LoggableTaskType taskType) private List GetInstalledCommandOutput() { List stdout = []; - using Process p = GetProcess(Status.ExecutablePath, "install-update --list"); + CargoInstallTracker.RecoverAsync(CargoConfiguration.GetRoot(), CancellationToken.None) + .GetAwaiter().GetResult(); + using Process p = GetProcess(Status.ExecutablePath, "install --list"); IProcessTaskLogger logger = TaskLogger.CreateNew(LoggableTaskType.OtherTask, p); - logger.AddToStdOut("Other task: Call the install-update command"); p.Start(); + Task stderr = p.StandardError.ReadToEndAsync(); string? line; while ((line = p.StandardOutput.ReadLine()) is not null) @@ -307,37 +280,43 @@ private List GetInstalledCommandOutput() logger.AddToStdOut(line); stdout.Add(line); } - logger.AddToStdErr(p.StandardError.ReadToEnd()); + logger.AddToStdErr(stderr.GetAwaiter().GetResult()); p.WaitForExit(); - - List skippedRows = []; - var output = ParseInstallUpdateList(stdout, skippedRows); - foreach (var skippedRow in skippedRows) - logger.AddToStdErr($"Ignored unrecognized `install-update --list` row: {skippedRow}"); logger.Close(p.ExitCode); + if (p.ExitCode != 0) + throw new InvalidOperationException($"Cargo installed-package listing failed with exit code {p.ExitCode}."); + return ParseInstallList(stdout); + } - if (output.Count > 0) - return output; - - List fallbackStdout = []; - using Process fallback = GetProcess(Status.ExecutablePath, "install --list"); - IProcessTaskLogger fallbackLogger = TaskLogger.CreateNew( - LoggableTaskType.OtherTask, - fallback - ); - fallbackLogger.AddToStdOut( - "Falling back to `cargo install --list` (cargo-update reported no packages)" - ); - fallback.Start(); - while ((line = fallback.StandardOutput.ReadLine()) is not null) + private string? _hostTarget; + + internal async Task GetHostTargetAsync(CancellationToken cancellationToken) + { + if (_hostTarget is not null) + return _hostTarget; + using Process process = GetProcess(Status.ExecutablePath, "--version --verbose"); + process.Start(); + Task stdout = process.StandardOutput.ReadToEndAsync(cancellationToken); + Task stderr = process.StandardError.ReadToEndAsync(cancellationToken); + using var registration = cancellationToken.Register(() => { - fallbackLogger.AddToStdOut(line); - fallbackStdout.Add(line); - } - fallbackLogger.AddToStdErr(fallback.StandardError.ReadToEnd()); - fallback.WaitForExit(); - fallbackLogger.Close(fallback.ExitCode); - return ParseInstallList(fallbackStdout); + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException ex) + { + Logger.Debug($"Cargo host detection already exited during cancellation: {ex.Message}"); + } + }); + await process.WaitForExitAsync(cancellationToken); + string error = await stderr; + if (process.ExitCode != 0) + throw new InvalidOperationException($"Cargo host detection failed: {error}"); + _hostTarget = (await stdout).Split('\n') + .FirstOrDefault(line => line.StartsWith("host: ", StringComparison.Ordinal))?["host: ".Length..].Trim(); + return _hostTarget; } private Process GetProcess(string fileName, string extraArguments) @@ -354,6 +333,7 @@ private Process GetProcess(string fileName, string extraArguments) CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8, + WorkingDirectory = CargoConfiguration.WorkingDirectory, }, }; } diff --git a/src/UniGetUI.PackageEngine.Managers.Cargo/CargoArchive.cs b/src/UniGetUI.PackageEngine.Managers.Cargo/CargoArchive.cs new file mode 100644 index 0000000000..a8bf2507f5 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Cargo/CargoArchive.cs @@ -0,0 +1,116 @@ +using System.Formats.Tar; +using System.IO.Compression; + +namespace UniGetUI.PackageEngine.Managers.CargoManager; + +internal static class CargoArchive +{ + internal const long ExpandedLimit = 512L * 1024 * 1024; + internal const int EntryLimit = 10000; + + internal static string? Format(string name) => + name.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase) ? "tgz" + : name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) ? "zip" + : name.EndsWith(".tar", StringComparison.OrdinalIgnoreCase) ? "tar" + : null; + + internal static Dictionary Read(byte[] bytes, string format, + Func select, CancellationToken cancellationToken, + long selectedEntryLimit = ExpandedLimit) + { + Dictionary selected = new(StringComparer.Ordinal); + HashSet paths = new(StringComparer.OrdinalIgnoreCase); + long expanded = 0; + int count = 0; + using MemoryStream input = new(bytes, writable: false); + + void Entry(string rawName, Stream? content, long declaredSize, bool directory) + { + cancellationToken.ThrowIfCancellationRequested(); + if (++count > EntryLimit) + throw new InvalidDataException("Cargo archive contains too many entries."); + while (rawName.StartsWith("./", StringComparison.Ordinal)) + rawName = rawName[2..]; + if (directory && rawName.TrimEnd('/').Length == 0) + return; + string name = ValidateName(rawName.TrimEnd('/')); + if (!paths.Add(name)) + throw new InvalidDataException($"Duplicate Cargo archive entry: {name}"); + if (declaredSize < 0 || declaredSize > ExpandedLimit - expanded) + throw new InvalidDataException("Cargo archive expands beyond the allowed size."); + if (directory || content is null) + return; + bool keep = select(name); + if (keep && declaredSize > selectedEntryLimit) + throw new InvalidDataException("Selected Cargo archive entry exceeds the allowed size."); + using MemoryStream? output = keep ? new() : null; + byte[] buffer = new byte[64 * 1024]; + long size = 0; + int read; + while ((read = content.Read(buffer)) != 0) + { + cancellationToken.ThrowIfCancellationRequested(); + size += read; + expanded += read; + if (expanded > ExpandedLimit || (keep && size > selectedEntryLimit)) + throw new InvalidDataException("Cargo archive expands beyond the allowed size."); + output?.Write(buffer, 0, read); + } + if (size != declaredSize) + throw new InvalidDataException($"Truncated Cargo archive entry: {name}"); + if (output is not null) + selected.Add(name, output.ToArray()); + } + + if (format == "zip") + { + using ZipArchive archive = new(input, ZipArchiveMode.Read); + foreach (ZipArchiveEntry entry in archive.Entries) + { + int unixType = (entry.ExternalAttributes >> 16) & 0xF000; + if (unixType is not (0 or 0x8000 or 0x4000) + || (entry.ExternalAttributes & (int)FileAttributes.ReparsePoint) != 0) + throw new InvalidDataException("Cargo archive contains a link or special file."); + using Stream content = entry.Open(); + Entry(entry.FullName, content, entry.Length, entry.FullName.EndsWith('/')); + } + } + else if (format is "tgz" or "tar") + { + using Stream decompressed = format == "tgz" + ? new GZipStream(input, CompressionMode.Decompress, leaveOpen: true) + : input; + using TarReader reader = new(decompressed); + TarEntry? entry; + while ((entry = reader.GetNextEntry()) is not null) + { + bool directory = entry.EntryType == TarEntryType.Directory; + if (!directory && entry.EntryType is not (TarEntryType.RegularFile or TarEntryType.V7RegularFile)) + throw new InvalidDataException("Cargo archive contains a link or special file."); + Entry(entry.Name, entry.DataStream, entry.Length, directory); + } + } + else + throw new NotSupportedException($"Unsupported Cargo archive format: {format}"); + return selected; + } + + internal static string ValidateName(string name) + { + if (string.IsNullOrWhiteSpace(name) || name.Contains('\\') || name.Contains(':') + || name.StartsWith('/') || name.Any(c => c < 32) + || name.Split('/').Any(part => part is "" or "." or ".." + || part.EndsWith(' ') || part.EndsWith('.') || IsDeviceName(part))) + throw new InvalidDataException($"Unsafe Cargo archive path: {name}"); + return name; + } + + internal static bool IsDeviceName(string name) + { + string stem = name.Split('.')[0].ToUpperInvariant(); + return stem is "CON" or "PRN" or "AUX" or "NUL" + || (stem.Length == 4 && (stem.StartsWith("COM", StringComparison.Ordinal) + || stem.StartsWith("LPT", StringComparison.Ordinal)) && stem[3] is >= '1' and <= '9'); + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.Cargo/CargoBinaryResolver.cs b/src/UniGetUI.PackageEngine.Managers.Cargo/CargoBinaryResolver.cs new file mode 100644 index 0000000000..d84910ea9e --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Cargo/CargoBinaryResolver.cs @@ -0,0 +1,282 @@ +using System.Security.Cryptography; +using System.Text; +using Tomlyn.Model; +using UniGetUI.Core.Tools; + +namespace UniGetUI.PackageEngine.Managers.CargoManager; + +internal sealed record CargoBinaryPlan( + string Id, string Version, string Target, string[] Binaries, Uri Url, + string Format, string Hash, string? BinaryPath); + +internal sealed record CargoRelease +{ + public string? tag_name { get; init; } + public bool draft { get; init; } + public CargoReleaseAsset[] assets { get; init; } = []; +} + +internal sealed record CargoReleaseAsset +{ + public required string name { get; init; } + public required string browser_download_url { get; init; } + public string? digest { get; init; } +} + +internal sealed class CargoBinaryResolver(CargoHttp http) +{ + internal static bool IsSupportedTarget(string target) => + (target.StartsWith("x86_64-", StringComparison.Ordinal) + || target.StartsWith("aarch64-", StringComparison.Ordinal)) + && (target.EndsWith("-pc-windows-msvc", StringComparison.Ordinal) + || target.EndsWith("-unknown-linux-gnu", StringComparison.Ordinal) + || target.EndsWith("-unknown-linux-musl", StringComparison.Ordinal) + || target.EndsWith("-apple-darwin", StringComparison.Ordinal)); + + internal async Task ResolveAsync(string id, string version, string target, + CargoManifest manifest, Action log, CancellationToken cancellationToken) + { + if (!IsSupportedTarget(target)) + return Fallback("The Cargo host target is not supported by the binary installer.", log); + CargoManifestVersion selected = manifest.versions.Single(v => v.num == version); + if (selected.bin_names is not { Length: > 0 } binaries) + return Fallback("The published crate has no known executable names.", log); + foreach (string binary in binaries) + ValidateBinaryName(binary); + Uri crateUrl = new($"https://crates.io/api/v1/crates/{Uri.EscapeDataString(id)}/{Uri.EscapeDataString(version)}/download"); + await CargoHttp.PaceApiAsync(cancellationToken); + byte[] source = await http.GetAsync(crateUrl, CargoHttp.ArchiveLimit, false, cancellationToken) + ?? throw new InvalidDataException("The published Cargo source archive is missing."); + VerifyHash(source, "sha256:" + selected.checksum); + string manifestPath = $"{id}-{version}/Cargo.toml"; + var files = CargoArchive.Read(source, "tgz", name => name == manifestPath, cancellationToken, + CargoHttp.ManifestLimit); + if (!files.TryGetValue(manifestPath, out byte[]? manifestBytes) + || manifestBytes.Length > CargoHttp.ManifestLimit) + throw new InvalidDataException("The Cargo source archive has no valid package manifest."); + TomlTable document = CargoConfiguration.Parse(Encoding.UTF8.GetString(manifestBytes)); + TomlTable package = CargoConfiguration.Table(document, "package") + ?? throw new InvalidDataException("Cargo manifest has no package table."); + if (CargoConfiguration.String(package, "name") != id + || CargoConfiguration.String(package, "version") != version) + throw new InvalidDataException("Cargo source manifest does not match the selected package."); + string? repository = CargoConfiguration.String(package, "repository"); + TomlTable? metadata = CargoConfiguration.Table(package, "metadata"); + TomlTable? binstall = metadata is null ? null : CargoConfiguration.Table(metadata, "binstall"); + if (binstall is not null) + { + if (binstall.ContainsKey("signing")) + return Fallback("Publisher signing metadata requires an unsupported signature verifier.", log); + if (binstall.TryGetValue("disabled-strategies", out object? disabled) + && disabled is TomlArray strategies && strategies.OfType().Contains("crate-meta-data")) + return Fallback("The publisher disabled release-binary installation.", log); + if (CargoConfiguration.Table(binstall, "overrides") is { } overrides) + { + if (overrides.Keys.Any(key => key.StartsWith("cfg(", StringComparison.Ordinal))) + return Fallback("The publisher uses unsupported cfg target overrides.", log); + if (CargoConfiguration.Table(overrides, target) is { } targetOptions) + { + var merged = new TomlTable(); + foreach (var item in binstall) + merged[item.Key] = item.Value; + foreach (var item in targetOptions) + merged[item.Key] = item.Value; + binstall = merged; + } + if (binstall.ContainsKey("signing") + || binstall.Keys.Any(key => key is not ("pkg-url" or "pkg-fmt" or "bin-dir" + or "overrides" or "disabled-strategies"))) + return Fallback("The publisher uses unsupported binary installation metadata.", log); + } + } + + string? template = binstall is null ? null : CargoConfiguration.String(binstall, "pkg-url"); + string? binaryPath = binstall is null ? null : CargoConfiguration.String(binstall, "bin-dir"); + string? configuredFormat = binstall is null ? null : CargoConfiguration.String(binstall, "pkg-fmt"); + if (template is not null) + { + if (!template.Replace(" ", "").Contains("{version}", StringComparison.Ordinal)) + return Fallback("The publisher binary URL is not pinned to the package version.", log); + string? expanded = Expand(template, id, version, target, repository, configuredFormat, null); + if (expanded is null || !Uri.TryCreate(expanded, UriKind.Absolute, out Uri? url) + || url.Scheme != Uri.UriSchemeHttps) + return Fallback("The publisher binary URL uses unsupported metadata.", log); + string? format = configuredFormat ?? CargoArchive.Format(url.AbsolutePath); + if (!SupportedFormat(format)) + return Fallback("The publisher binary archive format is unsupported.", log); + string? hash = await ReadSidecarAsync(url, cancellationToken); + return hash is null ? Fallback("The publisher binary has no supported checksum.", log) + : new(id, version, target, binaries, url, format!, hash, binaryPath); + } + + if (!TryGitHubRepository(repository, out string? ownerAndRepo)) + return Fallback("The publisher has no supported release repository or explicit binary URL.", log); + foreach (string tag in new[] { $"v{version}", version }) + { + Uri releaseUrl = new($"https://api.github.com/repos/{ownerAndRepo}/releases/tags/{Uri.EscapeDataString(tag)}"); + byte[]? json = await http.GetAsync(releaseUrl, CargoHttp.ManifestLimit, true, cancellationToken); + if (json is null) + continue; + CargoRelease release = CargoJson.Deserialize(Encoding.UTF8.GetString(json)) + ?? throw new InvalidDataException("The publisher returned an empty release."); + if (release.draft || release.tag_name != tag) + throw new InvalidDataException("The publisher release does not match the selected version."); + var assets = release.assets.Where(asset => MatchesAsset(asset.name, id, version, target, binaries)).ToArray(); + if (assets.Length > 1) + return Fallback("Several publisher binaries match; refusing to guess.", log); + if (assets.Length == 0) + continue; + var asset = assets[0]; + Uri assetUrl = new(asset.browser_download_url); + string? hash = ValidHash(asset.digest) ? asset.digest : null; + if (hash is null) + { + foreach (var checksum in release.assets.Where(a => + a.name.Equals(asset.name + ".sha256", StringComparison.OrdinalIgnoreCase) + || a.name.Equals(asset.name + ".sha512", StringComparison.OrdinalIgnoreCase) + || a.name.Equals("SHA256SUMS", StringComparison.OrdinalIgnoreCase) + || a.name.Equals("SHA512SUMS", StringComparison.OrdinalIgnoreCase) + || a.name.Equals("checksums.txt", StringComparison.OrdinalIgnoreCase))) + { + byte[]? text = await http.GetAsync(new Uri(checksum.browser_download_url), + CargoHttp.ManifestLimit, false, cancellationToken); + hash = ParseChecksum(Encoding.UTF8.GetString(text!), asset.name); + if (hash is not null) + break; + } + } + if (hash is null) + hash = await ReadSidecarAsync(assetUrl, cancellationToken); + return hash is null ? Fallback("The publisher binary has no supported checksum.", log) + : new(id, version, target, binaries, assetUrl, CargoArchive.Format(asset.name)!, hash, binaryPath); + } + return Fallback("No compatible publisher binary is available for this version and target.", log); + } + + private static CargoBinaryPlan? Fallback(string reason, Action log) + { + log(reason + " Using Cargo to build from source."); + return null; + } + + internal static bool MatchesAsset(string name, string id, string version, string target, string[] binaries) + { + string? format = CargoArchive.Format(name); + if (format is null) + return false; + int suffixLength = name.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase) ? 7 : 4; + string stem = name[..^suffixLength]; + foreach (string prefix in binaries.Append(id).Distinct(StringComparer.Ordinal)) + { + foreach (string separator in new[] { "-", "_" }) + { + foreach (string tag in new[] { version, "v" + version }) + { + if (stem == $"{prefix}{separator}{tag}{separator}{target}" + || stem == $"{prefix}{separator}{target}{separator}{tag}") + return true; + } + } + } + return false; + } + + private static bool TryGitHubRepository(string? repository, out string? ownerAndRepo) + { + ownerAndRepo = null; + if (!Uri.TryCreate(repository, UriKind.Absolute, out Uri? uri) + || uri.Scheme != Uri.UriSchemeHttps || uri.Host != "github.com") + return false; + string[] parts = uri.AbsolutePath.Trim('/').Split('/'); + if (parts.Length != 2 || parts.Any(part => part.Length == 0 || part.Any(c => !char.IsAsciiLetterOrDigit(c) && c is not ('-' or '_' or '.')))) + return false; + string repo = parts[1].EndsWith(".git", StringComparison.Ordinal) ? parts[1][..^4] : parts[1]; + ownerAndRepo = parts[0] + "/" + repo; + return true; + } + + internal static string? Expand(string template, string id, string version, string target, + string? repository, string? format, string? binary) + { + var variables = new Dictionary + { + ["name"] = id, + ["version"] = version, + ["target"] = target, + ["repo"] = repository?.TrimEnd('/'), + ["bin"] = binary, + ["binary-ext"] = target.Contains("-windows-") ? ".exe" : "", + ["archive-suffix"] = format is null ? null : format == "bin" + ? target.Contains("-windows-", StringComparison.Ordinal) ? ".exe" : "" + : format == "tgz" ? ".tar.gz" : "." + format, + ["archive-format"] = format, + ["format"] = format, + }; + string result = template; + foreach (var variable in variables) + { + if (variable.Value is null) + continue; + result = result.Replace("{ " + variable.Key + " }", variable.Value, StringComparison.Ordinal) + .Replace("{" + variable.Key + "}", variable.Value, StringComparison.Ordinal); + } + return result.Contains('{') || result.Contains('}') ? null : result; + } + + private static bool SupportedFormat(string? format) => format is "zip" or "tgz" or "tar" or "bin"; + + private async Task ReadSidecarAsync(Uri url, CancellationToken cancellationToken) + { + foreach (string suffix in new[] { ".sha256", ".sha512" }) + { + Uri checksumUrl = new(url.AbsoluteUri + suffix); + byte[]? bytes = await http.GetAsync(checksumUrl, CargoHttp.ManifestLimit, true, cancellationToken); + if (bytes is not null) + return ParseChecksum(Encoding.UTF8.GetString(bytes), Path.GetFileName(url.AbsolutePath)) + ?? throw new InvalidDataException("The published binary checksum file is invalid or ambiguous."); + } + return null; + } + + internal static string? ParseChecksum(string text, string assetName) + { + List matches = []; + foreach (string line in text.Split('\n')) + { + string[] parts = line.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length is < 1 or > 2) + continue; + string hash = parts[0]; + if (parts.Length == 2 && parts[1].TrimStart('*') != assetName) + continue; + string candidate = (hash.Length == 128 ? "sha512:" : "sha256:") + hash; + if (ValidHash(candidate)) + matches.Add(candidate); + } + return matches.Distinct(StringComparer.OrdinalIgnoreCase).Count() == 1 ? matches[0] : null; + } + + internal static bool ValidHash(string? hash) => + hash is not null + && ((hash.StartsWith("sha256:", StringComparison.Ordinal) && hash.Length == 71) + || (hash.StartsWith("sha512:", StringComparison.Ordinal) && hash.Length == 135)) + && hash[7..].All(char.IsAsciiHexDigit); + + internal static void VerifyHash(byte[] bytes, string hash) + { + if (!ValidHash(hash)) + throw new InvalidDataException("The publisher checksum is invalid or unsupported."); + byte[] actual = hash.StartsWith("sha256:", StringComparison.Ordinal) + ? SHA256.HashData(bytes) : SHA512.HashData(bytes); + if (!CryptographicOperations.FixedTimeEquals(actual, Convert.FromHexString(hash[7..]))) + throw new InvalidDataException("Cargo download does not match its published checksum."); + } + + internal static void ValidateBinaryName(string name) + { + if (name.Length == 0 || name.Length > 255 + || name.Any(c => !char.IsAsciiLetterOrDigit(c) && c is not ('-' or '_' or '.')) + || name is "." or ".." || name.EndsWith('.') || CargoArchive.IsDeviceName(name)) + throw new InvalidDataException($"Invalid Cargo executable name: {name}"); + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.Cargo/CargoConfiguration.cs b/src/UniGetUI.PackageEngine.Managers.Cargo/CargoConfiguration.cs new file mode 100644 index 0000000000..e6861ec7fd --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Cargo/CargoConfiguration.cs @@ -0,0 +1,81 @@ +using Tomlyn; +using Tomlyn.Model; +using Tomlyn.Serialization; + +namespace UniGetUI.PackageEngine.Managers.CargoManager; + +internal static class CargoConfiguration +{ + internal static string WorkingDirectory => + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + internal static string Home => + Path.GetFullPath(Environment.GetEnvironmentVariable("CARGO_HOME") + is { Length: > 0 } home ? home : Path.Join(WorkingDirectory, ".cargo"), WorkingDirectory); + + internal static TomlTable ReadConfiguration() + { + string legacy = Path.Join(Home, "config"); + string path = File.Exists(legacy) ? legacy : Path.Join(Home, "config.toml"); + return File.Exists(path) ? Parse(File.ReadAllText(path)) : new TomlTable(); + } + + internal static TomlTable Parse(string text) => + TomlSerializer.Deserialize(text, CargoTomlContext.Default.TomlTable) + ?? throw new InvalidDataException("Cargo TOML document was empty."); + + internal static string GetRoot(string? explicitRoot = null) + { + if (!string.IsNullOrWhiteSpace(explicitRoot)) + return Path.GetFullPath(explicitRoot, WorkingDirectory); + if (Environment.GetEnvironmentVariable("CARGO_INSTALL_ROOT") is { Length: > 0 } root) + return Path.GetFullPath(root, WorkingDirectory); + var configuration = ReadConfiguration(); + if (configuration.TryGetValue("install", out object? installValue)) + { + if (installValue is not TomlTable install) + throw new InvalidDataException("Cargo install configuration must be a table."); + if (install.TryGetValue("root", out object? configured)) + { + if (configured is not string { Length: > 0 } configuredRoot) + throw new InvalidDataException("Cargo install.root configuration must be a nonempty path."); + return Path.GetFullPath(configuredRoot, Path.GetDirectoryName(Home) ?? Home); + } + } + return Home; + } + + internal static TomlTable? Table(TomlTable table, string key) => + table.TryGetValue(key, out object? value) ? value as TomlTable : null; + + internal static string? String(TomlTable table, string key) => + table.TryGetValue(key, out object? value) ? value as string : null; + + internal static bool HasBuildOverrides() + { + TomlTable configuration = ReadConfiguration(); + return Environment.GetEnvironmentVariable("CARGO_BUILD_TARGET") is { Length: > 0 } + || Environment.GetEnvironmentVariable("RUSTFLAGS") is { Length: > 0 } + || Environment.GetEnvironmentVariable("CARGO_ENCODED_RUSTFLAGS") is { Length: > 0 } + || Table(configuration, "build") is { Count: > 0 } + || Table(configuration, "target") is { Count: > 0 } + || Table(configuration, "source") is { Count: > 0 } + || Table(configuration, "registries") is { Count: > 0 } + || Table(configuration, "registry") is { Count: > 0 } + || configuration.ContainsKey("include"); + } + + internal static void RequireRegularPath(string path) + { + for (string? current = Path.GetFullPath(path); current is not null; + current = Path.GetDirectoryName(current)) + { + if (Path.Exists(current) + && (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) + throw new InvalidDataException($"Cargo installation path contains a link: {current}"); + } + } +} + +[TomlSerializable(typeof(TomlTable))] +internal partial class CargoTomlContext : TomlSerializerContext; diff --git a/src/UniGetUI.PackageEngine.Managers.Cargo/CargoHttp.cs b/src/UniGetUI.PackageEngine.Managers.Cargo/CargoHttp.cs new file mode 100644 index 0000000000..b513d26e4e --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Cargo/CargoHttp.cs @@ -0,0 +1,89 @@ +using System.Diagnostics; +using System.Net; +using UniGetUI.Core.Data; +using UniGetUI.Core.Tools; + +namespace UniGetUI.PackageEngine.Managers.CargoManager; + +internal sealed class CargoHttp : IDisposable +{ + internal const int ManifestLimit = 4 * 1024 * 1024; + internal const long ArchiveLimit = 256L * 1024 * 1024; + private static readonly SemaphoreSlim ApiGate = new(1); + private static long _lastApiRequest; + private readonly HttpClient _client; + internal static Func? TestHandlerFactory { get; set; } + + internal CargoHttp() + { + HttpMessageHandler handler; + if (TestHandlerFactory is { } factory) + handler = factory(); + else + { + var configured = CoreTools.GenericHttpClientParameters; + configured.AllowAutoRedirect = false; + handler = configured; + } + _client = new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(5) }; + _client.DefaultRequestHeaders.UserAgent.ParseAdd(CoreData.UserAgentString); + } + + internal static async Task PaceApiAsync(CancellationToken cancellationToken) + { + await ApiGate.WaitAsync(cancellationToken); + try + { + double elapsed = Stopwatch.GetElapsedTime(_lastApiRequest).TotalMilliseconds; + if (_lastApiRequest != 0 && elapsed < 1000) + await Task.Delay(TimeSpan.FromMilliseconds(1000 - elapsed), cancellationToken); + _lastApiRequest = Stopwatch.GetTimestamp(); + } + finally + { + ApiGate.Release(); + } + } + + internal async Task GetAsync(Uri url, long limit, bool allowMissing, + CancellationToken cancellationToken, Action? progress = null) + { + for (int redirect = 0; redirect <= 5; redirect++) + { + if (url.Scheme != Uri.UriSchemeHttps || !string.IsNullOrEmpty(url.UserInfo)) + throw new InvalidDataException($"Refusing a non-HTTPS or credential-bearing Cargo URL: {url}"); + using HttpResponseMessage response = await _client.GetAsync( + url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if ((int)response.StatusCode is 301 or 302 or 303 or 307 or 308) + { + url = response.Headers.Location is { } location + ? new Uri(url, location) + : throw new InvalidDataException("Cargo download redirect has no location."); + continue; + } + if (allowMissing && response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone) + return null; + response.EnsureSuccessStatusCode(); + long? length = response.Content.Headers.ContentLength; + if (length > limit) + throw new InvalidDataException("Cargo download exceeds the allowed size."); + await using Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using MemoryStream output = new(); + byte[] buffer = new byte[64 * 1024]; + int read; + while ((read = await stream.ReadAsync(buffer, cancellationToken)) != 0) + { + if (output.Length + read > limit) + throw new InvalidDataException("Cargo download exceeds the allowed size."); + output.Write(buffer, 0, read); + progress?.Invoke((ulong)output.Length, (ulong)Math.Max(0, length ?? 0)); + } + if (length is not null && output.Length != length) + throw new InvalidDataException("Cargo download was truncated."); + return output.ToArray(); + } + throw new InvalidDataException("Too many Cargo download redirects."); + } + + public void Dispose() => _client.Dispose(); +} diff --git a/src/UniGetUI.PackageEngine.Managers.Cargo/CargoInstallTracker.cs b/src/UniGetUI.PackageEngine.Managers.Cargo/CargoInstallTracker.cs new file mode 100644 index 0000000000..cd6a9183c8 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Cargo/CargoInstallTracker.cs @@ -0,0 +1,366 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Tomlyn; +using Tomlyn.Model; +using UniGetUI.Core.Logging; + +namespace UniGetUI.PackageEngine.Managers.CargoManager; + +internal sealed record CargoTransactionEntry(string Name, bool Existed, int? UnixMode = null, string? InstalledHash = null); +internal sealed record CargoTransactionJournal(CargoTransactionEntry[] Entries, bool Committed, + string? V1Hash = null, string? V2Hash = null); + +internal static class CargoInstallTracker +{ + private const string TransactionDirectory = ".unigetui-cargo-transaction"; + + internal static async Task> GetBuildArgumentsAsync( + string root, string id, CancellationToken cancellationToken, string? hostTarget = null, + string[]? expectedBinaries = null) + { + if (!File.Exists(Path.Join(root, ".crates2.json"))) + return []; + using var metadata = await CargoTrackingLock.AcquireAsync(root, cancellationToken); + Recover(root, metadata); + byte[] bytes = Read(metadata.V2); + if (bytes.Length == 0) + return []; + var document = JsonNode.Parse(bytes) as JsonObject + ?? throw new InvalidDataException("Invalid Cargo installation metadata."); + if (document["installs"] is not JsonObject installs) + throw new InvalidDataException("Invalid Cargo installation map."); + var entries = installs.Where(entry => entry.Key.StartsWith(id + " ", StringComparison.Ordinal)).ToArray(); + if (entries.Length == 0) + return []; + if (entries.Length > 1 || entries[0].Value is not JsonObject info) + throw new InvalidDataException($"Cargo package {id} has ambiguous installed build settings."); + List arguments = []; + if (info["features"] is JsonArray features && features.Count > 0) + { + string[] values = features.Select(feature => feature?.GetValue() + ?? throw new InvalidDataException("Invalid installed Cargo feature.")).ToArray(); + arguments.AddRange(["--features", UniGetUI.Core.Tools.CoreTools.EscapeCommandLineArgument(string.Join(',', values))]); + } + if (info["all_features"]?.GetValue() is true) + arguments.Add("--all-features"); + if (info["no_default_features"]?.GetValue() is true) + arguments.Add("--no-default-features"); + if (info["profile"]?.GetValue() is { } profile && profile != "release") + arguments.AddRange(["--profile", UniGetUI.Core.Tools.CoreTools.EscapeCommandLineArgument(profile)]); + // An explicitly installed cross target cannot be inferred from the process architecture. + if (info["target"]?.GetValue() is { Length: > 0 } target + && target != hostTarget) + arguments.AddRange(["--target", UniGetUI.Core.Tools.CoreTools.EscapeCommandLineArgument(target)]); + if (info["bins"] is JsonArray bins) + { + bool windows = info["target"]?.GetValue()?.Contains("-windows-", StringComparison.Ordinal) + ?? OperatingSystem.IsWindows(); + string[] names = bins.Select(bin => bin?.GetValue() + ?? throw new InvalidDataException("Invalid installed Cargo binary name.")) + .Select(bin => windows && bin.EndsWith(".exe", StringComparison.Ordinal) ? bin[..^4] : bin) + .ToArray(); + if (expectedBinaries is not null + && !names.Order(StringComparer.Ordinal).SequenceEqual(expectedBinaries.Order(StringComparer.Ordinal))) + { + foreach (string name in names) + { + CargoBinaryResolver.ValidateBinaryName(name); + arguments.AddRange(["--bin", UniGetUI.Core.Tools.CoreTools.EscapeCommandLineArgument(name)]); + } + } + } + return arguments; + } + + internal static async Task RecoverAsync(string root, CancellationToken cancellationToken) + { + if (!Directory.Exists(Path.Join(root, TransactionDirectory))) + return; + using var metadata = await CargoTrackingLock.AcquireAsync(root, cancellationToken); + Recover(root, metadata); + } + + internal static async Task InstallAsync(string root, CargoBinaryPlan plan, + IReadOnlyDictionary binaries, CancellationToken cancellationToken) + { + if (binaries.Count == 0) + throw new InvalidDataException("A Cargo binary installation must contain executables."); + using var metadata = await CargoTrackingLock.AcquireAsync(root, cancellationToken); + Recover(root, metadata); + string binDirectory = Path.Join(root, "bin"); + CargoConfiguration.RequireRegularPath(binDirectory); + Directory.CreateDirectory(binDirectory); + byte[] oldV1 = Read(metadata.V1); + byte[] oldV2 = Read(metadata.V2); + TomlTable v1Document = oldV1.Length == 0 ? new() : CargoConfiguration.Parse(Encoding.UTF8.GetString(oldV1)); + TomlTable v1 = CargoConfiguration.Table(v1Document, "v1") ?? new TomlTable(); + if (v1Document.ContainsKey("v1") && v1Document["v1"] is not TomlTable) + throw new InvalidDataException("Invalid Cargo v1 installation metadata."); + v1Document["v1"] = v1; + JsonObject v2Document = oldV2.Length == 0 ? new() : + JsonNode.Parse(oldV2) as JsonObject + ?? throw new InvalidDataException("Invalid Cargo v2 installation metadata."); + JsonObject installs = v2Document["installs"] as JsonObject ?? new(); + if (v2Document.ContainsKey("installs") && v2Document["installs"] is not JsonObject) + throw new InvalidDataException("Invalid Cargo v2 installation map."); + if (!v2Document.ContainsKey("installs")) + v2Document["installs"] = installs; + string source = "(registry+https://github.com/rust-lang/crates.io-index)"; + string key = $"{plan.Id} {plan.Version} {source}"; + string[] previous = v1.Keys.Where(k => k.StartsWith(plan.Id + " ", StringComparison.Ordinal) + && k.EndsWith(source, StringComparison.Ordinal)).ToArray(); + HashSet oldBinaries = []; + JsonObject installedInfo = previous.Select(oldKey => installs[oldKey]) + .OfType().FirstOrDefault()?.DeepClone() as JsonObject ?? new(); + foreach (string oldKey in previous) + oldBinaries.UnionWith(ReadBins(v1[oldKey])); + foreach (string binary in binaries.Keys) + { + CargoBinaryResolver.ValidateBinaryName(binary); + CargoConfiguration.RequireRegularPath(Path.Join(binDirectory, binary)); + foreach (var package in v1) + { + if (!previous.Contains(package.Key, StringComparer.Ordinal) + && ReadBins(package.Value).Contains(binary, StringComparer.OrdinalIgnoreCase)) + throw new IOException($"The executable {binary} is owned by another Cargo package."); + } + if (File.Exists(Path.Join(binDirectory, binary)) && !oldBinaries.Contains(binary)) + throw new IOException($"Refusing to overwrite untracked executable {binary}."); + } + string[] affected = oldBinaries.Union(binaries.Keys, StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray(); + foreach (string binary in affected) + { + CargoBinaryResolver.ValidateBinaryName(binary); + CargoConfiguration.RequireRegularPath(Path.Join(binDirectory, binary)); + } + foreach (string oldKey in previous) + { + v1.Remove(oldKey); + installs.Remove(oldKey); + } + TomlArray trackedBins = new(); + foreach (string name in binaries.Keys) + trackedBins.Add(name); + v1[key] = trackedBins; + JsonArray installedBins = new(); + foreach (string name in binaries.Keys) + { + JsonNode? node = JsonValue.Create(name); + installedBins.Add(node); + } + installedInfo["version_req"] = "=" + plan.Version; + installedInfo["bins"] = installedBins; + installedInfo["features"] = new JsonArray(); + installedInfo["all_features"] = false; + installedInfo["no_default_features"] = false; + installedInfo["profile"] = "release"; + installedInfo["target"] = plan.Target; + installedInfo["rustc"] = null; + installs[key] = installedInfo; + byte[] newV1 = Encoding.UTF8.GetBytes(TomlSerializer.Serialize(v1Document, CargoTomlContext.Default.TomlTable)); + byte[] newV2 = CargoJson.SerializeToUtf8Bytes(v2Document); + if (newV1.Length > CargoHttp.ManifestLimit || newV2.Length > CargoHttp.ManifestLimit) + throw new InvalidDataException("Updated Cargo tracking metadata exceeds the allowed size."); + string transaction = Path.Join(root, TransactionDirectory); + CargoConfiguration.RequireRegularPath(transaction); + Directory.CreateDirectory(transaction); + CargoTransactionJournal journal = new( + affected.Select(name => + { + string path = Path.Join(binDirectory, name); + bool exists = File.Exists(path); + return new CargoTransactionEntry(name, exists, + exists && !OperatingSystem.IsWindows() ? (int)File.GetUnixFileMode(path) : null, + binaries.TryGetValue(name, out byte[]? content) ? Hash(content) : null); + }).ToArray(), + false, Hash(newV1), Hash(newV2)); + // The journal is durable before the first installed binary or tracking file changes. + try + { + WriteDurable(Path.Join(transaction, "metadata-v1"), oldV1); + WriteDurable(Path.Join(transaction, "metadata-v2"), oldV2); + for (int i = 0; i < journal.Entries.Length; i++) + { + var entry = journal.Entries[i]; + if (entry.Existed) + WriteDurable(Path.Join(transaction, $"backup-{i}"), File.ReadAllBytes(Path.Join(binDirectory, entry.Name))); + } + WriteJournal(transaction, journal); + } + catch + { + Cleanup(transaction); + throw; + } + try + { + foreach (string name in affected) + { + cancellationToken.ThrowIfCancellationRequested(); + string destination = Path.Join(binDirectory, name); + CargoConfiguration.RequireRegularPath(destination); + if (binaries.TryGetValue(name, out byte[]? content)) + { + string staged = Path.Join(transaction, "staged-binary"); + WriteDurable(staged, content); + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(staged, UnixFileMode.UserRead | UnixFileMode.UserWrite + | UnixFileMode.UserExecute | UnixFileMode.GroupRead | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + File.Move(staged, destination, overwrite: true); + } + else if (File.Exists(destination)) + File.Delete(destination); + } + cancellationToken.ThrowIfCancellationRequested(); + WriteLocked(metadata.V1, newV1); + WriteLocked(metadata.V2, newV2); + WriteJournal(transaction, journal with { Committed = true }); + } + catch (Exception operationError) + { + try { Recover(root, metadata); } + catch (Exception rollbackError) + { + throw new AggregateException("Cargo installation failed and could not be rolled back.", + operationError, rollbackError); + } + throw; + } + Cleanup(transaction); + } + + internal static string[] ReadBins(object? value) + { + if (value is not TomlArray array || array.Any(item => item is not string)) + throw new InvalidDataException("Invalid binary list in Cargo installation metadata."); + return array.Cast().ToArray(); + } + + private static void Recover(string root, CargoTrackingLock metadata) + { + string transaction = Path.Join(root, TransactionDirectory); + if (!Directory.Exists(transaction)) + return; + CargoConfiguration.RequireRegularPath(transaction); + if (!Directory.EnumerateFileSystemEntries(transaction).Any()) + { + Directory.Delete(transaction); + return; + } + string journalPath = Path.Join(transaction, "journal.json"); + CargoConfiguration.RequireRegularPath(journalPath); + if (!File.Exists(journalPath)) + throw new IOException($"Incomplete Cargo transaction needs inspection before installation: {transaction}"); + var journal = CargoJson.Deserialize(File.ReadAllText(journalPath)) + ?? throw new InvalidDataException("Cargo transaction journal is empty."); + if (!journal.Committed) + { + // Cargo may have acquired the locks and installed something else after a crash. + // Never roll an interrupted transaction back over those later external changes. + foreach (string name in new[] { "metadata-v1", "metadata-v2" }) + CargoConfiguration.RequireRegularPath(Path.Join(transaction, name)); + byte[] originalV1 = File.ReadAllBytes(Path.Join(transaction, "metadata-v1")); + byte[] originalV2 = File.ReadAllBytes(Path.Join(transaction, "metadata-v2")); + if (!MatchesTransaction(Read(metadata.V1), originalV1, journal.V1Hash) + || !MatchesTransaction(Read(metadata.V2), originalV2, journal.V2Hash)) + throw new IOException($"Cargo metadata changed after an interrupted transaction; inspect {transaction} before recovery."); + string binDirectory = Path.Join(root, "bin"); + for (int i = 0; i < journal.Entries.Length; i++) + { + var entry = journal.Entries[i]; + CargoBinaryResolver.ValidateBinaryName(entry.Name); + string destination = Path.Join(binDirectory, entry.Name); + CargoConfiguration.RequireRegularPath(destination); + if (!File.Exists(destination)) + continue; + string backup = Path.Join(transaction, $"backup-{i}"); + if (entry.Existed) + CargoConfiguration.RequireRegularPath(backup); + byte[]? original = entry.Existed ? File.ReadAllBytes(backup) : null; + byte[] current = File.ReadAllBytes(destination); + if ((original is null || !current.AsSpan().SequenceEqual(original)) + && Hash(current) != entry.InstalledHash) + throw new IOException($"Cargo executable {entry.Name} changed after an interrupted transaction; manual recovery is required."); + } + for (int i = 0; i < journal.Entries.Length; i++) + { + var entry = journal.Entries[i]; + CargoBinaryResolver.ValidateBinaryName(entry.Name); + string destination = Path.Join(binDirectory, entry.Name); + CargoConfiguration.RequireRegularPath(destination); + if (entry.Existed) + { + string backup = Path.Join(transaction, $"backup-{i}"); + CargoConfiguration.RequireRegularPath(backup); + if (!File.Exists(destination) + || !File.ReadAllBytes(destination).AsSpan().SequenceEqual(File.ReadAllBytes(backup))) + File.Copy(backup, destination, overwrite: true); + if (!OperatingSystem.IsWindows() && entry.UnixMode is { } mode) + File.SetUnixFileMode(destination, (UnixFileMode)mode); + } + + else if (File.Exists(destination)) + File.Delete(destination); + } + WriteLocked(metadata.V1, originalV1); + WriteLocked(metadata.V2, originalV2); + WriteJournal(transaction, journal with { Committed = true }); + Logger.Warn("Rolled back an interrupted UniGetUI Cargo installation."); + } + Cleanup(transaction); + } + + private static string Hash(byte[] data) => Convert.ToHexString(SHA256.HashData(data)); + + private static bool MatchesTransaction(byte[] current, byte[] original, string? installedHash) => + current.AsSpan().SequenceEqual(original) || Hash(current) == installedHash; + + private static byte[] Read(FileStream stream) + { + if (stream.Length > CargoHttp.ManifestLimit) + throw new InvalidDataException("Cargo tracking metadata exceeds the allowed size."); + stream.Position = 0; + byte[] bytes = new byte[stream.Length]; + stream.ReadExactly(bytes); + return bytes; + } + + private static void WriteLocked(FileStream stream, byte[] data) + { + stream.Position = 0; + stream.Write(data); + stream.SetLength(data.Length); + stream.Flush(flushToDisk: true); + } + + private static void WriteDurable(string path, byte[] data) + { + CargoConfiguration.RequireRegularPath(path); + using FileStream stream = new(path, FileMode.Create, FileAccess.Write, FileShare.None); + stream.Write(data); + stream.Flush(flushToDisk: true); + } + + private static void WriteJournal(string transaction, CargoTransactionJournal journal) + { + string temporary = Path.Join(transaction, "journal.tmp"); + WriteDurable(temporary, CargoJson.SerializeToUtf8Bytes(journal)); + File.Move(temporary, Path.Join(transaction, "journal.json"), overwrite: true); + } + + private static void Cleanup(string transaction) + { + foreach (string path in Directory.EnumerateFileSystemEntries(transaction) + .OrderBy(path => Path.GetFileName(path) == "journal.json" ? 1 : 0)) + { + CargoConfiguration.RequireRegularPath(path); + if (Directory.Exists(path)) + throw new InvalidDataException("Unexpected directory in the Cargo transaction."); + File.Delete(path); + } + Directory.Delete(transaction); + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.Cargo/CargoJson.cs b/src/UniGetUI.PackageEngine.Managers.Cargo/CargoJson.cs index a7fbd9b329..0c99fe7f3e 100644 --- a/src/UniGetUI.PackageEngine.Managers.Cargo/CargoJson.cs +++ b/src/UniGetUI.PackageEngine.Managers.Cargo/CargoJson.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; @@ -11,6 +12,9 @@ internal static class CargoJson return JsonSerializer.Deserialize(json, GetTypeInfo()); } + public static byte[] SerializeToUtf8Bytes(T value) => + JsonSerializer.SerializeToUtf8Bytes(value, GetTypeInfo()); + private static JsonTypeInfo GetTypeInfo() { return (JsonTypeInfo?)CargoJsonContext.Default.GetTypeInfo(typeof(T)) @@ -23,4 +27,7 @@ private static JsonTypeInfo GetTypeInfo() [JsonSourceGenerationOptions(AllowTrailingCommas = true, WriteIndented = true)] [JsonSerializable(typeof(CargoManifest))] [JsonSerializable(typeof(CargoManifestVersionWrapper))] +[JsonSerializable(typeof(CargoRelease))] +[JsonSerializable(typeof(CargoTransactionJournal))] +[JsonSerializable(typeof(JsonObject))] internal sealed partial class CargoJsonContext : JsonSerializerContext; diff --git a/src/UniGetUI.PackageEngine.Managers.Cargo/CargoListParsing.cs b/src/UniGetUI.PackageEngine.Managers.Cargo/CargoListParsing.cs index 6b1488ee8e..84596d2672 100644 --- a/src/UniGetUI.PackageEngine.Managers.Cargo/CargoListParsing.cs +++ b/src/UniGetUI.PackageEngine.Managers.Cargo/CargoListParsing.cs @@ -6,112 +6,40 @@ internal sealed record CargoListEntry( string Id, string InstalledVersion, string? LatestVersion, - bool NeedsUpdate -); + bool NeedsUpdate, + string? SourceDescription = null +) +{ + internal bool IsCratesIo => SourceDescription is null + or "registry+https://github.com/rust-lang/crates.io-index" + or "registry+https://github.com/rust-lang/crates.io-index/" + or "registry+https://index.crates.io/" + or "registry+https://index.crates.io" + or "sparse+https://index.crates.io/" + or "registry+sparse+https://index.crates.io/"; +} public partial class Cargo { - [GeneratedRegex(@"^[A-Za-z0-9_][A-Za-z0-9_-]*$")] - private static partial Regex CrateNameRegex(); - - [GeneratedRegex(@"[ \t]{2,}|\t")] - private static partial Regex ColumnSeparatorRegex(); - - [GeneratedRegex(@"^v(?[0-9][A-Za-z0-9.+-]*)(?:\s+\(v[^)]*\))?$")] - private static partial Regex VersionCellRegex(); - - [GeneratedRegex(@"^(?[A-Za-z0-9_][A-Za-z0-9_-]*)\s+v(?[0-9][A-Za-z0-9.+-]*)(?:\s+\(.*\))?:$")] + [GeneratedRegex(@"^(?[A-Za-z0-9_][A-Za-z0-9_-]*)\s+v(?[0-9][A-Za-z0-9.+-]*)(?:\s+\((?.*)\))?:$")] private static partial Regex InstallListLineRegex(); - internal static List ParseInstallUpdateList( - IEnumerable lines, - List? skippedRows = null - ) - { - List entries = []; - bool insideTable = false; - - foreach (var rawLine in lines) - { - var line = rawLine.Trim(); - - if (line.Length is 0) - { - insideTable = false; - continue; - } - - if (IsTableHeader(line)) - { - insideTable = true; - continue; - } - - if (!insideTable) - continue; - - var entry = ParseInstallUpdateRow(line); - if (entry is null) - skippedRows?.Add(line); - else - entries.Add(entry); - } - - return entries; - } - internal static List ParseInstallList(IEnumerable lines) { List entries = []; - - foreach (var rawLine in lines) + foreach (string rawLine in lines) { var match = InstallListLineRegex().Match(rawLine.TrimEnd()); if (match.Success) - entries.Add( - new CargoListEntry( - match.Groups["id"].Value, - match.Groups["version"].Value, - null, - false - ) - ); + entries.Add(new CargoListEntry( + match.Groups["id"].Value, + match.Groups["version"].Value, + null, + false, + match.Groups["source"].Success ? match.Groups["source"].Value : null)); + else if (rawLine.Length > 0 && !char.IsWhiteSpace(rawLine[0])) + throw new InvalidDataException($"Unrecognized cargo install --list output: {rawLine}"); } - return entries; } - - private static bool IsTableHeader(string line) => - line.StartsWith("Package", StringComparison.Ordinal) - && line.Contains("Installed", StringComparison.Ordinal) - && line.Contains("Latest", StringComparison.Ordinal) - && line.Contains("Needs update", StringComparison.Ordinal); - - private static CargoListEntry? ParseInstallUpdateRow(string line) - { - var cells = ColumnSeparatorRegex().Split(line); - if (cells.Length < 4) - return null; - - var id = cells[0].Trim(); - if (!CrateNameRegex().IsMatch(id)) - return null; - - var installedVersion = ParseVersionCell(cells[1]); - if (installedVersion is null) - return null; - - return new CargoListEntry( - id, - installedVersion, - ParseVersionCell(cells[2]), - cells[3].Trim().Equals("Yes", StringComparison.OrdinalIgnoreCase) - ); - } - - private static string? ParseVersionCell(string cell) - { - var match = VersionCellRegex().Match(cell.Trim()); - return match.Success ? match.Groups["version"].Value : null; - } } diff --git a/src/UniGetUI.PackageEngine.Managers.Cargo/CargoManagedInstaller.cs b/src/UniGetUI.PackageEngine.Managers.Cargo/CargoManagedInstaller.cs new file mode 100644 index 0000000000..eeddf631cc --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Cargo/CargoManagedInstaller.cs @@ -0,0 +1,188 @@ +using System.Buffers.Binary; +using System.Net; +using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Serializable; + +namespace UniGetUI.PackageEngine.Managers.CargoManager; + +internal static class CargoManagedInstaller +{ + internal static async Task TryPerformAsync( + Cargo manager, IPackage package, InstallOptions options, OperationType operation, + Action log, Action progress, CancellationToken cancellationToken) + { + if (operation == OperationType.Uninstall) + return null; + if (options.SkipHashCheck) + throw new InvalidOperationException("Cargo installation requires integrity verification."); + if (CoreTools.IsAdministrator() || options.RunAsAdministrator + || package.OverridenOptions.RunAsAdministrator is true) + { + log("Elevated Cargo operations use Cargo source compilation."); + return null; + } + if (package.Source.Name != manager.DefaultSource.Name) + { + log("Non-crates.io packages use their original source through Cargo."); + return null; + } + var custom = operation == OperationType.Update + ? options.CustomParameters_Update : options.CustomParameters_Install; + if (custom.Count > 0 || CargoConfiguration.HasBuildOverrides() + || manager.Status.ExecutableCallArgs.Length > 0 + || manager.Status.OperationCallArgs.Count > 0) + { + log("Custom Cargo arguments/build settings require source compilation."); + return null; + } + string root = CargoConfiguration.GetRoot(options.CustomInstallLocation); + try + { + CargoConfiguration.RequireRegularPath(root); + CargoConfiguration.RequireRegularPath(Path.Join(root, "bin")); + } + catch (InvalidDataException ex) + { + log($"Linked Cargo installation paths use source compilation: {ex.Message}"); + return null; + } + await CargoInstallTracker.RecoverAsync(root, cancellationToken); + string? target = await manager.GetHostTargetAsync(cancellationToken); + if (target is null) + { + log("Cargo did not report a supported host target; using source compilation."); + return null; + } + CargoManifest manifest = await CratesIOClient.GetManifestAsync(package.Id, cancellationToken); + string version = CargoPkgOperationHelper.RequestedVersion(package, options, operation); + if (version.Length == 0) + { + CargoManifestVersion? latest = manifest.versions + .Where(v => !v.yanked && SemanticVersion.TryParse(v.num, out var parsed) + && (options.PreRelease || !parsed.IsPreRelease)) + .OrderByDescending(v => + { + SemanticVersion.TryParse(v.num, out var parsed); + return parsed; + }).FirstOrDefault(); + version = latest?.num ?? throw new InvalidDataException("No installable Cargo package version was found."); + } + if (!manifest.versions.Any(v => v.num == version && !v.yanked)) + { + log("The requested Cargo version requires Cargo's own version resolution."); + return null; + } + options.Version = version; + if (operation == OperationType.Update) + { + var preserved = await CargoInstallTracker.GetBuildArgumentsAsync(root, package.Id, + cancellationToken, target, manifest.versions.Single(v => v.num == version).bin_names); + if (preserved.Count > 0) + { + options.CustomParameters_Update.AddRange(preserved); + log("Preserving the installed package's Cargo build settings through source compilation."); + return null; + } + } + using CargoHttp http = new(); + CargoBinaryPlan? plan; + try + { + plan = await new CargoBinaryResolver(http).ResolveAsync( + package.Id, version, target, manifest, log, cancellationToken); + } + catch (HttpRequestException ex) when (ex.StatusCode is null or HttpStatusCode.NotFound + or HttpStatusCode.Gone or HttpStatusCode.Forbidden or HttpStatusCode.TooManyRequests + or HttpStatusCode.ServiceUnavailable or HttpStatusCode.BadGateway or HttpStatusCode.GatewayTimeout) + { + log($"Publisher binaries are unavailable ({ex.Message}); building {package.Id} {version} with Cargo."); + return null; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + log($"Publisher resolution timed out; building {package.Id} {version} with Cargo."); + return null; + } + if (plan is null) + return null; + log($"Downloading publisher binary {plan.Url} for Cargo package {plan.Id} {plan.Version}."); + byte[]? archive = await http.GetAsync(plan.Url, CargoHttp.ArchiveLimit, true, cancellationToken, + (downloaded, total) => progress(OperationProgress.FromDownload(downloaded, total))); + if (archive is null) + { + log($"The selected publisher binary is no longer available; building {package.Id} {version} with Cargo."); + return null; + } + CargoBinaryResolver.VerifyHash(archive, plan.Hash); + var binaries = ExtractBinaries(archive, plan, cancellationToken); + progress(OperationProgress.ForStage(operation == OperationType.Update + ? OperationProgressStage.Updating : OperationProgressStage.Installing)); + await CargoInstallTracker.InstallAsync(root, plan, binaries, cancellationToken); + manager.InvalidateInstalledCache(); + return OperationVeredict.Success; + } + + internal static IReadOnlyDictionary ExtractBinaries(byte[] archive, + CargoBinaryPlan plan, CancellationToken cancellationToken) + { + bool windows = plan.Target.Contains("-windows-", StringComparison.Ordinal); + string[] names = plan.Binaries.Select(name => name + (windows ? ".exe" : "")).ToArray(); + Dictionary files; + if (plan.Format == "bin") + { + if (names.Length != 1) + throw new InvalidDataException("A raw Cargo binary cannot contain multiple executables."); + files = new() { [names[0]] = archive }; + } + else + files = CargoArchive.Read(archive, plan.Format, + name => names.Contains(name.Split('/')[^1], StringComparer.Ordinal), cancellationToken); + Dictionary result = new(StringComparer.Ordinal); + foreach (string name in names) + { + KeyValuePair[] matches; + if (plan.BinaryPath is not null && plan.Format != "bin") + { + string bin = windows ? name[..^4] : name; + string path = CargoBinaryResolver.Expand(plan.BinaryPath, plan.Id, plan.Version, + plan.Target, null, plan.Format, bin) + ?? throw new InvalidDataException("Unsupported publisher binary-path template."); + CargoArchive.ValidateName(path); + matches = files.Where(file => file.Key == path).ToArray(); + } + else + matches = files.Where(file => file.Key.Split('/')[^1] == name).ToArray(); + if (matches.Length != 1) + throw new InvalidDataException($"The publisher archive does not contain one unambiguous {name} executable."); + ValidateExecutable(matches[0].Value, plan.Target); + result.Add(name, matches[0].Value); + } + return result; + } + + internal static void ValidateExecutable(byte[] bytes, string target) + { + if (bytes.Length < 64) + throw new InvalidDataException("The publisher executable is truncated."); + bool arm = target.StartsWith("aarch64-", StringComparison.Ordinal); + bool valid; + if (target.Contains("-windows-", StringComparison.Ordinal)) + { + int offset = BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(0x3C)); + valid = bytes[0] == 'M' && bytes[1] == 'Z' && offset >= 64 && offset <= bytes.Length - 6 + && BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(offset)) == 0x4550 + && BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(offset + 4)) == (arm ? 0xAA64 : 0x8664); + } + else if (target.Contains("-linux-", StringComparison.Ordinal)) + valid = bytes[0] == 0x7F && bytes[1] == 'E' && bytes[2] == 'L' && bytes[3] == 'F' + && bytes[4] == 2 && bytes[5] == 1 + && BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(18)) == (arm ? 183 : 62); + else + valid = BinaryPrimitives.ReadUInt32LittleEndian(bytes) == 0xFEEDFACF + && BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(4)) == (arm ? 0x100000C : 0x1000007); + if (!valid) + throw new InvalidDataException($"The downloaded executable does not match Cargo target {target}."); + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.Cargo/CargoTrackingLock.cs b/src/UniGetUI.PackageEngine.Managers.Cargo/CargoTrackingLock.cs new file mode 100644 index 0000000000..f6a5bd06d6 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Cargo/CargoTrackingLock.cs @@ -0,0 +1,92 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace UniGetUI.PackageEngine.Managers.CargoManager; + +internal sealed partial class CargoTrackingLock : IDisposable +{ + internal FileStream V1 { get; } + internal FileStream V2 { get; } + + private CargoTrackingLock(FileStream v1, FileStream v2) => (V1, V2) = (v1, v2); + + internal static async Task AcquireAsync(string root, CancellationToken cancellationToken) + { + CargoConfiguration.RequireRegularPath(root); + Directory.CreateDirectory(root); + FileStream v1 = await OpenAsync(Path.Join(root, ".crates.toml"), cancellationToken); + try + { + FileStream v2 = await OpenAsync(Path.Join(root, ".crates2.json"), cancellationToken); + return new(v1, v2); + } + catch + { + Unlock(v1); + throw; + } + } + + private static async Task OpenAsync(string path, CancellationToken cancellationToken) + { + CargoConfiguration.RequireRegularPath(path); + FileStream stream = new(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite); + try + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + if (OperatingSystem.IsWindows()) + { + try + { + stream.Lock(0, long.MaxValue); + return stream; + } + catch (IOException ex) when ((ex.HResult & 0xFFFF) == 33) + { } + } + else + { + if (Flock(stream.SafeFileHandle, 2 | 4) == 0) + return stream; + int error = Marshal.GetLastPInvokeError(); + if (error is not (11 or 35 or 4)) + throw new IOException("Unable to lock Cargo tracking metadata.", new Win32Exception(error)); + } + await Task.Delay(100, cancellationToken); + } + } + catch + { + stream.Dispose(); + throw; + } + } + + private static void Unlock(FileStream stream) + { + try + { + if (OperatingSystem.IsWindows()) + stream.Unlock(0, long.MaxValue); + else if (Flock(stream.SafeFileHandle, 8) != 0) + throw new IOException("Unable to unlock Cargo tracking metadata.", + new Win32Exception(Marshal.GetLastPInvokeError())); + } + finally + { + stream.Dispose(); + } + } + + [LibraryImport("libc", EntryPoint = "flock", SetLastError = true)] + private static partial int Flock(SafeFileHandle file, int operation); + + public void Dispose() + { + try { Unlock(V2); } + finally { Unlock(V1); } + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.Cargo/CratesIOClient.cs b/src/UniGetUI.PackageEngine.Managers.Cargo/CratesIOClient.cs index 3304ee42b1..bdd3a44b29 100644 --- a/src/UniGetUI.PackageEngine.Managers.Cargo/CratesIOClient.cs +++ b/src/UniGetUI.PackageEngine.Managers.Cargo/CratesIOClient.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Collections.Concurrent; +using System.Text; using UniGetUI.Core.Data; using UniGetUI.Core.Tools; @@ -64,6 +65,35 @@ internal sealed class CratesIOClient { public const string ApiUrl = "https://crates.io/api/v1"; internal static string? TEST_ApiUrlOverride { private get; set; } + private static readonly ConcurrentDictionary Cache = new(); + + internal static void InvalidateCache() => Cache.Clear(); + + internal static async Task GetManifestAsync(string packageId, + CancellationToken cancellationToken) + { + var manifest = await FetchAsync( + new Uri($"{GetApiUrl()}/crates/{Uri.EscapeDataString(packageId)}"), cancellationToken); + if (manifest.crate is null || manifest.versions is null) + throw new NullResponseException($"Null response for Cargo package {packageId}"); + return manifest; + } + + internal static CargoManifestVersion? SelectUpdate(CargoManifest manifest, string installed) + { + if (!SemanticVersion.TryParse(installed, out var current)) + throw new InvalidDataException($"Invalid installed Cargo version: {installed}"); + return manifest.versions + .Where(version => !version.yanked + && SemanticVersion.TryParse(version.num, out var parsed) + && !parsed.IsPreRelease && parsed.CompareTo(current) > 0) + .OrderByDescending(version => + { + SemanticVersion.TryParse(version.num, out var parsed); + return parsed; + }) + .FirstOrDefault(); + } public static Tuple GetManifest(string packageId) { @@ -90,18 +120,39 @@ public static CargoManifestVersion GetManifestVersion(string packageId, string v private static string GetApiUrl() => TEST_ApiUrlOverride ?? ApiUrl; internal static T Fetch(Uri url) + => FetchAsync(url, CancellationToken.None).GetAwaiter().GetResult(); + + private static async Task FetchAsync(Uri url, CancellationToken cancellationToken) { + string key = url.AbsoluteUri; + if (Cache.TryGetValue(key, out var cached) && DateTime.UtcNow - cached.Time < TimeSpan.FromMinutes(5)) + return CargoJson.Deserialize(cached.Json) + ?? throw new NullResponseException($"Null cached response for {url}"); + await CargoHttp.PaceApiAsync(cancellationToken); using HttpClient client = new(CoreTools.GenericHttpClientParameters); client.DefaultRequestHeaders.UserAgent.ParseAdd(CoreData.UserAgentString); using var request = new HttpRequestMessage(HttpMethod.Get, url); - using HttpResponseMessage response = client.Send(request); + using HttpResponseMessage response = await client.SendAsync( + request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); - - string manifestStr = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + await using Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using MemoryStream output = new(); + byte[] buffer = new byte[8192]; + int read; + while ((read = await stream.ReadAsync(buffer, cancellationToken)) != 0) + { + if (output.Length + read > CargoHttp.ManifestLimit) + throw new InvalidDataException($"Cargo API response exceeds the allowed size: {url}"); + output.Write(buffer, 0, read); + } + string manifestStr = Encoding.UTF8.GetString(output.ToArray()); var manifest = CargoJson.Deserialize(manifestStr) ?? throw new NullResponseException($"Null response for request to {url}"); + if (Cache.Count >= 512) + Cache.Clear(); + Cache[key] = (DateTime.UtcNow, manifestStr); return manifest; } } diff --git a/src/UniGetUI.PackageEngine.Managers.Cargo/Helpers/CargoPkgDetailsHelper.cs b/src/UniGetUI.PackageEngine.Managers.Cargo/Helpers/CargoPkgDetailsHelper.cs index ff0817db25..e09c07d20a 100644 --- a/src/UniGetUI.PackageEngine.Managers.Cargo/Helpers/CargoPkgDetailsHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Cargo/Helpers/CargoPkgDetailsHelper.cs @@ -9,6 +9,7 @@ internal sealed class CargoPkgDetailsHelper(Cargo manager) : BasePkgDetailsHelpe { protected override void GetDetails_UnSafe(IPackageDetails details) { + RequireCratesIo(details.Package); details.InstallerType = "Source"; INativeTaskLogger logger = Manager.TaskLogger.CreateNew( @@ -82,18 +83,13 @@ protected override IReadOnlyList GetScreenshots_UnSafe(IPackage package) protected override string? GetInstallLocation_UnSafe(IPackage package) { - // Cargo installs binaries into $CARGO_HOME/bin (default ~/.cargo/bin). - var cargoHome = Environment.GetEnvironmentVariable("CARGO_HOME"); - if (string.IsNullOrEmpty(cargoHome)) - cargoHome = Path.Join( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".cargo" - ); - return Path.Join(cargoHome, "bin"); + var options = package.GetInstallOptions().GetAwaiter().GetResult(); + return Path.Join(CargoConfiguration.GetRoot(options.CustomInstallLocation), "bin"); } protected override IReadOnlyList GetInstallableVersions_UnSafe(IPackage package) { + RequireCratesIo(package); INativeTaskLogger logger = Manager.TaskLogger.CreateNew( Enums.LoggableTaskType.LoadPackageVersions ); @@ -111,4 +107,11 @@ protected override IReadOnlyList GetInstallableVersions_UnSafe(IPackage throw; } } + + private void RequireCratesIo(IPackage package) + { + if (package.Source.Name != Manager.DefaultSource.Name) + throw new NotSupportedException( + $"Cargo metadata and source downloads are only available for crates.io packages, not {package.Source.Name}."); + } } diff --git a/src/UniGetUI.PackageEngine.Managers.Cargo/Helpers/CargoPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.Cargo/Helpers/CargoPkgOperationHelper.cs index 78b337da48..8e6013ad49 100644 --- a/src/UniGetUI.PackageEngine.Managers.Cargo/Helpers/CargoPkgOperationHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Cargo/Helpers/CargoPkgOperationHelper.cs @@ -1,136 +1,92 @@ -using UniGetUI.Core.SettingsEngine; using UniGetUI.Core.Tools; using UniGetUI.PackageEngine.Classes.Manager.BaseProviders; using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Interfaces.ManagerProviders; using UniGetUI.PackageEngine.Serializable; namespace UniGetUI.PackageEngine.Managers.CargoManager; -internal sealed class CargoPkgOperationHelper(Cargo cargo) : BasePkgOperationHelper(cargo) +internal sealed class CargoPkgOperationHelper(Cargo cargo) + : BasePkgOperationHelper(cargo), IManagedPackageOperationHelper { - private const string BinstallPackageId = "cargo-binstall"; - - private static bool TargetsBinstallItself(IPackage package) => - package.Id.Equals(BinstallPackageId, StringComparison.OrdinalIgnoreCase); - - private bool UsesBinstall(IPackage package) => - ((Cargo)Manager).HasBinstall && !package.OverridenOptions.Cargo_DoNotUseBinstall; - - private static bool OperationIsBrokered(IPackage package) => - Settings.Get(Settings.K.UseAgentBroker) && !package.Source.IsVirtualManager; + internal static string RequestedVersion(IPackage package, InstallOptions options, OperationType operation) => + options.Version.Length > 0 ? options.Version + : operation == OperationType.Update && package.IsUpgradable ? package.NewVersionString + : package.HasConcreteVersion ? package.VersionString : ""; protected override IReadOnlyList _getOperationParameters( - IPackage package, - InstallOptions options, - OperationType operation - ) + IPackage package, InstallOptions options, OperationType operation) { - // --version is omitted when there is nothing to pin, so cargo picks the latest itself. An - // unpinned imported package reports the localized "Latest" as its version, which is - // display text rather than a version and is more than one word in several languages. - string requestedVersion = options.Version.Length > 0 - ? options.Version - : package.HasConcreteVersion - ? package.VersionString - : ""; - string[] versionArguments = requestedVersion.Length > 0 - ? ["--version", CoreTools.EscapeCommandLineArgument(requestedVersion)] - : []; - - package.OverridenOptions.Cargo_CustomInstallPathRequested = - options.CustomInstallLocation.Length is not 0; - - bool hasBinstall = UsesBinstall(package); - bool targetsBinstallItself = - TargetsBinstallItself(package) - && !package.OverridenOptions.Cargo_CustomInstallPathRequested; - - List parameters; - switch (operation) + if (options.SkipHashCheck && operation != OperationType.Uninstall) + throw new InvalidOperationException("Cargo installation requires integrity verification."); + string version = RequestedVersion(package, options, operation); + if (operation == OperationType.Update + && SemanticVersion.TryParse(version, out var requested) + && SemanticVersion.TryParse(package.VersionString, out var installed) + && requested.CompareTo(installed) < 0) + throw new InvalidOperationException("Refusing to downgrade an installed Cargo package during an update."); + List arguments = [operation == OperationType.Uninstall ? "uninstall" : "install", package.Id]; + if (operation is not OperationType.Uninstall) { - case OperationType.Install: - if (hasBinstall) - parameters = - [Manager.Properties.InstallVerb, .. versionArguments, package.Id]; - else if (targetsBinstallItself) - parameters = - ["install", package.Id, .. versionArguments, "--locked", "--force"]; - else - parameters = ["install", package.Id, .. versionArguments]; - break; - - case OperationType.Update: - if (hasBinstall) - parameters = [Manager.Properties.UpdateVerb, package.Id]; - else if (targetsBinstallItself) - parameters = ["install", package.Id, "--locked", "--force"]; - else - parameters = ["install", package.Id, "--force"]; - break; - - case OperationType.Uninstall: - parameters = [Manager.Properties.UninstallVerb, package.Id]; - break; - - default: - throw new InvalidDataException("Invalid package operation"); + if (version.Length > 0) + arguments.AddRange(["--version", CoreTools.EscapeCommandLineArgument(version)]); + arguments.Add("--locked"); + if (operation == OperationType.Update) + arguments.Add("--force"); } - - if (operation is OperationType.Install or OperationType.Update) + if (options.CustomInstallLocation.Length > 0) + arguments.AddRange(["--root", CoreTools.EscapeCommandLineArgument( + CargoConfiguration.GetRoot(options.CustomInstallLocation))]); + IReadOnlyList custom = operation switch { - if (hasBinstall) + OperationType.Update => options.CustomParameters_Update, + OperationType.Uninstall => options.CustomParameters_Uninstall, + _ => options.CustomParameters_Install, + }; + if (operation != OperationType.Uninstall && package.Source.Name != cargo.DefaultSource.Name + && !custom.Any(argument => argument is "--git" or "--path" or "--index" or "--registry")) + { + var source = package.Source.Url + ?? throw new InvalidOperationException("The installed Cargo package has no original source."); + if (source.IsFile) + arguments.AddRange(["--path", CoreTools.EscapeCommandLineArgument(source.LocalPath)]); + else if (package.Source.Name.StartsWith("registry+", StringComparison.Ordinal)) + arguments.AddRange(["--index", CoreTools.EscapeCommandLineArgument(source.AbsoluteUri)]); + else if (source.Scheme == Uri.UriSchemeHttps) { - parameters.Add("--no-confirm"); - - if (targetsBinstallItself) - parameters.AddRange(["--disable-strategies", "compile"]); - - if (options.SkipHashCheck) - parameters.Add("--skip-signatures"); - - if (options.CustomInstallLocation != "") - parameters.AddRange(["--install-path", CoreTools.EscapeCommandLineArgument(options.CustomInstallLocation)]); + arguments.AddRange(["--git", CoreTools.EscapeCommandLineArgument(source.GetLeftPart(UriPartial.Path))]); + if (source.Fragment.Length > 1) + arguments.AddRange(["--rev", CoreTools.EscapeCommandLineArgument(source.Fragment[1..])]); } + else + throw new InvalidOperationException("The installed Cargo package source requires explicit Cargo source arguments."); } - - parameters.AddRange( - operation switch - { - OperationType.Update => options.CustomParameters_Update, - OperationType.Uninstall => options.CustomParameters_Uninstall, - _ => options.CustomParameters_Install, - } - ); - - return parameters; + if (custom.Any(argument => argument is "--no-confirm" or "--skip-signatures" + or "--install-path" or "--disable-strategies" or "--strategies" + or "--pkg-url" or "--pkg-fmt" or "--bin-dir" or "--only-signed" + || argument.StartsWith("--install-path=", StringComparison.Ordinal) + || argument.StartsWith("--disable-strategies=", StringComparison.Ordinal) + || argument.StartsWith("--bin-dir=", StringComparison.Ordinal) + || argument.StartsWith("--strategies=", StringComparison.Ordinal) + || argument.StartsWith("--pkg-", StringComparison.Ordinal))) + throw new InvalidOperationException( + "Cargo extension arguments are no longer supported. Replace them with cargo install options."); + arguments.AddRange(custom); + return arguments; } + public Task TryPerformAsync(IPackage package, InstallOptions options, + OperationType operation, Action log, Action progress, + CancellationToken cancellationToken) => + CargoManagedInstaller.TryPerformAsync(cargo, package, options, operation, log, progress, cancellationToken); + protected override OperationVeredict _getOperationResult( - IPackage package, - OperationType operation, - IReadOnlyList processOutput, - int returnCode - ) + IPackage package, OperationType operation, IReadOnlyList processOutput, int returnCode) { - if (returnCode == 0) - { - ((Cargo)Manager).InvalidateInstalledCache(); - return OperationVeredict.Success; - } - - if ( - operation is OperationType.Install or OperationType.Update - && TargetsBinstallItself(package) - && !package.OverridenOptions.Cargo_CustomInstallPathRequested - && UsesBinstall(package) - && !OperationIsBrokered(package) - ) - { - package.OverridenOptions.Cargo_DoNotUseBinstall = true; - return OperationVeredict.AutoRetry; - } - - return OperationVeredict.Failure; + if (returnCode != 0) + return OperationVeredict.Failure; + cargo.InvalidateInstalledCache(); + return OperationVeredict.Success; } } diff --git a/src/UniGetUI.PackageEngine.Managers.Cargo/InternalsVisibleTo.cs b/src/UniGetUI.PackageEngine.Managers.Cargo/InternalsVisibleTo.cs index eeb63dad19..08d2b35667 100644 --- a/src/UniGetUI.PackageEngine.Managers.Cargo/InternalsVisibleTo.cs +++ b/src/UniGetUI.PackageEngine.Managers.Cargo/InternalsVisibleTo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("UniGetUI.PackageEngine.Tests")] +[assembly: InternalsVisibleTo("UniGetUI.Cargo.Smoke")] diff --git a/src/UniGetUI.PackageEngine.Managers.Cargo/Tomlyn.LICENSE.txt b/src/UniGetUI.PackageEngine.Managers.Cargo/Tomlyn.LICENSE.txt new file mode 100644 index 0000000000..42d5a1fd89 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Cargo/Tomlyn.LICENSE.txt @@ -0,0 +1,23 @@ +Copyright (c) 2019-2026, Alexandre Mutel +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification +, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/UniGetUI.PackageEngine.Managers.Cargo/UniGetUI.PackageEngine.Managers.Cargo.csproj b/src/UniGetUI.PackageEngine.Managers.Cargo/UniGetUI.PackageEngine.Managers.Cargo.csproj index cfa6fbf5a2..0d53d6862b 100644 --- a/src/UniGetUI.PackageEngine.Managers.Cargo/UniGetUI.PackageEngine.Managers.Cargo.csproj +++ b/src/UniGetUI.PackageEngine.Managers.Cargo/UniGetUI.PackageEngine.Managers.Cargo.csproj @@ -1,6 +1,7 @@ $(SharedTargetFrameworks) + true @@ -23,5 +24,8 @@ + + diff --git a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs index 6da2d939e9..0c63fc369f 100644 --- a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs +++ b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs @@ -11,6 +11,7 @@ using UniGetUI.PackageEngine.Classes.Packages.Classes; using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Interfaces.ManagerProviders; using UniGetUI.PackageEngine.Operations.Reboot; using UniGetUI.PackageEngine.PackageClasses; using UniGetUI.PackageEngine.PackageLoader; @@ -346,7 +347,7 @@ protected override async Task PerformOperation() if (!ShouldUseAgentBroker()) { if (!Package.Manager.Capabilities.SerializesOperations) - return await base.PerformOperation(); + return await PerformLocalOperation(); SemaphoreSlim gate = ManagerOperationGates.GetOrAdd( Package.Manager.Name, @@ -378,7 +379,7 @@ protected override async Task PerformOperation() try { - return await base.PerformOperation(); + return await PerformLocalOperation(); } finally { @@ -389,6 +390,27 @@ protected override async Task PerformOperation() return await PerformBrokerOperation(); } + private async Task PerformLocalOperation() + { + if (Package.Manager.OperationHelper is IManagedPackageOperationHelper managed) + { + try + { + var result = await managed.TryPerformAsync(Package, Options, Role, + message => Line(message, LineType.Information), ReportProgress, CancellationToken); + if (result is not null) + return result.Value; + // Resolution may have pinned an initially unversioned request before fallback. + PrepareProcessStartInfo(); + } + catch (OperationCanceledException) when (CancellationToken.IsCancellationRequested) + { + return OperationVeredict.Canceled; + } + } + return await base.PerformOperation(); + } + private static readonly ConcurrentDictionary ManagerOperationGates = new(); diff --git a/src/UniGetUI.PackageEngine.Tests/CargoArchiveTests.cs b/src/UniGetUI.PackageEngine.Tests/CargoArchiveTests.cs new file mode 100644 index 0000000000..a26d207824 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/CargoArchiveTests.cs @@ -0,0 +1,162 @@ +using System.Buffers.Binary; +using System.Formats.Tar; +using System.IO.Compression; +using UniGetUI.PackageEngine.Managers.CargoManager; + +namespace UniGetUI.PackageEngine.Tests; + +public sealed class CargoArchiveTests +{ + [Theory] + [InlineData("zip")] + [InlineData("tar")] + [InlineData("tgz")] + public void ReadReturnsOnlySelectedBytesAndNormalizesOrdinaryDotPrefix(string format) + { + (string, byte[])[] entries = [("./release/tool", [3, 4, 5]), ("release/README", [9])]; + byte[] bytes = Build(format, entries); + var files = CargoArchive.Read(bytes, format, path => path == "release/tool", default); + Assert.Equal("release/tool", Assert.Single(files).Key); + Assert.Equal([3, 4, 5], files["release/tool"]); + } + + [Theory] + [InlineData("../tool")] + [InlineData("release/../../tool")] + [InlineData("/absolute/tool")] + [InlineData("C:/drive/tool")] + [InlineData("release\\tool")] + [InlineData("release//tool")] + [InlineData("release/tool.")] + [InlineData("release/tool ")] + [InlineData("release/CON")] + [InlineData("release/NUL.exe")] + public void ReadRejectsUnsafePathsEvenForUnselectedEntries(string name) + { + foreach (string format in new[] { "zip", "tgz" }) + { + byte[] bytes = Build(format, (name, [1])); + Assert.Throws(() => + CargoArchive.Read(bytes, format, _ => false, default)); + } + } + + [Theory] + [InlineData("zip")] + [InlineData("tgz")] + public void ReadRejectsCaseInsensitiveAndNormalizedDuplicatePaths(string format) + { + Assert.Throws(() => CargoArchive.Read( + Build(format, ("release/tool", [1]), ("release/TOOL", [2])), format, _ => true, default)); + Assert.Throws(() => CargoArchive.Read( + Build(format, ("release/tool", [1]), ("./release/tool", [2])), format, _ => false, default)); + } + + [Fact] + public void ReadRejectsZipSymlinksAndTarLinks() + { + using var zipBytes = new MemoryStream(); + using (var zip = new ZipArchive(zipBytes, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = zip.CreateEntry("tool"); + entry.ExternalAttributes = unchecked((int)0xA1FF0000); + using Stream content = entry.Open(); + content.WriteByte(1); + } + Assert.Throws(() => + CargoArchive.Read(zipBytes.ToArray(), "zip", _ => false, default)); + foreach (TarEntryType type in new[] { TarEntryType.SymbolicLink, TarEntryType.HardLink }) + { + using var tarBytes = new MemoryStream(); + using (var writer = new TarWriter(tarBytes, leaveOpen: true)) + writer.WriteEntry(new PaxTarEntry(type, "tool") { LinkName = "../outside" }); + Assert.Throws(() => + CargoArchive.Read(tarBytes.ToArray(), "tar", _ => false, default)); + } + } + + [Fact] + public void ReadEnforcesEntryLimitAndDeclaredExpandedLimitWithoutLargeAllocation() + { + var entries = Enumerable.Range(0, CargoArchive.EntryLimit) + .Select(i => ("entry-" + i, Array.Empty())).ToArray(); + Assert.Empty(CargoArchive.Read(CargoTestData.Zip(entries), "zip", _ => false, default)); + Assert.Contains("too many entries", Assert.Throws(() => CargoArchive.Read( + CargoTestData.Zip([.. entries, ("one-too-many", [])]), "zip", _ => false, default)).Message); + byte[] oversized = CargoTestData.Zip(("tool", [1])); + SetZipDeclaredSize(oversized, (uint)CargoArchive.ExpandedLimit + 1); + Assert.Contains("allowed size", Assert.Throws(() => + CargoArchive.Read(oversized, "zip", _ => false, default)).Message); + } + + [Fact] + public void ReadRejectsTruncatedPayloadAndHonorsCancellation() + { + byte[] truncated = CargoTestData.Zip(("tool", [1, 2, 3])); + SetZipDeclaredSize(truncated, 4); + Assert.Throws(() => CargoArchive.Read(truncated, "zip", _ => true, default)); + using var tarBytes = new MemoryStream(); + using (var writer = new TarWriter(tarBytes, leaveOpen: true)) + using (var content = new MemoryStream(new byte[1024])) + writer.WriteEntry(new UstarTarEntry(TarEntryType.RegularFile, "tool") { DataStream = content }); + Assert.Throws(() => CargoArchive.Read(tarBytes.ToArray()[..600], "tar", _ => true, default)); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + Assert.ThrowsAny(() => + CargoArchive.Read(CargoTestData.Zip(("tool", [1])), "zip", _ => true, cancellation.Token)); + Assert.Throws(() => CargoArchive.Read([], "xz", _ => true, default)); + } + + [Theory] + [InlineData("zip")] + [InlineData("tgz")] + public void SelectedEntryLimitAllowsExactBoundaryAndRejectsNextByte(string format) + { + byte[] bytes = Build(format, ("tool", [1, 2, 3]), ("unselected", [4, 5, 6, 7])); + var files = CargoArchive.Read(bytes, format, n => n == "tool", default, selectedEntryLimit: 3); + Assert.Equal([1, 2, 3], files["tool"]); + Assert.Single(files); + Assert.Throws(() => + CargoArchive.Read(bytes, format, _ => true, default, selectedEntryLimit: 3)); + Assert.Throws(() => + CargoArchive.Read(bytes, format, n => n == "tool", default, selectedEntryLimit: 2)); + } + + [Fact] + public void CumulativeExpandedSizeRejectsCompressedBombWithoutRetainingPayload() + { + // Reuse one 4 MiB buffer; both ZIPs remain small. The unselected entries + // must still be drained and counted, without retaining 512 MiB in memory. + byte[] content = new byte[4 * 1024 * 1024]; + int count = (int)(CargoArchive.ExpandedLimit / content.Length); + var entries = Enumerable.Range(0, count).Select(i => ("entry-" + i, content)).ToArray(); + Assert.Empty(CargoArchive.Read(CargoTestData.Zip(entries), "zip", _ => false, default)); + Assert.Contains("allowed size", Assert.Throws(() => + CargoArchive.Read(CargoTestData.Zip([.. entries, ("over-limit", content)]), + "zip", _ => false, default)).Message); + } + + [Theory] + [InlineData("tool.ZIP", "zip")] + [InlineData("tool.tar.gz", "tgz")] + [InlineData("tool.tgz", "tgz")] + [InlineData("tool.tar", "tar")] + [InlineData("tool.tar.xz", null)] + [InlineData("tool.exe", null)] + public void FormatRecognizesOnlySupportedArchiveSuffixes(string name, string? expected) => + Assert.Equal(expected, CargoArchive.Format(name)); + + private static byte[] Build(string format, params (string Name, byte[] Bytes)[] entries) => + format == "zip" ? CargoTestData.Zip(entries) : CargoTestData.Tar(format == "tgz", entries); + + private static void SetZipDeclaredSize(byte[] bytes, uint size) + { + for (int i = 0; i <= bytes.Length - 28; i++) + if (BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(i)) == 0x02014B50) + { + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(i + 24), size); + return; + } + throw new InvalidOperationException("Fixture ZIP central directory was not found."); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/CargoBinDirectoryTests.cs b/src/UniGetUI.PackageEngine.Tests/CargoBinDirectoryTests.cs index b3ed9f70ee..04bb294d52 100644 --- a/src/UniGetUI.PackageEngine.Tests/CargoBinDirectoryTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/CargoBinDirectoryTests.cs @@ -2,6 +2,7 @@ namespace UniGetUI.PackageEngine.Tests; +[Collection(nameof(OperationOrchestrationTestCollection))] public sealed class CargoBinDirectoryTests { private static Func Env(params (string Name, string Value)[] values) => @@ -49,11 +50,11 @@ public void GetCargoBinDirectories_IgnoresBlankValues() } [Fact] - public void IsCargoBinaryPresent_FindsBinariesUnderCargoHomeWhenNotOnPath() + public void DiscoveryFindsCargoUnderCargoHomeAndRequiresExecutablePermissions() { string binaryName = OperatingSystem.IsWindows() - ? "cargo-unigetui-detection-probe.exe" - : "cargo-unigetui-detection-probe"; + ? "cargo.exe" + : "cargo"; string cargoHome = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); string binDirectory = Path.Join(cargoHome, "bin"); string binaryPath = Path.Join(binDirectory, binaryName); @@ -61,22 +62,23 @@ public void IsCargoBinaryPresent_FindsBinariesUnderCargoHomeWhenNotOnPath() try { - Assert.False(Cargo.IsCargoBinaryPresent(binaryName)); + Environment.SetEnvironmentVariable("CARGO_HOME", cargoHome); + var manager = new Cargo(); + Assert.DoesNotContain(binaryPath, manager.FindCandidateExecutableFiles()); Directory.CreateDirectory(binDirectory); File.WriteAllText(binaryPath, ""); - Environment.SetEnvironmentVariable("CARGO_HOME", cargoHome); if (!OperatingSystem.IsWindows()) { - Assert.False(Cargo.IsCargoBinaryPresent(binaryName)); + Assert.DoesNotContain(binaryPath, manager.FindCandidateExecutableFiles()); File.SetUnixFileMode( binaryPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute ); } - Assert.True(Cargo.IsCargoBinaryPresent(binaryName)); + Assert.Contains(binaryPath, manager.FindCandidateExecutableFiles()); } finally { diff --git a/src/UniGetUI.PackageEngine.Tests/CargoBinaryResolverTests.cs b/src/UniGetUI.PackageEngine.Tests/CargoBinaryResolverTests.cs new file mode 100644 index 0000000000..c73ac0de00 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/CargoBinaryResolverTests.cs @@ -0,0 +1,304 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using UniGetUI.PackageEngine.Managers.CargoManager; + +namespace UniGetUI.PackageEngine.Tests; + +[Collection(nameof(OperationOrchestrationTestCollection))] +public sealed class CargoBinaryResolverTests +{ + private const string ReleaseUrl = "https://api.github.com/repos/publisher/tool/releases/tags/v1.2.3"; + private const string AssetName = "fixture-tool-1.2.3-x86_64-pc-windows-msvc.zip"; + private const string AssetUrl = "https://github.com/publisher/tool/releases/download/v1.2.3/" + AssetName; + + private static CargoManifest Source(CargoTestHttpFixture fixture, string metadata = "", + string name = CargoTestData.Id, string version = CargoTestData.Version) + { + byte[] source = CargoTestData.Tar(true, + ("fixture-tool-1.2.3/Cargo.toml", Encoding.UTF8.GetBytes($""" + [package] + name = "{name}" + version = "{version}" + repository = "https://github.com/publisher/tool" + {metadata} + """))); + fixture.Bytes(CargoTestData.SourceUrl, source); + return CargoTestData.Manifest(CargoTestData.VersionInfo(checksum: CargoTestData.Hash(source)[7..])); + } + + private static Task Resolve(CargoHttp http, CargoManifest manifest, List? log = null, + string target = CargoTestData.Target, CancellationToken cancellationToken = default) => + new CargoBinaryResolver(http).ResolveAsync(CargoTestData.Id, CargoTestData.Version, target, + manifest, message => log?.Add(message), cancellationToken); + + [Fact] + public async Task ExplicitPublisherMetadataUsesVerifiedSourceAndSeparateBinaryChecksum() + { + using var fixture = new CargoTestHttpFixture(); + var manifest = Source(fixture, """ + [package.metadata.binstall] + pkg-url = "https://publisher.test/{name}/{version}/fixture.zip" + pkg-fmt = "zip" + bin-dir = "release/{bin}{binary-ext}" + """); + byte[] binary = CargoTestData.Zip(("release/tool.exe", CargoTestData.Executable())); + fixture.Text(CargoTestData.BinaryUrl + ".sha256", CargoTestData.Hash(binary)[7..] + " *fixture.zip"); + using var http = new CargoHttp(); + var plan = Assert.IsType(await Resolve(http, manifest)); + Assert.Equal(CargoTestData.Id, plan.Id); + Assert.Equal(CargoTestData.Version, plan.Version); + Assert.Equal(CargoTestData.Target, plan.Target); + Assert.Equal(["tool"], plan.Binaries); + Assert.Equal(CargoTestData.BinaryUrl, plan.Url.AbsoluteUri); + Assert.Equal("zip", plan.Format); + Assert.Equal("release/{bin}{binary-ext}", plan.BinaryPath); + Assert.Equal(CargoTestData.Hash(binary), plan.Hash); + Assert.NotEqual("sha256:" + manifest.versions[0].checksum, plan.Hash); + Assert.Equal([CargoTestData.SourceUrl, CargoTestData.BinaryUrl + ".sha256"], + fixture.Requests.Select(u => u.AbsoluteUri)); + Assert.Equal(CargoTestData.Executable(), + CargoManagedInstaller.ExtractBinaries(binary, plan, default)["tool.exe"]); + } + + [Fact] + public async Task TargetOverrideUsesExactTripleWithoutRequestingTheDefaultAsset() + { + using var fixture = new CargoTestHttpFixture(); + var manifest = Source(fixture, """ + [package.metadata.binstall] + pkg-url = "https://publisher.test/{version}/wrong.tar" + pkg-fmt = "tar" + [package.metadata.binstall.overrides.x86_64-pc-windows-msvc] + pkg-url = "https://publisher.test/{name}/{version}/fixture.zip" + pkg-fmt = "zip" + """); + fixture.Text(CargoTestData.BinaryUrl + ".sha256", new string('a', 64)); + using var http = new CargoHttp(); + var plan = Assert.IsType(await Resolve(http, manifest)); + Assert.Equal(CargoTestData.BinaryUrl, plan.Url.AbsoluteUri); + Assert.Equal("zip", plan.Format); + Assert.Equal([CargoTestData.SourceUrl, CargoTestData.BinaryUrl + ".sha256"], + fixture.Requests.Select(u => u.AbsoluteUri)); + } + + [Fact] + public async Task GitHubReleaseDigestPinsExactAssetWithoutThirdPartyRequests() + { + using var fixture = new CargoTestHttpFixture(); + var manifest = Source(fixture); + string digest = CargoTestData.Hash([10, 20, 30]); + fixture.Text(ReleaseUrl, $$""" + {"tag_name":"v1.2.3","draft":false,"assets":[ + {"name":"{{AssetName}}","browser_download_url":"{{AssetUrl}}","digest":"{{digest}}"}, + {"name":"fixture-tool-1.2.3-aarch64-pc-windows-msvc.zip","browser_download_url":"https://github.com/unused"} + ]} + """); + using var http = new CargoHttp(); + var plan = Assert.IsType(await Resolve(http, manifest)); + Assert.Equal(AssetUrl, plan.Url.AbsoluteUri); + Assert.Equal(digest, plan.Hash); + Assert.Equal(CargoTestData.Target, plan.Target); + Assert.Equal([CargoTestData.SourceUrl, ReleaseUrl], fixture.Requests.Select(u => u.AbsoluteUri)); + } + + [Fact] + public async Task GitHubChecksumAssetSelectsOnlyTheMatchingFilename() + { + using var fixture = new CargoTestHttpFixture(); + var manifest = Source(fixture); + string digest = new string('b', 128); + fixture.Text(ReleaseUrl, $$""" + {"tag_name":"v1.2.3","assets":[ + {"name":"{{AssetName}}","browser_download_url":"{{AssetUrl}}"}, + {"name":"SHA512SUMS","browser_download_url":"https://github.com/publisher/tool/sums"} + ]} + """); + fixture.Text("https://github.com/publisher/tool/sums", + new string('a', 128) + " other.zip\n" + digest + " *" + AssetName); + using var http = new CargoHttp(); + var plan = Assert.IsType(await Resolve(http, manifest)); + Assert.Equal("sha512:" + digest, plan.Hash); + Assert.Equal(AssetUrl, plan.Url.AbsoluteUri); + Assert.Equal(3, fixture.Requests.Count); + } + + [Fact] + public async Task MissingIntegrityFallsBackButAnInvalidPresentChecksumFails() + { + using var fixture = new CargoTestHttpFixture(); + var manifest = Source(fixture, """ + [package.metadata.binstall] + pkg-url = "https://publisher.test/{name}/{version}/fixture.zip" + """); + fixture.Status(CargoTestData.BinaryUrl + ".sha256", HttpStatusCode.NotFound); + fixture.Status(CargoTestData.BinaryUrl + ".sha512", HttpStatusCode.Gone); + using var http = new CargoHttp(); + List log = []; + Assert.Null(await Resolve(http, manifest, log)); + Assert.Contains("no supported checksum", Assert.Single(log)); + fixture.Text(CargoTestData.BinaryUrl + ".sha256", "not a valid checksum"); + await Assert.ThrowsAsync(() => Resolve(http, manifest)); + Assert.DoesNotContain(fixture.Requests, u => u.AbsoluteUri == CargoTestData.BinaryUrl); + } + + [Theory] + [InlineData("missing")] + [InlineData("ambiguous")] + public async Task MissingOrAmbiguousPublisherAssetsSelectExplainedSourceFallback(string scenario) + { + using var fixture = new CargoTestHttpFixture(); + var manifest = Source(fixture); + if (scenario == "missing") + { + fixture.Status(ReleaseUrl, HttpStatusCode.NotFound); + fixture.Status(ReleaseUrl.Replace("/v1.2.3", "/1.2.3"), HttpStatusCode.NotFound); + } + else + fixture.Text(ReleaseUrl, $$""" + {"tag_name":"v1.2.3","assets":[ + {"name":"{{AssetName}}","browser_download_url":"{{AssetUrl}}"}, + {"name":"fixture-tool-v1.2.3-x86_64-pc-windows-msvc.zip","browser_download_url":"{{AssetUrl}}"} + ]} + """); + using var http = new CargoHttp(); + List log = []; + Assert.Null(await Resolve(http, manifest, log)); + Assert.Contains(scenario == "missing" ? "No compatible" : "refusing to guess", Assert.Single(log)); + Assert.DoesNotContain(fixture.Requests, u => u.Host.Contains("quickinstall", StringComparison.Ordinal)); + } + + [Theory] + [InlineData("other-package", "1.2.3")] + [InlineData("fixture-tool", "1.2.4")] + public async Task SourceManifestIdentityMismatchFailsInsteadOfFallingBack(string name, string version) + { + using var fixture = new CargoTestHttpFixture(); + var manifest = Source(fixture, name: name, version: version); + using var http = new CargoHttp(); + Assert.Contains("does not match", (await Assert.ThrowsAsync(() => + Resolve(http, manifest))).Message); + Assert.Equal(CargoTestData.SourceUrl, Assert.Single(fixture.Requests).AbsoluteUri); + } + + [Fact] + public async Task SourceArchiveChecksumMismatchStopsBeforeManifestOrPublisherTrust() + { + using var fixture = new CargoTestHttpFixture(); + var manifest = Source(fixture); + manifest = manifest with { versions = [manifest.versions[0] with { checksum = new string('0', 64) }] }; + using var http = new CargoHttp(); + List log = []; + Assert.Contains("published checksum", (await Assert.ThrowsAsync(() => + Resolve(http, manifest, log))).Message); + Assert.Empty(log); + Assert.Single(fixture.Requests); + } + + [Theory] + [InlineData("signing = { algorithm = 'minisign' }")] + [InlineData("disabled-strategies = ['crate-meta-data']")] + [InlineData("pkg-url = 'https://publisher.test/latest/tool.zip'")] + [InlineData("pkg-url = 'https://publisher.test/{version}/{unknown}.zip'")] + [InlineData("pkg-url = 'http://publisher.test/{version}/tool.zip'")] + [InlineData("pkg-url = 'https://publisher.test/{version}/tool.xz'")] + public async Task UnsupportedPublisherMetadataFallsBackWithoutGuessing(string metadata) + { + using var fixture = new CargoTestHttpFixture(); + var manifest = Source(fixture, "[package.metadata.binstall]\n" + metadata); + using var http = new CargoHttp(); + List log = []; + Assert.Null(await Resolve(http, manifest, log)); + Assert.Contains("Using Cargo to build from source", Assert.Single(log)); + Assert.Equal(CargoTestData.SourceUrl, Assert.Single(fixture.Requests).AbsoluteUri); + } + + [Theory] + [InlineData("x86_64-pc-windows-msvc", true)] + [InlineData("aarch64-pc-windows-msvc", true)] + [InlineData("x86_64-unknown-linux-gnu", true)] + [InlineData("aarch64-unknown-linux-musl", true)] + [InlineData("x86_64-apple-darwin", true)] + [InlineData("aarch64-apple-darwin", true)] + [InlineData("i686-pc-windows-msvc", false)] + [InlineData("x86_64-pc-windows-gnu", false)] + [InlineData("wasm32-unknown-unknown", false)] + public void SupportedTargetSelectionIsExplicit(string target, bool expected) => + Assert.Equal(expected, CargoBinaryResolver.IsSupportedTarget(target)); + + [Theory] + [InlineData("fixture-tool-1.2.3-x86_64-unknown-linux-gnu.tar.gz", true)] + [InlineData("tool-v1.2.3-x86_64-unknown-linux-gnu.zip", true)] + [InlineData("fixture-tool-1.2.3-x86_64-unknown-linux-musl.tar.gz", false)] + [InlineData("fixture-tool-1.2.3-aarch64-unknown-linux-gnu.tar.gz", false)] + [InlineData("fixture-tool-1.2.4-x86_64-unknown-linux-gnu.zip", false)] + [InlineData("fixture-tool-1.2.3.4-x86_64-unknown-linux-gnu.zip", false)] + [InlineData("fixture-tool-1.2.30-x86_64-unknown-linux-gnu.zip", false)] + [InlineData("fixture-tool-1.2.3-x86_64-unknown-linux-gnu-extra.zip", false)] + [InlineData("unrelated-1.2.3-x86_64-unknown-linux-gnu.zip", false)] + [InlineData("fixture-tool-1.2.3-x86_64-unknown-linux-gnu.zip.sha256", false)] + public void AssetSelectionRequiresPackageVersionArchitectureAndLibc(string name, bool expected) => + Assert.Equal(expected, CargoBinaryResolver.MatchesAsset(name, CargoTestData.Id, + CargoTestData.Version, "x86_64-unknown-linux-gnu", ["tool"])); + + [Fact] + public void ExpandSubstitutesOnlyKnownAvailableVariables() + { + Assert.Equal("https://publisher.test/tool/fixture-tool/1.2.3/x86_64-pc-windows-msvc/tool.exe.zip", + CargoBinaryResolver.Expand("{repo}/{ name }/{version}/{target}/{bin}{binary-ext}{archive-suffix}", + CargoTestData.Id, CargoTestData.Version, CargoTestData.Target, + "https://publisher.test/tool/", "zip", "tool")); + Assert.Null(CargoBinaryResolver.Expand("{unknown}", "id", "1", "target", null, null, null)); + Assert.Null(CargoBinaryResolver.Expand("{bin}", "id", "1", "target", null, null, null)); + } + + [Fact] + public void ChecksumParserRequiresOneValidMatchingDigestAndVerifierRejectsTampering() + { + byte[] content = [1, 3, 5, 7]; + string digest = CargoTestData.Hash(content); + Assert.Equal(digest, CargoBinaryResolver.ParseChecksum( + new string('f', 64) + " other.zip\n" + digest[7..] + " *tool.zip\n" + digest[7..] + " tool.zip", "tool.zip")); + Assert.Null(CargoBinaryResolver.ParseChecksum( + digest[7..] + " tool.zip\n" + new string('0', 64) + " tool.zip", "tool.zip")); + Assert.Null(CargoBinaryResolver.ParseChecksum(digest[7..] + " other.zip", "tool.zip")); + Assert.Null(CargoBinaryResolver.ParseChecksum(new string('g', 64), "tool.zip")); + CargoBinaryResolver.VerifyHash(content, digest); + CargoBinaryResolver.VerifyHash(content, "sha512:" + Convert.ToHexString(SHA512.HashData(content))); + Assert.Contains("published checksum", Assert.Throws(() => + CargoBinaryResolver.VerifyHash([1, 3, 5, 8], digest)).Message); + Assert.Contains("unsupported", Assert.Throws(() => + CargoBinaryResolver.VerifyHash(content, "md5:" + new string('0', 32))).Message); + } + + [Theory] + [InlineData("draft")] + [InlineData("wrong-tag")] + public async Task InvalidReleaseIdentityFailsInsteadOfTryingAnotherPublisher(string scenario) + { + using var fixture = new CargoTestHttpFixture(); + var manifest = Source(fixture); + string draft = scenario == "draft" ? "true" : "false"; + string tag = scenario == "draft" ? "v1.2.3" : "v1.2.4"; + fixture.Text(ReleaseUrl, $$"""{"tag_name":"{{tag}}","draft":{{draft}},"assets":[]}"""); + using var http = new CargoHttp(); + Assert.Contains("does not match", (await Assert.ThrowsAsync(() => + Resolve(http, manifest))).Message); + Assert.Equal([CargoTestData.SourceUrl, ReleaseUrl], fixture.Requests.Select(u => u.AbsoluteUri)); + } + + [Fact] + public async Task UnsupportedTargetAndMissingBinaryNamesFallbackBeforeAnyNetwork() + { + using var fixture = new CargoTestHttpFixture(); + using var http = new CargoHttp(); + var manifest = CargoTestData.Manifest(CargoTestData.VersionInfo(binaries: [])); + List log = []; + Assert.Null(await Resolve(http, manifest, log, target: "i686-pc-windows-msvc")); + Assert.Contains("host target", Assert.Single(log)); + log.Clear(); + Assert.Null(await Resolve(http, manifest, log)); + Assert.Contains("no known executable names", Assert.Single(log)); + Assert.Empty(fixture.Requests); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/CargoClientTests.cs b/src/UniGetUI.PackageEngine.Tests/CargoClientTests.cs index 57b6e883dd..9548b54b89 100644 --- a/src/UniGetUI.PackageEngine.Tests/CargoClientTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/CargoClientTests.cs @@ -3,11 +3,13 @@ namespace UniGetUI.PackageEngine.Tests; +[Collection(nameof(OperationOrchestrationTestCollection))] public sealed class CargoClientTests : IDisposable { public void Dispose() { CratesIOClient.TEST_ApiUrlOverride = null; + CratesIOClient.InvalidateCache(); } [Fact] @@ -97,4 +99,75 @@ public void GetManifest_ThrowsWhenResponseContainsNullCrate() Assert.Throws(() => CratesIOClient.GetManifest("ripgrep")); } + + [Fact] + public void SelectUpdateChoosesHighestStableNonYankedVersionRegardlessOfOrder() + { + var manifest = CargoTestData.Manifest( + CargoTestData.VersionInfo("9.0.0", yanked: true), + CargoTestData.VersionInfo("2.0.0-beta.1"), + CargoTestData.VersionInfo("1.4.0"), + CargoTestData.VersionInfo("1.2.4"), + CargoTestData.VersionInfo("invalid"), + CargoTestData.VersionInfo("1.3.0")); + Assert.Equal("1.4.0", CratesIOClient.SelectUpdate(manifest, "1.2.3")?.num); + } + + [Theory] + [InlineData("1.2.3")] + [InlineData("1.2.3+build.5")] + [InlineData("2.0.0")] + public void SelectUpdateNeverOffersEqualBuildMetadataOrDowngrades(string installed) + { + var manifest = CargoTestData.Manifest( + CargoTestData.VersionInfo("1.2.2"), + CargoTestData.VersionInfo("1.2.3+build.99"), + CargoTestData.VersionInfo("3.0.0-alpha.1"), + CargoTestData.VersionInfo("9.0.0", yanked: true)); + Assert.Null(CratesIOClient.SelectUpdate(manifest, installed)); + Assert.Null(CratesIOClient.SelectUpdate(CargoTestData.Manifest(), installed)); + } + + [Fact] + public void SelectUpdatePromotesPrereleaseToStableAndRejectsInvalidInstalledVersion() + { + var manifest = CargoTestData.Manifest(CargoTestData.VersionInfo("1.2.3")); + Assert.Equal("1.2.3", CratesIOClient.SelectUpdate(manifest, "1.2.3-rc.1")?.num); + Assert.Contains("Invalid installed Cargo version", + Assert.Throws(() => CratesIOClient.SelectUpdate(manifest, "invalid")).Message); + } + + [Fact] + public void ManifestCacheAvoidsDuplicateRequestsAndManagerSuccessInvalidatesIt() + { + using var server = new TestHttpServer(_ => (200, """ + {"crate":{"name":"tool","max_stable_version":"1.2.3","max_version":"1.2.3","newest_version":"1.2.3"}, + "versions":[{"num":"1.2.3","checksum":"abc","dl_path":"/download","yanked":false}]} + """, "application/json")); + CratesIOClient.TEST_ApiUrlOverride = server.BaseUri.AbsoluteUri.TrimEnd('/') + "/api/v1"; + Assert.Equal("1.2.3", CratesIOClient.GetManifest("tool").Item2.versions[0].num); + Assert.Equal("1.2.3", CratesIOClient.GetManifest("tool").Item2.versions[0].num); + Assert.Equal(["/api/v1/crates/tool"], server.RequestPaths); + var manager = new Cargo(); + var package = new Infrastructure.Builders.PackageBuilder().WithManager(manager).WithId("tool").Build(); + Assert.Equal(Enums.OperationVeredict.Success, + manager.OperationHelper.GetResult(package, Enums.OperationType.Install, [], 0)); + Assert.Equal("1.2.3", CratesIOClient.GetManifest("tool").Item2.versions[0].num); + Assert.Equal(["/api/v1/crates/tool", "/api/v1/crates/tool"], server.RequestPaths); + } + + [Theory] + [InlineData(429)] + [InlineData(503)] + public async Task ApiErrorsPropagateAndAreNotCachedAsSuccessfulNoUpdateResults(int status) + { + using var server = new TestHttpServer(_ => (status, "unavailable", "text/plain")); + CratesIOClient.TEST_ApiUrlOverride = server.BaseUri.AbsoluteUri.TrimEnd('/') + "/api/v1"; + for (int request = 0; request < 2; request++) + { + var error = await Assert.ThrowsAsync(() => CratesIOClient.GetManifestAsync("tool", default)); + Assert.Equal((System.Net.HttpStatusCode)status, error.StatusCode); + } + Assert.Equal(["/api/v1/crates/tool", "/api/v1/crates/tool"], server.RequestPaths); + } } diff --git a/src/UniGetUI.PackageEngine.Tests/CargoConfigurationTests.cs b/src/UniGetUI.PackageEngine.Tests/CargoConfigurationTests.cs new file mode 100644 index 0000000000..6e388ec840 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/CargoConfigurationTests.cs @@ -0,0 +1,94 @@ +using UniGetUI.PackageEngine.Managers.CargoManager; + +namespace UniGetUI.PackageEngine.Tests; + +[Collection(nameof(OperationOrchestrationTestCollection))] +public sealed class CargoConfigurationTests +{ + [Fact] + public void GetRootHonorsExplicitEnvironmentConfigurationAndHomePrecedence() + { + using var sandbox = new CargoTestSandbox(); + using var environment = sandbox.IsolateEnvironment(); + Assert.Equal(sandbox.PathFor("home"), CargoConfiguration.GetRoot()); + sandbox.WriteText("home/config.toml", "[install]\nroot = 'relative-install'"); + Assert.Equal(sandbox.PathFor("relative-install"), CargoConfiguration.GetRoot()); + using var installRoot = new CargoTestEnvironment(("CARGO_INSTALL_ROOT", sandbox.PathFor("env-root"))); + Assert.Equal(sandbox.PathFor("env-root"), CargoConfiguration.GetRoot()); + Assert.Equal(sandbox.PathFor("explicit-root"), CargoConfiguration.GetRoot(sandbox.PathFor("explicit-root"))); + Assert.Equal(Path.GetFullPath("relative-root", CargoConfiguration.WorkingDirectory), + CargoConfiguration.GetRoot("relative-root")); + } + + [Fact] + public void LegacyConfigWinsAndTomlParserPreservesQuotedAndMultilineValues() + { + using var sandbox = new CargoTestSandbox(); + using var environment = sandbox.IsolateEnvironment(); + sandbox.WriteText("home/config.toml", "[install]\nroot = 'ignored'"); + sandbox.WriteText("home/config", """ + [install] + root = 'legacy path' + [metadata] + "key.with.dots" = '''first + second''' + """); + Assert.Equal(sandbox.PathFor("legacy path"), CargoConfiguration.GetRoot()); + var metadata = CargoConfiguration.Table(CargoConfiguration.ReadConfiguration(), "metadata")!; + Assert.Equal("first\nsecond", CargoConfiguration.String(metadata, "key.with.dots")?.Replace("\r\n", "\n")); + Assert.Null(CargoConfiguration.Table(metadata, "absent")); + Assert.Null(CargoConfiguration.String(metadata, "absent")); + } + + [Fact] + public void HasBuildOverridesDetectsConfigAndEnvironmentWithoutInventingDefaults() + { + using var sandbox = new CargoTestSandbox(); + using var environment = sandbox.IsolateEnvironment(); + Assert.False(CargoConfiguration.HasBuildOverrides()); + sandbox.WriteText("home/config.toml", "[build]\ntarget = 'aarch64-unknown-linux-musl'"); + Assert.True(CargoConfiguration.HasBuildOverrides()); + sandbox.WriteText("home/config.toml", "[build]"); + Assert.False(CargoConfiguration.HasBuildOverrides()); + using var target = new CargoTestEnvironment(("CARGO_BUILD_TARGET", "x86_64-unknown-linux-gnu")); + Assert.True(CargoConfiguration.HasBuildOverrides()); + } + + [Fact] + public void InvalidTomlCannotSilentlyBecomeAnEmptyConfiguration() + { + Assert.ThrowsAny(() => CargoConfiguration.Parse("[install\nroot = 'broken'")); + } + + [Theory] + [InlineData("source")] + [InlineData("registries")] + [InlineData("registry")] + [InlineData("target")] + [InlineData("include")] + public void CargoSourceRegistryAndTargetConfigurationConservativelyDisablesManagedCompilationBypass(string section) + { + using var sandbox = new CargoTestSandbox(); + using var environment = sandbox.IsolateEnvironment(); + sandbox.WriteText("home/config.toml", section == "include" + ? "include = ['other.toml']" : $"[{section}]\nsetting = 'custom'"); + Assert.True(CargoConfiguration.HasBuildOverrides()); + } + + [Theory] + [InlineData("wrong-table")] + [InlineData("wrong-path-type")] + [InlineData("empty-path")] + public void InvalidInstallRootConfigurationDoesNotSilentlyUseTheDefault(string scenario) + { + using var sandbox = new CargoTestSandbox(); + using var environment = sandbox.IsolateEnvironment(); + sandbox.WriteText("home/config.toml", scenario switch + { + "wrong-table" => "install = 42", + "wrong-path-type" => "[install]\nroot = 42", + _ => "[install]\nroot = ''", + }); + Assert.Throws(() => CargoConfiguration.GetRoot()); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/CargoDetailsProvenanceTests.cs b/src/UniGetUI.PackageEngine.Tests/CargoDetailsProvenanceTests.cs new file mode 100644 index 0000000000..2db0a6e5c0 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/CargoDetailsProvenanceTests.cs @@ -0,0 +1,187 @@ +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Managers.CargoManager; +using UniGetUI.PackageEngine.PackageClasses; +using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; +using UniGetUI.PackageEngine.Tests.Infrastructure.Helpers; + +namespace UniGetUI.PackageEngine.Tests; + +[Collection(nameof(OperationOrchestrationTestCollection))] +public sealed class CargoDetailsProvenanceTests : IDisposable +{ + private const string Id = "collision-tool"; + private const string Checksum = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + private const string ManifestPath = "/api/v1/crates/" + Id; + private readonly CargoTestSandbox _sandbox = new(); + private readonly Cargo _manager = new FixtureCargo(); + private readonly TestHttpServer _server = new(_ => (200, """ + { + "crate": { + "name": "collision-tool", + "description": "Public crates.io package, unrelated to the same-id foreign package.", + "homepage": "https://publisher.test/collision-tool", + "keywords": ["cli", "rust"], + "max_stable_version": "2.1.0", + "max_version": "2.1.0", + "newest_version": "2.1.0" + }, + "categories": [ + {"category": "Command-line utilities", "description": "CLI tools", "id": "command-line-utilities"} + ], + "versions": [ + { + "num": "2.1.0", "checksum": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "dl_path": "/api/v1/crates/collision-tool/2.1.0/download", "yanked": false + }, + { + "num": "1.2.3", + "checksum": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "dl_path": "/api/v1/crates/collision-tool/1.2.3/download", + "crate_size": 4096, "license": "MIT", "published_by": {"name": "Fixture Publisher"}, + "updated_at": "2026-10-20T12:00:00Z", "yanked": false + }, + { + "num": "1.0.0", "checksum": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "dl_path": "/api/v1/crates/collision-tool/1.0.0/download", "yanked": false + } + ] + } + """, "application/json")); + + public CargoDetailsProvenanceTests() + { + CratesIOClient.InvalidateCache(); + CratesIOClient.TEST_ApiUrlOverride = _server.BaseUri.AbsoluteUri.TrimEnd('/') + "/api/v1"; + _manager.Initialize(); + // The public wrappers return early for an unready manager. Prove these tests + // reach Cargo's provider rather than passing because initialization was skipped. + Assert.True(_manager.IsReady()); + } + + public void Dispose() + { + CratesIOClient.TEST_ApiUrlOverride = null; + CratesIOClient.InvalidateCache(); + _server.Dispose(); + _sandbox.Dispose(); + } + + [Theory] + [InlineData("git")] + [InlineData("path")] + [InlineData("private-registry")] + public void ForeignSourcesCannotReceiveCratesIoDetailsOrSourceDownloadMetadata(string sourceKind) + { + var foreign = new PackageDetails(Package(ForeignSource(sourceKind))); + _manager.DetailsHelper.GetDetails(foreign); + + AssertNoCratesIoMetadata(foreign); + Assert.Empty(_server.RequestPaths); + + // The same identifier really exists in the fixture registry. Warming its + // cache must not allow the foreign package to inherit its download metadata. + var registry = new PackageDetails(Package()); + _manager.DetailsHelper.GetDetails(registry); + Assert.Equal("1.2.3", registry.Version); + Assert.Equal(Checksum, registry.InstallerHash); + Assert.Equal([ManifestPath], _server.RequestPaths); + + _manager.DetailsHelper.GetDetails(foreign); + AssertNoCratesIoMetadata(foreign); + Assert.Equal([ManifestPath], _server.RequestPaths); + } + + [Theory] + [InlineData("git")] + [InlineData("path")] + [InlineData("private-registry")] + public void ForeignSourcesCannotReceiveCratesIoVersionCatalogs(string sourceKind) + { + var foreign = Package(ForeignSource(sourceKind)); + Assert.Empty(_manager.DetailsHelper.GetVersions(foreign)); + Assert.Empty(_server.RequestPaths); + + Assert.Equal(["2.1.0", "1.2.3", "1.0.0"], _manager.DetailsHelper.GetVersions(Package())); + Assert.Equal([ManifestPath], _server.RequestPaths); + + Assert.Empty(_manager.DetailsHelper.GetVersions(foreign)); + Assert.Equal([ManifestPath], _server.RequestPaths); + } + + [Fact] + public void CratesIoDetailsUseSelectedVersionSourceUrlAndChecksum() + { + var details = new PackageDetails(Package()); + _manager.DetailsHelper.GetDetails(details); + + Assert.Equal("1.2.3", details.Version); + Assert.Equal("Source", details.InstallerType); + Assert.Equal("https://crates.io/api/v1/crates/collision-tool/1.2.3/download", + details.InstallerUrl?.AbsoluteUri); + Assert.Equal(Checksum, details.InstallerHash); + Assert.Equal(4096, details.InstallerSize); + Assert.Equal(new Uri(_server.BaseUri, ManifestPath), details.ManifestUrl); + Assert.Equal("Public crates.io package, unrelated to the same-id foreign package.", details.Description); + Assert.Equal("Fixture Publisher", details.Author); + Assert.Equal("Fixture Publisher", details.Publisher); + Assert.Equal("MIT", details.License); + Assert.Equal("https://publisher.test/collision-tool", details.HomepageUrl?.AbsoluteUri); + Assert.Equal("2026-10-20T12:00:00Z", details.UpdateDate); + Assert.Equal(["cli", "rust", "Command-line utilities"], details.Tags); + Assert.Equal([ManifestPath], _server.RequestPaths); + } + + [Fact] + public void CratesIoVersionCatalogReturnsManifestVersions() + { + Assert.Equal(["2.1.0", "1.2.3", "1.0.0"], _manager.DetailsHelper.GetVersions(Package())); + Assert.Equal([ManifestPath], _server.RequestPaths); + } + + private Package Package(IManagerSource? source = null) => + new PackageBuilder().WithManager(_manager).WithId(Id).WithVersion("1.2.3") + .WithSource(source ?? _manager.DefaultSource).Build(); + + private IManagerSource ForeignSource(string sourceKind) + { + string url = sourceKind switch + { + "git" => "https://github.com/private-owner/collision-tool#deadbeef", + "path" => new Uri(_sandbox.PathFor("local-collision-tool")).AbsoluteUri, + _ => "https://private.test/index", + }; + string name = sourceKind switch + { + "git" => "git+" + url, + "path" => _sandbox.PathFor("local-collision-tool"), + _ => "registry+" + url, + }; + return new SourceBuilder().WithManager(_manager).WithName(name).WithUrl(url).Build(); + } + + private static void AssertNoCratesIoMetadata(PackageDetails details) + { + Assert.Null(details.Version); + Assert.Null(details.Description); + Assert.Null(details.ManifestUrl); + Assert.Null(details.InstallerType); + Assert.Null(details.InstallerUrl); + Assert.Null(details.InstallerHash); + Assert.Equal(0, details.InstallerSize); + Assert.Empty(details.Tags); + } + + // Keep the actual Cargo details provider and public readiness checks, but never + // launch a process or depend on a locally installed Cargo executable. + private sealed class FixtureCargo : Cargo + { + protected override void _loadManagerExecutableFile(out bool found, out string path, out string callArguments) + { + found = true; + path = "cargo-not-launched-by-details-tests"; + callArguments = ""; + } + + protected override void _loadManagerVersion(out string version) => version = "1.99.0-fixture"; + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/CargoHttpTests.cs b/src/UniGetUI.PackageEngine.Tests/CargoHttpTests.cs new file mode 100644 index 0000000000..ea2a44d29a --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/CargoHttpTests.cs @@ -0,0 +1,110 @@ +using System.Net; +using UniGetUI.PackageEngine.Managers.CargoManager; + +namespace UniGetUI.PackageEngine.Tests; + +[Collection(nameof(OperationOrchestrationTestCollection))] +public sealed class CargoHttpTests +{ + private const string Url = "https://publisher.test/asset"; + + [Fact] + public async Task HttpsRedirectsPreservePayloadAndReportConcreteProgress() + { + using var fixture = new CargoTestHttpFixture(); + fixture.Redirect(Url, "/verified"); + fixture.Bytes("https://publisher.test/verified", [7, 8, 9]); + using var http = new CargoHttp(); + List<(ulong Downloaded, ulong Total)> progress = []; + Assert.Equal([7, 8, 9], await http.GetAsync(new Uri(Url), 3, false, CancellationToken.None, + (downloaded, total) => progress.Add((downloaded, total)))); + Assert.Equal([Url, "https://publisher.test/verified"], fixture.Requests.Select(u => u.AbsoluteUri)); + Assert.Equal((3UL, 3UL), progress[^1]); + } + + [Theory] + [InlineData("http://publisher.test/asset")] + [InlineData("https://user:password@publisher.test/asset")] + public async Task NonHttpsAndCredentialUrlsFailBeforeAnyRequest(string url) + { + using var fixture = new CargoTestHttpFixture(); + using var http = new CargoHttp(); + var error = await Assert.ThrowsAsync(() => + http.GetAsync(new Uri(url), 10, false, CancellationToken.None)); + Assert.Contains("non-HTTPS or credential-bearing", error.Message); + Assert.Empty(fixture.Requests); + } + + [Fact] + public async Task RedirectDowngradeAndRedirectLoopsAreRejected() + { + using var fixture = new CargoTestHttpFixture(); + fixture.Redirect(Url, "http://publisher.test/asset"); + using var http = new CargoHttp(); + await Assert.ThrowsAsync(() => + http.GetAsync(new Uri(Url), 10, false, CancellationToken.None)); + Assert.Single(fixture.Requests); + fixture.Requests.Clear(); + fixture.Redirect(Url, Url); + var error = await Assert.ThrowsAsync(() => + http.GetAsync(new Uri(Url), 10, false, CancellationToken.None)); + Assert.Contains("Too many", error.Message); + Assert.Equal(6, fixture.Requests.Count); + } + + [Theory] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.Gone)] + public async Task MissingOptionalAssetsReturnNullButRequiredDownloadsFail(HttpStatusCode status) + { + using var fixture = new CargoTestHttpFixture(); + fixture.Status(Url, status); + using var http = new CargoHttp(); + Assert.Null(await http.GetAsync(new Uri(Url), 10, true, CancellationToken.None)); + var error = await Assert.ThrowsAsync(() => + http.GetAsync(new Uri(Url), 10, false, CancellationToken.None)); + Assert.Equal(status, error.StatusCode); + } + + [Fact] + public async Task DeclaredAndStreamedBoundsAndTruncationAreEnforced() + { + using var fixture = new CargoTestHttpFixture(); + using var http = new CargoHttp(); + fixture.Bytes(Url, [1, 2, 3, 4]); + Assert.Contains("allowed size", (await Assert.ThrowsAsync(() => + http.GetAsync(new Uri(Url), 3, false, CancellationToken.None))).Message); + fixture.Routes[Url] = () => + { + var content = new StreamContent(new NonSeekableStream([1, 2, 3, 4])); + return new(HttpStatusCode.OK) { Content = content }; + }; + Assert.Contains("allowed size", (await Assert.ThrowsAsync(() => + http.GetAsync(new Uri(Url), 3, false, CancellationToken.None))).Message); + fixture.Routes[Url] = () => + { + var content = new ByteArrayContent([1, 2]); + content.Headers.ContentLength = 3; + return new(HttpStatusCode.OK) { Content = content }; + }; + Assert.Contains("truncated", (await Assert.ThrowsAsync(() => + http.GetAsync(new Uri(Url), 3, false, CancellationToken.None))).Message); + } + + [Fact] + public async Task CancellationDoesNotBecomeAMissingAsset() + { + using var fixture = new CargoTestHttpFixture(); + using var http = new CargoHttp(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => + http.GetAsync(new Uri(Url), 10, true, cancellation.Token)); + Assert.Empty(fixture.Requests); + } + + private sealed class NonSeekableStream(byte[] bytes) : MemoryStream(bytes) + { + public override bool CanSeek => false; + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/CargoInstallTrackerTests.cs b/src/UniGetUI.PackageEngine.Tests/CargoInstallTrackerTests.cs new file mode 100644 index 0000000000..0dbf575a25 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/CargoInstallTrackerTests.cs @@ -0,0 +1,361 @@ +using System.Collections; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Nodes; +using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine.Managers.CargoManager; + +namespace UniGetUI.PackageEngine.Tests; + +[Collection(nameof(OperationOrchestrationTestCollection))] +public sealed class CargoInstallTrackerTests +{ + private const string Transaction = "root/.unigetui-cargo-transaction"; + private const string Registry = "(registry+https://github.com/rust-lang/crates.io-index)"; + private static string Key(string version) => $"fixture-tool {version} {Registry}"; + private static string Bin(string name) => name + (OperatingSystem.IsWindows() ? ".exe" : ""); + private static string Hash(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)); + private static CargoBinaryPlan Plan(string version = "1.2.3") => CargoTestData.Plan(version: version); + + private static Task Install(CargoTestSandbox sandbox, string version, params (string Name, byte[] Bytes)[] binaries) => + CargoInstallTracker.InstallAsync(sandbox.PathFor("root"), Plan(version), + binaries.ToDictionary(b => Bin(b.Name), b => b.Bytes), default); + + private static JsonObject Metadata(CargoTestSandbox sandbox) => + JsonNode.Parse(File.ReadAllText(sandbox.PathFor("root/.crates2.json")))!.AsObject(); + + private static void WriteMetadata(CargoTestSandbox sandbox, JsonObject metadata) => + sandbox.WriteText("root/.crates2.json", JsonSerializer.Serialize(metadata, CargoJsonContext.Default.JsonObject)); + + [Fact] + public async Task MultiBinaryInstallAndUpdatePreserveUnrelatedAndUnknownMetadata() + { + using var sandbox = new CargoTestSandbox(); + await Install(sandbox, "1.2.3", ("tool", [1, 2]), ("obsolete", [3])); + sandbox.WriteText("root/.crates.toml", File.ReadAllText(sandbox.PathFor("root/.crates.toml")) + """ + + "unrelated 9.0.0 (path+file:///local)" = ["unrelated"] + [future] + marker = "keep-toml" + """); + sandbox.Write("root/bin/unrelated", [99]); + var before = Metadata(sandbox); + before["future"] = new JsonObject { ["marker"] = "keep-json" }; + before["installs"]![Key("1.2.3")]!["publisher-field"] = "preserve-on-upgrade"; + before["installs"]!["unrelated 9.0.0 (path+file:///local)"] = + new JsonObject { ["bins"] = new JsonArray("unrelated"), ["opaque"] = new JsonArray(7, 8) }; + WriteMetadata(sandbox, before); + + await Install(sandbox, "2.0.0", ("tool", [4, 5]), ("companion", [6, 7, 8])); + Assert.Equal([4, 5], File.ReadAllBytes(sandbox.PathFor("root/bin/" + Bin("tool")))); + Assert.Equal([6, 7, 8], File.ReadAllBytes(sandbox.PathFor("root/bin/" + Bin("companion")))); + Assert.False(File.Exists(sandbox.PathFor("root/bin/" + Bin("obsolete")))); + Assert.Equal([99], File.ReadAllBytes(sandbox.PathFor("root/bin/unrelated"))); + var v1 = CargoConfiguration.Parse(File.ReadAllText(sandbox.PathFor("root/.crates.toml"))); + Assert.Equal("keep-toml", CargoConfiguration.String(CargoConfiguration.Table(v1, "future")!, "marker")); + var installsV1 = CargoConfiguration.Table(v1, "v1")!; + Assert.False(installsV1.ContainsKey(Key("1.2.3"))); + Assert.Equal(new[] { Bin("tool"), Bin("companion") }, CargoInstallTracker.ReadBins(installsV1[Key("2.0.0")])); + Assert.Equal(["unrelated"], CargoInstallTracker.ReadBins(installsV1["unrelated 9.0.0 (path+file:///local)"])); + var after = Metadata(sandbox); + Assert.True(JsonNode.DeepEquals(before["future"], after["future"])); + Assert.True(JsonNode.DeepEquals(before["installs"]!["unrelated 9.0.0 (path+file:///local)"], + after["installs"]!["unrelated 9.0.0 (path+file:///local)"])); + Assert.Null(after["installs"]![Key("1.2.3")]); + var info = after["installs"]![Key("2.0.0")]!; + Assert.Equal("preserve-on-upgrade", info["publisher-field"]!.GetValue()); + Assert.Equal("=2.0.0", info["version_req"]!.GetValue()); + Assert.Equal("release", info["profile"]!.GetValue()); + Assert.Equal(CargoTestData.Target, info["target"]!.GetValue()); + Assert.Equal(new[] { Bin("tool"), Bin("companion") }, info["bins"]!.AsArray().Select(n => n!.GetValue())); + Assert.False(Directory.Exists(sandbox.PathFor(Transaction))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UntrackedAndOtherPackageCollisionsLeaveBothMetadataFilesUntouched(bool tracked) + { + using var sandbox = new CargoTestSandbox(); + await Install(sandbox, "1.2.3", ("original", [1])); + sandbox.Write("root/bin/" + Bin("collision"), [90, 91]); + if (tracked) + { + sandbox.WriteText("root/.crates.toml", File.ReadAllText(sandbox.PathFor("root/.crates.toml")) + + $"\n\"another 1.0.0 {Registry}\" = [\"{Bin("collision")}\"]\n"); + var document = Metadata(sandbox); + document["installs"]![$"another 1.0.0 {Registry}"] = + new JsonObject { ["bins"] = new JsonArray(Bin("collision")) }; + WriteMetadata(sandbox, document); + } + byte[] oldV1 = File.ReadAllBytes(sandbox.PathFor("root/.crates.toml")); + byte[] oldV2 = File.ReadAllBytes(sandbox.PathFor("root/.crates2.json")); + var error = await Assert.ThrowsAsync(() => + Install(sandbox, "2.0.0", ("original", [2]), ("collision", [3]))); + Assert.Contains(tracked ? "owned by another" : "untracked executable", error.Message); + Assert.Equal(oldV1, File.ReadAllBytes(sandbox.PathFor("root/.crates.toml"))); + Assert.Equal(oldV2, File.ReadAllBytes(sandbox.PathFor("root/.crates2.json"))); + Assert.Equal([1], File.ReadAllBytes(sandbox.PathFor("root/bin/" + Bin("original")))); + Assert.Equal([90, 91], File.ReadAllBytes(sandbox.PathFor("root/bin/" + Bin("collision")))); + Assert.False(Directory.Exists(sandbox.PathFor(Transaction))); + } + + [Fact] + public async Task FailureAfterFirstBinaryRollsBackAllBinariesAndBothMetadataFiles() + { + using var sandbox = new CargoTestSandbox(); + await Install(sandbox, "1.2.3", ("aaa", [1]), ("obsolete", [7])); + byte[] oldV1 = File.ReadAllBytes(sandbox.PathFor("root/.crates.toml")); + byte[] oldV2 = File.ReadAllBytes(sandbox.PathFor("root/.crates2.json")); + // Sorted commit writes aaa first, deletes obsolete, then encounters an actual IO + // failure moving a file onto this directory. No timing or injected production hooks. + Directory.CreateDirectory(sandbox.PathFor("root/bin/" + Bin("zzz"))); + Exception? error = await Record.ExceptionAsync(() => + Install(sandbox, "2.0.0", ("aaa", [2]), ("zzz", [9]))); + if (OperatingSystem.IsWindows()) + Assert.IsType(error); + else + Assert.IsAssignableFrom(error); + Assert.Equal([1], File.ReadAllBytes(sandbox.PathFor("root/bin/" + Bin("aaa")))); + Assert.Equal([7], File.ReadAllBytes(sandbox.PathFor("root/bin/" + Bin("obsolete")))); + Assert.Equal(oldV1, File.ReadAllBytes(sandbox.PathFor("root/.crates.toml"))); + Assert.Equal(oldV2, File.ReadAllBytes(sandbox.PathFor("root/.crates2.json"))); + Assert.True(Directory.Exists(sandbox.PathFor("root/bin/" + Bin("zzz")))); + Assert.False(Directory.Exists(sandbox.PathFor(Transaction))); + } + + [Fact] + public async Task PreCanceledInstallDoesNotChangeExistingFilesOrMetadata() + { + using var sandbox = new CargoTestSandbox(); + await Install(sandbox, "1.2.3", ("tool", [1])); + byte[] oldV1 = File.ReadAllBytes(sandbox.PathFor("root/.crates.toml")); + byte[] oldV2 = File.ReadAllBytes(sandbox.PathFor("root/.crates2.json")); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => CargoInstallTracker.InstallAsync( + sandbox.PathFor("root"), Plan("2.0.0"), new Dictionary { [Bin("tool")] = [2] }, + cancellation.Token)); + Assert.Equal([1], File.ReadAllBytes(sandbox.PathFor("root/bin/" + Bin("tool")))); + Assert.Equal(oldV1, File.ReadAllBytes(sandbox.PathFor("root/.crates.toml"))); + Assert.Equal(oldV2, File.ReadAllBytes(sandbox.PathFor("root/.crates2.json"))); + Assert.False(Directory.Exists(sandbox.PathFor(Transaction))); + } + + [Fact] + public async Task CancellationDuringMultiBinaryCommitRestoresOriginalFilesAndMetadata() + { + using var sandbox = new CargoTestSandbox(); + await Install(sandbox, "1.2.3", ("aaa", [1]), ("bbb", [2])); + byte[] oldV1 = File.ReadAllBytes(sandbox.PathFor("root/.crates.toml")); + byte[] oldV2 = File.ReadAllBytes(sandbox.PathFor("root/.crates2.json")); + using var cancellation = new CancellationTokenSource(); + var binaries = new CancelingBinaryMap(new Dictionary + { + [Bin("aaa")] = [3], + [Bin("bbb")] = [4], + }, cancellation, sandbox.PathFor(Transaction + "/journal.json")); + await Assert.ThrowsAnyAsync(() => CargoInstallTracker.InstallAsync( + sandbox.PathFor("root"), Plan("2.0.0"), binaries, cancellation.Token)); + Assert.Equal(1, binaries.CommitLookups); + Assert.Equal([1], File.ReadAllBytes(sandbox.PathFor("root/bin/" + Bin("aaa")))); + Assert.Equal([2], File.ReadAllBytes(sandbox.PathFor("root/bin/" + Bin("bbb")))); + Assert.Equal(oldV1, File.ReadAllBytes(sandbox.PathFor("root/.crates.toml"))); + Assert.Equal(oldV2, File.ReadAllBytes(sandbox.PathFor("root/.crates2.json"))); + Assert.False(Directory.Exists(sandbox.PathFor(Transaction))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RecoverRestoresUncommittedTransactionsAndKeepsCommittedOnes(bool committed) + { + using var sandbox = new CargoTestSandbox(); + await Install(sandbox, "1.2.3", ("tool", [1, 2])); + byte[] oldV1 = File.ReadAllBytes(sandbox.PathFor("root/.crates.toml")); + byte[] oldV2 = File.ReadAllBytes(sandbox.PathFor("root/.crates2.json")); + sandbox.Write(Transaction + "/metadata-v1", oldV1); + sandbox.Write(Transaction + "/metadata-v2", oldV2); + sandbox.Write(Transaction + "/backup-0", [1, 2]); + var journal = new CargoTransactionJournal( + [new(Bin("tool"), true, InstalledHash: Hash([5, 6])), + new(Bin("new-tool"), false, InstalledHash: Hash([9]))], committed, + Hash("[v1]\n"u8.ToArray()), Hash("""{"installs":{}}"""u8.ToArray())); + sandbox.WriteText(Transaction + "/journal.json", + JsonSerializer.Serialize(journal, CargoJsonContext.Default.CargoTransactionJournal)); + sandbox.Write("root/bin/" + Bin("tool"), [5, 6]); + sandbox.Write("root/bin/" + Bin("new-tool"), [9]); + sandbox.WriteText("root/.crates.toml", "[v1]\n"); + sandbox.WriteText("root/.crates2.json", """{"installs":{}}"""); + await CargoInstallTracker.RecoverAsync(sandbox.PathFor("root"), default); + Assert.Equal(committed ? new byte[] { 5, 6 } : [1, 2], + File.ReadAllBytes(sandbox.PathFor("root/bin/" + Bin("tool")))); + Assert.Equal(committed, File.Exists(sandbox.PathFor("root/bin/" + Bin("new-tool")))); + Assert.Equal(committed ? "[v1]\n"u8.ToArray() : oldV1, + File.ReadAllBytes(sandbox.PathFor("root/.crates.toml"))); + Assert.Equal(committed ? """{"installs":{}}"""u8.ToArray() : oldV2, + File.ReadAllBytes(sandbox.PathFor("root/.crates2.json"))); + Assert.False(Directory.Exists(sandbox.PathFor(Transaction))); + await CargoInstallTracker.RecoverAsync(sandbox.PathFor("root"), default); + Assert.False(Directory.Exists(sandbox.PathFor(Transaction))); + } + + [Fact] + public async Task MissingJournalAndUnsafeRecoveryNamesFailWithoutTouchingOutsideFiles() + { + using var sandbox = new CargoTestSandbox(); + sandbox.WriteText("outside", "untouched"); + Directory.CreateDirectory(sandbox.PathFor(Transaction)); + await CargoInstallTracker.RecoverAsync(sandbox.PathFor("root"), default); + Assert.False(Directory.Exists(sandbox.PathFor(Transaction))); + sandbox.Write(Transaction + "/staged-binary", [1]); + Assert.Contains("needs inspection", (await Assert.ThrowsAsync(() => + CargoInstallTracker.RecoverAsync(sandbox.PathFor("root"), default))).Message); + sandbox.WriteText(Transaction + "/journal.json", """ + {"Entries":[{"Name":"../../outside","Existed":false}],"Committed":false} + """); + sandbox.Write(Transaction + "/metadata-v1", File.ReadAllBytes(sandbox.PathFor("root/.crates.toml"))); + sandbox.Write(Transaction + "/metadata-v2", File.ReadAllBytes(sandbox.PathFor("root/.crates2.json"))); + await Assert.ThrowsAsync(() => + CargoInstallTracker.RecoverAsync(sandbox.PathFor("root"), default)); + Assert.Equal("untouched", File.ReadAllText(sandbox.PathFor("outside"))); + Assert.True(Directory.Exists(sandbox.PathFor(Transaction))); + } + + [Theory] + [InlineData("v1")] + [InlineData("v2")] + [InlineData("binary")] + public async Task RecoveryRefusesToOverwriteLaterExternalCargoChanges(string changed) + { + using var sandbox = new CargoTestSandbox(); + await Install(sandbox, "1.2.3", ("tool", [1, 2])); + sandbox.Write(Transaction + "/metadata-v1", File.ReadAllBytes(sandbox.PathFor("root/.crates.toml"))); + sandbox.Write(Transaction + "/metadata-v2", File.ReadAllBytes(sandbox.PathFor("root/.crates2.json"))); + sandbox.Write(Transaction + "/backup-0", [1, 2]); + byte[] plannedV1 = "[v1]\n"u8.ToArray(); + byte[] plannedV2 = """{"installs":{}}"""u8.ToArray(); + var journal = new CargoTransactionJournal( + [new(Bin("tool"), true, InstalledHash: Hash([5, 6])), + new(Bin("new-tool"), false, InstalledHash: Hash([9]))], + false, Hash(plannedV1), Hash(plannedV2)); + sandbox.WriteText(Transaction + "/journal.json", + JsonSerializer.Serialize(journal, CargoJsonContext.Default.CargoTransactionJournal)); + sandbox.Write("root/.crates.toml", changed == "v1" ? "# later Cargo\n[v1]\n"u8.ToArray() : plannedV1); + sandbox.Write("root/.crates2.json", changed == "v2" ? """{"installs":{},"external":true}"""u8.ToArray() : plannedV2); + sandbox.Write("root/bin/" + Bin("tool"), [5, 6]); + sandbox.Write("root/bin/" + Bin("new-tool"), changed == "binary" ? [99] : [9]); + byte[] currentV1 = File.ReadAllBytes(sandbox.PathFor("root/.crates.toml")); + byte[] currentV2 = File.ReadAllBytes(sandbox.PathFor("root/.crates2.json")); + var error = await Assert.ThrowsAsync(() => + CargoInstallTracker.RecoverAsync(sandbox.PathFor("root"), default)); + Assert.Contains(changed == "binary" ? "executable" : "metadata", error.Message); + Assert.Equal(currentV1, File.ReadAllBytes(sandbox.PathFor("root/.crates.toml"))); + Assert.Equal(currentV2, File.ReadAllBytes(sandbox.PathFor("root/.crates2.json"))); + Assert.Equal([5, 6], File.ReadAllBytes(sandbox.PathFor("root/bin/" + Bin("tool")))); + Assert.Equal(changed == "binary" ? new byte[] { 99 } : [9], + File.ReadAllBytes(sandbox.PathFor("root/bin/" + Bin("new-tool")))); + Assert.True(File.Exists(sandbox.PathFor(Transaction + "/journal.json"))); + Assert.Equal([1, 2], File.ReadAllBytes(sandbox.PathFor(Transaction + "/backup-0"))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task LegacyRecoveryWithoutHashesOnlyAcceptsUnchangedOriginalFiles(bool externallyChanged) + { + using var sandbox = new CargoTestSandbox(); + await Install(sandbox, "1.2.3", ("tool", [1, 2])); + byte[] oldV1 = File.ReadAllBytes(sandbox.PathFor("root/.crates.toml")); + byte[] oldV2 = File.ReadAllBytes(sandbox.PathFor("root/.crates2.json")); + sandbox.Write(Transaction + "/metadata-v1", oldV1); + sandbox.Write(Transaction + "/metadata-v2", oldV2); + sandbox.Write(Transaction + "/backup-0", [1, 2]); + sandbox.WriteText(Transaction + "/journal.json", $$""" + {"Entries":[{"Name":"{{Bin("tool")}}","Existed":true}],"Committed":false} + """); + if (externallyChanged) + sandbox.Write("root/bin/" + Bin("tool"), [99]); + if (externallyChanged) + Assert.Contains("changed after", (await Assert.ThrowsAsync(() => + CargoInstallTracker.RecoverAsync(sandbox.PathFor("root"), default))).Message); + else + await CargoInstallTracker.RecoverAsync(sandbox.PathFor("root"), default); + Assert.Equal(oldV1, File.ReadAllBytes(sandbox.PathFor("root/.crates.toml"))); + Assert.Equal(oldV2, File.ReadAllBytes(sandbox.PathFor("root/.crates2.json"))); + Assert.Equal(externallyChanged ? new byte[] { 99 } : [1, 2], + File.ReadAllBytes(sandbox.PathFor("root/bin/" + Bin("tool")))); + Assert.Equal(externallyChanged, Directory.Exists(sandbox.PathFor(Transaction))); + } + + [Fact] + public async Task SourceUpdateArgumentsPreserveInstalledFeaturesProfileAndCrossTarget() + { + using var sandbox = new CargoTestSandbox(); + Assert.Empty(await CargoInstallTracker.GetBuildArgumentsAsync(sandbox.PathFor("root"), "fixture-tool", default)); + await Install(sandbox, "1.2.3", ("tool", [1])); + var metadata = Metadata(sandbox); + var info = metadata["installs"]![Key("1.2.3")]!; + info["features"] = new JsonArray("fast", "color"); + info["all_features"] = true; + info["no_default_features"] = true; + info["profile"] = "custom-release"; + info["target"] = "aarch64-unknown-linux-musl"; + WriteMetadata(sandbox, metadata); + string[] expected = + [ + "--features", CoreTools.EscapeCommandLineArgument("fast,color"), "--all-features", + "--no-default-features", "--profile", CoreTools.EscapeCommandLineArgument("custom-release"), + "--target", CoreTools.EscapeCommandLineArgument("aarch64-unknown-linux-musl"), + ]; + Assert.Equal(expected, await CargoInstallTracker.GetBuildArgumentsAsync(sandbox.PathFor("root"), + "fixture-tool", default, CargoTestData.Target)); + Assert.Equal(expected[..^2], await CargoInstallTracker.GetBuildArgumentsAsync(sandbox.PathFor("root"), + "fixture-tool", default, "aarch64-unknown-linux-musl")); + Assert.Empty(await CargoInstallTracker.GetBuildArgumentsAsync(sandbox.PathFor("root"), "other", default)); + metadata["installs"]![Key("2.0.0")] = info.DeepClone(); + WriteMetadata(sandbox, metadata); + Assert.Contains("ambiguous", (await Assert.ThrowsAsync(() => + CargoInstallTracker.GetBuildArgumentsAsync(sandbox.PathFor("root"), "fixture-tool", default))).Message); + } + + [Fact] + public async Task InvalidMetadataAndEmptyInstallFailBeforeBinaryMutation() + { + using var sandbox = new CargoTestSandbox(); + await Assert.ThrowsAsync(() => Install(sandbox, "1.2.3")); + sandbox.WriteText("root/.crates.toml", "v1 = 42"); + sandbox.WriteText("root/.crates2.json", """{"installs":{}}"""); + await Assert.ThrowsAsync(() => Install(sandbox, "1.2.3", ("tool", [1]))); + sandbox.WriteText("root/.crates.toml", "[v1]"); + sandbox.WriteText("root/.crates2.json", """{"installs":[]}"""); + await Assert.ThrowsAsync(() => Install(sandbox, "1.2.3", ("tool", [1]))); + Assert.False(File.Exists(sandbox.PathFor("root/bin/" + Bin("tool")))); + Assert.Throws(() => CargoInstallTracker.ReadBins("not-an-array")); + } + + // A deterministic cancellation trigger on the dictionary contract used by the + // commit loop: no sleeps, filesystem watcher races, or production test hooks. + private sealed class CancelingBinaryMap( + IReadOnlyDictionary inner, CancellationTokenSource cancellation, string journalPath) + : IReadOnlyDictionary + { + internal int CommitLookups { get; private set; } + public byte[] this[string key] => inner[key]; + public IEnumerable Keys => inner.Keys; + public IEnumerable Values => inner.Values; + public int Count => inner.Count; + public bool ContainsKey(string key) => inner.ContainsKey(key); + public bool TryGetValue(string key, out byte[] value) + { + if (File.Exists(journalPath)) + { + CommitLookups++; + cancellation.Cancel(); + } + return inner.TryGetValue(key, out value!); + } + public IEnumerator> GetEnumerator() => inner.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/CargoInteroperabilityTests.cs b/src/UniGetUI.PackageEngine.Tests/CargoInteroperabilityTests.cs new file mode 100644 index 0000000000..75a6c799b7 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/CargoInteroperabilityTests.cs @@ -0,0 +1,166 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json.Nodes; +using UniGetUI.PackageEngine.Managers.CargoManager; + +namespace UniGetUI.PackageEngine.Tests; + +[Collection(nameof(OperationOrchestrationTestCollection))] +public sealed class CargoInteroperabilityTests +{ + private static string Target => + (RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "aarch64-" : "x86_64-") + + (OperatingSystem.IsWindows() ? "pc-windows-msvc" + : OperatingSystem.IsMacOS() ? "apple-darwin" : "unknown-linux-gnu"); + private static string Bin(string name) => name + (OperatingSystem.IsWindows() ? ".exe" : ""); + + private static Task NativeInstall(CargoTestSandbox sandbox) => CargoInstallTracker.InstallAsync( + sandbox.PathFor("root"), CargoTestData.Plan(target: Target, binaries: ["fixture-one", "fixture-two"]), + new Dictionary + { + [Bin("fixture-one")] = CargoTestData.Executable(Target), + [Bin("fixture-two")] = CargoTestData.Executable(Target), + }, default); + + private static Process StartCargo(CargoTestSandbox sandbox, params string[] arguments) + { + ProcessStartInfo info = new("cargo") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = sandbox.Root, + }; + info.Environment["CARGO_HOME"] = sandbox.PathFor("home"); + info.Environment["CARGO_INSTALL_ROOT"] = sandbox.PathFor("root"); + info.Environment.Remove("CARGO_BUILD_TARGET"); + info.Environment["CARGO_NET_OFFLINE"] = "true"; + info.Environment["CARGO_TERM_COLOR"] = "never"; + foreach (string argument in arguments) + info.ArgumentList.Add(argument); + var process = new Process { StartInfo = info }; + Assert.True(process.Start(), "Cargo did not start."); + return process; + } + + private static async Task<(int ExitCode, string Output, string Error)> RunCargo( + CargoTestSandbox sandbox, params string[] arguments) + { + using Process process = StartCargo(sandbox, arguments); + Task output = process.StandardOutput.ReadToEndAsync(); + Task error = process.StandardError.ReadToEndAsync(); + try + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await process.WaitForExitAsync(timeout.Token); + return (process.ExitCode, await output, await error); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + } + + [Fact] + public async Task RealCargoListsNativeMultiBinaryInstallAndUninstallsFromIsolatedRoot() + { + using var sandbox = new CargoTestSandbox(); + await NativeInstall(sandbox); + var listed = await RunCargo(sandbox, "install", "--list", "--root", sandbox.PathFor("root"), "--offline"); + Assert.True(listed.ExitCode == 0, $"cargo install --list failed: {listed.Error}"); + var entry = Assert.Single(Cargo.ParseInstallList(listed.Output.Replace("\r\n", "\n").Split('\n'))); + Assert.Equal(CargoTestData.Id, entry.Id); + Assert.Equal(CargoTestData.Version, entry.InstalledVersion); + Assert.True(entry.IsCratesIo); + Assert.Contains(" " + Bin("fixture-one"), listed.Output); + Assert.Contains(" " + Bin("fixture-two"), listed.Output); + var removed = await RunCargo(sandbox, "uninstall", "--root", sandbox.PathFor("root"), CargoTestData.Id, "--offline"); + Assert.True(removed.ExitCode == 0, $"cargo uninstall failed: {removed.Error}"); + Assert.False(File.Exists(sandbox.PathFor("root/bin/" + Bin("fixture-one")))); + Assert.False(File.Exists(sandbox.PathFor("root/bin/" + Bin("fixture-two")))); + var v1 = CargoConfiguration.Parse(File.ReadAllText(sandbox.PathFor("root/.crates.toml"))); + Assert.Empty(CargoConfiguration.Table(v1, "v1")!); + Assert.Empty(JsonNode.Parse(File.ReadAllText(sandbox.PathFor("root/.crates2.json")))! + ["installs"]!.AsObject()); + var empty = await RunCargo(sandbox, "install", "--list", "--root", sandbox.PathFor("root"), "--offline"); + Assert.True(empty.ExitCode == 0, empty.Error); + Assert.Empty(Cargo.ParseInstallList(empty.Output.Split('\n'))); + } + + [Fact] + public async Task SeparateCargoProcessWaitsForNativeTrackingLockAndSucceedsAfterRelease() + { + using var sandbox = new CargoTestSandbox(); + await NativeInstall(sandbox); + CargoTrackingLock? held = await CargoTrackingLock.AcquireAsync(sandbox.PathFor("root"), default); + using Process process = StartCargo(sandbox, "uninstall", "--root", sandbox.PathFor("root"), + CargoTestData.Id, "--offline"); + var blocking = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var error = new StringBuilder(); + async Task ReadError() + { + while (await process.StandardError.ReadLineAsync() is { } line) + { + error.AppendLine(line); + if (line.Contains("Blocking", StringComparison.OrdinalIgnoreCase) + && line.Contains("lock", StringComparison.OrdinalIgnoreCase)) + blocking.TrySetResult(); + } + } + Task stderr = ReadError(); + Task stdout = process.StandardOutput.ReadToEndAsync(); + Task exited = process.WaitForExitAsync(); + try + { + Task first = await Task.WhenAny(blocking.Task, exited).WaitAsync(TimeSpan.FromSeconds(30)); + Assert.True(first == blocking.Task, "Cargo did not contend on the native lock: " + error); + Assert.False(process.HasExited); + Assert.True(File.Exists(sandbox.PathFor("root/bin/" + Bin("fixture-one")))); + held.Dispose(); + held = null; + await exited.WaitAsync(TimeSpan.FromSeconds(30)); + await stderr; + await stdout; + Assert.True(process.ExitCode == 0, "Cargo failed after lock release: " + error); + Assert.False(File.Exists(sandbox.PathFor("root/bin/" + Bin("fixture-one")))); + Assert.False(File.Exists(sandbox.PathFor("root/bin/" + Bin("fixture-two")))); + } + finally + { + held?.Dispose(); + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + await stderr; + await stdout; + } + } + + [Fact] + public async Task CanceledContenderDoesNotLeakTrackingLocks() + { + using var sandbox = new CargoTestSandbox(); + string root = sandbox.PathFor("root"); + using (var held = await CargoTrackingLock.AcquireAsync(root, default)) + { + using var cancellation = new CancellationTokenSource(); + Task pending = CargoTrackingLock.AcquireAsync(root, cancellation.Token); + Assert.False(pending.IsCompleted); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(async () => await pending); + } + using var acquired = await CargoTrackingLock.AcquireAsync(root, default); + Assert.Equal(Path.Join(root, ".crates.toml"), acquired.V1.Name); + Assert.Equal(Path.Join(root, ".crates2.json"), acquired.V2.Name); + Assert.True(acquired.V1.CanWrite); + Assert.True(acquired.V2.CanWrite); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/CargoListParsingTests.cs b/src/UniGetUI.PackageEngine.Tests/CargoListParsingTests.cs index b653f5e6d6..9c477d2808 100644 --- a/src/UniGetUI.PackageEngine.Tests/CargoListParsingTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/CargoListParsingTests.cs @@ -8,213 +8,122 @@ private static string[] Lines(string output) => output.Replace("\r\n", "\n").Split('\n', StringSplitOptions.None); [Fact] - public void ParseInstallUpdateList_ParsesRealCargoUpdateOutput() + public void ParseInstallList_PreservesRegistryGitAndPathProvenance() { - var entries = Cargo.ParseInstallUpdateList( - Lines( - """ - Polling registry 'https://index.crates.io/'.. - - Package Installed Latest Needs update - cargo-update v16.4.1 v22.1.1 Yes - cargo-binstall v1.21.1 v1.21.1 No - - cargo-binstall contains removed executables (cargo-binstall.exe), which will be re-installed on update — you can remove it with cargo uninstall cargo-binstall - - """ - ) - ); - - Assert.Equal(2, entries.Count); - - Assert.Equal("cargo-update", entries[0].Id); - Assert.Equal("16.4.1", entries[0].InstalledVersion); - Assert.Equal("22.1.1", entries[0].LatestVersion); - Assert.True(entries[0].NeedsUpdate); - - Assert.Equal("cargo-binstall", entries[1].Id); - Assert.Equal("1.21.1", entries[1].InstalledVersion); - Assert.Equal("1.21.1", entries[1].LatestVersion); - Assert.False(entries[1].NeedsUpdate); - } - - [Fact] - public void ParseInstallUpdateList_SkipsRowWithoutPackageName() - { - List skipped = []; - - var entries = Cargo.ParseInstallUpdateList( - Lines( - """ - Package Installed Latest Needs update - v1.21.1 v1.21.2 Yes - cargo-update v16.4.1 v22.1.1 Yes - """ - ), - skipped - ); - - Assert.Equal("cargo-update", Assert.Single(entries).Id); - Assert.Equal("v1.21.1 v1.21.2 Yes", Assert.Single(skipped)); - } - - [Fact] - public void ParseInstallUpdateList_AcceptsPrereleaseAndBuildMetadataVersions() - { - var entries = Cargo.ParseInstallUpdateList( - Lines( - """ - Package Installed Latest Needs update - bat v0.24.0-beta.1 v0.26.1 Yes - eza v1.0.0+build.5 v1.1.0-rc.2 Yes - """ - ) - ); - - Assert.Equal(2, entries.Count); - Assert.Equal("0.24.0-beta.1", entries[0].InstalledVersion); - Assert.Equal("0.26.1", entries[0].LatestVersion); - Assert.Equal("1.0.0+build.5", entries[1].InstalledVersion); - Assert.Equal("1.1.0-rc.2", entries[1].LatestVersion); - } - - [Fact] - public void ParseInstallUpdateList_StripsAlternativeVersionNote() - { - var entry = Assert.Single( - Cargo.ParseInstallUpdateList( - Lines( - """ - Package Installed Latest Needs update - cargo-binstall v1.21.1 v1.21.2 (v1.23.0-rc.1 available) Yes - """ - ) - ) - ); - - Assert.Equal("cargo-binstall", entry.Id); - Assert.Equal("1.21.1", entry.InstalledVersion); - Assert.Equal("1.21.2", entry.LatestVersion); - Assert.True(entry.NeedsUpdate); - } - - [Theory] - [InlineData("N/A")] - [InlineData("^1.21")] - [InlineData("=1.21.1")] - public void ParseInstallUpdateList_ReportsUnknownLatestVersionAsNull(string latestCell) - { - var entry = Assert.Single( - Cargo.ParseInstallUpdateList( - Lines( - $""" - Package Installed Latest Needs update - cargo-binstall v1.21.1 {latestCell} No - """ - ) - ) - ); - - Assert.Equal("1.21.1", entry.InstalledVersion); - Assert.Null(entry.LatestVersion); - Assert.False(entry.NeedsUpdate); - } - - [Fact] - public void ParseInstallUpdateList_SkipsRowsWithoutParsableInstalledVersion() - { - var entries = Cargo.ParseInstallUpdateList( - Lines( - """ - Package Installed Latest Needs update - cargo-binstall No v1.21.1 Yes - """ - ) - ); - - Assert.Empty(entries); - } - - [Fact] - public void ParseInstallUpdateList_IgnoresGitPackageTable() - { - var entries = Cargo.ParseInstallUpdateList( + var entries = Cargo.ParseInstallList( Lines( """ - Checking 1 git package. - - Package Installed Latest Needs update - mygitpkg 1a2b3c4d5e6f7890abcdef1234567890abcdef12 9876543210fedcba9876543210fedcba98765432 Yes + cargo-binstall v1.21.1: + cargo-binstall.exe + cargo-update v16.4.1: + cargo-install-update.exe + rg v14.1.0 (git+https://github.com/publisher/rg?branch=main#abcdef): + rg.exe + private-tool v2.1.0 (registry+https://registry.example.test/index): + private-tool.exe + local-tool v0.1.0 (C:\Program Files (x86)\local-tool): + local-tool.exe """ ) ); - Assert.Empty(entries); + Assert.Equal(["cargo-binstall", "cargo-update", "rg", "private-tool", "local-tool"], entries.Select(e => e.Id)); + Assert.Equal(["1.21.1", "16.4.1", "14.1.0", "2.1.0", "0.1.0"], entries.Select(e => e.InstalledVersion)); + Assert.True(entries[0].IsCratesIo); + Assert.True(entries[1].IsCratesIo); + Assert.Equal("git+https://github.com/publisher/rg?branch=main#abcdef", entries[2].SourceDescription); + Assert.Equal("registry+https://registry.example.test/index", entries[3].SourceDescription); + Assert.Equal(@"C:\Program Files (x86)\local-tool", entries[4].SourceDescription); + Assert.All(entries.Skip(2), e => Assert.False(e.IsCratesIo)); + Assert.All(entries, e => + { + Assert.Null(e.LatestVersion); + Assert.False(e.NeedsUpdate); + }); } [Fact] - public void ParseInstallUpdateList_IgnoresLinesOutsideTheTable() + public void ParseInstallList_EmptyAndIndentedBinaryOutputProducesNoInventedPackages() { - var entries = Cargo.ParseInstallUpdateList( + Assert.Empty(Cargo.ParseInstallList([])); + var entries = Cargo.ParseInstallList( Lines( """ - Polling registry 'https://index.crates.io/'. - cargo-update is no longer part of its registry — you can remove it with cargo uninstall cargo-update + binary.exe + fake-header v1.0.0: """ ) ); - Assert.Empty(entries); } - [Fact] - public void ParseInstallUpdateList_SkipsRowsWithMissingColumns() + [Theory] + [InlineData("warning: unexpected stdout")] + [InlineData("invalid name v1.0.0:")] + [InlineData("-option v1.0.0:")] + [InlineData("missing-version:")] + [InlineData("tool v:")] + [InlineData("tool v1.0.0")] + [InlineData("Package Installed Latest Needs update")] + public void ParseInstallList_MalformedUnindentedOutputFailsInsteadOfReturningEmptyInventory(string line) { - var entries = Cargo.ParseInstallUpdateList( - Lines( - """ - Package Installed Latest Needs update - bat v0.26.1 Yes - """ - ) - ); - - Assert.Empty(entries); + var error = Assert.Throws(() => + Cargo.ParseInstallList(["valid v1.2.3:", " valid.exe", line])); + Assert.Contains("Unrecognized cargo install --list output", error.Message); } [Fact] - public void ParseInstallUpdateList_ReadsNeedsUpdateFromItsOwnColumn() + public void ParseInstallList_AcceptsPrereleaseAndBuildMetadataVersions() { - var entries = Cargo.ParseInstallUpdateList( + var entries = Cargo.ParseInstallList( Lines( """ - Package Installed Latest Needs update - bat v0.26.1 v0.26.2 No - eza v1.0.0 v1.1.0 Yes + bat v0.24.0-beta.1: + bat + eza v1.0.0+build.5: + eza """ ) ); - Assert.False(entries[0].NeedsUpdate); - Assert.True(entries[1].NeedsUpdate); + Assert.Equal(2, entries.Count); + Assert.Equal("0.24.0-beta.1", entries[0].InstalledVersion); + Assert.Equal("1.0.0+build.5", entries[1].InstalledVersion); } - [Fact] - public void ParseInstallUpdateList_IgnoresNotesWhenNotSeparatedByABlankLine() + [Theory] + [InlineData("legacy", true)] + [InlineData("legacy-slash", true)] + [InlineData("index-slash", true)] + [InlineData("index", true)] + [InlineData("sparse", true)] + [InlineData("registry-sparse", true)] + [InlineData("private-registry", false)] + [InlineData("private-sparse", false)] + [InlineData("git", false)] + [InlineData("path", false)] + public void ParseInstallList_OnlyCratesIoSourcesQualifyForRegistryUpdates(string scenario, bool cratesIo) { + string source = scenario switch + { + "legacy" => "registry+https://github.com/rust-lang/crates.io-index", + "legacy-slash" => "registry+https://github.com/rust-lang/crates.io-index/", + "index-slash" => "registry+https://index.crates.io/", + "index" => "registry+https://index.crates.io", + "sparse" => "sparse+https://index.crates.io/", + "registry-sparse" => "registry+sparse+https://index.crates.io/", + "private-registry" => "registry+https://private.test/index", + "private-sparse" => "sparse+https://private.test/index/", + "git" => "https://github.com/publisher/tool#deadbeef", + _ => "/tmp/local-tool", + }; var entry = Assert.Single( - Cargo.ParseInstallUpdateList( - Lines( - """ - Package Installed Latest Needs update - bat v0.26.1 v0.26.2 Yes - bat contains removed executables (bat.exe), which will be re-installed on update — you can remove it with cargo uninstall bat - """ - ) - ) + Cargo.ParseInstallList(Lines($"tool v1.2.3 ({source}):\n tool")) ); - - Assert.Equal("bat", entry.Id); + Assert.Equal("tool", entry.Id); + Assert.Equal("1.2.3", entry.InstalledVersion); + Assert.Equal(source, entry.SourceDescription); + Assert.Equal(cratesIo, entry.IsCratesIo); } [Fact] diff --git a/src/UniGetUI.PackageEngine.Tests/CargoManagedInstallerTests.cs b/src/UniGetUI.PackageEngine.Tests/CargoManagedInstallerTests.cs new file mode 100644 index 0000000000..3ad8bb3ba4 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/CargoManagedInstallerTests.cs @@ -0,0 +1,221 @@ +using System.Buffers.Binary; +using System.Net; +using System.Text; +using UniGetUI.PackageEngine.Classes.Manager.ManagerHelpers; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces.ManagerProviders; +using UniGetUI.PackageEngine.Managers.CargoManager; +using UniGetUI.PackageEngine.Serializable; +using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; +using UniGetUI.PackageEngine.Tests.Infrastructure.Helpers; + +namespace UniGetUI.PackageEngine.Tests; + +[Collection(nameof(OperationOrchestrationTestCollection))] +public sealed class CargoManagedInstallerTests +{ + [Theory] + [InlineData("x86_64-pc-windows-msvc")] + [InlineData("aarch64-pc-windows-msvc")] + [InlineData("x86_64-unknown-linux-gnu")] + [InlineData("aarch64-unknown-linux-musl")] + [InlineData("x86_64-apple-darwin")] + [InlineData("aarch64-apple-darwin")] + public void ExecutableValidationAcceptsCorrectHeaderAndRejectsOppositeArchitecture(string target) + { + byte[] bytes = CargoTestData.Executable(target); + CargoManagedInstaller.ValidateExecutable(bytes, target); + string other = target.StartsWith("x86_64-", StringComparison.Ordinal) + ? target.Replace("x86_64-", "aarch64-", StringComparison.Ordinal) + : target.Replace("aarch64-", "x86_64-", StringComparison.Ordinal); + Assert.Contains("does not match", Assert.Throws(() => + CargoManagedInstaller.ValidateExecutable(bytes, other)).Message); + Assert.Contains("truncated", Assert.Throws(() => + CargoManagedInstaller.ValidateExecutable(bytes[..63], target)).Message); + } + + [Fact] + public void ExecutableValidationRejectsTextBadMagicAndInvalidPeOffsets() + { + Assert.Throws(() => + CargoManagedInstaller.ValidateExecutable(new byte[128], CargoTestData.Target)); + foreach (int offset in new[] { -1, 63, 123, int.MaxValue }) + { + byte[] bytes = CargoTestData.Executable(); + BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(0x3C), offset); + Assert.Throws(() => + CargoManagedInstaller.ValidateExecutable(bytes, CargoTestData.Target)); + } + } + + [Fact] + public void ExtractBinariesRequiresAllExpectedRenamedExecutablesAndExplicitLayout() + { + byte[] first = CargoTestData.Executable(); + byte[] second = CargoTestData.Executable(); + second[^1] = 42; + byte[] zip = CargoTestData.Zip(("release/tool.exe", first), ("release/companion.exe", second), + ("other/tool.exe", second), ("README", [1])); + var plan = CargoTestData.Plan(binaries: ["tool", "companion"], binaryPath: "release/{bin}{binary-ext}"); + var binaries = CargoManagedInstaller.ExtractBinaries(zip, plan, default); + Assert.Equal(["companion.exe", "tool.exe"], binaries.Keys.Order(StringComparer.Ordinal)); + Assert.Equal(first, binaries["tool.exe"]); + Assert.Equal(second, binaries["companion.exe"]); + Assert.Throws(() => + CargoManagedInstaller.ExtractBinaries(zip, plan with { BinaryPath = null }, default)); + Assert.Throws(() => CargoManagedInstaller.ExtractBinaries( + CargoTestData.Zip(("release/tool.exe", first)), plan, default)); + } + + [Fact] + public void RawExecutableIsAllowedOnlyForOneMatchingBinary() + { + byte[] executable = CargoTestData.Executable("aarch64-unknown-linux-musl"); + var plan = CargoTestData.Plan(target: "aarch64-unknown-linux-musl", format: "bin"); + var files = CargoManagedInstaller.ExtractBinaries(executable, plan, default); + Assert.Equal("tool", Assert.Single(files).Key); + Assert.Equal(executable, files["tool"]); + Assert.Throws(() => + CargoManagedInstaller.ExtractBinaries(executable, plan with { Binaries = ["tool", "second"] }, default)); + Assert.Throws(() => + CargoManagedInstaller.ExtractBinaries(executable, plan with { Target = "x86_64-unknown-linux-musl" }, default)); + } + + [Theory] + [InlineData("CON")] + [InlineData("nul.exe")] + [InlineData("../outside")] + [InlineData("tool/path")] + [InlineData("tool.")] + [InlineData("")] + public void BinaryNamesRejectTraversalAndWindowsDevices(string name) => + Assert.Throws(() => CargoBinaryResolver.ValidateBinaryName(name)); + + [Theory] + [InlineData("elevated")] + [InlineData("features")] + [InlineData("build-target")] + [InlineData("foreign-source")] + public async Task ManagedIneligibleOperationsDeferWithoutHttpOrMutation(string scenario) + { + using var sandbox = new CargoTestSandbox(); + using var environment = sandbox.IsolateEnvironment(); + using var http = new CargoTestHttpFixture(); + var manager = new Cargo(); + var builder = new PackageBuilder().WithManager(manager).WithId("fixture-tool").WithVersion("1.2.3"); + if (scenario == "foreign-source") + builder.WithSource(new SourceBuilder().WithManager(manager).WithName("private") + .WithUrl("https://private.test/index").Build()); + var options = new InstallOptions { CustomInstallLocation = sandbox.PathFor("root") }; + if (scenario == "elevated") + options.RunAsAdministrator = true; + if (scenario == "features") + options.CustomParameters_Install.AddRange(["--features", "fast"]); + using var target = new CargoTestEnvironment(("CARGO_BUILD_TARGET", + scenario == "build-target" ? "aarch64-unknown-linux-musl" : null)); + List logs = []; + List progress = []; + var helper = Assert.IsAssignableFrom(manager.OperationHelper); + Assert.Null(await helper.TryPerformAsync(builder.Build(), options, OperationType.Install, + logs.Add, progress.Add, default)); + Assert.Contains(scenario switch + { + "elevated" => "Elevated", + "foreign-source" => "Non-crates.io", + _ => "source compilation", + }, Assert.Single(logs)); + Assert.Empty(http.Requests); + Assert.Empty(progress); + Assert.False(Directory.Exists(sandbox.PathFor("root"))); + } + + [Theory] + [InlineData("verified")] + [InlineData("missing-checksum")] + [InlineData("missing-binary")] + [InlineData("mismatched-binary")] + public async Task ManagedInstallEnforcesIntegrityAndKeepsExactVersionOnFallback(string scenario) + { + using var sandbox = new CargoTestSandbox(); + using var environment = sandbox.IsolateEnvironment(); + using var fixture = new CargoTestHttpFixture(); + var manager = new Cargo + { + Status = new ManagerStatus { Found = true, ExecutablePath = "cargo", ExecutableCallArgs = "" }, + }; + string target = await manager.GetHostTargetAsync(default) + ?? throw new InvalidOperationException("Local Cargo did not report its host."); + byte[] executable = CargoTestData.Executable(target); + string binaryName = "tool" + (target.Contains("-windows-", StringComparison.Ordinal) ? ".exe" : ""); + byte[] binary = CargoTestData.Zip(("release/" + binaryName, executable)); + byte[] source = CargoTestData.Tar(true, ("fixture-tool-1.2.3/Cargo.toml", Encoding.UTF8.GetBytes(""" + [package] + name = "fixture-tool" + version = "1.2.3" + [package.metadata.binstall] + pkg-url = "https://publisher.test/{name}/{version}/fixture.zip" + pkg-fmt = "zip" + """))); + fixture.Bytes(CargoTestData.SourceUrl, source); + if (scenario == "missing-checksum") + { + fixture.Status(CargoTestData.BinaryUrl + ".sha256", HttpStatusCode.NotFound); + fixture.Status(CargoTestData.BinaryUrl + ".sha512", HttpStatusCode.NotFound); + } + else + fixture.Text(CargoTestData.BinaryUrl + ".sha256", CargoTestData.Hash(binary)[7..]); + if (scenario == "missing-binary") + fixture.Status(CargoTestData.BinaryUrl, HttpStatusCode.NotFound); + else + fixture.Bytes(CargoTestData.BinaryUrl, + scenario == "mismatched-binary" ? [.. binary, 1] : binary); + using var api = new TestHttpServer(_ => (200, $$""" + {"crate":{"name":"fixture-tool","max_stable_version":"1.2.3","max_version":"1.2.3","newest_version":"1.2.3"}, + "versions":[{"num":"1.2.3","checksum":"{{CargoTestData.Hash(source)[7..]}}", + "dl_path":"/download","bin_names":["tool"],"yanked":false}]} + """, "application/json")); + CratesIOClient.TEST_ApiUrlOverride = api.BaseUri.AbsoluteUri.TrimEnd('/') + "/api/v1"; + CratesIOClient.InvalidateCache(); + try + { + var options = new InstallOptions { CustomInstallLocation = sandbox.PathFor("root") }; + var package = new PackageBuilder().WithManager(manager).WithId(CargoTestData.Id) + .WithVersion(CargoTestData.Version).Build(); + var helper = Assert.IsAssignableFrom(manager.OperationHelper); + List log = []; + List progress = []; + Task Run() => + helper.TryPerformAsync(package, options, OperationType.Install, log.Add, progress.Add, default); + if (scenario == "mismatched-binary") + Assert.Contains("published checksum", (await Assert.ThrowsAsync(Run)).Message); + else if (scenario == "verified") + { + Assert.Equal(OperationVeredict.Success, await Run()); + Assert.Equal(executable, File.ReadAllBytes(sandbox.PathFor("root/bin/" + binaryName))); + Assert.Contains(progress, p => p.Stage == OperationProgressStage.Installing); + Assert.Contains(progress, p => p.Stage == OperationProgressStage.Downloading); + } + else + { + Assert.Null(await Run()); + Assert.Contains(log, line => line.Contains("Cargo", StringComparison.Ordinal)); + } + Assert.Equal("1.2.3", options.Version); + Assert.Equal(["install", CargoTestData.Id, "--version", UniGetUI.Core.Tools.CoreTools.EscapeCommandLineArgument("1.2.3"), + "--locked", "--root", UniGetUI.Core.Tools.CoreTools.EscapeCommandLineArgument(options.CustomInstallLocation)], + manager.OperationHelper.GetParameters(package, options, OperationType.Install)); + if (scenario != "verified") + { + Assert.False(File.Exists(sandbox.PathFor("root/bin/" + binaryName))); + Assert.False(File.Exists(sandbox.PathFor("root/.crates.toml"))); + Assert.False(File.Exists(sandbox.PathFor("root/.crates2.json"))); + } + Assert.Equal(["/api/v1/crates/fixture-tool"], api.RequestPaths); + } + finally + { + CratesIOClient.TEST_ApiUrlOverride = null; + CratesIOClient.InvalidateCache(); + } + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/CargoManagedOperationRoutingTests.cs b/src/UniGetUI.PackageEngine.Tests/CargoManagedOperationRoutingTests.cs new file mode 100644 index 0000000000..34e68672d8 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/CargoManagedOperationRoutingTests.cs @@ -0,0 +1,162 @@ +using Devolutions.Now.Policy.Client; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.PackageEngine.Classes.Manager.BaseProviders; +using UniGetUI.PackageEngine.Classes.Manager.ManagerHelpers; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Interfaces.ManagerProviders; +using UniGetUI.PackageEngine.Managers.CargoManager; +using UniGetUI.PackageEngine.Operations; +using UniGetUI.PackageEngine.Serializable; +using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; + +namespace UniGetUI.PackageEngine.Tests; + +[Collection(nameof(OperationOrchestrationTestCollection))] +public sealed class CargoManagedOperationRoutingTests +{ + [Fact] + public async Task LocalManagedSuccessReportsProgressWithoutStartingCargo() + { + bool original = Settings.Get(Settings.K.UseAgentBroker); + Settings.Set(Settings.K.UseAgentBroker, false); + try + { + var manager = new ManagedCargo(); + manager.Helper.Perform = (_, progress, _) => + { + progress(OperationProgress.ForStage(OperationProgressStage.Installing)); + return Task.FromResult(OperationVeredict.Success); + }; + using var operation = CreateOperation(manager, "first"); + Assert.Equal(OperationVeredict.Success, await operation.Run()); + Assert.Equal(["first"], manager.Helper.Calls); + Assert.Equal(OperationProgressStage.Installing, operation.CurrentProgress.Stage); + Assert.False(operation.ProcessWasStarted); + } + finally + { + Settings.Set(Settings.K.UseAgentBroker, original); + } + } + + [Fact] + public async Task ManagedOperationsRunInsideTheExistingCargoSerializationGate() + { + bool original = Settings.Get(Settings.K.UseAgentBroker); + Settings.Set(Settings.K.UseAgentBroker, false); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + try + { + var manager = new ManagedCargo(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + manager.Helper.Perform = async (package, _, _) => + { + if (package.Id == "first") + { + entered.TrySetResult(); + await release.Task; + } + return OperationVeredict.Success; + }; + using var first = CreateOperation(manager, "first"); + using var second = CreateOperation(manager, "second"); + Task firstRun = first.Run(); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Task secondRun = second.Run(); + Assert.True(second.IsWaitingForManager); + Assert.Equal(["first"], manager.Helper.Calls); + release.TrySetResult(); + Assert.Equal([OperationVeredict.Success, OperationVeredict.Success], + await Task.WhenAll(firstRun, secondRun).WaitAsync(TimeSpan.FromSeconds(10))); + Assert.Equal(["first", "second"], manager.Helper.Calls); + Assert.False(first.ProcessWasStarted); + Assert.False(second.ProcessWasStarted); + } + finally + { + release.TrySetResult(); + Settings.Set(Settings.K.UseAgentBroker, original); + } + } + + [Fact] + public async Task BrokerUnavailableNeverCallsTheLocalManagedInstaller() + { + bool original = Settings.Get(Settings.K.UseAgentBroker); + var originalFactory = PackageOperation.BrokerTransportFactory; + var transport = new UnavailableTransport(); + Settings.Set(Settings.K.UseAgentBroker, true); + PackageOperation.BrokerTransportFactory = () => transport; + try + { + var manager = new ManagedCargo(); + using var operation = CreateOperation(manager, "fixture-tool"); + Assert.Equal(OperationVeredict.Failure, await operation.Run()); + Assert.Empty(manager.Helper.Calls); + Assert.False(operation.ProcessWasStarted); + Assert.Contains("/v1/health", transport.Paths); + Assert.False(string.IsNullOrWhiteSpace(operation.Metadata.FailureMessage)); + } + finally + { + PackageOperation.BrokerTransportFactory = originalFactory; + Settings.Set(Settings.K.UseAgentBroker, original); + } + } + + private static ProbeOperation CreateOperation(ManagedCargo manager, string id) => + new(new PackageBuilder().WithManager(manager).WithId(id).WithVersion("1.2.3").Build()); + + private sealed class ProbeOperation(IPackage package) : InstallPackageOperation(package, new InstallOptions(), true) + { + internal Task Run() => PerformOperation(); + internal bool ProcessWasStarted => process.StartInfo.FileName != "" + && ProcessStarted(); + private bool ProcessStarted() + { + try { return process.Id != 0; } + catch (InvalidOperationException) { return false; } + } + } + + private sealed class ManagedCargo : Cargo + { + internal ProbeHelper Helper { get; } + internal ManagedCargo() + { + Helper = new ProbeHelper(this); + OperationHelper = Helper; + Status = new ManagerStatus { Found = true, ExecutablePath = "cargo-must-not-start", ExecutableCallArgs = "" }; + } + } + + private sealed class ProbeHelper(Cargo manager) : BasePkgOperationHelper(manager), IManagedPackageOperationHelper + { + internal List Calls { get; } = []; + internal Func, CancellationToken, Task> Perform { get; set; } = + (_, _, _) => Task.FromResult(OperationVeredict.Success); + public Task TryPerformAsync(IPackage package, InstallOptions options, OperationType operation, + Action log, Action progress, CancellationToken cancellationToken) + { + Calls.Add(package.Id); + return Perform(package, progress, cancellationToken); + } + protected override IReadOnlyList _getOperationParameters(IPackage package, InstallOptions options, OperationType operation) => + ["install", package.Id]; + protected override OperationVeredict _getOperationResult(IPackage package, OperationType operation, + IReadOnlyList processOutput, int returnCode) => OperationVeredict.Failure; + } + + private sealed class UnavailableTransport : IBrokerTransport + { + internal List Paths { get; } = []; + public Devolutions.Now.Policy.Api.Transport Kind => Devolutions.Now.Policy.Api.Transport.HttpNamedPipe; + public Task Send(BrokerTransportRequest request, CancellationToken cancellationToken = default) + { + Paths.Add(request.Path); + throw new BrokerClientException(BrokerClientErrorKind.BrokerUnavailable, "Simulated outage", request.Path); + } + public void Dispose() { } + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/CargoOperationHelperTests.cs b/src/UniGetUI.PackageEngine.Tests/CargoOperationHelperTests.cs index 40ccbe3ab2..4d4f0b8e23 100644 --- a/src/UniGetUI.PackageEngine.Tests/CargoOperationHelperTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/CargoOperationHelperTests.cs @@ -1,9 +1,9 @@ using System.Diagnostics; -using UniGetUI.Core.SettingsEngine; using UniGetUI.Core.Tools; using UniGetUI.PackageEngine.Classes.Manager.ManagerHelpers; using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Interfaces.ManagerProviders; using UniGetUI.PackageEngine.Managers.CargoManager; using UniGetUI.PackageEngine.Operations; using UniGetUI.PackageEngine.PackageClasses; @@ -15,180 +15,116 @@ namespace UniGetUI.PackageEngine.Tests; [Collection(nameof(OperationOrchestrationTestCollection))] public sealed class CargoOperationHelperTests { - private static void WithBinstallAvailable(Action test) - { - string binaryName = OperatingSystem.IsWindows() ? "cargo-binstall.exe" : "cargo-binstall"; - string cargoHome = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); - string binaryPath = Path.Join(cargoHome, "bin", binaryName); - string? previousCargoHome = Environment.GetEnvironmentVariable("CARGO_HOME"); - - try - { - Directory.CreateDirectory(Path.Join(cargoHome, "bin")); - File.WriteAllText(binaryPath, ""); - if (!OperatingSystem.IsWindows()) - File.SetUnixFileMode( - binaryPath, - UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute - ); - - Environment.SetEnvironmentVariable("CARGO_HOME", cargoHome); - var manager = new Cargo(); - Assert.True(manager.HasBinstall); - - test(manager); - } - finally - { - Environment.SetEnvironmentVariable("CARGO_HOME", previousCargoHome); - Directory.Delete(cargoHome, true); - } - } - - private static Package BinstallPackage(Cargo manager) => + private static Package TestPackage(Cargo manager, string id = "ripgrep") => new PackageBuilder() .WithManager(manager) - .WithId("cargo-binstall") - .WithVersion("1.19.0") - .WithNewVersion("1.23.0") + .WithId(id) + .WithVersion("14.1.0") + .WithNewVersion("14.1.1") .Build(); - [Fact] - public void UpdateParametersDropTheCompileStrategyWhenBinstallTargetsItself() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DependenciesAndCommandsNeverUseExtensionsEvenWhenInstalled(bool helpersPresent) { - WithBinstallAvailable(manager => + using var sandbox = new CargoTestSandbox(); + using var environment = sandbox.IsolateEnvironment(); + if (helpersPresent) { - var parameters = manager.OperationHelper.GetParameters( - BinstallPackage(manager), - new InstallOptions(), - OperationType.Update - ); - - Assert.Equal( - ["binstall", "cargo-binstall", "--no-confirm", "--disable-strategies", "compile"], - parameters - ); - }); - } - - [Fact] - public void UpdateParametersKeepEveryStrategyForOtherPackages() - { - WithBinstallAvailable(manager => + foreach (string helper in new[] { "cargo-binstall", "cargo-install-update" }) + { + sandbox.Write("home/bin/" + helper + (OperatingSystem.IsWindows() ? ".exe" : ""), []); + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(sandbox.PathFor("home/bin/" + helper), + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + } + var manager = new Cargo(); + Assert.Empty(manager.Dependencies); + Assert.False(manager.Capabilities.CanSkipIntegrityChecks); + Assert.True(manager.Capabilities.SupportsCustomLocations); + Assert.IsAssignableFrom(manager.OperationHelper); + foreach (string id in new[] { "ripgrep", "cargo-binstall", "cargo-update" }) { - var package = new PackageBuilder() - .WithManager(manager) - .WithId("ripgrep") - .WithVersion("14.1.0") - .WithNewVersion("14.1.1") - .Build(); - - var parameters = manager.OperationHelper.GetParameters( - package, - new InstallOptions(), - OperationType.Update - ); - - Assert.Equal(["binstall", "ripgrep", "--no-confirm"], parameters); - }); + var package = TestPackage(manager, id); + Assert.Equal( + ["install", id, "--version", CoreTools.EscapeCommandLineArgument("14.1.1"), "--locked", "--force"], + manager.OperationHelper.GetParameters(package, new InstallOptions(), OperationType.Update)); + } } - [Fact] - public void AFailedBinstallSelfUpdateRetriesThroughCargoInstall() + [Theory] + [InlineData(OperationType.Install)] + [InlineData(OperationType.Update)] + public void SourceAndStandaloneCommandsPinTheExactSelectedVersion(OperationType operation) { - WithBinstallAvailable(manager => - { - var package = BinstallPackage(manager); - IReadOnlyList output = - [ - "error: failed to move `\\cargo-installAeQfV8\\cargo-binstall.exe` to `\\cargo-binstall.exe`", - "Caused by:", - " Access is denied. (os error 5)", - ]; - - var veredict = manager.OperationHelper.GetResult( - package, - OperationType.Update, - output, - 101 - ); - - Assert.Equal(OperationVeredict.AutoRetry, veredict); - Assert.True(package.OverridenOptions.Cargo_DoNotUseBinstall); - - var retryParameters = manager.OperationHelper.GetParameters( - package, - new InstallOptions(), - OperationType.Update - ); - - Assert.Equal(["install", "cargo-binstall", "--locked", "--force"], retryParameters); - Assert.Equal( - OperationVeredict.Failure, - manager.OperationHelper.GetResult(package, OperationType.Update, output, 101) - ); - }); + var manager = new Cargo(); + var package = TestPackage(manager); + var options = new InstallOptions(); + string version = operation == OperationType.Update ? "14.1.1" : "14.1.0"; + List expected = ["install", "ripgrep", "--version", CoreTools.EscapeCommandLineArgument(version), "--locked"]; + if (operation == OperationType.Update) + expected.Add("--force"); + Assert.Equal(expected, manager.OperationHelper.GetParameters(package, options, operation)); + Assert.Equal(expected, manager.OperationHelper.GetStandaloneParameters(package, options, operation)); + + options.Version = "15.0.0-rc.2"; + expected[3] = CoreTools.EscapeCommandLineArgument("15.0.0-rc.2"); + Assert.Equal(expected, manager.OperationHelper.GetParameters(package, options, operation)); + + options.Version = "15.0.0"; + expected[3] = CoreTools.EscapeCommandLineArgument("15.0.0"); + Assert.Equal(expected, manager.OperationHelper.GetParameters(package, options, operation)); + Assert.Equal(expected, manager.OperationHelper.GetStandaloneParameters(package, options, operation)); } - [Fact] - public void AFailedInstallOfBinstallItselfRetriesThroughCargoInstall() + [Theory] + [InlineData(OperationType.Install)] + [InlineData(OperationType.Update)] + [InlineData(OperationType.Uninstall)] + public void CustomRootAndSourceArgumentsArePreserved(OperationType operation) { - WithBinstallAvailable(manager => + using var sandbox = new CargoTestSandbox(); + var manager = new Cargo(); + var options = new InstallOptions { CustomInstallLocation = sandbox.PathFor("root with spaces") }; + options.CustomParameters_Install.AddRange(["--features", "fast", "--no-default-features"]); + options.CustomParameters_Update.AddRange(["--git", "https://github.com/publisher/tool", "--tag", "v2"]); + options.CustomParameters_Uninstall.AddRange(["--bin", "rg"]); + List expected = operation == OperationType.Uninstall + ? ["uninstall", "ripgrep"] + : ["install", "ripgrep", "--version", CoreTools.EscapeCommandLineArgument( + operation == OperationType.Install ? "14.1.0" : "14.1.1"), "--locked"]; + if (operation == OperationType.Update) + expected.Add("--force"); + expected.AddRange(["--root", CoreTools.EscapeCommandLineArgument(options.CustomInstallLocation)]); + expected.AddRange(operation switch { - var package = BinstallPackage(manager); - - Assert.Equal( - OperationVeredict.AutoRetry, - manager.OperationHelper.GetResult(package, OperationType.Install, [], 1) - ); - - var retryParameters = manager.OperationHelper.GetParameters( - package, - new InstallOptions(), - OperationType.Install - ); - - Assert.Equal( - ["install", "cargo-binstall", "--version", "\"1.19.0\"", "--locked", "--force"], - retryParameters - ); + OperationType.Install => options.CustomParameters_Install, + OperationType.Update => options.CustomParameters_Update, + _ => options.CustomParameters_Uninstall, }); + Assert.Equal(expected, manager.OperationHelper.GetParameters(TestPackage(manager), options, operation)); + Assert.Equal(expected, manager.OperationHelper.GetStandaloneParameters(TestPackage(manager), options, operation)); } [Fact] - public void TheRetryRebuildsTheProcessCommandLineWithoutBinstall() + public void ProcessPreviewPreservesCargoOverrideAndUsesPinnedSourceCommand() { - WithBinstallAvailable(manager => + var manager = new Cargo { - manager.Status = new ManagerStatus + Status = new ManagerStatus { Found = true, - ExecutablePath = Path.Join("C:", "cargo", "bin", "cargo.exe"), - ExecutableCallArgs = "", - }; - var package = BinstallPackage(manager); - using var operation = new InspectableUpdate(package, new InstallOptions()); - - var firstAttempt = operation.PrepareProcessStartInfoForTests(); - - Assert.Equal( - Path.Join("C:", "cargo", "bin", "cargo.exe"), - firstAttempt.FileName - ); - Assert.Equal( - "binstall cargo-binstall --no-confirm --disable-strategies compile", - firstAttempt.Arguments.Trim() - ); - - Assert.Equal( - OperationVeredict.AutoRetry, - manager.OperationHelper.GetResult(package, OperationType.Update, [], 101) - ); - - var secondAttempt = operation.PrepareProcessStartInfoForTests(); - - Assert.Equal("install cargo-binstall --locked --force", secondAttempt.Arguments.Trim()); - }); + ExecutablePath = Path.Join(Path.GetTempPath(), "custom-cargo.exe"), + ExecutableCallArgs = "+stable", + }, + }; + using var operation = new InspectableUpdate(TestPackage(manager), new InstallOptions()); + ProcessStartInfo info = operation.PrepareProcessStartInfoForTests(); + Assert.Equal(manager.Status.ExecutablePath, info.FileName); + Assert.Equal("+stable install ripgrep --version " + CoreTools.EscapeCommandLineArgument("14.1.1") + + " --locked --force", info.Arguments.Trim()); } private sealed class InspectableUpdate : UpdatePackageOperation @@ -205,95 +141,112 @@ public ProcessStartInfo PrepareProcessStartInfoForTests() } } - [Fact] - public void ABrokeredFailureIsNotRetriedBecauseTheRequestWouldBeIdentical() + [Theory] + [InlineData("--no-confirm")] + [InlineData("--skip-signatures")] + [InlineData("--install-path")] + [InlineData("--install-path=elsewhere")] + [InlineData("--disable-strategies")] + [InlineData("--disable-strategies=compile")] + [InlineData("--strategies=quick-install")] + [InlineData("--pkg-url=https://publisher.test/tool")] + [InlineData("--pkg-fmt")] + [InlineData("--bin-dir")] + [InlineData("--bin-dir=bin")] + [InlineData("--only-signed")] + public void LegacyExtensionArgumentsFailWithMigrationMessage(string argument) { - WithBinstallAvailable(manager => + var manager = new Cargo(); + foreach (OperationType operation in new[] { OperationType.Install, OperationType.Update, OperationType.Uninstall }) { - bool originalSetting = Settings.Get(Settings.K.UseAgentBroker); - Settings.Set(Settings.K.UseAgentBroker, true); - try - { - var package = BinstallPackage(manager); + var options = new InstallOptions(); + options.CustomParameters_Install.Add(argument); + options.CustomParameters_Update.Add(argument); + options.CustomParameters_Uninstall.Add(argument); + var error = Assert.Throws(() => + manager.OperationHelper.GetParameters(TestPackage(manager), options, operation)); + Assert.Contains("Cargo extension arguments are no longer supported", error.Message); + } + } - Assert.Equal( - OperationVeredict.Failure, - manager.OperationHelper.GetResult(package, OperationType.Update, [], 101) - ); - Assert.False(package.OverridenOptions.Cargo_DoNotUseBinstall); - } - finally - { - Settings.Set(Settings.K.UseAgentBroker, originalSetting); - } - }); + [Theory] + [InlineData(OperationType.Install)] + [InlineData(OperationType.Update)] + public async Task IntegritySkipIsRejectedByCommandAndManagedPaths(OperationType operation) + { + var manager = new Cargo(); + var options = new InstallOptions { SkipHashCheck = true }; + var package = TestPackage(manager); + Assert.Contains("integrity verification", Assert.Throws(() => + manager.OperationHelper.GetParameters(package, options, operation)).Message); + var helper = Assert.IsAssignableFrom(manager.OperationHelper); + var error = await Assert.ThrowsAsync(() => + helper.TryPerformAsync(package, options, operation, _ => { }, _ => { }, CancellationToken.None)); + Assert.Contains("integrity verification", error.Message); } [Fact] - public void ACustomInstallPathKeepsTheUntouchedBinstallCommandLine() + public async Task ManagedUninstallDefersToCargoWithoutNetworkOrMutation() { - WithBinstallAvailable(manager => - { - var options = new InstallOptions - { - CustomInstallLocation = Path.Join("D:", "tools", "bin"), - }; - - var parameters = manager.OperationHelper.GetParameters( - BinstallPackage(manager), - options, - OperationType.Update - ); - - Assert.Equal( - [ - "binstall", - "cargo-binstall", - "--no-confirm", - "--install-path", - CoreTools.EscapeCommandLineArgument(Path.Join("D:", "tools", "bin")), - ], - parameters - ); - }); + using var sandbox = new CargoTestSandbox(); + using var environment = sandbox.IsolateEnvironment(); + using var http = new CargoTestHttpFixture(); + var manager = new Cargo(); + var helper = Assert.IsAssignableFrom(manager.OperationHelper); + Assert.Null(await helper.TryPerformAsync(TestPackage(manager), new InstallOptions(), + OperationType.Uninstall, _ => { }, _ => { }, CancellationToken.None)); + Assert.Empty(http.Requests); + Assert.False(Directory.Exists(sandbox.PathFor("root"))); + Assert.Equal(["uninstall", "ripgrep"], + manager.OperationHelper.GetParameters(TestPackage(manager), new InstallOptions { SkipHashCheck = true }, + OperationType.Uninstall)); } - [Fact] - public void ACustomInstallPathIsNeverRelocatedByTheRetry() + [Theory] + [InlineData("cargo-binstall")] + [InlineData("cargo-update")] + [InlineData("ripgrep")] + public void FailedOperationsNeverRetryThroughExtensions(string id) { - WithBinstallAvailable(manager => + var manager = new Cargo(); + var package = TestPackage(manager, id); + foreach (OperationType operation in new[] { OperationType.Install, OperationType.Update }) { - var package = BinstallPackage(manager); - var options = new InstallOptions - { - CustomInstallLocation = Path.Join("D:", "tools", "bin"), - }; - manager.OperationHelper.GetParameters(package, options, OperationType.Update); - - Assert.Equal( - OperationVeredict.Failure, - manager.OperationHelper.GetResult(package, OperationType.Update, [], 101) - ); - Assert.False(package.OverridenOptions.Cargo_DoNotUseBinstall); - }); + Assert.Equal(OperationVeredict.Failure, manager.OperationHelper.GetResult(package, operation, [], 101)); + Assert.Equal(OperationVeredict.Success, manager.OperationHelper.GetResult(package, operation, [], 0)); + } } - [Fact] - public void AFailedOperationOnOtherPackagesIsNotRetried() + [Theory] + [InlineData("git")] + [InlineData("registry")] + [InlineData("path")] + public void SourceUpdateCommandPreservesInstalledProvenance(string sourceKind) { - WithBinstallAvailable(manager => + using var sandbox = new CargoTestSandbox(); + var manager = new Cargo(); + string url = sourceKind switch { - var package = new PackageBuilder() - .WithManager(manager) - .WithId("ripgrep") - .WithVersion("14.1.0") - .Build(); - - Assert.Equal( - OperationVeredict.Failure, - manager.OperationHelper.GetResult(package, OperationType.Update, [], 101) - ); - Assert.False(package.OverridenOptions.Cargo_DoNotUseBinstall); + "git" => "https://github.com/publisher/tool?branch=main#abcdef", + "registry" => "https://private.test/index", + _ => new Uri(sandbox.PathFor("local source")).AbsoluteUri, + }; + string sourceName = sourceKind == "registry" ? "registry+" + url : sourceKind + "+" + url; + var source = new SourceBuilder().WithManager(manager).WithName(sourceName).WithUrl(url).Build(); + var package = new PackageBuilder().WithManager(manager).WithSource(source).WithId("tool") + .WithVersion("1.0.0").WithNewVersion("1.2.3").Build(); + List expected = ["install", "tool", "--version", CoreTools.EscapeCommandLineArgument("1.2.3"), + "--locked", "--force"]; + expected.AddRange(sourceKind switch + { + "git" => ["--git", CoreTools.EscapeCommandLineArgument("https://github.com/publisher/tool"), + "--rev", CoreTools.EscapeCommandLineArgument("abcdef")], + "registry" => ["--index", CoreTools.EscapeCommandLineArgument(url)], + _ => new[] { "--path", CoreTools.EscapeCommandLineArgument(sandbox.PathFor("local source")) }, }); + Assert.Equal(expected, manager.OperationHelper.GetParameters(package, new InstallOptions(), OperationType.Update)); + Assert.Equal(expected, manager.OperationHelper.GetStandaloneParameters(package, new InstallOptions(), OperationType.Update)); + Assert.Equal(["uninstall", "tool"], + manager.OperationHelper.GetParameters(package, new InstallOptions(), OperationType.Uninstall)); } } diff --git a/src/UniGetUI.PackageEngine.Tests/Infrastructure/Helpers/CargoTestFixtures.cs b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Helpers/CargoTestFixtures.cs new file mode 100644 index 0000000000..3a7e11d219 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Helpers/CargoTestFixtures.cs @@ -0,0 +1,191 @@ +using System.Buffers.Binary; +using System.Formats.Tar; +using System.IO.Compression; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using UniGetUI.PackageEngine.Managers.CargoManager; + +namespace UniGetUI.PackageEngine.Tests; + +internal sealed class CargoTestSandbox : IDisposable +{ + internal string Root { get; } = Path.Join(Path.GetTempPath(), "UniGetUI-CargoTests", Guid.NewGuid().ToString("N")); + + internal CargoTestSandbox() => Directory.CreateDirectory(Root); + + internal string PathFor(string name) => Path.Join(Root, name.Replace('/', Path.DirectorySeparatorChar)); + + internal void Write(string name, byte[] bytes) + { + string path = PathFor(name); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllBytes(path, bytes); + } + + internal void WriteText(string name, string text) => Write(name, Encoding.UTF8.GetBytes(text)); + + internal IDisposable IsolateEnvironment() => new CargoTestEnvironment( + ("CARGO_HOME", PathFor("home")), ("CARGO_INSTALL_ROOT", null), ("CARGO_BUILD_TARGET", null), + ("RUSTFLAGS", null), ("CARGO_ENCODED_RUSTFLAGS", null)); + + public void Dispose() => Directory.Delete(Root, recursive: true); +} + +internal sealed class CargoTestEnvironment : IDisposable +{ + private readonly (string Name, string? Value)[] _previous; + + internal CargoTestEnvironment(params (string Name, string? Value)[] variables) + { + _previous = variables.Select(v => (v.Name, Environment.GetEnvironmentVariable(v.Name))).ToArray(); + foreach (var (name, value) in variables) + Environment.SetEnvironmentVariable(name, value); + } + + public void Dispose() + { + foreach (var (name, value) in _previous) + Environment.SetEnvironmentVariable(name, value); + } +} + +internal sealed class CargoTestHttpFixture : IDisposable +{ + private readonly Func? _previous = CargoHttp.TestHandlerFactory; + internal Dictionary> Routes { get; } = new(StringComparer.Ordinal); + internal List Requests { get; } = []; + + internal CargoTestHttpFixture() => CargoHttp.TestHandlerFactory = () => new Handler(this); + + internal void Bytes(string url, byte[] bytes) => + Routes[url] = () => new(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) }; + + internal void Text(string url, string text) => Bytes(url, Encoding.UTF8.GetBytes(text)); + + internal void Status(string url, HttpStatusCode status) => Routes[url] = () => new(status); + + internal void Redirect(string url, string location) + { + Routes[url] = () => + { + var response = new HttpResponseMessage(HttpStatusCode.Found); + response.Headers.Location = new Uri(location, UriKind.RelativeOrAbsolute); + return response; + }; + } + + public void Dispose() => CargoHttp.TestHandlerFactory = _previous; + + private sealed class Handler(CargoTestHttpFixture fixture) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Uri uri = request.RequestUri!; + Assert.Equal("https", uri.Scheme); + Assert.Equal(HttpMethod.Get, request.Method); + fixture.Requests.Add(uri); + if (!fixture.Routes.TryGetValue(uri.AbsoluteUri, out var respond)) + throw new InvalidOperationException("Unexpected fake Cargo request: " + uri); + return Task.FromResult(respond()); + } + } +} + +internal static class CargoTestData +{ + internal const string Id = "fixture-tool"; + internal const string Version = "1.2.3"; + internal const string Target = "x86_64-pc-windows-msvc"; + internal const string SourceUrl = "https://crates.io/api/v1/crates/fixture-tool/1.2.3/download"; + internal const string BinaryUrl = "https://publisher.test/fixture-tool/1.2.3/fixture.zip"; + + internal static string Hash(byte[] bytes) => "sha256:" + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + + internal static CargoBinaryPlan Plan(string version = Version, string target = Target, + string[]? binaries = null, string format = "zip", string? binaryPath = null) => + new(Id, version, target, binaries ?? ["tool"], new Uri(BinaryUrl), format, Hash([]), binaryPath); + + internal static CargoManifest Manifest(params CargoManifestVersion[] versions) => new() + { + crate = new() + { + name = Id, + max_stable_version = Version, + max_version = Version, + newest_version = Version, + }, + versions = versions, + }; + + internal static CargoManifestVersion VersionInfo(string version = Version, string checksum = "", + bool yanked = false, string[]? binaries = null) => new() + { + num = version, + checksum = checksum, + dl_path = "/source-download", + yanked = yanked, + bin_names = binaries ?? ["tool"], + }; + + internal static byte[] Zip(params (string Name, byte[] Bytes)[] entries) + { + using var output = new MemoryStream(); + using (var zip = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true)) + foreach (var (name, bytes) in entries) + { + using Stream stream = zip.CreateEntry(name).Open(); + stream.Write(bytes); + } + return output.ToArray(); + } + + internal static byte[] Tar(bool gzip, params (string Name, byte[] Bytes)[] entries) + { + using var output = new MemoryStream(); + using (var tar = new TarWriter(output, leaveOpen: true)) + foreach (var (name, bytes) in entries) + { + using var content = new MemoryStream(bytes); + tar.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, name) { DataStream = content }); + } + if (!gzip) + return output.ToArray(); + using var compressed = new MemoryStream(); + using (var zip = new GZipStream(compressed, CompressionLevel.Fastest, leaveOpen: true)) + zip.Write(output.ToArray()); + return compressed.ToArray(); + } + + // Minimal inert headers: these payloads are validated/staged, never launched. + internal static byte[] Executable(string target = Target) + { + byte[] bytes = new byte[128]; + bool arm = target.StartsWith("aarch64-", StringComparison.Ordinal); + if (target.Contains("-windows-", StringComparison.Ordinal)) + { + bytes[0] = (byte)'M'; + bytes[1] = (byte)'Z'; + BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(0x3C), 64); + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(64), 0x4550); + BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(68), (ushort)(arm ? 0xAA64 : 0x8664)); + } + else if (target.Contains("-linux-", StringComparison.Ordinal)) + { + bytes[0] = 0x7F; + bytes[1] = (byte)'E'; + bytes[2] = (byte)'L'; + bytes[3] = (byte)'F'; + bytes[4] = 2; + bytes[5] = 1; + BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(18), (ushort)(arm ? 183 : 62)); + } + else + { + BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0xFEEDFACF); + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(4), arm ? 0x100000CU : 0x1000007U); + } + return bytes; + } +} From 8c9eb6667511efaa8c9ca20afbdd2057a01a6b2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Wed, 21 Oct 2026 18:04:40 -0400 Subject: [PATCH 2/3] Preserve Cargo display-only sources in package inventories Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/CLI.md | 2 + .../Cargo.cs | 14 +- .../CargoInventoryTests.cs | 136 ++++++++++++++++++ 3 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 src/UniGetUI.PackageEngine.Tests/CargoInventoryTests.cs diff --git a/docs/CLI.md b/docs/CLI.md index 541690728b..ce9a11f89a 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -115,6 +115,8 @@ UniGetUI checks crates.io versions itself, excluding yanked and prerelease updat Git, local-path, and other-registry packages remain listed with their source identity; they are not matched against crates.io for updates. Their details, version catalogs, and source downloads are not substituted with potentially unrelated same-name crates.io packages. +Named-registry and directory-source display labels remain listed even when Cargo does +not provide a source URL; they do not prevent other packages in the same root from loading. Local, non-elevated installations first attempt a publisher-provided binary for the exact selected version and Cargo host target. The C# installer supports ZIP, TAR, TAR.GZ/TGZ, and diff --git a/src/UniGetUI.PackageEngine.Managers.Cargo/Cargo.cs b/src/UniGetUI.PackageEngine.Managers.Cargo/Cargo.cs index 950f6c55e1..0c405975a4 100644 --- a/src/UniGetUI.PackageEngine.Managers.Cargo/Cargo.cs +++ b/src/UniGetUI.PackageEngine.Managers.Cargo/Cargo.cs @@ -218,10 +218,14 @@ public void InvalidateInstalledCache() CratesIOClient.InvalidateCache(); } - private IReadOnlyList GetPackages(LoggableTaskType taskType) + private IReadOnlyList GetPackages(LoggableTaskType taskType) => + GetPackages(TaskRecycler>.RunOrAttach(GetInstalledCommandOutput, 15), taskType); + + internal IReadOnlyList GetPackages( + IEnumerable entries, + LoggableTaskType taskType) { List Packages = []; - var entries = TaskRecycler>.RunOrAttach(GetInstalledCommandOutput, 15); foreach (var entry in entries) { var name = CoreTools.FormatAsName(entry.Id); @@ -233,7 +237,11 @@ private IReadOnlyList GetPackages(LoggableTaskType taskType) : description.StartsWith("git+", StringComparison.Ordinal) ? description["git+".Length..] : description; if (!Uri.TryCreate(location, UriKind.Absolute, out Uri? sourceUrl)) - throw new InvalidDataException($"Unrecognized Cargo package source: {description}"); + { + sourceUrl = new Uri("about:blank"); + Logger.Debug( + $"Cargo source {description} is a display label without a direct URL; preserving its source identity."); + } source = new ManagerSource(this, description, sourceUrl); } if (taskType is LoggableTaskType.ListUpdates) diff --git a/src/UniGetUI.PackageEngine.Tests/CargoInventoryTests.cs b/src/UniGetUI.PackageEngine.Tests/CargoInventoryTests.cs new file mode 100644 index 0000000000..d0bf7e87ff --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/CargoInventoryTests.cs @@ -0,0 +1,136 @@ +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Managers.CargoManager; +using UniGetUI.PackageEngine.Serializable; +using UniGetUI.PackageEngine.Tests.Infrastructure.Helpers; + +namespace UniGetUI.PackageEngine.Tests; + +[Collection(nameof(OperationOrchestrationTestCollection))] +public sealed class CargoInventoryTests : IDisposable +{ + public void Dispose() + { + CratesIOClient.TEST_ApiUrlOverride = null; + CratesIOClient.InvalidateCache(); + } + + [Theory] + [InlineData("named-registry")] + [InlineData("registry-url")] + [InlineData("registry-path")] + [InlineData("unix-directory")] + [InlineData("windows-directory")] + public void InstalledInventoryPreservesDisplayOnlySourcesWithoutDroppingOtherPackages(string scenario) + { + string description = SourceDescription(scenario); + var entries = Cargo.ParseInstallList( + [ + "public-tool v1.0.0:", + " public-tool", + $"foreign-tool v2.0.0 ({description}):", + " foreign-tool", + "another-public-tool v3.0.0:", + " another-public-tool", + ]); + var manager = new Cargo(); + var packages = manager.GetPackages(entries, LoggableTaskType.ListInstalledPackages); + + Assert.Equal(["public-tool", "foreign-tool", "another-public-tool"], packages.Select(package => package.Id)); + Assert.Equal(["1.0.0", "2.0.0", "3.0.0"], packages.Select(package => package.VersionString)); + Assert.Same(manager.DefaultSource, packages[0].Source); + Assert.Same(manager.DefaultSource, packages[2].Source); + Assert.Equal(description, packages[1].Source.Name); + Assert.Equal(new Uri("about:blank"), packages[1].Source.Url); + Assert.NotEqual(manager.DefaultSource.Name, packages[1].Source.Name); + Assert.Equal(["uninstall", "foreign-tool"], + manager.OperationHelper.GetParameters(packages[1], new InstallOptions(), OperationType.Uninstall)); + var error = Assert.Throws(() => + manager.OperationHelper.GetParameters(packages[1], new InstallOptions(), OperationType.Install)); + Assert.Contains("explicit Cargo source arguments", error.Message); + } + + [Fact] + public void UpdateDiscoverySkipsDisplayOnlySourcesAndStillReturnsCratesIoUpdates() + { + using var server = new TestHttpServer(request => + request.Url?.AbsolutePath == "/api/v1/crates/public-tool" + ? (200, """ + { + "crate": { + "name": "public-tool", + "max_stable_version": "1.1.0", + "max_version": "1.1.0", + "newest_version": "1.1.0" + }, + "versions": [ + {"num":"1.1.0","checksum":"fixture","dl_path":"/source","yanked":false} + ] + } + """, "application/json") + : (500, "A non-crates.io package must not query this server.", "text/plain")); + CratesIOClient.InvalidateCache(); + CratesIOClient.TEST_ApiUrlOverride = server.BaseUri.AbsoluteUri.TrimEnd('/') + "/api/v1"; + var entries = Cargo.ParseInstallList( + [ + "foreign-tool v9.0.0 (registry `company`):", + " foreign-tool", + "public-tool v1.0.0:", + " public-tool", + "vendor-tool v2.0.0 (dir /tmp/vendor):", + " vendor-tool", + ]); + + var manager = new Cargo(); + var installed = manager.GetPackages(entries, LoggableTaskType.ListInstalledPackages); + Assert.Equal(["foreign-tool", "public-tool", "vendor-tool"], installed.Select(package => package.Id)); + Assert.Empty(server.RequestPaths); + + var update = Assert.Single(manager.GetPackages(entries, LoggableTaskType.ListUpdates)); + Assert.Equal("public-tool", update.Id); + Assert.Equal("1.0.0", update.VersionString); + Assert.Equal("1.1.0", update.NewVersionString); + Assert.Same(manager.DefaultSource, update.Source); + Assert.Equal(["/api/v1/crates/public-tool"], server.RequestPaths); + } + + [Theory] + [InlineData("git")] + [InlineData("registry-index")] + [InlineData("local-path")] + public void InstalledInventoryPreservesKnownSourceUris(string scenario) + { + using var sandbox = new CargoTestSandbox(); + string path = sandbox.PathFor("local tool"); + string description = scenario switch + { + "git" => "https://github.com/publisher/tool?branch=main#abcdef", + "registry-index" => "registry+https://private.test/index", + _ => path, + }; + Uri expected = scenario switch + { + "git" => new Uri(description), + "registry-index" => new Uri("https://private.test/index"), + _ => new Uri(path), + }; + var manager = new Cargo(); + var entry = Assert.Single(Cargo.ParseInstallList([$"tool v1.2.3 ({description}):"])); + + var package = Assert.Single(manager.GetPackages([entry], LoggableTaskType.ListInstalledPackages)); + + Assert.Equal("tool", package.Id); + Assert.Equal("1.2.3", package.VersionString); + Assert.Equal(description, package.Source.Name); + Assert.Equal(expected, package.Source.Url); + } + + private static string SourceDescription(string scenario) => scenario switch + { + "named-registry" => "registry `company`", + "registry-url" => "registry `https://private.test/index`", + "registry-path" => @"registry `C:\registry index`", + "unix-directory" => "dir /tmp/vendor", + "windows-directory" => @"dir C:\vendor packages", + _ => throw new ArgumentOutOfRangeException(nameof(scenario)), + }; +} From 3788edc11fdbecb2f83c18bce6ab7cd6d7e148af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Wed, 21 Oct 2026 22:08:41 -0400 Subject: [PATCH 3/3] Make Cargo installer tests independent of runner elevation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CargoManagedInstaller.cs | 11 ++- .../CargoManagedInstallerTests.cs | 70 +++++++++++++++++-- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/src/UniGetUI.PackageEngine.Managers.Cargo/CargoManagedInstaller.cs b/src/UniGetUI.PackageEngine.Managers.Cargo/CargoManagedInstaller.cs index eeddf631cc..a2e39aa46c 100644 --- a/src/UniGetUI.PackageEngine.Managers.Cargo/CargoManagedInstaller.cs +++ b/src/UniGetUI.PackageEngine.Managers.Cargo/CargoManagedInstaller.cs @@ -9,15 +9,22 @@ namespace UniGetUI.PackageEngine.Managers.CargoManager; internal static class CargoManagedInstaller { - internal static async Task TryPerformAsync( + internal static Task TryPerformAsync( Cargo manager, IPackage package, InstallOptions options, OperationType operation, Action log, Action progress, CancellationToken cancellationToken) + => TryPerformAsync(manager, package, options, operation, log, progress, + CoreTools.IsAdministrator(), cancellationToken); + + internal static async Task TryPerformAsync( + Cargo manager, IPackage package, InstallOptions options, OperationType operation, + Action log, Action progress, bool processIsElevated, + CancellationToken cancellationToken) { if (operation == OperationType.Uninstall) return null; if (options.SkipHashCheck) throw new InvalidOperationException("Cargo installation requires integrity verification."); - if (CoreTools.IsAdministrator() || options.RunAsAdministrator + if (processIsElevated || options.RunAsAdministrator || package.OverridenOptions.RunAsAdministrator is true) { log("Elevated Cargo operations use Cargo source compilation."); diff --git a/src/UniGetUI.PackageEngine.Tests/CargoManagedInstallerTests.cs b/src/UniGetUI.PackageEngine.Tests/CargoManagedInstallerTests.cs index 3ad8bb3ba4..7263464f84 100644 --- a/src/UniGetUI.PackageEngine.Tests/CargoManagedInstallerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/CargoManagedInstallerTests.cs @@ -1,6 +1,7 @@ using System.Buffers.Binary; using System.Net; using System.Text; +using UniGetUI.Core.Tools; using UniGetUI.PackageEngine.Classes.Manager.ManagerHelpers; using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Interfaces.ManagerProviders; @@ -115,9 +116,8 @@ public async Task ManagedIneligibleOperationsDeferWithoutHttpOrMutation(string s scenario == "build-target" ? "aarch64-unknown-linux-musl" : null)); List logs = []; List progress = []; - var helper = Assert.IsAssignableFrom(manager.OperationHelper); - Assert.Null(await helper.TryPerformAsync(builder.Build(), options, OperationType.Install, - logs.Add, progress.Add, default)); + Assert.Null(await CargoManagedInstaller.TryPerformAsync(manager, builder.Build(), options, + OperationType.Install, logs.Add, progress.Add, processIsElevated: false, default)); Assert.Contains(scenario switch { "elevated" => "Elevated", @@ -129,6 +129,66 @@ public async Task ManagedIneligibleOperationsDeferWithoutHttpOrMutation(string s Assert.False(Directory.Exists(sandbox.PathFor("root"))); } + [Theory] + [InlineData(true, false, null)] + [InlineData(true, false, false)] + [InlineData(false, true, null)] + [InlineData(false, false, true)] + public async Task ElevatedExecutionAlwaysUsesSourceCompilationWithoutHttpOrMutation( + bool processIsElevated, bool requestedElevation, bool? packageElevation) + { + using var sandbox = new CargoTestSandbox(); + using var environment = sandbox.IsolateEnvironment(); + using var fixture = new CargoTestHttpFixture(); + var manager = new Cargo(); + var package = new PackageBuilder().WithManager(manager).WithId(CargoTestData.Id) + .WithVersion(CargoTestData.Version).Build(); + package.OverridenOptions.RunAsAdministrator = packageElevation; + var options = new InstallOptions + { + RunAsAdministrator = requestedElevation, + CustomInstallLocation = sandbox.PathFor("root"), + }; + List log = []; + List progress = []; + + Assert.Null(await CargoManagedInstaller.TryPerformAsync(manager, package, options, + OperationType.Install, log.Add, progress.Add, processIsElevated, default)); + + Assert.Equal("Elevated Cargo operations use Cargo source compilation.", Assert.Single(log)); + Assert.Empty(fixture.Requests); + Assert.Empty(progress); + Assert.False(Directory.Exists(sandbox.PathFor("root"))); + Assert.Empty(options.Version); + } + + [Fact] + public async Task ShippingEntryUsesActualProcessElevation() + { + using var sandbox = new CargoTestSandbox(); + using var environment = sandbox.IsolateEnvironment(); + using var fixture = new CargoTestHttpFixture(); + var manager = new Cargo(); + var source = new SourceBuilder().WithManager(manager).WithName("private") + .WithUrl("https://private.test/index").Build(); + var package = new PackageBuilder().WithManager(manager).WithSource(source) + .WithId(CargoTestData.Id).WithVersion(CargoTestData.Version).Build(); + var options = new InstallOptions { CustomInstallLocation = sandbox.PathFor("root") }; + var helper = Assert.IsAssignableFrom(manager.OperationHelper); + List log = []; + List progress = []; + + Assert.Null(await helper.TryPerformAsync(package, options, OperationType.Install, + log.Add, progress.Add, default)); + + Assert.Equal(CoreTools.IsAdministrator() + ? "Elevated Cargo operations use Cargo source compilation." + : "Non-crates.io packages use their original source through Cargo.", Assert.Single(log)); + Assert.Empty(fixture.Requests); + Assert.Empty(progress); + Assert.False(Directory.Exists(sandbox.PathFor("root"))); + } + [Theory] [InlineData("verified")] [InlineData("missing-checksum")] @@ -181,11 +241,11 @@ public async Task ManagedInstallEnforcesIntegrityAndKeepsExactVersionOnFallback( var options = new InstallOptions { CustomInstallLocation = sandbox.PathFor("root") }; var package = new PackageBuilder().WithManager(manager).WithId(CargoTestData.Id) .WithVersion(CargoTestData.Version).Build(); - var helper = Assert.IsAssignableFrom(manager.OperationHelper); List log = []; List progress = []; Task Run() => - helper.TryPerformAsync(package, options, OperationType.Install, log.Add, progress.Add, default); + CargoManagedInstaller.TryPerformAsync(manager, package, options, OperationType.Install, + log.Add, progress.Add, processIsElevated: false, default); if (scenario == "mismatched-binary") Assert.Contains("published checksum", (await Assert.ThrowsAsync(Run)).Message); else if (scenario == "verified")