Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion dotnet/src/Devolutions.Pinget.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
215 changes: 215 additions & 0 deletions dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => 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()
{
Expand Down
61 changes: 59 additions & 2 deletions dotnet/src/Devolutions.Pinget.Core/Repository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down Expand Up @@ -213,9 +214,15 @@ public void SetRequestHeader(string name, string value)

// ── Source management ──

/// <summary>
/// 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.
/// </summary>
public List<SourceRecord> ListSources()
{
RefreshSystemWingetSources();
RefreshSystemWingetSourcesForQuery(TimeSpan.Zero);
Comment thread
GabrielDuf marked this conversation as resolved.
return _store.Sources.ToList();
}

Expand Down Expand Up @@ -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");
Expand All @@ -567,6 +574,9 @@ public ListResponse List(ListQuery query)
bool hasFilter = ListQueryNeedsAvailableLookup(query);
bool needsAvailable = hasFilter || query.UpgradeOnly;
var warnings = new List<string>();
if (sourceRefreshWarning is not null)
warnings.Add(sourceRefreshWarning);

var installed = InstalledPackages.Collect(query.InstallScope);
if (!OperatingSystem.IsWindows())
warnings.Add(InstalledStateUnsupportedWarning);
Expand Down Expand Up @@ -4106,6 +4116,53 @@ private void RefreshSystemWingetSources()
};
}

/// <summary>
/// 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.
/// </summary>
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)
Expand Down
Loading
Loading