From cacadc34aebfaac94c365313d4a9e658a981e6c4 Mon Sep 17 00:00:00 2001 From: Greeva Patel Date: Thu, 12 Nov 2026 22:21:02 +0530 Subject: [PATCH] fix(sdk/c): find JSON keys, not matching values; decode string escapes The C SDK's *_from_json decoders read string values wrongly in two ways. The key lookup was strstr() on the quoted key name, which also matches a string value with the same text. eipc_ack_from_json() looks up "error" and found the status value "error" first, so every failed ack came back with an empty error message. A header whose service_id is "priority" lost its priority the same way. A key is now a quoted name followed by a colon. String values were copied verbatim between the quotes, so escapes were never decoded. A value written by eipc_ack_to_json() or eipc_intent_to_json() came back with its backslashes, and text from Go's encoding/json, which writes < > & as \u003c \u003e \u0026, came back as those six-character codes. The decoder now handles \" \\ \/ \b \f \n \r \t and \uXXXX (as UTF-8, with surrogate pairs); an over-long value is still truncated, but never inside a decoded \uXXXX. \u0000 becomes U+FFFD, since a NUL would cut the C string short. test_chat_json gains two cases that fail before this change and pass after it. --- sdk/c/src/eipc_json.c | 154 ++++++++++++++++++++++++++--------- sdk/c/tests/test_chat_json.c | 76 +++++++++++++++++ 2 files changed, 193 insertions(+), 37 deletions(-) diff --git a/sdk/c/src/eipc_json.c b/sdk/c/src/eipc_json.c index 2094850..b1bb5f7 100644 --- a/sdk/c/src/eipc_json.c +++ b/sdk/c/src/eipc_json.c @@ -15,60 +15,148 @@ /* ---------- JSON Parse Helpers ---------- */ -static int json_find_string(const char *json, const char *key, - char *out, size_t out_size) { +#define JSON_BAD_HEX 0xFFFFFFFFu + +/* The value of four hex digits at p, or JSON_BAD_HEX. */ +static uint32_t json_hex4(const char *p) { + uint32_t v = 0; + int i; + for (i = 0; i < 4; i++) { + char c = p[i]; + v <<= 4; + if (c >= '0' && c <= '9') + v |= (uint32_t)(c - '0'); + else if (c >= 'a' && c <= 'f') + v |= (uint32_t)(c - 'a' + 10); + else if (c >= 'A' && c <= 'F') + v |= (uint32_t)(c - 'A' + 10); + else + return JSON_BAD_HEX; + } + return v; +} + +/* Write code point cp as UTF-8 into out (room for 4 bytes); return the length. */ +static size_t json_utf8(uint32_t cp, char *out) { + if (cp < 0x80) { + out[0] = (char)cp; + return 1; + } + if (cp < 0x800) { + out[0] = (char)(0xC0 | (cp >> 6)); + out[1] = (char)(0x80 | (cp & 0x3F)); + return 2; + } + if (cp < 0x10000) { + out[0] = (char)(0xE0 | (cp >> 12)); + out[1] = (char)(0x80 | ((cp >> 6) & 0x3F)); + out[2] = (char)(0x80 | (cp & 0x3F)); + return 3; + } + out[0] = (char)(0xF0 | (cp >> 18)); + out[1] = (char)(0x80 | ((cp >> 12) & 0x3F)); + out[2] = (char)(0x80 | ((cp >> 6) & 0x3F)); + out[3] = (char)(0x80 | (cp & 0x3F)); + return 4; +} + +/* The start of the value for key, or NULL. Only a "key" followed by a colon + * is a key: strstr() alone also matched the same text as a string value, so + * in {"status":"error","error":"disk full"} the lookup for "error" landed on + * the status value and returned nothing. */ +static const char *json_find_value(const char *json, const char *key) { char pattern[128]; - const char *start, *end; - size_t len; + const char *p = json; + size_t plen; snprintf(pattern, sizeof(pattern), "\"%s\"", key); - start = strstr(json, pattern); - if (!start) { - out[0] = '\0'; - return -1; + plen = strlen(pattern); + while ((p = strstr(p, pattern)) != NULL) { + const char *v = p + plen; + while (*v == ' ' || *v == '\t' || *v == '\n' || *v == '\r') + v++; + if (*v == ':') { + v++; + while (*v == ' ' || *v == '\t' || *v == '\n' || *v == '\r') + v++; + return v; + } + p += plen; } + return NULL; +} - start += strlen(pattern); - while (*start == ' ' || *start == ':' || *start == '\t' || *start == '\n') - start++; +static int json_find_string(const char *json, const char *key, + char *out, size_t out_size) { + const char *start, *end; + size_t len; - if (*start != '"') { + start = json_find_value(json, key); + if (!start || *start != '"') { out[0] = '\0'; return -1; } start++; + /* Decode the escapes as the value is copied: the encoders write \" \\ + * \n \r \t (json_escape), and other JSON encoders also write \/ \b \f + * and \uXXXX (Go's encoding/json writes < > & as \u003c \u003e + * \u0026). A value longer than out_size - 1 bytes is truncated as + * before; a decoded \uXXXX is never cut in half. */ + len = 0; end = start; while (*end && *end != '"') { - if (*end == '\\' && *(end + 1)) + char enc[4]; + size_t n = 1; + + if (*end != '\\') { + enc[0] = *end++; + } else if (end[1] == 'u') { + uint32_t cp = json_hex4(end + 2); + end += (cp == JSON_BAD_HEX) ? 2 : 6; + if (cp >= 0xD800 && cp <= 0xDBFF && end[0] == '\\' && end[1] == 'u') { + uint32_t lo = json_hex4(end + 2); + if (lo >= 0xDC00 && lo <= 0xDFFF) { + cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00); + end += 6; + } + } + /* A NUL would cut the C string short, so \u0000 becomes + * U+FFFD, like a malformed escape or an unpaired surrogate. */ + if (cp == 0 || cp == JSON_BAD_HEX || (cp >= 0xD800 && cp <= 0xDFFF)) + cp = 0xFFFD; + n = json_utf8(cp, enc); + } else if (end[1]) { + switch (end[1]) { + case 'b': enc[0] = '\b'; break; + case 'f': enc[0] = '\f'; break; + case 'n': enc[0] = '\n'; break; + case 'r': enc[0] = '\r'; break; + case 't': enc[0] = '\t'; break; + default: enc[0] = end[1]; break; /* \" \\ \/ */ + } end += 2; - else - end++; + } else { + break; /* a lone backslash at the end of the input */ + } + + if (len + n >= out_size) + break; + memcpy(out + len, enc, n); + len += n; } - len = (size_t)(end - start); - if (len >= out_size) - len = out_size - 1; - memcpy(out, start, len); out[len] = '\0'; return 0; } static int json_find_uint64(const char *json, const char *key, uint64_t *out) { - char pattern[128]; - const char *start; - - snprintf(pattern, sizeof(pattern), "\"%s\"", key); - start = strstr(json, pattern); + const char *start = json_find_value(json, key); if (!start) { *out = 0; return -1; } - start += strlen(pattern); - while (*start == ' ' || *start == ':' || *start == '\t' || *start == '\n') - start++; - if (*start == '"') start++; *out = (uint64_t)strtoull(start, NULL, 10); @@ -86,20 +174,12 @@ static int json_find_int(const char *json, const char *key, int *out) { } static int json_find_float(const char *json, const char *key, float *out) { - char pattern[128]; - const char *start; - - snprintf(pattern, sizeof(pattern), "\"%s\"", key); - start = strstr(json, pattern); + const char *start = json_find_value(json, key); if (!start) { *out = 0.0f; return -1; } - start += strlen(pattern); - while (*start == ' ' || *start == ':' || *start == '\t' || *start == '\n') - start++; - if (*start == '"') start++; *out = (float)strtod(start, NULL); diff --git a/sdk/c/tests/test_chat_json.c b/sdk/c/tests/test_chat_json.c index 66a1aa8..aa7aca4 100644 --- a/sdk/c/tests/test_chat_json.c +++ b/sdk/c/tests/test_chat_json.c @@ -85,12 +85,88 @@ static int test_chat_null_args(void) { return 0; } +/* The *_from_json decoders must undo JSON string escapes. They used to copy + * the bytes between the quotes verbatim, so a value written by + * eipc_ack_to_json() or eipc_intent_to_json() came back with its + * backslashes, and text from Go's encoding/json, which writes <, > and & + * as \u003c, \u003e and \u0026, came back as those six-character codes. */ +static int test_json_string_escapes_decoded(void) { + eipc_ack_t ack, ack_parsed; + eipc_intent_t intent, intent_parsed; + eipc_chat_response_t resp; + char json[1024]; + size_t written = 0; + + memset(&ack, 0, sizeof(ack)); + strncpy(ack.request_id, "req-1", sizeof(ack.request_id) - 1); + strncpy(ack.status, "failed", sizeof(ack.status) - 1); + strncpy(ack.error, "open \"C:\\tmp\"\nfailed\tagain", sizeof(ack.error) - 1); + ASSERT(eipc_ack_to_json(&ack, json, sizeof(json), &written) == EIPC_OK, "ack serialize"); + ASSERT(eipc_ack_from_json(json, strlen(json), &ack_parsed) == EIPC_OK, "ack deserialize"); + ASSERT(strcmp(ack_parsed.error, ack.error) == 0, "ack error round-trips"); + + memset(&intent, 0, sizeof(intent)); + strncpy(intent.intent, "say \"hi\"", sizeof(intent.intent) - 1); + strncpy(intent.session_id, "sess-9", sizeof(intent.session_id) - 1); + intent.confidence = 0.5f; + ASSERT(eipc_intent_to_json(&intent, json, sizeof(json), &written) == EIPC_OK, + "intent serialize"); + ASSERT(eipc_intent_from_json(json, strlen(json), &intent_parsed) == EIPC_OK, + "intent deserialize"); + ASSERT(strcmp(intent_parsed.intent, "say \"hi\"") == 0, "intent round-trips"); + + /* Go's json.Marshal writes < and & as \u003c and \u0026; \/, a + * \u00e9 and a surrogate pair are valid JSON from any encoder. */ + const char *from_go = + "{\"session_id\":\"s-1\"," + "\"response\":\"a \\u003c b \\u0026 c \\u00e9 \\/ \\ud83d\\ude00\"," + "\"model\":\"m\",\"tokens_used\":3}"; + ASSERT(eipc_chat_response_from_json(from_go, strlen(from_go), &resp) == EIPC_OK, + "go response deserialize"); + ASSERT(strcmp(resp.response, "a < b & c \xc3\xa9 / \xf0\x9f\x98\x80") == 0, + "\\u escapes decoded to UTF-8"); + ASSERT(resp.tokens_used == 3, "tokens_used after escaped string"); + + /* A NUL would cut the C string short; \u0000 decodes to U+FFFD. */ + const char *with_nul = "{\"intent\":\"a\\u0000b\",\"confidence\":1}"; + ASSERT(eipc_intent_from_json(with_nul, strlen(with_nul), &intent_parsed) == EIPC_OK, + "intent with \\u0000 deserialize"); + ASSERT(strcmp(intent_parsed.intent, "a\xEF\xBF\xBD" "b") == 0, "\\u0000 decoded to U+FFFD"); + return 0; +} + +/* A key is a quoted name followed by a colon. The lookup used strstr() on + * "error" alone, so it found the status value "error" first and returned + * an empty error message for every ack that reported a failure. */ +static int test_json_key_not_matched_in_value(void) { + eipc_ack_t ack, parsed; + eipc_header_t hdr; + char json[512]; + size_t written = 0; + + memset(&ack, 0, sizeof(ack)); + strncpy(ack.request_id, "req-2", sizeof(ack.request_id) - 1); + strncpy(ack.status, "error", sizeof(ack.status) - 1); + strncpy(ack.error, "disk full", sizeof(ack.error) - 1); + ASSERT(eipc_ack_to_json(&ack, json, sizeof(json), &written) == EIPC_OK, "ack serialize"); + ASSERT(eipc_ack_from_json(json, strlen(json), &parsed) == EIPC_OK, "ack deserialize"); + ASSERT(strcmp(parsed.status, "error") == 0, "status"); + ASSERT(strcmp(parsed.error, "disk full") == 0, "error message after an \"error\" status"); + + const char *h = "{\"service_id\":\"priority\",\"priority\":2}"; + ASSERT(eipc_header_from_json(h, strlen(h), &hdr) == EIPC_OK, "header deserialize"); + ASSERT(hdr.priority == 2, "number after a string value equal to its key"); + return 0; +} + int main(void) { int p=0,f=0,t=0; printf("=== EIPC Chat JSON Tests ===\n\n"); RUN(test_chat_request_json_roundtrip); RUN(test_chat_response_json_roundtrip); RUN(test_chat_null_args); + RUN(test_json_key_not_matched_in_value); + RUN(test_json_string_escapes_decoded); printf("\n%d/%d passed, %d failed\n",p,t,f); return f>0?1:0; }