From a533c46ca69a648856de0409e4f6a46b9203203f Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Fri, 17 Jul 2026 09:37:37 -0400 Subject: [PATCH 1/2] [2.0-stable] Gated port: MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY child-process isolation (#5987) Ports the base-directory PID-guard fix (main #6619) into release/2.0-stable, gated behind a runtime-compatibility containment change (WINAPPSDK_CHANGEID_63098302) so apps can revert. MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY is set by the Windows App SDK auto-initializer (a requirement for PublishSingleFile SxS manifest redirection). Because it is a process environment variable it is inherited by child processes spawned via CreateProcess, so a child's MRTCore resolved the parent's resources.pri and reg-free WinRT redirection could be steered by a parent-controlled directory. The setter now also stamps MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY_PID with the owning process id. Gated behind WINAPPSDK_CHANGEID_63098302 (ContainmentV2 form): - Enabled (default): MRM.cpp and catalog.cpp honor the base directory only when the stamp matches the current process; inherited (foreign) values fall back to the module directory. - Disabled (via RuntimeCompatibilityOptions): the base directory is honored regardless of the stamp (byte-for-byte prior behavior). Changes: - UndockedRegFreeWinRT-AutoInitializer.cs: stamp the owning process id (ungated; consumers ignore it when the change is disabled). - MRM.cpp: add WINAPPSDK_CHANGEID_63098302 and gate the base-directory PID guard. - catalog.cpp: gate the %MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY% strip on the change (WindowsAppRuntime_DLL already links the FrameworkUdk containment worker). - MrmTests.cpp: EnvironmentVariableIsolationTests (enabled: in-process guard + real cross-process insulation) and BaseDirectoryContainmentDisabledTest (IsolationLevel=Method; disables 63098302 and verifies the inherited base directory is honored). Verified locally (x64 Debug): MrmUnitTest 25/26 (1 skipped child-only worker), both enabled and disabled cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dac4165f-ec13-4406-9206-60cd55b24c2b --- dev/MRTCore/mrt/Core/src/MRM.cpp | 49 ++- dev/MRTCore/mrt/Core/unittests/MrmTests.cpp | 289 ++++++++++++++++++ .../UndockedRegFreeWinRT-AutoInitializer.cs | 9 + dev/UndockedRegFreeWinRT/catalog.cpp | 43 +++ 4 files changed, 387 insertions(+), 3 deletions(-) diff --git a/dev/MRTCore/mrt/Core/src/MRM.cpp b/dev/MRTCore/mrt/Core/src/MRM.cpp index c730d9d3ed..851921d902 100644 --- a/dev/MRTCore/mrt/Core/src/MRM.cpp +++ b/dev/MRTCore/mrt/Core/src/MRM.cpp @@ -19,11 +19,19 @@ #include #include +#include // Bug 63048673: Restore MrmGetFilePathFromName always-succeed contract (return S_OK with a // best-effort path when no PRI file exists) instead of failing with ERROR_FILE_NOT_FOUND. #define WINAPPSDK_CHANGEID_63048673 63048673 +// Bug 63098344/63098302: MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY is a process environment variable +// (inherited across CreateProcess). Honor it only when it was stamped by the current process, so a +// child does not resolve the parent's resources.pri. The change is gated so apps can revert to the +// prior (unguarded) behavior via RuntimeCompatibilityOptions. +// See https://github.com/microsoft/WindowsAppSDK/issues/5987. +#define WINAPPSDK_CHANGEID_63098302 63098302 + using namespace Microsoft::Resources; typedef struct _MrmObjects @@ -956,6 +964,26 @@ STDAPI_(void) MrmFreeResource(_In_opt_ void* resource) return; } +// The base directory is communicated via the MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY process +// environment variable, which is inherited by child processes spawned via CreateProcess. Its owner +// also stamps its process id in MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY_PID. Honor the base +// directory only when that stamp matches the current process; otherwise it was inherited from a +// parent and must be ignored (a child would otherwise resolve resources from the parent's directory). +// See https://github.com/microsoft/WindowsAppSDK/issues/5987. +static bool IsBaseDirectoryStampedByCurrentProcess() +{ + wchar_t stampedPidText[16]{}; + const DWORD length{ ::GetEnvironmentVariableW( + L"MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY_PID", stampedPidText, ARRAYSIZE(stampedPidText)) }; + if ((length == 0) || (length >= ARRAYSIZE(stampedPidText))) + { + // Not stamped (absent) or unexpectedly long: do not honor the base directory. + return false; + } + + return static_cast(wcstoul(stampedPidText, nullptr, 10)) == ::GetCurrentProcessId(); +} + // When filename is provided, append filename to current module path. If the file doesn't exist, it will // append the filename to parent path. If none exists, file in current module // path will be returned. @@ -980,6 +1008,10 @@ STDAPI MrmGetFilePathFromName(_In_opt_ PCWSTR filename, _Outptr_ PWSTR* filePath // ERROR_FILE_NOT_FOUND when no PRI file exists. const bool fallbackChangeEnabled = WinAppSdk::Containment::IsChangeEnabled(); + // When disabled via RuntimeCompatibilityOptions, retain the pre-fix behavior of honoring an + // inherited MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY regardless of which process stamped it. + const bool baseDirPidGuardEnabled = WinAppSdk::Containment::IsChangeEnabled(); + wil::unique_cotaskmem_string exeDir; RETURN_IF_FAILED(wil::GetModuleFileNameW(nullptr, exeDir)); PCWSTR exeName = wil::find_last_path_segment(exeDir.get()); @@ -1015,9 +1047,20 @@ STDAPI MrmGetFilePathFromName(_In_opt_ PCWSTR filename, _Outptr_ PWSTR* filePath searchStart = SearchPass::exeDirForResourcesPri; if (SUCCEEDED(wil::TryGetEnvironmentVariableW(L"MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY", baseDir)) && baseDir) { - searchStart = SearchPass::BaseDirForResourcesPri; - RETURN_IF_FAILED(StringCchLengthW(baseDir.get(), STRSAFE_MAX_CCH, &baseDirCount)); - RETURN_IF_FAILED(SizeTAdd(baseDirCount, 1, &baseDirCount)); + // The base directory is a process environment variable (inherited across CreateProcess); + // when the guard is enabled and the value was not stamped by this process, ignore it so a + // child does not load the parent's resources.pri. When the guard is disabled, honor it + // regardless (pre-fix behavior). + if (baseDirPidGuardEnabled && !IsBaseDirectoryStampedByCurrentProcess()) + { + baseDir.reset(); + } + else + { + searchStart = SearchPass::BaseDirForResourcesPri; + RETURN_IF_FAILED(StringCchLengthW(baseDir.get(), STRSAFE_MAX_CCH, &baseDirCount)); + RETURN_IF_FAILED(SizeTAdd(baseDirCount, 1, &baseDirCount)); + } } } diff --git a/dev/MRTCore/mrt/Core/unittests/MrmTests.cpp b/dev/MRTCore/mrt/Core/unittests/MrmTests.cpp index e9bc7e84c0..1b2fc3c348 100644 --- a/dev/MRTCore/mrt/Core/unittests/MrmTests.cpp +++ b/dev/MRTCore/mrt/Core/unittests/MrmTests.cpp @@ -6,10 +6,17 @@ #include "..\src\MRM.h" #include +#include +#include + // Change ID that gates the MrmGetFilePathFromName always-succeed fallback (see MRM.cpp). Defined // here (mirroring the inline definition in MRM.cpp) so the disabled-behavior test can name it. #define WINAPPSDK_CHANGEID_63048673 63048673 +// Change ID that gates the MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY per-process PID guard (see +// MRM.cpp). Defined here, mirroring MRM.cpp, so the disabled-behavior test can name it. +#define WINAPPSDK_CHANGEID_63098302 63098302 + using namespace WEX::Common; using namespace WEX::TestExecution; using namespace WEX::Logging; @@ -768,4 +775,286 @@ class ContainmentDisabledTest VERIFY_IS_NULL(path); } }; + +// Regression tests for https://github.com/microsoft/WindowsAppSDK/issues/5987 (enabled/default state). +// +// MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY is a process environment variable set by the Windows App +// SDK auto-initializer (for PublishSingleFile SxS redirection). Because it is a process environment +// variable it is inherited by child processes spawned via CreateProcess. To keep a child from loading +// the parent's resources.pri, the auto-initializer also stamps MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY_PID +// with the owning process id, and MrmGetFilePathFromName honors the base directory only when that stamp +// matches the current process (WINAPPSDK_CHANGEID_63098302, enabled by default). +class EnvironmentVariableIsolationTests +{ +public: + TEST_CLASS(EnvironmentVariableIsolationTests); + + // In-process guard: the base directory is honored only when its PID stamp matches this process. + TEST_METHOD(GetFilePath_HonorsBaseDirectoryOnlyWhenStampedByCurrentProcess) + { + const std::wstring baseDir{ CreateTempDirWithResourcesPri() }; + // AppContext.BaseDirectory (the real setter's value) ends with a directory separator, and + // MrmGetFilePathFromName's buffer sizing relies on that, so mimic it here. + const std::wstring baseDirWithSeparator{ baseDir + L"\\" }; + const std::wstring moduleDir{ GetCurrentProcessModuleDirectory() }; + + VERIFY_WIN32_BOOL_SUCCEEDED(SetEnvironmentVariableW( + L"MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY", baseDirWithSeparator.c_str())); + + wchar_t* path{}; + + // Stamped by the current process: the base directory is honored. + SetBaseDirectoryPid(GetCurrentProcessId()); + VERIFY_ARE_EQUAL(S_OK, MrmGetFilePathFromName(nullptr, &path)); + VERIFY_IS_TRUE(IsUnderDirectory(path, baseDirWithSeparator), + String().Format(L"Expected a path under the base directory, got '%s'.", path)); + MrmFreeResource(path); + path = nullptr; + + // Stamped by a foreign process (as when inherited across CreateProcess): the base directory is + // ignored and the module (exe) directory is used instead. + SetBaseDirectoryPid(GetCurrentProcessId() ^ 0x1); + VERIFY_ARE_EQUAL(S_OK, MrmGetFilePathFromName(nullptr, &path)); + VERIFY_IS_FALSE(IsUnderDirectory(path, baseDirWithSeparator), + String().Format(L"Expected the inherited base directory to be ignored, got '%s'.", path)); + VERIFY_IS_TRUE(IsUnderDirectory(path, moduleDir), + String().Format(L"Expected a path under the module directory, got '%s'.", path)); + MrmFreeResource(path); + path = nullptr; + + // No stamp at all: the base directory is ignored. + VERIFY_WIN32_BOOL_SUCCEEDED(SetEnvironmentVariableW( + L"MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY_PID", nullptr)); + VERIFY_ARE_EQUAL(S_OK, MrmGetFilePathFromName(nullptr, &path)); + VERIFY_IS_FALSE(IsUnderDirectory(path, baseDirWithSeparator), + String().Format(L"Expected an unstamped base directory to be ignored, got '%s'.", path)); + MrmFreeResource(path); + path = nullptr; + + SetEnvironmentVariableW(L"MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY", nullptr); + SetEnvironmentVariableW(L"MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY_PID", nullptr); + RemoveTempDirectory(baseDir); + } + + // End-to-end insulation: a real child process must ignore the inherited base directory. This + // reproduces the reported scenario where one Windows App SDK app launches another via CreateProcess. + TEST_METHOD(ChildProcessIsInsulatedFromInheritedBaseDirectory) + { + const std::wstring baseDir{ CreateTempDirWithResourcesPri() }; + const std::wstring baseDirWithSeparator{ baseDir + L"\\" }; + + // Configure the environment exactly as the setter does for this (parent) process, then spawn a + // child that inherits it. The child's process id differs from this stamp, so it must not honor + // the base directory. + VERIFY_WIN32_BOOL_SUCCEEDED(SetEnvironmentVariableW( + L"MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY", baseDirWithSeparator.c_str())); + SetBaseDirectoryPid(GetCurrentProcessId()); + VERIFY_WIN32_BOOL_SUCCEEDED(SetEnvironmentVariableW(L"MRM_TEST_CHILD_WORKER", L"1")); + + const std::wstring commandLine{ BuildChildWorkerCommandLine() }; + Log::Comment(String().Format(L"Spawning child: %s", commandLine.c_str())); + + std::wstring mutableCommandLine{ commandLine }; + STARTUPINFOW startupInfo{}; + startupInfo.cb = sizeof(startupInfo); + PROCESS_INFORMATION processInfo{}; + const BOOL created{ CreateProcessW(nullptr, mutableCommandLine.data(), nullptr, nullptr, + /*bInheritHandles*/ FALSE, /*dwCreationFlags*/ 0, /*lpEnvironment*/ nullptr, + /*lpCurrentDirectory*/ nullptr, &startupInfo, &processInfo) }; + + // Clear the environment before asserting so it never leaks into sibling tests. + SetEnvironmentVariableW(L"MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY", nullptr); + SetEnvironmentVariableW(L"MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY_PID", nullptr); + SetEnvironmentVariableW(L"MRM_TEST_CHILD_WORKER", nullptr); + + VERIFY_WIN32_BOOL_SUCCEEDED(created); + VERIFY_ARE_EQUAL(WAIT_OBJECT_0, WaitForSingleObject(processInfo.hProcess, 60 * 1000)); + + DWORD exitCode{ MAXDWORD }; + VERIFY_WIN32_BOOL_SUCCEEDED(GetExitCodeProcess(processInfo.hProcess, &exitCode)); + CloseHandle(processInfo.hThread); + CloseHandle(processInfo.hProcess); + RemoveTempDirectory(baseDir); + + // The child runs only ChildProcessInsulationWorker; a zero exit code means it passed (i.e. it + // ignored the inherited base directory and resolved resources under its own module directory). + VERIFY_ARE_EQUAL(0u, exitCode, + String().Format(L"Child insulation worker exit code: %u (0 == insulated).", exitCode)); + } + + // Runs only inside the child spawned by ChildProcessIsInsulatedFromInheritedBaseDirectory (gated on + // the MRM_TEST_CHILD_WORKER sentinel). It is skipped during a normal test pass. + TEST_METHOD(ChildProcessInsulationWorker) + { + wchar_t sentinel[8]{}; + if ((GetEnvironmentVariableW(L"MRM_TEST_CHILD_WORKER", sentinel, ARRAYSIZE(sentinel)) == 0) || + (wcscmp(sentinel, L"1") != 0)) + { + Log::Comment(L"Skipping: this worker runs only when spawned by the parent insulation test."); + Log::Result(TestResults::Skipped); + return; + } + + // This process inherited MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY (and its foreign PID stamp) + // from the parent. Confirm MRTCore does not resolve resources under that inherited directory. + wchar_t inheritedBaseDir[MAX_PATH]{}; + GetEnvironmentVariableW(L"MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY", + inheritedBaseDir, ARRAYSIZE(inheritedBaseDir)); + const std::wstring moduleDir{ GetCurrentProcessModuleDirectory() }; + + wchar_t* path{}; + VERIFY_ARE_EQUAL(S_OK, MrmGetFilePathFromName(nullptr, &path)); + Log::Comment(String().Format( + L"Child resolved '%s' (inherited base directory '%s', module directory '%s').", + path, inheritedBaseDir, moduleDir.c_str())); + + VERIFY_IS_TRUE(IsUnderDirectory(path, moduleDir), + String().Format(L"Child was not insulated: '%s' is not under its module directory.", path)); + if (inheritedBaseDir[0] != L'\0') + { + VERIFY_IS_FALSE(IsUnderDirectory(path, inheritedBaseDir), + String().Format(L"Child was not insulated: '%s' is under the inherited base directory.", path)); + } + MrmFreeResource(path); + } + +private: + static void SetBaseDirectoryPid(DWORD pid) + { + wchar_t pidText[16]{}; + VERIFY_ARE_EQUAL(0, _ultow_s(pid, pidText, ARRAYSIZE(pidText), 10)); + VERIFY_WIN32_BOOL_SUCCEEDED(SetEnvironmentVariableW( + L"MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY_PID", pidText)); + } + + static bool IsUnderDirectory(PCWSTR path, const std::wstring& directoryWithSeparator) + { + return (directoryWithSeparator.size() > 0) && + (_wcsnicmp(path, directoryWithSeparator.c_str(), directoryWithSeparator.size()) == 0); + } + + static std::wstring GetCurrentProcessModuleDirectory() + { + wchar_t modulePath[MAX_PATH]{}; + VERIFY_ARE_NOT_EQUAL(0u, GetModuleFileNameW(nullptr, modulePath, ARRAYSIZE(modulePath))); + std::wstring directory{ modulePath }; + directory.resize(directory.find_last_of(L'\\') + 1); // keep the trailing separator + return directory; + } + + static std::wstring CreateTempDirWithResourcesPri() + { + wchar_t tempPath[MAX_PATH]{}; + const DWORD tempLength{ GetTempPathW(ARRAYSIZE(tempPath), tempPath) }; + VERIFY_IS_TRUE((tempLength > 0) && (tempLength < ARRAYSIZE(tempPath))); + + const std::wstring directory{ std::wstring(tempPath) + L"MrmBaseDirTest_" + + std::to_wstring(GetCurrentProcessId()) + L"_" + std::to_wstring(GetTickCount64()) }; + VERIFY_IS_TRUE(CreateDirectoryW(directory.c_str(), nullptr) || + (GetLastError() == ERROR_ALREADY_EXISTS)); + + const std::wstring resourcesPri{ directory + L"\\resources.pri" }; + const HANDLE file{ CreateFileW(resourcesPri.c_str(), GENERIC_WRITE, 0, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr) }; + VERIFY_ARE_NOT_EQUAL(INVALID_HANDLE_VALUE, file); + CloseHandle(file); + return directory; + } + + static void RemoveTempDirectory(const std::wstring& directory) + { + DeleteFileW((directory + L"\\resources.pri").c_str()); + RemoveDirectoryW(directory.c_str()); + } + + static std::wstring BuildChildWorkerCommandLine() + { + // Prefer the TAEF console runner (te.exe) sitting next to whatever process is hosting this test + // (te.exe or TE.ProcessHost.exe); fall back to the current host if it isn't found. + wchar_t hostPath[MAX_PATH]{}; + VERIFY_ARE_NOT_EQUAL(0u, GetModuleFileNameW(nullptr, hostPath, ARRAYSIZE(hostPath))); + const std::wstring hostDirectory{ GetCurrentProcessModuleDirectory() }; + std::wstring runnerPath{ hostDirectory + L"te.exe" }; + if (GetFileAttributesW(runnerPath.c_str()) == INVALID_FILE_ATTRIBUTES) + { + runnerPath = hostPath; + } + + // Resolve this test binary's own path so the child loads the same DLL. + HMODULE selfModule{}; + VERIFY_WIN32_BOOL_SUCCEEDED(GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(&EnvironmentVariableIsolationTests::BuildChildWorkerCommandLine), + &selfModule)); + wchar_t selfPath[MAX_PATH]{}; + VERIFY_ARE_NOT_EQUAL(0u, GetModuleFileNameW(selfModule, selfPath, ARRAYSIZE(selfPath))); + + // Run only the worker, in-process, so the child's module identity is the runner. + return L"\"" + runnerPath + L"\" \"" + std::wstring(selfPath) + + L"\" /inproc /name:*ChildProcessInsulationWorker"; + } +}; + +// Verifies the containment behavior of the base-directory PID guard (WINAPPSDK_CHANGEID_63098302). +// When an app disables the change, MrmGetFilePathFromName must revert to the pre-fix behavior of +// honoring MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY even when it was stamped by (inherited from) a +// different process. +// +// IsolationLevel=Method: WinAppSdk::Containment::IsChangeEnabled<>() caches its result in a +// process-lifetime function-local static, so the disabled-state test must run in its own process, +// separate from EnvironmentVariableIsolationTests (which exercises the enabled default state). +class BaseDirectoryContainmentDisabledTest +{ +public: + BEGIN_TEST_CLASS(BaseDirectoryContainmentDisabledTest) + TEST_CLASS_PROPERTY(L"IsolationLevel", L"Method") + END_TEST_CLASS() + + TEST_METHOD(InheritedBaseDirectoryHonoredWhenDisabled) + { + // Disable the base-directory PID guard at the FrameworkUdk containment worker. + const UINT32 disabledChanges[] = { 63098302 }; + WinAppSdk::Containment::WinAppSDKRuntimeConfiguration config{}; + config.disabledChanges = disabledChanges; + config.disabledChangesCount = ARRAYSIZE(disabledChanges); + VERIFY_SUCCEEDED(WinAppSdk::Containment::SetConfiguration(&config)); + VERIFY_IS_FALSE((WinAppSdk::Containment::IsChangeEnabled())); + + // Create a base directory (with resources.pri) distinct from this module's directory. + wchar_t tempPath[MAX_PATH]{}; + const DWORD tempLength{ GetTempPathW(ARRAYSIZE(tempPath), tempPath) }; + VERIFY_IS_TRUE((tempLength > 0) && (tempLength < ARRAYSIZE(tempPath))); + const std::wstring baseDir{ std::wstring(tempPath) + L"MrmBaseDirDisabled_" + + std::to_wstring(GetCurrentProcessId()) + L"_" + std::to_wstring(GetTickCount64()) }; + VERIFY_IS_TRUE(CreateDirectoryW(baseDir.c_str(), nullptr) || (GetLastError() == ERROR_ALREADY_EXISTS)); + const std::wstring resourcesPri{ baseDir + L"\\resources.pri" }; + const HANDLE file{ CreateFileW(resourcesPri.c_str(), GENERIC_WRITE, 0, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr) }; + VERIFY_ARE_NOT_EQUAL(INVALID_HANDLE_VALUE, file); + CloseHandle(file); + + const std::wstring baseDirWithSeparator{ baseDir + L"\\" }; + VERIFY_WIN32_BOOL_SUCCEEDED(SetEnvironmentVariableW( + L"MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY", baseDirWithSeparator.c_str())); + + // Stamp a FOREIGN process id (as when inherited across CreateProcess). + wchar_t pidText[16]{}; + VERIFY_ARE_EQUAL(0, _ultow_s(GetCurrentProcessId() ^ 0x1, pidText, ARRAYSIZE(pidText), 10)); + VERIFY_WIN32_BOOL_SUCCEEDED(SetEnvironmentVariableW( + L"MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY_PID", pidText)); + + // With the guard disabled, the foreign-stamped base directory is honored (pre-fix behavior): + // the resolved path is under the base directory, not the module directory. + wchar_t* path{}; + VERIFY_ARE_EQUAL(S_OK, MrmGetFilePathFromName(nullptr, &path)); + VERIFY_IS_TRUE(_wcsnicmp(path, baseDirWithSeparator.c_str(), baseDirWithSeparator.size()) == 0, + String().Format(L"Expected the inherited base directory to be honored (disabled), got '%s'.", path)); + MrmFreeResource(path); + + SetEnvironmentVariableW(L"MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY", nullptr); + SetEnvironmentVariableW(L"MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY_PID", nullptr); + DeleteFileW(resourcesPri.c_str()); + RemoveDirectoryW(baseDir.c_str()); + } +}; } // namespace UnitTest diff --git a/dev/UndockedRegFreeWinRT/UndockedRegFreeWinRT-AutoInitializer.cs b/dev/UndockedRegFreeWinRT/UndockedRegFreeWinRT-AutoInitializer.cs index 2e07c542df..72ca32066c 100644 --- a/dev/UndockedRegFreeWinRT/UndockedRegFreeWinRT-AutoInitializer.cs +++ b/dev/UndockedRegFreeWinRT/UndockedRegFreeWinRT-AutoInitializer.cs @@ -26,6 +26,15 @@ internal static void AccessWindowsAppSDK() // Set base directory env var for PublishSingleFile support (referenced by SxS redirection) Environment.SetEnvironmentVariable("MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY", AppContext.BaseDirectory); + // Stamp the owning process id alongside the base directory. This is a process environment + // variable, so it is inherited by child processes spawned via CreateProcess. Consumers + // (MRTCore, undocked reg-free WinRT) honor the base directory only when this stamp matches + // the current process, preventing a child from loading the parent's resources.pri (or from + // being steered to read files from a parent-controlled directory). + // See https://github.com/microsoft/WindowsAppSDK/issues/5987. + Environment.SetEnvironmentVariable("MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY_PID", + Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture)); + // No error handling needed as the target function does nothing (just {return S_OK}). // It's the act of calling the function causing the DllImport to load the DLL that // matters. This provides the moral equivalent of a native DLL's Import Address diff --git a/dev/UndockedRegFreeWinRT/catalog.cpp b/dev/UndockedRegFreeWinRT/catalog.cpp index fab0f9a5b0..d08170fb56 100644 --- a/dev/UndockedRegFreeWinRT/catalog.cpp +++ b/dev/UndockedRegFreeWinRT/catalog.cpp @@ -15,6 +15,15 @@ #include <../DynamicDependency/API/MddWinRT.h> +#include +#include + +// Bug 63098302: MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY is a process environment variable +// (inherited across CreateProcess). Honor it only when it was stamped by the current process. The +// change is gated so apps can revert to the prior (unguarded) behavior via RuntimeCompatibilityOptions. +// See https://github.com/microsoft/WindowsAppSDK/issues/5987. +#define WINAPPSDK_CHANGEID_63098302 63098302 + using namespace std; using namespace Microsoft::WRL; @@ -154,12 +163,46 @@ HRESULT WinRTLoadComponentFromFilePath(PCWSTR manifestPath) } } +// Honor the base directory only when it was stamped by the current process (see MRM.cpp). The base +// directory is a process environment variable and is inherited across CreateProcess, so an inherited +// value must not steer this process's SxS DLL redirection. +static bool IsBaseDirectoryStampedByCurrentProcess() +{ + wchar_t stampedPidText[16]{}; + const DWORD length{ ::GetEnvironmentVariableW( + L"MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY_PID", stampedPidText, ARRAYSIZE(stampedPidText)) }; + if ((length == 0) || (length >= ARRAYSIZE(stampedPidText))) + { + return false; + } + + return static_cast(wcstoul(stampedPidText, nullptr, 10)) == ::GetCurrentProcessId(); +} + HRESULT WinRTLoadComponentFromString(std::string_view xmlStringValue) { try { auto wideXmlString = ::Microsoft::Utf8::ToUtf16(xmlStringValue.data()); + // MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY is a process environment variable inherited across + // CreateProcess. When the guard is enabled and the value was not stamped by this process, strip + // the token so redirection falls back to the application directory (the non-PublishSingleFile + // behavior) rather than a directory inherited from a parent process. When the change is disabled + // via RuntimeCompatibilityOptions, honor it regardless (pre-fix behavior). + // See https://github.com/microsoft/WindowsAppSDK/issues/5987. + if (WinAppSdk::Containment::IsChangeEnabled() && + !IsBaseDirectoryStampedByCurrentProcess()) + { + const std::wstring baseDirectoryToken{ L"%MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY%" }; + for (size_t pos{ wideXmlString.find(baseDirectoryToken) }; + pos != std::wstring::npos; + pos = wideXmlString.find(baseDirectoryToken, pos)) + { + wideXmlString.erase(pos, baseDirectoryToken.size()); + } + } + // Expand any env vars, such as %MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY% in asmv3:file.loadFrom auto expandedSize = ExpandEnvironmentStringsW(wideXmlString.data(), nullptr, 0); RETURN_HR_IF(HRESULT_FROM_WIN32(GetLastError()), expandedSize == 0); From ea141c034f1f367b8630bdfb781f8e2e9cf68f97 Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Mon, 20 Jul 2026 15:13:39 -0400 Subject: [PATCH 2/2] catalog.cpp: only consult containment when the base-dir token is present (#5987) RCA of a PR-validation regression in CompatibilityTests (CanSetRuntimeCompatibilityOptions and VerifyDisabledChanges failing intermittently with E_ILLEGAL_STATE_CHANGE, "Configuration already set or locked"): WinAppSdk::Containment::IsChangeEnabled<>() locks the process's runtime-compatibility configuration on first call. catalog.cpp's WinRTLoadComponentFromString runs during reg-free WinRT activation - before an app (or test) can call RuntimeCompatibilityOptions.Apply(). Calling IsChangeEnabled unconditionally there locked the configuration early, so a subsequent Apply() failed. The manifest only references %MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY% for PublishSingleFile apps, so only consult the containment change when that token is actually present. Non-single-file activation (the common case, including CompatibilityTests) no longer calls IsChangeEnabled and no longer locks the configuration. MRTCore MrmUnitTest is unaffected (catalog.cpp is not in that binary). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dac4165f-ec13-4406-9206-60cd55b24c2b --- dev/UndockedRegFreeWinRT/catalog.cpp | 29 +++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/dev/UndockedRegFreeWinRT/catalog.cpp b/dev/UndockedRegFreeWinRT/catalog.cpp index d08170fb56..602b588638 100644 --- a/dev/UndockedRegFreeWinRT/catalog.cpp +++ b/dev/UndockedRegFreeWinRT/catalog.cpp @@ -186,21 +186,28 @@ HRESULT WinRTLoadComponentFromString(std::string_view xmlStringValue) auto wideXmlString = ::Microsoft::Utf8::ToUtf16(xmlStringValue.data()); // MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY is a process environment variable inherited across - // CreateProcess. When the guard is enabled and the value was not stamped by this process, strip - // the token so redirection falls back to the application directory (the non-PublishSingleFile - // behavior) rather than a directory inherited from a parent process. When the change is disabled - // via RuntimeCompatibilityOptions, honor it regardless (pre-fix behavior). + // CreateProcess. When the value was not stamped by this process, strip the token so redirection + // falls back to the application directory (the non-PublishSingleFile behavior) rather than a + // directory inherited from a parent process. + // + // The manifest only references the token for PublishSingleFile apps, so only consult the + // containment change when the token is actually present. IsChangeEnabled locks the process's + // runtime-compatibility configuration on first call, and this code runs during reg-free WinRT + // activation - before an app can call RuntimeCompatibilityOptions.Apply(); calling it + // unconditionally would lock the configuration early and make Apply() fail. // See https://github.com/microsoft/WindowsAppSDK/issues/5987. - if (WinAppSdk::Containment::IsChangeEnabled() && + const std::wstring baseDirectoryToken{ L"%MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY%" }; + size_t tokenPos{ wideXmlString.find(baseDirectoryToken) }; + if ((tokenPos != std::wstring::npos) && + WinAppSdk::Containment::IsChangeEnabled() && !IsBaseDirectoryStampedByCurrentProcess()) { - const std::wstring baseDirectoryToken{ L"%MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY%" }; - for (size_t pos{ wideXmlString.find(baseDirectoryToken) }; - pos != std::wstring::npos; - pos = wideXmlString.find(baseDirectoryToken, pos)) + // When disabled via RuntimeCompatibilityOptions, honor the token regardless (pre-fix behavior). + do { - wideXmlString.erase(pos, baseDirectoryToken.size()); - } + wideXmlString.erase(tokenPos, baseDirectoryToken.size()); + tokenPos = wideXmlString.find(baseDirectoryToken, tokenPos); + } while (tokenPos != std::wstring::npos); } // Expand any env vars, such as %MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY% in asmv3:file.loadFrom