From a45a3ca37914910ba8d70f348fb6748ee97e04fa Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Tue, 21 Jul 2026 12:13:58 -0500 Subject: [PATCH] Fix truncated POST/GET /api/safety-contact response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving the 988 crisis-line contact returned truncated, unparseable JSON — cut mid-"set_at" at the file's byte length (e.g. 178 of a 218-byte response). The contact written to disk was complete; only the HTTP response was clipped, so a real customer's crisis-contact save came back corrupt. Root cause is in the el runtime's response writer, not a handler buffer: fs_read stores the file's byte count in a thread-local (_tl_fs_read_len) for binary-safe file serving, and the response writer uses that length when non-zero instead of strlen(body) (el_runtime.c:1409). Both safety-contact handlers call fs_read (the POST read-back verify; the GET file read) and then return a LONGER wrapped JSON string, so the response is capped to the file size. Soul-source fix (no runtime change needed): - POST: verify persistence via fs_write's return (1 = all bytes written) instead of an fs_read read-back — removes the fs_read, so nothing caps the response. - GET: fs_read is required, so reset the thread-local after it with a no-op fs_read("") (fs_read zeroes the length before it opens a path) so the wrapped response is sent in full. Verified: POST (crisis-line + custom) and GET now return complete, valid JSON (parses cleanly, full contact incl. set_at). Regenerated dist/soul.c + dist/safety.c (3GB RSS watchdog, release el_runtime v1.0.0-20260501). Full suite still green: verify-soul-contract GATE PASS (PRESENCE + IMMUTABILITY), genesis boot survives (/health 200, no segfault), bounded- persona floor still compiled in. NOTE: the underlying runtime leak (any handler that fs_reads then returns a longer string) is worth a proper fix in el_runtime.c (use the max of strlen and _tl_fs_read_len) so this class can't recur. --- dist/safety.c | 6 +++--- dist/soul.c | 6 +++--- safety.el | 17 +++++++++++++---- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/dist/safety.c b/dist/safety.c index 980b3f0..7446fd9 100644 --- a/dist/safety.c +++ b/dist/safety.c @@ -340,6 +340,7 @@ el_val_t handle_safety_contact_get(void) { if (str_eq(raw, EL_STR(""))) { return EL_STR("{\"configured\":false}"); } + el_val_t _reset = fs_read(EL_STR("")); return el_str_concat(el_str_concat(EL_STR("{\"configured\":true,\"contact\":"), raw), EL_STR("}")); return 0; } @@ -359,9 +360,8 @@ el_val_t handle_safety_contact_post(el_val_t body) { el_val_t crisis_str = ({ el_val_t _if_result_51 = 0; if (is_crisis) { _if_result_51 = (EL_STR("true")); } else { _if_result_51 = (EL_STR("false")); } _if_result_51; }); el_val_t now = time_format(time_now(), EL_STR("%Y-%m-%dT%H:%M:%SZ")); el_val_t contact_json = el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("{\"name\":\""), json_safe(name)), EL_STR("\"")), EL_STR(",\"contact_method\":\"")), json_safe(method)), EL_STR("\"")), EL_STR(",\"contact_value\":\"")), json_safe(value)), EL_STR("\"")), EL_STR(",\"relationship\":\"")), json_safe(rel)), EL_STR("\"")), EL_STR(",\"confirmed\":true")), EL_STR(",\"is_crisis_line\":")), crisis_str), EL_STR(",\"set_at\":\"")), now), EL_STR("\"}")); - fs_write(safety_contact_path(), contact_json); - el_val_t check = fs_read(safety_contact_path()); - if (str_eq(check, EL_STR(""))) { + el_val_t write_ok = fs_write(safety_contact_path(), contact_json); + if (write_ok == 0) { return EL_STR("{\"ok\":false,\"error\":\"write_failed\"}"); } return el_str_concat(el_str_concat(EL_STR("{\"configured\":true,\"contact\":"), contact_json), EL_STR(",\"ok\":true}")); diff --git a/dist/soul.c b/dist/soul.c index 0265c00..0ab6aa0 100644 --- a/dist/soul.c +++ b/dist/soul.c @@ -25754,6 +25754,7 @@ el_val_t handle_safety_contact_get(void) { if (str_eq(raw, EL_STR(""))) { return EL_STR("{\"configured\":false}"); } + el_val_t _reset = fs_read(EL_STR("")); return el_str_concat(el_str_concat(EL_STR("{\"configured\":true,\"contact\":"), raw), EL_STR("}")); return 0; } @@ -25773,9 +25774,8 @@ el_val_t handle_safety_contact_post(el_val_t body) { el_val_t crisis_str = ({ el_val_t _if_result_51 = 0; if (is_crisis) { _if_result_51 = (EL_STR("true")); } else { _if_result_51 = (EL_STR("false")); } _if_result_51; }); el_val_t now = time_format(time_now(), EL_STR("%Y-%m-%dT%H:%M:%SZ")); el_val_t contact_json = el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("{\"name\":\""), json_safe(name)), EL_STR("\"")), EL_STR(",\"contact_method\":\"")), json_safe(method)), EL_STR("\"")), EL_STR(",\"contact_value\":\"")), json_safe(value)), EL_STR("\"")), EL_STR(",\"relationship\":\"")), json_safe(rel)), EL_STR("\"")), EL_STR(",\"confirmed\":true")), EL_STR(",\"is_crisis_line\":")), crisis_str), EL_STR(",\"set_at\":\"")), now), EL_STR("\"}")); - fs_write(safety_contact_path(), contact_json); - el_val_t check = fs_read(safety_contact_path()); - if (str_eq(check, EL_STR(""))) { + el_val_t write_ok = fs_write(safety_contact_path(), contact_json); + if (write_ok == 0) { return EL_STR("{\"ok\":false,\"error\":\"write_failed\"}"); } return el_str_concat(el_str_concat(EL_STR("{\"configured\":true,\"contact\":"), contact_json), EL_STR(",\"ok\":true}")); diff --git a/safety.el b/safety.el index 0590916..e230813 100644 --- a/safety.el +++ b/safety.el @@ -438,6 +438,12 @@ fn safety_contact_path() -> String { fn handle_safety_contact_get() -> String { let raw: String = fs_read(safety_contact_path()) if str_eq(raw, "") { return "{\"configured\":false}" } + // fs_read set the runtime's binary-safe send length to len(raw); the HTTP + // response writer uses that length when non-zero, which would TRUNCATE this + // wrapped (longer) response to len(raw). Reset it with a no-op read of a + // missing path (fs_read zeroes the length before it opens) so the full + // response is sent. + let _reset: String = fs_read("") return "{\"configured\":true,\"contact\":" + raw + "}" } @@ -463,9 +469,12 @@ fn handle_safety_contact_post(body: String) -> String { + ",\"confirmed\":true" + ",\"is_crisis_line\":" + crisis_str + ",\"set_at\":\"" + now + "\"}" - fs_write(safety_contact_path(), contact_json) - // Read-back verify the write actually persisted. - let check: String = fs_read(safety_contact_path()) - if str_eq(check, "") { return "{\"ok\":false,\"error\":\"write_failed\"}" } + // Verify persistence via fs_write's return (1 = all bytes written, 0 = fail). + // The previous fs_read read-back set the runtime's binary-safe send length to + // the file size, which then TRUNCATED this longer JSON response to that size + // (the safety-contact 988 response was cut mid-"set_at"). Checking the write + // return avoids the fs_read entirely, so the full response is sent. + let write_ok: Int = fs_write(safety_contact_path(), contact_json) + if write_ok == 0 { return "{\"ok\":false,\"error\":\"write_failed\"}" } return "{\"configured\":true,\"contact\":" + contact_json + ",\"ok\":true}" }