diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e6fbc0..783dfae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: run: offloader info - name: Lint - run: ruff check src tests + run: ruff check src tests scripts build/windows - name: Test run: pytest -q --cov=offloader --cov-report=term --cov-report=xml @@ -121,7 +121,40 @@ jobs: python -m pip install --upgrade pip build twine python -m build python -m twine check dist/* + - name: Check that the source archive contains the Windows builder + run: >- + python -c "import glob, tarfile; + names = tarfile.open(glob.glob('dist/*.tar.gz')[0]).getnames(); + assert all(any(n.endswith('/build/windows/' + f) for n in names) + for f in ('build.py', 'sign.py', 'offloader.spec', 'installer.nsi'))" + - name: Install and check the built wheel outside the checkout + run: python scripts/check_wheel.py dist/*.whl - uses: actions/upload-artifact@v4 with: name: dist path: dist/ + + windows-bundle: + name: Windows unsigned desktop and installer + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install packaging dependencies + run: python -m pip install -e ".[gui]" -r requirements-build.txt + - name: Install NSIS + run: choco install nsis --version=3.12.0 -y --no-progress + - name: Build unsigned artifacts and check the frozen applications + run: python build/windows/build.py --clean --no-sign + - uses: actions/upload-artifact@v4 + with: + name: offloader-windows-unsigned + path: | + dist/windows/Offloader-*.exe + dist/windows/Offloader-*.zip + dist/windows/Offloader-*-inventory.json + dist/windows/SHA256SUMS.txt + if-no-files-found: error diff --git a/.gitignore b/.gitignore index f14c76f..416e834 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,14 @@ __pycache__/ *.py[cod] *.egg-info/ -build/ +/build/* +!/build/windows/ +/build/windows/__pycache__/ +/.pyinstaller/ +/.python-build/ dist/ .venv/ +.venv-build/ venv/ .pytest_cache/ .coverage diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..48bdb49 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,186 @@ +# Offloader agent guide + +Offloader is a Python verified-copy tool for large one-way transfers, with a +CLI and a PySide6 desktop app. Camera-card offload is its primary workflow; +the data profile supports arbitrary files without media dependencies. + +**Someone may reformat a camera card because Offloader said "Verified".** +Protecting the source, existing good copies, and the accuracy of that verdict +takes priority over features, throughput, and presentation. + +## Start here + +- Read [CONTRIBUTING.md](CONTRIBUTING.md) for development conventions. +- Before changing copy, verify, cleanup, or destination handling, read + [docs/data-safety.md](docs/data-safety.md). Its known limits are part of the + product's contract, not guarantees that have already been implemented. +- Use [ROADMAP.md](ROADMAP.md) for feature priorities and + [docs/release-plan.md](docs/release-plan.md) for the proposed Windows beta. + The release plan describes future work; verify the checkout before claiming + an installer, signing pipeline, or release gate exists. +- Inspect the working tree before edits. Preserve unrelated user changes. + Keep this guide concise and link to detailed documentation rather than + copying it wholesale. + +## Working with Owen + +- Minimize typing and manual effort. Complete authorized, reversible work + without repeatedly asking for confirmation. Ask only when a decision + materially changes the result; prefer clickable choices when available. +- Keep replies concise. Never use em dashes in written output. +- Put text intended for copying into fenced code blocks, one per paste target, + with labels and commentary outside the block. +- Use `rg` for content searches and `rg --files` for file discovery. Do not + use `grep`, `findstr`, or `Select-String`. +- Delegate bounded searches or implementation only when it saves net effort; + prefer an appropriate cheaper model and concise reports. Keep design + decisions and final diff review in the main agent. Pass search rules on. +- Do not steal focus, move the cursor, or launch interactive GUI verification + without authorization. Prefer headless checks. Never terminate user apps by + image name; target only a specific process launched for the task. +- Never add AI authorship, co-author trailers, or AI-session links to commits, + PRs, issues, documentation, or code comments. + +## Code map + +All module paths below are relative to `src/offloader/`. + +| Area | Files and responsibility | +| --- | --- | +| Entry points | `cli.py`, `__main__.py`, `gui/app.py`; root `run.py` prefers this checkout's `src/`, launching the GUI without arguments and forwarding arguments to the CLI | +| Copy and control | `engine.py`: scanning, destination validation, fan-out, staging, verification, progress, `JobControl`, and CLI control-file support | +| Integrity | `hashers.py`, `integrity.py`, `verify.py`, `retry.py`, `longpath.py` | +| Shared data | `models.py`; keep engine and model code independent of Qt | +| Reports | `reports/` for PDF, CSV, MHL 1.1, and HTML; `ascmhl.py` for ASC MHL histories and directory hashes | +| Media | `probe.py`, `thumbs.py`, `braw.py`, `ixml.py`, `companions.py` | +| Timeline import | `timeline.py`: optional OpenTimelineIO integration, media resolution, and ambiguity handling | +| Desktop | `gui/main_window.py`, `gui/worker.py`, `gui/queue_view.py`, mode/editor widgets, and `gui/drives.py` | +| Persistent state | `config.py`, `presets.py`, `history.py`; `volumes.py` discovers storage and `naming.py` handles naming | + +Python 3.10+ is supported. Core dependencies are xxhash and ReportLab; PySide6 +is the GUI extra. Timeline dependencies are separate extras. ffmpeg/ffprobe +on PATH enable media metadata and thumbnails but are not required for copying +and verification. Do not make a missing media tool block a valid transfer. + +## Safety invariants + +- Keep destination validation in the engine so CLI, GUI, and library callers + share it. Never weaken source-overlap, duplicate-target, self-copy, or + flattened-name collision checks to make a workflow pass. +- Write to staged `.offloader-partial` files. Preserve verification before + promotion in verified modes and atomic replacement of final paths. Failure + or cancellation must not truncate an existing good copy or leave incomplete + data under a plausible final name. Cleanup must target only owned staging + files, never source media or an unrelated existing destination. +- Preserve the distinction between `none`, `source-only`, and `full` + verification. Full verification rereads the destination with cache-eviction + handling; failed eviction must remain visible. Do not claim this proves + physical persistence past a drive/controller cache. +- A skipped file is not newly checksum-verified. `skip_existing` currently + compares size. Warnings, missing files, failed destinations, and report + failures must not disappear into a blanket success verdict. +- Keep manifests portable and independently usable at each destination. + Preserve relative paths and per-destination results. Hex digests compare + case-insensitively; C4 identifiers compare exactly. +- Keep pause, resume, and cancellation responsive during copying and retry + waits. Malformed or temporarily unreadable control-file contents mean no + change of state, not cancellation. Use the existing `JobControl` machinery. +- Treat filenames, removable-media metadata, manifests, and configuration as + untrusted input. Keep parsers bounded, reject unsafe XML constructs, escape + HTML output, and guard against traversal and collisions. +- Timeline resolution must refuse ambiguous matches rather than choosing an + arbitrary same-named file. Read [docs/timeline.md](docs/timeline.md) before + changing its source/destination rules, which differ from card offload. +- Keep configuration under `config.py`'s per-user directory. Preserve atomic + writes and unreadable-config fallback. Tests must use temporary state rather + than modifying the user's presets or history. +- Do not represent known gaps as implemented protections: concurrent instances + are not coordinated, size-only skip is not a checksum check, and directory + structure hashes are not yet rechecked. Consult the current safety document + for the full list before changing claims. + +## Development and validation + +Install development dependencies, preferably in an existing project virtual +environment or a new local one: + +```powershell +python -m pip install -e ".[dev]" +``` + +Inspect environment capabilities without launching the GUI: + +```powershell +python run.py info +``` + +Lint: + +```powershell +python -m ruff check src tests +``` + +Run the suite: + +```powershell +python -m pytest -q +``` + +Run the deeper property checks when relevant to safety or parser changes: + +```powershell +python -m pytest tests/test_fuzz.py tests/test_fuzz_edges.py tests/test_edge_cases.py --fuzz -q +``` + +Build Python distributions when packaging changes: + +```powershell +python -m build +``` + +The build command requires the `build` package, which CI installs separately. +GUI tests set Qt's offscreen platform before importing PySide6; CI also sets +`QT_QPA_PLATFORM=offscreen`. Do not launch the desktop just to run tests. + +Changes to copy, verify, or delete paths require a regression test that fails +without the fix. Start with affected tests, then run lint and the full suite +for code changes. Use temporary fixtures, injected failures, and disposable +media copies. Never test destructive behavior against original footage. +Documentation-only changes need link/content and diff checks, not a test run. + +Relevant suites include `test_data_safety.py`, `test_engine.py`, +`test_verify.py`, `test_retry.py`, and `test_control.py`. Parser, report, +timeline, and GUI suites live alongside them in `tests/`. Prefer independent +reference outputs for formats and checksums over tests that mirror the writer. +See CONTRIBUTING for existing synthetic BRAW/BWF fixtures and failure helpers. + +CI configuration is in [ci.yml](.github/workflows/ci.yml): cross-platform +tests, a Python minimum-version job, ffmpeg coverage, property-test soak, and +wheel/sdist validation. A configured job is not evidence of a passing run. +Report what was actually executed and any skips or environment limitations. + +## Implementation, documentation, and releases + +- Match surrounding code, use type hints on new public functions, and explain + non-obvious constraints in comments. Keep Qt imports inside the GUI layer + and long-running work outside the GUI thread. +- Update the relevant `docs/` file when behavior changes. Report geometry, + media parsing, ASC MHL, timeline resolution, and performance each have their + own reference documents linked from README. Update published test counts + only from actual results, not estimates. +- Measure performance before claiming improvement; follow + [docs/performance.md](docs/performance.md) and state cache/durability effects. +- `src/offloader/_version.py` is the version source. Setuptools reads its + literal through dynamic metadata; the package re-exports it and the Windows + bundle uses it for executable metadata. Do not add another version literal + or bump the version merely for documentation work. +- Alpha-OSK is a release-process reference, not a runtime dependency. Do not + copy its product IDs, signer configuration, update endpoint, elevation + behavior, or repository targets into Offloader. +- Follow the release plan's artifact and clean-machine gates when packaging + lands. Installation or updates must not force-stop an active transfer. + Preparing a release is distinct from publishing or sending announcements; + perform external actions only within the user's authorized scope. +- Treat data loss, false verification, and silent omissions as security issues. + Follow [SECURITY.md](SECURITY.md) for private reporting; do not publish + sensitive reproductions or contact anyone without authorization. diff --git a/CHANGELOG.md b/CHANGELOG.md index aaec2b9..dfa183d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ project uses [semantic versioning][semver]. ### Added +- Windows desktop/CLI bundles and an NSIS installer, with pinned dependencies, + embedded version metadata, signing by default, explicit unsigned CI builds, + source/file inventories, checksums, and headless artifact checks. Installation + uses an application lifetime lock and inventoried files for replacement, + rollback, and uninstall; configuration/history are preserved. Real signing + and clean-machine installation qualification remain pending. +- One release version source for Python package metadata, the application, + reports, and Windows executable metadata. CI also installs the built wheel + outside the checkout to check its CLI and version consistency. + - **Pause, resume and cancel from the command line.** `JobControl` has existed since the desktop app needed transport buttons, and is checked once per 8 MiB chunk, but the CLI never passed one — so a job started in a terminal could diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9295f02..eef401b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,7 @@ pip install -e ".[dev]" `ffmpeg` and `ffprobe` on `PATH` are optional — the suite runs without them. ```sh -pytest # 482 tests, about 20s +pytest # run the full suite pytest --fuzz # property tests at 3000 examples each, about 3 min ruff check src tests pytest --cov=offloader --cov-report=term-missing diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..7b515d2 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +include AGENTS.md CONTRIBUTING.md SECURITY.md ROADMAP.md CHANGELOG.md run.py +include requirements-build.txt +recursive-include build/windows *.py *.spec *.nsi +recursive-include scripts *.py +recursive-include docs *.md diff --git a/README.md b/README.md index dceaaa1..3197da7 100644 --- a/README.md +++ b/README.md @@ -514,6 +514,8 @@ general-purpose tool reports a filename, a size, and a placeholder icon. | Document | What is in it | | --- | --- | | [`ROADMAP.md`](ROADMAP.md) | What is next, why, and what this will not become | +| [`docs/release-plan.md`](docs/release-plan.md) | Windows beta release sequence, packaging, signing, acceptance gates, and recovery | +| [`docs/build-windows.md`](docs/build-windows.md) | Build, sign, and check Windows desktop bundles and installers | | [`docs/data-safety.md`](docs/data-safety.md) | Threat model: what is guaranteed, what is not, and the bugs behind each guarantee | | [`docs/report-layout.md`](docs/report-layout.md) | Every coordinate of the PDF, measured off the reference report | | [`docs/performance.md`](docs/performance.md) | Why not robocopy, with benchmarks and the confounds that made the first run worthless | @@ -552,18 +554,20 @@ what makes the report layer testable without moving bytes. ```sh pip install -e ".[dev]" -pytest # 482 tests, ~20s +pytest # run the full suite pytest --fuzz # same suite, 3000 examples per property (~3 min) ruff check src tests pytest --cov=offloader --cov-report=term-missing ``` -482 tests at 83% line coverage. They cover formatting against the reference's +727 tests passed with 5 skipped and 85% line coverage on Windows/Python 3.12 +in the latest local run. They cover formatting against the reference's exact strings, checksum vectors and streaming equivalence, copy/verify behaviour including simulated destination corruption, pause/resume/cancel concurrency, retry discrimination, BRAW container parsing, ffprobe parsing, preset and history persistence, card detection, PDF geometry read back with -PyMuPDF, the CLI, and the GUI. +PyMuPDF, the CLI, the GUI, and Windows installation ownership, rollback, +locking, build provenance, and signing failure handling. The GUI tests run on Qt's offscreen platform and drive the real queue controller — the worker thread actually copies files — so they cover the wiring diff --git a/ROADMAP.md b/ROADMAP.md index 8a30cce..c3c13d9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -41,6 +41,11 @@ remains the fallback for everything the chunk retry cannot reach. ## Next +The proposed first packaged Windows beta is tracked in +[`docs/release-plan.md`](docs/release-plan.md), with packaging, signing, +integrity gates, a private pilot, and recovery steps. It adapts Alpha-OSK's +release process to Offloader's data-safety requirements. + ### Verify what is already written `offloader verify` checks file hashes. ASC MHL also records **directory content diff --git a/build/windows/artifacts.py b/build/windows/artifacts.py new file mode 100644 index 0000000..8ada11b --- /dev/null +++ b/build/windows/artifacts.py @@ -0,0 +1,206 @@ +"""Identity and tamper-evident records for Windows build artifacts.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import subprocess +import tempfile +from pathlib import Path +from typing import Any + +_VERSION_RE = re.compile(r'^__version__\s*=\s*["\']([^"\']+)["\']', re.MULTILINE) +_SOURCE_SUFFIXES = {".py", ".spec", ".nsi", ".nsh", ".ico", ".bmp"} +_RECORD_NAME = ".offloader-build.json" +_MAX_RECORD_BYTES = 4 * 1024 * 1024 + + +def _is_reparse(path: Path) -> bool: + try: + metadata = path.lstat() + except OSError: + return False + if os.name == "nt": + try: + return bool(metadata.st_file_attributes & 0x400) + except OSError: + return False + return metadata.st_mode & 0o170000 == 0o120000 + + +def _reject_path_links(path: Path) -> None: + current = path.absolute() + for item in (current, *current.parents): + if _is_reparse(item): + raise RuntimeError(f"reparse point is not allowed in artifact path: {item}") + + +def _files( + root: Path, *, suffixes: set[str] | None = None, skip_pycache: bool = False +) -> list[tuple[Path, str]]: + _reject_path_links(root) + if not root.is_dir(): + raise RuntimeError(f"artifact root is not a directory: {root}") + found: list[tuple[Path, str]] = [] + def onerror(error: OSError) -> None: + raise RuntimeError(f"could not walk artifact tree: {error}") from error + + for current, directories, names in os.walk( + root, onerror=onerror, followlinks=False + ): + current_path = Path(current) + _reject_path_links(current_path) + kept_directories: list[str] = [] + for name in directories: + child = current_path / name + if skip_pycache and name == "__pycache__": + continue + if _is_reparse(child): + raise RuntimeError(f"reparse point is not allowed in artifact tree: {child}") + kept_directories.append(name) + directories[:] = kept_directories + for name in names: + child = current_path / name + if _is_reparse(child) or not child.is_file(): + raise RuntimeError(f"unsafe or non-file artifact input: {child}") + if suffixes is None or child.suffix.lower() in suffixes: + found.append((child, child.relative_to(root).as_posix())) + return found + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _source_files(repo: Path) -> list[tuple[Path, str]]: + files: list[tuple[Path, str]] = [] + for directory, suffixes in ( + (repo / "src", {".py"}), + (repo / "build" / "windows", _SOURCE_SUFFIXES), + ): + files.extend( + (path, path.relative_to(repo).as_posix()) + for path, _ in _files(directory, suffixes=suffixes, skip_pycache=True) + ) + for name in ("requirements-build.txt", "pyproject.toml", "LICENSE"): + path = repo / name + _reject_path_links(path) + if not path.is_file() or _is_reparse(path): + raise RuntimeError(f"required source file is missing or unsafe: {path}") + files.append((path, name)) + return sorted(files, key=lambda item: item[1].casefold()) + + +def source_identity(repo: Path) -> dict[str, str]: + """Return version, source commit, and deterministic source digest.""" + repo = Path(repo) + version_path = repo / "src" / "offloader" / "_version.py" + _reject_path_links(version_path) + match = _VERSION_RE.search(version_path.read_text(encoding="utf-8")) + if match is None: + raise RuntimeError(f"could not read release version from {version_path}") + try: + result = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=repo, check=True, + capture_output=True, text=True, + ) + except (OSError, subprocess.CalledProcessError) as exc: + raise RuntimeError("could not determine source commit") from exc + commit = result.stdout.strip() + if not commit: + raise RuntimeError("git returned an empty source commit") + digest = hashlib.sha256() + for path, relative in _source_files(repo): + digest.update(relative.encode("utf-8")) + digest.update(b"\0") + digest.update(_sha256(path).encode("ascii")) + digest.update(b"\0") + return {"version": match.group(1), "source_commit": commit, "source_digest": digest.hexdigest()} + + +def bundle_inventory(bundle: Path) -> dict[str, str]: + """Hash every safe bundle file, excluding the build record itself.""" + bundle = Path(bundle) + pairs = _files(bundle) + names: dict[str, str] = {} + folded_names: set[str] = set() + for path, relative in pairs: + key = relative.replace("\\", "/") + folded = key.casefold() + if folded in folded_names: + raise RuntimeError(f"case-insensitive bundle name collision: {key}") + folded_names.add(folded) + if folded == _RECORD_NAME.casefold(): + continue + names[key] = _sha256(path) + required = {"offloader.exe", "offloader-maintenance.exe", "offloader-cli.exe", ".offloader-install.lock"} + folded = {name.casefold() for name in names} + missing = sorted(name for name in required if name.casefold() not in folded) + if not any(name.casefold().startswith("_internal/python") and name.casefold().endswith(".dll") for name in names): + missing.append("_internal/python312.dll") + if missing: + raise RuntimeError(f"bundle is missing required files: {', '.join(missing)}") + return dict(sorted(names.items(), key=lambda item: item[0].casefold())) + + +def write_build_record(bundle: Path, identity: dict[str, str]) -> dict[str, Any]: + """Write the versioned build record atomically and return its contents.""" + bundle = Path(bundle) + files = bundle_inventory(bundle) + record: dict[str, Any] = {"schema": 1, **identity, "files": files} + target = bundle / _RECORD_NAME + _reject_path_links(bundle) + fd, temporary_name = tempfile.mkstemp(prefix=".offloader-build-", suffix=".tmp", dir=bundle) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(record, handle, sort_keys=True, separators=(",", ":")) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary_name, target) + finally: + if os.path.exists(temporary_name): + os.unlink(temporary_name) + return record + + +def validate_build_record(bundle: Path, identity: dict[str, str]) -> dict[str, Any]: + """Validate a build record and the complete current bundle contents.""" + bundle = Path(bundle) + target = bundle / _RECORD_NAME + _reject_path_links(bundle) + try: + if _is_reparse(target) or target.stat().st_size > _MAX_RECORD_BYTES: + raise RuntimeError("invalid build record path or size") + raw = target.read_bytes() + if len(raw) > _MAX_RECORD_BYTES: + raise RuntimeError("build record exceeds size limit") + record = json.loads(raw.decode("utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise RuntimeError("could not read build record") from exc + if not isinstance(record, dict) or record.get("schema") != 1: + raise RuntimeError("unsupported or malformed build record schema") + if set(record) != {"schema", *identity, "files"}: + raise RuntimeError("build record identity fields differ") + for key, value in identity.items(): + if record.get(key) != value: + raise RuntimeError(f"build record identity mismatch: {key}") + recorded = record.get("files") + if not isinstance(recorded, dict) or any( + not isinstance(name, str) or not isinstance(digest, str) + or not re.fullmatch(r"[0-9a-fA-F]{64}", digest) + for name, digest in recorded.items() + ): + raise RuntimeError("malformed build record file inventory") + actual = bundle_inventory(bundle) + if set(recorded) != set(actual): + raise RuntimeError("bundle file set differs from build record") + if any(recorded[name].lower() != actual[name].lower() for name in actual): + raise RuntimeError("bundle file hash differs from build record") + return record diff --git a/build/windows/build.py b/build/windows/build.py new file mode 100644 index 0000000..3b2ff8d --- /dev/null +++ b/build/windows/build.py @@ -0,0 +1,223 @@ +"""Build and verify Windows artifacts; use --no-sign for development and CI.""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.metadata +import json +import platform +import shutil +import subprocess +import sys +import zipfile +from pathlib import Path + +HERE = Path(__file__).resolve().parent +REPO = HERE.parents[1] +SPEC = HERE / "offloader.spec" +DIST = REPO / "dist" / "windows" +WORK = REPO / ".pyinstaller" / "windows" +OWN_EXECUTABLES = {"Offloader.exe", "offloader-cli.exe", "offloader-maintenance.exe"} +NATIVE_SUFFIXES = {".exe", ".dll", ".pyd"} + + +def run(command: list[str]) -> None: + subprocess.run(command, cwd=REPO, check=True) + + +def check_versions(bundle: Path, version: str) -> None: + from smoke import file_version + + for name in sorted(OWN_EXECUTABLES): + if file_version(bundle / name) != version: + raise RuntimeError(f"Wrong embedded version on {name}") + + +def check_signatures(bundle: Path, *, signing: bool, version: str) -> list[dict]: + import sign + + records = [] + for path in sorted(bundle.rglob("*")): + if path.suffix.lower() not in NATIVE_SUFFIXES or not path.is_file(): + continue + relative = path.relative_to(bundle).as_posix() + own = relative in OWN_EXECUTABLES + if signing: + status = sign.inspect_file(path) + if status["signature_status"] == "NotSigned": + sign.sign_file(path) + # Executables must belong to Offloader. Approved vendor DLL/PYD + # signatures are preserved, with trust and timestamps still required. + record = sign.verify_file( + path, expected_version=version if own else None, + allow_vendor=path.suffix.lower() in {".dll", ".pyd"}, + ) + records.append({**record, "path": relative}) + return records + + +def save_outputs(bundle: Path, setup: Path | None, identity: dict, + signatures: list[dict], signed: bool) -> None: + from artifacts import bundle_inventory + + version = identity["version"] + inventory = DIST / f"Offloader-{version}-inventory.json" + inventory.write_text(json.dumps({ + "schema": 1, **identity, "signed": signed, + "python": platform.python_version(), + "packages": sorted( + f"{distribution.metadata['Name']}=={distribution.version}" + for distribution in importlib.metadata.distributions() + ), + "files": bundle_inventory(bundle), "signatures": signatures, + }, indent=2) + "\n", encoding="utf-8") + archive = DIST / f"Offloader-{version}-windows-x64.zip" + with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as output: + for path in sorted(bundle.rglob("*")): + if path.is_file(): + output.write(path, f"Offloader/{path.relative_to(bundle).as_posix()}") + outputs = [archive, inventory] + if setup is not None: + outputs.append(setup) + lines = [] + for path in outputs: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + lines.append(f"{digest.hexdigest()} {path.name}") + (DIST / "SHA256SUMS.txt").write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def validate_outputs(bundle: Path, setup: Path | None, identity: dict) -> None: + """Require checksummed release outputs from the same completed build.""" + from artifacts import bundle_inventory + + version = identity["version"] + inventory = DIST / f"Offloader-{version}-inventory.json" + record = json.loads(inventory.read_text(encoding="utf-8")) + if not record.get("signed") or any(record.get(key) != value for key, value in identity.items()): + raise RuntimeError("Output inventory is unsigned or belongs to different sources") + if record.get("files") != bundle_inventory(bundle): + raise RuntimeError("Output inventory no longer matches the bundle") + expected = {inventory.name, f"Offloader-{version}-windows-x64.zip"} + if setup is not None: + expected.add(setup.name) + checksums = {} + for line in (DIST / "SHA256SUMS.txt").read_text(encoding="utf-8").splitlines(): + digest, name = line.split(" ", 1) + if name not in expected or name in checksums: + raise RuntimeError("Unexpected or duplicate output in checksum inventory") + checksums[name] = digest + if set(checksums) != expected: + raise RuntimeError("Checksum inventory is incomplete") + for name, expected_digest in checksums.items(): + digest = hashlib.sha256() + with (DIST / name).open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + if digest.hexdigest() != expected_digest: + raise RuntimeError(f"Release output changed after the build: {name}") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--clean", action="store_true", help="clear the PyInstaller cache") + parser.add_argument("--no-sign", action="store_true", help="unsigned development build") + parser.add_argument("--skip-build", action="store_true", help="validate and reuse the bundle") + parser.add_argument("--verify-only", action="store_true", help="check existing signed artifacts") + parser.add_argument("--no-installer", action="store_true", help="produce only the portable bundle") + args = parser.parse_args(argv) + if sys.platform != "win32": + parser.error("Windows artifacts must be built and verified on Windows") + if args.verify_only and (args.no_sign or args.skip_build or args.clean): + parser.error("--verify-only cannot be combined with --no-sign, --skip-build, or --clean") + if args.skip_build and args.clean: + parser.error("--clean cannot be combined with --skip-build") + + import artifacts + import installer + import sign + + bundle = DIST / "Offloader" + incomplete = DIST / ".offloader-build-incomplete" + try: + identity = artifacts.source_identity(REPO) + version = identity["version"] + setup = None if args.no_installer else DIST / f"Offloader-Setup-{version}.exe" + if args.verify_only: + if incomplete.exists(): + raise RuntimeError("The last build did not finish successfully") + artifacts.validate_build_record(bundle, identity) + validate_outputs(bundle, setup, identity) + check_versions(bundle, version) + check_signatures(bundle, signing=False, version=version) + if setup is not None: + sign.verify_file(setup, expected_version=version) + print("Artifact signatures, source identity, file hashes, and versions verified.") + return 0 + + if not args.no_sign: + # An exact commit is needed for a release. Development builds may + # use dirty sources, recorded by their content fingerprint. + dirty = subprocess.check_output( + ["git", "status", "--porcelain"], cwd=REPO, text=True, + ).strip() + if dirty: + raise RuntimeError("Signed builds require a clean checkout; use --no-sign for development") + sign.preflight() + if setup is not None: + installer.find_makensis() + if args.skip_build: + artifacts.validate_build_record(bundle, identity) + DIST.mkdir(parents=True, exist_ok=True) + incomplete.write_text("Build has not completed verification.\n", encoding="utf-8") + if not args.skip_build: + command = [sys.executable, "-m", "PyInstaller", "--noconfirm", + "--distpath", str(DIST), "--workpath", str(WORK)] + if args.clean: + command.append("--clean") + run([*command, str(SPEC)]) + (bundle / ".offloader-install.lock").touch() + shutil.copyfile(REPO / "LICENSE", bundle / "LICENSE") + artifacts.write_build_record(bundle, identity) + check_versions(bundle, version) + signatures = [] + if not args.no_sign: + signatures = check_signatures(bundle, signing=True, version=version) + # The record describes the actual bytes that enter the installer, + # including any new Authenticode signatures. + artifacts.write_build_record(bundle, identity) + if setup is not None: + uninstaller_record = DIST / ".offloader-uninstaller-signature.json" + if not args.no_sign: + uninstaller_record.unlink(missing_ok=True) + installer.build_installer( + bundle, setup, version, + sign_command=None if args.no_sign else [ + sys.executable, str(HERE / "sign.py"), "sign", "--version", version, + "--record", str(uninstaller_record), + ], + ) + if not args.no_sign: + record = json.loads(uninstaller_record.read_text(encoding="utf-8")) + signatures.append({**record, "path": "Uninstall.exe"}) + sign.sign_file(setup) + signatures.append({**sign.verify_file(setup, expected_version=version), + "path": setup.name}) + run([sys.executable, str(HERE / "smoke.py"), str(bundle)]) + if artifacts.source_identity(REPO) != identity: + raise RuntimeError("Sources changed while building; rebuild the candidate") + artifacts.validate_build_record(bundle, identity) + save_outputs(bundle, setup, identity, signatures, signed=not args.no_sign) + incomplete.unlink() + print(f"{'Unsigned development' if args.no_sign else 'Signed'} artifacts: {DIST}") + return 0 + except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as exc: + print(f"Windows build failed: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/windows/cli_entry.py b/build/windows/cli_entry.py new file mode 100644 index 0000000..31fb042 --- /dev/null +++ b/build/windows/cli_entry.py @@ -0,0 +1,47 @@ +"""Frozen console entry point for Offloader.""" + +from __future__ import annotations + +import importlib.util +import sys +from contextlib import ExitStack + + +def _timeline_requested(argv: list[str]) -> bool: + if not argv or "--help" in argv or "-h" in argv: + return False + return argv[0] == "resolve" or ( + argv[0] == "offload" + and any(arg == "--timeline" or arg.startswith("--timeline=") for arg in argv[1:]) + ) + + +def _main() -> int: + # Timeline adapters are deliberately outside the first Windows bundle. + # Detect this here so the frozen app does not suggest an unusable pip install. + if _timeline_requested(sys.argv[1:]) and importlib.util.find_spec("opentimelineio") is None: + print( + "Timeline import is not included in this Windows build of Offloader.", + file=sys.stderr, + ) + return 4 + + from offloader.cli import main as cli_main + + return cli_main() + + +def main() -> int: + from offloader.installation_lock import frozen_installation_lock + + with ExitStack() as lifetime: + try: + lifetime.enter_context(frozen_installation_lock()) + except OSError as exc: + print(f"Offloader cannot start: {exc}", file=sys.stderr) + return 4 + return _main() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/windows/gui_entry.py b/build/windows/gui_entry.py new file mode 100644 index 0000000..b16b8a0 --- /dev/null +++ b/build/windows/gui_entry.py @@ -0,0 +1,55 @@ +"""Frozen graphical entry point for Offloader.""" + +from __future__ import annotations + +import os +import sys +import traceback +from pathlib import Path + + +def _main() -> int: + # The smoke runner uses the real frozen event loop without opening a window + # indefinitely on CI. This variable has no effect during normal launches. + if os.environ.get("OFFLOADER_GUI_SMOKE") == "1": + from PySide6.QtCore import QTimer + from PySide6.QtWidgets import QApplication + + from offloader import PRODUCT_NAME, __version__ + from offloader.gui import theme + from offloader.gui.main_window import MainWindow + + app = QApplication(sys.argv) + app.setApplicationName(PRODUCT_NAME) + app.setApplicationVersion(__version__) + app.setOrganizationName(PRODUCT_NAME) + theme.apply(app) + window = MainWindow() + window.show() + QTimer.singleShot(250, app.quit) + return app.exec() + + from offloader.gui.app import main as gui_main + + return gui_main(sys.argv) + + +def main() -> int: + from offloader.installation_lock import frozen_installation_lock + + with frozen_installation_lock(): + return _main() + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception: + if os.environ.get("OFFLOADER_GUI_SMOKE") != "1": + raise + # Windowed executables have no stderr. Save the failure for the smoke + # runner instead of opening the bootloader's error dialog on CI. + Path(os.environ["OFFLOADER_GUI_SMOKE_LOG"]).write_text( + traceback.format_exc(), encoding="utf-8", + ) + raise SystemExit(1) from None diff --git a/build/windows/installer.nsi b/build/windows/installer.nsi new file mode 100644 index 0000000..3b33361 --- /dev/null +++ b/build/windows/installer.nsi @@ -0,0 +1,249 @@ +Unicode true +RequestExecutionLevel admin +SetCompressor /SOLID lzma +SetDatablockOptimize on + +!include "MUI2.nsh" +!include "FileFunc.nsh" +!include "LogicLib.nsh" +!include "nsDialogs.nsh" +!include "WinMessages.nsh" +!include "x64.nsh" + +!define PRODUCT_NAME "Offloader" +!define PRODUCT_VERSION "@PRODUCT_VERSION@" +!define BUNDLE_DIR "@BUNDLE_DIR@" +!define OUTPUT_FILE "@OUTPUT_FILE@" + +Name "${PRODUCT_NAME} ${PRODUCT_VERSION}" +OutFile "${OUTPUT_FILE}" +InstallDir "$PROGRAMFILES64\Offloader" +BrandingText "Offloader" + +VIProductVersion "@WINDOWS_VERSION@" +VIAddVersionKey /LANG=1033 "ProductName" "Offloader" +VIAddVersionKey /LANG=1033 "ProductVersion" "${PRODUCT_VERSION}" +VIAddVersionKey /LANG=1033 "FileVersion" "${PRODUCT_VERSION}" +VIAddVersionKey /LANG=1033 "FileDescription" "Offloader Setup" +VIAddVersionKey /LANG=1033 "OriginalFilename" "Offloader-Setup-${PRODUCT_VERSION}.exe" +VIAddVersionKey /LANG=1033 "LegalCopyright" "Copyright (c) Owen Kent" + +Var DesktopShortcut +Var StartMenuShortcut +Var ShortcutDialog +Var PreviousInstallLocation +Var PreviousDesktopShortcut +Var PreviousStartMenuShortcut + +!define MUI_ABORTWARNING +!define MUI_WELCOMEPAGE_TITLE "Welcome to the Offloader Setup Wizard" +!define MUI_LICENSEPAGE_TEXT_TOP "Review the license before installing Offloader." +!define MUI_FINISHPAGE_NOAUTOCLOSE +!define MUI_FINISHPAGE_RUN "$INSTDIR\Offloader.exe" +!define MUI_FINISHPAGE_RUN_TEXT "Launch Offloader" +!define MUI_FINISHPAGE_RUN_FUNCTION LaunchOffloader + +!insertmacro MUI_PAGE_WELCOME +!insertmacro MUI_PAGE_LICENSE "${BUNDLE_DIR}\LICENSE" +!insertmacro MUI_PAGE_DIRECTORY +Page custom ShortcutsCreate ShortcutsLeave +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH + +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES +!insertmacro MUI_UNPAGE_FINISH + +!insertmacro MUI_LANGUAGE "English" + +Function .onInit + ${IfNot} ${RunningX64} + MessageBox MB_ICONSTOP "Offloader requires 64-bit Windows." + Abort + ${EndIf} + StrCpy $DesktopShortcut "1" + StrCpy $StartMenuShortcut "1" + SetShellVarContext all + SetRegView 64 + ${GetOptions} $CMDLINE "/D=" $0 + IfErrors 0 install_directory_done + ReadRegStr $0 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Offloader" "InstallLocation" + IfErrors install_directory_done + StrCpy $INSTDIR $0 +install_directory_done: + ClearErrors +FunctionEnd + +Function ShortcutsCreate + nsDialogs::Create 1018 + Pop $ShortcutDialog + ${If} $ShortcutDialog == error + Abort + ${EndIf} + ${NSD_CreateCheckbox} 0 8u 100% 12u "Create a desktop shortcut" + Pop $0 + ${If} $DesktopShortcut == "1" + ${NSD_SetState} $0 ${BST_CHECKED} + ${EndIf} + ${NSD_OnClick} $0 ShortcutsDesktopChanged + ${NSD_CreateCheckbox} 0 30u 100% 12u "Create Start Menu shortcuts" + Pop $1 + ${If} $StartMenuShortcut == "1" + ${NSD_SetState} $1 ${BST_CHECKED} + ${EndIf} + ${NSD_OnClick} $1 ShortcutsStartMenuChanged + nsDialogs::Show +FunctionEnd + +Function ShortcutsDesktopChanged + Pop $0 + ${NSD_GetState} $0 $1 + ${If} $1 == ${BST_CHECKED} + StrCpy $DesktopShortcut "1" + ${Else} + StrCpy $DesktopShortcut "0" + ${EndIf} +FunctionEnd + +Function ShortcutsStartMenuChanged + Pop $0 + ${NSD_GetState} $0 $1 + ${If} $1 == ${BST_CHECKED} + StrCpy $StartMenuShortcut "1" + ${Else} + StrCpy $StartMenuShortcut "0" + ${EndIf} +FunctionEnd + +Function ShortcutsLeave +FunctionEnd + +Function LaunchOffloader + ${If} ${Silent} + Return + ${EndIf} + ClearErrors + ExecWait '"$INSTDIR\offloader-maintenance.exe" launch --target "$INSTDIR"' $0 + ${If} ${Errors} + MessageBox MB_ICONEXCLAMATION "Offloader was installed but could not be launched from this setup session. Start it from the Start Menu." + Return + ${EndIf} + ${If} $0 != 0 + MessageBox MB_ICONEXCLAMATION "Offloader was installed but could not be launched from this setup session. Start it from the Start Menu." + ${EndIf} +FunctionEnd + +Section "Install Offloader" SEC_MAIN + InitPluginsDir + ReadRegStr $PreviousInstallLocation HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Offloader" "InstallLocation" + ReadRegStr $PreviousDesktopShortcut HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Offloader" "DesktopShortcut" + ReadRegStr $PreviousStartMenuShortcut HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Offloader" "StartMenuShortcut" + ClearErrors + SetOutPath "$PLUGINSDIR\payload" + File /r "${BUNDLE_DIR}\*" + WriteUninstaller "$PLUGINSDIR\payload\Uninstall.exe" + IfErrors install_failed + ClearErrors + ExecWait '"$PLUGINSDIR\payload\offloader-maintenance.exe" install --payload "$PLUGINSDIR\payload" --target "$INSTDIR"' $0 + IfErrors install_failed + ${If} $0 != 0 + Goto install_failed + ${EndIf} + SetRegView 64 + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Offloader" "DisplayName" "Offloader" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Offloader" "DisplayVersion" "${PRODUCT_VERSION}" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Offloader" "Publisher" "Offloader contributors" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Offloader" "InstallLocation" "$INSTDIR" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Offloader" "DisplayIcon" "$INSTDIR\Offloader.exe" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Offloader" "UninstallString" "$\"$INSTDIR\Uninstall.exe$\"" + WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Offloader" "NoModify" 1 + WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Offloader" "NoRepair" 1 + IfErrors install_failed + ${If} $DesktopShortcut == "1" + CreateShortCut "$DESKTOP\Offloader.lnk" "$INSTDIR\Offloader.exe" + ${ElseIf} $PreviousInstallLocation == $INSTDIR + ${If} $PreviousDesktopShortcut == "1" + ${If} ${FileExists} "$DESKTOP\Offloader.lnk" + Delete "$DESKTOP\Offloader.lnk" + IfErrors install_failed + ${EndIf} + ${EndIf} + ${EndIf} + ${If} $StartMenuShortcut == "1" + CreateDirectory "$SMPROGRAMS\Offloader" + CreateShortCut "$SMPROGRAMS\Offloader\Offloader.lnk" "$INSTDIR\Offloader.exe" + CreateShortCut "$SMPROGRAMS\Offloader\Uninstall Offloader.lnk" "$INSTDIR\Uninstall.exe" + ${ElseIf} $PreviousInstallLocation == $INSTDIR + ${If} $PreviousStartMenuShortcut == "1" + ${If} ${FileExists} "$SMPROGRAMS\Offloader\Offloader.lnk" + Delete "$SMPROGRAMS\Offloader\Offloader.lnk" + IfErrors install_failed + ${EndIf} + ${If} ${FileExists} "$SMPROGRAMS\Offloader\Uninstall Offloader.lnk" + Delete "$SMPROGRAMS\Offloader\Uninstall Offloader.lnk" + IfErrors install_failed + ${EndIf} + RMDir "$SMPROGRAMS\Offloader" + ; Keep a directory containing unrelated shortcuts or user files. + ClearErrors + ${EndIf} + ${EndIf} + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Offloader" "DesktopShortcut" "$DesktopShortcut" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Offloader" "StartMenuShortcut" "$StartMenuShortcut" + IfErrors install_failed + Goto install_done +install_failed: + SetErrorLevel 1 + ${IfNot} ${Silent} + MessageBox MB_ICONSTOP "Offloader could not safely complete this installation." + ${EndIf} + Abort +install_done: +SectionEnd + +Section "Uninstall" + SetShellVarContext all + SetRegView 64 + InitPluginsDir + ReadRegStr $PreviousInstallLocation HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Offloader" "InstallLocation" + ClearErrors + CopyFiles /SILENT "$INSTDIR\offloader-maintenance.exe" "$PLUGINSDIR\offloader-maintenance.exe" + IfErrors uninstall_failed + ClearErrors + ExecWait '"$PLUGINSDIR\offloader-maintenance.exe" uninstall --target "$INSTDIR"' $0 + IfErrors uninstall_failed + ${If} $0 != 0 + Goto uninstall_failed + ${EndIf} + StrCmp $PreviousInstallLocation $INSTDIR 0 uninstall_cleanup_done + IfFileExists "$DESKTOP\Offloader.lnk" 0 +3 + ClearErrors + Delete "$DESKTOP\Offloader.lnk" + IfErrors uninstall_failed + IfFileExists "$SMPROGRAMS\Offloader\Offloader.lnk" 0 +3 + ClearErrors + Delete "$SMPROGRAMS\Offloader\Offloader.lnk" + IfErrors uninstall_failed + IfFileExists "$SMPROGRAMS\Offloader\Uninstall Offloader.lnk" 0 +3 + ClearErrors + Delete "$SMPROGRAMS\Offloader\Uninstall Offloader.lnk" + IfErrors uninstall_failed + ClearErrors + RMDir "$SMPROGRAMS\Offloader" + ClearErrors + DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Offloader" + IfErrors uninstall_failed +uninstall_cleanup_done: + ClearErrors + RMDir "$INSTDIR" + Goto uninstall_done +uninstall_failed: + SetErrorLevel 1 + ${IfNot} ${Silent} + MessageBox MB_ICONSTOP "Offloader could not safely uninstall because it is in use or its files have changed." + ${EndIf} + Abort +uninstall_done: +SectionEnd + +@UNINSTALL_FINALIZE@ diff --git a/build/windows/installer.py b/build/windows/installer.py new file mode 100644 index 0000000..f29acf3 --- /dev/null +++ b/build/windows/installer.py @@ -0,0 +1,108 @@ +"""Assemble an NSIS installer from a verified Windows directory bundle.""" + +from __future__ import annotations + +import os +import runpy +import shutil +import subprocess +import tempfile +from pathlib import Path + +HERE = Path(__file__).resolve().parent +TEMPLATE = HERE / "installer.nsi" +REQUIRED_BUNDLE_FILES = { + "Offloader.exe", + "offloader-cli.exe", + "offloader-maintenance.exe", + "LICENSE", +} + + +def find_makensis() -> Path: + """Return NSIS's compiler, including its normal Windows installation path.""" + found = shutil.which("makensis") + if found: + return Path(found) + for variable in ("ProgramFiles(x86)", "ProgramFiles"): + value = os.environ.get(variable) + if value: + candidate = Path(value) / "NSIS" / "makensis.exe" + if candidate.is_file(): + return candidate + raise RuntimeError("makensis.exe was not found; install NSIS 3.x to build the installer") + + +def _nsi_value(value: str) -> str: + if not value or any(char in value for char in ('"', "\r", "\n", "$")): + raise ValueError("NSIS value contains an unsafe character") + return value + + +def _bundle_path(bundle: Path) -> Path: + bundle = Path(bundle).resolve() + if not bundle.is_dir(): + raise ValueError(f"bundle is not a directory: {bundle}") + missing = sorted(name for name in REQUIRED_BUNDLE_FILES if not (bundle / name).is_file()) + if missing: + raise ValueError(f"bundle is missing required files: {', '.join(missing)}") + return bundle + + +def _command_for_uninstaller(sign_command: list[str] | None) -> str: + if sign_command is None: + return "" + if not sign_command or any(not isinstance(part, str) or not part for part in sign_command): + raise ValueError("sign command must contain executable arguments") + command = f'{subprocess.list2cmdline(sign_command)} "%1"' + if any(character in command for character in ("'", "\r", "\n", "$")): + raise ValueError("sign command contains an unsafe NSIS character") + return command + + +def _render(bundle: Path, output: Path, version: str, sign_command: list[str] | None) -> str: + version_parts = runpy.run_path(str(HERE / "versioning.py"))["windows_version"](version) + values = { + "@BUNDLE_DIR@": _nsi_value(str(bundle)), + "@OUTPUT_FILE@": _nsi_value(str(output)), + "@PRODUCT_VERSION@": _nsi_value(version), + "@WINDOWS_VERSION@": ".".join(str(part) for part in version_parts), + "@UNINSTALL_FINALIZE@": ( + f"!uninstfinalize '{_command_for_uninstaller(sign_command)}' = 0" + if sign_command is not None else "" + ), + } + source = TEMPLATE.read_text(encoding="utf-8") + for token, value in values.items(): + source = source.replace(token, value) + if any(token in source for token in values): + raise RuntimeError("installer template contains an unexpanded token") + return source + + +def build_installer(bundle: Path, output: Path, version: str, *, + sign_command: list[str] | None = None) -> Path: + """Compile ``bundle`` into ``output`` and return the resulting setup path. + + ``sign_command`` signs the generated uninstaller during NSIS compilation. + The release builder must also sign the resulting setup executable afterwards. + """ + bundle = _bundle_path(bundle) + output = Path(output).resolve() + if output.suffix.lower() != ".exe": + raise ValueError("installer output must be an .exe file") + output.parent.mkdir(parents=True, exist_ok=True) + script = _render(bundle, output, version, sign_command) + compiler = find_makensis() + with tempfile.TemporaryDirectory(prefix="offloader-nsis-") as temporary: + nsi = Path(temporary) / "offloader-installer.nsi" + nsi.write_text(script, encoding="utf-8", newline="\n") + result = subprocess.run( + [str(compiler), "/V2", "/WX", "/INPUTCHARSET", "UTF8", "/NOCONFIG", str(nsi)], + check=False, + ) + if result.returncode: + raise RuntimeError(f"makensis failed with exit code {result.returncode}") + if not output.is_file(): + raise RuntimeError("makensis completed without creating the installer") + return output diff --git a/build/windows/maintenance_entry.py b/build/windows/maintenance_entry.py new file mode 100644 index 0000000..29f0479 --- /dev/null +++ b/build/windows/maintenance_entry.py @@ -0,0 +1,46 @@ +"""Frozen entry point for safe Offloader installation maintenance.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from offloader.installation import ( + InstallationBusyError, + InstallationError, + install, + launch, + uninstall, +) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Install or remove an Offloader bundle safely.") + commands = parser.add_subparsers(dest="command", required=True) + install_parser = commands.add_parser("install") + install_parser.add_argument("--payload", type=Path, required=True) + install_parser.add_argument("--target", type=Path, required=True) + uninstall_parser = commands.add_parser("uninstall") + uninstall_parser.add_argument("--target", type=Path, required=True) + launch_parser = commands.add_parser("launch") + launch_parser.add_argument("--target", type=Path, required=True) + args = parser.parse_args(argv) + try: + if args.command == "install": + install(args.payload, args.target) + elif args.command == "uninstall": + uninstall(args.target) + else: + launch(args.target) + except InstallationBusyError: + print("Offloader is running. Finish or close it before changing the installation.", file=sys.stderr) + return 3 + except (InstallationError, OSError) as exc: + print(f"Offloader maintenance failed: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/windows/offloader.spec b/build/windows/offloader.spec new file mode 100644 index 0000000..98ffa75 --- /dev/null +++ b/build/windows/offloader.spec @@ -0,0 +1,150 @@ +# -*- mode: python ; coding: utf-8 -*- +"""PyInstaller definition for Offloader's Windows applications and maintenance.""" + +from __future__ import annotations + +import re +import runpy +from importlib.metadata import version as distribution_version +from pathlib import Path + +from PyInstaller.utils.hooks import copy_metadata +from PyInstaller.utils.win32.versioninfo import ( + FixedFileInfo, + StringFileInfo, + StringStruct, + StringTable, + VarFileInfo, + VarStruct, + VSVersionInfo, +) + + +REPO = Path(SPECPATH).resolve().parents[1] +SRC = REPO / "src" +windows_version = runpy.run_path(str(REPO / "build/windows/versioning.py"))["windows_version"] + + +def project_version() -> str: + version_file = SRC / "offloader" / "_version.py" + match = re.search( + r'^__version__\s*=\s*["\']([^"\']+)["\']', + version_file.read_text(encoding="utf-8"), + re.MULTILINE, + ) + if match is None: + raise RuntimeError(f"could not read __version__ from {version_file}") + return match.group(1) + + +def version_resource(version: str, filename: str, description: str) -> VSVersionInfo: + numbers = windows_version(version) + prerelease = bool(re.search(r"(?:a|b|rc)\d+$", version)) + return VSVersionInfo( + ffi=FixedFileInfo( + filevers=numbers, + prodvers=numbers, + mask=0x3F, + flags=0x2 if prerelease else 0, + OS=0x40004, + fileType=0x1, + subtype=0x0, + date=(0, 0), + ), + kids=[ + StringFileInfo([ + StringTable("040904B0", [ + StringStruct("CompanyName", "Offloader contributors"), + StringStruct("FileDescription", description), + StringStruct("FileVersion", version), + StringStruct("InternalName", Path(filename).stem), + StringStruct("LegalCopyright", "Copyright (c) Owen Kent"), + StringStruct("OriginalFilename", filename), + StringStruct("ProductName", "Offloader"), + StringStruct("ProductVersion", version), + ]) + ]), + VarFileInfo([VarStruct("Translation", [1033, 1200])]), + ], + ) + + +VERSION = project_version() +if distribution_version("offloader") != VERSION: + raise RuntimeError( + "installed offloader metadata does not match source version; reinstall the project" + ) +COMMON = dict( + pathex=[str(SRC)], + datas=copy_metadata("offloader") + [(str(REPO / "LICENSE"), ".")], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=["opentimelineio", "otio_fcp_adapter"], + noarchive=False, + optimize=0, +) + +cli_analysis = Analysis([str(REPO / "build/windows/cli_entry.py")], **COMMON) +gui_analysis = Analysis([str(REPO / "build/windows/gui_entry.py")], **COMMON) + +cli_pyz = PYZ(cli_analysis.pure) +gui_pyz = PYZ(gui_analysis.pure) + +cli_exe = EXE( + cli_pyz, + cli_analysis.scripts, + [], + exclude_binaries=True, + name="offloader-cli", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=True, + version=version_resource(VERSION, "offloader-cli.exe", "Offloader command line"), +) +gui_exe = EXE( + gui_pyz, + gui_analysis.scripts, + [], + exclude_binaries=True, + name="Offloader", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, + version=version_resource(VERSION, "Offloader.exe", "Offloader desktop application"), +) + +# Maintenance must run outside the installation while replacing/removing it. +# A separate onefile executable needs neither installed Python nor Qt. +maintenance_analysis = Analysis( + [str(REPO / "build/windows/maintenance_entry.py")], + pathex=[str(SRC)], datas=[], hiddenimports=[], + excludes=["PySide6", "reportlab", "opentimelineio", "otio_fcp_adapter"], +) +maintenance_exe = EXE( + PYZ(maintenance_analysis.pure), + maintenance_analysis.scripts, + maintenance_analysis.binaries, + maintenance_analysis.datas, + name="offloader-maintenance", + debug=False, strip=False, upx=False, console=True, + version=version_resource(VERSION, "offloader-maintenance.exe", "Offloader maintenance"), +) + +COLLECT( + gui_exe, + cli_exe, + maintenance_exe, + gui_analysis.binaries, + gui_analysis.datas, + cli_analysis.binaries, + cli_analysis.datas, + strip=False, + upx=False, + name="Offloader", +) diff --git a/build/windows/sign.py b/build/windows/sign.py new file mode 100644 index 0000000..e3379b0 --- /dev/null +++ b/build/windows/sign.py @@ -0,0 +1,443 @@ +"""Sign and verify Windows release files with the OK Studio certificate. + +This module deliberately has no build-side effects on import. ``sign_file`` is +the only operation which may open a hardware-token prompt; callers can use +``inspect_file`` to decide which files are eligible before invoking it. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import stat +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +DEFAULT_CERT_THUMBPRINT = "FC22B5221318F3F3F6B3EB2D969D7F99091557BF" +EXPECTED_CERTIFICATE_NAME = "OK Studio Inc." +CODE_SIGNING_EKU = "1.3.6.1.5.5.7.3.3" +TIMESTAMP_URL = "http://timestamp.digicert.com" + +# DLL/PYD coverage may retain signatures from these suppliers. This is an +# identity allowlist, rather than a broad "valid signature" exception. +KNOWN_VENDOR_PUBLISHERS = frozenset( + { + ("Microsoft Corporation", "Microsoft Corporation"), + ("Microsoft Windows Software Compatibility Publisher", "Microsoft Corporation"), + ("Python Software Foundation", "Python Software Foundation"), + ("The QT Company Oy", "The QT Company Oy"), + } +) + + +class SigningError(RuntimeError): + """A signing prerequisite or signature verification requirement failed.""" + + +@dataclass(frozen=True) +class SigningConfig: + """Public signing inputs discovered during :func:`preflight`.""" + + signtool: Path + certificate_thumbprint: str + certificate_subject: str + timestamp_url: str = TIMESTAMP_URL + + def to_dict(self) -> dict[str, str]: + """Return an inventory-safe representation without private-key data.""" + return { + "signtool": str(self.signtool), + "certificate_thumbprint": self.certificate_thumbprint, + "certificate_subject": self.certificate_subject, + "timestamp_url": self.timestamp_url, + } + + +def _completed(command: list[str], *, interactive: bool = False) -> subprocess.CompletedProcess[str]: + kwargs: dict[str, Any] = { + "text": True, + "capture_output": True, + "check": False, + "timeout": 120, + } + # Token middleware may show its own dialog while signing. Keep all + # discovery and verification processes hidden so release scripts do not + # steal focus. + if not interactive and hasattr(subprocess, "CREATE_NO_WINDOW"): + kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + try: + return subprocess.run(command, **kwargs) + except (OSError, subprocess.TimeoutExpired) as error: + raise SigningError(f"could not run {command[0]}: {error}") from error + + +def _normal_thumbprint(value: str) -> str: + thumbprint = value.replace(" ", "").upper() + if not re.fullmatch(r"[0-9A-F]{40}", thumbprint): + raise SigningError("certificate thumbprint must be exactly 40 hexadecimal characters") + return thumbprint + + +def _powershell_quote(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def _powershell_json(script: str) -> dict[str, Any]: + result = _completed(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script]) + if result.returncode != 0: + raise SigningError(f"PowerShell inspection failed: {result.stderr.strip()}") + try: + data = json.loads(result.stdout) + except json.JSONDecodeError as error: + raise SigningError("PowerShell inspection returned invalid JSON") from error + if not isinstance(data, dict): + raise SigningError("PowerShell inspection returned an unexpected result") + return data + + +def _certificate_metadata(thumbprint: str) -> dict[str, Any]: + store_path = f"Cert:\\CurrentUser\\My\\{thumbprint}" + script = ( + f"$cert = Get-Item -LiteralPath {_powershell_quote(store_path)} " + "-ErrorAction SilentlyContinue; " + "$result = if ($null -eq $cert) { [pscustomobject]@{ Found = $false } } else { " + "[pscustomobject]@{ Found = $true; Subject = $cert.Subject; " + "Thumbprint = $cert.Thumbprint; NotBefore = $cert.NotBefore.ToUniversalTime().ToString('o'); " + "NotAfter = $cert.NotAfter.ToUniversalTime().ToString('o'); HasPrivateKey = $cert.HasPrivateKey; " + "Ekus = @($cert.EnhancedKeyUsageList | ForEach-Object { $_.ObjectId }) " + "} }; $result | ConvertTo-Json -Compress" + ) + return _powershell_json(script) + + +def discover_signtool() -> Path: + """Find a deterministic x64 Windows SDK SignTool, honoring an explicit override.""" + override = os.environ.get("OFFLOADER_SIGNTOOL") + if override: + candidate = Path(override).expanduser() + if not candidate.is_file(): + raise SigningError(f"OFFLOADER_SIGNTOOL is not a file: {candidate}") + return candidate.resolve() + + roots = [ + Path(os.environ.get("ProgramFiles(x86)", r"C:\\Program Files (x86)")), + Path(os.environ.get("ProgramFiles", r"C:\\Program Files")), + ] + candidates: list[Path] = [] + for root in roots: + kit = root / "Windows Kits" / "10" / "bin" + if kit.is_dir(): + candidates.extend(path for path in kit.glob("*/x64/signtool.exe") if path.is_file()) + legacy = root / "Windows Kits" / "8.1" / "bin" / "x64" / "signtool.exe" + if legacy.is_file(): + candidates.append(legacy) + if not candidates: + raise SigningError("Windows SDK SignTool x64 was not found; set OFFLOADER_SIGNTOOL") + + def sort_key(path: Path) -> tuple[tuple[int, ...], str]: + parts = tuple(int(part) for part in re.findall(r"\d+", path.parent.parent.name)) + return parts, str(path).casefold() + + return max(candidates, key=sort_key).resolve() + + +def verify_config() -> SigningConfig: + """Return the signer identity and SignTool required for offline verification. + + This intentionally does not require the certificate store, private key, or + an unexpired certificate. A valid RFC 3161 timestamp must remain + verifiable after the signing certificate expires and on a clean machine. + """ + thumbprint = _normal_thumbprint(os.environ.get("OFFLOADER_SIGN_CERT_SHA1", DEFAULT_CERT_THUMBPRINT)) + return SigningConfig( + discover_signtool(), + thumbprint, + f"CN={EXPECTED_CERTIFICATE_NAME}, O={EXPECTED_CERTIFICATE_NAME}", + ) + + +def _certificate_time(metadata: dict[str, Any], field: str) -> datetime: + try: + value = datetime.fromisoformat(str(metadata[field]).replace("Z", "+00:00")) + except (KeyError, ValueError) as error: + raise SigningError(f"selected certificate has an unreadable {field} date") from error + if value.tzinfo is None or value.utcoffset() is None: + raise SigningError(f"selected certificate has a timezone-free {field} date") + return value.astimezone(timezone.utc) + + +def preflight() -> SigningConfig: + """Require a current, code-signing capable local certificate before signing.""" + config = verify_config() + thumbprint = config.certificate_thumbprint + metadata = _certificate_metadata(thumbprint) + if metadata.get("Found") is not True: + raise SigningError(f"code-signing certificate {thumbprint} was not found in CurrentUser\\My") + actual_thumbprint = _normal_thumbprint(str(metadata.get("Thumbprint", ""))) + subject = str(metadata.get("Subject", "")) + if actual_thumbprint != thumbprint: + raise SigningError("certificate store returned a different certificate thumbprint") + attributes = _subject_attributes(subject) + if attributes.get("CN") != EXPECTED_CERTIFICATE_NAME or attributes.get("O") != EXPECTED_CERTIFICATE_NAME: + raise SigningError("selected certificate is not the OK Studio Inc. code-signing certificate") + if metadata.get("HasPrivateKey") is not True: + raise SigningError("selected certificate has no associated private key") + ekus = metadata.get("Ekus") + if not isinstance(ekus, list) or CODE_SIGNING_EKU not in ekus: + raise SigningError("selected certificate is not authorized for code signing") + now = datetime.now(timezone.utc) + if _certificate_time(metadata, "NotBefore") > now: + raise SigningError("selected certificate is not valid yet") + if _certificate_time(metadata, "NotAfter") <= now: + raise SigningError("selected certificate has expired") + return SigningConfig(config.signtool, thumbprint, subject) + + +def _subject_attributes(subject: str) -> dict[str, str]: + return { + name.upper(): value.strip() + for name, value in re.findall(r"(?:^|,)\s*([A-Za-z][A-Za-z0-9.]*)=([^,]+)", subject) + } + + +def _file(path: Path | str) -> Path: + candidate = Path(path).absolute() + if not candidate.is_file(): + raise SigningError(f"file does not exist: {candidate}") + current = candidate + while True: + if _is_reparse_point(current): + raise SigningError(f"path crosses a symlink or junction: {current}") + parent = current.parent + if parent == current: + break + current = parent + return candidate + + +def _is_reparse_point(path: Path) -> bool: + """Check junctions as well as symlinks, which ``Path.is_symlink`` misses.""" + try: + metadata = path.lstat() + except OSError as error: + raise SigningError(f"could not inspect path component {path}: {error}") from error + attributes = getattr(metadata, "st_file_attributes", 0) + reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + return path.is_symlink() or bool(attributes & reparse_point) + + +def write_record(record: dict[str, object], path: Path | str) -> Path: + """Atomically write a verified inventory record to a normal filesystem path.""" + target = Path(path).absolute() + parent = target.parent + if not parent.is_dir(): + raise SigningError(f"record directory does not exist: {parent}") + current = parent + while True: + if _is_reparse_point(current): + raise SigningError(f"record path crosses a symlink or junction: {current}") + ancestor = current.parent + if ancestor == current: + break + current = ancestor + if target.exists() and _is_reparse_point(target): + raise SigningError(f"record path is a symlink or junction: {target}") + payload = (json.dumps(record, indent=2, sort_keys=True) + "\n").encode("utf-8") + temporary_name: str | None = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", prefix=f".{target.name}.", suffix=".tmp", dir=parent, delete=False + ) as temporary: + temporary_name = temporary.name + temporary.write(payload) + temporary.flush() + os.fsync(temporary.fileno()) + os.replace(temporary_name, target) + except OSError as error: + raise SigningError(f"could not write signature record {target}: {error}") from error + finally: + if temporary_name is not None: + temporary = Path(temporary_name) + if temporary.exists(): + temporary.unlink() + return target + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def inspect_file(path: Path | str) -> dict[str, object]: + """Return Authenticode metadata without treating an unsigned file as an error. + + Build orchestration should use this before signing. Only a ``NotSigned`` + status is eligible for :func:`sign_file`; valid vendor signatures should be + retained and checked with ``verify_file(..., allow_vendor=True)``. + """ + target = _file(path) + script = ( + f"$sig = Get-AuthenticodeSignature -LiteralPath {_powershell_quote(str(target))}; " + "[pscustomobject]@{ Status = [string]$sig.Status; StatusMessage = [string]$sig.StatusMessage; " + "SignerSubject = if ($null -eq $sig.SignerCertificate) { $null } else { $sig.SignerCertificate.Subject }; " + "SignerThumbprint = if ($null -eq $sig.SignerCertificate) { $null } else { $sig.SignerCertificate.Thumbprint }; " + "TimestampSubject = if ($null -eq $sig.TimeStamperCertificate) { $null } else { $sig.TimeStamperCertificate.Subject }; " + "TimestampThumbprint = if ($null -eq $sig.TimeStamperCertificate) { $null } else { $sig.TimeStamperCertificate.Thumbprint }; " + "FileVersion = [string](Get-Item -LiteralPath " + f"{_powershell_quote(str(target))}).VersionInfo.FileVersion }} | ConvertTo-Json -Compress" + ) + metadata = _powershell_json(script) + return { + "path": str(target), + "sha256": _sha256(target), + "signature_status": str(metadata.get("Status", "")), + "signature_status_message": str(metadata.get("StatusMessage", "")), + "signer_subject": metadata.get("SignerSubject"), + "signer_thumbprint": metadata.get("SignerThumbprint"), + "timestamp_subject": metadata.get("TimestampSubject"), + "timestamp_thumbprint": metadata.get("TimestampThumbprint"), + "timestamp_present": bool(metadata.get("TimestampThumbprint")), + "file_version": metadata.get("FileVersion") or None, + } + + +def _require_success(result: subprocess.CompletedProcess[str], operation: str) -> None: + if result.returncode == 0: + return + output = f"{result.stdout}\n{result.stderr}".casefold() + if "cancel" in output or "abort" in output: + raise SigningError(f"{operation} was cancelled") + raise SigningError(f"{operation} failed with SignTool exit code {result.returncode}: {result.stderr.strip()}") + + +def _is_own_signer(record: dict[str, object], config: SigningConfig) -> bool: + subject = record.get("signer_subject") + thumbprint = record.get("signer_thumbprint") + return ( + isinstance(subject, str) + and _subject_attributes(subject).get("CN") == EXPECTED_CERTIFICATE_NAME + and _subject_attributes(subject).get("O") == EXPECTED_CERTIFICATE_NAME + and isinstance(thumbprint, str) + and _normal_thumbprint(thumbprint) == config.certificate_thumbprint + ) + + +def _is_known_vendor(record: dict[str, object]) -> bool: + subject = record.get("signer_subject") + if not isinstance(subject, str): + return False + attributes = _subject_attributes(subject) + return (attributes.get("CN"), attributes.get("O")) in KNOWN_VENDOR_PUBLISHERS + + +def _verify(path: Path | str, config: SigningConfig, expected_version: str | None, allow_vendor: bool) -> dict[str, object]: + target = _file(path) + # /tw turns a missing timestamp into a SignTool warning. Warnings have a + # non-zero exit code and are deliberately fatal here. + result = _completed( + [str(config.signtool), "verify", "/pa", "/all", "/tw", str(target)] + ) + _require_success(result, "signature verification") + record = inspect_file(target) + if record["signature_status"] != "Valid": + raise SigningError(f"Authenticode status is {record['signature_status']!r}, not Valid") + if not record["timestamp_present"]: + raise SigningError("signature has no RFC 3161 timestamp") + own_signer = _is_own_signer(record, config) + vendor_signed = _is_known_vendor(record) + if not own_signer and not (allow_vendor and vendor_signed): + raise SigningError("signature signer is not the selected OK Studio certificate") + if expected_version is not None and record["file_version"] != expected_version: + raise SigningError( + f"file version {record['file_version']!r} does not match {expected_version!r}" + ) + record["vendor_signed"] = vendor_signed and not own_signer + return record + + +def verify_file( + path: Path | str, expected_version: str | None = None, allow_vendor: bool = False +) -> dict[str, object]: + """Cryptographically verify a signed file and return a serializable record. + + ``allow_vendor`` is intended only for DLL/PYD dependency coverage. It does + not allow arbitrary trusted publishers, only ``KNOWN_VENDOR_PUBLISHERS``. + """ + return _verify(path, verify_config(), expected_version, allow_vendor) + + +def sign_file(path: Path | str) -> dict[str, object]: + """Sign a confirmed-unsigned file and return its verified inventory record.""" + target = _file(path) + before = inspect_file(target) + if before["signature_status"] != "NotSigned": + raise SigningError( + f"refusing to replace existing signature with status {before['signature_status']!r}" + ) + config = preflight() + result = _completed( + [ + str(config.signtool), + "sign", + "/sha1", + config.certificate_thumbprint, + "/fd", + "SHA256", + "/tr", + config.timestamp_url, + "/td", + "SHA256", + str(target), + ], + interactive=True, + ) + _require_success(result, "signing") + return _verify(target, config, None, False) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("preflight") + for name in ("inspect", "sign", "verify"): + command = commands.add_parser(name) + command.add_argument("path", type=Path) + command.add_argument("--version") + if name == "sign": + command.add_argument("--record", type=Path) + if name == "verify": + command.add_argument("--allow-vendor", action="store_true") + args = parser.parse_args() + try: + if args.command == "preflight": + result: dict[str, object] = preflight().to_dict() + elif args.command == "inspect": + result = inspect_file(args.path) + elif args.command == "sign": + result = sign_file(args.path) + if args.version is not None and result["file_version"] != args.version: + raise SigningError("signed file version does not match --version") + if args.record is not None: + write_record(result, args.record) + else: + result = verify_file(args.path, args.version, args.allow_vendor) + except SigningError as error: + print(f"signing error: {error}", file=sys.stderr) + return 1 + print(json.dumps(result, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/windows/smoke.py b/build/windows/smoke.py new file mode 100644 index 0000000..ba00b06 --- /dev/null +++ b/build/windows/smoke.py @@ -0,0 +1,178 @@ +"""Headless smoke checks for a completed Windows bundle.""" + +from __future__ import annotations + +import argparse +import ctypes +import os +import re +import shutil +import subprocess +import tempfile +from pathlib import Path + +HERE = Path(__file__).resolve().parent +DEFAULT_BUNDLE = HERE.parents[1] / "dist" / "windows" / "Offloader" + + +REPO = HERE.parents[1] + + +def run(command: list[str], env: dict[str, str], cwd: Path, expected: int = 0) -> str: + result = subprocess.run( + command, + env=env, + cwd=cwd, + text=True, + capture_output=True, + check=False, + timeout=120, + creationflags=subprocess.CREATE_NO_WINDOW, + ) + if result.returncode != expected: + raise RuntimeError( + f"expected exit {expected}, got {result.returncode}: {' '.join(command)}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + return result.stdout + + +def source_version() -> str: + text = (REPO / "src/offloader/_version.py").read_text(encoding="utf-8") + match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', text, re.MULTILINE) + if match is None: + raise RuntimeError("could not read source version") + return match.group(1) + + +def file_version(path: Path) -> str: + size = ctypes.windll.version.GetFileVersionInfoSizeW(str(path), None) + if not size: + raise ctypes.WinError() + buffer = ctypes.create_string_buffer(size) + if not ctypes.windll.version.GetFileVersionInfoW(str(path), 0, size, buffer): + raise ctypes.WinError() + value = ctypes.c_void_p() + length = ctypes.c_uint() + query = r"\StringFileInfo\040904B0\FileVersion" + if not ctypes.windll.version.VerQueryValueW( + buffer, query, ctypes.byref(value), ctypes.byref(length) + ): + raise ctypes.WinError() + return ctypes.wstring_at(value, length.value).rstrip("\0") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("bundle", nargs="?", type=Path, default=DEFAULT_BUNDLE) + args = parser.parse_args() + cli = args.bundle.resolve() / "offloader-cli.exe" + gui = args.bundle.resolve() / "Offloader.exe" + if not cli.is_file() or not gui.is_file(): + parser.error(f"bundle executables not found under {args.bundle}") + + with tempfile.TemporaryDirectory(prefix="offloader-smoke-") as temporary: + root = Path(temporary) + env = os.environ.copy() + for name in tuple(env): + if name.startswith("PYTHON") or name.startswith("QT_"): + env.pop(name) + env["APPDATA"] = str(root / "appdata") + env["QT_QPA_PLATFORM"] = "offscreen" + system_root = Path(env.get("SystemRoot", r"C:\Windows")) + env["PATH"] = os.pathsep.join((str(args.bundle.resolve()), str(system_root / "System32"))) + + expected_version = source_version() + version = run([str(cli), "--version"], env, root) + if version.strip() != f"Offloader {expected_version}": + raise RuntimeError(f"unexpected version output: {version!r}") + for executable in (cli, gui): + if file_version(executable) != expected_version: + raise RuntimeError(f"wrong FileVersion metadata on {executable}") + run([str(cli), "info"], env, root) + + gui_env = env.copy() + gui_env["OFFLOADER_GUI_SMOKE"] = "1" + gui_log = root / "gui-error.txt" + gui_env["OFFLOADER_GUI_SMOKE_LOG"] = str(gui_log) + try: + run([str(gui)], gui_env, root) + except RuntimeError: + if gui_log.exists(): + print(gui_log.read_text(encoding="utf-8")) + raise + + source = root / "source" + source.mkdir() + (source / "clip.bin").write_bytes(bytes(range(256)) * 32) + destinations = [root / "copy-a", root / "copy-b"] + run( + [ + str(cli), "offload", "--source", str(source), + "--dest", str(destinations[0]), "--dest", str(destinations[1]), + "--verify", "full", "--generic", + "--report", "pdf,csv,mhl,html,ascmhl", "--quiet", + ], + env, + root, + ) + for destination in destinations: + if (destination / "clip.bin").read_bytes() != (source / "clip.bin").read_bytes(): + raise RuntimeError(f"Copied bytes differ at {destination}") + names = ("JobReport.mhl",) + if destination == destinations[0]: + names += ("JobReport.pdf", "JobReport.csv", "JobReport.html") + for name in names: + if not list(destination.rglob(name)): + raise RuntimeError(f"Missing {name} at {destination}") + if not (destination / "ascmhl").is_dir(): + raise RuntimeError(f"Missing ASC MHL history at {destination}") + run( + [str(cli), "verify", str(destination), "--allow-cache", "--quiet"], + env, + root, + ) + + copied = destinations[0] / "clip.bin" + data = bytearray(copied.read_bytes()) + data[len(data) // 2] ^= 0xFF + copied.write_bytes(data) + run( + [str(cli), "verify", str(destinations[0]), "--allow-cache", "--quiet"], + env, + root, + expected=1, + ) + + # Exercise the shipped onefile helper without UAC, registry writes, + # shortcuts, or changes to a real installation. + from offloader.installation_lock import installation_lock + + helper = args.bundle.resolve() / "offloader-maintenance.exe" + installed = root / "Installed Offloader" + install_command = [str(helper), "install", "--payload", str(args.bundle.resolve()), + "--target", str(installed)] + run(install_command, env, root) + installed_cli = installed / "offloader-cli.exe" + if run([str(installed_cli), "--version"], env, root).strip() != version.strip(): + raise RuntimeError("Installed CLI version differs from the bundle") + unrelated = installed / "user-notes.txt" + unrelated.write_text("preserve this file", encoding="utf-8") + with installation_lock(installed): + run(install_command, env, root, expected=3) + run([str(helper), "uninstall", "--target", str(installed)], env, root, expected=3) + with installation_lock(installed, exclusive=True): + run([str(installed_cli), "--version"], env, root, expected=4) + run(install_command, env, root) + extracted_helper = root / "maintenance.exe" + shutil.copyfile(installed / "offloader-maintenance.exe", extracted_helper) + run([str(extracted_helper), "uninstall", "--target", str(installed)], env, root) + if installed_cli.exists() or unrelated.read_text(encoding="utf-8") != "preserve this file": + raise RuntimeError("Uninstall did not preserve the application ownership boundary") + + print(f"smoke checks passed: {args.bundle.resolve()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/windows/versioning.py b/build/windows/versioning.py new file mode 100644 index 0000000..47b475c --- /dev/null +++ b/build/windows/versioning.py @@ -0,0 +1,20 @@ +"""Map supported release versions to ordered Windows version fields.""" + +import re + + +def windows_version(version: str) -> tuple[int, int, int, int]: + match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:(a|b|rc)(\d+))?", version) + if match is None: + raise ValueError(f"Unsupported Windows bundle version: {version!r}") + major, minor, patch = (int(part) for part in match.group(1, 2, 3)) + stage = match.group(4) + sequence = int(match.group(5) or 0) + # Reserve disjoint ranges so a late alpha never sorts above the first beta. + if sequence > 999: + raise ValueError("Windows prerelease sequence must be between 0 and 999") + fourth = {"a": 1000, "b": 2000, "rc": 3000, None: 65535}[stage] + sequence + fields = (major, minor, patch, fourth) + if any(field > 65535 for field in fields): + raise ValueError(f"Windows version field exceeds 65535: {version!r}") + return fields diff --git a/docs/build-windows.md b/docs/build-windows.md new file mode 100644 index 0000000..b944711 --- /dev/null +++ b/docs/build-windows.md @@ -0,0 +1,194 @@ +# Windows builds + +The Windows builder produces a directory bundle, a portable ZIP, and an NSIS +installer named `Offloader-Setup-{version}.exe`. `Offloader.exe` (desktop) and +`offloader-cli.exe` (console) need the adjacent `_internal` directory and +`.offloader-install.lock`. Copy the entire bundle. The standalone +`offloader-maintenance.exe` manages installation and removal. + +Signing defaults on. Use `--no-sign` for development and hosted CI. Unsigned +artifacts are not release downloads. The signing and installer code is +implemented; hardware-token signing and independent clean-machine +qualification remain gates in the [release plan](release-plan.md). +Nothing is automatically published. + +## Build + +Use Windows x64 and Python 3.12. Create a clean packaging environment so an +unrelated globally installed module cannot become an accidental dependency: + +```powershell +python -m venv .venv-build +``` + +Install the project and the pinned runtime/build dependency set: + +```powershell +.\.venv-build\Scripts\python.exe -m pip install -e ".[gui]" -r requirements-build.txt +``` + +Install [NSIS 3.12](https://nsis.sourceforge.io/Download) to build an installer. +The compiler is discovered on PATH or in its standard Program Files location. +Build development artifacts from the repository root: + +```powershell +.\.venv-build\Scripts\python.exe build\windows\build.py --clean --no-sign +``` + +The script also accepts invocation by absolute path from another directory. +Output goes to `dist/windows/Offloader/`, with intermediates in +`.pyinstaller/windows/`. `--clean` clears PyInstaller's cache; it does not +delete the tracked `build/windows/` sources. Building replaces the previous +bundle, so do not build over executables currently in use. + +| Option | Behavior | +| --- | --- | +| Default | Require clean sources, sign the bundle, assemble/sign the installer, verify, smoke-test, and write inventories/checksums | +| `--no-sign` | Explicit unsigned development output; never accesses the signing key | +| `--no-installer` | Build only the portable bundle; signing still defaults on | +| `--skip-build` | Reuse only a bundle with matching source commit, source digest, version, file set, and hashes | +| `--verify-only` | Check signed artifacts, source identity, versions, and final checksums without rebuilding or accessing the key | + +The builder writes `.offloader-build.json` inside the bundle, an external +`Offloader-{version}-inventory.json` with dependency versions and signature +coverage, and `SHA256SUMS.txt` for the final installer, ZIP, and inventory. +A failed build leaves `.offloader-build-incomplete`; it must not be promoted. +Source changes during a build invalidate the candidate. These inventories are +provenance and tamper checks, not a complete third-party license inventory or SBOM. + +## Signing + +The default certificate is the existing OK Studio Inc. certificate identified +by its exact public thumbprint. `OFFLOADER_SIGN_CERT_SHA1` selects a renewed +certificate with the same company identity. `OFFLOADER_SIGNTOOL` can select a +specific Windows SDK tool; otherwise the newest installed x64 SDK is used. +Preflight checks certificate dates, code-signing usage, and private-key +association without unlocking the hardware token. No PIN or private key is +stored in the repository. + +Own executables and unsigned native dependencies receive SHA-256 Authenticode +signatures and RFC 3161 timestamps. Valid, timestamped DLL/PYD signatures from +Microsoft, Python Software Foundation, and The Qt Company are retained under +an explicit publisher allowlist. Invalid or unexpected signatures stop the +build. The generated uninstaller is signed during NSIS compilation, followed +by the setup executable. Verification requires trust, the selected signer or +approved dependency publisher, timestamps, and matching application versions. +Cancelled signing, timestamp errors, and verification warnings are failures. + +Verification needs SignTool but does not require the private key or a local +copy of the signing certificate. It can verify timestamped artifacts after +the signing certificate expires. The timestamp endpoint and verification +flags follow [SignTool's documented contract](https://learn.microsoft.com/en-us/windows/win32/seccrypto/signtool). + +## Installation safety + +The installer defaults to Program Files/Offloader with administrator approval. +Desktop and Start Menu shortcuts default on. Silent `/S` installation accepts +the standard [NSIS `/D=` contract](https://nsis.sourceforge.io/Docs/Chapter3.html): +the absolute target is last and unquoted, including when it contains spaces. + +Every installed GUI and CLI process holds a shared installation lock for its +whole lifetime. Installation and uninstall require exclusive access and fail +while any instance remains open, including an idle one. No process is killed. +New launches cannot start a job while files are being changed. The persistent +lock file remains after uninstall to avoid creating competing lock identities. +This lock protects program files; destination coordination between concurrent +offloads remains unimplemented. + +Maintenance rejects nonempty unowned targets, link/junction paths, unsafe +inventories, changed managed files, and collisions with unrelated files. +It stages and checks new files, retains old owned files for rollback, and +records interrupted work for recovery. Only inventoried application files +are removed. Per-user configuration/history and unrelated files are retained. +An incomplete-installation marker blocks application startup until recovery. + +Interactive Finish offers to launch Offloader using the non-elevated desktop +shell user's token and environment. If that identity cannot be obtained, it +asks the user to launch from Start Menu. There is no elevated fallback or +automatic launch during silent installation. + +The GUI wizard, alternate administrator credentials, shortcut behavior, and +real install/upgrade/uninstall still need clean-machine qualification. Automated +tests and temporary-directory maintenance checks do not replace those gates. + +`src/offloader/_version.py` is the version source. The Python distribution +reads its literal through setuptools dynamic metadata; the runtime imports it; +the spec reads it for Windows FileVersion and ProductVersion strings. The +build refuses stale installed Offloader metadata. Reinstall the editable +project after changing the version. No version was bumped for this slice. + +Setuptools uses `.python-build/` for its generated files, configured in +`setup.cfg`, so its source-archive cleanup does not discard the tracked +`build/windows/` scripts or NSIS template. CI checks their presence in the sdist. + +Numeric Windows versions reserve the fourth field for prerelease ordering: +alpha, beta, release candidate, then stable. Prerelease sequence numbers are +limited to 0 through 999, and every numeric field must fit in 16 bits. Other +PEP 440 forms are rejected rather than silently truncated. + +The spec excludes optional timeline import, disables UPX, and includes the +project license and distribution metadata. ffmpeg and ffprobe remain external. +Missing media tools reduce metadata/thumbnails, not copy verification. A +release-ready third-party license inventory and SBOM remain separate work. + +## Check the artifact + +Installer implementation validation on 2026-09-10 (Windows x64, Python 3.12.10, +NSIS 3.12): 727 tests passed with 5 skips and 85% line coverage. Lint passed. +The unsigned installer and portable bundle built, and the frozen smoke checks +passed, including temporary installation, reinstall, removal, lock contention, +and unrelated-file preservation. Wheel/sdist builds and isolated wheel +installation also passed. Signing preflight and read-only verification of +existing Microsoft, Python, and Qt dependency signatures passed. No hardware +signing, visible wizard, or desktop-user launch was performed. + +Historical baseline on 2026-09-10 before installer implementation (Windows x64, +Python 3.12.10): the directory +bundle built successfully and passed the smoke checks below. It contains +143,937,338 bytes before archiving. The development test environment passed +661 tests with 3 skips and 87% line coverage. Wheel/sdist builds, isolated +wheel installation, lint, and diff checks also passed. The packaging environment +uses the exact versions in `requirements-build.txt`; the source test run used +the existing development dependencies. + +[Hosted CI for 5963d04](https://github.com/owenpkent/offloader/actions/runs/34530344380) +passed on Windows, Linux, and macOS, including the Windows bundle, package +build, and property-test soak jobs. Independent clean-machine testing remains +pending. The required signed release flow is defined in the +[release plan](release-plan.md#windows-signing-flow-matching-alpha-osk). + +Run the headless smoke checks: + +```powershell +.\.venv-build\Scripts\python.exe build\windows\smoke.py +``` + +The runner checks CLI and embedded executable versions, starts the GUI with +Qt's offscreen platform, and isolates configuration in a temporary directory. +It removes development Python/Qt environment variables and media tools from +PATH. It then copies disposable data to two destinations, requests all five +report formats, re-verifies the copies, and checks that a flipped byte fails +verification. Re-verification uses `--allow-cache` so this check exercises +packaging and checksum behavior without claiming physical-drive qualification. + +The smoke runner also uses the frozen standalone maintenance helper to install, +reinstall, and uninstall in a temporary directory. It checks the installed CLI, +conflicting application/maintenance locks, and preservation of an unrelated +file. It does not run the NSIS wizard, change the registry, or create shortcuts. + +These checks do not validate installation, signing, a visible interactive +desktop, or independent hardware. The release still needs the plan's +clean-machine and real-storage tests. + +CI builds and checks unsigned installers and portable bundles on Windows and +uploads them with their inventories/checksums as `offloader-windows-unsigned`. +It never invokes the hardware key. The Python distribution job additionally +installs the wheel in a fresh environment outside the checkout, comparing +package metadata, runtime version, and CLI version. That check is implemented +in `scripts/check_wheel.py`. + +For changes to the build helpers, include them in linting: + +```powershell +python -m ruff check src tests scripts build/windows +``` diff --git a/docs/data-safety.md b/docs/data-safety.md index 42f096c..f07d01c 100644 --- a/docs/data-safety.md +++ b/docs/data-safety.md @@ -182,6 +182,13 @@ volume they are going to. ## What is still not protected +Packaged Windows applications also hold a shared installation lock for their +whole lifetime. Installer maintenance requires exclusive access and refuses +to change application files while any GUI or CLI instance remains open. +It does not terminate a transfer or coordinate destinations between offloads. +Application-file ownership, rollback, and remaining installation qualification +are described in [build-windows.md](build-windows.md#installation-safety). + Stated plainly, because a list of guarantees is only useful if its edges are known. diff --git a/docs/release-plan.md b/docs/release-plan.md new file mode 100644 index 0000000..ad02213 --- /dev/null +++ b/docs/release-plan.md @@ -0,0 +1,388 @@ +# Release plan + +Plan dated 2026-09-10. Scope: Offloader's first packaged desktop release, +using Alpha-OSK's release process as a reference. This is a proposed sequence, +not a commitment to a launch date. Completed implementation is recorded below. + +## Implementation progress + +The implementation now has a single version source in `src/offloader/_version.py`, +pinned Windows packaging dependencies, a PyInstaller bundle with GUI, CLI, and +maintenance executables, and source and bundle inventories. The Windows builder +supports the default signed flow plus `--no-sign`, `--skip-build`, +`--verify-only`, and `--no-installer`. It emits signed-build inventories and +SHA-256 checksums, and can assemble the NSIS installer. + +The installer uses transactional maintenance operations and a shared installed +GUI/CLI lifetime lock. Every running instance, including an idle one, must +close before maintenance proceeds, and maintenance never force-kills it. The +optional Finish launch runs under the desktop user's token and is being +implemented now. CI includes Windows bundle checks and fresh-environment wheel +installation. +See [build-windows.md](build-windows.md) for commands and validation details. + +Hardware-key signing, clean-machine interactive installation and alternate +credential checks, the complete third-party license inventory/SBOM, private +pilot, public release workflow, and release qualification remain pending. The +tables below retain the planned stage gates; implementation does not complete +those gates. + +## Release target + +Ship a **Windows x64 public beta** that someone without Python can install, +use to offload a card to two destinations, and independently re-verify from +the resulting manifests. Include the desktop app and a usable CLI. + +Keep macOS and Linux available from source and Python distributions initially. +Their existing CI coverage is useful, but does not establish that a packaged +desktop application works on either platform. Native installers follow their +own build and machine-validation gates. + +Start with a small private pilot, then publish a GitHub prerelease. Graduate +to a normal release after the acceptance checklist and pilot findings are +closed. A beta label does not relax any data-integrity gate. + +## Initial evidence and gaps + +Inspected local Offloader checkout at `fb59094`; working tree was clean before +this plan. Remote release state and CI results have not been checked, and no +tests or builds were run for this documentation task. + +| Area | Present in the checkout | Work needed for release | +| --- | --- | --- | +| Product | Engine, CLI, Qt desktop app, reports, BRAW/BWF support, optional timeline import | Exercise the frozen application against representative workflows | +| Version | One source in `src/offloader/_version.py` used by package metadata and the Windows bundle | Confirm the frozen release identity across all published assets | +| CI | Windows/macOS/Linux tests on Python 3.13, Linux Python 3.10, ffmpeg job, property-test soak, wheel/sdist build and metadata checks | Install built artifacts in fresh environments; build and smoke-test Windows desktop artifacts | +| Distribution | Frozen bundle, NSIS installer path, source and bundle inventories, and checksums | Hardware-key signing, clean-machine installation, release workflow, and publication documentation | +| Dependencies | Minimum versions and optional extras | Recorded build environment and pinned release dependency sets | +| Media tools | ffmpeg/ffprobe discovered externally; copying works without them | Explicit installer dependency policy and useful missing-tool messaging | +| Integrity | Detailed guarantees and remaining limits in `data-safety.md` | Release-specific regression evidence and operational validation | +| Updates | No updater found | Manual updates for the beta; documented safe upgrade and recovery | + +Sources: [`pyproject.toml`](../pyproject.toml), +[`CI`](../.github/workflows/ci.yml), [`README`](../README.md), +[`ROADMAP`](../ROADMAP.md), and [`data safety`](data-safety.md). +There are no tags in the inspected local checkout; that is not proof that +nothing has been published remotely. + +## What to take from Alpha-OSK + +Reference checkout: `C:/Users/owenp/dev/alpha-osk`. + +| Reference | Offloader adaptation | +| --- | --- | +| `src/__version__.py` and release rules in `AGENTS.md` | Establish one version source and enforce agreement before building | +| `build/windows/build.py`, `.spec`, `installer.nsh`, `sign.py` | PyInstaller bundle, NSIS installer, transactional maintenance, and explicit signing stage adapted to Offloader's entry points and dependencies | +| `docs/build/WINDOWS.md` release checklist | Publish the dependency lockfile and software bill of materials (SBOM) alongside the installer; verify embedded executable versions and signatures | +| Exact versioned installer naming | Define `Offloader-Setup-{version}.exe`; keep the contract stable | +| Dedicated `alpha-osk-releases` repository | Make the publication target explicit. Default to the existing `owenpkent/offloader` repo; a second repo is optional infrastructure | +| Clean-account installer validation | Test the downloaded, signed installer on a machine without the development environment | +| Website reads the releases API | Use GitHub Releases as the beta download page; if a website is added, avoid a second manually maintained version number | +| `docs/roadmap/LAUNCH_PLAN.md` and `launch_tasks.csv` | Keep a launch checklist with dependencies, evidence, and a small outreach phase | + +Alpha-OSK's launch plan is dated May 2026 and includes product-specific +telemetry work. Treat its dates and open checkboxes as historical context. +Its updater, elevation behavior, signing configuration, and user settings +migrations need independent design before reuse. Do not copy product IDs, +credentials, update endpoints, or installation paths. + +## Proposed scope decisions + +- **Channel:** Windows x64 beta first. Record the Windows versions actually + tested before publishing a support claim. +- **Version:** verify existing remote tags/releases first. If `0.1.0` is + unused, use `0.1.0b1` for the first public beta and `v0.1.0b1` for its tag. + Test the mapping to numeric Windows executable/installer version fields. + Otherwise choose the next unused version before freezing the candidate. +- **Install:** match Alpha-OSK's signed NSIS wizard and default to + `C:/Program Files/Offloader`, with UAC for installation. Launch the app as + the original, non-elevated user. Keep configuration/history in that user's + `%APPDATA%/Offloader`, preserving them on upgrade and ordinary uninstall. +- **Distribution:** GitHub prerelease with signed installer, SHA-256 checksums, + source commit, release notes, dependency lockfile, SBOM, and license inventory. Keep CI + wheel/sdist artifacts; defer PyPI publication until it serves an actual need. +- **Media tools:** for the first beta, keep ffmpeg/ffprobe external and clearly + identify unavailable metadata/thumbnails. A bundled build can follow after + selecting, documenting, and validating its redistribution arrangement. +- **Timeline support:** include and test OpenTimelineIO and the currently + declared adapter in the desktop bundle if timeline import is advertised for + that bundle. Otherwise mark that capability source-only for the beta. +- **Updates:** manual installation initially. Refuse replacement while the app + or CLI has an active job; never force-kill a copy to install an update. +- **Scope freeze:** defer new media features, cloud services, notifications, + auto-update, and a marketing website. Fix integrity and packaging blockers + discovered during qualification. + +These are working defaults for implementation, not statements that the +packaging or safeguards already exist. + +## Work sequence and exit criteria + +Owen owns release decisions and final publishing. Implementation and evidence +collection can be prepared in the repository. Progress is gated by results, +not elapsed time; set a public date after the first clean-machine pilot. + +| Stage | Deliverables | Exit criterion | +| --- | --- | --- | +| 1. Release foundation | Single version source; frozen scope; dependency pins; build instructions; chosen artifact names and repository | A clean checkout produces matching package/app/report versions; release identity is unambiguous | +| 2. Windows package | PyInstaller spec and entry points; icon/version metadata; NSIS installer; third-party notices; media-tool status | GUI and CLI work on a clean Windows account without Python; reports render and manifests re-verify | +| 3. Trust and installation | Signing integration; signature verification; safe active-job handling; install/upgrade/uninstall checks | Both executable and installer signatures validate; install lifecycle preserves user data and never interrupts a job | +| 4. Candidate qualification | All existing CI jobs green for the exact source commit; artifact smoke tests; integrity acceptance matrix below | Every gate has recorded evidence; no unexplained skips, false success, data loss, or blocking install defects | +| 5. Private pilot | Same candidate tested by 3 to 5 willing users on disposable copies of real media | At least 3 complete offload/re-verify workflows across 2 independent Windows machines; all blocking findings resolved and retested | +| 6. Public beta | GitHub prerelease; verified asset downloads; quick start; known limits; support instructions; brief demo | A new user can install, run, locate the reports, and report a problem using the published instructions | +| 7. Stabilize | Triage pilot/public reports; publish fixes with fresh versions; retain prior assets | At least one week of observation plus repeat acceptance evidence and no open integrity, installation, or recovery blockers before a normal release | + +Suggested implementation files: `build/windows/offloader.spec`, +`build/windows/build.py`, `build/windows/installer.nsi`, +`build/windows/sign.py`, `.github/workflows/release.yml`, and +`docs/build-windows.md`. Follow Alpha-OSK's separation of build, sign, and +publish, rather than assuming its scripts are drop-in compatible. + +The release workflow should prepare a draft with narrowly scoped permissions, +pin the source commit and build environment, and fail on version mismatch, +missing assets, failed checks, or invalid signatures. Inventory bundled native +executables and DLLs and verify their signing coverage. Sign the application +before installer assembly, sign the installer afterward, then calculate the +published hashes. Promote the exact tested artifact; a rebuild needs its own +qualification. Confirm access to the existing signing setup before making it +a build dependency. No new signing purchase or account setup is implied here. + +## Installer flow and Alpha-OSK parity + +The repository now contains the NSIS installer and transactional maintenance +path. The public Windows download will be `Offloader-Setup-{version}.exe`, a +signed NSIS installer requiring no Python. Clean-machine installation, +alternate credentials, and public release remain qualification gates. + +**First install:** open the installer, approve UAC with OK Studio Inc. shown +as publisher, then proceed through Welcome, License, Install Location, +Shortcut Options, Install Progress, and Finish. Finish offers Launch Offloader, +running under the original user's identity rather than the installer's admin +token. Desktop and Start Menu shortcuts default on; Back/Next navigation must +preserve the user's choices. The Start Menu also includes an uninstall entry. + +Alpha-OSK's additional research-participation page is product-specific. There +is no corresponding Offloader feature or consent page to add. + +| Behavior | Alpha-OSK reference | Planned Offloader behavior | +| --- | --- | --- | +| Packaging | Versioned, branded, signed NSIS setup executable | Same flow with Offloader identity, icon, artwork, and version metadata | +| Install location | Program Files x64 by default; UAC elevation | Program Files/Offloader by default, with a location page and validated target | +| Shortcuts | Desktop and Start Menu choices, checked by default; All Users context | Same choices and defaults; no automatic start-at-login registration | +| Installed components | Complete PyInstaller bundle and uninstaller | Desktop app, CLI, shared runtime, notices, and uninstaller; preserve external ffmpeg policy | +| Windows app listing | Name, version, publisher, location, icon, uninstall command | Matching Installed Apps entry with Offloader-specific keys; validate scope under alternate admin credentials | +| Finish/launch | Launch checkbox uses the original user's shell | Same user-facing launch option; never run Offloader with inherited installer elevation | +| Existing installation | Close app, remove previous installed files, replace with new version | Check GUI and CLI first; block if a transfer is active, then close idle instances gracefully before replacement | +| User state | Silent upgrade preserves learned data and settings | Preserve presets, history, and settings; installation never reads or changes camera media, destinations, or reports | +| Silent installation | `/S` and an explicit computed `/D=` target; user-context relaunch | Support silent operation and explicit validated target; active jobs cause a nonzero exit without modifying the installation | +| Uninstall | Confirmation and progress; optional removal of user data | Confirmation and progress; keep user data by default, with an explicit optional settings/history removal choice | + +**Upgrade/reinstall:** identify the existing Offloader installation, check +for active GUI and CLI jobs, and refuse replacement until they finish or the +user cancels them through Offloader. Check again before changing files to +close the race with a newly started job. For idle instances, request a normal +exit. Never use a force-kill fallback. Remove obsolete application-owned files, +install the new signed bundle, refresh shortcuts and the app listing, and +offer relaunch. Check every cleanup/install exit code; failure must not show +a success page or launch a half-installed app. Preserve recoverable prior +application files until replacement succeeds. + +**Uninstall:** apply the same active-job guard. Remove installed application +files, shortcuts, and app registration. Preserve per-user configuration unless +the user explicitly chooses its removal. Silent upgrade cleanup always keeps +user data. Remove only inventoried application files, never recursively erase +an arbitrary install directory that might contain user material. + +**Updater boundary:** installer parity includes the silent-install contract, +user-context relaunch, and safe settings preservation. An in-app download/ +update client is still a separate deferred feature. Future callers must verify +the installer signature, publisher, and version before elevation. Preserve +NSIS's `/D=` contract: last argument, unquoted even when the path has spaces, +and computed from a trusted installation target rather than an unvalidated +registry command. A generic unattended deployment must not launch an app in +a missing or unrelated user's session. + +**Acceptance gates:** exercise first install, custom path, same-version +reinstall, upgrade, failed upgrade recovery, silent install, and uninstall on +a clean Windows account. Include alternate admin credentials, Desktop/Start +Menu choices with Back/Next, settings preservation, and an active GUI or CLI +transfer during upgrade/uninstall. Confirm the signed publisher, app version, +normal-user launch, CLI behavior, and absence of any media/report changes. + +Reference code in the Alpha-OSK checkout: `build/windows/build.py` function +`_generate_nsi_script`, `build/windows/installer.nsh`, and `src/updater.py`. +Reuse the intended installer experience while testing Offloader's own identity, +state paths, file ownership, and active-job guarantees. + +## Windows signing flow, matching Alpha-OSK + +The builder implements this signing flow. Hardware-key signing remains a +release operation and is still pending qualification. `--no-sign` is the +explicit path for development and hosted CI artifacts. + +**Signing is mandatory for a Windows release, including a public beta.** +Unsigned CI artifacts are development outputs and must not be promoted to a +release download. + +Use Alpha-OSK's existing local build pattern and the same OK Studio Inc. +certificate, SafeNet hardware token, Windows SDK SignTool, and DigiCert +timestamp service. A new signing provider or hosted signing service is not +needed for this release. + +Implemented interface for `build/windows/build.py`: + +| Mode | Behavior | +| --- | --- | +| Default invocation | Build, sign application binaries, assemble NSIS installer, sign installer, verify signatures | +| `--no-sign` | Explicit unsigned development build; required in hosted PR CI; not eligible for publication | +| `--skip-build` | Repackage and sign the existing bundle after confirming its version and source identity | +| `--verify-only` | Verify existing application/installer signatures without signing or rebuilding | +| `--no-installer` | Produce the portable bundle; signing still defaults on | + +These modes are implemented; signed release use still requires the hardware +key, signature qualification, and the release gates below. +The release sequence is: + +1. Freeze the source commit and version; pass source checks. +2. Build the application bundle in the recorded packaging environment. +3. Sign the Offloader application binaries and verify native dependency + signature coverage under the policy below. +4. Assemble the installer from that signed bundle. +5. Sign the installer, then verify trust, intended publisher, timestamps, and + version metadata for the application and installer. +6. Run artifact smoke tests and clean-machine installation checks against + those exact signed files. +7. Calculate final checksums and prepare the draft release with its inventory. +8. Publish only after every gate passes and release publication is requested. + +The planned normal build command signs by default, as in Alpha-OSK. Any +signing or verification failure blocks promotion to the draft/publish stages. +A failed or cancelled hardware +prompt leaves the candidate unqualified; it must never trigger an unsigned +fallback. A rebuild or any binary change invalidates the prior qualification +and restarts signing and artifact checks. + +The workstation has Windows SDK SignTool and the existing OK Studio Inc. +code-signing certificate in the current-user store, with a private-key +association. The certificate expires on 2026-12-31; recheck validity and token +availability at release time. Store visibility does not prove the hardware +key is unlocked. + +1. **Implement release signing.** Keep development and hosted PR builds + unsigned through the explicit `--no-sign` option. Add `sign.py` as the + shared helper called by the default build for both application binaries + and the installer. Select the existing OK Studio certificate by its exact + thumbprint, with a local override for certificate renewal. Never commit a + PIN or private key. Use the current-user token setup from a normal shell. +2. **Define signature coverage.** Inventory the desktop executable, CLI, + bundled DLLs/Python extensions, and eventual installer. Decide how to + preserve valid vendor signatures and verify publisher identity for each + category before implementing bulk signing. Reject incomplete bundles and + paths that escape through symlinks or junctions. +3. **Sign and timestamp.** Use SHA-256 file and timestamp digests with RFC 3161. + The installed SignTool rejected the HTTPS form of DigiCert's timestamp URL; + Alpha-OSK's HTTP endpoint reached the signing step. Validate the returned + timestamp cryptographically. Treat missing tools, certificate problems, + cancelled PIN prompts, timestamp failures, and signing failures as failures + of the signed build. Never silently emit an unsigned release. +4. **Verify the result.** Require Authenticode trust, the intended signer, + a valid timestamp, and matching executable version metadata. Add automated + tests for wrong signer, missing timestamp, cancelled signing, and invalid + bundle paths without accessing the real hardware key. +5. **Integrate the installer.** Sign application binaries before assembling + the installer, then sign and verify the installer. Run smoke tests on the + signed output, calculate final checksums, and retain an artifact/signature + inventory. This stage depends on the installer implementation. +6. **Qualify and publish later.** On a clean Windows account, check the + displayed publisher and normal launch/install behavior. Keep hardware-key + signing on the release workstation initially; hosted PR CI should never + require the token. Publish only the exact signed artifacts that passed + qualification, after a separate release instruction. + +Implementation reference: Microsoft's +[SignTool documentation](https://learn.microsoft.com/en-us/windows/win32/seccrypto/signtool). + +## Candidate acceptance matrix + +Use dedicated fixture folders and disposable copies, never the sole copy of +production material. Record candidate version, source SHA, artifact SHA-256, +OS/filesystem, result, and evidence location for every row. + +| Gate | Required evidence | +| --- | --- | +| Fresh installation | Install and launch without Python or developer PATH entries; exercise GUI, CLI, Unicode/spaced paths, report fonts, and missing media tools | +| Ordinary offload | Copy a representative card to two destinations with full verification; independently re-verify both manifests and inspect all advertised report formats | +| Data profile | Transfer arbitrary binary files with no media tools; verify bytes and reports | +| Media profiles | BRAW with/without proxy, BWF/iXML, and common camera media; confirm unavailable thumbnails are represented honestly | +| Optional timelines | If bundled, exercise supported adapter discovery, missing media, and ambiguous basenames from the frozen application | +| Unsafe destinations | Source/destination overlap, flatten collisions, and replacement of existing good files are refused or handled without destroying the good copy | +| Failure handling | Corruption, full disk, destination disconnect, permission failure, and transient source errors never yield a false Verified verdict; reports distinguish destination outcomes | +| Job control | Pause/resume/cancel in GUI and CLI, including retry waits; finished files survive and cancelled in-flight files never appear complete | +| Re-verification | Alter a copied byte and remove a file; verification fails with actionable output and correct exit status | +| Install lifecycle | Upgrade, reinstall, ordinary uninstall, and fallback to the previous candidate preserve presets/history and leave media/reports untouched; active GUI/CLI jobs prevent replacement | +| Real storage | Removable source and two physical destinations; record devices, filesystems, elapsed time, throughput, memory, and responsiveness with the candidate version | + +Keep each existing regression test relevant to these guarantees in the release +gate. Add tests when implementation changes introduce a new failure mode; +do not substitute a source test run for validation of the shipped bundle. + +### Known limits that affect the release + +- **Concurrent writers:** destination coordination is absent. Before a public + desktop beta, add a lock respected by both GUI and CLI or an equivalent + enforced refusal of overlapping Offloader jobs, with stale-lock recovery + tests. Merely documenting the possibility of a false verdict is insufficient + for an ordinary multi-instance desktop workflow. +- **Case-insensitive targets off Windows:** defer native macOS/Linux packages + until destination-aware collision handling is implemented or those targets + are safely refused. Keep the existing source-use limitation visible. +- **Size-only skip:** expose its actual meaning wherever it is offered; a + skipped file must not be presented as checksum-verified by this run. Confirm + this behavior in both UI and reports, and fix any misleading presentation. +- **ASC MHL structure:** file verification does not yet check directory + structure hashes. Describe the supported verification scope accurately; + structure verification remains the next trust improvement in the roadmap. +- **Mutable sources and hardware caches:** carry the existing documented + limits into release notes. Do not promise an independent source reread or + physical persistence beyond what the implementation establishes. + +## Publish and recover + +- [ ] Record the final candidate's source SHA, checks, machine results, and + pilot findings in a versioned release record. +- [ ] Update `CHANGELOG.md`, installation instructions, supported platforms, + dependencies, and known limits to match the final bundle. +- [ ] Prepare release notes explaining what ships, how to install, how to + verify a transfer, and how to report a problem. Include a screenshot and a + short card-to-two-destinations demo using non-sensitive sample material. +- [ ] Verify signing and checksums on assets downloaded from the draft. +- [ ] Confirm every Windows download passed the mandatory signing flow above; + block publication if any artifact is unsigned, has the wrong signer, lacks + a valid timestamp, or differs from the qualified candidate. +- [ ] Review the concrete draft, then publish the prerelease and source tag + against the tested commit. Keep repository targets explicit. +- [ ] Validate the public download links, perform one installed smoke test, + and watch incoming issues closely during the first 48 hours. +- [ ] Prepare outreach for existing relevant contacts/communities after the + download works. Sending messages and posting announcements are separate + actions from preparing this release plan. + +If a candidate reports false success, damages data, or cannot safely complete +installation, halt promotion immediately. Mark the affected release clearly, +direct users to the previous tested installer where one exists, and publish +a corrected candidate under a new version. Never silently replace an existing +version's executable. Keep prior assets and checksums available for diagnosis. + +For the first beta, there may be no previous packaged release: withdrawal and +an explicit known-issue notice are the recovery path until a fix qualifies. +Any downgrade must be tested against the user's current configuration/history +format; do not ask users to delete state as the default recovery procedure. + +## First implementation slice + +Version unification, artifact identity and inventory, the NSIS installer path, +transactional maintenance, shared installed-instance locking, signing hooks, +and checksum records are implemented. The clean-account GUI walkthrough, +hardware-key signing, alternate-credential install checks, complete license +inventory/SBOM, private pilot, release workflow, and candidate qualification +remain to be done. diff --git a/pyproject.toml b/pyproject.toml index fc3b3d5..b5a7e38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "offloader" -version = "0.1.0" +dynamic = ["version"] description = "Verified copy for large data transfers, with camera-card offload, ShotPut Pro-compatible reports, ASC MHL, and Blackmagic RAW support" readme = "README.md" requires-python = ">=3.10" @@ -75,6 +75,9 @@ offloader-gui = "offloader.gui.app:main" [tool.setuptools.packages.find] where = ["src"] +[tool.setuptools.dynamic] +version = {attr = "offloader._version.__version__"} + [tool.pytest.ini_options] testpaths = ["tests"] # Qt runs headless because each GUI test module sets QT_QPA_PLATFORM before diff --git a/requirements-build.txt b/requirements-build.txt new file mode 100644 index 0000000..9588abf --- /dev/null +++ b/requirements-build.txt @@ -0,0 +1,16 @@ +# Windows x64 / Python 3.12 packaging environment. Refresh and smoke-test together. +PyInstaller==6.22.2 +pyinstaller-hooks-contrib==2026.7 +altgraph==0.17.5 +packaging==26.3 +pefile==2024.8.26 +pywin32-ctypes==0.2.3 +setuptools==84.0.0 +xxhash==4.0.1 +reportlab==5.0.1 +pillow==12.3.0 +charset-normalizer==3.5.1 +PySide6==6.11.2 +PySide6_Addons==6.11.2 +PySide6_Essentials==6.11.2 +shiboken6==6.11.2 diff --git a/scripts/check_wheel.py b/scripts/check_wheel.py new file mode 100644 index 0000000..b712406 --- /dev/null +++ b/scripts/check_wheel.py @@ -0,0 +1,56 @@ +"""Install a built wheel away from the checkout and check its public entry points.""" + +from __future__ import annotations + +import argparse +import os +import runpy +import subprocess +import tempfile +import venv +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("wheel", type=Path, help="Wheel to install in a fresh environment") + args = parser.parse_args() + wheel = args.wheel.resolve(strict=True) + expected = runpy.run_path(str(ROOT / "src/offloader/_version.py"))["__version__"] + env = os.environ.copy() + for name in ("PYTHONPATH", "PYTHONHOME"): + env.pop(name, None) + flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 + with tempfile.TemporaryDirectory(prefix="offloader-wheel-") as directory: + work = Path(directory) + venv.EnvBuilder(with_pip=True).create(work / "venv") + scripts = work / "venv" / ("Scripts" if os.name == "nt" else "bin") + python = scripts / ("python.exe" if os.name == "nt" else "python") + + def run(*command: str) -> str: + result = subprocess.run( + command, cwd=work, env=env, check=True, capture_output=True, + text=True, timeout=180, creationflags=flags, + ) + return result.stdout.strip() + + run(str(python), "-I", "-m", "pip", "install", str(wheel)) + reported = run( + str(python), "-I", "-c", + "from importlib.metadata import version; import offloader; " + "print(version('offloader')); print(offloader.__version__)", + ) + if reported.splitlines() != [expected, expected]: + raise RuntimeError(f"Wheel version mismatch: {reported!r}; expected {expected}") + cli = scripts / ("offloader.exe" if os.name == "nt" else "offloader") + if run(str(cli), "--version") != f"Offloader {expected}": + raise RuntimeError("Installed CLI version differs from the release version") + run(str(cli), "info") + run(str(python), "-I", "-m", "offloader", "--help") + print(f"Wheel install, metadata, and CLI passed: {wheel.name}") + + +if __name__ == "__main__": + main() diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..3a94ec9 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,4 @@ +[build] +# Keep setuptools' generated files separate from the tracked Windows builder. +# Otherwise sdist prunes build/windows even when MANIFEST.in includes it. +build_base = .python-build diff --git a/src/offloader/__init__.py b/src/offloader/__init__.py index dcbebda..1b626cd 100644 --- a/src/offloader/__init__.py +++ b/src/offloader/__init__.py @@ -1,6 +1,6 @@ """Verified copy for large data transfers, with camera-card offload and ShotPut Pro-compatible reporting built in.""" -__version__ = "0.1.0" +from ._version import __version__ as __version__ PRODUCT_NAME = "Offloader" diff --git a/src/offloader/_version.py b/src/offloader/_version.py new file mode 100644 index 0000000..572f389 --- /dev/null +++ b/src/offloader/_version.py @@ -0,0 +1,3 @@ +"""Release version, read statically by setuptools and the desktop build.""" + +__version__ = "0.1.0" diff --git a/src/offloader/installation.py b/src/offloader/installation.py new file mode 100644 index 0000000..4be1327 --- /dev/null +++ b/src/offloader/installation.py @@ -0,0 +1,725 @@ +"""Safe, inventory-backed lifecycle operations for a Windows installation. + +This module deliberately knows nothing about NSIS. The installer extracts a +complete, signed payload and asks the standalone maintenance executable to use +these operations. Keeping ownership checks here makes upgrade and uninstall +safe even when invoked without the wizard. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +import stat +import uuid +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path, PurePath +from typing import Any + +from .installation_lock import InstallationBusyError, installation_lock + +MANIFEST_NAME = ".offloader-install.json" +MARKER_NAME = ".offloader-installing" +LOCK_NAME = ".offloader-install.lock" +FORMAT_VERSION = 1 +MAX_MANIFEST_BYTES = 4 * 1024 * 1024 +RESERVED_ROOT_NAMES = {LOCK_NAME, MANIFEST_NAME, MARKER_NAME, "Uninstall.exe"} +BUILD_METADATA_NAME = ".offloader-build.json" +IGNORED_PAYLOAD_NAMES = {LOCK_NAME, BUILD_METADATA_NAME} +WINDOWS_RESERVED_NAMES = { + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", + "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", + "LPT6", "LPT7", "LPT8", "LPT9", +} + + +class InstallationError(RuntimeError): + """An installation target or its contents are unsafe to change.""" + + +@dataclass(frozen=True) +class Inventory: + """The exact application files that the lifecycle is allowed to manage.""" + + files: dict[str, dict[str, Any]] + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for block in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _raise_walk_error(error: OSError) -> None: + raise error + + +def _path_exists_or_link(path: Path) -> bool: + return path.exists() or path.is_symlink() + + +def _is_reparse(path: Path) -> bool: + """Treat Windows junctions and symlinks as unsafe path indirections.""" + try: + info = path.lstat() + except OSError: + raise + attributes = getattr(info, "st_file_attributes", 0) + return stat.S_ISLNK(info.st_mode) or bool(attributes & stat.FILE_ATTRIBUTE_REPARSE_POINT) + + +def _safe_relative(value: str) -> Path: + if not isinstance(value, str) or not value or "\x00" in value: + raise InstallationError("inventory contains an invalid path") + candidate = PurePath(value) + raw_parts = value.split("/") + if (candidate.is_absolute() or any(part in ("", ".", "..") for part in raw_parts) + or any(part[-1:] in (" ", ".") for part in raw_parts)): + raise InstallationError(f"inventory path is unsafe: {value!r}") + # A Windows manifest must remain safe when inspected on a non-Windows host. + if ":" in value or "\\" in value or any( + any(char in '<>:"\\|?*' or ord(char) < 32 for char in part) + or part.split(".", 1)[0].upper() in WINDOWS_RESERVED_NAMES + for part in raw_parts): + raise InstallationError(f"inventory path is unsafe: {value!r}") + return Path(*raw_parts) + + +def _path_under(root: Path, relative: str) -> Path: + return root / _safe_relative(relative) + + +def _assert_contained_path(root: Path, path: Path) -> None: + """Ensure an existing child path cannot escape through a reparse ancestor.""" + try: + relative = path.relative_to(root) + except ValueError as exc: + raise InstallationError("path escapes its installation root") from exc + current = root + if _is_reparse(current): + raise InstallationError("installation root is a reparse point") + for part in relative.parts: + current /= part + if _path_exists_or_link(current) and _is_reparse(current): + raise InstallationError(f"reparse point is not allowed in path: {current}") + + +def _validate_tree_root(path: Path, *, allow_missing: bool = False) -> None: + """Reject a reparse point at the root or any existing ancestor.""" + absolute = path.absolute() + if not absolute.is_absolute(): + raise InstallationError("installation target must be absolute") + probe = absolute + missing: list[Path] = [] + while not probe.exists() and not probe.is_symlink(): + missing.append(probe) + if probe.parent == probe: + break + probe = probe.parent + if not probe.exists() and not allow_missing: + raise InstallationError(f"path does not exist: {path}") + chain = [probe] + while chain[-1].parent != chain[-1]: + chain.append(chain[-1].parent) + for item in reversed(chain): + if _path_exists_or_link(item) and _is_reparse(item): + raise InstallationError(f"reparse point is not allowed in path: {item}") + + +def _validate_payload(payload: Path) -> Inventory: + payload = payload.absolute() + if not payload.is_dir(): + raise InstallationError("payload must be a directory") + _validate_tree_root(payload) + files: dict[str, dict[str, Any]] = {} + casefolded: set[str] = set() + for current, dirs, names in os.walk(payload, followlinks=False, onerror=_raise_walk_error): + current_path = Path(current) + for directory in dirs: + if _is_reparse(current_path / directory): + raise InstallationError("payload contains a symlink or junction") + for name in names: + item = current_path / name + if _is_reparse(item) or not item.is_file(): + raise InstallationError("payload contains an unsafe non-file") + relative = item.relative_to(payload).as_posix() + _safe_relative(relative) + first = relative.split("/", 1)[0] + if relative.casefold() in {name.casefold() for name in IGNORED_PAYLOAD_NAMES}: + continue + if (first.casefold() in {name.casefold() for name in RESERVED_ROOT_NAMES - {"Uninstall.exe"}} + or first.casefold().startswith(".offloader-")): + raise InstallationError(f"payload uses a reserved installation path: {relative}") + folded = relative.casefold() + if folded in casefolded: + raise InstallationError("payload has case-colliding file names") + casefolded.add(folded) + files[relative] = {"sha256": _sha256(item), "size": item.stat().st_size} + if not files: + raise InstallationError("payload contains no files") + return Inventory(files) + + +def _inventory_data(inventory: Inventory) -> dict[str, Any]: + return {"format": FORMAT_VERSION, "files": inventory.files} + + +def _parse_inventory(data: Any) -> Inventory: + if not isinstance(data, dict) or data.get("format") != FORMAT_VERSION: + raise InstallationError("installation manifest has an unsupported format") + records = data.get("files") + if not isinstance(records, dict) or not records: + raise InstallationError("installation manifest has no files") + parsed: dict[str, dict[str, Any]] = {} + casefolded: set[str] = set() + for relative, record in records.items(): + _safe_relative(relative) + first = relative.split("/", 1)[0] + if (first.casefold() in {name.casefold() for name in RESERVED_ROOT_NAMES - {"Uninstall.exe"}} + or first.casefold().startswith(".offloader-")): + raise InstallationError("installation manifest claims a reserved path") + if not isinstance(record, dict): + raise InstallationError("installation manifest has an invalid record") + digest = record.get("sha256") + size = record.get("size") + if (not isinstance(digest, str) or len(digest) != 64 + or any(char not in "0123456789abcdef" for char in digest.lower()) + or not isinstance(size, int) or size < 0): + raise InstallationError("installation manifest has an invalid file hash") + folded = relative.casefold() + if folded in casefolded: + raise InstallationError("installation manifest has case-colliding files") + casefolded.add(folded) + parsed[relative] = {"sha256": digest.lower(), "size": size} + return Inventory(parsed) + + +def _read_json(path: Path) -> Any: + try: + with path.open("rb") as handle: + raw = handle.read(MAX_MANIFEST_BYTES + 1) + if len(raw) > MAX_MANIFEST_BYTES: + raise InstallationError(f"{path.name} is too large") + return json.loads(raw.decode("utf-8")) + except InstallationError: + raise + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise InstallationError(f"could not read {path.name}") from exc + + +def _read_manifest(target: Path) -> Inventory | None: + path = target / MANIFEST_NAME + if not _path_exists_or_link(path): + return None + if _is_reparse(path) or not path.is_file(): + raise InstallationError("installation manifest is unsafe") + return _parse_inventory(_read_json(path)) + + +def _write_json_atomic(path: Path, data: Any) -> None: + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + try: + with temporary.open("x", encoding="utf-8", newline="\n") as handle: + json.dump(data, handle, sort_keys=True, separators=(",", ":")) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + if temporary.exists(): + temporary.unlink() + + +def _assert_file_matches(path: Path, record: dict[str, Any], *, root: Path | None = None) -> None: + if root is not None: + _assert_contained_path(root, path) + if _is_reparse(path) or not path.is_file() or path.stat().st_size != record["size"]: + raise InstallationError(f"managed file was changed or is unsafe: {path}") + if _sha256(path) != record["sha256"]: + raise InstallationError(f"managed file was changed: {path}") + + +def _validate_owned_files(target: Path, inventory: Inventory) -> None: + for relative, record in inventory.files.items(): + path = _path_under(target, relative) + _assert_contained_path(target, path) + _assert_file_matches(path, record, root=target) + + +def _target_is_root(target: Path) -> bool: + return target == Path(target.anchor) + + +def _assert_target_preflight(payload: Path, target: Path) -> None: + if not target.is_absolute(): + raise InstallationError("installation target must be absolute") + if _target_is_root(target): + raise InstallationError("installation target cannot be a drive root") + if os.name == "nt" and str(target).startswith(("\\\\", "\\\\?\\", "\\\\.\\")): + raise InstallationError("installation target must be on a local drive") + _validate_tree_root(target, allow_missing=True) + payload_resolved = payload.resolve(strict=True) + target_resolved = target.resolve(strict=False) + try: + target_resolved.relative_to(payload_resolved) + except ValueError: + pass + else: + raise InstallationError("installation target cannot be inside the payload") + try: + payload_resolved.relative_to(target_resolved) + except ValueError: + pass + else: + raise InstallationError("payload cannot be inside the installation target") + + +def _allowed_root_entries(inventory: Inventory | None) -> set[str]: + entries = {LOCK_NAME, MANIFEST_NAME, MARKER_NAME} + if inventory is not None: + entries.update(path.split("/", 1)[0] for path in inventory.files) + return entries + + +def _assert_fresh_target(target: Path) -> None: + if not target.exists(): + return + manifest = _read_manifest(target) + if manifest is not None: + return + entries = {item.name for item in target.iterdir()} + if entries - {LOCK_NAME}: + raise InstallationError("refusing to install into a nonempty unowned directory") + + +def _assert_destination_collisions(target: Path, old: Inventory | None, + new: Inventory) -> None: + old_files = old.files if old else {} + for relative in new.files: + destination = _path_under(target, relative) + _assert_contained_path(target, destination) + for parent in destination.parents: + if parent == target.parent: + break + if parent.exists() and (not parent.is_dir() or _is_reparse(parent)): + raise InstallationError(f"unowned path blocks installation: {parent}") + if parent == target: + break + if _path_exists_or_link(destination) and relative not in old_files: + raise InstallationError(f"unowned file collides with installation: {destination}") + if _path_exists_or_link(destination) and destination.is_dir(): + raise InstallationError(f"directory collides with application file: {destination}") + if old is None: + for item in target.iterdir(): + if item.name not in _allowed_root_entries(old): + raise InstallationError(f"unowned path exists in installation: {item}") + + +def _make_transaction(target: Path, old: Inventory | None, new: Inventory, stage: str) -> dict[str, Any]: + token = uuid.uuid4().hex + backup = f".offloader-backup-{token}" + marker = { + "format": FORMAT_VERSION, + "stage": stage, + "backup": backup, + "old": _inventory_data(old) if old else None, + "new": _inventory_data(new), + "moved_old": [], + "promoted": [], + "moving_old": None, + "moving_promoted": None, + "phase": "mutating", + } + _write_json_atomic(target / MARKER_NAME, marker) + return marker + + +def _transaction_path(target: Path, value: Any, prefix: str) -> Path: + if not isinstance(value, str) or not re.fullmatch(rf"{re.escape(prefix)}[0-9a-f]{{32}}", value): + raise InstallationError("installation recovery marker is unsafe") + path = target / value + if _is_reparse(path) if _path_exists_or_link(path) else False: + raise InstallationError("installation recovery directory is unsafe") + return path + + +def _read_transaction(target: Path) -> tuple[dict[str, Any], Inventory | None, Inventory]: + marker = target / MARKER_NAME + if _is_reparse(marker) or not marker.is_file(): + raise InstallationError("installation recovery marker is unsafe") + data = _read_json(marker) + if not isinstance(data, dict) or data.get("format") != FORMAT_VERSION: + raise InstallationError("installation recovery marker is invalid") + old_data = data.get("old") + old = _parse_inventory(old_data) if old_data is not None else None + new = _parse_inventory(data.get("new")) + if data.get("phase") not in ("mutating", "committing", "committed"): + raise InstallationError("installation recovery marker is invalid") + for key in ("moved_old", "promoted"): + values = data.get(key) + if not isinstance(values, list) or any(not isinstance(value, str) for value in values): + raise InstallationError("installation recovery marker is invalid") + if len(values) != len(set(values)): + raise InstallationError("installation recovery marker is invalid") + if any(value not in (old.files if old else {}) for value in data["moved_old"]): + raise InstallationError("installation recovery marker is invalid") + if any(value not in new.files for value in data["promoted"]): + raise InstallationError("installation recovery marker is invalid") + for key, records in (("moving_old", old.files if old else {}), + ("moving_promoted", new.files)): + value = data.get(key) + if value is not None and (not isinstance(value, str) or value not in records): + raise InstallationError("installation recovery marker is invalid") + _transaction_path(target, data.get("stage"), ".offloader-stage-") + _transaction_path(target, data.get("backup"), ".offloader-backup-") + return data, old, new + + +def _save_transaction(target: Path, transaction: dict[str, Any]) -> None: + _write_json_atomic(target / MARKER_NAME, transaction) + + +def _remove_owned_tree(root: Path, allowed: Iterable[str]) -> None: + """Remove a temporary tree only when every file is one we created.""" + allowed_set = set(allowed) + if not root.exists(): + return + if _is_reparse(root) or not root.is_dir(): + raise InstallationError("transaction directory is unsafe") + found: set[str] = set() + directories: list[Path] = [] + for current, dirs, names in os.walk(root, topdown=True, followlinks=False, + onerror=_raise_walk_error): + current_path = Path(current) + directories.append(current_path) + for directory in dirs: + if _is_reparse(current_path / directory): + raise InstallationError("transaction directory is unsafe") + for name in names: + item = current_path / name + if _is_reparse(item) or not item.is_file(): + raise InstallationError("transaction directory is unsafe") + found.add(item.relative_to(root).as_posix()) + if not found.issubset(allowed_set): + raise InstallationError("transaction directory contains unowned files") + for relative in found: + (root / relative).unlink() + for directory in reversed(directories): + directory.rmdir() + + +def _recover(target: Path) -> None: + marker = target / MARKER_NAME + if not marker.exists(): + return + data = _read_json(marker) + if isinstance(data, dict) and data.get("action") == "uninstall": + _recover_uninstall(target, data) + return + transaction, old, new = _read_transaction(target) + stage = _transaction_path(target, transaction["stage"], ".offloader-stage-") + backup = _transaction_path(target, transaction["backup"], ".offloader-backup-") + _reconcile_install_intents(target, transaction, old, new, stage, backup) + if transaction["phase"] == "committing": + manifest = _read_manifest(target) + if manifest is None: + transaction["phase"] = "mutating" + _save_transaction(target, transaction) + elif manifest != new: + raise InstallationError("installation manifest does not match a committing update") + else: + transaction["phase"] = "committed" + _save_transaction(target, transaction) + if transaction["phase"] == "committed": + _validate_owned_files(target, new) + _remove_owned_tree(stage, new.files) + _remove_owned_tree(backup, old.files if old else ()) + marker.unlink() + return + for relative in tuple(transaction["promoted"]): + destination = _path_under(target, relative) + if _path_exists_or_link(destination): + _assert_file_matches(destination, new.files[relative], root=target) + destination.unlink() + transaction["promoted"].remove(relative) + _save_transaction(target, transaction) + if old is not None: + for relative in tuple(transaction["moved_old"]): + source = _path_under(backup, relative) + destination = _path_under(target, relative) + _assert_contained_path(target, destination) + if _path_exists_or_link(source): + _assert_file_matches(source, old.files[relative], root=backup) + if _path_exists_or_link(destination): + raise InstallationError("cannot safely recover an occupied application path") + destination.parent.mkdir(parents=True, exist_ok=True) + os.replace(source, destination) + elif _path_exists_or_link(destination): + _assert_file_matches(destination, old.files[relative], root=target) + else: + raise InstallationError("cannot safely recover a missing application file") + transaction["moved_old"].remove(relative) + _save_transaction(target, transaction) + _write_json_atomic(target / MANIFEST_NAME, _inventory_data(old)) + else: + manifest = target / MANIFEST_NAME + if manifest.exists(): + raise InstallationError("cannot safely recover an unexpected manifest") + _remove_owned_tree(stage, new.files) + _remove_owned_tree(backup, old.files if old else ()) + marker.unlink() + + +def _reconcile_install_intents(target: Path, transaction: dict[str, Any], old: Inventory | None, + new: Inventory, stage: Path, backup: Path) -> None: + """Resolve the tiny crash interval between a recorded intent and os.replace.""" + moving_old = transaction["moving_old"] + if moving_old is not None: + record = old.files[moving_old] if old else None + source = _path_under(target, moving_old) + destination = _path_under(backup, moving_old) + if source.exists() and not destination.exists(): + _assert_file_matches(source, record, root=target) + elif not source.exists() and destination.exists(): + _assert_file_matches(destination, record, root=backup) + transaction["moved_old"].append(moving_old) + else: + raise InstallationError("cannot safely reconcile an interrupted backup move") + transaction["moving_old"] = None + _save_transaction(target, transaction) + moving_new = transaction["moving_promoted"] + if moving_new is not None: + source = _path_under(stage, moving_new) + destination = _path_under(target, moving_new) + if source.exists() and not destination.exists(): + _assert_file_matches(source, new.files[moving_new], root=stage) + elif not source.exists() and destination.exists(): + _assert_file_matches(destination, new.files[moving_new], root=target) + transaction["promoted"].append(moving_new) + else: + raise InstallationError("cannot safely reconcile an interrupted promotion") + transaction["moving_promoted"] = None + _save_transaction(target, transaction) + + +def _recover_uninstall(target: Path, transaction: Any) -> None: + """Restore an interrupted uninstall before any new maintenance operation.""" + if not isinstance(transaction, dict) or transaction.get("format") != FORMAT_VERSION: + raise InstallationError("installation recovery marker is invalid") + old = _parse_inventory(transaction.get("old")) + moved = transaction.get("moved_old") + if (not isinstance(moved, list) or any(not isinstance(item, str) for item in moved) + or len(moved) != len(set(moved)) or any(item not in old.files for item in moved)): + raise InstallationError("installation recovery marker is invalid") + backup = _transaction_path(target, transaction.get("backup"), ".offloader-uninstall-") + phase = transaction.get("phase") + if phase not in ("mutating", "committed"): + raise InstallationError("installation recovery marker is invalid") + moving = transaction.get("moving_old") + if moving is not None and (not isinstance(moving, str) or moving not in old.files): + raise InstallationError("installation recovery marker is invalid") + if moving is not None: + source = _path_under(target, moving) + destination = _path_under(backup, moving) + if source.exists() and not destination.exists(): + _assert_file_matches(source, old.files[moving], root=target) + elif not source.exists() and destination.exists(): + _assert_file_matches(destination, old.files[moving], root=backup) + moved.append(moving) + else: + raise InstallationError("cannot safely reconcile an interrupted uninstall") + transaction["moving_old"] = None + _write_json_atomic(target / MARKER_NAME, transaction) + if phase == "committed": + if (target / MANIFEST_NAME).exists(): + raise InstallationError("completed uninstall still has an installation manifest") + _remove_owned_tree(backup, old.files) + (target / MARKER_NAME).unlink() + return + for relative in tuple(moved): + source = _path_under(backup, relative) + destination = _path_under(target, relative) + _assert_contained_path(target, destination) + if _path_exists_or_link(source): + _assert_file_matches(source, old.files[relative], root=backup) + if _path_exists_or_link(destination): + raise InstallationError("cannot safely recover an occupied application path") + destination.parent.mkdir(parents=True, exist_ok=True) + os.replace(source, destination) + elif _path_exists_or_link(destination): + _assert_file_matches(destination, old.files[relative], root=target) + else: + raise InstallationError("cannot safely recover a missing application file") + moved.remove(relative) + _write_json_atomic(target / MARKER_NAME, transaction) + _write_json_atomic(target / MANIFEST_NAME, _inventory_data(old)) + _remove_owned_tree(backup, old.files) + (target / MARKER_NAME).unlink() + + +def _copy_to_stage(payload: Path, stage: Path, inventory: Inventory) -> None: + stage.mkdir() + try: + for relative, record in inventory.files.items(): + source = _path_under(payload, relative) + destination = _path_under(stage, relative) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) + _assert_file_matches(destination, record, root=stage) + except Exception: + _remove_owned_tree(stage, inventory.files) + raise + + +def install(payload: Path, target: Path) -> None: + """Install ``payload`` into ``target`` without touching unowned files.""" + payload = Path(payload).absolute() + requested_target = Path(target) + if not requested_target.is_absolute(): + raise InstallationError("installation target must be absolute") + target = requested_target.absolute() + new = _validate_payload(payload) + _assert_target_preflight(payload, target) + try: + target.mkdir(parents=True, exist_ok=True) + with installation_lock(target, exclusive=True): + _recover(target) + _assert_fresh_target(target) + old = _read_manifest(target) + if old is not None: + _validate_owned_files(target, old) + _assert_destination_collisions(target, old, new) + stage = target / f".offloader-stage-{uuid.uuid4().hex}" + transaction = _make_transaction(target, old, new, stage.name) + backup = target / transaction["backup"] + try: + _copy_to_stage(payload, stage, new) + backup.mkdir() + if old is not None: + for relative in old.files: + source = _path_under(target, relative) + destination = _path_under(backup, relative) + destination.parent.mkdir(parents=True, exist_ok=True) + _assert_contained_path(target, source) + _assert_contained_path(backup, destination) + transaction["moving_old"] = relative + _save_transaction(target, transaction) + os.replace(source, destination) + transaction["moved_old"].append(relative) + transaction["moving_old"] = None + _save_transaction(target, transaction) + for relative in new.files: + source = _path_under(stage, relative) + destination = _path_under(target, relative) + destination.parent.mkdir(parents=True, exist_ok=True) + _assert_contained_path(stage, source) + _assert_contained_path(target, destination) + transaction["moving_promoted"] = relative + _save_transaction(target, transaction) + os.replace(source, destination) + transaction["promoted"].append(relative) + transaction["moving_promoted"] = None + _save_transaction(target, transaction) + transaction["phase"] = "committing" + _save_transaction(target, transaction) + _write_json_atomic(target / MANIFEST_NAME, _inventory_data(new)) + transaction["phase"] = "committed" + _save_transaction(target, transaction) + _remove_owned_tree(stage, new.files) + _remove_owned_tree(backup, old.files if old else ()) + (target / MARKER_NAME).unlink() + except Exception: + if transaction.get("phase") != "committed": + _recover(target) + raise + except InstallationBusyError: + raise + + +def uninstall(target: Path) -> None: + """Remove only exact, unchanged files listed in the installation manifest.""" + requested_target = Path(target) + if not requested_target.is_absolute(): + raise InstallationError("installation target must be an absolute non-root directory") + target = requested_target.absolute() + if _target_is_root(target): + raise InstallationError("installation target must be an absolute non-root directory") + _validate_tree_root(target) + try: + with installation_lock(target, exclusive=True): + _recover(target) + inventory = _read_manifest(target) + if inventory is None: + raise InstallationError("no Offloader installation manifest found") + _validate_owned_files(target, inventory) + backup = target / f".offloader-uninstall-{uuid.uuid4().hex}" + backup.mkdir() + marker = { + "format": FORMAT_VERSION, + "action": "uninstall", + "backup": backup.name, + "old": _inventory_data(inventory), + "moved_old": [], + "moving_old": None, + "phase": "mutating", + } + _write_json_atomic(target / MARKER_NAME, marker) + try: + for relative in inventory.files: + source = _path_under(target, relative) + destination = _path_under(backup, relative) + destination.parent.mkdir(parents=True, exist_ok=True) + _assert_contained_path(target, source) + _assert_contained_path(backup, destination) + marker["moving_old"] = relative + _write_json_atomic(target / MARKER_NAME, marker) + os.replace(source, destination) + marker["moved_old"].append(relative) + marker["moving_old"] = None + _write_json_atomic(target / MARKER_NAME, marker) + manifest = target / MANIFEST_NAME + manifest.unlink() + marker["phase"] = "committed" + _write_json_atomic(target / MARKER_NAME, marker) + _remove_owned_tree(backup, inventory.files) + (target / MARKER_NAME).unlink() + # Only remove empty directories created by this application. + for relative in sorted(inventory.files, key=lambda value: value.count("/"), reverse=True): + directory = _path_under(target, relative).parent + while directory != target: + try: + directory.rmdir() + except OSError: + break + directory = directory.parent + except Exception: + if marker.get("phase") != "committed": + _recover(target) + raise + except InstallationBusyError: + raise + + +def launch(target: Path) -> None: + """Launch the exact installed GUI under the interactive desktop user.""" + requested_target = Path(target) + if not requested_target.is_absolute() or _target_is_root(requested_target): + raise InstallationError("installation target must be an absolute non-root directory") + target = requested_target.absolute() + _validate_tree_root(target) + with installation_lock(target): + if _path_exists_or_link(target / MARKER_NAME): + raise InstallationError("installation is incomplete; re-run Setup to recover it") + inventory = _read_manifest(target) + if inventory is None or "Offloader.exe" not in inventory.files: + raise InstallationError("no complete Offloader desktop installation was found") + executable = target / "Offloader.exe" + _assert_file_matches(executable, inventory.files["Offloader.exe"], root=target) + from .windows_launch import launch_as_desktop_user + + launch_as_desktop_user(executable) diff --git a/src/offloader/installation_lock.py b/src/offloader/installation_lock.py new file mode 100644 index 0000000..8de7460 --- /dev/null +++ b/src/offloader/installation_lock.py @@ -0,0 +1,123 @@ +"""Keep installed programs and maintenance from running at the same time. + +This lock protects application files only. It does not coordinate copy jobs or +their destinations. Windows releases use a shared byte-range lock opened for +reading, so a normal user needs no write access to Program Files. +""" + +from __future__ import annotations + +import os +import stat +import sys +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + +LOCK_NAME = ".offloader-install.lock" +INCOMPLETE_NAME = ".offloader-installing" + + +class InstallationBusyError(OSError): + """The installation is in use, being changed, or needs recovery.""" + + +def _check_lock_path(path: Path) -> None: + for candidate in (path, *path.parents): + try: + info = candidate.lstat() + except FileNotFoundError: + continue + if stat.S_ISLNK(info.st_mode) or getattr(info, "st_file_attributes", 0) & 0x400: + raise OSError(f"Installation lock cannot use a link or reparse point: {candidate}") + + +@contextmanager +def _windows_lock(path: Path, exclusive: bool) -> Iterator[None]: + import ctypes + from ctypes import wintypes + + class Overlapped(ctypes.Structure): + _fields_ = [ + ("Internal", ctypes.c_size_t), ("InternalHigh", ctypes.c_size_t), + ("Offset", wintypes.DWORD), ("OffsetHigh", wintypes.DWORD), + ("hEvent", wintypes.HANDLE), + ] + + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kernel.CreateFileW.argtypes = [ + wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p, + wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE, + ] + kernel.CreateFileW.restype = wintypes.HANDLE + kernel.LockFileEx.argtypes = [ + wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD, + wintypes.DWORD, ctypes.POINTER(Overlapped), + ] + kernel.LockFileEx.restype = wintypes.BOOL + kernel.CloseHandle.argtypes = [wintypes.HANDLE] + kernel.CloseHandle.restype = wintypes.BOOL + # No FILE_SHARE_DELETE: replacing the lock would create a second lock domain. + handle = kernel.CreateFileW(str(path), 0x80000000, 3, None, + 4 if exclusive else 3, 0x00200000, None) + if handle == wintypes.HANDLE(-1).value: + raise ctypes.WinError(ctypes.get_last_error()) + try: + overlapped = Overlapped() + if not kernel.LockFileEx(handle, 1 | (2 if exclusive else 0), 0, 1, 0, + ctypes.byref(overlapped)): + error = ctypes.get_last_error() + if error in (32, 33, 997): + raise InstallationBusyError( + "Offloader is running or installation maintenance is in progress. " + "Close Offloader and its CLI, then try again." + ) + raise ctypes.WinError(error) + yield + finally: + # Closing releases the lock, including on exceptions or process exit. + kernel.CloseHandle(handle) + + +@contextmanager +def installation_lock(root: Path, *, exclusive: bool = False) -> Iterator[None]: + """Take a nonblocking lock, exclusive for maintenance, shared for programs. + + Maintenance creates the lock only after validating its target. Programs + require the existing lock so missing installation metadata fails closed. + The file must remain in place across upgrades and uninstall. + """ + path = Path(root).absolute() / LOCK_NAME + _check_lock_path(path) + if sys.platform == "win32": + with _windows_lock(path, exclusive): + yield + else: + # Also exercise the maintenance state machine on non-Windows CI. + import fcntl + + fd = os.open(path, os.O_RDONLY | (os.O_CREAT if exclusive else 0), 0o644) + try: + try: + fcntl.flock(fd, (fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH) + | fcntl.LOCK_NB) + except BlockingIOError as exc: + raise InstallationBusyError("Offloader installation is in use") from exc + yield + finally: + os.close(fd) + + +@contextmanager +def frozen_installation_lock() -> Iterator[None]: + """Hold the shared lock for the complete lifetime of a frozen application.""" + if not getattr(sys, "frozen", False): + yield + return + root = Path(sys.executable).absolute().parent + with installation_lock(root): + if (root / INCOMPLETE_NAME).exists(): + raise InstallationBusyError( + "Offloader installation is incomplete. Re-run the installer to recover it." + ) + yield diff --git a/src/offloader/windows_launch.py b/src/offloader/windows_launch.py new file mode 100644 index 0000000..af404c4 --- /dev/null +++ b/src/offloader/windows_launch.py @@ -0,0 +1,217 @@ +"""Launch an installed executable with the unelevated interactive shell token.""" + +from __future__ import annotations + +import ctypes +import subprocess +import sys +from ctypes import wintypes +from pathlib import Path + +PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 +TOKEN_ASSIGN_PRIMARY = 0x0001 +TOKEN_DUPLICATE = 0x0002 +TOKEN_QUERY = 0x0008 +TOKEN_ELEVATION = 20 +TOKEN_PRIMARY = 1 +SECURITY_IMPERSONATION = 2 +LOGON_WITH_PROFILE = 1 +CREATE_UNICODE_ENVIRONMENT = 0x00000400 + + +class _StartupInfo(ctypes.Structure): + _fields_ = [ + ("cb", wintypes.DWORD), ("lpReserved", wintypes.LPWSTR), + ("lpDesktop", wintypes.LPWSTR), ("lpTitle", wintypes.LPWSTR), + ("dwX", wintypes.DWORD), ("dwY", wintypes.DWORD), + ("dwXSize", wintypes.DWORD), ("dwYSize", wintypes.DWORD), + ("dwXCountChars", wintypes.DWORD), ("dwYCountChars", wintypes.DWORD), + ("dwFillAttribute", wintypes.DWORD), ("dwFlags", wintypes.DWORD), + ("wShowWindow", wintypes.WORD), ("cbReserved2", wintypes.WORD), + ("lpReserved2", ctypes.POINTER(ctypes.c_byte)), + ("hStdInput", wintypes.HANDLE), ("hStdOutput", wintypes.HANDLE), + ("hStdError", wintypes.HANDLE), + ] + + +class _ProcessInformation(ctypes.Structure): + _fields_ = [ + ("hProcess", wintypes.HANDLE), ("hThread", wintypes.HANDLE), + ("dwProcessId", wintypes.DWORD), ("dwThreadId", wintypes.DWORD), + ] + + +class _TokenElevation(ctypes.Structure): + _fields_ = [("TokenIsElevated", wintypes.DWORD)] + + +class _WindowsApi: + """Small native boundary so launch policy can be tested without Win32 calls.""" + + def __init__(self) -> None: + self.kernel = ctypes.WinDLL("kernel32", use_last_error=True) + self.advapi = ctypes.WinDLL("advapi32", use_last_error=True) + self.userenv = ctypes.WinDLL("userenv", use_last_error=True) + self.user = ctypes.WinDLL("user32", use_last_error=True) + self.user.GetShellWindow.restype = wintypes.HWND + self.user.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)] + self.user.GetWindowThreadProcessId.restype = wintypes.DWORD + self.kernel.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + self.kernel.OpenProcess.restype = wintypes.HANDLE + self.kernel.ProcessIdToSessionId.argtypes = [wintypes.DWORD, ctypes.POINTER(wintypes.DWORD)] + self.kernel.ProcessIdToSessionId.restype = wintypes.BOOL + self.kernel.CloseHandle.argtypes = [wintypes.HANDLE] + self.kernel.CloseHandle.restype = wintypes.BOOL + self.advapi.OpenProcessToken.argtypes = [ + wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE), + ] + self.advapi.OpenProcessToken.restype = wintypes.BOOL + self.advapi.GetTokenInformation.argtypes = [ + wintypes.HANDLE, wintypes.DWORD, ctypes.c_void_p, wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + ] + self.advapi.GetTokenInformation.restype = wintypes.BOOL + self.advapi.DuplicateTokenEx.argtypes = [ + wintypes.HANDLE, wintypes.DWORD, ctypes.c_void_p, wintypes.DWORD, + wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE), + ] + self.advapi.DuplicateTokenEx.restype = wintypes.BOOL + self.advapi.CreateProcessWithTokenW.argtypes = [ + wintypes.HANDLE, wintypes.DWORD, wintypes.LPCWSTR, wintypes.LPWSTR, + wintypes.DWORD, ctypes.c_void_p, wintypes.LPCWSTR, + ctypes.POINTER(_StartupInfo), ctypes.POINTER(_ProcessInformation), + ] + self.advapi.CreateProcessWithTokenW.restype = wintypes.BOOL + self.userenv.CreateEnvironmentBlock.argtypes = [ + ctypes.POINTER(ctypes.c_void_p), wintypes.HANDLE, wintypes.BOOL, + ] + self.userenv.CreateEnvironmentBlock.restype = wintypes.BOOL + self.userenv.DestroyEnvironmentBlock.argtypes = [ctypes.c_void_p] + self.userenv.DestroyEnvironmentBlock.restype = wintypes.BOOL + + @staticmethod + def _error() -> OSError: + return ctypes.WinError(ctypes.get_last_error()) + + def desktop_shell_process_id(self) -> int: + window = self.user.GetShellWindow() + if not window: + raise OSError("Windows desktop shell is unavailable") + process_id = wintypes.DWORD() + if not self.user.GetWindowThreadProcessId(window, ctypes.byref(process_id)) or not process_id.value: + raise self._error() + return process_id.value + + def is_current_session(self, process_id: int) -> bool: + shell_session = wintypes.DWORD() + current_session = wintypes.DWORD() + if not self.kernel.ProcessIdToSessionId(process_id, ctypes.byref(shell_session)): + raise self._error() + current_pid = self.kernel.GetCurrentProcessId() + if not self.kernel.ProcessIdToSessionId(current_pid, ctypes.byref(current_session)): + raise self._error() + return shell_session.value == current_session.value + + def open_process_query(self, process_id: int) -> wintypes.HANDLE: + handle = self.kernel.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, process_id) + if not handle: + raise self._error() + return handle + + def open_process_token(self, process: wintypes.HANDLE) -> wintypes.HANDLE: + token = wintypes.HANDLE() + if not self.advapi.OpenProcessToken( + process, TOKEN_QUERY | TOKEN_DUPLICATE, ctypes.byref(token) + ): + raise self._error() + return token + + def token_is_elevated(self, token: wintypes.HANDLE) -> bool: + elevation = _TokenElevation() + size = wintypes.DWORD() + if not self.advapi.GetTokenInformation( + token, TOKEN_ELEVATION, ctypes.byref(elevation), ctypes.sizeof(elevation), ctypes.byref(size) + ): + raise self._error() + return bool(elevation.TokenIsElevated) + + def duplicate_primary_token(self, token: wintypes.HANDLE) -> wintypes.HANDLE: + primary = wintypes.HANDLE() + desired_access = TOKEN_QUERY | TOKEN_DUPLICATE | TOKEN_ASSIGN_PRIMARY + if not self.advapi.DuplicateTokenEx( + token, desired_access, None, SECURITY_IMPERSONATION, TOKEN_PRIMARY, ctypes.byref(primary) + ): + raise self._error() + return primary + + def create_environment_block(self, shell_token: wintypes.HANDLE) -> ctypes.c_void_p: + environment = ctypes.c_void_p() + # Do not inherit variables such as APPDATA from the elevated installer. + if not self.userenv.CreateEnvironmentBlock(ctypes.byref(environment), shell_token, False): + raise self._error() + return environment + + def destroy_environment_block(self, environment: ctypes.c_void_p) -> None: + # Cleanup cannot justify retaining the desktop-user token or its profile. + self.userenv.DestroyEnvironmentBlock(environment) + + def create_process_with_token( + self, token: wintypes.HANDLE, executable: Path, command_line: str, cwd: Path, + environment: ctypes.c_void_p, + ) -> tuple[wintypes.HANDLE, wintypes.HANDLE]: + startup = _StartupInfo() + startup.cb = ctypes.sizeof(startup) + process = _ProcessInformation() + command_buffer = ctypes.create_unicode_buffer(command_line) + if not self.advapi.CreateProcessWithTokenW( + token, LOGON_WITH_PROFILE, str(executable), command_buffer, CREATE_UNICODE_ENVIRONMENT, + environment, str(cwd), + ctypes.byref(startup), ctypes.byref(process), + ): + raise self._error() + return process.hProcess, process.hThread + + def close_handle(self, handle: wintypes.HANDLE) -> None: + if handle: + self.kernel.CloseHandle(handle) + + +def _windows_api() -> _WindowsApi: + return _WindowsApi() + + +def launch_as_desktop_user(executable: Path) -> None: + """Start ``executable`` under the unelevated same-session Explorer token. + + This has no elevated or ordinary-subprocess fallback. An installer that + cannot prove the shell identity must leave Finish launch unavailable. + """ + if sys.platform != "win32": + raise OSError("desktop-user launch is only available on Windows") + target = Path(executable) + if not target.is_absolute() or not target.is_file(): + raise OSError("desktop-user launch requires an existing absolute executable path") + api = _windows_api() + process = token = primary = environment = child_process = child_thread = None + try: + shell_pid = api.desktop_shell_process_id() + if not api.is_current_session(shell_pid): + raise OSError("Windows desktop shell is not in the installer session") + process = api.open_process_query(shell_pid) + token = api.open_process_token(process) + if api.token_is_elevated(token): + raise OSError("Windows desktop shell token is elevated") + primary = api.duplicate_primary_token(token) + environment = api.create_environment_block(token) + command_line = subprocess.list2cmdline([str(target)]) + child_process, child_thread = api.create_process_with_token( + primary, target, command_line, target.parent, environment + ) + finally: + try: + if environment is not None: + api.destroy_environment_block(environment) + finally: + for handle in (child_thread, child_process, primary, token, process): + if handle is not None: + api.close_handle(handle) diff --git a/tests/test_control.py b/tests/test_control.py index 404fd26..5d040c2 100644 --- a/tests/test_control.py +++ b/tests/test_control.py @@ -5,6 +5,7 @@ import threading import time from pathlib import Path +from types import SimpleNamespace from offloader import engine from offloader.models import FileStatus, VerificationMode @@ -206,9 +207,10 @@ def test_file_control_pauses_and_resumes_a_running_offload(tmp_path: Path): control=control)), daemon=True, ) - worker.start() - + # The first checkpoint must see pause, even when the whole fixture could + # finish before the next rate-limited control-file poll. path.write_text("pause\n", encoding="utf-8") + worker.start() deadline = time.monotonic() + 5 while not control.paused and time.monotonic() < deadline: time.sleep(0.02) @@ -239,7 +241,11 @@ def test_deleting_the_control_file_releases_a_paused_job(tmp_path: Path): assert not control.paused -def test_file_control_cancels_a_running_offload(tmp_path: Path): +def test_file_control_cancels_a_running_offload(tmp_path: Path, monkeypatch): + clock = [1000.0] + monkeypatch.setattr( + engine, "time", SimpleNamespace(monotonic=lambda: clock[0], sleep=time.sleep), + ) source = tmp_path / "card" source.mkdir() for index in range(12): @@ -258,6 +264,9 @@ def progress(event: engine.ProgressEvent) -> None: seen.add(event.file_name) if len(seen) == 3: path.write_text("cancel\n", encoding="utf-8") + # poll=0 is clamped to 50 ms. Make the next real checkpoint poll + # eligible without depending on disk speed or sleeping in the test. + clock[0] += 1.0 job = engine.run(source, _options(tmp_path), progress, control) @@ -288,8 +297,8 @@ def test_cancel_reaches_a_job_that_is_already_paused(tmp_path: Path): control=control)), daemon=True, ) - worker.start() path.write_text("pause\n", encoding="utf-8") + worker.start() deadline = time.monotonic() + 5 while not control.paused and time.monotonic() < deadline: time.sleep(0.02) diff --git a/tests/test_installation.py b/tests/test_installation.py new file mode 100644 index 0000000..bba9338 --- /dev/null +++ b/tests/test_installation.py @@ -0,0 +1,262 @@ +"""The installer may only replace files it can identify exactly.""" + +from __future__ import annotations + +import json + +import pytest + +from offloader.installation import ( + LOCK_NAME, + MANIFEST_NAME, + InstallationBusyError, + InstallationError, + install, + launch, + uninstall, +) +from offloader.installation_lock import installation_lock + + +def _payload(root, text: str = "first"): + payload = root / "payload" + (payload / "runtime").mkdir(parents=True) + (payload / "Offloader.exe").write_text(text, encoding="utf-8") + (payload / "runtime" / "support.dll").write_text("support", encoding="utf-8") + (payload / "offloader-maintenance.exe").write_text("helper", encoding="utf-8") + return payload + + +class _SimulatedStop(BaseException): + """A process stop that intentionally bypasses the maintenance exception handler.""" + + +def test_install_upgrade_and_uninstall_preserve_unowned_file(tmp_path): + target = tmp_path / "installed" + first = _payload(tmp_path / "one") + install(first, target) + extra = target / "readme-from-user.txt" + extra.write_text("keep me", encoding="utf-8") + second = _payload(tmp_path / "two", "second") + + install(second, target) + + assert (target / "Offloader.exe").read_text(encoding="utf-8") == "second" + assert extra.read_text(encoding="utf-8") == "keep me" + uninstall(target) + assert extra.read_text(encoding="utf-8") == "keep me" + assert not (target / MANIFEST_NAME).exists() + assert (target / LOCK_NAME).exists() + + +def test_install_refuses_unowned_collision_without_changing_existing_file(tmp_path): + target = tmp_path / "installed" + target.mkdir() + existing = target / "Offloader.exe" + existing.write_text("not ours", encoding="utf-8") + + with pytest.raises(InstallationError, match="nonempty unowned"): + install(_payload(tmp_path / "payload"), target) + + assert existing.read_text(encoding="utf-8") == "not ours" + + +def test_install_refuses_malformed_manifest_and_keeps_files(tmp_path): + target = tmp_path / "installed" + target.mkdir() + manifest = target / MANIFEST_NAME + manifest.write_text('{"format": 1, "files": {"../outside": {}}}', encoding="utf-8") + + with pytest.raises(InstallationError): + install(_payload(tmp_path / "payload"), target) + + assert manifest.exists() + + +def test_payload_cannot_claim_installer_manifest(tmp_path): + payload = _payload(tmp_path / "payload") + (payload / MANIFEST_NAME).write_text("not payload-owned", encoding="utf-8") + + with pytest.raises(InstallationError, match="reserved"): + install(payload, tmp_path / "installed") + + +def test_failed_upgrade_restores_previous_inventory(tmp_path, monkeypatch): + target = tmp_path / "installed" + install(_payload(tmp_path / "first", "old"), target) + replacement = _payload(tmp_path / "second", "new") + import offloader.installation as installation + + real_replace = installation.os.replace + + def fail_promotion(source, destination): + if str(source).endswith("Offloader.exe") and ".offloader-stage-" in str(source): + raise OSError("simulated disk failure") + return real_replace(source, destination) + + monkeypatch.setattr(installation.os, "replace", fail_promotion) + with pytest.raises(OSError, match="simulated disk failure"): + install(replacement, target) + + assert (target / "Offloader.exe").read_text(encoding="utf-8") == "old" + assert not (target / ".offloader-installing").exists() + assert json.loads((target / MANIFEST_NAME).read_text(encoding="utf-8"))["format"] == 1 + + +def test_active_application_lock_blocks_maintenance(tmp_path): + target = tmp_path / "installed" + payload = _payload(tmp_path / "payload") + install(payload, target) + + with installation_lock(target): + with pytest.raises(InstallationBusyError): + install(payload, target) + + +def test_uninstall_refuses_changed_managed_file(tmp_path): + target = tmp_path / "installed" + install(_payload(tmp_path / "payload"), target) + app = target / "Offloader.exe" + app.write_text("changed", encoding="utf-8") + + with pytest.raises(InstallationError, match="changed"): + uninstall(target) + + assert app.read_text(encoding="utf-8") == "changed" + + +def test_launch_only_uses_the_verified_installed_desktop_executable(tmp_path, monkeypatch): + target = tmp_path / "installed" + install(_payload(tmp_path / "payload"), target) + import offloader.windows_launch as windows_launch + + launched = [] + monkeypatch.setattr(windows_launch, "launch_as_desktop_user", launched.append) + + launch(target) + + assert launched == [target / "Offloader.exe"] + + +def test_recovery_reconciles_stop_after_old_file_move_before_journal_save(tmp_path, monkeypatch): + target = tmp_path / "installed" + first = _payload(tmp_path / "first", "old") + second = _payload(tmp_path / "second", "new") + install(first, target) + import offloader.installation as installation + + real_replace = installation.os.replace + + def stop_after_backup(source, destination): + result = real_replace(source, destination) + if str(source).endswith("Offloader.exe") and ".offloader-backup-" in str(destination): + raise _SimulatedStop() + return result + + monkeypatch.setattr(installation.os, "replace", stop_after_backup) + with pytest.raises(_SimulatedStop): + install(second, target) + monkeypatch.setattr(installation.os, "replace", real_replace) + + install(first, target) + + assert (target / "Offloader.exe").read_text(encoding="utf-8") == "old" + assert not (target / ".offloader-installing").exists() + + +def test_recovery_rolls_forward_stop_after_manifest_write(tmp_path, monkeypatch): + target = tmp_path / "installed" + first = _payload(tmp_path / "first", "old") + second = _payload(tmp_path / "second", "new") + install(first, target) + import offloader.installation as installation + + real_write = installation._write_json_atomic + old_digest = json.loads((target / MANIFEST_NAME).read_text(encoding="utf-8"))["files"][ + "Offloader.exe" + ]["sha256"] + + def stop_after_manifest(path, data): + real_write(path, data) + if path.name == MANIFEST_NAME and data["files"]["Offloader.exe"]["sha256"] != old_digest: + raise _SimulatedStop() + + monkeypatch.setattr(installation, "_write_json_atomic", stop_after_manifest) + with pytest.raises(_SimulatedStop): + install(second, target) + monkeypatch.setattr(installation, "_write_json_atomic", real_write) + + install(second, target) + + assert (target / "Offloader.exe").read_text(encoding="utf-8") == "new" + assert not (target / ".offloader-installing").exists() + + +def test_recovery_retries_after_stop_during_rollback(tmp_path, monkeypatch): + target = tmp_path / "installed" + first = _payload(tmp_path / "first", "old") + second = _payload(tmp_path / "second", "new") + install(first, target) + import offloader.installation as installation + + real_replace = installation.os.replace + state = {"promotion_failed": False} + + def stop_during_rollback(source, destination): + source_text = str(source) + destination_text = str(destination) + if ".offloader-stage-" in source_text and source_text.endswith("Offloader.exe"): + state["promotion_failed"] = True + raise OSError("simulated promotion failure") + result = real_replace(source, destination) + if (state["promotion_failed"] and ".offloader-backup-" in source_text + and source_text.endswith("Offloader.exe") and destination_text.endswith("Offloader.exe")): + raise _SimulatedStop() + return result + + monkeypatch.setattr(installation.os, "replace", stop_during_rollback) + with pytest.raises(_SimulatedStop): + install(second, target) + monkeypatch.setattr(installation.os, "replace", real_replace) + + install(first, target) + + assert (target / "Offloader.exe").read_text(encoding="utf-8") == "old" + assert not (target / ".offloader-installing").exists() + + +@pytest.mark.parametrize("operation", ["install", "uninstall"]) +def test_committed_cleanup_failure_recovers_without_touching_unowned_file( + tmp_path, monkeypatch, operation): + target = tmp_path / "installed" + first = _payload(tmp_path / "first", "old") + second = _payload(tmp_path / "second", "new") + install(first, target) + keep = target / "keep.txt" + keep.write_text("user data", encoding="utf-8") + import offloader.installation as installation + + real_cleanup = installation._remove_owned_tree + + def fail_backup_cleanup(root, allowed): + if ".offloader-backup-" in root.name or ".offloader-uninstall-" in root.name: + raise OSError("simulated cleanup failure") + return real_cleanup(root, allowed) + + monkeypatch.setattr(installation, "_remove_owned_tree", fail_backup_cleanup) + with pytest.raises(OSError, match="cleanup"): + if operation == "install": + install(second, target) + else: + uninstall(target) + monkeypatch.setattr(installation, "_remove_owned_tree", real_cleanup) + + if operation == "install": + install(second, target) + assert (target / "Offloader.exe").read_text(encoding="utf-8") == "new" + else: + with pytest.raises(InstallationError, match="no Offloader installation manifest"): + uninstall(target) + assert not (target / "Offloader.exe").exists() + assert keep.read_text(encoding="utf-8") == "user data" + assert not (target / ".offloader-installing").exists() diff --git a/tests/test_installation_lock.py b/tests/test_installation_lock.py new file mode 100644 index 0000000..914afa8 --- /dev/null +++ b/tests/test_installation_lock.py @@ -0,0 +1,97 @@ +"""A running application must exclude maintenance across process boundaries.""" + +import os +import subprocess +import sys + +import pytest + +from offloader.installation_lock import ( + INCOMPLETE_NAME, + LOCK_NAME, + InstallationBusyError, + frozen_installation_lock, + installation_lock, +) + + +def attempt_lock(root, exclusive=False, crash=False): + script = ( + "import os, sys\n" + "from pathlib import Path\n" + "from offloader.installation_lock import installation_lock, InstallationBusyError\n" + "try:\n" + f" with installation_lock(Path(sys.argv[1]), exclusive={exclusive!r}):\n" + f" {'os._exit(0)' if crash else 'pass'}\n" + "except InstallationBusyError:\n" + " sys.exit(7)\n" + ) + result = subprocess.run( + [sys.executable, "-c", script, str(root)], + capture_output=True, text=True, timeout=15, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + assert result.returncode in (0, 7), result.stderr + return result.returncode + + +def test_running_applications_share_lock_and_block_maintenance(tmp_path): + with installation_lock(tmp_path, exclusive=True): + pass + with installation_lock(tmp_path): + assert attempt_lock(tmp_path) == 0 + assert attempt_lock(tmp_path, exclusive=True) == 7 + assert attempt_lock(tmp_path, exclusive=True) == 0 + + +def test_maintenance_blocks_new_launches_and_other_installers(tmp_path): + with installation_lock(tmp_path, exclusive=True): + assert attempt_lock(tmp_path) == 7 + assert attempt_lock(tmp_path, exclusive=True) == 7 + + +def test_crashed_process_does_not_leave_stale_lock(tmp_path): + assert attempt_lock(tmp_path, exclusive=True, crash=True) == 0 + assert attempt_lock(tmp_path, exclusive=True) == 0 + + +def test_launch_requires_existing_lock(tmp_path): + with pytest.raises(FileNotFoundError): + with installation_lock(tmp_path): + pytest.fail("missing installation metadata must not allow startup") + + +def test_lock_released_after_exception(tmp_path): + with pytest.raises(ValueError), installation_lock(tmp_path, exclusive=True): + raise ValueError("failed installation") + assert attempt_lock(tmp_path, exclusive=True) == 0 + + +def test_incomplete_installation_cannot_launch(tmp_path, monkeypatch): + (tmp_path / LOCK_NAME).touch() + (tmp_path / INCOMPLETE_NAME).touch() + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "executable", str(tmp_path / "Offloader.exe")) + with pytest.raises(InstallationBusyError, match="incomplete"): + with frozen_installation_lock(): + pytest.fail("incomplete installation launched") + + +def test_lock_rejects_symbolic_links(tmp_path): + original = tmp_path / "other" + original.touch() + try: + (tmp_path / LOCK_NAME).symlink_to(original) + except OSError: + pytest.skip("symlink creation requires Windows developer mode") + with pytest.raises(OSError, match="reparse"): + with installation_lock(tmp_path, exclusive=True): + pytest.fail("linked lock accepted") + assert original.read_bytes() == b"" + + +@pytest.mark.skipif(os.name != "nt", reason="Windows file-sharing contract") +def test_lock_cannot_be_replaced_while_held(tmp_path): + with installation_lock(tmp_path, exclusive=True): + with pytest.raises(PermissionError): + (tmp_path / LOCK_NAME).unlink() diff --git a/tests/test_windows_artifacts.py b/tests/test_windows_artifacts.py new file mode 100644 index 0000000..22b846b --- /dev/null +++ b/tests/test_windows_artifacts.py @@ -0,0 +1,98 @@ +"""Artifact identity and bundle record checks.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +MODULE = Path(__file__).parents[1] / "build/windows/artifacts.py" +SPEC = importlib.util.spec_from_file_location("windows_artifacts", MODULE) +assert SPEC and SPEC.loader +artifacts = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(artifacts) + + +def _bundle(root: Path) -> Path: + bundle = root / "Offloader" + internal = bundle / "_internal" + internal.mkdir(parents=True) + for name in ("Offloader.exe", "offloader-cli.exe", "offloader-maintenance.exe"): + (bundle / name).write_bytes(name.encode()) + (internal / "python312.dll").write_bytes(b"python") + (bundle / ".offloader-install.lock").write_bytes(b"") + return bundle + + +def test_source_identity_is_literal_and_deterministic(): + repo = Path(__file__).parents[1] + first = artifacts.source_identity(repo) + second = artifacts.source_identity(repo) + assert first == second + assert first["version"] == "0.1.0" + assert len(first["source_commit"]) >= 7 + assert len(first["source_digest"]) == 64 + + +def test_build_record_round_trip_and_tamper_detection(tmp_path: Path): + bundle = _bundle(tmp_path) + identity = {"version": "0.1.0", "source_commit": "abc", "source_digest": "d" * 64} + record = artifacts.write_build_record(bundle, identity) + assert record["schema"] == 1 + assert artifacts.validate_build_record(bundle, identity) == record + (bundle / "Offloader.exe").write_bytes(b"changed") + with pytest.raises(RuntimeError, match="hash"): + artifacts.validate_build_record(bundle, identity) + + +def test_record_rejects_wrong_identity_and_unexpected_file(tmp_path: Path): + bundle = _bundle(tmp_path) + identity = {"version": "0.1.0", "source_commit": "abc", "source_digest": "d" * 64} + artifacts.write_build_record(bundle, identity) + wrong = {**identity, "version": "9.9.9"} + with pytest.raises(RuntimeError, match="identity"): + artifacts.validate_build_record(bundle, wrong) + (bundle / "unexpected.txt").write_bytes(b"extra") + with pytest.raises(RuntimeError, match="file set"): + artifacts.validate_build_record(bundle, identity) + + +def test_inventory_rejects_missing_required_executable(tmp_path: Path): + bundle = _bundle(tmp_path) + (bundle / "offloader-maintenance.exe").unlink() + with pytest.raises(RuntimeError, match="missing"): + artifacts.bundle_inventory(bundle) + + +def test_inventory_rejects_case_insensitive_duplicate(tmp_path: Path, monkeypatch): + bundle = _bundle(tmp_path) + original = artifacts._files + files = original(bundle) + files.append((bundle / "Offloader.exe", "OFFLOADER.EXE")) + monkeypatch.setattr(artifacts, "_files", lambda root: files) + with pytest.raises(RuntimeError, match="collision"): + artifacts.bundle_inventory(bundle) + + +def test_inventory_rejects_reparse_input(tmp_path: Path): + bundle = _bundle(tmp_path) + link = bundle / "linked.txt" + try: + link.symlink_to(bundle / "Offloader.exe") + except (OSError, NotImplementedError): + pytest.skip("symlink creation unavailable") + with pytest.raises(RuntimeError, match="reparse|unsafe"): + artifacts.bundle_inventory(bundle) + + +def test_inventory_surfaces_unreadable_walk(tmp_path: Path, monkeypatch): + bundle = _bundle(tmp_path) + + def failing_walk(*args, onerror, **kwargs): + onerror(OSError("access denied")) + yield from () + + monkeypatch.setattr(artifacts.os, "walk", failing_walk) + with pytest.raises(RuntimeError, match="walk"): + artifacts.bundle_inventory(bundle) diff --git a/tests/test_windows_build.py b/tests/test_windows_build.py new file mode 100644 index 0000000..b59884c --- /dev/null +++ b/tests/test_windows_build.py @@ -0,0 +1,38 @@ +"""Release ordering and frozen CLI routing must not change normal command semantics.""" + +import runpy +from pathlib import Path + +import pytest + +BUILD = Path(__file__).resolve().parents[1] / "build/windows" +windows_version = runpy.run_path(str(BUILD / "versioning.py"))["windows_version"] +timeline_requested = runpy.run_path(str(BUILD / "cli_entry.py"))["_timeline_requested"] + + +def test_windows_release_order(): + releases = ["0.1.0a999", "0.1.0b1", "0.1.0b999", "0.1.0rc1", "0.1.0", "0.1.1a1"] + versions = [windows_version(value) for value in releases] + assert all(first < second for first, second in zip(versions, versions[1:], strict=False)) + + +@pytest.mark.parametrize("version", ["0.1.0a1000", "65536.0.0", "0.1.0.dev1", "-1.0.0"]) +def test_unsupported_windows_versions_fail_before_build(version): + with pytest.raises(ValueError): + windows_version(version) + + +@pytest.mark.parametrize("args", [ + ["resolve", "--help"], ["offload", "--source", "resolve"], + ["offload", "--name", "resolve"], [], +]) +def test_frozen_cli_does_not_treat_filenames_or_help_as_timeline_import(args): + assert not timeline_requested(args) + + +@pytest.mark.parametrize("args", [ + ["resolve", "--timeline", "edit.xml"], ["offload", "--timeline", "edit.xml"], + ["offload", "--timeline=edit.xml"], +]) +def test_frozen_cli_explains_excluded_timeline_support(args): + assert timeline_requested(args) diff --git a/tests/test_windows_installer.py b/tests/test_windows_installer.py new file mode 100644 index 0000000..05c8e42 --- /dev/null +++ b/tests/test_windows_installer.py @@ -0,0 +1,123 @@ +"""NSIS generation stays deterministic and has the maintenance helper contract.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +BUILD = Path(__file__).resolve().parents[1] / "build" / "windows" +SPEC = importlib.util.spec_from_file_location("windows_installer", BUILD / "installer.py") +assert SPEC and SPEC.loader +installer = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(installer) +try: + MAKENSIS = installer.find_makensis() +except RuntimeError: + MAKENSIS = None + + +def _bundle(root: Path) -> Path: + bundle = root / "Offloader" + bundle.mkdir() + for name in installer.REQUIRED_BUNDLE_FILES: + (bundle / name).write_text(name, encoding="utf-8") + return bundle + + +def test_render_uses_payload_helper_and_uninstaller_finalizer(tmp_path): + rendered = installer._render( + _bundle(tmp_path), tmp_path / "Offloader-Setup-1.2.3.exe", "1.2.3", + ["python", "sign.py", "sign"], + ) + + assert 'offloader-maintenance.exe" install --payload' in rendered + assert 'offloader-maintenance.exe" uninstall --target' in rendered + assert "!uninstfinalize" in rendered + assert 'VIProductVersion "1.2.3.65535"' in rendered + assert "PreviousInstallLocation" in rendered + assert 'PreviousInstallLocation == $INSTDIR' in rendered + assert "InstallDirRegKey" not in rendered + assert "${GetOptions} $CMDLINE \"/D=\" $0" in rendered + assert "ClearErrors\n SetOutPath" in rendered + assert "StrCmp $PreviousInstallLocation $INSTDIR 0 uninstall_cleanup_done" in rendered + assert "@" not in rendered + + +def test_build_rejects_bundle_without_maintenance_helper(tmp_path): + bundle = _bundle(tmp_path) + (bundle / "offloader-maintenance.exe").unlink() + + with pytest.raises(ValueError, match="offloader-maintenance.exe"): + installer.build_installer(bundle, tmp_path / "setup.exe", "1.2.3") + + +def test_build_invokes_makensis_and_returns_output(tmp_path, monkeypatch): + bundle = _bundle(tmp_path) + compiler = tmp_path / "makensis.exe" + compiler.write_text("placeholder", encoding="utf-8") + output = tmp_path / "setup.exe" + calls = [] + + class Result: + returncode = 0 + + def fake_run(command, check): + calls.append(command) + output.write_bytes(b"setup") + return Result() + + monkeypatch.setattr(installer, "find_makensis", lambda: compiler) + monkeypatch.setattr(installer.subprocess, "run", fake_run) + + assert installer.build_installer(bundle, output, "1.2.3") == output.resolve() + assert calls[0][:2] == [str(compiler), "/V2"] + assert "/WX" in calls[0] + + +@pytest.mark.skipif(MAKENSIS is None, reason="NSIS is not installed") +def test_unsigned_nsis_bundle_compiles(tmp_path): + output = tmp_path / "Offloader-Setup-1.2.3.exe" + + result = installer.build_installer(_bundle(tmp_path), output, "1.2.3") + + assert result.is_file() + assert result.stat().st_size > 0 + + +@pytest.mark.skipif(MAKENSIS is None, reason="NSIS is not installed") +def test_nsis_finalizer_receives_a_quoted_uninstaller_path(tmp_path): + bundle = _bundle(tmp_path) + record = tmp_path / "recorded path.txt" + script = tmp_path / "record signer.py" + script.write_text( + "from pathlib import Path\nimport json\nimport sys\n" + "Path(sys.argv[1]).write_text(json.dumps(sys.argv[2:]), encoding='utf-8')\n" + "raise SystemExit(0 if len(sys.argv) == 3 else 9)\n", + encoding="utf-8", + ) + + installer.build_installer( + bundle, tmp_path / "Offloader Setup.exe", "1.2.3", + sign_command=[sys.executable, str(script), str(record)], + ) + + recorded = json.loads(record.read_text(encoding="utf-8")) + assert len(recorded) == 1 + assert Path(recorded[0]).name.startswith("nst") + + +@pytest.mark.skipif(MAKENSIS is None, reason="NSIS is not installed") +def test_nsis_finalizer_nonzero_exit_fails_compilation(tmp_path): + bundle = _bundle(tmp_path) + script = tmp_path / "reject signer.py" + script.write_text("raise SystemExit(7)\n", encoding="utf-8") + + with pytest.raises(RuntimeError, match="makensis failed"): + installer.build_installer( + bundle, tmp_path / "Offloader Setup.exe", "1.2.3", + sign_command=[sys.executable, str(script)], + ) diff --git a/tests/test_windows_launch.py b/tests/test_windows_launch.py new file mode 100644 index 0000000..e349221 --- /dev/null +++ b/tests/test_windows_launch.py @@ -0,0 +1,148 @@ +"""Launch policy tests use a fake Win32 boundary and never create a process.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from offloader import windows_launch + + +class FakeWindowsApi: + def __init__(self, *, shell_pid: int = 44, same_session: bool = True, elevated: bool = False, + error_at: str | None = None) -> None: + self.shell_pid = shell_pid + self.same_session = same_session + self.elevated = elevated + self.error_at = error_at + self.calls: list[object] = [] + self.closed: list[object] = [] + self.destroyed: list[object] = [] + + def _raise(self, name: str) -> None: + if self.error_at == name: + raise OSError(name) + + def desktop_shell_process_id(self) -> int: + self.calls.append("shell") + self._raise("shell") + if not self.shell_pid: + raise OSError("no shell") + return self.shell_pid + + def is_current_session(self, process_id: int) -> bool: + self.calls.append(("session", process_id)) + self._raise("session") + return self.same_session + + def open_process_query(self, process_id: int) -> int: + self.calls.append(("open_process", process_id)) + self._raise("open_process") + return 101 + + def open_process_token(self, process: int) -> int: + self.calls.append(("open_token", process)) + self._raise("open_token") + return 102 + + def token_is_elevated(self, token: int) -> bool: + self.calls.append(("elevated", token)) + self._raise("elevated") + return self.elevated + + def duplicate_primary_token(self, token: int) -> int: + self.calls.append(("duplicate", token)) + self._raise("duplicate") + return 103 + + def create_environment_block(self, token: int) -> int: + self.calls.append(("environment", token)) + self._raise("environment") + return 106 + + def destroy_environment_block(self, environment: int) -> None: + self.destroyed.append(environment) + + def create_process_with_token( + self, token: int, executable: Path, command_line: str, cwd: Path, environment: int + ): + self.calls.append(("create", token, executable, command_line, cwd, environment)) + self._raise("create") + return 104, 105 + + def close_handle(self, handle: int) -> None: + self.closed.append(handle) + + +@pytest.fixture +def desktop_target(tmp_path: Path) -> Path: + target = tmp_path / "Offloader App.exe" + target.write_bytes(b"fixture") + return target + + +def configure_windows(monkeypatch, api: FakeWindowsApi) -> None: + monkeypatch.setattr(windows_launch.sys, "platform", "win32") + monkeypatch.setattr(windows_launch, "_windows_api", lambda: api) + + +def test_launch_uses_unelevated_shell_token_and_closes_every_handle( + monkeypatch, desktop_target: Path +): + api = FakeWindowsApi() + configure_windows(monkeypatch, api) + + windows_launch.launch_as_desktop_user(desktop_target) + + assert api.calls[-1] == ( + "create", 103, desktop_target, '"' + str(desktop_target) + '"', desktop_target.parent, 106, + ) + assert api.closed == [105, 104, 103, 102, 101] + assert api.destroyed == [106] + + +@pytest.mark.parametrize("api", [FakeWindowsApi(shell_pid=0), FakeWindowsApi(same_session=False)]) +def test_no_shell_or_other_session_fails_without_process_open(monkeypatch, desktop_target: Path, api): + configure_windows(monkeypatch, api) + with pytest.raises(OSError): + windows_launch.launch_as_desktop_user(desktop_target) + assert not any(call for call in api.calls if isinstance(call, tuple) and call[0] == "open_process") + assert api.closed == [] + + +def test_elevated_shell_fails_without_duplicate_or_fallback(monkeypatch, desktop_target: Path): + api = FakeWindowsApi(elevated=True) + configure_windows(monkeypatch, api) + with pytest.raises(OSError, match="elevated"): + windows_launch.launch_as_desktop_user(desktop_target) + assert not any(call for call in api.calls if isinstance(call, tuple) and call[0] in {"duplicate", "create"}) + assert api.closed == [102, 101] + assert api.destroyed == [] + + +def test_create_failure_closes_source_and_duplicate_token(monkeypatch, desktop_target: Path): + api = FakeWindowsApi(error_at="create") + configure_windows(monkeypatch, api) + with pytest.raises(OSError, match="create"): + windows_launch.launch_as_desktop_user(desktop_target) + assert api.closed == [103, 102, 101] + assert api.destroyed == [106] + + +def test_environment_failure_closes_tokens_without_creating_process(monkeypatch, desktop_target: Path): + api = FakeWindowsApi(error_at="environment") + configure_windows(monkeypatch, api) + with pytest.raises(OSError, match="environment"): + windows_launch.launch_as_desktop_user(desktop_target) + assert not any(call for call in api.calls if isinstance(call, tuple) and call[0] == "create") + assert api.closed == [103, 102, 101] + assert api.destroyed == [] + + +def test_requires_existing_absolute_executable_without_calling_windows(monkeypatch, tmp_path: Path): + api = FakeWindowsApi() + configure_windows(monkeypatch, api) + with pytest.raises(OSError, match="existing absolute"): + windows_launch.launch_as_desktop_user(tmp_path / "missing.exe") + assert api.calls == [] diff --git a/tests/test_windows_pipeline.py b/tests/test_windows_pipeline.py new file mode 100644 index 0000000..1ba9809 --- /dev/null +++ b/tests/test_windows_pipeline.py @@ -0,0 +1,140 @@ +"""The release pipeline cannot bypass signing failures or reuse stale bytes.""" + +import importlib.util +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +BUILD = Path(__file__).resolve().parents[1] / "build/windows" + + +@pytest.fixture +def pipeline(tmp_path, monkeypatch): + spec = importlib.util.spec_from_file_location("windows_pipeline", BUILD / "build.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + monkeypatch.setattr(module.sys, "platform", "win32") + module.DIST = tmp_path + (tmp_path / "Offloader").mkdir() + calls = [] + identity = {"version": "0.1.0", "source_commit": "a" * 40, "source_digest": "b" * 64} + artifacts = SimpleNamespace( + source_identity=lambda _: identity, + validate_build_record=lambda *args: calls.append("validate"), + write_build_record=lambda *args: calls.append("record"), + ) + def build_installer(*args, **kwargs): + calls.append(("installer", kwargs)) + (tmp_path / ".offloader-uninstaller-signature.json").write_text("{}") + + installer = SimpleNamespace( + find_makensis=lambda: calls.append("nsis"), + build_installer=build_installer, + ) + sign = SimpleNamespace( + preflight=lambda: calls.append("preflight"), + sign_file=lambda *args: calls.append("sign installer"), + verify_file=lambda *args, **kwargs: {"verified": True}, + ) + for name, value in (("artifacts", artifacts), ("installer", installer), ("sign", sign)): + monkeypatch.setitem(sys.modules, name, value) + monkeypatch.setattr(module.subprocess, "check_output", lambda *a, **k: "") + monkeypatch.setattr(module, "run", lambda cmd: calls.append("smoke" if "smoke.py" in str(cmd) else "freeze")) + monkeypatch.setattr(module, "check_versions", lambda *a: calls.append("versions")) + monkeypatch.setattr(module, "check_signatures", lambda *a, **k: calls.append("sign bundle") or []) + monkeypatch.setattr(module, "save_outputs", lambda *a, **k: calls.append(("outputs", k))) + monkeypatch.setattr(module, "validate_outputs", lambda *a: calls.append("validate outputs")) + return module, calls, artifacts, sign + + +def test_unsigned_build_never_accesses_signing_key(pipeline): + module, calls, _, _ = pipeline + assert module.main(["--no-sign"]) == 0 + assert "preflight" not in calls + assert "sign bundle" not in calls + assert "sign installer" not in calls + assert ("installer", {"sign_command": None}) in calls + assert "smoke" in calls + assert not (module.DIST / ".offloader-build-incomplete").exists() + + +def test_signed_pipeline_orders_signing_before_assembly(pipeline): + module, calls, _, _ = pipeline + assert module.main([]) == 0 + installer_index = next(i for i, value in enumerate(calls) if isinstance(value, tuple) and value[0] == "installer") + assert calls.index("sign bundle") < installer_index < calls.index("sign installer") + assert calls.index("sign installer") < calls.index("smoke") + + +def test_signing_failure_leaves_candidate_incomplete(pipeline): + module, calls, _, sign = pipeline + + def fail(*args): + raise RuntimeError("token cancelled") + + sign.sign_file = fail + assert module.main([]) == 1 + assert not any(isinstance(value, tuple) and value[0] == "outputs" for value in calls) + assert (module.DIST / ".offloader-build-incomplete").exists() + + +def test_stale_skip_build_fails_before_mutation(pipeline): + module, calls, artifacts, _ = pipeline + + def fail(*args): + raise RuntimeError("source mismatch") + + artifacts.validate_build_record = fail + assert module.main(["--skip-build", "--no-sign"]) == 1 + assert "freeze" not in calls + assert not (module.DIST / ".offloader-build-incomplete").exists() + + +def test_verify_only_does_not_sign_rebuild_or_publish(pipeline): + module, calls, _, _ = pipeline + assert module.main(["--verify-only"]) == 0 + assert "validate" in calls + assert "preflight" not in calls + assert "freeze" not in calls + assert "record" not in calls + assert "sign installer" not in calls + + +def test_signed_build_refuses_dirty_sources(pipeline, monkeypatch): + module, calls, _, _ = pipeline + monkeypatch.setattr(module.subprocess, "check_output", lambda *a, **k: " M src/offloader/cli.py") + assert module.main([]) == 1 + assert "preflight" not in calls + assert "freeze" not in calls + + +def test_sources_changed_during_build_block_outputs(pipeline): + module, calls, artifacts, _ = pipeline + identities = iter([{"version": "0.1.0"}, {"version": "0.1.1"}]) + artifacts.source_identity = lambda _: next(identities) + assert module.main(["--no-sign"]) == 1 + assert not any(isinstance(value, tuple) and value[0] == "outputs" for value in calls) + + +def test_failed_smoke_check_blocks_outputs(pipeline, monkeypatch): + module, calls, _, _ = pipeline + + def run(cmd): + if "smoke.py" in str(cmd): + raise RuntimeError("frozen artifact is broken") + + monkeypatch.setattr(module, "run", run) + assert module.main(["--no-sign"]) == 1 + assert not any(isinstance(value, tuple) and value[0] == "outputs" for value in calls) + assert (module.DIST / ".offloader-build-incomplete").exists() + + +@pytest.mark.parametrize("args", [ + ["--verify-only", "--no-sign"], ["--verify-only", "--skip-build"], + ["--skip-build", "--clean"], +]) +def test_conflicting_modes_fail(pipeline, args): + with pytest.raises(SystemExit): + pipeline[0].main(args) diff --git a/tests/test_windows_sign.py b/tests/test_windows_sign.py new file mode 100644 index 0000000..1d98ab2 --- /dev/null +++ b/tests/test_windows_sign.py @@ -0,0 +1,239 @@ +"""Mocked coverage for the release signing helper, without a hardware token.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +BUILD = Path(__file__).resolve().parents[1] / "build/windows" + + +@pytest.fixture +def signing_module(): + spec = importlib.util.spec_from_file_location("offloader_windows_sign_test", BUILD / "sign.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + yield module + sys.modules.pop(spec.name, None) + + +def result(returncode: int = 0, stdout: str = "", stderr: str = ""): + return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr) + + +def config(module): + return module.SigningConfig(Path("C:/sdk/x64/signtool.exe"), module.DEFAULT_CERT_THUMBPRINT, + "CN=OK Studio Inc., O=OK Studio Inc.") + + +def metadata(*, status="Valid", subject="CN=OK Studio Inc., O=OK Studio Inc.", thumbprint=None, + timestamp="CN=DigiCert Timestamp", version="0.1.0"): + return { + "Status": status, + "StatusMessage": "ok", + "SignerSubject": subject, + "SignerThumbprint": thumbprint or "FC22B5221318F3F3F6B3EB2D969D7F99091557BF", + "TimestampSubject": timestamp, + "TimestampThumbprint": "A" * 40 if timestamp else None, + "FileVersion": version, + } + + +def test_preflight_uses_exact_default_thumbprint_and_checks_private_key(signing_module, monkeypatch): + module = signing_module + monkeypatch.setattr(module, "discover_signtool", lambda: Path("C:/sdk/signtool.exe")) + monkeypatch.setattr(module, "_certificate_metadata", lambda thumb: { + "Found": True, "Thumbprint": thumb, "Subject": "CN=OK Studio Inc., O=OK Studio Inc.", + "NotBefore": "2025-12-31T00:00:00+00:00", "NotAfter": "2026-12-31T00:00:00+00:00", + "HasPrivateKey": True, "Ekus": [module.CODE_SIGNING_EKU], + }) + assert module.preflight().certificate_thumbprint == module.DEFAULT_CERT_THUMBPRINT + + +def test_preflight_rejects_missing_code_signing_eku(signing_module, monkeypatch): + module = signing_module + monkeypatch.setattr(module, "verify_config", lambda: config(module)) + monkeypatch.setattr(module, "_certificate_metadata", lambda thumb: { + "Found": True, "Thumbprint": thumb, "Subject": "CN=OK Studio Inc., O=OK Studio Inc.", + "NotBefore": "2025-12-31T00:00:00+00:00", "NotAfter": "2026-12-31T00:00:00+00:00", + "HasPrivateKey": True, "Ekus": [], + }) + with pytest.raises(module.SigningError, match="code signing"): + module.preflight() + + +def test_preflight_rejects_not_yet_valid_certificate(signing_module, monkeypatch): + module = signing_module + monkeypatch.setattr(module, "verify_config", lambda: config(module)) + monkeypatch.setattr(module, "_certificate_metadata", lambda thumb: { + "Found": True, "Thumbprint": thumb, "Subject": "CN=OK Studio Inc., O=OK Studio Inc.", + "NotBefore": "2099-12-31T00:00:00+00:00", "NotAfter": "2100-12-31T00:00:00+00:00", + "HasPrivateKey": True, "Ekus": [module.CODE_SIGNING_EKU], + }) + with pytest.raises(module.SigningError, match="not valid yet"): + module.preflight() + + +def test_sign_rejects_existing_vendor_signature_before_prompt(signing_module, monkeypatch, tmp_path): + target = tmp_path / "vendor.dll" + target.write_bytes(b"vendor") + monkeypatch.setattr(module := signing_module, "inspect_file", lambda path: {"signature_status": "Valid"}) + monkeypatch.setattr(module, "preflight", lambda: pytest.fail("token should not be queried")) + with pytest.raises(module.SigningError, match="refusing"): + module.sign_file(target) + + +def test_sign_uses_sha256_rfc3161_and_verifies_afterward(signing_module, monkeypatch, tmp_path): + module = signing_module + target = tmp_path / "Offloader.exe" + target.write_bytes(b"unsigned") + commands = [] + monkeypatch.setattr(module, "inspect_file", lambda path: {"signature_status": "NotSigned"}) + monkeypatch.setattr(module, "preflight", lambda: config(module)) + monkeypatch.setattr(module, "_completed", lambda command, interactive=False: (commands.append((command, interactive)) or result())) + expected = {"signature_status": "Valid", "timestamp_present": True} + monkeypatch.setattr(module, "_verify", lambda path, cfg, version, allow_vendor: expected) + assert module.sign_file(target) is expected + command, interactive = commands[0] + assert interactive is True + assert command[1:] == ["sign", "/sha1", module.DEFAULT_CERT_THUMBPRINT, "/fd", "SHA256", "/tr", + module.TIMESTAMP_URL, "/td", "SHA256", str(target.resolve())] + + +def test_verify_requires_signtool_success_and_timestamp(signing_module, monkeypatch, tmp_path): + module = signing_module + target = tmp_path / "Offloader.exe" + target.write_bytes(b"signed") + monkeypatch.setattr(module, "verify_config", lambda: config(module)) + monkeypatch.setattr(module, "_completed", lambda *args, **kwargs: result(1, stderr="warning: no timestamp")) + with pytest.raises(module.SigningError, match="exit code 1"): + module.verify_file(target) + + monkeypatch.setattr(module, "_completed", lambda *args, **kwargs: result()) + monkeypatch.setattr(module, "inspect_file", lambda path: { + "signature_status": "Valid", "timestamp_present": False, "signer_subject": "CN=OK Studio Inc., O=OK Studio Inc.", + "signer_thumbprint": module.DEFAULT_CERT_THUMBPRINT, "file_version": "0.1.0", + }) + with pytest.raises(module.SigningError, match="timestamp"): + module.verify_file(target) + + +def test_verify_rejects_wrong_signer_and_version(signing_module, monkeypatch, tmp_path): + module = signing_module + target = tmp_path / "Offloader.exe" + target.write_bytes(b"signed") + monkeypatch.setattr(module, "verify_config", lambda: config(module)) + monkeypatch.setattr(module, "_completed", lambda *args, **kwargs: result()) + wrong = {"signature_status": "Valid", "timestamp_present": True, "signer_subject": "CN=Other, O=Other", + "signer_thumbprint": "B" * 40, "file_version": "0.1.0"} + monkeypatch.setattr(module, "inspect_file", lambda path: wrong) + with pytest.raises(module.SigningError, match="signer"): + module.verify_file(target) + wrong["signer_subject"] = "CN=OK Studio Inc., O=OK Studio Inc." + wrong["signer_thumbprint"] = module.DEFAULT_CERT_THUMBPRINT + with pytest.raises(module.SigningError, match="version"): + module.verify_file(target, expected_version="0.2.0") + + +def test_verify_vendor_only_allows_explicit_cn_and_organization(signing_module, monkeypatch, tmp_path): + module = signing_module + target = tmp_path / "Qt6Core.dll" + target.write_bytes(b"signed") + monkeypatch.setattr(module, "verify_config", lambda: config(module)) + monkeypatch.setattr(module, "_completed", lambda *args, **kwargs: result()) + vendor = {"signature_status": "Valid", "timestamp_present": True, + "signer_subject": "CN=The QT Company Oy, O=The QT Company Oy", "signer_thumbprint": "C" * 40, + "file_version": "0.1.0"} + monkeypatch.setattr(module, "inspect_file", lambda path: vendor.copy()) + with pytest.raises(module.SigningError, match="signer"): + module.verify_file(target) + assert module.verify_file(target, allow_vendor=True)["vendor_signed"] is True + vendor["signer_subject"] = "CN=The QT Company Oy, O=An Impostor" + with pytest.raises(module.SigningError, match="signer"): + module.verify_file(target, allow_vendor=True) + + +def test_inspection_returns_raw_notsigned_inventory(signing_module, monkeypatch, tmp_path): + module = signing_module + target = tmp_path / "plain.exe" + target.write_bytes(b"plain") + monkeypatch.setattr(module, "_powershell_json", lambda script: metadata(status="NotSigned", subject=None, thumbprint=None, timestamp=None)) + record = module.inspect_file(target) + assert record["signature_status"] == "NotSigned" + assert record["timestamp_present"] is False + assert len(record["sha256"]) == 64 + + +def test_cancelled_signing_is_a_failure(signing_module, monkeypatch, tmp_path): + module = signing_module + target = tmp_path / "Offloader.exe" + target.write_bytes(b"unsigned") + monkeypatch.setattr(module, "inspect_file", lambda path: {"signature_status": "NotSigned"}) + monkeypatch.setattr(module, "preflight", lambda: config(module)) + monkeypatch.setattr(module, "_completed", lambda *args, **kwargs: result(1, stderr="operation cancelled")) + with pytest.raises(module.SigningError, match="cancelled"): + module.sign_file(target) + + +def test_sign_rejects_missing_path_without_checking_for_a_token(signing_module, monkeypatch, tmp_path): + module = signing_module + monkeypatch.setattr(module, "preflight", lambda: pytest.fail("token should not be queried")) + with pytest.raises(module.SigningError, match="does not exist"): + module.sign_file(tmp_path / "missing.exe") + + +def test_verify_uses_config_without_reading_certificate_store(signing_module, monkeypatch, tmp_path): + module = signing_module + target = tmp_path / "signed.exe" + target.write_bytes(b"signed") + monkeypatch.setattr(module, "verify_config", lambda: config(module)) + monkeypatch.setattr(module, "preflight", lambda: pytest.fail("verification must not need a key")) + monkeypatch.setattr(module, "_completed", lambda *args, **kwargs: result()) + monkeypatch.setattr(module, "inspect_file", lambda path: { + "signature_status": "Valid", "timestamp_present": True, + "signer_subject": "CN=OK Studio Inc., O=OK Studio Inc.", + "signer_thumbprint": module.DEFAULT_CERT_THUMBPRINT, "file_version": "0.1.0", + }) + assert module.verify_file(target)["vendor_signed"] is False + + +def test_file_rejects_reparse_point_ancestor(signing_module, monkeypatch, tmp_path): + module = signing_module + target = tmp_path / "bundle" / "Offloader.exe" + target.parent.mkdir() + target.write_bytes(b"binary") + monkeypatch.setattr(module, "_is_reparse_point", lambda path: path == target.parent) + with pytest.raises(module.SigningError, match="symlink or junction"): + module._file(target) + + +def test_write_record_is_json_and_replaces_existing_file(signing_module, tmp_path): + record_path = tmp_path / "uninstaller-signature.json" + record_path.write_text("old", encoding="utf-8") + signing_module.write_record({"path": "Uninstall.exe", "signature_status": "Valid"}, record_path) + assert json.loads(record_path.read_text(encoding="utf-8")) == { + "path": "Uninstall.exe", "signature_status": "Valid", + } + + +def test_invalid_signtool_override_fails_closed(signing_module, monkeypatch, tmp_path): + module = signing_module + monkeypatch.setenv("OFFLOADER_SIGNTOOL", str(tmp_path / "missing-signtool.exe")) + with pytest.raises(module.SigningError, match="not a file"): + module.discover_signtool() + + +def test_powershell_inspection_parses_only_json(signing_module, monkeypatch): + module = signing_module + monkeypatch.setattr(module, "_completed", lambda *args, **kwargs: result(stdout=json.dumps({"Status": "Valid"}))) + assert module._powershell_json("test")["Status"] == "Valid" + monkeypatch.setattr(module, "_completed", lambda *args, **kwargs: result(stdout="not json")) + with pytest.raises(module.SigningError, match="invalid JSON"): + module._powershell_json("test")