From f9b1500e241aecd86364809652d059ad7e68aac0 Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:53:10 -0600 Subject: [PATCH 1/5] Build an unsigned Windows desktop and CLI bundle --- .github/workflows/ci.yml | 25 +++- .gitignore | 6 +- AGENTS.md | 186 ++++++++++++++++++++++++++++++ CHANGELOG.md | 7 ++ MANIFEST.in | 5 + README.md | 5 +- ROADMAP.md | 5 + build/windows/build.py | 45 ++++++++ build/windows/cli_entry.py | 34 ++++++ build/windows/gui_entry.py | 48 ++++++++ build/windows/offloader.spec | 132 +++++++++++++++++++++ build/windows/smoke.py | 151 ++++++++++++++++++++++++ build/windows/versioning.py | 20 ++++ docs/build-windows.md | 94 +++++++++++++++ docs/release-plan.md | 217 +++++++++++++++++++++++++++++++++++ pyproject.toml | 5 +- requirements-build.txt | 16 +++ scripts/check_wheel.py | 56 +++++++++ src/offloader/__init__.py | 2 +- src/offloader/_version.py | 3 + tests/test_windows_build.py | 38 ++++++ 21 files changed, 1095 insertions(+), 5 deletions(-) create mode 100644 AGENTS.md create mode 100644 MANIFEST.in create mode 100644 build/windows/build.py create mode 100644 build/windows/cli_entry.py create mode 100644 build/windows/gui_entry.py create mode 100644 build/windows/offloader.spec create mode 100644 build/windows/smoke.py create mode 100644 build/windows/versioning.py create mode 100644 docs/build-windows.md create mode 100644 docs/release-plan.md create mode 100644 requirements-build.txt create mode 100644 scripts/check_wheel.py create mode 100644 src/offloader/_version.py create mode 100644 tests/test_windows_build.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e6fbc0..4aff6d3 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,30 @@ jobs: python -m pip install --upgrade pip build twine python -m build python -m twine check dist/* + - 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 bundle + 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: Build the desktop and CLI bundle + run: python build/windows/build.py --clean + - name: Check the frozen applications with disposable data + run: python build/windows/smoke.py + - uses: actions/upload-artifact@v4 + with: + name: offloader-windows-unsigned + path: dist/windows/Offloader/ + if-no-files-found: error diff --git a/.gitignore b/.gitignore index f14c76f..bf6bdca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,13 @@ __pycache__/ *.py[cod] *.egg-info/ -build/ +/build/* +!/build/windows/ +/build/windows/__pycache__/ +/.pyinstaller/ 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..3fc8dd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ project uses [semantic versioning][semver]. ### Added +- An unsigned Windows directory bundle with desktop and CLI executables, + pinned packaging dependencies, embedded version metadata, and headless + artifact checks in CI. Installer and signing work 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/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..406d072 --- /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 +recursive-include scripts *.py +recursive-include docs *.md diff --git a/README.md b/README.md index dceaaa1..30a0143 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 and smoke-test the unsigned Windows desktop and CLI bundle | | [`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 | @@ -558,7 +560,8 @@ ruff check src tests pytest --cov=offloader --cov-report=term-missing ``` -482 tests at 83% line coverage. They cover formatting against the reference's +661 tests passed with 3 skipped and 87% 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, 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/build.py b/build/windows/build.py new file mode 100644 index 0000000..0009368 --- /dev/null +++ b/build/windows/build.py @@ -0,0 +1,45 @@ +"""Build the unsigned Windows onedir bundle from any working directory.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +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" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--clean", + action="store_true", + help="discard PyInstaller's work cache before building", + ) + args = parser.parse_args() + + if sys.platform != "win32": + parser.error("the Windows bundle must be built on Windows") + command = [ + sys.executable, + "-m", + "PyInstaller", + "--noconfirm", + "--distpath", + str(DIST), + "--workpath", + str(WORK), + ] + if args.clean: + command.append("--clean") + command.append(str(SPEC)) + return subprocess.run(command, cwd=REPO, check=False).returncode + + +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..3f791db --- /dev/null +++ b/build/windows/cli_entry.py @@ -0,0 +1,34 @@ +"""Frozen console entry point for Offloader.""" + +from __future__ import annotations + +import importlib.util +import sys + + +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() + + +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..61afcab --- /dev/null +++ b/build/windows/gui_entry.py @@ -0,0 +1,48 @@ +"""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) + + +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/offloader.spec b/build/windows/offloader.spec new file mode 100644 index 0000000..dcbaa6c --- /dev/null +++ b/build/windows/offloader.spec @@ -0,0 +1,132 @@ +# -*- mode: python ; coding: utf-8 -*- +"""PyInstaller definition for the unsigned Offloader Windows directory.""" + +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"), +) + +COLLECT( + gui_exe, + cli_exe, + gui_analysis.binaries, + gui_analysis.datas, + cli_analysis.binaries, + cli_analysis.datas, + strip=False, + upx=False, + name="Offloader", +) diff --git a/build/windows/smoke.py b/build/windows/smoke.py new file mode 100644 index 0000000..43d0b83 --- /dev/null +++ b/build/windows/smoke.py @@ -0,0 +1,151 @@ +"""Headless smoke checks for a completed Windows bundle.""" + +from __future__ import annotations + +import argparse +import ctypes +import os +import re +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, + ) + + 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..4180d86 --- /dev/null +++ b/docs/build-windows.md @@ -0,0 +1,94 @@ +# Windows bundle + +The first packaging slice produces an **unsigned directory bundle**, containing +`Offloader.exe` (desktop) and `offloader-cli.exe` (console). Both executables +need the adjacent `_internal` directory. Copy or archive the entire Offloader +directory, not either executable alone. + +This is a development artifact. An installer, signing, active-job installation +protection, and clean-machine qualification remain 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 +``` + +Build from the repository root: + +```powershell +.\.venv-build\Scripts\python.exe build\windows\build.py --clean +``` + +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. + +`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. + +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 + +Local validation on 2026-09-10 (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 and independent clean-machine +testing have not been run for this change. + +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. + +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 this bundle on Windows and uploads it as +`offloader-windows-unsigned`. The existing 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/release-plan.md b/docs/release-plan.md new file mode 100644 index 0000000..1cea496 --- /dev/null +++ b/docs/release-plan.md @@ -0,0 +1,217 @@ +# 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 first slice now has a single version source in `src/offloader/_version.py`, +pinned Windows packaging dependencies, an unsigned PyInstaller directory +bundle with GUI and CLI executables, and automated artifact checks. CI now +includes Windows bundle checks and fresh-environment wheel installation. +See [build-windows.md](build-windows.md) for commands and validation details. + +The installer, signing, complete third-party license inventory/SBOM, safe +installation during active jobs, private pilot, and clean-machine qualification +remain pending. The tables below retain the pre-implementation assessment and +the planned stage gates; creating a bundle 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 | `0.1.0` appears independently in `pyproject.toml` and `src/offloader/__init__.py` | One source used by package metadata, app, installer, reports, and release 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 | Source installation instructions and CI package artifacts | Frozen bundle, installer, signing, release workflow, installation 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` | Use a PyInstaller bundle, NSIS installer, 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:** prefer a per-user install that runs without administrator + rights. Keep configuration/history outside the application directory and + preserve 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. + +## 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. +- [ ] 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 and the unsigned local Windows bundle are implemented. +Headless artifact checks pass; the clean-account GUI walkthrough remains to +be done. Next add the installer, safe job handling, signing, and candidate +qualification in the order above. 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/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/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) From 5963d0401e4b1450995d1f22d491739ba2f96fc4 Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:06:48 -0600 Subject: [PATCH 2/5] Make file-control tests independent of transfer speed --- tests/test_control.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) 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) From 91f3867d1841f6d2f768888dc68dc71a519b3185 Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:30:01 -0600 Subject: [PATCH 3/5] Document the signed Windows release flow and CI results --- docs/build-windows.md | 9 ++++- docs/release-plan.md | 90 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/docs/build-windows.md b/docs/build-windows.md index 4180d86..d14b5b9 100644 --- a/docs/build-windows.md +++ b/docs/build-windows.md @@ -60,8 +60,13 @@ bundle built successfully and passed the smoke checks below. It contains 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 and independent clean-machine -testing have not been run for this change. +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 future signed release flow is defined in the +[release plan](release-plan.md#windows-signing-flow-matching-alpha-osk). Run the headless smoke checks: diff --git a/docs/release-plan.md b/docs/release-plan.md index 1cea496..23a2daa 100644 --- a/docs/release-plan.md +++ b/docs/release-plan.md @@ -136,6 +136,93 @@ 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. +## Windows signing flow, matching Alpha-OSK + +This signing flow is planned. Do not invoke the hardware key or sign artifacts +until signing is explicitly requested. The current build and CI artifacts +remain unsigned. + +**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. + +Planned 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 planned, not implemented in the current unsigned builder. +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 @@ -190,6 +277,9 @@ do not substitute a source test run for validation of the shipped bundle. 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, From e411feed6d9514925d9ab6fffa9ed98bd6905291 Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:10:09 -0600 Subject: [PATCH 4/5] Specify installer parity with Alpha-OSK --- docs/release-plan.md | 73 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/docs/release-plan.md b/docs/release-plan.md index 23a2daa..4d87fcf 100644 --- a/docs/release-plan.md +++ b/docs/release-plan.md @@ -84,9 +84,10 @@ credentials, update endpoints, or installation paths. 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:** prefer a per-user install that runs without administrator - rights. Keep configuration/history outside the application directory and - preserve them on upgrade and ordinary uninstall. +- **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. @@ -136,6 +137,72 @@ 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 + +Planned behavior, not an implemented installer. The current PR produces a +directory bundle. The public Windows download will be +`Offloader-Setup-{version}.exe`, a signed NSIS installer requiring no Python. + +**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 This signing flow is planned. Do not invoke the hardware key or sign artifacts From 9dbcbf461c4d778ef5c652f4f391d5bf0da24215 Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:57:31 -0600 Subject: [PATCH 5/5] Implement Windows installer and verified signing pipeline Add NSIS packaging, signing and timestamp verification, source and artifact inventories, and explicit unsigned builds for development and CI. Protect installation changes with application lifetime locks, inventoried file ownership, staged replacement, and recoverable rollback. Preserve user state and launch from Finish with the desktop user's token and environment. Setuptools silently pruned the tracked build/windows directory from source archives because it shared the generated build directory. Move generated files to .python-build and check the Windows builder's presence in CI. Validated on Windows: 727 tests passed, 5 skipped; Ruff, unsigned NSIS and portable builds, frozen smoke checks, wheel/sdist builds, isolated wheel installation, and final inventories/checksums passed. Hardware signing and clean-machine interactive installation remain unqualified. --- .github/workflows/ci.yml | 22 +- .gitignore | 1 + CHANGELOG.md | 9 +- CONTRIBUTING.md | 2 +- MANIFEST.in | 2 +- README.md | 9 +- build/windows/artifacts.py | 206 ++++++++ build/windows/build.py | 226 ++++++++- build/windows/cli_entry.py | 15 +- build/windows/gui_entry.py | 9 +- build/windows/installer.nsi | 249 ++++++++++ build/windows/installer.py | 108 +++++ build/windows/maintenance_entry.py | 46 ++ build/windows/offloader.spec | 20 +- build/windows/sign.py | 443 ++++++++++++++++++ build/windows/smoke.py | 27 ++ docs/build-windows.md | 123 ++++- docs/data-safety.md | 7 + docs/release-plan.md | 60 ++- setup.cfg | 4 + src/offloader/installation.py | 725 +++++++++++++++++++++++++++++ src/offloader/installation_lock.py | 123 +++++ src/offloader/windows_launch.py | 217 +++++++++ tests/test_installation.py | 262 +++++++++++ tests/test_installation_lock.py | 97 ++++ tests/test_windows_artifacts.py | 98 ++++ tests/test_windows_installer.py | 123 +++++ tests/test_windows_launch.py | 148 ++++++ tests/test_windows_pipeline.py | 140 ++++++ tests/test_windows_sign.py | 239 ++++++++++ 30 files changed, 3681 insertions(+), 79 deletions(-) create mode 100644 build/windows/artifacts.py create mode 100644 build/windows/installer.nsi create mode 100644 build/windows/installer.py create mode 100644 build/windows/maintenance_entry.py create mode 100644 build/windows/sign.py create mode 100644 setup.cfg create mode 100644 src/offloader/installation.py create mode 100644 src/offloader/installation_lock.py create mode 100644 src/offloader/windows_launch.py create mode 100644 tests/test_installation.py create mode 100644 tests/test_installation_lock.py create mode 100644 tests/test_windows_artifacts.py create mode 100644 tests/test_windows_installer.py create mode 100644 tests/test_windows_launch.py create mode 100644 tests/test_windows_pipeline.py create mode 100644 tests/test_windows_sign.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4aff6d3..783dfae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,6 +121,12 @@ 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 @@ -129,7 +135,7 @@ jobs: path: dist/ windows-bundle: - name: Windows unsigned desktop bundle + name: Windows unsigned desktop and installer runs-on: windows-latest steps: - uses: actions/checkout@v4 @@ -139,12 +145,16 @@ jobs: cache: pip - name: Install packaging dependencies run: python -m pip install -e ".[gui]" -r requirements-build.txt - - name: Build the desktop and CLI bundle - run: python build/windows/build.py --clean - - name: Check the frozen applications with disposable data - run: python build/windows/smoke.py + - 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/ + 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 bf6bdca..416e834 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__/ !/build/windows/ /build/windows/__pycache__/ /.pyinstaller/ +/.python-build/ dist/ .venv/ .venv-build/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fc8dd4..dfa183d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,12 @@ project uses [semantic versioning][semver]. ### Added -- An unsigned Windows directory bundle with desktop and CLI executables, - pinned packaging dependencies, embedded version metadata, and headless - artifact checks in CI. Installer and signing work remain pending. +- 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. 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 index 406d072..7b515d2 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +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 +recursive-include build/windows *.py *.spec *.nsi recursive-include scripts *.py recursive-include docs *.md diff --git a/README.md b/README.md index 30a0143..3197da7 100644 --- a/README.md +++ b/README.md @@ -515,7 +515,7 @@ general-purpose tool reports a filename, a size, and a placeholder icon. | --- | --- | | [`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 and smoke-test the unsigned Windows desktop and CLI bundle | +| [`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 | @@ -554,19 +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 ``` -661 tests passed with 3 skipped and 87% line coverage on Windows/Python 3.12 +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/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 index 0009368..3b2ff8d 100644 --- a/build/windows/build.py +++ b/build/windows/build.py @@ -1,10 +1,16 @@ -"""Build the unsigned Windows onedir bundle from any working directory.""" +"""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 @@ -12,33 +18,205 @@ 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 main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--clean", - action="store_true", - help="discard PyInstaller's work cache before building", - ) - args = parser.parse_args() +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("the Windows bundle must be built on Windows") - command = [ - sys.executable, - "-m", - "PyInstaller", - "--noconfirm", - "--distpath", - str(DIST), - "--workpath", - str(WORK), - ] - if args.clean: - command.append("--clean") - command.append(str(SPEC)) - return subprocess.run(command, cwd=REPO, check=False).returncode + 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__": diff --git a/build/windows/cli_entry.py b/build/windows/cli_entry.py index 3f791db..31fb042 100644 --- a/build/windows/cli_entry.py +++ b/build/windows/cli_entry.py @@ -4,6 +4,7 @@ import importlib.util import sys +from contextlib import ExitStack def _timeline_requested(argv: list[str]) -> bool: @@ -15,7 +16,7 @@ def _timeline_requested(argv: list[str]) -> bool: ) -def main() -> int: +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: @@ -30,5 +31,17 @@ def main() -> int: 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 index 61afcab..b16b8a0 100644 --- a/build/windows/gui_entry.py +++ b/build/windows/gui_entry.py @@ -8,7 +8,7 @@ from pathlib import Path -def main() -> int: +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": @@ -34,6 +34,13 @@ def main() -> int: 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()) 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 index dcbaa6c..98ffa75 100644 --- a/build/windows/offloader.spec +++ b/build/windows/offloader.spec @@ -1,5 +1,5 @@ # -*- mode: python ; coding: utf-8 -*- -"""PyInstaller definition for the unsigned Offloader Windows directory.""" +"""PyInstaller definition for Offloader's Windows applications and maintenance.""" from __future__ import annotations @@ -119,9 +119,27 @@ gui_exe = EXE( 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, 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 index 43d0b83..ba00b06 100644 --- a/build/windows/smoke.py +++ b/build/windows/smoke.py @@ -6,6 +6,7 @@ import ctypes import os import re +import shutil import subprocess import tempfile from pathlib import Path @@ -143,6 +144,32 @@ def main() -> int: 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 diff --git a/docs/build-windows.md b/docs/build-windows.md index d14b5b9..b944711 100644 --- a/docs/build-windows.md +++ b/docs/build-windows.md @@ -1,13 +1,16 @@ -# Windows bundle +# Windows builds -The first packaging slice produces an **unsigned directory bundle**, containing -`Offloader.exe` (desktop) and `offloader-cli.exe` (console). Both executables -need the adjacent `_internal` directory. Copy or archive the entire Offloader -directory, not either executable alone. +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. -This is a development artifact. An installer, signing, active-job installation -protection, and clean-machine qualification remain in the -[release plan](release-plan.md). Nothing is automatically published. +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 @@ -24,10 +27,12 @@ Install the project and the pinned runtime/build dependency set: .\.venv-build\Scripts\python.exe -m pip install -e ".[gui]" -r requirements-build.txt ``` -Build from the repository root: +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 +.\.venv-build\Scripts\python.exe build\windows\build.py --clean --no-sign ``` The script also accepts invocation by absolute path from another directory. @@ -36,12 +41,86 @@ Output goes to `dist/windows/Offloader/`, with intermediates in 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 @@ -54,7 +133,17 @@ release-ready third-party license inventory and SBOM remain separate work. ## Check the artifact -Local validation on 2026-09-10 (Windows x64, Python 3.12.10): the directory +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 @@ -65,7 +154,7 @@ 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 future signed release flow is defined in the +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: @@ -82,12 +171,18 @@ 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 this bundle on Windows and uploads it as -`offloader-windows-unsigned`. The existing Python distribution job additionally +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`. 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 index 4d87fcf..ad02213 100644 --- a/docs/release-plan.md +++ b/docs/release-plan.md @@ -6,16 +6,26 @@ not a commitment to a launch date. Completed implementation is recorded below. ## Implementation progress -The first slice now has a single version source in `src/offloader/_version.py`, -pinned Windows packaging dependencies, an unsigned PyInstaller directory -bundle with GUI and CLI executables, and automated artifact checks. CI now -includes Windows bundle checks and fresh-environment wheel installation. +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. -The installer, signing, complete third-party license inventory/SBOM, safe -installation during active jobs, private pilot, and clean-machine qualification -remain pending. The tables below retain the pre-implementation assessment and -the planned stage gates; creating a bundle does not complete those gates. +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 @@ -41,9 +51,9 @@ 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 | `0.1.0` appears independently in `pyproject.toml` and `src/offloader/__init__.py` | One source used by package metadata, app, installer, reports, and release assets | +| 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 | Source installation instructions and CI package artifacts | Frozen bundle, installer, signing, release workflow, installation documentation | +| 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 | @@ -62,7 +72,7 @@ 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` | Use a PyInstaller bundle, NSIS installer, and explicit signing stage, adapted to Offloader's entry points and dependencies | +| `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 | @@ -139,9 +149,10 @@ a build dependency. No new signing purchase or account setup is implied here. ## Installer flow and Alpha-OSK parity -Planned behavior, not an implemented installer. The current PR produces a -directory bundle. The public Windows download will be -`Offloader-Setup-{version}.exe`, a signed NSIS installer requiring no Python. +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, @@ -205,9 +216,9 @@ state paths, file ownership, and active-job guarantees. ## Windows signing flow, matching Alpha-OSK -This signing flow is planned. Do not invoke the hardware key or sign artifacts -until signing is explicitly requested. The current build and CI artifacts -remain unsigned. +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 @@ -218,7 +229,7 @@ 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. -Planned interface for `build/windows/build.py`: +Implemented interface for `build/windows/build.py`: | Mode | Behavior | | --- | --- | @@ -228,7 +239,8 @@ Planned interface for `build/windows/build.py`: | `--verify-only` | Verify existing application/installer signatures without signing or rebuilding | | `--no-installer` | Produce the portable bundle; signing still defaults on | -These modes are planned, not implemented in the current unsigned builder. +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. @@ -368,7 +380,9 @@ format; do not ask users to delete state as the default recovery procedure. ## First implementation slice -Version unification and the unsigned local Windows bundle are implemented. -Headless artifact checks pass; the clean-account GUI walkthrough remains to -be done. Next add the installer, safe job handling, signing, and candidate -qualification in the order above. +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/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/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_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_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")