From ca13a5eaa9c832447c4674bcf4f75a261a794303 Mon Sep 17 00:00:00 2001 From: EJainDev Date: Fri, 9 Oct 2026 20:35:19 -0400 Subject: [PATCH] feat: v1 touchup --- .github/workflows/build-and-test.yml | 16 +- CMakeLists.txt | 27 ++- CONTRIBUTING.md | 13 +- README.md | 78 ++++-- cmake/SerialXMLConfig.cmake.in | 5 + include/serial_xml.cxx | 351 ++++++++++++++++----------- tests/consumer/CMakeLists.txt | 30 +++ tests/consumer/invalid_name.cpp | 30 +++ tests/consumer/main.cpp | 14 ++ tests/consumer/run.cmake | 84 +++++++ tests/test_serial_xml.cpp | 138 +++++++++++ 11 files changed, 611 insertions(+), 175 deletions(-) create mode 100644 tests/consumer/CMakeLists.txt create mode 100644 tests/consumer/invalid_name.cpp create mode 100644 tests/consumer/main.cpp create mode 100644 tests/consumer/run.cmake diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index b55a1a1..f3f8b77 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -5,6 +5,11 @@ on: branches: - main paths: + - 'CMakeLists.txt' + - 'CMakePresets.json' + - 'cmake/**' + - '.devcontainer/**' + - 'README.md' - 'include/**' - '.github/workflows/**' - 'tests/**' @@ -15,6 +20,11 @@ on: branches: - main paths: + - 'CMakeLists.txt' + - 'CMakePresets.json' + - 'cmake/**' + - '.devcontainer/**' + - 'README.md' - 'include/**' - '.github/workflows/**' - 'tests/**' @@ -34,16 +44,18 @@ jobs: with: push: never runCmd: | + set -eu cmake --preset release-test-gcc cmake --build --preset build-release-test - cd build && ctest --output-on-failure --output-junit test-results.xml + ctest --test-dir build/release --output-on-failure --no-tests=error --output-junit test-results.xml + cmake -DSERIAL_XML_SOURCE_DIR="$PWD" -DSERIAL_XML_BINARY_DIR="$PWD/build/release" -P tests/consumer/run.cmake - name: Test Report if: ${{ !cancelled() }} uses: dorny/test-reporter@v3 with: name: C++ Tests - path: build/test-results.xml + path: build/release/test-results.xml reporter: java-junit fail-on-error: true list-tests: failed diff --git a/CMakeLists.txt b/CMakeLists.txt index 2ef6e7f..b6b68fb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,8 +1,8 @@ -cmake_minimum_required(VERSION 4.3) +cmake_minimum_required(VERSION 4.3.3) set(CMAKE_CXX_STANDARD 26) set(CMAKE_CXX_STANDARD_REQUIRED ON) -if(CMAKE_VERSION VERSION_LESS_EQUAL 4.3) +if(CMAKE_VERSION VERSION_LESS 4.4) set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD "451f2fe2-a8a2-47c3-bc32-94786d8fc91b") elseif(CMAKE_VERSION VERSION_GREATER_EQUAL 4.4) set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD "f35a9ac6-8463-4d38-8eec-5d6008153e7d") @@ -11,7 +11,7 @@ set(CMAKE_CXX_MODULE_STD 1) project( SerialXML - VERSION 0.1.0.0 + VERSION 1.0.0 LANGUAGES CXX) include(FetchContent) @@ -22,14 +22,14 @@ if(NOT StructuralTuple_FOUND) FetchContent_Declare( structural_tuple GIT_REPOSITORY https://github.com/EJainDev/StructuralTuple.git - GIT_TAG main) + GIT_TAG 7ad88be28ceece1d6d41da72754c6cf2c969fd6d) FetchContent_MakeAvailable(structural_tuple) endif() option(BUILD_TESTS "Enable building of tests" OFF) -option(BUILD_EXAMPLES "Enable building of examples" ON) -option(BUILD_BENCHMARKS "Enable building of benchmarks" ON) +option(BUILD_EXAMPLES "Enable building of examples" ${PROJECT_IS_TOP_LEVEL}) +option(BUILD_BENCHMARKS "Enable building of benchmarks" ${PROJECT_IS_TOP_LEVEL}) include(GNUInstallDirs) include(CMakePackageConfigHelpers) @@ -41,9 +41,16 @@ endif() # Library -set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -freflection") +# StructuralTuple's current release sets reflection flags only in its directory. +# Export them on the fetched target too, including its standard-module +# dependency. +if(TARGET structural_tuple AND NOT StructuralTuple_FOUND) + target_compile_options(structural_tuple PUBLIC -freflection) +endif() add_library(serial_xml STATIC) +add_library(serial_xml::serial_xml ALIAS serial_xml) +target_compile_options(serial_xml PUBLIC -freflection) target_sources( serial_xml PUBLIC FILE_SET @@ -57,7 +64,11 @@ target_sources( target_include_directories( serial_xml PUBLIC $ $) -target_link_libraries(serial_xml PUBLIC structural_tuple) +if(TARGET structural_tuple::structural_tuple) + target_link_libraries(serial_xml PUBLIC structural_tuple::structural_tuple) +else() + target_link_libraries(serial_xml PUBLIC structural_tuple) +endif() target_compile_features(serial_xml PUBLIC cxx_std_26) # Installation configuration diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8669b0f..5fb3333 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,10 +27,10 @@ Before submitting a bug report, ensure the issue hasn't been reported already. P To report compile errors, specify: 1. The exact error message or symptom. -2. The compiler and version (for example, GCC 16.1) and the CMake version used (for example, 4.3.0). +2. The compiler and version (for example, GCC 16.1) and the CMake version used (for example, 4.4.3). 3. The full compiler output / compilation log. 4. A minimal, self-contained reproducible example (smallest code that reproduces the problem). -5. The exact build commands or CMake preset used to reproduce the issue (for example: `cmake --preset debug-gcc-16` and `cmake --build --preset build-debug`). +5. The exact build commands or CMake preset used to reproduce the issue (for example: `cmake --preset debug-test-gcc` and `cmake --build --preset build-debug-test`). ### Runtime Bugs @@ -57,14 +57,15 @@ When possible, prefer **Implement and test (PR)** so the change can be reviewed ## Local Setup Instructions 1. Clone the repository. -2. Install GCC 16.1 and CMake 4.3 or higher. -3. Configure the project using the `debug-gcc-16` preset: `cmake --preset debug-gcc-16` -4. Build the project using the `build-debug` preset: `cmake --build --preset build-debug` +2. Install GCC 16.1 and CMake 4.3.3 or higher. +3. Configure the project using the `debug-test-gcc` preset: `cmake --preset debug-test-gcc` +4. Build the project using the `build-debug-test` preset: `cmake --build --preset build-debug-test` ## Testing Guidelines - Use Google Test as the testing framework. -- Run the full test suite before submitting changes: `ctest --preset build-debug` +- Run the full test suite before submitting changes: `ctest --test-dir build/debug --output-on-failure --no-tests=error` +- Check both consumer paths after a build: `cmake -DSERIAL_XML_SOURCE_DIR="$PWD" -DSERIAL_XML_BINARY_DIR="$PWD/build/debug" -P tests/consumer/run.cmake`. - All tests must pass. Do not break existing functionality. - Add new tests for new features. - Add regression tests for bug fixes. diff --git a/README.md b/README.md index fba755c..5eaba22 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# SerialXML +# SerialXML 1.0 ![Banner](./assets/SerialXML.png) ![Build and Tests](https://github.com/EJainDev/SerialXML/actions/workflows/build-and-test.yml/badge.svg) ![C++26](https://img.shields.io/badge/C%2B%2B-26-blue) -![CMake 4.3+](https://img.shields.io/badge/CMake-4.3%2B-orange) +![CMake 4.3.3+](https://img.shields.io/badge/CMake-4.3.3%2B-orange) ![License](https://img.shields.io/badge/license-MIT-lightgray) > Reflection based XML serialization and deserialization for C++26 @@ -41,10 +41,10 @@ import serial_xml; struct Person { int age; std::string favorite_food; -} +}; int main() { - std::print(to_xml(Person{3, "pizza"})); + std::print("{}", serial_xml::to_xml(Person{3, "pizza"})); } ``` @@ -171,11 +171,21 @@ attribute whitespace follow XML normalization rules. cannot be separated and causes an error. `raw` ranges read matching item tags directly from the parent. Unpacked objects and automatically handled optionals keep their tagged representation even with `raw`, following `to_xml` precedence. CDATA fields read the parent's CDATA sections in member -order, matching `to_xml`'s unwrapped CDATA output. Optional or externally split -CDATA fields can be ambiguous; use tagged string fields when boundaries must be +order, matching `to_xml`'s unwrapped CDATA output. The writer splits `]]>` across CDATA sections and emits carriage returns as +character references; the reader rejoins those canonical continuations. Adjacent +fields whose boundaries look exactly like a canonical split (a field ending in +`]]` followed by one starting in `>`) and optional or externally split CDATA fields +can be ambiguous; use tagged string fields when boundaries must be unambiguous. Standard tagged string fields can contain mixed ordinary text and CDATA sections, which are concatenated. +Serialization rejects forbidden XML 1.0 characters and malformed UTF-8 with +`std::invalid_argument`, including custom formatter output. Carriage returns in +text and tabs/newlines/carriage returns in attributes use character references to +preserve their values. Annotation names must be valid XML 1.0 names at compile +time; invalid runtime root overrides throw `std::invalid_argument`. Unicode, +underscores, and literal namespace prefixes are accepted. + See [the deserialization example](examples/deserialization.cpp) for round trips, custom string conversion, optional fields, and independent setters. @@ -184,6 +194,18 @@ custom string conversion, optional fields, and independent setters. ### CMake FetchContent (Recommended) ```cmake +cmake_minimum_required(VERSION 4.3.3) +# import std is experimental; its opt-in key depends on the CMake version. +if(CMAKE_VERSION VERSION_LESS 4.4) + set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD "451f2fe2-a8a2-47c3-bc32-94786d8fc91b") +else() + set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD "f35a9ac6-8463-4d38-8eec-5d6008153e7d") +endif() +set(CMAKE_CXX_STANDARD 26) +set(CMAKE_CXX_MODULE_STD ON) +project(my_app LANGUAGES CXX) + +include(FetchContent) FetchContent_Declare( serial_xml GIT_REPOSITORY https://github.com/EJainDev/SerialXML.git @@ -191,8 +213,8 @@ FetchContent_Declare( ) FetchContent_MakeAvailable(serial_xml) -add_executable(my_tests test.cpp) -target_link_libraries(my_tests PRIVATE serial_xml::serial_xml) +add_executable(my_app main.cpp) +target_link_libraries(my_app PRIVATE serial_xml::serial_xml) ``` ### Install from source @@ -201,26 +223,36 @@ target_link_libraries(my_tests PRIVATE serial_xml::serial_xml) git clone https://github.com/EJainDev/SerialXML.git cd SerialXML -cmake --preset "release-gcc-16" -cmake --build build - -cmake --install build +cmake --preset release-gcc -DBUILD_BENCHMARKS=OFF +cmake --build --preset build-release +cmake --install build/release --prefix "$HOME/.local" ``` -Then, in your `CMakeLists.txt`, put: +Use the same compiler and standard-module setup shown above, then replace the +FetchContent block with: ```cmake -find_package(SerialXML REQUIRED) +find_package(SerialXML 1.0 CONFIG REQUIRED) +add_executable(my_app main.cpp) target_link_libraries(my_app PRIVATE serial_xml::serial_xml) ``` +Configure your application with `-DCMAKE_PREFIX_PATH="$HOME/.local"` for this +install prefix. A source install also installs the fetched StructuralTuple dependency; +if using an existing StructuralTuple package, make its prefix available to consumers too. +Reflection compiler options and the C++26 requirement propagate from the library target. +FetchContent builds default to disabling examples and benchmarks when embedded. + ### Requirements | Component | Min Version | Notes | | --------- | ----------- | ----- | Compiler | GCC 16.1 | C++26 SIMD, Reflection, and more | -CMake | 4.3 | Change `std` experiment key for lower versions | +CMake | 4.3.3 | Ninja; experimental `import std` enabled before `project()` | C++ Standard | 26 | SIMD, Reflection, Annotations | +The dev container uses GCC 16.2.0 and CMake 4.4.3. Other compilers are not currently +supported. StructuralTuple is pinned to a tested commit. + ## Benchmarks The same executable compares serialization and deserialization with SerialXML, @@ -375,7 +407,15 @@ See [the mocking example](examples/mocking.cpp). ### The `prettify` function -A simple function to prettify (add indentation and newlines) the generated XML output. The only parameter is the output and it returns a new string with the output. +`serial_xml::prettify(xml)` validates the input and returns XML with two-space +indentation for element-only content. Subtrees containing direct text (including +whitespace), CDATA, or an `xml:space` attribute retain their original bytes. This +preserves string leaf values and mixed content, including quoted `>` characters, +comments, and processing instructions. Malformed input throws `deserialization_error`. + +Indentation adds whitespace between elements. Use compact `to_xml` output for +round trips involving raw scalar fields alongside child elements, or when every +whitespace node matters. ## Examples @@ -384,9 +424,9 @@ major SerialXML feature. Examples are built by default; configure the project an run an executable from the build directory, for example: ```bash -cmake --preset "release-gcc-16" -cmake --build build -./build/examples/hello_world +cmake --preset release-gcc -DBUILD_BENCHMARKS=OFF +cmake --build --preset build-release +./build/release/examples/hello_world ``` Set `-D BUILD_EXAMPLES=OFF` when configuring CMake to omit them from your build. diff --git a/cmake/SerialXMLConfig.cmake.in b/cmake/SerialXMLConfig.cmake.in index 0f267a6..09353b0 100644 --- a/cmake/SerialXMLConfig.cmake.in +++ b/cmake/SerialXMLConfig.cmake.in @@ -1,7 +1,12 @@ @PACKAGE_INIT@ +include(CMakeFindDependencyMacro) +find_dependency(StructuralTuple CONFIG) + include("${CMAKE_CURRENT_LIST_DIR}/serial_xml_targets.cmake") if(NOT TARGET serial_xml) add_library(serial_xml ALIAS serial_xml::serial_xml) endif() + +check_required_components(SerialXML) diff --git a/include/serial_xml.cxx b/include/serial_xml.cxx index e624cdc..ea117c4 100644 --- a/include/serial_xml.cxx +++ b/include/serial_xml.cxx @@ -10,11 +10,85 @@ import std; import structural_tuple; namespace serial_xml { +namespace detail { +constexpr bool xml_character(std::uint32_t c) { + return c == 9 || c == 10 || c == 13 || (c >= 0x20 && c <= 0xD7FF) || + (c >= 0xE000 && c <= 0xFFFD) || (c >= 0x10000 && c <= 0x10FFFF); +} + +constexpr bool next_codepoint(std::string_view input, std::size_t& i, std::uint32_t& c) { + auto first = static_cast(input[i++]); + c = first; + if (first < 0x80) return xml_character(c); + unsigned length = first >= 0xC2 && first <= 0xDF ? 2 + : first >= 0xE0 && first <= 0xEF ? 3 + : first >= 0xF0 && first <= 0xF4 ? 4 + : 0; + if (!length || input.size() - i < length - 1) return false; + c = first & (0x7F >> length); + for (unsigned j = 1; j < length; ++j) { + auto next = static_cast(input[i++]); + if ((next & 0xC0) != 0x80) return false; + c = (c << 6) | (next & 0x3F); + } + return !(length == 2 && c < 0x80) && !(length == 3 && c < 0x800) && + !(length == 4 && c < 0x10000) && xml_character(c); +} + +constexpr bool xml_name(std::string_view name) { + if (name.empty()) return false; + for (std::size_t i = 0; i < name.size();) { + bool first = i == 0; + std::uint32_t c; + if (!next_codepoint(name, i, c)) return false; + bool start = c == ':' || c == '_' || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= 0xC0 && c <= 0xD6) || (c >= 0xD8 && c <= 0xF6) || + (c >= 0xF8 && c <= 0x2FF) || (c >= 0x370 && c <= 0x37D) || + (c >= 0x37F && c <= 0x1FFF) || (c >= 0x200C && c <= 0x200D) || + (c >= 0x2070 && c <= 0x218F) || (c >= 0x2C00 && c <= 0x2FEF) || + (c >= 0x3001 && c <= 0xD7FF) || (c >= 0xF900 && c <= 0xFDCF) || + (c >= 0xFDF0 && c <= 0xFFFD) || (c >= 0x10000 && c <= 0xEFFFF); + if (!start && (first || !(c == '-' || c == '.' || (c >= '0' && c <= '9') || c == 0xB7 || + (c >= 0x300 && c <= 0x36F) || (c >= 0x203F && c <= 0x2040)))) + return false; + } + return true; +} + +template +void validate_xml_text(std::string_view input, F on_whitespace) { + for (std::size_t i = 0; i < input.size();) { + // Ordinary ASCII needs neither Unicode decoding nor whitespace handling. + if (input.size() - i >= sizeof(std::uint64_t)) { + std::uint64_t bytes; + std::memcpy(&bytes, input.data() + i, sizeof(bytes)); + constexpr auto high_bits = 0x8080808080808080ULL; + constexpr auto spaces = 0x2020202020202020ULL; + if (!(bytes & high_bits) && !((bytes - spaces) & ~bytes & high_bits)) { + i += sizeof(bytes); + continue; + } + } + const auto index = i; + std::uint32_t c; + if (!next_codepoint(input, i, c)) + throw std::invalid_argument("Invalid XML character or UTF-8 in serialized value"); + if (c == 9 || c == 10 || c == 13) on_whitespace(index, c); + } +} + +void validate_xml_text(std::string_view input) { + validate_xml_text(input, [](std::size_t, std::uint32_t) {}); +} +} // namespace detail + export template struct name { char value[N]; constexpr name(const char (&str)[N]) { + if (!detail::xml_name(std::string_view(str, N - 1))) + throw std::invalid_argument("Invalid XML name annotation"); for (std::size_t i = 0; i < N; ++i) value[i] = str[i]; } @@ -51,12 +125,6 @@ export constexpr cdata_ cdata; struct exclude_on_empty_ {}; export constexpr exclude_on_empty_ exclude_on_empty; -constexpr bool is_alpha(const char c) { return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); } - -constexpr bool is_num(const char c) { return (c >= '0' && c <= '9'); } - -constexpr bool is_alnum(const char c) { return is_alpha(c) || is_num(c); } - export template struct format { char value[N]{}; @@ -153,9 +221,14 @@ struct iter { char multiple[N2] = ""; constexpr iter(const char (&s)[N1]) { + if (N1 > 1 && !detail::xml_name(std::string_view(s, N1 - 1))) + throw std::invalid_argument("Invalid XML iteration name"); for (std::size_t i = 0; i < N1; ++i) single[i] = s[i]; } constexpr iter(const char (&s)[N1], const char (&m)[N2]) { + if ((N1 > 1 && !detail::xml_name(std::string_view(s, N1 - 1))) || + (N2 > 1 && !detail::xml_name(std::string_view(m, N2 - 1)))) + throw std::invalid_argument("Invalid XML iteration name"); for (std::size_t i = 0; i < N1; ++i) single[i] = s[i]; for (std::size_t i = 0; i < N2; ++i) multiple[i] = m[i]; } @@ -340,6 +413,8 @@ consteval auto get_annotations() } } + if (!detail::xml_name(name)) throw std::invalid_argument("Invalid XML member name"); + return structural_tuple::tuple{is_attribute, is_cdata, is_no_iter, @@ -560,7 +635,7 @@ consteval auto get_attribute_prefix() { thread_local std::vector escape_flags; -auto get_escape_bitmask(std::string_view input) -> std::size_t { +auto get_escape_bitmask(std::string_view input, bool attribute = false) -> std::size_t { using simd_t = std::simd::vec; const auto blocks = (input.size() + 63) / 64; escape_flags.assign(blocks, 0); @@ -576,6 +651,10 @@ auto get_escape_bitmask(std::string_view input) -> std::size_t { } } } + // Keep normalization-sensitive whitespace out of the partial SIMD mask. + detail::validate_xml_text(input, [&](std::size_t index, std::uint32_t c) { + if (c == 13 || attribute) escape_flags[index / 64] |= std::uint64_t{1} << (index % 64); + }); return blocks; } @@ -613,6 +692,15 @@ auto copy_with_escapes(char* buffer, std::string_view input, std::size_t blocks) case '\'': entity = "'"; break; + case '\r': + entity = " "; + break; + case '\n': + entity = " "; + break; + case '\t': + entity = " "; + break; default: std::unreachable(); } @@ -681,7 +769,7 @@ void add_attribute(std::string& result, std::string& buffer, const auto& value) buffer = format_value(value); } - auto padded_size = get_escape_bitmask(buffer); + auto padded_size = get_escape_bitmask(buffer, true); auto num_escapes = count_escapes(padded_size); @@ -772,16 +860,21 @@ void add_child(std::string& result, std::string& buffer, const auto& value) { }); } } else if constexpr (is_cdata) { - if constexpr (formatter.is_empty() && - (std::is_same_v || std::is_same_v)) { - result += ""; - } else { - result += "(value); - result += "]]>"; + buffer = format_value(value); + detail::validate_xml_text(buffer); + result += "")) { + result += "]]]]>"; + i += 3; + } else if (buffer[i] == '\r') { + result += "]]> "; } else { if constexpr (formatter.is_empty() && std::is_same_v) { buffer = std::ref(value); @@ -900,6 +993,7 @@ void to_xml(const T& value, std::string& result, std::string& buffer, bool first if (!fixed_name.empty()) { buffer = fixed_name; } else { + buffer.clear(); template for (constexpr auto a : annotations) { if constexpr (std::meta::has_template_arguments(std::meta::type_of(a)) && std::meta::template_of(std::meta::type_of(a)) == ^^::serial_xml::name) { @@ -920,6 +1014,7 @@ void to_xml(const T& value, std::string& result, std::string& buffer, bool first } } + if (!detail::xml_name(buffer)) throw std::invalid_argument("Invalid XML root name"); std::string name{buffer}; std::format_to(std::back_inserter(result), "<{}", name); @@ -938,12 +1033,7 @@ void to_xml(const T& value, std::string& result, std::string& buffer, bool first static constexpr auto view_name = std::string_view(m_name); - static_assert( - std::ranges::all_of( - view_name, [](char c) { return is_alnum(c) || c == '_' || c == '-' || c == '.'; }) || - !(view_name[0] == '_' || view_name[0] == '-' || view_name[0] == '.' || - is_num(view_name[0]) || std::ranges::starts_with(view_name, std::string_view("xml"))), - std::string("Invalid XML name: '") + std::string(view_name) + "'"); + static_assert(detail::xml_name(view_name), "Invalid XML name: " + std::string(view_name)); if constexpr (is_std && std::meta::template_of(value_m_t) == ^^std::optional) { handle_stl( @@ -978,13 +1068,7 @@ void to_xml(const T& value, std::string& result, std::string& buffer, bool first static constexpr auto view_name = std::string_view(m_name); - static_assert( - std::ranges::all_of( - view_name, [](char c) { return is_alnum(c) || c == '_' || c == '-' || c == '.'; }) || - !(view_name[0] == '_' || view_name[0] == '-' || view_name[0] == '.' || - is_num(view_name[0]) || - std::ranges::starts_with(view_name, std::string_view("xml"))), - std::string("Invalid XML name: '") + std::string(view_name) + "'"); + static_assert(detail::xml_name(view_name), "Invalid XML name: " + std::string(view_name)); if constexpr (iter_names.first == nullptr && is_std && !is_no_iter) { handle_stl= 0x20 && c <= 0xD7FF) || - (c >= 0xE000 && c <= 0xFFFD) || (c >= 0x10000 && c <= 0x10FFFF); - } + static bool valid_character(std::uint32_t c) { return xml_character(c); } static bool plain_ascii(std::string_view value, bool entities) { constexpr std::uint64_t high_bits = 0x8080808080808080ULL; constexpr std::uint64_t spaces = 0x2020202020202020ULL; @@ -1642,38 +1723,7 @@ class xml_reader { return result; } void validate_unicode_name(std::string_view result) { - decode(result, false); - // XML NameStartChar/NameChar ranges are wider than ASCII but narrower than UTF-8. - for (std::size_t i = 0; i < result.size();) { - auto c = static_cast(result[i]); - std::uint32_t codepoint = c; - unsigned length = 1; - if (c >= 0x80) { - length = c < 0xE0 ? 2 : c < 0xF0 ? 3 : 4; - codepoint = c & (0x7F >> length); - for (unsigned j = 1; j < length; ++j) - codepoint = (codepoint << 6) | (static_cast(result[i + j]) & 0x3F); - } - bool start = - codepoint == ':' || codepoint == '_' || (codepoint >= 'A' && codepoint <= 'Z') || - (codepoint >= 'a' && codepoint <= 'z') || (codepoint >= 0xC0 && codepoint <= 0xD6) || - (codepoint >= 0xD8 && codepoint <= 0xF6) || (codepoint >= 0xF8 && codepoint <= 0x2FF) || - (codepoint >= 0x370 && codepoint <= 0x37D) || - (codepoint >= 0x37F && codepoint <= 0x1FFF) || - (codepoint >= 0x200C && codepoint <= 0x200D) || - (codepoint >= 0x2070 && codepoint <= 0x218F) || - (codepoint >= 0x2C00 && codepoint <= 0x2FEF) || - (codepoint >= 0x3001 && codepoint <= 0xD7FF) || - (codepoint >= 0xF900 && codepoint <= 0xFDCF) || - (codepoint >= 0xFDF0 && codepoint <= 0xFFFD) || - (codepoint >= 0x10000 && codepoint <= 0xEFFFF); - bool continuation = start || codepoint == '-' || codepoint == '.' || - (codepoint >= '0' && codepoint <= '9') || codepoint == 0xB7 || - (codepoint >= 0x300 && codepoint <= 0x36F) || - (codepoint >= 0x203F && codepoint <= 0x2040); - if (!(i == 0 ? start : continuation)) fail("Invalid XML name character"); - i += length; - } + if (!xml_name(result)) fail("Invalid XML name character or UTF-8"); } void comment() { expect("" + : rest.starts_with("" + : "?>"; + i = xml.find(terminator, i) + std::string_view(terminator).size(); + if (rest.starts_with("') { + ++i; + break; + } } - append_indent(indent_level); - result.append(text); - result += '\n'; } - - if (text_end == std::string::npos) { - break; + const auto tag = std::string_view(xml).substr(begin, i - begin); + bool closing = tag.starts_with(""); + int depth = static_cast(stack.size()); + if (closing) { + --depth; + tokens[stack.back()].match = tokens.size(); + stack.pop_back(); + } + tokens.push_back({begin, i, tokens.size(), depth, tag.contains("xml:space")}); + if (opening) stack.push_back(tokens.size() - 1); + } + + std::string result = xml.substr(0, bom_size); + result.reserve(xml.size()); + for (std::size_t i = 0; i < tokens.size(); ++i) { + const auto& t = tokens[i]; + if (result.size() > bom_size && result.back() != '\n') result += '\n'; + result.append(static_cast(t.depth) * 2, ' '); + const auto end = tokens[t.match].end; + if (t.preserve && t.match != i) { + result.append(xml, t.begin, end - t.begin); + i = t.match; + } else { + result.append(xml, t.begin, t.end - t.begin); } - i = text_end; - } - - if (!result.empty() && result.back() == '\n') { - result.pop_back(); } - return result; } } // namespace serial_xml diff --git a/tests/consumer/CMakeLists.txt b/tests/consumer/CMakeLists.txt new file mode 100644 index 0000000..66c45cb --- /dev/null +++ b/tests/consumer/CMakeLists.txt @@ -0,0 +1,30 @@ +cmake_minimum_required(VERSION 4.3.3) +if(CMAKE_VERSION VERSION_LESS 4.4) + set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD "451f2fe2-a8a2-47c3-bc32-94786d8fc91b") +else() + set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD "f35a9ac6-8463-4d38-8eec-5d6008153e7d") +endif() +set(CMAKE_CXX_STANDARD 26) +set(CMAKE_CXX_MODULE_STD ON) +project(serial_xml_consumer LANGUAGES CXX) + +if(SERIAL_XML_SOURCE_DIR) + include(FetchContent) + FetchContent_Declare(serial_xml SOURCE_DIR "${SERIAL_XML_SOURCE_DIR}") + FetchContent_MakeAvailable(serial_xml) + if(BUILD_EXAMPLES OR BUILD_BENCHMARKS) + message(FATAL_ERROR "Embedded builds must default to library-only") + endif() +else() + find_package(SerialXML CONFIG REQUIRED) +endif() + +add_executable(consumer main.cpp) +target_link_libraries(consumer PRIVATE serial_xml::serial_xml) + +foreach(case RANGE 1 6) + add_executable(invalid_name_${case} EXCLUDE_FROM_ALL invalid_name.cpp) + target_compile_definitions(invalid_name_${case} + PRIVATE INVALID_NAME_CASE=${case}) + target_link_libraries(invalid_name_${case} PRIVATE serial_xml::serial_xml) +endforeach() diff --git a/tests/consumer/invalid_name.cpp b/tests/consumer/invalid_name.cpp new file mode 100644 index 0000000..9a623b5 --- /dev/null +++ b/tests/consumer/invalid_name.cpp @@ -0,0 +1,30 @@ +import std; +import serial_xml; + +#if INVALID_NAME_CASE == 1 +struct Record { + [[= serial_xml::name{"1bad"}]] int value; +}; +#elif INVALID_NAME_CASE == 2 +struct Record { + [[ = serial_xml::attribute, = serial_xml::name{"bad name"} ]] int value; +}; +#elif INVALID_NAME_CASE == 3 +struct[[= serial_xml::name{"bad name"}]] Record { + int value; +}; +#elif INVALID_NAME_CASE == 4 +struct Record { + [[= serial_xml::iter{"1bad", "items"}]] std::vector value; +}; +#elif INVALID_NAME_CASE == 5 +struct Record { + [[= serial_xml::iter{"item", "bad name"}]] std::vector value; +}; +#else +struct Record { + [[= serial_xml::name{""}]] int value; +}; +#endif + +int main() { auto xml = serial_xml::to_xml(Record{}); } diff --git a/tests/consumer/main.cpp b/tests/consumer/main.cpp new file mode 100644 index 0000000..089d6b2 --- /dev/null +++ b/tests/consumer/main.cpp @@ -0,0 +1,14 @@ +import std; +import serial_xml; + +struct Person { + int age; + std::string favorite_food; +}; + +int main() { + const auto xml = serial_xml::to_xml(Person{3, "pizza"}); + std::print("{}", xml); + const auto person = serial_xml::from_xml(xml); + return person.age == 3 && person.favorite_food == "pizza" ? 0 : 1; +} diff --git a/tests/consumer/run.cmake b/tests/consumer/run.cmake new file mode 100644 index 0000000..9c3d336 --- /dev/null +++ b/tests/consumer/run.cmake @@ -0,0 +1,84 @@ +# Run after a library build; all smoke artifacts stay in its ignored build +# directory. +cmake_minimum_required(VERSION 4.3.3) +if(NOT SERIAL_XML_SOURCE_DIR OR NOT SERIAL_XML_BINARY_DIR) + message(FATAL_ERROR "Set SERIAL_XML_SOURCE_DIR and SERIAL_XML_BINARY_DIR") +endif() +function(run) + execute_process(COMMAND ${ARGV} RESULT_VARIABLE result) + if(NOT result STREQUAL "0") + message(FATAL_ERROR "Consumer check failed: ${ARGV}") + endif() +endfunction() +set(smoke "${SERIAL_XML_BINARY_DIR}/consumer-smoke") +set(dependency_args) +if(EXISTS "${SERIAL_XML_BINARY_DIR}/_deps/structural_tuple-src") + list( + APPEND + dependency_args + "-DFETCHCONTENT_SOURCE_DIR_STRUCTURAL_TUPLE=${SERIAL_XML_BINARY_DIR}/_deps/structural_tuple-src" + ) +endif() +run("${CMAKE_COMMAND}" + -S + "${SERIAL_XML_SOURCE_DIR}/tests/consumer" + -B + "${smoke}/fetch" + -G + Ninja + "-DSERIAL_XML_SOURCE_DIR=${SERIAL_XML_SOURCE_DIR}" + ${dependency_args}) +run("${CMAKE_COMMAND}" --build "${smoke}/fetch" --target consumer) +run("${smoke}/fetch/consumer") +foreach(case RANGE 1 6) + execute_process( + COMMAND "${CMAKE_COMMAND}" --build "${smoke}/fetch" --target + invalid_name_${case} + RESULT_VARIABLE result + OUTPUT_VARIABLE output + ERROR_VARIABLE errors) + if(result STREQUAL "0" OR NOT "${output}${errors}" MATCHES "Invalid XML") + message( + FATAL_ERROR + "Expected invalid XML name diagnostic for case ${case}: ${output}${errors}" + ) + endif() +endforeach() +run("${CMAKE_COMMAND}" --install "${SERIAL_XML_BINARY_DIR}" --prefix + "${smoke}/prefix") +run("${CMAKE_COMMAND}" + -S + "${SERIAL_XML_SOURCE_DIR}/tests/consumer" + -B + "${smoke}/installed" + -G + Ninja + "-DCMAKE_PREFIX_PATH=${smoke}/prefix") +run("${CMAKE_COMMAND}" --build "${smoke}/installed" --target consumer) +run("${smoke}/installed/consumer") + +# Compile the published Quick Start and FetchContent setup verbatim, using local +# sources. +file(READ "${SERIAL_XML_SOURCE_DIR}/README.md" readme) +string(REGEX MATCH "```cpp\n// main.cpp\n([^`]*)```" quick_start "${readme}") +if(NOT quick_start) + message(FATAL_ERROR "README Quick Start code block not found") +endif() +file(WRITE "${smoke}/readme/main.cpp" "${CMAKE_MATCH_1}") +string(REGEX MATCH "```cmake\n(cmake_minimum_required[^`]*)```" cmake_example + "${readme}") +if(NOT cmake_example) + message(FATAL_ERROR "README CMake code block not found") +endif() +file(WRITE "${smoke}/readme/CMakeLists.txt" "${CMAKE_MATCH_1}") +run("${CMAKE_COMMAND}" + -S + "${smoke}/readme" + -B + "${smoke}/readme-build" + -G + Ninja + "-DFETCHCONTENT_SOURCE_DIR_SERIAL_XML=${SERIAL_XML_SOURCE_DIR}" + ${dependency_args}) +run("${CMAKE_COMMAND}" --build "${smoke}/readme-build" --target my_app) +run("${smoke}/readme-build/my_app") diff --git a/tests/test_serial_xml.cpp b/tests/test_serial_xml.cpp index 0c5b9b5..0d43fc6 100644 --- a/tests/test_serial_xml.cpp +++ b/tests/test_serial_xml.cpp @@ -1541,3 +1541,141 @@ TEST(FromXml, MalformedLateTextDoesNotUpdateInPlaceTarget) { EXPECT_EQ(original.first, "original first"); EXPECT_EQ(original.second, "original second"); } + +TEST(Escaping, PreservesXmlWhitespace) { + struct WhitespaceRecord { + [[= serial_xml::attribute]] std::string attribute; + std::string text; + [[= serial_xml::raw]] std::string raw; + }; + const WhitespaceRecord value{"a\t\n\r\r\nb", "a\r\nb\rc", "x\ry"}; + const auto xml = clean_to_xml(value); + EXPECT_EQ(xml, + "" + "a \nb cx y"); + auto parsed = serial_xml::from_xml(xml); + EXPECT_EQ(parsed.attribute, value.attribute); + EXPECT_EQ(parsed.text, value.text); + EXPECT_EQ(parsed.raw, value.raw); +} + +TEST(Escaping, CdataSplitsTerminatorsAndCarriageReturns) { + struct CdataRecord { + [[= serial_xml::cdata]] std::string first; + [[= serial_xml::cdata]] std::string second; + }; + const CdataRecord value{"a]]>b\r\nc]]>\r", "second"}; + const auto xml = clean_to_xml(value); + EXPECT_EQ(xml, + "b]]> " + "]]> "); + auto parsed = serial_xml::from_xml(xml); + EXPECT_EQ(parsed.first, value.first); + EXPECT_EQ(parsed.second, value.second); +} + +TEST(Escaping, RejectsInvalidCharactersAndUtf8) { + struct Text { + std::string value; + }; + struct Attribute { + [[= serial_xml::attribute]] std::string value; + }; + struct Raw { + [[= serial_xml::raw]] std::string value; + }; + struct Cdata { + [[= serial_xml::cdata]] std::string value; + }; + for (const auto& invalid : {std::string("a\0b", 3), std::string("\x01"), std::string("\xC0\xAF"), + std::string("\xED\xA0\x80"), std::string("\xF4\x90\x80\x80"), + std::string("\xEF\xBF\xBE"), std::string("\xE2\x82")}) { + EXPECT_THROW(clean_to_xml(Text{invalid}), std::invalid_argument); + EXPECT_THROW(clean_to_xml(Attribute{invalid}), std::invalid_argument); + EXPECT_THROW(clean_to_xml(Raw{invalid}), std::invalid_argument); + EXPECT_THROW(clean_to_xml(Cdata{invalid}), std::invalid_argument); + } + const std::string unicode = "é😀\t\n"; + EXPECT_EQ(serial_xml::from_xml(clean_to_xml(Text{unicode})).value, unicode); +} + +TEST(Naming, ValidatesRuntimeRootOverrides) { + struct RuntimeRootRecord { + int value; + }; + for (const auto& invalid : {"1bad", "bad name", "-bad", ".bad", "bad<", "\xC0\xAF"}) { + EXPECT_THROW(serial_xml::to_xml(RuntimeRootRecord{}, false, invalid), std::invalid_argument); + EXPECT_THROW(serial_xml::from_xml( + "0", invalid), + std::invalid_argument); + } + for (const auto& valid : {"_record", "ns:record", "é", "xml_record", "record-1.0"}) { + const auto xml = serial_xml::to_xml(RuntimeRootRecord{7}, false, valid); + EXPECT_EQ(serial_xml::from_xml(xml, valid).value, 7); + } +} + +TEST(Naming, SupportsUnicodeAndPrefixedAnnotations) { + struct[[= serial_xml::name{"ns:记录"}]] UnicodeNameRecord { + [[ = serial_xml::attribute, = serial_xml::name{"_é"} ]] int value; + [[= serial_xml::iter{"子", "ns:items"}]] std::vector items; + }; + const auto xml = clean_to_xml(UnicodeNameRecord{3, {4}}); + EXPECT_EQ(xml, "<子>4"); + EXPECT_EQ(serial_xml::from_xml(xml).items, std::vector{4}); +} + +TEST(Prettify, PreservesTextAndMixedContent) { + for (const std::string xml : + {"hello", " hello\n ", "ab", + "]]>", ""}) + EXPECT_EQ(serial_xml::prettify(xml), xml); + EXPECT_EQ(serial_xml::prettify("hello"), + "\n hello\n \n"); + struct PrettyTextRecord { + std::string text; + }; + const auto xml = serial_xml::prettify(clean_to_xml(PrettyTextRecord{"hello"})); + EXPECT_EQ(serial_xml::from_xml(xml).text, "hello"); + EXPECT_EQ(serial_xml::prettify(xml), xml); +} + +TEST(Prettify, HandlesQuotedMarkupCommentsAndInstructions) { + EXPECT_EQ( + serial_xml::prettify(" b?>" + "helloy]]>"), + "\n\n \n b?>\n" + " hello\n y]]>\n"); + EXPECT_THROW(serial_xml::prettify(""), serial_xml::deserialization_error); +} + +TEST(Prettify, PreservesUtf8BomBeforeDeclaration) { + const std::string xml = "\xEF\xBB\xBFhello"; + EXPECT_EQ(serial_xml::prettify(xml), + "\xEF\xBB\xBF\n\n hello\n"); + EXPECT_EQ(serial_xml::prettify("\xEF\xBB\xBFhello"), + "\xEF\xBB\xBFhello"); +} + +TEST(Escaping, WhitespaceAcrossSimdAndMaskBoundaries) { + struct BoundaryWhitespace { + [[= serial_xml::attribute]] std::string value; + }; + for (std::size_t length : {15, 16, 31, 32, 63, 64, 65, 127, 128}) { + std::string value(length, 'a'); + value += "\t\n\r"; + const auto xml = clean_to_xml(BoundaryWhitespace{value}); + EXPECT_EQ(xml, + ""); + EXPECT_EQ(serial_xml::from_xml(xml).value, value); + } +} + +std::string invalid_xml_formatter(const std::string&) { return std::string(1, '\x01'); } + +TEST(Escaping, ValidatesCustomFormatterOutput) { + struct InvalidFormattedText { + [[= serial_xml::format{invalid_xml_formatter}]] std::string value; + }; + EXPECT_THROW(clean_to_xml(InvalidFormattedText{"valid"}), std::invalid_argument); +}