Skip to content

Store PATH entries in environment variable form - #6549

Open
Simon Felix Conrad (IsAvaible) wants to merge 2 commits into
microsoft:masterfrom
IsAvaible:portable-path-env-variables
Open

Simon Felix Conrad (IsAvaible) wants to merge 2 commits into
microsoft:masterfrom
IsAvaible:portable-path-env-variables

Conversation

@IsAvaible

@IsAvaible Simon Felix Conrad (IsAvaible) commented Sep 22, 2026 •

Copy link
Copy Markdown

📖 Description

Portable installs append the fully expanded links path
(C:\Users\<name>\AppData\Local\Microsoft\WinGet\Links) to the user PATH.
It breaks silently on profile moves/renames, leaks the user name into the
registry, and has caused encoding bugs (#4317).

This PR stores entries in environment variable form when under a well-known
folder (%LOCALAPPDATA%\Microsoft\WinGet\Links). Values remain
REG_EXPAND_SZ, so Windows expands them at logon, no change for PATH
consumers. Contains/Remove were reworked from substring search to
per-entry normalized comparison so both old (expanded) and new
(variable-form) entries are handled safely.

What changed

  • AppInstallerSharedLib: new Filesystem::GetUnexpandedPath().
    Unexpands to %LOCALAPPDATA%/%APPDATA%/%USERPROFILE% (User scope only)
    or %ProgramData%/%ProgramFiles%/%ProgramFiles(x86)%/%SystemRoot%,
    with slash, quote, trailing-slash (drive-root aware), and NFKC
    normalization plus separator-boundary matching.
  • AppInstallerCommonCore (PathVariable): Append stores the
    unexpanded form (Machine scope never gets user vars; empty targets
    rejected); Contains/Remove compare normalized + expanded per-entry
    values (fixes prefix/subpath corruption, handles quotes, legacy entries,
    missing/empty PATH, overlong/malformed entries); new injectable
    constructor (scope, key, readOnly, broadcastEnvironmentChange).
  • Tests + docs: PathVariable tests moved to volatile registry keys (no
    admin, no broadcast); new GetUnexpandedPath case and 10 new
    PathVariable cases; Release Notes updated.

Compatibility / Limitations

  • Legacy expanded entries are detected, never duplicated, and cleanly removed;
    all other entries keep their stored form.
  • Existing entries stay expanded until removed/re-added; the portable index
    still stores expanded paths (possible follow-up).

🔗 References

🔍 Validation

Automated (from src\<ARCH>\<Config>\AppInstallerCLITests):

AppInstallerCLITests.exe "[pathVariable]"
AppInstallerCLITests.exe "GetUnexpandedPath"
AppInstallerCLITests.exe RefreshEnvironmentVariable_User
AppInstallerCLITests.exe VerifyPathRefreshExpandsValues

Covers: variable-form storage (User vs. Machine), legacy-entry dedup/removal,
subpath/prefix preservation, exact (non-substring) matching, quoted/empty/
NFKC/overlong inputs. All PathVariable tests use volatile keys, no admin,
no real-PATH modification.

Manual:

  1. wingetdev install <portable package>
  2. reg query HKCU\Environment /v Path shows %LOCALAPPDATA%\...\Links
  3. New shell resolves the portable alias
  4. wingetdev uninstall removes the entry, neighbors intact

✅ Checklist

📋 Issue Type

  • Bug fix
  • Feature
  • Task

AI Disclosure:
This feature was coded with Antigravitiy and Gemini Flash 3.8 Medium. I’ve tested it locally and verified the behaviour, but please feel free to suggest refinements.

Microsoft Reviewers: Open in CodeFlow

Writes entries added to the PATH variable (portable package links
locations and install directories) in environment variable form (e.g.
%LOCALAPPDATA%\Microsoft\WinGet\Links) instead of as fully expanded
paths. Entries are compared and removed after expanding environment
variables, so entries written by older versions as fully expanded paths
are still detected and removed without duplicates, and entries written
this way keep working when the underlying folder location changes (such
as after a user profile rename).

Key improvements and considerations:
- Guards against overlong or pathological environment strings in user
  PATH entries using try/catch fallbacks.
- Applies symmetric Unicode NFKC normalization across all path
  comparisons and stored values.
- Enforces scope isolation so user-specific variables are never written
  to Machine-scoped PATH.
- Tokenizes and normalizes individual entries in Contains and Remove,
  preventing subpath and prefix corruption while properly handling
  quoted paths and trailing delimiters.
- Exposes a dependency-injection constructor for volatile test registry
  roots with broadcast notifications enabled by default.
- Known limitation: Portable index records and ARP entries currently
  persist absolute paths and require follow-up work to store unexpanded
  forms for uninstalls post-profile rename.

Partially addresses microsoft#5298
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

@IsAvaible

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

@github-actions

This comment has been minimized.

@Trenly

Copy link
Copy Markdown
Contributor

Could you add E2E coverage for the new PATH persistence behavior? The current unit tests validate normalization and registry-key behavior with injected keys, but they do not verify the complete portable install/uninstall flow against the real user or machine environment registry.

Suggested scenarios:

  1. User-scope install stores an environment-variable PATH entry
    Install a portable package that uses the shared Links directory, then inspect HKCU\Environment\Path with environment-variable expansion disabled. Verify that the raw value contains:

    %LOCALAPPDATA%\Microsoft\WinGet\Links;
    

    and does not contain the expanded absolute profile path. Also verify that the normal expanded registry read resolves the entry to the current Links directory and that the portable command is available.

  2. Machine-scope install does not store user-scoped variables
    For an elevated machine-scope portable install, inspect HKLM\System\CurrentControlSet\Control\Session Manager\Environment\Path. Verify that the raw entry does not use %LOCALAPPDATA%, %APPDATA%, or %USERPROFILE%. Where the install path is under a system-known location, verify that an appropriate system variable such as %ProgramFiles% or %ProgramData% is used.

  3. Uninstall and cleanup
    Verify that uninstall removes the variable-form PATH entry and leaves unrelated PATH entries intact. The assertion should inspect both the raw and expanded registry values.

  4. Shared Links directory and deduplication
    Install two portable packages that use the same Links directory. Confirm that only one %LOCALAPPDATA%\Microsoft\WinGet\Links; entry exists. Uninstall the first package and verify that the entry remains; uninstall the second and verify that the entry is removed.

  5. Mixed user- and machine-scope installs
    Install one portable package at user scope and another at machine scope, with each package creating an entry in its respective Links directory. Inspect both registry locations and verify that:

    • HKCU\Environment\Path contains only the user Links directory, represented as %LOCALAPPDATA%\Microsoft\WinGet\Links.
    • HKLM\System\CurrentControlSet\Control\Session Manager\Environment\Path contains only the machine Links directory, represented using an appropriate machine-scoped path or variable such as %ProgramFiles%\WinGet\Links.
    • The user Links directory is not written to the machine PATH, and the machine Links directory is not written to the user PATH.
    • Uninstalling the user-scoped package removes only the user Links entry and leaves the machine entry intact.
    • Uninstalling the machine-scoped package then removes only the machine Links entry.

    This would ensure that the two Links directories do not collide and that variable-form storage does not cause scope leakage.

  6. Legacy expanded-entry compatibility
    Seed the user PATH with the old fully expanded Links path, then run the relevant install/repair and uninstall flow. Verify that WinGet recognizes and removes the legacy entry without removing adjacent PATH entries.

  7. PATH refresh expansion and ordering
    Exercise the install path that refreshes the current process PATH, using PATH values containing environment-variable references. Verify that the process environment contains expanded absolute paths rather than %...% references, with machine PATH entries preceding user PATH entries.

  8. Archive binaries that depend on PATH
    Install AppInstallerTest.ArchivePortableWithBinariesDependentOnPath and verify that the package’s install directory is added to the appropriate PATH registry value rather than the shared Links directory. Inspect the raw registry value to confirm that the entry uses the expected environment-variable form when the install directory is under a known folder, and verify that the archive’s dependent binaries can run successfully through the installed package. After uninstall, confirm that only the install-directory PATH entry is removed and that unrelated PATH entries remain intact. This should also cover the machine-scope variant to ensure cleanup uses the same scope in which the entry was created.

TestCommon.VerifyPortablePackage already reads both expanded and unexpanded registry values, so extending that helper with an expected raw PATH entry would likely avoid duplicating registry-inspection logic. These tests would validate the user-visible behavior and the actual registry boundary that the semantic unit tests cannot cover.

Adds end-to-end test coverage for environment-variable PATH persistence across 8 scenarios: user-scope unexpanded storage, machine-scope system variable isolation, uninstall cleanup, shared links deduplication, cross-scope isolation, legacy expanded entry recognition and cleanup, process PATH refresh expansion/ordering, and archive portables with dependent binaries.

Extends TestCommon.VerifyPortablePackage with expectedRawPath parameter and adds PATH registry helper methods.
@IsAvaible

Copy link
Copy Markdown
Author

Hey Kaleb Luedtke (@Trenly), thanks for the quick feedback! I've added the tests in this commit. Let me know what you think :)

@github-actions

Copy link
Copy Markdown

check-spelling-bot Report

🔴 Please review

See the 📂 files view, the 📜action log, or 📝 job summary for details.

Unrecognized words (6)

hkcu
hklm
precomposed
tude
unexpand
unexpansion

These words are not needed and should be removed AAD ABCD abi ACL'd AMap Amd appdata ARMNT asan Baz bitmask bluetooth boundparms brk Buf certs cgi CMSG codepage commandline constexpr Cov cswinrt CTL Dbg Dcom decompressor dedupe DEFT devhome Dns dsc ERANGE errcode errmsg errstr filemode Finalizers FULLWIDTH fuzzer GES github Hackathon HINSTANCE hlocal hmac Hyperlink ICONDIR icu idx img inet Intelli iwr JDK LCID lhs LONGLONG LPBYTE LPCWSTR LPDWORD LPSTR LPVOID LPWSTR MAJORVERSION MAXLENGTH maxvalue MDs MINORVERSION mta nlohmann NONAME NOUPDATE NTFS ofile oid oop OPTOUT outfile OUTOFMEMORY PARAMETERMAP pdb PDWORD pid PKCS pkix placeholders positionals posix pscustomobject pseudocode PSHOST publickey qword redirector regexes remoting reparse REQS rhs rowid RTTI runspace runtimes SARL savepoint Scm sid sqlite subdir subkey trimstart ttl typedef uninitialize uninstallation UNMARSHALING userprofile versioned Webserver website wildcards winreg WMI workaround Wpp wsl

Some files were automatically ignored 🙈

These sample patterns would exclude them:

^\Q.github/workflows/duplicate-surfacing.lock.yml\E$
^\Q.github/workflows/issue-closure-recommendation.lock.yml\E$

You should consider adding them to:

.github/actions/spelling/excludes.txt

File matching is via Perl regular expressions.

To check these files, more of their words need to be in the dictionary than not. You can use patterns.txt to exclude portions, add items to the dictionary (e.g. by adding them to allow.txt), or fix typos.

To accept these unrecognized words as correct, update file exclusions, and remove the previously acknowledged and now absent words, you could run the following commands

... in a clone of the git@github.com:IsAvaible/winget-cli.git repository
on the portable-path-env-variables branch (ℹ️ how do I use this?):

curl -s -S -L 'https://raw.githubusercontent.com/check-spelling/check-spelling/cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c/apply.pl' |
perl - 'https://github.com/microsoft/winget-cli/actions/runs/35787941057/attempts/1' &&
git commit -m 'Update check-spelling metadata'

Pattern suggestions ✂️ (2)

You could add these patterns to .github/actions/spelling/patterns.txt:

# Automatically suggested patterns

# hit-count: 1 file-count: 1
# assign regex
= /[^*].*?(?:[a-z]{3,}|[A-Z]{3,}|[A-Z][a-z]{2,}).*/[gi]?(?=\W|$)

# hit-count: 1 file-count: 1
# regex choice
\(\?:[^)]+\|[^)]+\)

Alternatively, if a pattern suggestion doesn't make sense for this project, add a # to the beginning of the line in the candidates file with the pattern to stop suggesting it.

Warnings and Notices ⚠️ (2)

See the 📂 files view, the 📜action log, or 📝 job summary for details.

⚠️ Warnings and Notices Count
ℹ️ candidate-pattern 2
⚠️ slow-file 2

See ⚠️ Event descriptions for more information.

If the flagged items are 🤯 false positives

If items relate to a ...

  • binary file (or some other file you wouldn't want to check at all).

    Please add a file path to the excludes.txt file matching the containing file.

    File paths are Perl 5 Regular Expressions - you can test yours before committing to verify it will match your files.

    ^ refers to the file's path from the root of the repository, so ^README\.md$ would exclude README.md (on whichever branch you're using).

  • well-formed pattern.

    If you can write a pattern that would match it,
    try adding it to the patterns.txt file.

    Patterns are Perl 5 Regular Expressions - you can test yours before committing to verify it will match your lines.

    Note that patterns can't match multiline strings.

string pathName = "Path";
var currentPathValue = (string)environmentRegistryKey.GetValue(pathName);
var rawPathValue = (string)environmentRegistryKey.GetValue(pathName, null, RegistryValueOptions.DoNotExpandEnvironmentNames);
rawPathValue = (string)environmentRegistryKey.GetValue(pathName, null, RegistryValueOptions.DoNotExpandEnvironmentNames);

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.

Why is var removed here?

Comment on lines +576 to +577
RegistryKey baseKey = scope == Scope.User ? Registry.CurrentUser : Registry.LocalMachine;
string pathSubKey = scope == Scope.User ? Constants.PathSubKey_User : Constants.PathSubKey_Machine;

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.

These ternaries duplicate the same conditional logic. An  if / else  block would evaluate the condition once and make the mutually exclusive branches clearer

Same comment for below; Only posting once to avoid multiple comments


// Verify normal expanded registry read resolves to the current Links directory
string expandedPath = TestCommon.GetExpandedPathValue(TestCommon.Scope.User);
Assert.That(expandedPath, Does.Contain(userLinksDirClean), "Expanded PATH should contain the resolved Links directory.");

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.

Instead of adding GetExpandedPathValue and GetRawPathValue , it could make more sense to change the signature on PathContainsValue to be PathContainsValue(string value, Scope scope = Scope.User, bool expanded = true) which would avoid some of the duplicate logic for fetching the registry keys. Then it's just a matter of setting the correct expansion option on the call inside PathContainsValue

/// Gets the PATH registry value kind.
/// </summary>
/// <param name="scope">Scope.</param>
/// <returns>The registry value kind, or ExpandString if not found.</returns>

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.

Why ExpandString if not found and not None or Unknown ?

// Verify command is available and executable via PATH lookup
string refreshedPath = TestCommon.GetExpandedPathValue(TestCommon.Scope.Machine).TrimEnd(';') + ";" +
TestCommon.GetExpandedPathValue(TestCommon.Scope.User);
ProcessStartInfo startInfo = new ProcessStartInfo("cmd.exe", $"/c {Constants.AppInstallerTestExeInstallerExe} /NoOperation")

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.

Apologies for any misdirection in my original comment - I don't know that we need to actually run the command. It is probably sufficient to ensure that the path was updated like you expect. However, if you do want to verify the command is available, I'd probably use the SearchPathW windows API to check that the executable name resolves without needing to start the process

{
expanded = Utility::ExpandEnvironmentVariables(trimmedEntry);
}
catch (...)

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.

Is this really a case we want to catch and fall back to the trimmed entry on? If the path can't be expanded, it feels like that's a case where terminating the context with an internal error would be appropriate instead of masking whatever caused the path to be un-expandable

expanded = trimmedEntry;
}

std::filesystem::path p{ std::move(expanded) };

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.

Please no single letter variable names.

expanded = trimmedEntry;
}

std::filesystem::path p{ std::move(expanded) };

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.

nit: Since the constructor builds the path from a reference based constructor, the move doesn't provide optimization here, and ownership of expanded isn't important since it's consumed immediately anyways.

{
result += AppInstaller::Filesystem::GetExpandedPath(pathEntry).u8string();
result += ';';
std::wstring expanded = NormalizeAndExpandPathEntry(pathEntry);

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.

NormalizeAndExpandPathEntry converts pathEntry to UTF-16, then below the result is converted back to UTF-8 if it isn't emtpy. Is there a way to avoid converting between the two encodings?

}
}

std::filesystem::path GetUnexpandedPath(const std::filesystem::path& path, bool allowUserVariables)

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.

There should already be helpers in Runtime.cpp that can be extended to do this. Specifically ReplaceProfilePathsWithEnvironmentVariable as an example of how we already do path collapsing, and ReplaceCommonPathPrefix as the method which performs the replacement. There's also GetWellKnownFolderPath instead of trying to expand the environment variables individually to get their path on disk.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants