diff --git a/lang/el-compiler/runtime/el_runtime.c b/lang/el-compiler/runtime/el_runtime.c index 2b75c6f..af0d945 100644 --- a/lang/el-compiler/runtime/el_runtime.c +++ b/lang/el-compiler/runtime/el_runtime.c @@ -82,8 +82,14 @@ static _Thread_local ElArena _tl_arena = {NULL, 0, 0}; static _Thread_local int _tl_arena_active = 0; /* Binary-safe fs_read length — set by fs_read, consumed by http_send_response. - * Allows serving PNGs and other binary files without strlen truncation. */ -static _Thread_local size_t _tl_fs_read_len = 0; + * Allows serving PNGs and other binary files without strlen truncation. + * PAIRED with the buffer pointer it describes: the length may only be applied + * to the exact buffer fs_read returned. Without the pairing, any handler that + * fs_read a file and then WRAPPED it into a larger response had that response + * truncated to the file's length (Content-Length lied AND the send stopped + * short) — the safety-contact onboarding trap, 2026-07-17. */ +static _Thread_local size_t _tl_fs_read_len = 0; +static _Thread_local const char* _tl_fs_read_buf = NULL; static void el_arena_track(char* p) { if (!_tl_arena_active || !p) return; @@ -101,6 +107,8 @@ static void el_arena_track(char* p) { void el_request_start(void) { _tl_arena.count = 0; _tl_arena_active = 1; + _tl_fs_read_len = 0; /* never let a previous request's file length */ + _tl_fs_read_buf = NULL; /* leak into this response's byte accounting */ } /* Called by http_worker after the El handler returns and the response is sent. @@ -1484,11 +1492,14 @@ static void http_send_response(int fd, const char* body) { } const char* eff_body = is_envelope ? env_body : body; - /* Use the real byte count from fs_read if available (handles binary files - * with embedded null bytes — PNG, WOFF2, etc.). Fall back to strlen for - * normal text/JSON responses where _tl_fs_read_len is 0. */ - size_t blen = (_tl_fs_read_len > 0) ? _tl_fs_read_len : strlen(eff_body); + /* Use the real byte count from fs_read ONLY when this body IS the exact + * buffer fs_read returned (binary files with embedded null bytes — PNG, + * WOFF2, etc.). Any other body — wrapped, enveloped, or derived — must be + * measured with strlen, or it is truncated/over-read to the file's size. */ + size_t blen = (_tl_fs_read_len > 0 && eff_body == _tl_fs_read_buf) + ? _tl_fs_read_len : strlen(eff_body); _tl_fs_read_len = 0; /* consume — one-shot per response */ + _tl_fs_read_buf = NULL; int head_only = _tl_http_head_only; JsonBuf hdrs; jb_init(&hdrs); @@ -1568,11 +1579,22 @@ static void* http_worker(void* arg) { const char* rs = EL_CSTR(r); /* Copy response out BEFORE arena teardown. * For binary files, _tl_fs_read_len holds the real byte count — - * use memcpy instead of strdup so null bytes are preserved. */ - size_t rlen = _tl_fs_read_len > 0 ? _tl_fs_read_len : (rs ? strlen(rs) : 0); + * use memcpy instead of strdup so null bytes are preserved. + * The stored length applies ONLY when the response IS the exact + * fs_read buffer; a wrapped/derived response must use strlen or + * it gets truncated (or over-read) to the file's length. */ + size_t rlen; + if (_tl_fs_read_len > 0 && rs && rs == _tl_fs_read_buf) { + rlen = _tl_fs_read_len; /* raw file bytes — binary-safe */ + } else { + rlen = rs ? strlen(rs) : 0; + _tl_fs_read_len = 0; /* hint doesn't describe this body */ + _tl_fs_read_buf = NULL; + } response = malloc(rlen + 1); if (response && rs) { memcpy(response, rs, rlen); response[rlen] = '\0'; } else if (response) { response[0] = '\0'; } + if (_tl_fs_read_len > 0) _tl_fs_read_buf = response; /* hint follows the copy */ } else { response = el_strdup_persist("el-runtime: no http handler registered"); } @@ -1822,10 +1844,20 @@ static void* http_worker_v2(void* arg) { el_val_t hmap = http_build_headers_map(hdr_block ? hdr_block : ""); el_val_t r = h(EL_STR(dispatch_method), EL_STR(path), hmap, EL_STR(body)); const char* rs = EL_CSTR(r); - size_t rlen = _tl_fs_read_len > 0 ? _tl_fs_read_len : (rs ? strlen(rs) : 0); + /* Same pairing rule as the v1 worker: the fs_read length is only + * trustworthy for the exact buffer fs_read returned. */ + size_t rlen; + if (_tl_fs_read_len > 0 && rs && rs == _tl_fs_read_buf) { + rlen = _tl_fs_read_len; /* raw file bytes — binary-safe */ + } else { + rlen = rs ? strlen(rs) : 0; + _tl_fs_read_len = 0; /* hint doesn't describe this body */ + _tl_fs_read_buf = NULL; + } response = malloc(rlen + 1); if (response && rs) { memcpy(response, rs, rlen); response[rlen] = '\0'; } else if (response) { response[0] = '\0'; } + if (_tl_fs_read_len > 0) _tl_fs_read_buf = response; /* hint follows the copy */ el_release(hmap); } else { response = el_strdup_persist( @@ -2024,6 +2056,7 @@ el_val_t http_response(el_val_t status, el_val_t headers_json, el_val_t body) { el_val_t fs_read(el_val_t pathv) { const char* path = EL_CSTR(pathv); _tl_fs_read_len = 0; + _tl_fs_read_buf = NULL; if (!path) return el_wrap_str(el_strdup("")); FILE* f = fopen(path, "rb"); if (!f) return el_wrap_str(el_strdup("")); @@ -2035,6 +2068,7 @@ el_val_t fs_read(el_val_t pathv) { size_t got = fread(buf, 1, (size_t)sz, f); buf[got] = '\0'; _tl_fs_read_len = got; /* store real byte count for binary-safe send */ + _tl_fs_read_buf = buf; /* ...valid ONLY for this exact buffer */ fclose(f); return el_wrap_str(buf); } @@ -3577,8 +3611,10 @@ el_val_t json_get_raw(el_val_t json_str, el_val_t key) { const char* k = EL_CSTR(key); const char* p = json_find_key(json, k); /* Clear fs_read binary-length hint — result is a fresh null-terminated - * string, not the raw file bytes, so Content-Length must use strlen. */ + * string, not the raw file bytes, so Content-Length must use strlen. + * (Kept although the pointer pairing now makes this redundant.) */ _tl_fs_read_len = 0; + _tl_fs_read_buf = NULL; if (!p) return el_wrap_str(el_strdup("")); const char* end = json_skip_value(p); size_t n = (size_t)(end - p); diff --git a/lang/releases/v1.0.0-20260501/el_runtime.c b/lang/releases/v1.0.0-20260501/el_runtime.c index e9d633c..d05edfe 100644 --- a/lang/releases/v1.0.0-20260501/el_runtime.c +++ b/lang/releases/v1.0.0-20260501/el_runtime.c @@ -71,8 +71,14 @@ static _Thread_local ElArena _tl_arena = {NULL, 0, 0}; static _Thread_local int _tl_arena_active = 0; /* Binary-safe fs_read length — set by fs_read, consumed by http_send_response. - * Allows serving PNGs and other binary files without strlen truncation. */ -static _Thread_local size_t _tl_fs_read_len = 0; + * Allows serving PNGs and other binary files without strlen truncation. + * PAIRED with the buffer pointer it describes: the length may only be applied + * to the exact buffer fs_read returned. Without the pairing, any handler that + * fs_read a file and then WRAPPED it into a larger response had that response + * truncated to the file's length (Content-Length lied AND the send stopped + * short) — the safety-contact onboarding trap, 2026-07-17. */ +static _Thread_local size_t _tl_fs_read_len = 0; +static _Thread_local const char* _tl_fs_read_buf = NULL; static void el_arena_track(char* p) { if (!_tl_arena_active || !p) return; @@ -90,6 +96,8 @@ static void el_arena_track(char* p) { void el_request_start(void) { _tl_arena.count = 0; _tl_arena_active = 1; + _tl_fs_read_len = 0; /* never let a previous request's file length */ + _tl_fs_read_buf = NULL; /* leak into this response's byte accounting */ } /* Called by http_worker after the El handler returns and the response is sent. @@ -1403,11 +1411,14 @@ static void http_send_response(int fd, const char* body) { } const char* eff_body = is_envelope ? env_body : body; - /* Use the real byte count from fs_read if available (handles binary files - * with embedded null bytes — PNG, WOFF2, etc.). Fall back to strlen for - * normal text/JSON responses where _tl_fs_read_len is 0. */ - size_t blen = (_tl_fs_read_len > 0) ? _tl_fs_read_len : strlen(eff_body); + /* Use the real byte count from fs_read ONLY when this body IS the exact + * buffer fs_read returned (binary files with embedded null bytes — PNG, + * WOFF2, etc.). Any other body — wrapped, enveloped, or derived — must be + * measured with strlen, or it is truncated/over-read to the file's size. */ + size_t blen = (_tl_fs_read_len > 0 && eff_body == _tl_fs_read_buf) + ? _tl_fs_read_len : strlen(eff_body); _tl_fs_read_len = 0; /* consume — one-shot per response */ + _tl_fs_read_buf = NULL; int head_only = _tl_http_head_only; JsonBuf hdrs; jb_init(&hdrs); @@ -1479,11 +1490,22 @@ static void* http_worker(void* arg) { const char* rs = EL_CSTR(r); /* Copy response out BEFORE arena teardown. * For binary files, _tl_fs_read_len holds the real byte count — - * use memcpy instead of strdup so null bytes are preserved. */ - size_t rlen = _tl_fs_read_len > 0 ? _tl_fs_read_len : (rs ? strlen(rs) : 0); + * use memcpy instead of strdup so null bytes are preserved. + * The stored length applies ONLY when the response IS the exact + * fs_read buffer; a wrapped/derived response must use strlen or + * it gets truncated (or over-read) to the file's length. */ + size_t rlen; + if (_tl_fs_read_len > 0 && rs && rs == _tl_fs_read_buf) { + rlen = _tl_fs_read_len; /* raw file bytes — binary-safe */ + } else { + rlen = rs ? strlen(rs) : 0; + _tl_fs_read_len = 0; /* hint doesn't describe this body */ + _tl_fs_read_buf = NULL; + } response = malloc(rlen + 1); if (response && rs) { memcpy(response, rs, rlen); response[rlen] = '\0'; } else if (response) { response[0] = '\0'; } + if (_tl_fs_read_len > 0) _tl_fs_read_buf = response; /* hint follows the copy */ } else { response = el_strdup_persist("el-runtime: no http handler registered"); } @@ -1805,10 +1827,20 @@ static void* http_worker_v2(void* arg) { el_val_t hmap = http_build_headers_map(hdr_block ? hdr_block : ""); el_val_t r = h(EL_STR(dispatch_method), EL_STR(path), hmap, EL_STR(body)); const char* rs = EL_CSTR(r); - size_t rlen = _tl_fs_read_len > 0 ? _tl_fs_read_len : (rs ? strlen(rs) : 0); + /* Same pairing rule as the v1 worker: the fs_read length is only + * trustworthy for the exact buffer fs_read returned. */ + size_t rlen; + if (_tl_fs_read_len > 0 && rs && rs == _tl_fs_read_buf) { + rlen = _tl_fs_read_len; /* raw file bytes — binary-safe */ + } else { + rlen = rs ? strlen(rs) : 0; + _tl_fs_read_len = 0; /* hint doesn't describe this body */ + _tl_fs_read_buf = NULL; + } response = malloc(rlen + 1); if (response && rs) { memcpy(response, rs, rlen); response[rlen] = '\0'; } else if (response) { response[0] = '\0'; } + if (_tl_fs_read_len > 0) _tl_fs_read_buf = response; /* hint follows the copy */ el_release(hmap); } else { response = el_strdup_persist( @@ -1924,6 +1956,7 @@ el_val_t http_response(el_val_t status, el_val_t headers_json, el_val_t body) { el_val_t fs_read(el_val_t pathv) { const char* path = EL_CSTR(pathv); _tl_fs_read_len = 0; + _tl_fs_read_buf = NULL; if (!path) return el_wrap_str(el_strdup("")); FILE* f = fopen(path, "rb"); if (!f) return el_wrap_str(el_strdup("")); @@ -1935,6 +1968,7 @@ el_val_t fs_read(el_val_t pathv) { size_t got = fread(buf, 1, (size_t)sz, f); buf[got] = '\0'; _tl_fs_read_len = got; /* store real byte count for binary-safe send */ + _tl_fs_read_buf = buf; /* ...valid ONLY for this exact buffer */ fclose(f); return el_wrap_str(buf); } @@ -3305,8 +3339,10 @@ el_val_t json_get_raw(el_val_t json_str, el_val_t key) { const char* k = EL_CSTR(key); const char* p = json_find_key(json, k); /* Clear fs_read binary-length hint — result is a fresh null-terminated - * string, not the raw file bytes, so Content-Length must use strlen. */ + * string, not the raw file bytes, so Content-Length must use strlen. + * (Kept although the pointer pairing now makes this redundant.) */ _tl_fs_read_len = 0; + _tl_fs_read_buf = NULL; if (!p) return el_wrap_str(el_strdup("")); const char* end = json_skip_value(p); size_t n = (size_t)(end - p);