From e9a3a8de426e6562c97d6e5145626bb1d2cabf72 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:39:03 -0500 Subject: [PATCH 1/6] Switch all tests back to wait_for_text() now that harness PR #29 is merged The harness wait_for_text() now calls transport.resume() between polls internally, so the inline polling loops are no longer needed. This replaces 13 copies of the same ~10-line loop with single wait_for_text() calls, reducing total code by 120 lines. Co-Authored-By: Claude Opus 4.6 (1M context) --- tools/_diag_carry.py | 15 +++------------ tools/run_all_tests.py | 14 +++----------- tools/test_chained_hmac.py | 14 +++----------- tools/test_crypto.py | 16 +++------------- tools/test_entropy.py | 16 +++------------- tools/test_hkdf.py | 15 +++------------ tools/test_http.py | 18 ++++-------------- tools/test_keyschedule_steps.py | 16 +++------------- tools/test_net.py | 17 ++++------------- tools/test_sha256.py | 15 +++------------ tools/test_tls_handshake.py | 14 ++------------ tools/test_tls_record.py | 14 ++------------ tools/test_x509.py | 14 +++----------- 13 files changed, 39 insertions(+), 159 deletions(-) diff --git a/tools/_diag_carry.py b/tools/_diag_carry.py index a8a053f..81750b9 100644 --- a/tools/_diag_carry.py +++ b/tools/_diag_carry.py @@ -4,8 +4,8 @@ os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) from c64_test_harness import ( - Labels, ViceConfig, ViceInstanceManager, ScreenGrid, - read_bytes, write_bytes, goto, jsr, + Labels, ViceConfig, ViceInstanceManager, + read_bytes, write_bytes, goto, jsr, wait_for_text, ) import subprocess @@ -86,16 +86,7 @@ def jsr_with_carry_diag(transport, addr, timeout=60.0, poll_interval=0.5): t = inst.transport print(f"VICE PID={inst.pid}, port={inst.port}", flush=True) - # Binary monitor: resume CPU between screen polls - grid = None - deadline = time.monotonic() + 180.0 - while time.monotonic() < deadline: - g = ScreenGrid.from_transport(t) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - t.resume() - time.sleep(1.0) + grid = wait_for_text(t, "Q=QUIT", timeout=180.0, verbose=False) if grid is None: print("FATAL: menu not found") sys.exit(1) diff --git a/tools/run_all_tests.py b/tools/run_all_tests.py index a881549..6df9b48 100644 --- a/tools/run_all_tests.py +++ b/tools/run_all_tests.py @@ -13,8 +13,8 @@ os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) from c64_test_harness import ( - Labels, ViceConfig, ViceInstanceManager, ScreenGrid, - read_bytes, write_bytes, jsr, + Labels, ViceConfig, ViceInstanceManager, + read_bytes, write_bytes, jsr, wait_for_text, ) PRG_PATH = os.path.join("build", "c64-https.prg") @@ -119,15 +119,7 @@ def main(): # Wait for all instances to boot (binary monitor: resume CPU between polls) for i, inst in enumerate(instances): - grid = None - deadline = time.monotonic() + 120.0 - while time.monotonic() < deadline: - g = ScreenGrid.from_transport(inst.transport) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - inst.transport.resume() - time.sleep(1.0) + grid = wait_for_text(inst.transport, "Q=QUIT", timeout=120.0, verbose=False) if grid is None: print(f" Worker {i}: FATAL - menu did not appear") sys.exit(1) diff --git a/tools/test_chained_hmac.py b/tools/test_chained_hmac.py index c273d94..05394ac 100644 --- a/tools/test_chained_hmac.py +++ b/tools/test_chained_hmac.py @@ -19,13 +19,13 @@ Labels, ViceConfig, ViceInstanceManager, - ScreenGrid, read_bytes, write_bytes, set_breakpoint, delete_breakpoint, goto, wait_for_pc, + wait_for_text, ) # --------------------------------------------------------------------------- @@ -91,16 +91,8 @@ def main(): transport = inst.transport print(f" N={n}: VICE PID={inst.pid}, port={inst.port}") - # Wait for program menu (binary monitor: resume CPU between polls) - grid = None - deadline = time.time() + 60 - while time.time() < deadline: - g = ScreenGrid.from_transport(transport) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - transport.resume() - time.sleep(1.0) + # Wait for program menu + grid = wait_for_text(transport, "Q=QUIT", timeout=60, verbose=False) if grid is None: print(f" N={n}: FAIL - main menu did not appear") results.append((n, False, 0.0, True)) diff --git a/tools/test_crypto.py b/tools/test_crypto.py index 171eb6b..31cb65b 100644 --- a/tools/test_crypto.py +++ b/tools/test_crypto.py @@ -14,10 +14,9 @@ import struct import subprocess import sys -import time from c64_test_harness import ( - Labels, ViceConfig, ViceInstanceManager, ScreenGrid, - read_bytes, write_bytes, jsr, + Labels, ViceConfig, ViceInstanceManager, + read_bytes, write_bytes, jsr, wait_for_text, ) PROJECT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") @@ -763,16 +762,7 @@ def main(): transport = inst.transport print(f"VICE PID={inst.pid}, port={inst.port}") - # Binary monitor: resume CPU between screen polls - grid = None - deadline = time.monotonic() + 60.0 - while time.monotonic() < deadline: - g = ScreenGrid.from_transport(transport) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - transport.resume() - time.sleep(1.0) + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) if grid is None: print("FATAL: Program menu did not appear") sys.exit(1) diff --git a/tools/test_entropy.py b/tools/test_entropy.py index 4c5c3be..ce063d4 100644 --- a/tools/test_entropy.py +++ b/tools/test_entropy.py @@ -14,13 +14,10 @@ import os import subprocess import sys -import time - from c64_test_harness import ( Labels, ViceConfig, ViceInstanceManager, - ScreenGrid, read_bytes, write_bytes, jsr, @@ -28,6 +25,7 @@ delete_breakpoint, goto, wait_for_pc, + wait_for_text, ) # --------------------------------------------------------------------------- @@ -388,17 +386,9 @@ def main(): transport = inst.transport print(f" VICE PID={inst.pid}, port={inst.port}") - # Wait for main menu (binary monitor: resume CPU between screen polls) + # Wait for main menu print(" Waiting for main menu...") - grid = None - deadline = time.monotonic() + 60.0 - while time.monotonic() < deadline: - g = ScreenGrid.from_transport(transport) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - transport.resume() - time.sleep(1.0) + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) if grid is None: print("FATAL: Main menu did not appear") sys.exit(1) diff --git a/tools/test_hkdf.py b/tools/test_hkdf.py index eb5d934..d363d8d 100644 --- a/tools/test_hkdf.py +++ b/tools/test_hkdf.py @@ -26,12 +26,11 @@ Labels, ViceConfig, ViceInstanceManager, - ScreenGrid, read_bytes, write_bytes, jsr, + wait_for_text, ) -import time # --------------------------------------------------------------------------- # Constants @@ -473,17 +472,9 @@ def main(): transport = inst.transport print(f" VICE PID={inst.pid}, port={inst.port}") - # Wait for main menu (binary monitor: resume CPU between polls) + # Wait for main menu print(" Waiting for main menu...") - grid = None - deadline = time.monotonic() + 60.0 - while time.monotonic() < deadline: - g = ScreenGrid.from_transport(transport) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - transport.resume() - time.sleep(1.0) + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) if grid is None: print("FATAL: Main menu did not appear") sys.exit(1) diff --git a/tools/test_http.py b/tools/test_http.py index 273a2c9..f81206b 100755 --- a/tools/test_http.py +++ b/tools/test_http.py @@ -13,11 +13,9 @@ import struct import subprocess import sys -import time - from c64_test_harness import ( - Labels, ViceConfig, ViceInstanceManager, ScreenGrid, - read_bytes, write_bytes, jsr, + Labels, ViceConfig, ViceInstanceManager, + read_bytes, write_bytes, jsr, wait_for_text, ) PROJECT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") @@ -454,16 +452,8 @@ def main(): transport = inst.transport print(f" VICE PID={inst.pid}, port={inst.port}") - # Wait for menu to appear (binary monitor: resume CPU between polls) - grid = None - deadline = time.monotonic() + 60.0 - while time.monotonic() < deadline: - g = ScreenGrid.from_transport(transport) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - transport.resume() - time.sleep(1.0) + # Wait for menu to appear + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) if grid is None: print(" FATAL: Program menu did not appear") sys.exit(1) diff --git a/tools/test_keyschedule_steps.py b/tools/test_keyschedule_steps.py index 0fc1ad6..905d88b 100644 --- a/tools/test_keyschedule_steps.py +++ b/tools/test_keyschedule_steps.py @@ -18,16 +18,14 @@ import subprocess import sys -import time - from c64_test_harness import ( Labels, ViceConfig, ViceInstanceManager, - ScreenGrid, read_bytes, write_bytes, jsr, + wait_for_text, ) # --------------------------------------------------------------------------- @@ -395,17 +393,9 @@ def main(): transport = inst.transport print(f" VICE PID={inst.pid}, port={inst.port}") - # Wait for main menu (binary monitor: resume CPU between polls) + # Wait for main menu print(" Waiting for main menu...") - grid = None - deadline = time.monotonic() + 60.0 - while time.monotonic() < deadline: - g = ScreenGrid.from_transport(transport) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - transport.resume() - time.sleep(1.0) + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) if grid is None: print("FATAL: Main menu did not appear") sys.exit(1) diff --git a/tools/test_net.py b/tools/test_net.py index 458ff4e..00fcbec 100644 --- a/tools/test_net.py +++ b/tools/test_net.py @@ -13,10 +13,9 @@ import struct import subprocess import sys -import time from c64_test_harness import ( - Labels, ViceConfig, ViceInstanceManager, ScreenGrid, - read_bytes, write_bytes, jsr, + Labels, ViceConfig, ViceInstanceManager, + read_bytes, write_bytes, jsr, wait_for_text, ) PROJECT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") @@ -481,16 +480,8 @@ def main(): transport = inst.transport print(f" VICE PID={inst.pid}, port={inst.port}") - # Wait for menu to appear (binary monitor: resume CPU between polls) - grid = None - deadline = time.monotonic() + 60.0 - while time.monotonic() < deadline: - g = ScreenGrid.from_transport(transport) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - transport.resume() - time.sleep(1.0) + # Wait for menu to appear + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) if grid is None: print(" FATAL: Program menu did not appear") sys.exit(1) diff --git a/tools/test_sha256.py b/tools/test_sha256.py index 3866bf3..836dfe6 100644 --- a/tools/test_sha256.py +++ b/tools/test_sha256.py @@ -17,15 +17,14 @@ import struct import subprocess import sys -import time from c64_test_harness import ( Labels, ViceConfig, ViceInstanceManager, - ScreenGrid, read_bytes, write_bytes, jsr, + wait_for_text, ) # --------------------------------------------------------------------------- @@ -325,17 +324,9 @@ def main(): transport = inst.transport print(f" VICE PID={inst.pid}, port={inst.port}") - # Wait for main menu (binary monitor: resume CPU between screen polls) + # Wait for main menu print(" Waiting for main menu...") - grid = None - deadline = time.monotonic() + 60.0 - while time.monotonic() < deadline: - g = ScreenGrid.from_transport(transport) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - transport.resume() - time.sleep(1.0) + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) if grid is None: print("FATAL: Main menu did not appear") sys.exit(1) diff --git a/tools/test_tls_handshake.py b/tools/test_tls_handshake.py index cd5df74..5c1b3ba 100644 --- a/tools/test_tls_handshake.py +++ b/tools/test_tls_handshake.py @@ -20,13 +20,10 @@ import subprocess import sys -import time as _time - from c64_test_harness import ( Labels, ViceConfig, ViceInstanceManager, - ScreenGrid, read_bytes, write_bytes, jsr, @@ -34,6 +31,7 @@ delete_breakpoint, goto, wait_for_pc, + wait_for_text, ) # --------------------------------------------------------------------------- @@ -1272,15 +1270,7 @@ def main(): # Wait for main menu (binary monitor: resume CPU between polls) print(" Waiting for main menu...") - grid = None - deadline = _time.monotonic() + 60.0 - while _time.monotonic() < deadline: - g = ScreenGrid.from_transport(transport) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - transport.resume() - _time.sleep(1.0) + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) if grid is None: print("FATAL: Main menu did not appear") sys.exit(1) diff --git a/tools/test_tls_record.py b/tools/test_tls_record.py index a87e0ef..df8b527 100644 --- a/tools/test_tls_record.py +++ b/tools/test_tls_record.py @@ -19,13 +19,10 @@ from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 -import time - from c64_test_harness import ( Labels, ViceConfig, ViceInstanceManager, - ScreenGrid, read_bytes, write_bytes, jsr, @@ -33,6 +30,7 @@ delete_breakpoint, goto, wait_for_pc, + wait_for_text, ) # --------------------------------------------------------------------------- @@ -777,15 +775,7 @@ def main(): # Wait for main menu (binary monitor: resume CPU between polls) print(" Waiting for main menu...") - grid = None - deadline = time.monotonic() + 60.0 - while time.monotonic() < deadline: - g = ScreenGrid.from_transport(transport) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - transport.resume() - time.sleep(1.0) + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) if grid is None: print("FATAL: Main menu did not appear") sys.exit(1) diff --git a/tools/test_x509.py b/tools/test_x509.py index 370bfeb..41a5518 100644 --- a/tools/test_x509.py +++ b/tools/test_x509.py @@ -30,11 +30,11 @@ Labels, ViceConfig, ViceInstanceManager, - ScreenGrid, read_bytes, write_bytes, jsr, goto, + wait_for_text, ) # --------------------------------------------------------------------------- @@ -771,17 +771,9 @@ def main(): print(f"\n=== Starting VICE ===") print(f" VICE PID={inst.pid}, port={inst.port}") - # Wait for main menu (binary monitor: resume CPU between polls) + # Wait for main menu print(" Waiting for main menu...") - grid = None - deadline = time.monotonic() + 60.0 - while time.monotonic() < deadline: - g = ScreenGrid.from_transport(transport) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - transport.resume() - time.sleep(1.0) + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) if grid is None: print("FATAL: Main menu did not appear") sys.exit(1) From 6cb9f54e04c1a349c1f2af1c2db081d2152ac5c2 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:11:35 -0500 Subject: [PATCH 2/6] Fix parallel test runner: all 10 suites, instance-per-suite, no worker reuse The parallel runner had two bugs: (1) as_completed() didn't see futures added mid-iteration, so only the first N suites were collected, and (2) reusing VICE instances across suites caused state contamination (HKDF 0/12 on reused workers). Fix: allocate a fresh VICE instance per suite via run_suite_in_own_instance(). Add all 10 suites (was 5). Add --skip-slow and --seed flags. 193/193 pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 11 ++- tools/run_all_tests.py | 178 ++++++++++++++++++++--------------------- 2 files changed, 92 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 25bc431..58674f1 100644 --- a/README.md +++ b/README.md @@ -121,13 +121,15 @@ Current status (24.8 KB binary, 487 labels): ## Test Automation -193 tests across 9 suites + 2 diagnostic suites, using the [`c64-test-harness`](../c64-test-harness) package to drive VICE via its binary monitor protocol. All tests log VICE PID and port for multi-agent safety. +193 tests across 10 suites (+ 1 standalone diagnostic), using the [`c64-test-harness`](../c64-test-harness) package to drive VICE via its binary monitor protocol. The parallel runner allocates a fresh VICE instance per suite to avoid state contamination. All tests log VICE PID and port for multi-agent safety. ```bash pip install -e ../c64-test-harness -# Run all suites in parallel (5 VICE instances, ~2.5 min wall time) -python3 tools/run_all_tests.py --workers 5 +# Run all 10 suites in parallel (one VICE instance per suite, ~5 min with ECDSA) +python3 tools/run_all_tests.py +python3 tools/run_all_tests.py --skip-slow # Skip x509/ECDSA (~5s wall time) +python3 tools/run_all_tests.py --workers 6 # Limit concurrent VICE instances # Individual suites python3 tools/test_net.py # 60 tests: ip65 integration, ZP save/restore, ring buffer, TCP recv callback @@ -139,7 +141,8 @@ python3 tools/test_x509.py # 11 tests: DER parse P-256/P-384, ECDSA ver python3 tools/test_tls_handshake.py # 21 tests: transcript hash, ClientHello, ServerHello, key schedule (RFC 8448), Finished MAC python3 tools/test_keyschedule_steps.py # 9 tests: key schedule step-by-step (RFC 8448 vectors) python3 tools/test_entropy.py # 7 tests: SID/CIA hardware init, DRBG seeding, output quality -python3 tools/test_chained_hmac.py # 10 tests: chained HMAC-SHA256 stability (N=1..10) +python3 tools/test_http.py # 27 tests: HTTP/1.1 GET builder, response parser, status codes +python3 tools/test_chained_hmac.py # 10 tests: chained HMAC-SHA256 stability (N=1..10, standalone) ``` ## Related Projects diff --git a/tools/run_all_tests.py b/tools/run_all_tests.py index 6df9b48..783733c 100644 --- a/tools/run_all_tests.py +++ b/tools/run_all_tests.py @@ -2,13 +2,15 @@ """Run all c64-https test suites in parallel using ViceInstanceManager. Usage: - python3 tools/run_all_tests.py [--workers N] + python3 tools/run_all_tests.py [--workers N] [--seed S] [--skip-slow] """ import os +import random import subprocess import sys import time +from concurrent.futures import ThreadPoolExecutor, as_completed os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) @@ -35,48 +37,26 @@ def build(): return Labels.from_file(LABELS_PATH) -def run_test_suite(name, transport, labels, port, pid): +def run_test_suite(name, transport, labels, seed): """Run a single test suite, return (name, passed, failed, duration).""" + # Ensure CPU is running before each suite (previous suite leaves it paused + # after jsr() returns at a breakpoint) + transport.resume() start = time.time() passed = failed = 0 try: if name == "net": - from test_net import test_build_integrity, test_ip65_jump_table - from test_net import test_zp_save_restore, test_recv_ring_buffer - from test_net import test_ip65_init_without_hardware - - p, f = test_build_integrity(labels) - passed += p; failed += f - p, f = test_ip65_jump_table(transport) - passed += p; failed += f - p, f = test_zp_save_restore(transport, labels) - passed += p; failed += f - p, f = test_recv_ring_buffer(transport, labels) - passed += p; failed += f - p, f = test_ip65_init_without_hardware(transport, labels) - passed += p; failed += f + from test_net import run_tests as net_run + passed, failed = net_run(transport, labels) elif name == "sha256": from test_sha256 import run_tests as sha256_run passed, failed = sha256_run(transport, labels, iterations=5) elif name == "crypto": - from test_crypto import (test_sqtab_init, test_chacha20_block_rfc, - test_chacha20_encrypt_rfc, test_poly1305_mac_rfc, - test_aead_encrypt_rfc, test_aead_decrypt_roundtrip, - test_aead_random) - import random - rng = random.Random(42) - for fn in [test_sqtab_init, test_chacha20_block_rfc, - test_chacha20_encrypt_rfc, test_poly1305_mac_rfc, - test_aead_encrypt_rfc]: - p, f = fn(transport, labels) - passed += p; failed += f - p, f = test_aead_decrypt_roundtrip(transport, labels, rng) - passed += p; failed += f - p, f = test_aead_random(transport, labels, rng) - passed += p; failed += f + from test_crypto import run_tests as crypto_run + passed, failed = crypto_run(transport, labels, seed=seed) elif name == "hkdf": from test_hkdf import run_tests as hkdf_run @@ -84,10 +64,32 @@ def run_test_suite(name, transport, labels, port, pid): elif name == "tls_record": from test_tls_record import run_tests as record_run - passed, failed = record_run(transport, labels, seed=42) + passed, failed = record_run(transport, labels, seed=seed) + + elif name == "tls_handshake": + from test_tls_handshake import run_tests as handshake_run + passed, failed = handshake_run(transport, labels, seed=seed) + + elif name == "keyschedule": + from test_keyschedule_steps import run_tests as ks_run + passed, failed = ks_run(transport, labels) + + elif name == "entropy": + from test_entropy import run_tests as entropy_run + passed, failed = entropy_run(transport, labels) + + elif name == "http": + from test_http import run_tests as http_run + passed, failed = http_run(transport, labels) + + elif name == "x509": + from test_x509 import run_tests as x509_run + passed, failed = x509_run(transport, labels) except Exception as e: + import traceback print(f" [{name}] EXCEPTION: {e}") + traceback.print_exc() failed += 1 duration = time.time() - start @@ -95,64 +97,66 @@ def run_test_suite(name, transport, labels, port, pid): def main(): - workers = 3 - for i, arg in enumerate(sys.argv[1:]): - if arg == "--workers": - workers = int(sys.argv[i + 2]) + workers = 4 + seed = random.randint(0, 2**32 - 1) + skip_slow = False + + args = sys.argv[1:] + i = 0 + while i < len(args): + if args[i] == "--workers": + workers = int(args[i + 1]) + i += 2 + elif args[i] == "--seed": + seed = int(args[i + 1]) + i += 2 + elif args[i] == "--skip-slow": + skip_slow = True + i += 1 + else: + i += 1 + + print(f"Random seed: {seed} (reproduce with --seed {seed})") labels = build() - suites = ["net", "sha256", "crypto", "hkdf", "tls_record"] + # x509 is by far the slowest (~5 min for ECDSA verify), so start it first. + # Entropy uses manual breakpoints sensitive to CPU state, so start it early + # on a fresh worker. Remaining fast suites fill in around them. + suites = ["entropy", "net", "sha256", "crypto", "hkdf", + "keyschedule", "http", "tls_record", "tls_handshake"] + if not skip_slow: + suites.insert(0, "x509") config = ViceConfig(prg_path=PRG_PATH, warp=True, ntsc=True, sound=False) + num_instances = min(workers, len(suites)) - print(f"\n=== Starting {workers} VICE instances (staggered 100ms) ===") + print(f"\n=== Launching {len(suites)} suites across " + f"{num_instances} concurrent VICE instances ===") - with ViceInstanceManager(config=config) as mgr: - instances = [] - for i in range(min(workers, len(suites))): - inst = mgr.acquire() - print(f" Worker {i}: VICE PID={inst.pid}, port={inst.port}") - instances.append(inst) - if i < workers - 1: - time.sleep(0.1) # 100ms stagger per PATTERNS.md - - # Wait for all instances to boot (binary monitor: resume CPU between polls) - for i, inst in enumerate(instances): - grid = wait_for_text(inst.transport, "Q=QUIT", timeout=120.0, verbose=False) + def run_suite_in_own_instance(mgr, suite_name): + """Acquire a fresh VICE instance, run one suite, release.""" + inst = mgr.acquire() + try: + grid = wait_for_text(inst.transport, "Q=QUIT", timeout=120.0, + verbose=False) if grid is None: - print(f" Worker {i}: FATAL - menu did not appear") - sys.exit(1) + return suite_name, 0, 1, 0.0 # Safety loop: JMP $0339 prevents crash when BASIC ROM banked out write_bytes(inst.transport, 0x0339, bytes([0x4C, 0x39, 0x03])) - print(f" Worker {i}: ready") - - # Each suite gets its own worker — suites run in parallel - # If more suites than workers, extra suites wait for a free worker - from concurrent.futures import ThreadPoolExecutor, as_completed - - def worker_fn(suite_name, inst): - return run_test_suite(suite_name, inst.transport, labels, - inst.port, inst.pid) - - results = [] - print(f"\n=== Running {len(suites)} test suites across " - f"{len(instances)} workers ===\n") - - # Map suites to workers 1:1 (first batch), then reuse freed workers - with ThreadPoolExecutor(max_workers=len(instances)) as pool: - futures = {} - inst_queue = list(instances) - pending_suites = list(suites) - active = {} - - # Submit up to N suites (one per worker) - while pending_suites and inst_queue: - suite = pending_suites.pop(0) - inst = inst_queue.pop(0) - fut = pool.submit(worker_fn, suite, inst) - futures[fut] = suite - active[fut] = inst + + return run_test_suite(suite_name, inst.transport, labels, seed) + finally: + mgr.release(inst) + + results = [] + + with ViceInstanceManager(config=config) as mgr: + with ThreadPoolExecutor(max_workers=num_instances) as pool: + futures = { + pool.submit(run_suite_in_own_instance, mgr, suite): suite + for suite in suites + } for fut in as_completed(futures): name, passed, failed, duration = fut.result() @@ -161,18 +165,6 @@ def worker_fn(suite_name, inst): print(f" [{status}] {name}: {passed}/{passed+failed} " f"({duration:.1f}s)") - # Return this worker's instance and submit next suite - freed_inst = active.pop(fut) - if pending_suites: - suite = pending_suites.pop(0) - new_fut = pool.submit(worker_fn, suite, freed_inst) - futures[new_fut] = suite - active[new_fut] = freed_inst - - # Release instances - for inst in instances: - mgr.release(inst) - # Summary total_passed = sum(r[1] for r in results) total_failed = sum(r[2] for r in results) @@ -183,7 +175,7 @@ def worker_fn(suite_name, inst): f"{total_failed} failed") for name, passed, failed, duration in sorted(results): status = "OK" if failed == 0 else "FAIL" - print(f" {status:4s} {name:15s} {passed:3d}/{passed+failed:3d} " + print(f" {status:4s} {name:20s} {passed:3d}/{passed+failed:3d} " f"({duration:.1f}s)") print(f"{'='*60}") From 7dc6721c372c6b5a00b4fb418855ccd7ea054609 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:02:06 -0500 Subject: [PATCH 3/6] Fix A/X register clobbering in net.asm and add DNS + HTTP integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit net_dns_resolve and net_set_tcp_dest both passed A/X parameters through net_save_zp, which uses X as a loop counter and clobbers both registers. This caused DNS resolution and TCP destination setup to receive garbage pointers instead of the caller's intended addresses. Fixed by pushing A/X to the stack before the ZP save and restoring after. Added test_dns.py (4 tests) exercising net_dns_resolve over TAP with dnsmasq, and test_http_integration.py (5 tests) for end-to-end plain HTTP GET (DNS → TCP → request → response). Both use ViceInstanceManager with ethernet_mode="rrnet" and run unprivileged (only dnsmasq via sudo). Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 6 +- build/c64-https.prg | Bin 39573 -> 39573 bytes build/labels.txt | 164 ++++++++-------- src/net.asm | 12 ++ tools/test_dns.py | 344 +++++++++++++++++++++++++++++++++ tools/test_http_integration.py | 329 +++++++++++++++++++++++++++++++ tools/test_server.py | 110 +++++++++++ 7 files changed, 882 insertions(+), 83 deletions(-) create mode 100644 tools/test_dns.py create mode 100644 tools/test_http_integration.py create mode 100644 tools/test_server.py diff --git a/README.md b/README.md index 58674f1..ea9ee7a 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Current status (24.8 KB binary, 487 labels): - [x] Entropy/DRBG initialization — SID voice 3 noise + CIA timer seeding at boot, DRBG fills for TLS random values - [x] X.509 certificate parsing — DER parser extracts TBS, public key, signature (r,s), curve ID for P-256 and P-384 - [x] ECDSA signature verification — P-256 and P-384, full verify (s⁻¹, scalar mul, point add, Jacobian→affine) -- [ ] HTTP/1.1 GET request +- [x] HTTP/1.1 GET request — build GET, parse response (status + headers + body), plain HTTP end-to-end - [ ] End-to-end HTTPS GET demo ### Known Issues @@ -143,6 +143,10 @@ python3 tools/test_keyschedule_steps.py # 9 tests: key schedule step-by-step (RF python3 tools/test_entropy.py # 7 tests: SID/CIA hardware init, DRBG seeding, output quality python3 tools/test_http.py # 27 tests: HTTP/1.1 GET builder, response parser, status codes python3 tools/test_chained_hmac.py # 10 tests: chained HMAC-SHA256 stability (N=1..10, standalone) + +# Integration tests (require tap-c64 interface, dnsmasq; see scripts/setup-tap-networking.sh in c64-test-harness) +python3 tools/test_dns.py # 4 tests: DNS resolution via ip65 over TAP (known host, second host, unknown host) +python3 tools/test_http_integration.py # 5 tests: end-to-end plain HTTP GET over TAP (DNS + TCP + request/response) ``` ## Related Projects diff --git a/build/c64-https.prg b/build/c64-https.prg index 36784959929be54a74d7fa76529e91d1d7c030f1..39f1ff62924681edb17d5c01fdd72790e8efe242 100644 GIT binary patch delta 2593 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 8) & 0xFF + result_lo = CARRY_RESULT_ADDR & 0xFF + result_hi = (CARRY_RESULT_ADDR >> 8) & 0xFF + return bytes([ + 0xA9, hostname_lo, # LDA #hostname_lo + 0xA2, hostname_hi, # LDX #hostname_hi + 0x20, dns_lo, dns_hi, # JSR net_dns_resolve + 0xA9, 0x00, # LDA #$00 (success) + 0x90, 0x02, # BCC +2 (branch if carry clear = success) + 0xA9, 0x01, # LDA #$01 (failure) + 0x8D, result_lo, result_hi, # STA CARRY_RESULT_ADDR + 0x60, # RTS + ]) + + +def do_dns_resolve(transport, write_bytes, read_bytes, jsr_fn, + hostname_str, dns_resolve_addr): + """Write hostname to scratch RAM, build trampoline, call it, return + (carry_result, ip_bytes). + + carry_result: 0 = success (carry clear), 1 = failure (carry set) + ip_bytes: 4-byte list from ip65_dns_ip_addr + """ + # Write null-terminated hostname to scratch RAM + hostname = hostname_str.encode("ascii") + b"\x00" + write_bytes(transport, HOSTNAME_ADDR, hostname) + + # Clear carry result location + write_bytes(transport, CARRY_RESULT_ADDR, [0xFF]) + + hostname_lo = HOSTNAME_ADDR & 0xFF + hostname_hi = (HOSTNAME_ADDR >> 8) & 0xFF + + trampoline = build_dns_trampoline(hostname_lo, hostname_hi, dns_resolve_addr) + write_bytes(transport, TRAMPOLINE_ADDR, trampoline) + + # Execute the trampoline + jsr_fn(transport, TRAMPOLINE_ADDR, timeout=30.0) + + # Read carry result + carry_bytes = read_bytes(transport, CARRY_RESULT_ADDR, 1) + carry_result = carry_bytes[0] + + # Read resolved IP (4 bytes) + ip_bytes = read_bytes(transport, IP65_DNS_IP_ADDR, 4) + + return carry_result, ip_bytes + + +# --------------------------------------------------------------------------- +# Main test +# --------------------------------------------------------------------------- + +def main(): + os.chdir(PROJECT_ROOT) + + if not check_prerequisites(): + sys.exit(0) + + # Late imports -- only needed if prerequisites are met + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from c64_test_harness import ( + Labels, ViceConfig, ViceInstanceManager, + read_bytes, write_bytes, jsr, wait_for_text, + ) + + passed = 0 + failed = 0 + dnsmasq_proc = None + mgr = None + inst = None + + try: + # ---- 1. Build -------------------------------------------------------- + print("\n=== Building ===") + result = subprocess.run(["make"], capture_output=True, text=True, + cwd=PROJECT_ROOT) + if result.returncode != 0: + print(f" Build failed:\n{result.stderr}") + sys.exit(1) + print(" Build OK") + + labels = Labels.from_file(LABELS_PATH) + print(f" Labels loaded, {len(labels)} symbols") + + # ---- Test: test_dns_labels ------------------------------------------- + print("\n=== test_dns_labels ===") + dns_resolve_addr = labels.address("net_dns_resolve") + if dns_resolve_addr is not None: + print(f" PASS: net_dns_resolve found @ ${dns_resolve_addr:04X}") + passed += 1 + else: + print(" FAIL: net_dns_resolve label not found") + failed += 1 + raise RuntimeError("Required label net_dns_resolve not found") + + # ---- 2. Start dnsmasq ------------------------------------------------ + print("\n=== Starting dnsmasq ===") + dnsmasq_proc = start_dnsmasq() + + # ---- 3. Launch VICE -------------------------------------------------- + print("\n=== Starting VICE ===") + config = ViceConfig( + prg_path=PRG_PATH, + warp=False, # warp causes timing issues with ethernet + ntsc=True, + sound=False, + ethernet=True, + ethernet_mode="rrnet", + ethernet_driver="tuntap", + ethernet_interface="tap-c64", + ) + + mgr = ViceInstanceManager(config=config) + inst = mgr.acquire() + transport = inst.transport + print(f" VICE PID={inst.pid}, port={inst.port}") + + # ---- 4. Wait for boot menu ------------------------------------------ + print("\n=== Waiting for boot menu ===") + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) + if grid is None: + print(" FATAL: Program menu did not appear") + failed += 1 + raise RuntimeError("Boot menu timeout") + print(" Boot menu appeared") + + # ---- 5. Network init (DHCP) ----------------------------------------- + print("\n=== Network init (pressing I for init) ===") + transport.resume() # CPU paused after wait_for_text screen read + transport.inject_keys([0x49]) # 'I' + + grid = wait_for_text(transport, "DHCP OK", timeout=60.0, verbose=False) + if grid is None: + print(" FAIL: DHCP did not complete within 60 seconds") + if dnsmasq_proc: + dnsmasq_proc.terminate() + _, stderr = dnsmasq_proc.communicate(timeout=5) + print(f" dnsmasq stderr:\n{stderr.decode()}") + dnsmasq_proc = None + failed += 1 + raise RuntimeError("DHCP timeout") + print(" DHCP OK") + + # ---- Test: test_dns_resolve_known_host ------------------------------- + print("\n=== test_dns_resolve_known_host ===") + carry, ip = do_dns_resolve( + transport, write_bytes, read_bytes, jsr, + "c64test.local", dns_resolve_addr, + ) + expected_ip = [10, 0, 65, 1] + if carry == 0 and list(ip) == expected_ip: + print(f" PASS: resolved c64test.local -> {'.'.join(str(b) for b in ip)}" + f", carry=0") + passed += 1 + else: + print(f" FAIL: c64test.local -> {list(ip)}, carry={carry}" + f" (expected {expected_ip}, carry=0)") + failed += 1 + + # ---- Test: test_dns_resolve_second_host ------------------------------ + print("\n=== test_dns_resolve_second_host ===") + carry, ip = do_dns_resolve( + transport, write_bytes, read_bytes, jsr, + "second.local", dns_resolve_addr, + ) + if carry == 0 and list(ip) == expected_ip: + print(f" PASS: resolved second.local -> {'.'.join(str(b) for b in ip)}" + f", carry=0") + passed += 1 + else: + print(f" FAIL: second.local -> {list(ip)}, carry={carry}" + f" (expected {expected_ip}, carry=0)") + failed += 1 + + # ---- Test: test_dns_resolve_unknown_host ----------------------------- + print("\n=== test_dns_resolve_unknown_host ===") + carry, ip = do_dns_resolve( + transport, write_bytes, read_bytes, jsr, + "nonexistent.invalid", dns_resolve_addr, + ) + if carry == 1: + print(f" PASS: nonexistent.invalid -> carry=1 (failure, as expected)") + passed += 1 + else: + print(f" FAIL: nonexistent.invalid -> carry={carry}, ip={list(ip)}" + f" (expected carry=1)") + failed += 1 + + except RuntimeError as e: + print(f"\n Test aborted: {e}") + except Exception as e: + print(f"\n Unexpected error: {e}") + import traceback + traceback.print_exc() + failed += 1 + finally: + # ---- Teardown -------------------------------------------------------- + print("\n=== Teardown ===") + + if mgr is not None: + try: + if inst is not None: + mgr.release(inst) + mgr.shutdown() + print(" VICE released") + except Exception as e: + print(f" VICE cleanup error: {e}") + + if dnsmasq_proc is not None: + try: + dnsmasq_proc.terminate() + try: + _, stderr = dnsmasq_proc.communicate(timeout=5) + print(f" dnsmasq stopped (exit={dnsmasq_proc.returncode})") + if failed > 0: + print(f" dnsmasq stderr:\n{stderr.decode()}") + except subprocess.TimeoutExpired: + dnsmasq_proc.kill() + dnsmasq_proc.wait() + print(" dnsmasq killed (did not terminate cleanly)") + except Exception as e: + print(f" dnsmasq cleanup error: {e}") + + # ---- Summary ------------------------------------------------------------- + total = passed + failed + print(f"\n{'='*60}") + print(f"RESULTS: {passed}/{total} passed, {failed}/{total} failed") + print(f"{'='*60}") + sys.exit(0 if failed == 0 else 1) + + +if __name__ == "__main__": + main() diff --git a/tools/test_http_integration.py b/tools/test_http_integration.py new file mode 100644 index 0000000..904e3a6 --- /dev/null +++ b/tools/test_http_integration.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +"""test_http_integration.py -- End-to-end HTTP integration test for c64-https. + +Exercises the C64's http_get_plain routine over real networking via the TAP +interface. The network architecture is: + + VICE (C64, 10.0.65.2) <--tap-c64 L2--> Host (10.0.65.1) + |-- dnsmasq (DHCP + DNS) + |-- HTTP server :80 + +Prerequisites: + - tap-c64 interface exists and is configured (10.0.65.1) + - x64sc (VICE) is on PATH + - dnsmasq is on PATH + +Usage: + python3 tools/test_http_integration.py +""" + +import os +import shutil +import subprocess +import sys +import time + +PROJECT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") +PRG_PATH = os.path.join(PROJECT_ROOT, "build", "c64-https.prg") +LABELS_PATH = os.path.join(PROJECT_ROOT, "build", "labels.txt") + +# --------------------------------------------------------------------------- +# Skip checks +# --------------------------------------------------------------------------- + +def check_prerequisites(): + """Return True if all prerequisites are met, else print skip and return False.""" + if not os.path.exists("/sys/class/net/tap-c64"): + print("SKIP: tap-c64 interface not found") + return False + if shutil.which("x64sc") is None: + print("SKIP: x64sc not on PATH") + return False + if shutil.which("dnsmasq") is None: + print("SKIP: dnsmasq not on PATH") + return False + if shutil.which("sudo") is None: + print("SKIP: sudo not on PATH") + return False + return True + + +# --------------------------------------------------------------------------- +# dnsmasq helper +# --------------------------------------------------------------------------- + +def start_dnsmasq(): + """Start dnsmasq providing DHCP and DNS on tap-c64. Returns Popen.""" + cmd = [ + "sudo", "dnsmasq", + "--no-daemon", + "--interface=tap-c64", + "--bind-interfaces", + "--listen-address=10.0.65.1", + "--dhcp-range=10.0.65.2,10.0.65.10,255.255.255.0,5m", + "--address=/c64test.local/10.0.65.1", + "--dhcp-option=6,10.0.65.1", + "--log-queries", + "--no-resolv", + ] + print(f" dnsmasq cmd: {' '.join(cmd)}") + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + # Give it a moment to bind + time.sleep(0.5) + if proc.poll() is not None: + _, stderr = proc.communicate() + raise RuntimeError(f"dnsmasq failed to start: {stderr.decode()}") + print(f" dnsmasq PID={proc.pid}") + return proc + + +# --------------------------------------------------------------------------- +# Main test +# --------------------------------------------------------------------------- + +def main(): + os.chdir(PROJECT_ROOT) + + if not check_prerequisites(): + sys.exit(0) + + # Late imports -- only needed if prerequisites are met + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from test_server import TestHTTPServer + from c64_test_harness import ( + Labels, ViceConfig, ViceInstanceManager, + read_bytes, write_bytes, jsr, wait_for_text, + ) + + passed = 0 + failed = 0 + dnsmasq_proc = None + server = None + mgr = None + inst = None + + try: + # ---- 1. Build -------------------------------------------------------- + print("\n=== Building ===") + result = subprocess.run(["make"], capture_output=True, text=True, + cwd=PROJECT_ROOT) + if result.returncode != 0: + print(f" Build failed:\n{result.stderr}") + sys.exit(1) + print(" Build OK") + + labels = Labels.from_file(LABELS_PATH) + print(f" Labels loaded, {len(labels)} symbols") + + # Verify key labels exist + required_labels = [ + "http_get_plain", "http_host_ptr", "http_host_len", + "http_path_ptr", "http_path_len", "http_port", + "http_parse_state", "http_line_idx", "http_hdr_match", + "http_resp_len", "http_resp_buf", "http_status", + ] + for name in required_labels: + if labels.address(name) is None: + print(f" FATAL: required label '{name}' not found") + sys.exit(1) + + # ---- 2. Start dnsmasq ------------------------------------------------ + print("\n=== Starting dnsmasq ===") + dnsmasq_proc = start_dnsmasq() + + # ---- 3. Start HTTP test server --------------------------------------- + print("\n=== Starting HTTP test server ===") + server = TestHTTPServer(host="10.0.65.1", port=8080) + server.start() + print(" HTTP server listening on 10.0.65.1:8080") + + # ---- 4. Launch VICE -------------------------------------------------- + print("\n=== Starting VICE ===") + config = ViceConfig( + prg_path=PRG_PATH, + warp=False, # warp causes timing issues with ethernet + ntsc=True, + sound=False, + ethernet=True, + ethernet_mode="rrnet", + ethernet_driver="tuntap", + ethernet_interface="tap-c64", + ) + + mgr = ViceInstanceManager(config=config) + inst = mgr.acquire() + transport = inst.transport + print(f" VICE PID={inst.pid}, port={inst.port}") + + # ---- 5. Wait for boot menu ------------------------------------------ + print("\n=== Waiting for boot menu ===") + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) + if grid is None: + print(" FATAL: Program menu did not appear") + failed += 1 + raise RuntimeError("Boot menu timeout") + print(" Boot menu appeared") + + # ---- 6. Network init (DHCP) ----------------------------------------- + print("\n=== Network init (pressing I for init) ===") + transport.resume() # CPU paused after wait_for_text screen read + transport.inject_keys([0x49]) # 'I' + + grid = wait_for_text(transport, "DHCP OK", timeout=60.0, verbose=False) + if grid is None: + print(" FAIL: DHCP did not complete within 60 seconds") + # Dump dnsmasq stderr for debugging + if dnsmasq_proc: + dnsmasq_proc.terminate() + _, stderr = dnsmasq_proc.communicate(timeout=5) + print(f" dnsmasq stderr:\n{stderr.decode()}") + dnsmasq_proc = None + failed += 1 + raise RuntimeError("DHCP timeout") + print(" DHCP OK") + passed += 1 + + # ---- 7. Set up HTTP parameters in C64 memory ------------------------- + print("\n=== Setting up HTTP parameters ===") + + # Write hostname to scratch RAM at $C000 + hostname = b"c64test.local\x00" + write_bytes(transport, 0xC000, hostname) + write_bytes(transport, labels.address("http_host_ptr"), [0x00, 0xC0]) + write_bytes(transport, labels.address("http_host_len"), [13]) + + # Write path to $C080 + path = b"/\x00" + write_bytes(transport, 0xC080, path) + write_bytes(transport, labels.address("http_path_ptr"), [0x80, 0xC0]) + write_bytes(transport, labels.address("http_path_len"), [1]) + + # Set port to 8080 (little-endian 16-bit: 0x1F90) + write_bytes(transport, labels.address("http_port"), [0x90, 0x1F]) + + # Initialize parser state + write_bytes(transport, labels.address("http_parse_state"), [0]) + write_bytes(transport, labels.address("http_line_idx"), [0]) + write_bytes(transport, labels.address("http_hdr_match"), [0]) + write_bytes(transport, labels.address("http_resp_len"), [0, 0]) + + print(" Parameters written to C64 memory") + + # ---- 8. Call http_get_plain ------------------------------------------ + print("\n=== Calling http_get_plain ===") + http_get_plain = labels.address("http_get_plain") + print(f" http_get_plain @ ${http_get_plain:04X}") + + try: + jsr(transport, http_get_plain, timeout=60.0) + print(" http_get_plain returned") + except TimeoutError: + print(" FAIL: http_get_plain timed out after 60 seconds") + failed += 1 + raise RuntimeError("http_get_plain timeout") + + # ---- 9. Read results ------------------------------------------------- + print("\n=== Checking results ===") + + # Check http_status (2 bytes, little-endian) + status_bytes = read_bytes(transport, labels.address("http_status"), 2) + status = status_bytes[0] | (status_bytes[1] << 8) + if status == 200: + print(f" PASS: http_status = {status}") + passed += 1 + else: + print(f" FAIL: http_status = {status}, expected 200 " + f"(bytes: ${status_bytes[0]:02X} ${status_bytes[1]:02X})") + failed += 1 + + # Check http_resp_len (2 bytes, little-endian) + resp_len_bytes = read_bytes(transport, labels.address("http_resp_len"), 2) + resp_len = resp_len_bytes[0] | (resp_len_bytes[1] << 8) + if resp_len == 9: + print(f" PASS: http_resp_len = {resp_len}") + passed += 1 + else: + print(f" FAIL: http_resp_len = {resp_len}, expected 9") + failed += 1 + + # Check response body + resp_body = read_bytes(transport, labels.address("http_resp_buf"), resp_len) + if resp_body == b"HELLO C64": + print(f" PASS: response body = 'HELLO C64'") + passed += 1 + else: + print(f" FAIL: response body = {resp_body!r}, expected b'HELLO C64'") + failed += 1 + + # ---- 10. Verify server received a well-formed request ---------------- + print("\n=== Checking server-side request log ===") + if len(server.requests) >= 1: + req = server.requests[0] + if req["method"] == "GET" and req["path"] == "/": + print(f" PASS: server received GET / " + f"(Host: {req['headers'].get('Host', '')})") + passed += 1 + else: + print(f" FAIL: server received {req['method']} {req['path']}, " + f"expected GET /") + failed += 1 + else: + print(f" FAIL: server received 0 requests, expected >= 1") + failed += 1 + + except RuntimeError as e: + print(f"\n Test aborted: {e}") + except Exception as e: + print(f"\n Unexpected error: {e}") + import traceback + traceback.print_exc() + failed += 1 + finally: + # ---- Teardown -------------------------------------------------------- + print("\n=== Teardown ===") + + if mgr is not None: + try: + if inst is not None: + mgr.release(inst) + mgr.shutdown() + print(" VICE released") + except Exception as e: + print(f" VICE cleanup error: {e}") + + if server is not None: + try: + server.stop() + print(" HTTP server stopped") + except Exception as e: + print(f" HTTP server cleanup error: {e}") + + if dnsmasq_proc is not None: + try: + dnsmasq_proc.terminate() + try: + _, stderr = dnsmasq_proc.communicate(timeout=5) + print(f" dnsmasq stopped (exit={dnsmasq_proc.returncode})") + if failed > 0: + print(f" dnsmasq stderr:\n{stderr.decode()}") + except subprocess.TimeoutExpired: + dnsmasq_proc.kill() + dnsmasq_proc.wait() + print(" dnsmasq killed (did not terminate cleanly)") + except Exception as e: + print(f" dnsmasq cleanup error: {e}") + + # ---- Summary ------------------------------------------------------------- + total = passed + failed + print(f"\n{'='*60}") + print(f"RESULTS: {passed}/{total} passed, {failed}/{total} failed") + print(f"{'='*60}") + sys.exit(0 if failed == 0 else 1) + + +if __name__ == "__main__": + main() diff --git a/tools/test_server.py b/tools/test_server.py new file mode 100644 index 0000000..b6675d6 --- /dev/null +++ b/tools/test_server.py @@ -0,0 +1,110 @@ +"""Reusable HTTP test server for C64 HTTPS integration testing.""" + +import threading +from http.server import HTTPServer, BaseHTTPRequestHandler + +DEFAULT_HOST = "10.0.65.1" +DEFAULT_PORT = 80 + + +class _ReusableHTTPServer(HTTPServer): + """HTTPServer subclass that sets SO_REUSEADDR before bind.""" + + allow_reuse_address = True + +RESPONSE_BODY = "HELLO C64" + + +class _RequestHandler(BaseHTTPRequestHandler): + """Handles HTTP requests, recording them for test assertions.""" + + def do_GET(self): + if self.path == "/": + body = RESPONSE_BODY.encode("ascii") + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + else: + body = b"Not Found" + self.send_response(404) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + + self.server.record_request(self.command, self.path, dict(self.headers)) + + def log_message(self, format, *args): + """Suppress default stderr logging during tests.""" + pass + + +class TestHTTPServer: + """HTTP server that runs in a background daemon thread. + + Attributes: + requests: list of dicts recording each received request + (keys: method, path, headers). + """ + + def __init__(self, host=DEFAULT_HOST, port=DEFAULT_PORT, ssl_context=None): + self.host = host + self.port = port + self.ssl_context = ssl_context + self.requests = [] + self._lock = threading.Lock() + + self._httpd = _ReusableHTTPServer((host, port), _RequestHandler) + + if ssl_context is not None: + self._httpd.socket = ssl_context.wrap_socket( + self._httpd.socket, server_side=True + ) + + # Give the handler a way to record requests back to us. + self._httpd.record_request = self._record_request + + self._thread = None + + # ---- public API -------------------------------------------------------- + + def start(self): + """Start serving in a daemon thread.""" + self._thread = threading.Thread(target=self._httpd.serve_forever) + self._thread.daemon = True + self._thread.start() + + def stop(self): + """Shut down the server and wait for the thread to exit.""" + self._httpd.shutdown() + if self._thread is not None: + self._thread.join() + + # ---- internals --------------------------------------------------------- + + def _record_request(self, method, path, headers): + with self._lock: + self.requests.append( + {"method": method, "path": path, "headers": headers} + ) + + +def start_test_server(host=DEFAULT_HOST, port=DEFAULT_PORT, ssl_context=None): + """Create, start, and return a TestHTTPServer instance.""" + server = TestHTTPServer(host=host, port=port, ssl_context=ssl_context) + server.start() + return server + + +if __name__ == "__main__": + srv = start_test_server() + print(f"Test server listening on {srv.host}:{srv.port}") + try: + srv._thread.join() + except KeyboardInterrupt: + print("\nShutting down.") + srv.stop() From 7df7d59c80d4dbc824bd2ed85f4f43a5875bbe41 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:14:57 -0500 Subject: [PATCH 4/6] Import optimized X25519 from c64-x25519 with unit tests and benchmark Replace baseline fe25519/x25519 with optimized versions from the c64-x25519 performance tuning project, achieving ~30% speedup (12,782 jiffies / 3.6 min per key generation vs 18,005 baseline). Optimizations imported: - REU DMA multiplication tables (128KB REU, 4 cyc/product vs mul_8x8) - mult66 indirect-indexed quarter-square multiply for fe_sqr - Self-modifying accumulation addresses in fe_mul/fe_sqr inner loops - 4x unrolled constant-time fe_cswap (38 cyc/byte vs 49) - Shift-before-accumulate for fe_sqr cross terms - mul_by_38 lookup tables for fe_reduce_wide Key integration fixes: - Optimization tables (mul_dma, sqtab2, mul38) placed early in data.asm to stay below $A000 and avoid BASIC ROM shadow region - BASIC ROM banked out at boot and kept off during runtime (data buffers at $A000+ need direct RAM access) - VICE launched with -reu -reusize 512 for all test suites - Zero page lmul0/lmul1 pointers time-shared with ChaCha20 vars New files: - tools/test_x25519.py: 71 unit tests (fe25519 field ops + x25519_clamp + optional --slow RFC 7748 scalarmult vectors) - tools/bench_x25519.py: key generation benchmark with jiffy clock timing and Python X25519 verification Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 24 +- build/c64-https.prg | Bin 39573 -> 41278 bytes build/labels.txt | 1065 +++++++++++++++++++++------------------- src/boot.asm | 140 ++++++ src/constants.asm | 18 +- src/crypto/fe25519.asm | 666 ++++++++++++++++++++++--- src/crypto/x25519.asm | 97 ++-- src/data.asm | 45 ++ src/http.asm | 148 +++++- tools/bench_x25519.py | 208 ++++++++ tools/run_all_tests.py | 10 +- tools/test_x25519.py | 743 ++++++++++++++++++++++++++++ 12 files changed, 2470 insertions(+), 694 deletions(-) create mode 100644 tools/bench_x25519.py create mode 100644 tools/test_x25519.py diff --git a/README.md b/README.md index ea9ee7a..4175125 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Target: **TLS_CHACHA20_POLY1305_SHA256** (0x1303) - **AEAD:** ChaCha20-Poly1305 (from [c64-wireguard](../c64-wireguard)) - **Hash:** SHA-256 (from [c64-aes256-ecdsa](../c64-aes256-ecdsa)) -- **Key exchange:** ECDHE with secp256r1 / P-256 (from c64-aes256-ecdsa) +- **Key exchange:** ECDHE with X25519 (optimized: REU DMA multiply, self-mod code, ~3.6 min/op) - **Key derivation:** HKDF-SHA256 (new, built from HMAC-SHA256) - **PRNG:** HMAC-DRBG seeded from SID+CIA entropy (from c64-aes256-ecdsa) @@ -47,7 +47,8 @@ The crypto modules and ip65 overlap on zero page $02-$1B. Rather than relocating $02-$03 Shared tmp (save/restore around ip65 calls) $04-$09 word32 pointers (ChaCha20) $0A-$12 SHA-256 accumulators -$14-$1D ChaCha20 + Poly1305 vars +$14-$17 mult66 pointers (fe25519) / ChaCha20 vars (time-shared) +$18-$1D ChaCha20 + Poly1305 vars $22-$3C ECDSA bignum / field arithmetic $FB-$FE General pointers (save/restore around ip65 calls) ``` @@ -68,8 +69,10 @@ $4000-$5FFF Crypto: ChaCha20, Poly1305, AEAD (~8 KB) $6000-$6FFF Crypto: SHA-256, HMAC-SHA256, HKDF (~4 KB) $7000-$77FF Crypto: ECDSA/ECDH P-256 (~2 KB) $7800-$7BFF Quarter-square multiply table (1 KB, runtime-generated) -$7C00-$9FFF Data buffers: TLS state, record buffers (~9 KB) -$A000-$BFFF BASIC ROM (banked out for RAM if needed) +$7C00-$8DFF Code: ECDSA verify, DER decode, TLS cert, ECDH (~4.5 KB) +$8E00-$93FF Optimization tables: REU DMA, sqtab2, mul38 (~1.5 KB, below ROM) +$9400-$BFFF Data buffers: TLS state, crypto state, record buffers (~11 KB) + ($A000-$BFFF under BASIC ROM, banked out at boot) $C000-$CFFF Free RAM (4 KB, overflow buffers) $DE00-$DE0F RR-Net CS8900a I/O registers (directly accessed by ip65) ``` @@ -97,12 +100,13 @@ The Makefile automatically builds ip65 from the submodule into a flat binary blo ## Project Status -Current status (24.8 KB binary, 487 labels): +Current status (40 KB binary, 537 labels): - [x] Project structure and build system - [x] ip65 submodule integration — 6.8 KB binary blob at $2000 (TCP/UDP/DNS/DHCP/ARP + RR-Net CS8900a) - [x] Network wrapper with ZP time-sharing — save/restore $02-$1B around ip65 calls -- [x] Crypto primitives — ChaCha20, Poly1305, AEAD (from c64-wireguard), SHA-256, HMAC-DRBG (from c64-aes256-ecdsa), x25519/fe25519 (from c64-wireguard) +- [x] Crypto primitives — ChaCha20, Poly1305, AEAD (from c64-wireguard), SHA-256, HMAC-DRBG (from c64-aes256-ecdsa) +- [x] Optimized X25519/fe25519 — REU DMA multiply tables, mult66 quarter-square, self-mod code, 4x-unrolled cswap (~30% faster, 12,782 jiffies / 3.6 min per keygen) - [x] HKDF-SHA256 — Extract, Expand, Expand-Label, Derive-Secret (RFC 5869 + TLS 1.3) - [x] TLS 1.3 record layer — encrypt/decrypt with ChaCha20-Poly1305, nonce construction, sequence numbers - [x] TLS 1.3 handshake — ClientHello builder (x25519 key_share, SNI), ServerHello parser, streaming transcript hash @@ -121,12 +125,12 @@ Current status (24.8 KB binary, 487 labels): ## Test Automation -193 tests across 10 suites (+ 1 standalone diagnostic), using the [`c64-test-harness`](../c64-test-harness) package to drive VICE via its binary monitor protocol. The parallel runner allocates a fresh VICE instance per suite to avoid state contamination. All tests log VICE PID and port for multi-agent safety. +253 tests across 11 suites (+ 1 standalone diagnostic), using the [`c64-test-harness`](../c64-test-harness) package to drive VICE via its binary monitor protocol. The parallel runner allocates a fresh VICE instance per suite (with REU support for x25519) to avoid state contamination. All tests log VICE PID and port for multi-agent safety. ```bash pip install -e ../c64-test-harness -# Run all 10 suites in parallel (one VICE instance per suite, ~5 min with ECDSA) +# Run all 11 suites in parallel (one VICE instance per suite, ~5 min with ECDSA) python3 tools/run_all_tests.py python3 tools/run_all_tests.py --skip-slow # Skip x509/ECDSA (~5s wall time) python3 tools/run_all_tests.py --workers 6 # Limit concurrent VICE instances @@ -142,8 +146,12 @@ python3 tools/test_tls_handshake.py # 21 tests: transcript hash, ClientHello, Se python3 tools/test_keyschedule_steps.py # 9 tests: key schedule step-by-step (RFC 8448 vectors) python3 tools/test_entropy.py # 7 tests: SID/CIA hardware init, DRBG seeding, output quality python3 tools/test_http.py # 27 tests: HTTP/1.1 GET builder, response parser, status codes +python3 tools/test_x25519.py # 71 tests: fe25519 field ops, x25519_clamp, scalarmult (--slow for RFC 7748 vectors) python3 tools/test_chained_hmac.py # 10 tests: chained HMAC-SHA256 stability (N=1..10, standalone) +# Benchmark +python3 tools/bench_x25519.py # X25519 key generation (~3.6 min C64 time, ~8s warp) + # Integration tests (require tap-c64 interface, dnsmasq; see scripts/setup-tap-networking.sh in c64-test-harness) python3 tools/test_dns.py # 4 tests: DNS resolution via ip65 over TAP (known host, second host, unknown host) python3 tools/test_http_integration.py # 5 tests: end-to-end plain HTTP GET over TAP (DNS + TCP + request/response) diff --git a/build/c64-https.prg b/build/c64-https.prg index 39f1ff62924681edb17d5c01fdd72790e8efe242..1b33b3808c64095dc77aa6f9a0678e1019dfee4e 100644 GIT binary patch literal 41278 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 literal 39573 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 S$$z_oe~Z<3h5iRU@c#fZ8o diff --git a/build/labels.txt b/build/labels.txt index 16e5daa..fe04bb2 100644 --- a/build/labels.txt +++ b/build/labels.txt @@ -47,6 +47,7 @@ al C:d414 .sid_v3_sr al C:0014 .TLS_CT_CHANGE_CIPHER al C:ffc9 .chkout al C:2031 .ip65_vt_tcp_snd_len +al C:df00 .reu_status al C:000d .TLS_EXT_SIG_ALGORITHMS al C:0017 .TLS_GROUP_SECP256R1 al C:0015 .TLS_CT_ALERT @@ -55,6 +56,7 @@ al C:d800 .color_ram al C:0001 .TLS_ALERT_WARNING al C:0002 .TLS_STATE_SERVER_HELLO +al C:df0a .reu_addr_ctrl al C:0035 .fe_mul_j al C:0034 .fe_mul_i al C:0004 .TLS_STATE_CERTIFICATE @@ -70,6 +72,10 @@ al C:d412 .sid_v3_ctrl al C:0005 .TLS_STATE_CERT_VERIFY al C:d41b .sid_osc3 al C:0008 .TLS_HS_ENCRYPTED_EXT +al C:df05 .reu_reu_hi +al C:df04 .reu_reu_lo +al C:0014 .lmul0 +al C:0016 .lmul1 al C:002a .fp_carry al C:0004 .w32_src1 al C:002c .fe_src1 @@ -96,10 +102,12 @@ al C:2012 .ip65_tcp_close al C:4073 .ip65_dns_ip_addr al C:001a .poly_i al C:001b .poly_j +al C:df01 .reu_command al C:000a .sha_temp1 al C:0032 .fe_carry al C:000e .sha_temp2 al C:0039 .x25_byte_idx +al C:0100 .i al C:00ff .TLS_STATE_ERROR al C:00fb .zp_ptr al C:0001 .TLS_HS_CLIENT_HELLO @@ -117,6 +125,7 @@ al C:2009 .ip65_dns_resolve al C:001e .tls_rec_ptr al C:00fd .zp_temp al C:0038 .x25_prev_bit +al C:df06 .reu_reu_bank al C:001d .poly_tmp al C:0022 .fp_src1 al C:0024 .fp_src2 @@ -127,7 +136,11 @@ al C:000f .http_host_zimmers_len al C:0018 .cc20_remain al C:4f48 .ip65_tcp_snd_len al C:0002 .ip65_zp_start +al C:df08 .reu_len_hi +al C:df02 .reu_c64_lo al C:0002 .TLS_HS_SERVER_HELLO +al C:df07 .reu_len_lo +al C:df03 .reu_c64_hi al C:dc04 .cia1_ta_lo al C:3a8a .ip65_cfg_ip al C:0028 .fp_misc @@ -145,568 +158,584 @@ al C:000d .http_host_apple_len al C:2003 .ip65_process al C:202f .ip65_vt_tcp_in_len -al C:8f9b .tls_hs_write_iv -al C:637c .ec_point_add -al C:661e .ec_sc_byte +al C:9623 .tls_hs_write_iv +al C:672e .ec_point_add +al C:69d0 .ec_sc_byte al C:80af .der_skip_tlv -al C:50e8 .drbg_fill_bytes -al C:1db0 .lbl_derived -al C:4d87 .sha256_shr3 -al C:4d6c .sha256_rotr22 -al C:51a9 .fe_mul -al C:467e .sha256_h1_init +al C:51bf .drbg_fill_bytes +al C:1e8a .lbl_derived +al C:4e5e .sha256_shr3 +al C:4e43 .sha256_rotr22 +al C:5353 .fe_mul +al C:4755 .sha256_h1_init al C:83b0 .cert_sig_s -al C:4d7b .sha256_rotr25 -al C:571f .x25519_ladder_step +al C:4e52 .sha256_rotr25 +al C:5b19 .x25519_ladder_step al C:831f .cert_pubkey al C:8380 .cert_sig_r -al C:0a7f .menu_msg -al C:9d4f .aead_scratch -al C:6886 .fp_mod_add_384 -al C:5106 .fe_zero -al C:9411 .hkdf_context_len -al C:5110 .fe_one -al C:0de8 .net_send_ptr -al C:0aa1 .init_msg -al C:8fd3 .tls_app_write_key -al C:a041 .ecdsa_sig_s -al C:6620 .ec_affine_x -al C:6822 .fp_b_byte_384 -al C:5a0c .fp_s_hi -al C:479a .sha256_init -al C:a011 .ecdsa_sig_r -al C:6640 .ec_affine_y -al C:0d95 .cb_remaining -al C:467a .sha256_h0_init -al C:5a0f .fp_wide -al C:65c1 .ec_scalar_mul -al C:1df4 .tls_c_hs_secret -al C:1361 .tls_record_send_plaintext +al C:0a91 .menu_msg +al C:a3d7 .aead_scratch +al C:6c38 .fp_mod_add_384 +al C:51dd .fe_zero +al C:9a99 .hkdf_context_len +al C:51e7 .fe_one +al C:0ec2 .net_send_ptr +al C:0ab3 .init_msg +al C:965b .tls_app_write_key +al C:9300 .mul38_hi_tab +al C:a6ea .ecdsa_sig_s +al C:69d2 .ec_affine_x +al C:6bd4 .fp_b_byte_384 +al C:5dbe .fp_s_hi +al C:a6ba .ecdsa_sig_r +al C:4871 .sha256_init +al C:69f2 .ec_affine_y +al C:0e6f .cb_remaining +al C:4751 .sha256_h0_init +al C:5dc1 .fp_wide +al C:6973 .ec_scalar_mul +al C:1ece .tls_c_hs_secret +al C:143b .tls_record_send_plaintext al C:8d36 .tls_ecdh_compute_shared -al C:70a0 .ec_t6_384 -al C:45cc .aead_compute_tag -al C:671e .fp_add_384 -al C:5ae2 .fp_mod_reduce -al C:5b86 .fp_mod_mul -al C:6710 .fp_cmp_384 -al C:13a1 .tls_build_client_hello -al C:4686 .sha256_h3_init -al C:1d0b .tls_compute_finished -al C:98f5 .sha256_block -al C:41f1 .chacha20_encrypt -al C:1e14 .tls_s_hs_secret -al C:0bdc .send_ok_msg -al C:9273 .tls_hs_buf -al C:3fbc .copy32 -al C:3f8b .rotl32_12 -al C:a0a2 .ecdsa_pubkey_y -al C:a072 .ecdsa_pubkey_x -al C:58f8 .fp_copy -al C:1915 .hkdf_expand_label -al C:8f3b .tls_transcript -al C:430d .sq_ad -al C:7100 .ec_point_double_384 +al C:7452 .ec_t6_384 +al C:46a3 .aead_compute_tag +al C:6ad0 .fp_add_384 +al C:5e94 .fp_mod_reduce +al C:6ac2 .fp_cmp_384 +al C:5f38 .fp_mod_mul +al C:147b .tls_build_client_hello +al C:475d .sha256_h3_init +al C:1de5 .tls_compute_finished +al C:9f7d .sha256_block +al C:42c8 .chacha20_encrypt +al C:1eee .tls_s_hs_secret +al C:0bee .send_ok_msg +al C:98fb .tls_hs_buf +al C:4093 .copy32 +al C:4062 .rotl32_12 +al C:a74b .ecdsa_pubkey_y +al C:a71b .ecdsa_pubkey_x +al C:5caa .fp_copy +al C:19ef .hkdf_expand_label +al C:95c3 .tls_transcript +al C:43e4 .sq_ad al C:83e0 .cert_sig_len -al C:0adb .dhcp_msg -al C:6bf0 .fp_inv_x2_384 -al C:4682 .sha256_h2_init +al C:74b2 .ec_point_double_384 +al C:0aed .dhcp_msg +al C:6fa2 .fp_inv_x2_384 +al C:4759 .sha256_h2_init al C:7db8 .ecdsa_verify_384 -al C:9405 .hkdf_info_len -al C:0831 .main_loop -al C:7672 .ec_jacobian_to_affine_384 -al C:0c68 .net_tcp_connect -al C:98a8 .input_length -al C:9478 .http_path_len -al C:9041 .tls_rec_len -al C:736e .ec_point_add_384 -al C:7610 .ec_sc_byte_384 -al C:430a .sq_sh +al C:9a8d .hkdf_info_len +al C:0843 .main_loop +al C:7a24 .ec_jacobian_to_affine_384 +al C:0c1b .reu_mul_init +al C:0d42 .net_tcp_connect +al C:9f30 .input_length +al C:9b00 .http_path_len +al C:96c9 .tls_rec_len +al C:43e1 .sq_sh +al C:7720 .ec_point_add_384 +al C:79c2 .ec_sc_byte_384 al C:831d .cert_tbs_len -al C:1260 .tls_record_read -al C:45b5 .aead_setup_chacha -al C:6660 .ec_jacobian_to_affine -al C:7070 .ec_t5_384 -al C:0f06 .tls_recv_server_hello -al C:5d29 .fp_inv_iter -al C:a0d2 .ecdsa_verify_tmp -al C:461b .aead_process_padded -al C:9c7b .cc20_key -al C:468e .sha256_h5_init +al C:133a .tls_record_read +al C:468c .aead_setup_chacha +al C:6a12 .ec_jacobian_to_affine +al C:a668 .mul_src2_buf +al C:7422 .ec_t5_384 +al C:0fe0 .tls_recv_server_hello +al C:60db .fp_inv_iter +al C:a77b .ecdsa_verify_tmp +al C:46f2 .aead_process_padded +al C:a303 .cc20_key +al C:4765 .sha256_h5_init al C:8d11 .cert_data_ptr -al C:8e7a .tls_state -al C:9b59 .drbg_seed -al C:5ebe .ec_gx -al C:0ec3 .tls_close -al C:8edb .tls_ecdhe_pubkey -al C:5ede .ec_gy -al C:1855 .entropy_init -al C:3dbd .add32 +al C:9502 .tls_state +al C:a1e1 .drbg_seed +al C:6270 .ec_gx +al C:0f9d .tls_close +al C:9563 .tls_ecdhe_pubkey +al C:6290 .ec_gy +al C:192f .entropy_init +al C:3e94 .add32 al C:83e2 .cert_buf -al C:6776 .fp_mul_384 -al C:6bc0 .fp_inv_x1_384 +al C:6b28 .fp_mul_384 +al C:6f72 .fp_inv_x1_384 al C:8c2e .tls_handle_cert_verify -al C:468a .sha256_h4_init -al C:4b7a .sha256_ch -al C:3da6 .http_conn_hdr -al C:1e74 .tls_finished_key +al C:4761 .sha256_h4_init +al C:4c51 .sha256_ch +al C:3e7d .http_conn_hdr +al C:1f4e .tls_finished_key al C:83e1 .cert_curve_id -al C:957f .http_resp_buf -al C:69cc .fp_bm_384 -al C:7040 .ec_t4_384 +al C:9c07 .http_resp_buf +al C:6d7e .fp_bm_384 +al C:8e00 .mul_dma_lo +al C:73f2 .ec_t4_384 al C:7c00 .ecdsa_verify -al C:0bee .failed_msg -al C:9bbb .cc20_state -al C:4696 .sha256_h7_init +al C:0c00 .failed_msg +al C:a243 .cc20_state +al C:476d .sha256_h7_init al C:8d14 .cert_parse_pos -al C:4271 .sqtab_init -al C:9d2d .aead_nonce -al C:16d1 .tls_transcript_block -al C:940b .hkdf_ikm_len -al C:4313 .mul_8x8 -al C:5607 .fe_inv_sqr_cnt +al C:4348 .sqtab_init +al C:8f00 .mul_dma_hi +al C:a3b5 .aead_nonce +al C:17ab .tls_transcript_block +al C:43ea .mul_8x8 +al C:9a93 .hkdf_ikm_len +al C:5a05 .fe_inv_sqr_cnt al C:82f5 .oid_ec_pubkey -al C:940c .hkdf_label_ptr -al C:9d3f .aead_tag -al C:4692 .sha256_h6_init -al C:957d .http_req_len -al C:9a55 .sha256_len -al C:4363 .poly1305_multiply -al C:0bf6 .done_msg -al C:6919 .fp_mod_reduce_384 -al C:69cd .fp_mod_mul_384 -al C:9412 .hkdf_out_len -al C:3f09 .rotl32_8 -al C:9f3f .x25_b -al C:5d9e .fp_inv_x2 -al C:9e3f .x25_scalar -al C:6e00 .ec_p1_384 -al C:7010 .ec_t3_384 -al C:0d4e .net_tcp_recv_cb -al C:9f1f .x25_a -al C:5d7e .fp_inv_x1 -al C:9f7f .x25_cb +al C:9a94 .hkdf_label_ptr +al C:a3c7 .aead_tag +al C:a667 .mul_cached_a +al C:4769 .sha256_h6_init +al C:9c05 .http_req_len +al C:a0dd .sha256_len +al C:443a .poly1305_multiply +al C:0c08 .done_msg +al C:6ccb .fp_mod_reduce_384 +al C:6d7f .fp_mod_mul_384 +al C:9a9a .hkdf_out_len +al C:3fe0 .rotl32_8 +al C:a5c7 .x25_b +al C:6150 .fp_inv_x2 +al C:a4c7 .x25_scalar +al C:71b2 .ec_p1_384 +al C:73c2 .ec_t3_384 +al C:0e28 .net_tcp_recv_cb +al C:a5a7 .x25_a +al C:6130 .fp_inv_x1 +al C:a607 .x25_cb al C:8d18 .cert_end_lo -al C:a1a2 .ev_u1_384 -al C:6c80 .fp_r2_384 -al C:08a7 .print_string -al C:0c81 .net_set_tcp_dest -al C:9f9f .x25_e +al C:a84b .ev_u1_384 +al C:7032 .fp_r2_384 +al C:08b9 .print_string +al C:0d5b .net_set_tcp_dest +al C:a627 .x25_e al C:8d19 .cert_end_hi -al C:3ddc .add32_to_dst -al C:9bba .drbg_buf_idx -al C:0c3d .net_dhcp -al C:0c09 .http_host_zimmers -al C:8fc7 .tls_hs_read_iv -al C:3ee4 .rotl32_1 -al C:5a0a .fp_a_byte -al C:9b38 .hmac_data_len +al C:3eb3 .add32_to_dst +al C:a242 .drbg_buf_idx +al C:0d17 .net_dhcp +al C:0ccf .reu_fetch_mul_row +al C:0ce3 .http_host_zimmers +al C:964f .tls_hs_read_iv +al C:3fbb .rotl32_1 +al C:5dbc .fp_a_byte +al C:a1c0 .hmac_data_len +al C:9200 .mul38_lo_tab al C:8cec .cv_label -al C:506d .extra_sid_lo -al C:4271 .fp_init_sqtab -al C:1d66 .tls_verify_finished -al C:9e1f .fe_p +al C:5144 .extra_sid_lo +al C:4348 .fp_init_sqtab +al C:1e40 .tls_verify_finished +al C:a4a7 .fe_p al C:809f .der_skip -al C:8ebb .tls_ecdhe_privkey -al C:1714 .tls_transcript_save -al C:3f2c .rotl32_4 -al C:a142 .ev_point_save -al C:1222 .tls_record_write -al C:3fb6 .rotl32_7 -al C:506e .extra_sid_hi -al C:9b99 .drbg_seed_len -al C:44c7 .poly1305_final -al C:5a0b .fp_b_byte -al C:1dc3 .lbl_s_hs_traffic +al C:9543 .tls_ecdhe_privkey +al C:12fc .tls_record_write +al C:17ee .tls_transcript_save +al C:4003 .rotl32_4 +al C:a7eb .ev_point_save +al C:408d .rotl32_7 +al C:5145 .extra_sid_hi +al C:a221 .drbg_seed_len +al C:459e .poly1305_final +al C:5dbd .fp_b_byte +al C:1e9d .lbl_s_hs_traffic al C:3b27 .http_get -al C:6fe0 .ec_t2_384 -al C:610e .ec_point_double -al C:1dec .lbl_finished -al C:595f .fp_mul -al C:0e6c .tls_send -al C:1734 .tls_transcript_init -al C:1db7 .lbl_c_hs_traffic +al C:64c0 .ec_point_double +al C:7392 .ec_t2_384 +al C:1ec6 .lbl_finished +al C:5d11 .fp_mul +al C:0f46 .tls_send +al C:180e .tls_transcript_init +al C:1e91 .lbl_c_hs_traffic al C:8d0e .cert_list_len_lo -al C:8fa7 .tls_hs_read_key -al C:6d10 .ec_n_384 -al C:6cb0 .fp_r3_384 -al C:0d78 .cb_copy_byte -al C:1d90 .empty_hash -al C:0ec9 .tls_send_client_hello -al C:9e5f .x25_u -al C:0a30 .banner_msg -al C:69d4 .fp_mod_inv_384 +al C:962f .tls_hs_read_key +al C:70c2 .ec_n_384 +al C:7062 .fp_r3_384 +al C:0e52 .cb_copy_byte +al C:1e6a .empty_hash +al C:5571 .mul38_in +al C:0fa3 .tls_send_client_hello +al C:a4e7 .x25_u +al C:0a42 .banner_msg +al C:6d86 .fp_mod_inv_384 al C:8d0d .cert_list_len_hi -al C:3dbb .http_bg_idx -al C:97a4 .tls_app_ptr -al C:1685 .tls_parse_encrypted_extensions -al C:0aba .net_fail_msg -al C:0b64 .dns_fail_msg -al C:9d3e .aead_data_len -al C:0c19 .http_host_apple -al C:3dfb .xor32 -al C:0b0b .no_net_msg -al C:0d2f .net_recv_ready -al C:0aef .dhcp_fail_msg -al C:9040 .tls_rec_type -al C:9cab .poly_h -al C:43d4 .poly1305_reduce -al C:4ce3 .sha256_rotr1 -al C:6f20 .ec_p3_384 -al C:6fb0 .ec_t1_384 -al C:4d18 .sha256_rotr2 -al C:4a2e .sha256_load_word -al C:197f .tls_derive_secret -al C:0acf .net_ok_msg -al C:0a19 .print_resp_body -al C:0b78 .dns_ok_msg -al C:6c20 .fp_r0_384 -al C:16d0 .tls_hostname_len -al C:4f53 .hmac_drbg_update -al C:70d0 .ec_set_modp_384 -al C:3e19 .xor32_in_place -al C:4d1e .sha256_rotr6 -al C:6754 .fp_rshift1_384 -al C:9406 .hkdf_salt_ptr -al C:4238 .poly1305_clamp -al C:55e9 .fe_inv_dst -al C:0b94 .tcp_ok_msg -al C:4dbd .hmac_sha256 -al C:4d27 .sha256_rotr7 -al C:98e9 .sha_temp3 -al C:9d39 .aead_aad_ptr -al C:58de .x25519_base -al C:4d05 .sha256_rotr8 -al C:6746 .fp_is_zero_384 -al C:0c29 .net_init -al C:5a0e .fp_p_hi -al C:661f .ec_sc_mask -al C:8e78 .tcp_recv_head -al C:50c0 .drbg_random_byte +al C:3e92 .http_bg_idx +al C:9e2c .tls_app_ptr +al C:175f .tls_parse_encrypted_extensions +al C:0acc .net_fail_msg +al C:0b76 .dns_fail_msg +al C:a3c6 .aead_data_len +al C:0cf3 .http_host_apple +al C:3ed2 .xor32 +al C:0b1d .no_net_msg +al C:0e09 .net_recv_ready +al C:0b01 .dhcp_fail_msg +al C:a333 .poly_h +al C:44ab .poly1305_reduce +al C:4dba .sha256_rotr1 +al C:96c8 .tls_rec_type +al C:7362 .ec_t1_384 +al C:72d2 .ec_p3_384 +al C:4b05 .sha256_load_word +al C:4def .sha256_rotr2 +al C:1a59 .tls_derive_secret +al C:0ae1 .net_ok_msg +al C:0a2b .print_resp_body +al C:0b8a .dns_ok_msg +al C:6fd2 .fp_r0_384 +al C:17aa .tls_hostname_len +al C:502a .hmac_drbg_update +al C:7482 .ec_set_modp_384 +al C:3ef0 .xor32_in_place +al C:4df5 .sha256_rotr6 +al C:6b06 .fp_rshift1_384 +al C:9a8e .hkdf_salt_ptr +al C:430f .poly1305_clamp +al C:59e7 .fe_inv_dst +al C:0ba6 .tcp_ok_msg +al C:4e94 .hmac_sha256 +al C:4dfe .sha256_rotr7 +al C:9f71 .sha_temp3 +al C:a3c1 .aead_aad_ptr +al C:5c90 .x25519_base +al C:4ddc .sha256_rotr8 +al C:6af8 .fp_is_zero_384 +al C:0d03 .net_init +al C:5dc0 .fp_p_hi +al C:69d1 .ec_sc_mask +al C:9500 .tcp_recv_head +al C:5197 .drbg_random_byte al C:837f .cert_pubkey_len -al C:6da0 .ec_gx_384 +al C:7152 .ec_gx_384 al C:80d1 .x509_parse_cert -al C:0bb9 .tls_ok_msg -al C:0d91 .cb_done -al C:4cf4 .sha256_rotl1 -al C:174e .tls_transcript_update -al C:5a0d .fp_p_lo -al C:0afc .dhcp_ok_msg -al C:8f7b .tls_hs_write_key -al C:4584 .aead_derive_otk -al C:6e90 .ec_p2_384 -al C:6763 .fp_chk_one_384 -al C:3da0 .http_host_hdr -al C:a1d2 .ev_u2_384 -al C:0dd2 .net_save_zp -al C:6c50 .fp_r1_384 -al C:5608 .x25519_clamp -al C:5942 .fp_is_zero -al C:47da .sha256_update +al C:0bcb .tls_ok_msg +al C:0e6b .cb_done +al C:4dcb .sha256_rotl1 +al C:1828 .tls_transcript_update +al C:5dbf .fp_p_lo +al C:0b0e .dhcp_ok_msg +al C:9603 .tls_hs_write_key +al C:7242 .ec_p2_384 +al C:465b .aead_derive_otk +al C:6b15 .fp_chk_one_384 +al C:3e77 .http_host_hdr +al C:a87b .ev_u2_384 +al C:0eac .net_save_zp +al C:7002 .fp_r1_384 +al C:5a06 .x25519_clamp +al C:5cf4 .fp_is_zero +al C:48b1 .sha256_update al C:8d10 .cert_data_len_lo -al C:4664 .aead_verify_tag -al C:9ccc .poly_s -al C:506f .drbg_init_entropy -al C:9cbc .poly_r -al C:4a3d .sha256_load_word_to_temp2 -al C:4b96 .sha256_maj -al C:0b2b .http_get_msg -al C:9473 .http_host_ptr +al C:473b .aead_verify_tag +al C:a354 .poly_s +al C:5146 .drbg_init_entropy +al C:a344 .poly_r +al C:4b14 .sha256_load_word_to_temp2 +al C:4c6d .sha256_maj +al C:0b3d .http_get_msg +al C:9afb .http_host_ptr al C:8d0f .cert_data_len_hi -al C:0ddd .net_restore_zp -al C:6dd0 .ec_gy_384 -al C:a292 .ev_der_int_len -al C:9bfb .cc20_work -al C:4362 .mul_s_pg -al C:a293 .ev_der_copy_cnt -al C:0b48 .https_get_msg -al C:5022 .hmac_drbg_instantiate -al C:9453 .tls_master_secret -al C:4a4c .sha256_add_temp2_to_temp1 -al C:469a .sha256_k -al C:511a .fe_add +al C:0eb7 .net_restore_zp +al C:7182 .ec_gy_384 +al C:a93b .ev_der_int_len +al C:a283 .cc20_work +al C:4439 .mul_s_pg +al C:a93c .ev_der_copy_cnt +al C:0b5a .https_get_msg +al C:50f9 .hmac_drbg_instantiate +al C:9adb .tls_master_secret +al C:4b23 .sha256_add_temp2_to_temp1 +al C:4771 .sha256_k +al C:51f1 .fe_add al C:89e4 .tls_handle_certificate -al C:4d2d .sha256_rotr11 -al C:9b9a .drbg_output -al C:6b90 .fp_inv_v_384 -al C:199b .tls_derive_handshake_keys -al C:4311 .poly_prod_lo -al C:4d39 .sha256_rotr13 -al C:4888 .sha256_process_block -al C:1690 .tls_hostname -al C:70d9 .ec_set_modn_384 -al C:9784 .http_line_buf +al C:4e04 .sha256_rotr11 +al C:a222 .drbg_output +al C:6f42 .fp_inv_v_384 +al C:43e8 .poly_prod_lo +al C:1a75 .tls_derive_handshake_keys +al C:495f .sha256_process_block +al C:4e10 .sha256_rotr13 +al C:176a .tls_hostname +al C:748b .ec_set_modn_384 al C:8d1a .cert_bs_len -al C:5b8d .fp_mod_inv -al C:0c91 .net_tcp_send -al C:4471 .poly1305_update -al C:1010 .tls_select_keys -al C:4312 .poly_prod_hi -al C:3db9 .http_crlf -al C:4d48 .sha256_rotr17 -al C:4d51 .sha256_rotr18 -al C:4d5d .sha256_rotr19 -al C:5141 .fe_sub -al C:6821 .fp_a_byte_384 -al C:1186 .tls_record_decrypt +al C:9e0c .http_line_buf +al C:5f3f .fp_mod_inv +al C:43e9 .poly_prod_hi +al C:0d6b .net_tcp_send +al C:4548 .poly1305_update +al C:10ea .tls_select_keys +al C:3e90 .http_crlf +al C:4e1f .sha256_rotr17 +al C:4e28 .sha256_rotr18 +al C:4e34 .sha256_rotr19 +al C:5218 .fe_sub +al C:6bd3 .fp_a_byte_384 +al C:1260 .tls_record_decrypt al C:831b .cert_tbs_ptr -al C:9476 .http_path_ptr -al C:902b .tls_write_seq -al C:0c49 .net_poll -al C:8f5b .tls_transcript_h0 -al C:5a4f .fp_mod_add -al C:4bba .sha256_add_to_hash -al C:8f5f .tls_transcript_h1 -al C:9ca7 .cc20_counter -al C:8f63 .tls_transcript_h2 -al C:8d78 .tcp_recv_buf -al C:451c .aead_encrypt -al C:8f67 .tls_transcript_h3 -al C:940f .hkdf_context_ptr -al C:9cdc .poly_product -al C:9782 .http_hdr_match -al C:8f6b .tls_transcript_h4 -al C:6d70 .ec_b_384 -al C:9373 .tls_hs_len -al C:1db0 .empty_context -al C:53d5 .fe_inv -al C:8f6f .tls_transcript_h5 -al C:9fbf .x25_basepoint -al C:5950 .fp_rshift1 -al C:8f73 .tls_transcript_h6 -al C:0d97 .net_init_cb_addrs -al C:8f77 .tls_transcript_h7 -al C:0867 .do_net_init -al C:9dbf .fe_tmp2 -al C:52bd .fe_sqr -al C:9ddf .fe_tmp3 -al C:a071 .ecdsa_sig_len -al C:9c3b .cc20_keystream -al C:5aaf .fp_mod_sub -al C:0b80 .tcp_fail_msg -al C:9d9f .fe_tmp1 -al C:0dea .net_send_len -al C:156a .tls_parse_server_hello -al C:9935 .sha256_w -al C:5b63 .fp_rem -al C:9043 .tls_rec_buf -al C:1293 .tls_recv_record -al C:9375 .hkdf_prk -al C:9dff .fe_tmp4 -al C:1de7 .lbl_key -al C:66fc .fp_copy_384 -al C:5225 .fe_reduce_wide -al C:9ebf .x25_z2 -al C:9edf .x25_x3 -al C:93b5 .hkdf_info_buf -al C:9e9f .x25_x2 -al C:9eff .x25_z3 -al C:08b8 .do_http_get -al C:139f .tls_recv_count -al C:0c53 .net_dns_resolve -al C:3e5c .rotr32_8 -al C:17af .tls_transcript_hash -al C:9a57 .hmac_key -al C:0bea .ok_msg -al C:0f60 .tls_recv_encrypted -al C:9a97 .hmac_opad_block -al C:9d5f .fe_wide -al C:6d40 .ec_a_384 -al C:98e5 .sha_h +al C:9afe .http_path_ptr +al C:96b3 .tls_write_seq +al C:0d23 .net_poll +al C:95e3 .tls_transcript_h0 +al C:5e01 .fp_mod_add +al C:4c91 .sha256_add_to_hash +al C:95e7 .tls_transcript_h1 +al C:a32f .cc20_counter +al C:95eb .tls_transcript_h2 +al C:9400 .tcp_recv_buf +al C:45f3 .aead_encrypt +al C:95ef .tls_transcript_h3 +al C:9a97 .hkdf_context_ptr +al C:a364 .poly_product +al C:9e0a .http_hdr_match +al C:95f3 .tls_transcript_h4 +al C:7122 .ec_b_384 +al C:99fb .tls_hs_len +al C:1e8a .empty_context +al C:57d3 .fe_inv +al C:95f7 .tls_transcript_h5 +al C:a647 .x25_basepoint +al C:5d02 .fp_rshift1 +al C:95fb .tls_transcript_h6 +al C:0e71 .net_init_cb_addrs +al C:95ff .tls_transcript_h7 +al C:0879 .do_net_init +al C:0ccd .reu_init_a +al C:a447 .fe_tmp2 +al C:5574 .fe_sqr +al C:a467 .fe_tmp3 +al C:a71a .ecdsa_sig_len +al C:a2c3 .cc20_keystream +al C:5e61 .fp_mod_sub +al C:0b92 .tcp_fail_msg +al C:a427 .fe_tmp1 +al C:0cce .reu_init_b +al C:0ec4 .net_send_len +al C:1644 .tls_parse_server_hello +al C:9fbd .sha256_w +al C:5f15 .fp_rem +al C:96cb .tls_rec_buf +al C:136d .tls_recv_record +al C:99fd .hkdf_prk +al C:a487 .fe_tmp4 +al C:1ec1 .lbl_key +al C:6aae .fp_copy_384 +al C:546f .fe_reduce_wide +al C:a547 .x25_z2 +al C:a567 .x25_x3 +al C:9a3d .hkdf_info_buf +al C:a527 .x25_x2 +al C:a587 .x25_z3 +al C:08ca .do_http_get +al C:1479 .tls_recv_count +al C:0d2d .net_dns_resolve +al C:3f33 .rotr32_8 +al C:1889 .tls_transcript_hash +al C:9100 .sqtab2_hi +al C:a0df .hmac_key +al C:0bfc .ok_msg +al C:103a .tls_recv_encrypted +al C:a11f .hmac_opad_block +al C:a3e7 .fe_wide +al C:70f2 .ec_a_384 +al C:9f6d .sha_h +al C:9000 .sqtab2_lo al C:8d1b .cv_sig_len -al C:8fff .tls_app_read_key -al C:68e6 .fp_mod_sub_384 -al C:60de .ec_set_modp -al C:0dec .tls_connect -al C:9781 .http_parse_state -al C:503e .hmac_drbg_generate -al C:506c .extra_sid_count -al C:98d9 .sha_e -al C:940e .hkdf_label_len -al C:3f91 .rotr32_1 -al C:98d5 .sha_d -al C:0d5f .cb_load_ptr_lo -al C:9409 .hkdf_ikm_ptr -al C:98e1 .sha_g -al C:98dd .sha_f -al C:5e3e .ec_p -al C:98c9 .sha_a -al C:3e82 .rotr32_4 -al C:4360 .mul_a -al C:9e7f .x25_result -al C:1387 .tls_record_recv_and_decrypt -al C:901f .tls_app_read_iv -al C:0d65 .cb_load_ptr_hi -al C:4361 .mul_b -al C:98d1 .sha_c -al C:52c8 .fe_mul_a24 -al C:9fe0 .ecdsa_hash +al C:9687 .tls_app_read_key +al C:6c98 .fp_mod_sub_384 +al C:6490 .ec_set_modp +al C:0ec6 .tls_connect +al C:9e09 .http_parse_state +al C:5115 .hmac_drbg_generate +al C:5143 .extra_sid_count +al C:9f61 .sha_e +al C:9a96 .hkdf_label_len +al C:4068 .rotr32_1 +al C:9f5d .sha_d +al C:0e39 .cb_load_ptr_lo +al C:9a91 .hkdf_ikm_ptr +al C:9f69 .sha_g +al C:9f65 .sha_f +al C:61f0 .ec_p +al C:9f51 .sha_a +al C:3f59 .rotr32_4 +al C:4437 .mul_a +al C:a507 .x25_result +al C:1461 .tls_record_recv_and_decrypt +al C:96a7 .tls_app_read_iv +al C:0e3f .cb_load_ptr_hi +al C:4438 .mul_b +al C:56cc .fe_mul_a24 +al C:9f59 .sha_c +al C:a689 .ecdsa_hash al C:89e2 .cert_buf_len al C:803f .der_read_tag -al C:1711 .tls_transcript_block_len -al C:5902 .fp_zero -al C:7612 .ec_affine_x_384 -al C:98cd .sha_b -al C:3ee1 .rotr32_7 -al C:4da0 .sha256_shr10 -al C:1712 .tls_transcript_total_lo -al C:50fc .fe_copy -al C:6b60 .fp_inv_u_384 +al C:17eb .tls_transcript_block_len +al C:5cb4 .fp_zero +al C:79c4 .ec_affine_x_384 +al C:9f55 .sha_b +al C:3fb8 .rotr32_7 +al C:4e77 .sha256_shr10 +al C:17ec .tls_transcript_total_lo +al C:51d3 .fe_copy +al C:6f12 .fp_inv_u_384 al C:8319 .der_len -al C:4850 .sha256_final -al C:1995 .hkdf_tls13_prefix -al C:4012 .chacha20_init -al C:5177 .fe_reduce_final -al C:6731 .fp_sub_384 +al C:4927 .sha256_final +al C:1a6f .hkdf_tls13_prefix +al C:40e9 .chacha20_init +al C:524e .fe_reduce_final +al C:6ae3 .fp_sub_384 al C:82fc .oid_prime256v1 -al C:977f .http_resp_len -al C:3d91 .http_get_verb -al C:5dfe .fp_r2 -al C:1713 .tls_transcript_total_hi -al C:561b .x25519_scalarmult -al C:5e1e .fp_r3 -al C:5e5e .ec_n -al C:6ce0 .ec_p_384 -al C:5dbe .fp_r0 -al C:3d25 .http_get_plain -al C:5dde .fp_r1 -al C:8e7b .tls_client_random -al C:60e7 .ec_set_modn -al C:10bc .tls_seq_increment -al C:6825 .fp_p_hi_384 -al C:5e9e .ec_b -al C:7611 .ec_sc_mask_384 +al C:9e07 .http_resp_len +al C:3e68 .http_get_verb +al C:5512 .mul_by_38 +al C:61b0 .fp_r2 +al C:17ed .tls_transcript_total_hi +al C:5a19 .x25519_scalarmult +al C:61d0 .fp_r3 +al C:6210 .ec_n +al C:7092 .ec_p_384 +al C:6170 .fp_r0 +al C:3dfc .http_get_plain +al C:6190 .fp_r1 +al C:9503 .tls_client_random +al C:6499 .ec_set_modn +al C:1196 .tls_seq_increment +al C:6bd7 .fp_p_hi_384 +al C:79c3 .ec_sc_mask_384 al C:7f63 .ecdsa_parse_der_sig -al C:0c27 .http_path_root -al C:5e7e .ec_a -al C:9033 .tls_read_seq -al C:3fd2 .zero32 -al C:947d .http_req_buf -al C:9479 .http_port -al C:6823 .fp_s_hi_384 -al C:7642 .ec_affine_y_384 -al C:97a8 .input_buffer -al C:3dbc .http_bg_src -al C:590c .fp_cmp -al C:9b39 .hmac_result -al C:9f5f .x25_da -al C:a102 .ev_u1 -al C:3b2c .http_build_get +al C:6250 .ec_b +al C:0d01 .http_path_root +al C:6230 .ec_a +al C:96bb .tls_read_seq +al C:40a9 .zero32 +al C:9b05 .http_req_buf +al C:9b01 .http_port +al C:6bd5 .fp_s_hi_384 +al C:79f4 .ec_affine_y_384 +al C:9e30 .input_buffer +al C:3e93 .http_bg_src +al C:5cbe .fp_cmp +al C:a1c1 .hmac_result +al C:a5e7 .x25_da +al C:a7ab .ev_u1 +al C:3c03 .http_build_get al C:8d13 .cert_data_offset -al C:699a .fp_rem_384 -al C:0d3b .net_recv_byte -al C:a010 .ecdsa_hash_len -al C:0bcb .send_fail_msg -al C:9cfd .poly1305_tag -al C:a122 .ev_u2 -al C:9d3c .aead_data_ptr -al C:4184 .chacha20_block -al C:9c9b .cc20_nonce -al C:1e54 .tls_verify_data -al C:9ad7 .hmac_data_buf -al C:4a66 .sha256_sig0 +al C:6d4c .fp_rem_384 +al C:0e15 .net_recv_byte +al C:a6b9 .ecdsa_hash_len +al C:0bdd .send_fail_msg +al C:a385 .poly1305_tag +al C:a7cb .ev_u2 +al C:a3c4 .aead_data_ptr +al C:425b .chacha20_block +al C:a323 .cc20_nonce +al C:1f2e .tls_verify_data +al C:a15f .hmac_data_buf +al C:4b3d .sha256_sig0 al C:8a69 .x509_extract_pubkey -al C:55eb .fe_inv_sqrn_tmp2 -al C:97a6 .tls_app_len -al C:4aab .sha256_sig1 -al C:0cb5 .net_tcp_close -al C:60f0 .ec_mulp -al C:4b35 .sha256_big_sig1 -al C:454b .aead_decrypt -al C:4af0 .sha256_big_sig0 -al C:6826 .fp_wide_384 +al C:59e9 .fe_inv_sqrn_tmp2 +al C:9e2e .tls_app_len +al C:4b82 .sha256_sig1 +al C:0d8f .net_tcp_close +al C:64a2 .ec_mulp +al C:4c0c .sha256_big_sig1 +al C:4622 .aead_decrypt +al C:4bc7 .sha256_big_sig0 +al C:6bd8 .fp_wide_384 al C:8d5e .zp_save_buf -al C:10d4 .tls_record_encrypt -al C:8e9b .tls_server_random -al C:9395 .hkdf_okm -al C:69cb .fp_bc_384 -al C:591a .fp_add -al C:9783 .http_line_idx -al C:1088 .tls_build_nonce -al C:8efb .tls_server_pubkey -al C:18d6 .hkdf_expand -al C:70e2 .ec_mulp_384 -al C:1dcf .lbl_c_ap_traffic -al C:1ddb .lbl_s_ap_traffic -al C:0d4e .cb_load_len_lo +al C:11ae .tls_record_encrypt +al C:9523 .tls_server_random +al C:9a1d .hkdf_okm +al C:6d7d .fp_bc_384 +al C:5ccc .fp_add +al C:9e0b .http_line_idx +al C:1162 .tls_build_nonce +al C:9583 .tls_server_pubkey +al C:5573 .mul38_hi +al C:19b0 .hkdf_expand +al C:7494 .ec_mulp_384 +al C:1ea9 .lbl_c_ap_traffic +al C:1eb5 .lbl_s_ap_traffic +al C:5572 .mul38_lo +al C:0e28 .cb_load_len_lo al C:7c11 .ecdsa_verify_256 -al C:0fd8 .tls_send_finished -al C:1264 .tls_enc_aead_len -al C:0ba3 .tls_fail_msg -al C:1b65 .tls_derive_traffic_keys +al C:10b2 .tls_send_finished +al C:133e .tls_enc_aead_len +al C:0bb5 .tls_fail_msg +al C:1c3f .tls_derive_traffic_keys al C:80b8 .der_match_oid al C:8d1c .tls_ecdh_generate_keypair -al C:903b .tls_rec_header -al C:139e .tls_recv_state -al C:a202 .ev_point_save_384 -al C:592d .fp_sub -al C:0d54 .cb_load_len_hi -al C:3d95 .http_version -al C:1dea .lbl_iv -al C:08a6 .net_initialized -al C:9267 .tls_nonce -al C:9d0d .aead_key -al C:3ff2 .cc20_qr_table -al C:1e34 .tls_derived_tmp -al C:60be .ec_t6 -al C:9413 .tls_early_secret -al C:609e .ec_t5 -al C:3e37 .rotr32_16 -al C:3c0d .http_recv_response -al C:5b85 .fp_bm -al C:607e .ec_t4 -al C:5efe .ec_p1 -al C:605e .ec_t3 -al C:8e79 .tcp_recv_tail -al C:603e .ec_t2 -al C:3e7f .rotr32_12 -al C:5fbe .ec_p3 -al C:601e .ec_t1 -al C:518d .fe_cswap -al C:0e9a .tls_recv -al C:5d2b .fp_chk_one -al C:5f5e .ec_p2 -al C:0cbf .net_print_ip +al C:96c3 .tls_rec_header +al C:1478 .tls_recv_state +al C:a8ab .ev_point_save_384 +al C:5cdf .fp_sub +al C:0e2e .cb_load_len_hi +al C:3e6c .http_version +al C:1ec4 .lbl_iv +al C:08b8 .net_initialized +al C:98ef .tls_nonce +al C:a395 .aead_key +al C:40c9 .cc20_qr_table +al C:1f0e .tls_derived_tmp +al C:6470 .ec_t6 +al C:9a9b .tls_early_secret +al C:6450 .ec_t5 +al C:3f0e .rotr32_16 +al C:3ce4 .http_recv_response +al C:5f37 .fp_bm +al C:6430 .ec_t4 +al C:62b0 .ec_p1 +al C:6410 .ec_t3 +al C:9501 .tcp_recv_tail +al C:63f0 .ec_t2 +al C:3f56 .rotr32_12 +al C:6370 .ec_p3 +al C:63d0 .ec_t1 +al C:5264 .fe_cswap +al C:0f74 .tls_recv +al C:60dd .fp_chk_one +al C:6310 .ec_p2 +al C:0d99 .net_print_ip al C:8309 .oid_sha256_ecdsa -al C:5d5e .fp_inv_v -al C:090c .do_https_get -al C:4452 .poly1305_block -al C:8f1b .tls_shared_secret -al C:5d3e .fp_inv_u +al C:6110 .fp_inv_v +al C:091e .do_https_get +al C:4529 .poly1305_block +al C:95a3 .tls_shared_secret +al C:60f0 .fp_inv_u al C:8304 .oid_secp384r1 -al C:947b .http_status -al C:98ed .sha_t1 -al C:5b84 .fp_bc -al C:4307 .sq_acc -al C:3fe2 .cc20_constants -al C:98f1 .sha_t2 -al C:9fdf .ecdsa_curve_id -al C:9475 .http_host_len -al C:75b3 .ec_scalar_mul_384 -al C:9433 .tls_handshake_secret -al C:4227 .poly1305_init -al C:98ad .sha256_h1 +al C:9b03 .http_status +al C:9f75 .sha_t1 +al C:5f36 .fp_bc +al C:43de .sq_acc +al C:40b9 .cc20_constants +al C:9f79 .sha_t2 +al C:a688 .ecdsa_curve_id +al C:7965 .ec_scalar_mul_384 +al C:9afd .http_host_len +al C:9abb .tls_handshake_secret +al C:42fe .poly1305_init +al C:9f35 .sha256_h1 al C:8d17 .cert_ext_len_lo -al C:98a9 .sha256_h0 -al C:8ff3 .tls_app_write_iv -al C:98b5 .sha256_h3 -al C:9a77 .hmac_val -al C:403f .chacha20_quarter_round -al C:98b1 .sha256_h2 -al C:1266 .tls_send_record -al C:430f .sq_i -al C:98bd .sha256_h5 -al C:9408 .hkdf_salt_len -al C:98b9 .sha256_h4 -al C:6706 .fp_zero_384 +al C:9f31 .sha256_h0 +al C:967b .tls_app_write_iv +al C:9f3d .sha256_h3 +al C:a0ff .hmac_val +al C:4116 .chacha20_quarter_round +al C:9f39 .sha256_h2 +al C:1340 .tls_send_record +al C:43e6 .sq_i +al C:9f45 .sha256_h5 +al C:9a90 .hkdf_salt_len +al C:6ab8 .fp_zero_384 al C:8d16 .cert_ext_len_hi -al C:98c5 .sha256_h7 -al C:98c1 .sha256_h6 -al C:9a35 .sha256_hash -al C:6824 .fp_p_lo_384 -al C:0d70 .cb_loop -al C:137c .tls_record_send_encrypted -al C:9d3b .aead_aad_len +al C:9f41 .sha256_h4 +al C:9f4d .sha256_h7 +al C:9f49 .sha256_h6 +al C:a0bd .sha256_hash +al C:6bd6 .fp_p_lo_384 +al C:0e4a .cb_loop +al C:1456 .tls_record_send_encrypted +al C:a3c3 .aead_aad_len al C:8311 .oid_sha384_ecdsa -al C:5163 .fe_cmp_p -al C:186d .hkdf_extract +al C:523a .fe_cmp_p +al C:1947 .hkdf_extract al C:804a .der_read_length diff --git a/src/boot.asm b/src/boot.asm index 47abc5f..a8e9cd9 100644 --- a/src/boot.asm +++ b/src/boot.asm @@ -37,11 +37,28 @@ jsr entropy_init jsr drbg_init_entropy + ; build quarter-square multiply table (needed by Poly1305, fe25519, ECDSA) + jsr sqtab_init + + ; pre-compute REU multiply rows (depends on sqtab being populated) + ; Ensure BASIC ROM is off — data buffers and REU DMA targets live at $A000+ + lda $01 + and #%11111110 + sta $01 + jsr reu_mul_init + ; print menu lda #menu_msg jsr print_string + ; Ensure BASIC ROM stays off for all runtime operation. + ; Data buffers (fe_wide, x25_*, ECDSA) live at $A000-$BFFF. + ; The C64 writes to RAM under ROM, but reads hit ROM unless banked out. + lda $01 + and #%11111110 + sta $01 + ; enter main loop jmp main_loop @@ -488,6 +505,129 @@ done_msg: !text "CONNECTION CLOSED" !byte $0d, 0 +; ============================================================================= +; REU multiply table initialization (from c64-x25519 optimizations) +; ============================================================================= + +; ============================================================================= +; reu_mul_init - Generate 256 full multiplication rows and stash in REU +; +; For each a = 0..255, computes a*b for b = 0..255 and stashes: +; 256 lo bytes at REU offset a*512 +; 256 hi bytes at REU offset a*512+256 +; +; Uses mul_dma_lo/mul_dma_hi as staging buffers. +; Uses mul_8x8 (requires sqtab to be initialized first). +; Clobbers: A, X, Y +; ============================================================================= +reu_mul_init: + lda #0 + sta reu_init_a ; outer counter (multiplier a) + +@outer: + ; For current a, compute a*b for all b=0..255 + lda #0 + sta reu_init_b ; inner counter (multiplicand b) + +@inner: + lda reu_init_a + ldx reu_init_b + jsr mul_8x8 ; poly_prod_lo/hi = a * b + + ldx reu_init_b + lda poly_prod_lo + sta mul_dma_lo,x + lda poly_prod_hi + sta mul_dma_hi,x + + inc reu_init_b + bne @inner ; loop b = 0..255 + + ; Stash lo table (256 bytes) to REU at offset a*512 + lda #mul_dma_lo + sta reu_c64_hi + lda #0 + sta reu_reu_lo ; REU offset low = 0 + lda reu_init_a + asl ; A = a * 2 (high byte of offset) + sta reu_reu_hi + lda #0 + adc #0 ; carry into bank if a >= 128 + sta reu_reu_bank + lda #0 + sta reu_len_lo + lda #1 + sta reu_len_hi ; length = 256 + lda #0 + sta reu_addr_ctrl ; both addresses increment + lda #%10110000 ; execute + autoload + STASH (C64->REU) + sta reu_command + + ; Stash hi table (256 bytes) to REU at offset a*512+256 + lda #mul_dma_hi + sta reu_c64_hi + lda #0 + sta reu_reu_lo + lda reu_init_a + asl ; a*2 (carry = bit 7 of a) + lda #0 + adc #0 ; bank = a >> 7 + sta reu_reu_bank + lda reu_init_a + asl ; a*2 + ora #1 ; +1 for hi page (a*2 is even, so OR works) + sta reu_reu_hi + lda #0 + sta reu_len_lo + lda #1 + sta reu_len_hi ; length = 256 + lda #0 + sta reu_addr_ctrl + lda #%10110000 ; execute + autoload + STASH + sta reu_command + + inc reu_init_a + beq @init_done ; if wrapped to 0, done + jmp @outer +@init_done: + ; Pre-configure constant REU registers for fetch routine + lda #mul_dma_lo + sta reu_c64_hi + lda #0 + sta reu_reu_lo + sta reu_len_lo + sta reu_addr_ctrl + lda #2 + sta reu_len_hi ; length high = 2 (512 bytes) + rts + +reu_init_a: !byte 0 +reu_init_b: !byte 0 + +; ============================================================================= +; reu_fetch_mul_row - DMA a multiplication table row from REU to C64 +; +; Input: mul_cached_a = multiplier value (0-255) +; Fetches 512 bytes: 256 lo bytes to mul_dma_lo, 256 hi bytes to mul_dma_hi +; Clobbers: A +; ============================================================================= +reu_fetch_mul_row: + lda mul_cached_a + asl ; A = multiplier * 2, carry = bit 7 + sta reu_reu_hi + lda #0 + adc #0 ; bank = carry from shift + sta reu_reu_bank + lda #%10110001 ; execute + autoload + FETCH (REU->C64) + sta reu_command + rts + ; ============================================================================= ; hostname and path data ; ============================================================================= diff --git a/src/constants.asm b/src/constants.asm index 67a3bb3..3deaf67 100644 --- a/src/constants.asm +++ b/src/constants.asm @@ -52,13 +52,17 @@ sha_temp1 = $0a ; 4 bytes ($0A-$0D) sha_temp2 = $0e ; 4 bytes ($0E-$11) sha256_round = $12 ; 1 byte -; --- ChaCha20 state --- +; --- ChaCha20 state / mult66 pointers (time-shared: fe25519 and ChaCha20 never run simultaneously) --- cc20_round = $14 ; 1 byte cc20_qr_idx = $15 ; 1 byte cc20_data_ptr = $16 ; 2 bytes ($16-$17) cc20_remain = $18 ; 1 byte (also poly1305_update counter) cc20_buf_pos = $19 ; 1 byte +; --- mult66 indirect-indexed multiply pointers (time-shared with ChaCha20) --- +lmul0 = $14 ; 2 bytes ($14-$15) — sqtab lookup pointer +lmul1 = $16 ; 2 bytes ($16-$17) — sqtab_hi lookup pointer + ; --- Poly1305 state --- poly_i = $1a ; 1 byte poly_j = $1b ; 1 byte @@ -106,6 +110,18 @@ zp_count = $fe ; 1 byte sqtab_lo = $7800 ; 512 bytes: floor(n^2/4) low bytes sqtab_hi = $7a00 ; 512 bytes: floor(n^2/4) high bytes +; --- REU (Ram Expansion Unit) registers --- +reu_status = $df00 ; status register +reu_command = $df01 ; command register +reu_c64_lo = $df02 ; C64 base address low +reu_c64_hi = $df03 ; C64 base address high +reu_reu_lo = $df04 ; REU base address low +reu_reu_hi = $df05 ; REU base address high +reu_reu_bank = $df06 ; REU bank +reu_len_lo = $df07 ; transfer length low +reu_len_hi = $df08 ; transfer length high +reu_addr_ctrl = $df0a ; address control + ; --- SID voice 3 setup for noise (entropy collection) --- sid_base = $d400 sid_v3_freq_lo = $d40e diff --git a/src/crypto/fe25519.asm b/src/crypto/fe25519.asm index 5c1ffc9..9d1c016 100644 --- a/src/crypto/fe25519.asm +++ b/src/crypto/fe25519.asm @@ -1,6 +1,13 @@ ; ============================================================================= ; fe25519.asm - Field arithmetic mod p = 2^255 - 19 ; +; Optimized version imported from c64-x25519 project. +; Key optimizations over baseline: +; - fe_mul: REU DMA table lookup + 2x inner loop unroll (no mul_8x8 calls) +; - fe_sqr: Dedicated squaring with mult66 quarter-square + symmetry exploit +; - fe_reduce_wide: mul38 lookup tables instead of mul_8x8 calls +; - fe_cswap: Self-modifying abs,Y + 4x unroll (38 cyc/byte vs 49) +; ; 32-byte little-endian field elements. ; Uses ZP pointers fe_src1, fe_src2, fe_dst for operands. ; Reuses mul_8x8 and sqtab from poly1305.asm for multiplication. @@ -8,11 +15,12 @@ ; Key design: ; - Little-endian throughout (matches 6502 carry propagation and X25519 wire) ; - DEX/DEY for all carry-dependent loops (CPX/CPY clobber carry) -; - Reduction mod p: 2^256 = 38 mod p, so multiply overflow by 38 and add +; - Reduction mod p: 2^256 ≡ 38 mod p, so multiply overflow by 38 and add ; -; Adapted from c64-wireguard for c64-https TLS 1.3 ECDH. -; ZP equates (fe_src1, fe_src2, fe_dst, etc.) defined in constants.asm. -; Data labels (fe_wide, fe_tmp1..4, fe_p) defined in data.asm. +; ZP equates (fe_src1, fe_src2, fe_dst, lmul0, lmul1) defined in constants.asm. +; Data labels (fe_wide, fe_tmp1..4, fe_p, mul_cached_a, mul_src2_buf, +; mul_dma_lo, mul_dma_hi, sqtab2_lo, sqtab2_hi, mul38_lo_tab, +; mul38_hi_tab) defined in data.asm. ; ============================================================================= ; ============================================================================= @@ -69,7 +77,7 @@ fe_add: iny dex ; DEX doesn't affect carry bne @add_loop - bcs @must_reduce ; carry out -> result >= 2^256 > p + bcs @must_reduce ; carry out → result >= 2^256 > p ; Check if result >= p jsr fe_cmp_p @@ -107,7 +115,7 @@ fe_sub: iny dex bne @sub_loop - bcs @done ; no borrow -> done + bcs @done ; no borrow → done ; Borrow: add p clc @@ -139,7 +147,7 @@ fe_cmp_p: bne @greater dey bpl @cmp_loop - sec ; equal -> >= p + sec ; equal → >= p rts @less: clc @@ -175,21 +183,129 @@ fe_reduce_final: ; ; Input: A = swap mask (0x00 = no swap, 0xFF = swap) ; Clobbers: A, X, Y +; +; Self-modifying code: patches absolute,Y addresses into the inner loop +; to replace indirect-indexed (zp),Y loads/stores (4-5 cyc vs 5-6 cyc each). +; Eliminates redundant re-read of src1 by keeping value in X register. +; Unrolled 4x to reduce loop overhead (32 bytes / 4 = 8 iterations). +; +; Per byte: lda abs,Y(4) + tax(2) + eor abs,Y(4) + and zp(3) + sta zp(3) +; + txa(2) + eor zp(3) + sta abs,Y(5) + lda abs,Y(4) + eor zp(3) +; + sta abs,Y(5) = 38 cycles/byte +; Old: 49 cycles/byte (indirect-indexed + redundant re-read) +; Savings: ~11 cyc/byte * 32 bytes * 512 calls = ~180k cycles ; ============================================================================= fe_cswap: sta fe_carry ; save mask + + ; Patch src1 address into lda/sta abs,Y instructions (8 patches) + lda fe_src1 + sta @ld_a1+1 + sta @st_a1+1 + sta @ld_a2+1 + sta @st_a2+1 + sta @ld_a3+1 + sta @st_a3+1 + sta @ld_a4+1 + sta @st_a4+1 + lda fe_src1+1 + sta @ld_a1+2 + sta @st_a1+2 + sta @ld_a2+2 + sta @st_a2+2 + sta @ld_a3+2 + sta @st_a3+2 + sta @ld_a4+2 + sta @st_a4+2 + + ; Patch src2 address into eor/lda/sta abs,Y instructions (12 patches) + lda fe_src2 + sta @eor_b1+1 + sta @ld_b1+1 + sta @st_b1+1 + sta @eor_b2+1 + sta @ld_b2+1 + sta @st_b2+1 + sta @eor_b3+1 + sta @ld_b3+1 + sta @st_b3+1 + sta @eor_b4+1 + sta @ld_b4+1 + sta @st_b4+1 + lda fe_src2+1 + sta @eor_b1+2 + sta @ld_b1+2 + sta @st_b1+2 + sta @eor_b2+2 + sta @ld_b2+2 + sta @st_b2+2 + sta @eor_b3+2 + sta @ld_b3+2 + sta @st_b3+2 + sta @eor_b4+2 + sta @ld_b4+2 + sta @st_b4+2 + ldy #31 @loop: - lda (fe_src1),y - eor (fe_src2),y ; diff = a ^ b - and fe_carry ; mask it - sta fe_loop ; temp - lda (fe_src1),y + ; --- Byte at Y --- +@ld_a1: lda $ffff,y ; a[y] (patched) + tax ; X = a[y] +@eor_b1:eor $ffff,y ; a[y] ^ b[y] (patched) + and fe_carry ; diff + sta fe_loop ; save diff + txa ; A = a[y] + eor fe_loop ; a[y] ^ diff +@st_a1: sta $ffff,y ; store new a[y] (patched) +@ld_b1: lda $ffff,y ; b[y] (patched) + eor fe_loop ; b[y] ^ diff +@st_b1: sta $ffff,y ; store new b[y] (patched) + + dey + + ; --- Byte at Y --- +@ld_a2: lda $ffff,y + tax +@eor_b2:eor $ffff,y + and fe_carry + sta fe_loop + txa eor fe_loop - sta (fe_src1),y - lda (fe_src2),y +@st_a2: sta $ffff,y +@ld_b2: lda $ffff,y + eor fe_loop +@st_b2: sta $ffff,y + + dey + + ; --- Byte at Y --- +@ld_a3: lda $ffff,y + tax +@eor_b3:eor $ffff,y + and fe_carry + sta fe_loop + txa + eor fe_loop +@st_a3: sta $ffff,y +@ld_b3: lda $ffff,y eor fe_loop - sta (fe_src2),y +@st_b3: sta $ffff,y + + dey + + ; --- Byte at Y --- +@ld_a4: lda $ffff,y + tax +@eor_b4:eor $ffff,y + and fe_carry + sta fe_loop + txa + eor fe_loop +@st_a4: sta $ffff,y +@ld_b4: lda $ffff,y + eor fe_loop +@st_b4: sta $ffff,y + dey bpl @loop rts @@ -197,8 +313,11 @@ fe_cswap: ; ============================================================================= ; fe_mul - (fe_dst) = (fe_src1) * (fe_src2) mod p ; -; Schoolbook 32x32->64-byte multiply using mul_8x8 (quarter-square table). -; Then reduce mod p. +; Combined REU DMA table lookup + 2x inner loop unroll. +; Each outer iteration: DMA fetches 512-byte mul row for src1[i], +; then inner loop does direct table lookup (mul_dma_lo/hi,Y) instead of +; mult66 quarter-square. Inner loop unrolled 2x to reduce branch overhead. +; ; Clobbers: A, X, Y ; ============================================================================= fe_mul: @@ -210,62 +329,163 @@ fe_mul: dex bpl @zero_wide - ; 2. Schoolbook multiply: src1[i] * src2[j] + ; 2. Copy src2 to absolute buffer (needed for indexed access) + ldy #31 +@copy_src2: + lda (fe_src2),y + sta mul_src2_buf,y + dey + bpl @copy_src2 + + ; 3. Schoolbook multiply with REU DMA lookup + self-mod accumulation lda #0 sta fe_mul_i @mul_outer: ldy fe_mul_i lda (fe_src1),y - beq @skip_zero ; skip if src1[i] == 0 + bne @nonzero_i + jmp @skip_zero +@nonzero_i: + sta mul_cached_a ; cache src1[i] for inner loop + + ; DMA the multiplication row for src1[i] from REU + jsr reu_fetch_mul_row + + ; Self-mod: patch accumulation addresses to base = fe_wide + i + ; Patch BOTH copies of the unrolled inner loop + lda #fe_wide + adc #0 ; handle page crossing + sta @accum_ld1+2 + sta @accum_st1+2 + sta @accum_ld1_b+2 + sta @accum_st1_b+2 + ; For +1 accesses (high byte of product), base is fe_wide + i + 1 + lda #<(fe_wide+1) + clc + adc fe_mul_i + sta @accum_ld2+1 + sta @accum_st2+1 + sta @accum_ld2_b+1 + sta @accum_st2_b+1 + lda #>(fe_wide+1) + adc #0 + sta @accum_ld2+2 + sta @accum_st2+2 + sta @accum_ld2_b+2 + sta @accum_st2_b+2 lda #0 sta fe_mul_j + + ; ===== UNROLLED 2x INNER LOOP ===== + ; First copy processes j, second copy processes j+1 + ; Loop exit check only after second copy + @mul_inner: - ldy fe_mul_i - lda (fe_src1),y ; A = src1[i] - pha - ldy fe_mul_j - lda (fe_src2),y ; A = src2[j] - beq @skip_j_zero ; skip if zero - tax ; X = src2[j] - pla ; A = src1[i] - jsr mul_8x8 ; poly_prod_lo/hi = result + ; --- First copy: process src2[j] --- + ldx fe_mul_j + ldy mul_src2_buf,x ; Y = src2[j] + beq @next_j_first ; skip if zero + + ; --- REU table lookup: mul_cached_a * Y --- + lda mul_dma_lo,y ; lo byte of product (4 cycles) + sta poly_prod_lo + lda mul_dma_hi,y ; hi byte of product (4 cycles) + sta poly_prod_hi ; Add 16-bit product to fe_wide[i+j] + ldx fe_mul_j + + clc +@accum_ld1: + lda fe_wide,x ; patched to fe_wide+i base + adc poly_prod_lo +@accum_st1: + sta fe_wide,x +@accum_ld2: + lda fe_wide+1,x ; patched to fe_wide+i+1 base + adc poly_prod_hi +@accum_st2: + sta fe_wide+1,x + bcc @next_j_first + + ; Propagate carry (rare path) lda fe_mul_i clc adc fe_mul_j - tax ; X = i+j - clc + adc #2 + tax +@prop_carry_a: + cpx #64 + bcs @next_j_first + sec lda fe_wide,x - adc poly_prod_lo + adc #0 sta fe_wide,x inx - lda fe_wide,x - adc poly_prod_hi + bcs @prop_carry_a + +@next_j_first: + inc fe_mul_j ; advance j, no exit check + + ; --- Second copy: process src2[j+1] --- + ldx fe_mul_j + ldy mul_src2_buf,x ; Y = src2[j] + beq @next_j ; skip if zero + + ; --- REU table lookup: mul_cached_a * Y --- + lda mul_dma_lo,y ; lo byte of product (4 cycles) + sta poly_prod_lo + lda mul_dma_hi,y ; hi byte of product (4 cycles) + sta poly_prod_hi + + ; Add 16-bit product to fe_wide[i+j] + ldx fe_mul_j + + clc +@accum_ld1_b: + lda fe_wide,x ; patched to fe_wide+i base + adc poly_prod_lo +@accum_st1_b: sta fe_wide,x +@accum_ld2_b: + lda fe_wide+1,x ; patched to fe_wide+i+1 base + adc poly_prod_hi +@accum_st2_b: + sta fe_wide+1,x bcc @next_j - ; Propagate carry -@prop_carry: - inx + ; Propagate carry (rare path) + lda fe_mul_i + clc + adc fe_mul_j + clc + adc #2 + tax +@prop_carry_b: cpx #64 bcs @next_j sec lda fe_wide,x adc #0 sta fe_wide,x - bcs @prop_carry - jmp @next_j + inx + bcs @prop_carry_b -@skip_j_zero: - pla ; discard src1[i] @next_j: inc fe_mul_j lda fe_mul_j cmp #32 - bcc @mul_inner + bcs @skip_zero + jmp @mul_inner @skip_zero: inc fe_mul_i @@ -275,7 +495,7 @@ fe_mul: jmp @mul_outer @mul_done: - ; 3. Reduce mod p + ; 4. Reduce mod p jsr fe_reduce_wide ; Copy result to (fe_dst) @@ -293,6 +513,7 @@ fe_mul: ; fe_reduce_wide - Reduce fe_wide[0..63] mod p into fe_wide[0..31] ; ; fe_wide[32..63] * 38 + fe_wide[0..31], with second pass for overflow. +; Uses mul38 lookup tables for speed. ; Clobbers: A, X, Y ; ============================================================================= fe_reduce_wide: @@ -301,13 +522,14 @@ fe_reduce_wide: sta fe_carry ldx #0 @reduce1: - lda fe_wide+32,x + ldy fe_wide+32,x ; Y = byte value (table index) beq @reduce1_zero - stx fe_loop ; save byte index - ldx #38 - jsr mul_8x8 ; poly_prod_lo/hi = byte * 38 - ldx fe_loop ; restore byte index + ; Table lookup: Y * 38 + lda mul38_lo_tab,y + sta poly_prod_lo + lda mul38_hi_tab,y + sta poly_prod_hi ; Add product + running carry to fe_wide[x] clc @@ -348,8 +570,11 @@ fe_reduce_wide: ; If carry remains, multiply by 38 and add to bottom lda fe_carry beq @done - ldx #38 - jsr mul_8x8 + tay ; Y = carry value + lda mul38_lo_tab,y + sta poly_prod_lo + lda mul38_hi_tab,y + sta poly_prod_hi clc lda fe_wide @@ -387,16 +612,312 @@ fe_reduce_wide: @done: rts +; ============================================================================= +; mul_by_38 - Multiply A by 38, result in poly_prod_hi:poly_prod_lo +; +; Uses shift-and-add: 38 = 32 + 4 + 2 +; Input: A = multiplicand (0-255) +; Output: poly_prod_lo/poly_prod_hi = A * 38 (16-bit, max 9690=$25DA) +; Clobbers: A, Y +; Preserves: X +; ============================================================================= +mul_by_38: + sta mul38_in ; save input + ; 16-bit shift register starts as A + lda mul38_in + sta mul38_lo + lda #0 + sta mul38_hi + + ; shift left 1 -> A*2, add to prod + asl mul38_lo + rol mul38_hi + lda mul38_lo + sta poly_prod_lo + lda mul38_hi + sta poly_prod_hi ; prod = A*2 + + ; shift left 1 more -> A*4, add to prod + asl mul38_lo + rol mul38_hi ; mul38 = A*4 + clc + lda poly_prod_lo + adc mul38_lo + sta poly_prod_lo + lda poly_prod_hi + adc mul38_hi + sta poly_prod_hi ; prod = A*2 + A*4 = A*6 + + ; shift left 3 more -> A*32, add to prod + asl mul38_lo + rol mul38_hi ; A*8 + asl mul38_lo + rol mul38_hi ; A*16 + asl mul38_lo + rol mul38_hi ; A*32 + clc + lda poly_prod_lo + adc mul38_lo + sta poly_prod_lo + lda poly_prod_hi + adc mul38_hi + sta poly_prod_hi ; prod = A*6 + A*32 = A*38 + rts + +mul38_in: !byte 0 +mul38_lo: !byte 0 +mul38_hi: !byte 0 + ; ============================================================================= ; fe_sqr - (fe_dst) = (fe_src1)^2 mod p +; +; Dedicated squaring: exploits symmetry a[i]*a[j] = a[j]*a[i]. +; Uses mult66 indirect-indexed multiply + self-modifying accumulation +; (same technique as fe_mul). Cross terms added twice to fuse doubling. +; 1. Cross terms: accumulate 2*a[i]*a[j] for i < j (inline mult66, shift-before-accum) +; 2. Diagonal: add a[i]^2 at position 2*i (inline mult66) +; 3. Reduce mod p +; ; Clobbers: A, X, Y ; ============================================================================= fe_sqr: - lda fe_src1 - sta fe_src2 - lda fe_src1+1 - sta fe_src2+1 - jmp fe_mul + ; 1. Zero the 64-byte product buffer + ldx #63 + lda #0 +@zero_wide: + sta fe_wide,x + dex + bpl @zero_wide + + ; 2. Copy src1 to absolute buffer (src1==src2 for squaring) + ldy #31 +@copy_src: + lda (fe_src1),y + sta mul_src2_buf,y + dey + bpl @copy_src + + ; 3. Set up ZP pointers for mult66 indirect-indexed multiply + lda #>sqtab_lo + sta lmul0+1 + lda #>sqtab_hi + sta lmul1+1 + + ; 4. Cross terms with mult66 + self-mod, shift-before-accumulate + lda #0 + sta fe_mul_i +@sqr_outer: + ldy fe_mul_i + lda (fe_src1),y + bne @sqr_nonzero_i + jmp @sqr_skip_i +@sqr_nonzero_i: + sta mul_cached_a ; cache a[i] for inner loop + + ; Self-mod: patch accumulation addresses to base = fe_wide + i + lda #fe_wide + adc #0 ; handle page crossing + sta @sqr_accum_ld1+2 + sta @sqr_accum_st1+2 + ; For +1 accesses (high byte of product) + lda #<(fe_wide+1) + clc + adc fe_mul_i + sta @sqr_accum_ld2+1 + sta @sqr_accum_st2+1 + lda #>(fe_wide+1) + adc #0 + sta @sqr_accum_ld2+2 + sta @sqr_accum_st2+2 + + ; Set up ZP pointer low byte = a[i] once per outer loop + lda mul_cached_a + sta lmul0 ; lmul0 = sqtab_lo + a[i] + sta lmul1 ; lmul1 = sqtab_hi + a[i] + + ; j starts at i+1 + lda fe_mul_i + clc + adc #1 + sta fe_mul_j + +@sqr_inner: + ldx fe_mul_j + ldy mul_src2_buf,x ; Y = a[j] + bne @sqr_nonzero_j ; skip if zero + jmp @sqr_next_j +@sqr_nonzero_j: + + ; --- mult66 inline: a[i] * a[j] --- + tya ; A = a[j] + sec + sbc mul_cached_a ; A = a[j] - a[i] + tax ; X = difference (or wrapped) + + ; (lmul0),Y = sqtab_lo[a[i] + a[j]] + lda (lmul0),y + bcc @sqr_neg_diff ; branch if a[j] < a[i] + + ; Positive difference path (carry SET): + sbc sqtab_lo,x + sta poly_prod_lo + lda (lmul1),y + sbc sqtab_hi,x + sta poly_prod_hi + jmp @sqr_accum + +@sqr_neg_diff: + ; Negative difference path (carry CLEAR): + sbc sqtab2_lo,x + sta poly_prod_lo + lda (lmul1),y + sbc sqtab2_hi,x + sta poly_prod_hi + ; --- END mult66 --- + +@sqr_accum: + ; Double the product (shift-before-accumulate replaces second addition) + asl poly_prod_lo + rol poly_prod_hi + lda #0 + adc #0 ; A = carry from ROL (0 or 1) + sta poly_carry ; save 17th bit + + ; Single addition of doubled product to fe_wide[i+j] + ldx fe_mul_j + + clc +@sqr_accum_ld1: + lda fe_wide,x ; patched to fe_wide+i base + adc poly_prod_lo +@sqr_accum_st1: + sta fe_wide,x +@sqr_accum_ld2: + lda fe_wide+1,x ; patched to fe_wide+i+1 base + adc poly_prod_hi +@sqr_accum_st2: + sta fe_wide+1,x + + ; Capture accumulation carry and combine with shift carry + lda #0 + adc poly_carry ; A = accum_carry + shift_carry (0, 1, or 2) + beq @sqr_next_j ; if both zero, skip + + ; Add combined carries to fe_wide[i+j+2] + ldx fe_mul_i + tay ; Y = combined carry value + txa + clc + adc fe_mul_j + clc + adc #2 + tax + tya ; A = combined carry value + clc + adc fe_wide,x + sta fe_wide,x + bcc @sqr_next_j + ; Propagate further carries +@sqr_prop1: + inx + cpx #64 + bcs @sqr_next_j + sec + lda fe_wide,x + adc #0 + sta fe_wide,x + bcs @sqr_prop1 + +@sqr_next_j: + inc fe_mul_j + lda fe_mul_j + cmp #32 + bcs @sqr_skip_i + jmp @sqr_inner + +@sqr_skip_i: + inc fe_mul_i + lda fe_mul_i + cmp #31 ; i goes 0..30 (j needs room for i+1) + bcs @sqr_cross_done + jmp @sqr_outer +@sqr_cross_done: + + ; 5. Add diagonal terms: a[i]^2 at position 2*i (inline mult66) + ; For self-multiply: diff=0, sqtab[0]=0, so result = sqtab[2*a[i]] + ; With lmul0 = a[i], Y = a[i]: (lmul0),Y = sqtab[2*a[i]] + lda #0 + sta fe_mul_i +@diag_outer: + ldy fe_mul_i + lda (fe_src1),y + beq @diag_skip ; skip if a[i] == 0 + + ; Set up mult66 pointers for self-multiply + sta lmul0 ; lmul0 low = a[i] + sta lmul1 ; lmul1 low = a[i] + tay ; Y = a[i] + + ; (lmul0),Y = sqtab_lo[a[i] + a[i]] = sqtab_lo[2*a[i]] + ; (lmul1),Y = sqtab_hi[a[i] + a[i]] = sqtab_hi[2*a[i]] + ; diff = 0, sqtab[0] = 0, no subtraction needed + lda (lmul0),y ; lo byte of a[i]^2 + sta poly_prod_lo + lda (lmul1),y ; hi byte of a[i]^2 + sta poly_prod_hi + + ; Add to fe_wide[2*i] + lda fe_mul_i + asl ; A = 2*i + tax + + clc + lda fe_wide,x + adc poly_prod_lo + sta fe_wide,x + inx + lda fe_wide,x + adc poly_prod_hi + sta fe_wide,x + bcc @diag_skip + + ; Propagate carry +@diag_prop: + inx + cpx #64 + bcs @diag_skip + sec + lda fe_wide,x + adc #0 + sta fe_wide,x + bcs @diag_prop + +@diag_skip: + inc fe_mul_i + lda fe_mul_i + cmp #32 + bcs @sqr_reduce + jmp @diag_outer + +@sqr_reduce: + ; 6. Reduce mod p (same as fe_mul) + jsr fe_reduce_wide + + ; Copy result to (fe_dst) + ldy #31 +@copy_result: + lda fe_wide,y + sta (fe_dst),y + dey + bpl @copy_result + + jsr fe_reduce_final + rts ; ============================================================================= ; fe_mul_a24 - (fe_dst) = (fe_src1) * 121665 mod p @@ -421,7 +942,7 @@ fe_mul_a24: lda (fe_src1),y beq @skip_zero_a24 - ; src1[i] * $41 -> add at offset i + ; src1[i] * $41 → add at offset i ldx #$41 jsr mul_8x8 ldx fe_mul_i @@ -437,7 +958,7 @@ fe_mul_a24: bne + inc fe_wide+3,x + - ; src1[i] * $DB -> add at offset i+1 + ; src1[i] * $DB → add at offset i+1 ldy fe_mul_i lda (fe_src1),y ldx #$db @@ -455,7 +976,7 @@ fe_mul_a24: bne + inc fe_wide+4,x + - ; src1[i] * $01 -> add at offset i+2 + ; src1[i] * $01 → add at offset i+2 ldy fe_mul_i lda (fe_src1),y ldx fe_mul_i @@ -473,11 +994,10 @@ fe_mul_a24: cpx #32 bcc @outer - ; Reduce: fe_wide[32..34] * 38 -> add to fe_wide[0..31] + ; Reduce: fe_wide[32..34] * 38 → add to fe_wide[0..31] lda fe_wide+32 beq @r_b33 - ldx #38 - jsr mul_8x8 + jsr mul_by_38 clc lda fe_wide adc poly_prod_lo @@ -497,8 +1017,7 @@ fe_mul_a24: @r_b33: lda fe_wide+33 beq @r_b34 - ldx #38 - jsr mul_8x8 + jsr mul_by_38 clc lda fe_wide+1 adc poly_prod_lo @@ -518,8 +1037,7 @@ fe_mul_a24: @r_b34: lda fe_wide+34 beq @r_done_a24 - ldx #38 - jsr mul_8x8 + jsr mul_by_38 clc lda fe_wide+2 adc poly_prod_lo @@ -581,7 +1099,7 @@ fe_inv: sta fe_dst+1 jsr fe_copy ; fe_tmp1 = z - ; --- z2 = z^2 -> fe_tmp2 --- + ; --- z2 = z^2 → fe_tmp2 --- lda #fe_tmp1 @@ -592,7 +1110,7 @@ fe_inv: sta fe_dst+1 jsr fe_sqr ; fe_tmp2 = z^2 - ; --- z4 = z2^2 -> fe_tmp3 --- + ; --- z4 = z2^2 → fe_tmp3 --- lda #fe_tmp2 @@ -603,7 +1121,7 @@ fe_inv: sta fe_dst+1 jsr fe_sqr ; fe_tmp3 = z^4 - ; --- z8 = z4^2 -> fe_tmp3 --- + ; --- z8 = z4^2 → fe_tmp3 --- lda #fe_tmp3 @@ -614,7 +1132,7 @@ fe_inv: sta fe_dst+1 jsr fe_sqr ; fe_tmp3 = z^8 - ; --- z9 = z8 * z -> fe_tmp3 --- + ; --- z9 = z8 * z → fe_tmp3 --- lda #fe_tmp3 @@ -629,7 +1147,7 @@ fe_inv: sta fe_dst+1 jsr fe_mul ; fe_tmp3 = z^9 - ; --- z11 = z9 * z2 -> x25_a (saved for final step) --- + ; --- z11 = z9 * z2 → x25_a (saved for final step) --- lda #fe_tmp3 @@ -644,7 +1162,7 @@ fe_inv: sta fe_dst+1 jsr fe_mul ; x25_a = z^11 - ; --- z22 = z11^2 -> fe_tmp2 --- + ; --- z22 = z11^2 → fe_tmp2 --- lda #x25_a @@ -655,7 +1173,7 @@ fe_inv: sta fe_dst+1 jsr fe_sqr ; fe_tmp2 = z^22 - ; --- z_5_0 = z22 * z9 = z^31 -> fe_tmp2 --- + ; --- z_5_0 = z22 * z9 = z^31 → fe_tmp2 --- lda #fe_tmp2 @@ -684,7 +1202,7 @@ fe_inv: lda #5 jsr fe_inv_sqrn_tmp2 ; fe_tmp2 = z_5_0^(2^5) - ; --- z_10_0 = fe_tmp2 * fe_tmp3 -> x25_b (saved) --- + ; --- z_10_0 = fe_tmp2 * fe_tmp3 → x25_b (saved) --- lda #fe_tmp2 @@ -755,7 +1273,7 @@ fe_inv: sta fe_dst+1 jsr fe_mul ; fe_tmp2 = z^(2^40-1) - ; --- z_50_0: square 10x, multiply with z_10_0 -> x25_da (saved) --- + ; --- z_50_0: square 10x, multiply with z_10_0 → x25_da (saved) --- lda #10 jsr fe_inv_sqrn_tmp2 ; fe_tmp2 = z_40_0^(2^10) @@ -773,7 +1291,7 @@ fe_inv: sta fe_dst+1 jsr fe_mul ; x25_da = z^(2^50-1) - ; --- z_100_0: copy z_50_0 to tmp2, square 50x, multiply -> x25_cb (saved) --- + ; --- z_100_0: copy z_50_0 to tmp2, square 50x, multiply → x25_cb (saved) --- lda #x25_da diff --git a/src/crypto/x25519.asm b/src/crypto/x25519.asm index 130785b..41a2a46 100644 --- a/src/crypto/x25519.asm +++ b/src/crypto/x25519.asm @@ -4,6 +4,10 @@ ; Montgomery ladder scalar multiplication on Curve25519. ; Uses fe25519.asm field arithmetic. ; +; Optimized version imported from c64-x25519 project: +; - Streamlined bit extraction (single read of scalar byte, no double-read) +; - RFC 7748 u-coordinate high-bit masking in scalarmult +; ; API: ; x25519_clamp - Clamp 32-byte scalar per RFC 7748 ; x25519_scalarmult - Montgomery ladder: result = scalar * u-point @@ -14,12 +18,10 @@ ; ; ZP equates (x25_prev_bit, x25_byte_idx, x25_bit_mask) in constants.asm. ; Data labels (x25_scalar, x25_u, x25_result, etc.) in data.asm. -; -; Adapted from c64-wireguard for c64-https TLS 1.3 ECDH. ; ============================================================================= ; ============================================================================= -; x25519_clamp - Clamp scalar per RFC 7748 S5 +; x25519_clamp - Clamp scalar per RFC 7748 §5 ; ; Clear bits 0, 1, 2 of byte 0 ; Clear bit 7 of byte 31 @@ -71,7 +73,10 @@ x25519_scalarmult: sta fe_dst+1 jsr fe_zero - ; x_3 = u + ; x_3 = u (mask high bit per RFC 7748 decodeUCoordinate) + lda x25_u+31 + and #$7f + sta x25_u+31 lda #x25_u @@ -102,7 +107,7 @@ x25519_scalarmult: sta x25_bit_mask @bit_loop: - ; Get current bit k_t + ; Get current bit k_t (single extraction) ldx x25_byte_idx lda x25_scalar,x and x25_bit_mask @@ -110,20 +115,11 @@ x25519_scalarmult: lda #1 @bit_zero: ; A = k_t (0 or 1) - ; swap = k_t XOR prev_bit - eor x25_prev_bit - ; Save k_t for next iteration - pha - ldx x25_byte_idx - lda x25_scalar,x - and x25_bit_mask - beq @save_zero - lda #1 -@save_zero: - sta x25_prev_bit - pla ; A = swap flag (0 or 1) + tax ; X = k_t (save for prev_bit update) + eor x25_prev_bit ; A = swap = k_t XOR old prev_bit + stx x25_prev_bit ; update prev_bit = k_t - ; Convert to mask: 0 -> $00, 1 -> $FF + ; Convert to mask: 0 → $00, 1 → $FF beq @no_swap_mask lda #$ff @no_swap_mask: @@ -248,7 +244,7 @@ x25519_scalarmult: ; Clobbers: A, X, Y, all fe_* ZP vars ; ============================================================================= x25519_ladder_step: - ; A = x_2 + z_2 -> x25_a + ; A = x_2 + z_2 → x25_a lda #x25_x2 @@ -263,22 +259,15 @@ x25519_ladder_step: sta fe_dst+1 jsr fe_add - ; B = x_2 - z_2 -> x25_b - lda #x25_x2 - sta fe_src1+1 - lda #x25_z2 - sta fe_src2+1 + ; B = x_2 - z_2 → x25_b + ; fe_src1=x25_x2, fe_src2=x25_z2 still set from fe_add above lda #x25_b sta fe_dst+1 jsr fe_sub - ; AA = A^2 -> fe_tmp3 + ; AA = A^2 → fe_tmp3 lda #x25_a @@ -289,7 +278,7 @@ x25519_ladder_step: sta fe_dst+1 jsr fe_sqr ; fe_tmp3 = AA - ; BB = B^2 -> fe_tmp4 + ; BB = B^2 → fe_tmp4 lda #x25_b @@ -300,7 +289,7 @@ x25519_ladder_step: sta fe_dst+1 jsr fe_sqr ; fe_tmp4 = BB - ; E = AA - BB -> x25_e + ; E = AA - BB → x25_e lda #fe_tmp3 @@ -315,7 +304,7 @@ x25519_ladder_step: sta fe_dst+1 jsr fe_sub ; x25_e = E = AA - BB - ; C = x_3 + z_3 -> fe_tmp1 (temp) + ; C = x_3 + z_3 → fe_tmp1 (temp) lda #x25_x3 @@ -330,22 +319,15 @@ x25519_ladder_step: sta fe_dst+1 jsr fe_add ; fe_tmp1 = C - ; D = x_3 - z_3 -> fe_tmp2 (temp) - lda #x25_x3 - sta fe_src1+1 - lda #x25_z3 - sta fe_src2+1 + ; D = x_3 - z_3 → fe_tmp2 (temp) + ; fe_src1=x25_x3, fe_src2=x25_z3 still set from fe_add above lda #fe_tmp2 sta fe_dst+1 jsr fe_sub ; fe_tmp2 = D - ; DA = D * A -> x25_da + ; DA = D * A → x25_da lda #fe_tmp2 @@ -360,7 +342,7 @@ x25519_ladder_step: sta fe_dst+1 jsr fe_mul ; x25_da = D * A - ; CB = C * B -> x25_cb + ; CB = C * B → x25_cb lda #fe_tmp1 @@ -389,14 +371,11 @@ x25519_ladder_step: lda #>x25_x3 sta fe_dst+1 jsr fe_add ; x25_x3 = DA + CB + ; fe_dst=x25_x3 still set; copy to fe_src1 for squaring lda #x25_x3 sta fe_src1+1 - lda #x25_x3 - sta fe_dst+1 jsr fe_sqr ; x25_x3 = (DA + CB)^2 ; z_3 = x_1 * (DA - CB)^2 @@ -414,16 +393,14 @@ x25519_ladder_step: lda #>x25_z3 sta fe_dst+1 jsr fe_sub ; x25_z3 = DA - CB + ; fe_dst=x25_z3 still set; copy to fe_src1 for squaring lda #x25_z3 sta fe_src1+1 - lda #x25_z3 - sta fe_dst+1 jsr fe_sqr ; x25_z3 = (DA - CB)^2 ; Now z_3 = x_1 * (DA-CB)^2 + ; fe_dst=x25_z3 still set from fe_sqr above lda #x25_u @@ -432,10 +409,6 @@ x25519_ladder_step: sta fe_src2 lda #>x25_z3 sta fe_src2+1 - lda #x25_z3 - sta fe_dst+1 jsr fe_mul ; x25_z3 = x_1 * (DA - CB)^2 ; x_2 = AA * BB @@ -454,7 +427,7 @@ x25519_ladder_step: jsr fe_mul ; x25_x2 = AA * BB ; z_2 = E * (AA + a24*E) - ; First: a24*E -> fe_tmp1 + ; First: a24*E → fe_tmp1 lda #x25_e @@ -465,7 +438,8 @@ x25519_ladder_step: sta fe_dst+1 jsr fe_mul_a24 ; fe_tmp1 = a24 * E - ; AA + a24*E -> fe_tmp1 + ; AA + a24*E → fe_tmp1 + ; fe_dst=fe_tmp1 still set from fe_mul_a24 above lda #fe_tmp3 @@ -474,21 +448,14 @@ x25519_ladder_step: sta fe_src2 lda #>fe_tmp1 sta fe_src2+1 - lda #fe_tmp1 - sta fe_dst+1 jsr fe_add ; fe_tmp1 = AA + a24*E ; z_2 = E * (AA + a24*E) + ; fe_src2=fe_tmp1 still set from fe_add above lda #x25_e sta fe_src1+1 - lda #fe_tmp1 - sta fe_src2+1 lda #x25_z2 diff --git a/src/data.asm b/src/data.asm index 2f95a7d..8f4067b 100644 --- a/src/data.asm +++ b/src/data.asm @@ -7,6 +7,45 @@ ; ============================================================================= zp_save_buf: !fill 26, 0 ; saves $02-$1B during ip65 calls +; ============================================================================= +; fe25519/x25519 optimization tables — MUST live below $A000 to avoid +; BASIC ROM shadow. REU DMA and CPU reads need direct RAM access. +; Placed here (early in data section) to guarantee addresses < $A000. +; ============================================================================= + +; --- REU DMA target buffers (page-aligned for LDA abs,Y without penalty) --- + !align 255, 0 ; align to next page boundary +mul_dma_lo: + !fill 256, 0 ; DMA target: lo bytes of a*b for current a +mul_dma_hi: + !fill 256, 0 ; DMA target: hi bytes of a*b for current a + +; --- mult66 second quarter-square table --- +sqtab2_lo: + !byte 0 + !for i, 1, 255 { + !byte <(((256-i)*(256-i))/4 - 1) + } + +sqtab2_hi: + !byte 0 + !for i, 1, 255 { + !byte >(((256-i)*(256-i))/4 - 1) + } + +; --- mul_by_38 lookup tables --- +mul38_lo_tab: + !byte 0 + !for i, 1, 255 { + !byte <(i * 38) + } + +mul38_hi_tab: + !byte 0 + !for i, 1, 255 { + !byte >(i * 38) + } + ; ============================================================================= ; Network layer buffers ; ============================================================================= @@ -230,6 +269,12 @@ x25_basepoint: !byte 9 !fill 31, 0 +; --- fe_mul optimization buffers (from c64-x25519 optimizations) --- +mul_cached_a: + !byte 0 ; cached src1[i] for inlined multiply +mul_src2_buf: + !fill 32, 0 ; absolute copy of src2 for fast indexed access + ; ============================================================================= ; ECDSA signature verification (moved from ecdsa_verify.asm to avoid ; $7800-$7BFF sqtab memory collision) diff --git a/src/http.asm b/src/http.asm index c9e2ede..40e7f23 100644 --- a/src/http.asm +++ b/src/http.asm @@ -16,43 +16,139 @@ ; Output: C=0 success (response in http_resp_buf), C=1 failure ; ============================================================================= http_get: - ; 1. DNS resolve hostname - ; jsr net_dns_resolve - ; bcs @error + ; --- 1. DNS resolve hostname --- + lda http_host_ptr + ldx http_host_ptr+1 + jsr net_dns_resolve + bcc @dns_ok + jmp @error +@dns_ok: + + ; --- 2. Set TCP destination IP --- + lda #ip65_dns_ip_addr + jsr net_set_tcp_dest + + ; --- 3. TCP connect on http_port --- + lda http_port + ldx http_port+1 + jsr net_tcp_connect + bcc @tcp_ok + jmp @error +@tcp_ok: - ; 2. TCP connect to resolved IP on port 443 - ; jsr net_tcp_connect - ; bcs @error + ; --- 4. Copy hostname to tls_hostname for SNI --- + lda http_host_ptr + sta zp_ptr + lda http_host_ptr+1 + sta zp_ptr+1 + ldy #0 +@copy_host: + cpy http_host_len + beq @copy_host_done + lda (zp_ptr),y + sta tls_hostname,y + iny + bne @copy_host ; always branches (hostname < 256) +@copy_host_done: + lda #0 + sta tls_hostname,y ; null-terminate + sty tls_hostname_len - ; 3. TLS handshake - ; jsr tls_connect - ; bcs @error + ; --- 5. TLS handshake --- + jsr tls_connect + bcc @tls_ok + jmp @tls_error +@tls_ok: - ; 4. Build GET request + ; --- 6. Build HTTP GET request --- jsr http_build_get - ; bcs @error - ; 5. Send via TLS - ; lda #http_req_buf - ; ... set length ... - ; jsr tls_send - ; bcs @error + ; --- 7. Send request via TLS --- + lda #http_req_buf + sta tls_app_ptr+1 + lda http_req_len + sta tls_app_len + lda http_req_len+1 + sta tls_app_len+1 + jsr tls_send + bcs @close_error - ; 6. Receive response via TLS - ; jsr http_recv_response - ; bcs @error + ; --- 8. Receive response via TLS --- + ; Initialise parser state + lda #0 + sta http_parse_state + sta http_line_idx + sta http_hdr_match + sta http_resp_len + sta http_resp_len+1 - ; 7. Close TLS + TCP - ; jsr tls_close + ; Poll + receive loop + lda #0 + sta @recv_timeout + sta @recv_timeout+1 +@recv_loop: + jsr net_poll + jsr tls_recv + bcs @recv_no_data + ; Got decrypted data in tls_app_ptr / tls_app_len + ; Copy tls_app_ptr to ZP for indirect addressing + lda tls_app_ptr + sta zp_ptr + lda tls_app_ptr+1 + sta zp_ptr+1 + + ; Feed decrypted bytes into the TCP ring buffer + ldy #0 +@feed_loop: + cpy tls_app_len ; low byte only (TLS records < 256) + beq @feed_done + lda (zp_ptr),y + ldx tcp_recv_tail + sta tcp_recv_buf,x + inx + stx tcp_recv_tail + iny + bne @feed_loop ; always branches +@feed_done: + ; Parse from ring buffer + jsr http_recv_response + bcc @recv_complete ; C=0 means parsing complete + ; Reset timeout counter on progress + lda #0 + sta @recv_timeout + sta @recv_timeout+1 + jmp @recv_loop + +@recv_no_data: + inc @recv_timeout + bne @recv_loop + inc @recv_timeout+1 + bne @recv_loop + ; Timeout — accept whatever we have + +@recv_complete: + jsr tls_close + jsr net_tcp_close clc rts -; @error: -; jsr tls_close -; sec -; rts +@recv_timeout: !word 0 + +@tls_error: + jsr net_tcp_close +@error: + sec + rts + +@close_error: + jsr tls_close + jsr net_tcp_close + sec + rts ; ============================================================================= ; http_build_get - construct HTTP/1.1 GET request in http_req_buf diff --git a/tools/bench_x25519.py b/tools/bench_x25519.py new file mode 100644 index 0000000..26ce696 --- /dev/null +++ b/tools/bench_x25519.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""bench_x25519.py -- X25519 key generation benchmark on C64. + +Runs x25519_base (scalar * basepoint 9) on the C64 and measures +wall-clock and jiffy-clock time. Verifies result against RFC 7748. + +Usage: + python3 tools/bench_x25519.py [--no-verify] [--no-blank] +""" + +import os +import subprocess +import sys +import time + +from c64_test_harness import ( + Labels, ViceConfig, ViceInstanceManager, + read_bytes, write_bytes, jsr, wait_for_text, +) + +try: + from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey + from cryptography.hazmat.primitives.serialization import ( + Encoding, PublicFormat, + ) + HAS_CRYPTO = True +except ImportError: + HAS_CRYPTO = False + +PROJECT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") +PRG_PATH = os.path.join(PROJECT_ROOT, "build", "c64-https.prg") +LABELS_PATH = os.path.join(PROJECT_ROOT, "build", "labels.txt") + +NTSC_HZ = 60 +NTSC_CYCLES_PER_SEC = 1_022_727 + +# Trampoline and result storage in cassette buffer area +TRAMPOLINE_ADDR = 0x0360 +BENCH_TICKS_ADDR = 0x0350 # 3 bytes for jiffy clock snapshot + +# Test scalar for basepoint multiply (x25519_base clamps this internally) +BENCH_SCALAR = bytes.fromhex( + "a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4" +) + + +def compute_expected_pubkey(scalar_bytes): + """Compute expected public key = clamp(scalar) * basepoint(9) via Python.""" + if not HAS_CRYPTO: + return None + # X25519PrivateKey.from_private_bytes applies clamping internally + privkey = X25519PrivateKey.from_private_bytes(scalar_bytes) + pubkey_bytes = privkey.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + return pubkey_bytes + + +def build_trampoline(labels, blank=True): + """Build 6502 trampoline: zero jiffy, [blank VIC], jsr x25519_base, + snap jiffy, [unblank], rts.""" + code = bytearray() + + # SEI; zero jiffy clock ($A0-$A2, big-endian) + code += bytes([0x78]) # SEI + code += bytes([0xA9, 0x00]) # LDA #$00 + code += bytes([0x85, 0xA0]) # STA $A0 + code += bytes([0x85, 0xA1]) # STA $A1 + code += bytes([0x85, 0xA2]) # STA $A2 + code += bytes([0x58]) # CLI + + # Blank VIC-II (disable DEN bit 4 of $D011) for ~20-25% speedup + if blank: + code += bytes([0xAD, 0x11, 0xD0]) # LDA $D011 + code += bytes([0x29, 0xEF]) # AND #$EF + code += bytes([0x8D, 0x11, 0xD0]) # STA $D011 + + # JSR x25519_base + addr = labels["x25519_base"] + code += bytes([0x20, addr & 0xFF, addr >> 8]) + + # SEI; snapshot jiffy clock to BENCH_TICKS_ADDR + bt = BENCH_TICKS_ADDR + code += bytes([0x78]) # SEI + code += bytes([0xA5, 0xA0]) # LDA $A0 + code += bytes([0x8D, bt & 0xFF, bt >> 8]) # STA bench_ticks+0 + code += bytes([0xA5, 0xA1]) # LDA $A1 + code += bytes([0x8D, (bt+1) & 0xFF, (bt+1) >> 8]) # STA bench_ticks+1 + code += bytes([0xA5, 0xA2]) # LDA $A2 + code += bytes([0x8D, (bt+2) & 0xFF, (bt+2) >> 8]) # STA bench_ticks+2 + code += bytes([0x58]) # CLI + + # Unblank VIC-II + if blank: + code += bytes([0xAD, 0x11, 0xD0]) # LDA $D011 + code += bytes([0x09, 0x10]) # ORA #$10 + code += bytes([0x8D, 0x11, 0xD0]) # STA $D011 + + code += bytes([0x60]) # RTS + return bytes(code) + + +def jiffies_to_str(ticks): + secs = ticks / NTSC_HZ + if secs < 60: + return f"{ticks} jiffies ({secs:.1f}s)" + mins = secs / 60 + return f"{ticks} jiffies ({mins:.1f} min / {secs:.0f}s)" + + +def main(): + os.chdir(PROJECT_ROOT) + + verify = True + blank = True + for arg in sys.argv[1:]: + if arg == "--no-verify": + verify = False + elif arg == "--no-blank": + blank = False + + # Build + print("Building...") + result = subprocess.run(["make"], capture_output=True, text=True, + cwd=PROJECT_ROOT) + if result.returncode != 0: + print(f"Build failed:\n{result.stderr}") + sys.exit(1) + + labels = Labels.from_file(LABELS_PATH) + + for name in ["x25519_base", "x25_scalar", "x25_result"]: + if labels.address(name) is None: + print(f"FATAL: '{name}' label not found") + sys.exit(1) + + trampoline = build_trampoline(labels, blank=blank) + + config = ViceConfig(prg_path=PRG_PATH, warp=True, ntsc=True, sound=False, + extra_args=["-reu", "-reusize", "512"]) + + print(f"Trampoline: {len(trampoline)} bytes at ${TRAMPOLINE_ADDR:04X}") + print(f"VIC-II blanking: {'ON' if blank else 'OFF'}") + + with ViceInstanceManager(config=config) as mgr: + inst = mgr.acquire() + transport = inst.transport + print(f"VICE PID={inst.pid}, port={inst.port}") + + grid = wait_for_text(transport, "Q=QUIT", timeout=120.0, verbose=False) + if grid is None: + print("FATAL: Boot menu did not appear") + sys.exit(1) + + # Safety loop at $0339 + write_bytes(transport, 0x0339, bytes([0x4C, 0x39, 0x03])) + + # Compute expected result + expected = compute_expected_pubkey(BENCH_SCALAR) + + # Write scalar and trampoline + write_bytes(transport, labels["x25_scalar"], BENCH_SCALAR) + write_bytes(transport, TRAMPOLINE_ADDR, trampoline) + + print(f"\n{'='*60}") + print(f" X25519 key generation: scalar * basepoint(9)") + print(f" Scalar: {BENCH_SCALAR[:16].hex()}...") + print(f"{'='*60}") + print(f"\n Running... (expect ~2-5 min wall clock in warp mode)") + + wall_start = time.time() + jsr(transport, TRAMPOLINE_ADDR, timeout=7200.0) + wall_elapsed = time.time() - wall_start + + # Read jiffy ticks (3 bytes, big-endian) + ticks_data = read_bytes(transport, BENCH_TICKS_ADDR, 3) + ticks = (ticks_data[0] << 16) | (ticks_data[1] << 8) | ticks_data[2] + + # Read result + result_bytes = read_bytes(transport, labels["x25_result"], 32) + + c64_secs = ticks / NTSC_HZ + est_cycles = c64_secs * NTSC_CYCLES_PER_SEC + + print(f"\n--- Results ---") + print(f" Jiffy clock: {jiffies_to_str(ticks)}") + print(f" Wall clock: {wall_elapsed:.1f}s ({wall_elapsed/60:.1f} min)") + if wall_elapsed > 0: + print(f" Warp factor: {c64_secs/wall_elapsed:.1f}x") + print(f" Est. cycles: {est_cycles:,.0f}") + print(f" C64 real-time: {c64_secs:.0f}s ({c64_secs/60:.1f} min)") + + if verify: + if expected is None: + print(f" Correctness: SKIPPED (pip install cryptography)") + print(f" result: {result_bytes.hex()}") + elif result_bytes == expected: + print(f" Correctness: PASS (matches Python X25519)") + else: + print(f" Correctness: FAIL") + print(f" expected: {expected.hex()}") + print(f" got: {result_bytes.hex()}") + + mgr.release(inst) + + print("\nDone.") + + +if __name__ == "__main__": + main() diff --git a/tools/run_all_tests.py b/tools/run_all_tests.py index 783733c..cf5403f 100644 --- a/tools/run_all_tests.py +++ b/tools/run_all_tests.py @@ -86,6 +86,10 @@ def run_test_suite(name, transport, labels, seed): from test_x509 import run_tests as x509_run passed, failed = x509_run(transport, labels) + elif name == "x25519": + from test_x25519 import run_tests as x25519_run + passed, failed = x25519_run(transport, labels, seed=seed) + except Exception as e: import traceback print(f" [{name}] EXCEPTION: {e}") @@ -124,11 +128,13 @@ def main(): # Entropy uses manual breakpoints sensitive to CPU state, so start it early # on a fresh worker. Remaining fast suites fill in around them. suites = ["entropy", "net", "sha256", "crypto", "hkdf", - "keyschedule", "http", "tls_record", "tls_handshake"] + "keyschedule", "http", "tls_record", "tls_handshake", + "x25519"] if not skip_slow: suites.insert(0, "x509") - config = ViceConfig(prg_path=PRG_PATH, warp=True, ntsc=True, sound=False) + config = ViceConfig(prg_path=PRG_PATH, warp=True, ntsc=True, sound=False, + extra_args=["-reu", "-reusize", "512"]) num_instances = min(workers, len(suites)) print(f"\n=== Launching {len(suites)} suites across " diff --git a/tools/test_x25519.py b/tools/test_x25519.py new file mode 100644 index 0000000..0a4dd2a --- /dev/null +++ b/tools/test_x25519.py @@ -0,0 +1,743 @@ +#!/usr/bin/env python3 +"""test_x25519.py -- fe25519 field arithmetic and X25519 key exchange tests. + +Tests fe_add, fe_sub, fe_mul, fe_sqr, fe_inv, fe_cswap, fe_mul_a24, +fe_copy, fe_zero, fe_one, x25519_clamp, and (with --slow) x25519_scalarmult +against Python reference implementations and RFC 7748 test vectors. + +Uses the binary monitor test harness -- jsr() is event-based via +checkpoints, so no polling or retry wrappers are needed. + +Usage: + python3 tools/test_x25519.py [--seed S] [--verbose] [--slow] +""" + +import os +import random +import subprocess +import sys + +from c64_test_harness import ( + Labels, ViceConfig, ViceInstanceManager, + read_bytes, write_bytes, jsr, wait_for_text, +) + +PROJECT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") +PRG_PATH = os.path.join(PROJECT_ROOT, "build", "c64-https.prg") +LABELS_PATH = os.path.join(PROJECT_ROOT, "build", "labels.txt") + +VERBOSE = False +SLOW = False + +# p = 2^255 - 19 +P = (1 << 255) - 19 + + +# ============================================================================ +# Python reference implementations +# ============================================================================ + +def fe_add_ref(a, b): + return (a + b) % P + +def fe_sub_ref(a, b): + return (a - b) % P + +def fe_mul_ref(a, b): + return (a * b) % P + +def fe_sqr_ref(a): + return (a * a) % P + +def fe_inv_ref(a): + return pow(a, P - 2, P) + +def fe_mul_a24_ref(a): + return (a * 121665) % P + +def int_to_le32(val): + """Convert integer to 32-byte little-endian bytes.""" + return (val % P).to_bytes(32, "little") + +def le32_to_int(data): + """Convert 32-byte little-endian bytes to integer.""" + return int.from_bytes(data, "little") + +def rand_fe(rng): + """Generate a random field element in [0, p-1].""" + return rng.randint(0, P - 1) + +def clamp_ref(scalar): + """Clamp scalar per RFC 7748.""" + s = bytearray(scalar) + s[0] &= 0xF8 + s[31] = (s[31] & 0x7F) | 0x40 + return bytes(s) + + +# RFC 7748 Section 6.1 test vectors +SCALAR_1 = bytes.fromhex( + "a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4") +U_1 = bytes.fromhex( + "e6db6867583030db3594c1a424b15f7c726624ec26b3353b10a903a6d0ab1c4c") +EXPECTED_1 = bytes.fromhex( + "c3da55379de9c6908e94ea4df28d084f32eccf03491c71f754b4075577a28552") + +SCALAR_2 = bytes.fromhex( + "4b66e9d4d1b4673c5ad22691957d6af5c11b6421e0ea01d42ca4169e7918ba0d") +U_2 = bytes.fromhex( + "e5210f12786811d3f4b7959d0538ae2c31dbe7106fc03c3efc4cd549c715a493") +EXPECTED_2 = bytes.fromhex( + "95cbde9476e8907d7aade45cb4b873f88b595a68799fa152e6f8f7647aac7957") + + +# ============================================================================ +# C64 helper functions +# ============================================================================ + +def set_fe_ptrs(transport, labels, src1=None, src2=None, dst=None): + """Set fe_src1, fe_src2, fe_dst zero-page pointers.""" + if src1 is not None: + write_bytes(transport, labels["fe_src1"], + bytes([src1 & 0xFF, src1 >> 8])) + if src2 is not None: + write_bytes(transport, labels["fe_src2"], + bytes([src2 & 0xFF, src2 >> 8])) + if dst is not None: + write_bytes(transport, labels["fe_dst"], + bytes([dst & 0xFF, dst >> 8])) + + +def write_fe(transport, addr, val): + """Write a field element (integer) to C64 memory as 32-byte LE.""" + write_bytes(transport, addr, int_to_le32(val)) + + +def read_fe(transport, addr): + """Read a 32-byte LE field element from C64 memory, return as integer.""" + return le32_to_int(read_bytes(transport, addr, 32)) + + +def c64_fe_add(transport, labels, a, b): + """Compute a + b mod p on C64.""" + write_fe(transport, labels["fe_tmp1"], a) + write_fe(transport, labels["fe_tmp2"], b) + set_fe_ptrs(transport, labels, + src1=labels["fe_tmp1"], + src2=labels["fe_tmp2"], + dst=labels["fe_tmp3"]) + jsr(transport, labels["fe_add"]) + return read_fe(transport, labels["fe_tmp3"]) + + +def c64_fe_sub(transport, labels, a, b): + """Compute a - b mod p on C64.""" + write_fe(transport, labels["fe_tmp1"], a) + write_fe(transport, labels["fe_tmp2"], b) + set_fe_ptrs(transport, labels, + src1=labels["fe_tmp1"], + src2=labels["fe_tmp2"], + dst=labels["fe_tmp3"]) + jsr(transport, labels["fe_sub"]) + return read_fe(transport, labels["fe_tmp3"]) + + +def c64_fe_mul(transport, labels, a, b): + """Compute a * b mod p on C64.""" + write_fe(transport, labels["fe_tmp1"], a) + write_fe(transport, labels["fe_tmp2"], b) + set_fe_ptrs(transport, labels, + src1=labels["fe_tmp1"], + src2=labels["fe_tmp2"], + dst=labels["fe_tmp3"]) + jsr(transport, labels["fe_mul"], timeout=120.0) + return read_fe(transport, labels["fe_tmp3"]) + + +def c64_fe_sqr(transport, labels, a): + """Compute a^2 mod p on C64.""" + write_fe(transport, labels["fe_tmp1"], a) + set_fe_ptrs(transport, labels, + src1=labels["fe_tmp1"], + dst=labels["fe_tmp3"]) + jsr(transport, labels["fe_sqr"], timeout=120.0) + return read_fe(transport, labels["fe_tmp3"]) + + +def c64_fe_inv(transport, labels, a): + """Compute a^(p-2) mod p on C64.""" + write_fe(transport, labels["fe_tmp1"], a) + set_fe_ptrs(transport, labels, + src1=labels["fe_tmp1"], + dst=labels["fe_tmp3"]) + # fe_inv takes ~253 squarings + 11 muls -- very slow + jsr(transport, labels["fe_inv"], timeout=600.0) + return read_fe(transport, labels["fe_tmp3"]) + + +def c64_fe_mul_a24(transport, labels, a): + """Compute a * 121665 mod p on C64.""" + write_fe(transport, labels["fe_tmp1"], a) + set_fe_ptrs(transport, labels, + src1=labels["fe_tmp1"], + dst=labels["fe_tmp3"]) + jsr(transport, labels["fe_mul_a24"], timeout=60.0) + return read_fe(transport, labels["fe_tmp3"]) + + +def c64_fe_copy(transport, labels, a): + """Copy a field element via fe_copy.""" + write_fe(transport, labels["fe_tmp1"], a) + set_fe_ptrs(transport, labels, + src1=labels["fe_tmp1"], + dst=labels["fe_tmp3"]) + jsr(transport, labels["fe_copy"]) + return read_fe(transport, labels["fe_tmp3"]) + + +def c64_fe_zero(transport, labels): + """Zero a field element via fe_zero.""" + # Write nonzero first to prove it gets zeroed + write_fe(transport, labels["fe_tmp3"], P - 1) + set_fe_ptrs(transport, labels, dst=labels["fe_tmp3"]) + jsr(transport, labels["fe_zero"]) + return read_fe(transport, labels["fe_tmp3"]) + + +def c64_fe_one(transport, labels): + """Set a field element to 1 via fe_one.""" + write_fe(transport, labels["fe_tmp3"], P - 1) + set_fe_ptrs(transport, labels, dst=labels["fe_tmp3"]) + jsr(transport, labels["fe_one"]) + return read_fe(transport, labels["fe_tmp3"]) + + +def c64_x25519_clamp(transport, labels, scalar): + """Clamp a scalar on C64. Returns clamped scalar bytes.""" + write_bytes(transport, labels["x25_scalar"], scalar) + jsr(transport, labels["x25519_clamp"]) + return read_bytes(transport, labels["x25_scalar"], 32) + + +def c64_x25519_scalarmult(transport, labels, scalar, u): + """Compute scalar * u on C64. Returns 32-byte result.""" + write_bytes(transport, labels["x25_scalar"], scalar) + write_bytes(transport, labels["x25_u"], u) + jsr(transport, labels["x25519_scalarmult"], timeout=7200.0) + return read_bytes(transport, labels["x25_result"], 32) + + +# ============================================================================ +# Test functions -- fe25519 field operations +# ============================================================================ + +def test_fe_copy_zero_one(transport, labels): + """Test fe_copy, fe_zero, fe_one.""" + passed = failed = 0 + + # fe_zero + result = c64_fe_zero(transport, labels) + if result == 0: + passed += 1 + if VERBOSE: + print(" PASS fe_zero") + else: + failed += 1 + print(f" FAIL fe_zero: got {result}") + + # fe_one + result = c64_fe_one(transport, labels) + if result == 1: + passed += 1 + if VERBOSE: + print(" PASS fe_one") + else: + failed += 1 + print(f" FAIL fe_one: got {result}") + + # fe_copy + test_val = 0xDEADBEEF_CAFEBABE_12345678_9ABCDEF0 + result = c64_fe_copy(transport, labels, test_val) + if result == test_val: + passed += 1 + if VERBOSE: + print(" PASS fe_copy") + else: + failed += 1 + print(f" FAIL fe_copy: expected {test_val:#x}, got {result:#x}") + + return passed, failed + + +def test_fe_add(transport, labels, rng): + """Test fe_add with boundary cases and random inputs.""" + passed = failed = 0 + + cases = [ + ("0+0", 0, 0), + ("0+1", 0, 1), + ("1+1", 1, 1), + ("p-1+1", P - 1, 1), + ("p-1+p-1", P - 1, P - 1), + ("large+large", P - 10, 15), + ] + for i in range(6): + a, b = rand_fe(rng), rand_fe(rng) + cases.append((f"random #{i}", a, b)) + + for name, a, b in cases: + expected = fe_add_ref(a, b) + result = c64_fe_add(transport, labels, a, b) + if result == expected: + passed += 1 + if VERBOSE: + print(f" PASS add {name}") + else: + failed += 1 + print(f" FAIL add {name}: expected {expected}, got {result}") + + return passed, failed + + +def test_fe_sub(transport, labels, rng): + """Test fe_sub with boundary cases and random inputs.""" + passed = failed = 0 + + cases = [ + ("0-0", 0, 0), + ("1-0", 1, 0), + ("1-1", 1, 1), + ("0-1", 0, 1), + ("10-20", 10, 20), + ("p-1-0", P - 1, 0), + ] + for i in range(6): + a, b = rand_fe(rng), rand_fe(rng) + cases.append((f"random #{i}", a, b)) + + for name, a, b in cases: + expected = fe_sub_ref(a, b) + result = c64_fe_sub(transport, labels, a, b) + if result == expected: + passed += 1 + if VERBOSE: + print(f" PASS sub {name}") + else: + failed += 1 + print(f" FAIL sub {name}: expected {expected}, got {result}") + + return passed, failed + + +def test_fe_mul(transport, labels, rng): + """Test fe_mul with identity, zero, and random inputs.""" + passed = failed = 0 + + cases = [ + ("0*0", 0, 0), + ("0*1", 0, 1), + ("1*1", 1, 1), + ("2*3", 2, 3), + ("a*0", rand_fe(rng), 0), + ("1*a", 1, rand_fe(rng)), + ] + for i in range(4): + a, b = rand_fe(rng), rand_fe(rng) + cases.append((f"random #{i}", a, b)) + + for name, a, b in cases: + expected = fe_mul_ref(a, b) + result = c64_fe_mul(transport, labels, a, b) + if result == expected: + passed += 1 + if VERBOSE: + print(f" PASS mul {name}") + else: + failed += 1 + print(f" FAIL mul {name}:") + print(f" a = {a}") + print(f" b = {b}") + print(f" expected = {expected}") + print(f" got = {result}") + + return passed, failed + + +def test_fe_sqr(transport, labels, rng): + """Test fe_sqr against Python reference.""" + passed = failed = 0 + + cases = [0, 1, 2, P - 1, rand_fe(rng), rand_fe(rng), rand_fe(rng)] + + for i, a in enumerate(cases): + expected = fe_sqr_ref(a) + result = c64_fe_sqr(transport, labels, a) + if result == expected: + passed += 1 + if VERBOSE: + print(f" PASS sqr #{i}") + else: + failed += 1 + print(f" FAIL sqr #{i}: a={a}, expected={expected}, got={result}") + + return passed, failed + + +def test_fe_inv(transport, labels, rng): + """Test fe_inv: inv(1)==1, inv(2)*2==1. + + Full fe_inv takes ~10 minutes per call in VICE. Test inv(1) which is + fast, plus inv(2) as a second case (small value, verifiable). + """ + passed = failed = 0 + + cases = [1, 2] + + for i, a in enumerate(cases): + print(f" inv test #{i} (a={a:#x})...", end="", flush=True) + inv_a = c64_fe_inv(transport, labels, a) + expected = fe_inv_ref(a) + + if inv_a == expected: + passed += 1 + print(" PASS" if VERBOSE else " ok") + else: + failed += 1 + print(" FAIL") + print(f" expected inv = {expected}") + print(f" got inv = {inv_a}") + product = (a * inv_a) % P + print(f" a * got_inv mod p = {product}") + + return passed, failed + + +def test_fe_cswap(transport, labels, rng): + """Test fe_cswap constant-time swap with mask=$00 and mask=$FF.""" + passed = failed = 0 + + a = rand_fe(rng) + b = rand_fe(rng) + + cswap_addr = labels["fe_cswap"] + trampoline = labels["input_buffer"] + + # No-swap test (mask = $00) + write_fe(transport, labels["fe_tmp1"], a) + write_fe(transport, labels["fe_tmp2"], b) + set_fe_ptrs(transport, labels, + src1=labels["fe_tmp1"], + src2=labels["fe_tmp2"]) + write_bytes(transport, trampoline, bytes([ + 0xA9, 0x00, # LDA #$00 + 0x4C, cswap_addr & 0xFF, cswap_addr >> 8, # JMP fe_cswap + ])) + jsr(transport, trampoline) + r_a = read_fe(transport, labels["fe_tmp1"]) + r_b = read_fe(transport, labels["fe_tmp2"]) + + if r_a == a and r_b == b: + passed += 1 + if VERBOSE: + print(" PASS cswap no-swap") + else: + failed += 1 + print(f" FAIL cswap no-swap: a changed={r_a != a}, b changed={r_b != b}") + + # Swap test (mask = $FF) + write_fe(transport, labels["fe_tmp1"], a) + write_fe(transport, labels["fe_tmp2"], b) + set_fe_ptrs(transport, labels, + src1=labels["fe_tmp1"], + src2=labels["fe_tmp2"]) + write_bytes(transport, trampoline, bytes([ + 0xA9, 0xFF, # LDA #$FF + 0x4C, cswap_addr & 0xFF, cswap_addr >> 8, # JMP fe_cswap + ])) + jsr(transport, trampoline) + r_a = read_fe(transport, labels["fe_tmp1"]) + r_b = read_fe(transport, labels["fe_tmp2"]) + + if r_a == b and r_b == a: + passed += 1 + if VERBOSE: + print(" PASS cswap swap") + else: + failed += 1 + print(f" FAIL cswap swap: expected ({b:#x},{a:#x}), " + f"got ({r_a:#x},{r_b:#x})") + + return passed, failed + + +def test_fe_mul_a24(transport, labels, rng): + """Test fe_mul_a24 (multiply by 121665).""" + passed = failed = 0 + + cases = [0, 1, 2, 121665, P - 1, + rand_fe(rng), rand_fe(rng), rand_fe(rng)] + + for i, a in enumerate(cases): + expected = fe_mul_a24_ref(a) + result = c64_fe_mul_a24(transport, labels, a) + if result == expected: + passed += 1 + if VERBOSE: + print(f" PASS mul_a24 #{i}") + else: + failed += 1 + print(f" FAIL mul_a24 #{i}: a={a}, expected={expected}, " + f"got={result}") + + return passed, failed + + +def test_fe_add_sub_inverse(transport, labels, rng): + """Test that (a + b) - b == a (add/sub are inverses).""" + passed = failed = 0 + + for i in range(5): + a = rand_fe(rng) + b = rand_fe(rng) + sum_ab = c64_fe_add(transport, labels, a, b) + result = c64_fe_sub(transport, labels, sum_ab, b) + if result == a: + passed += 1 + if VERBOSE: + print(f" PASS add_sub_inverse #{i}") + else: + failed += 1 + print(f" FAIL add_sub_inverse #{i}: expected {a}, got {result}") + + return passed, failed + + +# ============================================================================ +# Test functions -- x25519 +# ============================================================================ + +def test_x25519_clamp(transport, labels, rng): + """Test x25519_clamp against reference implementation.""" + passed = failed = 0 + + # Fixed cases + cases = [ + bytes(range(32)), + bytes([0xFF] * 32), + bytes([0x00] * 32), + bytes([0xA5] * 32), + ] + # Random cases + for _ in range(6): + cases.append(bytes(rng.getrandbits(8) for _ in range(32))) + + for i, scalar in enumerate(cases): + expected = clamp_ref(scalar) + result = c64_x25519_clamp(transport, labels, scalar) + if result == expected: + passed += 1 + if VERBOSE: + print(f" PASS clamp #{i}") + else: + failed += 1 + print(f" FAIL clamp #{i}:") + print(f" input: {scalar.hex()}") + print(f" expected: {expected.hex()}") + print(f" got: {result.hex()}") + # Show which bytes differ + for j in range(32): + if expected[j] != result[j]: + print(f" byte[{j}]: expected 0x{expected[j]:02x}, " + f"got 0x{result[j]:02x}") + + return passed, failed + + +def test_x25519_rfc7748_vector1(transport, labels): + """RFC 7748 Section 6.1 test vector 1.""" + passed = failed = 0 + + print(" RFC 7748 vector 1...", end="", flush=True) + result = c64_x25519_scalarmult(transport, labels, SCALAR_1, U_1) + + if result == EXPECTED_1: + passed += 1 + print(" PASS") + else: + failed += 1 + print(" FAIL") + print(f" expected: {EXPECTED_1.hex()}") + print(f" got: {result.hex()}") + + return passed, failed + + +def test_x25519_rfc7748_vector2(transport, labels): + """RFC 7748 Section 6.1 test vector 2.""" + passed = failed = 0 + + print(" RFC 7748 vector 2...", end="", flush=True) + result = c64_x25519_scalarmult(transport, labels, SCALAR_2, U_2) + + if result == EXPECTED_2: + passed += 1 + print(" PASS") + else: + failed += 1 + print(" FAIL") + print(f" expected: {EXPECTED_2.hex()}") + print(f" got: {result.hex()}") + + return passed, failed + + +# ============================================================================ +# Main +# ============================================================================ + +def run_tests(transport, labels, seed): + """Run all test groups. Returns (passed, failed).""" + rng = random.Random(seed) + total_passed = 0 + total_failed = 0 + + test_groups = [ + ("fe_copy/zero/one", + lambda: test_fe_copy_zero_one(transport, labels)), + ("fe_add", + lambda: test_fe_add(transport, labels, rng)), + ("fe_sub", + lambda: test_fe_sub(transport, labels, rng)), + ("fe_add/sub inverse", + lambda: test_fe_add_sub_inverse(transport, labels, rng)), + ("fe_mul", + lambda: test_fe_mul(transport, labels, rng)), + ("fe_sqr", + lambda: test_fe_sqr(transport, labels, rng)), + ("fe_mul_a24", + lambda: test_fe_mul_a24(transport, labels, rng)), + ("fe_cswap", + lambda: test_fe_cswap(transport, labels, rng)), + ("fe_inv", + lambda: test_fe_inv(transport, labels, rng)), + ("x25519_clamp", + lambda: test_x25519_clamp(transport, labels, rng)), + ] + + if SLOW: + test_groups += [ + ("x25519 RFC 7748 vector 1", + lambda: test_x25519_rfc7748_vector1(transport, labels)), + ("x25519 RFC 7748 vector 2", + lambda: test_x25519_rfc7748_vector2(transport, labels)), + ] + else: + print("\n (x25519 scalarmult tests skipped -- " + "use --slow to enable, ~100 min each)") + + for name, test_fn in test_groups: + print(f"\n--- {name} ---") + try: + p, f = test_fn() + total_passed += p + total_failed += f + status = "OK" if f == 0 else "FAIL" + print(f" {status}: {p}/{p + f} passed") + except Exception as e: + total_failed += 1 + print(f" ERROR: {e}") + import traceback + traceback.print_exc() + + return total_passed, total_failed + + +def main(): + global VERBOSE, SLOW + os.chdir(PROJECT_ROOT) + + seed = random.randint(0, 2**32 - 1) + args = sys.argv[1:] + i = 0 + while i < len(args): + if args[i] == "--seed" and i + 1 < len(args): + seed = int(args[i + 1]) + i += 2 + elif args[i] == "--verbose": + VERBOSE = True + i += 1 + elif args[i] == "--slow": + SLOW = True + i += 1 + else: + i += 1 + + random.seed(seed) + print(f"Random seed: {seed} (reproduce with --seed {seed})") + + # Build + print("\n=== Building ===") + subprocess.run(["make", "clean"], capture_output=True, cwd=PROJECT_ROOT) + result = subprocess.run(["make"], capture_output=True, text=True, + cwd=PROJECT_ROOT) + if result.returncode != 0: + print(f"Build failed:\n{result.stderr}") + sys.exit(1) + + assert os.path.exists(PRG_PATH), f"{PRG_PATH} not found after build" + print(f" Build OK: {PRG_PATH}") + + # Load labels + labels = Labels.from_file(LABELS_PATH) + + required = [ + "fe_src1", "fe_src2", "fe_dst", + "fe_copy", "fe_zero", "fe_one", + "fe_add", "fe_sub", "fe_mul", "fe_sqr", "fe_inv", + "fe_cswap", "fe_mul_a24", + "fe_tmp1", "fe_tmp2", "fe_tmp3", + "x25519_clamp", "x25519_scalarmult", + "x25_scalar", "x25_u", "x25_result", + "input_buffer", + ] + for name in required: + if labels.address(name) is None: + print(f"FATAL: '{name}' label not found in {LABELS_PATH}") + sys.exit(1) + + print(f" Labels loaded: {len(required)} required labels verified") + + # Launch VICE + config = ViceConfig(prg_path=PRG_PATH, warp=True, ntsc=True, sound=False, + extra_args=["-reu", "-reusize", "512"]) + print("\n=== Starting VICE ===") + + with ViceInstanceManager(config=config) as mgr: + inst = mgr.acquire() + transport = inst.transport + print(f"VICE PID={inst.pid}, port={inst.port}") + + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) + if grid is None: + print("FATAL: Program menu did not appear") + sys.exit(1) + + print(" VICE ready, running tests...") + + # Safety: write JMP $0339 at $0339 so CPU loops harmlessly + # after jsr() returns (prevents crash when BASIC ROM is banked out) + write_bytes(transport, 0x0339, bytes([0x4C, 0x39, 0x03])) + + passed, failed = run_tests(transport, labels, seed) + + mgr.release(inst) + + total = passed + failed + print(f"\n{'='*60}") + print(f"RESULTS: {passed}/{total} passed, {failed}/{total} failed") + print(f"{'='*60}") + sys.exit(0 if failed == 0 else 1) + + +if __name__ == "__main__": + main() From 001d3a7d57b7b735b098b5c5db211231011b38ad Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:48:41 -0500 Subject: [PATCH 5/6] Add consolidated network test environment with context manager Introduces tools/net_test_env.py with NetworkTestEnv context manager that handles TAP interface, dnsmasq, and optional HTTPS server lifecycle. Replaces duplicated inline setup/teardown code across network test files and guarantees cleanup via __exit__, signal handlers, and atexit. Migrates test_dns.py as proof-of-concept. Includes 14 unit tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- tools/net_test_env.py | 544 +++++++++++++++++++++++++++++++++++++ tools/test_dns.py | 343 +++++++++-------------- tools/test_net_test_env.py | 231 ++++++++++++++++ 3 files changed, 907 insertions(+), 211 deletions(-) create mode 100644 tools/net_test_env.py create mode 100644 tools/test_net_test_env.py diff --git a/tools/net_test_env.py b/tools/net_test_env.py new file mode 100644 index 0000000..6938ebb --- /dev/null +++ b/tools/net_test_env.py @@ -0,0 +1,544 @@ +#!/usr/bin/env python3 +"""net_test_env.py -- Consolidated network test environment for C64 VICE emulator tests. + +Provides a NetworkTestEnv context manager that handles TAP interface setup, +dnsmasq lifecycle, and optional HTTP/HTTPS server startup. Replaces the +duplicated inline setup/teardown code across test_dns.py, test_http_integration.py, +and test_https_integration.py. + +Usage as context manager: + with NetworkTestEnv(dns_records={"c64test.local": "10.0.65.1"}) as env: + # env.dnsmasq_proc is running + # env.server is running if http_server=True + run_tests(...) + +Usage as CLI: + python3 tools/net_test_env.py --dns-record c64test.local=10.0.65.1 + python3 tools/net_test_env.py --wrap python3 tools/test_dns.py +""" + +from __future__ import annotations + +import argparse +import atexit +import os +import shutil +import signal +import ssl +import subprocess +import sys +import time +from typing import Optional + +# Allow importing test_server from the same directory. +sys.path.insert(0, os.path.dirname(__file__)) +from test_server import TestHTTPServer + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +TAP_SYSFS = "/sys/class/net/{iface}" +SETUP_TAP_SCRIPT = os.path.join( + os.path.dirname(__file__), "..", "..", + "c64-test-harness", "scripts", "setup-tap-networking.sh", +) +# Resolve to absolute path +SETUP_TAP_SCRIPT = os.path.normpath(SETUP_TAP_SCRIPT) + +DEFAULT_DNS_RECORDS: dict[str, str] = {"c64test.local": "10.0.65.1"} + + +# --------------------------------------------------------------------------- +# Standalone helpers +# --------------------------------------------------------------------------- + +def skip_if_no_network(tap_interface: str = "tap-c64") -> bool: + """Check if network test prerequisites are missing. + + Returns True if tests should be skipped (i.e., something is missing). + Prints a SKIP message for the first missing prerequisite found. + """ + if not os.path.exists(TAP_SYSFS.format(iface=tap_interface)): + print(f"SKIP: {tap_interface} interface not found") + return True + if shutil.which("x64sc") is None: + print("SKIP: x64sc not on PATH") + return True + if shutil.which("dnsmasq") is None: + print("SKIP: dnsmasq not on PATH") + return True + if shutil.which("sudo") is None: + print("SKIP: sudo not on PATH") + return True + return False + + +def start_dnsmasq( + tap_interface: str = "tap-c64", + tap_address: str = "10.0.65.1", + dhcp_range: tuple[str, str] = ("10.0.65.2", "10.0.65.10"), + dns_records: dict[str, str] | None = None, + extra_args: list[str] | None = None, + verbose: bool = True, +) -> subprocess.Popen: + """Start dnsmasq providing DHCP and DNS on a TAP interface. + + Args: + tap_interface: Network interface to bind to. + tap_address: Listen address for dnsmasq. + dhcp_range: (start, end) IP range for DHCP leases. + dns_records: Mapping of hostname -> IP for --address entries. + extra_args: Additional command-line arguments for dnsmasq. + verbose: Print the command and PID. + + Returns: + The Popen object for the dnsmasq process. + + Raises: + RuntimeError: If dnsmasq exits immediately after launch. + """ + if dns_records is None: + dns_records = dict(DEFAULT_DNS_RECORDS) + + range_start, range_end = dhcp_range + cmd = [ + "sudo", "dnsmasq", + "--no-daemon", + f"--interface={tap_interface}", + "--bind-interfaces", + f"--listen-address={tap_address}", + f"--dhcp-range={range_start},{range_end},255.255.255.0,5m", + f"--dhcp-option=6,{tap_address}", + "--log-queries", + "--no-resolv", + ] + for hostname, ip in dns_records.items(): + cmd.append(f"--address=/{hostname}/{ip}") + if extra_args: + cmd.extend(extra_args) + + if verbose: + print(f" dnsmasq cmd: {' '.join(cmd)}") + + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + # Give it a moment to bind ports. + time.sleep(0.5) + if proc.poll() is not None: + _, stderr = proc.communicate() + raise RuntimeError(f"dnsmasq failed to start: {stderr.decode()}") + + if verbose: + print(f" dnsmasq PID={proc.pid}") + return proc + + +def stop_dnsmasq(proc: subprocess.Popen, timeout: int = 5) -> None: + """Terminate a dnsmasq process gracefully, killing it if necessary. + + Args: + proc: The Popen object returned by start_dnsmasq(). + timeout: Seconds to wait for graceful termination before killing. + """ + if proc.poll() is not None: + return # Already exited. + try: + proc.terminate() + try: + proc.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + except OSError: + pass # Process already gone. + + +def _kill_stale_dnsmasq() -> None: + """Kill any leftover dnsmasq processes. Errors are silently ignored.""" + try: + subprocess.run( + ["sudo", "killall", "dnsmasq"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except OSError: + pass + + +# --------------------------------------------------------------------------- +# NetworkTestEnv context manager +# --------------------------------------------------------------------------- + +class NetworkTestEnv: + """Context manager that sets up and tears down the full network test environment. + + Manages: + - TAP interface creation (optional, delegates to setup-tap-networking.sh) + - dnsmasq lifecycle (DHCP + DNS) + - Optional HTTP/HTTPS server via TestHTTPServer + + Example:: + + with NetworkTestEnv(http_server=True, http_port=8080) as env: + assert env.dnsmasq_proc.poll() is None # running + assert env.server is not None + # ... run VICE tests ... + """ + + def __init__( + self, + tap_interface: str = "tap-c64", + tap_address: str = "10.0.65.1", + dhcp_range: tuple[str, str] = ("10.0.65.2", "10.0.65.10"), + dns_records: dict[str, str] | None = None, + extra_dnsmasq_args: list[str] | None = None, + setup_tap: bool = True, + teardown_tap: bool = False, + http_server: bool = False, + http_host: str = "10.0.65.1", + http_port: int = 80, + ssl_context: ssl.SSLContext | None = None, + verbose: bool = True, + ): + self.tap_interface = tap_interface + self.tap_address = tap_address + self.dhcp_range = dhcp_range + self.dns_records = dns_records if dns_records is not None else dict(DEFAULT_DNS_RECORDS) + self.extra_dnsmasq_args = extra_dnsmasq_args + self.setup_tap = setup_tap + self.teardown_tap = teardown_tap + self.http_server_enabled = http_server + self.http_host = http_host + self.http_port = http_port + self.ssl_context = ssl_context + self.verbose = verbose + + self._dnsmasq_proc: subprocess.Popen | None = None + self._server: TestHTTPServer | None = None + self._torn_down = False + self._prev_sigint = None + self._prev_sigterm = None + + # ---- Properties -------------------------------------------------------- + + @property + def dnsmasq_proc(self) -> subprocess.Popen | None: + """The running dnsmasq Popen object, or None if not started.""" + return self._dnsmasq_proc + + @property + def server(self) -> TestHTTPServer | None: + """The running TestHTTPServer instance, or None if not started.""" + return self._server + + # ---- Prerequisite check ------------------------------------------------ + + def check_prerequisites(self) -> list[str]: + """Return a list of missing prerequisites. Empty list means all OK.""" + missing: list[str] = [] + if not self.setup_tap and not os.path.exists( + TAP_SYSFS.format(iface=self.tap_interface) + ): + missing.append(f"{self.tap_interface} interface not found (and setup_tap=False)") + if shutil.which("dnsmasq") is None: + missing.append("dnsmasq not on PATH") + if shutil.which("sudo") is None: + missing.append("sudo not on PATH") + if self.setup_tap and not os.path.isfile(SETUP_TAP_SCRIPT): + missing.append(f"TAP setup script not found: {SETUP_TAP_SCRIPT}") + return missing + + # ---- Setup / teardown -------------------------------------------------- + + def setup(self) -> "NetworkTestEnv": + """Set up the network test environment. + + 1. Create TAP interface if needed. + 2. Kill stale dnsmasq processes. + 3. Start dnsmasq. + 4. Start HTTP server if requested. + + Returns self for chaining. + """ + # Install signal handlers and atexit for safety. + self._install_signal_handlers() + atexit.register(self.teardown) + + # 1. TAP interface. + tap_exists = os.path.exists(TAP_SYSFS.format(iface=self.tap_interface)) + if self.setup_tap and not tap_exists: + if self.verbose: + print(f" Setting up TAP interface {self.tap_interface}...") + result = subprocess.run( + ["sudo", SETUP_TAP_SCRIPT], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError( + f"TAP setup failed (exit {result.returncode}):\n{result.stderr}" + ) + if self.verbose: + print(f" TAP interface {self.tap_interface} created") + elif tap_exists: + if self.verbose: + print(f" TAP interface {self.tap_interface} already exists") + else: + if self.verbose: + print(f" Skipping TAP setup (setup_tap=False)") + + # 2. Kill stale dnsmasq. + _kill_stale_dnsmasq() + + # 3. Start dnsmasq. + if self.verbose: + print(" Starting dnsmasq...") + self._dnsmasq_proc = start_dnsmasq( + tap_interface=self.tap_interface, + tap_address=self.tap_address, + dhcp_range=self.dhcp_range, + dns_records=self.dns_records, + extra_args=self.extra_dnsmasq_args, + verbose=self.verbose, + ) + + # 4. HTTP server. + if self.http_server_enabled: + if self.verbose: + proto = "HTTPS" if self.ssl_context else "HTTP" + print(f" Starting {proto} server on {self.http_host}:{self.http_port}...") + self._server = TestHTTPServer( + host=self.http_host, + port=self.http_port, + ssl_context=self.ssl_context, + ) + self._server.start() + if self.verbose: + proto = "HTTPS" if self.ssl_context else "HTTP" + print(f" {proto} server listening on {self.http_host}:{self.http_port}") + + return self + + def teardown(self) -> None: + """Tear down the network test environment. Idempotent.""" + if self._torn_down: + return + self._torn_down = True + + if self.verbose: + print(" NetworkTestEnv teardown...") + + # Stop HTTP server. + if self._server is not None: + try: + self._server.stop() + if self.verbose: + proto = "HTTPS" if self.ssl_context else "HTTP" + print(f" {proto} server stopped") + except Exception as e: + print(f" WARNING: HTTP server stop failed: {e}") + self._server = None + + # Stop dnsmasq. + if self._dnsmasq_proc is not None: + try: + stop_dnsmasq(self._dnsmasq_proc) + if self.verbose: + print(f" dnsmasq stopped (exit={self._dnsmasq_proc.returncode})") + except Exception as e: + print(f" WARNING: dnsmasq stop failed: {e}") + self._dnsmasq_proc = None + + # Teardown TAP if requested. + if self.teardown_tap: + try: + subprocess.run( + ["sudo", "ip", "link", "delete", self.tap_interface], + capture_output=True, + ) + if self.verbose: + print(f" TAP interface {self.tap_interface} removed") + except Exception as e: + print(f" WARNING: TAP teardown failed: {e}") + + # Restore signal handlers. + self._restore_signal_handlers() + + # Unregister atexit (best-effort; atexit doesn't support unregister, + # but the idempotent guard above prevents double-teardown). + + # ---- Context manager protocol ------------------------------------------ + + def __enter__(self) -> "NetworkTestEnv": + return self.setup() + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.teardown() + + # ---- Signal handling --------------------------------------------------- + + def _install_signal_handlers(self) -> None: + """Install SIGINT/SIGTERM handlers that trigger teardown.""" + def _handler(signum, frame): + self.teardown() + # Re-raise with default handler so the process exits with the + # correct signal status. + signal.signal(signum, signal.SIG_DFL) + os.kill(os.getpid(), signum) + + try: + self._prev_sigint = signal.signal(signal.SIGINT, _handler) + self._prev_sigterm = signal.signal(signal.SIGTERM, _handler) + except (OSError, ValueError): + # signal.signal can fail if not on the main thread. + pass + + def _restore_signal_handlers(self) -> None: + """Restore previous signal handlers.""" + try: + if self._prev_sigint is not None: + signal.signal(signal.SIGINT, self._prev_sigint) + self._prev_sigint = None + if self._prev_sigterm is not None: + signal.signal(signal.SIGTERM, self._prev_sigterm) + self._prev_sigterm = None + except (OSError, ValueError): + pass + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def _parse_dns_record(value: str) -> tuple[str, str]: + """Parse a 'host=ip' string into a (host, ip) tuple.""" + if "=" not in value: + raise argparse.ArgumentTypeError( + f"DNS record must be in host=ip format, got: {value!r}" + ) + host, ip = value.split("=", 1) + return host.strip(), ip.strip() + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Set up network test environment for C64 VICE emulator tests.", + ) + parser.add_argument( + "--setup-tap", action="store_true", default=True, + help="Set up TAP interface if it doesn't exist (default: True)", + ) + parser.add_argument( + "--no-setup-tap", action="store_false", dest="setup_tap", + help="Skip TAP interface setup", + ) + parser.add_argument( + "--teardown-tap", action="store_true", default=False, + help="Tear down TAP interface on exit", + ) + parser.add_argument( + "--dns-record", action="append", type=_parse_dns_record, + metavar="HOST=IP", dest="dns_records", + help="DNS record (repeatable). Default: c64test.local=10.0.65.1", + ) + parser.add_argument( + "--http-port", type=int, default=None, + help="Start an HTTP server on this port", + ) + parser.add_argument( + "--https-port", type=int, default=None, + help="Start an HTTPS server on this port (generates self-signed cert)", + ) + parser.add_argument( + "--wrap", nargs=argparse.REMAINDER, metavar="CMD", + help="Run CMD with the environment set up, then teardown and exit", + ) + parser.add_argument( + "--quiet", action="store_true", default=False, + help="Suppress verbose output", + ) + + args = parser.parse_args() + + # Build dns_records dict. + dns_records: dict[str, str] | None = None + if args.dns_records: + dns_records = dict(args.dns_records) + + # Determine HTTP/HTTPS settings. + http_server = args.http_port is not None or args.https_port is not None + http_port = args.https_port or args.http_port or 80 + ssl_ctx: ssl.SSLContext | None = None + + if args.https_port is not None: + import tempfile + cert_dir = tempfile.mkdtemp(prefix="c64tls_") + cert_path = os.path.join(cert_dir, "cert.pem") + key_path = os.path.join(cert_dir, "key.pem") + subprocess.run([ + "openssl", "req", "-new", "-x509", + "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-keyout", key_path, "-out", cert_path, + "-days", "1", "-nodes", + "-subj", "/CN=c64test.local", + ], check=True, capture_output=True) + ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ssl_ctx.minimum_version = ssl.TLSVersion.TLSv1_3 + ssl_ctx.maximum_version = ssl.TLSVersion.TLSv1_3 + ssl_ctx.load_cert_chain(cert_path, key_path) + if not args.quiet: + print(f"Generated self-signed TLS cert in {cert_dir}") + + verbose = not args.quiet + + env = NetworkTestEnv( + dns_records=dns_records, + extra_dnsmasq_args=None, + setup_tap=args.setup_tap, + teardown_tap=args.teardown_tap, + http_server=http_server, + http_port=http_port, + ssl_context=ssl_ctx, + verbose=verbose, + ) + + # Check prerequisites before doing anything. + missing = env.check_prerequisites() + if missing: + for m in missing: + print(f"ERROR: {m}") + return 1 + + if args.wrap: + # --wrap mode: setup, run command, teardown, exit with command's code. + if not args.wrap: + parser.error("--wrap requires a command") + with env: + if verbose: + print(f"\n Running: {' '.join(args.wrap)}") + result = subprocess.run(args.wrap) + return result.returncode + else: + # Interactive mode: setup, print status, wait for Ctrl+C. + with env: + proto = "HTTPS" if ssl_ctx else "HTTP" if http_server else None + print(f"\n{'='*60}") + print(f"Network test environment is running.") + print(f" TAP interface: {env.tap_interface}") + print(f" dnsmasq PID: {env.dnsmasq_proc.pid}") + if env.server is not None: + print(f" {proto} server: {env.http_host}:{env.http_port}") + print(f" DNS records: {env.dns_records}") + print(f"{'='*60}") + print(f"Press Ctrl+C to stop.\n") + try: + while True: + time.sleep(1.0) + except KeyboardInterrupt: + print("\nInterrupted.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/test_dns.py b/tools/test_dns.py index 1ae6a22..5817a9b 100644 --- a/tools/test_dns.py +++ b/tools/test_dns.py @@ -13,15 +13,16 @@ """ import os -import shutil import subprocess import sys -import time PROJECT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") PRG_PATH = os.path.join(PROJECT_ROOT, "build", "c64-https.prg") LABELS_PATH = os.path.join(PROJECT_ROOT, "build", "labels.txt") +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from net_test_env import NetworkTestEnv, skip_if_no_network + # ip65_dns_ip_addr: 4 bytes storing the resolved IP address IP65_DNS_IP_ADDR = 0x4073 @@ -31,64 +32,6 @@ CARRY_RESULT_ADDR = 0xC0F0 -# --------------------------------------------------------------------------- -# Skip checks -# --------------------------------------------------------------------------- - -def check_prerequisites(): - """Return True if all prerequisites are met, else print skip and return False.""" - if not os.path.exists("/sys/class/net/tap-c64"): - print("SKIP: tap-c64 interface not found") - return False - if shutil.which("x64sc") is None: - print("SKIP: x64sc not on PATH") - return False - if shutil.which("dnsmasq") is None: - print("SKIP: dnsmasq not on PATH") - return False - if shutil.which("sudo") is None: - print("SKIP: sudo not on PATH (needed for dnsmasq)") - return False - return True - - -# --------------------------------------------------------------------------- -# dnsmasq helper -# --------------------------------------------------------------------------- - -def start_dnsmasq(): - """Start dnsmasq providing DHCP and DNS on tap-c64. Returns Popen. - - dnsmasq needs root for port 53, so we launch it via sudo. - """ - cmd = [ - "sudo", "dnsmasq", - "--no-daemon", - "--interface=tap-c64", - "--bind-interfaces", - "--listen-address=10.0.65.1", - "--dhcp-range=10.0.65.2,10.0.65.10,255.255.255.0,5m", - "--address=/c64test.local/10.0.65.1", - "--address=/second.local/10.0.65.1", - "--dhcp-option=6,10.0.65.1", - "--log-queries", - "--no-resolv", - ] - print(f" dnsmasq cmd: {' '.join(cmd)}") - proc = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - # Give it a moment to bind - time.sleep(0.5) - if proc.poll() is not None: - _, stderr = proc.communicate() - raise RuntimeError(f"dnsmasq failed to start: {stderr.decode()}") - print(f" dnsmasq PID={proc.pid}") - return proc - - # --------------------------------------------------------------------------- # DNS resolve helper # --------------------------------------------------------------------------- @@ -164,11 +107,10 @@ def do_dns_resolve(transport, write_bytes, read_bytes, jsr_fn, def main(): os.chdir(PROJECT_ROOT) - if not check_prerequisites(): + if skip_if_no_network(): sys.exit(0) # Late imports -- only needed if prerequisites are met - sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from c64_test_harness import ( Labels, ViceConfig, ViceInstanceManager, read_bytes, write_bytes, jsr, wait_for_text, @@ -176,161 +118,140 @@ def main(): passed = 0 failed = 0 - dnsmasq_proc = None mgr = None inst = None - try: - # ---- 1. Build -------------------------------------------------------- - print("\n=== Building ===") - result = subprocess.run(["make"], capture_output=True, text=True, - cwd=PROJECT_ROOT) - if result.returncode != 0: - print(f" Build failed:\n{result.stderr}") - sys.exit(1) - print(" Build OK") - - labels = Labels.from_file(LABELS_PATH) - print(f" Labels loaded, {len(labels)} symbols") - - # ---- Test: test_dns_labels ------------------------------------------- - print("\n=== test_dns_labels ===") - dns_resolve_addr = labels.address("net_dns_resolve") - if dns_resolve_addr is not None: - print(f" PASS: net_dns_resolve found @ ${dns_resolve_addr:04X}") - passed += 1 - else: - print(" FAIL: net_dns_resolve label not found") - failed += 1 - raise RuntimeError("Required label net_dns_resolve not found") - - # ---- 2. Start dnsmasq ------------------------------------------------ - print("\n=== Starting dnsmasq ===") - dnsmasq_proc = start_dnsmasq() - - # ---- 3. Launch VICE -------------------------------------------------- - print("\n=== Starting VICE ===") - config = ViceConfig( - prg_path=PRG_PATH, - warp=False, # warp causes timing issues with ethernet - ntsc=True, - sound=False, - ethernet=True, - ethernet_mode="rrnet", - ethernet_driver="tuntap", - ethernet_interface="tap-c64", - ) - - mgr = ViceInstanceManager(config=config) - inst = mgr.acquire() - transport = inst.transport - print(f" VICE PID={inst.pid}, port={inst.port}") - - # ---- 4. Wait for boot menu ------------------------------------------ - print("\n=== Waiting for boot menu ===") - grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) - if grid is None: - print(" FATAL: Program menu did not appear") - failed += 1 - raise RuntimeError("Boot menu timeout") - print(" Boot menu appeared") - - # ---- 5. Network init (DHCP) ----------------------------------------- - print("\n=== Network init (pressing I for init) ===") - transport.resume() # CPU paused after wait_for_text screen read - transport.inject_keys([0x49]) # 'I' - - grid = wait_for_text(transport, "DHCP OK", timeout=60.0, verbose=False) - if grid is None: - print(" FAIL: DHCP did not complete within 60 seconds") - if dnsmasq_proc: - dnsmasq_proc.terminate() - _, stderr = dnsmasq_proc.communicate(timeout=5) - print(f" dnsmasq stderr:\n{stderr.decode()}") - dnsmasq_proc = None - failed += 1 - raise RuntimeError("DHCP timeout") - print(" DHCP OK") - - # ---- Test: test_dns_resolve_known_host ------------------------------- - print("\n=== test_dns_resolve_known_host ===") - carry, ip = do_dns_resolve( - transport, write_bytes, read_bytes, jsr, - "c64test.local", dns_resolve_addr, - ) - expected_ip = [10, 0, 65, 1] - if carry == 0 and list(ip) == expected_ip: - print(f" PASS: resolved c64test.local -> {'.'.join(str(b) for b in ip)}" - f", carry=0") - passed += 1 - else: - print(f" FAIL: c64test.local -> {list(ip)}, carry={carry}" - f" (expected {expected_ip}, carry=0)") - failed += 1 - - # ---- Test: test_dns_resolve_second_host ------------------------------ - print("\n=== test_dns_resolve_second_host ===") - carry, ip = do_dns_resolve( - transport, write_bytes, read_bytes, jsr, - "second.local", dns_resolve_addr, - ) - if carry == 0 and list(ip) == expected_ip: - print(f" PASS: resolved second.local -> {'.'.join(str(b) for b in ip)}" - f", carry=0") - passed += 1 - else: - print(f" FAIL: second.local -> {list(ip)}, carry={carry}" - f" (expected {expected_ip}, carry=0)") - failed += 1 - - # ---- Test: test_dns_resolve_unknown_host ----------------------------- - print("\n=== test_dns_resolve_unknown_host ===") - carry, ip = do_dns_resolve( - transport, write_bytes, read_bytes, jsr, - "nonexistent.invalid", dns_resolve_addr, - ) - if carry == 1: - print(f" PASS: nonexistent.invalid -> carry=1 (failure, as expected)") - passed += 1 - else: - print(f" FAIL: nonexistent.invalid -> carry={carry}, ip={list(ip)}" - f" (expected carry=1)") + with NetworkTestEnv( + dns_records={"c64test.local": "10.0.65.1", "second.local": "10.0.65.1"}, + setup_tap=False, + ) as env: + try: + # ---- 1. Build -------------------------------------------------------- + print("\n=== Building ===") + result = subprocess.run(["make"], capture_output=True, text=True, + cwd=PROJECT_ROOT) + if result.returncode != 0: + print(f" Build failed:\n{result.stderr}") + sys.exit(1) + print(" Build OK") + + labels = Labels.from_file(LABELS_PATH) + print(f" Labels loaded, {len(labels)} symbols") + + # ---- Test: test_dns_labels ------------------------------------------- + print("\n=== test_dns_labels ===") + dns_resolve_addr = labels.address("net_dns_resolve") + if dns_resolve_addr is not None: + print(f" PASS: net_dns_resolve found @ ${dns_resolve_addr:04X}") + passed += 1 + else: + print(" FAIL: net_dns_resolve label not found") + failed += 1 + raise RuntimeError("Required label net_dns_resolve not found") + + # ---- 2. Launch VICE -------------------------------------------------- + print("\n=== Starting VICE ===") + config = ViceConfig( + prg_path=PRG_PATH, + warp=False, # warp causes timing issues with ethernet + ntsc=True, + sound=False, + ethernet=True, + ethernet_mode="rrnet", + ethernet_driver="tuntap", + ethernet_interface="tap-c64", + ) + + mgr = ViceInstanceManager(config=config) + inst = mgr.acquire() + transport = inst.transport + print(f" VICE PID={inst.pid}, port={inst.port}") + + # ---- 3. Wait for boot menu ------------------------------------------ + print("\n=== Waiting for boot menu ===") + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) + if grid is None: + print(" FATAL: Program menu did not appear") + failed += 1 + raise RuntimeError("Boot menu timeout") + print(" Boot menu appeared") + + # ---- 4. Network init (DHCP) ----------------------------------------- + print("\n=== Network init (pressing I for init) ===") + transport.resume() # CPU paused after wait_for_text screen read + transport.inject_keys([0x49]) # 'I' + + grid = wait_for_text(transport, "DHCP OK", timeout=60.0, verbose=False) + if grid is None: + print(" FAIL: DHCP did not complete within 60 seconds") + failed += 1 + raise RuntimeError("DHCP timeout") + print(" DHCP OK") + + # ---- Test: test_dns_resolve_known_host ------------------------------- + print("\n=== test_dns_resolve_known_host ===") + carry, ip = do_dns_resolve( + transport, write_bytes, read_bytes, jsr, + "c64test.local", dns_resolve_addr, + ) + expected_ip = [10, 0, 65, 1] + if carry == 0 and list(ip) == expected_ip: + print(f" PASS: resolved c64test.local -> {'.'.join(str(b) for b in ip)}" + f", carry=0") + passed += 1 + else: + print(f" FAIL: c64test.local -> {list(ip)}, carry={carry}" + f" (expected {expected_ip}, carry=0)") + failed += 1 + + # ---- Test: test_dns_resolve_second_host ------------------------------ + print("\n=== test_dns_resolve_second_host ===") + carry, ip = do_dns_resolve( + transport, write_bytes, read_bytes, jsr, + "second.local", dns_resolve_addr, + ) + if carry == 0 and list(ip) == expected_ip: + print(f" PASS: resolved second.local -> {'.'.join(str(b) for b in ip)}" + f", carry=0") + passed += 1 + else: + print(f" FAIL: second.local -> {list(ip)}, carry={carry}" + f" (expected {expected_ip}, carry=0)") + failed += 1 + + # ---- Test: test_dns_resolve_unknown_host ----------------------------- + print("\n=== test_dns_resolve_unknown_host ===") + carry, ip = do_dns_resolve( + transport, write_bytes, read_bytes, jsr, + "nonexistent.invalid", dns_resolve_addr, + ) + if carry == 1: + print(f" PASS: nonexistent.invalid -> carry=1 (failure, as expected)") + passed += 1 + else: + print(f" FAIL: nonexistent.invalid -> carry={carry}, ip={list(ip)}" + f" (expected carry=1)") + failed += 1 + + except RuntimeError as e: + print(f"\n Test aborted: {e}") + except Exception as e: + print(f"\n Unexpected error: {e}") + import traceback + traceback.print_exc() failed += 1 + finally: + # ---- Teardown (VICE only -- dnsmasq handled by NetworkTestEnv) ------- + print("\n=== Teardown ===") - except RuntimeError as e: - print(f"\n Test aborted: {e}") - except Exception as e: - print(f"\n Unexpected error: {e}") - import traceback - traceback.print_exc() - failed += 1 - finally: - # ---- Teardown -------------------------------------------------------- - print("\n=== Teardown ===") - - if mgr is not None: - try: - if inst is not None: - mgr.release(inst) - mgr.shutdown() - print(" VICE released") - except Exception as e: - print(f" VICE cleanup error: {e}") - - if dnsmasq_proc is not None: - try: - dnsmasq_proc.terminate() + if mgr is not None: try: - _, stderr = dnsmasq_proc.communicate(timeout=5) - print(f" dnsmasq stopped (exit={dnsmasq_proc.returncode})") - if failed > 0: - print(f" dnsmasq stderr:\n{stderr.decode()}") - except subprocess.TimeoutExpired: - dnsmasq_proc.kill() - dnsmasq_proc.wait() - print(" dnsmasq killed (did not terminate cleanly)") - except Exception as e: - print(f" dnsmasq cleanup error: {e}") + if inst is not None: + mgr.release(inst) + mgr.shutdown() + print(" VICE released") + except Exception as e: + print(f" VICE cleanup error: {e}") # ---- Summary ------------------------------------------------------------- total = passed + failed diff --git a/tools/test_net_test_env.py b/tools/test_net_test_env.py new file mode 100644 index 0000000..13546dd --- /dev/null +++ b/tools/test_net_test_env.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""Unit tests for net_test_env.py. + +All external dependencies (TAP interfaces, dnsmasq, VICE, subprocess) are mocked. +No sudo, no network, no VICE required. +""" + +import subprocess +import unittest +from unittest.mock import MagicMock, patch, call + + +class TestSkipIfNoNetwork(unittest.TestCase): + """Tests for the skip_if_no_network() helper.""" + + @patch("net_test_env.shutil.which", return_value="/usr/bin/thing") + @patch("net_test_env.os.path.exists", return_value=False) + def test_skip_if_no_network_missing_tap(self, mock_exists, mock_which): + from net_test_env import skip_if_no_network + self.assertTrue(skip_if_no_network()) + mock_exists.assert_called_once_with("/sys/class/net/tap-c64") + + @patch("net_test_env.shutil.which", return_value="/usr/bin/thing") + @patch("net_test_env.os.path.exists", return_value=True) + def test_skip_if_no_network_all_present(self, mock_exists, mock_which): + from net_test_env import skip_if_no_network + self.assertFalse(skip_if_no_network()) + + @patch("net_test_env.shutil.which") + @patch("net_test_env.os.path.exists", return_value=True) + def test_skip_if_no_network_missing_dnsmasq(self, mock_exists, mock_which): + from net_test_env import skip_if_no_network + + def which_side_effect(name): + if name == "dnsmasq": + return None + return "/usr/bin/" + name + + mock_which.side_effect = which_side_effect + self.assertTrue(skip_if_no_network()) + + +class TestCheckPrerequisites(unittest.TestCase): + """Tests for NetworkTestEnv.check_prerequisites().""" + + @patch("net_test_env.shutil.which", return_value="/usr/bin/thing") + @patch("net_test_env.os.path.exists", return_value=False) + @patch("net_test_env.os.path.isfile", return_value=True) + def test_check_prerequisites_missing_tap(self, mock_isfile, mock_exists, mock_which): + from net_test_env import NetworkTestEnv + env = NetworkTestEnv(setup_tap=False) + missing = env.check_prerequisites() + self.assertTrue(any("interface not found" in m for m in missing)) + + @patch("net_test_env.shutil.which") + @patch("net_test_env.os.path.exists", return_value=True) + @patch("net_test_env.os.path.isfile", return_value=True) + def test_check_prerequisites_missing_dnsmasq(self, mock_isfile, mock_exists, mock_which): + from net_test_env import NetworkTestEnv + + def which_side_effect(name): + if name == "dnsmasq": + return None + return "/usr/bin/" + name + + mock_which.side_effect = which_side_effect + env = NetworkTestEnv(setup_tap=False) + missing = env.check_prerequisites() + self.assertTrue(any("dnsmasq" in m for m in missing)) + + +class TestStartDnsmasq(unittest.TestCase): + """Tests for start_dnsmasq() command construction.""" + + @patch("net_test_env.time.sleep") + @patch("net_test_env.subprocess.Popen") + def test_start_dnsmasq_command_construction(self, mock_popen_cls, mock_sleep): + from net_test_env import start_dnsmasq + + mock_proc = MagicMock() + mock_proc.poll.return_value = None + mock_proc.pid = 12345 + mock_popen_cls.return_value = mock_proc + + dns = {"example.local": "10.0.65.1", "other.local": "10.0.65.2"} + start_dnsmasq(dns_records=dns, verbose=False) + + cmd = mock_popen_cls.call_args[0][0] + self.assertIn("--address=/example.local/10.0.65.1", cmd) + self.assertIn("--address=/other.local/10.0.65.2", cmd) + self.assertIn("--interface=tap-c64", cmd) + self.assertIn("sudo", cmd) + + @patch("net_test_env.time.sleep") + @patch("net_test_env.subprocess.Popen") + def test_start_dnsmasq_extra_args(self, mock_popen_cls, mock_sleep): + from net_test_env import start_dnsmasq + + mock_proc = MagicMock() + mock_proc.poll.return_value = None + mock_proc.pid = 12345 + mock_popen_cls.return_value = mock_proc + + start_dnsmasq(extra_args=["--port=5353", "--bogus-priv"], verbose=False) + + cmd = mock_popen_cls.call_args[0][0] + self.assertIn("--port=5353", cmd) + self.assertIn("--bogus-priv", cmd) + + +class TestStopDnsmasq(unittest.TestCase): + """Tests for stop_dnsmasq().""" + + def test_stop_dnsmasq_already_exited(self): + from net_test_env import stop_dnsmasq + + mock_proc = MagicMock() + mock_proc.poll.return_value = 0 + stop_dnsmasq(mock_proc) + mock_proc.terminate.assert_not_called() + + def test_stop_dnsmasq_graceful(self): + from net_test_env import stop_dnsmasq + + mock_proc = MagicMock() + mock_proc.poll.return_value = None + mock_proc.communicate.return_value = (b"", b"") + stop_dnsmasq(mock_proc) + mock_proc.terminate.assert_called_once() + mock_proc.kill.assert_not_called() + + def test_stop_dnsmasq_timeout_kills(self): + from net_test_env import stop_dnsmasq + + mock_proc = MagicMock() + mock_proc.poll.return_value = None + mock_proc.communicate.side_effect = subprocess.TimeoutExpired(cmd="dnsmasq", timeout=5) + stop_dnsmasq(mock_proc, timeout=5) + mock_proc.terminate.assert_called_once() + mock_proc.kill.assert_called_once() + + +class TestContextManager(unittest.TestCase): + """Tests for NetworkTestEnv as a context manager.""" + + @patch("net_test_env.TestHTTPServer") + @patch("net_test_env._kill_stale_dnsmasq") + @patch("net_test_env.start_dnsmasq") + @patch("net_test_env.stop_dnsmasq") + @patch("net_test_env.os.path.exists", return_value=True) + def test_context_manager_teardown_on_exception( + self, mock_exists, mock_stop, mock_start, mock_kill, mock_http_cls + ): + from net_test_env import NetworkTestEnv + + mock_proc = MagicMock() + mock_proc.returncode = 0 + mock_start.return_value = mock_proc + + try: + with NetworkTestEnv(setup_tap=False, verbose=False) as env: + raise ValueError("boom") + except ValueError: + pass + + mock_stop.assert_called_once_with(mock_proc) + + @patch("net_test_env.TestHTTPServer") + @patch("net_test_env._kill_stale_dnsmasq") + @patch("net_test_env.start_dnsmasq") + @patch("net_test_env.stop_dnsmasq") + @patch("net_test_env.os.path.exists", return_value=True) + def test_teardown_idempotent( + self, mock_exists, mock_stop, mock_start, mock_kill, mock_http_cls + ): + from net_test_env import NetworkTestEnv + + mock_proc = MagicMock() + mock_proc.returncode = 0 + mock_start.return_value = mock_proc + + env = NetworkTestEnv(setup_tap=False, verbose=False) + env.setup() + env.teardown() + env.teardown() # second call should be a no-op + + mock_stop.assert_called_once_with(mock_proc) + + +class TestDnsRecordsDefault(unittest.TestCase): + """Test default DNS records.""" + + def test_dns_records_default(self): + from net_test_env import NetworkTestEnv + env = NetworkTestEnv() + self.assertEqual(env.dns_records, {"c64test.local": "10.0.65.1"}) + + +class TestHTTPServerStarted(unittest.TestCase): + """Test that HTTP server is started when http_server=True.""" + + @patch("net_test_env.TestHTTPServer") + @patch("net_test_env._kill_stale_dnsmasq") + @patch("net_test_env.start_dnsmasq") + @patch("net_test_env.os.path.exists", return_value=True) + def test_http_server_started_when_enabled( + self, mock_exists, mock_start, mock_kill, mock_http_cls + ): + from net_test_env import NetworkTestEnv + + mock_proc = MagicMock() + mock_proc.poll.return_value = None + mock_proc.pid = 99 + mock_start.return_value = mock_proc + + mock_server = MagicMock() + mock_http_cls.return_value = mock_server + + env = NetworkTestEnv(setup_tap=False, http_server=True, verbose=False) + env.setup() + + mock_http_cls.assert_called_once_with( + host="10.0.65.1", port=80, ssl_context=None + ) + mock_server.start.assert_called_once() + + env.teardown() + + +if __name__ == "__main__": + unittest.main() From a7b676af130a79bf46a770da525b737b1afe0e86 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Sun, 12 Apr 2026 13:10:51 -0500 Subject: [PATCH 6/6] Add end-to-end bridge tests for DHCP and HTTP GET Vendor the c64-test-harness bridge networking scripts (setup + cleanup) and extend them with dnsmasq (DHCP + DNS overrides). Build a reusable tools/https_e2e/ library with BridgeEnv context manager, single-VICE launcher, boot menu helpers, and HTTP listener. Two passing e2e tests drive the real c64-https binary in VICE over RR-Net at normal speed: Phase 1 verifies DHCP, Phase 2 verifies plain HTTP GET to a local listener. HTTPS (Phase 3) to follow. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 2 + README.md | 29 ++++ scripts/cleanup-bridge-tap.sh | 161 ++++++++++++++++++ scripts/setup-bridge-tap.sh | 169 +++++++++++++++++++ tests/test_phase1_dhcp.py | 135 +++++++++++++++ tests/test_phase2_http.py | 270 ++++++++++++++++++++++++++++++ tools/https_e2e/__init__.py | 30 ++++ tools/https_e2e/c64_menu.py | 80 +++++++++ tools/https_e2e/env.py | 183 ++++++++++++++++++++ tools/https_e2e/http_listener.py | 78 +++++++++ tools/https_e2e/vice_on_bridge.py | 162 ++++++++++++++++++ 11 files changed, 1299 insertions(+) create mode 100755 scripts/cleanup-bridge-tap.sh create mode 100755 scripts/setup-bridge-tap.sh create mode 100644 tests/test_phase1_dhcp.py create mode 100644 tests/test_phase2_http.py create mode 100644 tools/https_e2e/__init__.py create mode 100644 tools/https_e2e/c64_menu.py create mode 100644 tools/https_e2e/env.py create mode 100644 tools/https_e2e/http_listener.py create mode 100644 tools/https_e2e/vice_on_bridge.py diff --git a/.gitignore b/.gitignore index dc26f26..28b2cfe 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ __pycache__/ ip65-build/*.o ip65-build/*.bin ip65-build/*.map +.claude/ +.serena/ diff --git a/README.md b/README.md index 4175125..4eb9506 100644 --- a/README.md +++ b/README.md @@ -155,8 +155,37 @@ python3 tools/bench_x25519.py # X25519 key generation (~3.6 min C64 time # Integration tests (require tap-c64 interface, dnsmasq; see scripts/setup-tap-networking.sh in c64-test-harness) python3 tools/test_dns.py # 4 tests: DNS resolution via ip65 over TAP (known host, second host, unknown host) python3 tools/test_http_integration.py # 5 tests: end-to-end plain HTTP GET over TAP (DNS + TCP + request/response) + +# End-to-end bridge tests (require br-c64 bridge, RR-Net; see below) +sudo PYTHONPATH=tools python3 tests/test_phase1_dhcp.py # DHCP over RR-Net bridge +sudo PYTHONPATH=tools python3 tests/test_phase2_http.py # Plain HTTP GET over bridge ``` +### End-to-End Bridge Tests + +Full end-to-end tests that drive the real c64-https binary in VICE over a Linux bridge with RR-Net ethernet (the same pattern used by [`c64-test-harness` bridge networking](../c64-test-harness/docs/bridge_networking.md)). VICE runs at **normal speed** (warp breaks RR-Net DHCP), so these tests need generous timeouts (~90-120s per phase). + +**Setup:** + +```bash +# Create the bridge, TAP interfaces, and start dnsmasq (DHCP + DNS) +sudo ./scripts/setup-bridge-tap.sh + +# Tear down (also handles stale VICE processes, legacy tap-c64, vicerc files) +sudo ./scripts/cleanup-bridge-tap.sh +``` + +The setup script creates `br-c64` with `tap-c64-0`/`tap-c64-1`, assigns `10.0.65.1/24` to the bridge, and starts dnsmasq providing DHCP (pool 10.0.65.50-150) with DNS overrides (`zimmers.net` and `apple.com` → `10.0.65.1`). The `BridgeEnv` context manager in `tools/https_e2e/env.py` wraps both scripts for use in tests. + +**Library:** `tools/https_e2e/` exposes a reusable public API: + +| Module | Public API | +|--------|-----------| +| `env.py` | `BridgeEnv` (context manager), `check_prerequisites()` | +| `vice_on_bridge.py` | `launch_vice_on_bridge()` → `ViceHandle`, `shutdown_vice()` | +| `c64_menu.py` | `press_key()`, `wait_for_screen_text()`, `get_screen_text()` | +| `http_listener.py` | `start_http_listener()` → `HttpListenerHandle`, `stop_http_listener()` | + ## Related Projects - [c64-aes256-ecdsa](../c64-aes256-ecdsa) — AES-256, SHA-256, ECDSA P-256, HMAC-DRBG diff --git a/scripts/cleanup-bridge-tap.sh b/scripts/cleanup-bridge-tap.sh new file mode 100755 index 0000000..fbf561f --- /dev/null +++ b/scripts/cleanup-bridge-tap.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# cleanup-bridge-tap.sh -- Tear down the bridge+dnsmasq env set up by +# setup-bridge-tap.sh. Idempotent -- safe to run if already torn down. +# +# Kills any leftover VICE processes, tears down the br-c64 bridge and +# its tap-c64-0/tap-c64-1 interfaces, removes the iptables FORWARD rules, +# and cleans up stale /tmp/vice_eth_*.rc files. +# +# Usage: +# sudo ./scripts/cleanup-bridge-tap.sh + +set -u # don't set -e: we want to keep going through all cleanup steps + +BRIDGE="br-c64" +TAP0="tap-c64-0" +TAP1="tap-c64-1" +TAP_LEGACY="tap-c64" +DNSMASQ_PIDFILE="/tmp/c64-https-dnsmasq.pid" + +echo "=== c64-https bridge networking cleanup ===" +echo + +# --- 1. Kill any leftover x64sc processes ------------------------------------ +echo "[1/6] Killing any leftover x64sc processes..." +if pgrep -x x64sc > /dev/null 2>&1; then + pgrep -a x64sc | while read -r pid cmd; do + echo " killing PID $pid: $cmd" + done + pkill -TERM x64sc 2>/dev/null || true + sleep 1 + if pgrep -x x64sc > /dev/null 2>&1; then + pkill -KILL x64sc 2>/dev/null || true + sleep 1 + fi + if pgrep -x x64sc > /dev/null 2>&1; then + echo " WARNING: x64sc still running after SIGKILL" + else + echo " all x64sc processes killed" + fi +else + echo " no x64sc processes running" +fi +echo + +# --- 2. Kill dnsmasq (pidfile + /proc scan) ---------------------------------- +echo "[2/6] Killing dnsmasq processes..." +found_dns=0 + +# 2a. Primary path: pidfile +if [[ -f "$DNSMASQ_PIDFILE" ]]; then + PID="$(cat "$DNSMASQ_PIDFILE" 2>/dev/null || true)" + if [[ -n "$PID" ]] && kill -0 "$PID" 2>/dev/null; then + if grep -q dnsmasq "/proc/$PID/comm" 2>/dev/null; then + kill "$PID" 2>/dev/null || true + for _ in 1 2 3 4 5; do + kill -0 "$PID" 2>/dev/null || break + sleep 0.2 + done + kill -9 "$PID" 2>/dev/null || true + echo " [killed] dnsmasq pid=$PID (via pidfile)" + found_dns=1 + else + echo " [ok] pidfile pid $PID is not dnsmasq, skipping" + fi + else + echo " [ok] dnsmasq pidfile pid $PID already gone" + fi + rm -f "$DNSMASQ_PIDFILE" +fi + +# 2b. Fallback: scan /proc cmdlines for dnsmasq bound to our TAPs/bridge +if command -v pgrep > /dev/null; then + while read -r pid; do + if [[ -n "$pid" ]]; then + cmdline=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null || echo "") + if echo "$cmdline" | grep -qE "(tap-c64-|br-c64|tap-c64)"; then + echo " [killed] dnsmasq pid=$pid (via /proc scan): $cmdline" + kill -TERM "$pid" 2>/dev/null || true + found_dns=1 + fi + fi + done < <(pgrep -x dnsmasq 2>/dev/null) +fi + +if [[ "$found_dns" == "0" ]]; then + echo " no dnsmasq processes found" +fi +echo + +# --- 3. Remove iptables FORWARD rules ---------------------------------------- +echo "[3/6] Removing iptables FORWARD rules..." +removed=0 +for DEV in "$BRIDGE" "$TAP0" "$TAP1" "$TAP_LEGACY"; do + if iptables -D FORWARD -i "$DEV" -j ACCEPT 2>/dev/null; then + echo " [removed] FORWARD -i $DEV" + removed=$((removed + 1)) + fi + if iptables -D FORWARD -o "$DEV" -j ACCEPT 2>/dev/null; then + echo " [removed] FORWARD -o $DEV" + removed=$((removed + 1)) + fi +done +if [[ "$removed" == "0" ]]; then + echo " no FORWARD rules to remove" +fi +echo + +# --- 4. Tear down TAP interfaces and bridge ----------------------------------- +echo "[4/6] Tearing down TAP interfaces and bridge..." +for TAP_DEV in "$TAP0" "$TAP1" "$TAP_LEGACY"; do + if ip link show "$TAP_DEV" > /dev/null 2>&1; then + ip link set "$TAP_DEV" down 2>/dev/null || true + ip tuntap del dev "$TAP_DEV" mode tap 2>/dev/null + if ip link show "$TAP_DEV" > /dev/null 2>&1; then + echo " WARNING: $TAP_DEV still exists" + else + echo " [removed] $TAP_DEV" + fi + else + echo " [ok] $TAP_DEV already absent" + fi +done + +if ip link show "$BRIDGE" > /dev/null 2>&1; then + ip link set "$BRIDGE" down 2>/dev/null || true + ip link del "$BRIDGE" type bridge 2>/dev/null + if ip link show "$BRIDGE" > /dev/null 2>&1; then + echo " WARNING: $BRIDGE still exists" + else + echo " [removed] $BRIDGE" + fi +else + echo " [ok] $BRIDGE already absent" +fi +echo + +# --- 5. Remove stale temp vicerc files ---------------------------------------- +echo "[5/6] Removing stale /tmp/vice_eth_*.rc files..." +shopt -s nullglob +rc_files=(/tmp/vice_eth_*.rc) +if [[ ${#rc_files[@]} -gt 0 ]]; then + for f in "${rc_files[@]}"; do + rm -f "$f" && echo " [removed] $f" + done +else + echo " no stale vicerc files" +fi +shopt -u nullglob +echo + +# --- 6. Remove stale dnsmasq pidfile (if not already cleaned) ----------------- +echo "[6/6] Final pidfile cleanup..." +if [[ -f "$DNSMASQ_PIDFILE" ]]; then + rm -f "$DNSMASQ_PIDFILE" + echo " [removed] $DNSMASQ_PIDFILE" +else + echo " [ok] no stale pidfile" +fi +echo + +echo "=== Cleanup complete ===" diff --git a/scripts/setup-bridge-tap.sh b/scripts/setup-bridge-tap.sh new file mode 100755 index 0000000..c9a52a9 --- /dev/null +++ b/scripts/setup-bridge-tap.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# setup-bridge-tap.sh -- Bridge + TAP + dnsmasq for c64-https end-to-end tests. +# +# Vendored and extended from c64-test-harness/scripts/setup-bridge-tap.sh. +# Creates br-c64 with tap-c64-0 and tap-c64-1, host IP 10.0.65.1/24, iptables +# FORWARD rules, and then starts a dnsmasq bound to br-c64 that: +# - serves DHCP leases on 10.0.65.50-10.0.65.150 (1h) +# - pushes default gw + DNS = 10.0.65.1 +# - overrides zimmers.net and apple.com to 10.0.65.1 +# +# Idempotent -- safe to run twice. Run via sudo. Pair with cleanup-bridge-tap.sh. +# +# Usage: +# sudo ./scripts/setup-bridge-tap.sh + +set -euo pipefail + +BRIDGE="br-c64" +BRIDGE_ADDR="10.0.65.1/24" +BRIDGE_IP="${BRIDGE_ADDR%/*}" +TAP0="tap-c64-0" +TAP1="tap-c64-1" +TAP_USER="${SUDO_USER:-$USER}" + +DNSMASQ_PIDFILE="/tmp/c64-https-dnsmasq.pid" +DNSMASQ_LOGFILE="/tmp/c64-https-dnsmasq.log" +DHCP_RANGE_START="10.0.65.50" +DHCP_RANGE_END="10.0.65.150" +DHCP_LEASE="1h" + +echo "Bridge: $BRIDGE ($BRIDGE_ADDR)" +echo "TAP devices: $TAP0, $TAP1 (owner: $TAP_USER)" +echo "dnsmasq: pid=$DNSMASQ_PIDFILE log=$DNSMASQ_LOGFILE" +echo + +# --- Bridge ------------------------------------------------------------------ + +if ip link show "$BRIDGE" &>/dev/null; then + echo "[ok] $BRIDGE already exists" +else + ip link add name "$BRIDGE" type bridge + echo "[created] $BRIDGE" +fi + +if [[ -f "/sys/devices/virtual/net/$BRIDGE/bridge/stp_state" ]]; then + if [[ "$(cat /sys/devices/virtual/net/$BRIDGE/bridge/stp_state)" != "0" ]]; then + ip link set "$BRIDGE" type bridge stp_state 0 + echo "[disabled] STP on $BRIDGE" + fi +fi + +if ip addr show "$BRIDGE" | grep -q "$BRIDGE_IP"; then + echo "[ok] $BRIDGE has $BRIDGE_ADDR" +else + ip addr add "$BRIDGE_ADDR" dev "$BRIDGE" + echo "[addr] $BRIDGE_ADDR assigned" +fi + +if ip link show "$BRIDGE" | grep -q 'state UP'; then + echo "[ok] $BRIDGE is UP" +else + ip link set "$BRIDGE" up + echo "[up] $BRIDGE" +fi + +# --- TAP interfaces ---------------------------------------------------------- + +for TAP_DEV in "$TAP0" "$TAP1"; do + if ip link show "$TAP_DEV" &>/dev/null; then + echo "[ok] $TAP_DEV already exists" + else + ip tuntap add dev "$TAP_DEV" mode tap user "$TAP_USER" + echo "[created] $TAP_DEV" + fi + + if ip link show "$TAP_DEV" 2>/dev/null | grep -q "master $BRIDGE"; then + echo "[ok] $TAP_DEV already bridged" + else + ip link set "$TAP_DEV" master "$BRIDGE" + echo "[bridge] $TAP_DEV added to $BRIDGE" + fi + + if ip link show "$TAP_DEV" | grep -q 'state UP'; then + echo "[ok] $TAP_DEV is UP" + else + ip link set "$TAP_DEV" up + echo "[up] $TAP_DEV" + fi +done + +# --- iptables FORWARD rules -------------------------------------------------- + +for DEV in "$BRIDGE" "$TAP0" "$TAP1"; do + if ! iptables -C FORWARD -i "$DEV" -j ACCEPT 2>/dev/null; then + iptables -A FORWARD -i "$DEV" -j ACCEPT + echo "[added] FORWARD: $DEV inbound" + fi + if ! iptables -C FORWARD -o "$DEV" -j ACCEPT 2>/dev/null; then + iptables -A FORWARD -o "$DEV" -j ACCEPT + echo "[added] FORWARD: $DEV outbound" + fi +done + +# --- dnsmasq ----------------------------------------------------------------- +# Stop any stale dnsmasq we previously started. + +if [[ -f "$DNSMASQ_PIDFILE" ]]; then + OLD_PID="$(cat "$DNSMASQ_PIDFILE" 2>/dev/null || true)" + if [[ -n "$OLD_PID" ]] && kill -0 "$OLD_PID" 2>/dev/null; then + # Only kill if it's actually a dnsmasq process + if grep -q dnsmasq "/proc/$OLD_PID/comm" 2>/dev/null; then + kill "$OLD_PID" 2>/dev/null || true + sleep 0.3 + kill -9 "$OLD_PID" 2>/dev/null || true + echo "[killed] stale dnsmasq pid=$OLD_PID" + fi + fi + rm -f "$DNSMASQ_PIDFILE" +fi + +if ! command -v dnsmasq >/dev/null 2>&1; then + echo "ERROR: dnsmasq not installed" >&2 + exit 1 +fi + +# Start dnsmasq as a daemon with its own pidfile. --bind-interfaces + listen +# on the bridge ip so we don't clash with a system resolver on other ifaces. +: >"$DNSMASQ_LOGFILE" +dnsmasq \ + --keep-in-foreground \ + --pid-file="$DNSMASQ_PIDFILE" \ + --interface="$BRIDGE" \ + --bind-interfaces \ + --listen-address="$BRIDGE_IP" \ + --no-resolv \ + --no-hosts \ + --dhcp-range="$DHCP_RANGE_START,$DHCP_RANGE_END,255.255.255.0,$DHCP_LEASE" \ + --dhcp-option=3,"$BRIDGE_IP" \ + --dhcp-option=6,"$BRIDGE_IP" \ + --address=/zimmers.net/"$BRIDGE_IP" \ + --address=/apple.com/"$BRIDGE_IP" \ + --log-queries \ + --log-dhcp \ + >>"$DNSMASQ_LOGFILE" 2>&1 & +DNSMASQ_PID=$! +disown "$DNSMASQ_PID" 2>/dev/null || true + +# dnsmasq in --keep-in-foreground does NOT write the pidfile itself, so we +# write the child PID manually. +echo "$DNSMASQ_PID" >"$DNSMASQ_PIDFILE" + +# Wait briefly for it to bind. +for _ in 1 2 3 4 5 6 7 8 9 10; do + if ! kill -0 "$DNSMASQ_PID" 2>/dev/null; then + echo "ERROR: dnsmasq exited early. Log tail:" >&2 + tail -20 "$DNSMASQ_LOGFILE" >&2 || true + exit 1 + fi + if ss -lnup 2>/dev/null | grep -q "$BRIDGE_IP:53" \ + && ss -lnup 2>/dev/null | grep -q "$BRIDGE_IP:67"; then + break + fi + sleep 0.2 +done +echo "[dnsmasq] pid=$DNSMASQ_PID bound to $BRIDGE_IP (DHCP $DHCP_RANGE_START-$DHCP_RANGE_END)" + +echo +echo "Done. Bridge $BRIDGE ready, dnsmasq serving DHCP+DNS on $BRIDGE_IP." +echo "Tear down with: sudo ./scripts/cleanup-bridge-tap.sh" diff --git a/tests/test_phase1_dhcp.py b/tests/test_phase1_dhcp.py new file mode 100644 index 0000000..ad12dc3 --- /dev/null +++ b/tests/test_phase1_dhcp.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Phase 1 e2e test: boot c64-https.prg in VICE, press I, see DHCP OK. + +This test runs the real c64-https binary in VICE on a Linux bridge with +RR-Net ethernet and a host-side dnsmasq. It exercises ip65's net_dhcp +end-to-end. It touches NO TLS/HTTP logic -- it only asserts that the +boot menu appears and that pressing 'I' produces the 'DHCP OK' banner. + +Run: + PYTHONPATH=tools python3 tests/test_phase1_dhcp.py + +Exit codes: + 0 -- PASS + 0 -- SKIP (clearly printed) + 1 -- FAIL +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys + +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +_TOOLS = os.path.join(_REPO_ROOT, "tools") +if _TOOLS not in sys.path: + sys.path.insert(0, _TOOLS) + +PRG_PATH = os.path.join(_REPO_ROOT, "build", "c64-https.prg") + +# Exact literal from src/boot.asm (menu_msg @ line 424-426). +MENU_NEEDLE = "Q=QUIT" +# dhcp_ok_msg @ boot.asm:448 is "DHCP OK - IP: ". Match the load-bearing prefix. +DHCP_OK_NEEDLE = "DHCP OK" + +MENU_TIMEOUT = 90.0 +DHCP_TIMEOUT = 90.0 + + +def _skip(reason: str) -> int: + print(f"SKIP: {reason}") + return 0 + + +def _ensure_built() -> bool: + if os.path.isfile(PRG_PATH): + return True + print("[build] c64-https.prg missing, running make...") + r = subprocess.run(["make"], cwd=_REPO_ROOT, capture_output=True, text=True) + if r.returncode != 0: + print(f" make failed (exit {r.returncode}):\n{r.stderr}") + return False + return os.path.isfile(PRG_PATH) + + +def main() -> int: + # ---- Prerequisite / skip gating ---------------------------------------- + from https_e2e import ( + BridgeEnv, + check_prerequisites, + launch_vice_on_bridge, + shutdown_vice, + press_key, + wait_for_screen_text, + get_screen_text, + ) + + missing = check_prerequisites() + if missing: + return _skip("missing prerequisites: " + "; ".join(missing)) + + if not _ensure_built(): + return _skip("c64-https.prg could not be built") + + # ---- Run the test ------------------------------------------------------ + handle = None + try: + with BridgeEnv() as env: + try: + print(f"\n=== Launching VICE on {env.tap0} with {PRG_PATH} ===") + handle = launch_vice_on_bridge( + prg_path=PRG_PATH, + tap=env.tap0, + ready_timeout=90.0, + ) + transport = handle.transport + + print(f"\n=== Waiting for boot menu ({MENU_NEEDLE!r}) ===") + try: + wait_for_screen_text( + transport, MENU_NEEDLE, timeout=MENU_TIMEOUT + ) + except TimeoutError as e: + print(f"FAIL: boot menu did not appear\n{e}") + return 1 + print(" boot menu OK") + + print("\n=== Pressing 'I' for DHCP init ===") + press_key(transport, "I") + + print(f"\n=== Waiting up to {DHCP_TIMEOUT:.0f}s for {DHCP_OK_NEEDLE!r} ===") + try: + final = wait_for_screen_text( + transport, DHCP_OK_NEEDLE, timeout=DHCP_TIMEOUT + ) + except TimeoutError as e: + print(f"FAIL: DHCP did not complete\n{e}") + dnsmasq_log = "/tmp/c64-https-dnsmasq.log" + if os.path.isfile(dnsmasq_log): + print(f"\n--- tail of {dnsmasq_log} ---") + with open(dnsmasq_log, "rb") as f: + data = f.read()[-4000:] + print(data.decode("utf-8", errors="replace")) + return 1 + + print("\n=== PASS: DHCP OK seen on screen ===") + snippet = "\n".join(final.splitlines()[:25]) + print(f"--- final screen (first 25 lines) ---\n{snippet}") + return 0 + finally: + # Shut VICE down BEFORE BridgeEnv tears down the TAPs. + if handle is not None: + try: + shutdown_vice(handle) + except Exception as e: # noqa: BLE001 + print(f" shutdown_vice: {e}") + handle = None + except Exception as e: + print(f"FAIL: unexpected error: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_phase2_http.py b/tests/test_phase2_http.py new file mode 100644 index 0000000..6375b0b --- /dev/null +++ b/tests/test_phase2_http.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Phase 2 e2e test: boot c64-https.prg, do DHCP, then plain HTTP GET. + +This test extends Phase 1 by pressing 'H' after DHCP succeeds, which +triggers a plain HTTP GET to zimmers.net (resolved via dnsmasq to the +host bridge IP 10.0.65.1). A Python HTTP server on 10.0.65.1:80 serves +a known response body. + +Run: + sudo PYTHONPATH=tools python3 tests/test_phase2_http.py + +Exit codes: + 0 -- PASS + 0 -- SKIP (clearly printed) + 1 -- FAIL +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import time + +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +_TOOLS = os.path.join(_REPO_ROOT, "tools") +if _TOOLS not in sys.path: + sys.path.insert(0, _TOOLS) + +PRG_PATH = os.path.join(_REPO_ROOT, "build", "c64-https.prg") + +# Screen needles (from src/boot.asm string labels). +MENU_NEEDLE = "Q=QUIT" +DHCP_OK_NEEDLE = "DHCP OK" +# Response body served by our test HTTP server. +RESPONSE_BODY = "HELLO FROM TEST SERVER" + +MENU_TIMEOUT = 90.0 +DHCP_TIMEOUT = 90.0 +HTTP_TIMEOUT = 120.0 + + +def _skip(reason: str) -> int: + print(f"SKIP: {reason}") + return 0 + + +def _ensure_built() -> bool: + if os.path.isfile(PRG_PATH): + return True + print("[build] c64-https.prg missing, running make...") + r = subprocess.run(["make"], cwd=_REPO_ROOT, capture_output=True, text=True) + if r.returncode != 0: + print(f" make failed (exit {r.returncode}):\n{r.stderr}") + return False + return os.path.isfile(PRG_PATH) + + +def _dump_diagnostics(transport=None) -> None: + """Print dnsmasq log and host-side connectivity checks for post-mortem.""" + dnsmasq_log = "/tmp/c64-https-dnsmasq.log" + if os.path.isfile(dnsmasq_log): + print(f"\n--- tail of {dnsmasq_log} ---") + with open(dnsmasq_log, "rb") as f: + data = f.read()[-4000:] + print(data.decode("utf-8", errors="replace")) + + # Host-side DNS check + try: + r = subprocess.run( + ["dig", "+short", "@10.0.65.1", "www.zimmers.net"], + capture_output=True, text=True, timeout=5, + ) + print(f"\n dig @10.0.65.1 www.zimmers.net -> {r.stdout.strip()}") + except Exception as e: + print(f" dig check failed: {e}") + + # Host-side HTTP check + try: + import urllib.request + resp = urllib.request.urlopen("http://10.0.65.1:80/", timeout=3) + print(f" HTTP from host: {resp.status} {resp.read()[:100]}") + except Exception as e: + print(f" HTTP from host failed: {e}") + + # ip65 error code from C64 memory + if transport is not None: + try: + transport.resume() + err_data = transport.read_memory(0x4CEA, 1) + print(f" ip65_error at $4CEA = 0x{err_data[0]:02X}") + except Exception as e: + print(f" ip65_error read failed: {e}") + + +def main() -> int: + from https_e2e import ( + BridgeEnv, + check_prerequisites, + launch_vice_on_bridge, + shutdown_vice, + press_key, + wait_for_screen_text, + get_screen_text, + start_http_listener, + stop_http_listener, + ) + + missing = check_prerequisites() + if missing: + return _skip("missing prerequisites: " + "; ".join(missing)) + + if not _ensure_built(): + return _skip("c64-https.prg could not be built") + + handle = None + listener = None + try: + with BridgeEnv() as env: + try: + # --- Start HTTP listener on bridge IP --- + print(f"\n=== Starting HTTP listener on {env.bridge_ip}:80 ===") + listener = start_http_listener( + host=env.bridge_ip, + port=80, + response_body=RESPONSE_BODY, + ) + print(f" listener ready on {listener.host}:{listener.port}") + + # --- Launch VICE --- + print(f"\n=== Launching VICE on {env.tap0} with {PRG_PATH} ===") + handle = launch_vice_on_bridge( + prg_path=PRG_PATH, + tap=env.tap0, + ready_timeout=90.0, + ) + transport = handle.transport + + # --- Wait for boot menu --- + print(f"\n=== Waiting for boot menu ({MENU_NEEDLE!r}) ===") + try: + wait_for_screen_text(transport, MENU_NEEDLE, timeout=MENU_TIMEOUT) + except TimeoutError as e: + print(f"FAIL: boot menu did not appear\n{e}") + return 1 + print(" boot menu OK") + + # --- DHCP init --- + print("\n=== Pressing 'I' for DHCP init ===") + press_key(transport, "I") + + print(f"\n=== Waiting up to {DHCP_TIMEOUT:.0f}s for {DHCP_OK_NEEDLE!r} ===") + try: + wait_for_screen_text(transport, DHCP_OK_NEEDLE, timeout=DHCP_TIMEOUT) + except TimeoutError as e: + print(f"FAIL: DHCP did not complete\n{e}") + _dump_diagnostics(transport) + return 1 + print(" DHCP OK") + + # --- HTTP GET --- + print("\n=== Pressing 'H' for plain HTTP GET ===") + press_key(transport, "H") + + print(f"\n=== Waiting up to {HTTP_TIMEOUT:.0f}s for HTTP OK ===") + # After pressing H, the C64 prints: + # "HTTP GET WWW.ZIMMERS.NET..." + # then on success: "OK" followed by response body, + # or on failure: "FAILED". + # + # We cannot simply wait_for_screen_text("OK") because + # "DHCP OK" is already on screen. Instead we poll and + # look for "OK" appearing *after* the "HTTP GET" line, + # or for "FAILED" after it, or for the response body. + deadline = time.monotonic() + HTTP_TIMEOUT + final = "" + http_started = False + result = None # "pass" | "fail" + + while time.monotonic() < deadline: + try: + transport.resume() + except Exception: + pass + time.sleep(2.0) + try: + final = get_screen_text(transport) + except Exception: + continue + + upper = final.upper() + + # Check if the HTTP GET banner appeared + idx_get = upper.find("HTTP GET") + if idx_get < 0: + continue + if not http_started: + print(" HTTP GET initiated") + http_started = True + + after_get = upper[idx_get:] + + # Check for FAILED after HTTP GET + if "FAILED" in after_get: + result = "fail" + break + + # Check for OK after HTTP GET line (not DHCP OK). + lines_after = after_get.split("\n") + for line in lines_after[1:]: # skip "HTTP GET..." line + stripped = line.strip() + if stripped == "OK" or stripped.startswith("OK"): + result = "pass" + break + + # Also check for response body as a success indicator. + if RESPONSE_BODY[:12].upper() in upper: + result = "pass" + + if result: + break + + if result == "fail" or result != "pass": + reason = ("HTTP GET reported FAILED" if result == "fail" + else f"HTTP GET did not complete within {HTTP_TIMEOUT:.0f}s") + print(f"FAIL: {reason}") + if result != "fail": + try: + final = get_screen_text(transport) + except Exception: + pass + print(f"\n--- final screen ---\n{final}") + _dump_diagnostics(transport) + return 1 + + print("\n=== PASS: HTTP GET OK seen on screen ===") + snippet = "\n".join(final.splitlines()[:25]) + print(f"--- final screen (first 25 lines) ---\n{snippet}") + + # Check for response body on screen. + body_upper = RESPONSE_BODY.upper() + if body_upper in final.upper(): + print(f" response body verified: {RESPONSE_BODY!r}") + else: + # Not a hard failure -- the body might have scrolled off. + print(f" (response body not found on screen, may have scrolled)") + + return 0 + finally: + if listener is not None: + try: + stop_http_listener(listener) + except Exception as e: + print(f" stop_http_listener: {e}") + listener = None + if handle is not None: + try: + shutdown_vice(handle) + except Exception as e: + print(f" shutdown_vice: {e}") + handle = None + except Exception as e: + print(f"FAIL: unexpected error: {e}") + import traceback + traceback.print_exc() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/https_e2e/__init__.py b/tools/https_e2e/__init__.py new file mode 100644 index 0000000..20e3943 --- /dev/null +++ b/tools/https_e2e/__init__.py @@ -0,0 +1,30 @@ +"""https_e2e -- End-to-end test helpers for the c64-https program. + +Public API used by tests/test_phase1_dhcp.py and (later) higher phases: + + from https_e2e import ( + BridgeEnv, + launch_vice_on_bridge, shutdown_vice, + press_key, wait_for_screen_text, + check_prerequisites, + ) + +Internals live in underscored helpers in each submodule. +""" + +from .env import BridgeEnv, check_prerequisites +from .vice_on_bridge import launch_vice_on_bridge, shutdown_vice +from .c64_menu import press_key, wait_for_screen_text, get_screen_text +from .http_listener import start_http_listener, stop_http_listener + +__all__ = [ + "BridgeEnv", + "check_prerequisites", + "launch_vice_on_bridge", + "shutdown_vice", + "press_key", + "wait_for_screen_text", + "get_screen_text", + "start_http_listener", + "stop_http_listener", +] diff --git a/tools/https_e2e/c64_menu.py b/tools/https_e2e/c64_menu.py new file mode 100644 index 0000000..bcc8336 --- /dev/null +++ b/tools/https_e2e/c64_menu.py @@ -0,0 +1,80 @@ +"""Keyboard / screen helpers for interacting with the c64-https boot menu. + +Everything goes through the canonical c64-test-harness entry points: +- keyboard input uses transport.inject_keys() -- the same path as + harness.send_key() +- screen reads use ScreenGrid.from_transport(); between polls we call + transport.resume() so the binary monitor's memory read does not leave + the CPU paused. +""" + +from __future__ import annotations + +import time +from typing import Optional + +from c64_test_harness.backends.vice_binary import BinaryViceTransport +from c64_test_harness.screen import ScreenGrid + + +def press_key(transport: BinaryViceTransport, ch: str | int) -> None: + """Press a single ASCII/PETSCII key on the C64. + + Accepts either a one-character str (upper- or lower-case) or an int + (raw PETSCII / screen code). For letters, we send the uppercase ASCII + value -- the boot menu reads $49 etc. via CHRIN which handles this. + """ + if isinstance(ch, str): + if len(ch) != 1: + raise ValueError(f"press_key: expected 1 char, got {ch!r}") + code = ord(ch.upper()) + else: + code = int(ch) & 0xFF + # Ensure CPU isn't paused from a prior screen read. + try: + transport.resume() + except Exception: # noqa: BLE001 + pass + transport.inject_keys([code]) + + +def get_screen_text(transport: BinaryViceTransport) -> str: + """Read the current C64 screen as a flat string.""" + grid = ScreenGrid.from_transport(transport) + return grid.continuous_text() + + +def wait_for_screen_text( + transport: BinaryViceTransport, + needle: str, + timeout: float = 90.0, + poll_interval: float = 0.75, + verbose: bool = False, +) -> str: + """Poll the screen until `needle` appears (case-insensitive). + + Returns the final screen text on success. Raises TimeoutError on + failure, with the last screen text in the exception message. + """ + needle_upper = needle.upper() + deadline = time.monotonic() + timeout + last_text = "" + last_err: Exception | None = None + while time.monotonic() < deadline: + try: + # Binary monitor pauses CPU on reads -- resume each iteration. + transport.resume() + time.sleep(poll_interval) + last_text = get_screen_text(transport) + if needle_upper in last_text.upper(): + if verbose: + print(f"[screen] matched {needle!r}") + return last_text + except Exception as e: # noqa: BLE001 + last_err = e + time.sleep(0.3) + raise TimeoutError( + f"screen text {needle!r} not seen within {timeout:.0f}s.\n" + f"Last screen text:\n{last_text!r}\n" + f"Last poll error: {last_err}" + ) diff --git a/tools/https_e2e/env.py b/tools/https_e2e/env.py new file mode 100644 index 0000000..f94aa79 --- /dev/null +++ b/tools/https_e2e/env.py @@ -0,0 +1,183 @@ +"""BridgeEnv -- context manager wrapping scripts/setup-bridge-tap.sh. + +Runs the vendored setup script (br-c64 + tap-c64-0/1 + dnsmasq) on __enter__ +and the cleanup script on __exit__. Polls until dnsmasq is listening on +10.0.65.1:53 (DNS UDP) and :67 (DHCP). Tolerates repeated entry by letting +the setup script itself be idempotent. +""" + +from __future__ import annotations + +import os +import shutil +import socket +import subprocess +import time +from contextlib import contextmanager + +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +_SETUP_SCRIPT = os.path.join(_REPO_ROOT, "scripts", "setup-bridge-tap.sh") +_CLEANUP_SCRIPT = os.path.join(_REPO_ROOT, "scripts", "cleanup-bridge-tap.sh") + +BRIDGE_IP = "10.0.65.1" +BRIDGE_IFACE = "br-c64" +TAP0 = "tap-c64-0" +TAP1 = "tap-c64-1" + + +def check_prerequisites() -> list[str]: + """Return a list of missing prereqs. Empty list means all OK.""" + missing: list[str] = [] + for tool in ("x64sc", "dnsmasq", "sudo", "ip", "iptables"): + if shutil.which(tool) is None: + missing.append(f"{tool} not on PATH") + if not os.path.isfile(_SETUP_SCRIPT): + missing.append(f"setup script missing: {_SETUP_SCRIPT}") + if not os.path.isfile(_CLEANUP_SCRIPT): + missing.append(f"cleanup script missing: {_CLEANUP_SCRIPT}") + # sudo without password? + try: + r = subprocess.run( + ["sudo", "-n", "true"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=5, + ) + if r.returncode != 0: + missing.append("sudo requires a password (NOPASSWD not configured)") + except (OSError, subprocess.TimeoutExpired) as e: + missing.append(f"sudo probe failed: {e}") + return missing + + +def _port_open_udp(host: str, port: int) -> bool: + """Crude UDP 'is something listening' probe -- check /proc/net/udp.""" + # UDP sockets don't accept connections, so best to scan /proc/net/udp. + try: + with open("/proc/net/udp", "r") as f: + lines = f.read().splitlines()[1:] + except OSError: + return False + # Format: sl local_address rem_address st ... + # local_address is HEX_IP:HEX_PORT where HEX_IP is little-endian for IPv4. + try: + packed = socket.inet_aton(host) + hex_ip = "".join(f"{b:02X}" for b in reversed(packed)) + except OSError: + return False + needle = f"{hex_ip}:{port:04X}" + for line in lines: + parts = line.split() + if len(parts) >= 2 and parts[1].upper() == needle: + return True + return False + + +_DNSMASQ_PIDFILE = "/tmp/c64-https-dnsmasq.pid" + + +def _pid_alive(pid: int) -> bool: + try: + os.kill(pid, 0) + except PermissionError: + return True # process exists but owned by another user (e.g. nobody) + except (OSError, ProcessLookupError): + return False + return True + + +def _wait_for_dnsmasq(timeout: float = 10.0) -> None: + """Wait until dnsmasq is serving DNS on 10.0.65.1:53. + + dnsmasq's DHCP listener uses a raw packet socket (not a regular UDP + socket bound to :67), so we only check :53 for the UDP listener and + rely on the pidfile + process liveness as the DHCP-ready signal. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + dns_ok = _port_open_udp(BRIDGE_IP, 53) + pid_ok = False + if os.path.isfile(_DNSMASQ_PIDFILE): + try: + with open(_DNSMASQ_PIDFILE) as f: + pid = int(f.read().strip()) + pid_ok = _pid_alive(pid) + except (OSError, ValueError): + pid_ok = False + if dns_ok and pid_ok: + return + time.sleep(0.2) + raise RuntimeError( + f"dnsmasq not ready within {timeout}s " + f"(dns_on_{BRIDGE_IP}:53={_port_open_udp(BRIDGE_IP, 53)})" + ) + + +def _run_sudo_script(script: str) -> subprocess.CompletedProcess: + return subprocess.run( + ["sudo", script], + capture_output=True, + text=True, + ) + + +class BridgeEnv: + """Context manager that brings up br-c64 + taps + dnsmasq. + + Usage:: + + with BridgeEnv() as env: + # env.tap0 / env.bridge_ip available + ... + """ + + bridge_ip = BRIDGE_IP + bridge_iface = BRIDGE_IFACE + tap0 = TAP0 + tap1 = TAP1 + + def __init__(self, verbose: bool = True): + self.verbose = verbose + self._entered = False + + def __enter__(self) -> "BridgeEnv": + # Clean any stale state first so repeated entry is safe. + if self.verbose: + print(f"[BridgeEnv] cleanup stale state...") + _run_sudo_script(_CLEANUP_SCRIPT) # errors ignored + + if self.verbose: + print(f"[BridgeEnv] running setup: {_SETUP_SCRIPT}") + r = _run_sudo_script(_SETUP_SCRIPT) + if r.returncode != 0: + raise RuntimeError( + f"setup-bridge-tap.sh failed (exit {r.returncode}):\n" + f"STDOUT:\n{r.stdout}\nSTDERR:\n{r.stderr}" + ) + if self.verbose: + # Show a compact tail + tail = "\n".join(r.stdout.splitlines()[-6:]) + print(f"[BridgeEnv] setup ok:\n{tail}") + + if not os.path.isdir(f"/sys/class/net/{self.bridge_iface}"): + raise RuntimeError(f"{self.bridge_iface} not up after setup") + for t in (self.tap0, self.tap1): + if not os.path.isdir(f"/sys/class/net/{t}"): + raise RuntimeError(f"{t} not up after setup") + + _wait_for_dnsmasq(timeout=10.0) + if self.verbose: + print(f"[BridgeEnv] dnsmasq bound to {BRIDGE_IP}:53/67") + + self._entered = True + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + if self.verbose: + print(f"[BridgeEnv] cleanup...") + r = _run_sudo_script(_CLEANUP_SCRIPT) + if r.returncode != 0 and self.verbose: + print( + f"[BridgeEnv] cleanup non-zero exit={r.returncode}\n" + f"STDOUT:\n{r.stdout}\nSTDERR:\n{r.stderr}" + ) diff --git a/tools/https_e2e/http_listener.py b/tools/https_e2e/http_listener.py new file mode 100644 index 0000000..d559a28 --- /dev/null +++ b/tools/https_e2e/http_listener.py @@ -0,0 +1,78 @@ +"""Simple HTTP listener for e2e testing. + +Runs a background HTTP server on a specified host:port. Every GET request +returns a fixed 200 OK with a short body. The server runs in a daemon +thread so the test can drive VICE in the main thread. + +Binding to port 80 requires root. The test already runs under sudo +(BridgeEnv needs it), so no special handling is needed here. + +Public API: + start_http_listener(host, port) -> HttpListenerHandle + stop_http_listener(handle) +""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, HTTPServer + +# Fixed response body served for every GET. +DEFAULT_RESPONSE_BODY = "HELLO FROM TEST SERVER" + + +class _Handler(BaseHTTPRequestHandler): + """Serves a canned 200 OK response for any GET.""" + + # Class-level attribute set before server starts. + response_body: str = DEFAULT_RESPONSE_BODY + + def do_GET(self) -> None: # noqa: N802 + body = self.response_body.encode("ascii") + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + + # Silence per-request log lines. + def log_message(self, format: str, *args: object) -> None: # noqa: A002 + pass + + +@dataclass +class HttpListenerHandle: + """Returned by start_http_listener; pass to stop_http_listener.""" + server: HTTPServer + thread: threading.Thread + host: str + port: int + + +def start_http_listener( + host: str = "10.0.65.1", + port: int = 80, + response_body: str = DEFAULT_RESPONSE_BODY, +) -> HttpListenerHandle: + """Start an HTTP server in a daemon thread. Returns a handle.""" + # Set the response body on the handler class before creating the server. + _Handler.response_body = response_body + + server = HTTPServer((host, port), _Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return HttpListenerHandle(server=server, thread=thread, host=host, port=port) + + +def stop_http_listener(handle: HttpListenerHandle) -> None: + """Shut the server down cleanly.""" + try: + handle.server.shutdown() + except Exception: # noqa: BLE001 + pass + try: + handle.server.server_close() + except Exception: # noqa: BLE001 + pass diff --git a/tools/https_e2e/vice_on_bridge.py b/tools/https_e2e/vice_on_bridge.py new file mode 100644 index 0000000..d805f5b --- /dev/null +++ b/tools/https_e2e/vice_on_bridge.py @@ -0,0 +1,162 @@ +"""Launch a single VICE instance on the c64-https bridge. + +Mirrors the single-instance half of c64-test-harness's bridge_vice_pair +fixture. Normal-speed RR-Net, CS8900a initialised, MAC programmed, PRG +autoloaded via ViceConfig.prg_path. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Optional + +from c64_test_harness.backends.vice_binary import BinaryViceTransport +from c64_test_harness.backends.vice_lifecycle import ViceConfig, ViceProcess +from c64_test_harness.backends.vice_manager import PortAllocator +from c64_test_harness.ethernet import set_cs8900a_mac +from c64_test_harness.execute import jsr, load_code +from c64_test_harness.memory import read_bytes +from c64_test_harness.screen import ScreenGrid +from c64_test_harness.bridge_ping import ( + cs8900a_rxctl_code, + cs8900a_read_linectl_code, + cs8900a_write_linectl_code, +) + +DEFAULT_MAC = bytes.fromhex("02C6400000A1") # 02:C6:40:00:00:A1 -- c64-https + + +@dataclass +class ViceHandle: + """Everything a test needs to drive and shut down a VICE instance.""" + process: ViceProcess + transport: BinaryViceTransport + allocator: PortAllocator + port: int + + +def _connect(port: int, proc: ViceProcess, timeout: float = 30.0) -> BinaryViceTransport: + deadline = time.monotonic() + timeout + last: Exception | None = None + while time.monotonic() < deadline: + try: + return BinaryViceTransport(port=port) + except Exception as e: # noqa: BLE001 + last = e + if proc._proc is not None and proc._proc.poll() is not None: + raise RuntimeError(f"VICE on port {port} exited early") from e + time.sleep(0.25) + raise RuntimeError(f"could not connect to VICE on port {port}: {last}") + + +def _wait_for_ready(transport: BinaryViceTransport, timeout: float = 60.0) -> None: + """Wait for either BASIC READY (no PRG autoload) or for an autostarted + program to have taken over the screen. We poll continuous_text() for + either 'READY' or common c64-https banner text. + """ + deadline = time.monotonic() + timeout + last_text = "" + while time.monotonic() < deadline: + try: + transport.resume() + time.sleep(0.5) + grid = ScreenGrid.from_transport(transport) + text = grid.continuous_text().upper() + last_text = text + if "READY" in text or "C64-HTTPS" in text or "Q=QUIT" in text: + return + except Exception: # noqa: BLE001 + time.sleep(0.3) + raise RuntimeError( + f"BASIC READY / banner not seen within {timeout}s. Last text:\n{last_text}" + ) + + +def _init_cs8900a(transport: BinaryViceTransport, code: int = 0xC000, scratch: int = 0xC1E0) -> None: + load_code(transport, code, cs8900a_rxctl_code()) + jsr(transport, code, timeout=5.0) + load_code(transport, code, cs8900a_read_linectl_code(scratch)) + jsr(transport, code, timeout=5.0) + linectl = read_bytes(transport, scratch, 2) + load_code(transport, code, cs8900a_write_linectl_code(linectl[0] | 0xC0, linectl[1])) + jsr(transport, code, timeout=5.0) + + +def launch_vice_on_bridge( + prg_path: str, + tap: str = "tap-c64-0", + mac: bytes = DEFAULT_MAC, + port_range: tuple[int, int] = (6560, 6580), + ready_timeout: float = 60.0, + verbose: bool = True, +) -> ViceHandle: + """Start one VICE on the bridge, autoload prg_path, init CS8900a. + + The program's own code is running by the time this returns -- because + we use -autostart, ip65 boots as soon as BASIC runs it. The CS8900a + init is NOT performed on c64-https (it takes over the chip itself); + we only run it here to match the harness pattern's "known-good" init + before the program grabs the chip. In practice c64-https re-initialises + the chip on its own so this is harmless. + + Returns a ViceHandle. Call shutdown_vice() to stop cleanly. + """ + allocator = PortAllocator(port_range_start=port_range[0], port_range_end=port_range[1]) + port = allocator.allocate() + res = allocator.take_socket(port) + if res is not None: + res.close() + + config = ViceConfig( + port=port, + prg_path=prg_path, + warp=False, # load-bearing: warp breaks DHCP + sound=False, + minimize=False, + ethernet=True, + ethernet_mode="rrnet", + ethernet_interface=tap, + ethernet_driver="tuntap", + extra_args=["-reu", "-reusize", "512"], # boot.asm uses REU for mul tables + ) + + proc = ViceProcess(config) + proc.start() + if verbose: + pid = proc._proc.pid if proc._proc is not None else "?" + print(f"[vice] started pid={pid} port={port} tap={tap}") + + try: + transport = _connect(port, proc, timeout=20.0) + _wait_for_ready(transport, timeout=ready_timeout) + # Best-effort: program a MAC via the harness helper. c64-https + # may overwrite this on its own init pass; that's fine. + try: + set_cs8900a_mac(transport, mac) + except Exception as e: # noqa: BLE001 + if verbose: + print(f"[vice] set_cs8900a_mac skipped: {e}") + except Exception: + # Clean up on failure. + proc.stop() + allocator.release(port) + raise + + return ViceHandle(process=proc, transport=transport, allocator=allocator, port=port) + + +def shutdown_vice(handle: ViceHandle) -> None: + """Close transport, stop VICE process, release port.""" + try: + handle.transport.close() + except Exception: # noqa: BLE001 + pass + try: + handle.process.stop() + except Exception: # noqa: BLE001 + pass + try: + handle.allocator.release(handle.port) + except Exception: # noqa: BLE001 + pass