diff --git a/README.md b/README.md index 25bc431..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 @@ -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,12 @@ 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) + +# 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 3678495..39f1ff6 100644 Binary files a/build/c64-https.prg and b/build/c64-https.prg differ diff --git a/build/labels.txt b/build/labels.txt index 65a4af0..16e5daa 100644 --- a/build/labels.txt +++ b/build/labels.txt @@ -150,7 +150,7 @@ al C:637c .ec_point_add al C:661e .ec_sc_byte al C:80af .der_skip_tlv al C:50e8 .drbg_fill_bytes -al C:1da4 .lbl_derived +al C:1db0 .lbl_derived al C:4d87 .sha256_shr3 al C:4d6c .sha256_rotr22 al C:51a9 .fe_mul @@ -166,7 +166,7 @@ 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:0ddc .net_send_ptr +al C:0de8 .net_send_ptr al C:0aa1 .init_msg al C:8fd3 .tls_app_write_key al C:a041 .ecdsa_sig_s @@ -176,12 +176,12 @@ 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:0d89 .cb_remaining +al C:0d95 .cb_remaining al C:467a .sha256_h0_init al C:5a0f .fp_wide al C:65c1 .ec_scalar_mul -al C:1de8 .tls_c_hs_secret -al C:1355 .tls_record_send_plaintext +al C:1df4 .tls_c_hs_secret +al C:1361 .tls_record_send_plaintext al C:8d36 .tls_ecdh_compute_shared al C:70a0 .ec_t6_384 al C:45cc .aead_compute_tag @@ -189,12 +189,12 @@ 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:1395 .tls_build_client_hello +al C:13a1 .tls_build_client_hello al C:4686 .sha256_h3_init -al C:1cff .tls_compute_finished +al C:1d0b .tls_compute_finished al C:98f5 .sha256_block al C:41f1 .chacha20_encrypt -al C:1e08 .tls_s_hs_secret +al C:1e14 .tls_s_hs_secret al C:0bdc .send_ok_msg al C:9273 .tls_hs_buf al C:3fbc .copy32 @@ -202,7 +202,7 @@ al C:3f8b .rotl32_12 al C:a0a2 .ecdsa_pubkey_y al C:a072 .ecdsa_pubkey_x al C:58f8 .fp_copy -al C:1909 .hkdf_expand_label +al C:1915 .hkdf_expand_label al C:8f3b .tls_transcript al C:430d .sq_ad al C:7100 .ec_point_double_384 @@ -214,7 +214,7 @@ 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:0c62 .net_tcp_connect +al C:0c68 .net_tcp_connect al C:98a8 .input_length al C:9478 .http_path_len al C:9041 .tls_rec_len @@ -222,11 +222,11 @@ al C:736e .ec_point_add_384 al C:7610 .ec_sc_byte_384 al C:430a .sq_sh al C:831d .cert_tbs_len -al C:1254 .tls_record_read +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:0efa .tls_recv_server_hello +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 @@ -236,10 +236,10 @@ al C:8d11 .cert_data_ptr al C:8e7a .tls_state al C:9b59 .drbg_seed al C:5ebe .ec_gx -al C:0eb7 .tls_close +al C:0ec3 .tls_close al C:8edb .tls_ecdhe_pubkey al C:5ede .ec_gy -al C:1849 .entropy_init +al C:1855 .entropy_init al C:3dbd .add32 al C:83e2 .cert_buf al C:6776 .fp_mul_384 @@ -248,7 +248,7 @@ 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:1e68 .tls_finished_key +al C:1e74 .tls_finished_key al C:83e1 .cert_curve_id al C:957f .http_resp_buf al C:69cc .fp_bm_384 @@ -260,7 +260,7 @@ al C:4696 .sha256_h7_init al C:8d14 .cert_parse_pos al C:4271 .sqtab_init al C:9d2d .aead_nonce -al C:16c5 .tls_transcript_block +al C:16d1 .tls_transcript_block al C:940b .hkdf_ikm_len al C:4313 .mul_8x8 al C:5607 .fe_inv_sqr_cnt @@ -281,7 +281,7 @@ 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:0d42 .net_tcp_recv_cb +al C:0d4e .net_tcp_recv_cb al C:9f1f .x25_a al C:5d7e .fp_inv_x1 al C:9f7f .x25_cb @@ -289,7 +289,7 @@ 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:0c7b .net_set_tcp_dest +al C:0c81 .net_set_tcp_dest al C:9f9f .x25_e al C:8d19 .cert_end_hi al C:3ddc .add32_to_dst @@ -303,49 +303,49 @@ al C:9b38 .hmac_data_len al C:8cec .cv_label al C:506d .extra_sid_lo al C:4271 .fp_init_sqtab -al C:1d5a .tls_verify_finished +al C:1d66 .tls_verify_finished al C:9e1f .fe_p al C:809f .der_skip al C:8ebb .tls_ecdhe_privkey -al C:1708 .tls_transcript_save +al C:1714 .tls_transcript_save al C:3f2c .rotl32_4 al C:a142 .ev_point_save -al C:1216 .tls_record_write +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:1db7 .lbl_s_hs_traffic +al C:1dc3 .lbl_s_hs_traffic al C:3b27 .http_get al C:6fe0 .ec_t2_384 al C:610e .ec_point_double -al C:1de0 .lbl_finished +al C:1dec .lbl_finished al C:595f .fp_mul -al C:0e60 .tls_send -al C:1728 .tls_transcript_init -al C:1dab .lbl_c_hs_traffic +al C:0e6c .tls_send +al C:1734 .tls_transcript_init +al C:1db7 .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:0d6c .cb_copy_byte -al C:1d84 .empty_hash -al C:0ebd .tls_send_client_hello +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:8d0d .cert_list_len_hi al C:3dbb .http_bg_idx al C:97a4 .tls_app_ptr -al C:1679 .tls_parse_encrypted_extensions +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:0d23 .net_recv_ready +al C:0d2f .net_recv_ready al C:0aef .dhcp_fail_msg al C:9040 .tls_rec_type al C:9cab .poly_h @@ -355,12 +355,12 @@ 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:1973 .tls_derive_secret +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:16c4 .tls_hostname_len +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 @@ -386,9 +386,9 @@ al C:837f .cert_pubkey_len al C:6da0 .ec_gx_384 al C:80d1 .x509_parse_cert al C:0bb9 .tls_ok_msg -al C:0d85 .cb_done +al C:0d91 .cb_done al C:4cf4 .sha256_rotl1 -al C:1742 .tls_transcript_update +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 @@ -397,7 +397,7 @@ 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:0dc6 .net_save_zp +al C:0dd2 .net_save_zp al C:6c50 .fp_r1_384 al C:5608 .x25519_clamp al C:5942 .fp_is_zero @@ -412,7 +412,7 @@ al C:4b96 .sha256_maj al C:0b2b .http_get_msg al C:9473 .http_host_ptr al C:8d0f .cert_data_len_hi -al C:0dd1 .net_restore_zp +al C:0ddd .net_restore_zp al C:6dd0 .ec_gy_384 al C:a292 .ev_der_int_len al C:9bfb .cc20_work @@ -428,18 +428,18 @@ 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:198f .tls_derive_handshake_keys +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:1684 .tls_hostname +al C:1690 .tls_hostname al C:70d9 .ec_set_modn_384 al C:9784 .http_line_buf al C:8d1a .cert_bs_len al C:5b8d .fp_mod_inv -al C:0c85 .net_tcp_send +al C:0c91 .net_tcp_send al C:4471 .poly1305_update -al C:1004 .tls_select_keys +al C:1010 .tls_select_keys al C:4312 .poly_prod_hi al C:3db9 .http_crlf al C:4d48 .sha256_rotr17 @@ -447,7 +447,7 @@ al C:4d51 .sha256_rotr18 al C:4d5d .sha256_rotr19 al C:5141 .fe_sub al C:6821 .fp_a_byte_384 -al C:117a .tls_record_decrypt +al C:1186 .tls_record_decrypt al C:831b .cert_tbs_ptr al C:9476 .http_path_ptr al C:902b .tls_write_seq @@ -467,13 +467,13 @@ 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:1da4 .empty_context +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:0d8b .net_init_cb_addrs +al C:0d97 .net_init_cb_addrs al C:8f77 .tls_transcript_h7 al C:0867 .do_net_init al C:9dbf .fe_tmp2 @@ -484,15 +484,15 @@ al C:9c3b .cc20_keystream al C:5aaf .fp_mod_sub al C:0b80 .tcp_fail_msg al C:9d9f .fe_tmp1 -al C:0dde .net_send_len -al C:155e .tls_parse_server_hello +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:1287 .tls_recv_record +al C:1293 .tls_recv_record al C:9375 .hkdf_prk al C:9dff .fe_tmp4 -al C:1ddb .lbl_key +al C:1de7 .lbl_key al C:66fc .fp_copy_384 al C:5225 .fe_reduce_wide al C:9ebf .x25_z2 @@ -501,13 +501,13 @@ al C:93b5 .hkdf_info_buf al C:9e9f .x25_x2 al C:9eff .x25_z3 al C:08b8 .do_http_get -al C:1393 .tls_recv_count +al C:139f .tls_recv_count al C:0c53 .net_dns_resolve al C:3e5c .rotr32_8 -al C:17a3 .tls_transcript_hash +al C:17af .tls_transcript_hash al C:9a57 .hmac_key al C:0bea .ok_msg -al C:0f54 .tls_recv_encrypted +al C:0f60 .tls_recv_encrypted al C:9a97 .hmac_opad_block al C:9d5f .fe_wide al C:6d40 .ec_a_384 @@ -516,7 +516,7 @@ 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:0de0 .tls_connect +al C:0dec .tls_connect al C:9781 .http_parse_state al C:503e .hmac_drbg_generate al C:506c .extra_sid_count @@ -524,7 +524,7 @@ al C:98d9 .sha_e al C:940e .hkdf_label_len al C:3f91 .rotr32_1 al C:98d5 .sha_d -al C:0d53 .cb_load_ptr_lo +al C:0d5f .cb_load_ptr_lo al C:9409 .hkdf_ikm_ptr al C:98e1 .sha_g al C:98dd .sha_f @@ -533,27 +533,27 @@ al C:98c9 .sha_a al C:3e82 .rotr32_4 al C:4360 .mul_a al C:9e7f .x25_result -al C:137b .tls_record_recv_and_decrypt +al C:1387 .tls_record_recv_and_decrypt al C:901f .tls_app_read_iv -al C:0d59 .cb_load_ptr_hi +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:89e2 .cert_buf_len al C:803f .der_read_tag -al C:1705 .tls_transcript_block_len +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:1706 .tls_transcript_total_lo +al C:1712 .tls_transcript_total_lo al C:50fc .fe_copy al C:6b60 .fp_inv_u_384 al C:8319 .der_len al C:4850 .sha256_final -al C:1989 .hkdf_tls13_prefix +al C:1995 .hkdf_tls13_prefix al C:4012 .chacha20_init al C:5177 .fe_reduce_final al C:6731 .fp_sub_384 @@ -561,7 +561,7 @@ al C:82fc .oid_prime256v1 al C:977f .http_resp_len al C:3d91 .http_get_verb al C:5dfe .fp_r2 -al C:1707 .tls_transcript_total_hi +al C:1713 .tls_transcript_total_hi al C:561b .x25519_scalarmult al C:5e1e .fp_r3 al C:5e5e .ec_n @@ -571,7 +571,7 @@ 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:10b0 .tls_seq_increment +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 @@ -593,7 +593,7 @@ al C:a102 .ev_u1 al C:3b2c .http_build_get al C:8d13 .cert_data_offset al C:699a .fp_rem_384 -al C:0d2f .net_recv_byte +al C:0d3b .net_recv_byte al C:a010 .ecdsa_hash_len al C:0bcb .send_fail_msg al C:9cfd .poly1305_tag @@ -601,52 +601,52 @@ al C:a122 .ev_u2 al C:9d3c .aead_data_ptr al C:4184 .chacha20_block al C:9c9b .cc20_nonce -al C:1e48 .tls_verify_data +al C:1e54 .tls_verify_data al C:9ad7 .hmac_data_buf al C:4a66 .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:0ca9 .net_tcp_close +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:8d5e .zp_save_buf -al C:10c8 .tls_record_encrypt +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:107c .tls_build_nonce +al C:1088 .tls_build_nonce al C:8efb .tls_server_pubkey -al C:18ca .hkdf_expand +al C:18d6 .hkdf_expand al C:70e2 .ec_mulp_384 -al C:1dc3 .lbl_c_ap_traffic -al C:1dcf .lbl_s_ap_traffic -al C:0d42 .cb_load_len_lo +al C:1dcf .lbl_c_ap_traffic +al C:1ddb .lbl_s_ap_traffic +al C:0d4e .cb_load_len_lo al C:7c11 .ecdsa_verify_256 -al C:0fcc .tls_send_finished -al C:1258 .tls_enc_aead_len +al C:0fd8 .tls_send_finished +al C:1264 .tls_enc_aead_len al C:0ba3 .tls_fail_msg -al C:1b59 .tls_derive_traffic_keys +al C:1b65 .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:1392 .tls_recv_state +al C:139e .tls_recv_state al C:a202 .ev_point_save_384 al C:592d .fp_sub -al C:0d48 .cb_load_len_hi +al C:0d54 .cb_load_len_hi al C:3d95 .http_version -al C:1dde .lbl_iv +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:1e28 .tls_derived_tmp +al C:1e34 .tls_derived_tmp al C:60be .ec_t6 al C:9413 .tls_early_secret al C:609e .ec_t5 @@ -662,10 +662,10 @@ al C:3e7f .rotr32_12 al C:5fbe .ec_p3 al C:601e .ec_t1 al C:518d .fe_cswap -al C:0e8e .tls_recv +al C:0e9a .tls_recv al C:5d2b .fp_chk_one al C:5f5e .ec_p2 -al C:0cb3 .net_print_ip +al C:0cbf .net_print_ip al C:8309 .oid_sha256_ecdsa al C:5d5e .fp_inv_v al C:090c .do_https_get @@ -692,7 +692,7 @@ al C:98b5 .sha256_h3 al C:9a77 .hmac_val al C:403f .chacha20_quarter_round al C:98b1 .sha256_h2 -al C:125a .tls_send_record +al C:1266 .tls_send_record al C:430f .sq_i al C:98bd .sha256_h5 al C:9408 .hkdf_salt_len @@ -703,10 +703,10 @@ al C:98c5 .sha256_h7 al C:98c1 .sha256_h6 al C:9a35 .sha256_hash al C:6824 .fp_p_lo_384 -al C:0d64 .cb_loop -al C:1370 .tls_record_send_encrypted +al C:0d70 .cb_loop +al C:137c .tls_record_send_encrypted al C:9d3b .aead_aad_len al C:8311 .oid_sha384_ecdsa al C:5163 .fe_cmp_p -al C:1861 .hkdf_extract +al C:186d .hkdf_extract al C:804a .der_read_length diff --git a/src/net.asm b/src/net.asm index e961dba..d87d5fd 100644 --- a/src/net.asm +++ b/src/net.asm @@ -57,7 +57,13 @@ net_poll: ; Output: C=0 success (IP in ip65_dns_ip_addr), C=1 failure ; ============================================================================= net_dns_resolve: + pha ; save A (hostname lo) across ZP save + txa + pha ; save X (hostname hi) across ZP save jsr net_save_zp + pla + tax ; restore X + pla ; restore A jsr ip65_dns_set_host ; AX = hostname pointer jsr ip65_dns_resolve php @@ -94,7 +100,13 @@ net_tcp_connect: ; Input: A/X = pointer to 4-byte IP address ; ============================================================================= net_set_tcp_dest: + pha ; save A (IP ptr lo) across ZP save + txa + pha ; save X (IP ptr hi) across ZP save jsr net_save_zp + pla + tax ; restore X + pla ; restore A jsr ip65_set_tcp_dest ; AX = pointer to 4-byte IP jsr net_restore_zp rts 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..783733c 100644 --- a/tools/run_all_tests.py +++ b/tools/run_all_tests.py @@ -2,19 +2,21 @@ """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__)), "..")) 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") @@ -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,72 +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 = 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) + 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() @@ -169,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) @@ -191,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}") 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_dns.py b/tools/test_dns.py new file mode 100644 index 0000000..1ae6a22 --- /dev/null +++ b/tools/test_dns.py @@ -0,0 +1,344 @@ +#!/usr/bin/env python3 +"""test_dns.py -- DNS resolution tests for c64-https. + +Tests the net_dns_resolve routine over real networking via the TAP interface. + +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_dns.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") + +# ip65_dns_ip_addr: 4 bytes storing the resolved IP address +IP65_DNS_IP_ADDR = 0x4073 + +# Scratch RAM locations +HOSTNAME_ADDR = 0xC000 +TRAMPOLINE_ADDR = 0xC100 +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 +# --------------------------------------------------------------------------- + +def build_dns_trampoline(hostname_lo, hostname_hi, dns_resolve_addr): + """Build a 6502 trampoline that calls net_dns_resolve and stores + the carry result (0=success, 1=failure) at CARRY_RESULT_ADDR. + + Layout at TRAMPOLINE_ADDR ($C100): + LDA #hostname_lo + LDX #hostname_hi + JSR net_dns_resolve + LDA #$00 ; assume success (carry clear) + BCC +2 ; skip next instruction if carry clear + LDA #$01 ; failure (carry set) + STA $C0F0 ; store result + RTS + """ + dns_lo = dns_resolve_addr & 0xFF + dns_hi = (dns_resolve_addr >> 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_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_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_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_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() 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)