Skip to content

<filesystem>: weakly_canonical fails with ERROR_ACCESS_DENIED inside Windows AppContainer #6286

@bsosnader

Description

@bsosnader

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:

  1. 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.
  2. 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, &params);
    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_DOSVOLUME_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:
    1. 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.
    2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingfilesystemC++17 filesystem

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions