VirtualDesktop 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,912 @@
1
+ """Silent subprocess execution, ACL-restricted work directories, and UAC elevation.
2
+
3
+ Everything in this module exists for one reason: the driver install must happen in an
4
+ **elevated child process** while the caller keeps running unelevated. Two consequences
5
+ drive the design:
6
+
7
+ * The caller must receive the child's exact outcome, so the child reports back over a
8
+ named pipe created by the caller (not over stdout, which ``ShellExecuteW`` cannot
9
+ connect, and not over a world-writable temp file, which any local process could forge).
10
+ * The elevated child must not be able to run attacker-controlled code, so it is launched
11
+ with ``-E -s`` (ignore ``PYTHON*`` environment variables and the per-user site
12
+ directory) and only ever consumes files from a verified, digest-pinned cache.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import ctypes
18
+ import json
19
+ import os
20
+ import shutil
21
+ import subprocess
22
+ import sys
23
+ import tempfile
24
+ import time
25
+ import uuid
26
+ from ctypes import wintypes
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
30
+
31
+ from ._log import get_logger
32
+ from .errors import ConfigError, ElevationDenied, ElevationRequired, OffscreenMountError
33
+ from ._winapi import IS_WINDOWS, format_hresult, require_windows
34
+
35
+ log = get_logger("process")
36
+
37
+ __all__ = [
38
+ "CREATE_NO_WINDOW",
39
+ "CommandResult",
40
+ "run_command",
41
+ "secure_mkdir",
42
+ "secure_write_json",
43
+ "ResponsePipe",
44
+ "ElevationResult",
45
+ "elevate_and_run_helper",
46
+ "helper_command",
47
+ "import_roots",
48
+ "last_getlasterror",
49
+ ]
50
+
51
+ CREATE_NO_WINDOW = 0x08000000
52
+ SW_HIDE = 0
53
+ SW_SHOWNORMAL = 1
54
+ _STILL_ACTIVE = 259
55
+ _SEE_MASK_NOCLOSEPROCESS = 0x00000040
56
+ _SEE_MASK_NOASYNC = 0x00000100
57
+ _DEFAULT_CHUNK = 4096
58
+
59
+ if IS_WINDOWS: # pragma: no branch
60
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
61
+ shell32 = ctypes.WinDLL("shell32", use_last_error=True)
62
+ advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
63
+ else: # pragma: no cover
64
+ kernel32 = shell32 = advapi32 = None
65
+
66
+
67
+ def last_getlasterror() -> int:
68
+ if not IS_WINDOWS:
69
+ return 0
70
+ return ctypes.get_last_error()
71
+
72
+
73
+ # --------------------------------------------------------------------------------------
74
+ # Hardened directories
75
+ # --------------------------------------------------------------------------------------
76
+
77
+
78
+ class _SECURITY_ATTRIBUTES(ctypes.Structure):
79
+ _fields_ = [
80
+ ("nLength", wintypes.DWORD),
81
+ ("lpSecurityDescriptor", ctypes.c_void_p),
82
+ ("bInheritHandle", wintypes.BOOL),
83
+ ]
84
+
85
+
86
+ class _SHELLEXECUTEINFOW(ctypes.Structure):
87
+ """``SHELLEXECUTEINFOW`` - needed to obtain the elevated child's process handle."""
88
+
89
+ _fields_ = [
90
+ ("cbSize", wintypes.DWORD),
91
+ ("fMask", ctypes.c_ulong),
92
+ ("hwnd", wintypes.HWND),
93
+ ("lpVerb", ctypes.c_wchar_p),
94
+ ("lpFile", ctypes.c_wchar_p),
95
+ ("lpParameters", ctypes.c_wchar_p),
96
+ ("lpDirectory", ctypes.c_wchar_p),
97
+ ("nShow", ctypes.c_int),
98
+ ("hInstApp", wintypes.HINSTANCE),
99
+ ("lpIDList", ctypes.c_void_p),
100
+ ("lpClass", ctypes.c_wchar_p),
101
+ ("hkeyClass", wintypes.HKEY),
102
+ ("dwHotKey", wintypes.DWORD),
103
+ ("hIcon", wintypes.HANDLE),
104
+ ("hProcess", wintypes.HANDLE),
105
+ ]
106
+
107
+
108
+ _SDDL_REVISION_1 = 1
109
+ _SE_FILE_OBJECT = 1
110
+ _DACL_SECURITY_INFORMATION = 0x00000004
111
+ _PROTECTED_DACL_SECURITY_INFORMATION = 0x80000000
112
+
113
+
114
+ def _owner_only_sd(access: Tuple[str, ...] = ("FA",)) -> Optional[ctypes.c_void_p]:
115
+ """Build a protected DACL: full control for SYSTEM/Administrators/owner.
116
+
117
+ ``access`` overrides the permission mask granted to the owner (``OW``), which is how
118
+ the response pipe grants ``GW`` (generic write) instead of full control.
119
+ """
120
+ if not IS_WINDOWS:
121
+ return None
122
+ owner_mask = "".join(access)
123
+ sddl = f"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;{owner_mask};;;OW)"
124
+ descriptor = ctypes.c_void_p()
125
+ ok = advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW(
126
+ ctypes.c_wchar_p(sddl),
127
+ ctypes.c_uint(_SDDL_REVISION_1),
128
+ ctypes.byref(descriptor),
129
+ None,
130
+ )
131
+ if not ok:
132
+ log.debug("could not build security descriptor (%s): %s", sddl, last_getlasterror())
133
+ return None
134
+ return descriptor
135
+
136
+
137
+ def _owner_sid_string() -> Optional[str]:
138
+ """The current process token's user SID in SDDL form (``S-1-5-21-...``)."""
139
+ if not IS_WINDOWS:
140
+ return None
141
+ TOKEN_QUERY = 0x0008
142
+ TokenUser = 1
143
+ token = wintypes.HANDLE()
144
+ if not advapi32.OpenProcessToken(
145
+ kernel32.GetCurrentProcess(), TOKEN_QUERY, ctypes.byref(token)
146
+ ):
147
+ return None
148
+ try:
149
+ needed = wintypes.DWORD()
150
+ advapi32.GetTokenInformation(token, TokenUser, None, 0, ctypes.byref(needed))
151
+ if not needed.value:
152
+ return None
153
+ buffer = ctypes.create_string_buffer(needed.value)
154
+ if not advapi32.GetTokenInformation(
155
+ token, TokenUser, buffer, needed.value, ctypes.byref(needed)
156
+ ):
157
+ return None
158
+ # TOKEN_USER is a SID_AND_ATTRIBUTES struct: the first pointer-sized field is the SID.
159
+ sid = ctypes.cast(buffer, ctypes.POINTER(ctypes.c_void_p))[0]
160
+ text = ctypes.c_wchar_p()
161
+ if not advapi32.ConvertSidToStringSidW(ctypes.c_void_p(sid), ctypes.byref(text)):
162
+ return None
163
+ try:
164
+ return text.value
165
+ finally:
166
+ kernel32.LocalFree(text)
167
+ finally:
168
+ kernel32.CloseHandle(token)
169
+
170
+
171
+ #: FILE_GENERIC_WRITE, spelled numerically on purpose. The SDDL abbreviation ``GW`` maps to
172
+ #: a mask that also includes DELETE-related bits, which a named pipe cannot satisfy, so the
173
+ #: client's ``GENERIC_WRITE`` open failed the access check with ERROR_ACCESS_DENIED
174
+ #: (measured: ``GW`` = FAIL(5), ``0x12019b`` = OK). The numeric mask is the exact
175
+ #: *mappable* subset and is honoured on every Windows version tested.
176
+ FILE_GENERIC_WRITE = 0x0012019B
177
+
178
+ #: Directory equivalent: SDDL can safely use ``FA`` for directories because the object is
179
+ #: created with the caller as owner, so generic bits resolve normally.
180
+ FILE_ALL_ACCESS_SDDL = "FA"
181
+
182
+
183
+ def _security_descriptor(
184
+ user_access: str = FILE_ALL_ACCESS_SDDL,
185
+ owner_sid: Optional[str] = None,
186
+ ) -> Optional[ctypes.c_void_p]:
187
+ """Build a *protected* DACL: SYSTEM + Administrators + this user's SID.
188
+
189
+ ``user_access`` is the access mask granted to the current user, either an SDDL
190
+ abbreviation (``FA``) or a numeric mask (``0x0012019B``). The current user's SID is
191
+ written out explicitly instead of relying on the ``OW`` ("owner rights") SID: ``OW``
192
+ only applies to objects that actually have an owner, which a freshly created **named
193
+ pipe** does not (``ConvertStringSecurityDescriptorToSecurityDescriptor`` leaves the
194
+ owner unset), so an ``OW``-only ACE silently grants nothing and the client gets
195
+ ERROR_ACCESS_DENIED.
196
+ """
197
+ if not IS_WINDOWS:
198
+ return None
199
+ sid = owner_sid or _owner_sid_string()
200
+ user_ace = f"(A;OICI;{user_access};;;{sid})" if sid else ""
201
+ sddl = f"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA){user_ace}"
202
+ descriptor = ctypes.c_void_p()
203
+ ok = advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW(
204
+ ctypes.c_wchar_p(sddl),
205
+ ctypes.c_uint(_SDDL_REVISION_1),
206
+ ctypes.byref(descriptor),
207
+ None,
208
+ )
209
+ if not ok:
210
+ log.debug("could not build security descriptor (%s): %s", sddl, last_getlasterror())
211
+ return None
212
+ return descriptor
213
+
214
+
215
+ def _local_free(descriptor: Optional[ctypes.c_void_p]) -> None:
216
+ if descriptor:
217
+ kernel32.LocalFree(descriptor)
218
+
219
+
220
+ if IS_WINDOWS: # pragma: no branch
221
+ advapi32.OpenProcessToken.argtypes = [
222
+ wintypes.HANDLE,
223
+ wintypes.DWORD,
224
+ ctypes.POINTER(wintypes.HANDLE),
225
+ ]
226
+ advapi32.OpenProcessToken.restype = wintypes.BOOL
227
+ advapi32.GetTokenInformation.argtypes = [
228
+ wintypes.HANDLE,
229
+ ctypes.c_int,
230
+ ctypes.c_void_p,
231
+ wintypes.DWORD,
232
+ ctypes.POINTER(wintypes.DWORD),
233
+ ]
234
+ advapi32.GetTokenInformation.restype = wintypes.BOOL
235
+ advapi32.ConvertSidToStringSidW.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_wchar_p)]
236
+ advapi32.ConvertSidToStringSidW.restype = wintypes.BOOL
237
+ advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW.argtypes = [
238
+ ctypes.c_wchar_p,
239
+ ctypes.c_uint,
240
+ ctypes.POINTER(ctypes.c_void_p),
241
+ ctypes.POINTER(wintypes.DWORD),
242
+ ]
243
+ advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW.restype = wintypes.BOOL
244
+ kernel32.CreateDirectoryW.argtypes = [
245
+ ctypes.c_wchar_p,
246
+ ctypes.POINTER(_SECURITY_ATTRIBUTES),
247
+ ]
248
+ kernel32.CreateDirectoryW.restype = wintypes.BOOL
249
+ kernel32.GetCurrentProcess.restype = wintypes.HANDLE
250
+
251
+
252
+ def secure_mkdir(path: os.PathLike) -> Path:
253
+ """``mkdir -p`` with an ACL that excludes other local users where possible.
254
+
255
+ **Every** missing component is created with the hardened descriptor, not just the leaf:
256
+ creating a child inside a permissive parent leaves that parent traversable by other
257
+ users, and ``CreateDirectoryW`` fails with ``ERROR_PATH_NOT_FOUND`` (3) when its parent
258
+ does not exist — so delegating to Python's ``parents=True`` would silently skip the ACL
259
+ work for exactly the nested paths this library uses.
260
+
261
+ Falls back to a plain ``mkdir`` (and logs at debug level) when the security APIs are
262
+ unavailable; the directory then still lives under the user's own cache/profile root.
263
+ """
264
+ target = Path(path)
265
+ if target.exists():
266
+ return target
267
+ missing: List[Path] = []
268
+ cursor = target
269
+ while not cursor.exists():
270
+ missing.append(cursor)
271
+ if cursor.parent == cursor: # pragma: no cover - filesystem root
272
+ break
273
+ cursor = cursor.parent
274
+ for directory in reversed(missing):
275
+ descriptor = _security_descriptor(FILE_ALL_ACCESS_SDDL)
276
+ if descriptor is None:
277
+ directory.mkdir(exist_ok=True)
278
+ continue
279
+ attributes = _SECURITY_ATTRIBUTES()
280
+ attributes.nLength = ctypes.sizeof(_SECURITY_ATTRIBUTES)
281
+ attributes.lpSecurityDescriptor = descriptor
282
+ attributes.bInheritHandle = False
283
+ try:
284
+ if not kernel32.CreateDirectoryW(str(directory), ctypes.byref(attributes)):
285
+ error = last_getlasterror()
286
+ if error != 183 and not directory.exists(): # ERROR_ALREADY_EXISTS
287
+ log.debug("hardened CreateDirectory failed (%s); falling back", error)
288
+ directory.mkdir(exist_ok=True)
289
+ finally:
290
+ _local_free(descriptor)
291
+ return target
292
+
293
+
294
+ def secure_write_json(path: os.PathLike, payload: Dict[str, Any]) -> Path:
295
+ """Atomically write JSON into a file that is not group/world accessible."""
296
+ target = Path(path)
297
+ secure_mkdir(target.parent)
298
+ text = json.dumps(payload, indent=2, sort_keys=True, default=str)
299
+ temp = target.with_suffix(target.suffix + f".{uuid.uuid4().hex[:8]}.tmp")
300
+ temp.write_text(text, encoding="utf-8")
301
+ os.replace(temp, target)
302
+ return target
303
+
304
+
305
+ # --------------------------------------------------------------------------------------
306
+ # Subprocess execution
307
+ # --------------------------------------------------------------------------------------
308
+
309
+
310
+ @dataclass
311
+ class CommandResult:
312
+ argv: Sequence[str]
313
+ returncode: int
314
+ stdout: str = ""
315
+ stderr: str = ""
316
+ log_path: Optional[str] = None
317
+ duration: float = 0.0
318
+
319
+ @property
320
+ def ok(self) -> bool:
321
+ return self.returncode == 0
322
+
323
+ @property
324
+ def requires_reboot(self) -> bool:
325
+ return self.returncode == 3010
326
+
327
+ @property
328
+ def rendered(self) -> str:
329
+ return " ".join(str(a) if " " not in str(a) else f'"{a}"' for a in self.argv)
330
+
331
+ def to_dict(self) -> dict:
332
+ return {
333
+ "argv": list(self.argv),
334
+ "returncode": self.returncode,
335
+ "requires_reboot": self.requires_reboot,
336
+ "log_path": self.log_path,
337
+ "duration": round(self.duration, 3),
338
+ "stdout_tail": self.stdout[-2000:],
339
+ "stderr_tail": self.stderr[-2000:],
340
+ }
341
+
342
+
343
+ def run_command(
344
+ argv: Sequence[str],
345
+ *,
346
+ timeout: float = 300.0,
347
+ log_path: Optional[os.PathLike] = None,
348
+ env: Optional[Dict[str, str]] = None,
349
+ cwd: Optional[os.PathLike] = None,
350
+ ) -> CommandResult:
351
+ """Run a command with no console window, capturing output and never using a shell.
352
+
353
+ ``argv`` is always passed as a list: no ``shell=True`` anywhere in this library, so
354
+ quotes inside a path can never turn into command injection.
355
+ """
356
+ if not argv:
357
+ raise ConfigError("run_command requires a non-empty argv", code="empty-argv")
358
+ argv = [str(a) for a in argv]
359
+ started = time.monotonic()
360
+ try:
361
+ completed = subprocess.run(
362
+ argv,
363
+ capture_output=True,
364
+ text=True,
365
+ timeout=timeout,
366
+ cwd=str(cwd) if cwd else None,
367
+ env=env,
368
+ creationflags=CREATE_NO_WINDOW if IS_WINDOWS else 0,
369
+ errors="replace",
370
+ )
371
+ stdout, stderr, code = completed.stdout or "", completed.stderr or "", completed.returncode
372
+ except FileNotFoundError as exc:
373
+ stdout, stderr, code = "", str(exc), 127
374
+ except subprocess.TimeoutExpired as exc:
375
+ stdout = (exc.stdout or b"").decode("utf-8", "replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "")
376
+ stderr = f"timed out after {timeout}s"
377
+ code = 124
378
+ duration = time.monotonic() - started
379
+ result = CommandResult(
380
+ argv=argv,
381
+ returncode=code,
382
+ stdout=stdout,
383
+ stderr=stderr,
384
+ log_path=str(log_path) if log_path else None,
385
+ duration=duration,
386
+ )
387
+ if log_path:
388
+ try:
389
+ secure_mkdir(Path(log_path).parent)
390
+ with open(log_path, "a", encoding="utf-8") as handle:
391
+ handle.write(f"\n=== {time.strftime('%Y-%m-%d %H:%M:%S')} {result.rendered}\n")
392
+ handle.write(f"--- exit code: {code} ({duration:.2f}s)\n")
393
+ if stdout:
394
+ handle.write("--- stdout\n" + stdout + "\n")
395
+ if stderr:
396
+ handle.write("--- stderr\n" + stderr + "\n")
397
+ except OSError as exc: # pragma: no cover - logging must never break the flow
398
+ log.warning("could not write command log %s: %s", log_path, exc)
399
+ log.debug("ran %s -> %s in %.2fs", result.rendered, code, duration)
400
+ return result
401
+
402
+
403
+ # --------------------------------------------------------------------------------------
404
+ # Response pipe (child -> parent, spoof-proof)
405
+ # --------------------------------------------------------------------------------------
406
+
407
+ _PIPE_ACCESS_OUTBOUND = 0x00000002
408
+ #: Duplex is required, not a preference: a client that asks for ``GENERIC_WRITE`` is
409
+ #: checked against the pipe's *write* right, but an outbound-only pipe grants
410
+ #: ``FILE_WRITE_DATA`` and the generic mapping then fails the access check with
411
+ #: ERROR_ACCESS_DENIED (measured: outbound=FAIL 5, duplex=OK).
412
+ _PIPE_ACCESS_DUPLEX = 0x00000003
413
+ _FILE_FLAG_FIRST_PIPE_INSTANCE = 0x00080000
414
+ _PIPE_TYPE_BYTE = 0x00000000
415
+ _PIPE_READMODE_BYTE = 0x00000000
416
+ _PIPE_WAIT = 0x00000000
417
+ #: Non-blocking mode. Used deliberately for the **server** side of the response pipe so
418
+ #: that a caller-supplied timeout is actually honoured: in blocking mode
419
+ #: ``ConnectNamedPipe`` waits for ``nDefaultTimeOut`` (we would have to pass 10 minutes to
420
+ #: cover a slow UAC prompt), which would make ``wait_for_message(timeout=...)`` a lie.
421
+ _PIPE_NOWAIT = 0x00000001
422
+ _INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
423
+
424
+ if IS_WINDOWS: # pragma: no branch
425
+ # Explicit prototypes matter here: without them ctypes assumes ``int``, which
426
+ # truncates 64-bit HANDLEs and silently breaks named-pipe I/O on some builds.
427
+ kernel32.CreateNamedPipeW.argtypes = [
428
+ ctypes.c_wchar_p,
429
+ wintypes.DWORD,
430
+ wintypes.DWORD,
431
+ wintypes.DWORD,
432
+ wintypes.DWORD,
433
+ wintypes.DWORD,
434
+ wintypes.DWORD,
435
+ ctypes.c_void_p,
436
+ ]
437
+ kernel32.CreateNamedPipeW.restype = wintypes.HANDLE
438
+ kernel32.ConnectNamedPipe.argtypes = [wintypes.HANDLE, ctypes.c_void_p]
439
+ kernel32.ConnectNamedPipe.restype = wintypes.BOOL
440
+ kernel32.DisconnectNamedPipe.argtypes = [wintypes.HANDLE]
441
+ kernel32.DisconnectNamedPipe.restype = wintypes.BOOL
442
+ kernel32.ReadFile.argtypes = [
443
+ wintypes.HANDLE,
444
+ ctypes.c_void_p,
445
+ wintypes.DWORD,
446
+ ctypes.POINTER(wintypes.DWORD),
447
+ ctypes.c_void_p,
448
+ ]
449
+ kernel32.ReadFile.restype = wintypes.BOOL
450
+ kernel32.WriteFile.argtypes = [
451
+ wintypes.HANDLE,
452
+ ctypes.c_void_p,
453
+ wintypes.DWORD,
454
+ ctypes.POINTER(wintypes.DWORD),
455
+ ctypes.c_void_p,
456
+ ]
457
+ kernel32.WriteFile.restype = wintypes.BOOL
458
+ kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
459
+ kernel32.CloseHandle.restype = wintypes.BOOL
460
+ kernel32.CreateFileW.argtypes = [
461
+ ctypes.c_wchar_p,
462
+ wintypes.DWORD,
463
+ wintypes.DWORD,
464
+ ctypes.c_void_p,
465
+ wintypes.DWORD,
466
+ wintypes.DWORD,
467
+ wintypes.HANDLE,
468
+ ]
469
+ kernel32.CreateFileW.restype = wintypes.HANDLE
470
+
471
+
472
+ class ResponsePipe:
473
+ """A one-shot named pipe the elevated child writes its JSON result into."""
474
+
475
+ def __init__(self, name: Optional[str] = None):
476
+ require_windows("ResponsePipe")
477
+ token = os.environ.get("USERNAME", "user")
478
+ self.name = name or rf"\\.\pipe\VirtualDesktop-{token}-{uuid.uuid4().hex}"
479
+ self._handle: Optional[int] = None
480
+ # The default named-pipe DACL does not always include the creating user's own
481
+ # account (WriteDAC is refused for non-elevated creators), and an outbound-only
482
+ # pipe fails the client's generic-write access check outright. Grant generic write
483
+ # explicitly to the owner, SYSTEM and Administrators, and keep the pipe duplex: it
484
+ # is then reliably usable by the elevated child while still excluding every other
485
+ # local user.
486
+ self._descriptor = _security_descriptor(f"0x{FILE_GENERIC_WRITE:08X}")
487
+ self._attrs = _SECURITY_ATTRIBUTES()
488
+ self._attrs.nLength = ctypes.sizeof(_SECURITY_ATTRIBUTES)
489
+ self._attrs.lpSecurityDescriptor = self._descriptor
490
+ self._attrs.bInheritHandle = False
491
+
492
+ def create(self) -> str:
493
+ handle = kernel32.CreateNamedPipeW(
494
+ self.name,
495
+ _PIPE_ACCESS_DUPLEX | _FILE_FLAG_FIRST_PIPE_INSTANCE,
496
+ _PIPE_TYPE_BYTE | _PIPE_READMODE_BYTE | _PIPE_NOWAIT,
497
+ 1,
498
+ 64 * 1024,
499
+ 64 * 1024,
500
+ 1000 * 60 * 10,
501
+ ctypes.byref(self._attrs),
502
+ )
503
+ if not handle or handle == _INVALID_HANDLE_VALUE:
504
+ raise OffscreenMountError(
505
+ f"could not create the response pipe {self.name}: {last_getlasterror()}",
506
+ code="pipe-create-failed",
507
+ )
508
+ self._handle = handle
509
+ return self.name
510
+
511
+ def wait_for_message(
512
+ self,
513
+ *,
514
+ timeout: float = 600.0,
515
+ poll: float = 0.05,
516
+ on_listening: Optional[callable] = None,
517
+ ) -> Optional[dict]:
518
+ """Block until the child connects (or the timeout expires), then read its JSON.
519
+
520
+ ``on_listening`` is invoked once, as soon as the pipe is listening, so a caller can
521
+ start the client *after* the server is ready (optional and purely an optimisation).
522
+ """
523
+ if self._handle is None:
524
+ raise OffscreenMountError("pipe not created", code="pipe-not-created")
525
+ deadline = time.monotonic() + timeout
526
+ connected = False
527
+ announced = False
528
+ access_denied_since: Optional[float] = None
529
+ while time.monotonic() < deadline:
530
+ if kernel32.ConnectNamedPipe(self._handle, None):
531
+ connected = True
532
+ break
533
+ error = last_getlasterror()
534
+ if error == 535: # ERROR_PIPE_CONNECTED - client connected before we called
535
+ connected = True
536
+ break
537
+ # In PIPE_NOWAIT mode the *connected* state is reported as ERROR_NO_DATA (232)
538
+ # on the next ConnectNamedPipe call, so that error is the success signal here
539
+ # (measured: listening=536 -> client writes -> connected-but-reported-as-232).
540
+ if error == 232:
541
+ connected = True
542
+ break
543
+ # ERROR_PIPE_LISTENING (536) is the only proof that the pipe is *ready*; firing
544
+ # the callback any earlier would let a client race into ERROR_PIPE_BUSY.
545
+ if not announced and error == 536 and on_listening is not None:
546
+ announced = True
547
+ try:
548
+ on_listening()
549
+ except Exception: # pragma: no cover - callback must never break the wait
550
+ log.debug("on_listening callback raised", exc_info=True)
551
+ if error == 5: # ERROR_ACCESS_DENIED - the client could not open the pipe
552
+ if access_denied_since is None:
553
+ access_denied_since = time.monotonic()
554
+ elif time.monotonic() - access_denied_since > min(2.0, max(timeout / 4.0, 0.25)):
555
+ log.error(
556
+ "the helper cannot open %s (ERROR_ACCESS_DENIED); it will not be "
557
+ "able to report its result",
558
+ self.name,
559
+ )
560
+ return None
561
+ time.sleep(poll)
562
+ if not connected:
563
+ log.warning("no response received on %s within %.0fs", self.name, timeout)
564
+ return None
565
+ buffer = ctypes.create_string_buffer(64 * 1024)
566
+ read = wintypes.DWORD()
567
+ chunks: List[bytes] = []
568
+ deadline = time.monotonic() + 5.0
569
+ while True:
570
+ ok = kernel32.ReadFile(
571
+ self._handle, buffer, ctypes.sizeof(buffer) - 1, ctypes.byref(read), None
572
+ )
573
+ if not ok:
574
+ error = last_getlasterror()
575
+ if error == 234: # ERROR_MORE_DATA - message-mode leftovers, keep reading
576
+ continue
577
+ # In PIPE_NOWAIT mode a read with no data pending reports ERROR_NO_DATA
578
+ # (232); the client may simply not have written yet, so retry briefly.
579
+ if error == 232 and time.monotonic() < deadline:
580
+ time.sleep(0.02)
581
+ continue
582
+ if error not in (232, 109): # 109 = ERROR_BROKEN_PIPE
583
+ log.debug("ReadFile on %s failed with %s", self.name, error)
584
+ break
585
+ if read.value == 0:
586
+ break
587
+ chunks.append(buffer.raw[: read.value])
588
+ payload = b"".join(chunks).decode("utf-8", "replace").strip()
589
+ if not payload:
590
+ return None
591
+ try:
592
+ data = json.loads(payload)
593
+ except json.JSONDecodeError:
594
+ log.warning("unparsable helper response: %r", payload[:400])
595
+ return None
596
+ return data if isinstance(data, dict) else None
597
+
598
+ def close(self) -> None:
599
+ if self._handle:
600
+ kernel32.DisconnectNamedPipe(self._handle)
601
+ kernel32.CloseHandle(self._handle)
602
+ self._handle = None
603
+ if getattr(self, "_descriptor", None):
604
+ _local_free(self._descriptor)
605
+ self._descriptor = None
606
+
607
+ def __enter__(self) -> "ResponsePipe":
608
+ self.create()
609
+ return self
610
+
611
+ def __exit__(self, exc_type, exc, tb) -> None:
612
+ self.close()
613
+
614
+
615
+ def write_response(pipe_name: str, payload: Dict[str, Any], *, timeout: float = 30.0) -> bool:
616
+ """Child-side helper: open ``pipe_name`` and write the JSON payload.
617
+
618
+ Retries on ``ERROR_PIPE_BUSY`` (231), which is what a client sees if it races ahead of
619
+ the server's ``ConnectNamedPipe`` call. Returning silently on a busy pipe would lose the
620
+ child's result *and* hang the waiting parent, so this call is deliberately persistent.
621
+ """
622
+ require_windows("write_response")
623
+ GENERIC_WRITE = 0x40000000
624
+ OPEN_EXISTING = 3
625
+ ERROR_PIPE_BUSY = 231
626
+ handle = None
627
+ deadline = time.monotonic() + timeout
628
+ while True:
629
+ handle = kernel32.CreateFileW(pipe_name, GENERIC_WRITE, 0, None, OPEN_EXISTING, 0, None)
630
+ if handle and handle != _INVALID_HANDLE_VALUE:
631
+ break
632
+ error = last_getlasterror()
633
+ if error == ERROR_PIPE_BUSY and time.monotonic() < deadline:
634
+ time.sleep(0.05)
635
+ continue
636
+ log.warning("helper could not open response pipe %s: %s", pipe_name, error)
637
+ return False
638
+ try:
639
+ data = json.dumps(payload, default=str).encode("utf-8")
640
+ written = wintypes.DWORD()
641
+ ok = kernel32.WriteFile(handle, data, len(data), ctypes.byref(written), None)
642
+ if not ok:
643
+ log.warning("helper could not write to %s: %s", pipe_name, last_getlasterror())
644
+ return bool(ok)
645
+ finally:
646
+ kernel32.CloseHandle(handle)
647
+
648
+
649
+ # --------------------------------------------------------------------------------------
650
+ # Elevation
651
+ # --------------------------------------------------------------------------------------
652
+
653
+
654
+ @dataclass
655
+ class ElevationResult:
656
+ launched: bool
657
+ response: Optional[dict] = None
658
+ shell_result: int = 0
659
+ timed_out: bool = False
660
+
661
+ @property
662
+ def helper_ok(self) -> bool:
663
+ return bool(self.response and self.response.get("ok"))
664
+
665
+
666
+ def package_root() -> str:
667
+ """Directory that must be importable for ``import VirtualDesktop`` to work."""
668
+ return str(Path(__file__).resolve().parent.parent)
669
+
670
+
671
+ def import_roots() -> List[str]:
672
+ """Every directory the *current* interpreter imports ``VirtualDesktop`` from.
673
+
674
+ This matters for an installed package, and it is not the same as :func:`package_root`:
675
+
676
+ * a **source checkout** imports the package because its parent directory is on ``sys.path``;
677
+ * a **wheel install** imports it from ``site-packages``, and the *parent* of the package
678
+ directory is then ``site-packages`` while the code itself lives in
679
+ ``site-packages/VirtualDesktop``.
680
+
681
+ ``_helper.py`` adds its own directory and that directory's parent to ``sys.path``, which is
682
+ enough when the elevated child runs the *same* interpreter. Handing it the real site-packages
683
+ as well keeps the helper working even when the elevated child resolves a different Python
684
+ (a different venv, or a system interpreter), which would otherwise fail with
685
+ ``ModuleNotFoundError: VirtualDesktop`` and a silent pipe.
686
+ """
687
+ roots: List[str] = []
688
+ package_dir = Path(__file__).resolve().parent
689
+ for candidate in (package_dir.parent, package_dir):
690
+ text = str(candidate)
691
+ if text not in roots:
692
+ roots.append(text)
693
+ try:
694
+ import site
695
+
696
+ for candidate in list(site.getsitepackages()) + [site.getusersitepackages()]:
697
+ text = str(candidate)
698
+ if text and text not in roots and Path(text).is_dir():
699
+ roots.append(text)
700
+ except Exception: # pragma: no cover - site is always importable on CPython
701
+ log.debug("could not enumerate site-packages for the helper environment", exc_info=True)
702
+ return roots
703
+
704
+
705
+ def helper_environment(base: Optional[Dict[str, str]] = None) -> Dict[str, str]:
706
+ """A deliberately minimal environment for the elevated helper.
707
+
708
+ ``-E`` (ignore ``PYTHON*`` variables) is *not* used any more, because it also discards the
709
+ ``PYTHONPATH`` the helper needs when the library is not installed into site-packages.
710
+ Security is preserved by constructing the environment ourselves instead of inheriting one:
711
+ only the variables below are passed on, and ``PYTHONPATH`` is built from
712
+ :func:`import_roots` rather than taken from the caller, so neither an inherited variable nor
713
+ a stale one can redirect what the helper imports. ``PYTHONSTARTUP``/``PYTHONHOME``/
714
+ ``PYTHONUSERBASE`` are explicitly removed.
715
+ """
716
+ source = dict(os.environ if base is None else base)
717
+ allowed = (
718
+ "SystemRoot",
719
+ "SystemDrive",
720
+ "windir",
721
+ "ComSpec",
722
+ "PATH",
723
+ "PATHEXT",
724
+ "TEMP",
725
+ "TMP",
726
+ "USERPROFILE",
727
+ "HOMEDRIVE",
728
+ "HOMEPATH",
729
+ "APPDATA",
730
+ "LOCALAPPDATA",
731
+ "ProgramData",
732
+ "ProgramFiles",
733
+ "ProgramFiles(x86)",
734
+ "ProgramW6432",
735
+ "CommonProgramFiles",
736
+ "NUMBER_OF_PROCESSORS",
737
+ "PROCESSOR_ARCHITECTURE",
738
+ "PROCESSOR_ARCHITEW6432",
739
+ "USERNAME",
740
+ "USERDOMAIN",
741
+ "SESSIONNAME",
742
+ "PUBLIC",
743
+ "LANG",
744
+ "VIRTUALDESKTOP_HELPER_TRACE",
745
+ )
746
+ env = {key: source[key] for key in allowed if key in source}
747
+ env["PYTHONPATH"] = os.pathsep.join(import_roots())
748
+ env["PYTHONDONTWRITEBYTECODE"] = "1"
749
+ env["PYTHONIOENCODING"] = "utf-8"
750
+ for forbidden in ("PYTHONSTARTUP", "PYTHONHOME", "PYTHONUSERBASE", "PYTHONWARNINGS"):
751
+ env.pop(forbidden, None)
752
+ return env
753
+
754
+
755
+ def helper_command(*args: str) -> List[str]:
756
+ """The argv used to re-enter this library as the elevated helper.
757
+
758
+ Runs the module *by absolute path* rather than with ``-m``: ``-m`` resolves the package
759
+ through ``sys.path``, and the elevated child's ``sys.path`` contains only its own working
760
+ directory — which is not necessarily the project directory (measured: the helper died with
761
+ ``No module named VirtualDesktop`` while the pipe stayed silent). Invoking
762
+ ``<package>/_helper.py`` works for a source checkout and for an installed wheel alike,
763
+ because the script directory is always on ``sys.path``.
764
+ """
765
+ helper = Path(__file__).resolve().with_name("_helper.py")
766
+ # ``--no-elevate`` tells the child it is already privileged: without it the child asks UAC
767
+ # for elevation *again* for an uninstall, because the token probe reports "not elevated"
768
+ # inside the elevated child on this machine (measured: the nested request never surfaced a
769
+ # prompt and the parent timed out with ``driver-uninstall-timeout``).
770
+ return [sys.executable, "-s", str(helper), "_elevated-install", "--no-elevate", *args]
771
+
772
+
773
+ def elevate_and_run_helper(
774
+ args: Sequence[str],
775
+ *,
776
+ timeout: float = 900.0,
777
+ log_dir: Optional[os.PathLike] = None,
778
+ show_ui: bool = False,
779
+ extra_env: Optional[Dict[str, str]] = None,
780
+ ) -> ElevationResult:
781
+ """Re-launch this library as an elevated helper via UAC and read its result.
782
+
783
+ Uses ``ShellExecuteExW`` (not ``ShellExecuteW``) with ``SEE_MASK_NOCLOSEPROCESS`` so the
784
+ child's process handle is available. That matters operationally: if the helper dies
785
+ immediately the wait ends at once with an actionable error instead of blocking for the
786
+ full (10-15 minute) driver-install timeout — which is exactly how a broken helper used to
787
+ present itself: "no response on the pipe" and nothing else.
788
+
789
+ ``ShellExecuteExW`` cannot pass an environment block, so the child is started through
790
+ ``cmd.exe /c set ... && python ...``: the *sanitised* environment is injected that way and
791
+ the helper inherits nothing else.
792
+ """
793
+ require_windows("elevate_and_run_helper")
794
+ answer: Optional[dict] = None
795
+ timed_out = False
796
+ shell_code = 0
797
+ exit_code: Optional[int] = None
798
+ env = helper_environment(os.environ | dict(extra_env or {}))
799
+
800
+ with ResponsePipe() as pipe:
801
+ argv = helper_command("--response-pipe", pipe.name, *args)
802
+ log.debug("elevating: %s", " ".join(argv))
803
+ launcher, parameters = _elevation_launcher(argv, env)
804
+ shell32.ShellExecuteExW.argtypes = [ctypes.POINTER(_SHELLEXECUTEINFOW)]
805
+ shell32.ShellExecuteExW.restype = wintypes.BOOL
806
+ info = _SHELLEXECUTEINFOW()
807
+ info.cbSize = ctypes.sizeof(_SHELLEXECUTEINFOW)
808
+ info.fMask = _SEE_MASK_NOCLOSEPROCESS | _SEE_MASK_NOASYNC
809
+ info.lpVerb = "runas"
810
+ info.lpFile = launcher
811
+ info.lpParameters = parameters
812
+ info.lpDirectory = str(log_dir) if log_dir else None
813
+ info.nShow = SW_HIDE if not show_ui else SW_SHOWNORMAL
814
+ ctypes.set_last_error(0)
815
+ if not shell32.ShellExecuteExW(ctypes.byref(info)):
816
+ error = ctypes.get_last_error()
817
+ log.warning("ShellExecuteExW(runas) failed with Win32 error %s", error)
818
+ raise ElevationDenied(
819
+ f"elevation was refused (Win32 error {error})",
820
+ details={"win32_error": error},
821
+ )
822
+ shell_code = int(info.hInstApp or 0)
823
+ handle = int(info.hProcess or 0)
824
+ if not handle:
825
+ raise ElevationDenied(
826
+ "elevation was accepted but no process was created",
827
+ details={"shell_result": shell_code},
828
+ )
829
+ try:
830
+ answer, timed_out, exit_code = _await_helper(pipe, handle, timeout)
831
+ finally:
832
+ kernel32.CloseHandle(wintypes.HANDLE(handle))
833
+
834
+ if answer is None and not timed_out:
835
+ raise OffscreenMountError(
836
+ f"the elevated helper exited with code {exit_code} before reporting a result",
837
+ code="helper-exited-early",
838
+ hint="Run `VirtualDesktop install-driver --dry-run` and then the printed helper "
839
+ "command from an elevated terminal to see its output.",
840
+ details={"exit_code": exit_code, "shell_result": shell_code},
841
+ )
842
+ return ElevationResult(
843
+ launched=True,
844
+ response=answer,
845
+ shell_result=shell_code or 32,
846
+ timed_out=timed_out,
847
+ )
848
+
849
+
850
+ def _elevation_launcher(argv: Sequence[str], env: Dict[str, str]) -> tuple:
851
+ """Return ``(lpFile, lpParameters)`` for ``ShellExecuteExW``.
852
+
853
+ The helper is started **directly** (no ``cmd.exe`` wrapper). An earlier revision injected
854
+ the sanitised environment through ``cmd /c set "K=V" && ...``; that died with exit code 1
855
+ and produced no log, because the assembled command line ran into the ``cmd.exe`` length
856
+ limit. Direct invocation works because:
857
+
858
+ * the helper is addressed by absolute path, so its own directory lands on ``sys.path``;
859
+ * the elevated child inherits a normal user environment from the shell (measured: ``TEMP``,
860
+ ``LOCALAPPDATA`` and ``SystemRoot`` are all present), so nothing has to be injected;
861
+ * every value the helper needs from us (cache dir, strategy, signature policy, log dir) is
862
+ already an explicit command-line argument.
863
+
864
+ ``env`` is therefore only used by the caller that builds it (for diagnostics); it is not
865
+ placed on the command line.
866
+ """
867
+ inner = subprocess.list2cmdline([str(a) for a in argv])
868
+ if len(inner) > 8000: # cmd/ShellExecute safety margin
869
+ raise ConfigError(
870
+ "the helper command line is too long to launch reliably",
871
+ code="helper-command-too-long",
872
+ details={"length": len(inner)},
873
+ )
874
+ return str(argv[0]), subprocess.list2cmdline([str(a) for a in argv[1:]])
875
+
876
+
877
+ def _await_helper(pipe: "ResponsePipe", handle: int, timeout: float):
878
+ """Wait for the pipe message, aborting early if the helper process dies."""
879
+ kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
880
+ kernel32.WaitForSingleObject.restype = wintypes.DWORD
881
+ kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
882
+ kernel32.GetExitCodeProcess.restype = wintypes.BOOL
883
+
884
+ deadline = time.monotonic() + timeout
885
+ while time.monotonic() < deadline:
886
+ answer = pipe.wait_for_message(timeout=1.0, poll=0.05)
887
+ if answer is not None:
888
+ return answer, False, None
889
+ code = wintypes.DWORD()
890
+ if kernel32.GetExitCodeProcess(wintypes.HANDLE(handle), ctypes.byref(code)):
891
+ if code.value != _STILL_ACTIVE:
892
+ # Give the pipe one last chance: the child may have written and exited.
893
+ return pipe.wait_for_message(timeout=2.0, poll=0.05), False, int(code.value)
894
+ time.sleep(0.1)
895
+ return None, True, None
896
+
897
+
898
+ def default_log_dir(cache_dir: Optional[os.PathLike] = None) -> Path:
899
+ from ._download import default_cache_dir
900
+
901
+ root = Path(cache_dir) if cache_dir else default_cache_dir()
902
+ return secure_mkdir(root / "logs")
903
+
904
+
905
+ def tail_file(path: os.PathLike, *, lines: int = 40) -> str:
906
+ """Last ``lines`` lines of a file, for error messages."""
907
+ try:
908
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
909
+ content = handle.readlines()
910
+ except OSError:
911
+ return ""
912
+ return "".join(content[-lines:])