win: support running inside an AppContainer (lowbox token)

Closed
DY
4mo ago
+967-53

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 else CreateNamedPipe returns ERROR_ACCESS_DENIED. uv__unique_pipe_name generates \\?\pipe\uv\<ptr>-<pid> and uv__pipe_server treats ERROR_ACCESS_DENIED as a name collision and retries with a new name forever, so any uv_spawn with piped stdio (and uv_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), so uv_fs_realpath always fails with EPERM.
  • The default \Device\Null ACL denies AppContainer tokens (all SKUs; some hosts re-grant it per boot), failing every spawn with a UV_IGNORE stdio slot. Deliberately not worked around here — see the Process section.

Changes

Pipes

  • uv__unique_pipe_name generates \\?\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_server bounds ERROR_ACCESS_DENIED retries: 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_BUSY re-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 negative UV_E* codes at the API boundary (it previously returned raw positive Win32 codes), and CRT fd exhaustion surfaces as UV_EMFILE instead of UV_UNKNOWN (a dead errno == UV_EMFILE comparison).
  • uv_pipe_bind distinguishes EACCES (namespace denied — renaming will never help) from EADDRINUSE (name genuinely taken — renaming can help). The ambiguity is resolved by probing whether the name exists (WaitNamedPipe with no timeout, which doesn't consume a connection; probed through the \\.\ spelling since WaitNamedPipe rejects \\?\), not by token inspection — so a genuine collision inside a sandbox still reports EADDRINUSE, 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 keeps EADDRINUSE, 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 CancelSynchronousIo on 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 as uv__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) / 3 code units) so an exact-fill line is never delivered torn with an embedded NUL or an nread larger than the buffer. Allocations too small to hold even one converted character report UV_ENOBUFS instead 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_handle preserves the real error when the second GetFinalPathNameByHandleW call fails (it was overwritten with ERROR_INVALID_HANDLE, surfacing as EBADF), and fs__realpath captures GetLastError() before CloseHandle() can clobber it.
  • The ACCESS_DENIED stat fallback (which sandboxed processes take for every denied path) no longer reads path[-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: splitting X:\name, \\?\X:\name, \\.\X:\name, or rooted \name previously left a drive-relative (X:), volume-device (\\?\X:), or empty directory to search, which could silently stat the wrong file.

Process

  • Opening NUL for UV_IGNORE stdio slots is unchanged: a denied open fails the spawn with the real error, exactly as upstream. The default \Device\Null ACL 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 to NUL so 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() — returns 1 when the process token is an AppContainer token (GetTokenInformation(TokenIsAppContainer), cached with uv_once); 0 otherwise and on non-Windows. Declared in uv.h, documented in misc.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 as UV_EPERM (the standard ERROR_ACCESS_DENIED mapping).
  • 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.