fix: bound JSON extraction and decode Unicode escapes

This commit is contained in:
najdek committed 2026-10-12 18:48:23 +02:00
1 parent 0d39bd5cf4
commit 3c42e3cf0e
13 files changed
+690 -83

No files matched your search

+2 -4
View File
@@ -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
+16 -7
View File
@@ -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
+2
View File
@@ -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:
+22 -2
View File
@@ -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);
+236 -40
View File
@@ -3,6 +3,7 @@
#include <string.h>
#include <stdarg.h>
#include <ctype.h>
#include <stdint.h>
#include <sys/stat.h>
#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;
+28 -17
View File
@@ -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);
}
+1 -1
View File
@@ -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));
+13 -11
View File
@@ -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);
+1 -1
View File
@@ -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.
+65
View File
@@ -0,0 +1,65 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#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;
}
+61
View File
@@ -0,0 +1,61 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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;
}
+94
View File
@@ -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()
+149
View File
@@ -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()