Skip to content

Keep the pre-indexed source file cache inside the configured app root - #51

Merged
Gabriel Dufresne (GabrielDuf) merged 4 commits into
masterfrom
portable-source-file-cache
Oct 6, 2026
Merged

Gabriel Dufresne (GabrielDuf) merged 4 commits into
masterfrom
portable-source-file-cache

Conversation

@GabrielDuf

Copy link
Copy Markdown
Contributor

Setting PINGET_APPROOT (or RepositoryOptions.AppRoot) relocated the store but not the pre-indexed source file cache, so manifests kept landing in the default per-user app root. Reported downstream as Devolutions/UniGetUI#5337: a portable UniGetUI still wrote Pinget files into %LOCALAPPDATA%, even for users on the WinGet backend who never selected Pinget.

Root cause

PreIndexedSource.TempCachePath resolved GetPackagedFileCacheRoot(null) — it ignored the app root the repository was opened with — so every manifest and versionData.mszyml fetched from a pre-indexed source's CDN went to:

%LOCALAPPDATA%\Devolutions\Pinget\Microsoft\Windows Package Manager\<bucket>\<source>\...

Our own test suite shows it: Show_Preindexed* open a repository under a temp app root and delete it in finally, yet their fixtures accumulate in the developer's real profile (V2_M\test\manifests\Test.*.yaml).

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, so for a custom root the two implementations cached to different places entirely.

Changes

Three commits, reviewable independently; each builds and is green on its own.

c357a55 — Cache pre-indexed source files under the configured app root. Threads the app root through GetCachedSourceFile and LoadV2VersionData; cache_root in Rust now always derives from the app root. Passing null still resolves the default root, so an ordinary install keeps its existing cache and needs no migration. The app root is no longer an optional trailing parameter on either helper — omitting it used to compile fine and silently reintroduce the bug.

26f13dd — Sanitize the source identifier in the cache path. Pre-existing hole, split out so it can be dropped on its own. TempCachePath interpolated SourceRecord.Identifier raw, unlike SourceStateDir/SourceInstalledDbPath beside it and unlike Rust's temp_cache_path. The identifier is never validated (AddSource takes the caller's name; the packaged loader takes it from WinGet's metadata YAML), and Path.Combine treats a second-character colon as rooted:

Path.Combine(<cacheRoot>, "V2_M", "c:foo")  ->  c:foo

so an identifier alone could put the cache outside the app root and defeat the containment the first commit establishes.

aeb7b97 — Bump version to 0.13.0. Via scripts/Set-PingetVersion.ps1. Minor bump per repo convention (no patch releases in history), and the right slot anyway given the cache relocations and the two now-required parameters.

Verification

End-to-end in UniGetUI, not just in isolation: two portable copies of the same build, one with the shipped Devolutions.Pinget.Core.dll and one with this branch's, each serving one package details request for a package not yet cached.

new files in %LOCALAPPDATA%\Devolutions\Pinget where they went
Shipped 0.12.0 — Inkscape.Inkscape 2 V2_M\...\manifests\i\Inkscape\..., V2_PVD\...\versionData.mszyml
This branch — qBittorrent.qBittorrent 0 <portable>\Settings\Pinget\Microsoft\Windows Package Manager\...

Installer metadata is unaffected — a cold-miss re-fetch of Inkscape.Inkscape under the fix returns the same version, installer URL, SHA256 and type as the stock build.

All AGENTS.md gates pass at 0.13.0:

Gate Result
cargo fmt --check exit 0
cargo clippy --workspace --tests -- -D warnings exit 0
cargo test -p pinget-core 196 passed, 0 failed
cargo test -p pinget-cli 11 passed, 0 failed
dotnet build slnx -c Release succeeded
dotnet test Core.Tests 239 passed, 1 skipped, 0 failed
pwsh RunTests.ps1 13 passed, 0 failed
dotnet format --verify-no-changes only the 6 pre-existing IDE0060, none in touched files

The new Show_Preindexed_CachesSourceFilesInsideTheConfiguredAppRoot is a genuine regression test — it fails without the fix and asserts both the V2_M and V2_PVD buckets land under the app root, so dropping _appRoot from any single call site is caught.

Behaviour changes worth a reviewer's sign-off

  1. Packaged layout relocates. When the app root is WinGet's own ...\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState, .NET previously cached to %LOCALAPPDATA%\Devolutions\Pinget\... and now caches under that LocalState. Rust always did this and CoreTests.cs:143 documents it as the intent, but other Devolutions.Pinget.Core consumers in packaged layout will see their manifest cache move. One-time re-download; no correctness impact.
  2. Rust non-packaged loses a sweepable location. The cache moves out of %TEMP%\cache into the app root. Nothing evicts these buckets — source remove deletes only the source state dir — so a long-lived install now grows permanently where OS temp cleanup could previously reclaim it. Worth deciding separately whether source remove should purge V2_M/V2_PVD/REST_M.
  3. Sanitizing changes paths for identifiers containing \ / : * ? " < > | — those entries are orphaned and re-fetched once.
  4. Non-Windows .NET moves from $TMPDIR/pinget/... into the app root. Covered by tests, but only exercised on Windows here.

Nothing migrates or deletes an existing %LOCALAPPDATA%\Devolutions\Pinget, so users keep whatever is already there and need to clear it once to see the difference.

Downstream

After a 0.13.0 release, UniGetUI needs Devolutions.Pinget.Core and Devolutions.Pinget.Cli.Rust bumped off 0.12.0 in UniGetUI.PackageEngine.Managers.WinGet.csproj and UniGetUI.Avalonia.csproj.

🤖 Generated with Claude Code

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The source-identifier sanitization needs dedicated regression coverage for unsafe identifier characters.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Keeps Pinget caches within the configured app root, improving portable-install isolation.

Changes:

  • Threads the app root through .NET pre-indexed cache operations and sanitizes source identifiers.
  • Aligns Rust cache placement with the configured app root.
  • Adds regression coverage and bumps packages to 0.13.0.
File Description
rust/​crates/​pinget-core/​src/​lib.rs Relocates Rust caches and tests the path.
rust/​crates/​pinget-core/​Cargo.toml Bumps core to 0.13.0.
rust/​crates/​pinget-com/​Cargo.toml Bumps COM bridge to 0.13.0.
rust/​crates/​pinget-cli/​Cargo.toml Bumps CLI and core dependency.
nuget/​Devolutions.Pinget.Cli.Rust/​Devolutions.Pinget.Cli.Rust.csproj Bumps Rust NuGet package.
nuget/​Devolutions.Pinget.Cli.DotNet/​Devolutions.Pinget.Cli.DotNet.csproj Bumps .NET NuGet package.
dotnet/​src/​Devolutions.Pinget.PowerShell.Engine/​PowerShellEngineVersion.cs Updates PowerShell engine version.
dotnet/​src/​Devolutions.Pinget.PowerShell.Cmdlets/​ModuleFiles/​Devolutions.Pinget.Client.psd1 Updates module version.
dotnet/​src/​Devolutions.Pinget.Core/​SourceStore.cs Makes path sanitization reusable internally.
dotnet/​src/​Devolutions.Pinget.Core/​Repository.cs Passes the configured app root to cache calls.
dotnet/​src/​Devolutions.Pinget.Core/​PreIndexedSource.cs Roots caches correctly and sanitizes identifiers.
dotnet/​src/​Devolutions.Pinget.Core.Tests/​CoreTests.cs Adds cache-containment regression coverage.
dotnet/​src/​Devolutions.Pinget.Cli/​Program.cs Updates CLI version.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread dotnet/src/Devolutions.Pinget.Core/PreIndexedSource.cs
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) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

Cache path propagation is complete and regression coverage verifies both containment and identifier sanitization.

Review effort: Balanced
Findings: None

Resolved since last review (1)

@GabrielDuf
Gabriel Dufresne (GabrielDuf) merged commit d6c05b0 into master Oct 6, 2026
11 of 14 checks passed
@GabrielDuf
Gabriel Dufresne (GabrielDuf) deleted the portable-source-file-cache branch October 6, 2026 12:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants