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.
- VirtualDesktop/__init__.py +121 -0
- VirtualDesktop/__main__.py +10 -0
- VirtualDesktop/_bundle.py +369 -0
- VirtualDesktop/_compat.py +576 -0
- VirtualDesktop/_devices.py +335 -0
- VirtualDesktop/_download.py +462 -0
- VirtualDesktop/_helper.py +31 -0
- VirtualDesktop/_log.py +85 -0
- VirtualDesktop/_process.py +912 -0
- VirtualDesktop/_sigcheck.ps1 +32 -0
- VirtualDesktop/_sigcheck.py +321 -0
- VirtualDesktop/_winapi.py +714 -0
- VirtualDesktop/capture.py +671 -0
- VirtualDesktop/cli.py +673 -0
- VirtualDesktop/config.py +255 -0
- VirtualDesktop/desktop.py +231 -0
- VirtualDesktop/desktop_capture.py +159 -0
- VirtualDesktop/desktop_session.py +533 -0
- VirtualDesktop/display_setup.py +790 -0
- VirtualDesktop/driver.py +831 -0
- VirtualDesktop/driver_manifest.json +118 -0
- VirtualDesktop/errors.py +211 -0
- VirtualDesktop/manifest.py +310 -0
- VirtualDesktop/session.py +514 -0
- VirtualDesktop/window.py +869 -0
- virtualdesktop-1.0.1.dist-info/METADATA +307 -0
- virtualdesktop-1.0.1.dist-info/RECORD +31 -0
- virtualdesktop-1.0.1.dist-info/WHEEL +5 -0
- virtualdesktop-1.0.1.dist-info/entry_points.txt +2 -0
- virtualdesktop-1.0.1.dist-info/licenses/LICENSE +21 -0
- virtualdesktop-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""Download, cache, verify and safely extract release artifacts.
|
|
2
|
+
|
|
3
|
+
Security posture (all of it is enforced, not optional):
|
|
4
|
+
|
|
5
|
+
* **HTTPS only** – the manifest rejects anything else and redirects are re-checked.
|
|
6
|
+
* **Digest pinning** – a file is only accepted when its SHA-256 matches the manifest.
|
|
7
|
+
Anything else is moved to ``<cache>/quarantine`` and never executed.
|
|
8
|
+
* **Size ceilings** – a declared size is enforced (±1 %), and an undeclared size is capped
|
|
9
|
+
at ``max_bytes`` so a hostile endpoint cannot exhaust the disk.
|
|
10
|
+
* **Atomic publish** – downloads land in ``<cache>/.tmp`` and are moved into place with
|
|
11
|
+
``os.replace`` only after verification, so a crash never leaves a half-written artifact.
|
|
12
|
+
* **Locking** – a per-artifact lock file serialises concurrent processes (multi-process
|
|
13
|
+
safety matters because the first ``mount()`` of two processes can race).
|
|
14
|
+
* **Safe extraction** – zip members are validated against traversal, absolute paths,
|
|
15
|
+
symlinks, member-count, and expansion-ratio limits before anything is written.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
import ssl
|
|
24
|
+
import stat
|
|
25
|
+
import tempfile
|
|
26
|
+
import time
|
|
27
|
+
import urllib.error
|
|
28
|
+
import urllib.request
|
|
29
|
+
import uuid
|
|
30
|
+
import zipfile
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Callable, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
34
|
+
|
|
35
|
+
from ._log import get_logger
|
|
36
|
+
from .errors import DownloadError, ExtractionError, IntegrityError
|
|
37
|
+
from .manifest import Artifact
|
|
38
|
+
|
|
39
|
+
log = get_logger("download")
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"default_cache_dir",
|
|
43
|
+
"verified_path",
|
|
44
|
+
"cache_status",
|
|
45
|
+
"download_artifact",
|
|
46
|
+
"extract_archive",
|
|
47
|
+
"file_sha256",
|
|
48
|
+
"sha256_of",
|
|
49
|
+
"FileLock",
|
|
50
|
+
"VerifyResult",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
_CHUNK = 256 * 1024
|
|
54
|
+
#: A zip may legitimately expand, but not without bound (zip-bomb guard).
|
|
55
|
+
_MAX_EXPANSION_RATIO = 40
|
|
56
|
+
_MAX_MEMBERS = 512
|
|
57
|
+
_USER_AGENT = "VirtualDesktop/0.1 (+https://github.com/example/VirtualDesktop)"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def default_cache_dir() -> Path:
|
|
61
|
+
"""Per-user cache root, overridable with ``VIRTUALDESKTOP_CACHE_DIR``."""
|
|
62
|
+
override = os.environ.get("VIRTUALDESKTOP_CACHE_DIR")
|
|
63
|
+
if override:
|
|
64
|
+
return Path(override).expanduser()
|
|
65
|
+
base = os.environ.get("LOCALAPPDATA") or os.environ.get("XDG_CACHE_HOME")
|
|
66
|
+
if base:
|
|
67
|
+
return Path(base) / "VirtualDesktop" / "cache"
|
|
68
|
+
return Path(tempfile.gettempdir()) / "VirtualDesktop-cache" # pragma: no cover
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def file_sha256(path: os.PathLike, *, chunk_size: int = 1024 * 1024) -> str:
|
|
72
|
+
digest = hashlib.sha256()
|
|
73
|
+
with open(path, "rb") as handle:
|
|
74
|
+
for block in iter(lambda: handle.read(chunk_size), b""):
|
|
75
|
+
digest.update(block)
|
|
76
|
+
return digest.hexdigest()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
#: Backwards-compatible alias.
|
|
80
|
+
sha256_of = file_sha256
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class VerifyResult:
|
|
85
|
+
"""Outcome of checking one cached artifact against its pinned digest."""
|
|
86
|
+
|
|
87
|
+
path: Path
|
|
88
|
+
expected: str
|
|
89
|
+
actual: str = ""
|
|
90
|
+
size: int = 0
|
|
91
|
+
ok: bool = False
|
|
92
|
+
reason: str = ""
|
|
93
|
+
|
|
94
|
+
def to_dict(self) -> dict:
|
|
95
|
+
return {
|
|
96
|
+
"path": str(self.path),
|
|
97
|
+
"expected": self.expected,
|
|
98
|
+
"actual": self.actual,
|
|
99
|
+
"size": self.size,
|
|
100
|
+
"ok": self.ok,
|
|
101
|
+
"reason": self.reason,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def verified_path(artifact: Artifact, cache_dir: Optional[os.PathLike] = None) -> Optional[Path]:
|
|
106
|
+
"""Return the cached file path when it exists *and* matches the pinned digest."""
|
|
107
|
+
root = Path(cache_dir) if cache_dir else default_cache_dir()
|
|
108
|
+
candidate = root / artifact.filename
|
|
109
|
+
if not candidate.is_file():
|
|
110
|
+
return None
|
|
111
|
+
if artifact.size is not None and candidate.stat().st_size != artifact.size:
|
|
112
|
+
log.debug("cache size mismatch for %s", candidate)
|
|
113
|
+
return None
|
|
114
|
+
if file_sha256(candidate) != artifact.sha256:
|
|
115
|
+
log.warning(
|
|
116
|
+
"cached artifact %s does not match the pinned digest; it will be re-downloaded",
|
|
117
|
+
candidate,
|
|
118
|
+
)
|
|
119
|
+
return None
|
|
120
|
+
return candidate
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def cache_status(artifact: Artifact, cache_dir: Optional[os.PathLike] = None) -> VerifyResult:
|
|
124
|
+
"""Detailed verification report, used by ``VirtualDesktop check``."""
|
|
125
|
+
root = Path(cache_dir) if cache_dir else default_cache_dir()
|
|
126
|
+
path = root / artifact.filename
|
|
127
|
+
result = VerifyResult(path=path, expected=artifact.sha256)
|
|
128
|
+
if not path.is_file():
|
|
129
|
+
result.reason = "not-cached"
|
|
130
|
+
return result
|
|
131
|
+
result.size = path.stat().st_size
|
|
132
|
+
result.actual = file_sha256(path)
|
|
133
|
+
if result.actual != artifact.sha256:
|
|
134
|
+
result.reason = "digest-mismatch"
|
|
135
|
+
return result
|
|
136
|
+
if artifact.size is not None and result.size != artifact.size:
|
|
137
|
+
result.reason = "size-mismatch"
|
|
138
|
+
return result
|
|
139
|
+
result.ok = True
|
|
140
|
+
result.reason = "ok"
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class FileLock:
|
|
145
|
+
"""Cross-process advisory lock built on ``O_CREAT | O_EXCL``.
|
|
146
|
+
|
|
147
|
+
Windows has no portable ``flock``; creating a lock file exclusively is atomic on NTFS
|
|
148
|
+
and behaves correctly across processes. Stale locks (crashed process) are reclaimed
|
|
149
|
+
after ``stale_after`` seconds.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
def __init__(self, path: os.PathLike, *, timeout: float = 180.0, stale_after: float = 900.0):
|
|
153
|
+
self.path = Path(path)
|
|
154
|
+
self.timeout = timeout
|
|
155
|
+
self.stale_after = stale_after
|
|
156
|
+
self._fd: Optional[int] = None
|
|
157
|
+
|
|
158
|
+
def __enter__(self) -> "FileLock":
|
|
159
|
+
deadline = time.monotonic() + self.timeout
|
|
160
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
delay = 0.05
|
|
162
|
+
while True:
|
|
163
|
+
try:
|
|
164
|
+
self._fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
165
|
+
os.write(self._fd, f"{os.getpid()} {time.time():.3f}".encode())
|
|
166
|
+
return self
|
|
167
|
+
except FileExistsError:
|
|
168
|
+
try:
|
|
169
|
+
age = time.time() - self.path.stat().st_mtime
|
|
170
|
+
except OSError:
|
|
171
|
+
age = 0
|
|
172
|
+
if age > self.stale_after:
|
|
173
|
+
log.warning("reclaiming stale download lock %s (age %.0fs)", self.path, age)
|
|
174
|
+
try:
|
|
175
|
+
self.path.unlink()
|
|
176
|
+
except OSError:
|
|
177
|
+
pass
|
|
178
|
+
continue
|
|
179
|
+
if time.monotonic() > deadline:
|
|
180
|
+
raise DownloadError(
|
|
181
|
+
f"timed out waiting for the artifact lock {self.path}",
|
|
182
|
+
code="download-lock-timeout",
|
|
183
|
+
hint="Another process is downloading the driver. Remove the .lock file if it is stale.",
|
|
184
|
+
)
|
|
185
|
+
time.sleep(delay)
|
|
186
|
+
delay = min(delay * 1.5, 0.5)
|
|
187
|
+
except OSError as exc: # pragma: no cover - filesystem specific
|
|
188
|
+
raise DownloadError(f"cannot create lock {self.path}: {exc}") from exc
|
|
189
|
+
|
|
190
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
191
|
+
if self._fd is not None:
|
|
192
|
+
try:
|
|
193
|
+
os.close(self._fd)
|
|
194
|
+
finally:
|
|
195
|
+
self._fd = None
|
|
196
|
+
try:
|
|
197
|
+
self.path.unlink()
|
|
198
|
+
except OSError: # pragma: no cover
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _quarantine(path: Path, *, reason: str, dest_dir: Optional[Path] = None) -> Optional[Path]:
|
|
203
|
+
"""Move a bad artifact out of the way so it can never be executed or reused.
|
|
204
|
+
|
|
205
|
+
Quarantining (rather than deleting) keeps the evidence for the operator, which matters
|
|
206
|
+
when the cause is a hostile mirror rather than a flaky connection.
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
target_dir = dest_dir if dest_dir is not None else path.parent / "quarantine"
|
|
210
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
target = target_dir / f"{path.name}.{int(time.time())}.{uuid.uuid4().hex[:8]}.bad"
|
|
212
|
+
os.replace(path, target)
|
|
213
|
+
log.error("quarantined %s (%s) as %s", path, reason, target)
|
|
214
|
+
return target
|
|
215
|
+
except OSError as exc: # pragma: no cover - defensive
|
|
216
|
+
log.error("could not quarantine %s: %s", path, exc)
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _make_ssl_context() -> ssl.SSLContext:
|
|
221
|
+
context = ssl.create_default_context()
|
|
222
|
+
context.check_hostname = True
|
|
223
|
+
context.verify_mode = ssl.CERT_REQUIRED
|
|
224
|
+
return context
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def download_artifact(
|
|
228
|
+
artifact: Artifact,
|
|
229
|
+
*,
|
|
230
|
+
cache_dir: Optional[os.PathLike] = None,
|
|
231
|
+
timeout: float = 120.0,
|
|
232
|
+
progress: Optional[Callable[[int, Optional[int]], None]] = None,
|
|
233
|
+
force: bool = False,
|
|
234
|
+
max_retries: int = 3,
|
|
235
|
+
mirror_extra: Sequence[str] = (),
|
|
236
|
+
) -> Path:
|
|
237
|
+
"""Return a verified local copy of ``artifact``, downloading it when needed.
|
|
238
|
+
|
|
239
|
+
``mirror_extra`` adds URL prefixes for this call only (the ``VIRTUALDESKTOP_GITHUB_MIRRORS``
|
|
240
|
+
environment variable is folded in by the callers). The artifact's own ``mirrors`` from the
|
|
241
|
+
manifest are always tried first after the primary URL.
|
|
242
|
+
"""
|
|
243
|
+
cache = Path(cache_dir) if cache_dir else default_cache_dir()
|
|
244
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
245
|
+
target = cache / artifact.filename
|
|
246
|
+
|
|
247
|
+
if not force:
|
|
248
|
+
existing = verified_path(artifact, cache)
|
|
249
|
+
if existing is not None:
|
|
250
|
+
log.debug("using cached artifact %s", existing)
|
|
251
|
+
return existing
|
|
252
|
+
if target.exists():
|
|
253
|
+
_quarantine(target, reason="digest mismatch", dest_dir=cache / "quarantine")
|
|
254
|
+
|
|
255
|
+
with FileLock(cache / f"{artifact.filename}.lock"):
|
|
256
|
+
# Another process may have completed the download while we waited.
|
|
257
|
+
if not force:
|
|
258
|
+
existing = verified_path(artifact, cache)
|
|
259
|
+
if existing is not None:
|
|
260
|
+
return existing
|
|
261
|
+
|
|
262
|
+
tmp_dir = cache / ".tmp"
|
|
263
|
+
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
264
|
+
tmp_path = tmp_dir / f"{artifact.filename}.{os.getpid()}.{uuid.uuid4().hex[:8]}.part"
|
|
265
|
+
limit = artifact.size or 512 * 1024 * 1024
|
|
266
|
+
if artifact.size:
|
|
267
|
+
# Allow a tiny slack for servers that re-encode, but stay tight.
|
|
268
|
+
limit = int(artifact.size * 1.01) + 1024
|
|
269
|
+
|
|
270
|
+
# Every candidate URL serves the *same bytes*; the digest below is what decides. The
|
|
271
|
+
# mirrors exist because github.com is unreachable or unusably slow on many networks in
|
|
272
|
+
# mainland China, and they are tried in order after the primary URL fails.
|
|
273
|
+
urls = artifact.candidate_urls(mirror_extra)
|
|
274
|
+
last_error: Optional[Exception] = None
|
|
275
|
+
for url in urls:
|
|
276
|
+
for attempt in range(1, max_retries + 1):
|
|
277
|
+
try:
|
|
278
|
+
_fetch(artifact, tmp_path, url=url, timeout=timeout, limit=limit, progress=progress)
|
|
279
|
+
break
|
|
280
|
+
except (urllib.error.URLError, TimeoutError, OSError, DownloadError) as exc:
|
|
281
|
+
last_error = exc
|
|
282
|
+
log.warning(
|
|
283
|
+
"download attempt %d/%d from %s failed: %s", attempt, max_retries, url, exc
|
|
284
|
+
)
|
|
285
|
+
if attempt == max_retries:
|
|
286
|
+
break
|
|
287
|
+
time.sleep(min(2 ** attempt, 8))
|
|
288
|
+
else: # pragma: no cover - the inner loop always breaks or exhausts
|
|
289
|
+
continue
|
|
290
|
+
if tmp_path.exists() and tmp_path.stat().st_size:
|
|
291
|
+
break
|
|
292
|
+
else:
|
|
293
|
+
raise DownloadError(
|
|
294
|
+
f"failed to download {artifact.filename} from {len(urls)} URL(s): {last_error}",
|
|
295
|
+
code="download-failed",
|
|
296
|
+
details={"urls": urls, "filename": artifact.filename},
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
digest = file_sha256(tmp_path)
|
|
300
|
+
if digest != artifact.sha256:
|
|
301
|
+
size = tmp_path.stat().st_size
|
|
302
|
+
_quarantine(tmp_path, reason="sha256 mismatch", dest_dir=cache / "quarantine")
|
|
303
|
+
raise IntegrityError(
|
|
304
|
+
f"SHA-256 mismatch for {artifact.filename}: expected {artifact.sha256}, got {digest}",
|
|
305
|
+
code="digest-mismatch",
|
|
306
|
+
details={
|
|
307
|
+
"url": artifact.url,
|
|
308
|
+
"expected": artifact.sha256,
|
|
309
|
+
"actual": digest,
|
|
310
|
+
"bytes": size,
|
|
311
|
+
},
|
|
312
|
+
hint="The download was corrupted or the mirror serves different content; "
|
|
313
|
+
"update the manifest if the upstream release legitimately changed.",
|
|
314
|
+
)
|
|
315
|
+
if artifact.size is not None and tmp_path.stat().st_size != artifact.size:
|
|
316
|
+
actual_size = tmp_path.stat().st_size
|
|
317
|
+
_quarantine(tmp_path, reason="size mismatch", dest_dir=cache / "quarantine")
|
|
318
|
+
raise IntegrityError(
|
|
319
|
+
f"size mismatch for {artifact.filename}: expected {artifact.size}, got {actual_size}",
|
|
320
|
+
code="size-mismatch",
|
|
321
|
+
)
|
|
322
|
+
os.replace(tmp_path, target)
|
|
323
|
+
log.info(
|
|
324
|
+
"verified %s (%d bytes, sha256 %s)", target.name, target.stat().st_size, digest[:12]
|
|
325
|
+
)
|
|
326
|
+
return target
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _fetch(
|
|
330
|
+
artifact: Artifact,
|
|
331
|
+
tmp_path: Path,
|
|
332
|
+
*,
|
|
333
|
+
url: Optional[str] = None,
|
|
334
|
+
timeout: float,
|
|
335
|
+
limit: int,
|
|
336
|
+
progress: Optional[Callable[[int, Optional[int]], None]],
|
|
337
|
+
) -> None:
|
|
338
|
+
"""Download ``url`` (default: the artifact's primary URL) into ``tmp_path``."""
|
|
339
|
+
source = url or artifact.url
|
|
340
|
+
request = urllib.request.Request(
|
|
341
|
+
source,
|
|
342
|
+
headers={"User-Agent": _USER_AGENT, "Accept": "application/octet-stream"},
|
|
343
|
+
)
|
|
344
|
+
handlers: List[urllib.request.BaseHandler] = [urllib.request.HTTPSHandler(context=_make_ssl_context())]
|
|
345
|
+
opener = urllib.request.build_opener(*handlers)
|
|
346
|
+
written = 0
|
|
347
|
+
with opener.open(request, timeout=timeout) as response:
|
|
348
|
+
final_url = response.geturl()
|
|
349
|
+
if not str(final_url).lower().startswith("https://"):
|
|
350
|
+
raise DownloadError(
|
|
351
|
+
f"refusing to download over an insecure channel: {final_url}",
|
|
352
|
+
code="insecure-redirect",
|
|
353
|
+
)
|
|
354
|
+
declared = response.headers.get("Content-Length")
|
|
355
|
+
total = int(declared) if declared and declared.isdigit() else artifact.size
|
|
356
|
+
if total and total > limit:
|
|
357
|
+
raise DownloadError(
|
|
358
|
+
f"remote file is {total} bytes which exceeds the accepted limit of {limit}",
|
|
359
|
+
code="size-exceeded",
|
|
360
|
+
)
|
|
361
|
+
with open(tmp_path, "wb") as handle:
|
|
362
|
+
while True:
|
|
363
|
+
chunk = response.read(_CHUNK)
|
|
364
|
+
if not chunk:
|
|
365
|
+
break
|
|
366
|
+
written += len(chunk)
|
|
367
|
+
if written > limit:
|
|
368
|
+
raise DownloadError(
|
|
369
|
+
f"download exceeded the accepted limit of {limit} bytes",
|
|
370
|
+
code="size-exceeded",
|
|
371
|
+
)
|
|
372
|
+
handle.write(chunk)
|
|
373
|
+
if progress is not None:
|
|
374
|
+
progress(written, total)
|
|
375
|
+
handle.flush()
|
|
376
|
+
os.fsync(handle.fileno())
|
|
377
|
+
if progress is not None:
|
|
378
|
+
progress(written, written)
|
|
379
|
+
log.debug("fetched %s -> %s (%d bytes)", source, tmp_path, written)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def extract_archive(
|
|
383
|
+
archive_path: os.PathLike,
|
|
384
|
+
dest_dir: os.PathLike,
|
|
385
|
+
*,
|
|
386
|
+
strip_components: int = 0,
|
|
387
|
+
members: Sequence[str] = (),
|
|
388
|
+
overwrite: bool = True,
|
|
389
|
+
) -> Path:
|
|
390
|
+
"""Safely extract a zip archive and return the destination directory.
|
|
391
|
+
|
|
392
|
+
Raises :class:`ExtractionError` on any suspicious member instead of skipping it — a
|
|
393
|
+
silently skipped member would surface later as a confusing "INF not found".
|
|
394
|
+
"""
|
|
395
|
+
archive_path = Path(archive_path)
|
|
396
|
+
dest = Path(dest_dir)
|
|
397
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
398
|
+
try:
|
|
399
|
+
zf = zipfile.ZipFile(archive_path)
|
|
400
|
+
except zipfile.BadZipFile as exc:
|
|
401
|
+
raise ExtractionError(
|
|
402
|
+
f"{archive_path} is not a valid zip archive: {exc}", code="bad-archive"
|
|
403
|
+
) from exc
|
|
404
|
+
|
|
405
|
+
with zf:
|
|
406
|
+
infos = zf.infolist()
|
|
407
|
+
if len(infos) > _MAX_MEMBERS:
|
|
408
|
+
raise ExtractionError(
|
|
409
|
+
f"archive contains {len(infos)} members (limit {_MAX_MEMBERS})",
|
|
410
|
+
code="archive-too-many-members",
|
|
411
|
+
)
|
|
412
|
+
total_uncompressed = sum(i.file_size for i in infos)
|
|
413
|
+
archive_size = max(archive_path.stat().st_size, 1)
|
|
414
|
+
if total_uncompressed > archive_size * _MAX_EXPANSION_RATIO:
|
|
415
|
+
raise ExtractionError(
|
|
416
|
+
f"archive expands to {total_uncompressed} bytes ({_MAX_EXPANSION_RATIO}x limit); refusing",
|
|
417
|
+
code="archive-bomb",
|
|
418
|
+
)
|
|
419
|
+
root = dest.resolve()
|
|
420
|
+
extracted: List[str] = []
|
|
421
|
+
for info in infos:
|
|
422
|
+
name = info.filename.replace("\\", "/")
|
|
423
|
+
if not name or name.endswith("/"):
|
|
424
|
+
continue
|
|
425
|
+
mode = (info.external_attr >> 16) & 0xFFFF
|
|
426
|
+
if mode and stat.S_ISLNK(mode):
|
|
427
|
+
raise ExtractionError(
|
|
428
|
+
f"archive member {info.filename!r} is a symlink; refusing",
|
|
429
|
+
code="archive-symlink",
|
|
430
|
+
)
|
|
431
|
+
parts = [p for p in name.split("/") if p not in ("", ".")]
|
|
432
|
+
if any(p == ".." for p in parts):
|
|
433
|
+
raise ExtractionError(
|
|
434
|
+
f"archive member {info.filename!r} escapes the extraction root",
|
|
435
|
+
code="archive-traversal",
|
|
436
|
+
)
|
|
437
|
+
if len(parts) <= strip_components:
|
|
438
|
+
continue
|
|
439
|
+
relative = Path(*parts[strip_components:])
|
|
440
|
+
target = (root / relative).resolve()
|
|
441
|
+
if root != target and root not in target.parents:
|
|
442
|
+
raise ExtractionError(
|
|
443
|
+
f"archive member {info.filename!r} resolves outside {root}",
|
|
444
|
+
code="archive-traversal",
|
|
445
|
+
)
|
|
446
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
447
|
+
if target.exists() and not overwrite:
|
|
448
|
+
extracted.append(str(relative).replace("\\", "/"))
|
|
449
|
+
continue
|
|
450
|
+
with zf.open(info) as src, open(target, "wb") as dst:
|
|
451
|
+
shutil.copyfileobj(src, dst, _CHUNK)
|
|
452
|
+
extracted.append(str(relative).replace("\\", "/"))
|
|
453
|
+
|
|
454
|
+
missing = [m for m in members if m not in set(extracted)]
|
|
455
|
+
if missing:
|
|
456
|
+
raise ExtractionError(
|
|
457
|
+
f"archive {archive_path.name} is missing expected member(s): {', '.join(missing)}",
|
|
458
|
+
code="archive-missing-members",
|
|
459
|
+
details={"found": extracted[:50]},
|
|
460
|
+
)
|
|
461
|
+
log.debug("extracted %s -> %s (%d files)", archive_path.name, dest, len(extracted))
|
|
462
|
+
return dest
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Absolute-path entry point for the elevated helper.
|
|
2
|
+
|
|
3
|
+
Run as a *script* (``python <this file> _elevated-install ...``) rather than with ``-m``,
|
|
4
|
+
because the elevated child's ``sys.path`` contains only its own working directory, which is
|
|
5
|
+
not necessarily the project directory.
|
|
6
|
+
|
|
7
|
+
Bootstrapping note: this file lives *inside* the package, so ``python script.py`` puts
|
|
8
|
+
``<...>/VirtualDesktop`` on ``sys.path`` — enough to import ``errors``/``cli`` as siblings,
|
|
9
|
+
but **not** enough for ``import VirtualDesktop.cli``, which needs the *parent* directory.
|
|
10
|
+
Both are added below, so it works for a source checkout and for an installed wheel, with no
|
|
11
|
+
reliance on the current working directory or on ``PYTHONPATH``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
_PACKAGE_DIR = Path(__file__).resolve().parent
|
|
20
|
+
_PARENT_DIR = _PACKAGE_DIR.parent
|
|
21
|
+
for _candidate in (str(_PARENT_DIR), str(_PACKAGE_DIR)):
|
|
22
|
+
if _candidate not in sys.path:
|
|
23
|
+
sys.path.insert(0, _candidate)
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
from VirtualDesktop.cli import main
|
|
27
|
+
except ModuleNotFoundError: # pragma: no cover - fallback for a renamed checkout
|
|
28
|
+
from cli import main # type: ignore[no-redef]
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
sys.exit(main())
|
VirtualDesktop/_log.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Logging helpers.
|
|
2
|
+
|
|
3
|
+
The library never configures the root logger and never attaches handlers unless the
|
|
4
|
+
application explicitly asks for it (``enable_debug_logging()`` or the
|
|
5
|
+
``VIRTUALDESKTOP_LOG_FILE`` environment variable), so importing it stays side-effect free.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from logging.handlers import RotatingFileHandler
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
__all__ = ["get_logger", "enable_debug_logging", "configure_from_env", "LOG_NAMESPACE"]
|
|
17
|
+
|
|
18
|
+
LOG_NAMESPACE = "VirtualDesktop"
|
|
19
|
+
_CONFIGURED = False
|
|
20
|
+
|
|
21
|
+
_FORMAT = "%(asctime)s %(levelname)-7s [%(name)s] %(message)s"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_logger(name: str = "") -> logging.Logger:
|
|
25
|
+
"""Return a namespaced logger (``VirtualDesktop.<name>``)."""
|
|
26
|
+
if not name:
|
|
27
|
+
return logging.getLogger(LOG_NAMESPACE)
|
|
28
|
+
if name.startswith(LOG_NAMESPACE):
|
|
29
|
+
return logging.getLogger(name)
|
|
30
|
+
return logging.getLogger(f"{LOG_NAMESPACE}.{name}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _ensure_namespace_handler(handler: logging.Handler) -> logging.Logger:
|
|
34
|
+
logger = logging.getLogger(LOG_NAMESPACE)
|
|
35
|
+
logger.addHandler(handler)
|
|
36
|
+
# Do not duplicate records through the root logger; libraries must not own root.
|
|
37
|
+
logger.propagate = False
|
|
38
|
+
return logger
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def enable_debug_logging(
|
|
42
|
+
*,
|
|
43
|
+
level: int = logging.DEBUG,
|
|
44
|
+
stream=None,
|
|
45
|
+
log_file: Optional[str] = None,
|
|
46
|
+
max_bytes: int = 1_000_000,
|
|
47
|
+
backup_count: int = 2,
|
|
48
|
+
) -> logging.Logger:
|
|
49
|
+
"""Turn on library logging once; safe to call repeatedly."""
|
|
50
|
+
global _CONFIGURED
|
|
51
|
+
logger = logging.getLogger(LOG_NAMESPACE)
|
|
52
|
+
logger.setLevel(level)
|
|
53
|
+
if not _CONFIGURED:
|
|
54
|
+
formatter = logging.Formatter(_FORMAT)
|
|
55
|
+
if stream is None and not log_file:
|
|
56
|
+
stream = sys.stderr
|
|
57
|
+
if stream is not None:
|
|
58
|
+
handler = logging.StreamHandler(stream)
|
|
59
|
+
handler.setFormatter(formatter)
|
|
60
|
+
_ensure_namespace_handler(handler)
|
|
61
|
+
if log_file:
|
|
62
|
+
try:
|
|
63
|
+
directory = os.path.dirname(os.path.abspath(log_file))
|
|
64
|
+
if directory:
|
|
65
|
+
os.makedirs(directory, exist_ok=True)
|
|
66
|
+
file_handler = RotatingFileHandler(
|
|
67
|
+
log_file, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8"
|
|
68
|
+
)
|
|
69
|
+
except OSError: # pragma: no cover - depends on filesystem state
|
|
70
|
+
file_handler = None
|
|
71
|
+
if file_handler is not None:
|
|
72
|
+
file_handler.setFormatter(formatter)
|
|
73
|
+
_ensure_namespace_handler(file_handler)
|
|
74
|
+
_CONFIGURED = True
|
|
75
|
+
return logger
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def configure_from_env() -> None:
|
|
79
|
+
"""Honour ``VIRTUALDESKTOP_DEBUG`` / ``VIRTUALDESKTOP_LOG_FILE`` if set."""
|
|
80
|
+
debug = os.environ.get("VIRTUALDESKTOP_DEBUG", "").strip().lower()
|
|
81
|
+
log_file = os.environ.get("VIRTUALDESKTOP_LOG_FILE") or None
|
|
82
|
+
if debug in ("1", "true", "yes", "on"):
|
|
83
|
+
enable_debug_logging(level=logging.DEBUG, log_file=log_file)
|
|
84
|
+
elif log_file:
|
|
85
|
+
enable_debug_logging(level=logging.INFO, stream=None, log_file=log_file)
|