From ae701884f031d35582d8bb03d98bc26afdd46560 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:17:52 +0800 Subject: [PATCH 1/3] fix: preserve valid Unicode wallet asset codes Co-Authored-By: Codex --- application/account_facts.py | 24 +++++--- tests/test_binance_account_facts.py | 86 ++++++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 8 deletions(-) diff --git a/application/account_facts.py b/application/account_facts.py index 4c61b9ff..5f138333 100644 --- a/application/account_facts.py +++ b/application/account_facts.py @@ -10,6 +10,7 @@ import hashlib import json import re +import unicodedata from collections.abc import Mapping from datetime import datetime, timezone from decimal import Decimal, InvalidOperation, localcontext @@ -28,7 +29,19 @@ MAX_EARN_POSITIONS = 10_000 _HEX_SHA256 = re.compile(r"[0-9a-f]{64}\Z") _GIT_SHA = re.compile(r"[0-9a-f]{40}\Z") -_ASSET = re.compile(r"[A-Z0-9]{1,128}\Z") +_HANGUL_FILLERS = frozenset("\u115f\u1160\u3164\uffa0") + + +def _valid_asset(value: Any) -> bool: + if not isinstance(value, str) or not 1 <= len(value) <= 128: + return False + for character in value: + if character.isascii(): + if not ("A" <= character <= "Z" or "0" <= character <= "9"): + return False + elif character in _HANGUL_FILLERS or unicodedata.category(character)[0] not in {"L", "N"}: + return False + return True class AccountFactsUnavailable(ValueError): @@ -189,9 +202,7 @@ def collect_account_facts( asset = row.get("asset") if not isinstance(asset, str): raise _fail("spot_asset_type_invalid") - if not asset.isascii(): - raise _fail("spot_asset_non_ascii") - if not _ASSET.fullmatch(asset): + if not _valid_asset(asset): raise _fail("spot_asset_format_invalid") if asset in assets: raise _fail("spot_asset_duplicate") @@ -237,8 +248,7 @@ def collect_account_facts( asset = row.get("asset") product_id = row.get("productId") if ( - not isinstance(asset, str) - or not _ASSET.fullmatch(asset) + not _valid_asset(asset) or not isinstance(product_id, str) or not product_id or product_id in seen_products @@ -383,7 +393,7 @@ def validate_account_facts_payload(payload: Any) -> dict[str, Any]: }: raise _fail("payload_invalid") asset = row.get("asset") - if not isinstance(asset, str) or not _ASSET.fullmatch(asset) or asset in seen_assets: + if not _valid_asset(asset) or asset in seen_assets: raise _fail("payload_invalid") seen_assets.add(asset) amount_texts = [row.get(name) for name in ("spot_free", "spot_locked", "flexible_earn", "quantity")] diff --git a/tests/test_binance_account_facts.py b/tests/test_binance_account_facts.py index 4680bdfa..edca82b1 100644 --- a/tests/test_binance_account_facts.py +++ b/tests/test_binance_account_facts.py @@ -446,7 +446,7 @@ def get_account(self): ({"uid": "123456", "balances": ["synthetic-private-row"]}, "spot_balance_row_invalid"), ({"uid": "123456", "balances": [{"asset": 17}]}, "spot_asset_type_invalid"), ({"uid": "123456", "balances": [{"asset": "synthetic-bad-symbol"}]}, "spot_asset_format_invalid"), - ({"uid": "123456", "balances": [{"asset": "币"}]}, "spot_asset_non_ascii"), + ({"uid": "123456", "balances": [{"asset": "A\u200b"}]}, "spot_asset_format_invalid"), ({"uid": "123456", "balances": [ {"asset": "SYNTHETIC", "free": "0", "locked": "0"}, {"asset": "SYNTHETIC", "free": "0", "locked": "0"}, @@ -465,6 +465,90 @@ def get_account(self): assert client.calls == [("spot",)] +@pytest.mark.parametrize("asset", ["币", "12", "𐐀", "A" * 128, "汉" * 128]) +def test_unicode_asset_rules_preserve_spot_earn_and_payload_values(asset): + class UnicodeAsset(FakeBinance): + def get_account(self): + self.calls.append(("spot",)) + return { + "uid": "123456", + "balances": [{"asset": asset, "free": "0.5", "locked": "0"}], + } + + def get_simple_earn_flexible_product_position(self, *, current, size): + self.calls.append(("earn", current, size)) + return { + "rows": [{"asset": asset, "productId": "synthetic-product", "totalAmount": "0.25"}], + "total": 1, + } + + client = UnicodeAsset() + payload = _collect(client) + assert payload["assets"] == [{ + "asset": asset, + "quantity": "0.75", + "spot_free": "0.5", + "spot_locked": "0", + "flexible_earn": "0.25", + }] + encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + round_tripped = json.loads(encoded) + assert round_tripped["assets"][0]["asset"] == asset + validate_account_facts_payload(round_tripped) + + +@pytest.mark.parametrize("asset", [ + "", " ", "btc", "A-B", "A\u0301", "A\u200b", "A\u202e", "A\ud800", "😀", "A" * 129, + "A\u115f", "A\u1160", "A\u3164", "A\uffa0", +]) +def test_unicode_asset_rules_reject_unsafe_or_out_of_contract_values(asset): + class InvalidAsset(FakeBinance): + def get_account(self): + self.calls.append(("spot",)) + return { + "uid": "123456", + "balances": [{"asset": asset, "free": "0.5", "locked": "0"}], + } + + with pytest.raises(AccountFactsUnavailable, match="spot_asset_format_invalid"): + _collect(InvalidAsset()) + + +@pytest.mark.parametrize("asset", ["A\u115f", "A\u1160", "A\u3164", "A\uffa0"]) +def test_unicode_earn_asset_rejects_invisible_hangul_fillers(asset): + client = FakeBinance(earn_pages=[{ + "rows": [{"asset": asset, "productId": "synthetic-product", "totalAmount": "0.25"}], + "total": 1, + }]) + with pytest.raises(AccountFactsUnavailable, match="flexible_earn_page_invalid"): + _collect(client) + assert client.calls == [("spot",), ("earn", 1, 100)] + + +@pytest.mark.parametrize("asset", ["A\u115f", "A\u1160", "A\u3164", "A\uffa0"]) +def test_payload_validator_rejects_invisible_hangul_fillers(asset): + payload = _collect() + payload["assets"][0]["asset"] = asset + with pytest.raises(AccountFactsUnavailable, match="payload_invalid"): + validate_account_facts_payload(payload) + + +def test_exact_duplicate_unicode_asset_is_rejected_without_normalization(): + class DuplicateUnicodeAsset(FakeBinance): + def get_account(self): + self.calls.append(("spot",)) + return { + "uid": "123456", + "balances": [ + {"asset": "币", "free": "0.5", "locked": "0"}, + {"asset": "币", "free": "0.5", "locked": "0"}, + ], + } + + with pytest.raises(AccountFactsUnavailable, match="spot_asset_duplicate"): + _collect(DuplicateUnicodeAsset()) + + def test_rejects_earn_asset_without_an_explicit_spot_balance_row(): class SpotWithoutEarnAsset(FakeBinance): def get_account(self): From c8a8cb605e797d9b7baa51d4f9f2f2bce285d335 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:49:38 +0800 Subject: [PATCH 2/3] Classify private-safe account-facts publishing failures Co-Authored-By: Codex --- scripts/publish_binance_account_facts.py | 53 ++++++- tests/test_binance_account_facts.py | 179 +++++++++++++++++++++++ 2 files changed, 229 insertions(+), 3 deletions(-) diff --git a/scripts/publish_binance_account_facts.py b/scripts/publish_binance_account_facts.py index 936a7795..86dde13a 100644 --- a/scripts/publish_binance_account_facts.py +++ b/scripts/publish_binance_account_facts.py @@ -17,6 +17,24 @@ QRS_ENDPOINT = "https://qsl-strategy-switch-console.pigbibi.workers.dev/api/internal/binance-account-facts" +_HTTP_ERROR_LABELS = { + (400, "invalid_binance_account_facts"): "http_400_report_invalid", + (400, "invalid_binance_account_facts_time"): "http_400_report_time_invalid", + (400, "invalid_binance_account_facts_quantity"): "http_400_report_quantity_invalid", + (400, "invalid_binance_account_facts_assets"): "http_400_report_assets_invalid", + (401, "binance_account_facts_token_invalid"): "http_401_token_invalid", + (409, "binance_account_facts_binding_unmatched"): "http_409_binding_unmatched", + (409, "binance_account_facts_identity_mismatch"): "http_409_identity_mismatch", + (409, "binance_account_facts_observation_invalid"): "http_409_observation_invalid", + (409, "binance_account_facts_observation_conflict"): "http_409_observation_conflict", + (413, "binance_account_facts_payload_too_large"): "http_413_payload_too_large", + (503, "binance_account_facts_token_unavailable"): "http_503_receiver_unavailable", + (503, "binance_account_facts_binding_missing"): "http_503_receiver_unavailable", + (503, "binance_account_facts_binding_invalid"): "http_503_receiver_unavailable", + (503, "binance_account_facts_storage_unavailable"): "http_503_receiver_unavailable", + (503, "binance_account_facts_unavailable"): "http_503_receiver_unavailable", +} +_HTTP_REJECTED = "account_facts_publish_http_rejected" class PublishError(ValueError): @@ -28,6 +46,23 @@ def redirect_request(self, request, response, code, message, headers, new_url): return None +def _http_failure_code(error: HTTPError) -> str: + try: + raw = error.read(4097) + if not isinstance(raw, bytes) or len(raw) > 4096: + return _HTTP_REJECTED + body = json.loads(raw.decode("utf-8")) + if (not isinstance(body, dict) or set(body) != {"ok", "error"} + or body.get("ok") is not False or not isinstance(body.get("error"), str)): + return _HTTP_REJECTED + label = _HTTP_ERROR_LABELS.get((error.code, body["error"])) + if label is None: + return _HTTP_REJECTED + return f"account_facts_publish_{label}" + except (UnicodeError, ValueError, TypeError, RecursionError): + return _HTTP_REJECTED + + def publish_account_facts(*, facts_path: Path, env) -> str: if env.get("BINANCE_ACCOUNT_FACTS_ENABLED") != "true": raise PublishError("account_facts_publish_disabled") @@ -60,12 +95,24 @@ def publish_account_facts(*, facts_path: Path, env) -> str: raise PublishError("account_facts_publish_rejected") raw_ack = response.read(4097) if len(raw_ack) > 4096: - raise PublishError("account_facts_publish_rejected") + raise PublishError("account_facts_publish_ack_invalid") ack = json.loads(raw_ack.decode("utf-8")) except PublishError: raise - except (HTTPError, URLError, OSError, TimeoutError, UnicodeError, json.JSONDecodeError): - raise PublishError("account_facts_publish_failed") from None + except HTTPError as error: + try: + failure_code = _http_failure_code(error) + except TimeoutError: + raise PublishError("account_facts_publish_timeout") from None + except (URLError, OSError): + raise PublishError("account_facts_publish_network_failed") from None + raise PublishError(failure_code) from None + except TimeoutError: + raise PublishError("account_facts_publish_timeout") from None + except (URLError, OSError): + raise PublishError("account_facts_publish_network_failed") from None + except (UnicodeError, ValueError, TypeError, AttributeError, RecursionError): + raise PublishError("account_facts_publish_ack_invalid") from None if not isinstance(ack, dict) or ack.get("status") not in {"published", "unchanged"}: raise PublishError("account_facts_publish_rejected") return ack["status"] diff --git a/tests/test_binance_account_facts.py b/tests/test_binance_account_facts.py index edca82b1..02f843b0 100644 --- a/tests/test_binance_account_facts.py +++ b/tests/test_binance_account_facts.py @@ -4,6 +4,7 @@ import json from datetime import datetime, timedelta, timezone from pathlib import Path +from urllib.error import URLError import pytest @@ -1095,3 +1096,181 @@ def open(self, _request, timeout): "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN": "synthetic-token", }, ) + + +@pytest.mark.parametrize(("status", "receiver_code", "safe_label"), [ + (400, "invalid_binance_account_facts_assets", "http_400_report_assets_invalid"), + (401, "binance_account_facts_token_invalid", "http_401_token_invalid"), + (409, "binance_account_facts_observation_conflict", "http_409_observation_conflict"), + (413, "binance_account_facts_payload_too_large", "http_413_payload_too_large"), + (503, "binance_account_facts_binding_missing", "http_503_receiver_unavailable"), +]) +def test_publisher_classifies_only_known_http_error_pairs_without_leaking_body( + tmp_path, monkeypatch, status, receiver_code, safe_label +): + from io import BytesIO + from urllib.error import HTTPError + from scripts import publish_binance_account_facts as publisher + + facts_path = tmp_path / "facts.json" + facts_path.write_text(json.dumps(_collect()), encoding="utf-8") + secret = "synthetic-sensitive-placeholder" + private_url = f"https://private.example/path?token={secret}" + reads = [] + + class Opener: + def open(self, _request, timeout): + assert timeout == 20 + error = HTTPError( + private_url, + status, + "private exception detail", + hdrs=None, + fp=BytesIO(json.dumps({"ok": False, "error": receiver_code}).encode()), + ) + original_read = error.read + + def tracked_read(amount=None): + reads.append(amount) + return original_read(amount) + + error.read = tracked_read + raise error + + monkeypatch.setattr(publisher, "build_opener", lambda _handler: Opener()) + with pytest.raises(publisher.PublishError) as raised: + publisher.publish_account_facts( + facts_path=facts_path, + env={ + "BINANCE_ACCOUNT_FACTS_ENABLED": "true", + "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN": "synthetic-token", + }, + ) + assert str(raised.value) == f"account_facts_publish_{safe_label}" + assert receiver_code not in str(raised.value) + assert secret not in str(raised.value) + assert "private.example" not in str(raised.value) + assert reads == [4097] + + +@pytest.mark.parametrize(("status", "body"), [ + (400, b"{\"ok\":false,\"error\":\"unknown-private-code\",\"private\":\"SENSITIVE\"}"), + (418, b"SENSITIVE private body"), + (409, b"x" * 4097), +]) +def test_publisher_rejects_unknown_or_oversized_http_error_body_closed( + tmp_path, monkeypatch, status, body +): + from io import BytesIO + from urllib.error import HTTPError + from scripts import publish_binance_account_facts as publisher + + facts_path = tmp_path / "facts.json" + facts_path.write_text(json.dumps(_collect()), encoding="utf-8") + reads = [] + + class Opener: + def open(self, _request, timeout): + error = HTTPError( + "https://private.example/sensitive-path", + status, + "private exception detail", + hdrs=None, + fp=BytesIO(body), + ) + original_read = error.read + + def tracked_read(amount=None): + reads.append(amount) + return original_read(amount) + + error.read = tracked_read + raise error + + monkeypatch.setattr(publisher, "build_opener", lambda _handler: Opener()) + with pytest.raises(publisher.PublishError) as raised: + publisher.publish_account_facts( + facts_path=facts_path, + env={ + "BINANCE_ACCOUNT_FACTS_ENABLED": "true", + "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN": "synthetic-token", + }, + ) + assert str(raised.value) == "account_facts_publish_http_rejected" + assert "SENSITIVE" not in str(raised.value) + assert "private.example" not in str(raised.value) + assert reads == [4097] + + +@pytest.mark.parametrize(("failure", "reason"), [ + (lambda: URLError("private network detail"), "account_facts_publish_network_failed"), + (lambda: OSError("private socket detail"), "account_facts_publish_network_failed"), + (lambda: TimeoutError("private timeout detail"), "account_facts_publish_timeout"), +]) +def test_publisher_classifies_transport_failures_without_retry_or_leak( + tmp_path, monkeypatch, failure, reason +): + from scripts import publish_binance_account_facts as publisher + + facts_path = tmp_path / "facts.json" + facts_path.write_text(json.dumps(_collect()), encoding="utf-8") + calls = [] + + class Opener: + def open(self, _request, timeout): + calls.append(timeout) + raise failure() + + monkeypatch.setattr(publisher, "build_opener", lambda _handler: Opener()) + with pytest.raises(publisher.PublishError) as raised: + publisher.publish_account_facts( + facts_path=facts_path, + env={ + "BINANCE_ACCOUNT_FACTS_ENABLED": "true", + "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN": "synthetic-secret", + }, + ) + assert str(raised.value) == reason + assert "private" not in str(raised.value) + assert "synthetic-secret" not in str(raised.value) + assert calls == [20] + + +@pytest.mark.parametrize("ack", [b"not-json", b"\xff\xfe"]) +def test_publisher_classifies_invalid_ack_without_leaking_or_retrying(tmp_path, monkeypatch, ack): + from scripts import publish_binance_account_facts as publisher + + facts_path = tmp_path / "facts.json" + facts_path.write_text(json.dumps(_collect()), encoding="utf-8") + calls = [] + + class Response: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self, limit): + assert limit == 4097 + return ack + + class Opener: + def open(self, _request, timeout): + calls.append(timeout) + return Response() + + monkeypatch.setattr(publisher, "build_opener", lambda _handler: Opener()) + with pytest.raises(publisher.PublishError) as raised: + publisher.publish_account_facts( + facts_path=facts_path, + env={ + "BINANCE_ACCOUNT_FACTS_ENABLED": "true", + "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN": "synthetic-secret", + }, + ) + assert str(raised.value) == "account_facts_publish_ack_invalid" + assert "synthetic-secret" not in str(raised.value) + assert calls == [20] From 55bd244b57fde1ef65d4591897405b1fb0f9ab4e Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:37:16 +0800 Subject: [PATCH 3/3] Diagnose account facts receiver from the existing runner with one readonly request Co-Authored-By: Codex --- .../diagnose-account-facts-receiver.yml | 48 +++++ ...diagnose_binance_account_facts_receiver.py | 170 ++++++++++++++++++ scripts/publish_binance_account_facts.py | 31 ++-- tests/test_binance_account_facts.py | 161 ++++++++++++++++- 4 files changed, 393 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/diagnose-account-facts-receiver.yml create mode 100644 scripts/diagnose_binance_account_facts_receiver.py diff --git a/.github/workflows/diagnose-account-facts-receiver.yml b/.github/workflows/diagnose-account-facts-receiver.yml new file mode 100644 index 00000000..148154e7 --- /dev/null +++ b/.github/workflows/diagnose-account-facts-receiver.yml @@ -0,0 +1,48 @@ +name: Diagnose Binance Account Facts Receiver + +"on": + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: binance-account-facts-readonly + cancel-in-progress: false + +jobs: + readiness: + if: github.event_name == 'workflow_dispatch' && github.repository == 'QuantStrategyLab/BinancePlatform' && github.ref == 'refs/heads/main' + runs-on: self-hosted + timeout-minutes: 2 + environment: binance-runtime + steps: + - name: Checkout the exact dispatch revision + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Require the exact current main revision + shell: bash + run: | + set -euo pipefail + if [[ "$GITHUB_EVENT_NAME" != "workflow_dispatch" || \ + "$GITHUB_REPOSITORY" != "QuantStrategyLab/BinancePlatform" || \ + "$GITHUB_REF" != "refs/heads/main" || \ + ! "$GITHUB_SHA" =~ ^[0-9a-f]{40}$ || \ + "$(git rev-parse HEAD)" != "$GITHUB_SHA" ]]; then + echo "receiver_diagnosis_source_unqualified" >&2 + exit 1 + fi + current_main_sha="$(git ls-remote https://github.com/QuantStrategyLab/BinancePlatform.git refs/heads/main 2>/dev/null | awk 'NF == 2 && $2 == "refs/heads/main" { print $1 }')" + if [[ ! "$current_main_sha" =~ ^[0-9a-f]{40}$ || "$current_main_sha" != "$GITHUB_SHA" ]]; then + echo "receiver_diagnosis_main_sha_mismatch" >&2 + exit 1 + fi + + - name: Read receiver readiness once + shell: bash + env: + BINANCE_ACCOUNT_FACTS_SYNC_TOKEN: ${{ secrets.BINANCE_ACCOUNT_FACTS_SYNC_TOKEN }} + run: python3 scripts/diagnose_binance_account_facts_receiver.py diff --git a/scripts/diagnose_binance_account_facts_receiver.py b/scripts/diagnose_binance_account_facts_receiver.py new file mode 100644 index 00000000..7401fbc4 --- /dev/null +++ b/scripts/diagnose_binance_account_facts_receiver.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Make one authenticated, read-only readiness request to the QRS receiver.""" +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from urllib.error import HTTPError +from urllib.request import HTTPRedirectHandler, Request, build_opener + +QRS_ENDPOINT = "https://qsl-strategy-switch-console.pigbibi.workers.dev/api/internal/binance-account-facts" + +_READINESS_FIELDS = {"ok", "ready", "binding_valid", "account_options_readable", "unique_match"} +_READINESS_ERROR_FIELDS = _READINESS_FIELDS | {"error"} +_TOKEN_ERROR_CODES = {"binance_account_facts_token_invalid"} +_BINDING_ERROR_CODES = {"binance_account_facts_binding_unmatched"} +_CONFIG_ERROR_CODES = { + "binance_account_facts_token_unavailable", + "binance_account_facts_binding_missing", + "binance_account_facts_binding_invalid", +} +_READINESS_ERROR_CODES = _TOKEN_ERROR_CODES | _BINDING_ERROR_CODES | _CONFIG_ERROR_CODES | { + "binance_account_facts_account_options_unavailable", + "binance_account_facts_unavailable", +} +_MAX_BODY_BYTES = 4096 + + +class _NoRedirect(HTTPRedirectHandler): + def redirect_request(self, request, response, code, message, headers, new_url): + return None + + +@dataclass(frozen=True) +class ReceiverDiagnosis: + category: str + http_status: int | None = None + body_shape: str = "none" + receiver_error: str | None = None + cloudflare_code: int | None = None + + +def _json_body_shape(raw: bytes, headers) -> tuple[str, str | None, int | None]: + if len(raw) > _MAX_BODY_BYTES: + return "oversized", None, None + if not raw: + return "empty", None, None + try: + def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON key") + result[key] = value + return result + + body = json.loads( + raw.decode("utf-8"), + object_pairs_hook=unique_object, + parse_constant=lambda _value: (_ for _ in ()).throw(ValueError("invalid JSON constant")), + ) + except (UnicodeError, ValueError, RecursionError): + return "invalid_json", None, None + if not isinstance(body, dict): + return "json_non_object", None, None + + if (set(body) == _READINESS_FIELDS + and all(type(body.get(key)) is bool for key in _READINESS_FIELDS)): + if all(body[key] is True for key in _READINESS_FIELDS): + return "readiness", None, None + return "readiness_not_ready", None, None + + if (set(body) == _READINESS_ERROR_FIELDS + and all(type(body.get(key)) is bool for key in _READINESS_FIELDS) + and body.get("ok") is False and body.get("ready") is False + and isinstance(body.get("error"), str) + and body["error"] in _READINESS_ERROR_CODES): + error = body["error"] + if error in _TOKEN_ERROR_CODES: + return "receiver_error", "token_invalid", None + if error in _BINDING_ERROR_CODES: + return "receiver_error", "binding_unmatched", None + if error in _CONFIG_ERROR_CODES: + return "receiver_error", "config_or_binding", None + if error == "binance_account_facts_account_options_unavailable": + return "receiver_error", "account_options_unavailable", None + return "receiver_error", "receiver_unavailable", None + + if (set(body) == {"ok", "error"} and body.get("ok") is False + and isinstance(body.get("error"), str) + and body["error"] in _TOKEN_ERROR_CODES): + return "token_error", "token_invalid", None + + cf_code = _cloudflare_error_code(body, headers) + if cf_code is not None: + return "cloudflare_error", None, cf_code + return "json_object_other", None, None + + +def _cloudflare_error_code(body: dict, headers) -> int | None: + """Expose only the numeric code from the standard Cloudflare error envelope.""" + if str(headers.get("Server", "")).lower() != "cloudflare": + return None + if set(body) != {"success", "errors", "messages", "result"}: + return None + if body.get("success") is not False or body.get("messages") != [] or body.get("result") is not None: + return None + errors = body.get("errors") + if not isinstance(errors, list) or len(errors) != 1 or not isinstance(errors[0], dict): + return None + error = errors[0] + if (set(error) != {"code", "message"} or type(error.get("code")) is not int + or not 0 <= error["code"] <= 999_999 or not isinstance(error.get("message"), str)): + return None + return error["code"] + + +def _read_once(request: Request, *, opener_factory=build_opener) -> tuple[int, bytes, object]: + try: + with opener_factory(_NoRedirect).open(request, timeout=15) as response: + return response.status, response.read(_MAX_BODY_BYTES + 1), response.headers + except HTTPError as error: + try: + return error.code, error.read(_MAX_BODY_BYTES + 1), error.headers or {} + finally: + error.close() + + +def diagnose_receiver(*, token: str, opener_factory=build_opener) -> ReceiverDiagnosis: + if not isinstance(token, str) or not token or token != token.strip(): + return ReceiverDiagnosis("token_missing") + request = Request( + QRS_ENDPOINT, + headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, + method="GET", + ) + try: + status, raw, headers = _read_once(request, opener_factory=opener_factory) + except Exception: + return ReceiverDiagnosis("transport") + body_shape, safe_code, cf_code = _json_body_shape(raw, headers) + if status == 200 and body_shape == "readiness": + return ReceiverDiagnosis("ready", status, body_shape) + if safe_code: + return ReceiverDiagnosis(safe_code, status, body_shape, safe_code) + if cf_code is not None: + return ReceiverDiagnosis("cloudflare_rejected", status, body_shape, cloudflare_code=cf_code) + if 300 <= status <= 399: + return ReceiverDiagnosis("redirect", status, body_shape) + if 100 <= status <= 599: + return ReceiverDiagnosis("http_rejected", status, body_shape) + return ReceiverDiagnosis("response_invalid", body_shape=body_shape) + + +def main() -> int: + result = diagnose_receiver(token=os.environ.get("BINANCE_ACCOUNT_FACTS_SYNC_TOKEN", "")) + fields = [f"category={result.category}"] + if result.http_status is not None: + fields.append(f"http_status={result.http_status}") + fields.append(f"body_shape={result.body_shape}") + if result.receiver_error is not None: + fields.append(f"receiver_code={result.receiver_error}") + if result.cloudflare_code is not None: + fields.append(f"cloudflare_code={result.cloudflare_code}") + print("binance_account_facts_receiver_diagnosis " + " ".join(fields)) + return 0 if result.category == "ready" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/publish_binance_account_facts.py b/scripts/publish_binance_account_facts.py index 86dde13a..4e26af43 100644 --- a/scripts/publish_binance_account_facts.py +++ b/scripts/publish_binance_account_facts.py @@ -47,20 +47,29 @@ def redirect_request(self, request, response, code, message, headers, new_url): def _http_failure_code(error: HTTPError) -> str: + status = error.code + if type(status) is not int or not 100 <= status <= 599: + return _HTTP_REJECTED try: raw = error.read(4097) - if not isinstance(raw, bytes) or len(raw) > 4096: - return _HTTP_REJECTED - body = json.loads(raw.decode("utf-8")) - if (not isinstance(body, dict) or set(body) != {"ok", "error"} - or body.get("ok") is not False or not isinstance(body.get("error"), str)): - return _HTTP_REJECTED - label = _HTTP_ERROR_LABELS.get((error.code, body["error"])) - if label is None: - return _HTTP_REJECTED - return f"account_facts_publish_{label}" + if not isinstance(raw, bytes): + shape = "invalid_body" + elif len(raw) > 4096: + shape = "body_oversized" + else: + shape = "body_invalid_json" + body = json.loads(raw.decode("utf-8")) + if isinstance(body, dict): + shape = "body_unrecognized_json" + if (set(body) == {"ok", "error"} and body.get("ok") is False + and isinstance(body.get("error"), str)): + shape = "body_unknown_error_code" + label = _HTTP_ERROR_LABELS.get((status, body["error"])) + if label is not None: + return f"account_facts_publish_{label}" + return f"account_facts_publish_http_{status}_{shape}" except (UnicodeError, ValueError, TypeError, RecursionError): - return _HTTP_REJECTED + return f"account_facts_publish_http_{status}_body_invalid_json" def publish_account_facts(*, facts_path: Path, env) -> str: diff --git a/tests/test_binance_account_facts.py b/tests/test_binance_account_facts.py index 02f843b0..7acbc0af 100644 --- a/tests/test_binance_account_facts.py +++ b/tests/test_binance_account_facts.py @@ -1153,13 +1153,16 @@ def tracked_read(amount=None): assert reads == [4097] -@pytest.mark.parametrize(("status", "body"), [ - (400, b"{\"ok\":false,\"error\":\"unknown-private-code\",\"private\":\"SENSITIVE\"}"), - (418, b"SENSITIVE private body"), - (409, b"x" * 4097), +@pytest.mark.parametrize(("status", "body", "reason"), [ + (400, b"{\"ok\":false,\"error\":\"unknown-private-code\",\"private\":\"SENSITIVE\"}", + "account_facts_publish_http_400_body_unrecognized_json"), + (400, b"{\"ok\":false,\"error\":\"unknown-private-code\"}", + "account_facts_publish_http_400_body_unknown_error_code"), + (418, b"SENSITIVE private body", "account_facts_publish_http_418_body_invalid_json"), + (409, b"x" * 4097, "account_facts_publish_http_409_body_oversized"), ]) def test_publisher_rejects_unknown_or_oversized_http_error_body_closed( - tmp_path, monkeypatch, status, body + tmp_path, monkeypatch, status, body, reason ): from io import BytesIO from urllib.error import HTTPError @@ -1196,7 +1199,8 @@ def tracked_read(amount=None): "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN": "synthetic-token", }, ) - assert str(raised.value) == "account_facts_publish_http_rejected" + assert str(raised.value) == reason + assert str(status) in str(raised.value) assert "SENSITIVE" not in str(raised.value) assert "private.example" not in str(raised.value) assert reads == [4097] @@ -1274,3 +1278,148 @@ def open(self, _request, timeout): assert str(raised.value) == "account_facts_publish_ack_invalid" assert "synthetic-secret" not in str(raised.value) assert calls == [20] + + +def test_receiver_diagnosis_is_one_authenticated_get_with_closed_success(): + from scripts import diagnose_binance_account_facts_receiver as diagnosis + from scripts import publish_binance_account_facts as publisher + + assert diagnosis.QRS_ENDPOINT == publisher.QRS_ENDPOINT + calls = [] + + class Response: + status = 200 + headers = {"Content-Type": "application/json"} + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self, limit): + assert limit == 4097 + return json.dumps({ + "ok": True, "ready": True, "binding_valid": True, + "account_options_readable": True, "unique_match": True, + }).encode() + + class Opener: + def open(self, request, timeout): + calls.append(request) + assert timeout == 15 + return Response() + + result = diagnosis.diagnose_receiver(token="synthetic-secret", opener_factory=lambda _handler: Opener()) + assert result.category == "ready" + assert result.http_status == 200 + assert result.body_shape == "readiness" + assert len(calls) == 1 + assert calls[0].method == "GET" + assert calls[0].data is None + assert calls[0].get_header("Authorization") == "Bearer synthetic-secret" + assert "synthetic-secret" not in repr(result) + + +@pytest.mark.parametrize( + "status,body,headers,category,shape,receiver_code,cf_code", + [ + (403, {"success": False, "errors": [{"code": 1020, "message": "private edge detail"}], + "messages": [], "result": None}, {"Server": "cloudflare"}, + "cloudflare_rejected", "cloudflare_error", None, 1020), + (403, {"private": "sensitive response"}, {"Server": "cloudflare"}, + "http_rejected", "json_object_other", None, None), + (401, {"ok": False, "error": []}, {}, + "http_rejected", "json_object_other", None, None), + (401, {"ok": False, "error": {}}, {}, + "http_rejected", "json_object_other", None, None), + (401, {"ok": False, "error": "binance_account_facts_token_invalid"}, {}, + "token_invalid", "token_error", "token_invalid", None), + (503, {"ok": False, "ready": False, "binding_valid": False, + "account_options_readable": False, "unique_match": False, + "error": "binance_account_facts_account_options_unavailable"}, {}, + "account_options_unavailable", "receiver_error", "account_options_unavailable", None), + ], +) +def test_receiver_diagnosis_emits_only_closed_status_and_body_metadata( + status, body, headers, category, shape, receiver_code, cf_code +): + from io import BytesIO + from urllib.error import HTTPError + from scripts import diagnose_binance_account_facts_receiver as diagnosis + + calls = [] + + class Opener: + def open(self, request, timeout): + calls.append(request) + assert timeout == 15 + error = HTTPError( + "https://private.example/private-path", status, "sensitive exception", + headers, BytesIO(json.dumps(body).encode()), + ) + raise error + + result = diagnosis.diagnose_receiver(token="synthetic-token", opener_factory=lambda _handler: Opener()) + assert result.category == category + assert result.http_status == status + assert result.body_shape == shape + assert result.receiver_error == receiver_code + assert result.cloudflare_code == cf_code + assert len(calls) == 1 and calls[0].method == "GET" and calls[0].data is None + assert "private edge detail" not in repr(result) + assert "private.example" not in repr(result) + assert "sensitive exception" not in repr(result) + + +def test_receiver_diagnosis_missing_token_and_transport_failure_never_retry(): + from scripts import diagnose_binance_account_facts_receiver as diagnosis + + calls = [] + missing = diagnosis.diagnose_receiver(token="", opener_factory=lambda *_: calls.append("unexpected")) + assert missing.category == "token_missing" + assert calls == [] + + class Opener: + def open(self, request, timeout): + calls.append((request.method, timeout)) + raise OSError("private transport detail") + + failed = diagnosis.diagnose_receiver(token="synthetic-token", opener_factory=lambda _handler: Opener()) + assert failed.category == "transport" + assert len(calls) == 1 and calls[0] == ("GET", 15) + + +def test_receiver_diagnosis_rejects_ambiguous_json_and_unverified_cloudflare_code(): + from scripts import diagnose_binance_account_facts_receiver as diagnosis + + shape, code, cf_code = diagnosis._json_body_shape( + b'{"ok":true,"ok":false}', {"Server": "cloudflare"} + ) + assert (shape, code, cf_code) == ("invalid_json", None, None) + envelope = json.dumps({ + "success": False, + "errors": [{"code": 1020, "message": "private"}], + "messages": [], "result": None, + }).encode() + shape, code, cf_code = diagnosis._json_body_shape(envelope, {"Server": "other"}) + assert (shape, code, cf_code) == ("json_object_other", None, None) + + +def test_receiver_diagnosis_workflow_is_manual_main_only_and_get_only(): + workflow = (Path(__file__).parents[1] / ".github" / "workflows" + / "diagnose-account-facts-receiver.yml").read_text(encoding="utf-8") + assert '"on":\n workflow_dispatch:' in workflow + assert "refs/heads/main" in workflow + assert "git ls-remote https://github.com/QuantStrategyLab/BinancePlatform.git refs/heads/main" in workflow + assert 'ref: ${{ github.sha }}' in workflow + assert "git rev-parse HEAD" in workflow + assert "group: binance-account-facts-readonly" in workflow + assert "environment: binance-runtime" in workflow + assert "runs-on: self-hosted" in workflow + assert "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN: ${{ secrets.BINANCE_ACCOUNT_FACTS_SYNC_TOKEN }}" in workflow + assert "python3 scripts/diagnose_binance_account_facts_receiver.py" in workflow + assert "account_facts_publish" not in workflow + assert "read_binance_account_facts.py" not in workflow + assert "workflow_run:" not in workflow and "schedule:" not in workflow and "push:" not in workflow + assert "RUNTIME_TARGET_ENABLED" not in workflow and "BINANCE_ACCOUNT_FACTS_ENABLED" not in workflow