From c357a555db5df68fe571091bb3cc6b8015e98d90 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Mon, 5 Oct 2026 15:46:43 -0400 Subject: [PATCH 1/4] Cache pre-indexed source files under the configured app root TempCachePath resolved GetPackagedFileCacheRoot(null), so every manifest and versionData file fetched from a pre-indexed source's CDN landed in the default per-user app root no matter which root the repository was opened with. A host that sets PINGET_APPROOT - a portable UniGetUI, or anything passing RepositoryOptions.AppRoot - had its store relocated but kept writing these files to %LOCALAPPDATA%\Devolutions\Pinget\Microsoft\ Windows Package Manager. Pinget's own tests showed it: they open a repository under a temp app root and delete it afterwards, yet their fixtures accumulate in the developer's real profile. Thread the app root through GetCachedSourceFile and LoadV2VersionData instead. Passing null still resolves the default root, so an ordinary install keeps its existing cache and needs no migration. The Rust twin had drifted the other way: cache_root honoured the app root only for the packaged layout and otherwise fell back to %TEMP%\cache. Both implementations now derive the cache from the app root alone, so they agree on every platform. The app root is no longer an optional trailing parameter on either helper. Omitting it used to compile fine and silently reintroduce the bug. Co-Authored-By: Claude Opus 5 (1M context) --- .../CoreTests.cs | 46 +++++++++++++++++++ .../PreIndexedSource.cs | 26 ++++------- .../src/Devolutions.Pinget.Core/Repository.cs | 6 +-- rust/crates/pinget-core/src/lib.rs | 18 ++++---- 4 files changed, 68 insertions(+), 28 deletions(-) diff --git a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs index f546fe9..b45174b 100644 --- a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs +++ b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs @@ -3658,6 +3658,52 @@ public void Show_PreindexedStaleIndex_RefreshesBeforeLatestSelection() } } + [Fact] + public void Show_Preindexed_CachesSourceFilesInsideTheConfiguredAppRoot() + { + const string packageId = "Test.AppRootScopedCachePackage"; + var catalog = PreindexedCatalogFixture.Create(packageId, "1.0.0"); + + using var server = new TestPreindexedSourceServer(catalog.MsixBytes, catalog.Files); + var appRoot = TestPaths.CreateTempAppRoot(); + try + { + using var repo = Repository.Open(new RepositoryOptions + { + AppRoot = appRoot, + PreIndexedSourceAutoUpdateInterval = TimeSpan.FromDays(1), + }); + ReplaceSources(repo, ("test", server.Url, SourceKind.PreIndexed)); + WritePreindexedIndex(appRoot, repo, "test", catalog.IndexBytes); + + var result = repo.ShowManifest(new PackageQuery + { + Id = packageId, + Exact = true, + Source = "test", + }); + + Assert.Equal("1.0.0", result.PackageVersion); + Assert.NotEmpty(result.CachedFiles); + Assert.All(result.CachedFiles, file => Assert.StartsWith( + Path.GetFullPath(appRoot), + Path.GetFullPath(file), + StringComparison.OrdinalIgnoreCase)); + + var cacheRoot = Path.Combine(appRoot, "Microsoft", "Windows Package Manager"); + Assert.True(Directory.Exists(cacheRoot)); + var cached = Directory.GetFiles(cacheRoot, "*", SearchOption.AllDirectories); + Assert.Contains(cached, file => + file.Contains(Path.Combine("V2_M", "test"), StringComparison.OrdinalIgnoreCase)); + Assert.Contains(cached, file => + file.Contains(Path.Combine("V2_PVD", "test"), StringComparison.OrdinalIgnoreCase)); + } + finally + { + TestPaths.DeleteAppRoot(appRoot); + } + } + [Fact] public void Show_PreindexedStaleIndex_NotModifiedKeepsLocalIndex() { diff --git a/dotnet/src/Devolutions.Pinget.Core/PreIndexedSource.cs b/dotnet/src/Devolutions.Pinget.Core/PreIndexedSource.cs index 019c092..ef7372f 100644 --- a/dotnet/src/Devolutions.Pinget.Core/PreIndexedSource.cs +++ b/dotnet/src/Devolutions.Pinget.Core/PreIndexedSource.cs @@ -378,7 +378,7 @@ public static string ResolveV1RelativePath(SqliteConnection conn, long? pathPart // V2 version data: fetch versionData.mszyml from CDN, decompress CK+deflate → YAML public static (List Entries, string VersionDataFile) LoadV2VersionData( - HttpClient client, SqliteConnection conn, SourceRecord source, long packageRowid, string packageHash, string? appRoot = null) + HttpClient client, SqliteConnection conn, SourceRecord source, long packageRowid, string packageHash, string? appRoot) { // Resolve package id from packages table using var idCmd = conn.CreateCommand(); @@ -390,13 +390,13 @@ public static (List Entries, string VersionDataFile) LoadV2V var hashPrefix = packageHash.Length >= 8 ? packageHash[..8].ToLowerInvariant() : packageHash.ToLowerInvariant(); var relativePath = $"packages/{packageId}/{hashPrefix}/versionData.mszyml"; - var bytes = GetCachedSourceFile(client, "V2_PVD", source, relativePath, packageHash); + var bytes = GetCachedSourceFile(client, "V2_PVD", source, relativePath, packageHash, appRoot); var yaml = DecompressMszyml(bytes); var doc = ParseV2VersionDataDocument(yaml) ?? throw new InvalidOperationException("Failed to parse versionData.mszyml"); - var cacheDir = TempCachePath("V2_PVD", source.Identifier); + var cacheDir = TempCachePath(appRoot, "V2_PVD", source.Identifier); return (doc.Versions, Path.Combine(cacheDir, relativePath.Replace('/', '\\'))); } @@ -541,10 +541,10 @@ private static void SkipNode(IParser parser) } public static byte[] GetCachedSourceFile( - HttpClient client, string bucket, SourceRecord source, string relativePath, string? expectedHash) + HttpClient client, string bucket, SourceRecord source, string relativePath, string? expectedHash, string? appRoot) { var normalizedRelative = relativePath.Replace('\\', '/'); - var cacheDir = TempCachePath(bucket, source.Identifier); + var cacheDir = TempCachePath(appRoot, bucket, source.Identifier); var cachePath = Path.Combine(cacheDir, normalizedRelative.Replace('/', '\\')); // Check cache first @@ -596,18 +596,12 @@ public static byte[] GetCachedSourceFileFromMsix(SourceRecord source, string rel throw new InvalidOperationException($"Source file '{relativePath}' not found in MSIX for {source.Name}"); } - private static string TempCachePath(string bucket, string sourceIdentifier) + private static string TempCachePath(string? appRoot, string bucket, string sourceIdentifier) { - if (OperatingSystem.IsWindows()) - { - return Path.Combine( - SourceStoreManager.GetPackagedFileCacheRoot(null), - bucket, - sourceIdentifier); - } - - var tempDir = Path.GetTempPath(); - return Path.Combine(tempDir, "pinget", bucket, sourceIdentifier); + return Path.Combine( + SourceStoreManager.GetPackagedFileCacheRoot(appRoot), + bucket, + sourceIdentifier); } private static bool HashMatches(string? expected, byte[] data) diff --git a/dotnet/src/Devolutions.Pinget.Core/Repository.cs b/dotnet/src/Devolutions.Pinget.Core/Repository.cs index 8190f4f..4e9b659 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Repository.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Repository.cs @@ -1944,7 +1944,7 @@ private List VersionsFromV2(int sourceIndex, long packageRowid, stri var versions = PreIndexedSource.QueryV1Versions(conn, packageRowid); var selected = SelectV1Version(versions, query.Version, query.Channel, display.Id, source.Name); var relativePath = PreIndexedSource.ResolveV1RelativePath(conn, selected.PathPart); - var bytes = PreIndexedSource.GetCachedSourceFile(_client, "V1_M", source, relativePath, selected.ManifestHash); + var bytes = PreIndexedSource.GetCachedSourceFile(_client, "V1_M", source, relativePath, selected.ManifestHash, _appRoot); var manifest = ParseYamlManifest(bytes); var structuredDocument = ParseYamlManifestDocuments(bytes); manifest = manifest with { Version = selected.Version, Channel = selected.Channel }; @@ -1993,7 +1993,7 @@ private List VersionsFromV2(int sourceIndex, long packageRowid, stri var source = _store.Sources[sourceIndex]; var (entries, vdFile) = PreIndexedSource.LoadV2VersionData(_client, conn, source, packageRowid, packageHash, _appRoot); var selected = SelectV2Version(entries, query.Version, query.Channel, display.Id, source.Name); - var bytes = PreIndexedSource.GetCachedSourceFile(_client, "V2_M", source, selected.ManifestRelativePath, selected.ManifestHash); + var bytes = PreIndexedSource.GetCachedSourceFile(_client, "V2_M", source, selected.ManifestRelativePath, selected.ManifestHash, _appRoot); var manifest = ParseYamlManifest(bytes); var structuredDocument = ParseYamlManifestDocuments(bytes); manifest = manifest with { Version = selected.Version }; @@ -3524,7 +3524,7 @@ private List EnrichCorrelatedViaIndex(List installed, try { var bytes = PreIndexedSource.GetCachedSourceFile( - _client, "V2_M", source, latest.ManifestRelativePath, latest.ManifestHash); + _client, "V2_M", source, latest.ManifestRelativePath, latest.ManifestHash, _appRoot); var manifest = ParseYamlManifest(bytes); installed[idx].CorrelatedRequiresExplicitUpgrade = manifest.RequireExplicitUpgrade; installed[idx].CorrelatedLacksCompatibleInstaller = !ManifestHasCompatibleInstaller(manifest); diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index bcadfac..86e0bee 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -6331,15 +6331,7 @@ fn packaged_file_cache_root(app_root: &Path) -> PathBuf { } fn cache_root(app_root: &Path) -> PathBuf { - if uses_packaged_layout(app_root) { - packaged_file_cache_root(app_root) - } else { - default_cache_root_fallback() - } -} - -fn default_cache_root_fallback() -> PathBuf { - std::env::temp_dir().join("cache") + packaged_file_cache_root(app_root) } fn packaged_source_type(kind: SourceKind) -> &'static str { @@ -11321,6 +11313,14 @@ mod tests { app_root.join("sources").join("winget_test") ); assert_eq!(pins_db_path(&app_root), app_root.join("pins.db")); + assert_eq!( + temp_cache_path(&app_root, "V1_M", &source.identifier), + app_root + .join("Microsoft") + .join("Windows Package Manager") + .join("V1_M") + .join("Test.Source") + ); } #[cfg(windows)] From 26f13dd0326a98dd7f38ce445af1fdbf6e43794d Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Mon, 5 Oct 2026 15:47:14 -0400 Subject: [PATCH 2/4] Sanitize the source identifier in the cache path TempCachePath interpolated SourceRecord.Identifier as a path segment without sanitizing it, unlike SourceStateDir and SourceInstalledDbPath beside it, and unlike the Rust twin temp_cache_path, which has always called sanitize_path_segment. The identifier is not validated anywhere: AddSource takes it from the caller's source name, and the packaged-layout loader takes it straight from WinGet's metadata YAML. Path.Combine treats a segment whose second character is a colon as rooted, so an identifier like `c:foo` discards the cache root outright and writes outside the app root - which defeats the containment the previous commit establishes. Separators in the identifier escape it a directory at a time. It was also a live divergence: for any identifier containing one of \ / : * ? " < > | the two implementations cached to different directories and re-fetched each other's files. Co-Authored-By: Claude Opus 5 (1M context) --- dotnet/src/Devolutions.Pinget.Core/PreIndexedSource.cs | 2 +- dotnet/src/Devolutions.Pinget.Core/SourceStore.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Devolutions.Pinget.Core/PreIndexedSource.cs b/dotnet/src/Devolutions.Pinget.Core/PreIndexedSource.cs index ef7372f..c4e4e8e 100644 --- a/dotnet/src/Devolutions.Pinget.Core/PreIndexedSource.cs +++ b/dotnet/src/Devolutions.Pinget.Core/PreIndexedSource.cs @@ -601,7 +601,7 @@ private static string TempCachePath(string? appRoot, string bucket, string sourc return Path.Combine( SourceStoreManager.GetPackagedFileCacheRoot(appRoot), bucket, - sourceIdentifier); + SourceStoreManager.SanitizePathSegment(sourceIdentifier)); } private static bool HashMatches(string? expected, byte[] data) diff --git a/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs b/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs index 79130b7..6ec9e76 100644 --- a/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs +++ b/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs @@ -642,6 +642,6 @@ private static void AddYamlKeyValue(Dictionary yamlSource, stri return value; } - private static string SanitizePathSegment(string value) => string.Concat(value.Select(c => + internal static string SanitizePathSegment(string value) => string.Concat(value.Select(c => @"\/:*?""<>|".Contains(c) ? '_' : c)); } From aeb7b971c5e5829f19be1af5fe597f3c6cf13209 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Mon, 5 Oct 2026 15:51:39 -0400 Subject: [PATCH 3/4] Bump version to 0.13.0 Co-Authored-By: Claude Opus 5 (1M context) --- 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 3527dad..29dc923 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.12.0"; +const string Version = "0.13.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 ef64ee3..ee2cb4e 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.12.0' + ModuleVersion = '0.13.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 08bee35..1c6722f 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.12.0"; + public const string Current = "0.13.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 24f4153..2c31e6e 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.12.0 + 0.13.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 db90fa9..751e292 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.12.0 + 0.13.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 7a21679..1acbe9a 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.12.0" +version = "0.13.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.12.0", path = "../pinget-core" } +pinget-core = { version = "0.13.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 f7f6193..a7bcfc6 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.12.0" +version = "0.13.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 339b8ae..f71e7b8 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.12.0" +version = "0.13.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 8e9ef9006eb7cbe789cc3bf61b2fbcf78a14cc2b Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Mon, 5 Oct 2026 16:25:53 -0400 Subject: [PATCH 4/4] Cover identifier sanitization in the cache path test The existing containment test used the source identifier `test`, which sanitizes to itself, so it stayed green with the SanitizePathSegment call removed - the app-root fix and the path-containment fix were not covered independently. Drive the same flow with the identifier `c:foo`. Path.Combine treats a second-character colon as rooted, so without sanitizing, the cached file escapes the app root entirely and the containment assertion fails; with it, the cache lands under `V2_M\c_foo` and `V2_PVD\c_foo` inside the configured app root. Co-Authored-By: Claude Opus 5 (1M context) --- .../CoreTests.cs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs index b45174b..fd30891 100644 --- a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs +++ b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs @@ -3704,6 +3704,53 @@ public void Show_Preindexed_CachesSourceFilesInsideTheConfiguredAppRoot() } } + [Fact] + public void Show_PreindexedUnsafeIdentifier_SanitizesTheCacheSegmentInsideTheAppRoot() + { + const string packageId = "Test.UnsafeIdentifierCachePackage"; + const string sourceName = "c:foo"; + var catalog = PreindexedCatalogFixture.Create(packageId, "1.0.0"); + + using var server = new TestPreindexedSourceServer(catalog.MsixBytes, catalog.Files); + var appRoot = TestPaths.CreateTempAppRoot(); + try + { + using var repo = Repository.Open(new RepositoryOptions + { + AppRoot = appRoot, + PreIndexedSourceAutoUpdateInterval = TimeSpan.FromDays(1), + }); + ReplaceSources(repo, (sourceName, server.Url, SourceKind.PreIndexed)); + WritePreindexedIndex(appRoot, repo, sourceName, catalog.IndexBytes); + + var result = repo.ShowManifest(new PackageQuery + { + Id = packageId, + Exact = true, + Source = sourceName, + }); + + Assert.Equal("1.0.0", result.PackageVersion); + Assert.NotEmpty(result.CachedFiles); + Assert.All(result.CachedFiles, file => Assert.StartsWith( + Path.GetFullPath(appRoot), + Path.GetFullPath(file), + StringComparison.OrdinalIgnoreCase)); + + var cacheRoot = Path.Combine(appRoot, "Microsoft", "Windows Package Manager"); + Assert.True(Directory.Exists(cacheRoot)); + var cached = Directory.GetFiles(cacheRoot, "*", SearchOption.AllDirectories); + Assert.Contains(cached, file => + file.Contains(Path.Combine("V2_M", "c_foo"), StringComparison.OrdinalIgnoreCase)); + Assert.Contains(cached, file => + file.Contains(Path.Combine("V2_PVD", "c_foo"), StringComparison.OrdinalIgnoreCase)); + } + finally + { + TestPaths.DeleteAppRoot(appRoot); + } + } + [Fact] public void Show_PreindexedStaleIndex_NotModifiedKeepsLocalIndex() {