From 58798abdcab6ce92028f8df89e05fbc424735f93 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 1 Sep 2026 10:51:18 -0400 Subject: [PATCH 1/6] Let a host relocate storage without losing the machine's sources --- README.md | 26 ++++++ .../CoreTests.cs | 69 ++++++++++++++++ .../src/Devolutions.Pinget.Core/Repository.cs | 45 ++++++++++- rust/crates/pinget-core/src/lib.rs | 80 ++++++++++++++++--- 4 files changed, 208 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e4fa6d8..936dc8c 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,32 @@ Pinget is designed to keep **source-backed functionality** working cross-platfor - `list` and upgrade inventory return empty results with an unsupported warning - `install`, `uninstall`, executed `upgrade`, and non-dry-run `import` return explicit no-op results with unsupported warnings +## 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. + +| 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`. | + +Source modes: + +- `auto` mirrors the machine's configured WinGet sources, falling back to the private store when + they cannot be read. +- `private` uses only the source list stored under the app root, seeded with the stock `winget` + and `msstore` entries. +- `system-winget-mirror` mirrors the machine's WinGet sources with no fallback. + +Without `PINGET_SOURCE_MODE`, setting `PINGET_APPROOT` also selects `private`, on the assumption +that a caller supplying its own root wants an isolated instance. A host that relocates storage but +still wants the machine's real source list must set `PINGET_SOURCE_MODE=auto` as well; otherwise +sources the user added to WinGet are silently absent. + +In the C# API, an explicit `RepositoryOptions.SourceMode` always wins over the environment; the +variable only applies when the caller leaves it at `SourceMode.Auto`. + ## Custom REST sources Both implementations support custom REST sources, including third-party services such as `winget.pro`. diff --git a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs index c32f093..b76af18 100644 --- a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs +++ b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs @@ -365,6 +365,75 @@ public void PackagedSecureSettingsStub_DelegatesToSystemWingetExport() } } + [Theory] + [InlineData("auto", SourceMode.Auto)] + [InlineData(" Auto ", SourceMode.Auto)] + [InlineData("PRIVATE", SourceMode.Private)] + [InlineData("system-winget-mirror", SourceMode.SystemWingetMirror)] + [InlineData("SystemWingetMirror", SourceMode.SystemWingetMirror)] + public void TryParseSourceMode_AcceptsKnownSpellings(string value, SourceMode expected) + { + Assert.True(Repository.TryParseSourceMode(value, out var parsed)); + Assert.Equal(expected, parsed); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("mirror")] + [InlineData("system_winget_mirror")] + public void TryParseSourceMode_RejectsUnknownSpellings(string? value) + { + Assert.False(Repository.TryParseSourceMode(value, out _)); + } + + [Fact] + public void ResolveRequestedSourceMode_KeepsPrivateDefaultForACustomAppRoot() + { + Assert.Equal( + SourceMode.Private, + Repository.ResolveRequestedSourceMode(SourceMode.Auto, @"C:\custom", null)); + Assert.Equal( + SourceMode.Auto, + Repository.ResolveRequestedSourceMode(SourceMode.Auto, null, null)); + } + + [Fact] + public void ResolveRequestedSourceMode_LetsTheEnvironmentOverrideACustomAppRoot() + { + Assert.Equal( + SourceMode.Auto, + Repository.ResolveRequestedSourceMode(SourceMode.Auto, @"C:\custom", "auto")); + Assert.Equal( + SourceMode.SystemWingetMirror, + Repository.ResolveRequestedSourceMode(SourceMode.Auto, @"C:\custom", "system-winget-mirror")); + Assert.Equal( + SourceMode.Private, + Repository.ResolveRequestedSourceMode(SourceMode.Auto, null, "private")); + } + + [Fact] + public void ResolveRequestedSourceMode_IgnoresTheEnvironmentWhenTheCallerIsExplicit() + { + Assert.Equal( + SourceMode.SystemWingetMirror, + Repository.ResolveRequestedSourceMode(SourceMode.SystemWingetMirror, @"C:\custom", "private")); + Assert.Equal( + SourceMode.Private, + Repository.ResolveRequestedSourceMode(SourceMode.Private, null, "auto")); + } + + [Fact] + public void ResolveRequestedSourceMode_IgnoresAnUnparseableEnvironmentValue() + { + Assert.Equal( + SourceMode.Private, + Repository.ResolveRequestedSourceMode(SourceMode.Auto, @"C:\custom", "not-a-mode")); + Assert.Equal( + SourceMode.Auto, + Repository.ResolveRequestedSourceMode(SourceMode.Auto, null, "not-a-mode")); + } + [Fact] public void RepositoryOpen_UsesCustomAppRoot() { diff --git a/dotnet/src/Devolutions.Pinget.Core/Repository.cs b/dotnet/src/Devolutions.Pinget.Core/Repository.cs index e14e503..b1e0b02 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Repository.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Repository.cs @@ -12,6 +12,7 @@ namespace Devolutions.Pinget.Core; public class Repository : IDisposable { private const string AppRootEnvironmentVariable = "PINGET_APPROOT"; + private const string SourceModeEnvironmentVariable = "PINGET_SOURCE_MODE"; 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."; @@ -65,9 +66,7 @@ public static Repository Open(RepositoryOptions? options = null) var requestedAppRoot = options.AppRoot ?? Environment.GetEnvironmentVariable(AppRootEnvironmentVariable); var appRoot = SourceStoreManager.NormalizeAppRoot(requestedAppRoot); SourceStoreManager.EnsureAppDirs(appRoot); - var requestedSourceMode = options.SourceMode == SourceMode.Auto && requestedAppRoot is not null - ? SourceMode.Private - : options.SourceMode; + var requestedSourceMode = ResolveRequestedSourceMode(options.SourceMode, requestedAppRoot); var (sourceMode, store) = SourceStoreManager.LoadEffective(appRoot, requestedSourceMode); var client = new HttpClient(new SocketsHttpHandler { @@ -79,6 +78,46 @@ public static Repository Open(RepositoryOptions? options = null) return new Repository(appRoot, client, store, sourceMode, options.Diagnostics, options.PreIndexedSourceAutoUpdateInterval); } + internal static SourceMode ResolveRequestedSourceMode(SourceMode configuredSourceMode, string? requestedAppRoot) => + ResolveRequestedSourceMode( + configuredSourceMode, + requestedAppRoot, + Environment.GetEnvironmentVariable(SourceModeEnvironmentVariable)); + + internal static SourceMode ResolveRequestedSourceMode( + SourceMode configuredSourceMode, + string? requestedAppRoot, + string? sourceModeFromEnvironment) + { + if (configuredSourceMode != SourceMode.Auto) + return configuredSourceMode; + + if (TryParseSourceMode(sourceModeFromEnvironment, out var parsedSourceMode)) + return parsedSourceMode; + + return requestedAppRoot is null ? SourceMode.Auto : SourceMode.Private; + } + + internal static bool TryParseSourceMode(string? value, out SourceMode sourceMode) + { + switch (value?.Trim().ToLowerInvariant()) + { + case "auto": + sourceMode = SourceMode.Auto; + return true; + case "private": + sourceMode = SourceMode.Private; + return true; + case "system-winget-mirror": + case "systemwingetmirror": + sourceMode = SourceMode.SystemWingetMirror; + return true; + default: + sourceMode = SourceMode.Auto; + return false; + } + } + internal static IEnumerable GetSqliteNativeLibraryCandidates(string assemblyDirectory) { yield return Path.Combine(assemblyDirectory, "e_sqlite3.dll"); diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index 7de2cda..71e98fc 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -50,6 +50,8 @@ 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 APP_ROOT_ENV_VAR: &str = "PINGET_APPROOT"; +const SOURCE_MODE_ENV_VAR: &str = "PINGET_SOURCE_MODE"; #[cfg(windows)] const PACKAGED_FAMILY_NAME: &str = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe"; #[cfg(windows)] @@ -172,6 +174,29 @@ pub enum SourceMode { SystemWingetMirror, } +impl SourceMode { + fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "auto" => Some(Self::Auto), + "private" => Some(Self::Private), + "system-winget-mirror" | "systemwingetmirror" => Some(Self::SystemWingetMirror), + _ => None, + } + } + + fn from_env() -> Option { + Self::parse(&std::env::var(SOURCE_MODE_ENV_VAR).ok()?) + } + + fn resolve(from_env: Option, has_app_root_override: bool) -> Self { + from_env.unwrap_or(if has_app_root_override { + Self::Private + } else { + Self::Auto + }) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum EffectiveSourceMode { Private, @@ -193,11 +218,7 @@ impl RepositoryOptions { /// Uses the default per-user app-data root that the CLI also uses. pub fn for_current_user() -> Result { - let source_mode = if std::env::var_os("PINGET_APPROOT").is_some() { - SourceMode::Private - } else { - SourceMode::Auto - }; + let source_mode = SourceMode::resolve(SourceMode::from_env(), std::env::var_os(APP_ROOT_ENV_VAR).is_some()); Ok(Self::new(default_app_root()?).with_source_mode(source_mode)) } @@ -5044,7 +5065,7 @@ fn ensure_app_dirs(app_root: &Path) -> Result<()> { } fn default_app_root() -> Result { - if let Some(app_root) = std::env::var_os("PINGET_APPROOT") { + if let Some(app_root) = std::env::var_os(APP_ROOT_ENV_VAR) { return Ok(PathBuf::from(app_root)); } @@ -11147,21 +11168,62 @@ mod tests { ); } + #[test] + fn source_mode_parses_accepted_spellings() { + assert_eq!(SourceMode::parse("auto"), Some(SourceMode::Auto)); + assert_eq!(SourceMode::parse(" Auto "), Some(SourceMode::Auto)); + assert_eq!(SourceMode::parse("PRIVATE"), Some(SourceMode::Private)); + assert_eq!( + SourceMode::parse("system-winget-mirror"), + Some(SourceMode::SystemWingetMirror) + ); + assert_eq!( + SourceMode::parse("SystemWingetMirror"), + Some(SourceMode::SystemWingetMirror) + ); + } + + #[test] + fn source_mode_rejects_unknown_spellings() { + assert_eq!(SourceMode::parse(""), None); + assert_eq!(SourceMode::parse("mirror"), None); + assert_eq!(SourceMode::parse("system_winget_mirror"), None); + } + + #[test] + fn source_mode_resolution_keeps_private_default_for_a_custom_app_root() { + assert_eq!(SourceMode::resolve(None, true), SourceMode::Private); + assert_eq!(SourceMode::resolve(None, false), SourceMode::Auto); + } + + #[test] + fn source_mode_resolution_lets_the_environment_override_a_custom_app_root() { + assert_eq!(SourceMode::resolve(Some(SourceMode::Auto), true), SourceMode::Auto); + assert_eq!( + SourceMode::resolve(Some(SourceMode::SystemWingetMirror), true), + SourceMode::SystemWingetMirror + ); + assert_eq!( + SourceMode::resolve(Some(SourceMode::Private), false), + SourceMode::Private + ); + } + #[cfg(windows)] #[test] fn default_app_root_outside_package_avoids_packaged_layout() { // Tests run without AppX package identity, so the default app root must not // resolve to the WinGet packaged LocalState — that location requires a // brokered/elevated writer for its secure-settings stream. - let prior = std::env::var_os("PINGET_APPROOT"); + let prior = std::env::var_os(APP_ROOT_ENV_VAR); // SAFETY: tests in this module are not run concurrently with other env mutators. - unsafe { std::env::remove_var("PINGET_APPROOT") }; + unsafe { std::env::remove_var(APP_ROOT_ENV_VAR) }; let result = default_app_root(); if let Some(prior) = prior { // SAFETY: restoring the prior value before assertions panic, single-threaded test. - unsafe { std::env::set_var("PINGET_APPROOT", prior) }; + unsafe { std::env::set_var(APP_ROOT_ENV_VAR, prior) }; } let app_root = result.expect("default app root"); From 514bee63d9d3609733cd8f16d33da0bfa4ce3f80 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 1 Sep 2026 11:02:29 -0400 Subject: [PATCH 2/6] Cover the source-mode environment variable end to end --- .../CoreTests.cs | 50 +++++++++++++++++++ rust/crates/pinget-core/src/lib.rs | 20 ++++++++ 2 files changed, 70 insertions(+) diff --git a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs index b76af18..635505e 100644 --- a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs +++ b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs @@ -12,6 +12,12 @@ namespace Devolutions.Pinget.Core.Tests; +[CollectionDefinition(Name, DisableParallelization = true)] +public class RepositoryStateCollection +{ + public const string Name = "RepositoryState"; +} + public class VersionCompareTests { [Theory] @@ -112,6 +118,7 @@ public void SelectRestVersion_WithRequestedChannelMismatch_Throws() } } +[Collection(RepositoryStateCollection.Name)] public class SourceStoreTests { [Fact] @@ -434,6 +441,47 @@ public void ResolveRequestedSourceMode_IgnoresAnUnparseableEnvironmentValue() Repository.ResolveRequestedSourceMode(SourceMode.Auto, null, "not-a-mode")); } + [Theory] + [InlineData(null, false)] + [InlineData("auto", true)] + [InlineData("system-winget-mirror", true)] + [InlineData("private", false)] + [InlineData("not-a-mode", false)] + public void RepositoryOpen_ReadsTheSourceModeEnvironmentVariable(string? sourceMode, bool expectsMirror) + { + var appRoot = TestPaths.CreateTempAppRoot(); + var originalRunner = SystemWingetSourceStore.CommandRunner; + var originalSourceMode = Environment.GetEnvironmentVariable("PINGET_SOURCE_MODE"); + 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"} +""", + ""); + Environment.SetEnvironmentVariable("PINGET_SOURCE_MODE", sourceMode); + + using var repo = Repository.Open(new RepositoryOptions + { + AppRoot = appRoot, + UserAgent = "pinget-dotnet-tests/1.0", + }); + + var mirrorPath = Path.Combine(appRoot, "system-sources.json"); + Assert.Equal(expectsMirror, File.Exists(mirrorPath)); + + if (expectsMirror) + Assert.Contains("contoso", File.ReadAllText(mirrorPath)); + } + finally + { + SystemWingetSourceStore.CommandRunner = originalRunner; + Environment.SetEnvironmentVariable("PINGET_SOURCE_MODE", originalSourceMode); + TestPaths.DeleteAppRoot(appRoot); + } + } + [Fact] public void RepositoryOpen_UsesCustomAppRoot() { @@ -1246,6 +1294,7 @@ PRIMARY KEY (package_id, source_id) } } +[Collection(RepositoryStateCollection.Name)] public class RepositoryParityTests { [Fact] @@ -3061,6 +3110,7 @@ public void CreateRepairInstallRequest_ForcesReinstallOfResolvedInstalledPackage } } +[Collection(RepositoryStateCollection.Name)] public class RepositoryEmbeddingTests { private const string TesslPackageId = "tessl.tessl"; diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index 71e98fc..8bc77db 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -11190,6 +11190,26 @@ mod tests { assert_eq!(SourceMode::parse("system_winget_mirror"), None); } + #[test] + fn source_mode_reads_its_environment_variable() { + // The literal is deliberate: reading SOURCE_MODE_ENV_VAR here would keep this test + // self-consistent with a renamed constant instead of catching the rename. + let prior = std::env::var_os("PINGET_SOURCE_MODE"); + // SAFETY: no other test in this module reads or writes PINGET_SOURCE_MODE. + unsafe { std::env::set_var("PINGET_SOURCE_MODE", "system-winget-mirror") }; + + let from_env = SourceMode::from_env(); + + match prior { + // SAFETY: restoring before any assertion can panic, as the test above does. + Some(prior) => unsafe { std::env::set_var("PINGET_SOURCE_MODE", prior) }, + // SAFETY: as above; the variable was unset when the test started. + None => unsafe { std::env::remove_var("PINGET_SOURCE_MODE") }, + } + + assert_eq!(from_env, Some(SourceMode::SystemWingetMirror)); + } + #[test] fn source_mode_resolution_keeps_private_default_for_a_custom_app_root() { assert_eq!(SourceMode::resolve(None, true), SourceMode::Private); From 294bf325cacc19b870228c3ee6dc891e5cb720bb Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 1 Sep 2026 11:10:45 -0400 Subject: [PATCH 3/6] Bump version to 0.11.0 --- dotnet/src/Devolutions.Pinget.Cli/Program.cs | 2 +- .../ModuleFiles/Devolutions.Pinget.Client.psd1 | 2 +- .../PowerShellEngineVersion.cs | 2 +- .../Devolutions.Pinget.Cli.DotNet.csproj | 2 +- .../Devolutions.Pinget.Cli.Rust.csproj | 2 +- rust/crates/pinget-cli/Cargo.toml | 4 ++-- rust/crates/pinget-com/Cargo.toml | 2 +- rust/crates/pinget-core/Cargo.toml | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/dotnet/src/Devolutions.Pinget.Cli/Program.cs b/dotnet/src/Devolutions.Pinget.Cli/Program.cs index 688b57e..62fcd04 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.10.0"; +const string Version = "0.11.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.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1 b/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1 index 040e78f..ceeec40 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.10.0' + ModuleVersion = '0.11.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 23bef94..b35780e 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.10.0"; + public const string Current = "0.11.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 fd4eb82..88d4ffb 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.10.0 + 0.11.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 7b5639e..3ee2ebe 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.10.0 + 0.11.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 98c702c..2a81cef 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.10.0" +version = "0.11.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.10.0", path = "../pinget-core" } +pinget-core = { version = "0.11.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 7e4a36f..bf0129f 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.10.0" +version = "0.11.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 2332fb9..1ff3f35 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.10.0" +version = "0.11.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" From 723b411f61930514dccdebf610683a2954449671 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 1 Sep 2026 11:27:20 -0400 Subject: [PATCH 4/6] Drop a redundant Read import from the core test module --- rust/crates/pinget-core/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index 8bc77db..73d73f2 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -10663,7 +10663,7 @@ fn write_portable_arp_entry(entry: &PortableArpEntry<'_>) -> Result<()> { #[cfg(test)] mod tests { - use std::io::{Read as _, Write}; + use std::io::Write; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering}; From bd8f440e7a4d072f419b80fdfad66cbd275743d0 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 1 Sep 2026 13:16:30 -0400 Subject: [PATCH 5/6] Address review: unsafe env mutation and Auto precedence --- README.md | 6 +- .../CoreTests.cs | 25 +++++--- dotnet/src/Devolutions.Pinget.Core/Models.cs | 7 ++- .../src/Devolutions.Pinget.Core/Repository.cs | 8 +-- .../pinget-cli/tests/source_mode_env.rs | 60 +++++++++++++++++++ rust/crates/pinget-core/src/lib.rs | 20 ------- 6 files changed, 92 insertions(+), 34 deletions(-) create mode 100644 rust/crates/pinget-cli/tests/source_mode_env.rs diff --git a/README.md b/README.md index 936dc8c..0b31537 100644 --- a/README.md +++ b/README.md @@ -271,8 +271,10 @@ that a caller supplying its own root wants an isolated instance. A host that rel still wants the machine's real source list must set `PINGET_SOURCE_MODE=auto` as well; otherwise sources the user added to WinGet are silently absent. -In the C# API, an explicit `RepositoryOptions.SourceMode` always wins over the environment; the -variable only applies when the caller leaves it at `SourceMode.Auto`. +Precedence is the same in both implementations: an explicitly chosen mode wins, then +`PINGET_SOURCE_MODE`, then the app-root inference. In the C# API `RepositoryOptions.SourceMode` is +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. ## Custom REST sources diff --git a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs index 635505e..5117a67 100644 --- a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs +++ b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs @@ -399,10 +399,10 @@ public void ResolveRequestedSourceMode_KeepsPrivateDefaultForACustomAppRoot() { Assert.Equal( SourceMode.Private, - Repository.ResolveRequestedSourceMode(SourceMode.Auto, @"C:\custom", null)); + Repository.ResolveRequestedSourceMode(null, @"C:\custom", null)); Assert.Equal( SourceMode.Auto, - Repository.ResolveRequestedSourceMode(SourceMode.Auto, null, null)); + Repository.ResolveRequestedSourceMode(null, null, null)); } [Fact] @@ -410,13 +410,13 @@ public void ResolveRequestedSourceMode_LetsTheEnvironmentOverrideACustomAppRoot( { Assert.Equal( SourceMode.Auto, - Repository.ResolveRequestedSourceMode(SourceMode.Auto, @"C:\custom", "auto")); + Repository.ResolveRequestedSourceMode(null, @"C:\custom", "auto")); Assert.Equal( SourceMode.SystemWingetMirror, - Repository.ResolveRequestedSourceMode(SourceMode.Auto, @"C:\custom", "system-winget-mirror")); + Repository.ResolveRequestedSourceMode(null, @"C:\custom", "system-winget-mirror")); Assert.Equal( SourceMode.Private, - Repository.ResolveRequestedSourceMode(SourceMode.Auto, null, "private")); + Repository.ResolveRequestedSourceMode(null, null, "private")); } [Fact] @@ -430,15 +430,26 @@ public void ResolveRequestedSourceMode_IgnoresTheEnvironmentWhenTheCallerIsExpli Repository.ResolveRequestedSourceMode(SourceMode.Private, null, "auto")); } + [Fact] + public void ResolveRequestedSourceMode_TreatsAnExplicitAutoAsAChoiceRatherThanAnUnsetValue() + { + Assert.Equal( + SourceMode.Auto, + Repository.ResolveRequestedSourceMode(SourceMode.Auto, @"C:\custom", "private")); + Assert.Equal( + SourceMode.Auto, + Repository.ResolveRequestedSourceMode(SourceMode.Auto, @"C:\custom", null)); + } + [Fact] public void ResolveRequestedSourceMode_IgnoresAnUnparseableEnvironmentValue() { Assert.Equal( SourceMode.Private, - Repository.ResolveRequestedSourceMode(SourceMode.Auto, @"C:\custom", "not-a-mode")); + Repository.ResolveRequestedSourceMode(null, @"C:\custom", "not-a-mode")); Assert.Equal( SourceMode.Auto, - Repository.ResolveRequestedSourceMode(SourceMode.Auto, null, "not-a-mode")); + Repository.ResolveRequestedSourceMode(null, null, "not-a-mode")); } [Theory] diff --git a/dotnet/src/Devolutions.Pinget.Core/Models.cs b/dotnet/src/Devolutions.Pinget.Core/Models.cs index 50f1059..71a78d9 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Models.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Models.cs @@ -47,7 +47,12 @@ public record RepositoryOptions /// Storage root for Pinget state and caches. /// public string? AppRoot { get; init; } - public SourceMode SourceMode { get; init; } = SourceMode.Auto; + /// + /// Source resolution mode. Leave unset to let the app root and the + /// PINGET_SOURCE_MODE environment variable decide; any explicit value, + /// including , wins over both. + /// + public SourceMode? SourceMode { get; init; } public string UserAgent { get; init; } = "pinget-dotnet/0.1"; public Action? Diagnostics { get; init; } diff --git a/dotnet/src/Devolutions.Pinget.Core/Repository.cs b/dotnet/src/Devolutions.Pinget.Core/Repository.cs index b1e0b02..9fb53dc 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Repository.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Repository.cs @@ -78,19 +78,19 @@ public static Repository Open(RepositoryOptions? options = null) return new Repository(appRoot, client, store, sourceMode, options.Diagnostics, options.PreIndexedSourceAutoUpdateInterval); } - internal static SourceMode ResolveRequestedSourceMode(SourceMode configuredSourceMode, string? requestedAppRoot) => + internal static SourceMode ResolveRequestedSourceMode(SourceMode? configuredSourceMode, string? requestedAppRoot) => ResolveRequestedSourceMode( configuredSourceMode, requestedAppRoot, Environment.GetEnvironmentVariable(SourceModeEnvironmentVariable)); internal static SourceMode ResolveRequestedSourceMode( - SourceMode configuredSourceMode, + SourceMode? configuredSourceMode, string? requestedAppRoot, string? sourceModeFromEnvironment) { - if (configuredSourceMode != SourceMode.Auto) - return configuredSourceMode; + if (configuredSourceMode is { } explicitSourceMode) + return explicitSourceMode; if (TryParseSourceMode(sourceModeFromEnvironment, out var parsedSourceMode)) return parsedSourceMode; diff --git a/rust/crates/pinget-cli/tests/source_mode_env.rs b/rust/crates/pinget-cli/tests/source_mode_env.rs new file mode 100644 index 0000000..dcc7736 --- /dev/null +++ b/rust/crates/pinget-cli/tests/source_mode_env.rs @@ -0,0 +1,60 @@ +//! Covers the `PINGET_SOURCE_MODE` wiring through a real process. +//! +//! This lives here rather than as a unit test because asserting on an environment +//! variable requires setting one, and `std::env::set_var` may not race with any +//! concurrent read of the environment. The core test binary is multi-threaded, so a +//! child process is the only way to satisfy that precondition. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn temp_app_root(name: &str) -> PathBuf { + let root = std::env::temp_dir() + .join("pinget-cli-tests") + .join(format!("{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("create temp app root"); + root +} + +fn run_source_list(app_root: &Path, source_mode: Option<&str>) { + let mut command = Command::new(env!("CARGO_BIN_EXE_pinget")); + command.args(["source", "list"]).env("PINGET_APPROOT", app_root); + + match source_mode { + Some(value) => command.env("PINGET_SOURCE_MODE", value), + None => command.env_remove("PINGET_SOURCE_MODE"), + }; + + let output = command.output().expect("run pinget source list"); + assert!( + output.status.success(), + "pinget source list failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// The mirror store only exists once the machine's WinGet sources are mirrored, which +/// needs a real `winget`, so the observable assertion is Windows-only. The parsing and +/// precedence rules are covered by unit tests on every platform. +#[cfg(windows)] +#[test] +fn source_mode_environment_variable_overrides_the_custom_app_root_default() { + let app_root = temp_app_root("with-source-mode"); + run_source_list(&app_root, Some("auto")); + assert!( + app_root.join("system-sources.json").exists(), + "expected PINGET_SOURCE_MODE=auto to mirror the system WinGet sources" + ); +} + +#[cfg(windows)] +#[test] +fn a_custom_app_root_alone_still_selects_the_private_store() { + let app_root = temp_app_root("without-source-mode"); + run_source_list(&app_root, None); + assert!( + !app_root.join("system-sources.json").exists(), + "expected a custom app root alone to keep using the private source store" + ); +} diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index 73d73f2..8e7f81c 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -11190,26 +11190,6 @@ mod tests { assert_eq!(SourceMode::parse("system_winget_mirror"), None); } - #[test] - fn source_mode_reads_its_environment_variable() { - // The literal is deliberate: reading SOURCE_MODE_ENV_VAR here would keep this test - // self-consistent with a renamed constant instead of catching the rename. - let prior = std::env::var_os("PINGET_SOURCE_MODE"); - // SAFETY: no other test in this module reads or writes PINGET_SOURCE_MODE. - unsafe { std::env::set_var("PINGET_SOURCE_MODE", "system-winget-mirror") }; - - let from_env = SourceMode::from_env(); - - match prior { - // SAFETY: restoring before any assertion can panic, as the test above does. - Some(prior) => unsafe { std::env::set_var("PINGET_SOURCE_MODE", prior) }, - // SAFETY: as above; the variable was unset when the test started. - None => unsafe { std::env::remove_var("PINGET_SOURCE_MODE") }, - } - - assert_eq!(from_env, Some(SourceMode::SystemWingetMirror)); - } - #[test] fn source_mode_resolution_keeps_private_default_for_a_custom_app_root() { assert_eq!(SourceMode::resolve(None, true), SourceMode::Private); From 3593e80491eb1f2292c0e96827b620c0d822fe2d Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 1 Sep 2026 13:24:36 -0400 Subject: [PATCH 6/6] Gate the source-mode integration test at the crate level --- rust/crates/pinget-cli/tests/source_mode_env.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/rust/crates/pinget-cli/tests/source_mode_env.rs b/rust/crates/pinget-cli/tests/source_mode_env.rs index dcc7736..d1426a6 100644 --- a/rust/crates/pinget-cli/tests/source_mode_env.rs +++ b/rust/crates/pinget-cli/tests/source_mode_env.rs @@ -1,3 +1,9 @@ +// The observable assertion needs a real `winget` to mirror from, so the whole file is +// Windows-only. Gating at the crate level rather than per-test keeps the helpers and +// imports from being dead code on other platforms. Parsing and precedence are covered +// by pinget-core unit tests on every platform. +#![cfg(windows)] + //! Covers the `PINGET_SOURCE_MODE` wiring through a real process. //! //! This lives here rather than as a unit test because asserting on an environment @@ -34,10 +40,6 @@ fn run_source_list(app_root: &Path, source_mode: Option<&str>) { ); } -/// The mirror store only exists once the machine's WinGet sources are mirrored, which -/// needs a real `winget`, so the observable assertion is Windows-only. The parsing and -/// precedence rules are covered by unit tests on every platform. -#[cfg(windows)] #[test] fn source_mode_environment_variable_overrides_the_custom_app_root_default() { let app_root = temp_app_root("with-source-mode"); @@ -48,7 +50,6 @@ fn source_mode_environment_variable_overrides_the_custom_app_root_default() { ); } -#[cfg(windows)] #[test] fn a_custom_app_root_alone_still_selects_the_private_store() { let app_root = temp_app_root("without-source-mode");