From 482ffb1008bd98c281d6190cc0109fd647922bbe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 00:42:53 +0000 Subject: [PATCH] Add intensive parser unit tester and LLVM fuzzer for tinygltf_json.h backend Co-authored-by: syoyo <18676+syoyo@users.noreply.github.com> --- .gitignore | 2 + CMakeLists.txt | 9 + tests/Makefile | 1 + tests/fuzzer/README.md | 11 + tests/fuzzer/fuzz_gltf_customjson.cc | 76 ++ tests/fuzzer/meson.build | 6 + tests/tester_intensive_customjson.cc | 1079 ++++++++++++++++++++++++++ 7 files changed, 1184 insertions(+) create mode 100644 tests/fuzzer/fuzz_gltf_customjson.cc create mode 100644 tests/tester_intensive_customjson.cc diff --git a/.gitignore b/.gitignore index 78f3074..15d073a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # CMake +build/ CMakeCache.txt CMakeFiles CMakeScripts @@ -68,6 +69,7 @@ imgui.ini loader_example tests/tester tests/tester_noexcept +tests/tester_intensive_customjson tests/issue-97.gltf tests/issue-261.gltf tests/issue-495-external.gltf diff --git a/CMakeLists.txt b/CMakeLists.txt index b6f51e8..cfcf403 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -56,6 +56,15 @@ if (TINYGLTF_BUILD_TESTS) ) target_compile_definitions(tester_customjson PRIVATE TINYGLTF_USE_CUSTOM_JSON) add_test(NAME tester_customjson COMMAND tester_customjson WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/tests) + + # Intensive parser tests for the custom JSON backend + add_executable(tester_intensive_customjson tests/tester_intensive_customjson.cc) + target_include_directories(tester_intensive_customjson PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/tests + ) + target_compile_definitions(tester_intensive_customjson PRIVATE TINYGLTF_USE_CUSTOM_JSON) + add_test(NAME tester_intensive_customjson COMMAND tester_intensive_customjson WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/tests) endif (TINYGLTF_BUILD_TESTS) # diff --git a/tests/Makefile b/tests/Makefile index c90e909..a6c15f1 100644 --- a/tests/Makefile +++ b/tests/Makefile @@ -4,3 +4,4 @@ all: ../tiny_gltf.h clang++ -I../ $(EXTRA_CXXFLAGS) -std=c++11 -g -O0 -o tester tester.cc clang++ -DTINYGLTF_NOEXCEPTION -I../ $(EXTRA_CXXFLAGS) -std=c++11 -g -O0 -o tester_noexcept tester.cc + clang++ -DTINYGLTF_USE_CUSTOM_JSON -I../ $(EXTRA_CXXFLAGS) -std=c++11 -g -O0 -o tester_intensive_customjson tester_intensive_customjson.cc diff --git a/tests/fuzzer/README.md b/tests/fuzzer/README.md index b7c5223..c19d223 100644 --- a/tests/fuzzer/README.md +++ b/tests/fuzzer/README.md @@ -7,6 +7,11 @@ Do fuzzing test for TinyGLTF API. * [x] LoadASCIIFromMemory * [ ] LoadBinaryFromMemory +### Custom JSON backend (`tinygltf_json.h`) + +* [x] LoadASCIIFromMemory +* [x] LoadBinaryFromMemory + ## Requirements * meson @@ -36,11 +41,17 @@ $ cd build $ ninja ``` +This builds two fuzzers: + +* `fuzz_gltf` – default nlohmann/json backend +* `fuzz_gltf_customjson` – custom `tinygltf_json.h` backend (tests both ASCII and binary parsing paths) + ## How to run Increase memory limit. e.g. `-rss_limit_mb=50000` ``` $ ./fuzz_gltf -rss_limit_mb=20000 -jobs 4 +$ ./fuzz_gltf_customjson -rss_limit_mb=20000 -jobs 4 ``` diff --git a/tests/fuzzer/fuzz_gltf_customjson.cc b/tests/fuzzer/fuzz_gltf_customjson.cc new file mode 100644 index 0000000..cdab09e --- /dev/null +++ b/tests/fuzzer/fuzz_gltf_customjson.cc @@ -0,0 +1,76 @@ +/* + * LLVM libFuzzer harness for tinygltf with the custom JSON backend + * (tinygltf_json.h). + * + * Exercises: + * 1. LoadASCIIFromString – glTF JSON parsing + * 2. LoadBinaryFromMemory – GLB binary parsing + * + * Build (clang with libFuzzer): + * clang++ -std=c++11 -fsanitize=address,fuzzer \ + * -DTINYGLTF_USE_CUSTOM_JSON \ + * -I../../ fuzz_gltf_customjson.cc \ + * -o fuzz_gltf_customjson + * + * Run: + * ./fuzz_gltf_customjson -rss_limit_mb=20000 -jobs 4 + */ + +#include +#include +#include +#include +#include + +#define STB_IMAGE_IMPLEMENTATION +#define STB_IMAGE_WRITE_IMPLEMENTATION +#define TINYGLTF_IMPLEMENTATION +#ifndef TINYGLTF_USE_CUSTOM_JSON +#define TINYGLTF_USE_CUSTOM_JSON +#endif +#include "tiny_gltf.h" + +/* Fuzz the ASCII (JSON) parser path */ +static void fuzz_ascii(const uint8_t *data, size_t size) { + tinygltf::Model model; + tinygltf::TinyGLTF ctx; + std::string err; + std::string warn; + + const char *str = reinterpret_cast(data); + + bool ret = + ctx.LoadASCIIFromString(&model, &err, &warn, str, + static_cast(size), /* base_dir */ ""); + (void)ret; +} + +/* Fuzz the binary (GLB) parser path */ +static void fuzz_binary(const uint8_t *data, size_t size) { + tinygltf::Model model; + tinygltf::TinyGLTF ctx; + std::string err; + std::string warn; + + bool ret = ctx.LoadBinaryFromMemory(&model, &err, &warn, data, + static_cast(size), + /* base_dir */ ""); + (void)ret; +} + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { + if (size == 0) return 0; + + /* Use the first byte to select the parse path, pass the rest as input. */ + uint8_t selector = data[0]; + const uint8_t *payload = data + 1; + size_t payload_size = size - 1; + + if (selector & 1) { + fuzz_binary(payload, payload_size); + } else { + fuzz_ascii(payload, payload_size); + } + + return 0; +} diff --git a/tests/fuzzer/meson.build b/tests/fuzzer/meson.build index c69ed13..15c915d 100644 --- a/tests/fuzzer/meson.build +++ b/tests/fuzzer/meson.build @@ -7,3 +7,9 @@ executable('fuzz_gltf', cpp_args : '-fsanitize=address,fuzzer', link_args : '-fsanitize=address,fuzzer' ) +executable('fuzz_gltf_customjson', + 'fuzz_gltf_customjson.cc', + include_directories : incdirs, + cpp_args : ['-fsanitize=address,fuzzer', '-DTINYGLTF_USE_CUSTOM_JSON'], + link_args : '-fsanitize=address,fuzzer' ) + diff --git a/tests/tester_intensive_customjson.cc b/tests/tester_intensive_customjson.cc new file mode 100644 index 0000000..d681b7e --- /dev/null +++ b/tests/tester_intensive_customjson.cc @@ -0,0 +1,1079 @@ +/* + * Intensive parser unit tests for tinygltf with the custom JSON backend + * (tinygltf_json.h). + * + * These tests exercise the custom JSON parser and glTF loader with a wide + * range of edge-case inputs: deeply nested structures, malformed JSON, + * truncated inputs, unicode escapes, number edge cases, and round-trip + * serialisation/deserialisation. + * + * Build: + * c++ -std=c++11 -DTINYGLTF_USE_CUSTOM_JSON -I.. -I. -g -O0 \ + * -o tester_intensive_customjson tester_intensive_customjson.cc + */ + +#define TINYGLTF_IMPLEMENTATION +#define STB_IMAGE_IMPLEMENTATION +#define STB_IMAGE_WRITE_IMPLEMENTATION +#ifndef TINYGLTF_USE_CUSTOM_JSON +#define TINYGLTF_USE_CUSTOM_JSON +#endif +#include "tiny_gltf.h" + +#define CATCH_CONFIG_MAIN +#include "catch.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* ---------- helpers --------------------------------------------------------*/ + +/* Convenience: parse JSON string through the custom backend. */ +static tinygltf::detail::JsonDocument JsonConstruct(const char *str) { + tinygltf::detail::JsonDocument doc; + tinygltf::detail::JsonParse(doc, str, strlen(str)); + return doc; +} + +/* Helper: access the i-th element of an array-type json node. */ +static tinygltf::detail::json &JsonArrayAt(tinygltf::detail::json &arr, + size_t idx) { + auto it = arr.begin(); + for (size_t i = 0; i < idx; ++i) ++it; + return *it; +} + +/* Load a glTF JSON string through TinyGLTF and return success. */ +static bool LoadGltfFromString(const char *json, + tinygltf::Model *model = nullptr, + std::string *err = nullptr, + std::string *warn = nullptr) { + tinygltf::Model tmp_model; + std::string tmp_err, tmp_warn; + if (!model) model = &tmp_model; + if (!err) err = &tmp_err; + if (!warn) warn = &tmp_warn; + tinygltf::TinyGLTF ctx; + return ctx.LoadASCIIFromString(model, err, warn, json, + static_cast(strlen(json)), + /* base_dir */ ""); +} + +/* ===== Section 1: Custom JSON parser edge-cases =========================== */ + +TEST_CASE("cj-parse-empty-input", "[customjson][parse]") { + auto doc = JsonConstruct(""); + REQUIRE(doc.is_null()); +} + +TEST_CASE("cj-parse-whitespace-only", "[customjson][parse]") { + auto doc = JsonConstruct(" \t\n\r "); + REQUIRE(doc.is_null()); +} + +TEST_CASE("cj-parse-null", "[customjson][parse]") { + auto doc = JsonConstruct("null"); + REQUIRE(doc.is_null()); +} + +TEST_CASE("cj-parse-true", "[customjson][parse]") { + auto doc = JsonConstruct("true"); + REQUIRE(doc.is_boolean()); + REQUIRE(doc.get() == true); +} + +TEST_CASE("cj-parse-false", "[customjson][parse]") { + auto doc = JsonConstruct("false"); + REQUIRE(doc.is_boolean()); + REQUIRE(doc.get() == false); +} + +TEST_CASE("cj-parse-integer-zero", "[customjson][parse]") { + auto doc = JsonConstruct("0"); + REQUIRE(doc.is_number()); + REQUIRE(doc.get() == 0); +} + +TEST_CASE("cj-parse-negative-zero", "[customjson][parse]") { + auto doc = JsonConstruct("-0"); + REQUIRE(doc.is_number()); + // -0 should still compare equal to 0 + REQUIRE(doc.get() == 0.0); +} + +TEST_CASE("cj-parse-positive-integer", "[customjson][parse]") { + auto doc = JsonConstruct("42"); + REQUIRE(doc.is_number()); + REQUIRE(doc.get() == 42); +} + +TEST_CASE("cj-parse-negative-integer", "[customjson][parse]") { + auto doc = JsonConstruct("-999"); + REQUIRE(doc.is_number()); + REQUIRE(doc.get() == -999); +} + +TEST_CASE("cj-parse-large-integer", "[customjson][parse]") { + auto doc = JsonConstruct("2147483647"); + REQUIRE(doc.is_number()); + REQUIRE(doc.get() == 2147483647); +} + +TEST_CASE("cj-parse-double", "[customjson][parse]") { + auto doc = JsonConstruct("3.14159"); + REQUIRE(doc.is_number()); + REQUIRE(doc.get() == Approx(3.14159)); +} + +TEST_CASE("cj-parse-double-exponent", "[customjson][parse]") { + auto doc = JsonConstruct("1.5e10"); + REQUIRE(doc.is_number()); + REQUIRE(doc.get() == Approx(1.5e10)); +} + +TEST_CASE("cj-parse-double-negative-exponent", "[customjson][parse]") { + auto doc = JsonConstruct("2.5e-3"); + REQUIRE(doc.is_number()); + REQUIRE(doc.get() == Approx(0.0025)); +} + +TEST_CASE("cj-parse-double-positive-exponent", "[customjson][parse]") { + auto doc = JsonConstruct("1E+2"); + REQUIRE(doc.is_number()); + REQUIRE(doc.get() == Approx(100.0)); +} + +TEST_CASE("cj-parse-simple-string", "[customjson][parse]") { + auto doc = JsonConstruct("\"hello world\""); + REQUIRE(doc.is_string()); + REQUIRE(doc.get() == "hello world"); +} + +TEST_CASE("cj-parse-empty-string", "[customjson][parse]") { + auto doc = JsonConstruct("\"\""); + REQUIRE(doc.is_string()); + REQUIRE(doc.get().empty()); +} + +TEST_CASE("cj-parse-string-escapes", "[customjson][parse]") { + // Test all standard JSON escape sequences + auto doc = JsonConstruct("\"a\\nb\\tc\\rd\\\\e\\\"/f\""); + REQUIRE(doc.is_string()); + std::string expected = "a\nb\tc\rd\\e\"/f"; + REQUIRE(doc.get() == expected); +} + +TEST_CASE("cj-parse-string-unicode-escape", "[customjson][parse]") { + // \u0041 == 'A' + auto doc = JsonConstruct("\"\\u0041\""); + REQUIRE(doc.is_string()); + REQUIRE(doc.get() == "A"); +} + +TEST_CASE("cj-parse-string-unicode-non-ascii", "[customjson][parse]") { + // \u00E9 == é (UTF-8: 0xC3 0xA9) + auto doc = JsonConstruct("\"caf\\u00E9\""); + REQUIRE(doc.is_string()); + REQUIRE(doc.get() == "caf\xC3\xA9"); +} + +TEST_CASE("cj-parse-string-unicode-cjk", "[customjson][parse]") { + // \u4E16 == 世 (UTF-8: 0xE4 0xB8 0x96) + auto doc = JsonConstruct("\"\\u4E16\""); + REQUIRE(doc.is_string()); + std::string expected; + expected += '\xE4'; + expected += '\xB8'; + expected += '\x96'; + REQUIRE(doc.get() == expected); +} + +TEST_CASE("cj-parse-empty-array", "[customjson][parse]") { + auto doc = JsonConstruct("[]"); + REQUIRE(doc.is_array()); + REQUIRE(doc.size() == 0); +} + +TEST_CASE("cj-parse-array-of-ints", "[customjson][parse]") { + auto doc = JsonConstruct("[1, 2, 3, 4, 5]"); + REQUIRE(doc.is_array()); + REQUIRE(doc.size() == 5); + for (size_t i = 0; i < 5; i++) { + REQUIRE(JsonArrayAt(doc, i).get() == static_cast(i + 1)); + } +} + +TEST_CASE("cj-parse-nested-arrays", "[customjson][parse]") { + auto doc = JsonConstruct("[[1, 2], [3, [4, 5]]]"); + REQUIRE(doc.is_array()); + REQUIRE(doc.size() == 2); + REQUIRE(JsonArrayAt(doc, 0).is_array()); + REQUIRE(JsonArrayAt(doc, 0).size() == 2); + REQUIRE(JsonArrayAt(doc, 1).is_array()); + REQUIRE(JsonArrayAt(doc, 1).size() == 2); + REQUIRE(JsonArrayAt(JsonArrayAt(doc, 1), 1).is_array()); + REQUIRE(JsonArrayAt(JsonArrayAt(doc, 1), 1).size() == 2); +} + +TEST_CASE("cj-parse-empty-object", "[customjson][parse]") { + auto doc = JsonConstruct("{}"); + REQUIRE(doc.is_object()); + REQUIRE(doc.size() == 0); +} + +TEST_CASE("cj-parse-simple-object", "[customjson][parse]") { + auto doc = JsonConstruct("{\"a\": 1, \"b\": \"hello\", \"c\": true}"); + REQUIRE(doc.is_object()); + REQUIRE(doc.size() == 3); + + int val = 0; + REQUIRE(tinygltf::detail::GetInt(doc["a"], val)); + REQUIRE(val == 1); + + std::string s; + REQUIRE(tinygltf::detail::GetString(doc["b"], s)); + REQUIRE(s == "hello"); + + REQUIRE(doc["c"].get() == true); +} + +TEST_CASE("cj-parse-nested-object", "[customjson][parse]") { + auto doc = JsonConstruct("{\"outer\": {\"inner\": {\"value\": 42}}}"); + REQUIRE(doc.is_object()); + REQUIRE(doc["outer"].is_object()); + REQUIRE(doc["outer"]["inner"].is_object()); + int v = 0; + REQUIRE(tinygltf::detail::GetInt(doc["outer"]["inner"]["value"], v)); + REQUIRE(v == 42); +} + +TEST_CASE("cj-parse-mixed-types-array", "[customjson][parse]") { + auto doc = + JsonConstruct("[null, true, false, 42, 3.14, \"text\", [], {}]"); + REQUIRE(doc.is_array()); + REQUIRE(doc.size() == 8); + REQUIRE(JsonArrayAt(doc, 0).is_null()); + REQUIRE(JsonArrayAt(doc, 1).is_boolean()); + REQUIRE(JsonArrayAt(doc, 2).is_boolean()); + REQUIRE(JsonArrayAt(doc, 3).is_number()); + REQUIRE(JsonArrayAt(doc, 4).is_number()); + REQUIRE(JsonArrayAt(doc, 5).is_string()); + REQUIRE(JsonArrayAt(doc, 6).is_array()); + REQUIRE(JsonArrayAt(doc, 7).is_object()); +} + +/* ===== Section 2: Deeply nested structures ================================ */ + +TEST_CASE("cj-deep-nested-arrays", "[customjson][depth]") { + // Build a deeply nested array: [[[[...]]]] + const int depth = 200; + std::string json; + for (int i = 0; i < depth; i++) json += "["; + json += "1"; + for (int i = 0; i < depth; i++) json += "]"; + + auto doc = JsonConstruct(json.c_str()); + REQUIRE(doc.is_array()); + + // Walk down + tinygltf::detail::json *cur = &doc; + for (int i = 0; i < depth - 1; i++) { + REQUIRE(cur->is_array()); + REQUIRE(cur->size() == 1); + cur = &JsonArrayAt(*cur, 0); + } + REQUIRE(cur->is_array()); + REQUIRE(cur->size() == 1); + REQUIRE(JsonArrayAt(*cur, 0).get() == 1); +} + +TEST_CASE("cj-deep-nested-objects", "[customjson][depth]") { + const int depth = 100; + std::string json; + for (int i = 0; i < depth; i++) { + json += "{\"k\":"; + } + json += "42"; + for (int i = 0; i < depth; i++) { + json += "}"; + } + + auto doc = JsonConstruct(json.c_str()); + REQUIRE(doc.is_object()); + + tinygltf::detail::json *cur = &doc; + for (int i = 0; i < depth - 1; i++) { + REQUIRE(cur->is_object()); + cur = &((*cur)["k"]); + } + REQUIRE(cur->is_object()); + int v = 0; + REQUIRE(tinygltf::detail::GetInt((*cur)["k"], v)); + REQUIRE(v == 42); +} + +/* ===== Section 3: Malformed input handling ================================ */ + +TEST_CASE("cj-malformed-truncated-object", "[customjson][malformed]") { + auto doc = JsonConstruct("{\"key\":"); + // Should return null on parse error + REQUIRE(doc.is_null()); +} + +TEST_CASE("cj-malformed-truncated-array", "[customjson][malformed]") { + auto doc = JsonConstruct("[1, 2,"); + REQUIRE(doc.is_null()); +} + +TEST_CASE("cj-malformed-trailing-comma-array", "[customjson][malformed]") { + auto doc = JsonConstruct("[1, 2, 3,]"); + // Trailing comma is not valid JSON + REQUIRE(doc.is_null()); +} + +TEST_CASE("cj-malformed-trailing-comma-object", "[customjson][malformed]") { + auto doc = JsonConstruct("{\"a\": 1,}"); + REQUIRE(doc.is_null()); +} + +TEST_CASE("cj-malformed-missing-colon", "[customjson][malformed]") { + auto doc = JsonConstruct("{\"key\" \"value\"}"); + REQUIRE(doc.is_null()); +} + +TEST_CASE("cj-malformed-missing-comma", "[customjson][malformed]") { + auto doc = JsonConstruct("[1 2 3]"); + REQUIRE(doc.is_null()); +} + +TEST_CASE("cj-malformed-double-comma", "[customjson][malformed]") { + auto doc = JsonConstruct("[1,,2]"); + REQUIRE(doc.is_null()); +} + +TEST_CASE("cj-malformed-unquoted-key", "[customjson][malformed]") { + auto doc = JsonConstruct("{key: 1}"); + REQUIRE(doc.is_null()); +} + +TEST_CASE("cj-malformed-single-quoted-string", "[customjson][malformed]") { + auto doc = JsonConstruct("{'key': 1}"); + REQUIRE(doc.is_null()); +} + +TEST_CASE("cj-malformed-unterminated-string", "[customjson][malformed]") { + auto doc = JsonConstruct("\"hello"); + REQUIRE(doc.is_null()); +} + +TEST_CASE("cj-malformed-bad-escape", "[customjson][malformed]") { + auto doc = JsonConstruct("\"bad\\xescape\""); + // \\x is not a valid JSON escape + REQUIRE(doc.is_null()); +} + +TEST_CASE("cj-malformed-truncated-unicode-escape", "[customjson][malformed]") { + auto doc = JsonConstruct("\"trunc\\u00\""); + REQUIRE(doc.is_null()); +} + +TEST_CASE("cj-malformed-incomplete-true", "[customjson][malformed]") { + auto doc = JsonConstruct("tru"); + REQUIRE(doc.is_null()); +} + +TEST_CASE("cj-malformed-incomplete-false", "[customjson][malformed]") { + auto doc = JsonConstruct("fals"); + REQUIRE(doc.is_null()); +} + +TEST_CASE("cj-malformed-incomplete-null", "[customjson][malformed]") { + auto doc = JsonConstruct("nul"); + REQUIRE(doc.is_null()); +} + +TEST_CASE("cj-malformed-extra-data", "[customjson][malformed]") { + // Valid JSON followed by extra data + auto doc = JsonConstruct("42 extra"); + REQUIRE(doc.is_null()); +} + +TEST_CASE("cj-malformed-just-brace", "[customjson][malformed]") { + auto doc = JsonConstruct("{"); + REQUIRE(doc.is_null()); +} + +TEST_CASE("cj-malformed-just-bracket", "[customjson][malformed]") { + auto doc = JsonConstruct("["); + REQUIRE(doc.is_null()); +} + +TEST_CASE("cj-malformed-mismatched-brackets", "[customjson][malformed]") { + auto doc = JsonConstruct("[}"); + REQUIRE(doc.is_null()); +} + +TEST_CASE("cj-malformed-mismatched-braces", "[customjson][malformed]") { + auto doc = JsonConstruct("{]"); + REQUIRE(doc.is_null()); +} + +/* ===== Section 4: glTF-level loading edge-cases =========================== */ + +TEST_CASE("cj-gltf-empty-string", "[customjson][gltf]") { + REQUIRE(false == LoadGltfFromString("")); +} + +TEST_CASE("cj-gltf-garbage-input", "[customjson][gltf]") { + REQUIRE(false == LoadGltfFromString("not json at all")); +} + +TEST_CASE("cj-gltf-valid-null", "[customjson][gltf]") { + REQUIRE(false == LoadGltfFromString("null")); +} + +TEST_CASE("cj-gltf-valid-array-root", "[customjson][gltf]") { + // glTF expects root to be an object, not an array + REQUIRE(false == LoadGltfFromString("[]")); +} + +TEST_CASE("cj-gltf-minimal-valid", "[customjson][gltf]") { + // Minimal valid glTF 2.0 + const char *json = R"({"asset":{"version":"2.0"}})"; + tinygltf::Model model; + std::string err, warn; + REQUIRE(true == LoadGltfFromString(json, &model, &err, &warn)); + REQUIRE(model.asset.version == "2.0"); +} + +TEST_CASE("cj-gltf-empty-meshes", "[customjson][gltf]") { + const char *json = R"({ + "asset": {"version": "2.0"}, + "meshes": [] + })"; + tinygltf::Model model; + std::string err, warn; + REQUIRE(true == LoadGltfFromString(json, &model, &err, &warn)); + REQUIRE(model.meshes.empty()); +} + +TEST_CASE("cj-gltf-scene-with-nodes", "[customjson][gltf]") { + const char *json = R"({ + "asset": {"version": "2.0"}, + "scenes": [{"nodes": [0]}], + "nodes": [{"name": "TestNode"}] + })"; + tinygltf::Model model; + std::string err, warn; + REQUIRE(true == LoadGltfFromString(json, &model, &err, &warn)); + REQUIRE(model.scenes.size() == 1); + REQUIRE(model.nodes.size() == 1); + REQUIRE(model.nodes[0].name == "TestNode"); +} + +TEST_CASE("cj-gltf-node-with-transform", "[customjson][gltf]") { + const char *json = R"({ + "asset": {"version": "2.0"}, + "nodes": [{ + "translation": [1.0, 2.0, 3.0], + "rotation": [0.0, 0.0, 0.0, 1.0], + "scale": [1.0, 1.0, 1.0] + }] + })"; + tinygltf::Model model; + std::string err, warn; + REQUIRE(true == LoadGltfFromString(json, &model, &err, &warn)); + REQUIRE(model.nodes.size() == 1); + REQUIRE(model.nodes[0].translation.size() == 3); + REQUIRE(model.nodes[0].translation[0] == Approx(1.0)); + REQUIRE(model.nodes[0].translation[1] == Approx(2.0)); + REQUIRE(model.nodes[0].translation[2] == Approx(3.0)); + REQUIRE(model.nodes[0].rotation.size() == 4); + REQUIRE(model.nodes[0].scale.size() == 3); +} + +TEST_CASE("cj-gltf-material-pbr", "[customjson][gltf]") { + const char *json = R"({ + "asset": {"version": "2.0"}, + "materials": [{ + "pbrMetallicRoughness": { + "baseColorFactor": [1.0, 0.5, 0.25, 1.0], + "metallicFactor": 0.8, + "roughnessFactor": 0.2 + } + }] + })"; + tinygltf::Model model; + std::string err, warn; + REQUIRE(true == LoadGltfFromString(json, &model, &err, &warn)); + REQUIRE(model.materials.size() == 1); + auto &pbr = model.materials[0].pbrMetallicRoughness; + REQUIRE(pbr.baseColorFactor.size() == 4); + REQUIRE(pbr.baseColorFactor[0] == Approx(1.0)); + REQUIRE(pbr.baseColorFactor[1] == Approx(0.5)); + REQUIRE(pbr.baseColorFactor[2] == Approx(0.25)); + REQUIRE(pbr.metallicFactor == Approx(0.8)); + REQUIRE(pbr.roughnessFactor == Approx(0.2)); +} + +TEST_CASE("cj-gltf-accessors-and-bufferviews", "[customjson][gltf]") { + const char *json = R"({ + "asset": {"version": "2.0"}, + "buffers": [{"uri": "data:application/octet-stream;base64,AAAAAAAAAAA=", "byteLength": 8}], + "bufferViews": [{"buffer": 0, "byteOffset": 0, "byteLength": 8}], + "accessors": [{ + "bufferView": 0, + "componentType": 5126, + "count": 2, + "type": "SCALAR", + "max": [1.0], + "min": [0.0] + }] + })"; + tinygltf::Model model; + std::string err, warn; + REQUIRE(true == LoadGltfFromString(json, &model, &err, &warn)); + REQUIRE(model.buffers.size() == 1); + REQUIRE(model.bufferViews.size() == 1); + REQUIRE(model.accessors.size() == 1); + REQUIRE(model.accessors[0].componentType == TINYGLTF_COMPONENT_TYPE_FLOAT); + REQUIRE(model.accessors[0].count == 2); + REQUIRE(model.accessors[0].type == TINYGLTF_TYPE_SCALAR); +} + +TEST_CASE("cj-gltf-extensions", "[customjson][gltf]") { + const char *json = R"({ + "asset": {"version": "2.0"}, + "extensionsUsed": ["KHR_lights_punctual"], + "extensions": { + "KHR_lights_punctual": { + "lights": [{ + "color": [1.0, 1.0, 1.0], + "intensity": 5.0, + "type": "point" + }] + } + } + })"; + tinygltf::Model model; + std::string err, warn; + REQUIRE(true == LoadGltfFromString(json, &model, &err, &warn)); + REQUIRE(model.extensionsUsed.size() == 1); + REQUIRE(model.extensionsUsed[0] == "KHR_lights_punctual"); + REQUIRE(model.extensions.count("KHR_lights_punctual") == 1); +} + +TEST_CASE("cj-gltf-extras", "[customjson][gltf]") { + const char *json = R"({ + "asset": {"version": "2.0"}, + "nodes": [{ + "name": "Test", + "extras": {"custom_field": 123, "nested": {"a": [1,2,3]}} + }] + })"; + tinygltf::Model model; + std::string err, warn; + REQUIRE(true == LoadGltfFromString(json, &model, &err, &warn)); + REQUIRE(model.nodes.size() == 1); + // extras should be preserved as a Value + REQUIRE(model.nodes[0].extras.IsObject()); +} + +/* ===== Section 5: Round-trip serialisation ================================ */ + +TEST_CASE("cj-roundtrip-minimal", "[customjson][roundtrip]") { + const char *json = R"({"asset":{"version":"2.0"}})"; + tinygltf::Model model; + std::string err, warn; + REQUIRE(true == LoadGltfFromString(json, &model, &err, &warn)); + + // Serialise to string + std::stringstream os; + tinygltf::TinyGLTF ctx; + REQUIRE(true == ctx.WriteGltfSceneToStream(&model, os, false, false)); + + // Re-parse and verify + tinygltf::Model model2; + std::string json2 = os.str(); + REQUIRE(true == + ctx.LoadASCIIFromString(&model2, &err, &warn, json2.c_str(), + static_cast(json2.size()), "")); + REQUIRE(model2.asset.version == "2.0"); +} + +TEST_CASE("cj-roundtrip-with-nodes", "[customjson][roundtrip]") { + const char *json = R"({ + "asset": {"version": "2.0"}, + "scenes": [{"nodes": [0]}], + "nodes": [ + {"name": "Root", "children": [1]}, + {"name": "Child", "translation": [1.0, 2.0, 3.0]} + ] + })"; + tinygltf::Model model; + std::string err, warn; + REQUIRE(true == LoadGltfFromString(json, &model, &err, &warn)); + + std::stringstream os; + tinygltf::TinyGLTF ctx; + REQUIRE(true == ctx.WriteGltfSceneToStream(&model, os, false, false)); + + tinygltf::Model model2; + std::string json2 = os.str(); + REQUIRE(true == + ctx.LoadASCIIFromString(&model2, &err, &warn, json2.c_str(), + static_cast(json2.size()), "")); + REQUIRE(model2.nodes.size() == 2); + REQUIRE(model2.nodes[0].name == "Root"); + REQUIRE(model2.nodes[1].name == "Child"); + REQUIRE(model2.nodes[1].translation[0] == Approx(1.0)); +} + +TEST_CASE("cj-roundtrip-material", "[customjson][roundtrip]") { + const char *json = R"({ + "asset": {"version": "2.0"}, + "materials": [{ + "name": "TestMat", + "pbrMetallicRoughness": { + "baseColorFactor": [0.8, 0.2, 0.1, 1.0], + "metallicFactor": 0.5, + "roughnessFactor": 0.7 + }, + "doubleSided": true + }] + })"; + tinygltf::Model model; + std::string err, warn; + REQUIRE(true == LoadGltfFromString(json, &model, &err, &warn)); + + std::stringstream os; + tinygltf::TinyGLTF ctx; + REQUIRE(true == ctx.WriteGltfSceneToStream(&model, os, false, false)); + + tinygltf::Model model2; + std::string json2 = os.str(); + REQUIRE(true == + ctx.LoadASCIIFromString(&model2, &err, &warn, json2.c_str(), + static_cast(json2.size()), "")); + REQUIRE(model2.materials.size() == 1); + REQUIRE(model2.materials[0].name == "TestMat"); + REQUIRE(model2.materials[0].doubleSided == true); + REQUIRE(model2.materials[0].pbrMetallicRoughness.metallicFactor == + Approx(0.5)); +} + +/* ===== Section 6: Binary (GLB) loading ==================================== */ + +TEST_CASE("cj-glb-invalid-magic", "[customjson][glb]") { + // GLB header: magic (4), version (4), length (4) = 12 bytes + // Wrong magic number + unsigned char data[12] = {0x00, 0x00, 0x00, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00}; + tinygltf::Model model; + tinygltf::TinyGLTF ctx; + std::string err, warn; + bool ret = ctx.LoadBinaryFromMemory(&model, &err, &warn, data, sizeof(data)); + REQUIRE(false == ret); +} + +TEST_CASE("cj-glb-truncated-header", "[customjson][glb]") { + // Less than 12 bytes + unsigned char data[8] = {0x67, 0x6C, 0x54, 0x46, 0x02, 0x00, 0x00, 0x00}; + tinygltf::Model model; + tinygltf::TinyGLTF ctx; + std::string err, warn; + bool ret = ctx.LoadBinaryFromMemory(&model, &err, &warn, data, sizeof(data)); + REQUIRE(false == ret); +} + +TEST_CASE("cj-glb-zero-length", "[customjson][glb]") { + tinygltf::Model model; + tinygltf::TinyGLTF ctx; + std::string err, warn; + bool ret = ctx.LoadBinaryFromMemory(&model, &err, &warn, nullptr, 0); + REQUIRE(false == ret); +} + +/* ===== Section 7: JSON detail helpers ===================================== */ + +TEST_CASE("cj-detail-GetInt", "[customjson][detail]") { + auto doc = JsonConstruct("{\"val\": 42}"); + int v = 0; + REQUIRE(tinygltf::detail::GetInt(doc["val"], v)); + REQUIRE(v == 42); +} + +TEST_CASE("cj-detail-GetDouble", "[customjson][detail]") { + auto doc = JsonConstruct("{\"val\": 3.14}"); + double v = 0; + REQUIRE(tinygltf::detail::GetDouble(doc["val"], v)); + REQUIRE(v == Approx(3.14)); +} + +TEST_CASE("cj-detail-GetNumber", "[customjson][detail]") { + auto doc = JsonConstruct("{\"val\": 99}"); + double v = 0; + REQUIRE(tinygltf::detail::GetNumber(doc["val"], v)); + REQUIRE(v == Approx(99.0)); +} + +TEST_CASE("cj-detail-GetString", "[customjson][detail]") { + auto doc = JsonConstruct("{\"val\": \"hello\"}"); + std::string v; + REQUIRE(tinygltf::detail::GetString(doc["val"], v)); + REQUIRE(v == "hello"); +} + +TEST_CASE("cj-detail-IsArray", "[customjson][detail]") { + auto doc = JsonConstruct("[1, 2]"); + REQUIRE(tinygltf::detail::IsArray(doc)); + auto doc2 = JsonConstruct("42"); + REQUIRE_FALSE(tinygltf::detail::IsArray(doc2)); +} + +TEST_CASE("cj-detail-IsObject", "[customjson][detail]") { + auto doc = JsonConstruct("{\"a\": 1}"); + REQUIRE(tinygltf::detail::IsObject(doc)); + auto doc2 = JsonConstruct("[]"); + REQUIRE_FALSE(tinygltf::detail::IsObject(doc2)); +} + +TEST_CASE("cj-detail-IsEmpty", "[customjson][detail]") { + auto doc = JsonConstruct("{}"); + REQUIRE(tinygltf::detail::IsEmpty(doc)); + auto doc2 = JsonConstruct("{\"a\": 1}"); + REQUIRE_FALSE(tinygltf::detail::IsEmpty(doc2)); +} + +TEST_CASE("cj-detail-JsonIsNull", "[customjson][detail]") { + auto doc = JsonConstruct("null"); + REQUIRE(tinygltf::detail::JsonIsNull(doc)); + auto doc2 = JsonConstruct("42"); + REQUIRE_FALSE(tinygltf::detail::JsonIsNull(doc2)); +} + +/* ===== Section 8: Serialisation helpers =================================== */ + +TEST_CASE("cj-detail-JsonFromString", "[customjson][detail]") { + auto j = tinygltf::detail::JsonFromString("test"); + REQUIRE(j.is_string()); + REQUIRE(j.get() == "test"); +} + +TEST_CASE("cj-detail-JsonSetObject", "[customjson][detail]") { + tinygltf::detail::json j; + tinygltf::detail::JsonSetObject(j); + REQUIRE(j.is_object()); + REQUIRE(j.size() == 0); +} + +TEST_CASE("cj-detail-JsonAddMember", "[customjson][detail]") { + tinygltf::detail::json j; + tinygltf::detail::JsonSetObject(j); + auto val = tinygltf::detail::JsonFromString("bar"); + tinygltf::detail::JsonAddMember(j, "foo", std::move(val)); + REQUIRE(j.size() == 1); + std::string s; + REQUIRE(tinygltf::detail::GetString(j["foo"], s)); + REQUIRE(s == "bar"); +} + +TEST_CASE("cj-detail-JsonPushBack", "[customjson][detail]") { + auto j = JsonConstruct("[]"); + tinygltf::detail::json val; + val = JsonConstruct("42"); + tinygltf::detail::JsonPushBack(j, std::move(val)); + REQUIRE(j.size() == 1); + REQUIRE(JsonArrayAt(j, 0).get() == 42); +} + +/* ===== Section 9: Stress / large input ==================================== */ + +TEST_CASE("cj-large-array", "[customjson][stress]") { + // Array with 1000 integers + std::string json = "["; + for (int i = 0; i < 1000; i++) { + if (i > 0) json += ","; + json += std::to_string(i); + } + json += "]"; + auto doc = JsonConstruct(json.c_str()); + REQUIRE(doc.is_array()); + REQUIRE(doc.size() == 1000); + REQUIRE(JsonArrayAt(doc, 0).get() == 0); + REQUIRE(JsonArrayAt(doc, 999).get() == 999); +} + +TEST_CASE("cj-large-object", "[customjson][stress]") { + // Object with 500 keys + std::string json = "{"; + for (int i = 0; i < 500; i++) { + if (i > 0) json += ","; + json += "\"key" + std::to_string(i) + "\":" + std::to_string(i); + } + json += "}"; + auto doc = JsonConstruct(json.c_str()); + REQUIRE(doc.is_object()); + REQUIRE(doc.size() == 500); + int v = -1; + REQUIRE(tinygltf::detail::GetInt(doc["key0"], v)); + REQUIRE(v == 0); + REQUIRE(tinygltf::detail::GetInt(doc["key499"], v)); + REQUIRE(v == 499); +} + +TEST_CASE("cj-long-string", "[customjson][stress]") { + // String with 10000 characters + std::string content(10000, 'x'); + std::string json = "\"" + content + "\""; + auto doc = JsonConstruct(json.c_str()); + REQUIRE(doc.is_string()); + REQUIRE(doc.get().size() == 10000); +} + +TEST_CASE("cj-many-escapes", "[customjson][stress]") { + // String with many escape sequences + std::string json = "\""; + for (int i = 0; i < 500; i++) { + json += "\\n\\t"; + } + json += "\""; + auto doc = JsonConstruct(json.c_str()); + REQUIRE(doc.is_string()); + REQUIRE(doc.get().size() == 1000); +} + +/* ===== Section 10: glTF complex models ==================================== */ + +TEST_CASE("cj-gltf-multiple-meshes-primitives", "[customjson][gltf]") { + const char *json = R"({ + "asset": {"version": "2.0"}, + "meshes": [ + {"primitives": [{"attributes": {"POSITION": 0}, "mode": 4}]}, + {"primitives": [ + {"attributes": {"POSITION": 0}, "mode": 4}, + {"attributes": {"POSITION": 0, "NORMAL": 1}, "mode": 4} + ]} + ], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3", + "max": [1,1,1], "min": [0,0,0]}, + {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3", + "max": [1,1,1], "min": [0,0,0]} + ], + "bufferViews": [{"buffer": 0, "byteLength": 36}], + "buffers": [{"uri": "data:application/octet-stream;base64,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "byteLength": 36}] + })"; + tinygltf::Model model; + std::string err, warn; + REQUIRE(true == LoadGltfFromString(json, &model, &err, &warn)); + REQUIRE(model.meshes.size() == 2); + REQUIRE(model.meshes[0].primitives.size() == 1); + REQUIRE(model.meshes[1].primitives.size() == 2); +} + +TEST_CASE("cj-gltf-animations", "[customjson][gltf]") { + const char *json = R"({ + "asset": {"version": "2.0"}, + "animations": [{ + "channels": [{"sampler": 0, "target": {"node": 0, "path": "translation"}}], + "samplers": [{"input": 0, "output": 1, "interpolation": "LINEAR"}] + }], + "nodes": [{"name": "AnimNode"}], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "count": 2, "type": "SCALAR", + "max": [1.0], "min": [0.0]}, + {"bufferView": 0, "componentType": 5126, "count": 2, "type": "VEC3", + "max": [1,1,1], "min": [0,0,0]} + ], + "bufferViews": [{"buffer": 0, "byteLength": 24}], + "buffers": [{"uri": "data:application/octet-stream;base64,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "byteLength": 24}] + })"; + tinygltf::Model model; + std::string err, warn; + REQUIRE(true == LoadGltfFromString(json, &model, &err, &warn)); + REQUIRE(model.animations.size() == 1); + REQUIRE(model.animations[0].channels.size() == 1); + REQUIRE(model.animations[0].samplers.size() == 1); + REQUIRE(model.animations[0].channels[0].target_path == "translation"); +} + +TEST_CASE("cj-gltf-cameras", "[customjson][gltf]") { + const char *json = R"({ + "asset": {"version": "2.0"}, + "cameras": [ + { + "type": "perspective", + "perspective": { + "aspectRatio": 1.5, + "yfov": 0.66, + "zfar": 100.0, + "znear": 0.01 + } + }, + { + "type": "orthographic", + "orthographic": { + "xmag": 10.0, + "ymag": 10.0, + "zfar": 100.0, + "znear": 0.01 + } + } + ] + })"; + tinygltf::Model model; + std::string err, warn; + REQUIRE(true == LoadGltfFromString(json, &model, &err, &warn)); + REQUIRE(model.cameras.size() == 2); + REQUIRE(model.cameras[0].type == "perspective"); + REQUIRE(model.cameras[1].type == "orthographic"); +} + +TEST_CASE("cj-gltf-skins", "[customjson][gltf]") { + const char *json = R"({ + "asset": {"version": "2.0"}, + "nodes": [ + {"name": "Armature"}, + {"name": "Bone1"}, + {"name": "Bone2"} + ], + "skins": [{ + "joints": [1, 2], + "skeleton": 0 + }] + })"; + tinygltf::Model model; + std::string err, warn; + REQUIRE(true == LoadGltfFromString(json, &model, &err, &warn)); + REQUIRE(model.skins.size() == 1); + REQUIRE(model.skins[0].joints.size() == 2); + REQUIRE(model.skins[0].skeleton == 0); +} + +/* ===== Section 11: Truncated glTF JSON at various positions =============== */ + +TEST_CASE("cj-gltf-truncated-at-key", "[customjson][truncated]") { + REQUIRE(false == LoadGltfFromString("{\"asset\"")); +} + +TEST_CASE("cj-gltf-truncated-at-colon", "[customjson][truncated]") { + REQUIRE(false == LoadGltfFromString("{\"asset\":")); +} + +TEST_CASE("cj-gltf-truncated-mid-value", "[customjson][truncated]") { + REQUIRE(false == LoadGltfFromString("{\"asset\":{\"ver")); +} + +TEST_CASE("cj-gltf-truncated-after-comma", "[customjson][truncated]") { + REQUIRE(false == + LoadGltfFromString("{\"asset\":{\"version\":\"2.0\"},")); +} + +/* ===== Section 12: JSON dump / serialisation ============================== */ + +TEST_CASE("cj-dump-null", "[customjson][dump]") { + auto doc = JsonConstruct("null"); + std::string s = doc.dump(-1); + REQUIRE(s == "null"); +} + +TEST_CASE("cj-dump-bool", "[customjson][dump]") { + auto doc = JsonConstruct("true"); + REQUIRE(doc.dump(-1) == "true"); + auto doc2 = JsonConstruct("false"); + REQUIRE(doc2.dump(-1) == "false"); +} + +TEST_CASE("cj-dump-int", "[customjson][dump]") { + auto doc = JsonConstruct("42"); + REQUIRE(doc.dump(-1) == "42"); +} + +TEST_CASE("cj-dump-string", "[customjson][dump]") { + auto doc = JsonConstruct("\"hello\""); + std::string s = doc.dump(-1); + REQUIRE(s == "\"hello\""); +} + +TEST_CASE("cj-dump-array", "[customjson][dump]") { + auto doc = JsonConstruct("[1,2,3]"); + std::string s = doc.dump(-1); + // Should contain all three values + REQUIRE(s.find("1") != std::string::npos); + REQUIRE(s.find("2") != std::string::npos); + REQUIRE(s.find("3") != std::string::npos); +} + +TEST_CASE("cj-dump-object", "[customjson][dump]") { + auto doc = JsonConstruct("{\"key\":\"val\"}"); + std::string s = doc.dump(-1); + REQUIRE(s.find("\"key\"") != std::string::npos); + REQUIRE(s.find("\"val\"") != std::string::npos); +} + +TEST_CASE("cj-dump-roundtrip", "[customjson][dump]") { + const char *input = "{\"a\":[1,2,3],\"b\":{\"c\":true}}"; + auto doc = JsonConstruct(input); + std::string s = doc.dump(-1); + // Re-parse the dump output + auto doc2 = JsonConstruct(s.c_str()); + REQUIRE(doc2.is_object()); + REQUIRE(doc2["a"].is_array()); + REQUIRE(doc2["a"].size() == 3); + REQUIRE(doc2["b"]["c"].get() == true); +} + +/* ===== Section 13: Fuzz-like systematic truncation ======================== */ + +TEST_CASE("cj-systematic-truncation", "[customjson][fuzzlike]") { + // Take a valid JSON string and verify the parser doesn't crash + // at every truncation point + const char *valid_json = + R"({"asset":{"version":"2.0"},"nodes":[{"name":"test","translation":[1.0,2.0,3.0]}]})"; + size_t len = strlen(valid_json); + + for (size_t i = 0; i < len; i++) { + std::string truncated(valid_json, i); + tinygltf::detail::JsonDocument doc; + // Should not crash; may fail gracefully + tinygltf::detail::JsonParse(doc, truncated.c_str(), truncated.size()); + // No assertion on result - just checking it doesn't crash/hang + } +} + +TEST_CASE("cj-systematic-byte-flip", "[customjson][fuzzlike]") { + // Take a valid JSON string and flip each byte, verify no crash + const char *valid_json = R"({"asset":{"version":"2.0"}})"; + size_t len = strlen(valid_json); + + for (size_t i = 0; i < len; i++) { + std::string mutated(valid_json, len); + mutated[i] = static_cast(mutated[i] ^ 0xFF); + tinygltf::detail::JsonDocument doc; + tinygltf::detail::JsonParse(doc, mutated.c_str(), mutated.size()); + // No assertion - just checking it doesn't crash + } +} + +TEST_CASE("cj-systematic-null-insertion", "[customjson][fuzzlike]") { + // Insert null bytes at various positions + const char *valid_json = R"({"a":"b","c":1})"; + size_t len = strlen(valid_json); + + for (size_t i = 0; i <= len; i++) { + std::string mutated(valid_json, len); + mutated.insert(i, 1, '\0'); + tinygltf::detail::JsonDocument doc; + tinygltf::detail::JsonParse(doc, mutated.c_str(), mutated.size()); + // No assertion - just checking it doesn't crash + } +}