diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 1f7e456..b0d1251 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -15,10 +15,8 @@ jobs: - uses: actions/checkout@v4 - name: Build host-test environment run: docker build -f tests/Dockerfile -t pldmgr-tests . - - name: Test HTTP responses with sanitizers and real sockets - run: docker run --rm --network=none -v "$GITHUB_WORKSPACE:/src:ro" pldmgr-tests - - name: Test JSON encoding with sanitizers and an independent decoder - run: docker run --rm --network=none -v "$GITHUB_WORKSPACE:/src:ro" pldmgr-tests python3 tests/run_json_escape_tests.py + - name: Test backend JSON and HTTP with sanitizers and real sockets + run: docker run --rm --network=none -v "$GITHUB_WORKSPACE:/src:ro" pldmgr-tests make test-host frontend: runs-on: ubuntu-latest diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 06bab69..7d3a21c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -49,7 +49,7 @@ are enabled by default. Test files, storage paths, and binaries are temporary; the suite does not connect to a console or launch payloads. On a host with Clang, its sanitizer runtime, Python 3, pkg-config, and -libmicrohttpd development files installed: +libmicrohttpd/libcurl development files installed: ```bash make test-host @@ -60,9 +60,7 @@ The container option builds libmicrohttpd **1.0.1**, matching `build_deps.sh`: ```bash docker build -f tests/Dockerfile -t ps5-payload-manager-tests . -docker run --rm --network=none -v "$(pwd):/src:ro" ps5-payload-manager-tests -docker run --rm --network=none -v "$(pwd):/src:ro" ps5-payload-manager-tests \ - python3 tests/run_json_escape_tests.py +docker run --rm --network=none -v "$(pwd):/src:ro" ps5-payload-manager-tests make test-host ``` The build downloads test dependencies; the test container itself has external @@ -74,10 +72,21 @@ The JSON encoder suite compiles actual `src/utils.c` and checks its output with Python's independent JSON and UTF-8 decoders. It covers Unicode, all non-NUL control bytes, invalid UTF-8 replacement, every small escape/scalar boundary, truncation status, and guarded destination bounds, with ASan/UBSan enabled. -C strings cannot represent embedded NUL; escaped-NUL decoding belongs to the -separate decoder contract. `make test-host` runs both host suites. +C strings cannot represent embedded NUL. The decoder rejects decoded NUL in the +requested value, while accepting valid escaped NUL in unrelated fields. -`.github/workflows/verify.yml` runs both host suites, existing frontend tests, +The decoder suite passes exact, non-NUL-terminated buffers to actual +`src/json_helpers.c`; it checks bounds, direct-member selection, all escapes, +UTF-8, surrogate pairs, malformed input, and strict/prefix overflow contracts. +Objects are validated completely with a nesting limit of 64. Strict extraction +clears the result on overflow; descriptions/release notes explicitly opt into +UTF-8-safe prefixes. The backend suite uses real repository, source save/load, +and slot metadata read/write code inside temporary storage. It parses their +results independently with Python. Unused network/PS5 code is discarded at +link time, and no download or payload launch occurs. `make test-host` runs all +four host suites with sanitizers. + +`.github/workflows/verify.yml` runs all host suites, existing frontend tests, and frontend build without publishing or deploying. Frontend lint still reports existing errors. The host suite tests response ownership/status handling, request cleanup, and autoload-list bounds. Storage transactions, autoload worker concurrency, and PS5 firmware behavior remain diff --git a/Makefile b/Makefile index 50ae4e5..ff968b2 100644 --- a/Makefile +++ b/Makefile @@ -51,6 +51,8 @@ all: $(ELF) .PHONY: test-host test-frontend test test-host: HOST_CC="$(HOST_CC)" $(PYTHON) tests/run_json_escape_tests.py + HOST_CC="$(HOST_CC)" $(PYTHON) tests/run_json_decode_tests.py + HOST_CC="$(HOST_CC)" $(PYTHON) tests/run_json_backend_tests.py HOST_CC="$(HOST_CC)" $(PYTHON) tests/run_http_tests.py test-frontend: diff --git a/include/json_helpers.h b/include/json_helpers.h index 74197cf..5a0e817 100644 --- a/include/json_helpers.h +++ b/include/json_helpers.h @@ -14,11 +14,31 @@ typedef struct JsonListBuilder { /* Append formatted text to the builder. Returns 0 on success, -1 on overflow. */ int json_append(JsonListBuilder *jb, const char *fmt, ...); -/* Extract a quoted string value for a given key from a JSON object bounded by - * [obj_start, obj_end). Returns 0 on success, -1 if key not found. */ +enum { + JSON_STRING_OK = 0, + JSON_STRING_TRUNCATED = 1, + JSON_STRING_MISSING = -1, + JSON_STRING_INVALID = -2, + JSON_STRING_TOO_LONG = -3 +}; + +/* Extract a direct member of one complete object in [obj_start, obj_end). + * Decode all JSON escapes/Unicode, validating the entire object (max depth 64). + * Duplicate requested keys, non-string values, invalid UTF-8, and decoded NUL + * in the requested value are invalid. Errors clear out when capacity is nonzero. + * Strict extraction never returns a partial identifier/URL/checksum. */ int json_extract_string(const char *obj_start, const char *obj_end, const char *key, char *out, size_t out_size); +/* Display-text variant: overflow returns TRUNCATED with a terminated prefix of + * complete UTF-8 scalars. Other results have the same contract as above. */ +int json_extract_string_prefix(const char *obj_start, const char *obj_end, + const char *key, char *out, size_t out_size); + +/* Validate one object starting at '{', returning its exclusive end. Never reads + * outside [start, limit); allows following array members after the object. */ +int json_find_object_end(const char *start, const char *limit, const char **end); + /* Read an entire file into a malloc'd buffer. Caller must free *out_buf. * Returns 0 on success, -1 on failure. */ int read_file_text(const char *path, char **out_buf, size_t *out_size); diff --git a/src/json_helpers.c b/src/json_helpers.c index 187d613..e42bfa8 100644 --- a/src/json_helpers.c +++ b/src/json_helpers.c @@ -3,6 +3,7 @@ #include #include #include +#include #include #include "json_helpers.h" @@ -32,52 +33,247 @@ int json_append(JsonListBuilder *jb, const char *fmt, ...) { return 0; } -int json_extract_string(const char *obj_start, const char *obj_end, - const char *key, char *out, size_t out_size) { - char key_pattern[96]; - const char *p; - const char *colon; - const char *q; - size_t pos = 0; +typedef struct { + const char *p, *end; +} JsonReader; - if (out_size == 0) { - return -1; - } - out[0] = '\0'; +typedef struct { + char *out; + size_t capacity, written; + int truncated; + const char *expected; + size_t expected_size, matched; + int equal; +} JsonStringSink; - snprintf(key_pattern, sizeof(key_pattern), "\"%s\"", key); - p = strstr(obj_start, key_pattern); - if (!p || p >= obj_end) { - return -1; - } +static void json_space(JsonReader *r) { + while (r->p < r->end && (*r->p == ' ' || *r->p == '\t' || + *r->p == '\r' || *r->p == '\n')) r->p++; +} - colon = strchr(p + strlen(key_pattern), ':'); - if (!colon || colon >= obj_end) { - return -1; - } - - q = colon + 1; - while (q < obj_end && isspace((unsigned char)*q)) { - q++; - } - if (q >= obj_end || *q != '"') { - return -1; - } - q++; - - while (q < obj_end && *q != '"') { - if (*q == '\\' && (q + 1) < obj_end) { - q++; - } - if (pos + 1 < out_size) { - out[pos++] = *q; - } - q++; - } - out[pos] = '\0'; +static int json_take(JsonReader *r, char c) { + if (r->p == r->end || *r->p != c) return -1; + r->p++; return 0; } +static int json_hex4(JsonReader *r, uint32_t *value) { + if ((size_t)(r->end - r->p) < 4) return -1; + *value = 0; + for (int i = 0; i < 4; i++) { + unsigned char c = (unsigned char)*r->p++; + unsigned digit; + if (c >= '0' && c <= '9') digit = c - '0'; + else if (c >= 'a' && c <= 'f') digit = c - 'a' + 10; + else if (c >= 'A' && c <= 'F') digit = c - 'A' + 10; + else return -1; + *value = (*value << 4) | digit; + } + return 0; +} + +static int json_emit(JsonStringSink *sink, const char *bytes, size_t size) { + if (!sink) return 0; + if (sink->expected && sink->equal) { + if (size > sink->expected_size - sink->matched || + memcmp(bytes, sink->expected + sink->matched, size) != 0) sink->equal = 0; + else sink->matched += size; + } + if (sink->out) { + if (memchr(bytes, 0, size)) return -1; /* C-string API cannot represent NUL. */ + if (!sink->truncated) { + if (size >= sink->capacity - sink->written) sink->truncated = 1; + else { + memcpy(sink->out + sink->written, bytes, size); + sink->written += size; + sink->out[sink->written] = '\0'; + } + } + } + return 0; +} + +static int json_string(JsonReader *r, JsonStringSink *sink) { + if (json_take(r, '"') != 0) return -1; + while (r->p < r->end) { + unsigned char c = (unsigned char)*r->p++; + char bytes[4]; + const char *part = bytes; + size_t size = 1; + if (c == '"') return 0; + if (c < 0x20) return -1; + if (c == '\\') { + if (r->p == r->end) return -1; + switch (*r->p++) { + case '"': bytes[0] = '"'; break; + case '\\': bytes[0] = '\\'; break; + case '/': bytes[0] = '/'; break; + case 'b': bytes[0] = '\b'; break; + case 'f': bytes[0] = '\f'; break; + case 'n': bytes[0] = '\n'; break; + case 'r': bytes[0] = '\r'; break; + case 't': bytes[0] = '\t'; break; + case 'u': { + uint32_t cp, low; + if (json_hex4(r, &cp) != 0) return -1; + if (cp >= 0xd800 && cp <= 0xdbff) { + if (json_take(r, '\\') != 0 || json_take(r, 'u') != 0 || + json_hex4(r, &low) != 0 || low < 0xdc00 || low > 0xdfff) return -1; + cp = 0x10000 + ((cp - 0xd800) << 10) + (low - 0xdc00); + } else if (cp >= 0xdc00 && cp <= 0xdfff) return -1; + if (cp < 0x80) bytes[0] = (char)cp; + else if (cp < 0x800) { + size = 2; + bytes[0] = (char)(0xc0 | (cp >> 6)); + bytes[1] = (char)(0x80 | (cp & 63)); + } else if (cp < 0x10000) { + size = 3; + bytes[0] = (char)(0xe0 | (cp >> 12)); + bytes[1] = (char)(0x80 | ((cp >> 6) & 63)); + bytes[2] = (char)(0x80 | (cp & 63)); + } else { + size = 4; + bytes[0] = (char)(0xf0 | (cp >> 18)); + bytes[1] = (char)(0x80 | ((cp >> 12) & 63)); + bytes[2] = (char)(0x80 | ((cp >> 6) & 63)); + bytes[3] = (char)(0x80 | (cp & 63)); + } + break; + } + default: return -1; + } + } else if (c < 0x80) bytes[0] = (char)c; + else { + part = r->p - 1; + if (c >= 0xc2 && c <= 0xdf) size = 2; + else if (c >= 0xe0 && c <= 0xef) size = 3; + else if (c >= 0xf0 && c <= 0xf4) size = 4; + else return -1; + if ((size_t)(r->end - part) < size) return -1; + for (size_t i = 1; i < size; i++) + if ((unsigned char)part[i] < 0x80 || (unsigned char)part[i] > 0xbf) return -1; + unsigned char second = (unsigned char)part[1]; + if ((c == 0xe0 && second < 0xa0) || (c == 0xed && second >= 0xa0) || + (c == 0xf0 && second < 0x90) || (c == 0xf4 && second > 0x8f)) return -1; + r->p = part + size; + } + if (json_emit(sink, part, size) != 0) return -1; + } + return -1; +} + +static int json_digit(JsonReader *r) { + return r->p < r->end && *r->p >= '0' && *r->p <= '9'; +} + +static int json_value(JsonReader *r, unsigned depth) { + json_space(r); + if (r->p == r->end) return -1; + char c = *r->p; + if (c == '"') return json_string(r, NULL); + if (c == '{' || c == '[') { + if (depth >= 64) return -1; + r->p++; + char close = c == '{' ? '}' : ']'; + json_space(r); + if (r->p < r->end && *r->p == close) { r->p++; return 0; } + for (;;) { + if (c == '{') { + if (json_string(r, NULL) != 0) return -1; + json_space(r); + if (json_take(r, ':') != 0) return -1; + } + if (json_value(r, depth + 1) != 0) return -1; + json_space(r); + if (r->p < r->end && *r->p == close) { r->p++; return 0; } + if (json_take(r, ',') != 0) return -1; + json_space(r); + } + } + if (c == 't' || c == 'f' || c == 'n') { + const char *literal = c == 't' ? "true" : c == 'f' ? "false" : "null"; + size_t size = strlen(literal); + if ((size_t)(r->end - r->p) < size || memcmp(r->p, literal, size) != 0) return -1; + r->p += size; + return 0; + } + if (c == '-') r->p++; + if (!json_digit(r)) return -1; + if (*r->p == '0') r->p++; + else while (json_digit(r)) r->p++; + if (r->p < r->end && *r->p == '.') { + r->p++; + if (!json_digit(r)) return -1; + while (json_digit(r)) r->p++; + } + if (r->p < r->end && (*r->p == 'e' || *r->p == 'E')) { + r->p++; + if (r->p < r->end && (*r->p == '+' || *r->p == '-')) r->p++; + if (!json_digit(r)) return -1; + while (json_digit(r)) r->p++; + } + return 0; +} + +int json_find_object_end(const char *start, const char *limit, const char **end) { + if (!end) return -1; + *end = NULL; + if (!start || !limit || start >= limit || *start != '{') return -1; + JsonReader r = { start, limit }; + if (json_value(&r, 0) != 0) return -1; + *end = r.p; + return 0; +} + +static int json_extract(const char *start, const char *end, const char *key, + char *out, size_t capacity, int prefix) { + if (!out || !capacity) return JSON_STRING_INVALID; + out[0] = '\0'; + if (!start || !end || start > end || !key) return JSON_STRING_INVALID; + JsonReader r = { start, end }; + JsonStringSink value = { .out = out, .capacity = capacity }; + int found = 0; + json_space(&r); + if (json_take(&r, '{') != 0) goto invalid; + json_space(&r); + if (r.p < r.end && *r.p == '}') r.p++; + else for (;;) { + JsonStringSink member = { .expected = key, .expected_size = strlen(key), .equal = 1 }; + if (json_string(&r, &member) != 0) goto invalid; + json_space(&r); + if (json_take(&r, ':') != 0) goto invalid; + json_space(&r); + if (member.equal && member.matched == member.expected_size) { + if (found || json_string(&r, &value) != 0) goto invalid; + found = 1; + } else if (json_value(&r, 1) != 0) goto invalid; + json_space(&r); + if (r.p < r.end && *r.p == '}') { r.p++; break; } + if (json_take(&r, ',') != 0) goto invalid; + json_space(&r); + } + json_space(&r); + if (r.p != r.end) goto invalid; + if (!found) return JSON_STRING_MISSING; + if (!value.truncated) return JSON_STRING_OK; + if (prefix) return JSON_STRING_TRUNCATED; + out[0] = '\0'; + return JSON_STRING_TOO_LONG; +invalid: + out[0] = '\0'; + return JSON_STRING_INVALID; +} + +int json_extract_string(const char *start, const char *end, const char *key, + char *out, size_t capacity) { + return json_extract(start, end, key, out, capacity, 0); +} + +int json_extract_string_prefix(const char *start, const char *end, const char *key, + char *out, size_t capacity) { + return json_extract(start, end, key, out, capacity, 1); +} + int read_file_text(const char *path, char **out_buf, size_t *out_size) { FILE *f; long fsize; diff --git a/src/repository.c b/src/repository.c index 6768d65..b58ef1d 100644 --- a/src/repository.c +++ b/src/repository.c @@ -194,41 +194,52 @@ int parse_repository_payloads(const char *json, RepoPayload **out_items, size_t } memset(item, 0, sizeof(*item)); - if (json_extract_string(p, end, "name", item->name, sizeof(item->name)) != 0 || - json_extract_string(p, end, "filename", item->filename, sizeof(item->filename)) != 0 || - json_extract_string(p, end, "url", item->url, sizeof(item->url)) != 0) { + if (json_extract_string(p, end + 1, "name", item->name, sizeof(item->name)) != 0 || + json_extract_string(p, end + 1, "filename", item->filename, sizeof(item->filename)) != 0 || + json_extract_string(p, end + 1, "url", item->url, sizeof(item->url)) != 0) { free(item); p = end + 1; continue; } /* Extract id or fallback to slug(name) */ - json_extract_string(p, end, "id", item->id, sizeof(item->id)); + json_extract_string(p, end + 1, "id", item->id, sizeof(item->id)); if (item->id[0] == '\0') { slot_slug(item->name, item->id, sizeof(item->id)); } - json_extract_string(p, end, "source", item->source, sizeof(item->source)); - json_extract_string(p, end, "source_direct", item->source_direct, sizeof(item->source_direct)); - json_extract_string(p, end, "description", item->description, sizeof(item->description)); - json_extract_string(p, end, "last_update", item->last_update, sizeof(item->last_update)); - json_extract_string(p, end, "version", item->version, sizeof(item->version)); - json_extract_string(p, end, "checksum", item->checksum, sizeof(item->checksum)); - json_extract_string(p, end, "author", item->author, sizeof(item->author)); - json_extract_string(p, end, "details_url", item->details_url, sizeof(item->details_url)); + json_extract_string(p, end + 1, "source", item->source, sizeof(item->source)); + json_extract_string(p, end + 1, "source_direct", item->source_direct, sizeof(item->source_direct)); + json_extract_string_prefix(p, end + 1, "description", item->description, sizeof(item->description)); + json_extract_string(p, end + 1, "last_update", item->last_update, sizeof(item->last_update)); + json_extract_string(p, end + 1, "version", item->version, sizeof(item->version)); + int checksum_result = json_extract_string(p, end + 1, "checksum", item->checksum, sizeof(item->checksum)); + /* A supplied oversized/non-string hash must not turn into an absent, + * optional checksum after extraction fails. Hex validation is separate. */ + if (checksum_result != JSON_STRING_OK && checksum_result != JSON_STRING_MISSING) { + free(item); + p = end + 1; + continue; + } + json_extract_string(p, end + 1, "author", item->author, sizeof(item->author)); + json_extract_string(p, end + 1, "details_url", item->details_url, sizeof(item->details_url)); - json_extract_string(p, end, "release_notes", item->release_notes, sizeof(item->release_notes)); - if (strlen(item->release_notes) == sizeof(item->release_notes) - 1) { - strcpy(item->release_notes + sizeof(item->release_notes) - 4, "..."); + int notes_result = json_extract_string_prefix(p, end + 1, "release_notes", + item->release_notes, sizeof(item->release_notes)); + if (notes_result == JSON_STRING_TRUNCATED) { + size_t length = strlen(item->release_notes); + if (length > sizeof(item->release_notes) - 4) length = sizeof(item->release_notes) - 4; + while (length && ((unsigned char)item->release_notes[length] & 0xc0) == 0x80) length--; + strcpy(item->release_notes + length, "..."); } - json_extract_string(p, end, "icon", item->icon, sizeof(item->icon)); + json_extract_string(p, end + 1, "icon", item->icon, sizeof(item->icon)); /* Only allow inline data URIs (e.g. data:image/svg+xml;base64,... or data:image/png;base64,...); reject external URLs */ if (strncmp(item->icon, "data:image/", 11) != 0 || strlen(item->icon) > 12288) { item->icon[0] = '\0'; } - if (json_extract_string(p, end, "category", item->category, sizeof(item->category)) != 0 || + if (json_extract_string(p, end + 1, "category", item->category, sizeof(item->category)) != 0 || strlen(item->category) == 0) { strncpy(item->category, "Uncategorized", sizeof(item->category) - 1); } diff --git a/src/slot.c b/src/slot.c index 699828d..1e862d2 100644 --- a/src/slot.c +++ b/src/slot.c @@ -231,7 +231,7 @@ int slot_read_meta(const char *slot_dir, SlotMeta *out_meta) { json_extract_string(json, end, "source", out_meta->source, sizeof(out_meta->source)); json_extract_string(json, end, "source_direct", out_meta->source_direct, sizeof(out_meta->source_direct)); json_extract_string(json, end, "category", out_meta->category, sizeof(out_meta->category)); - json_extract_string(json, end, "description", out_meta->description, sizeof(out_meta->description)); + json_extract_string_prefix(json, end, "description", out_meta->description, sizeof(out_meta->description)); json_extract_string(json, end, "author", out_meta->author, sizeof(out_meta->author)); json_extract_string(json, end, "details_url", out_meta->details_url, sizeof(out_meta->details_url)); diff --git a/src/sources.c b/src/sources.c index b735d5f..87bc7ff 100644 --- a/src/sources.c +++ b/src/sources.c @@ -55,9 +55,10 @@ static int load_sources(SourceEntry *out, int *out_count) { if (!arr_start) { free(json); *out_count = count; return 0; } const char *p = arr_start + 1; - while (count < MAX_SOURCES && (p = strchr(p, '{')) != NULL) { - const char *end = strchr(p, '}'); - if (!end) break; + while (count < MAX_SOURCES && p < json + size && + (p = memchr(p, '{', (size_t)(json + size - p))) != NULL) { + const char *end; + if (json_find_object_end(p, json + size, &end) != 0) break; char id[64] = "", name[256] = "", url[1024] = ""; char removable_str[8] = ""; @@ -67,7 +68,7 @@ static int load_sources(SourceEntry *out, int *out_count) { json_extract_string(p, end, "url", url, sizeof(url)); json_extract_string(p, end, "removable", removable_str, sizeof(removable_str)); - if (strcmp(id, "default") == 0) { p = end + 1; continue; } + if (strcmp(id, "default") == 0) { p = end; continue; } if (name[0] && url[0] && id[0]) { strncpy(out[count].id, id, sizeof(out[count].id) - 1); @@ -76,7 +77,7 @@ static int load_sources(SourceEntry *out, int *out_count) { out[count].removable = (strcmp(removable_str, "false") != 0); count++; } - p = end + 1; + p = end; } free(json); @@ -160,24 +161,25 @@ int sources_save(const char *json, size_t len) { if (!arr_start) { free(sources); return -1; } const char *p = arr_start + 1; - while (count < MAX_SOURCES && (p = strchr(p, '{')) != NULL) { - const char *end = strchr(p, '}'); - if (!end) break; + while (count < MAX_SOURCES && p < json + len && + (p = memchr(p, '{', (size_t)(json + len - p))) != NULL) { + const char *end; + if (json_find_object_end(p, json + len, &end) != 0) break; char id[64] = "", name[256] = "", url[1024] = ""; json_extract_string(p, end, "id", id, sizeof(id)); json_extract_string(p, end, "name", name, sizeof(name)); json_extract_string(p, end, "url", url, sizeof(url)); - if (strcmp(id, "default") == 0) { p = end + 1; continue; } - if (!name[0] || !url[0] || !id[0]) { p = end + 1; continue; } + if (strcmp(id, "default") == 0) { p = end; continue; } + if (!name[0] || !url[0] || !id[0]) { p = end; continue; } strncpy(sources[count].id, id, sizeof(sources[count].id) - 1); strncpy(sources[count].name, name, sizeof(sources[count].name) - 1); strncpy(sources[count].url, url, sizeof(sources[count].url) - 1); sources[count].removable = 1; count++; - p = end + 1; + p = end; } int ret = save_sources(sources, count); diff --git a/tests/Dockerfile b/tests/Dockerfile index 7387797..9835259 100644 --- a/tests/Dockerfile +++ b/tests/Dockerfile @@ -1,7 +1,7 @@ FROM ubuntu:24.04 RUN apt-get update && apt-get install -y --no-install-recommends \ - clang libclang-rt-18-dev make libc6-dev curl pkg-config python3 ca-certificates \ + clang libclang-rt-18-dev make libc6-dev curl libcurl4-openssl-dev pkg-config python3 ca-certificates \ && rm -rf /var/lib/apt/lists/* # Match the library shipped by build_deps.sh rather than Ubuntu's older MHD. diff --git a/tests/json_backend_driver.c b/tests/json_backend_driver.c new file mode 100644 index 0000000..8fa6742 --- /dev/null +++ b/tests/json_backend_driver.c @@ -0,0 +1,65 @@ +#include +#include +#include +#include +#include "repository.h" +#include "slot.h" +#include "sources.h" +#include "json_helpers.h" +#include "utils.h" + +static void string(const char *value) { + size_t size = 6 * strlen(value) + 1; + char *escaped = malloc(size); + if (!escaped || pldmgr_json_escape(value, escaped, size) != 0) exit(2); + printf("\"%s\"", escaped); + free(escaped); +} + +int main(int argc, char **argv) { + if (argc != 3) return 2; + char *input = malloc(65536); + if (!input) return 2; + size_t size = fread(input, 1, 65535, stdin); + input[size] = 0; + if (argv[1][0] == 'R') { + RepoPayload *items; + size_t count; + int result = parse_repository_payloads(input, &items, &count); + printf("{\"status\":%d,\"count\":%zu,\"items\":[", result, count); + for (size_t i = 0; i < count; i++) { + if (i) putchar(','); + printf("{\"name\":"); string(items[i].name); + printf(",\"filename\":"); string(items[i].filename); + printf(",\"url\":"); string(items[i].url); + printf(",\"description\":"); string(items[i].description); + printf(",\"release_notes\":"); string(items[i].release_notes); + printf(",\"checksum\":"); string(items[i].checksum); + putchar('}'); + } + puts("]}"); + free(items); + } else if (argv[1][0] == 'S') { + if (sources_save(input, size) != 0) return 3; + char *output = malloc(65536); + if (!output) return 2; + int result = sources_list_json(output, 65536); + if (result != 0) { free(output); free(input); return 4; } + fwrite(output, 1, strlen(output), stdout); + free(output); + } else if (argv[1][0] == 'M') { + if (mkdir(argv[2], 0700) != 0) return 3; + char path[1024]; + snprintf(path, sizeof(path), "%s/meta.json", argv[2]); + if (write_file_text(path, input, size) != 0) return 3; + SlotMeta meta; + if (slot_read_meta(argv[2], &meta) != 0 || slot_write_meta(argv[2], &meta) != 0) return 4; + char *output; + size_t written; + if (read_file_text(path, &output, &written) != 0) return 5; + fwrite(output, 1, written, stdout); + free(output); + } else return 2; + free(input); + return 0; +} diff --git a/tests/json_decode_driver.c b/tests/json_decode_driver.c new file mode 100644 index 0000000..50043ed --- /dev/null +++ b/tests/json_decode_driver.c @@ -0,0 +1,61 @@ +#include +#include +#include +#include "json_helpers.h" + +static char *unhex(const char *hex, size_t *size, int terminated) { + *size = strcmp(hex, "-") == 0 ? 0 : strlen(hex) / 2; + char *out = malloc(*size + (terminated || !*size)); + if (!out) exit(2); + for (size_t i = 0; i < *size; i++) { + unsigned byte; + if (sscanf(hex + 2 * i, "%2x", &byte) != 1) exit(2); + out[i] = (char)byte; + } + if (terminated) out[*size] = 0; + return out; +} + +int main(void) { + char mode, input_hex[32769], key_hex[8193]; + size_t capacity, bound; + while (scanf(" %c %zu %zu %32768s %8192s", &mode, &capacity, &bound, + input_hex, key_hex) == 5) { + size_t input_size, key_size; +#ifdef LEGACY_DECODE + char *input = unhex(input_hex, &input_size, 1); +#else + /* Exact allocation: bounded input deliberately has no trailing NUL. */ + char *input = unhex(input_hex, &input_size, 0); +#endif + char *key = unhex(key_hex, &key_size, 1); + unsigned char output[4098]; + if (capacity > 4096 || bound > input_size) return 2; + memset(output, 0xa5, sizeof(output)); + int result; +#ifndef LEGACY_DECODE + if (mode == 'P') result = json_extract_string_prefix(input, input + bound, key, + (char *)output + 1, capacity); + else if (mode == 'E') { + const char *end; + result = json_find_object_end(input, input + bound, &end); + printf("%d %zu\n", result, end ? (size_t)(end - input) : 0); + free(input); free(key); + continue; + } else +#endif + result = json_extract_string(input, input + bound, key, (char *)output + 1, capacity); + if (output[0] != 0xa5) return 3; + for (size_t i = capacity + 1; i < sizeof(output); i++) + if (output[i] != 0xa5) return 3; + if (capacity && !memchr(output + 1, 0, capacity)) return 4; + printf("%d ", result); + if (capacity) { + size_t size = strlen((char *)output + 1); + for (size_t i = 0; i < size; i++) printf("%02x", output[i + 1]); + } + puts(""); + free(input); free(key); + } + return ferror(stdin) ? 5 : 0; +} diff --git a/tests/run_json_backend_tests.py b/tests/run_json_backend_tests.py new file mode 100644 index 0000000..bcb9cb0 --- /dev/null +++ b/tests/run_json_backend_tests.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Actual repository parsing, source save/load, and slot metadata round trips.""" +import argparse +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--no-sanitize", action="store_true") + args = parser.parse_args() + os.environ.setdefault("ASAN_OPTIONS", "detect_leaks=1:halt_on_error=1") + os.environ.setdefault("UBSAN_OPTIONS", "halt_on_error=1:print_stacktrace=1") + compiler = shlex.split(os.environ.get("HOST_CC", "clang")) + with tempfile.TemporaryDirectory(prefix="pldmgr-json-backend-") as temp: + build = Path(temp) + storage = build / "data" + storage.mkdir() + (build / "pldmgr.h").write_text((ROOT / "include/pldmgr.h").read_text().replace("/data/pldmgr", str(storage))) + (build / "assets_cacert_pem.h").write_text("static const unsigned char assets_cacert_pem[] = {0};\n" + "static const unsigned int assets_cacert_pem_len = 0;\n") + binary = build / "json_backend_tests" + command = compiler + ["-std=c11", "-D_DEFAULT_SOURCE", "-g", "-O1", "-Wall", "-Wextra", + "-ffunction-sections", "-fdata-sections", + "-Wl,-dead_strip" if sys.platform == "darwin" else "-Wl,--gc-sections", + "-I" + str(build), "-Iinclude", "-pthread"] + if not args.no_sanitize: + command += ["-fsanitize=address,undefined", "-fno-omit-frame-pointer"] + subprocess.run(command + ["src/json_helpers.c", "src/utils.c", "src/repository.c", + "src/sources.c", "src/slot.c", "tests/json_backend_driver.c", + "-o", str(binary)], cwd=ROOT, check=True) + checks = 0 + + def run(mode, document): + nonlocal checks + checks += 1 + slot = build / f"slot-{checks}" + result = subprocess.run([str(binary), mode, str(slot)], input=json.dumps(document).encode(), + capture_output=True) + if result.returncode: + raise AssertionError(result.stderr.decode() or f"Backend driver failed: {result.returncode}") + return json.loads(result.stdout) + + value = 'Zażółć Україна 日本語 😀 } { "quote" \\path\nnext\tline\x01' + item = {"id": "test", "name": value, "filename": "safe.elf", + "url": "https://example.invalid/safe.elf", "description": value, + "release_notes": value, "checksum": "a" * 64} + for manifest in [[item], {"payloads": [item]}, item]: + actual = run("R", manifest) + assert actual["status"] == 0 and actual["count"] == 1, actual + for field in ["name", "filename", "url", "description", "release_notes", "checksum"]: + assert actual["items"][0][field] == item[field], (field, actual) + exact = dict(item, release_notes="a" * 2047) + assert run("R", [exact])["items"][0]["release_notes"] == exact["release_notes"] + for notes in ["a" * 3000, "日" * 1000, "a" * 2043 + "😀" * 50]: + actual = run("R", [dict(item, release_notes=notes)])["items"][0]["release_notes"] + assert actual.endswith("...") and len(actual.encode()) <= 2047, actual + assert notes.startswith(actual[:-3]), actual + actual = run("R", [dict(item, description="😀" * 1000)])["items"][0]["description"] + assert actual == "😀" * 255 + # Strict fields must not become valid-looking shortened identifiers/hashes. + for field, content in [("name", "a" * 128), ("filename", "a" * 256), + ("url", "a" * 1024), ("checksum", "a" * 65), ("checksum", None)]: + assert run("R", [dict(item, **{field: content})])["count"] == 0 + assert run("R", [dict(item, checksum="")])["count"] == 1 + nested = {"nested": item, "filename": "safe.elf", "url": item["url"]} + assert run("R", [nested])["count"] == 0 + source = {"id": "custom", "name": value, "url": "https://example.invalid/source.json", "removable": True} + actual = run("S", {"sources": [source]}) + assert actual["sources"][1] == source, actual + meta = {"schema": 2, "kind": "repo", "filename": "safe.elf", "installed_at": "2026-10-12", + "id": "test", "source_url": source["url"], "source_name": value, + "name": value, "version": "1.0", "checksum": "a" * 64, + "url": item["url"], "description": value, "author": value} + actual = run("M", meta) + for field, content in meta.items(): + assert actual[field] == content, (field, actual) + local = {"schema": 2, "kind": "local", "filename": "safe.elf", "origin": "web_upload", + "origin_detail": value, "installed_at": "2026-10-12"} + actual = run("M", local) + for field, content in local.items(): + assert actual[field] == content, (field, actual) + print(f"JSON backend: {checks} real repository/source/metadata scenarios passed") + + +if __name__ == "__main__": + main() diff --git a/tests/run_json_decode_tests.py b/tests/run_json_decode_tests.py new file mode 100644 index 0000000..baa3ae2 --- /dev/null +++ b/tests/run_json_decode_tests.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Bounded production JSON decoding, with Python supplying expected UTF-8.""" +import argparse +import json +import os +from pathlib import Path +import random +import shlex +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--source", default="src/json_helpers.c") + parser.add_argument("--legacy-api", action="store_true") + parser.add_argument("--no-sanitize", action="store_true") + args = parser.parse_args() + os.environ.setdefault("ASAN_OPTIONS", "detect_leaks=1:halt_on_error=1") + os.environ.setdefault("UBSAN_OPTIONS", "halt_on_error=1:print_stacktrace=1") + cases = [] + + def add(document, key="value", value=None, error=None, capacities=(4096,), bound=None): + if isinstance(document, str): + document = document.encode() + if bound is None: + bound = len(document) + for capacity in capacities: + for mode in ("S", "P"): + if error is not None: + expected_status, expected = error, b"" + else: + data = value.encode() + prefix = b"" + for char in value: + scalar = char.encode() + if len(prefix) + len(scalar) >= capacity: + break + prefix += scalar + if not capacity: + expected_status, expected = -2, b"" + elif prefix == data: + expected_status, expected = 0, data + elif mode == "S": + expected_status, expected = -3, b"" + else: + expected_status, expected = 1, prefix + cases.append((mode, capacity, bound, document, key, expected_status, expected)) + + values = ["", 'quote" backslash\\ slash/', "\b\f\n\r\t", "".join(map(chr, range(1, 32))), + "Zażółć Україна 日本語 😀", "¢€😀", "\u0080\u07ff\u0800\ud7ff\ue000\uffff\U0010ffff"] + for value in values: + for ascii_only in (True, False): + add(json.dumps({"value": value}, ensure_ascii=ascii_only), value=value) + document = json.dumps({"nested": {"value": "wrong"}, "value": value, + "tail": [True, False, None, -1.25e3, {"x": ":{}[]"}]}, + ensure_ascii=ascii_only) + add(document, value=value, capacities=range(len(value.encode()) + 3)) + add('{"\\u0076alue":"escaped key"}', value="escaped key") + for key in ['quo"te\\日本語', 'k' * 150, '']: + add(json.dumps({key: "correct"}), key=key, value="correct") + add('{"x":"value","value":"correct"}', value="correct") + add('{"nested":{"value":"wrong"}}', error=-1) + add('{"value2":"wrong"}', error=-1) + add('{}', error=-1) + add('{"value":"first","value":"second"}', error=-2) + add('{"value":123}', error=-2) + add('{"value":null}', error=-2) + add('{"value":"ok","unrelated":"\\u0000"}', value="ok") + add('{"value":"\\u0000"}', error=-2) + malformed = [b'{"value":"unterminated}', b'{"value":"x\\"}', + b'{"value":"\\q"}', b'{"value":"\\u123"}', b'{"value":"\\uxxxx"}', + b'{"value":"\\ud800"}', b'{"value":"\\udc00"}', + b'{"value":"\\ud800\\u0041"}', b'{"value":"\x00"}', b'{"value":"\x01"}', + b'{"value":"\xc0\x80"}', b'{"value":"\xed\xa0\x80"}', + b'{"value":"\xf4\x90\x80\x80"}', b'{"value":"\xe2\x82"}', + b'{"value":"ok",}', b'{"value":"ok"}junk', b'{"value":"ok","x":01}', + b'{"value":"ok","x":1.}', b'{"value":"ok","x":1e}', + b'{"value":"ok","x":tru}', b'{"value":"ok","x":[1,]}', + b'{"value":"ok","x":{"k":"\\q"}}', b'[]', b'', b'\v{"value":"ok"}'] + for document in malformed: + add(document, error=-2, capacities=(1, 4096)) + # End can cut at every byte, including inside Unicode escapes/scalars. + document = '{"value":"😀\\n\\ud83d\\ude00"}'.encode() + for bound in range(len(document)): + add(document, error=-2, bound=bound) + add(b'{"value":"ok"} trailing bytes', value="ok", bound=len(b'{"value":"ok"}')) + # A missing key must not be found in data after the supplied bound. + add(b'{} {"value":"outside"}', error=-1, bound=2) + add('{"value":"ok","x":' + '[' * 63 + '0' + ']' * 63 + '}', value="ok") + add('{"value":"ok","x":' + '[' * 64 + '0' + ']' * 64 + '}', error=-2) + rng = random.Random(20261012) + alphabet = ['a', '"', '\\', '\n', '\t', '\x01', 'ż', 'Я', '日', '😀'] + for _ in range(500): + value = "".join(rng.choice(alphabet) for _ in range(rng.randrange(60))) + document = json.dumps({"value": value, "nested": ["value", {"value": "wrong"}]}, + ensure_ascii=rng.choice([True, False])) + add(document, value=json.loads(document)["value"], capacities=(rng.randrange(200),)) + # Validate object framing independently of subsequent array members. + if not args.legacy_api: + documents = [(b'{"value":"} { \\\"", "nested":{"x":[]}}, {"next":true}', 0), + (b'{"x":[1,{"z":"}"}]}]', 0), (b'{"x":[1,]}', -1)] + for document, status in documents: + if status == 0: + _, character_end = json.JSONDecoder().raw_decode(document.decode()) + end = len(document.decode()[:character_end].encode()) + else: + end = 0 + cases.append(("E", 4096, len(document), document, "", status, str(end).encode())) + if status == 0: + for bound in range(end): + cases.append(("E", 4096, bound, document, "", -1, b"0")) + compiler = shlex.split(os.environ.get("HOST_CC", "clang")) + with tempfile.TemporaryDirectory(prefix="pldmgr-json-decode-") as temp: + binary = Path(temp) / "json_decode_tests" + command = compiler + ["-std=c11", "-D_DEFAULT_SOURCE", "-g", "-O1", "-Wall", "-Wextra", "-Iinclude"] + if not args.no_sanitize: + command += ["-fsanitize=address,undefined", "-fno-omit-frame-pointer"] + if args.legacy_api: + command += ["-DLEGACY_DECODE"] + subprocess.run(command + [args.source, "tests/json_decode_driver.c", "-o", str(binary)], + cwd=ROOT, check=True) + protocol = "".join(f"{mode} {capacity} {bound} {doc.hex() or '-'} {key.encode().hex() or '-'}\n" + for mode, capacity, bound, doc, key, _, _ in cases) + process = subprocess.run([str(binary)], input=protocol, text=True, capture_output=True) + if process.returncode: + raise SystemExit(process.stderr or f"Decoder driver failed: {process.returncode}") + output = process.stdout.splitlines() + assert len(output) == len(cases) + failures = [] + for case, line in zip(cases, output): + mode, capacity, bound, doc, key, expected_status, expected = case + status, result = line.split(" ", 1) + actual = result.encode() if mode == "E" else bytes.fromhex(result) + # Old status codes differ; compare content and success/error semantics. + status_ok = int(status) == expected_status if not args.legacy_api else ((int(status) == 0) == (expected_status >= 0)) + if not status_ok or actual != expected: + failures.append((doc[:120], mode, capacity, bound, int(status), actual, expected_status, expected)) + if failures: + for failure in failures[:8]: + print("FAIL document/mode/capacity/bound/status/output/expected:", failure) + raise SystemExit(f"JSON decoder: {len(failures)} failures / {len(cases)} cases") + print(f"JSON decoder: {len(cases)} bounded decoding/selection checks passed") + + +if __name__ == "__main__": + main()