From a2b907cdedea4710d6e06abf612282ba678ec723 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 12 May 2026 15:50:12 -0400 Subject: [PATCH 1/2] fix: fall back to non-packaged app root when no AppX identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pinget unconditionally pointed its default app root at the WinGet packaged LocalState, so any first-run command from a non-packaged, non-elevated process tried to create %ProgramData%\Microsoft\WinGet\{sid}\settings\pkg\... and failed with "Access is denied" — making pinget unusable when bundled by callers like UniGetUI. Detect package identity via GetCurrentPackageFullName and, when absent, default the app root to %LOCALAPPDATA%\Devolutions\ Pinget; PINGET_APPROOT still opts a packaged caller back into the WinGet LocalState layout. Co-Authored-By: Claude Opus 4.7 (1M context) --- rust/crates/pinget-core/Cargo.toml | 2 +- rust/crates/pinget-core/src/lib.rs | 59 +++++++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/rust/crates/pinget-core/Cargo.toml b/rust/crates/pinget-core/Cargo.toml index 7226887..ba80876 100644 --- a/rust/crates/pinget-core/Cargo.toml +++ b/rust/crates/pinget-core/Cargo.toml @@ -25,4 +25,4 @@ zip = "8.5.1" [target.'cfg(windows)'.dependencies] winreg = "0.55.0" -windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading"] } +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_Packaging_Appx", "Win32_System_Threading"] } diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index bd26555..2aec677 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -26,6 +26,8 @@ use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW; #[cfg(windows)] use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser}; #[cfg(windows)] +use windows_sys::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName; +#[cfg(windows)] use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; #[cfg(windows)] use winreg::{RegKey, enums::*}; @@ -2995,10 +2997,13 @@ fn default_app_root() -> Result { #[cfg(windows)] { - Ok(local_app_data - .join("Packages") - .join(PACKAGED_FAMILY_NAME) - .join("LocalState")) + if has_current_package_identity() { + return Ok(local_app_data + .join("Packages") + .join(PACKAGED_FAMILY_NAME) + .join("LocalState")); + } + Ok(local_app_data.join("Devolutions").join("Pinget")) } #[cfg(not(windows))] @@ -3007,6 +3012,19 @@ fn default_app_root() -> Result { } } +#[cfg(windows)] +fn has_current_package_identity() -> bool { + // APPMODEL_ERROR_NO_PACKAGE; documented Win32 error returned when the calling + // process has no AppX package identity. + const APPMODEL_ERROR_NO_PACKAGE: u32 = 15700; + + let mut length: u32 = 0; + // SAFETY: GetCurrentPackageFullName accepts a null name buffer when length is 0; it returns + // ERROR_INSUFFICIENT_BUFFER for packaged callers and APPMODEL_ERROR_NO_PACKAGE otherwise. + let rc = unsafe { GetCurrentPackageFullName(&mut length, std::ptr::null_mut()) }; + rc != APPMODEL_ERROR_NO_PACKAGE +} + fn store_path(app_root: &Path) -> PathBuf { app_root.join("sources.json") } @@ -6741,8 +6759,12 @@ mod tests { #[cfg(windows)] #[test] - fn packaged_layout_defaults_to_packaged_paths() { - let app_root = default_app_root().expect("default app root"); + fn packaged_layout_paths_resolve_from_packaged_app_root() { + let local_app_data = dirs::data_local_dir().expect("LocalAppData"); + let app_root = local_app_data + .join("Packages") + .join(PACKAGED_FAMILY_NAME) + .join("LocalState"); assert!(uses_packaged_layout(&app_root)); assert!( user_settings_path(&app_root) @@ -6761,6 +6783,31 @@ mod tests { ); } + #[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"); + // SAFETY: tests in this module are not run concurrently with other env mutators. + unsafe { std::env::remove_var("PINGET_APPROOT") }; + + 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) }; + } + + let app_root = result.expect("default app root"); + assert!(!uses_packaged_layout(&app_root), "got packaged layout for {app_root:?}"); + assert!( + app_root.ends_with(Path::new("Devolutions").join("Pinget")), + "unexpected fallback app root: {app_root:?}" + ); + } + #[test] fn packaged_source_stream_overlays_defaults_and_metadata() { let store = parse_packaged_source_store( From fdb6f0500c6acf9acda777cb892a527dbc3c739d Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 12 May 2026 16:17:25 -0400 Subject: [PATCH 2/2] fix(dotnet): fall back to non-packaged app root when no AppX identity Mirrors the Rust fix in NormalizeAppRoot: detect AppX package identity via GetCurrentPackageFullName and, when absent, default the app root to %LOCALAPPDATA%\Devolutions\Pinget instead of WinGet's SYSTEM-owned secure-settings location under %ProgramData%\Microsoft\WinGet\{sid}\ settings\pkg\Microsoft.DesktopAppInstaller. This keeps the dotnet tool and PowerShell module usable from non-packaged, non-elevated callers. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CoreTests.cs | 19 +++++++++++-- .../Devolutions.Pinget.Core/SourceStore.cs | 28 +++++++++++++++++-- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs index 421908a..ec78284 100644 --- a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs +++ b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs @@ -39,17 +39,32 @@ public void DefaultSources_ContainsWingetAndMsstore() } [Fact] - public void PackagedLayout_DefaultsToPackagedSettingsAndCachePaths() + public void PackagedLayout_PathsResolveFromPackagedAppRoot() { if (!OperatingSystem.IsWindows()) return; - var appRoot = SourceStoreManager.NormalizeAppRoot(null); + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var appRoot = Path.Combine(localAppData, "Packages", SourceStoreManager.PackagedFamilyName, "LocalState"); Assert.EndsWith(Path.Combine("Packages", SourceStoreManager.PackagedFamilyName, "LocalState"), appRoot, StringComparison.OrdinalIgnoreCase); Assert.EndsWith(Path.Combine("Packages", SourceStoreManager.PackagedFamilyName, "LocalState", "settings.json"), SettingsStoreManager.UserSettingsPath(appRoot), StringComparison.OrdinalIgnoreCase); Assert.EndsWith(Path.Combine("Packages", SourceStoreManager.PackagedFamilyName, "LocalState", "Microsoft", "Windows Package Manager"), SourceStoreManager.GetPackagedFileCacheRoot(appRoot), StringComparison.OrdinalIgnoreCase); } + [Fact] + public void DefaultAppRootOutsidePackage_AvoidsPackagedLayout() + { + // 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. + if (!OperatingSystem.IsWindows()) + return; + + var appRoot = SourceStoreManager.NormalizeAppRoot(null); + Assert.EndsWith(Path.Combine("Devolutions", "Pinget"), appRoot, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(Path.Combine("Packages", SourceStoreManager.PackagedFamilyName), appRoot, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void PackagedSourceYaml_OverlaysDefaultsAndMetadata() { diff --git a/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs b/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs index c69ac4e..5dcf89f 100644 --- a/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs +++ b/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using System.Security.Principal; using System.Text.Json; using System.Text.Json.Serialization; @@ -55,9 +56,30 @@ public static string NormalizeAppRoot(string? appRoot) return Path.GetFullPath(appRoot); var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - return OperatingSystem.IsWindows() - ? Path.Combine(localAppData, "Packages", PackagedFamilyName, "LocalState") - : Path.Combine(localAppData, "pinget"); + if (!OperatingSystem.IsWindows()) + return Path.Combine(localAppData, "pinget"); + + if (HasCurrentPackageIdentity()) + return Path.Combine(localAppData, "Packages", PackagedFamilyName, "LocalState"); + + return Path.Combine(localAppData, "Devolutions", "Pinget"); + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = false)] + private static extern int GetCurrentPackageFullName(ref uint packageFullNameLength, IntPtr packageFullName); + + private static bool HasCurrentPackageIdentity() + { + if (!OperatingSystem.IsWindows()) + return false; + + // APPMODEL_ERROR_NO_PACKAGE; documented Win32 error returned when the + // calling process has no AppX package identity. + const int AppModelErrorNoPackage = 15700; + + uint length = 0; + var rc = GetCurrentPackageFullName(ref length, IntPtr.Zero); + return rc != AppModelErrorNoPackage; } public static void EnsureAppDirs(string? appRoot = null)