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) 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(