|
ProtoCore v0.0.1
Deterministic, zero-heap network stack for embedded targets
|
Layer 7 (Application) - embedded web assets generated from web_assets/input/. More...
Go to the source code of this file.
Variables | |
| const char | PC_DASHBOARD_PAGE [] |
| Real-time SVG telemetry dashboard page (PC_ENABLE_DASHBOARD). | |
| const char | PC_PROV_FORM [] |
| Captive-portal form for WiFi credential entry (provisioning service). | |
| const char | PC_PROV_SAVED_HTML [] |
| Success page shown after credentials are saved and the device reboots. | |
| const char | PC_TERMINAL_PAGE [] |
| Self-contained WebSocket terminal page (green-phosphor CRT theme; auto ws/wss). | |
| const char | PC_SERVICE_WORKER [] |
| Service worker: precaches the app shell from the versioned manifest and serves it stale-while-revalidate. | |
| const char | PC_STATS_JSON [] |
| Runtime stats JSON (rendered via send_template); add/rename fields to taste. | |
| const char | PC_METRICS_PROM [] |
| All available Prometheus metrics; comment a value line out with a leading # to drop it. | |
Layer 7 (Application) - embedded web assets generated from web_assets/input/.
One declaration per source document under web_assets/input/ (its base name is the C symbol). On ESP32 these const arrays live in flash (DROM), read directly - no filesystem or heap. Edit web_assets/input/ and re-run web_assets/wizard/build_assets.py.
`. Both were tracked. Arduino compiles every `.cpp` under a library's src/, so both copies were compiled and both defined the same symbols. They are const, which is internal linkage in C++ and would have been harmless, but web_assets.h declares them extern, which makes them external and therefore a collision.
check_examples.py reads sketch source for API misuse and never links. Worse, the file had already been touched by a green commit: "fix the web.h guard" made the duplicate's include guard unique, which is what a linter asks for and the exact opposite of what the file needed.web.cpp and web.h. web_assets.* is the generated pair (web_assets/wizard/build_assets.py emits it) and all six consumers already included web_assets.h; nothing included web.h, not even web.cpp.ci_tooling/check/check_duplicate_symbols.py, wired into CI. It reports a file-scope variable defined in two .cpp files, but only when the definition actually carries external linkage - non-const, or const with an extern declaration in a header. That distinction is load-bearing: without it the check flags the four codecs that each define their own const DIST_BASE[] / LEN_BASE[], which never collide because a file-scope const is internal linkage in C++. Proven by restoring the duplicate and watching all 7 symbols report.check_src_banned.py --all on Windows now reports 971 known ratcheted site(s) remain, 16 fixed, matching Linux._key() built its baseline key from str(path), and --all collects through pathlib.Path.rglob, which yields a WindowsPath. So the scanner produced src\services\web\httpcache\httpcache.cpp|19|rev#1 while the committed baseline holds src/services/web/httpcache/httpcache.cpp|19|rev#1. Nothing matched, so nothing was ever recognized as known. The module already had a _norm() helper for exactly this, but only collect() used it._key() normalizes the path before building the key. The gate is the same on both platforms now, which is the point of committing the baseline at all.pc_aesgcm was simply 7.6x slower than the same chip's own AES-GCM, and docs/FEATURE_PERFORMANCE.md recorded that as a property of the silicon.SOC_AES_SUPPORT_GCM is unset for the S3, so the code concluded the die had no GCM and hand-rolled a replacement, driving the block cipher 16 bytes at a time with a software GHASH. The capability macro describes one mode of one peripheral; it is not a statement that no fast path exists. The vendor's implementation knows what its own silicon can do.pc_aesgcm_seal_tag() took raw key bytes, so every packet stood an mbedtls GCM context up and tore it down: 9,221 cycles, fixed regardless of message size. ~10% of a 1 KiB record, ~30% of a 256 B TLS record, and most of a small interactive SSH packet.init+setkey+free timed alone is 513 cycles, ~18x under the truth, because a loop that never encrypts never takes the AES peripheral. The cost only appears as acquire/use/release around real work.board_drivers/hal/ (vendor / portable, no weak symbol), and pc_aesgcm became keyed - pc_aesgcm_key_init once per key, seal/open per record, with the raw-key entry points deleted rather than kept as a shim so no caller can pay the lifecycle invisibly. SSH holds a context per direction in its keymat and now stores no raw GCM key at all; SMB/IKEv2/IPsec-ESP build one per call at the call site, where the cost is at least visible.km->aes_key_* instead of building a context. A zeroed GHASH table makes the tag a constant, so the round trip still passed while forged packets were accepted - the tamper assertion is what caught it. Shipping code was never affected (the KEX install path always builds the context), but it is a sharp edge worth knowing: an uninitialized AEAD context fails open, not closed.pc_quic_aes128_gcm and pc_dtls_record carried the identical pair of errors and got the identical pair of fixes: 562,292 -> 80,361 (7.0x) and 578,730 -> 91,209 (6.3x). crypto/aead/aes128gcm.cpp had no vendor AEAD path at all, so it was deleted outright and replaced by esp/portable backends; the AES-128 block primitive (pc_aes128, QUIC header protection) went with it, leaving the core with declarations only. Four raw-key entry points collapsed to two keyed ones - attached-tag is just detached with tag_out = ct_out + len.pc_aes128gcm_open() used to reject a buffer shorter than a tag; the detached api has no combined length to check, and QUIC's ct_len comes off the wire, so ct_len - TAG_LEN would have wrapped to a huge size_t. Explicit guards restored in both callers. DTLS was only accidentally safe - a wrapped length happened to fail a later > out_cap test.clang-format had reflowed the struct member, so a literal-string replacement missed it and - unlike the others - that one had no assertion. The RFC 9001 A.2 wire vector caught it. Every replacement asserts now.native_lora 19/19, and the file now compiles into the S3 image.arduino-cli build of an unrelated sketch died on services/radio/lora/lora.cpp:14: fatal error: services/lora/lora.h: No such file or directory.services/radio/lora/ without updating that file's own #include, and the dws_ -> pc_ rename skipped the file entirely: 13 stale tokens, including its guard #if DWS_ENABLE_LORA.PC_ENABLE_LORA=1 would have compiled every pc_lora_* declaration and then failed to link a single definition - the feature was shipped-broken, and only the default of 0 hid it. The one line outside the guard was the #include, which is precisely why that is all the compiler ever complained about.DWS_/DET_/dws_/detws_ count is now 0.#include under src/ resolves to a real file - that holds whether or not the code around it is enabled.native_primitives 21/21 including four new pc_sb cases.shared_primitives/strbuf.h failed to compile: ‘'uint32_t’ was not declared in this scope, pointing at a signature that had been there all along.**Root cause:**strbuf.hdeclaredpc_sb_u32(pc_sb *, uint32_t)but included only<stddef.h> and<string.h>. Every file that included it happened to pull in<stdint.h>first, so the header was never compiled on its own and the missing include never showed.**Fix:** include<stdint.h>` in the header that uses it.src/services/file_transfer/smb/ntlmssp.cpp stopped compiling with narrowing conversion of 1346592592 from int to uint8_t. 1346592592 is ‘'PC_P’- a four-character literal - where the source had written'P'.**Root cause:** the rule-18 converter prefixes a converted constant and then renames that token across the tree. One of the constants was namedP<tt>(an sntrup761 spec parameter). The rename used (?<![\w])P , and **quotes are not word characters**, so it matched inside literals and prose: -{'N','T','L','M','S','S','P',0}became'PC_P'- the NTLMSSP signature
- NMEA test vectors",12202.1236,W,1,08,"
became,PC_W,- silently changing what they assert
comments:"Q-command codec"->PC_Q-command,"single-bit R/W"->R/PC_W
**Why the gates missed it:** a mangled character literal violates no naming rule, so check_symbols and check_src_banned were both green with'PC_P'in the source. **The compiler caught it** - the narrowing conversion. Types found what pattern matching could not.**Fix:** revert everyPC_<single char>globally (no library macro is one character, so all of them were damage), and make the converter refuse to rename any name of 2 characters or fewer, printing why. The three sntrup761 parameters were then named by hand asPC_SNTRUP_P/Q/W, keeping the spec mapping in a comment - which also removes the#define P 761hazard that rewrites the tokenPin every header that translation unit includes (SYMBOLS.md s2'sOUTPUTproblem, exactly).**Lesson:** a tree-wide rename's safety is a property of the NAME, not of the tool.EDGE_MESH_RESP_MAX can be swept blind;P` cannot, and no amount of care in the sweep fixes that - only refusing the input does.examples/Foundation/Basic builds (908 KB flash / 41% RAM), flashes to an ESP32-S3, joins WiFi, and serves GET / with keep-alive plus the framework's automatic 404.fatal error: network_drivers/physical/physical.h: No such file or directory.#include directives and matching each against an installed library. network_drivers/physical/physical.h matches nothing until ProtoCore's src/ is already on the include path, and the only include that puts it there is protocore.h. With the physical include first, the compile dies before the library is ever discovered. Alphabetical include order is what produced it (n sorts before p), so the house style caused the bug: this is the one place a sketch must not sort, because the header that identifies the library has to lead.src/ directly with explicit include paths, so it never exercises Arduino's library resolution. 5756 host tests passed against sketches that could not build. Only a real arduino-cli compile reaches this code path - which is exactly why the examples compile gate exists as its own task.protocore.h first in all 132 sketches (120 plain, 12 whose include carried a trailing comment). Twelve of them already carried the comment "library entry header (also sets the src/ include root)" - the reason was known and written down, and the order was still wrong. Documenting a constraint does not enforce it.ci_tooling/check/check_examples.py, which reads sketches AND README fenced code. The guard was verified by injecting a regression and confirming a non-zero exit..*? body match, so it mis-attributed members between enums and missed most of them entirely - it saw 376 members where the tree has 894. The first scoping pass therefore fixed 68 and silently left 76. Bounding the body with [^{}]* and stripping #if lines from it (enum bodies are conditionally compiled, so the flag names were being harvested as members) fixed both. The harvester now lives in ci_tooling/lib/src_symbols.py so the sweep and the checker cannot disagree.examples/**/README.md teach #include <WiFi.h>, WiFi.localIP(), WiFiClient, or WiFiUDP in their annotated-source blocks, while 0 of the .ino sketches use any of them. A beginner who follows a README instead of reading the sketch next to it writes exactly the code the library bans, and it will not link against the transport API the sketch actually uses.pc_client_* / pc_udp_* / pc_net_egress_ip()), but the annotated-source blocks are hand-rolled on purpose - the heavy annotation is the teaching content and cannot be generated - so the migration updated the code and left 86 hand-written copies behind. (2) docs/SRCBANNED.md rule 6 explicitly states **"Applies to `examples/` too"** and even documents the check (‘rg -n 'WiFiClient|WiFiUDP|AsyncUDP’ src/ examples/), butci_tooling/check/check_src_banned.pyscans onlysrc/, explicitly exemptsexamples/, and never scans markdown at all. The rule that would have caught this was written down and never enforced where it claimed to apply.**Second class, same cause:** 62 READMEs also use **unscoped enum members** -server.on("/", HTTP_GET, h) andcase HTTP_11:- butHttpMethodis anenum class(src/protocore.h:76), so that code does not compile at all. Every sketch uses the scopedHttpMethod::HTTP_GETform (84 files); zero use the bare form. Same hand-rolled blocks, same migration, same drift.**Fix (planned):** correct both classes in place, preserving the surrounding annotation, and extend the guardrail to enforce rule 6 overexamples/for both.inoand README fenced code, so the documented scope and the enforced scope finally match. The remaining bans staysrc/`-only per SRCBANNED.smb encrypt = required share) with all four SMB 3.1.1 ciphers (AES-128/256-GCM, AES-128/256-CCM), each reading the share file byte-exact.smb_open -> SMB_ERR_PROTOCOL), while GCM worked.SMB2_GLOBAL_CAP_ENCRYPTION (MS-SMB2 §3.2.4.2.2.2). Samba then negotiated no cipher and rejected the unencrypted session. Additionally, a share-level (not global) smb encrypt = required does not set the session SMB2_SESSION_FLAG_ENCRYPT_DATA, so the client - which only encrypted when the server set that flag - never turned encryption on; it needs client-forced encryption (like smbclient -e, MS-SMB2 §3.2.4.1.5).pc_aesccm_open_tag reads the AAD and received tag after writing the recovered plaintext. The SMB codec decrypts in place (rx -> rx, a no-heap design GCM tolerated by read-before-write), so the plaintext overwrote the TRANSFORM_HEADER's AAD (rx[20..51]) and tag (rx[4..19]) before the MAC consumed them - it authenticated garbage and failed closed. Separate-buffer tests couldn't see it (no aliasing).SMB2_GLOBAL_CAP_ENCRYPTION in NEGOTIATE and add SmbConfig.encrypt (client-forced encryption: activate once a cipher is negotiated, regardless of the server session flag). (2) pc_smb2_decrypt snapshots the 48 header bytes (AAD + tag) into locals before decrypting, making in-place decrypt correct for every cipher - the crypto primitive's contract is "out may alias the ciphertext", never the AAD/tag, so the codec that aliased the whole buffer is what must preserve them. Read the authoritative MS-SMB2 spec + the smbclient reference trace to get the negotiation right.crypto/aesccm, detached-tag AES-256-GCM in crypto/aesgcm, all four ciphers wired into the codec + negotiate + key derivation. KAT'd vs pyca/cryptography.MAC=00:00:00:00:00:00 on the P4 - an Ethernet-only board whose PHY plainly has a MAC (e8:f6:0a:e0:a7:8d). The library exposed no way to read the active interface's hardware address.pc_net_mac() returns the WiFi station MAC via WiFi.macAddress() (what ESP-NOW / WiFi diagnostics need), which reads back zeros when the WiFi driver was never started, as on the wired P4. It does exactly what it is documented to do; the real gap was the absence of an interface-neutral "MAC on the wire" accessor for the egress link. Not a regression - the physical-layer vendor-partition HW test just exposed it.pc_net_egress_mac() - reads the live default-route netif's hwaddr (lwIP), so it returns the Ethernet PHY's MAC on a wired link and the WiFi STA MAC on a wireless one, independent of which driver started; fallback stub on host / no-backend builds. Clarified pc_net_mac()'s doc as WiFi-STA-specific and cross-referenced the new accessor. HW-verified: P4 egress_mac=e8:f6:0a:e0:a7:8d (with wifi_sta_mac zeros); S3 egress_mac=94:a9:90:d1:7a:b8 == its wifi_sta_mac (WiFi is the egress there).GET / HTTP/1.1\r\n, then trickle one header line (X-a: b\r\n) every ~3 s and never terminate the headers - filled the entire fixed connection pool (MAX_CONNS) and denied it to legitimate clients forever. A scratchpad/slowloris2.py with 12 holders saw every legitimate probe DENIED (ConnectionResetError) with no recovery.CONN_TIMEOUT_MS, 5 s), and last_activity_ms is refreshed on every accepted RX byte (tcp.cpp, the recv handler). A trickle that drips a byte just under the idle window therefore keeps the idle timer alive indefinitely while never completing a request - so a partial request could hold a slot for as long as the attacker kept dripping. There was no deadline a trickle could not reset.client_header_timeout semantic. A new per-slot req_start_ms is armed once, on the first RX byte of a request (tcp.cpp), and cannot be reset by later trickle bytes. The per-tick session poll (http_poll_slot) reaps any slot still in the header phase (parse_state < PARSE_BODY) past PC_REQUEST_TIMEOUT_MS (default 10 s) with a 408 Request Timeout + Connection: close, freeing the slot. It is scoped to the header phase, so a legitimate slow body upload (which sits in PARSE_BODY for its whole duration, governed by the streaming handler + idle timer) is never reaped; WebSocket / SSE slots are skipped by the poll before the check. req_start_ms is disarmed on request completion so a kept-alive connection re-arms per request.test_dispatch (reaped past deadline, survives before it, a completed slow request is not reaped, a PARSE_BODY upload is not reaped) plus the full transport/app/ accept-gate/keepalive suites. HW (ESP32-P4) - the pool now recovers: probes DENIED at t=2/5/8 s (pool full), SERVED at t=11/14/17 s once the 10 s deadline reaps the holders; a threaded-trickle client (idle timer kept fresh) receives a real HTTP/1.1 408 Request Timeout at t=10.1 s, confirming the request deadline (not the 5 s idle sweep) is what fires; heap stable, no panic across the attack cycles.SSH_MSG_KEX_ECDH_INIT. OpenSSH (the 16/16 algorithm matrix) never hit it because its modern defaults happen to match the order we advertise.KEX_ECDH_INIT, and got a TCP RST with no KEX_ECDH_REPLY. Captured on the wire and reproduced deterministically by feeding the exact KEXINIT + init bytes through ssh_kexinit_parse → ssh_kex_generate → ssh_kexdh_handle in a host test (ssh_kexdh_handle returned -1).negotiate_alg (ssh_transport.cpp) iterated the SERVER's candidate list and picked the first name the client also offered - server preference. RFC 4253 §7.1 mandates client preference: "iterate
over the client's algorithms ... choose the first the server also supports." When the server holds an RSA host key, its prefer_rsa ordering ranks ecdh-sha2-nistp256 + rsa-sha2-512 above curve25519 + ssh-ed25519, so a client that lists curve25519/ed25519 first made the two sides negotiate different algorithms: the server chose nistp256 and expected a 65-byte init, the client sent a 32-byte curve25519 init, parse_ecdh_init_p256 rejected it (n != 65), and ssh_kexdh_handle returned -1 → RST. OpenSSH advertises its algorithms in the same PQC/curve-first order we do, so it always guessed our top pick and never diverged; a differently-ordered implementation was required to expose it. The same server-preference applied to cipher/MAC/compression (they happened to agree only because the top choices matched).negotiate_alg now iterates the client's name-list in order and takes the first name any available server candidate matches (RFC 4253 §7.1 client preference) - for KEX, host key, cipher, MAC, and compression. Host-tested (native_ssh 196/196, incl. a new client-preference cipher test and a regression that drives the exact captured CycloneSSH bytes) and HW-verified on the ESP32-P4: CycloneSSH now completes the full KEX + auth + a byte-exact encrypted-channel echo, and OpenSSH is unaffected.ssh_conn_saturation / ssh_slowloris / ssh_handshake_flood) plus concurrent sntrup761 handshakes killed mid-flight and rapid connect/RST churn.rst:0xc (SW_CPU_RESET)) with any of three panics - assert failed: tcp_output ... (tcp_output: invalid pcb), assert failed: tcp_update_rcv_ann_wnd ... (new_rcv_ann_wnd <= 0xffff), or Guru Meditation Error: Load access fault. A remote peer could reboot the server at will by opening SSH connections and resetting them mid-handshake - a denial of service.tcpip_thread re-checks that the slot still owns the captured pcb before touching lwIP (k->pcb == conn_pool[slot].pcb, since a remote RST frees the pcb via the error callback between capture and execution) - except PC_OP_RECVED, which called tcp_recved(k->pcb, k->len) unguarded (tcp.cpp). The RX-ack path (pc_conn_ack_consumed) checks the pcb worker-side, but the marshaled tcp_recved runs later; if the connection was torn down in between, tcp_recved walks a freed pcb - tcp_update_rcv_ann_wnd (window assert) and, when reopening the window, a window-update tcp_output (invalid pcb) - which is why one hole produced all three signatures. A serial esp_rom_printf trace confirmed every guarded tcp_output ran on an ESTABLISHED pcb (state==4), so the crash was not at any guarded send site - it was the unguarded receive-ack.PC_OP_RECVED with the same O(1) liveness check as PC_OP_SEND/PC_OP_OUTPUT - skip tcp_recved (return ERR_CLSD) when k->pcb != conn_pool[slot].pcb. Verified on HW: this eliminates the tcp_update_rcv_ann_wnd assert and the reboot on the targeted mid-flight-kill + connect/RST churn repro (0 reboots, heap flat ~282 KB). A second, distinct tcp_output: invalid pcb reboot remained under the broader DoS suite (ssh_slowloris / ssh_handshake_flood) - a NULL-pcb hole in the same guards, now also fixed; see the entry below.assert failed: tcp_output ... invalid pcb -> rst:0xc (SW_CPU_RESET), ~26-36 reboots per high-intensity DoS run on an ESP32-P4.pc_pentest.py --only ssh_slowloris - 8 half-open partial-banner connections ("SSH-2.0-sl" + a dribble of . bytes), refreshed ~19 s; also ssh_handshake_flood. Heap stays flat, so a use-after-free, not exhaustion.k->pcb == conn_pool[slot].pcb (a stale pcb after a torn-down connection won't match the slot's live pcb). But the guard compared pointers without a NULL test. pc_conn_flush() marshals PC_OP_OUTPUT with conn_pool[slot].pcb, which is NULL for a slot that was torn down between the caller and the op - so a captured-NULL vs a live-NULL (NULL == NULL) passed the guard and called tcp_output(NULL) -> lwIP's "invalid pcb" panic. The esp_coredump backtrace showed exactly this: frame pc_tcp_do tcp.cpp:297 (PC_OP_OUTPUT) with k->op=PC_OP_OUTPUT, k->slot=0, k->pcb=0x0, conn_pool[0].pcb=0x0, state=CONN_FREE. An earlier esp_rom_printf trace had wrongly ruled this path out (it only ever printed the non-NULL survivors), which is why the coredump was decisive.PC_OP_SEND / PC_OP_OUTPUT / PC_OP_RECVED guards - k->pcb && k->pcb == conn_pool[slot].pcb (SEND: !k->pcb || k->pcb != ...). A captured-NULL now skips (ERR_CLSD) instead of calling tcp_output/tcp_write/tcp_recved(NULL). HW-verified on the P4: the full DoS suite that rebooted it 26-36x now runs 3 PASS / 0 findings, 0 reboots, 0 asserts, heap flat; ssh_handshake_flood also dropped from ~237 s (crash-stalled) to ~19 s. The pentest reboot detector was fixed in the same pass (an ESP32 reboot times out rather than ECONNREFUSED), so a crash-and-recover is no longer mis-read as "alive".PC_OP_CLOSE teardown-reorder tried earlier changed the reboot count by nothing and was reverted rather than shipped - the real bug was the missing NULL test, not the CLOSE path.ssh -o HostKeyAlgorithms=).rsa-sha2-256 got Unable to negotiate ... no matching host key type. The server's advertised server_host_key_algorithms list came across truncated: ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-512,rsrsa-sha2-256 cut to rs. rsa-sha2-512 (one earlier) still worked, which is what made it look key-specific rather than a buffer overrun.ssh_kexinit_build() assembled the host-key name-list into a 48-byte stack buffer (ssh_transport.cpp char hklist[48]). The full four-algorithm list ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-512,rsa-sha2-256 is 57 chars + NUL = 58 bytes, so the bounded snprintf in build_hostkey_list() stopped after ...rsa-sha2-512,rs. It only bites when all three key types are held at once - the SSH example loads just RSA and the pentest rig just ed25519, so the list was always short enough before; a device provisioned with all three (a realistic deployment) hit it.hklist[64]. Regression test test_kexinit_hostkey_list_carries_all_four_when_all_keys_loaded (native_ssh_transport) provisions all three host keys and asserts every algorithm, incl. rsa-sha2-256, survives in the built KEXINIT; it fails on the old 48-byte buffer and passes on 64. Confirmed on HW: the P4 interop matrix went 15/16 -> 16/16 after the fix (every KEX incl. the mlkem768x25519 hybrid, every cipher/MAC, all four host-key algorithms).RX_BUF_SIZE below the streaming floor (S3/P4/S31 = 2048, C3/C5/C6/H4 = 1536, the rest = 1024), enabling PC_ENABLE_UPLOAD/OTA/WEBDAV (streaming) left that small ring in place instead of raising it to 8192. HW-measured on an S3 pinned at 2048: a 4 KB upload succeeds byte-exact, but a 64 KB streamed upload is reset ~5.6 s in (curl exit 56, 0 bytes stored). With the ring at 8192, the same 64 KB (0.7 s) and a 256 KB (1.6 s, ~160 KB/s) upload round-trip byte-exact.protocore_config.h gated on defined(PC_RX_BUF_SIZE_DEFAULTED) - a marker set only in the base #ifndef RX_BUF_SIZE branch. A board profile is included first and sets RX_BUF_SIZE itself, so that branch (and the marker) is skipped and the upsize is a silent no-op for every profile that pins the ring. (2) The failure is not a deadlock (the initial guess): with ack-on-consume the peer's advertised window tracks ring free space, so a sub-window ring forces a sustained upload to dribble a ring-full at a time and spend long spells in backpressure. The idle timer is refreshed only when a segment is accepted, never during backpressure (deliberate - a truly stuck connection must still be reaped, [[tcp.cpp]] :850), so a prolonged sub-window backpressure spell trips the 5 s idle timeout and the connection is reset mid-upload.protocore_config.h into board_drivers/board_profiles/derived_sizing.h (the sizing layer's job), included last once every feature flag is known, and drop the DEFAULTED gate so the floor is enforced against whatever set the value - profile, -D, or base default - as a monotone raise (below floor -> lift; at/above -> untouched, so a deliberately roomy ring is preserved). Because the streaming floor is a full TCP window (8192), it is real DRAM per connection: the classic-ESP32 streaming examples (FileUpload / OTA / OtaRollback / WebDav) dial MAX_CONNS down so 8192 * MAX_CONNS fits the ~122 KB dram0_0_seg.:80 back through it - the whole-firmware, two-task path host tests cannot reach.tcpip-forward all succeeded ("tunnel up"), but the first curl through the forwarded port panicked the device with assert_single_owner scratch.cpp:78 "scratch arena
borrowed from a foreign task". Symptom (2): after that was fixed, the first request returned HTTP 200 but every subsequent one hung / returned 000 until the channel eventually freed.poll() runs in a different task, so opening the forwarded-tcpip channel decrypted a packet from a foreign task and tripped the tripwire. Root cause (2): the client held a single channel slot and only freed it on the relay's CHANNEL_CLOSE, which OpenSSH sends late - it waits for the device's EOF, which never came because the bridged keep-alive :80 connection never closes. So the slot stayed busy and later forwarded-tcpip opens were refused ("administratively prohibited").PC_SCRATCH_SLOTS = PC_WORKER_COUNT + 1 when PC_ENABLE_SSH_CLIENT, claimed in begin() via pc_worker_set_self() so every later decrypt in that task uses the client's own arena. (2) replace the single channel with a pool (PC_SSH_CLIENT_MAX_CHANNELS, with PC_CLIENT_CONNS auto-provisioned to 1 + N), and tear a channel down promptly on the relay's EOF (the forwarded peer is done sending; for a request/response bridge the reply is already delivered) instead of waiting for its CLOSE. Re-flashed: single, 6 rapid-sequential and 4 concurrent requests all return HTTP 200 with the device's body byte-for-byte, sustained across repeated bursts; native_ssh + native_ssh_conn + native_pqc (220 cases) stay green.sftp client - the exact whole-firmware path host tests cannot reach.put round-tripped byte-exact, but any larger put reset the connection during the first SSH2_FXP_WRITE (client saw "broken pipe"); the device did not crash (heap stable, no panic).CHANNEL_OPEN_CONFIRMATION advertised SSH_CHAN_MAX_PACKET = 32768 as the max packet we can receive, but the transport rejects any inbound packet larger than SSH_PKT_BUF_SIZE (2048) - so a peer that believed the advertisement (an SFTP write) sent a packet the transport threw away. (2) more fundamentally, ssh_pkt_recv() appended the whole incoming read to its SSH_PKT_BUF_SIZE buffer before extracting packets and disconnected if the read exceeded the remaining space - so a single TCP read carrying several back-to-back CHANNEL_DATA messages (which is exactly how a client pipelines a large SFTP write's fragments) overflowed the buffer even though every individual packet fit. Interactive shells never send enough at once to hit either.SSH_CHAN_MAX_PACKET from SSH_PKT_BUF_SIZE (- 64) so we never advertise more than we can receive, and it scales when the buffer is raised for throughput. (2) ssh_pkt_recv() now consumes its input incrementally - append as much as fits, extract every complete packet to drain the buffer, then append more - so a multi-packet read is processed instead of rejected. Re-flashed: a 60 KB put/get round-trips byte-exact over the SD card, and native_ssh + native_ssh_conn (209 cases) stay green.put/get worked byte-exact but any ls on a non-empty directory reset the connection.build_entry() serialized a directory entry with an SftpWriter, which reserves a 4-byte packet-length prefix, and returned the entry length - but the entry data started at ent + 4, while do_readdir() copied from ent[0]. So every NAME entry was prefixed with 4 bytes of the discarded length field, malforming the response; the client rejected it and disconnected.build_entry() drops the reserved prefix (memmove(ent, ent + 4, len)) so the entry bytes start at ent[0]. Re-flashed: ls -l lists files with correct sizes / longnames, and rename / rm take effect.PC_ENABLE_EDGE_MESH) on two ESP32-S3s - the exact whole-firmware path host mock-seam tests cannot reach (the mock transport does not model TCP flush/close).X-Cache: MISS, mesh_misses incremented, the origin logged a hit from B) instead of pulling from A (X-Cache: MESH). A hand-crafted valid mesh request to A over :7645 got the connection reset with 0 bytes (WinError 10054) - so A built a response but it never reached the peer.PROTO_MESH serve pump queued the whole response with pc_conn_send (which tcp_writes with TCP_WRITE_FLAG_COPY) and then immediately called pc_conn_close - the immediate teardown, which tcp_closes and, if the FIN cannot queue, tcp_aborts (RST), discarding the just-queued response the peer had not read yet. The requester then saw a closed connection with no complete frame → MESH_FAIL → treated as a miss → origin fallthrough.pc_conn_flush(slot) (tcp_output) then pc_conn_begin_close(slot)CONN_CLOSING until the peer ACKs, finalizing from the sent callback once the TX drains (the same sequence websocket_sse uses for its response-then-close). The closing_finalize path does not dispatch the proto on_close, and pc_conn_send already COPY'd the bytes, so the MeshConn is freed proactively right after begin_close. Re-flashed both rigs: B's cold miss now serves X-Cache: MESH byte-exact from A with Age propagated, the origin is fetched exactly once (by A), and fall-through / symmetry / loop-free all pass. Classic HW-only integration bug (green host tests, a real RST on the wire).Range: bytes=15-35 request against a cold cache returned the full 200 body (83 bytes, HTTP/1.0, X-Cache: MISS) instead of a 206 window. A fresh-**HIT** Range request worked (byte-exact 206), so range parsing/serving was correct - only the miss path lost the range.serve_hit read the Range header from http_pool[slot]. On a fresh hit that runs synchronously in dispatch while http_pool[slot] is still the client request. But a miss/stale entry is served from the poll loop after the async origin fetch, by which point http_pool[slot] has been reset/reused (the tell: the miss response was HTTP/1.0 - send_chunked's version fallback fires because http_pool[slot].version is no longer HTTP_11), so Range (and the version) were gone.Range header at middleware time (when http_pool[slot] is valid) into a per-slot owned buffer EdgeCacheProxyCtx::range_hdr[MAX_CONNS][48], and resolve the window against that in serve_hit. Re-flashed: a cold-MISS Range: bytes=15-35 now returns 206 + Content-Range: bytes 15-35/83 + the byte-exact 21-byte window, and HIT/416/HEAD/Accept-Ranges all stay byte-exact.http_pool[slot]-goes-stale-after-the-async-fetch root cause means a miss response is emitted as HTTP/1.0 (Connection: close instead of keep-alive), and store_response's Vary-value capture reads the stale request - so a Vary response cached on a miss stores an empty/garbage secondary key (it degrades to always-miss-and-refetch for that variant; never serves wrong content). Both are latent efficiency issues present since the RAM tier and want a holistic fix (preserve or snapshot the client request across the suspend); tracked in TODO.md.:80 was refused.GET /cdn/<path> got a connection refusal; the board pinged fine and actively refused :80. A serial DIAG confirmed the edge cache itself was configured (map=1, begin=1 listeners) but no HTTP port was open.server.begin() with no port and no prior server.listen(). begin() with no argument requires a registered listener (else it returns PC_ERR_NO_LISTENERS and starts nothing); the single-HTTP-port form is server.begin(80). A library-contract slip in the example, not a library defect.server.begin(80) in examples/L7-Application/EdgeCache. Re-flashed; the full MISS -> HIT -> REVALIDATED(304) -> purge path then passed byte-exact against a real origin. Exactly the class of whole-firmware integration bug that host mock-seam tests miss and a real HW run catches.native_codeql suite. CI never caught it because the CodeQL workflow builds native_codeql with pio test --without-testing (compile-only, to trace the build) - its assertions have never executed in CI.native_codeql config, test_get_route_advertises_head_in_allow (POST to a GET-only route → expect 405 + GET/HEAD in Allow) and test_405_includes_allow_header (DELETE to a POST-only route → expect Allow: POST) both failed (Expected Non-NULL), while the same tests driven with GET (test_method_mismatch_returns_405, test_405_allow_lists_all_methods_for_path) and HEAD passed.PC_ENABLE_CSRF, and csrf_gate (protocore.cpp) runs before the route loop and rejects any un-tokened state-changing method (POST/PUT/PATCH/DELETE) with 403, so those requests never reach the §6.5.5 405/Allow dispatch. GET/HEAD/OPTIONS are CSRF-exempt, so they still 405. This is the intended, fail-closed security behavior (don't leak a path's allowed methods to an un-tokened request); the test simply didn't account for CSRF being on.X-CSRF-Token (via feed_unsafe + a suite-level csrf_set_secret in setUp, both #if PC_ENABLE_CSRF), so a legitimate token-bearing request reaches the 405/Allow dispatch; with CSRF off the request line is plain. Verified: native_codeql:test_dispatch 11/11 (was 2 failed) and native_app:test_dispatch (CSRF off) still green.test/servers/dtls_wolfssl). The HRR builder shipped in v6.17.0 but was never wired into the state machine until the HRR group renegotiation landed, so no released version ever sent an HRR - the bug was latent in unexercised code.-326, record layer version error). The direct one-round-trip path (client offers X25519 up front) worked, because its first server message is a ServerHello - which did use the DTLS codepoints - while the HRR did not.tls13_build_server_hello already took a dtls flag to emit legacy_version 0xFEFD and supported_versions 0xFEFC (RFC 9147 §5.3), but tls13_build_hello_retry_request - a separate builder for the same ServerHello structure - hard-coded 0x0303 / 0x0304. The byte-exact HRR KAT (native_dtls_tls13) had pinned those TLS codepoints, so it stayed green: another self-referential KAT that shared the mistake.tls13_build_hello_retry_request gained the same dtls flag (0xFEFD / 0xFEFC when set); the DTLS state machine passes dtls=true, and the HRR KAT was recomputed with the DTLS codepoints. Verified end-to-end: wolfSSL now completes the full handshake through a HelloRetryRequest and an application-data round trip (HANDSHAKE OK (via HelloRetryRequest) ... INTEROP OK).dtls_conn; test/servers/dtls_wolfssl). Shipped in v6.15.0 (record layer) and v6.18.0 (handshake).legacy_cookie** (RFC 9147 §5.3): the DTLS ClientHello carries an extra legacy_cookie field between legacy_session_id and cipher_suites; the shared tls13_parse_client_hello (written for QUIC) skipped it, so it read cipher_suites at the wrong offset and the parse failed.0xFEFC in supported_versions (not the TLS 0x0304) and puts legacy_version 0xFEFD in the ServerHello. The server checked for 0x0304 and sent a TLS ServerHello, so wolfSSL rejected it with a protocol_version alert."tls13 " label prefix with "dtls13" in every Expand-Label - both the key schedule (traffic secrets, Finished) and the record key/iv/sn. The DTLS code reused quic_hkdf_expand_label, which hard-coded "tls13 ", so every DTLS secret was wrong. Diagnosed by dumping our handshake-traffic secret and comparing to wolfSSL's SSLKEYLOGFILE, then bisecting transcript vs. key-schedule in an independent Python reconstruction.tls13_parse_client_hello takes a dtls flag that skips legacy_cookie; (2) tls13_build_server_hello + supported_versions parse use the DTLS codepoints under the same flag; (3) the label prefix became a first-class KDF variant - Tls13Kdf (TLS13_KDF / DTLS13_KDF), bound once into the Tls13KeySchedule and passed to the record-key derivation, replacing the hard-coded prefix (no bool dtls threaded through the schedule). The record KAT was recomputed with "dtls13". Verified end-to-end: wolfSSL now completes the handshake and an application-data round trip (INTEROP OK).ecdh-sha2-nistp256 KEX (v6.14.0).ssh_kexinit_build() return -1 in some builds; two native tests (test_begin_rekey_preserves_session_and_auth, test_ssh_transport_more_guards) failed with a stack-smash abort (SIGQUIT) once the payload crossed the buffer edge.SSH_KEXINIT_S_MAX was 512, exactly enough for the previous advertised suite. Adding ecdh-sha2-nistp256, (19 bytes) to kex_algorithms pushed the worst-case server KEXINIT (PQC hybrid + zlib s2c + all three host-key types + the full cipher/MAC lists ~= 580 bytes) past 512, tripping the w.len > SSH_KEXINIT_S_MAX guard (which had been marked "never exceeds"). The production packet buffer (SSH_PKT_BUF_SIZE = 2048) was fine; the fixed i_s[] store and one test's local 512-byte buffer were not.SSH_KEXINIT_S_MAX to 704 (headroom over the ~580 worst case) and grew the test's local kbuf to 1024. Also corrected test_kexinit_parse_rejects_missing_kex, which had used ecdh-sha2-nistp256 as its example of an unsupported KEX - now ecdh-sha2-nistp521.InterfaceBridge failed at link with an undefined reference to pc_bridge_publish() - chronically red since the example shipped (v6.8.0).build_flags by scraping the first documented pio ci command from its README.md (ci_tooling/generate/example_footprints.py). InterfaceBridge's README had no such command, so CI built it with empty flags: the library's iface_bridge_hw.cpp guards its body under #if PC_ENABLE_IFACE_BRIDGE, so with the flag absent pc_bridge_publish() compiled to nothing while the sketch (which sets the flag only in its own translation unit) still referenced it. An in-sketch #define never reaches the separately compiled library.## Build section with -DPC_ENABLE_IFACE_BRIDGE=1 to the README, matching every other feature-gated example. Verified pio ci links on a real ESP32 (esp32dev, 59.5% flash) and the ESP32 Build CI turned green.pio ci build-flag command in the README, not just an in-sketch #define.PROTO_NTRIP_CASTER = 9.PROTO_BRIDGE = 8, shipped v6.8.0) would accept connections but its handler was never invoked - the dispatch table returned no handler for the slot - so a bridged port did nothing. Latent because the v6.8.0 verification was a host codec test + a compile, not a live PROTO_BRIDGE connection.PC_PROTO_MAX (the dispatch-table size) was 8, and both proto_register() and proto_get() bound-check with (unsigned)proto < PC_PROTO_MAX. PROTO_BRIDGE = 8 fails 8 < 8, so the handler was neither stored nor fetched. Adding a ConnProto id at/above the table size silently disabled it.PC_PROTO_MAX to 10 and added a static_assert((unsigned)ConnProto::PROTO_NTRIP_CASTER < PC_PROTO_MAX, ...) next to the enum's config so any future proto that outgrows the table is a compile error, not a silent no-op.send_chunked) crashed the board mid-transfer; curl saw a connection reset (rc=56). Small requests succeeded.assert block_is_free ... "block must be free") inside emac_w5500_task -> esp_pbuf_allocate -> mem_mallocpsram_free flat), and heap_caps_check_integrity_all stayed OK. Our chunk_send_pump already honors tcp_sndbuf backpressure and is zero-heap.0x82 instead of 0x04 - a corrupted SPI read. A corrupted frame length from a bad SPI read makes the driver over-copy into a pbuf, which is what corrupts the heap. So the "crash" was a downstream symptom of bad wiring, not a code defect.PC_ETH_W5500_SPI_MHZ (default 20, the safe/upstream value proven at 200 MB) so the clock can be tuned to the wiring; documented the SPI-bound throughput curve and the signal-integrity ceiling in FEATURE_PERFORMANCE.md / HARDWARE_HOOKUP.md. No library code was at fault.send_chunked or serve_file) drops mid-stream at a non-deterministic point - observed once at 233 MB with a connection reset (curl rc=56) and once at 86 MB with a short clean close - while small requests to the same server keep succeeding. The same 1 GB download served by ESPAsyncWebServer completes fine.CONN_TIMEOUT_MS (5 s) idle sweep in check_timeouts() reaps any CONN_ACTIVE slot whose last_activity_ms is older than 5 s. That timestamp is refreshed on RX (recv callback) and on TX ACK (sent callback), so a healthy stream stays fresh - until a transient send stall (a Wi-Fi hiccup, or a brief full window with no ACKs) exceeds 5 s, at which point the sweep reaps a connection that is actively mid-transfer, truncating the body. (Same 5 s mechanism as the SSH "drops every framed packet after the banner" bug below, a different trigger.)pc_conn_touch_active(slot) each poll they run (they run every handle() loop through the on_poll seam), refreshing the idle timer so the sweep cannot reap an in-flight transfer. Dead-peer teardown for such a slot is delegated to lwIP's own retransmission timers, which abort a black-holed pcb through the err callback. The timestamp read that a size-based check would need lives on tcpip_thread, so refreshing from the worker-side pump (writing our own last_activity_ms) is the layer-clean signal.native (334, incl. test_transport) + native_keepalive (11) + native_range (20) all pass. HW (ESP32-S3): a deterministic reproduction - pause the client 9 s (> 5 s) mid-stream with SIGSTOP so ACKs starve - truncates on the pre-fix build and survives on the fixed build (the transfer resumes on SIGCONT and keeps streaming). Regression test test_active_send_not_reaped.ssh_msgtype_abuse against the S3 rig).SSH_MSG_SERVICE_REQUEST("ssh-userauth") gets SSH_MSG_SERVICE_ACCEPT back and the server advances to the userauth phase, all in cleartext (no NEWKEYS, no session keys derived, no host-key verification).SSH_MSG_SERVICE_REQUEST case in ssh_server_dispatch() had no phase guard (unlike KEXDH_INIT and USERAUTH_REQUEST, which check their phase). It processed a service request in any phase, so a client could jump SSH_PHASE_DH_INIT -> SSH_PHASE_AUTH, skipping the entire key exchange. RFC 4253 §10 requires the service request only after the key exchange (ssh_newkeys_complete() advances a fresh connection to SSH_PHASE_SERVICE and turns on encryption).if (s->phase != SSH_PHASE_SERVICE) return -1;, so a premature service request is rejected and the connection closed. Regression test test_service_request_before_newkeys_rejected. HW-verified: the pentest ssh_msgtype_abuse attack goes from 1 finding to 0, and a real OpenSSH login (which sends SERVICE_REQUEST in the correct phase, after NEWKEYS) still completes 6/6.reset by peer ~5 s later and the server's KEXINIT never arrives (ssh -v). tcpdump: the device ACKs the 672-byte client KEXINIT (lwIP received it), then sends nothing for exactly 5 s (CONN_TIMEOUT_MS) before the RST. No panic, no reboot - the device is healthy, it just never replies.ssh_conn_setup() - which installs the SSH dispatcher's binary-packet emit callback via ssh_server_set_emit_cb(ssh_emit) - had no production caller. It was declared, defined, and documented ("call from begin()"), but nothing ever called it, so s_srv.emit_cb stayed null. The server-identification banner is written directly by ssh_conn_accept() (not through the callback), so it still went out; but every framed SSH packet - KEXINIT, KEXDH_REPLY, NEWKEYS, channel data - is emitted through the null callback and silently dropped, so the handshake stalls forever and the client is reset on the idle timeout. On-device counters confirmed the receive path was perfect (rx_enter=2 bytes=713 disp=1 msg=20, KEXINIT parsed + reply built n=422) while SSHEMIT enter=0: ssh_emit was never reached because the callback was null.ssh_server_set_emit_cb(rec_emit) in test_ssh_server; ssh_conn_setup() in test_ssh_conn's setUp) and then drives ssh_server_dispatch / ssh_conn_rx directly. The mechanism was covered; the production wiring was not - a mock-seam blind spot.ssh_proto_handler() (the one accessor every consumer goes through to install SSH) now calls ssh_conn_setup() before returning, so registering the handler always wires the emit callback - it can never be forgotten again. Regression guard test_proto_handler_wires_emit clears the callback, calls ssh_proto_handler(), drives a banner+KEXINIT, and asserts the server's reply reaches the socket (fails without the fix; verified). HW-verified end to end on an ESP32-S3 vs OpenSSH 10.0: curve25519-sha256 KEX, ssh-ed25519 host key, NEWKEYS, Authenticated ... using "password", and a byte-exact channel echo over chacha20-poly1305.pio test -e native_ssh_conn (and every native env that compiles tcp.cpp) fails at the build stage: src/network_drivers/transport/tcp.cpp:31:10: fatal error: freertos/task.h: No such file or directory.#include "freertos/task.h" (for xTaskGetCurrentTaskHandle()) to tcp.cpp unconditionally, but the host build resolves freertos/* through test/mocks/freertos/ and only FreeRTOS.h + queue.h were mocked - task.h was missing. Its symbols are used only inside #if defined(ARDUINO) code, so on the host the include just needs to resolve.test/mocks/freertos/task.h (typedef TaskHandle_t + an xTaskGetCurrentTaskHandle() stub), matching the existing host-mock pattern. All native transport/session/SSH envs build again.QUIC handshake: CONNECTED, then the device sends CONNECTION_CLOSE error_code=0x07 (FRAME_ENCODING_ERROR) and the h3 GET / times out. aioquic's event log: ConnectionTerminated(error_code=7, frame_type=0).quic_frame_parse() (quic_frame.cpp) only decodes PADDING, PING, HANDSHAKE_DONE, ACK, ACK_ECN, CRYPTO, STREAM (0x08-0x0f), MAX_DATA, and CONNECTION_CLOSE; for every other frame type it returns 0 (line ~122, "a frame type this minimal server does not handle"), and process_frames() turns a 0 into FRAME_ENCODING_ERROR + closes the connection (quic_conn.cpp:193). But a real QUIC client sends connection-management + flow-control frames right after the handshake - MAX_STREAMS (0x12/0x13), MAX_STREAM_DATA (0x11), NEW_CONNECTION_ID (0x18), NEW_TOKEN (0x07), DATA_BLOCKED (0x14), STREAM_DATA_BLOCKED (0x15), STREAMS_BLOCKED (0x16/0x17), RESET_STREAM (0x04), STOP_SENDING (0x05), RETIRE_CONNECTION_ID (0x19), PATH_CHALLENGE/PATH_RESPONSE (0x1a/0x1b) - so the very first 1-RTT packet carrying the h3 request also carries one of these and the whole packet is rejected. RFC 9000 requires an endpoint to be able to parse every defined frame type (it may ignore the ones it does not act on); returning FRAME_ENCODING_ERROR for a well-formed known frame is both a spec violation and an interop break with any real client.quic_frame_parse() (+ named constants in quic_frame.h) now consumes (skips) all the standard frame types above with their correct varint/byte layout, so they parse successfully and the dispatcher ignores the ones with no server-side action (like it already does for MAX_DATA/PING). Grouped by wire shape (1/2/3 varints; length-prefixed NEW_TOKEN/NEW_CONNECTION_ID; fixed-width PATH_CHALLENGE/RESPONSE). HW-verified with aioquic: h3 GET / -> :status=200 (HeadersReceived + DataReceived, body served over QUIC). HTTP/3 device-as-server now works end to end.h3_cert() + begin() come up (h3_cert=1, BEGIN=1) and the QUIC listener binds UDP/443, but the first client handshake reboots the board: Guru Meditation ... Core 1 panic'ed (Unhandled debug exception) - a task stack canary trip, no clean assert message.quic_server_poll -> quic_conn_recv -> quic_tls_recv_crypto -> process_client_hello -> tls13_build_cert_verify -> ssh_ed25519_sign -> ed_scalarbase/scalarmult/add -> ssh_gf_mul. The QUIC TLS-1.3 handshake reuses the SSH ed25519 signer, whose software field arithmetic peaks at ~10.5 KB of stack. quic_server_poll runs on the worker task, whose default stack is only 8 KB unless SSH is enabled - and there was a compile guard forcing >= 12 KB (PC_WORKER_STACK_CURVE_MIN) for PC_ENABLE_SSH but not for PC_ENABLE_HTTP3, even though HTTP/3 exercises the same signer. So an HTTP/3-without-SSH build got the 8 KB default and overflowed.protocore_config.h - PC_ENABLE_HTTP3 now bumps the default PC_WORKER_TASK_STACK to 12 KB (same as SSH) and is included in the PC_WORKER_STACK_CURVE_MIN build guard. After the fix the QUIC handshake completes (QUIC handshake: CONNECTED from an aioquic client) with no crash.assert failed: tcp_write ... "Required to lock TCPIP core functionality!". On the stock PlatformIO arduino-2.x core the same firmware handshakes fine.CONFIG_LWIP_TCPIP_CORE_LOCKING off): tcpip_api_call marshals the op to one dedicated tcpip thread. Core-locking (arduino 3.x / IDF 5.x, the PSRAM core, flag on): tcpip_api_call instead takes the core lock and runs the op inline on the calling task. pc_tcp_marshal's "am I already in a safe context, so run inline instead of marshaling" test was on_tcpip_thread() = a task-handle compare (captured on the first pc_tcp_do). That is correct for the mailbox model, but under core-locking the captured "tcpip task" is just whichever task ran the first op, so the test false-positives for a normal caller (the handshake pump) and runs pc_tcp_do -> tcp_write without holding the core lock -> the assert. (This path never worked on core-locking cores; it is why CI only compiles arduino 3.x. It surfaced now because the PSRAM core is the first core-locking build actually HW-run with TLS.)tcp.cpp on_tcpip_thread() now branches on LWIP_TCPIP_CORE_LOCKING. Core-locking: use lwIP's own holder query sys_thread_tcpip(LWIP_CORE_LOCK_QUERY_HOLDER) (the exact predicate LWIP_ASSERT_CORE_LOCKED uses) - a direct lwIP call is safe iff we hold the lock. Mailbox: the original task-handle compare, byte-identical, so the shipped 2.x path cannot regress.h2) is served: curl -k -> HTTP 200 ver=2, openssl -alpn h2 -> ALPN protocol: h2 (TLS 1.2), curl --http2 -> 200. Both static pools live in PSRAM (s_h2@0x3c0e0000, s_pool@0x3c0fcf30), internal DRAM 18%. This also fixes TLS on the stock arduino-esp32 3.x core (same core-locking model).curl/OpenSSL/Python wedged the whole board).200 OK fine, but a default client (which leads with TLS 1.3) leaves the device completely dead - no ping, all ports closed, no panic and no watchdog reboot (a silent hard hang, not a crash). A single attempt could wedge it.tcpip_thread self-deadlocks. A raw lwIP sent callback (lowlevel_sent_cb, which runs in tcpip_thread) finalizes a closing slot -> pc_tls_conn_end() -> mbedtls_ssl_close_notify() -> server_bio_send() -> pc_conn_raw_send() -> pc_tcp_marshal(RAWSEND) -> tcpip_api_call() -> sys_arch_sem_wait() forever: it marshals a raw write onto tcpip_thread and blocks on the mailbox semaphore that only tcpip_thread can post - but the caller is tcpip_thread. The reentrancy guard TransportCtx::in_tcpip_thread was set only inside pc_tcp_do(), so raw lwIP callbacks (which never enter through pc_tcp_do) read it as false and wrongly re-marshal. (UDP got this right - its flag is set in the recv trampoline; TCP missed the raw callbacks.) Every task that then touches lwIP (the worker's pc_conn_detach) also blocks on the dead tcpip_thread, so the whole stack dies.tcp.cpp - replace the "inside `pc_tcp_do`" boolean with the actual tcpip_thread task handle (captured the first time pc_tcp_do runs) and compare xTaskGetCurrentTaskHandle() against it in on_tcpip_thread(). pc_tcp_marshal() now owns the context decision for every op: run pc_tcp_do inline when already in tcpip_thread (any raw callback), else tcpip_api_call. This is correct for raw callbacks that in_tcpip_thread never covered and kills the whole deadlock class, not just close_notify.curl -> HTTP 000, OpenSSL default -> handshake fails, read 0 bytes) while a -no_tls1_3 / max-1.2 client succeeds. OpenSSL reported writing a 1533-byte ClientHello.RX_BUF_SIZE (1024 B) ring. The recv callback refuses a segment that will not fit the ring (ERR_MEM, lossless backpressure), so a ClientHello bigger than the ring is refused forever and the handshake stalls until the idle-timeout reaper RSTs it. A 1.2-only ClientHello is small enough to fit, which is why it squeaked through. The ring was smaller than a single MSS segment - a latent limit a big handshake exposes (SSH's ~1.5 KB KEXINIT already had the same auto-upsize).protocore_config.h - when PC_ENABLE_TLS and RX_BUF_SIZE was left at its default, upsize it to 2048 (mirrors the existing SSH KEXINIT upsize). An explicit RX_BUF_SIZE build flag is honored. After both fixes: curl -> 200, interop peer 7/7 (TLS 1.2 ECDHE-ECDSA-AES256-GCM-SHA384), 20/20 default handshakes negotiate 1.2 with the board staying alive, 10/10 rapid curls 200./syslog/probe route - it 404'd even though the new firmware was live, since its /bench field was present).PC has a fixed flat route table of MAX_ROUTES (default 16); server.on() / on_ws() / on_sse() / dav() each consume one slot and return false (silently) when the table is full - the app does not check the return. The rig had grown to 20 registrations (dav + 17 handlers + ws + sse), so the four registered after slot 16 (/syslog/probe, /secure, /ws, /events) never took effect. The overflow first occurred at the FTP tick (when the count crossed 16), silently disabling the rig's WebSocket + SSE + auth surface from then on (those dims were covered in earlier ticks, so no false coverage was claimed, but the rig had regressed).platformio.ini -DMAX_ROUTES=28 (test-firmware config; not a library change). Verified on the rig: /syslog/probe now registers (7/7 interop), and /secure -> 401 + /events -> 200 are back.server.on()'s return value is worth checking in apps that register many routes. Whenever a new rig route 404s while a fresh /bench field proves the new firmware is live, suspect the route table is full before anything else. Watch the count as more device-as-client probes accumulate.PC_MAX_UDP_LISTENERS defaults to 2 (CoAP 5683 + SNMP 161 filled it), so ntp_server_begin()'s pc_udp_listen(123) returned false and the rig serial printed NTP=bind-failed. Fixed with -DPC_MAX_UDP_LISTENERS=4. Same rule: fixed pools that fail closed need their cap raised as the rig accretes protocols - check UDP listeners too, not just the HTTP route table.syslog_injection attack; recorded INFO).syslog_format() copies the caller's MSG verbatim into the RFC 5424 line (... - - - s), so a message containing CR/LF/control bytes is emitted as-is. At a collector that splits a stream/file on newlines this enables log forging (CWE-117): the attack sent legit\r\n<34>1 - evil ... FORGED-RECORD and the collector received it verbatim in one datagram.\n - the passthrough does not break a compliant peer or violate the spec. CWE-117 output neutralization is the responsibility of the code that logs untrusted data (the caller), which is why the attack records it as INFO. The device stayed up and the fixed PC_SYSLOG_MSG_MAX (256 B) bound held (a 2 KB message was refused: syslog_format -> 0, no datagram, largest observed datagram 80 B).syslog_format could replace control bytes in MSG with a safe char for defense-in-depth (many production syslog clients do). Left as caller responsibility per the library's "the app owns the log content" model; revisit if a user wants built-in sanitization.statsd_format copies the metric name verbatim, and StatsD packs multiple \n-separated metrics per UDP packet, so a newline in the name forges extra metrics at a compliant collector (and a : / | corrupts the name:value|type split). Recorded INFO by the statsd_injection attack; same verdict - the caller sanitizes the metric name. The fixed PC_STATSD_LINE_MAX (256 B) bound holds (a 2 KB name is dropped, largest observed datagram 51 B), so there is no over-read - only the app-level forging concern.ftp_malicious_server and smtp_malicious_server attacks - both showed the identical signature).pc_client_* -> lwIP), which begin() never exercises - the server listener path is warm at boot, but the first outbound connect+send (and, for SMTP, the first rapid connect+error+close churn) grows lwIP's TX pbuf / working-set backing once. Proof it is bounded, not monotonic: (1) for FTP, 8 more failed-open /ftp/probe/redis/probe connections dropped nothing further (plateaued at 130044); (2) the second and third identical attack runs were clean - 0 findings, heap steady within a ~4 B jitter (FTP 130044 -> 130044; SMTP 130012 -> 130008 -> 130012). A real per-connection leak would fall by another ~1.5 KB each run. The library's own code allocates nothing after begin(); the one-time growth is inside esp-idf/lwIP pools and is capped.sse_exhaustion attack).pentesting/pc_pentest.py --only sse_exhaustion against the S3 rig (pentesting/rig_firmware, pinned espressif32@6.13.0, MAX_CONNS=4, MAX_SSE_CONNS=2). A single clean run on a freshly-booted, healthy board (heap 252636) drove the server permanently unresponsive: /health -> 56 then 28 (timeout), no recovery past CONN_TIMEOUT_MS (5 s), and no crash/reboot (serial clean) - a hard wedge.GET /events connections beyond MAX_SSE_CONNS and the device stops answering every endpoint, forever, without rebooting (so no watchdog catches it). A remotely-triggerable permanent DoS.sse_free() had zero callers - dead code. WebSocket teardown is wired (ws_free() in protocore.cpp handle loop), but SSE had no equivalent, so a closed / idle-reaped / aborted SSE stream never released its sse_pool entry. An SSE upgrade also leaves the slot as ConnProto::PROTO_HTTP (SSE is a long-lived HTTP response, not a protocol switch), so the leaked binding persists on the HTTP slot. The kill step is in http_poll_slot(): if (sse_find(i)) return; skips HTTP dispatch for a slot it believes is a live SSE stream. Once sse_pool is full of leaked entries (slot_id 0,1), any new HTTP connection reusing conn slot 0 or 1 matches the stale sse_find() and is silently never dispatched -> the client hangs. Live JTAG on a wedged board: sse_pool[0]+sse_pool[1] both active (paths /events), conn_pool[1] already CONN_FREE (stale binding to a freed slot), a GET /health curl sat unparsed in a slot's rx ring, and conn_pool[0].last_activity_ms climbed 163048 -> 423695 across two halts (each hung-then-retried connection refreshed it), so the idle sweep never reaped it. loopTask/worker/tcpip_thread all alive.src/network_drivers/presentation/presentation.cpp - new http_release_upgrade_bindings(slot) calls ws_free(slot) + sse_free(slot) (both no-ops when unbound). Invoked from http_evt_close() (FIN/RST/error on an SSE or WS slot frees its binding) and http_conn_open() (a reused slot must not inherit a stale binding - covers the idle-sweep / abort free paths that never fire a close event). Verified on the rig: the exact sse_exhaustion burst that permanently wedged the board now recovers (/health -> 200 within ~5 s); native test_sse (+2 regression tests) and test_presentation green.auth_bypass attack).pentesting/pc_pentest.py --only auth_bypass against the S3 rig. Sending Authorization: Basic base64("admin:admin\x00GARBAGE") returned 200 on the protected route.correct-password + \0 + junk authenticated, because check_basic_auth compared the password with strcmp(pass, r->auth_pass) and strcmp stops at the first embedded NUL - so "admin\0GARBAGE" compared equal to "admin".strcmp/memcmp early-out, leaking via response timing how many leading credential bytes matched, and the NUL truncation means the decoded credential length was not validated.src/server/auth.cpp check_basic_auth used memcmp for the username (length-checked, ok) but strcmp for the password, and never bounded the password to its decoded byte length.plen = n - (pass - decoded)) and compare BOTH fields with a new constant-time, length-bounded ct_equal() - so an embedded NUL cannot truncate the compare and the byte loop always runs to completion (no timing early-out). Verified: native test_auth/test_digest_auth green (261), and on the rig the null-truncation vector now returns 401.strcmp/memcmp - use a length-bounded constant-time equality. A password check must be validated against its actual byte length, not a C-string terminator.src/network_drivers/transport/tcp.cpp lowlevel_recv_cb: the idle-timer refresh (slot->last_activity_ms = pc_millis()) now sits after the backpressure check, and the refused-segment branch explicitly does not refresh (it returns ERR_MEM without touching the timer), so a no-progress connection idle-times-out and is reaped. HW re-attack with the pentest rig (http_conn_saturation + oversized lines, then confirm the pool recovers past CONN_TIMEOUT_MS) is the remaining validation.http_conn_saturation + a burst of oversized requests closed with SO_LINGER=0 (RST) leaves the device pingable but not serving HTTP (curl -> 000), and it does not recover on its own past CONN_TIMEOUT_MS (5 s) - only a reset brings the server back. A fresh boot serves normally (/health -> 200), so the fix does not break normal HTTP.lowlevel_recv_cb set slot->last_activity_ms = pc_millis() before the backpressure check. An oversized request line (or any body larger than RX_BUF_SIZE) fills the RX ring; the segment is refused (ERR_MEM, kept as lwIP refused_data) and redelivered every retransmit. Each redelivery refreshed last_activity_ms, so check_timeouts always saw the slot as recently active and never reaped it. Live JTAG breakpoint at the reap check on a wedged slot: now=810066, last_act=806956, diff=3110 < timeout=5000 (and last_act climbing 358768 -> 806956 across samples = being refreshed). Four such slots leak and permanently wedge the 4-slot pool. A Slowloris-class DoS.last_activity_ms refresh in lowlevel_recv_cb to after the backpressure check, so a refused/redelivered segment does not refresh the idle timer - only data actually accepted into the ring (real progress) does. A no-progress connection now idle-times-out and is reaped; a legitimately backpressured connection the worker is draining still refreshes on each accepted segment.src/network_drivers/transport/tcp.cpp pc_tcp_do: pcb_still_bound() (and the O(1) k->pcb == conn_pool[k->slot].pcb check for the slot-carrying SEND/OUTPUT ops) re-validates the captured pcb at execution time on tcpip_thread; a stale pcb skips with ERR_CLSD instead of calling tcp_write/tcp_output on freed memory. HW re-attack with the pentest rig (http_oversized_request_line + http_conn_saturation, confirm no panic + no heap drift) is the remaining validation.pentesting/pc_pentest.py --host <rig> --diag (attacks http_oversized_request_line + http_conn_saturation) against the ESP32-S3 rig firmware (pentesting/rig_firmware, pinned espressif32@6.13.0, MAX_CONNS=4). Reproduced standalone with ~15 oversized-request-line connections.assert failed: tcp_output .../lwip/src/core/tcp_out.c:1249 (tcp_output: invalid pcb) + backtrace + rst:0xc (RTC_SW_CPU_RST). The determinism oracle also saw free heap drift down (251904 -> 245136) before the crash, and legitimate requests hang while the pool is saturated.src/network_drivers/transport/tcp.cpp:232, pc_tcp_do() (the tcpip_api_call marshalled raw-lwIP op) calls tcp_output(k->pcb) - and tcp_write(k->pcb, ...) for SEND/RAWSEND - without re-validating that k->pcb is still live. The worker captures the pcb, then marshals the op to tcpip_thread; in between, the connection can be torn down (RST / lwIP error callback / close nulls conn_pool[slot].pcb), leaving k->pcb stale. tcp_output on the freed/closed pcb trips lwIP's assertion and panics. Symbolized via addr2line on the -g rig firmware.elf: 0x420074c6 = pc_tcp_do (tcp.cpp:232) -> tcp_output (tcp_out.c:1251) -> __assert_func.pc_tcp_do for the SEND / RAWSEND / OUTPUT ops - skip with ERR_CLSD when the captured pcb is no longer the slot's live pcb (pcb_still_bound() scans the pool for RAWSEND, whose slot is 0; SEND/OUTPUT do the O(1) k->pcb == conn_pool[k->slot].pcb compare). Both reads are on tcpip_thread, where teardown also runs, so the compare is race-free. HW re-attack over JTAG to confirm the crash is gone is pending.ftp_emit.strnlen(_, cap)-bounded length, so the sum never wrapped. The risk is latent: ftp_emit/ftp_emit_uint/ftp_finish are general helpers that take a raw size_t length, and a future caller passing a near-SIZE_MAX length would wrap the check.n + slen > cap. With n and slen both size_t, a huge slen makes n + slen overflow to a small value that passes the check, after which memcpy(buf + n, s, slen) writes past buf.slen > cap - n, ri > cap - n, n >= cap). The codec keeps the invariant n <= cap on every non-sentinel return, so cap - n can never underflow; the checks are now provably safe for any length.offset + len > cap when len is (or could become) untrusted - use the subtraction form len > cap - offset and keep the offset <= cap invariant that makes it safe.relay_on_accept's bind lookup found no bind, so it closed the connection. The origin never saw the request. No handler output at all, which made it look like the event was dropped.PC::listen() returned PC_OK - which is 1, not 0 - but the relay example (and relay_listener.h's own docs) treat the return as the listener id passed to pc_relay_publish(). begin() assigns the actual listener index (0 for the only listener), so the bind was stored under id 1 while the accepted slot carried id 0; bind_by_listener() missed.listen() now returns the listener id (its index, _listener_count - 1) on success; errors stay negative. Updated the two tests that asserted == PC_OK and the header doc.PC_OK == 1 turned it into an off-by-one that only bit when the listener was not index 1.pc_client_open returns a valid slot instead of -1). Found by hardware testing; the host tests cannot catch it because they drive the engines through a mock send/recv seam, not the real pc_client transport.pc_client_open() returned -1 on the very first call, so the feature silently never talked to its peer on device.client.cpp compiles the real transport only under #if defined(ARDUINO) && PC_NEED_DET_CLIENT, else it falls through to a host stub whose pc_client_open returns -1. PC_NEED_DET_CLIENT was derived from only HTTP_CLIENT || MQTT || WS_CLIENT, omitting every newer feature that drives pc_client: the direct callers (relay, smtp, ssh port-forward) and the seam-based engines whose shipped example binds the seam to pc_client (smb, dnc). An SMB-only firmware got the stub.PC_ENABLE_RELAY || PC_ENABLE_SMTP || PC_SSH_PORT_FORWARD || PC_ENABLE_SMB || PC_ENABLE_DNC to the PC_NEED_DET_CLIENT derivation (which also force-enables the shared DNS resolver), so any feature that needs the outbound transport pulls it in.smb_open reach the real Samba NTLMv2 exchange.hmac_md5 <- ntlm_ntowfv2 <- smb_open, in a boot loop. Host tests never saw it (the native stack is large and unguarded).smb_open declared ~4 KB of working buffers on the stack (tx + rx = 2*PC_SMB_BUF, plus nt_resp + ntauth + sp2 + utf16 = 4*(PC_SMB_BUF/2)); smb_read/smb_write each add 2*PC_SMB_BUF. With the caller's frame this overran the default 8 KB Arduino loopTask stack, tripping the canary during the deep NTLMv2 call chain.SmbClientCtx static (matching the library's owner-context pattern), leaving only small locals on the stack. The SMB dialogue is sequential (open -> read/write -> close) so a single shared working set is correct; documented as not reentrant across two concurrent SMB connections.test_binary_part_not_truncated + all multipart cases pass; native_pentest 38/38 clean under ASan + UBSan with the rewritten scan). Previously documented as a "known limitation"; it is really a data-integrity bug and is now fixed.--BND) inside the payload, was truncated - the part's data_len stopped at the first NUL or the first boundary-looking bytes, corrupting binary uploads (images, firmware, ...).multipart_parse scanned the body with strstr (strstr(body, delim) / strstr(pos, "\r\n")), which (1) stops at the first NUL even though HttpReq::body is a byte buffer with an explicit body_len, and (2) matched the bare --boundary bytes anywhere, so a payload that merely contained those bytes (without the framing CRLF) was treated as a delimiter.body_len with a binary-safe mem_find (memcmp, no NUL stop) and to match the full RFC 2046 \r\n--boundary delimiter for the data sections, so only a true CRLF--boundary ends a part. The in-place NUL terminator is kept as a convenience for text parts; binary parts are read via part->data + part->data_len.strstr/strlen-scan a buffer that is length-tracked and may hold binary - use a length-bounded memcmp search; and match the full framed delimiter (CRLF--boundary), not the bare token, so payload bytes can never masquerade as a boundary.-fno-sanitize-recover=all, including new pc_strtof + GraphQL fuzz targets that feed huge exponents / integer literals; native_jwt 22/22 and native_exc_decoder 7/7 clean under UBSan). Completes the sweep started by the previous entry.v*10 audit. Five more sites; two carried a second, worse bug: an exponent parsed into an int then applied as for (k = 0; k < ex; k++) m *= 10.0 - a huge exponent (e.g. GraphQL 1e999999999) is both signed-overflow UB and a denial-of-service (billions of iterations hang the device).shared_primitives/numparse.h pc_strtof and services/iot/graphql/graphql.cpp (query number literal): the exponent overflowed and its 10^ex loop was unbounded - clamped the exponent (if (ex < 400) - 10^400 saturates the double to inf anyway), fixing UB + DoS. GraphQL's integer literal (long long ipart) also overflowed - now unsigned-accumulate.services/security/jwt/jwt.cpp jwt_claim_int (untrusted numeric claim) and services/system/exc_decoder/exc_decoder.cpp (crash-dump core id): the same signed v*10 - fixed by unsigned-accumulate + reinterpret (jwt) / clamp (exc_decoder).network_drivers/network/ip.cpp:54 matched the grep but is bounded - the if (digits >= 3) return false guard caps the octet at 3 digits (<= 999), so val*10 never overflows. Left as-is.10^ex loop is a DoS vector independent of the overflow - always clamp a parsed exponent to the type's real range.-fno-sanitize-recover=all: 33/33; the directly-affected suites - native_primitives, native_redis, native_snmp, native_http_client - all still green).-fno-sanitize-recover=all (so UBSan aborts instead of just printing) surfaced three latent undefined-behavior sites that earlier runs had been printing and ignoring. All three are hit by feeding a parser a very long / hostile digit string.services/net/snmp/snmp_ber.cpp ber_read_integer: sign-extended the BER INTEGER by seeding a signed long v = -1 and then v = (v << 8) | byte - left-shifting a negative signed value is UB (C++ < 20).shared_primitives/numparse.h pc_strtol: v = v * 10 + digit on a signed long - signed overflow is UB once the digits exceed LONG_MAX (the unsigned pc_strtoul was already safe).services/redis_resp.cpp RESP integer + double parsers: the same v = v * 10 + digit on a signed int64_t (the integer) and an unbounded int exp accumulator (the exponent).pc_strtol, RESP integer - neg ? (T)(0 - uv) : (T)uv, which also avoids the negate-MIN UB), and clamp the RESP double exponent (if (exp < 1000000) - a larger exponent saturates the double to inf/0 anyway). All fixes are value-identical for in-range inputs, so no test changed.v = v*10 + digit (or a neg_seed << 8) on a signed accumulator is UB the moment an attacker supplies enough digits; parse untrusted numbers into an unsigned type and reinterpret, or clamp. The fuzzer only caught these once it ran the sanitized binary with -fno-sanitize-recover=all - printing-but-not-failing UBSan output had hidden them.native_dnc 13/13; the program "M30" now round-trips through the EIA code intact.test_roundtrip_program for the new CNC DNC codec (services/machine_tool/dnc): the EIA round-trip of M30 came back as M0 - the 3 vanished.3; any block containing a 3 was corrupted.dnc_decode_feed) filtered XON/XOFF (DC1 0x11 / DC3 0x13) out of the byte stream as flow control - but in the EIA RS-244 tape code the data character 3 is 0x13. Filtering it from the forward program stream deleted the digit. The design conflated two different channels: the forward program data (sender -> controller, what the decoder reassembles) and the reverse flow-control channel (controller -> sender, XON/XOFF). They are opposite directions of a full-duplex link and must not share a filter.dnc_decode_feed entirely; the decoder now decodes the forward stream faithfully (0x13 = the data byte 3). Flow control lives only in dnc_flow_feed, which the caller drives from the reverse channel's bytes. Added test_decode_eia_three_is_not_xoff as a regression guard.3); never filter control bytes out of a data stream that may legitimately contain that byte value. Keep flow control on its own channel.WebSocketClient (a TLS WebSocket client), still overflowed dram0_0_seg by 408 bytes - but only on the arduino-esp32 3.x core, whose larger core footprint leaves less headroom than the 2.x core the PlatformIO job uses.tls.cpp +600, worker.cpp +160, listener.cpp +145. Two more instances of the same gc-liveness trap as the scratch arena.Ctx symbols, so --gc-sections (per-symbol) could not drop the big cold parts:TlsServerCtx bundled a 1-byte ready flag with the ~600-byte mbedTLS server config/cert/key/ ticket. pc_tls_ready() (called on the client path) reads ready, anchoring the whole server config into a client that never runs pc_tls_configure().DeferCtx bundled the per-worker FreeRTOS queue handles (hot; pc_defer() pushes to them) with the multi-hundred-byte static queue storage (only pc_workers_start() touches it), so the storage stayed linked in a build that never starts workers.TlsServerReadyCtx s_srv_ready apart from TlsServerCtx s_srv; DeferStorageCtx s_defer_store apart from DeferCtx s_defer. The cold halves are now referenced only by server-setup / worker-start code and garbage-collect out of client-only firmwares. Server examples that do use them are unchanged (HTTPS DRAM identical). Both new types end in Ctx, so the owner-context guard still passes. (listener.cpp +145 was left: the two splits already reclaimed far more than the 408-byte deficit.)f7767ba6, first red at 23e0797b - both owner-context-refactor commits..dram0.bss will not fit in region dram0_0_seg, overflowed by 944-1264 bytes. A per-object .o size diff showed only +62 bytes of DRAM growth across the whole tree, which could not explain a ~1 KB overflow.--gc-sections liveness change, invisible to a compiled-size diff (the object is compiled in both trees; only its linkage differs). The scratch owner-sweep merged three separate symbols - the 8 KB per-worker bump arena, the off[] offsets, and high_water[] - into one struct ScratchCtx s_scratch. session.cpp calls scratch_reset() every dispatch, touching only off[]; but off[] and the arena were now one symbol/section, and --gc-sections is per-section, so that always-live reference anchored the whole 8 KB. Previously the arena was its own symbol, referenced only by scratch_alloc(), which is dead code in a plain TLS/HTTP build (its callers are SSH / WebSocket / OIDC) - so the linker dropped it. The linked-map diff was unambiguous: scratch.cpp contributed 8 bytes of DRAM at f7767ba6 and 8228 bytes at HEAD. That +8 KB tipped the already DRAM-marginal TLS examples (~122 KB dram0_0_seg ceiling) over.struct ScratchArenaCtx s_scratch_arena), keeping only the small off[]/high_water[] metadata in ScratchCtx. scratch_alloc() is the sole referrer of the arena, so a firmware that never allocates scratch garbage-collects both again; the always-live scratch_reset() anchors only the tiny metadata. Both are *Ctx types, so the owner-context guard still passes. Semantics are byte-for-byte unchanged.Ctx, keep a large conditionally-used buffer in a separate owned symbol from small always-referenced fields, or --gc-sections can no longer drop the buffer from builds that never use it. Measure regressions with the linked map, not a compiled .o size sum.SignatureMalleability) verified when they must be rejected.ssh_ed25519_verify accepted a signature whose scalar S had been replaced by S + L (L = the group order). Because L*B is the identity, S*B - h*A recomputes the same R, so the recompute-and-compare-R verification passed for both S and S + L - i.e. a third party could maul a valid signature into a different byte string that still verifies.[0, L); that check was missing.ed_scalar_canonical() (compares the little-endian S against the group order ED_L from the top byte down) and reject up front in ssh_ed25519_verify when S >= L. Verification is public-data only, so a plain compare is fine. Legitimate signatures always have S < L (signing reduces mod L), so no valid vector or existing test changes.PC_ENABLE_* flag, a src/services/* implementation, and tests, but no ## heading in docs/FEATURES.md - so they were absent from the README/docs feature tables too (those tables are generated from FEATURES.md). The whole industrial-protocol wave (HART, GOOSE, MMS, PROFINET, PROFIBUS, J2735, NTCIP, OpenADR, ...), HTTP/3, and several infra features (Failsafe, Sleep Scheduler, Wear Leveling, Network Adaptation, PSRAM Pool, Themes, ...) had shipped without ever being listed.PC_ENABLE_* flag lacks a FEATURES.md entry, excluding a small allowlist of internal derived flags (STREAM_BODY, CLIENT_TLS). Docs-only; no library code changed.qt->flight_hs buffer, each builder called with sizeof(flight_hs) - flight_hs_len as its capacity. emit() was handed that same capacity but ignored it ((void)cap;) and did *plen += written unconditionally.flight_hs_len against sizeof(flight_hs). In the correct flow each builder returns <= cap, so the sum stayed in bounds, but the invariant was never enforced: if a builder ever returned more than the remaining room, a later builder's sizeof(flight_hs) - flight_hs_len would underflow to a huge cap and w_bytes() would memcpy past the fixed array.emit() now honors the cap contract it was given - it refuses an append that would run past the buffer (written > cap - *plen), maintaining the *plen <= cap invariant so the next builder's capacity subtraction can never underflow. w_bytes() also now checks w->pos > w->cap explicitly before the w->cap - w->pos bound so that subtraction provably cannot underflow even for a malformed Writer. Separately, the HTTP/3 DATA-frame coalescing in dispatch_request() now clamps with a room subtraction (sizeof(body) - body_len) instead of a body_len + take sum that could wrap. All three are the codebase's own overflow-safe idiom.quic_conn_send() advanced packet-number / CRYPTO-offset / stream-send state for a datagram it then discarded, so the retransmitted flight no longer matched what the peer had (or had not) received. Loss recovery made the stall worse instead of curing it.build_packet() had already bumped next_pn / crypto_tx_off / tx_sent / last_ae_pn. When the send was then amplification-blocked and dropped, that state stayed advanced - a build-then-discard desync.quic_conn_send(), before any packet is built, so a blocked send advances no packet state. Also reset the PTO backoff on acknowledged progress (RFC 9002 sec 6.2) so a recovering connection does not keep doubling its probe interval.ssh_conn.cpp sized every outbound wire buffer as SSH_PKT_BUF_SIZE + SSH_HMAC_SHA256_LEN (= 2080). A payload approaching SSH_PKT_BUF_SIZE with the hmac-sha2-512 MAC (64-byte tag) needs 4 + (1 + payload + pad) + 64 ≈ 2128 bytes, so ssh_pkt_send() would hit its wire_len > out_cap guard and return -1, dropping the packet. Latent because real payloads (channel-data chunks bounded by the peer window) never approached the max. With s2c compression the effective payload can also expand slightly (fixed-Huffman on incompressible data), and a dropped packet mid-stream desyncs the stateful cipher / compression stream (session corruption), so the under-size had to be fixed properly rather than relied upon to never trigger.SSH_WIRE_CAP in ssh_packet.h sized for the true worst case - 4 + 1 + SSH_MAX_EFFECTIVE_PAYLOAD + SSH_MAX_PAD(32) + SSH_MAX_MAC(64), where the effective payload grows to ssh_deflate_bound(SSH_PKT_BUF_SIZE) when PC_ENABLE_SSH_ZLIB is set. All four wire buffers in ssh_conn.cpp use it. Correct for every cipher/MAC mode, compressed or not.udp_bind from the app task would have asserted like the listener did).begin() from the app task too.udp.cpp (pc_udp_*, the single place lwIP UDP is touched) called raw udp_new / udp_bind / udp_recv / udp_remove / udp_sendto directly from the app task, never marshaled. On arduino-esp32 3.x (lwIP core-locking) that trips LWIP_ASSERT_CORE_LOCKED, so every UDP service - SNMP agent (:161), CoAP, the captive-portal DNS responder (:53), syslog, UDP telemetry, flow-export - would crash at begin() / on send. Same latency story as the listener: harmless on the IDF-4.x board all runtime HW used, and CI only compiles the 3.x core. (The TCP transport pc_tcp_do and the outbound client cc_do_* were already marshaled; UDP was the remaining raw path.)tcpip_api_call() (a udp_do dispatcher for listen / send / send-out), mirroring the TCP transport - including the s_in_tcpip_thread guard so a handler replying from the udp_recv trampoline (already in-thread) sends directly instead of re-marshaling (which would deadlock). All pc_udp_* callers are unchanged; the native host stubs are untouched. Native UDP-service suites green (native_coap / native_snmp / native_dns_resolver / native_syslog); HW: SNMP agent binds :161 and the device runs on the modern core.tcp_* / udp_* from outside a lwIP callback now goes through tcpip_api_call(); the transport layer is the one owner of that rule (listener, TCP conn, UDP, client all marshal). See the listener entry below for the runtime-test-the-3.x-core lesson.begin_tls() now starts the HTTPS listener and the server runs, where before it crash-looped at boot).begin_tls(): assert failed: tcp_alloc ...lwip/src/core/tcp.c:1854 (Required to lock TCPIP core functionality!), rebooting in a loop.listener_add() / listener_stop() (the path begin() and begin_tls() use) called raw lwIP tcp_new_ip_type / tcp_bind / tcp_listen_with_backlog / tcp_close directly from the app (setup/loop) task, which is not tcpip_thread and does not hold the TCPIP core lock. arduino-esp32 3.x ships CONFIG_LWIP_TCPIP_CORE_LOCKING=1 + CONFIG_LWIP_CHECK_THREAD_SAFETY=1, so LWIP_ASSERT_CORE_LOCKED fires. It was latent because (1) all prior runtime HW testing used the classic ESP32 via PlatformIO (arduino 2.x / IDF 4.x), where the assert is compiled out and the unlocked call merely raced, and (2) the "Arduino Build" CI only compiles the 3.x core, never runs it. The dynamic listener (SSH ssh -R) already marshaled correctly via tcpip_api_call(); the primary listener was the one unmarshaled path, written under a stale "this build has core-locking off" assumption.listener_add() / listener_stop() now route their create/close through the same listener_lwip_marshal() (tcpip_api_call) the dynamic listener uses, on ARDUINO. The raw path stays only for the native host build (no lwIP thread). This is correct on both cores: with core-locking it satisfies the lock; without it, it still removes the off-thread race. The stale comment was corrected.tcp_* from a non-callback context must go through tcpip_api_call() (transport pc_tcp_do and the client cc_do_* were already correct; the listener now matches).expecting SSH2_MSG_KEX_ECDH_REPLY then Connection reset by peer, and the serial log showed Guru Meditation Error: ... Stack canary watchpoint triggered (pc_worker).ssh_x25519) and ed25519 (ssh_ed25519_sign), in radix-2^16 with many ssh_gf temporaries per frame, nests deeper than the finite-field DH/RSA path - plus the field inversion calls the mbedTLS bignum modexp at the bottom of that chain. Measured peak worker-task stack use is ~10.5 KB (uxTaskGetStackHighWaterMark: a 16 KB stack left 5928 B free at the deepest point), which overflows the historical 8 KB worker default sized for the ~7 KB RSA path.PC_WORKER_TASK_STACK now defaults to 12288 when PC_ENABLE_SSH is set (8192 otherwise), a new PC_WORKER_STACK_CURVE_MIN (12288) documents the floor, and the compile-time guard enforces it for SSH builds (the RSA-only floor still applies to OIDC-only builds). Re-flashed at the shipped 12288 default: the curve25519 + ssh-ed25519 + ed25519-client-auth session runs to a data round-trip with 1832 B of stack free at peak, no canary trip; the DH-group14 + rsa-sha2-256 path is unchanged (both echo on hardware).#error catches any future lowering below the curve floor before it can reach the canary at runtime.native_ssh 127/127; the dispatcher now routes GLOBAL_REQUEST to a dedicated handler).ssh -R remote forwarding (which arrives as a tcpip-forward global request).SSH_MSG_GLOBAL_REQUEST (80), so every connection-wide request fell through to the default arm and got SSH_MSG_UNIMPLEMENTED (RFC 4253 §11.4). That is wrong: GLOBAL_REQUEST is a known message type - only the request name may be unknown - and RFC 4254 §4 says an unrecognized request is answered with SSH_MSG_REQUEST_FAILURE when want_reply is set, or ignored otherwise. In practice this broke OpenSSH client keepalives (keepalive@openssh.com, want_reply=true): the client saw an UNIMPLEMENTED rather than a SUCCESS/FAILURE reply and could treat the keepalive as unanswered, and benign want_reply=false requests (hostkeys-00@openssh.com, no-more-sessions@openssh.com) drew a spurious UNIMPLEMENTED.ssh_global_request_handle() (ssh_channel) now parses the request name + want_reply, replies REQUEST_FAILURE (or stays silent) per §4, and routes tcpip-forward / cancel-tcpip-forward to an opt-in remote-forward seam (accepted only when an owner is installed; a port-0 bind echoes its allocated port per §7.1). Host-tested (test_ssh_channel: accept / refuse / port-0 echo / no-reply / cancel / unknown-request / malformed).ssh -R listener + byte-bridge owner (the next phase); until it is installed the server correctly declines forwards rather than making a promise it cannot keep.native_ip / native_accept_gate / native_auth_lockout / native green with added v6 coverage).pc_ip_key / pc_conn_remote_key / pc_lwip_ip_key). In a security-forward library that is a real vulnerability, not a shortcut:/64 (2^64 addresses); collisions let many real addresses share one bucket (reset/share another's budget) or dodge their own cap.pc_ip (a family tag + 16 address bytes, the library's sockaddr_storage/lwIP ip_addr_t equivalent) is now carried end to end: pc_conn_remote_addr() / pc_lwip_to_ip() produce it, and every IP-keyed feature stores and matches the full address - bucket lookups by pc_ip_equal (throttle, lockout), allowlist by pc_ip_prefix_match (real v4 /0-32 and v6 /0-128 CIDR containment). The hash/word keys (pc_ip_key, pc_conn_remote_key, pc_lwip_ip_key) and the host-order PC_IPV4 allowlist macro are deleted; the public allowlist entry point is now CIDR text (listener_ip_allow_add_cidr("2001:db8::/32")). No hashing, no flattening.pc_ip and never re-key it.timerBegin(freq) is now frequency-based (was timerBegin(num, divider, countUp)), timerAttachInterrupt() dropped its edge argument, and timerAlarm() replaced timerAlarmWrite() + timerAlarmEnable(). The example's ISR setup used the 2.x forms, so it failed to compile under the core an Arduino IDE user installs.PreemptQueue.ino is guarded with #if ESP_ARDUINO_VERSION >= ESP_ARDUINO_VERSION_VAL(3, 0, 0) and uses the 3.x calls on the new core, the 2.x calls otherwise. It is the only example using that API (grep-verified)..github/workflows/arduino-build.yml) now compiles every example against the Arduino-ESP32 3.x core with arduino-cli - using each example's shipped build_opt.h, exactly as the IDE builds it - so 3.x / IDF-5 API drift is caught on every push, not just in a manual local sweep.native_espnow host suite still 7/7).EspNow in the Arduino IDE toolchain (esp32 core 3.x). The PlatformIO CI pins espressif32 @ ^6.0.0 (Arduino-ESP32 2.x, ESP-IDF 4.4), so it never exercised the 3.x API and reported green - the break only showed up under the core an Arduino IDE user actually installs.esp_now_recv_cb_t signature. The receive callback used the 4.x shape (const uint8_t *mac, const uint8_t *data, int len); under IDF 5 the source MAC moved into a struct and the type is now (const esp_now_recv_info_t *info, const uint8_t *data, int len), so registering the old callback was an invalid conversion (-fpermissive error).services/radio/espnow/espnow.cpp selects the callback signature with an #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0) guard (mirrors how ssh_rsa handles mbedTLS v2/v3) and reads the MAC from info->src_addr on 5.x, the mac argument on 4.x. Both cores now build. The whole binding is under #ifdef ARDUINO, so host tests are unaffected.build_opt.h) and were compile-swept against the 3.x core; a broad multi-feature 3.x compile found no other IDF-5 API breaks.services/dma). A combined webserver + continuous-DMA rig reported dma_errors: 8824 out of ~95946 frames (~8%) via an HTTP counter - while all 11 host tests passed. Textbook "a small happy-path
smoke hides the bug; stress on hardware surfaces it."pc_dma_event whose data is a pointer into the 2-deep ping-pong RX buffer into the 16-deep preempting work queue. pc_pq_post_from_isr() calls portYIELD_FROM_ISR(), but the simulator drives the callback from a task context (not a real ISR), where the yield is not an immediate context switch - so under load loop() ran several more completions before the high-priority task drained the queue, the two ping-pong buffers wrapped, and the older queued pointers read half-overwritten data. A descriptor consumed by another task must own its bytes, not point into a buffer that is reused a transfer or two later.DmaIngest uses the same copy pattern, and dma.h now documents that a deferred (queued) consumer must copy the len bytes rather than keep the RX pointer. The pointer API itself is unchanged and correct for an in-callback consumer (the standard DMA-HAL shape).native_dma) covers the ping-pong flip, byte-exact loopback round trip, and fail-closed paths; the deferred-consumer lifetime is a HW-load property, now covered by the on-device stress rig.ssh, pubkey auth, and ssh -L port forwarding all work with no client-side algorithm overrides).ssh against the server once a host key was provisioned. A stock ssh user@board reset during key exchange; even forcing the KEX through, RSA pubkey auth was refused. The earlier "handshake HW-verified" had only ever used a hand-built minimal client (the native test) and a tiny forced-algorithm client.ext-info-c) is ~1.5 KB. SSH_KEXINIT_MAX was 512, so ssh_kexinit_parse() rejected the payload outright (len > SSH_KEXINIT_MAX -> dispatch returns -1 -> RST), and the default 1024-byte RX_BUF_SIZE ring could not hold the banner + KEXINIT, resetting the handshake at key exchange. Fixed by raising SSH_KEXINIT_MAX to 2048 (client I_C; the server I_S stays small at SSH_KEXINIT_S_MAX) and auto-upsizing RX_BUF_SIZE to >= 2048 when SSH is enabled and the ring was left at its default (same idiom as the streaming-body upsize; an explicit RX_BUF_SIZE is honored).ext-info / server-sig-algs. Without it a modern OpenSSH client will not sign an RSA key (send_pubkey_test: no mutual signature algorithm) and falls back to password. Fixed by advertising ext-info-s in the server KEXINIT, detecting the client's ext-info-c, and sending SSH_MSG_EXT_INFO with server-sig-algs = rsa-sha2-256 as the first message after NEWKEYS. An inbound EXT_INFO is now accepted (ignored) rather than answered with UNIMPLEMENTED.test_ssh_server gains test_extinfo_build_advertises_server_sig_algs and test_large_client_kexinit_accepted; the full-handshake test now sends ext-info-c and asserts the server replies with EXT_INFO. (Curve25519 KEX + Ed25519 keys, which would let the client use its preferred algorithms instead of falling back to group14/rsa, are tracked separately on the roadmap.)test_ssh_server).direct-tcpip) close path on top of the channel layer.build_close_chan() (ssh_channel.cpp) frames the channel-close sequence as ten bytes - CHANNEL_EOF (type + recipient, bytes 0..4) immediately followed by CHANNEL_CLOSE (type + recipient, bytes 5..9) - in one output buffer, and the only emit site (the SSH_MSG_CHANNEL_CLOSE case in ssh_server.cpp) sent the whole buffer through a single ssh_pkt_send(), i.e. two SSH messages in one binary packet. RFC 4253 6 says a binary packet carries exactly one message; a strict client (openssh runs packet_check_eom() after each message handler) sees five trailing bytes after CHANNEL_EOF and disconnects with a packet-integrity error, so a channel could not be closed cleanly against openssh. It slipped earlier HW checks because those exercised the handshake + channel data, not a strict close.emit(buf, 5) then emit(buf + 5, 5) - so each is framed, encrypted, and sequence-numbered on its own. The builder is unchanged (its 10-byte layout is still the contract); the fix is at the emit boundary, with a comment so it is not re-bundled. Regression test test_inbound_close_emits_eof_then_close_separately asserts the dispatcher emits two packets (EOF then CLOSE).cpp:S3519, protocore.cpp).** append_resp_trailer() returned snprintf's would-be length, which can exceed the header buffer when the trailer (Date + CORS + user _extra_hdr cookies/headers + Connection) does not fit. The caller then sent (u16_t)hlen bytes from a fixed header[RESP_HDR_BUF_SIZE] - reading past the stack buffer. Fixed by clamping append_resp_trailer()'s return into [0, cap-1] so no send path (send / send_empty / redirect / send_template / send_chunked) can ever read past the buffer; an over-long header is now sent truncated-but-in-bounds.cpp:S5798 x5, services/net/snmp/snmp_crypto.cpp).** The memset()s that wipe the localized key, AES round keys, key stream, and CFB feedback at function exit can be elided by the optimizer (dead store on a buffer that is never read again), leaving secrets on the stack. Replaced with snmp_wipe(), a volatile-loop clear the compiler cannot remove (same idiom as ssh_wipe).cpp:S836 x2, presentation.cpp, websocket.cpp).** The drain loops ignored pc_conn_read_byte()'s return and fed byte to the parser even though a false return means nothing was read. Now they break when the read fails.conn_pool[slot_id] indexed without a bound (cpp:S3519).** The public response emitters dereferenced conn_pool[slot_id] on a caller-supplied id; added a slot_id >= MAX_CONNS guard at each entry (send / send_empty / redirect / send_template / send_chunked).(cond) ? 4 : 4 ternary (cpp:S3923, nats.cpp) reduced to 4; a while that always broke on the first iteration (cpp:S1751, http_parser.cpp Forwarded proto=) rewritten as the if it actually is; and a redundant if (!app(...)) return len; return len; (cpp:S3923, webdav.cpp) simplified to app(...); return len; (the helper is atomic - it leaves len unchanged on no-room).// NOSONAR with a reason): cpp:S5332 "using HTTP is insecure" on http_client.cpp (a URL parser in an HTTP client must accept http://; TLS is the caller's choice) and opcua.cpp (the OPC UA spec transport-profile URI http://opcfoundation.org/..., an identifier string that is never dereferenced as a URL). The 3 remaining cpp:S134/long-switch style notes are correct protocol-dispatch switches, left as-is.ws_alloc / sse_alloc returned null), the cleanup path called pc_conn_detach(pcb) and reset the slot but never closed or aborted the pcb.** The struct tcp_pcb was left open with no lwIP callbacks attached: a leaked pcb that lingered until lwIP's own timeout, with the application slot already freed. Under upgrade churn against an exhausted pool this slowly bled pcbs.pc_conn_abort_slot(slot) frees the TLS context (abrupt), detaches the pcb, resets the slot, and sends a RST; every drop path (WS/SSE close + upgrade-fail, session.cpp tls_abort, SSH/telnet/modbus/opcua) routes through it (or pc_conn_close(slot) for graceful), so a pcb can no longer be detached without also being closed. Verified on hardware (COM3): a WS-pool-exhaustion churn drove 12 pc_conn_abort_slot RSTs with every slot reclaimed and no pcb/slot leak (the device kept accepting throughout). Tracked-debt item closed in TODO.md.tail: write error: Broken pipe lines.get_test_comment in test/run_tests.sh extracted a test's doc comment with** ‘tail -n "+${fn_line}" "$abs_file" | head -20 | awk ’...'**.** Theawkexits at the first match (print; exit) andhead -20closes its input after 20 lines, so once the reader is gone GNUtailgetsEPIPEon its next write and printstail: write error: Broken pipeto stderr. The helper runs once per test function (~1900 calls in a full run), so the CI log filled with the message. Not a correctness bug (the report still generated), but noise that hides real warnings.**Fix:** fold the slice + scan into a singleawk -v start="$fn_line"pass that reads the file directly (NR < startskips,NR >= start + 20exits), so the earlyexitno longer closes a pipe and nothing writes into a dead reader. Verified behavior-identical to the old pipeline across everyvoid test_*()intest/test_*/` (1906 functions, 0 diffs, 0 stderr).shared_primitives/shim.h include. native_hostlink was the lone failure out of 103 envs.hostlink_build (services/fieldbus/hostlink/hostlink.cpp) wrote exactly the wire frame (@UU XX <text> FF * CR) and returned its length, but never wrote a NUL terminator, while the sibling ASCII builder sdi12_build does (it reserves n + 1 and writes ‘buf[n] = ’\0').** NUL-terminating the ASCII frame is the house convention, so a caller (and the test) that treats the returned buffer as a C-string reads off the end into uninitialized stack. It stayed latent because the stack byte after the frame happened to be zero; the shim migration shifted the binary layout and the byte was now garbage, soTEST_ASSERT_EQUAL_STRINGsaw @00RD0000001057*\rfollowed by junk and the test crashed (SIGHUP/ERRORED).**Fix:** makehostlink_buildconsistent withsdi12_build- reserve room for the terminator (if (total >= cap) return 0, wrap-safe: no addition) and writebuf[p] = '\0'after the frame. The return value is still the frame length (not counting the NUL), so callers may treat bufas either a sized buffer or a C-string. The existingnative_hostlinktests now pass 7/7; the overflow test (char small[8]`) still fails closed.size_t wrap as the codec entry below, in two network-facing parsers the first pass missed:ber_read_header, services/net/snmp/snmp_ber.cpp).** A long-form ASN.1 length is read as a full 4-octet value (up to 0xFFFFFFFF), then checked with d->pos + length_val > d->len. On the 32-bit ESP32 an attacker-supplied length of 0xFFFFFFFF makes d->pos + length_val wrap to d->pos - 1 (< d->len), so the bound is bypassed and a bogus huge length is returned; downstream ber_read_oid's i < len loop then reads out of bounds. SNMP is an attacker-reachable UDP agent. ber_skip had the same pattern. Fix: compare against the remaining capacity (length_val > d->len - d->pos, valid because d->pos <= d->len holds after the count-byte check).http_client_parse_response, services/net/http_client/http_client.cpp).** The hex chunk size csz is accumulated unbounded from the response, then clamped with if (in + csz > len). A malicious / MITM'd server sending an oversized chunk size makes in + csz wrap below len, skipping the clamp and leaving a multi-gigabyte memmove. Fix: if (csz > len - in) (wrap-safe; in <= len here).0xFFFFFFFFFFFFFFFF) that wraps a 64-bit size_t too, so it crashes the old code on the host and proves the fix. Tests added to test_snmp_ber and test_http_client.opcua (r_skip/ua_r_string lengths are int32, capped at 0x7FFFFFFF, and all callers guard > 0), protobuf (PB_WT_LEN compares in uint64_t; varints reject > 10 bytes), lwm2m (24-bit length bounded), and wamp_get_uri (body + 1 > out_cap) are all correctly bounded.overhead + declared_length before comparing to the buffer length, which wraps on a 32-bit size_t (the ESP32 target) for an attacker-controlled length field, so the > len guard could falsely pass and hand the caller an out-of-bounds slice. Affected: amqp_parse_frame (AMQP 0-9-1 32-bit size, services/iot/amqp/amqp.cpp), nats_parse MSG byte count (services/iot/nats/nats.cpp), and resp_parse $ bulk length (services/redis_resp.cpp). The 64-bit host tests never exercised the wrap, so it was latent. Fix: in each, compare the declared length against the remaining capacity (len - overhead) without adding, so no addition can wrap. Added oversized-length rejection tests to test_amqp / test_nats / test_redis_resp.stomp content-length parse now rejects on overflow AND a present-but-invalid content-length is a malformed frame (previously it silently fell back to NUL-delimited body parsing - a request-smuggling-style differential); parse_len caps at SIZE_MAX. flow_export IPFIX finish now fails closed when the message exceeds the 16-bit length field instead of truncating it. Tests added/updated accordingly.client.cpp guarded its body with only #if defined(ARDUINO) yet calls pc_dns_resolve(), whose declaration lives behind PC_ENABLE_DNS_RESOLVER. That flag is force-enabled only by a client transport (HTTP_CLIENT || MQTT || WS_CLIENT, protocore_config.h), so a build that enabled, e.g., WebSocket + SNMP + CoAP + Modbus but no client left the symbol undeclared: ‘error: 'pc_dns_resolve’ was not declared in this scope. Host builds were unaffected (the body is already#if defined(ARDUINO)), so the native suites never caught it. Fix: addPC_NEED_DET_CLIENT(set alongside the DNS-resolver force-enable) and gate the translation unit on #if defined(ARDUINO) && PC_NEED_DET_CLIENT`, so pc_client compiles exactly when a client transport needs it. Verified: a WebSocket+SNMP+CoAP+Modbus rig now builds and flashes, and the interop harness drives all four against real peers.upgrade token in the Connection header, nor that Sec-WebSocket-Key base64-decode to 16 bytes. Both are now required; a bad handshake gets 400 and no upgrade. Tests: test_web_terminal test_ws_upgrade_requires_connection_token, test_ws_upgrade_rejects_bad_key_length.mqtt_build_publish now rejects a Topic Name containing wildcards (+/#); mqtt_parse_publish rejects a Topic Name that is not well-formed UTF-8 or contains U+0000. The UTF-8 validator was extracted to a shared primitive (shared_primitives/utf8.h) and reused by WebSocket (no duplicate copy). Tests: test_mqtt test_publish_wildcard_topic_rejected, test_publish_topic_nul_or_bad_utf8_rejected.base64url_decode also accepted the standard +// alphabet, contradicting its name and the JWS base64url contract. Now strict (URL alphabet only); no caller fed it standard base64 (OIDC n/e are Base64urlUInt, not x5c). Not a signature-bypass vector (JWS signs the literal transmitted ASCII), hence LOW. Test: test_jwt test_base64url_strict_alphabet.begin(), so a captured Digest response could be replayed indefinitely. Replaced with a stateless, keyed, timestamped nonce (<issue_ms_hex>.<SHA-256(secret||issue) truncated>): no per-nonce table (compatible with the shared-nothing worker model), bounded lifetime (PC_DIGEST_NONCE_LIFETIME_MS, default 5 min), and an expired-but-valid response is answered 401 stale=true for a transparent retry (not counted against the lockout). Full per-nonce nc replay tracking remains intentionally out of scope: it requires shared mutable per-nonce state the deterministic worker model cannot hold safely; the bounded-lifetime nonce is the standard stateless mitigation. Tests: test_digest_auth test_nonce_is_stateless_timestamped, test_stale_nonce_triggers_transparent_retry.noSuchObject. Now distinguishes noSuchInstance (the name's object-type prefix matches a registered object, only the instance is absent) from noSuchObject (no such object at all). Test: test_snmp_agent test_get_bad_instance_v2c_nosuchinstance.send_chunked always emitted Transfer-Encoding: chunked, which is invalid for a 1.0 client. It now falls back to a close-delimited body (no Transfer-Encoding, Connection: close, raw bytes paged across loops, end signalled by the connection close) when the request is not HTTP/1.1. Tests: test_chunked test_http10_falls_back_to_close_delimited, test_http10_large_body_not_truncated. HW: --http1.0 /stream -> HTTP/1.0 200 + Connection: close, body intact; --http1.1 -> chunked.Depth: infinity was silently truncated to one level (MED, RFC 4918 9.1.1). A collection PROPFIND with Depth: infinity returned a one-level 207 the client would read as a complete tree. The server lists at most one level, so it now rejects infinity with 403 + the <D:propfind-finite-depth/> precondition body; Depth: 0/1 are unchanged. HW: Depth: infinity -> 403 (precondition body present), Depth: 1 -> 207.strcmp (MED, RFC 9110 13.1.2). Conditional GET only matched a single, byte-identical strong tag, so it ignored *, a comma-separated tag list, and the mandated weak comparison (an inbound W/"x" for our strong "x"). A standards-compliant cache revalidating with W/ tags, a list, or * got a full 200 instead of 304. Fix: inm_matches() handles * (matches the current representation), splits a list, and weak-compares (ignores a W/ prefix). Test: test_application test_serve_static_inm_star_list_weak (* / W/"x" / list-with-tag -> 304; list-without-tag -> 200).alg header was never validated (RFC 7515 5.2, MED). jwt_verify_hs256 computed HMAC-SHA256 and compared without checking the token's declared algorithm, so a token whose header said none / RS256 / HS384 was accepted as long as its signature equaled base64url(HMAC-SHA256(secret, signing_input)) - an algorithm- substitution hazard (not directly exploitable since the HMAC is still enforced and empty-sig none fails the length gate, hence MED). Fix: decode the header and require alg":"HS256"</tt> before verifying. Test: <tt>test_jwt</tt> <tt>test_alg_not_hs256_rejected</tt> (a
valid-HMAC token with alg <tt>none</tt> is rejected).
- <strong>Digest: the <tt>uri</tt> parameter was not matched to the request target (RFC 7616 3.4,
MED).</strong> <tt>check_digest_auth</tt> folded the client-supplied <tt>uri</tt> into HA2 but never
compared it to the actual request target, so a Digest response captured for route A
was structurally valid against any route under the same realm/nonce. Fix: reconstruct
the request target (<tt>path[?query]</tt>) and require it equals <tt>uri</tt>. Test:
<tt>test_digest_auth</tt> <tt>test_uri_mismatch_rejected</tt>.
- Audited clean: Basic auth (first-colon split), OIDC RS256 (strict alg/exp/nbf/iss/aud
- constant-time RSA block compare), TOTP/HOTP (RFC 4226 truncation), OAuth2 client,
constant-time HMAC compare. Noted for later (SHOULD/LOW): Digest nonce rotation + <tt>nc</tt>
replay tracking; stricter base64url; constant-time Digest response compare.
@section autotoc_md308 Standards-conformance audit, batch 1 (WS / MQTT / CoAP / Telnet)
- <strong>Status:</strong> FIXED (found by the parallel standards-conformance audit; specs read from
the downloaded RFC texts, mapped in docs/STANDARDS.md)
- <strong>Found:</strong> 2026-06-29, multi-agent conformance audit against the live specs.
- Five real conformance gaps, fixed with tests:
- <strong>WebSocket close left the TCP socket open (HIGH, RFC 6455 5.5.1).</strong> The plaintext
WS close/error path (protocore.cpp) only freed the WS slot and
<tt>http_reset</tt>'d it - it never closed the TCP connection (the TLS path did). The slot
stayed CONN_ACTIVE and re-armed as an HTTP parser, so bytes after the Close frame
were re-interpreted as a new HTTP request (state confusion). Fix: <tt>pc_conn_begin_close</tt>
on the slot so it leaves CONN_ACTIVE (the queued Close frame still flushes).
- <strong>MQTT PUBLISH QoS=3 accepted (HIGH, MQTT-3.3.1-4 / 4.8.0-1).</strong> A PUBLISH with both
QoS bits set was treated as QoS 2; the spec says it is malformed and the receiver
MUST close the connection. Fix: <tt>mqtt_parse_publish</tt> rejects qos==3; the handler
<tt>mq_close()</tt>s on a malformed PUBLISH.
- <strong>CoAP unsupported method returned 5.01 (MED, RFC 7252 5.8).</strong> Must be 4.05 Method
Not Allowed. Fixed.
- <strong>CoAP unrecognized critical option silently ignored (MED, RFC 7252 5.4.1).</strong> An
unknown odd-numbered (critical) option must yield 4.02 Bad Option (so e.g. Accept,
or Block when COAP_BLOCK is off, is rejected, not ignored). Fixed.
- <strong>Telnet literal IAC (0xFF) in output not doubled (MED, RFC 854).</strong> Echoed/printed
0xFF bytes were sent un-doubled, desyncing the client's command stream. Fix: a
<tt>send_escaped</tt> data path doubles IAC for echo + app output (protocol commands still
use the raw path).
- SNMP/BER, SSH, WebDAV, syslog, OIDC/TOTP/Basic-auth/OAuth2 audited clean.
@section autotoc_md309 CoAP Observe used millis() (would not build on host + pluggable-clock violation)
- <strong>Status:</strong> FIXED (found by the test-gap hardening pass)
- <strong>Found:</strong> 2026-06-29, adding a CI env that compiles the Observe-gated code.
- <strong>Symptom:</strong> <tt>coap_notify()</tt> (under <tt>PC_ENABLE_COAP_OBSERVE</tt>) built the notification
message-id with <tt>millis()</tt> and pulled <tt>\<Arduino.h\></tt> for it. The flag was enabled by no
test env, so the code had never been compiled on host - it failed to build there
(‘'millis’ was not declared<tt>), and even on ESP32 it violated the pluggable-clock rule
that</tt>pc_millis()<tt>is the single monotonic source (same class as the dns_resolver
bug above). Latent because the whole Observe path was never compiled in CI.
- **Fix:** use</tt>pc_millis()<tt>(include</tt>services/clock.h<tt>), drop the Arduino.h
include; added a</tt>native_coap_observe` env so the Observe-gated code is compiled + the
CoAP suite runs under the flag in CI (no longer bit-rots).
@section autotoc_md310 HTTP request smuggling: Transfer-Encoding ignored on inbound requests
- <strong>Status:</strong> FIXED (found by the test-gap hardening pass)
- <strong>Found:</strong> 2026-06-29, request-parser test-gap review.
- <strong>Symptom:</strong> the HTTP parser only framed request bodies by <tt>Content-Length</tt> and
ignored <tt>Transfer-Encoding</tt> entirely. A <tt>Transfer-Encoding: chunked</tt> request with no
<tt>Content-Length</tt> was treated as body-length-0, so the chunk octets
(<tt>5\\r\\nhello\\r\\n0\\r\\n\\r\\n</tt>) were left in the RX buffer and re-parsed as the next request
- a classic TE request-smuggling / desync vector on a keep-alive connection (and the
CL+TE combination is the canonical CL.TE desync).
- <strong>Fix:</strong> the server does not decode chunked request bodies, so reject any request
bearing <tt>Transfer-Encoding</tt> -> <tt>PARSE_ERROR</tt> (400), fail-closed, matching the existing
conflicting-<tt>Content-Length</tt> rejection (RFC 9112 §6.1/§6.3). Tests:
<tt>test_compliance</tt> <tt>test_transfer_encoding_chunked_rejected</tt> /
<tt>_with_content_length_rejected</tt> / <tt>_case_insensitive_rejected</tt>.
@section autotoc_md311 Byte-range parser integer overflow on a huge Range value
- <strong>Status:</strong> FIXED (v4.9.1; found by the edge-case audit)
- <strong>Found:</strong> 2026-06-28, agent edge-case test-gap audit.
- <strong>Symptom (latent):</strong> <tt>parse_byte_range</tt> accumulated <tt>start/end = *10 + digit</tt> with no
overflow guard, so a <tt>Range: bytes=99999999999999999999999-</tt> wraps <tt>size_t</tt> to a small
value that can pass the <tt>start \>= size</tt> check and yield a wrong <tt>206</tt> window.
- <strong>Fix:</strong> saturate the accumulator at <tt>SIZE_MAX</tt> on overflow, so a huge start is treated
as past-EOF (416) and a huge end clamps to the last byte - never a corrupt window.
@section autotoc_md312 If-Modified-Since month token could mis-parse (off-by-alignment)
- <strong>Status:</strong> FIXED (v4.9.1; in v4.9.0's just-shipped conditional-GET code)
- <strong>Found:</strong> 2026-06-28, agent edge-case test-gap audit.
- <strong>Symptom (latent):</strong> <tt>http_not_modified_since</tt> matched the month via
<tt>strstr(MONTHS, mon)</tt> without checking the match offset is a multiple of 3, so a
malformed token like <tt>ebM</tt> (which appears inside "FebMar") parsed as a valid month ->
a wrong 304/200 cache decision on malformed input. No memory-safety impact.
- <strong>Fix:</strong> reject the match unless <tt>(mp - MONTHS) % 3 == 0</tt>.
@section autotoc_md313 DNS resolver ignored the pluggable clock
- <strong>Status:</strong> FIXED (v4.9.1; found by the duplicate-code audit)
- <strong>Found:</strong> 2026-06-28, agent services duplicate-code audit.
- <strong>Symptom:</strong> <tt>services/dns_resolver</tt> polled its resolve deadline with <tt>millis()</tt> instead
of <tt>pc_millis()</tt>, so it ignored a custom clock (<tt>pc_set_clock</tt>) - violating the
pluggable-clock rule that <tt>pc_millis()</tt> is the single monotonic source.
- <strong>Fix:</strong> use <tt>pc_millis()</tt>. (The bigger dedup, pc_client reusing this one resolver,
shipped later as the shared-primitive DNS-owner change.)
@section autotoc_md314 Client ring used <tt>volatile</tt> indices (weak cross-core ordering)
- <strong>Status:</strong> FIXED (v4.8.1; found by analysis while unifying the ring primitive)
- <strong>Found:</strong> 2026-06-28, merging the server/client ring implementations.
- <strong>Symptom (latent):</strong> the outbound client ring (<tt>pc_client</tt>) used <tt>volatile size_t</tt>
head/tail. <tt>volatile</tt> blocks compiler reordering but provides no cross-core
acquire/release; on the dual-core ESP32 (producer = tcpip_thread, consumer = caller
loop, often different cores) the consumer could observe an advanced <tt>head</tt> before
the buffer bytes it published were visible -> a rare stale read. The server ring
already used <tt>pc_atomic</tt> (correct); the client was inconsistent.
- <strong>Fix:</strong> both transports now share <tt>ring.h</tt> (the <tt>pc_atomic</tt> SPSC index +
the drain math); the client ring's indices are <tt>pc_atomic</tt>, matching the server's
acquire/release ordering. One ring primitive, no hand-rolled wrap/ordering.
@section autotoc_md315 Client transport could deadlock on a large inbound transfer
- <strong>Status:</strong> FIXED (v4.8.0; same fix as the server, found by analysis during the
unified-client-transport pass)
- <strong>Found:</strong> 2026-06-28, reviewing <tt>pc_client</tt> against the server's ack-on-consume fix.
- <strong>Symptom (latent):</strong> an outbound client (http_client / mqtt / ws_client) reading a
response larger than the client ring could stall - the same class as the server
deadlock below, in the other direction.
- <strong>Root cause:</strong> <tt>pc_client</tt>'s recv callback ACKed on copy (<tt>tcp_recved</tt> in
<tt>cc_recv</tt>) with a 4 KB ring (< TCP_WND), so a sustained inbound transfer could fill
the ring, get refused (lwIP <tt>refused_data</tt>), and race.
- <strong>Fix:</strong> ack-on-consume on the client transport too - <tt>cc_recv</tt> no longer ACKs;
<tt>pc_client_read()</tt> marshals <tt>tcp_recved()</tt> for the bytes it drained. Default
<tt>PC_CLIENT_RX_BUF</tt> raised 4096 -> 8192 (>= TCP_WND). Client and server transports
now share one flow-control model.
- <strong>HW proof:</strong> device <tt>http_get()</tt> of a 12 KB body (> the 8 KB client ring, so it
wraps the ring and exercises the ack-on-consume read path) returned the full
<tt>len:12000</tt>, 5/5 - no truncation or deadlock.
@section autotoc_md316 RX flow-control deadlock on streamed uploads (WebDAV PUT)
- <strong>Status:</strong> FIXED (v4.6.0; host + HW validated)
- <strong>Found:</strong> 2026-06-28, stress-testing WebDAV streaming PUT on hardware.
- <strong>Symptom:</strong> large PUTs intermittently hung ~20 s (curl timeout), stored 0 bytes,
and repeated hangs eventually wedged every slot (device pings but HTTP is dead).
- <strong>Root cause:</strong> <tt>recv_cb</tt> ACKed received data on <strong>copy</strong> (<tt>tcp_recved</tt> at copy
time), decoupling the advertised TCP window from how full the ring actually was.
A slow consumer (flash writes) let the ring fill to <tt>RX_BUF_SIZE</tt>; the next
segment was refused (<tt>ERR_MEM</tt> -> lwIP <tt>refused_data</tt>). When <tt>RX_BUF_SIZE \<
TCP_WND</tt> the ring can never hold a full receive window, so refusals were constant
and lwIP's refused-data redelivery raced fatally. Serial trace nailed it: failing
PUTs stalled at exactly <tt>bytes_written == RX_BUF_SIZE</tt>. This was an <strong>interlayer</strong>
bug: the receive-window invariant was smeared across transport (ACK on copy),
presentation (drains the ring) and session (worker loop), with no single owner.
- <strong>Fix:</strong> ack-on-consume, owned entirely by transport. <tt>recv_cb</tt> no longer ACKs;
the worker calls <tt>pc_conn_ack_consumed(slot)</tt> once per loop and transport
reopens the window by exactly the bytes drained since the last ACK
(<tt>tcp_recved</tt> marshaled to tcpip_thread). The window now tracks ring occupancy,
so a slow sink cannot overflow the ring. <strong>TCP-level requirement:</strong> the ring must
hold at least one receive window (<tt>RX_BUF_SIZE \>= TCP_WND</tt>); a smaller ring is a
configuration error (you cannot advertise a window larger than your buffer).
- <strong>HW proof:</strong> RX=8192 (>= TCP_WND) + ack-on-consume -> 10/10 50 KB byte-exact,
<tt>backpressure=0</tt>. Pre-fix: RX=2048 -> ~40% hang + permanent wedge.
@section autotoc_md317 Boot stack-overflow when RX_BUF_SIZE is large (pool_init)
- <strong>Status:</strong> FIXED (v4.6.0; host validated)
- <strong>Found:</strong> 2026-06-28, while testing large rings for the deadlock above.
- <strong>Symptom:</strong> "Stack canary watchpoint triggered (loopTask)" at boot
(<tt>begin()</tt> -> <tt>pool_init</tt> -> <tt>memset</tt>) once <tt>RX_BUF_SIZE</tt> was set large (e.g. 8192).
- <strong>Root cause:</strong> <tt>conn_pool[i] = {}</tt> materializes a full <tt>sizeof(TcpConn)</tt>
temporary - the entire <tt>rx_buffer[RX_BUF_SIZE]</tt> - on the loopTask stack.
- <strong>Fix:</strong> reset from a single <tt>static const TcpConn blank = {}</tt> in BSS via
copy-assign (uses <tt>pc_atomic::operator=</tt>, no atomic-memset UB); no large stack
temporary.
@section autotoc_md318 WebDAV streamed PUT leaks the file handle on abort
- <strong>Status:</strong> FIXED (v4.6.0)
- <strong>Found:</strong> 2026-06-28, investigating the deadlock cascade to a permanent wedge.
- <strong>Symptom:</strong> a PUT torn down before completion (peer reset / timeout / the
deadlock above) never closed <tt>g_dav_put_file</tt>; after a few, LittleFS ran out of
open-file slots and <tt>open()</tt> failed ("no permits for creation").Root cause: the file was closed only on the PARSE_COMPLETE handler path; no cleanup hook for an aborted stream.Fix: added HttpStreamAbortCb to the parser stream hooks, fired from http_parser_reset when body_streaming && parse_state != PARSE_COMPLETE. WebDAV registers it and closes the half-written file. The completion path now also clears g_dav_put_active so the abort hook cannot double-close.g_dav_put_file/active/error/...) was a single global, and HttpStreamDataCb carried no slot/connection, so the data hook could not route bytes to per-connection state. Overlapping PUTs shared and clobbered the one global transfer.data hook slot-aware (HttpStreamDataCb(HttpReq*,
...); begin/abort already had it) and replaced the global state with per-slot g_dav_put[MAX_CONNS], so each connection streams to its own file. Aligns with the no-spaghetti directive (the data hook lacking connection context was the interlayer gap). HW: 4 concurrent PUTs with distinct payloads, all 4 byte-exact (was 0/4).TCP_SND_BUF (~5.7 KB) was truncated.pc_conn_send() and ignored the return; once the TCP send buffer filled, the remainder was silently dropped. Hidden on host because the mock tcp_sndbuf is constant and tcp_write never returns ERR_MEM.file_send_pump / chunk_send_pump) that page out one send-window per worker loop and resume on the sent callback; the chunked API became a pull generator (ChunkSource) so it can resume across loops. Definition in file web_assets.h.
|
extern |
Real-time SVG telemetry dashboard page (PC_ENABLE_DASHBOARD).
Definition at line 10 of file web_assets.cpp.
|
extern |
Captive-portal form for WiFi credential entry (provisioning service).
Definition at line 82 of file web_assets.cpp.
|
extern |
Success page shown after credentials are saved and the device reboots.
Definition at line 88 of file web_assets.cpp.
|
extern |
Self-contained WebSocket terminal page (green-phosphor CRT theme; auto ws/wss).
Definition at line 90 of file web_assets.cpp.
|
extern |
Service worker: precaches the app shell from the versioned manifest and serves it stale-while-revalidate.
Definition at line 118 of file web_assets.cpp.
|
extern |
Runtime stats JSON (rendered via send_template); add/rename fields to taste.
Definition at line 153 of file web_assets.cpp.
|
extern |
All available Prometheus metrics; comment a value line out with a leading # to drop it.
Definition at line 158 of file web_assets.cpp.