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/project.py ADDED
@@ -0,0 +1,313 @@
1
+ """Offline, non-destructive Zcash project bootstrap and real Rust integration verification.
2
+
3
+ Only ships reviewed, nonspendable public test vectors. Never reads or copies
4
+ real wallet PCZTs, keys, node credentials, or unapproved workspace files.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ from importlib import resources
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import stat
14
+ from pathlib import Path
15
+ import sys
16
+
17
+ from .client import Policy, ZerynexClient, ZerynexError
18
+
19
+ SCHEMA = 1
20
+ MAX_POLICY_BYTES = 4096
21
+ PREFIX = Path('.zerynex')
22
+ POLICY = PREFIX / 'policy.json'
23
+ CLEAN = PREFIX / 'fixtures' / 'clean-v6.bin'
24
+ TRANSPARENT = PREFIX / 'fixtures' / 'transparent-v6.bin'
25
+ MANIFEST = PREFIX / 'project.json'
26
+ WORKFLOW = Path('.github/workflows/zerynex-privacy.yml')
27
+ # A reviewed, immutable and publicly available release-base revision.
28
+ ACTION_SHA = '8dcbbba5584d1f168ec688ca42cd8f185ea904d1'
29
+ POLICY_BYTES = (json.dumps({'profile': 'external_signer', 'deny_transparent': True,
30
+ 'deny_sapling': False}, indent=2) + '\n').encode()
31
+ WORKFLOW_BYTES = ('''# Synthetic, nonspendable PCZT only. Do not add wallet transactions or keys.
32
+ name: Zcash privacy regression
33
+ on: [push, pull_request]
34
+ permissions:
35
+ contents: read
36
+ jobs:
37
+ privacy:
38
+ runs-on: ubuntu-latest
39
+ steps:
40
+ - uses: actions/checkout@v4
41
+ # Review and update this pinned immutable commit deliberately.
42
+ - uses: Oluwa-Laughter/zerynex@''' + ACTION_SHA + '''
43
+ id: privacy
44
+ with:
45
+ pczt-path: .zerynex/fixtures/clean-v6.bin
46
+ policy-path: .zerynex/policy.json
47
+ fail-on-findings: 'true'
48
+ ''').encode()
49
+
50
+ class ProjectError(Exception):
51
+ """Safe operator-facing message without exposing private project paths."""
52
+
53
+
54
+ def _root(path: str) -> Path:
55
+ candidate = Path(path).expanduser().absolute()
56
+ if candidate.is_symlink():
57
+ raise ProjectError('Project root cannot be a symlink.')
58
+ try:
59
+ root = candidate.resolve(strict=True)
60
+ if not root.is_dir():
61
+ raise ProjectError('Project root must be an existing directory.')
62
+ except (OSError, ValueError):
63
+ raise ProjectError('Project root is unavailable.') from None
64
+ return root
65
+
66
+
67
+ def _guard(root: Path, relative: Path, *, must_exist: bool) -> Path:
68
+ target = root / relative
69
+ for ancestor in (root, *[root.joinpath(*relative.parts[:i]) for i in range(1, len(relative.parts))]):
70
+ if ancestor.is_symlink() or (ancestor.exists() and not ancestor.is_dir()):
71
+ raise ProjectError('Project contains an unsafe destination directory.')
72
+ if target.is_symlink() or (target.exists() and not target.is_file()):
73
+ raise ProjectError('Generated project file location is unsafe.')
74
+ if must_exist and not target.is_file():
75
+ raise ProjectError('Project integration has not been initialized.')
76
+ if not must_exist and (target.exists() or target.is_symlink()):
77
+ raise ProjectError('Initialization would overwrite an existing file; no files were changed.')
78
+ return target
79
+
80
+
81
+ def _assets(*, ci: bool) -> dict[Path, bytes]:
82
+ fixture_root = resources.files('zerynex_py').joinpath('fixtures')
83
+ clean = fixture_root.joinpath('clean-v6.bin').read_bytes()
84
+ flagged = fixture_root.joinpath('transparent-v6.bin').read_bytes()
85
+ if not (0 < len(clean) <= 8192 and 0 < len(flagged) <= 8192 and clean != flagged):
86
+ raise ProjectError('Bundled synthetic fixtures are unavailable.')
87
+ manifest = {'schema_version': SCHEMA, 'synthetic_only': True,
88
+ 'fixture_sha256': {'clean-v6.bin': hashlib.sha256(clean).hexdigest(),
89
+ 'transparent-v6.bin': hashlib.sha256(flagged).hexdigest()},
90
+ 'policy_sha256': hashlib.sha256(POLICY_BYTES).hexdigest(),
91
+ 'expected': {'clean_findings': 0, 'transparent_rule': 'PCZT-TRANSPARENT-OUTPUTS'}}
92
+ bundle = {POLICY: POLICY_BYTES, CLEAN: clean, TRANSPARENT: flagged,
93
+ MANIFEST: (json.dumps(manifest, indent=2) + '\n').encode()}
94
+ if ci:
95
+ bundle[WORKFLOW] = WORKFLOW_BYTES
96
+ return bundle
97
+
98
+
99
+ def initialize(root: Path, *, ci: bool = False) -> dict[str, object]:
100
+ """Preflight all paths, then write with O_EXCL. Never overwrite project data."""
101
+ bundle = _assets(ci=ci)
102
+ targets = {relative: _guard(root, relative, must_exist=False) for relative in bundle}
103
+ created: list[Path] = []
104
+ try:
105
+ for relative, target in targets.items():
106
+ target.parent.mkdir(parents=True, exist_ok=True)
107
+ # POSIX O_NOFOLLOW hardens the already validated target path.
108
+ flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, 'O_NOFOLLOW', 0)
109
+ fd = os.open(target, flags, 0o644)
110
+ created.append(target)
111
+ with os.fdopen(fd, 'wb') as handle:
112
+ handle.write(bundle[relative])
113
+ except (OSError, ValueError):
114
+ for target in reversed(created):
115
+ try:
116
+ target.unlink()
117
+ except OSError:
118
+ pass
119
+ raise ProjectError('Could not safely initialize the project. New files were rolled back.') from None
120
+ return {'schema_version': SCHEMA, 'initialized': True, 'synthetic_only': True,
121
+ 'ci_configured': ci, 'created_files': [x.as_posix() for x in bundle],
122
+ 'limitation': 'No real PCZT files, node connections, wallet access, or signing.'}
123
+
124
+
125
+ def verify(root: Path, cli: str | None) -> dict[str, object]:
126
+ """Verify ONLY bundled synthetic fixture hashes using the real local Rust CLI."""
127
+ for file in (MANIFEST, POLICY, CLEAN, TRANSPARENT):
128
+ _guard(root, file, must_exist=True)
129
+ expected = _assets(ci=False)
130
+ # Do not read arbitrarily long or replaced files; bundled fixture hashes must match.
131
+ for relative, known in expected.items():
132
+ target = root / relative
133
+ if target.stat().st_size != len(known):
134
+ raise ProjectError('Project synthetic fixture or policy has been changed.')
135
+ with target.open('rb') as handle:
136
+ if handle.read(len(known) + 1) != known:
137
+ raise ProjectError('Project synthetic fixture or policy has been changed.')
138
+ client = ZerynexClient(cli=cli)
139
+ capabilities = client.capabilities()
140
+ if 'analyze_pczt' not in capabilities.operations or 'binary' not in capabilities.input_formats:
141
+ raise ProjectError('The Rust CLI does not support PCZT analysis.')
142
+ policy = Policy(deny_transparent=True)
143
+ clean = client.analyze_file(root / CLEAN, policy)
144
+ transparent = client.analyze_file(root / TRANSPARENT, policy)
145
+ clean_ok = clean.passed and not clean.findings
146
+ transparent_ok = (not transparent.passed and
147
+ any(f.rule == 'PCZT-TRANSPARENT-OUTPUTS' for f in transparent.findings))
148
+ passed = clean_ok and transparent_ok
149
+ return {'schema_version': SCHEMA, 'passed': passed, 'synthetic_only': True,
150
+ 'engine': 'local_rust_cli', 'checks': {
151
+ 'capabilities': True, 'fixture_integrity': True,
152
+ 'clean_fixture': clean_ok, 'transparent_detection': transparent_ok,
153
+ }, 'limitation': 'Only known nonspendable vectors. Does not certify integration with a real wallet, node or transaction.'}
154
+
155
+
156
+
157
+ # Local source policy: opt-in per PCZT. Never discover, enumerate, or upload
158
+ # user-controlled transaction files. The trusted developer controls this tree.
159
+ def _project_file(root: Path, raw: str) -> Path:
160
+ if not isinstance(raw, str) or not raw or "\x00" in raw:
161
+ raise ProjectError('Explicit PCZT path is required.')
162
+ candidate = Path(raw).expanduser()
163
+ if not candidate.is_absolute():
164
+ candidate = root / candidate
165
+ lexical = Path(os.path.abspath(candidate))
166
+ try:
167
+ relative = lexical.relative_to(root)
168
+ except ValueError:
169
+ raise ProjectError('PCZT must reside inside the selected project.') from None
170
+ if not relative.parts:
171
+ raise ProjectError('Select a PCZT file, not a directory.')
172
+ # Reject symlink traversal, even if it eventually resolves back inside
173
+ # the project. The file itself must be regular and bounded.
174
+ for i in range(1, len(relative.parts) + 1):
175
+ segment = root.joinpath(*relative.parts[:i])
176
+ if segment.is_symlink():
177
+ raise ProjectError('PCZT symlinks are not permitted.')
178
+ try:
179
+ fd = os.open(lexical, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) |
180
+ getattr(os, 'O_NONBLOCK', 0))
181
+ try:
182
+ meta = os.fstat(fd)
183
+ if not stat.S_ISREG(meta.st_mode) or not 0 < meta.st_size <= 8 * 1024 * 1024:
184
+ raise ProjectError('PCZT must be a regular file of at most 8 MiB.')
185
+ finally:
186
+ os.close(fd)
187
+ except (OSError, ValueError):
188
+ raise ProjectError('Selected PCZT file is unavailable.') from None
189
+ return lexical
190
+
191
+
192
+ def _policy_from_project(root: Path) -> Policy:
193
+ policy_path = _guard(root, POLICY, must_exist=True)
194
+ try:
195
+ fd = os.open(policy_path, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) |
196
+ getattr(os, 'O_NONBLOCK', 0))
197
+ try:
198
+ metadata = os.fstat(fd)
199
+ if not stat.S_ISREG(metadata.st_mode) or not 0 < metadata.st_size <= MAX_POLICY_BYTES:
200
+ raise ProjectError('Project policy must be a bounded regular file.')
201
+ with os.fdopen(fd, 'rb', closefd=False) as stream:
202
+ raw = stream.read(MAX_POLICY_BYTES + 1)
203
+ finally:
204
+ os.close(fd)
205
+ def unique(entries: list[tuple[str, object]]) -> dict[str, object]:
206
+ parsed: dict[str, object] = {}
207
+ for key, value in entries:
208
+ if key in parsed:
209
+ raise ValueError('duplicate')
210
+ parsed[key] = value
211
+ return parsed
212
+ data = json.loads(raw, object_pairs_hook=unique,
213
+ parse_constant=lambda _: (_ for _ in ()).throw(ValueError('constant')))
214
+ if (not isinstance(data, dict) or set(data) != {'profile', 'deny_transparent', 'deny_sapling'}
215
+ or data['profile'] not in ('external_signer', 'restricted_handoff')
216
+ or type(data['deny_transparent']) is not bool
217
+ or type(data['deny_sapling']) is not bool):
218
+ raise ValueError('policy')
219
+ return Policy(profile=data['profile'], deny_transparent=data['deny_transparent'],
220
+ deny_sapling=data['deny_sapling'])
221
+ except (OSError, ValueError, TypeError, UnicodeError, ZerynexError):
222
+ raise ProjectError('Project policy is invalid or cannot be read.') from None
223
+
224
+
225
+ def _safe_summary(rule_ids: list[str]) -> list[str]:
226
+ # Field paths, arbitrary report explanations and RPC metadata must never
227
+ # escape into terminal output or agent logs. Output only known rule IDs.
228
+ from .rules import bundled_rule_catalog
229
+ catalog = bundled_rule_catalog()
230
+ known = {entry['id'] for entry in catalog['rules']}
231
+ if any(rule not in known for rule in rule_ids):
232
+ raise ProjectError('Rust engine returned an unsupported policy rule.')
233
+ return sorted(set(rule_ids))
234
+
235
+
236
+ def check_project(root: Path, cli: str | None, pczt: str) -> dict[str, object]:
237
+ """Run one explicitly supplied PCZT through the real local Rust engine."""
238
+ _guard(root, MANIFEST, must_exist=True)
239
+ policy = _policy_from_project(root)
240
+ candidate = _project_file(root, pczt)
241
+ report = ZerynexClient(cli=cli).analyze_file(candidate, policy)
242
+ return {'schema_version': SCHEMA, 'passed': report.passed,
243
+ 'input': 'explicit_local_file', 'engine': 'local_rust_cli',
244
+ 'finding_count': len(report.findings),
245
+ 'rule_ids': _safe_summary([item.rule for item in report.findings]),
246
+ 'limitation': 'Structural policy findings only; not a signature, proof, anonymity or payment verification. No raw PCZT fields are returned.'}
247
+
248
+
249
+ def compare_project(root: Path, cli: str | None, before: str, after: str) -> dict[str, object]:
250
+ """Check explicit stage regression; does not assume equivalent transactions."""
251
+ _guard(root, MANIFEST, must_exist=True)
252
+ policy = _policy_from_project(root)
253
+ baseline = _project_file(root, before)
254
+ candidate = _project_file(root, after)
255
+ if baseline == candidate:
256
+ raise ProjectError('Before and after PCZT files must be distinct.')
257
+ result = ZerynexClient(cli=cli).compare_files(baseline, candidate, policy)
258
+ return {'schema_version': SCHEMA, 'passed': result.passed,
259
+ 'input': 'two_explicit_local_files',
260
+ 'engine': 'local_rust_cli', 'no_new_findings': result.no_new_findings,
261
+ 'before_findings_count': result.before_findings_count,
262
+ 'after_findings_count': result.after_findings_count,
263
+ 'added_rule_ids': _safe_summary([rule for rule, _ in result.added_findings]),
264
+ 'resolved_rule_ids': _safe_summary([rule for rule, _ in result.resolved_findings]),
265
+ 'limitation': 'Only selected policy regressions; not equivalent payments, proof validity or safe signing. No raw PCZT fields are returned.'}
266
+
267
+
268
+ def main() -> None:
269
+ parser = argparse.ArgumentParser(description='Initialize and verify offline Zerynex integration in another project.')
270
+ actions = parser.add_subparsers(dest='action', required=True)
271
+ for name in ('init', 'verify', 'check', 'compare'):
272
+ sub = actions.add_parser(name)
273
+ sub.add_argument('--project', required=True, help='Existing developer project directory')
274
+ sub.add_argument('--json', action='store_true', help='Sanitized machine-readable summary')
275
+ if name == 'init':
276
+ sub.add_argument('--ci', action='store_true', help='Scaffold pinned synthetic-only GitHub Action')
277
+ else:
278
+ sub.add_argument('--cli', default=os.environ.get('ZERYNEX_CLI'),
279
+ help='Locally installed trusted Rust CLI path')
280
+ if name == 'check':
281
+ sub.add_argument('--pczt', required=True, help='Explicit PCZT path inside this project')
282
+ elif name == 'compare':
283
+ sub.add_argument('--before', required=True, help='Explicit earlier PCZT file')
284
+ sub.add_argument('--after', required=True, help='Explicit later PCZT file')
285
+ args = parser.parse_args()
286
+ try:
287
+ root = _root(args.project)
288
+ if args.action == 'init':
289
+ result = initialize(root, ci=args.ci)
290
+ elif args.action == 'verify':
291
+ result = verify(root, args.cli)
292
+ elif args.action == 'check':
293
+ result = check_project(root, args.cli, args.pczt)
294
+ else:
295
+ result = compare_project(root, args.cli, args.before, args.after)
296
+ except (ProjectError, ZerynexError, OSError, ValueError):
297
+ # Never echo CLI output, selected PCZT bytes or private workspace paths.
298
+ print('Zerynex project setup or local verification was rejected.', file=sys.stderr)
299
+ raise SystemExit(1) from None
300
+ if args.json:
301
+ print(json.dumps(result, indent=2))
302
+ else:
303
+ print('Zerynex project', args.action, 'PASS' if result.get('passed', result.get('initialized')) else 'FAIL')
304
+ if args.action == 'init':
305
+ print('Synthetic-only integration created. Use verify with your trusted Rust CLI.')
306
+ elif args.action == 'verify':
307
+ print('Local Rust CLI, synthetic fixture integrity and expected rule behavior checked.')
308
+ else:
309
+ print('Explicit local PCZT policy report: no sensitive transaction content included.')
310
+ raise SystemExit(0 if result.get('passed', result.get('initialized')) else 2)
311
+
312
+ if __name__ == '__main__':
313
+ main()
@@ -0,0 +1,252 @@
1
+ """Offline, metadata-only refund linkability checks for Zcash swap integrations.
2
+
3
+ This intentionally does NOT process Zcash addresses, viewing keys, spend keys,
4
+ refund payments, PIR queries, or cryptographic secrets. References in the
5
+ input are developer-supplied random 128-bit opaque tokens, not hashes of keys.
6
+ Reports contain only fixed rule identifiers and aggregate counts.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ import re
15
+ import stat
16
+ import sys
17
+ from typing import Any
18
+
19
+ MAX_INPUT = 64 * 1024
20
+ MAX_ENTRIES = 100
21
+ TOKEN = re.compile(r'^[0-9a-f]{32}$')
22
+ RULES = (
23
+ 'SWAP-REFUND-ADDRESS-REUSE',
24
+ 'SWAP-VIEWING-SCOPE-REUSE',
25
+ 'SWAP-PUBLIC-REFUND-UNSCOPED',
26
+ 'SWAP-REFUND-LOOKUP-DISCLOSURE',
27
+ 'SWAP-SPENDING-KEY-CUSTODY-UNVERIFIED',
28
+ )
29
+ LIMITATION = ('Static, self-declared integration metadata only. No viewing-key cryptography, quantum-security, PIR, '
30
+ 'wallet behavior, chain activity, or safe recovery is verified. Do not provide addresses or keys.')
31
+
32
+ class RefundAuditInputError(ValueError):
33
+ pass
34
+
35
+
36
+ def _record(obj: object, keys: set[str]) -> dict[str, object]:
37
+ if type(obj) is not dict or set(obj) != keys:
38
+ raise RefundAuditInputError('Invalid privacy exposure manifest shape.')
39
+ return obj
40
+
41
+
42
+ def _token(value: object) -> str:
43
+ if type(value) is not str or not TOKEN.fullmatch(value):
44
+ raise RefundAuditInputError('Expected an opaque 128-bit random reference, not an address or key.')
45
+ return value
46
+
47
+
48
+ def _evaluate_manifest(manifest: object) -> tuple[dict[str, object], set[tuple[str, str]]]:
49
+ """Evaluate declarations while retaining opaque in-memory event identity."""
50
+ root = _record(manifest, {'schema_version', 'flows'})
51
+ if type(root['schema_version']) is not int or root['schema_version'] != 1:
52
+ raise RefundAuditInputError('Unsupported refund audit schema.')
53
+ flows = root['flows']
54
+ if type(flows) is not list or not 1 <= len(flows) <= MAX_ENTRIES:
55
+ raise RefundAuditInputError('Expected 1 to 100 application-declared swap flows.')
56
+ normalized: list[dict[str, Any]] = []
57
+ seen_flow: set[str] = set()
58
+ keyset = {'flow_ref', 'refund_address_ref', 'viewing_scope_ref',
59
+ 'published_publicly', 'lookup_mode', 'spending_key_custody'}
60
+ for item in flows:
61
+ row = _record(item, keyset)
62
+ refs = {k: _token(row[k]) for k in ('flow_ref', 'refund_address_ref', 'viewing_scope_ref')}
63
+ if refs['flow_ref'] in seen_flow:
64
+ raise RefundAuditInputError('Duplicate swap-flow record.')
65
+ seen_flow.add(refs['flow_ref'])
66
+ if type(row['published_publicly']) is not bool:
67
+ raise RefundAuditInputError('Expected an explicit public-disclosure boolean.')
68
+ if row['lookup_mode'] not in ('private_claimed', 'direct', 'unknown') or type(row['lookup_mode']) is not str:
69
+ raise RefundAuditInputError('Invalid wallet refund-lookup declaration.')
70
+ if row['spending_key_custody'] not in ('wallet_claimed', 'external', 'unknown') or type(row['spending_key_custody']) is not str:
71
+ raise RefundAuditInputError('Invalid spending-key custody declaration.')
72
+ normalized.append({**refs, 'published_publicly': row['published_publicly'],
73
+ 'lookup_mode': row['lookup_mode'], 'spending_key_custody': row['spending_key_custody']})
74
+
75
+ address_count: dict[str, int] = {}
76
+ scope_count: dict[str, int] = {}
77
+ for row in normalized:
78
+ address_count[row['refund_address_ref']] = address_count.get(row['refund_address_ref'], 0) + 1
79
+ scope_count[row['viewing_scope_ref']] = scope_count.get(row['viewing_scope_ref'], 0) + 1
80
+ findings: set[str] = set()
81
+ # Opaque flow references are intentionally never returned in a report.
82
+ events: set[tuple[str, str]] = set()
83
+ for row in normalized:
84
+ def flag(rule: str) -> None:
85
+ findings.add(rule)
86
+ events.add((row['flow_ref'], rule))
87
+
88
+ if address_count[row['refund_address_ref']] > 1:
89
+ flag('SWAP-REFUND-ADDRESS-REUSE')
90
+ if scope_count[row['viewing_scope_ref']] > 1:
91
+ flag('SWAP-VIEWING-SCOPE-REUSE')
92
+ if row['published_publicly'] and scope_count[row['viewing_scope_ref']] > 1:
93
+ flag('SWAP-PUBLIC-REFUND-UNSCOPED')
94
+ if row['lookup_mode'] != 'private_claimed':
95
+ flag('SWAP-REFUND-LOOKUP-DISCLOSURE')
96
+ if row['spending_key_custody'] != 'wallet_claimed':
97
+ flag('SWAP-SPENDING-KEY-CUSTODY-UNVERIFIED')
98
+ return {
99
+ 'schema_version': 1, 'passed': not findings, 'flows_checked': len(normalized),
100
+ 'public_refund_flows': sum(bool(row['published_publicly']) for row in normalized),
101
+ 'finding_count': len(findings), 'rule_ids': sorted(findings),
102
+ 'status': 'declaration_review_only', 'limitation': LIMITATION,
103
+ }, events
104
+
105
+
106
+ def audit_manifest(manifest: object) -> dict[str, object]:
107
+ """Sanitized, single-manifest privacy-declaration review."""
108
+ return _evaluate_manifest(manifest)[0]
109
+
110
+
111
+ def compare_manifests(baseline: object, current: object) -> dict[str, object]:
112
+ """Diff per-flow risk events without ever exporting opaque references.
113
+
114
+ A rule already present in the baseline can still be *new* on another
115
+ flow. New flow IDs are treated as new integration declarations.
116
+ """
117
+ before, old_events = _evaluate_manifest(baseline)
118
+ after, new_events = _evaluate_manifest(current)
119
+ added = new_events - old_events
120
+ resolved = old_events - new_events
121
+ return {
122
+ 'schema_version': 1,
123
+ 'passed': not added,
124
+ 'status': 'declaration_regression_review_only',
125
+ 'baseline_flows_checked': before['flows_checked'],
126
+ 'current_flows_checked': after['flows_checked'],
127
+ 'baseline_risk_events': len(old_events),
128
+ 'current_risk_events': len(new_events),
129
+ 'new_risk_events': len(added),
130
+ 'resolved_risk_events': len(resolved),
131
+ 'new_rule_ids': sorted({rule for _, rule in added}),
132
+ 'resolved_rule_ids': sorted({rule for _, rule in resolved}),
133
+ 'limitation': (
134
+ 'Only application-declared logical flow identity and static risk changes. '
135
+ 'It cannot attest stable wallet identity, viewing-key isolation, private lookup, '
136
+ 'quantum resistance or transaction privacy. References are never returned.'
137
+ ),
138
+ }
139
+
140
+
141
+ def _unique_pairs(pairs: list[tuple[str, object]]) -> dict[str, object]:
142
+ values: dict[str, object] = {}
143
+ for key, value in pairs:
144
+ if key in values:
145
+ raise RefundAuditInputError('Duplicate JSON field.')
146
+ values[key] = value
147
+ return values
148
+
149
+
150
+ def read_manifest(path: str) -> dict[str, object]:
151
+ target = Path(path)
152
+ if target.is_symlink():
153
+ raise RefundAuditInputError('Symlinks are not supported.')
154
+ try:
155
+ flags = os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_NONBLOCK', 0)
156
+ fd = os.open(target, flags)
157
+ try:
158
+ meta = os.fstat(fd)
159
+ if not stat.S_ISREG(meta.st_mode) or meta.st_size <= 0 or meta.st_size > MAX_INPUT:
160
+ raise RefundAuditInputError('Manifest is not a bounded regular file.')
161
+ with os.fdopen(fd, 'rb', closefd=False) as stream:
162
+ raw = stream.read(MAX_INPUT + 1)
163
+ if len(raw) > MAX_INPUT:
164
+ raise RefundAuditInputError('Manifest exceeds file limit.')
165
+ finally:
166
+ os.close(fd)
167
+ parsed = json.loads(raw, object_pairs_hook=_unique_pairs,
168
+ parse_constant=lambda _: (_ for _ in ()).throw(RefundAuditInputError('Invalid numeric constant.')))
169
+ if type(parsed) is not dict:
170
+ raise RefundAuditInputError('Manifest must be a JSON object.')
171
+ return parsed
172
+ except (OSError, UnicodeError, ValueError, TypeError):
173
+ raise RefundAuditInputError('Invalid local refund privacy manifest.') from None
174
+
175
+
176
+
177
+ def sarif_report(report: dict[str, object]) -> dict[str, object]:
178
+ """Portable SARIF 2.1.0 rules without source locations or sensitive refs."""
179
+ expected = ('new_rule_ids' if report.get('status') == 'declaration_regression_review_only'
180
+ else 'rule_ids')
181
+ present = report.get(expected)
182
+ if type(present) is not list or any(type(rule) is not str or rule not in RULES for rule in present):
183
+ raise RefundAuditInputError('Invalid internal audit findings.')
184
+ ordered = sorted(set(present))
185
+ return {
186
+ '$schema': 'https://json.schemastore.org/sarif-2.1.0.json',
187
+ 'version': '2.1.0',
188
+ 'runs': [{
189
+ 'tool': {'driver': {
190
+ 'name': 'Zerynex Refund Privacy Audit',
191
+ 'informationUri': 'https://zerynex.vercel.app/docs/refund-privacy-audit',
192
+ 'semanticVersion': '0.1.0',
193
+ 'rules': [
194
+ {'id': name,
195
+ 'shortDescription': {'text': 'Review declared Zcash swap refund privacy exposure'},
196
+ 'helpUri': 'https://zerynex.vercel.app/docs/refund-privacy-audit'}
197
+ for name in RULES
198
+ ],
199
+ }},
200
+ 'results': [
201
+ {'ruleId': name, 'level': 'warning',
202
+ 'message': {'text': 'Application-declared refund privacy risk requires review.'}}
203
+ for name in ordered
204
+ ],
205
+ 'properties': {
206
+ 'declarationOnly': True,
207
+ 'mode': 'regression' if expected == 'new_rule_ids' else 'full_audit',
208
+ 'warningCount': len(ordered),
209
+ 'noInputLocations': True,
210
+ },
211
+ }],
212
+ }
213
+
214
+ def main() -> None:
215
+ parser = argparse.ArgumentParser(description='Offline self-declared swap refund and viewing-key isolation audit.')
216
+ parser.add_argument('--manifest', required=True, help='Current local public-safe metadata JSON, never addresses or keys')
217
+ parser.add_argument('--baseline', help='Optional earlier metadata JSON: fail only on newly introduced per-flow risks')
218
+ output = parser.add_mutually_exclusive_group()
219
+ output.add_argument('--json', action='store_true', help='Sanitized machine-readable report')
220
+ output.add_argument('--sarif', action='store_true', help='Location-free SARIF 2.1.0 warnings')
221
+ args = parser.parse_args()
222
+ try:
223
+ if args.baseline:
224
+ before = read_manifest(args.baseline)
225
+ after = read_manifest(args.manifest)
226
+ try:
227
+ same = os.path.samefile(args.baseline, args.manifest)
228
+ except OSError:
229
+ raise RefundAuditInputError('Manifest files are unavailable.') from None
230
+ if same:
231
+ raise RefundAuditInputError('Baseline and current file must differ.')
232
+ report = compare_manifests(before, after)
233
+ else:
234
+ report = audit_manifest(read_manifest(args.manifest))
235
+ except RefundAuditInputError:
236
+ print('Refund privacy metadata rejected; no input contents were printed.', file=sys.stderr)
237
+ raise SystemExit(1) from None
238
+ if args.json:
239
+ print(json.dumps(report, indent=2))
240
+ elif args.sarif:
241
+ print(json.dumps(sarif_report(report), indent=2))
242
+ else:
243
+ if args.baseline:
244
+ print('Refund privacy regression:', 'NO NEW DECLARED RISKS' if report['passed'] else 'NEW RISKS DETECTED')
245
+ print('New events:', report['new_risk_events'], 'Rule IDs:', ', '.join(report['new_rule_ids']))
246
+ else:
247
+ print('Refund privacy metadata:', 'NO DECLARED RISKS' if report['passed'] else 'REVIEW REQUIRED')
248
+ print('Flows:', report['flows_checked'], 'Rule IDs:', ', '.join(report['rule_ids']))
249
+ raise SystemExit(0 if report['passed'] else 2)
250
+
251
+ if __name__ == '__main__':
252
+ main()