zerynex-python 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zerynex_py/__init__.py +21 -0
- zerynex_py/agent_workflows.py +125 -0
- zerynex_py/client.py +406 -0
- zerynex_py/fixtures/clean-v6.bin +0 -0
- zerynex_py/fixtures/refund-isolated.json +21 -0
- zerynex_py/fixtures/refund-regression-expanded.json +29 -0
- zerynex_py/fixtures/refund-risky.json +21 -0
- zerynex_py/fixtures/rule-catalog.json +95 -0
- zerynex_py/fixtures/transparent-v6.bin +0 -0
- zerynex_py/fixtures/zallet-pczt-lifecycle-contract.json +87 -0
- zerynex_py/fixtures/zallet-pczt-lifecycle-synthetic-openrpc.json +127 -0
- zerynex_py/mcp_doctor.py +152 -0
- zerynex_py/mcp_server.py +391 -0
- zerynex_py/node.py +189 -0
- zerynex_py/preflight.py +42 -0
- zerynex_py/project.py +313 -0
- zerynex_py/refund_privacy.py +252 -0
- zerynex_py/refund_workflows.py +118 -0
- zerynex_py/rpc_contract.py +172 -0
- zerynex_py/rules.py +55 -0
- zerynex_py/z3.py +107 -0
- zerynex_py/zallet_fixture.py +158 -0
- zerynex_python-0.2.0.dist-info/METADATA +287 -0
- zerynex_python-0.2.0.dist-info/RECORD +28 -0
- zerynex_python-0.2.0.dist-info/WHEEL +5 -0
- zerynex_python-0.2.0.dist-info/entry_points.txt +10 -0
- zerynex_python-0.2.0.dist-info/licenses/LICENSE +21 -0
- zerynex_python-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Fixed, bundled synthetic-only refund privacy MCP and preflight workflows.
|
|
2
|
+
|
|
3
|
+
No caller-controlled filesystem paths, arbitrary manifests, real wallet refs or
|
|
4
|
+
private PCZT data cross the agent trust boundary.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
from .client import ZerynexError
|
|
12
|
+
from .refund_privacy import audit_manifest, compare_manifests, RefundAuditInputError
|
|
13
|
+
|
|
14
|
+
SCENARIOS = ("isolated", "risky", "regression-expanded")
|
|
15
|
+
FIXTURE_FILES = {
|
|
16
|
+
"isolated": "refund-isolated.json",
|
|
17
|
+
"risky": "refund-risky.json",
|
|
18
|
+
"regression-expanded": "refund-regression-expanded.json",
|
|
19
|
+
}
|
|
20
|
+
LIMITATION = (
|
|
21
|
+
"Bundled synthetic and self-declared metadata only. No wallet, refund, viewing "
|
|
22
|
+
"keys, private information retrieval, quantum protection, or actual chain data."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_scenario(scenario: str) -> dict[str, object]:
|
|
27
|
+
if type(scenario) is not str or scenario not in FIXTURE_FILES:
|
|
28
|
+
raise ZerynexError("Only reviewed synthetic refund scenarios are allowed.")
|
|
29
|
+
try:
|
|
30
|
+
raw = files("zerynex_py").joinpath("fixtures", FIXTURE_FILES[scenario]).read_bytes()
|
|
31
|
+
if len(raw) > 64 * 1024 or not raw:
|
|
32
|
+
raise ValueError("bad resource")
|
|
33
|
+
def unique(pairs):
|
|
34
|
+
result = {}
|
|
35
|
+
for key, value in pairs:
|
|
36
|
+
if key in result:
|
|
37
|
+
raise ValueError("duplicate")
|
|
38
|
+
result[key] = value
|
|
39
|
+
return result
|
|
40
|
+
value = json.loads(raw, object_pairs_hook=unique)
|
|
41
|
+
if type(value) is not dict:
|
|
42
|
+
raise ValueError("bad data")
|
|
43
|
+
return value
|
|
44
|
+
except (OSError, UnicodeError, ValueError, TypeError):
|
|
45
|
+
raise ZerynexError("Bundled synthetic refund scenario unavailable.") from None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def synthetic_refund_audit(scenario: str = "isolated") -> dict[str, object]:
|
|
49
|
+
"""Return only a sanitized report on one of three known test scenarios."""
|
|
50
|
+
data = _load_scenario(scenario)
|
|
51
|
+
try:
|
|
52
|
+
report = audit_manifest(data)
|
|
53
|
+
except RefundAuditInputError:
|
|
54
|
+
raise ZerynexError("Bundled synthetic refund scenario failed validation.") from None
|
|
55
|
+
return {
|
|
56
|
+
"scenario": scenario,
|
|
57
|
+
"synthetic": True,
|
|
58
|
+
"report": report,
|
|
59
|
+
"limitation": LIMITATION,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def synthetic_refund_regression(
|
|
64
|
+
baseline: str = "risky", candidate: str = "regression-expanded"
|
|
65
|
+
) -> dict[str, object]:
|
|
66
|
+
"""Diff pre-reviewed scenarios, never expose flow tokens or arbitrary JSON."""
|
|
67
|
+
left, right = _load_scenario(baseline), _load_scenario(candidate)
|
|
68
|
+
try:
|
|
69
|
+
report = compare_manifests(left, right)
|
|
70
|
+
except RefundAuditInputError:
|
|
71
|
+
raise ZerynexError("Bundled synthetic refund comparison failed validation.") from None
|
|
72
|
+
return {
|
|
73
|
+
"baseline": baseline,
|
|
74
|
+
"candidate": candidate,
|
|
75
|
+
"synthetic": True,
|
|
76
|
+
"report": report,
|
|
77
|
+
"limitation": LIMITATION,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def synthetic_refund_smoke_suite() -> dict[str, object]:
|
|
82
|
+
"""Verify the known pass/fail and per-flow risk-delta expectations."""
|
|
83
|
+
expected = {
|
|
84
|
+
"isolated": (True, 0),
|
|
85
|
+
"risky": (False, 5),
|
|
86
|
+
"regression-expanded": (False, 5),
|
|
87
|
+
}
|
|
88
|
+
outcomes: list[dict[str, object]] = []
|
|
89
|
+
for scenario, (passed, finding_count) in expected.items():
|
|
90
|
+
try:
|
|
91
|
+
data = synthetic_refund_audit(scenario)["report"]
|
|
92
|
+
matched = data["passed"] is passed and data["finding_count"] == finding_count
|
|
93
|
+
except (ZerynexError, KeyError, TypeError):
|
|
94
|
+
matched = False
|
|
95
|
+
outcomes.append({"scenario": scenario, "matched_expectation": matched})
|
|
96
|
+
for baseline, candidate, passed, new_events in (
|
|
97
|
+
("isolated", "risky", False, 8),
|
|
98
|
+
("risky", "regression-expanded", False, 1),
|
|
99
|
+
("risky", "isolated", True, 0),
|
|
100
|
+
):
|
|
101
|
+
try:
|
|
102
|
+
data = synthetic_refund_regression(baseline, candidate)["report"]
|
|
103
|
+
matched = data["passed"] is passed and data["new_risk_events"] == new_events
|
|
104
|
+
except (ZerynexError, KeyError, TypeError):
|
|
105
|
+
matched = False
|
|
106
|
+
outcomes.append({
|
|
107
|
+
"scenario": baseline + "-to-" + candidate,
|
|
108
|
+
"matched_expectation": matched,
|
|
109
|
+
})
|
|
110
|
+
count = sum(item["matched_expectation"] is True for item in outcomes)
|
|
111
|
+
return {
|
|
112
|
+
"synthetic": True,
|
|
113
|
+
"passed": count == len(outcomes),
|
|
114
|
+
"matched": count,
|
|
115
|
+
"total": len(outcomes),
|
|
116
|
+
"tests": outcomes,
|
|
117
|
+
"limitation": LIMITATION,
|
|
118
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Offline, value-free OpenRPC method/parameter compatibility check."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import stat
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
MAX_SCHEMA = 1024 * 1024
|
|
14
|
+
MAX_CONTRACT = 64 * 1024
|
|
15
|
+
SAFE_NAME = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_.-]{0,95}$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ContractInputError(Exception):
|
|
19
|
+
"""Sanitized error, never copies JSON source values or private paths."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict:
|
|
23
|
+
"""Reject ambiguous JSON input with repeated keys at any depth."""
|
|
24
|
+
decoded = {}
|
|
25
|
+
for key, value in pairs:
|
|
26
|
+
if key in decoded:
|
|
27
|
+
raise ContractInputError("Duplicate JSON key.")
|
|
28
|
+
decoded[key] = value
|
|
29
|
+
return decoded
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def unwrap_openrpc_schema(data: dict) -> dict:
|
|
33
|
+
"""Accept only a bare OpenRPC document or saved rpc.discover JSON-RPC reply."""
|
|
34
|
+
if "jsonrpc" not in data:
|
|
35
|
+
return data
|
|
36
|
+
if (set(data) != {"jsonrpc", "id", "result"} or
|
|
37
|
+
data["jsonrpc"] != "2.0" or
|
|
38
|
+
(data["id"] is not None and type(data["id"]) not in (str, int)) or
|
|
39
|
+
not isinstance(data["result"], dict)):
|
|
40
|
+
raise ContractInputError("Invalid rpc.discover response.")
|
|
41
|
+
schema = data["result"]
|
|
42
|
+
if not isinstance(schema.get("openrpc"), str) or "jsonrpc" in schema:
|
|
43
|
+
raise ContractInputError("Expected OpenRPC schema result.")
|
|
44
|
+
return schema
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def read_json(path: str, limit: int) -> dict:
|
|
48
|
+
try:
|
|
49
|
+
selected = Path(path)
|
|
50
|
+
if selected.is_symlink():
|
|
51
|
+
raise ContractInputError
|
|
52
|
+
# On POSIX, O_NOFOLLOW rejects symlink substitutions between our
|
|
53
|
+
# pathname check and open(). O_NONBLOCK avoids waiting on a swapped
|
|
54
|
+
# special file (e.g. FIFO); fstat checks the opened descriptor.
|
|
55
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
56
|
+
fd = os.open(selected, flags)
|
|
57
|
+
with os.fdopen(fd, 'rb') as handle:
|
|
58
|
+
info = os.fstat(handle.fileno())
|
|
59
|
+
if not stat.S_ISREG(info.st_mode) or not 0 < info.st_size <= limit:
|
|
60
|
+
raise ContractInputError
|
|
61
|
+
raw = handle.read(limit + 1)
|
|
62
|
+
if len(raw) > limit:
|
|
63
|
+
raise ContractInputError
|
|
64
|
+
decoded = json.loads(raw, object_pairs_hook=reject_duplicate_keys)
|
|
65
|
+
if not isinstance(decoded, dict):
|
|
66
|
+
raise ContractInputError
|
|
67
|
+
return decoded
|
|
68
|
+
except (OSError, ValueError, UnicodeError, RecursionError, ContractInputError):
|
|
69
|
+
raise ContractInputError("Invalid or oversized local OpenRPC input.") from None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_methods(data: dict, *, contract: bool) -> dict[str, list[tuple[str, bool]]]:
|
|
73
|
+
if contract and (type(data.get("version")) is not int or data["version"] != 1 or
|
|
74
|
+
set(data) != {"version", "methods"}):
|
|
75
|
+
raise ContractInputError("Unsupported RPC contract version.")
|
|
76
|
+
if not contract and not isinstance(data.get("openrpc"), str):
|
|
77
|
+
raise ContractInputError("OpenRPC version is required.")
|
|
78
|
+
items = data.get("methods")
|
|
79
|
+
if not isinstance(items, list) or not items or len(items) > 512:
|
|
80
|
+
raise ContractInputError("Invalid method list.")
|
|
81
|
+
parsed = {}
|
|
82
|
+
for entry in items:
|
|
83
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("name"), str):
|
|
84
|
+
raise ContractInputError("Invalid method description.")
|
|
85
|
+
name = entry["name"]
|
|
86
|
+
if not SAFE_NAME.fullmatch(name) or name in parsed:
|
|
87
|
+
raise ContractInputError("Invalid or repeated method name.")
|
|
88
|
+
if contract and set(entry) != {"name", "params"}:
|
|
89
|
+
raise ContractInputError("Unexpected contract field.")
|
|
90
|
+
params = entry.get("params")
|
|
91
|
+
if not isinstance(params, list) or len(params) > 100:
|
|
92
|
+
raise ContractInputError("Invalid parameter list.")
|
|
93
|
+
found = set()
|
|
94
|
+
parsed_params = []
|
|
95
|
+
for param in params:
|
|
96
|
+
if not isinstance(param, dict):
|
|
97
|
+
raise ContractInputError("Invalid parameter declaration.")
|
|
98
|
+
pname = param.get("name")
|
|
99
|
+
required = param.get("required", False)
|
|
100
|
+
if (not isinstance(pname, str) or not SAFE_NAME.fullmatch(pname)
|
|
101
|
+
or pname in found or type(required) is not bool):
|
|
102
|
+
raise ContractInputError("Invalid or duplicate parameter declaration.")
|
|
103
|
+
if contract and set(param) != {"name", "required"}:
|
|
104
|
+
raise ContractInputError("Unexpected parameter contract field.")
|
|
105
|
+
found.add(pname)
|
|
106
|
+
parsed_params.append((pname, required))
|
|
107
|
+
parsed[name] = parsed_params
|
|
108
|
+
return parsed
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def compare(schema: dict, contract: dict, *, deny_new_required: bool = False) -> dict:
|
|
112
|
+
actual = parse_methods(schema, contract=False)
|
|
113
|
+
expected = parse_methods(contract, contract=True)
|
|
114
|
+
findings = []
|
|
115
|
+
for method, expected_params in expected.items():
|
|
116
|
+
if method not in actual:
|
|
117
|
+
findings.append({"rule": "RPC-METHOD-MISSING", "method": method})
|
|
118
|
+
continue
|
|
119
|
+
actual_params = actual[method]
|
|
120
|
+
position = {name: index for index, (name, _) in enumerate(actual_params)}
|
|
121
|
+
last = -1
|
|
122
|
+
for name, required in expected_params:
|
|
123
|
+
if name not in position:
|
|
124
|
+
findings.append({"rule": "RPC-PARAMETER-MISSING", "method": method, "parameter": name})
|
|
125
|
+
continue
|
|
126
|
+
at = position[name]
|
|
127
|
+
if at < last:
|
|
128
|
+
findings.append({"rule": "RPC-PARAMETER-ORDER", "method": method, "parameter": name})
|
|
129
|
+
last = max(last, at)
|
|
130
|
+
if actual_params[at][1] != required:
|
|
131
|
+
findings.append({"rule": "RPC-REQUIRED-FLAG-CHANGED", "method": method, "parameter": name})
|
|
132
|
+
if deny_new_required:
|
|
133
|
+
expected_names = {name for name, _ in expected_params}
|
|
134
|
+
for name, required in actual_params:
|
|
135
|
+
if required and name not in expected_names:
|
|
136
|
+
findings.append({"rule": "RPC-NEW-REQUIRED-PARAMETER", "method": method, "parameter": name})
|
|
137
|
+
return {
|
|
138
|
+
"passed": len(findings) == 0,
|
|
139
|
+
"checked_methods": len(expected),
|
|
140
|
+
"observed_methods": len(actual),
|
|
141
|
+
"deny_new_required": deny_new_required,
|
|
142
|
+
"finding_count": len(findings),
|
|
143
|
+
"findings": findings,
|
|
144
|
+
"limitation": "Offline method and parameter structure only. No RPC execution, payload semantics, wallet safety or release compatibility verified.",
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def main() -> int:
|
|
149
|
+
parser = argparse.ArgumentParser(description="Offline Zcash OpenRPC contract checker")
|
|
150
|
+
parser.add_argument("--schema", required=True, help="Local OpenRPC JSON file")
|
|
151
|
+
parser.add_argument("--contract", required=True, help="Expected method/parameter JSON file")
|
|
152
|
+
parser.add_argument("--deny-new-required", action="store_true", help="Report newly required parameters on contracted methods")
|
|
153
|
+
parser.add_argument("--json", action="store_true")
|
|
154
|
+
args = parser.parse_args()
|
|
155
|
+
try:
|
|
156
|
+
schema = unwrap_openrpc_schema(read_json(args.schema, MAX_SCHEMA))
|
|
157
|
+
report = compare(schema, read_json(args.contract, MAX_CONTRACT), deny_new_required=args.deny_new_required)
|
|
158
|
+
except ContractInputError:
|
|
159
|
+
print("Zerynex RPC: cannot evaluate input. Verify local schema and contract structure.", file=sys.stderr)
|
|
160
|
+
return 1
|
|
161
|
+
if args.json:
|
|
162
|
+
print(json.dumps(report, indent=2))
|
|
163
|
+
else:
|
|
164
|
+
print("Zerynex RPC: {} of {} contracted methods checked, {} finding(s).".format(
|
|
165
|
+
report["checked_methods"], report["checked_methods"], report["finding_count"]))
|
|
166
|
+
for item in report["findings"]:
|
|
167
|
+
print("{}: {} {}".format(item["rule"], item["method"], item.get("parameter", "")))
|
|
168
|
+
return 0 if report["passed"] else 2
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
sys.exit(main())
|
zerynex_py/rules.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Bounded, versioned public rule metadata; no transaction material or network."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from importlib.resources import files
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from .client import ZerynexError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_rule_catalog(raw: str) -> dict[str, object]:
|
|
10
|
+
if not isinstance(raw,str) or not 0 < len(raw) < 32 * 1024:
|
|
11
|
+
raise ZerynexError('Invalid local rule catalog size.')
|
|
12
|
+
try:
|
|
13
|
+
def no_duplicates(pairs):
|
|
14
|
+
record={}
|
|
15
|
+
for key,value in pairs:
|
|
16
|
+
if key in record:
|
|
17
|
+
raise ValueError('duplicate keys')
|
|
18
|
+
record[key]=value
|
|
19
|
+
return record
|
|
20
|
+
data=json.loads(raw, object_pairs_hook=no_duplicates)
|
|
21
|
+
if (not isinstance(data,dict) or set(data) != {'schema_version','tool','rule_set_version','scope','rules'} or
|
|
22
|
+
type(data['schema_version']) is not int or data['schema_version'] != 1 or
|
|
23
|
+
data['tool'] != 'zerynex' or not isinstance(data['rule_set_version'],str) or
|
|
24
|
+
not re.fullmatch(r'[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}', data['rule_set_version']) or
|
|
25
|
+
not isinstance(data['scope'],str) or not 20 < len(data['scope']) <= 512):
|
|
26
|
+
raise ValueError
|
|
27
|
+
rules=data['rules']
|
|
28
|
+
if not isinstance(rules,list) or not 1 <= len(rules) <= 32:
|
|
29
|
+
raise ValueError
|
|
30
|
+
ids=[]
|
|
31
|
+
for rule in rules:
|
|
32
|
+
if (not isinstance(rule,dict) or set(rule) != {'id','title','severity','category','applies_to','summary'} or
|
|
33
|
+
not all(isinstance(rule[x],str) for x in ('id','title','severity','category','summary')) or
|
|
34
|
+
not re.fullmatch(r'PCZT-[A-Z-]{3,64}',rule['id']) or
|
|
35
|
+
not 5 <= len(rule['title']) <= 120 or not 10 <= len(rule['summary']) <= 256 or
|
|
36
|
+
rule['category'] not in ('metadata','pool_policy','handoff','regression') or
|
|
37
|
+
rule['severity'] not in ('warning','error') or
|
|
38
|
+
not isinstance(rule['applies_to'],list) or
|
|
39
|
+
rule['applies_to'] not in (['stage_comparison'],['analysis','stage_comparison'])):
|
|
40
|
+
raise ValueError
|
|
41
|
+
ids.append(rule['id'])
|
|
42
|
+
if ids != sorted(set(ids)) or any(not item.startswith('PCZT-') for item in ids):
|
|
43
|
+
raise ValueError
|
|
44
|
+
return data
|
|
45
|
+
except (ValueError,TypeError,KeyError):
|
|
46
|
+
raise ZerynexError('Invalid rule catalog from the local Rust engine.') from None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def bundled_rule_catalog() -> dict[str, object]:
|
|
50
|
+
"""Read the packaged metadata snapshot (validated during release CI)."""
|
|
51
|
+
try:
|
|
52
|
+
raw=files('zerynex_py').joinpath('fixtures','rule-catalog.json').read_bytes()
|
|
53
|
+
return parse_rule_catalog(raw.decode('utf-8'))
|
|
54
|
+
except (OSError,UnicodeError,ValueError):
|
|
55
|
+
raise ZerynexError('Bundled rule catalog is unavailable.') from None
|
zerynex_py/z3.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Privacy-conscious local Z3 Zebra liveness/readiness checks.
|
|
2
|
+
|
|
3
|
+
Uses ONLY the documented public health routes on an explicitly loopback-only
|
|
4
|
+
endpoint. No arbitrary hostnames, DNS resolution, wallet JSON-RPC, auth cookies,
|
|
5
|
+
transaction data, node credentials, or external network calls.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, asdict
|
|
10
|
+
from http.client import HTTPConnection, HTTPException
|
|
11
|
+
import ipaddress
|
|
12
|
+
import json
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from .client import ZerynexError
|
|
16
|
+
|
|
17
|
+
Status = Literal["healthy", "not_ready", "unavailable"]
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ZebraHealthReport:
|
|
21
|
+
endpoint: str
|
|
22
|
+
liveness: Status
|
|
23
|
+
readiness: Status
|
|
24
|
+
summary: str
|
|
25
|
+
source: str = "Z3 Zebra documented /healthy and /ready endpoints"
|
|
26
|
+
caution: str = "This tests a local health endpoint only. It does not validate consensus, Zaino gRPC, or Zallet wallet functionality."
|
|
27
|
+
|
|
28
|
+
def as_dict(self) -> dict[str, str]:
|
|
29
|
+
return asdict(self)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _safe_loopback_host(host: str) -> str:
|
|
33
|
+
if host == "localhost":
|
|
34
|
+
return "127.0.0.1" # Avoid OS proxy and non-loopback DNS surprises.
|
|
35
|
+
try:
|
|
36
|
+
addr = ipaddress.ip_address(host)
|
|
37
|
+
except ValueError:
|
|
38
|
+
raise ZerynexError("Only numeric loopback addresses or localhost are allowed.") from None
|
|
39
|
+
if not addr.is_loopback:
|
|
40
|
+
raise ZerynexError("Z3 diagnostics may connect to loopback only.")
|
|
41
|
+
return addr.compressed
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _query(host: str, port: int, route: str, timeout: float) -> int | None:
|
|
45
|
+
connection = HTTPConnection(host, port, timeout=timeout)
|
|
46
|
+
try:
|
|
47
|
+
connection.request("GET", route, headers={"Accept": "text/plain", "Connection": "close"})
|
|
48
|
+
response = connection.getresponse()
|
|
49
|
+
# A health response may contain implementation detail; discard the body
|
|
50
|
+
# rather than putting it into logs or an agent response.
|
|
51
|
+
response.read(4096)
|
|
52
|
+
return response.status
|
|
53
|
+
except (OSError, TimeoutError, HTTPException):
|
|
54
|
+
return None
|
|
55
|
+
finally:
|
|
56
|
+
connection.close()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def check_zebra_local(
|
|
60
|
+
*,
|
|
61
|
+
host: str = "127.0.0.1",
|
|
62
|
+
port: int = 8080,
|
|
63
|
+
timeout: float = 1.5,
|
|
64
|
+
) -> ZebraHealthReport:
|
|
65
|
+
"""Check Z3/Zebra local liveness and readiness; never scan public hosts."""
|
|
66
|
+
safe_host = _safe_loopback_host(host)
|
|
67
|
+
if type(port) is not int or not 1 <= port <= 65535:
|
|
68
|
+
raise ZerynexError("Invalid local Zebra health port.")
|
|
69
|
+
if not isinstance(timeout, (float, int)) or not (0 < timeout <= 3):
|
|
70
|
+
raise ZerynexError("Timeout must be greater than zero and at most three seconds.")
|
|
71
|
+
live = _query(safe_host, port, "/healthy", float(timeout))
|
|
72
|
+
ready = _query(safe_host, port, "/ready", float(timeout))
|
|
73
|
+
|
|
74
|
+
liveness: Status = "healthy" if live == 200 else "not_ready" if live is not None else "unavailable"
|
|
75
|
+
readiness: Status = "healthy" if ready == 200 else "not_ready" if ready is not None else "unavailable"
|
|
76
|
+
if live == 200 and ready == 200:
|
|
77
|
+
summary = "Zebra's local health and readiness endpoints both returned HTTP 200."
|
|
78
|
+
elif live == 200:
|
|
79
|
+
summary = "Zebra's local liveness endpoint responded, but readiness is not confirmed."
|
|
80
|
+
else:
|
|
81
|
+
summary = "Zebra liveness is not confirmed. Check the local Z3 services and port mapping."
|
|
82
|
+
return ZebraHealthReport(
|
|
83
|
+
endpoint=f"{safe_host}:{port}",
|
|
84
|
+
liveness=liveness,
|
|
85
|
+
readiness=readiness,
|
|
86
|
+
summary=summary,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main() -> None:
|
|
91
|
+
import argparse
|
|
92
|
+
parser = argparse.ArgumentParser(description="Zerynex: local-only Z3 Zebra health diagnostics")
|
|
93
|
+
parser.add_argument("--host", default="127.0.0.1", help="localhost or numeric loopback only")
|
|
94
|
+
parser.add_argument("--port", type=int, default=8080, help="Z3_ZEBRA_HOST_HEALTH_PORT")
|
|
95
|
+
parser.add_argument("--timeout", type=float, default=1.5, help="Request timeout, at most three seconds")
|
|
96
|
+
args = parser.parse_args()
|
|
97
|
+
try:
|
|
98
|
+
result = check_zebra_local(host=args.host, port=args.port, timeout=args.timeout)
|
|
99
|
+
except ZerynexError:
|
|
100
|
+
parser.exit(1, "Invalid or disallowed local health probe configuration.\n")
|
|
101
|
+
print(json.dumps(result.as_dict(), indent=2))
|
|
102
|
+
if result.liveness != "healthy" or result.readiness != "healthy":
|
|
103
|
+
parser.exit(2)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
main()
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Offline policy check for locally supplied, release-labelled Zallet PCZT fixtures.
|
|
3
|
+
|
|
4
|
+
The manifest's producer attribution is an assertion by its author. This tool
|
|
5
|
+
cannot authenticate that a PCZT was produced by Zallet.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import re
|
|
16
|
+
import stat
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
MAX_MANIFEST = 16 * 1024
|
|
21
|
+
MAX_BASE64 = ((8 * 1024 * 1024 + 2) // 3) * 4
|
|
22
|
+
NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,79}\.b64\Z")
|
|
23
|
+
RULE = re.compile(r"[A-Z][A-Z0-9-]{0,79}\Z")
|
|
24
|
+
FIELD = re.compile(r"[a-z][a-z0-9_.-]{0,95}\Z")
|
|
25
|
+
DIGEST = re.compile(r"[0-9a-f]{64}\Z")
|
|
26
|
+
PINNED_RELEASE = "v0.1.0-beta.3"
|
|
27
|
+
PINNED_COMMIT = "987382f67e622915228686e9f956c6a9c9a7514c"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FixtureError(Exception):
|
|
31
|
+
"""Never include fixture content or filesystem paths in this error."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def unique_pairs(pairs):
|
|
35
|
+
result = {}
|
|
36
|
+
for key, value in pairs:
|
|
37
|
+
if key in result:
|
|
38
|
+
raise FixtureError("Ambiguous manifest JSON.")
|
|
39
|
+
result[key] = value
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def read_regular(path: Path, limit: int) -> bytes:
|
|
44
|
+
try:
|
|
45
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
46
|
+
fd = os.open(path, flags)
|
|
47
|
+
with os.fdopen(fd, "rb") as handle:
|
|
48
|
+
info = os.fstat(handle.fileno())
|
|
49
|
+
if not stat.S_ISREG(info.st_mode) or not 0 < info.st_size <= limit:
|
|
50
|
+
raise FixtureError("Invalid local fixture size or type.")
|
|
51
|
+
value = handle.read(limit + 1)
|
|
52
|
+
if not 0 < len(value) <= limit:
|
|
53
|
+
raise FixtureError("Invalid local fixture size or type.")
|
|
54
|
+
return value
|
|
55
|
+
except (OSError, ValueError):
|
|
56
|
+
raise FixtureError("Cannot read a regular local fixture.") from None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def validate_manifest(value: object) -> list[dict]:
|
|
60
|
+
if not isinstance(value, dict) or set(value) != {"version", "producer", "cases"} or type(value["version"]) is not int or value["version"] != 1:
|
|
61
|
+
raise FixtureError("Invalid fixture manifest.")
|
|
62
|
+
producer = value["producer"]
|
|
63
|
+
if (not isinstance(producer, dict) or set(producer) != {"release", "commit", "origin"}
|
|
64
|
+
or producer != {"release": PINNED_RELEASE, "commit": PINNED_COMMIT,
|
|
65
|
+
"origin": "locally-supplied-zallet-output"}):
|
|
66
|
+
raise FixtureError("Unsupported or unpinned producer assertion.")
|
|
67
|
+
cases = value["cases"]
|
|
68
|
+
if not isinstance(cases, list) or not 1 <= len(cases) <= 16:
|
|
69
|
+
raise FixtureError("Invalid fixture case count.")
|
|
70
|
+
names = set()
|
|
71
|
+
for case in cases:
|
|
72
|
+
if not isinstance(case, dict) or set(case) != {"file", "sha256", "deny_transparent", "expect"}:
|
|
73
|
+
raise FixtureError("Invalid fixture case.")
|
|
74
|
+
name = case["file"]
|
|
75
|
+
expect = case["expect"]
|
|
76
|
+
if (not isinstance(name, str) or not NAME.fullmatch(name) or name in names
|
|
77
|
+
or not isinstance(case["sha256"], str) or not DIGEST.fullmatch(case["sha256"])
|
|
78
|
+
or type(case["deny_transparent"]) is not bool
|
|
79
|
+
or not isinstance(expect, list) or len(expect) > 32):
|
|
80
|
+
raise FixtureError("Invalid fixture case.")
|
|
81
|
+
names.add(name)
|
|
82
|
+
seen = set()
|
|
83
|
+
for item in expect:
|
|
84
|
+
if (not isinstance(item, dict) or set(item) != {"rule", "field"}
|
|
85
|
+
or not isinstance(item["rule"], str) or not RULE.fullmatch(item["rule"])
|
|
86
|
+
or not isinstance(item["field"], str) or not FIELD.fullmatch(item["field"])):
|
|
87
|
+
raise FixtureError("Invalid finding expectation.")
|
|
88
|
+
pair = (item["rule"], item["field"])
|
|
89
|
+
if pair in seen:
|
|
90
|
+
raise FixtureError("Repeated finding expectation.")
|
|
91
|
+
seen.add(pair)
|
|
92
|
+
return cases
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def run(manifest_path: Path, cli: Path) -> dict:
|
|
96
|
+
try:
|
|
97
|
+
manifest = json.loads(read_regular(manifest_path, MAX_MANIFEST), object_pairs_hook=unique_pairs)
|
|
98
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
99
|
+
raise FixtureError("Invalid fixture manifest JSON.") from None
|
|
100
|
+
cases = validate_manifest(manifest)
|
|
101
|
+
results = []
|
|
102
|
+
for case in cases:
|
|
103
|
+
encoded = read_regular(manifest_path.parent / case["file"], MAX_BASE64)
|
|
104
|
+
if hashlib.sha256(encoded).hexdigest() != case["sha256"]:
|
|
105
|
+
raise FixtureError("Fixture digest mismatch.")
|
|
106
|
+
try:
|
|
107
|
+
decoded = base64.b64decode(encoded, validate=True)
|
|
108
|
+
if not decoded or len(decoded) > 8 * 1024 * 1024 or base64.b64encode(decoded) != encoded:
|
|
109
|
+
raise FixtureError("Invalid canonical base64 fixture.")
|
|
110
|
+
except (ValueError, base64.binascii.Error):
|
|
111
|
+
raise FixtureError("Invalid canonical base64 fixture.") from None
|
|
112
|
+
args = [str(cli), "-", "--input-format", "base64", "--json"]
|
|
113
|
+
if case["deny_transparent"]:
|
|
114
|
+
args.append("--deny-transparent")
|
|
115
|
+
try:
|
|
116
|
+
completed = subprocess.run(args, input=encoded, capture_output=True, timeout=30, check=False)
|
|
117
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
118
|
+
raise FixtureError("Local analyzer unavailable or timed out.") from None
|
|
119
|
+
if completed.returncode not in (0, 2) or len(completed.stdout) > 1024 * 1024:
|
|
120
|
+
raise FixtureError("Local analyzer rejected a fixture.")
|
|
121
|
+
try:
|
|
122
|
+
report = json.loads(completed.stdout, object_pairs_hook=unique_pairs)
|
|
123
|
+
findings = report["findings"]
|
|
124
|
+
actual = sorted((item["rule"], item["field"]) for item in findings)
|
|
125
|
+
if (not isinstance(findings, list) or len(findings) > 32
|
|
126
|
+
or type(report["deny_transparent"]) is not bool
|
|
127
|
+
or report["deny_transparent"] != case["deny_transparent"]
|
|
128
|
+
or (completed.returncode == 0) != (len(actual) == 0)
|
|
129
|
+
or any(not RULE.fullmatch(rule) or not FIELD.fullmatch(field)
|
|
130
|
+
for rule, field in actual)):
|
|
131
|
+
raise FixtureError("Invalid analyzer report.")
|
|
132
|
+
except (ValueError, KeyError, TypeError, AttributeError):
|
|
133
|
+
raise FixtureError("Invalid analyzer report.") from None
|
|
134
|
+
expected = sorted((item["rule"], item["field"]) for item in case["expect"])
|
|
135
|
+
results.append({"case": len(results) + 1, "matched": actual == expected,
|
|
136
|
+
"finding_count": len(actual), "rules": sorted(set(rule for rule, _ in actual))})
|
|
137
|
+
return {"evidence": "locally-supplied-fixture; producer origin asserted, not authenticated",
|
|
138
|
+
"release_assertion": PINNED_RELEASE, "case_count": len(results),
|
|
139
|
+
"passed": all(item["matched"] for item in results), "cases": results,
|
|
140
|
+
"limitation": "No wallet RPC, signing, proving, broadcast, or live Zallet interoperability verified."}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main() -> int:
|
|
144
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
145
|
+
parser.add_argument("--manifest", required=True, type=Path)
|
|
146
|
+
parser.add_argument("--cli", required=True, type=Path)
|
|
147
|
+
args = parser.parse_args()
|
|
148
|
+
try:
|
|
149
|
+
result = run(args.manifest, args.cli)
|
|
150
|
+
except FixtureError:
|
|
151
|
+
print("Zerynex: invalid local PCZT fixture or analyzer result.", file=sys.stderr)
|
|
152
|
+
return 1
|
|
153
|
+
print(json.dumps(result, indent=2))
|
|
154
|
+
return 0 if result["passed"] else 2
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
raise SystemExit(main())
|