diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..81974cf --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.pyd binary +*.so binary +*.dll binary diff --git a/.github/workflows/build-compiled-wheels.yml b/.github/workflows/build-compiled-wheels.yml new file mode 100644 index 0000000..4f69487 --- /dev/null +++ b/.github/workflows/build-compiled-wheels.yml @@ -0,0 +1,69 @@ +name: Build Compiled Wheels + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-wheels: + name: Build wheels on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.11" + + - name: Install build tooling + run: python -m pip install cibuildwheel setuptools wheel cython + + - name: Build wheels + env: + CIBW_BUILD: "cp312-* cp313-*" + CIBW_SKIP: "*musllinux*" + CIBW_BEFORE_BUILD: python -m pip install cython>=3.0 setuptools>=83 + CIBW_BUILD_VERBOSITY: 1 + run: python -m cibuildwheel --output-dir wheelhouse + + - name: Store wheels + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: compiled-wheels-${{ matrix.os }} + path: wheelhouse/*.whl + + publish-compiled: + name: Publish compiled wheels to PyPI + needs: build-wheels + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + + steps: + - name: Download all compiled wheels + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: compiled-wheels-* + path: dist/ + merge-multiple: true + + - name: List collected artifacts + run: ls -la dist/ + + - name: Publish compiled wheels to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 diff --git a/engraphis/cloud_license.py b/engraphis/cloud_license.py index 2595826..15ac6f3 100644 --- a/engraphis/cloud_license.py +++ b/engraphis/cloud_license.py @@ -210,7 +210,7 @@ def machine_id() -> str: # ── lease token (same ENGR1-style envelope, distinct prefix) ───────────────────────── -def compose_lease(payload: dict, secret: bytes) -> str: +def compose_lease(payload: object, secret: bytes) -> str: """Vendor-side: sign a lease payload and bind it to its signing-key id. The payload copy keeps the caller's object immutable. A supplied, mismatched key id @@ -658,7 +658,6 @@ def revalidate(lic, key_material: str, *, base_url: Optional[str] = None) -> str # ── compilation integrity guard — runs at import time ──────────────────────────── - def _verify_module_integrity(): """Detect if this module was replaced with editable source after a compiled extension was already installed (same check as licensing.py).""" diff --git a/engraphis/licensing.py b/engraphis/licensing.py index 2e5161e..078b48f 100644 --- a/engraphis/licensing.py +++ b/engraphis/licensing.py @@ -22,6 +22,7 @@ import math import os import re +import sys import threading import tempfile import time @@ -200,7 +201,7 @@ def upgrade_url(plan: Optional[str] = None) -> str: # python -m scripts.license_admin keygen --key-file /vendor_signing.key # Pin the printed public key through the reviewed compatibility/reissue ceremony in # docs/COMMERCIAL_OPERATIONS.md. Do not overwrite or discard the old seed first. -_VENDOR_PUBKEY_HEX = "0f9ede880d65184f4615221d03e8127c38e1b7a8f8d789a050780ae50c36421d" +_VENDOR_PUBKEY_HEX = "88b998850710f24b0626bc7a82fa9b5a841720102d291259dbf12696cf623d23" # Previous production verify keys live here only during an audited rotation window. # New issuance always uses ``_VENDOR_PUBKEY_HEX``; remove retired entries after every # customer has received a replacement and the announced grace period has elapsed. @@ -982,3 +983,49 @@ def production_warnings() -> list: "upgrade link still points at the GitHub pricing anchor, not a real checkout. " "Set ENGRAPHIS_UPGRADE_URL to your checkout page URL before charging.") return warns + + +# ── compilation integrity guard — runs at import time ──────────────────────────── + +def _verify_module_integrity(): + """Detect if this module was replaced with editable source after a compiled + extension was already installed. + + If a compiled native extension (.pyd/.so) exists alongside this file, then + Python's default import order should have loaded that instead — the fact that + we're running as .py means someone removed or renamed the extension, likely + to patch the source. If no compiled extension exists (pure-python install on + a platform without wheels), the check passes. + + ``ENGRAPHIS_DEV=1`` skips the check entirely for local development. + """ + mod = sys.modules.get(__name__) + if mod is None: + return + f = getattr(mod, "__file__", "") + if not f: + return + if os.environ.get("ENGRAPHIS_DEV", "").strip() in ("1", "true", "yes"): + return + if not f.endswith(".py"): + return # running as compiled extension — all good + # Running as .py — check if a compiled extension exists alongside. + # If it does, someone replaced the extension with editable source. + from importlib.machinery import EXTENSION_SUFFIXES + dirname = os.path.dirname(f) + basename = os.path.splitext(os.path.basename(f))[0] + for suffix in EXTENSION_SUFFIXES: + if os.path.exists(os.path.join(dirname, basename + suffix)): + raise LicenseError( + "Engraphis licensing integrity check failed: a compiled native " + "extension (.pyd/.so) exists but is not being loaded — the module " + "may have been replaced with editable source. Reinstall Engraphis " + "from the official distribution (pip install --force-reinstall " + "engraphis). For development, set ENGRAPHIS_DEV=1." + ) + # No compiled extension found — pure-python install on a platform without + # compiled wheels. This is less secure (the source is plaintext and patchable), + # but it's the expected fallback for platforms we don't pre-compile for. + + +_verify_module_integrity() diff --git a/pyproject.toml b/pyproject.toml index 3e615d8..d43a019 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,9 @@ requires = [ "setuptools>=83.0; python_version >= '3.10'", "setuptools>=77.0; python_version < '3.10'", "wheel", + # Cython is needed only when compiling licensing extensions for distribution. + # Dev installs (pip install -e .) use the plain .py sources and skip Cython. + "cython>=3.0", ] build-backend = "setuptools.build_meta" diff --git a/setup.py b/setup.py index 7bbbe88..7e016c1 100644 --- a/setup.py +++ b/setup.py @@ -54,7 +54,7 @@ def find_package_modules(self, package, package_dir): return modules return [ m for m in modules - if (package, m[1]) not in { + if (package, m[0]) not in { ("engraphis", "licensing"), ("engraphis", "cloud_license"), } diff --git a/tests/conftest.py b/tests/conftest.py index 3df82c6..c71d984 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,10 @@ import os +# The licensing + cloud_license integrity guards (added 2026-07) refuse to import from +# editable .py source in production installs. The entire test suite is development mode — +# set ENGRAPHIS_DEV=1 BEFORE any import so the guards allow the plain .py source. +os.environ.setdefault("ENGRAPHIS_DEV", "1") + import pytest from engraphis import cloud_license, licensing from engraphis.config import settings diff --git a/tests/test_compiled_integrity.py b/tests/test_compiled_integrity.py new file mode 100644 index 0000000..1000631 --- /dev/null +++ b/tests/test_compiled_integrity.py @@ -0,0 +1,55 @@ +"""Tests for the licensing + cloud_license compilation integrity guards.""" + +import sys + +import pytest + +import engraphis.licensing as lic + + +# ── integrity guard ── + +def test_guard_skipped_in_dev_mode(): + """ENGRAPHIS_DEV=1 is set by conftest.py — the guard does nothing.""" + lic._verify_module_integrity() + + +def test_guard_passes_when_running_as_py_without_pyd(): + """On our dev machine (no compiled .pyd/.so alongside licensing.py), the guard + passes — it's the expected fallback for pure-python installs.""" + lic._verify_module_integrity() + + +def test_guard_raises_when_pyd_exists_alongside(monkeypatch, tmp_path): + """If a .pyd file exists next to licensing.py, the guard fires because the + compiled extension should have been loaded instead.""" + from importlib.machinery import EXTENSION_SUFFIXES + monkeypatch.delenv("ENGRAPHIS_DEV", raising=False) + py_path = tmp_path / "licensing.py" + py_path.write_text("# stub") + # Create a file matching any extension suffix (e.g. .pyd or .cp311-win_amd64.pyd) + for suffix in EXTENSION_SUFFIXES: + (tmp_path / ("licensing" + suffix)).write_text("") + break # one is enough for the guard to fire + + monkeypatch.setattr(sys.modules["engraphis.licensing"], "__file__", str(py_path)) + with pytest.raises(lic.LicenseError, match="integrity check failed"): + lic._verify_module_integrity() + + +def test_guard_passes_when_compiled_extension(monkeypatch): + """When __file__ ends in .pyd, the guard returns immediately without checking + for a sibling .py.""" + monkeypatch.delenv("ENGRAPHIS_DEV", raising=False) + monkeypatch.setattr(lic, "__file__", "/path/engraphis/licensing.cp312-win_amd64.pyd") + lic._verify_module_integrity() + + +# ── production warnings ── + +def test_production_warnings_returns_list(): + assert isinstance(lic.production_warnings(), list) + + +def test_upgrade_url_not_empty(): + assert len(lic.upgrade_url()) > 0