diff --git a/README.md b/README.md index 0b31537..56cb737 100644 --- a/README.md +++ b/README.md @@ -250,13 +250,15 @@ Pinget is designed to keep **source-backed functionality** working cross-platfor ## Storage location and source mode -Two environment variables control where Pinget keeps its state and which sources it resolves -against. Both are read by the Rust CLI and the C# core. +Three environment variables control where Pinget keeps its state, which sources it resolves +against, and which WinGet it delegates source commands to. All three are read by the Rust CLI and +the C# core. | Variable | Effect | | --- | --- | | `PINGET_APPROOT` | Overrides the per-user storage root (`%LOCALAPPDATA%\Devolutions\Pinget` by default). Useful for embedding Pinget in a portable or sandboxed host. | | `PINGET_SOURCE_MODE` | Selects the source mode explicitly: `auto`, `private`, or `system-winget-mirror`. | +| `PINGET_WINGET_PATH` | Full path to the `winget.exe` that Pinget runs for source commands. Accepts the executable or the directory holding it. | Source modes: @@ -276,6 +278,29 @@ Precedence is the same in both implementations: an explicitly chosen mode wins, nullable for exactly this reason — leave it unset to let the environment decide, and note that setting it to `SourceMode.Auto` is an explicit choice that the environment will not override. +### Locating the system WinGet + +WinGet source management belongs to WinGet, so the mirror and `system-winget-mirror` modes shell +out to `winget source ...`. WinGet ships as an App Execution Alias, which means a host that +inherited a PATH without `%LOCALAPPDATA%\Microsoft\WindowsApps` cannot spawn it by name at all. +Pinget therefore looks for it in this order: + +1. `PINGET_WINGET_PATH`, when set - a host that already knows the location should say so, and a + value that holds no `winget.exe` is reported rather than silently ignored +2. the directory of the running executable, which is where Windows looks first when a bare + program name is spawned, so a host that ships its own copy keeps winning +3. the PATH +4. `%LOCALAPPDATA%\Microsoft\WindowsApps`, the execution-alias directory +5. the newest registered `Microsoft.DesktopAppInstaller` package root + +Only the last two are new lookups. The rest is the order a bare `winget` already resolved in, so +a machine that could run WinGet before resolves the very same executable. + +A query never fails because the source list could not be re-mirrored: `list` and `source list` +keep the mirror Pinget cached earlier and, for `list`, report a warning alongside the results. +Commands that change sources still fail loudly, since they have to see the real source list. The +mirror is re-exported at most every 15 minutes, so a query no longer spawns WinGet every time. + ## Custom REST sources Both implementations support custom REST sources, including third-party services such as `winget.pro`. diff --git a/dotnet/src/Devolutions.Pinget.Cli/Program.cs b/dotnet/src/Devolutions.Pinget.Cli/Program.cs index 62fcd04..3527dad 100644 --- a/dotnet/src/Devolutions.Pinget.Cli/Program.cs +++ b/dotnet/src/Devolutions.Pinget.Cli/Program.cs @@ -6,7 +6,7 @@ using Devolutions.Pinget.Cli; using Devolutions.Pinget.Core; -const string Version = "0.11.0"; +const string Version = "0.12.0"; const string UpgradeUnsupportedWarning = "Upgrading packages is not supported on this platform; no changes were made."; if (args.Length == 1 && (string.Equals(args[0], "--version", StringComparison.OrdinalIgnoreCase) || string.Equals(args[0], "-v", StringComparison.OrdinalIgnoreCase))) diff --git a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs index 5117a67..f546fe9 100644 --- a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs +++ b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs @@ -340,6 +340,221 @@ public void SystemWingetSourceStore_LoadUsesExportCommand() } } + [Fact] + public void SystemWingetSourceStore_ConfiguredProgramWinsOverTheSearchPath() + { + var configured = Path.Combine("tools", SystemWingetSourceStore.ProgramName); + var onPath = Path.Combine("windows", SystemWingetSourceStore.ProgramName); + + var resolved = SystemWingetSourceStore.ResolveProgram(configured, null, "windows", () => [], candidate => candidate == configured || candidate == onPath); + + Assert.Equal(configured, resolved); + } + + [Fact] + public void SystemWingetSourceStore_ConfiguredDirectoryResolvesToTheExecutable() + { + var program = Path.Combine("tools", SystemWingetSourceStore.ProgramName); + + var resolved = SystemWingetSourceStore.ResolveProgram("tools", null, null, () => [], candidate => candidate == program); + + Assert.Equal(program, resolved); + } + + [Fact] + public void SystemWingetSourceStore_MissingConfiguredProgramIsReported() + { + var error = Assert.Throws( + () => SystemWingetSourceStore.ResolveProgram("tools", null, null, () => [], _ => false)); + + Assert.Contains(SystemWingetSourceStore.ProgramEnvironmentVariable, error.Message); + } + + [Fact] + public void SystemWingetSourceStore_ProgramIsFoundOnTheSearchPath() + { + var program = Path.Combine("windows", SystemWingetSourceStore.ProgramName); + var searchPath = string.Join(Path.PathSeparator, ["empty", "windows"]); + + var resolved = SystemWingetSourceStore.ResolveProgram(null, null, searchPath, () => [], candidate => candidate == program); + + Assert.Equal(program, resolved); + } + + [Fact] + public void SystemWingetSourceStore_ProgramFallsBackToTheAppInstallerLocations() + { + var program = Path.Combine("WindowsApps", SystemWingetSourceStore.ProgramName); + + var resolved = SystemWingetSourceStore.ResolveProgram(null, null, "windows", () => ["WindowsApps"], candidate => candidate == program); + + Assert.Equal(program, resolved); + } + + [Fact] + public void SystemWingetSourceStore_UnresolvableProgramPointsAtTheEnvironmentVariable() + { + var error = Assert.Throws( + () => SystemWingetSourceStore.ResolveProgram(null, null, "windows", () => [], _ => false)); + + Assert.Contains(SystemWingetSourceStore.ProgramEnvironmentVariable, error.Message); + } + + [Theory] + [InlineData("Microsoft.DesktopAppInstaller_1.29.290.0_x64__8wekyb3d8bbwe", "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe", "1.29.290.0")] + [InlineData("Microsoft.DesktopAppInstaller_1.2.0.0_neutral_split.scale-100_8wekyb3d8bbwe", "Microsoft.DesktopAppInstaller_split.scale-100_8wekyb3d8bbwe", "1.2.0.0")] + public void SystemWingetSourceStore_ParsesPackageFullNames(string packageFullName, string familyName, string version) + { + var parsed = SystemWingetSourceStore.ParsePackageFullName(packageFullName); + + Assert.NotNull(parsed); + Assert.Equal(familyName, parsed!.Value.FamilyName); + Assert.Equal(version, parsed.Value.Version); + } + + [Theory] + [InlineData("Microsoft.DesktopAppInstaller_8wekyb3d8bbwe")] + [InlineData("Microsoft.DesktopAppInstaller")] + public void SystemWingetSourceStore_RejectsIncompletePackageFullNames(string packageFullName) + { + Assert.Null(SystemWingetSourceStore.ParsePackageFullName(packageFullName)); + } + + [Fact] + public void SystemWingetSourceStore_ApplicationDirectoryKeepsPrecedenceOverTheSearchPath() + { + var bundled = Path.Combine("app", SystemWingetSourceStore.ProgramName); + var onPath = Path.Combine("windows", SystemWingetSourceStore.ProgramName); + + var resolved = SystemWingetSourceStore.ResolveProgram( + null, + "app", + "windows", + () => [], + candidate => candidate == bundled || candidate == onPath); + + Assert.Equal(bundled, resolved); + } + + [Fact] + public void SystemWingetSourceStore_DoesNotEnumerateAppInstallerLocationsWhenThePathResolvesWinget() + { + var program = Path.Combine("windows", SystemWingetSourceStore.ProgramName); + var enumerated = false; + + var resolved = SystemWingetSourceStore.ResolveProgram(null, null, "windows", () => { enumerated = true; return []; }, candidate => candidate == program); + + Assert.Equal(program, resolved); + Assert.False(enumerated); + } + + [Fact] + public void SystemWingetSourceStore_BlankConfiguredProgramFallsThroughToThePath() + { + var program = Path.Combine("windows", SystemWingetSourceStore.ProgramName); + + var resolved = SystemWingetSourceStore.ResolveProgram(" ", null, "windows", () => [], candidate => candidate == program); + + Assert.Equal(program, resolved); + } + + [Fact] + public void SourceStoreManager_MirrorFreshnessFollowsTheExportStampNotTheStoreFile() + { + var appRoot = TestPaths.CreateTempAppRoot(); + try + { + Assert.False(SourceStoreManager.SystemWingetMirrorIsFresh(appRoot, TimeSpan.FromMinutes(15))); + + // A pre-indexed metadata save rewrites the mirror store without re-exporting. + SourceStoreManager.SaveSystemWingetMirrorStore(appRoot, SourceStore.Default()); + + Assert.False(SourceStoreManager.SystemWingetMirrorIsFresh(appRoot, TimeSpan.FromMinutes(15))); + + SourceStoreManager.StampSystemWingetMirrorExport(appRoot); + + Assert.True(SourceStoreManager.SystemWingetMirrorIsFresh(appRoot, TimeSpan.FromMinutes(15))); + Assert.False(SourceStoreManager.SystemWingetMirrorIsFresh(appRoot, TimeSpan.Zero)); + } + finally + { + TestPaths.DeleteAppRoot(appRoot); + } + } + + [Fact] + public void Repository_QueryKeepsTheCachedMirrorWhenTheSystemWingetCannotBeRun() + { + var appRoot = TestPaths.CreateTempAppRoot(); + var originalRunner = SystemWingetSourceStore.CommandRunner; + try + { + SystemWingetSourceStore.CommandRunner = _ => new WingetCommandResult( + 0, + """ +{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"} +""", + ""); + + using var repo = Repository.Open(new RepositoryOptions + { + AppRoot = appRoot, + SourceMode = SourceMode.SystemWingetMirror, + UserAgent = "pinget-dotnet-tests/1.0", + }); + + var cached = repo.ListSources().Select(source => source.Name).ToList(); + Assert.Equal(["contoso"], cached); + + SystemWingetSourceStore.CommandRunner = + _ => throw new InvalidOperationException($"{SystemWingetSourceStore.ProgramName} was not found on the PATH"); + + var warning = repo.RefreshSystemWingetSourcesForQuery(TimeSpan.Zero); + + Assert.NotNull(warning); + Assert.Contains("Could not refresh the system WinGet sources", warning); + Assert.Equal(cached, repo.ListSources().Select(source => source.Name).ToList()); + } + finally + { + SystemWingetSourceStore.CommandRunner = originalRunner; + TestPaths.DeleteAppRoot(appRoot); + } + } + + [Fact] + public void Repository_QueryDoesNotReExportAFreshMirror() + { + var appRoot = TestPaths.CreateTempAppRoot(); + var originalRunner = SystemWingetSourceStore.CommandRunner; + try + { + SystemWingetSourceStore.CommandRunner = _ => new WingetCommandResult( + 0, + """ +{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"} +""", + ""); + + using var repo = Repository.Open(new RepositoryOptions + { + AppRoot = appRoot, + SourceMode = SourceMode.SystemWingetMirror, + UserAgent = "pinget-dotnet-tests/1.0", + }); + + SystemWingetSourceStore.CommandRunner = + _ => throw new InvalidOperationException("the mirror was re-exported while it was still fresh"); + + Assert.Null(repo.RefreshSystemWingetSourcesForQuery(TimeSpan.FromMinutes(15))); + } + finally + { + SystemWingetSourceStore.CommandRunner = originalRunner; + TestPaths.DeleteAppRoot(appRoot); + } + } + [Fact] public void PackagedSecureSettingsStub_DelegatesToSystemWingetExport() { diff --git a/dotnet/src/Devolutions.Pinget.Core/Repository.cs b/dotnet/src/Devolutions.Pinget.Core/Repository.cs index 9fb53dc..8190f4f 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Repository.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Repository.cs @@ -13,6 +13,7 @@ public class Repository : IDisposable { private const string AppRootEnvironmentVariable = "PINGET_APPROOT"; private const string SourceModeEnvironmentVariable = "PINGET_SOURCE_MODE"; + private const int SystemWingetMirrorAutoRefreshMinutes = 15; internal const string InstalledStateUnsupportedWarning = "Installed package discovery is not supported on this platform; returning no installed packages."; internal const string InstallUnsupportedWarning = "Installing packages is not supported on this platform; no changes were made."; @@ -213,9 +214,15 @@ public void SetRequestHeader(string name, string value) // ── Source management ── + /// + /// Deliberately not gated by the query refresh interval: a caller asking for the source list + /// is looking straight at it, quite possibly right after adding a source through WinGet, so + /// this keeps re-exporting on every call as it always has. Only the package queries trade + /// that for one spawn per interval. + /// public List ListSources() { - RefreshSystemWingetSources(); + RefreshSystemWingetSourcesForQuery(TimeSpan.Zero); return _store.Sources.ToList(); } @@ -555,7 +562,7 @@ public CacheWarmResult WarmCache(PackageQuery query) public ListResponse List(ListQuery query) { - RefreshSystemWingetSources(); + var sourceRefreshWarning = RefreshSystemWingetSourcesForQuery(); if ((query.IncludeUnknown || query.IncludePinned) && !query.UpgradeOnly) throw new InvalidOperationException("--include-unknown and --include-pinned require --upgrade-available"); @@ -567,6 +574,9 @@ public ListResponse List(ListQuery query) bool hasFilter = ListQueryNeedsAvailableLookup(query); bool needsAvailable = hasFilter || query.UpgradeOnly; var warnings = new List(); + if (sourceRefreshWarning is not null) + warnings.Add(sourceRefreshWarning); + var installed = InstalledPackages.Collect(query.InstallScope); if (!OperatingSystem.IsWindows()) warnings.Add(InstalledStateUnsupportedWarning); @@ -4106,6 +4116,53 @@ private void RefreshSystemWingetSources() }; } + /// + /// A query must not fail because the sources could not be re-mirrored: the cached mirror is + /// what the previous queries already ran against. Mutating commands keep using the strict + /// refresh above, since they have to see the real source list. + /// + private string? RefreshSystemWingetSourcesForQuery() => + RefreshSystemWingetSourcesForQuery(TimeSpan.FromMinutes(SystemWingetMirrorAutoRefreshMinutes)); + + internal string? RefreshSystemWingetSourcesForQuery(TimeSpan maxAge) + { + switch (_sourceMode) + { + case EffectiveSourceMode.SystemWingetDirect: + try + { + _store = SystemWingetSourceStore.Load(); + return null; + } + catch (Exception ex) when (ex is InvalidOperationException or IOException + or UnauthorizedAccessException or System.Security.SecurityException) + { + return SystemWingetSourceRefreshWarning(ex); + } + + case EffectiveSourceMode.SystemWingetMirror: + if (SourceStoreManager.SystemWingetMirrorIsFresh(_appRoot, maxAge)) + return null; + + try + { + _store = SourceStoreManager.RefreshSystemWingetMirrorStore(_appRoot); + return null; + } + catch (Exception ex) when (ex is InvalidOperationException or IOException + or UnauthorizedAccessException or System.Security.SecurityException) + { + return SystemWingetSourceRefreshWarning(ex); + } + + default: + return null; + } + } + + private static string SystemWingetSourceRefreshWarning(Exception error) => + $"Could not refresh the system WinGet sources; using the sources Pinget cached earlier. {error.Message}"; + private void SaveStore() { if (MirrorsSystemWingetSources) diff --git a/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs b/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs index fda4b6b..79130b7 100644 --- a/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs +++ b/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs @@ -51,6 +51,7 @@ internal static class SourceStoreManager private const string LegacyStoreFileName = "sources.json"; private const string SystemWingetMirrorStoreFileName = "system-sources.json"; + private const string SystemWingetMirrorExportStampFileName = "system-sources.stamp"; private const string PackagedSourcesFileName = "user_sources"; private const string PackagedMetadataFileName = "sources_metadata"; private const string LegacyPinsFileName = "pins.db"; @@ -182,12 +183,49 @@ private static (EffectiveSourceMode Mode, SourceStore Store) TryLoadAutoSystemWi } } + /// + /// Freshness follows a stamp that only a successful winget source export writes. The + /// mirror store file itself is rewritten by ordinary pre-indexed metadata saves, so its + /// timestamp would let index activity keep a stale source list looking fresh forever. + /// + internal static bool SystemWingetMirrorIsFresh(string appRoot, TimeSpan maxAge) + { + if (!File.Exists(SystemWingetMirrorStorePath(appRoot))) + return false; + + var stamp = SystemWingetMirrorExportStampPath(appRoot); + if (!File.Exists(stamp)) + return false; + + var age = DateTime.UtcNow - File.GetLastWriteTimeUtc(stamp); + return age >= TimeSpan.Zero && age < maxAge; + } + + private static string SystemWingetMirrorExportStampPath(string appRoot) => + Path.Combine(NormalizeAppRoot(appRoot), SystemWingetMirrorExportStampFileName); + + /// + /// A stamp that cannot be written only costs the export gate, so it must not fail the export + /// that just succeeded. + /// + internal static void StampSystemWingetMirrorExport(string appRoot) + { + try + { + File.WriteAllText(SystemWingetMirrorExportStampPath(appRoot), DateTime.UtcNow.ToString("O")); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + } + } + internal static SourceStore RefreshSystemWingetMirrorStore(string appRoot) { var prior = LoadSystemWingetMirrorStore(appRoot) ?? SourceStore.Default(); var exported = SystemWingetSourceStore.Load(); MergeSourceCacheMetadata(exported, prior); SaveSystemWingetMirrorStore(appRoot, exported); + StampSystemWingetMirrorExport(appRoot); return exported; } diff --git a/dotnet/src/Devolutions.Pinget.Core/SystemWingetSourceStore.cs b/dotnet/src/Devolutions.Pinget.Core/SystemWingetSourceStore.cs index 56dfaa2..6e80ba1 100644 --- a/dotnet/src/Devolutions.Pinget.Core/SystemWingetSourceStore.cs +++ b/dotnet/src/Devolutions.Pinget.Core/SystemWingetSourceStore.cs @@ -1,11 +1,20 @@ +using System.ComponentModel; using System.Diagnostics; using System.Text.Json; +using Microsoft.Win32; namespace Devolutions.Pinget.Core; internal static class SystemWingetSourceStore { - private const string WingetExecutable = "winget"; + internal const string ProgramEnvironmentVariable = "PINGET_WINGET_PATH"; + + private const string AppModelPackagesPath = + @"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages"; + + private const string AppInstallerFamilyName = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe"; + + internal static string ProgramName { get; } = OperatingSystem.IsWindows() ? "winget.exe" : "winget"; internal static Func, WingetCommandResult> CommandRunner { get; set; } = RunWinget; @@ -128,7 +137,8 @@ private static WingetCommandResult RunChecked(IReadOnlyList args, string private static WingetCommandResult RunWinget(IReadOnlyList args) { - var psi = new ProcessStartInfo(WingetExecutable) + var program = ResolveProgram(); + var psi = new ProcessStartInfo(program) { UseShellExecute = false, RedirectStandardOutput = true, @@ -139,7 +149,19 @@ private static WingetCommandResult RunWinget(IReadOnlyList args) foreach (var arg in args) psi.ArgumentList.Add(arg); - using var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start winget."); + Process? started; + try + { + started = Process.Start(psi); + } + catch (Exception ex) when (ex is Win32Exception or PlatformNotSupportedException) + { + throw new InvalidOperationException( + $"Failed to run the WinGet source command with {program}: {ex.Message}", ex); + } + + using var process = started + ?? throw new InvalidOperationException($"Failed to run the WinGet source command with {program}."); var stdout = process.StandardOutput.ReadToEndAsync(); var stderr = process.StandardError.ReadToEndAsync(); process.WaitForExit(); @@ -147,6 +169,157 @@ private static WingetCommandResult RunWinget(IReadOnlyList args) return new WingetCommandResult(process.ExitCode, stdout.GetAwaiter().GetResult(), stderr.GetAwaiter().GetResult()); } + internal static string ResolveProgram() => + ResolveProgram( + Environment.GetEnvironmentVariable(ProgramEnvironmentVariable), + CurrentExecutableDirectory(), + Environment.GetEnvironmentVariable("PATH"), + FallbackDirectories, + File.Exists); + + private static string? CurrentExecutableDirectory() + { + var executable = Environment.ProcessPath; + return string.IsNullOrWhiteSpace(executable) ? AppContext.BaseDirectory : Path.GetDirectoryName(executable); + } + + /// + /// WinGet ships as an App Execution Alias, so a host that inherited a PATH without + /// %LOCALAPPDATA%\Microsoft\WindowsApps cannot spawn it by name at all. Look past the PATH + /// before giving up, and let a host that already knows the location say so. + /// + /// The directory of the running executable keeps the precedence it had while this started + /// winget by name: Windows resolves a bare program name against the application + /// directory before the PATH, and a host that ships its own copy relies on that. + /// + /// + internal static string ResolveProgram( + string? configured, + string? applicationDirectory, + string? searchPath, + Func> fallbackDirectories, + Func fileExists) + { + if (!string.IsNullOrWhiteSpace(configured)) + { + if (fileExists(configured)) + return configured; + + var nested = Path.Combine(configured, ProgramName); + if (fileExists(nested)) + return nested; + + throw new InvalidOperationException( + $"{ProgramEnvironmentVariable} is set to {configured}, where no {ProgramName} was found."); + } + + if (!string.IsNullOrWhiteSpace(applicationDirectory) && + FirstProgramIn([applicationDirectory], fileExists) is { } bundled) + { + return bundled; + } + + var searchDirectories = (searchPath ?? string.Empty) + .Split(Path.PathSeparator) + .Select(entry => entry.Trim().Trim('"')) + .Where(entry => entry.Length is not 0); + + if (FirstProgramIn(searchDirectories, fileExists) is { } onPath) + return onPath; + + // Enumerating the App Installer package locations reads the registry, so it stays + // behind the PATH: the machines that already resolve winget by name pay nothing. + if (FirstProgramIn(fallbackDirectories(), fileExists) is { } offPath) + return offPath; + + throw new InvalidOperationException( + $"{ProgramName} was not found on the PATH or in the App Installer install locations. " + + $"Set {ProgramEnvironmentVariable} to its full path, or install the App Installer."); + } + + private static string? FirstProgramIn(IEnumerable directories, Func fileExists) + { + foreach (var directory in directories) + { + var candidate = Path.Combine(directory, ProgramName); + if (fileExists(candidate)) + return candidate; + } + + return null; + } + + private static IEnumerable FallbackDirectories() + { + if (!OperatingSystem.IsWindows()) + return []; + + var directories = new List(); + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (!string.IsNullOrWhiteSpace(localAppData)) + directories.Add(Path.Combine(localAppData, "Microsoft", "WindowsApps")); + + directories.AddRange(AppInstallerPackageDirectories()); + return directories; + } + + private static List AppInstallerPackageDirectories() + { + if (!OperatingSystem.IsWindows()) + return []; + + try + { + using var packages = Registry.CurrentUser.OpenSubKey(AppModelPackagesPath); + if (packages is null) + return []; + + var found = new List<(string Version, string Directory)>(); + foreach (var packageFullName in packages.GetSubKeyNames()) + { + if (ParsePackageFullName(packageFullName) is not { } parsed || + !string.Equals(parsed.FamilyName, AppInstallerFamilyName, StringComparison.OrdinalIgnoreCase) || + parsed.ResourceId.StartsWith("split.", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + using var entry = packages.OpenSubKey(packageFullName); + if (entry?.GetValue("PackageRootFolder") is not string directory || + string.IsNullOrWhiteSpace(directory)) + { + continue; + } + + found.Add((parsed.Version, directory)); + } + + found.Sort((left, right) => -RestSource.CompareVersionStrings(left.Version, right.Version)); + return found.Select(match => match.Directory).ToList(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + return []; + } + } + + internal static (string Version, string FamilyName, string ResourceId)? ParsePackageFullName(string packageFullName) + { + var segments = packageFullName.Split('_'); + if (segments.Length < 5) + return null; + + var name = string.Join("_", segments[..^4]); + var version = segments[^4].Trim(); + var resourceId = segments[^2].Trim(); + var publisherId = segments[^1].Trim(); + if (name.Length is 0 || version.Length is 0 || publisherId.Length is 0) + return null; + + var familyName = resourceId.Length is 0 ? $"{name}_{publisherId}" : $"{name}_{resourceId}_{publisherId}"; + return (version, familyName, resourceId); + } + private static bool TryParseJsonSources(string json, out List sources) { sources = []; diff --git a/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1 b/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1 index ceeec40..ef64ee3 100644 --- a/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1 +++ b/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'Devolutions.Pinget.Client.psm1' - ModuleVersion = '0.11.0' + ModuleVersion = '0.12.0' CompatiblePSEditions = @('Desktop', 'Core') GUID = 'c6d1b5f2-5ccd-4771-9480-25caad7c58bd' Author = 'Devolutions' diff --git a/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PowerShellEngineVersion.cs b/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PowerShellEngineVersion.cs index b35780e..08bee35 100644 --- a/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PowerShellEngineVersion.cs +++ b/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PowerShellEngineVersion.cs @@ -2,5 +2,5 @@ namespace Devolutions.Pinget.PowerShell.Engine; public static class PowerShellEngineVersion { - public const string Current = "0.11.0"; + public const string Current = "0.12.0"; } diff --git a/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.csproj b/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.csproj index 88d4ffb..24f4153 100644 --- a/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.csproj +++ b/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.csproj @@ -1,7 +1,7 @@ - 0.11.0 + 0.12.0 Devolutions Inc. Devolutions Devolutions.Pinget.Cli.DotNet diff --git a/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.csproj b/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.csproj index 3ee2ebe..db90fa9 100644 --- a/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.csproj +++ b/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.csproj @@ -1,7 +1,7 @@ - 0.11.0 + 0.12.0 Devolutions Inc. Devolutions Devolutions.Pinget.Cli.Rust diff --git a/rust/crates/pinget-cli/Cargo.toml b/rust/crates/pinget-cli/Cargo.toml index 2a81cef..7a21679 100644 --- a/rust/crates/pinget-cli/Cargo.toml +++ b/rust/crates/pinget-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pinget-cli" -version = "0.11.0" +version = "0.12.0" edition = "2024" [lints] @@ -13,7 +13,7 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.102" clap = { version = "4.6.1", features = ["derive"] } -pinget-core = { version = "0.11.0", path = "../pinget-core" } +pinget-core = { version = "0.12.0", path = "../pinget-core" } chrono = "0.4.44" dirs = "6.0" jsonschema = { version = "0.30", default-features = false, features = ["resolve-file"] } diff --git a/rust/crates/pinget-com/Cargo.toml b/rust/crates/pinget-com/Cargo.toml index bf0129f..f7f6193 100644 --- a/rust/crates/pinget-com/Cargo.toml +++ b/rust/crates/pinget-com/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pinget-com" -version = "0.11.0" +version = "0.12.0" edition = "2024" description = "Windows-only native COM bridge for Pinget backed by pinget-core." license = "MIT" diff --git a/rust/crates/pinget-core/Cargo.toml b/rust/crates/pinget-core/Cargo.toml index 1ff3f35..339b8ae 100644 --- a/rust/crates/pinget-core/Cargo.toml +++ b/rust/crates/pinget-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pinget-core" -version = "0.11.0" +version = "0.12.0" edition = "2024" description = "Pure Rust Pinget core library that works directly with source caches, REST endpoints, and installed package state without COM." license = "MIT" diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index 8e7f81c..bcadfac 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -2,6 +2,7 @@ mod name_normalization; use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::ffi::OsString; use std::fmt::{Display, Formatter}; use std::fs::{self, OpenOptions}; use std::io::{Cursor, Read, Write}; @@ -50,8 +51,18 @@ const DEFAULT_USER_AGENT: &str = "pinget-rs/0.1"; const DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES: i64 = 15; const PREINDEXED_REFRESH_RETRY_MINUTES: i64 = 5; const SYSTEM_WINGET_MIRROR_STORE_FILE_NAME: &str = "system-sources.json"; +const SYSTEM_WINGET_MIRROR_EXPORT_STAMP_FILE_NAME: &str = "system-sources.stamp"; const APP_ROOT_ENV_VAR: &str = "PINGET_APPROOT"; const SOURCE_MODE_ENV_VAR: &str = "PINGET_SOURCE_MODE"; +const SYSTEM_WINGET_PROGRAM_ENV_VAR: &str = "PINGET_WINGET_PATH"; +const SYSTEM_WINGET_MIRROR_AUTO_REFRESH_MINUTES: u64 = 15; +#[cfg(windows)] +const SYSTEM_WINGET_PROGRAM_NAME: &str = "winget.exe"; +#[cfg(not(windows))] +const SYSTEM_WINGET_PROGRAM_NAME: &str = "winget"; +#[cfg(windows)] +const APPMODEL_PACKAGES_PATH: &str = + r"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages"; #[cfg(windows)] const PACKAGED_FAMILY_NAME: &str = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe"; #[cfg(windows)] @@ -1376,12 +1387,13 @@ impl Repository { bail!("list --source currently requires a query or explicit filter"); } - self.refresh_system_winget_sources()?; + let source_refresh_warning = self.refresh_system_winget_sources_for_query(); let has_filter = list_query_needs_available_lookup(query); let needs_available = has_filter || query.upgrade_only; let mut warnings = Vec::new(); + warnings.extend(source_refresh_warning); if !installed_package_discovery_supported() { warnings.push(INSTALLED_STATE_UNSUPPORTED_WARNING.to_owned()); } @@ -3685,6 +3697,41 @@ impl Repository { Ok(()) } + /// A query must not fail because the sources could not be re-mirrored: the cached + /// mirror is what the previous queries already ran against. Mutating commands keep + /// using the strict refresh above, since they have to see the real source list. + fn refresh_system_winget_sources_for_query(&mut self) -> Option { + self.refresh_system_winget_sources_for_query_with_max_age(StdDuration::from_secs( + SYSTEM_WINGET_MIRROR_AUTO_REFRESH_MINUTES * 60, + )) + } + + fn refresh_system_winget_sources_for_query_with_max_age(&mut self, max_age: StdDuration) -> Option { + match self.source_mode { + EffectiveSourceMode::Private => None, + EffectiveSourceMode::SystemWingetDirect => match load_system_winget_source_store() { + Ok(store) => { + self.store = store; + None + } + Err(error) => Some(system_winget_source_refresh_warning(&error)), + }, + EffectiveSourceMode::SystemWingetMirror => { + if system_winget_mirror_is_fresh(&self.app_root, max_age) { + return None; + } + + match refresh_system_winget_mirror_store(&self.app_root) { + Ok(store) => { + self.store = store; + None + } + Err(error) => Some(system_winget_source_refresh_warning(&error)), + } + } + } + } + fn update_system_winget_sources(&mut self, source_name: Option<&str>) -> Result> { self.refresh_system_winget_sources()?; let selected_names: Vec<_> = if source_name.is_none() { @@ -4915,9 +4962,6 @@ fn collect_appmodel_packages( scope: &str, flags: u32, ) -> Result<()> { - const APPMODEL_PACKAGES_PATH: &str = - r"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages"; - let appmodel = match root.open_subkey_with_flags(APPMODEL_PACKAGES_PATH, flags) { Ok(key) => key, Err(_) => return Ok(()), @@ -5324,6 +5368,38 @@ fn save_system_winget_mirror_store(app_root: &Path, store: &SourceStore) -> Resu write_json(system_winget_mirror_store_path(app_root), store) } +/// Freshness follows a stamp that only a successful `winget source export` writes. The +/// mirror store file itself is rewritten by ordinary pre-indexed metadata saves, so its +/// timestamp would let index activity keep a stale source list looking fresh forever. +fn system_winget_mirror_is_fresh(app_root: &Path, max_age: StdDuration) -> bool { + if !system_winget_mirror_store_path(app_root).exists() { + return false; + } + + fs::metadata(system_winget_mirror_export_stamp_path(app_root)) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|age| age < max_age) +} + +fn system_winget_mirror_export_stamp_path(app_root: &Path) -> PathBuf { + app_root.join(SYSTEM_WINGET_MIRROR_EXPORT_STAMP_FILE_NAME) +} + +/// A stamp that cannot be written only costs the export gate, so it must not fail the +/// export that just succeeded. +fn stamp_system_winget_mirror_export(app_root: &Path) { + let _ = fs::write( + system_winget_mirror_export_stamp_path(app_root), + Utc::now().to_rfc3339(), + ); +} + +fn system_winget_source_refresh_warning(error: &anyhow::Error) -> String { + format!("Could not refresh the system WinGet sources; using the sources Pinget cached earlier. {error:#}") +} + fn load_or_refresh_system_winget_mirror_store(app_root: &Path, force_refresh: bool) -> Result { if !force_refresh && let Some(store) = load_system_winget_mirror_store(app_root)? { return Ok(store); @@ -5337,6 +5413,7 @@ fn refresh_system_winget_mirror_store(app_root: &Path) -> Result { let mut exported = load_system_winget_source_store()?; merge_source_cache_metadata(&mut exported, &prior); save_system_winget_mirror_store(app_root, &exported)?; + stamp_system_winget_mirror_export(app_root); Ok(exported) } @@ -5597,10 +5674,11 @@ fn run_winget_source_command(args: &[String]) -> Result Result { use std::process::Command; - let output = Command::new("winget") + let program = resolve_system_winget_program()?; + let output = Command::new(&program) .args(args) .output() - .context("failed to run winget source command")?; + .with_context(|| format!("failed to run the WinGet source command with {}", program.display()))?; Ok(WingetSourceCommandResult { exit_code: output.status.code().unwrap_or(-1), @@ -5609,6 +5687,132 @@ fn run_winget_source_command_process(args: &[String]) -> Result Result { + resolve_system_winget_program_from( + std::env::var_os(SYSTEM_WINGET_PROGRAM_ENV_VAR), + current_executable_directory(), + std::env::var_os("PATH"), + &system_winget_fallback_directories, + &|candidate| candidate.is_file(), + ) +} + +fn current_executable_directory() -> Option { + std::env::current_exe() + .ok() + .and_then(|executable| executable.parent().map(Path::to_path_buf)) +} + +/// WinGet ships as an App Execution Alias, so a host that inherited a PATH without +/// %LOCALAPPDATA%\Microsoft\WindowsApps cannot spawn it by name at all. Look past the +/// PATH before giving up, and let a host that already knows the location say so. +/// +/// The directory of the running executable keeps the precedence it had while this spawned +/// `winget` by name: Windows resolves a bare program name against the application directory +/// before the PATH, and a host that ships its own copy relies on that. +fn resolve_system_winget_program_from( + configured: Option, + application_directory: Option, + search_path: Option, + fallback_directories: &dyn Fn() -> Vec, + is_file: &dyn Fn(&Path) -> bool, +) -> Result { + if let Some(configured) = configured.filter(|value| !value.to_string_lossy().trim().is_empty()) { + let configured = PathBuf::from(configured); + if is_file(&configured) { + return Ok(configured); + } + + let nested = configured.join(SYSTEM_WINGET_PROGRAM_NAME); + if is_file(&nested) { + return Ok(nested); + } + + bail!( + "{SYSTEM_WINGET_PROGRAM_ENV_VAR} is set to {}, where no {SYSTEM_WINGET_PROGRAM_NAME} was found.", + configured.display() + ); + } + + if let Some(program) = first_system_winget_program(application_directory, is_file) { + return Ok(program); + } + + let search_directories = search_path + .as_deref() + .map(|value| std::env::split_paths(value).collect::>()) + .unwrap_or_default(); + + if let Some(program) = first_system_winget_program(search_directories, is_file) { + return Ok(program); + } + + // Enumerating the App Installer package locations reads the registry, so it stays + // behind the PATH: the machines that already resolve winget by name pay nothing. + if let Some(program) = first_system_winget_program(fallback_directories(), is_file) { + return Ok(program); + } + + bail!( + "{SYSTEM_WINGET_PROGRAM_NAME} was not found on the PATH or in the App Installer install locations. Set {SYSTEM_WINGET_PROGRAM_ENV_VAR} to its full path, or install the App Installer." + ) +} + +fn first_system_winget_program( + directories: impl IntoIterator, + is_file: &dyn Fn(&Path) -> bool, +) -> Option { + directories.into_iter().find_map(|directory| { + let candidate = directory.join(SYSTEM_WINGET_PROGRAM_NAME); + is_file(&candidate).then_some(candidate) + }) +} + +#[cfg(windows)] +fn system_winget_fallback_directories() -> Vec { + let mut directories = Vec::new(); + if let Some(local_app_data) = dirs::data_local_dir() { + directories.push(local_app_data.join("Microsoft").join("WindowsApps")); + } + + directories.extend(app_installer_package_directories()); + directories +} + +#[cfg(not(windows))] +fn system_winget_fallback_directories() -> Vec { + Vec::new() +} + +#[cfg(windows)] +fn app_installer_package_directories() -> Vec { + let Ok(packages) = RegKey::predef(HKEY_CURRENT_USER).open_subkey(APPMODEL_PACKAGES_PATH) else { + return Vec::new(); + }; + + let mut found: Vec<(String, PathBuf)> = Vec::new(); + for key_name in packages.enum_keys().flatten() { + let Some(metadata) = parse_msix_package_full_name(&key_name) else { + continue; + }; + if metadata.family_name != PACKAGED_FAMILY_NAME || is_msix_split_resource_package(&metadata.resource_id) { + continue; + } + + let Ok(entry) = packages.open_subkey(&key_name) else { + continue; + }; + let Some(package_root) = read_reg_string(&entry, "PackageRootFolder") else { + continue; + }; + + found.push((metadata.version, PathBuf::from(package_root))); + } + + found.sort_by(|left, right| compare_version(&right.0, &left.0)); + found.into_iter().map(|(_, directory)| directory).collect() +} + fn parse_system_winget_source_export(output: &str) -> Result> { let trimmed = output.trim(); if trimmed.is_empty() { @@ -6113,7 +6317,7 @@ fn current_user_sid() -> Result { // SAFETY: sid_ptr points to len initialized UTF-16 code units. let sid_slice = unsafe { std::slice::from_raw_parts(sid_ptr, len) }; - let sid = std::ffi::OsString::from_wide(sid_slice).to_string_lossy().into_owned(); + let sid = OsString::from_wide(sid_slice).to_string_lossy().into_owned(); // SAFETY: sid_ptr was allocated by ConvertSidToStringSidW and must be released with LocalFree. unsafe { LocalFree(sid_ptr.cast()); @@ -11361,8 +11565,275 @@ mod tests { }) } + fn failing_system_winget_source_command(_args: &[String]) -> Result { + bail!("{SYSTEM_WINGET_PROGRAM_NAME} was not found on the PATH") + } + + static SYSTEM_WINGET_RUNNER_LOCK: Mutex<()> = Mutex::new(()); + + fn lock_system_winget_source_command_runner() -> std::sync::MutexGuard<'static, ()> { + SYSTEM_WINGET_RUNNER_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn swap_system_winget_source_command_runner(runner: WingetSourceCommandRunner) -> WingetSourceCommandRunner { + let mut current = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock"); + let previous = *current; + *current = runner; + previous + } + + fn search_path_of(directories: &[&Path]) -> OsString { + std::env::join_paths(directories).expect("search path") + } + + #[test] + fn configured_system_winget_program_wins_over_the_search_path() { + let configured = PathBuf::from("tools").join(SYSTEM_WINGET_PROGRAM_NAME); + let on_path = PathBuf::from("windows"); + + let program = resolve_system_winget_program_from( + Some(configured.as_os_str().to_os_string()), + None, + Some(search_path_of(&[on_path.as_path()])), + &Vec::new, + &|candidate| candidate == configured || candidate == on_path.join(SYSTEM_WINGET_PROGRAM_NAME), + ) + .expect("program"); + + assert_eq!(program, configured); + } + + #[test] + fn configured_system_winget_directory_resolves_to_the_executable() { + let directory = PathBuf::from("tools"); + let program = directory.join(SYSTEM_WINGET_PROGRAM_NAME); + + let resolved = resolve_system_winget_program_from( + Some(directory.as_os_str().to_os_string()), + None, + None, + &Vec::new, + &|candidate| candidate == program, + ) + .expect("program"); + + assert_eq!(resolved, program); + } + + #[test] + fn configured_system_winget_program_that_is_missing_is_reported() { + let error = resolve_system_winget_program_from( + Some(PathBuf::from("tools").into_os_string()), + None, + None, + &Vec::new, + &|_| false, + ) + .expect_err("missing configured program"); + + assert!( + error.to_string().contains(SYSTEM_WINGET_PROGRAM_ENV_VAR), + "unexpected error: {error}" + ); + } + + #[test] + fn system_winget_program_is_found_on_the_search_path() { + let first = PathBuf::from("empty"); + let second = PathBuf::from("windows"); + let program = second.join(SYSTEM_WINGET_PROGRAM_NAME); + + let resolved = resolve_system_winget_program_from( + None, + None, + Some(search_path_of(&[first.as_path(), second.as_path()])), + &Vec::new, + &|candidate| candidate == program, + ) + .expect("program"); + + assert_eq!(resolved, program); + } + + #[test] + fn system_winget_program_falls_back_to_the_app_installer_locations() { + let on_path = PathBuf::from("windows"); + let alias_directory = PathBuf::from("WindowsApps"); + let program = alias_directory.join(SYSTEM_WINGET_PROGRAM_NAME); + + let resolved = resolve_system_winget_program_from( + None, + None, + Some(search_path_of(&[on_path.as_path()])), + &|| vec![alias_directory.clone()], + &|candidate| candidate == program, + ) + .expect("program"); + + assert_eq!(resolved, program); + } + + #[test] + fn unresolvable_system_winget_program_points_at_the_environment_variable() { + let error = resolve_system_winget_program_from( + None, + None, + Some(search_path_of(&[Path::new("windows")])), + &Vec::new, + &|_| false, + ) + .expect_err("no program"); + + assert!( + error.to_string().contains(SYSTEM_WINGET_PROGRAM_ENV_VAR), + "unexpected error: {error}" + ); + } + + #[test] + fn the_application_directory_keeps_precedence_over_the_search_path() { + let application_directory = PathBuf::from("app"); + let on_path = PathBuf::from("windows"); + let bundled = application_directory.join(SYSTEM_WINGET_PROGRAM_NAME); + let on_path_program = on_path.join(SYSTEM_WINGET_PROGRAM_NAME); + + let resolved = resolve_system_winget_program_from( + None, + Some(application_directory), + Some(search_path_of(&[on_path.as_path()])), + &Vec::new, + &|candidate| candidate == bundled || candidate == on_path_program, + ) + .expect("program"); + + assert_eq!(resolved, bundled); + } + + #[test] + fn the_app_installer_locations_are_not_enumerated_when_the_path_resolves_winget() { + let on_path = PathBuf::from("windows"); + let program = on_path.join(SYSTEM_WINGET_PROGRAM_NAME); + let enumerated = AtomicBool::new(false); + + let resolved = resolve_system_winget_program_from( + None, + None, + Some(search_path_of(&[on_path.as_path()])), + &|| { + enumerated.store(true, AtomicOrdering::SeqCst); + Vec::new() + }, + &|candidate| candidate == program, + ) + .expect("program"); + + assert_eq!(resolved, program); + assert!(!enumerated.load(AtomicOrdering::SeqCst)); + } + + #[test] + fn a_blank_configured_system_winget_program_falls_through_to_the_path() { + let on_path = PathBuf::from("windows"); + let program = on_path.join(SYSTEM_WINGET_PROGRAM_NAME); + + let resolved = resolve_system_winget_program_from( + Some(OsString::from(" ")), + None, + Some(search_path_of(&[on_path.as_path()])), + &Vec::new, + &|candidate| candidate == program, + ) + .expect("program"); + + assert_eq!(resolved, program); + } + + #[test] + fn mirror_freshness_follows_the_export_stamp_not_the_store_file() { + let app_root = temp_app_root("mirror-freshness"); + assert!(!system_winget_mirror_is_fresh(&app_root, StdDuration::from_secs(900))); + + // A pre-indexed metadata save rewrites the mirror store without re-exporting. + save_system_winget_mirror_store(&app_root, &SourceStore::default()).expect("save mirror"); + assert!(!system_winget_mirror_is_fresh(&app_root, StdDuration::from_secs(900))); + + stamp_system_winget_mirror_export(&app_root); + assert!(system_winget_mirror_is_fresh(&app_root, StdDuration::from_secs(900))); + assert!(!system_winget_mirror_is_fresh(&app_root, StdDuration::ZERO)); + + let _ = fs::remove_dir_all(&app_root); + } + + #[test] + fn a_query_keeps_the_cached_mirror_when_the_system_winget_cannot_be_run() { + let _runner_guard = lock_system_winget_source_command_runner(); + let app_root = temp_app_root("mirror-refresh-failure"); + let previous = swap_system_winget_source_command_runner(fake_system_winget_source_export); + let opened = Repository::open_with_options( + RepositoryOptions::new(app_root.clone()).with_source_mode(SourceMode::SystemWingetMirror), + ); + swap_system_winget_source_command_runner(previous); + + let result = (|| -> Result<()> { + let mut repository = opened?; + let cached: Vec = repository + .list_sources() + .into_iter() + .map(|source| source.name) + .collect(); + assert_eq!(cached, vec!["contoso".to_owned()]); + + let previous = swap_system_winget_source_command_runner(failing_system_winget_source_command); + let warning = repository.refresh_system_winget_sources_for_query_with_max_age(StdDuration::ZERO); + swap_system_winget_source_command_runner(previous); + + let warning = warning.expect("refresh warning"); + assert!( + warning.contains("Could not refresh the system WinGet sources"), + "{warning}" + ); + let kept: Vec = repository + .list_sources() + .into_iter() + .map(|source| source.name) + .collect(); + assert_eq!(kept, cached); + Ok(()) + })(); + + let _ = fs::remove_dir_all(&app_root); + result.expect("cached mirror query"); + } + + #[test] + fn a_query_does_not_re_export_a_fresh_mirror() { + let _runner_guard = lock_system_winget_source_command_runner(); + let app_root = temp_app_root("mirror-refresh-fresh"); + let previous = swap_system_winget_source_command_runner(fake_system_winget_source_export); + let opened = Repository::open_with_options( + RepositoryOptions::new(app_root.clone()).with_source_mode(SourceMode::SystemWingetMirror), + ); + swap_system_winget_source_command_runner(previous); + + let result = (|| -> Result<()> { + let mut repository = opened?; + let previous = swap_system_winget_source_command_runner(failing_system_winget_source_command); + let warning = repository.refresh_system_winget_sources_for_query(); + swap_system_winget_source_command_runner(previous); + + assert!(warning.is_none(), "{warning:?}"); + Ok(()) + })(); + + let _ = fs::remove_dir_all(&app_root); + result.expect("fresh mirror query"); + } + #[test] fn packaged_secure_settings_stub_delegates_to_system_winget_export() { + let _runner_guard = lock_system_winget_source_command_runner(); let original_runner = { let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock"); let original = *runner; @@ -11389,6 +11860,7 @@ mod tests { #[test] fn system_winget_mirror_store_uses_private_cache_and_preserves_metadata() { + let _runner_guard = lock_system_winget_source_command_runner(); let original_runner = { let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock"); let original = *runner;