From d941b20ef59512b5a44f52ad090e1f0739a2e130 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Thu, 15 Oct 2026 18:15:55 -0400 Subject: [PATCH] fix(workflows): match Dispatcharr signature payload Serialize manifest payloads with ASCII escaping and a trailing newline, matching Dispatcharr verification. Verify existing signatures against the payload before skipping signing so manifests from the previous format are repaired on the next publish. --- .../src/pluginctl/publish/manifest.py | 23 ++++++++++----- .github/pluginctl/tests/test_manifest.py | 29 +++++++++++++++++-- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/.github/pluginctl/src/pluginctl/publish/manifest.py b/.github/pluginctl/src/pluginctl/publish/manifest.py index 76939c3a..1838c81f 100644 --- a/.github/pluginctl/src/pluginctl/publish/manifest.py +++ b/.github/pluginctl/src/pluginctl/publish/manifest.py @@ -192,8 +192,13 @@ def write_manifest_if_changed(dest: str, payload: dict, generated_at: str) -> bo return True +def _signing_payload(manifest: dict) -> str: + """Return the canonical bytes reconstructed by Dispatcharr for verification.""" + return json.dumps(manifest, separators=(",", ":"), ensure_ascii=True) + "\n" + + def sig_is_current(file: str, gpg_key_id: str) -> bool: - """True when the embedded ``.signature`` was issued by ``gpg_key_id``.""" + """True when the embedded signature is current and verifies for its payload.""" try: with open(file, encoding="utf-8") as fh: data = json.load(fh) @@ -208,12 +213,16 @@ def sig_is_current(file: str, gpg_key_id: str) -> bool: try: out = subprocess.run(["gpg", "--list-packets", tmp_path], capture_output=True, text=True).stdout + m = re.search(r"issuer key ID ([A-Fa-f0-9]{16})", out) + if not m or m.group(1).upper() != (gpg_key_id or "").upper(): + return False + verify = subprocess.run( + ["gpg", "--batch", "--verify", tmp_path, "-"], + input=_signing_payload(data["manifest"]), capture_output=True, text=True, + ) + return verify.returncode == 0 finally: os.unlink(tmp_path) - m = re.search(r"issuer key ID ([A-Fa-f0-9]{16})", out) - if not m: - return False - return m.group(1).upper() == (gpg_key_id or "").upper() def sign_manifest(file: str, gpg_key_id: str, passphrase: Optional[str]) -> bool: @@ -222,9 +231,7 @@ def sign_manifest(file: str, gpg_key_id: str, passphrase: Optional[str]) -> bool return True with open(file, encoding="utf-8") as fh: data = json.load(fh) - # jq -c writes a terminating newline. Dispatcharr reconstructs this exact - # historical payload before verification, so it is part of the signature. - payload = jsonio.dumps(data["manifest"]) + "\n" + payload = _signing_payload(data["manifest"]) args = ["gpg", "--batch", "--yes", "--armor", "--detach-sign", "--local-user", gpg_key_id, "--output", "-"] if passphrase: diff --git a/.github/pluginctl/tests/test_manifest.py b/.github/pluginctl/tests/test_manifest.py index 19c10236..6c659891 100644 --- a/.github/pluginctl/tests/test_manifest.py +++ b/.github/pluginctl/tests/test_manifest.py @@ -146,9 +146,9 @@ def test_write_manifest_uses_formatted_json_and_ignores_existing_whitespace(tmp_ assert dest.read_text(encoding="utf-8") == json.dumps({"manifest": payload}) -def test_sign_manifest_signs_compact_payload_with_jq_newline(tmp_path, monkeypatch): +def test_sign_manifest_uses_dispatcharr_verification_payload(tmp_path, monkeypatch): path = tmp_path / "manifest.json" - manifest = {"registry_name": "demo", "plugins": []} + manifest = {"registry_name": "démo", "plugins": []} path.write_text(json.dumps({"manifest": manifest}), encoding="utf-8") captured = {} @@ -164,10 +164,33 @@ def run(args, **kwargs): monkeypatch.setattr(m.subprocess, "run", run) assert m.sign_manifest(str(path), "0123456789ABCDEF", None) - assert captured["input"] == jsonio.dumps(manifest) + "\n" + assert captured["input"] == json.dumps(manifest, separators=(",", ":"), ensure_ascii=True) + "\n" assert json.loads(path.read_text(encoding="utf-8"))["signature"] == "signature" +def test_sig_is_current_rejects_signature_for_an_old_payload_format(tmp_path, monkeypatch): + path = tmp_path / "manifest.json" + manifest = {"registry_name": "démo", "plugins": []} + path.write_text(json.dumps({"manifest": manifest, "signature": "signature"}), encoding="utf-8") + captured = {} + + class Result: + def __init__(self, returncode=0, stdout=""): + self.returncode = returncode + self.stdout = stdout + + def run(args, **kwargs): + if "--list-packets" in args: + return Result(stdout="issuer key ID 0123456789ABCDEF") + captured["input"] = kwargs["input"] + return Result(returncode=1) + + monkeypatch.setattr(m.subprocess, "run", run) + + assert not m.sig_is_current(str(path), "0123456789ABCDEF") + assert captured["input"] == json.dumps(manifest, separators=(",", ":"), ensure_ascii=True) + "\n" + + def test_pages_url_returns_empty_string_when_pages_is_unavailable(monkeypatch): monkeypatch.setattr(gh, "api", lambda *args, **kwargs: None) assert gh.pages_url("org/repo") == ""