win: support running inside an AppContainer (lowbox token)
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_realpath device-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)
- An AppContainer process may only create named pipes under
\\?\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. - Console read cancellation relies on injecting a synthesized input record with
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.- The default
\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
- Console read cancellation works without input injection: line mode falls back to
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. - Backports upstream
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.- The
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
- Opening
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. The versionadded:: 1.52.0 annotation on uv_os_is_app_container was always false (upstream 1.52.x predates it and never shipped this API); it is marked as a fork extension.
Notes
- A capped access-denied failure from
uv_pipe()surfaces asUV_EPERM(the standardERROR_ACCESS_DENIEDmapping). - Sandbox-only branches are verified with a manual AppContainer harness (
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). - Accepted residuals: the exact-fill read sizing can split a surrogate pair at multiple-of-3 allocation sizes (lone surrogates are valid WTF-8, the documented contract of
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_realpath still always fails with EPERM inside 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 of VOLUME_NAME_NT and FileIdInformation.