Repository navigation
win: support running inside an AppContainer (lowbox token) - #5
dylan-conway wants to merge 30 commits into
Conversation
Returns 1 when the process runs with an AppContainer (lowbox) token, 0 otherwise and on non-Windows platforms. Several Windows facilities (the named pipe namespace, the mount manager) are restricted inside an AppContainer; embedders and upcoming libuv code need a cheap way to detect that situation.
An AppContainer (lowbox token) process may only create named pipes under \\?\pipe\LOCAL\. uv__unique_pipe_name generated names outside that prefix, so every CreateNamedPipe call failed with ERROR_ACCESS_DENIED, which uv__pipe_server treated as a name collision and retried with a new name forever, spinning at 100% CPU. Any uv_spawn with piped stdio or uv_pipe() hit this. - Prefix the generated names with LOCAL\. The names are internal and never surfaced (they embed a pointer value and the pid), and outside an AppContainer the prefix is just part of the name, so this is not observable. - Cap ERROR_ACCESS_DENIED retries in uv__pipe_server at one so a process that is denied access to the pipe namespace for other reasons fails instead of looping forever.
When the second GetFinalPathNameByHandleW call failed, the error was overwritten with ERROR_INVALID_HANDLE before being read by the caller, so the caller reported EBADF regardless of the real failure. Preserve the error across the buffer free instead.
uv_pipe returned the Windows error from uv__create_pipe_pair as a positive value instead of a negative UV_E* code. ERROR_ACCESS_DENIED could not previously escape uv__pipe_server's retry loop, but now that the retries are capped it reaches callers in exactly the sandboxed setups the cap is for, so translate it at the API boundary. The EMFILE branch below compared the CRT errno against UV_EMFILE and could never match, returning UV_UNKNOWN instead; compare against EMFILE. Also reset the ERROR_ACCESS_DENIED retry limit when an attempt fails with ERROR_PIPE_BUSY. A busy pipe proves the namespace is accessible, so a later ERROR_ACCESS_DENIED is another name collision rather than a sandbox denial. The stdio pipe names start from a constant seed and routinely walk several ERROR_PIPE_BUSY collisions, so two stale cross-user names anywhere in that walk could otherwise fail a spawn.
fs__realpath read GetLastError() after CloseHandle(handle). The last-error value is only defined after a failed call; a successful CloseHandle may overwrite it, losing the error fs__realpath_handle preserved. Capture it first, the way fs__readlink does.
Cancelling a pending console read relies on writing a synthesized input record (VK_RETURN for line reads, FOCUS_EVENT for raw reads) to wake the blocked reader. A sandboxed process (for example a Windows AppContainer) may read from a console it inherited while being denied WriteConsoleInputW on it, and the failure paths did not cope: - uv__cancel_read_console returned with uv_tty_output_lock still held and uv__read_console_status left at TRAP_REQUESTED, so the next tty operation deadlocked the loop thread. - The raw path left the registered console wait armed, so the read request never completed and closing the handle hung the loop. Make cancellation work and the failure paths sound: - Track the thread blocked in ReadConsoleW and fall back to CancelSynchronousIo when input injection fails; the reader then completes and releases the output lock as usual. If the reader cannot be cancelled, withdraw the trap, release the lock, and report the cancellation as pending so the eventual completion is suppressed. - For raw reads, claim the registered wait and post the completion directly; the wait callback and uv__tty_read_stop arbitrate via an interlocked exchange of read_raw_wait. A pending sentinel covers the window where the wait fires before RegisterWaitForSingleObject's out-parameter store is visible. - Suppress cancelled-read error completions in uv_process_tty_read_line_req the same way the success path suppresses the injected VK_RETURN, so a cancel-and-restart (uv_tty_set_mode) requeues the read instead of delivering a stray error.
The names uv__pipe_server tries are guessable: they derive from a fixed seed (the stdio path uses a compile-time constant) plus the pid, and retries increment by one. Since the retry limit was added, another process could deny pipe creation outright by squatting on the first two candidate names with spare instance capacity, which turns the victim's FILE_FLAG_FIRST_PIPE_INSTANCE create into ERROR_ACCESS_DENIED; before the limit, the loop walked past any finite squat. Re-randomize the name on an access-denied retry so the next candidate cannot be predicted. A second denial then really does mean the pipe namespace is inaccessible, which is what the retry limit is for.
The console read worker now unpublishes its thread handle (a critical section plus CloseHandle) between ReadConsoleW and the GetLastError read, and either call may clobber the thread's last-error value -- the same class of bug recently fixed twice in fs.c. In the worst case a genuine ReadConsoleW failure would surface as the wrong error, or as a zero-byte read if the value was overwritten with zero. Capture the error immediately after ReadConsoleW.
Reserve one byte for the NUL terminator when passing the buffer size to uv_utf16_to_wtf8() in the TTY line-read path. Without this, when all input characters encode to exactly 3 UTF-8 bytes (e.g. CJK) and the buffer size is divisible by 3, the NUL terminator is written one byte past the allocated buffer. Backport of libuv/libuv commit ec0ab5d (GHSA-4prr-4742-3ccf), which this branch predates; the regression test covers the conversion's bounds directly.
- pipe_emfile: exhaust the CRT fd table and assert uv_pipe() reports UV_EMFILE; it surfaced UV_UNKNOWN before the errno comparison fix. - tty_line_read_cancel_no_inject: cancel a pending console line read through a read-only console handle, on which the usual WriteConsoleInputW wake-up is denied; exercises the cancellation fallback that previously deadlocked the loop.
Upstream has since released 1.52.x without this API, so the versionadded annotation pointed at a release that does not contain it.
|
Ran a bun build pinned to this branch (through b919670) inside a real AppContainer on Windows Server 2019 (17763), with an API sweep covering process spawning, pipes, IPC, tty, fs, and sockets. Results for the commits here:
Two more AppContainer denials surfaced that libuv may want to own. Both verified on 17763. 1. The
When 2. Mount points created from inside an AppContainer are rewritten by the kernel. A junction created with Traversal through such a junction works everywhere. But For reference, the rest of the sweep's libuv-relevant results inside the container match this PR's description: |
|
Both landed, plus one more since your sweep ran (head was Junctions — NUL — Also relevant to the sweep: The window-station/desktop grant requirement and the rest of the host-responsibility list are documented on the bun side ( |
The path-splitting in the ACCESS_DENIED stat fallback read path[-1] for all-dot relative paths (the scan leaves split == 0 with no name seen, and the separator check then indexes split - 1), and the cleanup wrote an uninitialized splitchar over the path's last character for every branch that does not actually split (trailing-slash paths, '..', 'c:'). Sandboxed processes take this fallback for any denied path, so the undefined behavior went from theoretical to routine. Initialize splitchar, guard the separator check, and restore the path only when it was split.
uv_pipe_bind mapped CreateNamedPipe's ERROR_ACCESS_DENIED to UV_EADDRINUSE unconditionally: with FILE_FLAG_FIRST_PIPE_INSTANCE that error normally means the name is taken. A process that is denied the pipe namespace (e.g. a sandboxed process binding outside \.\pipe\LOCAL\) gets the same error for a completely free name and was told the address is in use, inviting futile rename-and-retry loops. Disambiguate by checking whether the name exists: WaitNamedPipe does not consume a pipe connection and reports ERROR_FILE_NOT_FOUND when no live instance exists. Name present stays UV_EADDRINUSE, including for genuine collisions inside a sandbox; name absent becomes UV_EACCES.
Some sandboxed processes (a Windows Server AppContainer, for example) are denied the NUL device, and uv__stdio_create opens NUL for every UV_IGNORE stdio slot, so any spawn with an ignored slot failed even though pipe and inherit slots work. When the open fails, substitute an anonymous pipe with the other end closed: reads see EOF and writes fail fast instead of blocking, the closest available approximation of the null device. The NUL handle is unchanged where it can be opened.
The off-by-one fix reserved one byte for the NUL terminator in the conversion buffer but kept sizing the ReadConsoleW request at bytes/3 code units, whose worst-case conversion needs the full allocation. On an exact fill the converter truncated mid-character and reported the required (larger) length, so read_cb received a torn character with an embedded NUL counted inside nread - and for allocations smaller than one code point, an nread larger than the buffer itself. Size the read to the conversion capacity, (bytes - 1) / 3, so the worst case always fits, and deliver nothing rather than torn bytes in the only remaining failure case (an allocation too small for a single character). Add a regression test that injects console input against a 6-byte allocation and checks the payload is complete sequences.
Sleep(1) rounds up to the system timer resolution, so 100 retries meant the loop thread could block for over a second at the default 15.6ms resolution. 32 tries keeps the publish-window coverage while bounding the worst case to a few hundred milliseconds.
Two gaps in the existence probe that decides between EADDRINUSE and EACCES: - WaitNamedPipe rejects the \?\ pipe-name spelling with ERROR_BAD_PATHNAME, so binds using that form never took the EACCES arm. Probe through the \.\ spelling. - A colliding server can disappear between the failed create and the probe, which used to misreport the (now free) name as EACCES. When the probe finds no live instance, retry the create once: a vanished collision now binds successfully, and a second denial really is the namespace.
Splitting "X:\name" wrote a NUL over the backslash, leaving "X:" as the directory to search - which Win32 resolves as the drive's current directory, not its root. If the current directory on that drive contained an entry with the same name, the fallback silently returned the wrong file's stats.
The NUL-device fallback created both pipe ends inheritable and closed the parent-side end immediately after. CreateProcess inherits every inheritable handle in the process, so a spawn racing on another thread could inherit the doomed end, keeping the pipe alive: reads in the child with the ignored slot would block instead of seeing EOF, and writes would fill the pipe buffer instead of failing fast. Create both ends non-inheritable and mark only the kept end.
ERROR_PIPE_BUSY can also be forced by a squatter (a pipe created with spare instance capacity), so walking predictably off the current name let an adversary steer the retry sequence; draw a fresh random name instead, as the access-denied path already does. In the bind probe, read each character of the name only after the previous one proved non-NUL, so single-character pipe names cannot read past the terminator, and note the deliberate limits of the disambiguation (a probe denied by a stricter sandbox, or a server vanishing twice, keeps the conservative verdict).
The drive-root fix only covered the bare "X:\name" spelling. Extend it to "\?\X:\name" and "\.\X:\name" (truncating those left "\?\X:", which opens the volume device) and to rooted "\name" (truncating left an empty string).
Delivering zero bytes for an allocation that cannot hold one converted character made such reads silently consume console input forever. Surface ENOBUFS to the read callback instead. Also size the test buffer exactly to the allocation so a regression to the old one-byte overflow is out-of-bounds under ASAN.
The mechanism was stated backwards: a squatter with spare instance capacity forces ERROR_ACCESS_DENIED on a FILE_FLAG_FIRST_PIPE_INSTANCE create; ERROR_PIPE_BUSY is what a pipe at full instance capacity produces.
6d1dc15 to
a8a4150
Compare
Scope the console-read-slot comment to the supported single-reader case and move pipe_emfile out of the tty block in the test list.
a8a4150 to
88e612c
Compare
…s a pipe
The UV_IGNORE fallback substituted an anonymous pipe for any
CreateFileW("NUL") failure and stamped the slot FOPEN|FDEV either way.
A CRT child then treated the dead pipe as a character device:
_isatty() is FDEV-driven, so python enabled line buffering and aborted
at its first print with EINVAL (ERROR_NO_DATA is absent from the CRT
errno table) instead of running to completion.
Only ERROR_ACCESS_DENIED takes the fallback now - any other NUL failure
stays a loud spawn error - and uv__create_nul_handle reports the
substitution so the caller stamps FOPEN|FPIPE for the pipe.
Publish the reader's duplicated thread handle (or a failed-duplication sentinel) before flipping the read status to IN_PROGRESS. Once the canceller observes IN_PROGRESS the handle is already visible, so the retry loop no longer waits on publication; the remaining ERROR_NOT_FOUND window is the short stretch around the wait itself, and SwitchToThread covers it the same way uv__pipe_interrupt_read does. A failed duplication now withdraws immediately instead of burning the whole retry budget, and the bound spins yields, not timers.
The bound is also reachable when the reader is preempted past the whole spin, not only on hosts whose console reads cannot be cancelled; the withdraw handles both.
fs_stat_locked_root_file stats C:\pagefile.sys (held exclusively by the memory manager, so the directory-listing fallback runs) through the drive-absolute, \?\-prefixed, and rooted spellings, with a same-named decoy in the cwd that the old drive-relative split used to pick up. tty_line_read_enobufs pins UV_ENOBUFS for a line-read allocation too small to hold one converted character; the old sizing wrote the NUL one byte past the allocation and reported a successful 3-byte read.
Substituting a closed-end anonymous pipe when opening NUL is denied made every ignored slot's writes fail, which well-behaved children treat as fatal (python aborts at its shutdown flush, git takes the simulated SIGPIPE path, gnulib-based tools exit nonzero) - trading one loud spawn error for many confusing child failures. Restore the upstream contract: the spawn fails with the real error. Environments that need ignored stdio inside an AppContainer grant the device to the container SIDs from an elevated context (the kernel resets the ACL each boot) or pass an open NUL handle in; the embedder can also supply its own sink.
The kernel canonicalizes the substitute name of junctions created under a sandbox device map (e.g. inside an AppContainer) to \??\Global\<drive>:\path, which fs__readlink_handle rejected, so uv_fs_readlink returned EINVAL and uv_fs_lstat reported such junctions as plain directories - dangerous for tree walkers, which would recurse through the junction instead of unlinking it. Accept the Global form and return its exact win32 spelling, \?\Global\<drive>:\path, by flipping the single character that distinguishes the NT prefix from the win32 one. No part of the target is dropped: the Global component is semantically meaningful (it forces the global device map, bypassing session-local drive mappings), and the returned string opens the same object and round-trips to an identical junction. Plain \??\<drive>: targets keep the existing drive-lettered rendering, and everything else still fails with ERROR_SYMLINK_NOT_SUPPORTED. The test plants the exact reparse data the kernel produces, so it runs without a container, and also pins the negative case: volume GUID mount points still refuse to render.
|
Updating the record since my last comment — two of the commits described above were subsequently reworked: NUL — the anonymous-pipe fallback was removed from this branch entirely ( Junctions — restored in The |
|
Superseded: upstream's AppContainer pipe-namespace fix (libuv#5181) has been cherry-picked into the |
win: support running inside an AppContainer (lowbox token)
Small commits that let libuv-based programs work inside a Windows AppContainer (lowbox token). Supersedes #4, which carried a
uv_fs_realpathdevice-mapping fallback (~400 lines) that the consumer can implement on its side instead; this PR is just the parts that have to live in libuv.What breaks today (verified on Windows 11 26200)
\\?\pipe\LOCAL\; anywhere elseCreateNamedPipereturnsERROR_ACCESS_DENIED.uv__unique_pipe_namegenerates\\?\pipe\uv\<ptr>-<pid>anduv__pipe_servertreatsERROR_ACCESS_DENIEDas a name collision and retries with a new name forever, so anyuv_spawnwith piped stdio (anduv_pipe()) busy-loops at 100% CPU.WriteConsoleInputW, which a sandboxed process may be denied on an inherited console; the failure paths deadlocked the loop or hung close.GetFinalPathNameByHandleW(VOLUME_NAME_DOS)is denied on every handle (the DOS-name translation opens the mount-manager device, which the sandbox blocks), souv_fs_realpathalways fails withEPERM.\Device\NullACL denies AppContainer tokens (all SKUs; some hosts re-grant it per boot), failing every spawn with aUV_IGNOREstdio slot. Deliberately not worked around here — see the Process section.Changes
Pipes
uv__unique_pipe_namegenerates\\?\pipe\LOCAL\uv\<ptr>-<pid>. The names are internal (never surfaced; embed a pointer and the pid) and outside an AppContainer the prefix is just six literal bytes in the name, so this is not observable.uv__pipe_serverboundsERROR_ACCESS_DENIEDretries: one retry with a freshly drawn random name (the previous candidates derive from a fixed seed + pid, so a squatter could deny pipe creation by occupying the guessable names), then the error is returned instead of looping.ERROR_PIPE_BUSYre-arms the limit — a busy pipe proves the namespace is accessible — and also draws a fresh random name, since a squatter can force that error too.uv_pipe()translates failures to negativeUV_E*codes at the API boundary (it previously returned raw positive Win32 codes), and CRT fd exhaustion surfaces asUV_EMFILEinstead ofUV_UNKNOWN(a deaderrno == UV_EMFILEcomparison).uv_pipe_binddistinguishesEACCES(namespace denied — renaming will never help) fromEADDRINUSE(name genuinely taken — renaming can help). The ambiguity is resolved by probing whether the name exists (WaitNamedPipewith no timeout, which doesn't consume a connection; probed through the\\.\spelling sinceWaitNamedPiperejects\\?\), not by token inspection — so a genuine collision inside a sandbox still reportsEADDRINUSE, and non-AppContainer sandboxes get the right answer too. When the probe finds the name free, the bind is retried once: a collision whose server just vanished binds successfully instead of misreporting. Deliberate limits: a probe that itself fails (e.g. denied by a stricter sandbox) conservatively keepsEADDRINUSE, and a server vanishing twice in a row can still be misread as a denial — one bounded retry is the trade against probing forever.TTY
CancelSynchronousIoon the reader thread, raw mode claims the registered wait and posts the completion directly, and the failure paths are sound either way (previously: leaked lock + armed trap, or a hung close). The reader publishes its thread handle before flipping the read status, so the canceller never waits on publication; the retry yields (SwitchToThread, the same shape asuv__pipe_interrupt_read) rather than sleeping, and a failed thread duplication withdraws immediately instead of burning the retry budget.ec0ab5d7(GHSA-4prr-4742-3ccf, off-by-one heap overflow in the utf-16→wtf-8 conversion), and fixes the sizing interaction the backport leaves behind: the read is sized to the conversion capacity ((bytes - 1) / 3code units) so an exact-fill line is never delivered torn with an embedded NUL or annreadlarger than the buffer. Allocations too small to hold even one converted character reportUV_ENOBUFSinstead of silently consuming input. (Upstream has the same latent exact-fill issue.)ReadConsoleW's error is captured before the reader thread is unpublished (GetLastError-clobber class).Filesystem
fs__realpath_handlepreserves the real error when the secondGetFinalPathNameByHandleWcall fails (it was overwritten withERROR_INVALID_HANDLE, surfacing asEBADF), andfs__realpathcapturesGetLastError()beforeCloseHandle()can clobber it.ACCESS_DENIEDstat fallback (which sandboxed processes take for every denied path) no longer readspath[-1]for all-dot relative paths or writes an uninitialized WCHAR over the path's last character, and it searches the right directory for root-adjacent spellings: splittingX:\name,\\?\X:\name,\\.\X:\name, or rooted\namepreviously left a drive-relative (X:), volume-device (\\?\X:), or empty directory to search, which could silently stat the wrong file.Process
NULforUV_IGNOREstdio slots is unchanged: a denied open fails the spawn with the real error, exactly as upstream. The default\Device\NullACL denies AppContainer tokens on every SKU (the kernel resets it to that default each boot); hosts that need ignored stdio inside a container grant the device to the container SIDs from an elevated context, or launch the sandboxed process with its std handles already wired toNULso children inherit or duplicate them (handle duplication performs no new access check). An earlier revision of this PR substituted a closed-end anonymous pipe on denial; it was dropped because failing writes turn every write-checking child (python, git, gnulib-based tools) into a confusing nonzero exit, and no mainstream runtime falls back when the null device is unavailable.API
uv_os_is_app_container()— returns1when the process token is an AppContainer token (GetTokenInformation(TokenIsAppContainer), cached withuv_once);0otherwise and on non-Windows. Declared inuv.h, documented inmisc.rst. (No internal caller needs it — the pipe and fs fixes are deliberately unconditional — so it stands as embedder API.)Tests
pipe_emfile(fd exhaustion →UV_EMFILE),tty_line_read_cancel_no_inject(cancellation through a read-only console handle, exercising the no-injection fallback in plain CI),tty_line_read_exact_fill(exact-fill line reads deliver complete sequences, with the buffer sized so ASAN catches a return of the overflow), and a bounds regression test for the wtf-8 conversion. Theversionadded:: 1.52.0annotation onuv_os_is_app_containerwas always false (upstream 1.52.x predates it and never shipped this API); it is marked as a fork extension.Notes
uv_pipe()surfaces asUV_EPERM(the standardERROR_ACCESS_DENIEDmapping).CreateAppContainerProfile, no admin required); libuv's own CI does not run sandboxed, but the downstream bun PR runs this commit inside a real AppContainer in its regular Windows CI (test/js/bun/windows/appcontainer.test.ts).uv_utf16_to_wtf8); the bind disambiguation's vanished-server retry narrows but cannot eliminate misclassification under adversarial churn (bounded by design); and when injection is denied and the cancel misses (reader preempted in the short pre-wait window, or its thread handle failed to duplicate), the trap is withdrawn and the next typed line is consumed and suppressed — bounded degradation where the old code hung the loop.Not in this PR
uv_fs_realpathstill always fails withEPERMinside an AppContainer (as it did before this PR). Junctions created by a sandboxed process are quarantined by the kernel (their reads fail); making them legible is deliberately out of scope. A working fallback requires building a device→drive-letter map, which is large and has no single-call solution; consumers that need it can implement it on top ofVOLUME_NAME_NTandFileIdInformation.