|
ProtoCore v1.0.16
Deterministic, zero-heap network stack for embedded targets
|
A running record of every bug found in this library: what broke, the root cause, the fix, and status. Newest first. A bug is logged here the moment it is found (even before it is fixed) so nothing slips.
Status key: OPEN (found, not fixed) - FIXED (fixed, validated) - SHIPPED (released).
protocore_ssh_conn_accept against ssh_client.c's begin() while tracing who owns a slot.protocore_ssh_conn_accept (ssh_conn.c:363) allocates by scanning s_sshc.conn_for_ssh[] from 0 for the first entry equal to 0xFF, then calls ssh_transport_init(j), whose first act is mem.set(s, 0, sizeof(*s)). The client takes SSH_CLI_SLOT 0 in begin() and reaches its relay through Tcp.client, so nothing ever writes conn_for_ssh[0] and the slot reads as free for the tunnel's whole life.PROTOCORE_ENABLE_SSH and PROTOCORE_ENABLE_SSH_CLIENT, the first inbound SSH connection takes slot 0 and zeroes the live tunnel's session: its keys, its phase, its V_C / V_S / I_C / I_S. Twelve ESP board profiles carry MAX_SSH_CONNS under the comment "SSH server + reverse-SSH client", so both roles in one image is a configuration the tree expects.native_ssh_client sets both flags but stands up no listener, so nothing in the matrix drives an accept against a claimed slot.protocore_ssh_conn_claim(ssh_slot, cid) / protocore_ssh_conn_release(ssh_slot) (ssh_conn.h). begin() claims slot 0 with its protocore_client cid once the dial succeeds and fails the tunnel closed if the slot is already owned; cli_teardown and protocore_ssh_tunnel_end release it. The accept scan is unchanged - it already passes over any entry that is not 0xFF.0xFF is the only value the four unguarded readers (ssh_emit:78, protocore_ssh_conn_send:162, protocore_ssh_conn_close_channel:209, protocore_ssh_conn_open_forwarded:255) test before &conn_pool[...], so a new one would read out of bounds at all four until each grew a test for it.!= 0xFF before indexing conn_pool, while protocore_ssh_conn_rx:301 and protocore_ssh_conn_close:424 check conn_for_ssh[j] != conn_slot and are safe under any value. No caller reaches the four with the client's slot today - the client sends through cli_send / Tcp.client, and those four are the server-side channel API - so this is a hardening question rather than a reachable defect.ssh_kexinit_parse against the span in ssh_conn.h; introduced by the sec 7.1 role fix in 85329fe0e, below.SSH_KEXINIT_MAX 2048 (protocore_config.h:7691): "A modern OpenSSH client's KEXINIT (post-quantum KEX names + cert
host-key algs + EtM MACs + ext-info-c) runs well past 1 KB, so this must be large enough to hold
it - a smaller bound silently rejects real clients at key exchange." `PROTOCORE_SSH_KEXINIT_S_MAX 704` (`ssh_transport.h:59`): "Max stored size of our own KEXINIT (I_S)", with static_assert(PROTOCORE_SSH_KEXINIT_S_MAX >= SSH_KEXINIT_S_WORST) (ssh_transport.c:120) proving 704 covers every name this build advertises.SSH_OFF_I_C 2048 bytes and SSH_OFF_I_S 704 (ssh_conn.h:59-61). That split is the server's: I_C is whatever a real client sends, I_S is our own. The client role inverts it - I_C is ours and I_S is the relay's - but the offsets are compile-time and do not move. ssh_kexinit_parse (ssh_transport.c:726-735) picks peer_cap = PROTOCORE_SSH_KEXINIT_S_MAX when as_client, then if (len > peer_cap) return -1. So the client caps the relay's KEXINIT at 704 bytes and refuses anything longer, while our own 704-byte list sits in a 2048-byte buffer.PROTOCORE_ENABLE_SSH_CLIENT fails key exchange against any relay whose KEXINIT exceeds 704 bytes. By the two comments above that is what a current OpenSSH sends.s_cli.i_s[SSH_KEXINIT_MAX] field ("server KEXINIT payload (for H); OpenSSH's is ~1.1 KB") assert, not something this session ran.test/integration/ssh/test_ssh_client builds its relay KEXINIT from the names the test needs, which is well under 704.PROTOCORE_SSH_I_C_MAX / PROTOCORE_SSH_I_S_MAX (ssh_transport.h:61) size each buffer from the roles compiled in: the peer bound (SSH_KEXINIT_MAX) in the role that receives into it, our own bound (PROTOCORE_SSH_KEXINIT_S_MAX) in the role that writes it, and the peer bound for both when both roles are built. SSH_OFF_I_S / SSH_OFF_KEXINIT and the kept_cap / peer_cap in ssh_kexinit_build / ssh_kexinit_parse all read the two names, so the span and the bounds checks cannot drift apart.nm -S on ssh_conn.o. native_ssh_client (both roles) s_sshc 0x4f0d = 20237, up 1344 from 18893 - one SSH_KEXINIT_MAX less one PROTOCORE_SSH_KEXINIT_S_MAX, which is the I_S buffer widening. native_ssh_conn (server only) 0x49cd = 18893: the server-only branch expands to the same two constants the file carried before, so a server build is unchanged. 17 SSH environments run 258 cases; native_ssh, native_ssh_pqc and native_ssh_comp still fail to build on pre-existing test-file errors.native_ssh_client running 11/11 and the other 16 SSH environments running 242 cases with no change. Found by building native_ssh_client with -DPROTOCORE_LOG_LEVEL=PROTOCORE_LOG_LEVEL_WARN (reverted after), which printed the client's own cli_fail reason: ssh-tunnel: KEXINIT negotiation failed.server_host_key_algorithms: "The server lists the algorithms for
which it has host keys; the client lists the algorithms that it is willing to accept." And on selection: "Iterate over client's kex algorithms, one at a time. Choose the first algorithm that
satisfies the following conditions: the server also supports the algorithm, ..."ssh_kexinit_parse (ssh_transport.c:622) is reached by both roles through SSH_TRANSPORT->kexinit_parse, and reads the incoming KEXINIT as if it were always the client's. Two consequences, both role-blind:negotiate_hostkey (ssh_transport.c:592) builds its candidate table from hostkey_rsa_available() / protocore_ssh_hostkey_ed25519_available() / protocore_ssh_hostkey_ecdsa_available(), and cand_match (:330) requires cands[c].avail. A client holds no host key of its own - it pins the relay's - so every candidate is unavailable and the call returns -1 for any name-list the relay could send.negotiate_alg (:351) iterates the incoming name-list. In the client role that is the server's list, in the server's preference order, rather than the client's own.test_diag_hostkey_availability_gates_client_negotiation. With no host key held, the tunnel reaches PROTOCORE_TUN_FAILED on the first poll after the relay's KEXINIT with 0 bytes written. Calling protocore_ssh_hostkey_ed25519_set() - provisioning the device with a server host key a client has no use for - is the only change, and the same handshake then reaches PROTOCORE_TUN_CONNECTING and writes 48 bytes whose first message byte is 30 (SSH_MSG_KEXDH_INIT).PROTOCORE_ENABLE_SSH_CLIENT cannot complete a key exchange against any relay unless the device happens to have been provisioned as an SSH server as well. The reverse tunnel is the whole point of the feature, so the feature does not work in its own configuration.ssh_client.c against a peer. native_ssh_client covered the state API, the cfg guard and the key derivation; nothing drove a KEXINIT into it. test/servers/peers/ssh_peer.py drives the device as a server, so the client role has never met a KEXINIT from anything.negotiate_alg takes one list plus a local capability table. In the server role that substitution is exact - the incoming list is the client's and the table is what the server supports. In the client role neither holds. The avail gate is where it fails first and is what was measured; it is not the whole of it. A client that got past it would still take the server's first preference in every category, which no line of sec 7.1 permits.ssh_kexinit_parse:649 stores the incoming KEXINIT into s->i_c. In the client role the incoming one is I_S. ssh_client.c keeps its own s_cli.i_c / s_cli.i_s and does not call compute_exchange_hash (which is static and hardcodes V_S), so this looks inert on the client path; I have not traced every reader of ssh_sess[].i_c to be sure.native_scp_server building and running 9/9.protocore_scp_on_open compared the parsed mode against bare SINK and SOURCE (ssh_scp.c:137, :146). The enum in scp.h:36-38 spells them SCP_MODE_SINK and SCP_MODE_SOURCE, and no alias for the short forms exists anywhere. Any build with PROTOCORE_ENABLE_SSH_SCP=1 failed with ‘error: 'SINK’ undeclared.**Why it was never caught:** the same gap that hid thessh_forward.cdefect - no environment in test_matrix.jsoncompiled the file. Both SSH file-transfer bindings were unbuilt, and one of the two did not compile.**Fix:** name the enum members as the header declares them.**Reach:** compile-time only, and only when the feature is on.PROTOCORE_ENABLE_SSH_SCP` defaults to 0, so no shipped default configuration was affected.application/sftp/ssh_sftp.c - nothing in the matrix built it, so nothing exercised the INIT path.handle_packet (ssh_sftp.c:316) matches PROTOCORE_SSH_FXP_INIT and immediately calls protocore_sftp_build_version, which writes the fixed PROTOCORE_SFTP_VERSION (3, sftp.h:30). The uint32 version the client sent is never read off the reader. A client that offers version 1 or 2 is answered 3 - a version higher than it asked for.longname to the NAME response, so a v2 client is answered in a dialect it cannot parse. Every current OpenSSH client speaks v3, which is why this has not been visible in practice.protocore_sftp_build_version and the owner's call.test_s5_a_new_request_does_not_abandon_the_armed_exchange in test_ssh_kbdint asserts the behavior as it is, and the RFC-correct assertion fails against it.keyboard-interactive request arms s_auth.ki[i] with pending and the user it named (ssh_auth.c:626). That slot is cleared only by its own INFO_RESPONSE (:727) or by protocore_ssh_auth_reset (:59); no other USERAUTH_REQUEST touches it. A client can therefore arm the exchange as user A, send a further request as user B, and then answer the original prompt - the INFO_RESPONSE is still accepted and authenticates A, the user the superseded request named.pw_cb(A, response), so the correct password for A is required. The defect is that the session authenticates as a user other than the one the most recent request named, so any layer that tracks the current user from the last USERAUTH_REQUEST disagrees with who actually authenticated.test_ssh_channel.c by RFC section: sec 6.10 had no test, and the reason is that nothing implements it.CHANNEL_REQUEST naming "exit-status" with want_reply FALSE and a uint32 exit_status, and "The channel needs to be closed with SSH_MSG_CHANNEL_CLOSE after this message."exit-status and exit-signal appear nowhere in src/. A channel that ran an exec is closed with EOF + CLOSE and no status, so a client sees a command that produced output and then ended with no exit code. ssh host cmd reports success regardless of what the command did, which matters most for the SCP path, where a failed transfer is indistinguishable from a successful one at the exit code.ssh_forward.c; both are pinned by test_s7_2_a_denied_target_is_not_dialed and test_s7_2_a_refused_connect_is_reported_after_the_confirmation, which assert what the code does today, not what the RFC names.SshForwardOpenCb returns int, and ssh_channel.c:436 maps any < 0 to build_open_failure(..., 2u). So an administratively denied target, a host longer than PROTOCORE_SSH_FWD_HOST_MAX, and a full PROTOCORE_SSH_FWD_MAX table all report SSH_OPEN_CONNECT_FAILED. Sec 5.1 names 1 (ADMINISTRATIVELY_PROHIBITED) for the policy denial and 4 (RESOURCE_SHORTAGE) for the full table. The callback cannot express which, so distinguishing them is a contract change, not a local fix.on_forward_open (ssh_forward.c:238) treats Tcp.client->open() < 0 as the connect failure, but protocore_client_open (tcp_client.c:308) calls cc_pump and then returns the slot id without consulting c->closed - the transport is non-blocking and the connect has not settled yet. A refused target therefore yields CHANNEL_OPEN_CONFIRMATION followed by EOF + CLOSE on the next protocore_ssh_forward_pump, never SSH_OPEN_CONNECT_FAILED. Not a protocol violation, but the client is told a channel opened to a host nothing ever connected to.ssh_forward.c:238 says the open "blocks on DNS + connect". It does not.on_rforward_open (ssh_forward.c:274) checks port 0, a duplicate binding, PROTOCORE_SSH_RFWD_MAX capacity and MAX_LISTENERS capacity. There is no port-range check anywhere in the file, so any authenticated user can bind 22, 80 or 443 on the device with ssh -R.PROTOCORE_SSH_PORT_FORWARD defaults to 0 and remote forwarding is inert until the application calls protocore_ssh_forward_begin(), so no default configuration is exposed. A deployment that turns forwarding on gets the whole port range.native_ssh_forward building and running 14/14.protocore_ssh_forward_begin calls Session.proto->add(PROTO_SSH_RFWD, &s_rfwd_handler) at ssh_forward.c:466. Session is declared in network_drivers/session/session.h:46, which the file never included - it includes session/proto_handler.h, which declares ProtoRegistryNs but not the Session instance. Any build with PROTOCORE_SSH_PORT_FORWARD=1 fails with ‘error: 'Session’ undeclared (first use in this function); did you mean 'SshSession'?.**Why it was never caught:** no environment intest_matrix.jsoncompiled the file. The whole of ssh -Landssh -Rwas unbuilt, so the error sat in the tree rather than failing CI. Found by addingnative_ssh_forward, the first env that builds it.**Fix:** the missing#include "network_drivers/session/session.h". The direction is acyclic - session.hreaches only intosession/andtransport/, never back intopresentation/.**Reach:** compile-time only, and only when the feature is on.PROTOCORE_SSH_PORT_FORWARDdefaults to 0 (protocore_config.h:6365`), so no shipped default configuration was affected.protocore_ssh_conn_open_forwarded (ssh_conn.c:271) releases the secure mark and returns -1 when ssh_pkt_send fails, while the channel protocore_ssh_channel_open_forwarded allocated at :263 stays pending. chan_alloc skips any slot with open || pending, so with the default PROTOCORE_SSH_MAX_CHANNELS == 1 no further channel opens on that connection.tcp_conn.c:443 arms the connection pool's idle sweep at CONN_TIMEOUT_MS (5000 ms, protocore_config.h:220); ssh_conn.c:133 binds .on_close = protocore_ssh_conn_close, so the sweep frees the SSH slot; and protocore_ssh_conn_accept calls protocore_ssh_channel_init (:357), which clears the slot's channels for the next tenant. A wedged channel therefore costs at most one idle timeout.ssh -R accept on that connection returns -1 and rfwd_on_accept closes the inbound socket. Worth tidying, not worth an API.chan_alloc that does not exist - the two handlers that clear pending both parse a peer message, and here the CHANNEL_OPEN never reached the wire. Adding a public release for a 5 s self-healing window is not worth the surface.cd435b1b6; this entry is the part that fix does not reach. Found in the network_drivers/presentation resource audit (F2).protocore_ssh_conn_send discarded the Tcp.conn->send return and returned len regardless, so a short send window read to the caller as a complete write. It now returns -1.ssh_pkt_send has already done s->seq_no_send++ (ssh_packet.c:372) and advanced the AEAD invocation counter (:352). A refused queue therefore leaves this end's send side one packet ahead of the peer's receive side, and every subsequent packet fails the peer's MAC. Returning -1 tells the caller the write failed; it does not undo the cipher step, and nothing retries the framed bytes. The session is unrecoverable from that point whether or not the caller notices.protocore_ssh_conn_send is the port-forward data path (ssh_forward.c:211, :521), which is exactly where sustained load fills a send buffer. ssh_scp.c:88,93 and ssh_sftp.c:170 discard the return entirely, so those two do not even learn.protocore_ssh_conn_send now returns -1; ssh_scp.c:88,93 and ssh_sftp.c:170 still discard that, so they neither learn nor close.ssh_packet.c ssh_pkt_emit). Confirmed by reading the whole path, found in the network_drivers/presentation resource audit (F1). Not reproduced on hardware yet - that is the next step, and the audit says the same.SSH_MSG_KEXDH_INIT with two packets and only the first reaches the wire.ssh_server.c:138 emit(i, reply.buf, n) - KEXDH_REPLY.ssh_server.c:141 emit(i, &newkeys, 1) - NEWKEYS, immediately after, nothing between.emit() (ssh_server.c:34) is static inline void and calls s_srv.emit_cb, discarding any result.emit_cb is bound to ssh_emit at ssh_conn.c:124.ssh_emit (ssh_conn.c:78) calls ssh_pkt_emit, and on 0 calls Workers.wake.ssh_pkt_emit (ssh_packet.c:157-160) opens with if (s->tx_ready) { return -1; } // one packet in flight per slot. The first emit sets tx_ready (:176). The second therefore returns -1 and the payload is never framed. emit() returns void, so nothing upstream learns of it, and ssh_newkeys_sent(i) on the very next line turns enc_out on regardless.Workers.wake -> protocore_worker_wake (worker.c) is protocore_platform_task_notify - it signals a task, it does not run one. The only drain is ssh_tx_drain, called at ssh_conn.c:419, after ssh_pkt_recv at :414 has returned, and at :316 on a later poll.CHANNEL_EOF + CHANNEL_CLOSE at ssh_server.c:356-360, where the second is likewise dropped and the peer keeps a half-open channel.test_ssh_server.c:186-190 asserts exactly this - emt_n == 2, emt_type[0] == KEXDH_REPLY, emt_type[1] == NEWKEYS - and passes. It installs its own callback through protocore_ssh_server_set_emit_cb, so it proves protocore_ssh_server_dispatch calls emit twice and never exercises ssh_emit or ssh_pkt_emit. The one-packet-per-slot rule is on the other side of the seam the test replaces. No native env covers the real emit path.SSH_WIRE_CAP = 2 * SSH_PKT_WIRE_MAX), and ssh_pkt_emit appends the second at tx_len instead of returning -1 when tx_ready. SSH streams binary packets back-to-back (RFC 4253 sec 6), so KEXDH_REPLY + NEWKEYS and CHANNEL_EOF + CHANNEL_CLOSE frame into tx_wire in sequence and leave on one drain; ssh_pkt_send bumps seq_no_send per call so the pair carries consecutive sequence numbers, and the enc_out flip after NEWKEYS keeps both under one state. The ssh_conn.c static_assert proves the doubled wire fits (needs 10644, budget 12992). Verified: native_ssh + native_ssh_conn, 268/268.0c8fa67db, the commit before the cascade, in a clean worktree with only the mmgr link deps added. Same eight tests, same counts. Nothing in the cascade touches it.:458 test_server_single_peer - protocore_coaps_server_ingest refuses the first application record, though the handshake completed and protocore_coaps_server_active_conns() is 1.:478 test_two_peers_routing, :551 test_cid_address_migration, :766 test_unknown_cid_dropped, :800 test_slot_lookup_same_port_different_ip, :832 test_slot_by_cid_skips_and_bounds, :862 test_cid_no_migration_when_address_unchanged, :883 test_cid_migration_same_port_different_ip. The slot-lifecycle tests around them all pass (test_ingest_ring_full, test_pool_full_rejects_new_peer, test_fatal_handshake_frees_slot, test_pto_ceiling_frees_slot), so the pool works and the lookup path is what does not.SIGFPE, which is Unity's exit code - it exits with the failure count, and 8 renders as signal 8. Under gdb the program runs to completion with no fault and no stack. The same artifact renders 5 failures as SIGTRAP and 1 as SIGHUP elsewhere in the suite.PROTOCORE_COAPS_MAX_CONNS (2) and PROTOCORE_COAPS_INGEST_RING (6) are sane in coaps_server.h:42,45, so the ring arithmetic at coaps_server.c:176,198 is not at fault.crypto.md F1 and F2 measure sntrup761 decapsulation at ~23 KB of frame and ML-KEM-768 decapsulation at ~9 KB, then rate both category C on the grounds that "a FreeRTOS worker
task on an ESP32 is typically stacked at 4-8 KB, so the SSH KEX path overflows by 3-5x".protocore_config.h:306-332, and the numbers there are the same ones the audit measured:#error guards fail the build when the stack is set by hand below the floor - :7053 (RSA/OIDC), :7062 (curve25519/ed25519), :7074 (ML-KEM), :7093 (sntrup761, naming 32768 server / 40960 client). A build that starves the KEX does not link, it stops at the preprocessor with the number it needs.f/ginv (the sntrup761 private key), r, and ML-KEM's shat/mprime - which is the same exposure as F7 and belongs with it, not with a stack-overflow finding.PROTOCORE_WORKER_TASK_STACK.2f6875793, 66c2a024a and the dtls_conn helper commit. Found by the native suite; the shape was proved with a worktree at the pre-cascade commit.test_dtls_conn.c:441, five tests), native_coaps_server took a SIGBUS, and the h3 / quic servers failed to complete a handshake.protocore_sha256_ctx / protocore_sha512_ctx into views - rx, tx and fs point into the caller's working bytes. Anything that duplicated or shared those bytes therefore aliased a live hash instead of snapshotting it. It surfaced three ways, and the first two look nothing alike:protocore_sha256_ctx b = a; was a snapshot when the context owned its storage. 19 sites finalized the copy and destroyed the running transcript.complete_handshake_from_flight(DtlsConn *, protocore_sha256_ctx tr, ...) is the same copy in argument form, which a search for ctx X = Y; does not find.tw[4096] and passed it to every entry point. A context keeps working out of the bytes it was initialized with, so a one-shot taking the same buffer overwrote it mid-life: the protocore_ed25519_verify between CertificateVerify and Finished clobbered the transcript.src/ holds every context in its owner and passes it by pointer. A sweep for all three forms across src/ returns nothing. The hazard was real but only the tests tripped it.final() compresses into a state copy and leaves the context running, so the snapshot they existed for is unnecessary. 23 test contexts take their own storage, and the one by-value helper rebinds onto its own span. Both context headers now state that the three pointers are the caller's and a struct copy aliases them.native_quic_tls and native_coaps passed on the same bug that made native_tls13_kdf derive a wrong master secret.src/ resource audit (crypto.md F11). The ban-19 half is closed - the buffer moved out of function scope into the caller's borrow - and this is the remainder. Needs a contract decision, not a patch.crypto/kdf/hkdf.c:21 sets HKDF_INFO_CAP to (2 + 1 + 255 + 1 + 255) = 514, and every consumer's PROTOCORE_HKDF_BORROW carries it. The file's own comment at :71-73 bounds what this tree actually produces: the longest label is tls13 client in (15) and the context is a Transcript-Hash (<= 32) or empty, so 2 + 1 + 15 + 1 + 32 = 51 bytes of the 514 are ever used.:78-79 reads strnlen(label_prefix, 255) and strnlen(label, 255 - prefix_len), so a caller that passed a long label would legitimately fill the buffer. Shrinking the buffer without shrinking the clamp turns an over-reservation into an overflow. The two move together or not at all.TUNING.md:100 says a pool size is the TU's precomputed worst case, not a chosen number - which argues for sizing to this tree's real callers and clamping to match. Against that, protocore_hkdf_expand_label_ctx is written as a protocol-general RFC 8446 sec 7.1 primitive whose label is opaque<7..255>, and narrowing the clamp narrows what it accepts. Roughly 460 bytes per consumer borrow ride on the answer.src/ resource audit (crypto.md F19). Needs a decision, not a patch: the two halves of crypto_opt.h disagree and only the maintainer can pick.crypto_opt.h:40-46 caveat 1 says to apply PROTOCORE_CRYPTO_HOT ONLY to code that is constant-time by structure, and "Do NOT put it on scalar-multiplication / bignum / point-arithmetic
code that relies on branchless mask-selects". All five TUs that carry it are those categories:crypto/asymmetric/bignum.c:32 and rsa.c:25 - bignum.crypto/asymmetric/curve25519.c:31 - a Montgomery ladder resting on protocore_gf_cswap:305.crypto/asymmetric/ed25519.c:32 - point arithmetic resting on ed_cswap:457.crypto/asymmetric/ecdsa.c:74,76 - point arithmetic resting on pt_table_select:666, and it takes the stronger PROTOCORE_CRYPTO_HOT_PEEL on the S3.-O level "buys them almost nothing - all risk, no reward". The same header's measured per-die defaults (:69-74) contradict that for two of them: on the P4, x25519 is 6.8% and ed25519 4.5% faster at -O3, and :54 records that the S3's Ed25519 sign is 1.2% SLOWER at -O3. So these TUs were benched, and the pragma on at least curve25519/ed25519 buys a measured win.#pragma GCC optimize applies to the whole TU, and the documented risk is if-conversion running backwards on a mask-select, turning dst->X[i] |= table[e].X[i] & mask; into a branch on a secret index. Nobody has disassembled to show it happens here. SRC_LAW.md's "Guarantees are proven at the binary" requires exactly that chain for a constant-time claim, and the chain does not exist for these five either way.145c3b925, found by the native suite (native_ssh_pqc).protocore_ssh_hostkey_ed25519_set() segfaulted. gdb put it in protocore_sha512_update with the key pointer at address 0, three frames under protocore_ed25519_pubkey.ssh_pkt[i].crypto_work. That member is null until ssh_pkt_slot_storage() binds it, and cli_crypto_work() returns null when the pool cannot cover the slot. Six sites read it without either:transport/ssh_transport.c:117 - protocore_ssh_hostkey_ed25519_set, the one that faulted.transport/ssh_transport.c:1011 - the Ed25519 arm of sign_hash; the ECDSA and RSA arms beside it were already guarded.connection/ssh_client.c x4 - cli_crypto_work() passed straight in as an argument, so the null check inside it bought the caller nothing.begin().hybrid_sntrup761_x25519 also reached back into the slot for its SHA-512 instead of using the work it was handed; it uses the parameter now.f7bc5d0dc, found by the native suite (native_coaps).test_quic_aead_open_rejects_short_ciphertext segfaulted.uint8_t key[16] where protocore_aes128gcm_open() takes struct protocore_aes128gcm_key *, and passed a null tag. Its comment claimed neither was dereferenced because open() rejects a short ciphertext first. That guard was moved out to the callers - quic_crypto.c:139 records the move - so the portable backend now casts the key and calls set_j0() on its first line, writing past a 16-byte stack array.protocore_ct_eq even with a valid key context. The guard is covered where it now lives, by test_gcm_open_rejects_short in test_quic_crypto. Every caller of protocore_aes128gcm_open was checked for the length guard first: quic_crypto.c, dtls_record.c and tls_record.c all hold it, and smb2.c reads its tag from a separate 16-byte copy.src/ resource audit (crypto.md F12). DEFERRED by the maintainer 2026-08-08: not to be chased until someone confirms it against a linked binary.Symptom: unconfirmed. On the accelerated builds, one X25519 key exchange, one ECDSA sign, one RSA verify and every AES-CCM record may each take and release heap, which would break the "no heap after `begin()`" guarantee on the configuration the library actually ships.
crypto/asymmetric/curve25519.c:270-285 - protocore_gf_inv declares four mbedtls_mpi and calls mbedtls_mpi_read_binary / mbedtls_mpi_exp_mod / mbedtls_mpi_write_binary.crypto/asymmetric/ecdsa.c:95-118,128-151,160-183,189-212 - the four mbedtls entry points, through mbedtls_ecp_group_load and mbedtls_ecp_mul.crypto/asymmetric/rsa.c:63-106.crypto/aead/aesccm.c:51-52,81-82 - mbedtls_ccm_setkey reaches mbedtls_cipher_setup.The other three mbedtls uses in that tree (aes256ctr.c:59, aes_cmac.c:35, and the SHA backends) take plain context structs and do not allocate.
mbedtls_mpi_grow under read_binary is mbedtls_calloc, exp_mod allocates its own sliding-window table, and cipher_setup's ctx_alloc_func() is mbedtls_calloc. Whether any of that is reachable depends on the platform's MBEDTLS_PLATFORM_MEMORY / MBEDTLS_PLATFORM_STD_CALLOC configuration, which this repo does not own.SRC_LAW.md requires a guarantee about emitted code to be proven at the binary, and that proof does not exist either way here. Confirming it needs the linked .text checked for a reachable allocator call on a real ESP32 build; the audit that raised it says the same about itself. Acting on an unconfirmed vendor-behavior claim would mean rewriting four working crypto paths on a guess.esp32dev, disassemble the linked image, and check whether mbedtls_calloc is reachable from protocore_gf_inv, the four ecdsa.c entry points, rsa.c's verify, and mbedtls_ccm_setkey. Either it is reachable, and this becomes a real SRC_LAW rule-1 violation with a known blast radius, or it is not, and the entry is closed as NOT A BUG.git_project/audit/resource/crypto.md F12.src/ resource audit. Cross-cutting: every site below is filed in its own area report, the class is filed in none.Symptom: the pools fail closed and these callers fail open.
crypto/mac/hmac_sha256.c:72 returns without setting ctx->okey, which is then used.crypto/rng/rng.c:74 dereferences an unchecked protocore_secure_persist_span().network_drivers/application/smb/smb2.c:917,929,987,998 pass protocore_secure_span(...).buf with no protocore_span_ok().network_drivers/presentation/http/http3/quic_crypto.c:46,50,180 write through protocore_secure_alloc with no NULL test.services/security/ikev2/ikev2.c:1434,1458 the same, and sk_message_build:1697 discards the seal result on top.services/system/esp/esp.c:78,113 the same.The AEAD backends close the trap: test/core_setup/hal/portable/portable_aes128gcm.c:209-213 writes through storage unconditionally, so a NULL arrives as a store rather than as a check.
mmgr/secure.h:99-105 states the contract - "Returns NULL if the request does not
fit - callers MUST handle null and fail closed" - and protocore_secure_span() yields an empty span so an omitted protocore_span_ok() writes nothing rather than dereferencing null. Both are advisory. Nothing in the signature forces the check, so six independent TUs skipped it the same way.hmac_sha256.c:72 leaves ctx->okey indeterminate and proceeds. Under deterministic ECDSA nonce generation that is a nonce-reuse path, and nonce reuse recovers the private key. The rest are NULL writes and a reset.protocore_span_ok() first.git_project/audit/resource/crypto.md, network_drivers_application.md, network_drivers_http.md, services_security_storage.md, services_net_system.md.PROTOCORE_PLAINTEXT_SCRATCH + PROTOCORE_PLAINTEXT_WORK_H2CONN + PROTOCORE_PLAINTEXT_WORK_H3CONN + 256, and the twelve board profiles pin PROTOCORE_PLAINTEXT_SCRATCH instead of the total, so a per-connection term always adds on top of whatever a die tuned. What remains open is the transient half: the scratch figure is still a per-die guess rather than the max over the declared PROTOCORE_PLAINTEXT_WORK_* transient terms, so the 8,760 B compressed-SSH draw against an 8,192 B c2/s2 scratch is still unproved.src/ resource audit. Cross-cutting: the derivation gap and the failure it causes are filed in two different area reports and connected in neither.protocore_config.h:6477 sets PROTOCORE_PLAINTEXT_ARENA_SIZE to 8192. The plaintext work terms sum to 15,011 B. Each module header asserts only that its own term fits the arena alone, so no two concurrent borrows are ever proved to coexist. The live consequence: SSH's real nested draw is 8,760 B with PROTOCORE_ENABLE_SSH_ZLIB=1, against the 8,192 B arena, so publickey auth fails closed on every compressed session.PROTOCORE_SECURE_ARENA_SIZE from its PROTOCORE_WORK_* terms, so a new draw that is not budgeted fails the build. The plaintext side has no equivalent derivation, so a new draw that is not budgeted fails at run time instead, on the path that happened to be second. PROTOCORE_PLAINTEXT_WORK_SSH_TRANSPORT is defined and referenced nowhere, which is the same gap showing from the other direction.git_project/audit/resource/mmgr_shared_primitives.md (the missing derivation, and the 15,011 B sum), network_drivers_ssh_security_codec.md (the 8,760 B draw and the orphan).h2_conn and h3_conn onto the connection-owns-the- memory law.h2_conn and h3_conn used to hold their frame, header-block, scratch and per-stream buffers inline, inside s_h2 (PROTOCORE_H2_POOL_ATTR) and s_qpool (PROTOCORE_QUIC_POOL_ATTR) - both of which a board can move to PSRAM. As pool borrows those bytes now live in PlainPoolStorageCtx.mem, which has no placement attribute, so on an S3 they are 346,816 B of internal DRAM where they were previously PSRAM-movable.[PROTOCORE_REG_POOL_SLOTS][PROTOCORE_PLAINTEXT_ARENA_SIZE], and PROTOCORE_REG_POOL_SLOTS is PROTOCORE_WORKER_COUNT + 1. A per-connection persistent borrow is therefore budgeted once per worker slot plus once for the ghost, which never serves a connection. On the default single-worker build that is 2x: 693,632 B on an S3 against ~400 KB of usable DRAM.PROTOCORE_WORK_SSH_CONN and PROTOCORE_WORK_TLS_CONN are budgeted the same way in s_secure_storage, which also has no placement attribute. The conversion made the existing accounting expensive enough to see, it did not introduce it.test/ for RFC conformance (git_project/audit/http1-core.md #1).http_parser.c:381 is ‘cl = cl * 10 + (size_t)(*q - '0’)inside the digit loop, with a digit-class check and a duplicate-disagreement check around it and no bound on the accumulator. Content-Length: 18446744073709551616folds to **0** on both 32- and 64-bitsize_t; Content-Length: 4294967301folds to **5** on 32-bit, which is the ESP32 target. The parser reaches PARSE_COMPLETE/PARSE_BODYand the residual octets are reparsed as a pipelined request.**Root cause:** the comment three lines above cites RFC 7230 ยง3.3.2Content-Length = 1*DIGITand the loop enforces exactly that - the grammar, not the range. RFC 9110 sec 8.6 states the other half in the same paragraph: a recipient "MUST anticipate potentially large decimal numerals and prevent parsing
errors due to integer conversion overflows". RFC 9112 sec 6.3 item 5 makes an invalidContent-Length a 400 and a connection close. This is a live request-smuggling vector.**Nothing can catch it:**test_pentest.c:217feeds the 20-digit value and then asserts only assert_http_bounded()(:105-112: buffer indices in range,parse_statea valid enum), which cannot distinguish 400 from silent acceptance.**Fix:** not written. Refuse whencl > (SIZE_MAX - 9) / 10before the multiply and return PARSE_BAD_REQUEST`, and give the test an assertion on the status rather than on boundedness.test/ for RFC conformance (git_project/audit/ssh-auth-connection-telnet.md #15).telnet.c:142 is raw_send(slot, "PC Telnet ready\r\n> ", 22);. The literal is 19 characters, 20 bytes with its NUL. Two bytes past the end of the string constant are read and pushed onto the wire on every connection.raw_send takes the count as given.test_telnet.c:60-69 compares only the first 6 bytes of the greeting.raw_send(slot, greet, sizeof(greet) - 1) (19 bytes); the accept test now pins the whole 25-byte output (negotiation + greeting) so a hand-counted over-read cannot regress. Verified: native_telnet.telnet.c TN_SB). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/ssh-auth-connection-telnet.md #4).TN_SB closed on a bare T_SE (240), but RFC 855 terminates a subnegotiation only on IAC SE (255 240). A data byte 240 inside the parameters ended the subnegotiation early, and the bytes after it fell through to the NVT command line - an injection.TN_SB_IAC state, so a subnegotiation ends only on IAC SE and a bare 240 is data. The test now uses IAC SE, and a negative test proves a bare 240 mid-parameters does not inject. Verified: native_telnet, 23/23.ssh_auth.c protocore_ssh_auth_parse_request). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/ssh-auth-connection-telnet.md #3).ssh_auth.c:279 reads the service field of SSH_MSG_USERAUTH_REQUEST into req->service, and that member appears nowhere else in the file. A request naming service "bogus" authenticates exactly as one naming "ssh-connection"."ssh-connection".ssh-connection (str.eq, the bounded compare), so a foreign name fails the request (RFC 4252 sec 5). Negative test test_parse_rejects_foreign_service. Verified: native_ssh, 265/265.ssh_auth.c protocore_ssh_auth_handle_pubkey). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/ssh-auth-connection-telnet.md #2).string(session_id) || request (RFC 4252 sec 7), but the length was taken from ssh_sess[i].session_id_len without consulting have_session_id. Before any KEX both are zero, so the blob degenerated to uint32(0) || prefix: a signature bound to no session at all, which is exactly the binding the field exists to provide.set_session_id_0_to_31() helper, so no test ever drove the handler with no session id bound.have_session_id is false. Negative test test_pubkey_without_session_id_fails. Reachability was already limited by the phase guard in ssh_server.c, so this closes the auth layer rather than a live hole. Verified: native_ssh + 14 more envs, 573/573.ssh_channel.c protocore_ssh_channel_handle_request). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/ssh-auth-connection-telnet.md #8, #9).mem.cmp name matches, so a CHANNEL_REQUEST truncated immediately after want_reply was answered CHANNEL_SUCCESS. A pty-req carrying no TERM, no dimensions and no mode blob, and an exec carrying no command, both succeeded.shell is unchanged, carrying no request-specific data. New negative test_chan_request_truncated_fields_refused; the two tests that pinned the old behaviour now assert the RFC's. Verified: native_ssh + 14 more envs, 573/573.telnet.c handle_data). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/ssh-auth-connection-telnet.md #12).CR NUL as a carriage return alone, and clients send it for Enter. CR returned early awaiting LF, then the NUL was swallowed by the b < 0x20 control-byte arm, so neither byte did anything: the line was never dispatched and the user's Enter did nothing., which never pairs CR with NUL.**Fix:** asaw_crflag pairs the CR with whichever byte completes it, andCR LFandCR NULboth end the line through onedispatch_line. Testtest_cr_nul_dispatches_line. Verified:native_telnet`, 24/24.ssh_server.c protocore_ssh_server_dispatch). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/ssh-auth-connection-telnet.md #13).if (!s->authed) return -1; and these two did not, so both were dispatched on an unauthenticated session. Not exploitable: chan_by_id finds no open channel before authentication, so both handlers were no-ops. Fixed as an asymmetry, not a live hole.authed_arms[] list the three suites iterate omitted exactly these two message types, so the gap was enshrined rather than caught.authed_arms[] list in all three suites now names all eight. Verified: native_ssh + 14 more envs, 573/573.test/test_matrix.json, native_ssh_hardened and native_ssh_pqc). Found 2026-08-09 while running the suites for the four fixes above.src but their flags never define PROTOCORE_ENABLE_SSH=1, so the derived arena terms for SSH were absent and the pool borrows on the publickey and KEX paths failed. Every borrow-taking test in them failed closed: test_ecdsa_publickey_auth_succeeds_when_password_disabled returned USERAUTH_FAILURE, and all five native_ssh_pqc KEX tests returned -1 out of ssh_kexinit_parse.native_ssh_pqc now runs 10 cases where it reported 5.PROTOCORE_ENABLE_SSH=1, then platformio.ini regenerated with test/gen_test_envs.py. A sweep of the matrix found no third env in this state. Verified: native_ssh_hardened 4/4, native_ssh_pqc 10/10.ssh_transport.c ssh_kexinit_parse, ssh_keymat.h, ssh_dh.c, ssh_packet.c). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/ssh-transport-kex.md #6).ssh_transport.c:674 rejected the KEXINIT when s2c != c2s, and :700 when m_s2c != m_c2s. RFC 4253 sec 7.1 negotiates the two cipher lists and the two MAC lists independently, so a client whose directions carried different preferences was refused outright even though each list negotiated cleanly on its own.cipher_alg / mac_alg, so the two directions had nowhere to differ. Everything else was already per-direction: aes_key_c2s/_s2c, chacha_key_*, gcm_ctx_*, and both 64-byte mac_key_* buffers.test_kexinit_parse_rejects_direction_mismatch asserted the -1, pinning the behaviour the RFC contradicts.cipher_mode_c2s/_s2c, mac_mode_c2s/_s2c) and read through km_send_cipher / km_recv_cipher / km_send_mac_mode / km_recv_mac_mode, mirroring the existing km_send_mac / km_recv_mac key selectors. ssh_keymat_wipe releases each GCM context on its own mode, so a mixed session cannot leak one. The test now asserts both directions negotiate independently. Verified: native_ssh + 16 more envs, 594 cases.ssh_transport.c ssh_kexinit_parse, ssh_server.c KEXDH_INIT arm). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/ssh-transport-kex.md #5).first_kex_packet_follows boolean, or the reserved uint32. A client that guesses sends its KEXDH_INIT ahead of our reply; when the guess lost negotiation that packet carries an algorithm nobody agreed on, and RFC 4253 sec 7.1 says it MUST be silently ignored. It was parsed as the genuine one instead.drop_guessed_kex_pkt when the flag is set and negotiation did not settle on the client's first-listed kex and host key - negotiate_alg now reports the winning entry's position, so "the guess held" is position 0 in both. The dispatcher consumes one KEXDH_INIT silently when the flag is up. Every negotiate_* caller moved from !f(...) to f(...) < 0, since position 0 is a success. Test test_wrong_kex_guess_is_dropped. Verified: native_ssh + 4 more envs, 342 cases.ssh_server.c NEWKEYS arm). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/ssh-transport-kex.md #12).ext_info_c was set, and ssh_kexinit_parse re-reads that flag from every KEXINIT including a re-key's, so the message went out again on each one. RFC 8308 sec 2.4 gives a server exactly two places to send it: after its first NEWKEYS, or immediately preceding USERAUTH_SUCCESS.test_ssh_kexinit_midsession_rekey drives a full re-key with an ext-info-c-bearing KEXINIT and never checked what the second NEWKEYS emitted.ext_info_sent on the session gates it to the first NEWKEYS. The re-key test now asserts no EXT_INFO follows. Verified: native_ssh, ssh_comp, ssh_kbdint, ssh_pqc, 337 cases.ssh_transport.c ssh_transport_recv_banner). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/ssh-transport-kex.md #15).n >= SSH_VERSION_MAX (256) measured on the content with CR stripped, so 255 content bytes plus CR LF - 257 on the wire - was accepted. RFC 4253 sec 4.2 bounds the string at 255 including the Carriage Return and Line Feed, so the content limit is 253.test_banner_and_build_caps pinned only the 256-byte rejection, leaving the RFC's actual bound untested in both directions.SSH_VERSION_CONTENT_MAX (253) is the bound the SSH- line is checked against. test_recv_banner_rfc_length_bound pins 253 accepted and 254 refused. Verified: native_ssh + 16 more envs.dtls_conn.c process_ciphertext_record), superseding the OPEN entry this replaces. Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/dtls13-rpk.md #3).fail(c, ALERT_DECRYPT_ERROR) and killed the connection, so anyone able to send a packet to the port could end an established session with 48 bytes of noise.open_app path had a tampered-record test; the connection path had none, and two tests asserted the fatal behaviour outright.dtls_record.c protocore_dtls_plaintext_parse), superseding the OPEN entry this replaces. Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/dtls13-rpk.md #4).|| that performed it.dtls_handshake.c reasm_add). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/dtls13-rpk.md #10).test_hs_reasm_overlap_and_duplicate exercises three overlapping fragments, all carrying identical bytes, so the silent overwrite looked correct.native_dtls_hs 22/22, 202/202 across eleven DTLS envs.dtls_conn.c). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/dtls13-rpk.md #1).handshake_failure (40), which tells the client the parameters could not be agreed - so a client that would have retried without a cookie gives up instead.illegal_parameter (47) on the cookie path only; the paths that genuinely cannot agree parameters still send 40. Verified: 214/214 across thirteen DTLS/TLS envs.tls13_msg.c). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/dtls13-rpk.md #2).test_tls13_dtls_client_hello_shape asserted the opposite - "a
non-empty legacy_cookie is skipped just the same" - and every test ClientHello writes 0x00.tls13_msg.c, dtls_conn.c). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/dtls13-rpk.md #5).PROTOCORE_ENABLE_TLS_RPK is 0, because the whole extension case sat inside that guard and was not even parsed.has_server_cert_type and offers_x509_server_cert beside the existing RPK flag. A client that sent the extension and named neither a type we can produce gets unsupported_certificate (43).tls_conn.c tls_conn_slot_storage). Found 2026-08-09 building the per-file suite the driver did not have.conn_init called protocore_secure_wipe(c, sizeof(*c)) and then secure.persist_span(). The wipe nulls the very pointers that record whether the connection already holds storage, so the guard could never fire: a slot that closed and was re-accepted took another borrow from the persistent end, which is never given back. MAX_TLS_CONNS inits exhaust the pool permanently.ssh_pkt_slot_storage had the answer already - a null check on the slot's own first pointer, one span, split by named offsets into the slot's fields - and the shape was not copied.tls_conn.c compiled in no test env at all.tls_conn_slot_storage(TlsConn *c) is ssh_pkt_slot_storage against TlsConn: returns early when c->tx is set, otherwise takes the one borrow and splits it across tx, rx, terms, hash_work, sign_work, ks_work and hello. ks_work is new - the key schedule reached its region by arithmetic on the borrow base, so it now has a field like the rest. init wipes the borrowed bytes and the session state, never the pointers. native_tls_conn is the env that was missing; the suite holds its connection in static storage, the way a pool slot is, and re-initialises the one slot per case, which is the path that was broken. Verified: native_tls_conn 5/5, 209/209 across thirteen TLS/DTLS/crypto envs.protocore_config.h). Found 2026-08-09 standing up native_tls_conn.tls_conn.c:41 asserts PROTOCORE_ENABLE_TLS_RPK because the portable arm authenticates by RFC 7250 raw public key, but the config guard accepted that flag only alongside PROTOCORE_ENABLE_DTLS or PROTOCORE_ENABLE_HTTP3. The two conditions cannot both hold for a standalone software-TLS build, so PROTOCORE_TLS_SOFTWARE was unbuildable on its own - which is why it had no env.tls_record.c protocore_tls_record_protect / _unprotect). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/tls13.md #1).protect would seal one and unprotect would hand it back as a valid record.test_empty_plaintext_carries_only_the_type sealed a zero-length alert and asserted it was accepted, pinning both halves of the violation.protect with a crafted content-type byte, since our own protect now declines to build it directly. The sec 5.4 all-zero-inner and sec 5.2 record-overflow guards were already correct but untested; both now have one. Verified: native_tls_record 20/20.tls13_msg.c, quic_tls.c, dtls_conn.c, tls_conn.c). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/tls13.md #2, #3).cipher_suites into a local and discarded it, and all three servers answered TLS_AES_128_GCM_SHA256 unconditionally - so a ClientHello that never offered that suite got it in the ServerHello, against RFC 8446 sec 4.1.3. legacy_compression_methods was likewise read and dropped, where sec 4.1.2 says it "MUST contain exactly one byte, set to zero" and a TLS 1.3 server aborts on anything else.offers_aes128gcm_sha256, matching the offers_tls13 shape already in Tls13ClientHello, and all three servers refuse a ClientHello without it. The compression rule is enforced in the parser, where a malformed field belongs.Tls13ClientHello lives inside the TLS connection's own borrow (tls_conn.c TLS_OFF_HELLO), and TLS_OFF_KS is derived from sizeof(Tls13ClientHello), so growing the struct shifts the offsets after it. They follow correctly because they are computed rather than written down, and the PROTOCORE_TLS_CONN_STATE_CAP static_assert proves the slot still covers them. Verified: 184/184 across all ten TLS/DTLS envs plus native_crypto_kat.hkdf.h protocore_hkdf_expand, test_crypto_kat.c, the RFC 5869 vector file and its curator/generator). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/tls13.md #5, #11).salt/ikm/prk and the suite called only protocore_hkdf_extract. Expand - the half that derives every TLS and QUIC traffic key - was checked against nothing published. The only >32-byte check anywhere was test_quic_crypto.c's expand_label_ref, a line-by-line mirror of the same T(i) loop in the same file, so a shared convention error (T(0) as 32 zeros rather than empty) agreed with itself and passed.protocore_hkdf_expand_label was public, and it wraps info in the RFC 8446 HkdfLabel structure. RFC 5869 Appendix A expands an arbitrary info, so its vectors could not reach the code through the exported surface at all.protocore_hkdf_expand is exported - the bare sec 2.3 primitive the file already implemented internally, which is what the RFC's own vectors address. The vector file, its curator and the generator field list now carry info, L and OKM for A.1-A.3, and both halves run. A.2 asks for 82 bytes, three SHA-256 blocks, which is the only published multi-block case and the first external check the T(i) chain has ever had. The mirror in test_quic_crypto.c stays, narrowed to what it alone covers - that the HkdfLabel wrapper feeds the chain the right info block - and its comment now names the external anchor. Verified: 201/201 across twelve crypto/TLS/DTLS envs.hkdf.c hkdf_expand). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/tls13.md #12).uint8_t incremented without a bound, so a request for more than 8160 bytes wrapped it and T(256) repeated T(1) - the output would silently reuse key material already emitted.quic_frame.c protocore_quic_build_connection_close, quic_conn.c protocore_quic_conn_close_app). Found 2026-08-09 implementing the HTTP/3 state-machine rules from git_project/audit/quic-http3-qpack.md.H3_FRAME_UNEXPECTED (0x0105) lands in the transport space of RFC 9000 sec 20.1, where 0x0100-0x01ff is CRYPTO_ERROR plus a TLS alert - so the peer would read a frame sequencing error as "crypto failure, TLS alert 5". The frame parser already accepted both types; only the builder and the queue were transport-only.queue_close records which was chosen. protocore_quic_conn_close_app is the entry point for an application error; per sec 10.2.3 the 0x1d frame is only legal in 0-RTT/1-RTT, so before those keys exist it falls back to a transport close with APPLICATION_ERROR, and there is a test for that arm. Verified: native_quic_frame, native_quic_conn, native_h3_conn and three more, 105/105.h3_conn.c). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/quic-http3-qpack.md #1, #2, #3, #8, #9).native_h3_conn and five more, 105/105.quic_packet.c, quic_conn.c, quic_tp.c). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/quic-http3-qpack.md #4, #5, #6, #7).protocore_quic_parse_long_header tested only the header-form bit and protocore_quic_parse_short_header only its absence, so a packet with the Fixed Bit (0x40) clear parsed normally where RFC 9000 sec 17.2 / 17.3.1 says it "MUST be discarded". The Reserved Bits were never read, though sec 17.2 makes a non-zero value a PROTOCOL_VIOLATION. initial_max_streams_bidi and _uni took any 62-bit varint, where sec 4.6 caps them at 2^60.quic_conn after the AEAD open rather than inside protocore_quic_packet_unprotect: the header is authenticated by then, so a bit flipped in flight cannot close a connection, and the connection is also the only layer that can send the error. Verified: native_quic_packet, native_quic_tp and six more.h2_conn.c dispatch_frame, handle_continuation). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/http2-hpack.md, the lower-severity remainder) and by an earlier spec audit of h2_conn.c.protocore_h2_parse_header validates the framing, and each handler validated what it needed to do its own job. Nothing owned the rules that are a property of the frame type itself.test_h2_unknown_stream_frames asserted three of these were tolerated, so the suite pinned the violation.last_peer_stream, which separates a stream that was never opened from one opened and since closed - the latter may still receive a late WINDOW_UPDATE (sec 6.9) and ignore it, and there is a test for each. A wrong PRIORITY length is a stream error, so the connection survives it; the rest are connection errors. Verified: native_h2conn 56/56.h2_conn.c handle_continuation, PROTOCORE_H2_MAX_CONTINUATION).PROTOCORE_H2_HDR_BLOCK, the bytes it may accumulate. A CONTINUATION carrying no payload adds no bytes, so a peer could send them without limit and the block would never have to end, at no memory cost to the attacker.PROTOCORE_H2_MAX_CONTINUATION bounds the frame count as well as the byte total. The two bounds answer different questions and the byte one could never have answered this. Verified: native_h2conn 56/56.test_coaps_server.c client_get_temp, assert_coap_205). Found 2026-08-09 sweeping the envs that build the shared HTTP sources.native_coaps_server ERRORED, 8 of 21 cases red, every one on TEST_ASSERT_TRUE(protocore_coaps_server_ingest(...)) returning FALSE.client_get_temp(DtlsRecordKeys *w, ...) called DtlsRecord.protect(&w, ...) - the address of its own pointer parameter, a DtlsRecordKeys **, where protect takes DtlsRecordKeys *. The sealer read a stack slot holding a pointer as key material, produced nothing, and returned 0. ring_push refuses a zero-length datagram, so the failure surfaced one call later at the ingest. assert_coap_205 had the same defect on unprotect. The two sibling call sites at :387 and :410 are correct - they take the address of real DtlsRecordKeys values, which is what made the wrong pair look idiomatic.native_coaps_server 21/21, native_coaps 6/6.h2_server.c cb_header / cb_headers_end). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/http2-hpack.md #3, #4, #5).HttpReq and marked PARSE_COMPLETE. A request with no :method, two :path fields, a pseudo-header after a regular one, an undefined pseudo-header, an uppercase field name, a colon inside a non-pseudo name, a value wrapped in whitespace, or a Connection / Transfer-Encoding / Upgrade field all reached the route dispatcher. The audit names this the request-smuggling surface: a front end and this server would disagree about where one request ends.:method -> :path -> :authority -> other), never as a validator. Nothing in the module held per-block state, so no duplicate or ordering rule could have been expressed even if one had been wanted.h2_server.c. The module had zero coverage.>> 5 and & 31. on_headers_end now returns the verdict, and the engine - which owns the frame borrow - answers RST_STREAM(PROTOCOL_ERROR) from the dispatcher's span. native_h2server is the env that was missing; a mutation run with the verdict disabled put 13 of its 15 cases red, so the suite observes the validation and not the transport. Verified: native_h2server 15/15, native_h2conn 36/36.h2_conn.c content_length_holds, note_content_length). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/http2-hpack.md, remainder).content-length: 10, send five octets, and have them delivered as a complete body; or declare 2 and send 5; or declare a body and end the stream with its headers. RFC 9113 sec 8.1.1 makes each of those malformed. Two disagreeing accounts of one body's length is the primitive every request-smuggling attack is built from.h2_server parsed content-length into HttpReq for the application's benefit and nothing compared it to anything. h2_conn, which owns both the stream state and the DATA accounting, never saw the header as meaning anything.native_h2conn 56/56.h2_conn.c handle_headers, decode_block). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/http2-hpack.md #14).handle_headers rejected every HEADERS whose stream id did not exceed last_peer_stream, so a trailer section - which RFC 9113 sec 8.1 permits as a second HEADERS on an open stream - killed the connection.test_h2_stream_id_must_increase pinned the conflation without separating the two cases.last_peer_stream is now looked up. No stream, or one past OPEN, is still the sec 5.1.1 connection error; a stream still OPEN makes the block a trailer section. A trailer that does not carry END_STREAM, or that contains a pseudo-header (sec 8.1), draws RST_STREAM(PROTOCOL_ERROR). Trailers are decoded so the HPACK dynamic table stays in step with the peer's encoder, but never delivered: the request they trail has already been dispatched, and delivering them would have re-run cb_headers_end and dispatched it twice. Verified: native_h2conn 36/36.h2_conn.c handle_data). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/http2-hpack.md #2).handle_data called c->cb.on_data(...) and only then looked the stream up, and it never read H2Stream.state at all. A DATA frame naming a stream no HEADERS had ever opened had its payload handed to the application as a legitimate request body. RFC 9113 sec 5.1 makes any frame other than HEADERS or PRIORITY on an idle stream a connection error of type PROTOCOL_ERROR; sec 6.1 makes DATA on a stream that is not open a stream error of type STREAM_CLOSED.state on END_STREAM, so it was placed after the delivery it should have gated. The audit names this the request-smuggling surface.test_h2_data_empty_and_unknown_stream fed DATA on never-opened stream 5 and asserted both that the call succeeded and that the body reached the app.test_h2_data_after_end_stream_resets_the_stream covers the sec 6.1 half. Verified: native_h2conn, 32/32.h2_conn.c dispatch_frame). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/http2-hpack.md #6, #7).c->conn_send_window += (int32_t)inc with inc up to 2^31-1 is signed overflow, which is undefined behaviour, not a large window.test_h2_window_update_zero_and_overflow. Verified: native_h2conn, 32/32.hpack.c protocore_hpack_decode, the 0x20 arm). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/http2-hpack.md #1).dyn_set_max silently lowered it to the table's storage. RFC 7541 sec 6.3 says a value above the limit the enclosing protocol determined MUST be a decoding error, and RFC 9113 sec 4.3 makes a decoding error in a field block a connection error of type COMPRESSION_ERROR. So a peer's illegal update was taken as a legal one.h2_conn.c advertises no SETTINGS_HEADER_TABLE_SIZE, so the RFC 9113 sec 6.5.2 default applies and it equals PROTOCORE_HPACK_TABLE_BYTES.test_dyn_size_update encoded an update to 100000 and asserted the decode returned TRUE, pinning the clamp as correct.nm > HPACK_BYTES before applying it. The test now pins the boundary - 4096 applied, 4097 and 100000 refused. Verified: native_hpack + 6 more, 96/96.hpack_prim.c protocore_hpack_decode_int). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/http2-hpack.md #8).hpack_prim.c:137 bounds the continuation with m > 28, so m == 28 is admitted and (b & 0x7f) << 28 shifts up to 127 into a uint32_t. Confirmed by compiling the function verbatim: input {0x1f,0x80,0x80,0x80,0x80,0x7f} returns TRUE with consumed = 6 and value = 4026531871 (0xf000001f); the encoded value is 34091302943.m == 28 only the low 4 bits of the septet still fit. RFC 7541 sec 5.1 requires that an integer exceeding implementation limits "in value or octet length" be a decoding error.test_hpack.c:439-440 pins only the m > 28 rejection.m == 28 && (b & 0x7f) > 0x0f, and the running sum is checked against 0xFFFFFFFF - v before it is added, so neither the shift nor the add can wrap. Test test_int_decode_rejects_overflowing_prefix_int pins the audit's own vector plus the boundary either side (0x0f accepted, 0x10 refused) and the octet-length bound. Verified: native_hpack, native_h2conn, native_qpack and four more, 96/96 - QPACK shares this primitive, so the fix is on the HTTP/3 path too.test/ for RFC conformance (git_project/audit/websocket-and-codecs.md #2).base64.c:330-333 breaks out of the loop on ‘’='and returns the bytes accumulated so far as the decoded length.protocore_base64url_decode("Zm9v=", 5, ...)returns 3. Everything after the first=is discarded without a diagnostic, so on the JWT/JWS path<segment>=<anything>decodes identically to<segment>.**Root cause:** the branch is commented "optional padding ends the input (public length information)", which is the base64 rule, not the base64url one. RFC 4648 sec 3.2/sec 5 and RFC 7515 sec 2 give base64url no padding at all, and sec 3.3 requires rejecting any character outside the alphabet. The surrounding decoder is carefully branchless for constant time; this one earlybreakis the hole in it, and it is also the only place the function can return a short count as success.**Nothing can catch it:**test_base64.c:141-147asserts the 3 and pins the behavior as correct.**Fix:** not written. Fold'='into thebad` accumulator like every other non-alphabet byte and drop the early break; correct the test to assert rejection.test/ for RFC conformance (git_project/audit/websocket-and-codecs.md #3).sse.c:110-142 protocore_sse_format memcpy's data, event and id into the record with no escaping. A value containing \n\n terminates the record early and the remainder is parsed by the client as new fields; a single \n silently truncates the field.test_sse.c:326-357 pins exact bytes only for newline-free values.data: continuation line, which is what the format is for.test/ for RFC conformance (git_project/audit/coap-mqtt.md #3).mqtt.c:1141 sets s_mqtt.tx[0] |= 0x08 and calls mq_tx(s_mqtt.inflight[i].len). tx is one shared framing buffer that every later protocore_mqtt_build_ack / pingreq / subscribe / second PUBLISH overwrites (:776, :790, :814, :822, :1067, :1077, :1116), so a retransmit sends the current contents of that buffer, with bit 3 set, for the old PUBLISH's length. A PINGREQ 0xC0 goes out as 0xC8.:1139-1140 states the invariant it relies on - "One packet is in
flight at a time, so tx is that packet" - and PROTOCORE_MQTT_MAX_INFLIGHT is 4, so the invariant is false. The retransmit needs the packet, and what it has is a buffer.native_mqtt is a host build with PROTOCORE_HAS_NET_STACK 0, so the whole transport - QoS 1/2 flows, dedup, keepalive, MqLinkState - is compiled by no test env.test/ for RFC conformance (git_project/audit/http-cache-and-auth.md #1).jwt.c:146-148 returns PROTO_TRUE from protocore_jwt_time_valid whenever now_epoch <= 0, so on a device that has not yet synced time every exp and nbf is accepted. A token that expired in 1970 validates.test_jwt.c:286-287 asserts the fail-open as correct behavior for both protocore_jwt_time_valid and protocore_jwt_verify_hs256_at.exp/nbf and no clock is available, or make the caller pass an explicit "time claims not required" flag so the decision is at the call site.test/ for RFC conformance (git_project/audit/http-cache-and-auth.md, non-RFC note).auth.c:519 is if (!str.eq(expected, response, sizeof(expected), PROTO_TRUE)) on the computed versus client-supplied Digest response - an early-exit byte compare on an authentication tag, which is a timing oracle. check_basic at auth.c:396-397 does use protocore_ct_eq, so the two auth paths in one file disagree.protostr.h:113 states the rule in the library's own words - "A secret comparison uses
`protocore_ct_eq`" - and str.eq is the general-purpose comparison that stops at the first difference.protocore_ct_eq over the full 64-hex-character width, matching the Basic path.test/mocks for harness holes (git_project/audit/mock-mnt-and-support-fixtures.md #3).file_serving.c:227 takes const protocore_mnt_backend *file_sys and the body never reads it - file_sys occurs in the file only at :227 (the parameter), :525/:527 (passed through from serve_file) and :530/:560 (recorded by serve_static). Line 230 goes straight to protocore_fs_open(file_root(), ...), the globally mounted store. An application following the documented serve_static("/ram/", protocore_mnt_ram(), "/") idiom serves from whatever is mounted, not from the backend it named.mnt.c:47-57) and file_serving.c:637/:642 and webdav_handler.c:433 read it back and pass it down, so the plumbing is complete up to the last function, which drops it. There is not even a (void)file_sys;, and the build is -std=gnu11 without -Wextra, so the unused parameter is silent.test_file_serving.c:24 declares static const protocore_mnt_backend *g_fs; and never assigns it, so all 18 serve_static() calls pass NULL. The setUp comment at :69-70 records the symptom ("resolves through the accessor, not through the backend handed to `serve_file()`") without treating it as a defect.file_sys when it is non-NULL and fall back to the accessor otherwise, and give the test a second backend so the two are distinguishable.ssh_packet.c ssh_recv_plain). Found 2026-08-08 auditing test/ for RFC conformance (git_project/audit/ssh-transport-kex.md #7).ssh_packet.c:747 checks only pad_len_byte >= pkt_len. All four encrypted receive paths check < 4 (:438, :514, :592, :700); the pre-NEWKEYS path does not, so a packet declaring 0 to 3 bytes of padding is accepted there.docs/SSH.md:73 claims the rule holds.test_ssh_server.c:582 covers only the over-large case (6 >= 6).< 4 arm as the other four. Verified: native_ssh + 16 more envs.test/vectors (git_project/audit/crypto-primitives-and-vectors.md #1).test/vectors/wycheproof_aes_128_gcm.json holds 40 vectors, all result:"valid". The runner executes all 40, so nothing is truncated downstream - the corpus itself contains no tampered tag, no flipped AAD byte, no truncated ciphertext. An AEAD is tested only on success.tools/crypto/curate_crypto_vectors.py:63-73 files a test under flagged when result=="invalid" or t.get("flags") is non-empty, then takes flagged[:CAP_FLAGGED] with the cap at 40 (:37). Every valid AES-GCM vector carries Pseudorandom/Ktv/SpecialCase (35/3/2 = 40 exactly), so the cap fills with valid-but-flagged entries before the first invalid one is reached. The HMAC corpora survive the same code only by accident of ordering.invalid first and fill the remainder with flagged-valid, or cap the two classes separately. Re-curating changes the committed corpus, so it wants its own commit.test/ for crypto coverage (git_project/audit/crypto-primitives-and-vectors.md #3).src/crypto/ct_eq.h:33 is the library's single comparator for, in its own doc comment (:8-10), "every MAC, tag, digest and signature check". Grep across test/ returns no reference to the symbol - not a functional equality test, let alone a timing one.test_base64.c:6-7 already uses.test/ for RFC conformance (git_project/audit/fieldbus-and-files-naming.md #1).tcp_conn.c:385, tcp_listener.c:506, tcp_client.c:175, udp/udp_client.c:48, udp/udp_listener.c:176 - so bits 6-7 are zeroed to Not-ECT on every connection that sets a DSCP.test_diffserv.c:57-68 always starts from pcb.tos = 0, and :35 pins protocore_dscp_to_tos(63) == 0xFC with the comment "ECN still 0" - so no test ever presents a pcb already carrying ECT(0), ECT(1) or CE.test/ for RFC conformance (git_project/audit/fieldbus-and-files-naming.md #7, #14).dns_wire.c:53 accepts any pointer offset within the message, including one greater than the current position, so forward and mutually-referential chains are followed - bounded only by the 8-hop PROTOCORE_DNS_PTR_HOPS counter. Separately, dns_wire.c:82 bounds the assembled name only by the caller's out_cap, so with a large buffer a chain of 63-octet labels yields a name past the RFC 1035 sec 2.3.4 limit of 255 octets.test_dns_wire.c:102-107 covers the self-pointer {0xC0,0x00} only - not a forward pointer, not a pointer past len, not an over-long assembled name.>= cur, and cap the assembled length at 255 independently of out_cap.test/ for standards conformance (git_project/audit/fieldbus-and-files-naming.md #2, #8).modbus_master.c:46, :127, :323, :443 - accepts a transaction id or reads adu[0..1], so a response cannot be matched to its request and a stale or injected response is accepted for whatever is outstanding. The same four check only len < 9 and the protocol id, never adu[4..5] (Length) against the delivered byte count.test_modbus_master.c:136, :376) with no assertion on them.shared/runops.h and mmgr/protostr.c each carried a full implementation of the same operations on top of mmgr/swar.h - the NUL scan, the first- difference index, the agreement walk behind eq/starts, the search behind find/has, and the bounded copy. Neither was built on the other. 44 call sites across 10 files took the header, and the rest of the tree took str.mmgr/ with the byte layer and the header they came from was left in place, so the two drifted independently. SRCBANNED #13 bans exactly this ("an alias, a
wrapper, a second spelling"), and the copies had already diverged in the small: runops.h used ?: (banned by #23, carried in sweep_baseline.json as 20 whitelisted ternaries) where protostr.c spells the same dispatch as if/else.str (proto_scan_nul -> str.len, proto_diff* -> str.diff(..., ci), proto_eq_str* -> str.eq(..., ci), proto_starts* -> str.starts, proto_find* / proto_has* -> str.find / str.has, proto_copy -> str.copy), and runops.h deleted. native_dns_resolver, native_mdns_service, native_client and native_h3_server gained +<mmgr/protostr.c>: the header was static inline and the module is not, so those four linked nothing before and needed the TU.str is extern const StrNs, not the folding static const table mem and swar use, so a call through it is an indirect call the compiler cannot fold. On the hot paths that took the header inline - the HTTP method compare and the route match in http.c - that is a real change, measurable on the S3 bench (test/penetration_testing/rig_firmware/src/main_swarbench.cpp).ssh_client.c:1155 is r_u32(&r); // their max packet - the CHANNEL_OPEN_CONFIRMATION field is parsed and discarded. The send pump then clamps to its own SSH_CLI_MAXPKT (16384, :1145) at :1312. Against a peer advertising a smaller maximum we emit an over-long CHANNEL_DATA; a strict peer drops the channel.maximum packet size half of the same per-channel state as the window, and the client keeps a private copy of that state instead of the one owner: ssh_client.c:302-303 declares send_win / recv_win and does its own arithmetic at :1273-1334 and :1477, duplicating SshFlow (ssh_flow_control.h), which the server side has used since the signaling move. A second implementation of an invariant is a place the invariant can be dropped, and this is the field that was dropped.ssh_client.c is compiled by no test env - all twelve native_ssh* entries in test/test_matrix.json list other sources, none lists ssh_client.c. ~1700 lines of client KEX, host-key verification, user auth and channels with zero coverage.CliChannel folds onto SshFlow, which stores peer_max_pkt at open and gates the send, so the cap is the peer's and the check cannot be forgotten. Wants a native_ssh_client env first, or the fix is unverifiable in the same way the bug was.ssh_packet.c:29 SshPacketState ssh_pkt[MAX_SSH_CONNS]; and ssh_keymat.c SshKeyMat ssh_keys[MAX_SSH_CONNS]; are file-scope arrays with external linkage. They hold decrypted packet bytes, key schedules and HMAC keys, and they are cleared by nothing on release.ssh_keymat.h:85 already says out loud ("physical separation only raises the bar, not a hard wall"). The secure pool exists for exactly this content and wipes on release; as BSS these arrays are outside both it and the plaintext arena, so they are covered by neither derived worst case.PROTOCORE_WORK_SSH_* term proved by a static_assert at the borrow site, the shape crypto/aead/aes128gcm.h already uses.src/.file_serving.c:132 calls sscanf(ims, "%*3s, %d %3s %d %d:%d:%d", ...). The comment four lines above it reads "Parses the date by hand (sscanf, no stdlib)". sscanf is stdlib - the sentence contradicts the line it describes, and a reader auditing for stdlib use believes it.strftime and strstr, so <stdio.h> and <string.h> are both live here. SRCBANNED ban 25 bans both headers in src/; the sites are in the ratcheted baseline, so the gate stays green and the comment is what a reader actually reads.sscanf pulls the full scanf format engine into the link, on a path that parses one fixed RFC 1123 shape - six integers and a three-character month at known positions.src/.src/ include <stdio.h> (SRCBANNED ban 25, 31 ratcheted baseline entries whose text reads "nothing in src/ formats"). 19 of them reference no stdio symbol at all - among them server/io/middleware.c:17, server/io/websocket_sse.c:25, server/core/logbuf.c, server/core/power_mgmt.c, codec/json/json.c, ssh/transport/ssh_transport.c. Eight more mention a stdio name only inside a comment.snprintf calls that were replaced by protocore_sb; nothing removes an include that nothing needs, and the baseline records the site rather than expiring it.file_serving.c (entry above), and the WSC_DBG / CL_DBG debug printf macros in ws_client.c:276 and http_client.c:354.src/ is C: 349 .c, 378 .h, zero .cpp. 46 comments still point at the old names - bignum.h:48 "see protocore_bignum.cpp", md.h:27 "md.c", presentation.h:14 "http_parser.h / http_parser.c", ssh_flow_control.h:13 "ssh_channel.c held the counters", websocket.c:459 "lowlevel_recv_cb() (tcp.c)", aes256ctr.h:56 "the mode ssh_packet.c uses", and 40 more. Every path names a file that cannot be opened..cpp -> .c conversion renamed files and left the prose. check_comments.py already owns the "a sentence about code that is no longer there" family but matches on phrasing, not on whether a path in a comment resolves.check_comments.py that resolves any <name>.<ext> token in a comment against the tree - mechanical, and it cannot regress after.frame.c:115 is size_t used = proto_scan_nul(out, cap); on entry. Appending n fragments walks the document n times, so the three emitters that build an array in a loop are O(n^2) in document length: dashboard.c:130-169, gpio_map.c:67-80, partition_monitor.c:99-113.(out, cap), so the length it needs is not in the signature and has to be recovered by scanning. protocore_frame_vbuild returns the length it wrote, so the caller already holds the number the scan re-derives.proto_scan_nul is the SWAR scan and tests four bytes per iteration rather than one. That is a constant factor on a quadratic.protocore_frame_append caller.#ifndef default in protocore_config.h:6469 (8192) and again in all eleven vendor/board_profiles/esp/*_defaults.h, scaled by die RAM: c2/c61/h2/h21/s2 8192, c3/c5/c6/h4 10240, s3/s31 12288, p4 16384.derived_sizing.h states that principle in its own header for RX_BUF_SIZE and the plaintext pool never got it.PROTOCORE_PLAINTEXT_WORK_WS_SEND / _WS_RECV (websocket.h:76,84), _SSH_TRANSPORT (ssh_packet.h:197), _OIDC (oidc.h:58), _DIAG (protocore.c:757). Each is static_asserted against the arena individually. The arena is not derived from them.PROTOCORE_PLAINTEXT_WORK_* terms - max, not sum, because the arena resets per dispatch - taken in protocore_config.h, which is the only file that sees them all. The eleven board-profile defaults then delete, and protocore_plaintext_alloc's NULL return becomes unreachable.dns_resolver.c:432 waits for its answer in while (!s_dr.done && (int32_t)(deadline - protocore_millis()) > 0) { Udp.listener->poll(); pcdelay(5); }. A resolve issued from inside a UDP handler never sees its reply and always returns false after the full PROTOCORE_DNS_TIMEOUT_MS.poll_all() sets s_lst.polling for its duration and returns immediately on a reentrant call, so a resolve called from inside a handler polls nothing for every iteration of the wait. And the listener pump belongs to session.c, which calls it from worker 0; reaching into it here drains every bound port's ring and runs every other service's handler from inside a DNS call.dns_resolver.c:303 (vendor arm, pcdelay only), tcp_client.c:268 (connect wait), and the tls.c handshake waits at :915, :954, :977.if (now - tu_timer >= delay) clears busy and runs it; otherwise the call returns busy and the caller comes back on its own tick. Busy is marked on the TX side only, so RX stays open and the reply handler always runs. resolve() collapses to one entry point returning READY / BUSY / FAILED, which is the shape ntp_service.h already states ("Returns immediately; the first sync
arrives asynchronously") and the shape lwIP's ERR_INPROGRESS uses.dns_resolver.{h,c} (both arms), tcp_client.c (protocore_client_open blocks on DNS then on connect), edge_cache_proxy.c:163/211/229, the AdsClient example, and test_dns_resolver / test_client. It converts as one piece: resolve() moving from proto_bool to an enum silently inverts if (!resolve(...)) at tcp_client.c:250.PROTOCORE_HAS_BUS batch. Pre-existing on main: 94c236e9 without the batch gives the identical four errors.pio test -e native_swar ERRORS in the compile stage. ‘deflate.c:297: unknown type name 'DeflateNs’and'deflate_raw' undeclared here (not in a function), and the same pair for InflateNs/inflate_rawatinflate.c:455.**Root cause:** both headers declare the namespace type and itsexterninside #if PROTOCORE_ENABLE_WS_DEFLATE(deflate.h:67-74,inflate.h:35-71), and both sources close that same gate before defining the instance:deflate.c:295is#endif // PROTOCORE_ENABLE_WS_DEFLATEand the definition sits at:297, one line past it. With the feature off the type is not in scope but the definition is still compiled, andnative_swaris an env that builds the codec with PROTOCORE_ENABLE_WS_DEFLATEoff.**Fix:** the definition moves above the#endifin both files, so the instance is inside the gate that declares what it is. This is the same shape asradio_power.c, where six entry points bound outside their gate.**What it uncovered:**native_swarstill fails, now at the link rather than the compile, on protocore_aesgcm_key_init/protocore_aesgcm_key_wipe/protocore_aesgcm_sealout ofssh_conn.c,ssh_dh.cand ssh_packet.c, plusbn_expmod_group14`. That is the entry below, and it is why the compile error had been sitting there: nothing downstream of it had ever run.native_swar, found 2026-08-08 behind the Deflate / Inflate gate fix above. The general question the last bullet raises is still open.pio test -e native_swar fails at the link with undefined references to protocore_aesgcm_key_init, protocore_aesgcm_key_wipe, protocore_aesgcm_seal and bn_expmod_group14, out of translation units the suite has nothing to do with. test_swar covers mmgr/swar.h, which is header-only lane math.test/test_matrix.json gives native_swar "src": [] and "flags": [], so gen_test_envs.py emits no build_src_filter and PlatformIO falls back to its default of +<*>: the env compiles every file under src/. With no flags, every PROTOCORE_ENABLE_* takes its default, so SSH is on and ssh_conn.c / ssh_dh.c / ssh_packet.c compile for real. The AEAD and bignum backends they call live under test/core_setup/hal/portable, which sits outside src/ and so is in no env's default filter. Same family as the native_quic_server entry below: an env whose source set does not close over what its sources call."src": [] (native_application, native_auth, native_base64, native_swar, native_transport and 20 more). Most pass, because their flags turn the heavy features off before those files compile to anything. native_swar has an empty flags list as well, which is what makes it the one that links the whole default feature set.native_swar takes "src": ["-<*>"] and "test_build_src": "no", which is what native_concurrency already uses for the same shape. test_swar covers mmgr/swar.h, which is header-only static inline, so the suite needs no library source at all and now builds none. (2026-08-08: the bounded scan it also covered moved to mmgr/protostr.c with the runops removal below, and its assertions went with it, so native_swar still links nothing.)src should keep meaning "the whole tree". That default is what let this env silently acquire translation units it was never meant to build, and 24 other envs carry it. They pass only because their flags switch the heavy features off before those files compile to anything, which is a coincidence rather than a decision.main.pio test -e native_ina219 ERRORS in the compile stage: ‘test_ina219.c:72: 'INA219_REG_BUS_VOLTAGE’ undeclared (first use in this function); did you mean 'INA219_REG_BUS'?. The identifier appears nowhere else in the tree.**Root cause:**ina219.h:34-39names the register pairINA219_REG_SHUNT/INA219_REG_BUS, matching the doc comments ("shunt voltage" / "bus voltage").test_begin_and_read_drive_the_bus spells the second one out in full. The reference is under no#if, so the env cannot compile at all.**Fix:** the test takes the name the library publishes. The header's pair is self-consistent and is whatina219.c` reads, so the test was the side that was wrong.PROTOCORE_HAS_BUS batch. Pre-existing on main: 94c236e9 without the batch fails identically, and the other 36 envs in that run pass.pio test -e native_application reports 96 cases, 2 failed. test_empty_route_pattern_matches_nothing (:1873) and test_path_param_segment_mismatches (:1930) both fail the same assertion, strstr(tcp_captured(), "404") returning NULL. The TEST_ASSERT_FALSE(handler_called) immediately above each one passes, so the matcher is refusing the route correctly and the response is what does not arrive.:885 runs the identical arm_slot / pcb / tcp_capture_reset / handle / strstr(out, "404") sequence on GET /nope.txt and passes. Cross-test route pollution is ruled out too: setUp calls protocore_server_reset() (protocore.c:117), which resets the route table, the mount points, the response headers, the middleware and the signal table.http.c:728 answers a route miss with s_http.not_found when one is set and the built-in 404 only when it is not. protocore_server_reset() empties every other owner that holds an app registration - HttpRoutes.reset(), Auth.reset(), protocore_mnt_point_reset(), protocore_resp_reset(), protocore_middleware_reset(), protocore_signal_reset() - but HTTP's own HttpCtx was not among them, because HTTP was the one owner that never exposed a reset. Two tests near the top of the suite call on_not_found(), so from that point on every route miss in the binary ran their handler instead. The path had nothing to do with it; GET /nope.txt passed only because it runs before those two.HttpNs gains reset, which puts HttpCtx back to the blank template the same way protocore_server_reset() does for its own ServerCtx, and protocore_server_reset() calls it. That also clears the edge-cache poll seam, which had the same escape.ac33d37a0 (its base) gives the identical link errors.pio test -e native_quic_server ERRORS in the link stage, before any test runs. native_h3_server builds the same file and passes.src list carries presentation/http/http3/h3_server.c, whose protocore_h3_resp_sink and protocore_h3_server_request reference conn_pool, http_pool, http_reset, Tcp, and Http. It carries neither the HTTP owner nor the TCP owner that define them, so ld reports eight undefined references out of h3_server.o.h3_server.c and tests quic_server.c alone. The dispatch bridge is what native_h3_server is for: it extends the full-app base, so the route table and the slot pools the bridge reaches are already in the image. The env now also carries the UDP listener, the client, the address mapping and ip.c, which is what quic_server.c binds its port through.protocore_quic_server_begin() take listen_on's already-bound path, which rebinds the handler without a fresh pcb.protocore_quic_server_stop() cleared the running flag, the pool and the ring cursors, but never called Udp.listener->close(), so the bind outlived the server it belonged to and a datagram arriving after the stop still filled the ingest ring. quic_server.h has documented the call as closing the binding since it was written.protocore_quic_server_stop() closes the port first, before releasing the pool, so nothing more reaches the ring while it is being torn down.PROTOCORE_ENABLE_HTTP3 or a nonzero PROTOCORE_REQUEST_TIMEOUT_MS or PROTOCORE_ENABLE_HTTP_DELIVERY with PROTOCORE_ENABLE_AUTH off. Any build that does fails to compile on an implicit declaration.server/clock/clock.h and http_delivery.h sat inside #if PROTOCORE_ENABLE_AUTH. Their callers are elsewhere: protocore_millis() runs the QUIC poll under PROTOCORE_ENABLE_HTTP3 and the request timeout under PROTOCORE_REQUEST_TIMEOUT_MS, and protocore_delivery_cache_control() runs under PROTOCORE_ENABLE_HTTP_DELIVERY alone. An include nested under a flag it does not belong to reads as correct as long as one flag implies the other in every configuration anyone has built.clock.h is unconditional and http_delivery.h takes its own PROTOCORE_ENABLE_HTTP_DELIVERY guard. sha1.h, base64.h and sha256.h came out with them: nothing in the file names a symbol from any of the three.presentation/http/http.c.native_application fails to compile with ‘'Auth’ undeclared,'HttpRoute' has no member named 'auth_id', and'CSRF_TOKEN_BUF' undeclared.**Root cause:** the extraction script took each function's body and the comment block above it, but not the#ifon the line before that, and not the#endifafter.protocore_csrf_gate, handle_ws_route,proto_authorize_request,lockout_client_ipandsend_too_many_requests arrived in the new file unguarded, whileprotocore.ckept four#if/#endifpairs with nothing between them. Every call site was still guarded, so the guards read as intact.**Blast radius:** any build with the moved feature off, which is every env except the ones that set the matching flag.**Fix:** the four guards are restored around the functions inhttp.c, the empty pairs are gone fromprotocore.c`, and the CSRF, lockout and forwarded-trust includes moved with the code that names them.test_auth passes its early cases and fails its late ones. test_application serves a static file in one case and 404s the identical setup two cases later. Nothing distinguishes a passing case from a failing one except how many ran before it.add() returns the row index. protocore_server_reset() emptied the route table and left every one of those tables full. Auth and the mount registry both cap at MAX_ROUTES, so once the count reached it Auth.add() returned PROTOCORE_AUTH_NONE and protocore_mnt_point_add() returned PROTOCORE_MNT_NONE. A route that holds those answers dispatches unguarded and serves nothing, silently, and both are the fail-open direction.MAX_ROUTES protected routes or mounts across its cases.Auth publishes a reset, mnt.c publishes protocore_mnt_point_reset(), and protocore_server_reset() calls both beside network.route->reset(). An id is a row index, so the tables empty with the routes that index them.test_auth failures above.native_auth, Auth.add() returns PROTOCORE_AUTH_NONE for the first credential set ever registered, so every protected route dispatches unguarded. 17 of 23 cases fail.PROTOCORE_SECURE_ARENA_SIZE is the sum of the PROTOCORE_WORK_* terms a build compiles, and protocore_config.h states the rule that every borrow declares one, proved by a static_assert in the owning module. route.c and auth.c both borrow from the pool and neither declared a term. Measured under native_auth: the arena is 1664 bytes (bignum 1408 + 256 round-up), the route table is 1416 and the credential table 1569. The route table binds first and leaves 248, so the credential table never binds.protocore_secure_span() bumps down from the scratch end, which mark walks and release reclaims. Both tables are held for the life of the program.secure.h publishes protocore_secure_persist_span(), over the arena's persistent end, which no mark reaches and which hands back zeroed bytes. Both tables take it, PROTOCORE_WORK_ROUTE_TABLE and PROTOCORE_WORK_AUTH_TABLE join PROTOCORE_SECURE_ARENA_SIZE, and each module static_asserts its struct against its term.native_keepalive.on_http("/res", ...) registers nothing. Every request answers 404 and no handler fires, while the keep-alive machinery around it behaves correctly, because a 404 is a normal response that keeps the connection alive. Four test_keepalive cases fail on the response body and the handler tally; the ones that only assert the Connection header pass.HttpRouteNs::init, and no caller anywhere in src/ calls it. BSS needed no init, so nothing existed to update. s_route stays NULL, add() returns NULL on its first guard, and every registration path drops the route it was handed. protocore_server_reset() calls network.route->reset(), which is also a no-op on a NULL handle, so the table reads empty and consistent at every seam.bind_route() taken on first use, the shape auth.c already uses for the same reason, and init leaves HttpRouteNs. A registration is the first thing that touches the table and every reader runs after one, so there is no moment a caller has to remember.ConnState, TcpConn, conn_pool and CONN_* come out undeclared in a test that changed nothing. native_accept_gate, native_presentation and native_session fail to build.TcpEvt and EvtType moved out of tcp.h into their own tcp_evt.h, and presentation.h, session.h and listener.h each narrowed their include to the new header, which is what they need. The slot types stayed in tcp.h. 24 white-box suites named the slot types while including only a layer header, so they were reaching tcp.h transitively and lost it the moment the layer stopped needing it.network_drivers/transport/tcp.h for the names it uses. The layer headers keep the narrow include.native_scp and native_ssh_sftp fail at link with ‘undefined reference to 'proto_scan_nul’, preceded by an implicit-declaration warning atsrc/mmgr/protoframe.c:113.**Root cause:**protocore_frame_appendcallsproto_scan_nul, which is astatic inlinein shared/runops.h, andprotoframe.cincludes onlymmgr/protoframe.hand shared/speed_opt/speed_opt.h. Under C99 rules the implicit declaration makes it an external call to a name no TU defines, so it survives compilation and dies at link. Every other env that buildsframe.calso builds a TU that pullsrunops.hin ahead of it, which is why only the two filesystem-application envs showed it.**Fix:**frame.cincludesshared/runops.h. (2026-08-08: that header is gone and the include is nowmmgr/protostr.h` - see the entry below.)-DPROTOCORE_HOT_FORCE still compiles the host path. PROTOCORE_VENDOR_MOCK comes out 1 and PROTOCORE_HOT comes out 0 at the same time, which the platform header states is impossible ("exact complements, there is no third"). No diagnostic: the env builds and its tests pass, against the wrong arm.protocore_platform.h derives PROTOCORE_HOST from the vendor axis, defining it only in the else-arm reached when no vendor matched, and PROTOCORE_HOT_FORCE selects PROTOCORE_VENDOR_MOCK in the arm above it. But gen_test_envs.py also passed -DPROTOCORE_HOST=1 in the flags every native env extends. The command-line define wins: the guard that would have set it is skipped, #ifndef PROTOCORE_HOST then finds it already defined, and #if PROTOCORE_HOST selects the host path under a mock vendor. The vendor axis cannot override a value handed to it from outside, and #undef is banned (SRC_LAW rule 11), so no arrangement inside the header could have recovered.PROTOCORE_HOT half of the tree had no test env at all - test/core_setup/*/mock/ exists to stand in for silicon and nothing was compiling against it. That is how the protocore_lwip_to_ip bug above survived: its arm was unreachable from the suite.-DPROTOCORE_HOST=1 from native_base. It was redundant - nothing on a native build matches a vendor, so the else-arm defines it anyway - and dropping it makes the vendor axis the only decider. A new native_hot_base extends the base with -DPROTOCORE_HOT_FORCE, and an env opts in with "base": "native_hot_base".src/ read PROTOCORE_HOST and each includes the platform chain in its first 40 lines; nothing in test/mocks or test/support reads it. The first env on the new base (native_udp_hot) carries #error on !PROTOCORE_HOT, so the fallback cannot return silently.protocore_ip.protocore_conn_remote_addr() and the accept-callback address both report 5.0.0.10 where the peer is 10.0.0.5, on any build that selects PROTOCORE_VENDOR_MOCK (-DPROTOCORE_HOT_FORCE). No host suite catches it because the non-hot arm returns PROTOCORE_IP_NONE and never reaches the mapping.protocore_net_ip4_u32 returns. lwIP's ip4_addr_get_u32 hands back the stored u32_t, whose memory bytes are the network octets. The mock (test/mocks/protocore_net_host.h:730) instead composes a numeric value, bytes[0]<<24 | bytes[1]<<16 | bytes[2]<<8 | bytes[3]. On a little-endian host those are byte reversed. protocore_lwip_to_ip (src/network_drivers/transport/tcp.c:915) peels with (uint8_t)be, (uint8_t)(be >> 8), ..., which is right for lwIP on a little-endian target and backwards for the mock.protocore_net_ip4_u32 has two other callers that compare or return the value opaquely (listener.c:486 interface tagging, tcp.c:890 protocore_conn_remote_ip), so changing the mock to match lwIP moves those too and needs its own verification pass.tcp.c into network_drivers/transport/net_addr.c as NetAddr.to_ip(), where it reads the word's bytes instead of shifting its value and is therefore correct on either endianness. TCP needed it on accept and on the per-slot accessor and UDP needs it per received datagram, so one owner sits beside both rather than inside either.10.0.0.5, 192.168.4.1, 224.0.0.251, 255.254.253.252 round-trip through NetAddr.to_ip() and back out of Ip.format() unchanged on the mock arm; a null source leaves the out-param PROTOCORE_IP_NONE rather than stale. Still to re-run: native_tcp, the listener suites, and test_iface (which asserts on protocore_ap_ip, the value listener.c compares against).native_coaps reports all six tests PASSED and then dies with SIGSEGV, so the env is recorded ERRORED and the suite's result is discarded. The crash is after test_coaps_forwards_handshake returns, not inside any test body.native_coaps_server separately fails three tests (test_server_single_peer, test_two_peers_routing, test_cid_address_migration), which may or may not share a cause.esp32dev env and errored all 315 suites), so there is no clean before-state to compare against. It has NOT been shown to predate the bus-owner or toolchain work, and it has not been shown to be caused by it either.native_coaps under gdb or valgrind for the faulting frame, then bisect against the merge-base if the frame does not name the cause.i2c.h, spi.h, uart.h) were changed to key their real arm off PROTOCORE_PLATFORM_HAS_BUS so a host build with the test seam drives the capture. The thirteen drivers that sit on them still split on #if PROTOCORE_HOT, so each took its own refusing stub and never composed a byte. The owner worked and the driver above it did not.PROTOCORE_HOT || PROTOCORE_PLATFORM_HAS_BUS condition on all thirteen drivers.pcdelay's host arm spun on protocore_millis(), and the host virtual clock only moves when a test calls set_millis, so any wait with a non-zero argument never terminated. Nothing had reached it because the host arm of every driver refused before delaying.protocore_pca9685_begin waits 500 us for the oscillator and protocore_sht3x_read waits 20 ms for a measurement.protocore_platform_task_delay(1) hand-off the RTOS arm does, and the host mock advances its virtual clock there. The microsecond source also advances one per read, so a sub-millisecond settle terminates and its elapsed time is deterministic.native_oidc for the first time: the suite had not compiled since the C conversion, so nothing had executed protocore_oidc_verify_with_key at all.PROTOCORE_OIDC_ERR_FORMAT (-1), including a valid, correctly-signed token that should have returned 0. Twelve of the suite's tests failed with the same code whatever they were actually testing, because the failure happened before any of their input was examined.protocore_oidc_verify_with_key borrows four buffers from the plaintext arena (header, signature, payload, issuer - about 2.6 KB together, hoisted off the worker stack). The C++ version scoped them with a PlaintextScope whose destructor released on every return path. The conversion deleted the guard and did not replace it with a protocore_plaintext_mark() / protocore_plaintext_release() pair, so every call leaked all four borrows. The comment above the allocation still said "PlaintextScope reclaims them on every return path", which is how it read as correct.!hdr || !sig || !pl || !iss is true and every token is rejected as malformed - a fail-closed authentication outage that no input can clear. The same exhaustion is reachable on a target wherever more than one verify runs before the arena is reset.check_src_banned does not model borrow/release pairing, and the leak is invisible to a reader because the comment describes the guard that used to be there.size_t scope = protocore_plaintext_mark(); before the first borrow, and protocore_plaintext_release(scope); on each of the eleven returns after it. The two early returns above the mark (null token, split3 failure) borrow nothing and are unchanged. Comment rewritten to describe what the code does now.PlaintextScope / SecureScope guards already converted by hand in src/, and the two in test_plaintext / test_secure_pool that had lost their release. Any other site where a deleted guard left a borrow unpaired has the same failure mode and the same invisibility.src/ for C++ constructs after the native compile sweep came back clean on the files it can reach.src/ is C11 only on the paths a native env compiles. Everything behind #if PROTOCORE_HOT is still C++, and no native env compiles it, so 307 envs of compile sweep cannot see any of it. Found: namespace fs { class FS; } plus fs::FS & parameters in server/core/exc_decoder.h / server/core/exc_coredump.c, namespace fs and fs::FS * / fs::File & in test/core_setup/hal/esp/esp_mnt_fs.{h,c}, and a whole namespace protocore_wal_fs_detail over fs::File in services/storage/wal/wal_fs.h. A target build of any of them is a hard C error.PROTOCORE_HOT is false on the host, so those regions were never parsed.protocore_exc_coredump_save now takes const protocore_mnt_backend * and goes through the vtable, so server/ names no vendor type; exc_decoder.h drops the namespace fs forward declaration.wal_fs.h cannot be retargeted onto the seam as it stands. Its durability barrier is File::flush(), and **protocore_mnt_backend has no sync/flush entry**. Translating it without one would leave WalDev::sync returning true having done nothing, which turns a power-loss-safe log into one that only looks safe. Adding sync to the vtable touches every backend (RAM disk, the ESP adapter, the lfs mock), so it is an owner decision rather than a mechanical fix.esp_mnt_fs.{h,c} legitimately names Arduino's fs::FS because wrapping it is the adapter's whole job. Resolved by compiling test/core_setup/hal/esp/ as C++ (owner decision), which needs the build rule and a SYMBOLS.md amendment recording the exemption.gpio_map.h failed with test/mocks/protocore_net_host.h:214:20: error: expected identifier before numeric constant.protocore_gpio_dir declared PROTOCORE_GPIO_IN, PROTOCORE_GPIO_IN_PULLUP, PROTOCORE_GPIO_IN_PULLDOWN, PROTOCORE_GPIO_OUT as enum members numbered 0/1/2/3 in that order; board_profiles/protocore_platform.h (and the host mock beside it) #define the same four names as the pin-mode argument, numbered IN=0, OUT=1, PULLUP=2, PULLDOWN=3. Where both headers reach one translation unit the macro wins, because substitution happens before the compiler sees the declaration.protocore_gpio_begin_pins, case PROTOCORE_GPIO_OUT: became case 1:, which is the enum's IN_PULLUP. A pin mapped as an output has dir == 3, matches no case, and falls to the default arm, so it was configured as an input: a mapped LED or relay never drives. A pin mapped IN_PULLUP (dir == 1) took the OUT arm and was configured as an output, so the panel drove a pin wired to a button. protocore_gpio_is_output() compared against the same rewritten constant, so the write route agreed with the wrong table.native_gpio_map was not one of them, so the enum was never parsed with the macro already defined. Where they did not meet, the enum spelled itself and every test passed. On a target the numbers silently disagree and nothing is diagnosed, because both sides are valid integers.PROTOCORE_GPIO_DIR_IN, PROTOCORE_GPIO_DIR_IN_PULLUP, PROTOCORE_GPIO_DIR_IN_PULLDOWN, PROTOCORE_GPIO_DIR_OUT (gpio_map.h), so the case labels are the enum and the arguments to protocore_platform_gpio_mode() stay the board profile's numbers. Call sites updated in gpio_map.c, test_gpio_map.c and the GpioMap example.h2_conn's last lambda to a named function, which put the builder's arguments on their own line.handle_headers() answers the new HEADERS with protocore_h2_build_rst_stream(b, cap, 0, 0) - stream id 0, error code 0 - and keeps the connection. RFC 9113 sec 6.4 says a RST_STREAM with a stream identifier of 0x00 is a connection error of type PROTOCOL_ERROR, so a conforming peer must respond with GOAWAY and close. The refusal that was meant to shed one stream takes the whole connection down instead, and the error code says NO_ERROR rather than REFUSED_STREAM (0x07), which is what tells a client the request was not processed and is safe to retry elsewhere.send_control() takes a size_t (*)(uint8_t *, size_t), which carries no room for the stream id, so the call site passed the two constants the signature allowed.test_h2_stream_table_full_rst asserts only that at least one RST_STREAM frame goes out, never which stream it names. No interop test drives the table to its limit.send_control's builder signature has to carry it (or the refusal path builds its frame directly). Deliberately left alone during the C conversion, whose contract was to change no behavior.src/ against v0.0.1 after check_owned_context reported four contexts with external linkage.resolve, service, intern, peek, new_node, find_port, teardown, do_dns - sat at global scope, and two of them collided outright: s_ctx was defined non-static in both relay_listener.c (RelayListenerCtx) and iface_bridge_hw.c (BridgeGlueCtx). Two external definitions of one name with different types is a duplicate symbol; a C tentative definition can also merge them, giving two unrelated subsystems one object sized to the larger and letting each scribble on the other's state.namespace { ... }, which is internal linkage. C has no anonymous namespace, so the conversion deleted the wrapper. Where it did not substitute static, the guarantee left with the construct. 156 files in 0.0.1 had an anonymous namespace; 8 still carried a symbol that lost its linkage, 51 symbols in total. graphql.c, relay_listener.c and iface_bridge_hw.c lost every symbol theirs held.check_owned_context globbed *.cpp only, so after the conversion it read 51 of 351 implementation files and never saw any of these. Even once widened it reports the three contexts and not the 48 functions, because it checks file-scope mutables rather than linkage in general. check_duplicate_symbols misses s_ctx because it only reports a duplicate that some header also declares extern, and neither does. Nothing else reads linkage at all.static on all 51, verified against the diff as exactly 51 lines each unchanged except for a prepended static. This is the same defect a previous session fixed by hand in crypto/pqc/sntrup761.c using nm on the object file, which is the authoritative check and the one to repeat once the tree compiles.protocore_gql_arg_*, protocore_espnow_* and their kind were declared outside the namespace in 0.0.1 and are correctly external.check_owned_context matched only the C++ spelling thread_local, so static _Thread_local int t_worker_id in mmgr/arena.c read as a loose global. The C11 spelling is now matched too; the variable was always correct.check_duplicate_symbols to .c, which it had stopped reading.PROTOCORE_THEME_BLOBS and PROTOCORE_THEME_BLOB_COUNT are each defined in two tracked files, binary_asset_blobs.c and binary_asset_blobs.c. Any build that compiles the whole tree - Arduino, and the ESP-IDF component, whose CMakeLists.txt globs src/*.c and src/*.cpp - gets two definitions of both and fails to link. The native envs name their sources explicitly and reference neither file, so nothing in the test matrix sees it.web_assets/wizard/gen_theme_blobs.py writes BASENAME + ".cpp" and renders return nullptr; with unbraced bodies. The C conversion produced the .c by hand-editing that generated output (nullptr -> NULL, braces added) and left the .cpp tracked. The generator was never converted with the tree, so feature-tables.yml re-runs it and git adds binary_asset_blobs.c explicitly - deleting the file by hand is undone by the next CI run. The .cpp is byte-identical to what the generator emits today, which is what identifies it as the live output and the .c as the hand-edited copy.check_duplicate_symbols globbed *.cpp only, so after the conversion it compared 51 files out of 351 and could not see a .c/.cpp pair. It reported OK throughout. No native env compiles either file, and no target build runs in CI..c and emits C (NULL, braced bodies); --check now reproduces the committed .c byte for byte. feature-tables.yml and .clang-format-ignore follow the rename. The dead binary_asset_blobs.c still has to be deleted - pending, because deleting it before the generator fix landed would only have brought it back.binary_asset_blobs pair.src/network_drivers/application/webdav/ holds both webdav.c (the filesystem-backed half: PROPFIND/PUT/COPY/MOVE over a mounted subtree) and webdav.c (the pure core: method classification, header parsing, the 207 Multi-Status XML builder). They define different symbols, so this does not break the link, and both are live - the test matrix builds webdav.c in five envs and webdav.c in three, and one env builds both.bddf3f4a3 ("move each module under the layer that owns it") moved the filesystem half into the directory that already held the pure core. webdav.c's own file comment still says the pure core "lives in network_drivers/application/webdav/", which is now the directory it is sitting in, so the comment reads as self-referential.check_duplicate_symbols to catch. Both compile.PROTOCORE_DIAG_JSON citation..c file gets reported as a stale citation and fails CI, with the symbol sitting in the tree the whole time.git ls-files src/*.h src/*.cpp test/*.h test/*.cpp penetration_testing/*.h penetration_testing/*.cpp. That list was written when every implementation file was a .cpp. The C conversion left 576 .c files outside it, and with them 78 PROTOCORE_* names and 63 pc_* functions that no longer appear anywhere the checker looks.*.c to each of the three globs. The set is meant to answer whether a symbol exists in the tree, so it follows the file extensions the tree actually uses.network_drivers/ against v0.0.1.sizeof grew on TcpConn, TcpEvt and protocore_ip, so the static pools built from them grew with no source change naming a size.enum class X : uint8_t states the width in the declaration. Rewriting it as a plain typedef enum drops that clause, and a C enum with no attribute is whatever the implementation picks, which is int on every target here. Six declarations lost it: ConnState, EvtType and protocore_conn_reason in transport/tcp.h, protocore_ip_family and protocore_ip_scope in network/ip.h, and protocore_tcp_op in transport/tcp.c. Three are stored rather than only passed, so each one multiplies: TcpConn holds _Atomic ConnState state and conn_pool is TcpConn[CONN_POOL_SLOTS]; TcpEvt holds EvtType type and every listener carries _queue_storage[EVT_QUEUE_DEPTH * sizeof(TcpEvt)]; protocore_ip holds protocore_ip_family family and is embedded wherever an address is stored. One byte to four, per instance, in the pools whose total is meant to be computable before flashing.PROTO_ENUM_PACKED on all six. The attribute asks for the narrowest type the values fit, which is a byte for each of these, and protocore_types.h already proves the toolchain honors it with a static_assert on proto_enum_probe. protocore_phy_ps in physical/physical.h was the same defect, found and fixed separately, which is what prompted pairing every enum class X : T in v0.0.1 against its counterpart here: 34 enums carried a declared width, 6 had lost it.WsCloseCode (uint16_t -> packed) still occupies two bytes because its values run 1000-4999 and packed widens to fit. DeflateResult and InflateResult (int32_t -> packed) narrow to a byte, and both are return values rather than stored fields. CborType and MsgpackType were folded into protocore_codec_type, which is packed.