From 90b4fc85505459803460dfd925e25a83f00669b7 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Thu, 30 Jul 2026 16:30:03 -0600 Subject: [PATCH] feat: identify the running code by source hash --- .gitignore | 3 ++ docs/dev/rest-api/endpoints.md | 4 +- docs/guide/troubleshooting.md | 4 +- tests/core/test_code_hash.py | 74 ++++++++++++++++++++++++++++++++++ tests/core/test_git_commit.py | 57 -------------------------- unshackle/core/__init__.py | 59 ++++++++++++++++++++------- unshackle/core/__main__.py | 4 +- unshackle/core/api/routes.py | 8 ++-- unshackle/core/utilities.py | 4 +- 9 files changed, 134 insertions(+), 83 deletions(-) create mode 100644 tests/core/test_code_hash.py delete mode 100644 tests/core/test_git_commit.py diff --git a/.gitignore b/.gitignore index 745dd310..ee420d65 100644 --- a/.gitignore +++ b/.gitignore @@ -257,3 +257,6 @@ __marimo__/ # Test fixtures with private/local content (services, IDs, credentials) tests/remote/e2e/fixtures/fixtures.yaml + +# Maintainer-side scripts, kept local +tools/ diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index f8894256..4a205505 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -43,7 +43,7 @@ Health check. **This is the only route exempt from authentication**: you can cal { "status": "ok", "version": "5.3.0", - "commit": "a24c9b9", + "code_hash": "1d22a1e", "update_check": { "update_available": false, "current_version": "5.3.0", @@ -52,7 +52,7 @@ Health check. **This is the only route exempt from authentication**: you can cal } ``` -`commit` is the short git commit of the running checkout. It is `null` when the server cannot read one (for example, an installed package with no `.git` directory, or a host without git). +`code_hash` fingerprints the framework source the server is running, so it identifies the code itself rather than the release. It is the same value for a git checkout and for an installed package built from that checkout, and it changes if anything under `unshackle/core`, `unshackle/commands`, `unshackle/utils`, or `unshackle/vaults` is edited. Service modules are not included. The value is `null` only when the source files cannot be read. If the update check fails (for example, no network), `update_available` and `latest_version` are `null` while `current_version` still reports the installed version. diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index 70cfa4fe..50cf356f 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -74,9 +74,11 @@ For example, `unshackle_debug_EXAMPLE_20260703-142530.jsonl`. The `` ta Every line is a self-contained JSON object. The first line of a session records the environment: ```json -{"timestamp":"2026-07-03T14:25:30.123456+00:00","session_id":"a1b2c3d4","level":"INFO","operation":"session_start","message":"Debug logging session started","context":{"unshackle_version":"5.3.0","unshackle_commit":"a24c9b9","python_version":"3.12.4 ...","platform":"Linux-6.18.5-x86_64","platform_system":"Linux","platform_release":"6.18.5"}} +{"timestamp":"2026-07-03T14:25:30.123456+00:00","session_id":"a1b2c3d4","level":"INFO","operation":"session_start","message":"Debug logging session started","context":{"unshackle_version":"5.3.0","unshackle_code_hash":"1d22a1e","python_version":"3.12.4 ...","platform":"Linux-6.18.5-x86_64","platform_system":"Linux","platform_release":"6.18.5"}} ``` +Quote `unshackle_code_hash` in a bug report. It fingerprints the framework source you are running, so it tells us the exact code state even when you did not install from git. A hash that matches no release means the source was edited locally. + Subsequent lines describe operations: service calls, DRM licence requests, vault lookups, downloader progress, and errors. Common fields include: | Field | Meaning | diff --git a/tests/core/test_code_hash.py b/tests/core/test_code_hash.py new file mode 100644 index 00000000..9aa10aa3 --- /dev/null +++ b/tests/core/test_code_hash.py @@ -0,0 +1,74 @@ +"""Unit tests for the code fingerprint behind the banner and the /api/health `code_hash` field. +It runs at import time, so it must always return a string and must never raise. It must change when +the framework source changes and stay unchanged when anything else does.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +import unshackle.core +from unshackle.core import __code_hash__, code_files, code_hash + +pytestmark = pytest.mark.unit + + +def test_is_hex_and_stable() -> None: + first = code_hash() + assert len(first) == 32 + assert int(first, 16) >= 0 + assert first == code_hash() + + +def test_exported_value_is_the_short_form() -> None: + assert __code_hash__ == code_hash()[:7] + + +def test_covers_the_framework_dirs_only() -> None: + rels = code_files() + assert "__main__.py" in rels + assert any(rel.startswith("core/") for rel in rels) + assert not any(rel.startswith("services/") for rel in rels) + assert not any("__pycache__" in rel for rel in rels) + assert all(rel.endswith(".py") for rel in rels) + assert rels == sorted(rels) # order must not depend on the filesystem + + +def test_content_changes_the_hash() -> None: + rels = ["core/__init__.py", "commands/dl.py"] + blobs = {"core/__init__.py": b"a", "commands/dl.py": b"b"} + before = code_hash(files=rels, read=blobs.__getitem__) + + blobs["commands/dl.py"] = b"b " + assert code_hash(files=rels, read=blobs.__getitem__) != before + + +def test_path_is_part_of_the_hash() -> None: + """A rename with identical bytes must not look like the same code.""" + same_bytes = {"core/a.py": b"x", "core/b.py": b"x"} + assert code_hash(files=["core/a.py"], read=same_bytes.__getitem__) != code_hash( + files=["core/b.py"], read=same_bytes.__getitem__ + ) + + +def test_file_order_does_not_matter_to_the_caller() -> None: + """code_files() sorts, so two orderings of the same set must agree once sorted.""" + blobs = {"core/a.py": b"x", "core/b.py": b"y"} + assert code_hash(files=sorted(blobs), read=blobs.__getitem__) == code_hash( + files=sorted(reversed(list(blobs))), read=blobs.__getitem__ + ) + + +def test_unreadable_source_yields_empty_string() -> None: + def unreadable(rel: str) -> bytes: + raise PermissionError(rel) + + assert code_hash(files=["core/__init__.py"], read=unreadable) == "" + + +def test_unwalkable_source_yields_empty_string(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A dir os.walk cannot enter must yield "", not a valid-looking hash of fewer files.""" + (tmp_path / "__main__.py").write_bytes(b"") + monkeypatch.setattr(unshackle.core, "_PKG", tmp_path) + assert code_hash() == "" diff --git a/tests/core/test_git_commit.py b/tests/core/test_git_commit.py deleted file mode 100644 index f375ceab..00000000 --- a/tests/core/test_git_commit.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Unit tests for the git commit probe behind the banner and the /api/health `commit` field. -The probe runs at import time, so it must always return a string and must never raise, whatever -state git and the checkout are in.""" - -from __future__ import annotations - -import subprocess -from pathlib import Path - -import pytest - -import unshackle.core -from unshackle.core import _git_commit - -pytestmark = pytest.mark.unit - -# Without a .git the probe returns before it runs git, so the subprocess stubs below prove nothing. -needs_checkout = pytest.mark.skipif( - not (Path(unshackle.core.__file__).parents[2] / ".git").exists(), reason="not running from a git checkout" -) - - -def test_returns_str_in_this_checkout() -> None: - assert isinstance(_git_commit(), str) - - -@pytest.mark.parametrize( - "error", - [ - FileNotFoundError("git"), - PermissionError("git"), - subprocess.TimeoutExpired(cmd="git", timeout=2), - subprocess.SubprocessError("boom"), - ], -) -@needs_checkout -def test_swallows_subprocess_errors(monkeypatch: pytest.MonkeyPatch, error: Exception) -> None: - def raise_it(*args: object, **kwargs: object) -> None: - raise error - - monkeypatch.setattr(subprocess, "run", raise_it) - assert _git_commit() == "" - - -@pytest.mark.parametrize( - ("returncode", "stdout", "expected"), - [ - (0, "a24c9b9\n", "a24c9b9"), - (128, "", ""), # empty repo, corrupt repo, or a stale worktree pointer - (0, "", ""), # no output despite success - ], -) -@needs_checkout -def test_maps_git_result(monkeypatch: pytest.MonkeyPatch, returncode: int, stdout: str, expected: str) -> None: - completed = subprocess.CompletedProcess(args=["git"], returncode=returncode, stdout=stdout, stderr="noise") - monkeypatch.setattr(subprocess, "run", lambda *a, **k: completed) - assert _git_commit() == expected diff --git a/unshackle/core/__init__.py b/unshackle/core/__init__.py index 7826b872..5005e996 100644 --- a/unshackle/core/__init__.py +++ b/unshackle/core/__init__.py @@ -1,24 +1,53 @@ -import subprocess +import hashlib +import os from pathlib import Path +from typing import Callable, Optional __version__ = "5.3.0" +_PKG = Path(__file__).parent.parent +# Framework code only. Services are user-swappable, so they are not part of the identity. +_CODE_DIRS = ("core", "commands", "utils", "vaults") -def _git_commit() -> str: - root = Path(__file__).parents[2] - if not (root / ".git").exists(): - return "" + +def _raise(error: OSError) -> None: + raise error + + +def code_files() -> list[str]: + """Framework source paths relative to the package root, in a platform-stable order.""" + rels = ["__main__.py"] + for name in _CODE_DIRS: + for root, dirs, files in os.walk(_PKG / name, onerror=_raise): + dirs[:] = [d for d in dirs if d != "__pycache__"] + rel = os.path.relpath(root, _PKG) + rels.extend(os.path.join(rel, f).replace(os.sep, "/") for f in files if f.endswith(".py")) + return sorted(rels) + + +def code_hash( + files: Optional[list[str]] = None, + read: Optional[Callable[[str], bytes]] = None, +) -> str: + """ + md5 of the framework source. Identifies the running code, not the release it claims to be. + + Pass `files` and `read` to digest a different byte source, such as blobs from git history + (see tools/resolve_code_hash.py). Returns "" when the source cannot be read. + """ + if read is None: + + def read(rel: str) -> bytes: + return (_PKG / rel).read_bytes() + + digest = hashlib.md5(usedforsecurity=False) try: - proc = subprocess.run( - ["git", "rev-parse", "--short", "HEAD"], - cwd=root, - capture_output=True, - text=True, - timeout=2, - ) - except (OSError, subprocess.SubprocessError): + for rel in code_files() if files is None else files: + digest.update(rel.encode()) + digest.update(read(rel)) + except OSError: return "" - return proc.stdout.strip() if proc.returncode == 0 else "" + return digest.hexdigest() -__commit__ = _git_commit() +__code_hash__ = code_hash()[:7] diff --git a/unshackle/core/__main__.py b/unshackle/core/__main__.py index 89f90616..23cf263f 100644 --- a/unshackle/core/__main__.py +++ b/unshackle/core/__main__.py @@ -10,7 +10,7 @@ from rich.padding import Padding from rich.text import Text from urllib3.exceptions import InsecureRequestWarning -from unshackle.core import __commit__, __version__ +from unshackle.core import __code_hash__, __version__ from unshackle.core.commands import Commands from unshackle.core.config import config from unshackle.core.console import ComfyRichHandler, console @@ -63,7 +63,7 @@ def main(version: bool, debug: bool) -> None: r" ▀▀▀ ▀▀ █▪ ▀▀▀▀ ▀▀▀ · ▀ ▀ ·▀▀▀ ·▀ ▀.▀▀▀ ▀▀▀ ", style="ascii.art", ), - f"v [repr.number]{__version__}[/]{f' ({__commit__})' if __commit__ else ''}" + f"v [repr.number]{__version__}[/]{f' (code hash {__code_hash__})' if __code_hash__ else ''}" f" - © 2025-{datetime.now().year} - github.com/unshackle-dl/unshackle", ), (1, 11, 1, 10), diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index c578170b..7605339b 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -8,7 +8,7 @@ import click from aiohttp import web from aiohttp_swagger3 import SwaggerDocs, SwaggerInfo, SwaggerUiSettings -from unshackle.core import __commit__, __version__ +from unshackle.core import __code_hash__, __version__ from unshackle.core.api.errors import APIError, APIErrorCode, build_error_response, handle_api_exception from unshackle.core.api.handlers import ( cancel_download_job_handler, @@ -105,10 +105,10 @@ async def health(request: web.Request) -> web.Response: version: type: string example: "2.0.0" - commit: + code_hash: type: string nullable: true - example: "a24c9b9" + example: "1d22a1e" update_check: type: object properties: @@ -133,7 +133,7 @@ async def health(request: web.Request) -> web.Response: update_info = {"update_available": None, "current_version": __version__, "latest_version": None} return web.json_response( - {"status": "ok", "version": __version__, "commit": __commit__ or None, "update_check": update_info} + {"status": "ok", "version": __version__, "code_hash": __code_hash__ or None, "update_check": update_info} ) diff --git a/unshackle/core/utilities.py b/unshackle/core/utilities.py index dd100b22..d101ed35 100644 --- a/unshackle/core/utilities.py +++ b/unshackle/core/utilities.py @@ -787,7 +787,7 @@ class DebugLogger: """Log the start of a new session with environment information.""" import platform - from unshackle.core import __commit__, __version__ + from unshackle.core import __code_hash__, __version__ self.log( level="INFO", @@ -795,7 +795,7 @@ class DebugLogger: message="Debug logging session started", context={ "unshackle_version": __version__, - "unshackle_commit": __commit__ or None, + "unshackle_code_hash": __code_hash__ or None, "python_version": sys.version, "platform": platform.platform(), "platform_system": platform.system(),