gu-7f3a2c 0.4.2__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,53 @@
1
+ """Signed, hash-verified updater for a self-hosted game or app."""
2
+
3
+ from .download import DownloadError
4
+ from .hello import (
5
+ HELLO_SIZE,
6
+ HELLO_SHA256,
7
+ HELLO_URL,
8
+ HelloError,
9
+ download_hello,
10
+ run_hello,
11
+ )
12
+ from .install import UpdateError, UpdateResult, update
13
+ from .manifest import Manifest, ManifestError
14
+ from .vmcheck import (
15
+ DMIData,
16
+ VMBlockedError,
17
+ VMDetection,
18
+ VMPolicy,
19
+ VMStatus,
20
+ VMVendor,
21
+ classify,
22
+ collect_dmi,
23
+ detect_vm,
24
+ ensure_vm_allowed,
25
+ )
26
+
27
+ __version__ = "0.4.2"
28
+
29
+ __all__ = [
30
+ "__version__",
31
+ "DMIData",
32
+ "DownloadError",
33
+ "HELLO_SIZE",
34
+ "HELLO_SHA256",
35
+ "HELLO_URL",
36
+ "HelloError",
37
+ "Manifest",
38
+ "ManifestError",
39
+ "UpdateError",
40
+ "UpdateResult",
41
+ "VMBlockedError",
42
+ "VMDetection",
43
+ "VMPolicy",
44
+ "VMStatus",
45
+ "VMVendor",
46
+ "classify",
47
+ "collect_dmi",
48
+ "detect_vm",
49
+ "download_hello",
50
+ "ensure_vm_allowed",
51
+ "run_hello",
52
+ "update",
53
+ ]
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m game_updater``."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
game_updater/cli.py ADDED
@@ -0,0 +1,175 @@
1
+ """Command line interface for game-updater."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from .download import DownloadError
10
+ from .hello import HelloError, run_hello
11
+ from .install import UpdateError, read_local_version, update
12
+ from .manifest import ManifestError
13
+ from .vmcheck import VMBlockedError, VMPolicy, ensure_vm_allowed
14
+
15
+
16
+ def _build_parser() -> argparse.ArgumentParser:
17
+ parser = argparse.ArgumentParser(
18
+ prog="game-updater",
19
+ description="Signed, hash-verified updater for a self-hosted game or app.",
20
+ )
21
+ parser.add_argument(
22
+ "--manifest",
23
+ required=False,
24
+ help="HTTPS URL of the signed manifest JSON (required unless --hello is used)",
25
+ )
26
+ parser.add_argument(
27
+ "--dir",
28
+ required=False,
29
+ help=(
30
+ "game directory to update (optional for --hello; defaults to the "
31
+ "system temp folder)"
32
+ ),
33
+ )
34
+ mode = parser.add_mutually_exclusive_group()
35
+ mode.add_argument(
36
+ "--check",
37
+ action="store_true",
38
+ help="only report local and remote versions (default)",
39
+ )
40
+ mode.add_argument(
41
+ "--apply",
42
+ action="store_true",
43
+ help="download, verify, and install the update",
44
+ )
45
+ mode.add_argument(
46
+ "--hello",
47
+ action="store_true",
48
+ help=(
49
+ "download and run the pinned hello.bat demo (defaults to the "
50
+ "system temp folder; Windows)"
51
+ ),
52
+ )
53
+ parser.add_argument(
54
+ "--yes",
55
+ action="store_true",
56
+ help="do not prompt before applying",
57
+ )
58
+ parser.add_argument(
59
+ "--run",
60
+ action="store_true",
61
+ help="after a successful apply, run the manifest's signed run entry",
62
+ )
63
+ parser.add_argument(
64
+ "--public-key",
65
+ default=None,
66
+ help="path to the base64 Ed25519 public key (defaults to the bundled key)",
67
+ )
68
+ parser.add_argument(
69
+ "--timeout",
70
+ type=float,
71
+ default=30.0,
72
+ help="network timeout in seconds (default: 30)",
73
+ )
74
+ parser.add_argument(
75
+ "--allow-insecure",
76
+ action="store_true",
77
+ help="allow http://localhost and file:// URLs (testing only)",
78
+ )
79
+ parser.add_argument(
80
+ "--vm-policy",
81
+ choices=[policy.value for policy in VMPolicy],
82
+ default=VMPolicy.OFF.value,
83
+ help=(
84
+ "VM policy: 'off' skips detection (default); 'allow' detects "
85
+ "but never blocks; 'block' refuses all update work when a VM "
86
+ "is detected"
87
+ ),
88
+ )
89
+ return parser
90
+
91
+
92
+ def _confirm(manifest) -> bool: # noqa: ANN001 - small CLI helper
93
+ print()
94
+ print(f"Update available: {manifest.version}")
95
+ if manifest.notes:
96
+ print(f"Notes: {manifest.notes}")
97
+ print("Files:")
98
+ for entry in manifest.files:
99
+ print(f" {entry.path} ({entry.size} bytes)")
100
+ if manifest.run:
101
+ print(f"After install, will run: {manifest.run}")
102
+ answer = input("Apply this update? [y/N] ").strip().lower()
103
+ return answer in ("y", "yes")
104
+
105
+
106
+ def main(argv: list[str] | None = None) -> int:
107
+ parser = _build_parser()
108
+ args = parser.parse_args(argv)
109
+
110
+ if args.hello:
111
+ try:
112
+ path = run_hello(args.dir, timeout=args.timeout)
113
+ except (HelloError, OSError) as exc:
114
+ print(f"error: {exc}", file=sys.stderr)
115
+ return 2
116
+ print(f"downloaded: {path}")
117
+ print("ran: hello.bat")
118
+ return 0
119
+
120
+ if not args.dir:
121
+ parser.error("--dir is required unless --hello is used")
122
+ if not args.manifest:
123
+ parser.error("--manifest is required unless --hello is used")
124
+
125
+ try:
126
+ detection = ensure_vm_allowed(args.vm_policy)
127
+ except VMBlockedError as exc:
128
+ print(f"error: {exc}", file=sys.stderr)
129
+ return 3
130
+
131
+ if detection is not None:
132
+ print(f"vm check: {detection.summary()}")
133
+
134
+ game_dir = Path(args.dir)
135
+ previous = read_local_version(game_dir)
136
+
137
+ try:
138
+ result = update(
139
+ manifest_url=args.manifest,
140
+ game_dir=game_dir,
141
+ apply=args.apply,
142
+ run_patch=args.run,
143
+ public_key_path=args.public_key,
144
+ timeout=args.timeout,
145
+ allow_insecure=args.allow_insecure,
146
+ confirm=None if args.yes or not args.apply else _confirm,
147
+ vm_policy=args.vm_policy,
148
+ vm_detection=detection,
149
+ )
150
+ except VMBlockedError as exc:
151
+ print(f"error: {exc}", file=sys.stderr)
152
+ return 3
153
+ except (UpdateError, ManifestError, DownloadError, OSError) as exc:
154
+ print(f"error: {exc}", file=sys.stderr)
155
+ return 2
156
+
157
+ print(f"local version: {result.previous_version or '(none)'}")
158
+ print(f"remote version: {result.version}")
159
+ print(f"files: {len(result.files)}")
160
+
161
+ if not result.applied:
162
+ if result.previous_version == result.version:
163
+ print("already up to date")
164
+ else:
165
+ print("not applied (run with --apply to install)")
166
+ return 0
167
+
168
+ print(f"applied: {result.previous_version or '(none)'} -> {result.version}")
169
+ if result.ran:
170
+ print(f"ran: {result.ran}")
171
+ return 0
172
+
173
+
174
+ if __name__ == "__main__":
175
+ raise SystemExit(main())
@@ -0,0 +1,142 @@
1
+ """HTTP download with size and SHA-256 verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import tempfile
8
+ import urllib.parse
9
+ import urllib.request
10
+ from pathlib import Path
11
+
12
+ _USER_AGENT = "gu-7f3a2c/0.4.2"
13
+
14
+
15
+ class DownloadError(Exception):
16
+ """Raised when a download fails or does not match the manifest."""
17
+
18
+
19
+ def check_url(url: str, *, allow_insecure: bool = False) -> None:
20
+ parsed = urllib.parse.urlparse(url)
21
+ if parsed.scheme == "https":
22
+ return
23
+ if allow_insecure and parsed.scheme in ("http", "file"):
24
+ if parsed.scheme == "http":
25
+ host = (parsed.hostname or "").lower()
26
+ if host not in ("localhost", "127.0.0.1", "::1"):
27
+ raise DownloadError(f"insecure HTTP is only allowed for localhost: {url}")
28
+ return
29
+ raise DownloadError(f"refusing non-HTTPS URL: {url}")
30
+
31
+
32
+ class _SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
33
+ def __init__(self, *, allow_insecure: bool = False) -> None:
34
+ super().__init__()
35
+ self.allow_insecure = allow_insecure
36
+
37
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001
38
+ check_url(newurl, allow_insecure=self.allow_insecure)
39
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
40
+
41
+
42
+ def _open_url(
43
+ url: str,
44
+ *,
45
+ timeout: float,
46
+ allow_insecure: bool,
47
+ ):
48
+ check_url(url, allow_insecure=allow_insecure)
49
+ opener = urllib.request.build_opener(
50
+ _SafeRedirectHandler(allow_insecure=allow_insecure)
51
+ )
52
+ request = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
53
+ return opener.open(request, timeout=timeout)
54
+
55
+
56
+ def download_verified(
57
+ url: str,
58
+ destination: Path,
59
+ *,
60
+ expected_sha256: str,
61
+ expected_size: int,
62
+ timeout: float = 30.0,
63
+ allow_insecure: bool = False,
64
+ chunk_size: int = 1024 * 1024,
65
+ ) -> None:
66
+ """Download ``url`` to ``destination`` and verify size + hash.
67
+
68
+ The file is written to a temporary path in the destination directory and
69
+ moved into place only after verification, so a partial download never
70
+ appears as a finished file.
71
+ """
72
+ check_url(url, allow_insecure=allow_insecure)
73
+
74
+ destination.parent.mkdir(parents=True, exist_ok=True)
75
+ hasher = hashlib.sha256()
76
+ written = 0
77
+
78
+ fd, temp_name = tempfile.mkstemp(
79
+ prefix=destination.name + ".",
80
+ suffix=".part",
81
+ dir=str(destination.parent),
82
+ )
83
+ temp_path = Path(temp_name)
84
+
85
+ try:
86
+ with os.fdopen(fd, "wb") as handle:
87
+ with _open_url(
88
+ url,
89
+ timeout=timeout,
90
+ allow_insecure=allow_insecure,
91
+ ) as response:
92
+ while True:
93
+ chunk = response.read(chunk_size)
94
+ if not chunk:
95
+ break
96
+ written += len(chunk)
97
+ if written > expected_size:
98
+ raise DownloadError(
99
+ f"{url}: response larger than expected ({written} > {expected_size})"
100
+ )
101
+ hasher.update(chunk)
102
+ handle.write(chunk)
103
+
104
+ if written != expected_size:
105
+ raise DownloadError(
106
+ f"{url}: size mismatch ({written} != {expected_size})"
107
+ )
108
+
109
+ actual = hasher.hexdigest()
110
+ if actual != expected_sha256.lower():
111
+ raise DownloadError(
112
+ f"{url}: sha256 mismatch ({actual} != {expected_sha256.lower()})"
113
+ )
114
+
115
+ os.replace(temp_path, destination)
116
+ except Exception:
117
+ try:
118
+ temp_path.unlink(missing_ok=True)
119
+ except OSError:
120
+ pass
121
+ raise
122
+
123
+
124
+ def fetch_bytes(
125
+ url: str,
126
+ *,
127
+ timeout: float = 30.0,
128
+ allow_insecure: bool = False,
129
+ max_bytes: int = 4 * 1024 * 1024,
130
+ ) -> bytes:
131
+ """Fetch a small resource (the manifest) with a hard size ceiling."""
132
+ with _open_url(
133
+ url,
134
+ timeout=timeout,
135
+ allow_insecure=allow_insecure,
136
+ ) as response:
137
+ data = response.read(max_bytes + 1)
138
+
139
+ if len(data) > max_bytes:
140
+ raise DownloadError(f"{url}: manifest exceeds {max_bytes} bytes")
141
+
142
+ return data
game_updater/hello.py ADDED
@@ -0,0 +1,120 @@
1
+ """Pinned, opt-in download of the hello.bat demo.
2
+
3
+ This module is intentionally separate from the signed-manifest update path. The
4
+ URL, size, and SHA-256 are pinned here, the file is re-hashed immediately before
5
+ execution, and nothing runs unless the caller asks for it.
6
+
7
+ The demo is Windows-only because it is a ``.bat`` file. It writes ``hello.txt``
8
+ next to itself and opens Notepad.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import os
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ from pathlib import Path
19
+
20
+ from .download import DownloadError, download_verified
21
+
22
+ HELLO_URL = "https://hello-phi-amber.vercel.app/hello.bat"
23
+ HELLO_SHA256 = "41e425416ddb186861598dffc847c2b7d585a9af7e94e130e2354362b61259da"
24
+ HELLO_SIZE = 120
25
+
26
+
27
+ def _default_work_dir() -> Path:
28
+ """Return the temp folder used when no work dir is given."""
29
+ return Path(tempfile.gettempdir()) / "game-updater-hello"
30
+
31
+
32
+ class HelloError(RuntimeError):
33
+ """Raised when the pinned hello.bat demo cannot be downloaded or run."""
34
+
35
+
36
+ def download_hello(
37
+ work_dir: str | os.PathLike[str] | None = None,
38
+ *,
39
+ timeout: float = 30.0,
40
+ url: str = HELLO_URL,
41
+ sha256: str = HELLO_SHA256,
42
+ size: int = HELLO_SIZE,
43
+ allow_insecure: bool = False,
44
+ ) -> Path:
45
+ """Download hello.bat into ``work_dir`` and verify its pinned hash.
46
+
47
+ If ``work_dir`` is None, the file is saved to
48
+ ``%TEMP%\\game-updater-hello\\hello.bat`` (the system temp folder).
49
+
50
+ Returns the path to the verified file. Raises :class:`HelloError` on a
51
+ download, size, or hash failure.
52
+ """
53
+
54
+ if work_dir is None:
55
+ work_dir = _default_work_dir()
56
+ destination = (Path(work_dir) / "hello.bat").resolve()
57
+ try:
58
+ download_verified(
59
+ url,
60
+ destination,
61
+ expected_sha256=sha256,
62
+ expected_size=size,
63
+ timeout=timeout,
64
+ allow_insecure=allow_insecure,
65
+ )
66
+ except DownloadError as exc:
67
+ raise HelloError(f"hello.bat download failed: {exc}") from exc
68
+
69
+ actual = hashlib.sha256(destination.read_bytes()).hexdigest()
70
+ if actual != sha256.lower():
71
+ raise HelloError(
72
+ f"hello.bat hash changed after download: {actual} != {sha256.lower()}"
73
+ )
74
+ return destination
75
+
76
+
77
+ def run_hello(
78
+ work_dir: str | os.PathLike[str] | None = None,
79
+ *,
80
+ timeout: float = 30.0,
81
+ ) -> Path:
82
+ """Download, verify, and run the pinned hello.bat demo.
83
+
84
+ With no ``work_dir``, the batch is downloaded to
85
+ ``%TEMP%\\game-updater-hello\\hello.bat`` and run from there. The batch file
86
+ is re-hashed immediately before execution. A nonzero exit code raises
87
+ :class:`HelloError`.
88
+ """
89
+
90
+ if sys.platform != "win32":
91
+ raise HelloError("the pinned hello.bat demo requires Windows")
92
+
93
+ # Resolve before changing cwd so relative work_dir values still work.
94
+ path = download_hello(work_dir, timeout=timeout).resolve()
95
+
96
+ actual = hashlib.sha256(path.read_bytes()).hexdigest()
97
+ if actual != HELLO_SHA256:
98
+ raise HelloError(
99
+ f"hello.bat changed before execution: {actual} != {HELLO_SHA256}"
100
+ )
101
+
102
+ command = ["cmd.exe", "/d", "/c", str(path)]
103
+ try:
104
+ completed = subprocess.run(command, cwd=str(path.parent), check=False)
105
+ except OSError as exc:
106
+ raise HelloError(f"cannot run hello.bat: {exc}") from exc
107
+
108
+ if completed.returncode != 0:
109
+ raise HelloError(f"hello.bat exited with code {completed.returncode}")
110
+ return path
111
+
112
+
113
+ __all__ = [
114
+ "HELLO_SIZE",
115
+ "HELLO_SHA256",
116
+ "HELLO_URL",
117
+ "HelloError",
118
+ "download_hello",
119
+ "run_hello",
120
+ ]