-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbuild.py
More file actions
343 lines (282 loc) · 12.1 KB
/
Copy pathbuild.py
File metadata and controls
343 lines (282 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
#!/usr/bin/env python3
"""Nuitka build orchestrator for the Prot Defender desktop shell.
Usage::
python build.py # standalone dist in build/prot-defender.dist
python build.py --onefile # single-file executable build/ProtDefender.exe
python build.py --clean # wipe build/ before compiling
python build.py --no-frontend # skip rebuilding the React bundle
python build.py --no-uac # do not embed the UAC admin manifest
python build.py --debug # build with debug symbols + console enabled
The script:
1. Verifies the React bundle exists (and rebuilds it via ``npm`` unless
``--no-frontend`` is given).
2. Stages the bundle into ``src/prot_defender/_assets/frontend/`` so the
compiled binary embeds the UI as package data.
3. Invokes Nuitka with the PySide6 plugin, the QtWebEngine data files and a
locked-down set of metadata.
4. Restores the staging area on exit so the workspace stays clean.
The build does NOT embed ``PROT_DEFENDER_DEBUG=1``; release builds always run
in hardened mode. To debug the compiled binary set the env var at launch time.
"""
from __future__ import annotations
import argparse
import hashlib
import os
import shutil
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
APP_NAME = "Prot Defender"
APP_VERSION = "0.1.0"
COMPANY = "Prot Defender Project"
DIST_NAME = "prot-defender"
ROOT = Path(__file__).resolve().parent
FRONTEND_DIR = ROOT / "frontend"
FRONTEND_DIST = FRONTEND_DIR / "dist"
PACKAGE_DIR = ROOT / "src" / "prot_defender"
ASSETS_DIR = PACKAGE_DIR / "_assets"
STAGED_FRONTEND = ASSETS_DIR / "frontend"
ENTRY = PACKAGE_DIR / "__main__.py"
BUILD_DIR = ROOT / "build"
MANIFEST_PATH = PACKAGE_DIR / "_integrity_manifest.py"
ICON_CANDIDATES = (ROOT / "assets" / "icon.ico", ROOT / "assets" / "icon.png")
# Suffixes covered by the integrity manifest. Must stay in lockstep with
# :mod:`prot_defender.integrity` so build-time and runtime agree.
PACKAGE_HASH_SUFFIXES = (".py",)
PACKAGE_EXCLUDE = {"_integrity_manifest.py", "__pycache__"}
FRONTEND_HASH_SUFFIXES = (
".html", ".js", ".mjs", ".css", ".json", ".svg", ".png", ".ico",
".woff", ".woff2", ".ttf", ".txt",
)
MANIFEST_PROTOCOL_VERSION = 1
def log(message: str) -> None:
print(f"[build] {message}", flush=True)
def fail(message: str) -> None:
print(f"[build][error] {message}", file=sys.stderr, flush=True)
raise SystemExit(1)
def run(cmd: list[str], cwd: Path | None = None) -> None:
log("$ " + " ".join(str(c) for c in cmd))
subprocess.run(cmd, cwd=cwd, check=True)
def ensure_tools() -> None:
try:
version = subprocess.run(
[sys.executable, "-m", "nuitka", "--version"],
capture_output=True,
text=True,
check=True,
).stdout
except (subprocess.CalledProcessError, FileNotFoundError):
fail(
"Nuitka is not installed. Install with: "
f"{sys.executable} -m pip install nuitka"
)
first_line = version.strip().splitlines()[0] if version.strip() else "unknown"
log(f"Nuitka: {first_line}")
try:
import PySide6 # noqa: F401
except ImportError:
fail("PySide6 is not installed in the active interpreter.")
def build_frontend(skip: bool) -> None:
if skip:
if not FRONTEND_DIST.exists():
fail("--no-frontend given but frontend/dist does not exist")
log("Reusing existing frontend/dist")
return
if not (FRONTEND_DIR / "package.json").exists():
fail(f"frontend/package.json not found at {FRONTEND_DIR}")
npm = shutil.which("npm.cmd" if os.name == "nt" else "npm")
if npm is None:
fail("npm not found on PATH; install Node.js or pass --no-frontend")
if not (FRONTEND_DIR / "node_modules").exists():
run([npm, "ci"], cwd=FRONTEND_DIR)
run([npm, "run", "build"], cwd=FRONTEND_DIR)
if not (FRONTEND_DIST / "index.html").exists():
fail("frontend build did not produce dist/index.html")
log(f"Frontend bundle ready at {FRONTEND_DIST}")
def stage_frontend() -> None:
"""Copy dist/ into the package's _assets/frontend/ for Nuitka to embed."""
if STAGED_FRONTEND.exists():
shutil.rmtree(STAGED_FRONTEND)
STAGED_FRONTEND.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(FRONTEND_DIST, STAGED_FRONTEND)
log(f"Staged frontend bundle into {STAGED_FRONTEND.relative_to(ROOT)}")
def _sha256_of(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(64 * 1024), b""):
digest.update(chunk)
return "sha256:" + digest.hexdigest()
def _walk_tree(root: Path, suffixes: tuple[str, ...], exclude: set[str] | None = None) -> dict[str, str]:
"""Return ``{relative_path: sha256:<hex>}`` for every matching file."""
exclude = exclude or set()
suffix_set = {s.lower() for s in suffixes}
hashes: dict[str, str] = {}
for candidate in sorted(root.rglob("*")):
if not candidate.is_file():
continue
if any(part in exclude for part in candidate.relative_to(root).parts):
continue
if candidate.suffix.lower() not in suffix_set:
continue
rel = candidate.relative_to(root).as_posix()
hashes[rel] = _sha256_of(candidate)
return hashes
def generate_manifest() -> None:
"""Emit ``_integrity_manifest.py`` with frontend + package hashes.
Hashes the *staged* frontend (so what Nuitka embeds is what we verify)
and every Python source under ``src/prot_defender``. The module itself
is excluded from the package hash to avoid a circular dependency.
"""
frontend_hashes = _walk_tree(
STAGED_FRONTEND, FRONTEND_HASH_SUFFIXES
)
package_hashes = _walk_tree(
PACKAGE_DIR, PACKAGE_HASH_SUFFIXES, exclude=PACKAGE_EXCLUDE | {"_assets"}
)
generated_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
lines: list[str] = [
'"""AUTO-GENERATED by build.py — do not edit by hand.',
"",
"Captures the SHA-256 of every bundled frontend file and every",
"Python source in the prot_defender package at build time. The",
"release binary refuses to start if any file on disk no longer",
"matches (see prot_defender.integrity).",
'"""',
"from __future__ import annotations",
"",
f"GENERATED_AT = {generated_at!r}",
f"PROTOCOL_VERSION = {MANIFEST_PROTOCOL_VERSION}",
"",
"FRONTEND_HASHES: dict[str, str] = {",
]
for rel in sorted(frontend_hashes):
lines.append(f" {rel!r}: {frontend_hashes[rel]!r},")
lines.append("}")
lines.append("")
lines.append("PACKAGE_HASHES: dict[str, str] = {")
for rel in sorted(package_hashes):
lines.append(f" {rel!r}: {package_hashes[rel]!r},")
lines.append("}")
lines.append("")
MANIFEST_PATH.write_text("\n".join(lines), encoding="utf-8")
log(
f"Wrote integrity manifest: "
f"{len(frontend_hashes)} frontend + {len(package_hashes)} package files"
)
def restore_staging() -> None:
if STAGED_FRONTEND.exists():
shutil.rmtree(STAGED_FRONTEND)
if MANIFEST_PATH.exists():
MANIFEST_PATH.unlink()
log("Restored staging area")
def icon_args() -> list[str]:
for candidate in ICON_CANDIDATES:
if candidate.exists():
return ["--windows-icon-from-ico", str(candidate)]
return []
def metadata_args() -> list[str]:
return [
"--company-name", COMPANY,
"--product-name", APP_NAME,
"--file-version", f"{APP_VERSION}.0",
"--product-version", f"{APP_VERSION}.0",
"--file-description", f"{APP_NAME} Security Desktop Shell",
"--copyright", "(c) Prot Defender Project",
]
def build_args(onefile: bool, debug: bool, uac: bool) -> list[str]:
args: list[str] = [
sys.executable, "-m", "nuitka",
"--standalone" if not onefile else "--onefile",
"--enable-plugin=pyside6",
# Embed the staged frontend bundle as package data. Nuitka maps the
# source dir onto the importable path ``prot_defender/_assets`` so
# ``security.bundled_frontend()`` resolves inside the frozen tree.
"--include-data-dir=" + str(ASSETS_DIR) + "=prot_defender/_assets",
"--include-package=prot_defender",
# QtWebEngine ships a child process + locale/resource blobs that must
# travel with the binary; pull all PySide6 data through to be safe.
"--include-package-data=PySide6",
"--include-module=prot_defender.qt_app",
"--include-module=prot_defender.gateway",
"--include-module=prot_defender.ipc",
"--include-module=prot_defender.mock_engine",
"--include-module=prot_defender.protocol",
"--include-module=prot_defender.security",
"--include-module=prot_defender.integrity",
"--include-module=prot_defender.hardening",
"--include-module=prot_defender._integrity_manifest",
"--output-dir", str(BUILD_DIR),
# Override the executable name; the surrounding ``*.dist`` folder is
# renamed in :func:`finalise` for a clean distribution layout.
"--output-filename", DIST_NAME + (".exe" if os.name == "nt" else ""),
"--assume-yes-for-downloads",
"--remove-output",
"--no-pyi-file",
]
if debug:
args += ["--debug", "--unstripped", "--include-debug-info"]
args.append("--windows-console-mode=force")
else:
if os.name == "nt":
args.append("--windows-console-mode=disable")
# An antivirus needs elevated privileges to talk to the privileged
# engine service; embed the UAC admin manifest so Windows prompts
# for consent on launch. Pass --no-uac for dev builds.
if uac and os.name == "nt":
args.append("--windows-uac-admin")
args += icon_args()
args += metadata_args()
args += ["--main", str(ENTRY)]
return args
def finalise(onefile: bool) -> Path:
"""Return the path of the built artifact, renaming folders if needed."""
if onefile:
ext = ".exe" if os.name == "nt" else ""
candidate = BUILD_DIR / f"{DIST_NAME}{ext}"
if candidate.exists():
return candidate
# Fallback: any newly-created executable in build/.
execs = sorted(
p for p in BUILD_DIR.iterdir()
if p.is_file() and p.suffix == (".exe" if os.name == "nt" else "")
)
return execs[0] if execs else BUILD_DIR
# Standalone: Nuitka emits ``__main__.dist`` (from the entry script name).
# Rename it to ``prot-defender.dist`` for a clean distribution layout.
produced = BUILD_DIR / "__main__.dist"
target = BUILD_DIR / f"{DIST_NAME}.dist"
if produced.exists():
if target.exists():
shutil.rmtree(target)
produced.rename(target)
if target.exists():
return target
# Fallback: any .dist directory.
folders = sorted(BUILD_DIR.glob("*.dist"))
return folders[0] if folders else BUILD_DIR
def main() -> None:
parser = argparse.ArgumentParser(description="Build Prot Defender with Nuitka.")
parser.add_argument("--onefile", action="store_true", help="produce a single-file executable")
parser.add_argument("--clean", action="store_true", help="delete build/ before building")
parser.add_argument("--no-frontend", action="store_true", help="skip the npm build step")
parser.add_argument("--no-uac", action="store_true", help="do not embed the UAC admin manifest")
parser.add_argument("--debug", action="store_true", help="build with debug symbols + console")
args = parser.parse_args()
ensure_tools()
if args.clean and BUILD_DIR.exists():
log(f"Removing {BUILD_DIR}")
shutil.rmtree(BUILD_DIR)
BUILD_DIR.mkdir(parents=True, exist_ok=True)
build_frontend(skip=args.no_frontend)
stage_frontend()
generate_manifest()
try:
cmd = build_args(onefile=args.onefile, debug=args.debug, uac=not args.no_uac)
run(cmd)
finally:
restore_staging()
artifact = finalise(onefile=args.onefile)
log(f"Build complete -> {artifact}")
if __name__ == "__main__":
main()