std::filesystem::weakly_canonical returns an error_code containing ERROR_ACCESS_DENIED (Win32 error 5) for any path inside a Windows AppContainer process, even when the AppContainer SID has been granted full ACL access on every directory in the path.
The cause is in _Canonical (stl/inc/filesystem line ~3039): it calls GetFinalPathNameByHandleW(handle, ..., FILE_NAME_NORMALIZED | VOLUME_NAME_DOS). The VOLUME_NAME_DOS flag requires the OS to query the Volume Mount Manager and the \GLOBAL?? Object Manager directory to translate the volume back to a drive letter. AppContainer tokens are denied access to both subsystems by the kernel, regardless of file-level ACL grants. _Canonical already retries with VOLUME_NAME_NT if VOLUME_NAME_DOS returns ERROR_PATH_NOT_FOUND (line ~3060) — that case handles paths with no DOS name (e.g. volumes without a drive letter). The AppContainer case is different: a DOS name DOES exist, but the caller can't reach the Mount Manager to retrieve it. Same recovery strategy works (fall back to VOLUME_NAME_NT); the retry just needs to also cover ERROR_ACCESS_DENIED.
The post-NT-canonicalization code (line ~3085) already prefixes the result with \\?\GLOBALROOT so it remains a valid Win32 path:
} else { // result is in the NT namespace, so apply the DOS to NT namespace prefix
_Result._Text.insert(0, LR"(\\?\GLOBALROOT)"sv);
}
So adding ERROR_ACCESS_DENIED to the retry condition automatically gets the same \\?\GLOBALROOT\Device\HarddiskVolumeN\... post-processing for free — no new code path, no new output format.
Calling GetFinalPathNameByHandleW directly with VOLUME_NAME_NT succeeds in the same AppContainer (verified empirically; see test case below).
This is observable in real-world ML scenarios: ONNX Runtime added a weakly_canonical-based path validation in v1.24.1 (PR microsoft/onnxruntime#26776), which broke loading any model with external data inside an AppContainer microsoft/onnxruntime#28508.
Command-line test case
The failure requires running inside an AppContainer. The single-file repro below:
- When run with no arguments (must be elevated so it can grant ACLs and create the AppContainer profile), creates an AppContainer profile, grants the AppContainer SID
readWrite on C:\ (so file/dir ACLs are not the limiting factor), then spawns itself as a child inside that AppContainer with --child. The child output is captured and printed by the parent. This is the failure case — the STL call fails inside the AppContainer.
- When run with
--child directly (outside any AppContainer, just from a normal cmd), it runs the test in the caller's own context. This is the control case — all three operations succeed, proving the failure is AppContainer-specific.
repro.cpp
#include <Windows.h>
#include <UserEnv.h>
#include <sddl.h>
#include <AclAPI.h>
#include <filesystem>
#include <iostream>
#include <string>
#include <system_error>
#pragma comment(lib, "Userenv.lib")
#pragma comment(lib, "Advapi32.lib")
namespace fs = std::filesystem;
static int run_child() {
fs::path p = L"C:\\";
std::error_code ec;
auto canonical = fs::weakly_canonical(p, ec);
std::wcout << L"std::filesystem::weakly_canonical(\"C:\\\") -> ";
if (ec) {
std::wcout << L"FAIL Win32=" << ec.value() << L" '"
<< std::wstring(ec.message().begin(), ec.message().end()) << L"'\n";
} else {
std::wcout << canonical.wstring() << L"\n";
}
CREATEFILE2_EXTENDED_PARAMETERS params{};
params.dwSize = sizeof(params);
params.dwFileFlags = FILE_FLAG_BACKUP_SEMANTICS; // required to open a directory handle
HANDLE h = ::CreateFile2(
p.c_str(), FILE_READ_ATTRIBUTES,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
OPEN_EXISTING, ¶ms);
if (h == INVALID_HANDLE_VALUE) {
std::wcout << L"CreateFile2 failed Win32=" << ::GetLastError() << L"\n";
return 1;
}
wchar_t buf[1024];
DWORD len_dos = ::GetFinalPathNameByHandleW(
h, buf, std::size(buf), FILE_NAME_NORMALIZED | VOLUME_NAME_DOS);
std::wcout << L"GetFinalPathNameByHandleW(NORMALIZED|VOLUME_NAME_DOS) -> ";
if (len_dos == 0) std::wcout << L"FAIL Win32=" << ::GetLastError() << L"\n";
else std::wcout << std::wstring_view(buf, len_dos) << L"\n";
DWORD len_nt = ::GetFinalPathNameByHandleW(
h, buf, std::size(buf), FILE_NAME_NORMALIZED | VOLUME_NAME_NT);
std::wcout << L"GetFinalPathNameByHandleW(NORMALIZED|VOLUME_NAME_NT) -> ";
if (len_nt == 0) std::wcout << L"FAIL Win32=" << ::GetLastError() << L"\n";
else std::wcout << std::wstring_view(buf, len_nt) << L"\n";
::CloseHandle(h);
return 0;
}
static int run_parent() {
const wchar_t* profile = L"STLWeaklyCanonicalRepro";
PSID appcontainer_sid = nullptr;
HRESULT hr = ::CreateAppContainerProfile(
profile, profile, profile, nullptr, 0, &appcontainer_sid);
if (hr == HRESULT_FROM_WIN32(ERROR_ALREADY_EXISTS)) {
hr = ::DeriveAppContainerSidFromAppContainerName(profile, &appcontainer_sid);
}
if (FAILED(hr)) {
std::wcout << L"CreateAppContainerProfile failed HRESULT=0x" << std::hex << hr << L"\n";
return 1;
}
// Grant the AppContainer SID readWrite on C:\ so file/dir ACLs are not the variable.
EXPLICIT_ACCESS_W ea{};
ea.grfAccessPermissions = GENERIC_READ | GENERIC_WRITE | GENERIC_EXECUTE;
ea.grfAccessMode = SET_ACCESS;
ea.grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
ea.Trustee.TrusteeForm = TRUSTEE_IS_SID;
ea.Trustee.TrusteeType = TRUSTEE_IS_GROUP;
ea.Trustee.ptstrName = static_cast<LPWSTR>(appcontainer_sid);
PACL old_acl = nullptr, new_acl = nullptr;
PSECURITY_DESCRIPTOR sd = nullptr;
::GetNamedSecurityInfoW(const_cast<LPWSTR>(L"C:\\"), SE_FILE_OBJECT, DACL_SECURITY_INFORMATION,
nullptr, nullptr, &old_acl, nullptr, &sd);
::SetEntriesInAclW(1, &ea, old_acl, &new_acl);
::SetNamedSecurityInfoW(const_cast<LPWSTR>(L"C:\\"), SE_FILE_OBJECT, DACL_SECURITY_INFORMATION,
nullptr, nullptr, new_acl, nullptr);
SECURITY_CAPABILITIES caps{};
caps.AppContainerSid = appcontainer_sid;
caps.CapabilityCount = 0;
SIZE_T attr_size = 0;
::InitializeProcThreadAttributeList(nullptr, 1, 0, &attr_size);
auto attr_buf = std::make_unique<char[]>(attr_size);
auto attr_list = reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(attr_buf.get());
::InitializeProcThreadAttributeList(attr_list, 1, 0, &attr_size);
::UpdateProcThreadAttribute(
attr_list, 0, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES,
&caps, sizeof(caps), nullptr, nullptr);
STARTUPINFOEXW si{};
si.StartupInfo.cb = sizeof(si);
si.lpAttributeList = attr_list;
HANDLE hOutRead = nullptr, hOutWrite = nullptr;
SECURITY_ATTRIBUTES sa{ sizeof(sa), nullptr, TRUE };
::CreatePipe(&hOutRead, &hOutWrite, &sa, 0);
::SetHandleInformation(hOutRead, HANDLE_FLAG_INHERIT, 0);
si.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
si.StartupInfo.hStdOutput = hOutWrite;
si.StartupInfo.hStdError = hOutWrite;
PROCESS_INFORMATION pi{};
wchar_t exe[MAX_PATH];
::GetModuleFileNameW(nullptr, exe, MAX_PATH);
std::wstring cmd = std::wstring(L"\"") + exe + L"\" --child";
if (!::CreateProcessW(
nullptr, cmd.data(), nullptr, nullptr, TRUE,
EXTENDED_STARTUPINFO_PRESENT, nullptr, nullptr,
&si.StartupInfo, &pi)) {
std::wcout << L"CreateProcessW failed Win32=" << ::GetLastError() << L"\n";
return 1;
}
::CloseHandle(hOutWrite);
char rdbuf[4096];
DWORD nread;
std::wcout << L"--- Output from AppContainer child process: ---\n";
while (::ReadFile(hOutRead, rdbuf, sizeof(rdbuf) - 1, &nread, nullptr) && nread > 0) {
rdbuf[nread] = 0;
std::cout << rdbuf;
}
::WaitForSingleObject(pi.hProcess, INFINITE);
::CloseHandle(pi.hProcess);
::CloseHandle(pi.hThread);
::CloseHandle(hOutRead);
::DeleteProcThreadAttributeList(attr_list);
::FreeSid(appcontainer_sid);
if (new_acl) ::LocalFree(new_acl);
if (sd) ::LocalFree(sd);
return 0;
}
int wmain(int argc, wchar_t** argv) {
if (argc > 1 && std::wstring_view(argv[1]) == L"--child") return run_child();
return run_parent();
}
Compile
C:\Temp>cl /EHsc /W4 /std:c++latest repro.cpp
Microsoft (R) C/C++ Optimizing Compiler Version 19.50.35730 for x64
Copyright (C) Microsoft Corporation. All rights reserved.
... (link output omitted)
Failure case (run from elevated cmd; parent grants ACL, spawns AppContainer child)
C:\Temp>repro.exe
--- Output from AppContainer child process: ---
std::filesystem::weakly_canonical("C:\") -> FAIL Win32=5 'Access is denied.'
GetFinalPathNameByHandleW(NORMALIZED|VOLUME_NAME_DOS) -> FAIL Win32=5
GetFinalPathNameByHandleW(NORMALIZED|VOLUME_NAME_NT) -> \Device\HarddiskVolume4\
Control case (run --child directly, outside any AppContainer)
C:\Temp>repro.exe --child
std::filesystem::weakly_canonical("C:\") -> C:\
GetFinalPathNameByHandleW(NORMALIZED|VOLUME_NAME_DOS) -> \\?\C:\
GetFinalPathNameByHandleW(NORMALIZED|VOLUME_NAME_NT) -> \Device\HarddiskVolume4\
All three succeed → confirms the failure is AppContainer-specific, not a path/ACL issue.
Expected behavior
std::filesystem::weakly_canonical should succeed inside a Windows AppContainer when the caller has the relevant file-level access (it does today for every other STL filesystem operation against the same path).
Suggested implementation: extend the existing _Canonical retry — currently re-tries VOLUME_NAME_NT on ERROR_PATH_NOT_FOUND only — to also retry on ERROR_ACCESS_DENIED. Optionally prefix the NT-form result with \\?\GLOBALROOT so subsequent <filesystem> calls (e.g., exists, is_directory) accept it.
Tradeoff worth discussing: the AppContainer fallback returns \\?\GLOBALROOT\Device\HarddiskVolumeN\... instead of C:\.... This is unusual but is a documented Win32 path form and round-trippable through <filesystem>. Alternatives the maintainers might prefer:
- Document the limitation in
<filesystem> and leave the workaround to callers.
- Add an opt-in
weakly_canonical overload for AppContainer-compatible canonicalization.
STL version
git commit hash of main checked: cc321274550bb8f8b3db97ce2971dfa7bd7d2d92 (cc32127)
The relevant code in stl/inc/filesystem:
weakly_canonical at line ~4109
_Canonical at line ~3039 (current VOLUME_NAME_DOS → VOLUME_NAME_NT retry on ERROR_PATH_NOT_FOUND only; this is the line we propose extending)
Additional context
- Reduction test (per the contributing guidelines): the bug is reproducible without STL by calling
GetFinalPathNameByHandleW directly. So strictly, the underlying behavior is in the Win32 / kernel layer (the AppContainer's restricted token cannot reach the Volume Mount Manager or \GLOBAL??, so VOLUME_NAME_DOS resolution fails). I am filing here rather than against the Windows SDK / kernel for two reasons:
- The kernel behavior is intentional security policy — AppContainers are designed to be denied access to volume / Mount Manager subsystems, and Microsoft will not relax that.
- MSVC STL is the layer that picks
VOLUME_NAME_DOS and decides which Win32 errors to retry on. The fix is small, self-contained, and entirely within _Canonical. The same retry shape already exists for ERROR_PATH_NOT_FOUND, and the existing post-NT \\?\GLOBALROOT prefixing already produces a Win32-usable path. Extending the retry by one error code is the highest-leverage point in the chain.
- AppContainer is Windows-only, so any fix would be
_WIN32-only.
std::filesystem::weakly_canonicalreturns anerror_codecontainingERROR_ACCESS_DENIED(Win32 error 5) for any path inside a Windows AppContainer process, even when the AppContainer SID has been granted full ACL access on every directory in the path.The cause is in
_Canonical(stl/inc/filesystemline ~3039): it callsGetFinalPathNameByHandleW(handle, ..., FILE_NAME_NORMALIZED | VOLUME_NAME_DOS). TheVOLUME_NAME_DOSflag requires the OS to query the Volume Mount Manager and the\GLOBAL??Object Manager directory to translate the volume back to a drive letter. AppContainer tokens are denied access to both subsystems by the kernel, regardless of file-level ACL grants._Canonicalalready retries withVOLUME_NAME_NTifVOLUME_NAME_DOSreturnsERROR_PATH_NOT_FOUND(line ~3060) — that case handles paths with no DOS name (e.g. volumes without a drive letter). The AppContainer case is different: a DOS name DOES exist, but the caller can't reach the Mount Manager to retrieve it. Same recovery strategy works (fall back toVOLUME_NAME_NT); the retry just needs to also coverERROR_ACCESS_DENIED.The post-NT-canonicalization code (line ~3085) already prefixes the result with
\\?\GLOBALROOTso it remains a valid Win32 path:So adding
ERROR_ACCESS_DENIEDto the retry condition automatically gets the same\\?\GLOBALROOT\Device\HarddiskVolumeN\...post-processing for free — no new code path, no new output format.Calling
GetFinalPathNameByHandleWdirectly withVOLUME_NAME_NTsucceeds in the same AppContainer (verified empirically; see test case below).This is observable in real-world ML scenarios: ONNX Runtime added a
weakly_canonical-based path validation in v1.24.1 (PR microsoft/onnxruntime#26776), which broke loading any model with external data inside an AppContainer microsoft/onnxruntime#28508.Command-line test case
The failure requires running inside an AppContainer. The single-file repro below:
readWriteonC:\(so file/dir ACLs are not the limiting factor), then spawns itself as a child inside that AppContainer with--child. The child output is captured and printed by the parent. This is the failure case — the STL call fails inside the AppContainer.--childdirectly (outside any AppContainer, just from a normal cmd), it runs the test in the caller's own context. This is the control case — all three operations succeed, proving the failure is AppContainer-specific.repro.cppCompile
Failure case (run from elevated cmd; parent grants ACL, spawns AppContainer child)
Control case (run
--childdirectly, outside any AppContainer)All three succeed → confirms the failure is AppContainer-specific, not a path/ACL issue.
Expected behavior
std::filesystem::weakly_canonicalshould succeed inside a Windows AppContainer when the caller has the relevant file-level access (it does today for every other STL filesystem operation against the same path).Suggested implementation: extend the existing
_Canonicalretry — currently re-triesVOLUME_NAME_NTonERROR_PATH_NOT_FOUNDonly — to also retry onERROR_ACCESS_DENIED. Optionally prefix the NT-form result with\\?\GLOBALROOTso subsequent<filesystem>calls (e.g.,exists,is_directory) accept it.Tradeoff worth discussing: the AppContainer fallback returns
\\?\GLOBALROOT\Device\HarddiskVolumeN\...instead ofC:\.... This is unusual but is a documented Win32 path form and round-trippable through<filesystem>. Alternatives the maintainers might prefer:<filesystem>and leave the workaround to callers.weakly_canonicaloverload for AppContainer-compatible canonicalization.STL version
git commit hash of
mainchecked:cc321274550bb8f8b3db97ce2971dfa7bd7d2d92(cc32127)The relevant code in
stl/inc/filesystem:weakly_canonicalat line ~4109_Canonicalat line ~3039 (currentVOLUME_NAME_DOS→VOLUME_NAME_NTretry onERROR_PATH_NOT_FOUNDonly; this is the line we propose extending)Additional context
GetFinalPathNameByHandleWdirectly. So strictly, the underlying behavior is in the Win32 / kernel layer (the AppContainer's restricted token cannot reach the Volume Mount Manager or\GLOBAL??, soVOLUME_NAME_DOSresolution fails). I am filing here rather than against the Windows SDK / kernel for two reasons:VOLUME_NAME_DOSand decides which Win32 errors to retry on. The fix is small, self-contained, and entirely within_Canonical. The same retry shape already exists forERROR_PATH_NOT_FOUND, and the existing post-NT\\?\GLOBALROOTprefixing already produces a Win32-usable path. Extending the retry by one error code is the highest-leverage point in the chain._WIN32-only.