diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..1113660 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,7 @@ +--- +Checks: '-*' +WarningsAsErrors: '*' +HeaderFilterRegex: 'src/main/(cpp|include)/.*' +FormatStyle: none +ExtraArgsBefore: + - '-std=c++23' diff --git a/.github/codecov.yml b/.github/codecov.yml index 8271a51..83154d2 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -5,11 +5,11 @@ coverage: status: project: default: - target: auto + target: 85% threshold: 1% patch: default: - target: auto + target: 85% threshold: 1% comment: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eca6401..8cf22cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -176,12 +176,34 @@ jobs: set -euo pipefail ctest --test-dir build --output-on-failure + - name: Packaging smoke (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + chmod +x scripts/ci/smoke_packaging.py + python3 scripts/ci/smoke_packaging.py \ + --binary build/prebyte \ + --platform "${{ matrix.platform }}" \ + --arch "${{ matrix.arch }}" \ + --checks binary reqpack index + - name: Test (Windows) if: runner.os == 'Windows' shell: pwsh run: | ctest --test-dir build --output-on-failure + - name: Packaging smoke (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + python scripts/ci/smoke_packaging.py ` + --binary build/prebyte.exe ` + --platform windows ` + --arch ${{ matrix.arch }} ` + --checks binary + - name: Package release artifact (Unix) if: startsWith(github.ref, 'refs/tags/v') && runner.os != 'Windows' shell: bash @@ -276,13 +298,8 @@ jobs: shell: bash run: | set -euo pipefail - gcovr \ - --root "$GITHUB_WORKSPACE" \ - --object-directory "$GITHUB_WORKSPACE/build-cmake/coverage" \ - --filter "$GITHUB_WORKSPACE/src/main/cpp" \ - --gcov-ignore-parse-errors=negative_hits.warn \ - --xml-pretty \ - --output build-cmake/coverage/coverage.xml + chmod +x scripts/ci/generate_coverage_report.sh + COVERAGE_MIN_LINE=85 ./scripts/ci/generate_coverage_report.sh - name: Upload coverage artifact uses: actions/upload-artifact@v4 @@ -300,6 +317,186 @@ jobs: fail_ci_if_error: false verbose: true + clang-tidy: + name: clang-tidy-linux-x86_64 + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install clang-tidy prerequisites + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends clang clang-tidy ninja-build pkg-config + + - name: Bootstrap vcpkg + shell: bash + run: | + set -euo pipefail + VCPKG_ROOT="$RUNNER_TEMP/vcpkg" + git clone --depth 1 https://github.com/microsoft/vcpkg.git "$VCPKG_ROOT" + "$VCPKG_ROOT/bootstrap-vcpkg.sh" -disableMetrics + echo "VCPKG_ROOT=$VCPKG_ROOT" >> "$GITHUB_ENV" + "$VCPKG_ROOT/vcpkg" install "lua:x64-linux" + + - name: Run clang-tidy analyze + shell: bash + run: | + set -euo pipefail + chmod +x scripts/ci/run_clang_tidy.sh + ./scripts/ci/run_clang_tidy.sh analyze \ + -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" \ + -DVCPKG_TARGET_TRIPLET=x64-linux + + - name: Run clang-tidy lint + shell: bash + run: | + set -euo pipefail + ./scripts/ci/run_clang_tidy.sh lint + + - name: Run clang-tidy security + shell: bash + run: | + set -euo pipefail + ./scripts/ci/run_clang_tidy.sh security + + sanitize: + name: asan-ubsan-linux-x86_64 + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install sanitizer prerequisites + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends clang ninja-build pkg-config + + - name: Bootstrap vcpkg + shell: bash + run: | + set -euo pipefail + VCPKG_ROOT="$RUNNER_TEMP/vcpkg" + git clone --depth 1 https://github.com/microsoft/vcpkg.git "$VCPKG_ROOT" + "$VCPKG_ROOT/bootstrap-vcpkg.sh" -disableMetrics + echo "VCPKG_ROOT=$VCPKG_ROOT" >> "$GITHUB_ENV" + "$VCPKG_ROOT/vcpkg" install "lua:x64-linux" + + - name: Run ASan/UBSan tests + shell: bash + run: | + set -euo pipefail + chmod +x scripts/ci/run_sanitize_tests.sh + ./scripts/ci/run_sanitize_tests.sh asan \ + -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" \ + -DVCPKG_TARGET_TRIPLET=x64-linux + + tsan: + name: tsan-linux-x86_64 + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install sanitizer prerequisites + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends clang ninja-build pkg-config + + - name: Bootstrap vcpkg + shell: bash + run: | + set -euo pipefail + VCPKG_ROOT="$RUNNER_TEMP/vcpkg" + git clone --depth 1 https://github.com/microsoft/vcpkg.git "$VCPKG_ROOT" + "$VCPKG_ROOT/bootstrap-vcpkg.sh" -disableMetrics + echo "VCPKG_ROOT=$VCPKG_ROOT" >> "$GITHUB_ENV" + "$VCPKG_ROOT/vcpkg" install "lua:x64-linux" + + - name: Run TSan tests + shell: bash + run: | + set -euo pipefail + chmod +x scripts/ci/run_sanitize_tests.sh + ./scripts/ci/run_sanitize_tests.sh tsan \ + -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" \ + -DVCPKG_TARGET_TRIPLET=x64-linux + + msan: + name: msan-linux-x86_64 + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install sanitizer prerequisites + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends clang ninja-build pkg-config + + - name: Bootstrap vcpkg with MSan + shell: bash + env: + VCPKG_ROOT: ${{ runner.temp }}/vcpkg + run: | + set -euo pipefail + chmod +x scripts/ci/bootstrap_vcpkg_lua.sh + ./scripts/ci/bootstrap_vcpkg_lua.sh msan + echo "VCPKG_ROOT=$VCPKG_ROOT" >> "$GITHUB_ENV" + + - name: Run MSan tests + shell: bash + run: | + set -euo pipefail + chmod +x scripts/ci/run_sanitize_tests.sh + ./scripts/ci/run_sanitize_tests.sh msan \ + -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" \ + -DVCPKG_TARGET_TRIPLET=x64-linux-msan \ + -DVCPKG_OVERLAY_TRIPLETS="$GITHUB_WORKSPACE/cmake/vcpkg/triplets" + + fuzz: + name: fuzz-linux-x86_64 + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install fuzzer prerequisites + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends clang ninja-build pkg-config + + - name: Bootstrap vcpkg + shell: bash + run: | + set -euo pipefail + VCPKG_ROOT="$RUNNER_TEMP/vcpkg" + git clone --depth 1 https://github.com/microsoft/vcpkg.git "$VCPKG_ROOT" + "$VCPKG_ROOT/bootstrap-vcpkg.sh" -disableMetrics + echo "VCPKG_ROOT=$VCPKG_ROOT" >> "$GITHUB_ENV" + "$VCPKG_ROOT/vcpkg" install "lua:x64-linux" + + - name: Run fuzzers + shell: bash + env: + PREBYTE_FUZZ_MAX_TOTAL_TIME: 60 + run: | + set -euo pipefail + chmod +x scripts/ci/run_fuzzers.sh + ./scripts/ci/run_fuzzers.sh \ + -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" \ + -DVCPKG_TARGET_TRIPLET=x64-linux + release: if: startsWith(github.ref, 'refs/tags/v') needs: build-test @@ -376,6 +573,15 @@ jobs: docker buildx create --name prebyte-builder --use docker buildx inspect --bootstrap + - name: Docker packaging smoke + shell: bash + run: | + set -euo pipefail + chmod +x scripts/ci/smoke_packaging.py + python3 scripts/ci/smoke_packaging.py \ + --checks docker \ + --version "${{ steps.meta.outputs.version }}" + - name: Build and push Docker image shell: bash run: | diff --git a/.gitignore b/.gitignore index 4e075ab..ececbca 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,13 @@ dist *.pbc *.gcov.json.gz tools/benchmark_compare/target/ + +# libFuzzer corpus growth (generated during fuzz runs) +tests/fault_tolerance/fuzz/corpus/ + +# libFuzzer crash/timeout/leak artifacts (repo root or build dir) +crash-* +slow-unit-* +timeout-* +leak-* +oom-* diff --git a/CMakeLists.txt b/CMakeLists.txt index 9687559..5dc5af4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.31) -project(Prebyte VERSION 1.0.5 LANGUAGES CXX) +project(Prebyte VERSION 1.1.0 LANGUAGES CXX) include(CTest) @@ -12,7 +12,11 @@ include(GNUInstallDirs) option(PREBYTE_BUILD_TESTS "Build test binary" ON) option(PREBYTE_BUILD_BENCHMARKS "Build benchmark binary" ON) +option(PREBYTE_BUILD_FUZZERS "Build libFuzzer targets" OFF) option(PREBYTE_ENABLE_COVERAGE "Enable GCC/GCov coverage instrumentation" OFF) +option(PREBYTE_ENABLE_SANITIZERS "Enable Clang sanitizer instrumentation" OFF) +set(PREBYTE_SANITIZERS "address,undefined" + CACHE STRING "Comma-separated Clang sanitizers: address, undefined, thread, memory") find_package(Lua REQUIRED) @@ -34,7 +38,7 @@ file(GLOB APP_SOURCES CONFIGURE_DEPENDS src/main/cpp/app/*.cpp) file(GLOB CLI_SOURCES CONFIGURE_DEPENDS src/main/cpp/cli/*.cpp) file(GLOB CONFIG_SOURCES CONFIGURE_DEPENDS src/main/cpp/config/*.cpp) file(GLOB IO_SOURCES CONFIGURE_DEPENDS src/main/cpp/io/*.cpp) -file(GLOB RUNTIME_SOURCES CONFIGURE_DEPENDS src/main/cpp/runtime/*.cpp) +file(GLOB_RECURSE RUNTIME_SOURCES CONFIGURE_DEPENDS src/main/cpp/runtime/*.cpp) file(GLOB SUPPORT_SOURCES CONFIGURE_DEPENDS src/main/cpp/support/*.cpp) file(GLOB TEMPLATE_AST_SOURCES CONFIGURE_DEPENDS src/main/cpp/template/ast/*.cpp) file(GLOB TEMPLATE_LEXER_SOURCES CONFIGURE_DEPENDS src/main/cpp/template/lexer/*.cpp) @@ -81,23 +85,90 @@ if(PREBYTE_ENABLE_COVERAGE) endif() endif() +if(PREBYTE_ENABLE_SANITIZERS) + if(NOT CMAKE_CXX_COMPILER_ID MATCHES "Clang|AppleClang") + message(FATAL_ERROR "PREBYTE_ENABLE_SANITIZERS requires Clang") + endif() + + if(PREBYTE_SANITIZERS STREQUAL "") + set(PREBYTE_SANITIZERS "address,undefined") + endif() + + string(REPLACE "," ";" PREBYTE_SANITIZER_LIST "${PREBYTE_SANITIZERS}") + set(PREBYTE_HAS_ADDRESS_SANITIZER OFF) + set(PREBYTE_HAS_THREAD_SANITIZER OFF) + set(PREBYTE_HAS_MEMORY_SANITIZER OFF) + + foreach(PREBYTE_SANITIZER IN LISTS PREBYTE_SANITIZER_LIST) + string(STRIP "${PREBYTE_SANITIZER}" PREBYTE_SANITIZER) + if(PREBYTE_SANITIZER STREQUAL "address") + set(PREBYTE_HAS_ADDRESS_SANITIZER ON) + elseif(PREBYTE_SANITIZER STREQUAL "thread") + set(PREBYTE_HAS_THREAD_SANITIZER ON) + elseif(PREBYTE_SANITIZER STREQUAL "memory") + set(PREBYTE_HAS_MEMORY_SANITIZER ON) + endif() + endforeach() + + if(PREBYTE_HAS_THREAD_SANITIZER AND PREBYTE_HAS_MEMORY_SANITIZER) + message(FATAL_ERROR "TSan and MSan cannot be combined") + endif() + if(PREBYTE_HAS_ADDRESS_SANITIZER AND PREBYTE_HAS_THREAD_SANITIZER) + message(FATAL_ERROR "ASan and TSan cannot be combined") + endif() + if(PREBYTE_HAS_ADDRESS_SANITIZER AND PREBYTE_HAS_MEMORY_SANITIZER) + message(FATAL_ERROR "ASan and MSan cannot be combined") + endif() + + set(PREBYTE_SANITIZER_COMPILE_OPTIONS -fsanitize=${PREBYTE_SANITIZERS} -fno-omit-frame-pointer -g) + if(PREBYTE_HAS_MEMORY_SANITIZER) + list(APPEND PREBYTE_SANITIZER_COMPILE_OPTIONS -fsanitize-memory-track-origins=1) + endif() + set(PREBYTE_SANITIZER_LINK_OPTIONS -fsanitize=${PREBYTE_SANITIZERS}) + + add_compile_options("$<$:${PREBYTE_SANITIZER_COMPILE_OPTIONS}>") + add_link_options(${PREBYTE_SANITIZER_LINK_OPTIONS}) +endif() + add_executable(prebyte src/main/cpp/main.cpp) target_link_libraries(prebyte PRIVATE prebyte_core) install(TARGETS prebyte RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) install(FILES README.md LICENSE DESTINATION ${CMAKE_INSTALL_DATADIR}/prebyte) if(PREBYTE_BUILD_TESTS) - file(GLOB TEST_UNIT_SOURCES CONFIGURE_DEPENDS tests/unit/*.cpp) - file(GLOB TEST_INTEGRATION_SOURCES CONFIGURE_DEPENDS tests/integration/*.cpp) + file(GLOB TEST_CORRECTNESS_UNIT_SOURCES CONFIGURE_DEPENDS tests/correctness/unit/*.cpp) + file(GLOB TEST_CORRECTNESS_INTEGRATION_SOURCES CONFIGURE_DEPENDS tests/correctness/integration/*.cpp) + file(GLOB TEST_CORRECTNESS_PROPERTY_SOURCES CONFIGURE_DEPENDS tests/correctness/property/*.cpp) + file(GLOB TEST_FAULT_TOLERANCE_REGRESSION_SOURCES CONFIGURE_DEPENDS tests/fault_tolerance/regression/*.cpp) + file(GLOB TEST_SECURITY_SOURCES CONFIGURE_DEPENDS tests/security/*.cpp) + file(GLOB TEST_CONCURRENCY_SOURCES CONFIGURE_DEPENDS tests/concurrency/*.cpp) + file(GLOB TEST_PORTABILITY_CLI_SOURCES CONFIGURE_DEPENDS tests/portability/cli/*.cpp) + file(GLOB TEST_PORTABILITY_PACKAGING_SOURCES CONFIGURE_DEPENDS tests/portability/packaging/*.cpp) + + set(PREBYTE_TEST_SOURCES + ${TEST_CORRECTNESS_UNIT_SOURCES} + ${TEST_CORRECTNESS_INTEGRATION_SOURCES} + ${TEST_CORRECTNESS_PROPERTY_SOURCES} + ${TEST_FAULT_TOLERANCE_REGRESSION_SOURCES} + ${TEST_SECURITY_SOURCES} + ${TEST_CONCURRENCY_SOURCES} + ${TEST_PORTABILITY_CLI_SOURCES} + ${TEST_PORTABILITY_PACKAGING_SOURCES} + ) add_executable(prebyte_tests - tests/TestHarness.cpp - tests/TestMain.cpp - ${TEST_UNIT_SOURCES} - ${TEST_INTEGRATION_SOURCES} + tests/harness/TestHarness.cpp + tests/harness/TestMain.cpp + tests/support/CliProcess.cpp + ${PREBYTE_TEST_SOURCES} ) - target_include_directories(prebyte_tests PRIVATE tests) + target_include_directories(prebyte_tests PRIVATE tests tests/harness tests/support) target_link_libraries(prebyte_tests PRIVATE prebyte_core) + target_compile_definitions(prebyte_tests PRIVATE + PREBYTE_CLI_BINARY="$" + PREBYTE_CLI_WORKDIR="${CMAKE_SOURCE_DIR}" + ) + add_dependencies(prebyte_tests prebyte) if(PREBYTE_ENABLE_COVERAGE) if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") @@ -108,10 +179,7 @@ if(PREBYTE_BUILD_TESTS) enable_testing() - set(PREBYTE_TEST_DISCOVERY_SOURCES - ${TEST_UNIT_SOURCES} - ${TEST_INTEGRATION_SOURCES} - ) + set(PREBYTE_TEST_DISCOVERY_SOURCES ${PREBYTE_TEST_SOURCES}) set(PREBYTE_DISCOVERED_TEST_NAMES) set(PREBYTE_DISABLED_TEST_NAMES) @@ -152,10 +220,119 @@ if(PREBYTE_BUILD_TESTS) endforeach() endif() +if(PREBYTE_BUILD_FUZZERS) + if(NOT CMAKE_CXX_COMPILER_ID MATCHES "Clang|AppleClang") + message(FATAL_ERROR "PREBYTE_BUILD_FUZZERS requires Clang with libFuzzer support") + endif() + + if(NOT PREBYTE_ENABLE_SANITIZERS) + message(FATAL_ERROR "PREBYTE_BUILD_FUZZERS requires PREBYTE_ENABLE_SANITIZERS") + endif() + + enable_testing() + + set(PREBYTE_FUZZ_TARGETS + fuzz_template_lexer + fuzz_template_parser + fuzz_json_parser + fuzz_yaml_parser + fuzz_toml_parser + fuzz_ini_parser + fuzz_env_parser + fuzz_compiled_template_serializer + fuzz_settings_loader + fuzz_include_resolver + fuzz_file_parser + fuzz_lua_chunk + fuzz_render_pbt + fuzz_app_runner + fuzz_batch_render + fuzz_structured_import + fuzz_lua_sandbox + ) + + set(PREBYTE_FUZZ_DIR tests/fault_tolerance/fuzz) + + set(PREBYTE_FUZZ_SOURCES + ${PREBYTE_FUZZ_DIR}/TemplateLexerFuzz.cpp + ${PREBYTE_FUZZ_DIR}/TemplateParserFuzz.cpp + ${PREBYTE_FUZZ_DIR}/JsonParserFuzz.cpp + ${PREBYTE_FUZZ_DIR}/YamlParserFuzz.cpp + ${PREBYTE_FUZZ_DIR}/TomlParserFuzz.cpp + ${PREBYTE_FUZZ_DIR}/IniParserFuzz.cpp + ${PREBYTE_FUZZ_DIR}/EnvParserFuzz.cpp + ${PREBYTE_FUZZ_DIR}/CompiledTemplateSerializerFuzz.cpp + ${PREBYTE_FUZZ_DIR}/SettingsLoaderFuzz.cpp + ${PREBYTE_FUZZ_DIR}/IncludeResolverFuzz.cpp + ${PREBYTE_FUZZ_DIR}/FileParserFuzz.cpp + ${PREBYTE_FUZZ_DIR}/LuaChunkFuzz.cpp + ${PREBYTE_FUZZ_DIR}/RenderPbtFuzz.cpp + ${PREBYTE_FUZZ_DIR}/AppRunnerFuzz.cpp + ${PREBYTE_FUZZ_DIR}/BatchRenderFuzz.cpp + ${PREBYTE_FUZZ_DIR}/StructuredImportFuzz.cpp + ${PREBYTE_FUZZ_DIR}/LuaSandboxFuzz.cpp + ) + + list(LENGTH PREBYTE_FUZZ_TARGETS PREBYTE_FUZZ_TARGET_COUNT) + math(EXPR PREBYTE_FUZZ_LAST_INDEX "${PREBYTE_FUZZ_TARGET_COUNT} - 1") + + if(DEFINED ENV{PREBYTE_FUZZ_MAX_TOTAL_TIME}) + set(PREBYTE_FUZZ_MAX_TOTAL_TIME "$ENV{PREBYTE_FUZZ_MAX_TOTAL_TIME}") + else() + set(PREBYTE_FUZZ_MAX_TOTAL_TIME 60) + endif() + + foreach(PREBYTE_FUZZ_INDEX RANGE ${PREBYTE_FUZZ_LAST_INDEX}) + list(GET PREBYTE_FUZZ_TARGETS ${PREBYTE_FUZZ_INDEX} PREBYTE_FUZZ_TARGET) + list(GET PREBYTE_FUZZ_SOURCES ${PREBYTE_FUZZ_INDEX} PREBYTE_FUZZ_SOURCE) + + add_executable(${PREBYTE_FUZZ_TARGET} ${PREBYTE_FUZZ_SOURCE}) + target_include_directories(${PREBYTE_FUZZ_TARGET} PRIVATE ${PREBYTE_FUZZ_DIR}/support) + target_link_libraries(${PREBYTE_FUZZ_TARGET} PRIVATE prebyte_core) + target_compile_options(${PREBYTE_FUZZ_TARGET} PRIVATE -fsanitize=fuzzer) + target_link_options(${PREBYTE_FUZZ_TARGET} PRIVATE -fsanitize=fuzzer) + + set(PREBYTE_FUZZ_CORPUS "${CMAKE_SOURCE_DIR}/${PREBYTE_FUZZ_DIR}/corpus/${PREBYTE_FUZZ_TARGET}") + add_test( + NAME ${PREBYTE_FUZZ_TARGET} + COMMAND ${PREBYTE_FUZZ_TARGET} + -max_total_time=${PREBYTE_FUZZ_MAX_TOTAL_TIME} + -close_fd_mask=3 + ${PREBYTE_FUZZ_CORPUS} + ) + set_tests_properties(${PREBYTE_FUZZ_TARGET} PROPERTIES + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + TIMEOUT 300 + ) + endforeach() + + add_executable(generate_fuzz_pbc_seed ${PREBYTE_FUZZ_DIR}/GeneratePbcSeed.cpp) + target_link_libraries(generate_fuzz_pbc_seed PRIVATE prebyte_core) + + set(PREBYTE_FUZZ_PBC_SEED ${CMAKE_SOURCE_DIR}/${PREBYTE_FUZZ_DIR}/seeds/pbc/minimal.pbc) + add_custom_command( + OUTPUT ${PREBYTE_FUZZ_PBC_SEED} + COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_SOURCE_DIR}/${PREBYTE_FUZZ_DIR}/seeds/pbc + COMMAND generate_fuzz_pbc_seed ${PREBYTE_FUZZ_PBC_SEED} + DEPENDS generate_fuzz_pbc_seed + VERBATIM + ) + add_custom_target(fuzz_pbc_seed DEPENDS ${PREBYTE_FUZZ_PBC_SEED}) + add_dependencies(fuzz_compiled_template_serializer fuzz_pbc_seed) + + add_custom_target(fuzz_regression + COMMAND ${CMAKE_COMMAND} -E env FUZZ_BUILD_DIR=${CMAKE_BINARY_DIR} + ${CMAKE_SOURCE_DIR}/scripts/ci/run_fuzz_regression.sh + DEPENDS ${PREBYTE_FUZZ_TARGETS} + USES_TERMINAL + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + ) +endif() + if(PREBYTE_BUILD_BENCHMARKS) find_package(Python3 REQUIRED COMPONENTS Interpreter) - add_executable(prebyte_benchmarks tests/BenchmarkMain.cpp) + add_executable(prebyte_benchmarks tests/performance/BenchmarkMain.cpp) target_link_libraries(prebyte_benchmarks PRIVATE prebyte_core) add_executable(benchmark_compare_prebyte tools/benchmark_compare/bench_prebyte.cpp) diff --git a/CMakePresets.json b/CMakePresets.json index 1f727cb..d022674 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -28,6 +28,82 @@ "PREBYTE_BUILD_BENCHMARKS": "OFF", "PREBYTE_ENABLE_COVERAGE": "ON" } + }, + { + "name": "tidy", + "displayName": "Clang-Tidy Ninja", + "inherits": "dev", + "binaryDir": "${sourceDir}/build-cmake/tidy", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "RelWithDebInfo", + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON", + "CMAKE_CXX_COMPILER": "clang++", + "CMAKE_CXX_SCAN_FOR_MODULES": "OFF", + "PREBYTE_BUILD_TESTS": "OFF", + "PREBYTE_BUILD_BENCHMARKS": "OFF" + } + }, + { + "name": "sanitize", + "displayName": "Sanitizer Ninja", + "inherits": "dev", + "binaryDir": "${sourceDir}/build-cmake/sanitize", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_CXX_COMPILER": "clang++", + "CMAKE_CXX_SCAN_FOR_MODULES": "OFF", + "PREBYTE_BUILD_TESTS": "ON", + "PREBYTE_BUILD_BENCHMARKS": "OFF", + "PREBYTE_ENABLE_SANITIZERS": "ON", + "PREBYTE_SANITIZERS": "address,undefined" + } + }, + { + "name": "tsan", + "displayName": "ThreadSanitizer Ninja", + "inherits": "dev", + "binaryDir": "${sourceDir}/build-cmake/tsan", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_CXX_COMPILER": "clang++", + "CMAKE_CXX_SCAN_FOR_MODULES": "OFF", + "PREBYTE_BUILD_TESTS": "ON", + "PREBYTE_BUILD_BENCHMARKS": "OFF", + "PREBYTE_ENABLE_SANITIZERS": "ON", + "PREBYTE_SANITIZERS": "thread" + } + }, + { + "name": "msan", + "displayName": "MemorySanitizer Ninja", + "inherits": "dev", + "binaryDir": "${sourceDir}/build-cmake/msan", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_CXX_COMPILER": "clang++", + "CMAKE_C_COMPILER": "clang", + "CMAKE_CXX_SCAN_FOR_MODULES": "OFF", + "PREBYTE_BUILD_TESTS": "ON", + "PREBYTE_BUILD_BENCHMARKS": "OFF", + "PREBYTE_ENABLE_SANITIZERS": "ON", + "PREBYTE_SANITIZERS": "memory" + } + }, + { + "name": "fuzz", + "displayName": "Fuzzer Ninja", + "inherits": "dev", + "binaryDir": "${sourceDir}/build-cmake/fuzz", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_CXX_COMPILER": "clang++", + "CMAKE_CXX_SCAN_FOR_MODULES": "OFF", + "PREBYTE_BUILD_TESTS": "OFF", + "PREBYTE_BUILD_BENCHMARKS": "OFF", + "PREBYTE_BUILD_FUZZERS": "ON", + "PREBYTE_ENABLE_SANITIZERS": "ON", + "PREBYTE_SANITIZERS": "address,undefined" + } } ], "buildPresets": [ @@ -58,6 +134,30 @@ "name": "coverage-tests", "configurePreset": "coverage", "targets": ["prebyte_tests"] + }, + { + "name": "tidy-core", + "configurePreset": "tidy", + "targets": ["prebyte_core"] + }, + { + "name": "sanitize-tests", + "configurePreset": "sanitize", + "targets": ["prebyte_tests"] + }, + { + "name": "tsan-tests", + "configurePreset": "tsan", + "targets": ["prebyte_tests"] + }, + { + "name": "msan-tests", + "configurePreset": "msan", + "targets": ["prebyte_tests"] + }, + { + "name": "fuzz", + "configurePreset": "fuzz" } ], "testPresets": [ @@ -74,6 +174,27 @@ "output": { "outputOnFailure": true } + }, + { + "name": "sanitize", + "configurePreset": "sanitize", + "output": { + "outputOnFailure": true + } + }, + { + "name": "tsan", + "configurePreset": "tsan", + "output": { + "outputOnFailure": true + } + }, + { + "name": "msan", + "configurePreset": "msan", + "output": { + "outputOnFailure": true + } } ] } diff --git a/README.md b/README.md index 96aed72..0be3971 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,17 @@ cat input.txt | docker run --rm -i ghcr.io/coditary/prebyte:latest ## Test +Full local validation (coverage, packaging, benchmarks, sanitizers, fuzzers, optional Docker): + +```bash +make +# or explicitly: +make all +make ci-full +``` + +Quick test run only: + ```bash make test ``` @@ -90,6 +101,8 @@ Or with CMake: ctest --preset dev ``` +`make` / `make all` runs the full local check suite via `scripts/ci/run_all_checks.sh` (expect 30–60+ minutes). Use `make start` to build the CLI only. + On Windows, prefer `cmake --build --preset dev --target prebyte_tests` then `ctest --preset dev`. ## Benchmark @@ -100,7 +113,7 @@ make benchmark `make benchmark` runs: -1. internal Prebyte benchmark history update in `tests/benchmarks/history.md` +1. internal Prebyte benchmark history update in `tests/performance/history.md` 2. Prebyte vs Go `text/template` and Rust Askama comparison from `tools/benchmark_compare/` Cross-engine comparison is a **manual local tool** (not run in CI). Case names and iteration counts live in `tools/benchmark_compare/manifest.json`; reports append to `tools/benchmark_compare/history.md`. @@ -137,7 +150,7 @@ Prebyte supports multithreaded rendering: `Engine::render()` is safe across thre The Askama benchmark requires Rust/Cargo (`cargo build` in `tools/benchmark_compare/`). -Benchmark history: `tests/benchmarks/history.md` (internal), `tools/benchmark_compare/history.md` (cross-engine). +Benchmark history: `tests/performance/history.md` (internal), `tools/benchmark_compare/history.md` (cross-engine). ## CLI @@ -545,12 +558,16 @@ Current implementation is split into focused modules: ## Tests And Fixtures -Tests live in: +Tests are grouped by non-functional requirement under `tests/`. See `tests/README.md` for the layout. -1. `tests/unit/` -2. `tests/integration/` +Main buckets: + +1. `tests/correctness/unit/` +2. `tests/correctness/integration/` 3. `tests/fixtures/` +Other NFR folders: `fault_tolerance/`, `security/`, `concurrency/`, `portability/`, `performance/`. + ## License MIT. See `LICENSE`. diff --git a/cmake/vcpkg/triplets/x64-linux-msan.cmake b/cmake/vcpkg/triplets/x64-linux-msan.cmake new file mode 100644 index 0000000..ef33015 --- /dev/null +++ b/cmake/vcpkg/triplets/x64-linux-msan.cmake @@ -0,0 +1,8 @@ +set(VCPKG_TARGET_ARCHITECTURE x64) +set(VCPKG_CRT_LINKAGE dynamic) +set(VCPKG_LIBRARY_LINKAGE dynamic) +set(VCPKG_CMAKE_SYSTEM_NAME Linux) + +set(VCPKG_CXX_FLAGS "-fsanitize=memory -fno-omit-frame-pointer -g -fsanitize-memory-track-origins=1") +set(VCPKG_C_FLAGS "-fsanitize=memory -fno-omit-frame-pointer -g -fsanitize-memory-track-origins=1") +set(VCPKG_LINKER_FLAGS "-fsanitize=memory") diff --git a/makefile b/makefile index aa33e01..5965110 100644 --- a/makefile +++ b/makefile @@ -1,13 +1,25 @@ -.PHONY: all start run test benchmark compare-benchmark configure reqpack reqpack-index clean +.PHONY: all start run test coverage analyze lint security static-analysis sanitize tsan msan fuzz fuzz-regression benchmark benchmark-gate compare-benchmark packaging-smoke packaging-smoke-docker ci-full ci-fast configure reqpack reqpack-index clean CMAKE_PRESET ?= dev CMAKE_BUILD_DIR := build-cmake/dev +COVERAGE_MIN_LINE ?= 85 +PREBYTE_FUZZ_MAX_TOTAL_TIME ?= 60 +COVERAGE_BUILD_DIR := build-cmake/coverage +CLANG_TIDY_BUILD_DIR := build-cmake/tidy CMAKE_CACHE := $(CMAKE_BUILD_DIR)/CMakeCache.txt COMPARE_DIR := tools/benchmark_compare PREBYTE_VERSION ?= $(shell python3 -c 'import pathlib,re; text = pathlib.Path("CMakeLists.txt").read_text(encoding="utf-8"); match = re.search(r"project\([^\n]*VERSION\s+([^\s)]+)", text); print(match.group(1) if match else "0.0.0")') REQPACK_OUTPUT_DIR ?= dist -all: start +all: ci-full + +ci-full: + chmod +x scripts/ci/run_all_checks.sh + ./scripts/ci/run_all_checks.sh + +ci-fast: + chmod +x scripts/ci/run_all_checks.sh + PREBYTE_SKIP_FUZZ=1 ./scripts/ci/run_all_checks.sh configure: @if [ -f "$(CMAKE_CACHE)" ]; then \ @@ -29,6 +41,44 @@ test: configure cmake --build --preset $(CMAKE_PRESET) --target prebyte_tests ctest --preset $(CMAKE_PRESET) +coverage: + cmake --preset coverage + cmake --build --preset coverage-tests + ctest --preset coverage + COVERAGE_MIN_LINE=$(COVERAGE_MIN_LINE) ./scripts/ci/generate_coverage_report.sh + +analyze: + ./scripts/ci/run_clang_tidy.sh analyze + +lint: + ./scripts/ci/run_clang_tidy.sh lint + +security: + ./scripts/ci/run_clang_tidy.sh security + +static-analysis: + ./scripts/ci/run_clang_tidy.sh all + +MSAN_CMAKE_ARGS ?= + +sanitize: + ./scripts/ci/run_sanitize_tests.sh asan + +tsan: + ./scripts/ci/run_sanitize_tests.sh tsan + +msan: + ./scripts/ci/run_sanitize_tests.sh msan $(MSAN_CMAKE_ARGS) + +fuzz: + PREBYTE_FUZZ_MAX_TOTAL_TIME=$(PREBYTE_FUZZ_MAX_TOTAL_TIME) ./scripts/ci/run_fuzzers.sh + +fuzz-regression: + chmod +x scripts/ci/run_fuzz_regression.sh + ./scripts/ci/run_fuzz_regression.sh + +.NOTPARALLEL: all ci-full ci-fast analyze lint security static-analysis sanitize tsan msan fuzz fuzz-regression + benchmark: configure cmake --build --preset $(CMAKE_PRESET) --target prebyte_benchmarks ./$(CMAKE_BUILD_DIR)/prebyte_benchmarks @@ -37,6 +87,18 @@ benchmark: configure compare-benchmark: configure cmake --build --preset $(CMAKE_PRESET) --target compare-benchmark +benchmark-gate: configure + cmake --build --preset $(CMAKE_PRESET) --target prebyte_benchmarks + python3 scripts/ci/check_benchmark_regression.py --benchmark-binary $(CMAKE_BUILD_DIR)/prebyte_benchmarks + +packaging-smoke: start + chmod +x scripts/ci/smoke_packaging.py + python3 scripts/ci/smoke_packaging.py --binary $(CMAKE_BUILD_DIR)/prebyte + +packaging-smoke-docker: start + chmod +x scripts/ci/smoke_packaging.py + PREBYTE_SMOKE_DOCKER=1 python3 scripts/ci/smoke_packaging.py --binary $(CMAKE_BUILD_DIR)/prebyte --checks docker + reqpack: start @host_os="$$(uname -s)"; \ host_arch="$$(uname -m)"; \ @@ -66,4 +128,4 @@ reqpack-index: --output "$(REQPACK_OUTPUT_DIR)/index.json" clean: - rm -rf build build-cmake "$(COMPARE_DIR)/bench_prebyte" + rm -rf build build-cmake "$(COMPARE_DIR)/bench_prebyte" crash-* diff --git a/scripts/ci/bootstrap_vcpkg_lua.sh b/scripts/ci/bootstrap_vcpkg_lua.sh new file mode 100755 index 0000000..a0a2e97 --- /dev/null +++ b/scripts/ci/bootstrap_vcpkg_lua.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${FUZZ_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +VCPKG_TRIPLET="${VCPKG_TARGET_TRIPLET:-x64-linux-msan}" +SANITIZER_KIND="${1:-asan}" +shift || true + +VCPKG_ROOT="${VCPKG_ROOT:-${RUNNER_TEMP:-/tmp}/vcpkg}" +OVERLAY_TRIPLETS="${VCPKG_OVERLAY_TRIPLETS:-$ROOT/cmake/vcpkg/triplets}" + +if [[ ! -x "$VCPKG_ROOT/vcpkg" ]]; then + git clone --depth 1 https://github.com/microsoft/vcpkg.git "$VCPKG_ROOT" + "$VCPKG_ROOT/bootstrap-vcpkg.sh" -disableMetrics +fi + +case "$SANITIZER_KIND" in + msan) + export CC=clang + export CXX=clang++ + VCPKG_TRIPLET=x64-linux-msan + ;; + asan|tsan) + VCPKG_TRIPLET="${VCPKG_TARGET_TRIPLET:-x64-linux}" + ;; + *) + printf 'Unsupported sanitizer kind for vcpkg bootstrap: %s\n' "$SANITIZER_KIND" >&2 + exit 1 + ;; +esac + +"$VCPKG_ROOT/vcpkg" install "lua:${VCPKG_TRIPLET}" \ + --overlay-triplets="$OVERLAY_TRIPLETS" \ + --binarysource=clear diff --git a/scripts/ci/check_benchmark_regression.py b/scripts/ci/check_benchmark_regression.py new file mode 100755 index 0000000..c2777c7 --- /dev/null +++ b/scripts/ci/check_benchmark_regression.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Run internal benchmarks and fail when cases exceed committed baselines.""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +import tempfile +from pathlib import Path + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def load_baselines(path: Path) -> dict[str, int]: + baselines: dict[str, int] = {} + for line in path.read_text(encoding='utf-8').splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith('#'): + continue + name, _, value = stripped.partition('=') + if not name or not value: + raise ValueError(f'invalid baseline line: {line!r}') + baselines[name.strip()] = int(value.strip()) + return baselines + + +def parse_latest_section(history_text: str) -> dict[str, int]: + rows: dict[str, int] = {} + in_table = False + for line in history_text.splitlines(): + if line.startswith('### '): + in_table = False + continue + if line.startswith('| Case |'): + in_table = True + continue + if not in_table or not line.startswith('|'): + continue + if line.startswith('| ---'): + continue + + columns = [column.strip() for column in line.strip('|').split('|')] + if len(columns) < 2: + continue + case_name = columns[0] + time_text = columns[1] + if not case_name or case_name == 'Case': + continue + rows[case_name] = int(time_text) + return rows + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + '--benchmark-binary', + type=Path, + help='Path to prebyte_benchmarks (default: build-cmake/dev/prebyte_benchmarks)', + ) + parser.add_argument( + '--baselines', + type=Path, + default=repo_root() / 'tests' / 'performance' / 'baselines.txt', + help='Baseline limits file', + ) + args = parser.parse_args() + + root = repo_root() + benchmark_binary = args.benchmark_binary or (root / 'build-cmake' / 'dev' / 'prebyte_benchmarks') + if not benchmark_binary.is_file(): + parser.error(f'benchmark binary not found: {benchmark_binary}') + + baselines = load_baselines(args.baselines) + with tempfile.TemporaryDirectory(prefix='prebyte-benchmark-gate-') as temp_dir: + history_path = Path(temp_dir) / 'history.md' + subprocess.run( + [str(benchmark_binary)], + check=True, + cwd=root, + env={**os.environ, 'PREBYTE_BENCHMARK_HISTORY': str(history_path)}, + ) + measured = parse_latest_section(history_path.read_text(encoding='utf-8')) + + failures: list[str] = [] + for case_name, limit in baselines.items(): + if case_name not in measured: + failures.append(f'missing benchmark case: {case_name}') + continue + actual = measured[case_name] + if actual > limit: + failures.append(f'{case_name}: {actual}us exceeds baseline {limit}us') + + if failures: + print('Benchmark regression gate failed:', file=sys.stderr) + for failure in failures: + print(f' - {failure}', file=sys.stderr) + return 1 + + print(f'Benchmark regression gate passed for {len(baselines)} case(s).') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/ci/fuzz_seed_guard.sh b/scripts/ci/fuzz_seed_guard.sh new file mode 100644 index 0000000..c3c303a --- /dev/null +++ b/scripts/ci/fuzz_seed_guard.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +# Relocate libFuzzer-generated SHA1 corpus entries that were accidentally written +# into a curated seed directory back into the target corpus directory. + +name_fuzz_corpus_entry() { + python3 "$ROOT/scripts/ci/name_fuzz_corpus_entry.py" "$1" +} + +rename_fuzz_corpus_entries() { + local corpus=$1 + python3 "$ROOT/scripts/ci/name_fuzz_corpus_entry.py" --rename-dir "$corpus" +} + +relocate_generated_seed_artifacts() { + local seeds_dir=$1 + local corpus=$2 + local artifact + + while IFS= read -r -d '' artifact; do + install_seed_into_corpus "$artifact" "$corpus" + rm -f "$artifact" + done < <(find "$seeds_dir" -maxdepth 1 -type f -regextype posix-extended -regex '.*/[0-9a-f]{40}$' -print0 2>/dev/null) + + rename_fuzz_corpus_entries "$corpus" +} diff --git a/scripts/ci/generate_coverage_report.sh b/scripts/ci/generate_coverage_report.sh new file mode 100755 index 0000000..a25a744 --- /dev/null +++ b/scripts/ci/generate_coverage_report.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${COVERAGE_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +OBJECT_DIR="${COVERAGE_OBJECT_DIR:-$ROOT/build-cmake/coverage}" +OUTPUT="${COVERAGE_OUTPUT:-$OBJECT_DIR/coverage.xml}" +FILTER="${COVERAGE_FILTER:-$ROOT/src/main/cpp}" +MIN_LINE="${COVERAGE_MIN_LINE:-85}" + +gcovr \ + --root "$ROOT" \ + --object-directory "$OBJECT_DIR" \ + --filter "$FILTER" \ + --gcov-ignore-parse-errors=negative_hits.warn \ + --fail-under-line "$MIN_LINE" \ + --xml-pretty \ + --output "$OUTPUT" diff --git a/scripts/ci/import_fuzz_regression.py b/scripts/ci/import_fuzz_regression.py new file mode 100755 index 0000000..579d6dd --- /dev/null +++ b/scripts/ci/import_fuzz_regression.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Import a libFuzzer crash input into the deterministic regression corpus.""" + +from __future__ import annotations + +import argparse +import hashlib +import shutil +import subprocess +import sys +from pathlib import Path + +VALID_TARGETS = ( + 'fuzz_template_lexer', + 'fuzz_template_parser', + 'fuzz_json_parser', + 'fuzz_yaml_parser', + 'fuzz_toml_parser', + 'fuzz_ini_parser', + 'fuzz_env_parser', + 'fuzz_compiled_template_serializer', + 'fuzz_settings_loader', + 'fuzz_include_resolver', + 'fuzz_file_parser', + 'fuzz_lua_chunk', + 'fuzz_render_pbt', + 'fuzz_app_runner', + 'fuzz_batch_render', + 'fuzz_structured_import', + 'fuzz_lua_sandbox', +) + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def suggest_name(crash_path: Path) -> str: + script = repo_root() / 'scripts' / 'ci' / 'name_fuzz_corpus_entry.py' + result = subprocess.run( + [sys.executable, str(script), '--print-name', str(crash_path)], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def unique_destination(directory: Path, name: str, data: bytes) -> Path: + candidate = directory / name + if candidate.exists(): + if candidate.read_bytes() == data: + return candidate + else: + return candidate + + digest = hashlib.sha1(data).hexdigest()[:8] + for suffix in range(2, 1000): + candidate = directory / f'{name}_{suffix}' + if not candidate.exists() or candidate.read_bytes() == data: + return candidate + + return directory / f'{name}_{digest}' + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--target', required=True, choices=VALID_TARGETS) + parser.add_argument('--crash', required=True, type=Path, help='Crash input file from libFuzzer') + parser.add_argument('--name', help='Optional regression entry name (without path)') + parser.add_argument('--also-seed', action='store_true', help='Also copy into curated seeds directory') + args = parser.parse_args() + + crash_path = args.crash.resolve() + if not crash_path.is_file(): + parser.error(f'crash file not found: {crash_path}') + + root = repo_root() + regression_dir = root / 'tests' / 'fault_tolerance' / 'fuzz' / 'regression' / args.target + regression_dir.mkdir(parents=True, exist_ok=True) + + data = crash_path.read_bytes() + entry_name = args.name or suggest_name(crash_path) + destination = unique_destination(regression_dir, entry_name, data) + shutil.copy2(crash_path, destination) + + print(f'Imported regression input: {destination.relative_to(root)}') + + if args.also_seed: + seeds_dir = root / 'tests' / 'fault_tolerance' / 'fuzz' / 'seeds' + seed_subdir = { + 'fuzz_template_lexer': 'template', + 'fuzz_template_parser': 'template', + 'fuzz_json_parser': 'json', + 'fuzz_yaml_parser': 'yaml', + 'fuzz_toml_parser': 'toml', + 'fuzz_ini_parser': 'ini', + 'fuzz_env_parser': 'env', + 'fuzz_compiled_template_serializer': 'pbc', + 'fuzz_settings_loader': 'settings', + 'fuzz_include_resolver': 'include', + 'fuzz_file_parser': 'file_parser', + 'fuzz_lua_chunk': 'lua_chunk', + 'fuzz_render_pbt': 'render_pbt', + 'fuzz_app_runner': 'app_runner', + 'fuzz_batch_render': 'batch_render', + 'fuzz_structured_import': 'structured_import', + 'fuzz_lua_sandbox': 'lua_sandbox', + }[args.target] + seed_dir = seeds_dir / seed_subdir + seed_dir.mkdir(parents=True, exist_ok=True) + seed_destination = unique_destination(seed_dir, f'regression_{entry_name}', data) + shutil.copy2(crash_path, seed_destination) + print(f'Also copied to seed: {seed_destination.relative_to(root)}') + + print('Replay with: ./scripts/ci/run_fuzz_regression.sh') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/ci/name_fuzz_corpus_entry.py b/scripts/ci/name_fuzz_corpus_entry.py new file mode 100755 index 0000000..1b040e9 --- /dev/null +++ b/scripts/ci/name_fuzz_corpus_entry.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Suggest or apply human-readable names for libFuzzer corpus entries.""" + +from __future__ import annotations + +import argparse +import hashlib +import re +import sys +from pathlib import Path + +SHA1_NAME = re.compile(r"^[0-9a-f]{40}$") +PRINTABLE = re.compile(r"[ -~]{4,}") + +FEATURES: tuple[tuple[str, str], ...] = ( + ('include', 'include'), + ('{{ for', 'for_loop'), + ('{{ if', 'if'), + ('lua:block', 'lua_block'), + ('{{ lua', 'lua'), + ('{{ fn ', 'function'), + ('{{ set ', 'set'), + ('{{ #', 'comment'), + ('strict_variables', 'strict'), + ('allow_includes', 'includes'), + ('error_on_false_input', 'false_input'), + ('max_include_depth', 'include_depth'), + ('max_loop_iteration', 'loop_limit'), + ('output_encoding', 'encoding'), + ('forbidden_env_vars', 'forbidden_env'), + ('allow_env', 'allow_env'), + ('{{ greeting', 'greeting'), + ('{{ name', 'name'), +) + +TARGET_HINTS: dict[str, str] = { + 'fuzz_app_runner': 'app_runner', + 'fuzz_batch_render': 'batch_render', + 'fuzz_structured_import': 'structured_import', + 'fuzz_lua_sandbox': 'lua_sandbox', + 'fuzz_render_pbt': 'render_pbt', + 'fuzz_template_lexer': 'template_lexer', + 'fuzz_template_parser': 'template_parser', + 'fuzz_json_parser': 'json', + 'fuzz_yaml_parser': 'yaml', + 'fuzz_toml_parser': 'toml', + 'fuzz_ini_parser': 'ini', + 'fuzz_env_parser': 'env', + 'fuzz_compiled_template_serializer': 'pbc', + 'fuzz_settings_loader': 'settings', + 'fuzz_include_resolver': 'include', + 'fuzz_file_parser': 'file_parser', + 'fuzz_lua_chunk': 'lua_chunk', +} + + +def digest_prefix(data: bytes) -> str: + return hashlib.sha1(data).hexdigest()[:8] + + +def decode_text(data: bytes) -> str: + return data.decode('utf-8', errors='replace') + + +def extract_snippet(data: bytes) -> str: + text = decode_text(data) + marker = text.find('{{') + if marker != -1: + return text[marker : marker + 96] + stripped = text.strip() + if stripped: + return stripped[:96] + for match in PRINTABLE.finditer(decode_text(data)): + return match.group(0)[:96] + return 'binary' + + +def slugify(text: str, max_len: int = 36) -> str: + slug = re.sub(r'[^a-z0-9]+', '_', text.lower()).strip('_') + return slug[:max_len] or 'input' + + +def is_useful_slug(slug: str) -> bool: + if len(slug) < 4: + return False + tokens = [token for token in slug.split('_') if token] + if not tokens: + return False + if len(tokens) > 6: + return False + return any(len(token) >= 4 for token in tokens) + + +def detect_format_tags(data: bytes, text: str) -> list[str]: + tags: list[str] = [] + stripped = text.lstrip() + if '{{' in text or '{%' in text: + tags.append('template') + elif stripped.startswith('{') or stripped.startswith('['): + tags.append('json') + if stripped.startswith('---') or re.search(r'^[A-Za-z0-9_]+:\s', stripped, re.MULTILINE): + tags.append('yaml') + if re.search(r'^\[[^\]]+\]', stripped, re.MULTILINE): + tags.append('toml') + if re.search(r'^\[[^\]]+\]\s*$', stripped, re.MULTILINE): + tags.append('ini') + if '=' in stripped and not tags: + tags.append('kv') + if 'return ' in text or 'local ' in text or 'function ' in text: + tags.append('lua') + return tags + + +def detect_feature_tags(text: str) -> list[str]: + lower = text.lower() + tags: list[str] = [] + for needle, tag in FEATURES: + if needle in lower and tag not in tags: + tags.append(tag) + return tags + + +def suggest_name(data: bytes, target_hint: str = '') -> str: + text = decode_text(data) + snippet = extract_snippet(data) + parts: list[str] = [] + + if target_hint: + parts.append(target_hint) + + for tag in detect_format_tags(data, text): + if tag not in parts: + parts.append(tag) + + for tag in detect_feature_tags(text): + if tag not in parts: + parts.append(tag) + + snippet_slug = slugify(re.sub(r'\{\{|\}\}', ' ', snippet)) + if is_useful_slug(snippet_slug) and snippet_slug not in parts: + parts.append(snippet_slug) + elif not any(tag in parts for tag in ('template', 'json', 'yaml', 'toml', 'ini', 'lua', 'kv')): + parts.append('binary') + + parts.append(digest_prefix(data)) + name = '_'.join(part for part in parts if part) + name = re.sub(r'_+', '_', name).strip('_') + return name[:120] + + +def target_hint_for(path: Path) -> str: + return TARGET_HINTS.get(path.name, '') + + +def unique_destination(directory: Path, name: str, data: bytes) -> Path: + candidate = directory / name + if candidate.exists(): + if candidate.read_bytes() == data: + return candidate + else: + return candidate + + stem = name + for suffix in range(2, 1000): + candidate = directory / f'{stem}_{suffix}' + if not candidate.exists() or candidate.read_bytes() == data: + return candidate + + return directory / f'{stem}_{digest_prefix(data)}' + + +def rename_hash_entries(directory: Path, dry_run: bool = False) -> int: + renamed = 0 + for path in sorted(directory.iterdir()): + if not path.is_file() or not SHA1_NAME.match(path.name): + continue + + data = path.read_bytes() + new_name = suggest_name(data, target_hint_for(directory)) + destination = unique_destination(directory, new_name, data) + if destination == path: + continue + + if dry_run: + print(f'{path.name} -> {destination.name}') + else: + path.rename(destination) + renamed += 1 + + return renamed + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('path', nargs='?', type=Path, help='Corpus file or directory') + parser.add_argument('--print-name', action='store_true', help='Print suggested name for one file') + parser.add_argument('--rename-dir', action='store_true', help='Rename SHA1 entries in a corpus directory') + parser.add_argument('--dry-run', action='store_true', help='Show renames without applying them') + args = parser.parse_args() + + if args.path is None: + parser.error('path is required') + + path = args.path + if args.print_name: + if not path.is_file(): + parser.error('--print-name requires a file path') + print(suggest_name(path.read_bytes())) + return 0 + + if args.rename_dir: + if not path.is_dir(): + parser.error('--rename-dir requires a directory path') + renamed = rename_hash_entries(path, dry_run=args.dry_run) + if not args.dry_run: + print(f'Renamed {renamed} entries in {path}', file=sys.stderr) + return 0 + + if path.is_file(): + print(suggest_name(path.read_bytes())) + return 0 + + parser.error('Use --print-name, --rename-dir, or pass a file path') + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/ci/run_all_checks.sh b/scripts/ci/run_all_checks.sh new file mode 100755 index 0000000..c1709b7 --- /dev/null +++ b/scripts/ci/run_all_checks.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${ALL_CHECKS_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +cd "$ROOT" + +export PREBYTE_PBT_ITERATIONS="${PREBYTE_PBT_ITERATIONS:-250}" + +run_step() { + printf '\n========== %s ==========\n' "$1" + shift + "$@" +} + +run_step "Coverage (tests + 85%% gate)" make coverage +if [[ "${PREBYTE_SKIP_STATIC_ANALYSIS:-}" != "1" ]]; then + run_step "Static analysis (clang-tidy analyze, lint, security)" make static-analysis +else + printf '\nSkipping static analysis (PREBYTE_SKIP_STATIC_ANALYSIS=1).\n' +fi +run_step "Packaging smoke (binary + ReqPack + index)" make packaging-smoke +run_step "Benchmark regression gate" make benchmark-gate +run_step "ASan/UBSan tests" make sanitize +run_step "ThreadSanitizer tests" make tsan +if [[ "${PREBYTE_SKIP_FUZZ:-}" != "1" ]]; then + run_step "libFuzzer targets + regression replay" make fuzz +else + printf '\nSkipping libFuzzer targets (PREBYTE_SKIP_FUZZ=1).\n' +fi + +if command -v docker >/dev/null 2>&1 && [[ "${PREBYTE_SKIP_DOCKER:-}" != "1" ]]; then + run_step "Docker packaging smoke" make packaging-smoke-docker +elif [[ "${PREBYTE_SKIP_DOCKER:-}" == "1" ]]; then + printf '\nSkipping Docker packaging smoke (PREBYTE_SKIP_DOCKER=1).\n' +else + printf '\nSkipping Docker packaging smoke (docker not installed).\n' +fi + +if [[ "${PREBYTE_ALL_CHECKS_MSAN:-}" == "1" ]]; then + run_step "MemorySanitizer tests" make msan +fi + +if [[ "${PREBYTE_ALL_CHECKS_COMPARE_BENCHMARK:-}" == "1" ]]; then + run_step "Cross-engine benchmark comparison" make compare-benchmark +fi + +printf '\n=== All checks passed ===\n' diff --git a/scripts/ci/run_clang_tidy.sh b/scripts/ci/run_clang_tidy.sh new file mode 100755 index 0000000..410b7b6 --- /dev/null +++ b/scripts/ci/run_clang_tidy.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${CLANG_TIDY_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +BUILD_DIR="${CLANG_TIDY_BUILD_DIR:-$ROOT/build-cmake/tidy}" +CMAKE_PRESET="${CLANG_TIDY_PRESET:-tidy}" +BUILD_PRESET="${CLANG_TIDY_BUILD_PRESET:-tidy-core}" + +ANALYZE_CHECKS='clang-analyzer-*,-clang-analyzer-optin.performance.Padding' +LINT_CHECKS='bugprone-*,-bugprone-easily-swappable-parameters,misc-throw-by-value-catch-by-reference,misc-use-after-move,performance-for-range-copy,performance-implicit-conversion-in-loop,performance-inefficient-vector-operation,performance-move-const-arg,performance-no-int-to-ptr,performance-noexcept-move-constructor,performance-unnecessary-copy-initialization,performance-unnecessary-value-param,readability-container-contains,readability-redundant-control-flow,readability-redundant-string-init,readability-suspicious-call-argument,modernize-avoid-bind,modernize-make-unique,modernize-make-shared,modernize-deprecated-headers,portability-simd-intrinsics' +SECURITY_CHECKS='cert-*,-cert-env33-c,concurrency-*,-concurrency-mt-unsafe,cppcoreguidelines-pro-type-reinterpret-cast,cppcoreguidelines-pro-type-cstyle-cast,cppcoreguidelines-pro-bounds-constant-array-index' + +resolve_run_clang_tidy() { + if command -v run-clang-tidy >/dev/null 2>&1; then + printf '%s\n' run-clang-tidy + return 0 + fi + + local candidate + for candidate in run-clang-tidy-21 run-clang-tidy-20 run-clang-tidy-19 run-clang-tidy-18; do + if command -v "$candidate" >/dev/null 2>&1; then + printf '%s\n' "$candidate" + return 0 + fi + done + + printf 'run-clang-tidy not found; install clang-tidy\n' >&2 + return 1 +} + +ensure_build() { + if [[ ! -f "$BUILD_DIR/compile_commands.json" ]]; then + cmake --preset "$CMAKE_PRESET" "$@" + fi + + cmake --build --preset "$BUILD_PRESET" --parallel +} + +run_profile() { + local profile=$1 + local checks=$2 + local runner + runner=$(resolve_run_clang_tidy) + + printf 'Running clang-tidy profile: %s\n' "$profile" + + local output="" + local status=0 + output=$("$runner" -p "$BUILD_DIR" -checks="$checks" -quiet 2>&1) || status=$? + + if [[ -n "$output" ]]; then + printf '%s\n' "$output" + fi + + if echo "$output" | grep -qE '(^|[^[])-warnings-as-errors\]|clang-diagnostic-error'; then + printf 'clang-tidy profile "%s" failed\n' "$profile" >&2 + return 1 + fi + + if [[ $status -ne 0 ]]; then + return "$status" + fi +} + +usage() { + cat < [extra cmake configure args...] + +Profiles: + analyze Static analyzer checks (clang-analyzer-*) + lint Bug-prone, performance, and modernization checks + security CERT, concurrency, and bounds checks + all Run analyze, lint, and security +EOF +} + +main() { + local profile=${1:-} + if [[ -z "$profile" ]]; then + usage >&2 + return 2 + fi + shift + + ensure_build "$@" + + case "$profile" in + analyze) + run_profile analyze "$ANALYZE_CHECKS" + ;; + lint) + run_profile lint "$LINT_CHECKS" + ;; + security) + run_profile security "$SECURITY_CHECKS" + ;; + all) + run_profile analyze "$ANALYZE_CHECKS" + run_profile lint "$LINT_CHECKS" + run_profile security "$SECURITY_CHECKS" + ;; + *) + usage >&2 + return 2 + ;; + esac +} + +main "$@" diff --git a/scripts/ci/run_fuzz_regression.sh b/scripts/ci/run_fuzz_regression.sh new file mode 100755 index 0000000..3c368f9 --- /dev/null +++ b/scripts/ci/run_fuzz_regression.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${FUZZ_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +BUILD_DIR="${FUZZ_BUILD_DIR:-$ROOT/build-cmake/fuzz}" +REGRESSION_DIR="$ROOT/tests/fault_tolerance/fuzz/regression" + +if [[ ! -d "$REGRESSION_DIR" ]]; then + printf 'Regression directory not found: %s\n' "$REGRESSION_DIR" >&2 + exit 1 +fi + +if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then + printf 'Fuzz build not found at %s. Configure with cmake --preset fuzz first.\n' "$BUILD_DIR" >&2 + exit 1 +fi + +replayed=0 +for target_dir in "$REGRESSION_DIR"/*/; do + [[ -d "$target_dir" ]] || continue + target=$(basename "$target_dir") + fuzzer="$BUILD_DIR/$target" + if [[ ! -x "$fuzzer" ]]; then + printf 'Missing fuzzer binary: %s\n' "$fuzzer" >&2 + exit 1 + fi + + shopt -s nullglob + entries=("$target_dir"/*) + if [[ ${#entries[@]} -eq 0 ]]; then + continue + fi + + for input in "${entries[@]}"; do + [[ -f "$input" ]] || continue + printf 'Replaying %s <= %s\n' "$target" "$(basename "$input")" + "$fuzzer" "$input" -runs=1 + replayed=$((replayed + 1)) + done +done + +if [[ "$replayed" -eq 0 ]]; then + printf 'No regression inputs found under %s\n' "$REGRESSION_DIR" >&2 + exit 1 +fi + +printf 'Replayed %s regression input(s) successfully.\n' "$replayed" diff --git a/scripts/ci/run_fuzzers.sh b/scripts/ci/run_fuzzers.sh new file mode 100755 index 0000000..bfef26d --- /dev/null +++ b/scripts/ci/run_fuzzers.sh @@ -0,0 +1,217 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${FUZZ_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +BUILD_DIR="${FUZZ_BUILD_DIR:-$ROOT/build-cmake/fuzz}" +CMAKE_PRESET="${FUZZ_PRESET:-fuzz}" +BUILD_PRESET="${FUZZ_BUILD_PRESET:-fuzz}" +MAX_TOTAL_TIME="${PREBYTE_FUZZ_MAX_TOTAL_TIME:-60}" + +FUZZ_TARGETS=( + fuzz_template_lexer + fuzz_template_parser + fuzz_json_parser + fuzz_yaml_parser + fuzz_toml_parser + fuzz_ini_parser + fuzz_env_parser + fuzz_compiled_template_serializer + fuzz_settings_loader + fuzz_include_resolver + fuzz_file_parser + fuzz_lua_chunk + fuzz_render_pbt + fuzz_app_runner + fuzz_batch_render + fuzz_structured_import + fuzz_lua_sandbox +) + +seed_dir_for_target() { + case "$1" in + fuzz_template_lexer|fuzz_template_parser) + printf '%s/tests/fault_tolerance/fuzz/seeds/template\n' "$ROOT" + ;; + fuzz_json_parser) + printf '%s/tests/fault_tolerance/fuzz/seeds/json\n' "$ROOT" + ;; + fuzz_yaml_parser) + printf '%s/tests/fault_tolerance/fuzz/seeds/yaml\n' "$ROOT" + ;; + fuzz_toml_parser) + printf '%s/tests/fault_tolerance/fuzz/seeds/toml\n' "$ROOT" + ;; + fuzz_ini_parser) + printf '%s/tests/fault_tolerance/fuzz/seeds/ini\n' "$ROOT" + ;; + fuzz_env_parser) + printf '%s/tests/fault_tolerance/fuzz/seeds/env\n' "$ROOT" + ;; + fuzz_compiled_template_serializer) + printf '%s/tests/fault_tolerance/fuzz/seeds/pbc\n' "$ROOT" + ;; + fuzz_settings_loader) + printf '%s/tests/fault_tolerance/fuzz/seeds/settings\n' "$ROOT" + ;; + fuzz_include_resolver) + printf '%s/tests/fault_tolerance/fuzz/seeds/include\n' "$ROOT" + ;; + fuzz_file_parser) + printf '%s/tests/fault_tolerance/fuzz/seeds/file_parser\n' "$ROOT" + ;; + fuzz_lua_chunk) + printf '%s/tests/fault_tolerance/fuzz/seeds/lua_chunk\n' "$ROOT" + ;; + fuzz_render_pbt) + printf '%s/tests/fault_tolerance/fuzz/seeds/render_pbt\n' "$ROOT" + ;; + fuzz_app_runner) + printf '%s/tests/fault_tolerance/fuzz/seeds/app_runner\n' "$ROOT" + ;; + fuzz_batch_render) + printf '%s/tests/fault_tolerance/fuzz/seeds/batch_render\n' "$ROOT" + ;; + fuzz_structured_import) + printf '%s/tests/fault_tolerance/fuzz/seeds/structured_import\n' "$ROOT" + ;; + fuzz_lua_sandbox) + printf '%s/tests/fault_tolerance/fuzz/seeds/lua_sandbox\n' "$ROOT" + ;; + *) + printf 'Unknown fuzz target: %s\n' "$1" >&2 + return 1 + ;; + esac +} + +validate_seed_uniqueness() { + local seeds_dir=$1 + local -A seen_hashes=() + local seed_file hash duplicate + + shopt -s nullglob + for seed_file in "$seeds_dir"/*; do + [[ -f "$seed_file" ]] || continue + hash=$(sha1sum "$seed_file" | awk '{print $1}') + duplicate="${seen_hashes[$hash]:-}" + if [[ -n "$duplicate" ]]; then + printf 'Duplicate fuzz seed content:\n %s\n %s\n' "$duplicate" "$seed_file" >&2 + return 1 + fi + seen_hashes[$hash]=$seed_file + done +} + +CORPUS_HASH_INDEX_FILE="" + +build_corpus_hash_index() { + local corpus=$1 + local entry + + CORPUS_HASH_INDEX_FILE=$(mktemp) + shopt -s nullglob + for entry in "$corpus"/*; do + [[ -f "$entry" ]] || continue + sha1sum "$entry" + done >"$CORPUS_HASH_INDEX_FILE" +} + +clear_corpus_hash_index() { + if [[ -n "$CORPUS_HASH_INDEX_FILE" ]]; then + rm -f "$CORPUS_HASH_INDEX_FILE" + CORPUS_HASH_INDEX_FILE="" + fi +} + +corpus_contains_seed() { + local corpus=$1 + local seed_file=$2 + local hash + + hash=$(sha1sum "$seed_file" | awk '{print $1}') + if [[ -f "$corpus/$hash" ]]; then + return 0 + fi + + if [[ -n "$CORPUS_HASH_INDEX_FILE" ]] && grep -q "^$hash " "$CORPUS_HASH_INDEX_FILE"; then + return 0 + fi + + return 1 +} + +install_seed_into_corpus() { + local seed_file=$1 + local corpus=$2 + local hash basename target + + if corpus_contains_seed "$corpus" "$seed_file"; then + return 0 + fi + + hash=$(sha1sum "$seed_file" | awk '{print $1}') + basename=$(basename "$seed_file") + if [[ "$basename" =~ ^[0-9a-f]{40}$ ]]; then + target=$(python3 "$ROOT/scripts/ci/name_fuzz_corpus_entry.py" --print-name "$seed_file") + else + target="$basename" + fi + + if [[ -f "$corpus/$target" ]] && ! cmp -s "$seed_file" "$corpus/$target"; then + target="${target}_${hash:0:8}" + fi + + cp "$seed_file" "$corpus/$target" + if [[ -n "$CORPUS_HASH_INDEX_FILE" ]]; then + printf '%s %s\n' "$hash" "$corpus/$target" >>"$CORPUS_HASH_INDEX_FILE" + fi +} + +# shellcheck source=scripts/ci/fuzz_seed_guard.sh +source "$ROOT/scripts/ci/fuzz_seed_guard.sh" + +bootstrap_corpus() { + local target=$1 + local corpus="$ROOT/tests/fault_tolerance/fuzz/corpus/$target" + local seeds_dir + seeds_dir=$(seed_dir_for_target "$target") + + printf 'Bootstrapping corpus for %s\n' "$target" + mkdir -p "$corpus" + build_corpus_hash_index "$corpus" + relocate_generated_seed_artifacts "$seeds_dir" "$corpus" + validate_seed_uniqueness "$seeds_dir" + + shopt -s nullglob + for seed_file in "$seeds_dir"/*; do + [[ -f "$seed_file" ]] || continue + install_seed_into_corpus "$seed_file" "$corpus" + done + + clear_corpus_hash_index + rename_fuzz_corpus_entries "$corpus" +} + +if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then + cmake --preset "$CMAKE_PRESET" "$@" +fi + +cmake --build --preset "$BUILD_PRESET" --parallel + +export ASAN_OPTIONS="${ASAN_OPTIONS:-detect_leaks=1:abort_on_error=1:detect_stack_use_after_return=1}" +export UBSAN_OPTIONS="${UBSAN_OPTIONS:-print_stacktrace=1:halt_on_error=1}" + +for target in "${FUZZ_TARGETS[@]}"; do + bootstrap_corpus "$target" + corpus="$ROOT/tests/fault_tolerance/fuzz/corpus/$target" + printf 'Running fuzzer %s for %ss\n' "$target" "$MAX_TOTAL_TIME" + "$BUILD_DIR/$target" \ + -max_total_time="$MAX_TOTAL_TIME" \ + -close_fd_mask=3 \ + "$corpus" + rename_fuzz_corpus_entries "$corpus" +done + +chmod +x "$ROOT/scripts/ci/run_fuzz_regression.sh" +printf 'Running fuzz regression replay\n' +"$ROOT/scripts/ci/run_fuzz_regression.sh" diff --git a/scripts/ci/run_fuzzers_extended.sh b/scripts/ci/run_fuzzers_extended.sh new file mode 100755 index 0000000..af0b935 --- /dev/null +++ b/scripts/ci/run_fuzzers_extended.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +set -uo pipefail + +ROOT="${FUZZ_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +BUILD_DIR="${FUZZ_BUILD_DIR:-$ROOT/build-cmake/fuzz}" +MAX_TOTAL_TIME="${PREBYTE_FUZZ_MAX_TOTAL_TIME:-300}" +LOG="${FUZZ_LOG:-$ROOT/build-cmake/fuzz-run-extended.log}" + +FUZZ_TARGETS=( + fuzz_template_lexer + fuzz_template_parser + fuzz_json_parser + fuzz_yaml_parser + fuzz_toml_parser + fuzz_ini_parser + fuzz_env_parser + fuzz_compiled_template_serializer + fuzz_settings_loader + fuzz_include_resolver + fuzz_file_parser + fuzz_lua_chunk + fuzz_render_pbt + fuzz_app_runner + fuzz_batch_render + fuzz_structured_import + fuzz_lua_sandbox +) + +seed_dir_for_target() { + case "$1" in + fuzz_template_lexer|fuzz_template_parser) + printf '%s/tests/fault_tolerance/fuzz/seeds/template\n' "$ROOT" + ;; + fuzz_json_parser) + printf '%s/tests/fault_tolerance/fuzz/seeds/json\n' "$ROOT" + ;; + fuzz_yaml_parser) + printf '%s/tests/fault_tolerance/fuzz/seeds/yaml\n' "$ROOT" + ;; + fuzz_toml_parser) + printf '%s/tests/fault_tolerance/fuzz/seeds/toml\n' "$ROOT" + ;; + fuzz_ini_parser) + printf '%s/tests/fault_tolerance/fuzz/seeds/ini\n' "$ROOT" + ;; + fuzz_env_parser) + printf '%s/tests/fault_tolerance/fuzz/seeds/env\n' "$ROOT" + ;; + fuzz_compiled_template_serializer) + printf '%s/tests/fault_tolerance/fuzz/seeds/pbc\n' "$ROOT" + ;; + fuzz_settings_loader) + printf '%s/tests/fault_tolerance/fuzz/seeds/settings\n' "$ROOT" + ;; + fuzz_include_resolver) + printf '%s/tests/fault_tolerance/fuzz/seeds/include\n' "$ROOT" + ;; + fuzz_file_parser) + printf '%s/tests/fault_tolerance/fuzz/seeds/file_parser\n' "$ROOT" + ;; + fuzz_lua_chunk) + printf '%s/tests/fault_tolerance/fuzz/seeds/lua_chunk\n' "$ROOT" + ;; + fuzz_render_pbt) + printf '%s/tests/fault_tolerance/fuzz/seeds/render_pbt\n' "$ROOT" + ;; + fuzz_app_runner) + printf '%s/tests/fault_tolerance/fuzz/seeds/app_runner\n' "$ROOT" + ;; + fuzz_batch_render) + printf '%s/tests/fault_tolerance/fuzz/seeds/batch_render\n' "$ROOT" + ;; + fuzz_structured_import) + printf '%s/tests/fault_tolerance/fuzz/seeds/structured_import\n' "$ROOT" + ;; + fuzz_lua_sandbox) + printf '%s/tests/fault_tolerance/fuzz/seeds/lua_sandbox\n' "$ROOT" + ;; + *) + return 1 + ;; + esac +} + +validate_seed_uniqueness() { + local seeds_dir=$1 + local -A seen_hashes=() + local seed_file hash duplicate + + shopt -s nullglob + for seed_file in "$seeds_dir"/*; do + [[ -f "$seed_file" ]] || continue + hash=$(sha1sum "$seed_file" | awk '{print $1}') + duplicate="${seen_hashes[$hash]:-}" + if [[ -n "$duplicate" ]]; then + printf 'Duplicate fuzz seed content:\n %s\n %s\n' "$duplicate" "$seed_file" >&2 + return 1 + fi + seen_hashes[$hash]=$seed_file + done +} + +corpus_contains_seed() { + local corpus=$1 + local seed_file=$2 + local hash entry + + hash=$(sha1sum "$seed_file" | awk '{print $1}') + if [[ -f "$corpus/$hash" ]]; then + return 0 + fi + + shopt -s nullglob + for entry in "$corpus"/*; do + [[ -f "$entry" ]] || continue + if cmp -s "$seed_file" "$entry"; then + return 0 + fi + done + + return 1 +} + +install_seed_into_corpus() { + local seed_file=$1 + local corpus=$2 + local hash basename target + + if corpus_contains_seed "$corpus" "$seed_file"; then + return 0 + fi + + hash=$(sha1sum "$seed_file" | awk '{print $1}') + basename=$(basename "$seed_file") + if [[ "$basename" =~ ^[0-9a-f]{40}$ ]]; then + target=$(python3 "$ROOT/scripts/ci/name_fuzz_corpus_entry.py" --print-name "$seed_file") + else + target="$basename" + fi + + if [[ -f "$corpus/$target" ]] && ! cmp -s "$seed_file" "$corpus/$target"; then + target="${target}_${hash:0:8}" + fi + + cp "$seed_file" "$corpus/$target" +} + +# shellcheck source=scripts/ci/fuzz_seed_guard.sh +source "$ROOT/scripts/ci/fuzz_seed_guard.sh" + +bootstrap_corpus() { + local target=$1 + local corpus="$ROOT/tests/fault_tolerance/fuzz/corpus/$target" + local seeds_dir + seeds_dir=$(seed_dir_for_target "$target") + + mkdir -p "$corpus" + relocate_generated_seed_artifacts "$seeds_dir" "$corpus" + validate_seed_uniqueness "$seeds_dir" + + shopt -s nullglob + for seed_file in "$seeds_dir"/*; do + [[ -f "$seed_file" ]] || continue + install_seed_into_corpus "$seed_file" "$corpus" + done + + rename_fuzz_corpus_entries "$corpus" +} + +export ASAN_OPTIONS="${ASAN_OPTIONS:-detect_leaks=1:abort_on_error=1:detect_stack_use_after_return=1}" +export UBSAN_OPTIONS="${UBSAN_OPTIONS:-print_stacktrace=1:halt_on_error=1}" + +: >"$LOG" +failures=0 + +for target in "${FUZZ_TARGETS[@]}"; do + bootstrap_corpus "$target" + corpus="$ROOT/tests/fault_tolerance/fuzz/corpus/$target" + { + printf '\n========== %s (%ss) ==========\n' "$target" "$MAX_TOTAL_TIME" + "$BUILD_DIR/$target" \ + -max_total_time="$MAX_TOTAL_TIME" \ + -close_fd_mask=3 \ + "$corpus" + status=$? + rename_fuzz_corpus_entries "$corpus" + if [[ $status -ne 0 ]]; then + printf 'FAILED: %s (exit %s)\n' "$target" "$status" + failures=$((failures + 1)) + else + printf 'PASSED: %s\n' "$target" + fi + } 2>&1 | tee -a "$LOG" +done + +printf '\nSummary: %s/%s fuzzers failed\n' "$failures" "${#FUZZ_TARGETS[@]}" | tee -a "$LOG" +exit "$failures" diff --git a/scripts/ci/run_sanitize_tests.sh b/scripts/ci/run_sanitize_tests.sh new file mode 100755 index 0000000..296b3bf --- /dev/null +++ b/scripts/ci/run_sanitize_tests.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${SANITIZE_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" + +usage() { + cat < [cmake configure args...] + +Runs the test suite with the selected Clang sanitizer preset. +EOF +} + +SANITIZER_KIND="${1:-}" +if [[ -z "$SANITIZER_KIND" ]]; then + usage >&2 + exit 2 +fi +shift + +case "$SANITIZER_KIND" in + asan) + BUILD_DIR="${SANITIZE_BUILD_DIR:-$ROOT/build-cmake/sanitize}" + CMAKE_PRESET="${SANITIZE_PRESET:-sanitize}" + BUILD_PRESET="${SANITIZE_BUILD_PRESET:-sanitize-tests}" + TEST_PRESET="${SANITIZE_TEST_PRESET:-sanitize}" + unset TSAN_OPTIONS MSAN_OPTIONS + export ASAN_OPTIONS="${ASAN_OPTIONS:-detect_leaks=1:abort_on_error=1:detect_stack_use_after_return=1}" + export UBSAN_OPTIONS="${UBSAN_OPTIONS:-print_stacktrace=1:halt_on_error=1}" + ;; + tsan) + BUILD_DIR="${SANITIZE_BUILD_DIR:-$ROOT/build-cmake/tsan}" + CMAKE_PRESET="${SANITIZE_PRESET:-tsan}" + BUILD_PRESET="${SANITIZE_BUILD_PRESET:-tsan-tests}" + TEST_PRESET="${SANITIZE_TEST_PRESET:-tsan}" + unset ASAN_OPTIONS UBSAN_OPTIONS MSAN_OPTIONS + export TSAN_OPTIONS="${TSAN_OPTIONS:-halt_on_error=1:history_size=7:second_deadlock_stack=1}" + ;; + msan) + BUILD_DIR="${SANITIZE_BUILD_DIR:-$ROOT/build-cmake/msan}" + CMAKE_PRESET="${SANITIZE_PRESET:-msan}" + BUILD_PRESET="${SANITIZE_BUILD_PRESET:-msan-tests}" + TEST_PRESET="${SANITIZE_TEST_PRESET:-msan}" + unset ASAN_OPTIONS UBSAN_OPTIONS TSAN_OPTIONS + export MSAN_OPTIONS="${MSAN_OPTIONS:-halt_on_error=1:print_stats=1}" + ;; + *) + usage >&2 + exit 2 + ;; +esac + +if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then + cmake --preset "$CMAKE_PRESET" "$@" +fi + +cmake --build --preset "$BUILD_PRESET" --parallel +ctest --preset "$TEST_PRESET" --output-on-failure diff --git a/scripts/ci/smoke_packaging.py b/scripts/ci/smoke_packaging.py new file mode 100755 index 0000000..4a70872 --- /dev/null +++ b/scripts/ci/smoke_packaging.py @@ -0,0 +1,420 @@ +#!/usr/bin/env python3 +"""Smoke-test release packaging artifacts and optional Docker images.""" + +from __future__ import annotations + +import argparse +import json +import platform +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile +import zipfile +from pathlib import Path + + +class SmokeError(RuntimeError): + pass + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def read_binary_version(binary: Path) -> str: + output = run_packaged_binary(binary, ["--version"]).strip() + if output.startswith("v"): + return output[1:] + return output + + +def read_project_version(root: Path) -> str: + cmake_lists = (root / "CMakeLists.txt").read_text(encoding="utf-8") + match = re.search(r"project\([^\n]*VERSION\s+([^\s)]+)", cmake_lists) + if not match: + raise SmokeError("failed to read project version from CMakeLists.txt") + return match.group(1) + + +def detect_platform() -> str: + system = platform.system().lower() + if system == "linux": + return "linux" + if system == "darwin": + return "macos" + if system == "windows": + return "windows" + raise SmokeError(f"unsupported host platform for packaging smoke tests: {system}") + + +def detect_arch() -> str: + machine = platform.machine().lower() + if machine in {"x86_64", "amd64"}: + return "x86_64" + if machine in {"aarch64", "arm64"}: + return "aarch64" + raise SmokeError(f"unsupported host architecture for packaging smoke tests: {machine}") + + +def run_command(command: list[str], *, cwd: Path | None = None, input_text: str | None = None) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run( + command, + cwd=str(cwd) if cwd is not None else None, + input=input_text, + capture_output=True, + text=True, + check=False, + timeout=120, + ) + except subprocess.TimeoutExpired as error: + raise SmokeError(f"command timed out: {' '.join(command)}") from error + + +def require_success(result: subprocess.CompletedProcess[str], context: str) -> str: + if result.returncode != 0: + raise SmokeError( + f"{context} failed (exit {result.returncode})\n" + f"stdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ) + return result.stdout + + +def run_packaged_binary(binary_path: Path, args: list[str], *, cwd: Path | None = None, input_text: str | None = None) -> str: + result = run_command([str(binary_path), *args], cwd=cwd, input_text=input_text) + return require_success(result, f"running packaged binary {binary_path}") + + +def package_binary(root: Path, binary: Path, dist_dir: Path, version: str, platform_name: str, arch: str) -> Path: + script = root / "scripts" / "ci" / "package_binary.py" + result = run_command( + [ + sys.executable, + str(script), + "--version", + version, + "--platform", + platform_name, + "--arch", + arch, + "--binary", + str(binary), + "--output-dir", + str(dist_dir.relative_to(root)), + ], + cwd=root, + ) + archive_path = Path(require_success(result, "package_binary.py").strip()) + if not archive_path.is_file(): + raise SmokeError(f"binary package not created: {archive_path}") + return archive_path + + +def extract_binary_archive(archive_path: Path, extract_dir: Path, platform_name: str) -> Path: + if platform_name == "windows": + with zipfile.ZipFile(archive_path) as archive: + archive.extractall(extract_dir) + matches = sorted(extract_dir.glob("prebyte-*/bin/prebyte.exe")) + if not matches: + raise SmokeError(f"packaged prebyte binary not found in {archive_path}") + return matches[0] + + with tarfile.open(archive_path, "r:gz") as archive: + archive.extractall(extract_dir, filter="data") + + matches = sorted(extract_dir.glob("prebyte-*/bin/prebyte")) + if not matches: + raise SmokeError(f"packaged prebyte binary not found in {archive_path}") + return matches[0] + + +def smoke_binary_package(root: Path, binary: Path, dist_dir: Path, version: str, platform_name: str, arch: str) -> None: + archive_path = package_binary(root, binary, dist_dir, version, platform_name, arch) + with tempfile.TemporaryDirectory(prefix="prebyte-binary-smoke-") as temp_dir: + packaged_binary = extract_binary_archive(archive_path, Path(temp_dir), platform_name) + output = run_packaged_binary(packaged_binary, ["--version"]) + if version not in output and f"v{version}" not in output: + raise SmokeError(f"packaged binary --version missing {version}: {output!r}") + + fixture_dir = root / "tests" / "fixtures" / "render_simple" + rendered = run_packaged_binary( + packaged_binary, + ["input.txt", "-Dname=Packaged"], + cwd=fixture_dir, + ) + if rendered != "Hello Packaged\n": + raise SmokeError(f"unexpected packaged render output: {rendered!r}") + + +def package_reqpack(root: Path, binary: Path, dist_dir: Path, version: str, platform_name: str, arch: str) -> Path: + if platform_name not in {"linux", "macos"}: + raise SmokeError(f"reqpack packaging is only supported on linux/macos, not {platform_name}") + if shutil.which("zstd") is None: + raise SmokeError("zstd is required for reqpack packaging smoke tests") + + script = root / "scripts" / "ci" / "package_reqpack.py" + result = run_command( + [ + sys.executable, + str(script), + "--version", + version, + "--platform", + platform_name, + "--arch", + arch, + "--binary", + str(binary), + "--output-dir", + str(dist_dir.relative_to(root)), + ], + cwd=root, + ) + archive_path = Path(require_success(result, "package_reqpack.py").strip()) + if not archive_path.is_file(): + raise SmokeError(f"reqpack archive not created: {archive_path}") + return archive_path + + +def extract_reqpack_payload(archive_path: Path, extract_dir: Path) -> Path: + payload_zst = extract_dir / "payload.tar.zst" + payload_tar = extract_dir / "payload.tar" + with tarfile.open(archive_path, "r") as archive: + required_entries = { + "metadata.json", + "reqpack.lua", + "payload/payload.tar.zst", + "hashes/payload.sha256", + } + names = {member.name.rstrip("/") for member in archive.getmembers()} + missing = required_entries - names + if missing: + raise SmokeError(f"reqpack archive missing entries: {', '.join(sorted(missing))}") + + payload_member = archive.getmember("payload/payload.tar.zst") + with archive.extractfile(payload_member) as payload_stream: + if payload_stream is None: + raise SmokeError("failed to read reqpack payload stream") + payload_zst.write_bytes(payload_stream.read()) + + require_success( + run_command(["zstd", "-d", "-f", str(payload_zst), "-o", str(payload_tar)]), + "decompressing reqpack payload", + ) + + install_root = extract_dir / "installed" + install_root.mkdir(parents=True, exist_ok=True) + with tarfile.open(payload_tar, "r") as payload_archive: + payload_archive.extractall(install_root, filter="data") + + matches = sorted(install_root.glob("bin/prebyte")) + if not matches: + raise SmokeError(f"reqpack payload missing bin/prebyte in {archive_path}") + return matches[0] + + +def smoke_reqpack_package(root: Path, binary: Path, dist_dir: Path, version: str, platform_name: str, arch: str) -> Path: + archive_path = package_reqpack(root, binary, dist_dir, version, platform_name, arch) + with tempfile.TemporaryDirectory(prefix="prebyte-reqpack-smoke-") as temp_dir: + packaged_binary = extract_reqpack_payload(archive_path, Path(temp_dir)) + output = run_packaged_binary(packaged_binary, ["--version"]) + if version not in output and f"v{version}" not in output: + raise SmokeError(f"reqpack payload --version missing {version}: {output!r}") + + fixture_dir = root / "tests" / "fixtures" / "render_simple" + rendered = run_packaged_binary( + packaged_binary, + ["input.txt", "-Dname=ReqPack"], + cwd=fixture_dir, + ) + if rendered != "Hello ReqPack\n": + raise SmokeError(f"unexpected reqpack render output: {rendered!r}") + return archive_path + + +def smoke_reqpack_index(root: Path, dist_dir: Path, version: str, platform_name: str, arch: str) -> None: + index_path = dist_dir / "index.json" + script = root / "scripts" / "ci" / "build_reqpack_index.py" + result = run_command( + [ + sys.executable, + str(script), + "--dist-dir", + str(dist_dir.relative_to(root)), + "--output", + str(index_path.relative_to(root)), + ], + cwd=root, + ) + require_success(result, "build_reqpack_index.py") + if not index_path.is_file(): + raise SmokeError(f"reqpack index not created: {index_path}") + + index = json.loads(index_path.read_text(encoding="utf-8")) + packages = index.get("packages", []) + if not packages: + raise SmokeError("reqpack index does not list any packages") + + matching = [ + package + for package in packages + if package.get("name") == "prebyte" + and package.get("version") == version + and package.get("architecture") == arch + and platform_name in package.get("system", []) + ] + if not matching: + raise SmokeError( + f"reqpack index missing package for prebyte {version} {platform_name}/{arch}: {packages!r}" + ) + + +def smoke_docker_image(root: Path, version: str) -> None: + if shutil.which("docker") is None: + raise SmokeError("docker is required for docker packaging smoke tests") + + image = "prebyte-packaging-smoke:local" + build = run_command( + [ + "docker", + "build", + "-t", + image, + "--build-arg", + f"PREBYTE_VERSION={version}", + str(root), + ] + ) + require_success(build, "docker build") + + version_output = require_success( + run_command(["docker", "run", "--rm", image, "--version"]), + "docker image --version", + ) + if version not in version_output and f"v{version}" not in version_output: + raise SmokeError(f"docker image --version missing {version}: {version_output!r}") + + fixture_dir = root / "tests" / "fixtures" / "render_simple" + render = require_success( + run_command( + [ + "docker", + "run", + "--rm", + "-v", + f"{fixture_dir}:/work:ro,z", + "-w", + "/work", + image, + "input.txt", + "-Dname=Docker", + ] + ), + "docker image render", + ) + if render != "Hello Docker\n": + raise SmokeError(f"unexpected docker render output: {render!r}") + + stdin_render = require_success( + run_command( + ["docker", "run", "--rm", "-i", image, "-Dname=Stdin", "--"], + input_text="Hello {{ name }}!\n", + ), + "docker stdin render", + ) + if stdin_render != "Hello Stdin!\n": + raise SmokeError(f"unexpected docker stdin render output: {stdin_render!r}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path, help="Path to built prebyte binary") + parser.add_argument("--repo-root", type=Path, default=repo_root()) + parser.add_argument("--version", help="Package version (default: project version)") + parser.add_argument("--platform", choices=["linux", "macos", "windows"], help="Target package platform") + parser.add_argument("--arch", choices=["x86_64", "aarch64"], help="Target package architecture") + parser.add_argument( + "--checks", + nargs="+", + choices=["binary", "reqpack", "index", "docker"], + default=["binary", "reqpack", "index"], + help="Smoke checks to run", + ) + parser.add_argument( + "--dist-dir", + type=Path, + help="Directory for generated packages (default: temp dir under repo dist/)", + ) + args = parser.parse_args() + + root = args.repo_root.resolve() + platform_name = args.platform or detect_platform() + arch = args.arch or detect_arch() + + binary_checks = {"binary", "reqpack", "index"} + if binary_checks.intersection(args.checks) and args.binary is None: + parser.error("--binary is required for binary, reqpack, and index checks") + + binary: Path | None = None + if args.binary is not None: + binary = args.binary.resolve() + if not binary.is_file(): + parser.error(f"binary not found: {binary}") + + version = args.version + if version is None and binary is not None: + version = read_binary_version(binary) + if version is None: + version = read_project_version(root) + + dist_dir = args.dist_dir + temp_dist: tempfile.TemporaryDirectory[str] | None = None + if dist_dir is None: + temp_dist = tempfile.TemporaryDirectory(prefix="prebyte-packaging-smoke-", dir=root / "dist") + dist_dir = Path(temp_dist.name) + else: + dist_dir = dist_dir.resolve() + dist_dir.mkdir(parents=True, exist_ok=True) + + try: + if "binary" in args.checks: + print("Smoke check: binary release archive") + assert binary is not None + smoke_binary_package(root, binary, dist_dir, version, platform_name, arch) + + reqpack_archive: Path | None = None + if "reqpack" in args.checks: + assert binary is not None + if platform_name == "windows": + print("Skipping reqpack smoke on windows") + else: + print("Smoke check: reqpack archive") + reqpack_archive = smoke_reqpack_package(root, binary, dist_dir, version, platform_name, arch) + + if "index" in args.checks: + assert binary is not None + if platform_name == "windows": + print("Skipping reqpack index smoke on windows") + else: + if reqpack_archive is None: + reqpack_archive = package_reqpack(root, binary, dist_dir, version, platform_name, arch) + print("Smoke check: reqpack index") + smoke_reqpack_index(root, dist_dir, version, platform_name, arch) + + if "docker" in args.checks: + print("Smoke check: docker image") + smoke_docker_image(root, version) + except SmokeError as error: + print(f"Packaging smoke test failed: {error}", file=sys.stderr) + return 1 + + print("Packaging smoke tests passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/main/cpp/PrebyteEngine.cpp b/src/main/cpp/PrebyteEngine.cpp index f9bb384..1275c23 100644 --- a/src/main/cpp/PrebyteEngine.cpp +++ b/src/main/cpp/PrebyteEngine.cpp @@ -6,12 +6,12 @@ #include "config/VariableDefinitionParser.h" #include "io/InputReader.h" #include "io/OutputWriter.h" -#include "runtime/CompiledTemplateCompiler.h" -#include "runtime/CompiledTemplateCache.h" -#include "runtime/FileMetadataCache.h" -#include "runtime/CompiledTemplateSerializer.h" -#include "runtime/CompiledProgramAnalysis.h" -#include "runtime/EngineRuntime.h" +#include "runtime/compiled/CompiledTemplateCompiler.h" +#include "runtime/compiled/CompiledTemplateCache.h" +#include "runtime/cache/FileMetadataCache.h" +#include "runtime/compiled/CompiledTemplateSerializer.h" +#include "runtime/compiled/CompiledProgramAnalysis.h" +#include "runtime/render/EngineRuntime.h" #include #include @@ -237,6 +237,7 @@ struct Prebyte::PreparedState { const EffectiveSettings& effective_settings = settings_for(path); prepare_render_session(); + render_session.include_anchor_root = path.parent_path(); CompiledTemplateSerializer serializer; if (path.extension() == ".pbc") { diff --git a/src/main/cpp/app/AppRunner.cpp b/src/main/cpp/app/AppRunner.cpp index c0bf442..ee73446 100644 --- a/src/main/cpp/app/AppRunner.cpp +++ b/src/main/cpp/app/AppRunner.cpp @@ -7,11 +7,11 @@ #include "config/VariableDefinitionParser.h" #include "io/InputReader.h" #include "io/OutputWriter.h" -#include "runtime/BuiltinRegistry.h" -#include "runtime/CompiledTemplateSerializer.h" -#include "runtime/IncludeResolver.h" -#include "runtime/LuaHelperRegistry.h" -#include "runtime/Renderer.h" +#include "runtime/expression/BuiltinRegistry.h" +#include "runtime/compiled/CompiledTemplateSerializer.h" +#include "runtime/resolution/IncludeResolver.h" +#include "runtime/lua/LuaHelperRegistry.h" +#include "runtime/render/Renderer.h" #include "support/TextUtil.h" #include "support/Version.h" @@ -152,6 +152,9 @@ RenderReport AppRunner::render_report(const Command& command) const { session.ignore_names_ref = &variable_context.ignore_names; session.effective_settings_cache_ref = &effective_settings_cache; session.start_time = start_time; + if (command.input_path.has_value()) { + session.include_anchor_root = command.input_path->parent_path(); + } BuiltinRegistry builtins; ExpressionEvaluator expression_engine(builtins); diff --git a/src/main/cpp/app/BatchProcessor.cpp b/src/main/cpp/app/BatchProcessor.cpp index 737669f..753c3de 100644 --- a/src/main/cpp/app/BatchProcessor.cpp +++ b/src/main/cpp/app/BatchProcessor.cpp @@ -10,10 +10,10 @@ #include "io/InputReader.h" #include "io/OutputWriter.h" #include "parser/JsonParser.h" -#include "runtime/BuiltinRegistry.h" -#include "runtime/CompiledTemplateSerializer.h" -#include "runtime/IncludeResolver.h" -#include "runtime/Renderer.h" +#include "runtime/expression/BuiltinRegistry.h" +#include "runtime/compiled/CompiledTemplateSerializer.h" +#include "runtime/resolution/IncludeResolver.h" +#include "runtime/render/Renderer.h" #include "support/Diagnostic.h" #include @@ -145,7 +145,7 @@ std::string resolve_output_filename(const BatchEntry& entry, const std::optional return *override_name; } if (!entry.output_name.empty()) { - if (entry.output_name.find('.') != std::string::npos || template_path.empty()) { + if (entry.output_name.contains('.') || template_path.empty()) { return entry.output_name; } return entry.output_name + template_path.extension().string(); diff --git a/src/main/cpp/config/RuleResolver.cpp b/src/main/cpp/config/RuleResolver.cpp index 829cd6d..ea77347 100644 --- a/src/main/cpp/config/RuleResolver.cpp +++ b/src/main/cpp/config/RuleResolver.cpp @@ -66,7 +66,7 @@ ResolvedConfiguration RuleResolver::resolve(const SettingsData& settings, const configuration.ignore_names.insert(cli_ignore_names.begin(), cli_ignore_names.end()); for (const std::string& rule_arg : cli_rule_args) { - if (rule_arg.find("::") != std::string::npos) { + if (rule_arg.contains("::")) { configuration.file_rules.push_back(parse_file_rule(rule_arg)); continue; } diff --git a/src/main/cpp/config/VariableDefinitionParser.cpp b/src/main/cpp/config/VariableDefinitionParser.cpp index 5c5df5f..4c74ea5 100644 --- a/src/main/cpp/config/VariableDefinitionParser.cpp +++ b/src/main/cpp/config/VariableDefinitionParser.cpp @@ -2,10 +2,10 @@ #include "datatypes/Data.h" #include "parser/FileParser.h" -#include "runtime/FileMetadataCache.h" +#include "runtime/cache/FileMetadataCache.h" #include "support/Diagnostic.h" +#include "support/FileUtil.h" -#include #include #include #include @@ -22,11 +22,7 @@ Diagnostic make_variable_error(const std::string& message) { } std::string read_file(const std::filesystem::path& path) { - std::ifstream stream(path); - if (!stream) { - throw DiagnosticError(make_variable_error("Cannot open file for variable import: " + path.string())); - } - return std::string(std::istreambuf_iterator(stream), std::istreambuf_iterator()); + return file_util::read_text_file(path); } Value data_to_value(const Data& data) { @@ -71,6 +67,11 @@ class StructuredImportCache { cache_[normalized] = StructuredImportCacheEntry{.mtime_ticks = metadata.mtime_ticks, .value = std::move(value)}; } + void clear() { + std::lock_guard lock(mutex_); + cache_.clear(); + } + private: static std::filesystem::path normalize(const std::filesystem::path& path) { if (path.empty()) { @@ -93,6 +94,10 @@ class StructuredImportCache { } +void VariableDefinitionParser::clear_import_cache() { + StructuredImportCache::instance().clear(); +} + VariableContext VariableDefinitionParser::parse(const std::vector& define_args, const std::map& base_variables, const std::set& base_ignore_names) const { diff --git a/src/main/cpp/io/InputBuffer.cpp b/src/main/cpp/io/InputBuffer.cpp index b05fa7a..dc94bf5 100644 --- a/src/main/cpp/io/InputBuffer.cpp +++ b/src/main/cpp/io/InputBuffer.cpp @@ -15,7 +15,7 @@ namespace prebyte { namespace { -constexpr std::size_t mmap_min_size = 16 * 1024; +constexpr std::size_t mmap_min_size = 16ULL * 1024ULL; #ifdef __linux__ std::string read_owned_fd(int fd, std::size_t size) { diff --git a/src/main/cpp/parser/FileParser.cpp b/src/main/cpp/parser/FileParser.cpp index 43fe91a..1ebb9b5 100644 --- a/src/main/cpp/parser/FileParser.cpp +++ b/src/main/cpp/parser/FileParser.cpp @@ -3,8 +3,6 @@ namespace prebyte { Data FileParser::parse(const std::string& filePath) { - Parser* parser; - if (filePath.empty()) { throw std::runtime_error("File path cannot be empty"); } @@ -14,32 +12,39 @@ Data FileParser::parse(const std::string& filePath) { } if (filePath.ends_with(".json")) { - parser = new JsonParser(); - } else if (filePath.ends_with(".yaml") || filePath.ends_with(".yml")) { - parser = new YamlParser(); - } else if (filePath.ends_with(".ini") || filePath.ends_with(".cfg")) { - parser = new IniParser(); - } else if (filePath.ends_with(".env")) { - parser = new EnvParser(); - } else if (filePath.ends_with(".toml")) { - parser = new TomlParser(); - } else { - throw std::runtime_error("Unsupported file format: " + filePath); - } - return parseFile(filePath, parser); + JsonParser parser; + return parseFile(filePath, &parser); + } + if (filePath.ends_with(".yaml") || filePath.ends_with(".yml")) { + YamlParser parser; + return parseFile(filePath, &parser); + } + if (filePath.ends_with(".ini") || filePath.ends_with(".cfg")) { + IniParser parser; + return parseFile(filePath, &parser); + } + if (filePath.ends_with(".env")) { + EnvParser parser; + return parseFile(filePath, &parser); + } + if (filePath.ends_with(".toml")) { + TomlParser parser; + return parseFile(filePath, &parser); + } + + throw std::runtime_error("Unsupported file format: " + filePath); } Data FileParser::parseFile(const std::string& filePath, Parser* parser) { - if (!parser->can_parse(filePath)) { - throw std::runtime_error("Cannot parse file with the selected parser: " + filePath); - } - try { - return parser->parse(std::filesystem::path(filePath)); - } catch (const std::exception& e) { - throw std::runtime_error("Error parsing file: " + std::string(e.what())); - } - - return Data(); + if (!parser->can_parse(filePath)) { + throw std::runtime_error("Cannot parse file with the selected parser: " + filePath); + } + + try { + return parser->parse(std::filesystem::path(filePath)); + } catch (const std::exception& e) { + throw std::runtime_error("Error parsing file: " + std::string(e.what())); + } } } diff --git a/src/main/cpp/parser/IniParser.cpp b/src/main/cpp/parser/IniParser.cpp index 4317baa..6990a9b 100644 --- a/src/main/cpp/parser/IniParser.cpp +++ b/src/main/cpp/parser/IniParser.cpp @@ -57,7 +57,7 @@ bool IniParser::can_parse(const std::filesystem::path& filepath) const { line.erase(0, line.find_first_not_of(" \t")); if (line.empty() || line[0] == ';' || line[0] == '#') continue; if (line.front() == '[' && line.back() == ']') return true; - if (line.find('=') != std::string::npos) return true; + if (line.contains('=')) return true; } return false; diff --git a/src/main/cpp/parser/JsonParser.cpp b/src/main/cpp/parser/JsonParser.cpp index bfd5a41..0b98f0e 100644 --- a/src/main/cpp/parser/JsonParser.cpp +++ b/src/main/cpp/parser/JsonParser.cpp @@ -1,8 +1,8 @@ #include "parser/JsonParser.h" #include "support/Diagnostic.h" +#include "support/FileUtil.h" -#include #include namespace prebyte { @@ -70,7 +70,7 @@ class JsonValueParser { skip_whitespace(); consume(':'); skip_whitespace(); - object[key] = parse_value(); + object.emplace(key, parse_value()); skip_whitespace(); if (peek() == '}') { advance(); @@ -201,18 +201,10 @@ class JsonValueParser { std::size_t index_ = 0; }; -std::string read_file(const std::filesystem::path& path) { - std::ifstream stream(path); - if (!stream) { - throw std::runtime_error("Could not open JSON file: " + path.string()); - } - return std::string(std::istreambuf_iterator(stream), std::istreambuf_iterator()); -} - } Data JsonParser::parse(const std::filesystem::path& filepath) { - return parse_string(read_file(filepath)); + return parse_string(file_util::read_text_file(filepath)); } bool JsonParser::can_parse(const std::filesystem::path& filepath) const { diff --git a/src/main/cpp/parser/TomlParser.cpp b/src/main/cpp/parser/TomlParser.cpp index e6fefd3..372f82b 100644 --- a/src/main/cpp/parser/TomlParser.cpp +++ b/src/main/cpp/parser/TomlParser.cpp @@ -1,10 +1,9 @@ #include "parser/TomlParser.h" +#include "support/FileUtil.h" #include "support/TextUtil.h" #include -#include -#include #include namespace prebyte { @@ -39,7 +38,10 @@ bool is_double_token(std::string_view value) { && is_integer_token(value.substr(dot + 1)); } -Data parse_toml_value(const std::string& raw) { +constexpr std::size_t kMaxTomlNestingDepth = 128; +constexpr std::size_t kMaxTomlArrayDepth = 64; + +Data parse_toml_value(const std::string& raw, std::size_t array_depth = 0) { const std::string value = text::trim(raw); if (value == "true") { return Data(true); @@ -51,12 +53,15 @@ Data parse_toml_value(const std::string& raw) { return Data(value.substr(1, value.size() - 2)); } if (value.size() >= 2 && value.front() == '[' && value.back() == ']') { + if (array_depth >= kMaxTomlArrayDepth) { + throw std::runtime_error("TOML array nesting is too deep"); + } Data::Array array; const std::string inner = value.substr(1, value.size() - 2); for (const std::string& item : text::split(inner, ',')) { const std::string trimmed = text::trim(item); if (!trimmed.empty()) { - array.push_back(parse_toml_value(trimmed)); + array.push_back(parse_toml_value(trimmed, array_depth + 1)); } } return Data(std::move(array)); @@ -81,6 +86,9 @@ void ensure_map(Data& data) { } void set_nested_value(Data& root, const std::vector& path, const Data& value) { + if (path.size() > kMaxTomlNestingDepth) { + throw std::runtime_error("TOML nesting is too deep"); + } ensure_map(root); Data* current = &root; for (std::size_t index = 0; index + 1 < path.size(); ++index) { @@ -108,18 +116,10 @@ std::string strip_comment(const std::string& line) { return result; } -std::string read_file(const std::filesystem::path& path) { - std::ifstream stream(path); - if (!stream) { - throw std::runtime_error("Could not open TOML file: " + path.string()); - } - return std::string(std::istreambuf_iterator(stream), std::istreambuf_iterator()); -} - } Data TomlParser::parse(const std::filesystem::path& filepath) { - return parse_string(read_file(filepath)); + return parse_string(file_util::read_text_file(filepath)); } bool TomlParser::can_parse(const std::filesystem::path& filepath) const { diff --git a/src/main/cpp/parser/YamlParser.cpp b/src/main/cpp/parser/YamlParser.cpp index 2203c5e..4446168 100644 --- a/src/main/cpp/parser/YamlParser.cpp +++ b/src/main/cpp/parser/YamlParser.cpp @@ -1,20 +1,70 @@ #include "parser/YamlParser.h" +#include "support/FileUtil.h" #include "support/TextUtil.h" -#include -#include -#include +#include namespace prebyte { namespace { +constexpr std::size_t kMaxYamlLines = 8192; +constexpr std::size_t kMaxYamlCollectionEntries = 8192; +constexpr std::size_t kMaxYamlScalarLength = 4096; + struct YamlLine { std::size_t indent = 0; std::string text; }; +bool looks_like_integer(std::string_view value) { + if (value.empty()) { + return false; + } + std::size_t index = 0; + if (value[index] == '-') { + if (value.size() == 1) { + return false; + } + ++index; + } + for (; index < value.size(); ++index) { + if (!std::isdigit(static_cast(value[index]))) { + return false; + } + } + return true; +} + +bool looks_like_double(std::string_view value) { + if (value.empty()) { + return false; + } + std::size_t index = 0; + bool saw_dot = false; + if (value[index] == '-') { + if (value.size() == 1) { + return false; + } + ++index; + } + for (; index < value.size(); ++index) { + const char ch = value[index]; + if (ch == '.') { + if (saw_dot) { + return false; + } + saw_dot = true; + continue; + } + if (!std::isdigit(static_cast(ch))) { + return false; + } + } + return saw_dot; +} + Data parse_scalar(const std::string& raw) { const std::string value = text::trim(raw); if (value == "true") { @@ -23,13 +73,19 @@ Data parse_scalar(const std::string& raw) { if (value == "false") { return Data(false); } - static const std::regex int_regex(R"(^-?\d+$)"); - static const std::regex double_regex(R"(^-?\d+\.\d+$)"); - if (std::regex_match(value, int_regex)) { - return Data(std::stoi(value)); - } - if (std::regex_match(value, double_regex)) { - return Data(std::stod(value)); + if (value.size() <= kMaxYamlScalarLength) { + if (looks_like_integer(value)) { + try { + return Data(std::stoi(value)); + } catch (const std::exception&) { // NOLINT(bugprone-empty-catch) + } + } + if (looks_like_double(value)) { + try { + return Data(std::stod(value)); + } catch (const std::exception&) { // NOLINT(bugprone-empty-catch) + } + } } if (value.size() >= 2 && value.front() == '"' && value.back() == '"') { return Data(value.substr(1, value.size() - 2)); @@ -42,6 +98,9 @@ std::vector tokenize_yaml(const std::string& input) { std::istringstream stream(input); std::string line; while (std::getline(stream, line)) { + if (lines.size() >= kMaxYamlLines) { + throw std::runtime_error("YAML input exceeds supported line limit"); + } const std::size_t first = line.find_first_not_of(' '); if (first == std::string::npos) { continue; @@ -80,6 +139,9 @@ class YamlValueParser { Data parse_map(std::size_t indent) { Data::Map map; while (index_ < lines_.size() && lines_[index_].indent == indent && !text::starts_with(lines_[index_].text, "- ")) { + if (map.size() >= kMaxYamlCollectionEntries) { + throw std::runtime_error("YAML map exceeds supported entry limit"); + } const std::string line = lines_[index_].text; const std::size_t colon = line.find(':'); if (colon == std::string::npos) { @@ -107,6 +169,9 @@ class YamlValueParser { Data parse_array(std::size_t indent) { Data::Array array; while (index_ < lines_.size() && lines_[index_].indent == indent && text::starts_with(lines_[index_].text, "- ")) { + if (array.size() >= kMaxYamlCollectionEntries) { + throw std::runtime_error("YAML array exceeds supported entry limit"); + } const std::string rest = text::trim(lines_[index_].text.substr(2)); ++index_; @@ -128,18 +193,10 @@ class YamlValueParser { std::size_t index_ = 0; }; -std::string read_file(const std::filesystem::path& path) { - std::ifstream stream(path); - if (!stream) { - throw std::runtime_error("Could not open YAML file: " + path.string()); - } - return std::string(std::istreambuf_iterator(stream), std::istreambuf_iterator()); -} - } Data YamlParser::parse(const std::filesystem::path& filepath) { - return parse_string(read_file(filepath)); + return parse_string(file_util::read_text_file(filepath)); } bool YamlParser::can_parse(const std::filesystem::path& filepath) const { diff --git a/src/main/cpp/runtime/FileMetadataCache.cpp b/src/main/cpp/runtime/cache/FileMetadataCache.cpp similarity index 84% rename from src/main/cpp/runtime/FileMetadataCache.cpp rename to src/main/cpp/runtime/cache/FileMetadataCache.cpp index 8e53cc0..06cd605 100644 --- a/src/main/cpp/runtime/FileMetadataCache.cpp +++ b/src/main/cpp/runtime/cache/FileMetadataCache.cpp @@ -1,4 +1,4 @@ -#include "runtime/FileMetadataCache.h" +#include "runtime/cache/FileMetadataCache.h" #include @@ -12,6 +12,7 @@ namespace prebyte { namespace { constexpr auto kMetadataCacheTtl = std::chrono::milliseconds(250); +constexpr std::size_t kMaxMetadataCacheEntries = 8192; #ifndef _WIN32 std::int64_t stat_mtime_ticks(const struct stat& info) { @@ -71,7 +72,23 @@ void FileMetadataCache::remember(const std::filesystem::path& path, FileMetadata } std::lock_guard lock(mutex_); - cache_[normalize_path(path)] = Entry{metadata, std::chrono::steady_clock::now() + kMetadataCacheTtl}; + const auto now = std::chrono::steady_clock::now(); + for (auto it = cache_.begin(); it != cache_.end();) { + if (now >= it->second.expires_at) { + it = cache_.erase(it); + } else { + ++it; + } + } + if (cache_.size() >= kMaxMetadataCacheEntries) { + cache_.clear(); + } + cache_[normalize_path(path)] = Entry{metadata, now + kMetadataCacheTtl}; +} + +void FileMetadataCache::clear() { + std::lock_guard lock(mutex_); + cache_.clear(); } void FileMetadataCache::invalidate(const std::filesystem::path& path) { diff --git a/src/main/cpp/runtime/CompiledTemplateCache.cpp b/src/main/cpp/runtime/compiled/CompiledTemplateCache.cpp similarity index 96% rename from src/main/cpp/runtime/CompiledTemplateCache.cpp rename to src/main/cpp/runtime/compiled/CompiledTemplateCache.cpp index e79cc55..fc12b87 100644 --- a/src/main/cpp/runtime/CompiledTemplateCache.cpp +++ b/src/main/cpp/runtime/compiled/CompiledTemplateCache.cpp @@ -1,6 +1,6 @@ -#include "runtime/CompiledTemplateCache.h" +#include "runtime/compiled/CompiledTemplateCache.h" -#include "runtime/FileMetadataCache.h" +#include "runtime/cache/FileMetadataCache.h" #include @@ -164,4 +164,10 @@ const CompiledProgram* CompiledTemplateCache::store_inline(std::string_view sour return &it->second.program; } +void CompiledTemplateCache::clear() { + std::lock_guard lock(mutex_); + cache_.clear(); + inline_cache_.clear(); +} + } diff --git a/src/main/cpp/runtime/CompiledTemplateCompiler.cpp b/src/main/cpp/runtime/compiled/CompiledTemplateCompiler.cpp similarity index 99% rename from src/main/cpp/runtime/CompiledTemplateCompiler.cpp rename to src/main/cpp/runtime/compiled/CompiledTemplateCompiler.cpp index 280ed65..4385d0f 100644 --- a/src/main/cpp/runtime/CompiledTemplateCompiler.cpp +++ b/src/main/cpp/runtime/compiled/CompiledTemplateCompiler.cpp @@ -1,6 +1,6 @@ -#include "runtime/CompiledTemplateCompiler.h" +#include "runtime/compiled/CompiledTemplateCompiler.h" -#include "runtime/FileMetadataCache.h" +#include "runtime/cache/FileMetadataCache.h" #include "support/Diagnostic.h" #include "template/lexer/TemplateLexer.h" #include "template/parser/TemplateParser.h" diff --git a/src/main/cpp/runtime/CompiledTemplateExecutor.cpp b/src/main/cpp/runtime/compiled/CompiledTemplateExecutor.cpp similarity index 97% rename from src/main/cpp/runtime/CompiledTemplateExecutor.cpp rename to src/main/cpp/runtime/compiled/CompiledTemplateExecutor.cpp index 1262fcb..4412956 100644 --- a/src/main/cpp/runtime/CompiledTemplateExecutor.cpp +++ b/src/main/cpp/runtime/compiled/CompiledTemplateExecutor.cpp @@ -1,12 +1,12 @@ -#include "runtime/CompiledTemplateExecutor.h" +#include "runtime/compiled/CompiledTemplateExecutor.h" #include "config/RuleResolver.h" -#include "runtime/CompiledTemplateCompiler.h" -#include "runtime/CompiledTemplateCache.h" -#include "runtime/CompiledProgramAnalysis.h" -#include "runtime/FileMetadataCache.h" -#include "runtime/CompiledTemplateSerializer.h" -#include "runtime/CompiledTemplateWriter.h" +#include "runtime/compiled/CompiledTemplateCompiler.h" +#include "runtime/compiled/CompiledTemplateCache.h" +#include "runtime/compiled/CompiledProgramAnalysis.h" +#include "runtime/cache/FileMetadataCache.h" +#include "runtime/compiled/CompiledTemplateSerializer.h" +#include "runtime/compiled/CompiledTemplateWriter.h" #include "support/Diagnostic.h" #include "support/TextUtil.h" @@ -369,7 +369,7 @@ void ensure_scalar_operand(const Value& value, const std::filesystem::path& curr bool contains_value(const Value& right, const Value& left) { if (const auto string_value = right.try_as_string_view()) { - return string_value->find(left.to_string()) != std::string_view::npos; + return string_value->contains(left.to_string()); } if (const Value::Object* object = right.try_as_object()) { return object->contains(left.to_string()); @@ -399,8 +399,8 @@ bool compare_with_order(std::strong_ordering ordering, std::string_view op_name) return ordering == std::strong_ordering::greater || ordering == std::strong_ordering::equal; } -const std::filesystem::path& function_file_for(const RenderSession::FunctionDefinition& function, - const std::filesystem::path& fallback) { +std::filesystem::path function_file_for(const RenderSession::FunctionDefinition& function, + const std::filesystem::path& fallback) { if (!function.definition_file.empty()) { return function.definition_file; } @@ -658,7 +658,7 @@ bool CompiledTemplateExecutor::evaluate_condition_bool(const CompiledProgram& pr case ExpressionOpcode::LoadVar: case ExpressionOpcode::LoadBuiltin: { const std::string_view name = data_view(program, cond.data_offset, cond.data_length); - if (cond.opcode == ExpressionOpcode::LoadVar && name != "ARGS" && name.find('.') == std::string_view::npos + if (cond.opcode == ExpressionOpcode::LoadVar && name != "ARGS" && !name.contains('.') && can_fast_path_variable_lookup(settings, session)) { if (const Value* value = session.lookup_scoped_value(name, true)) { return value->to_bool(); @@ -668,7 +668,7 @@ bool CompiledTemplateExecutor::evaluate_condition_bool(const CompiledProgram& pr } return false; } - if (cond.opcode == ExpressionOpcode::LoadVar && name.find('.') != std::string_view::npos) { + if (cond.opcode == ExpressionOpcode::LoadVar && name.contains('.')) { if (const auto fast_value = try_fast_loop_lookup(name, settings.case_sensitive_variables, session)) { return fast_value->to_bool(); } @@ -695,7 +695,7 @@ Value CompiledTemplateExecutor::evaluate_expression(const CompiledProgram& progr case ExpressionOpcode::LoadVar: case ExpressionOpcode::LoadBuiltin: { const std::string_view name = data_view(program, op.data_offset, op.data_length); - if (op.opcode == ExpressionOpcode::LoadVar && name != "ARGS" && name.find('.') == std::string_view::npos + if (op.opcode == ExpressionOpcode::LoadVar && name != "ARGS" && !name.contains('.') && can_fast_path_variable_lookup(settings, session)) { if (const Value* value = session.lookup_scoped_value(name, true)) { if (const auto string_value = value->try_as_string_view()) { @@ -711,7 +711,7 @@ Value CompiledTemplateExecutor::evaluate_expression(const CompiledProgram& progr } return Value(); } - if (op.opcode == ExpressionOpcode::LoadVar && name.find('.') != std::string_view::npos) { + if (op.opcode == ExpressionOpcode::LoadVar && name.contains('.')) { if (const auto fast_value = try_fast_loop_lookup(name, settings.case_sensitive_variables, session)) { return *fast_value; } @@ -787,7 +787,7 @@ Value CompiledTemplateExecutor::evaluate_expression(const CompiledProgram& progr case ExpressionOpcode::LoadVar: case ExpressionOpcode::LoadBuiltin: { const std::string_view name = data_view(program, op.data_offset, op.data_length); - if (op.opcode == ExpressionOpcode::LoadVar && name != "ARGS" && name.find('.') == std::string_view::npos + if (op.opcode == ExpressionOpcode::LoadVar && name != "ARGS" && !name.contains('.') && can_fast_path_variable_lookup(settings, session)) { if (const Value* value = session.lookup_scoped_value(name, true)) { if (const auto string_value = value->try_as_string_view()) { @@ -804,7 +804,7 @@ Value CompiledTemplateExecutor::evaluate_expression(const CompiledProgram& progr } else { stack.push_back(Value()); } - } else if (op.opcode == ExpressionOpcode::LoadVar && name.find('.') != std::string_view::npos) { + } else if (op.opcode == ExpressionOpcode::LoadVar && name.contains('.')) { if (const auto fast_value = try_fast_loop_lookup(name, settings.case_sensitive_variables, session)) { stack.push_back(*fast_value); } else { @@ -1020,8 +1020,8 @@ Value CompiledTemplateExecutor::call_function(const RenderSession::FunctionDefin const EffectiveSettings& settings, const std::filesystem::path& current_file, RenderSession& session) const { - const RenderSession::FunctionDefinition active_function = function; - const std::filesystem::path& function_file = function_file_for(active_function, current_file); + const RenderSession::FunctionDefinition& active_function = function; + const std::filesystem::path function_file = function_file_for(active_function, current_file); ensure_render_time_budget(settings, function_file, session); if (active_function.parameters.size() != arguments.size()) { throw DiagnosticError(make_runtime_error( diff --git a/src/main/cpp/runtime/CompiledTemplateSerializer.cpp b/src/main/cpp/runtime/compiled/CompiledTemplateSerializer.cpp similarity index 85% rename from src/main/cpp/runtime/CompiledTemplateSerializer.cpp rename to src/main/cpp/runtime/compiled/CompiledTemplateSerializer.cpp index 036ae9e..305b284 100644 --- a/src/main/cpp/runtime/CompiledTemplateSerializer.cpp +++ b/src/main/cpp/runtime/compiled/CompiledTemplateSerializer.cpp @@ -1,8 +1,8 @@ -#include "runtime/CompiledTemplateSerializer.h" +#include "runtime/compiled/CompiledTemplateSerializer.h" #include "io/InputBuffer.h" -#include "runtime/CompiledTemplateCache.h" -#include "runtime/FileMetadataCache.h" +#include "runtime/compiled/CompiledTemplateCache.h" +#include "runtime/cache/FileMetadataCache.h" #include "support/Diagnostic.h" #include @@ -65,7 +65,7 @@ std::int64_t read_i64(std::string_view bytes, std::size_t& offset) { std::string read_string(std::string_view bytes, std::size_t& offset) { const std::uint32_t size = read_u32(bytes, offset); - if (offset + size > bytes.size()) { + if (size > bytes.size() - offset) { throw DiagnosticError(make_compile_error("Unexpected end of compiled template")); } std::string value(bytes.substr(offset, size)); @@ -73,6 +73,24 @@ std::string read_string(std::string_view bytes, std::size_t& offset) { return value; } +void require_remaining(std::string_view bytes, std::size_t offset, std::size_t needed, + const std::filesystem::path& path) { + if (needed > bytes.size() || offset > bytes.size() - needed) { + throw DiagnosticError(make_compile_error("Invalid compiled template section size", path)); + } +} + +std::size_t mul_or_throw(std::uint32_t count, std::size_t item_size, const std::filesystem::path& path) { + if (item_size != 0 && count > std::numeric_limits::max() / item_size) { + throw DiagnosticError(make_compile_error("Compiled template section too large", path)); + } + return static_cast(count) * item_size; +} + +constexpr std::size_t kInstructionBytes = 5 * sizeof(std::uint32_t); +constexpr std::size_t kDependencyMinBytes = sizeof(std::uint32_t) + sizeof(std::int64_t); +constexpr std::size_t kFunctionMinBytes = 6 * sizeof(std::uint32_t); + } std::string CompiledTemplateSerializer::serialize(const CompiledProgram& program) const { @@ -158,7 +176,7 @@ CompiledProgram CompiledTemplateSerializer::deserialize(std::string_view bytes, const std::uint32_t dependency_count = read_u32(bytes, offset); const std::uint32_t function_count = read_u32(bytes, offset); - program.template_instructions.reserve(template_count); + require_remaining(bytes, offset, mul_or_throw(template_count, kInstructionBytes, compiled_path), compiled_path); for (std::uint32_t index = 0; index < template_count; ++index) { program.template_instructions.push_back(TemplateInstruction{ .opcode = static_cast(read_u32(bytes, offset)), @@ -169,7 +187,7 @@ CompiledProgram CompiledTemplateSerializer::deserialize(std::string_view bytes, }); } - program.expression_instructions.reserve(expression_count); + require_remaining(bytes, offset, mul_or_throw(expression_count, kInstructionBytes, compiled_path), compiled_path); for (std::uint32_t index = 0; index < expression_count; ++index) { program.expression_instructions.push_back(ExpressionInstruction{ .opcode = static_cast(read_u32(bytes, offset)), @@ -180,24 +198,25 @@ CompiledProgram CompiledTemplateSerializer::deserialize(std::string_view bytes, }); } - if (offset + data_size > bytes.size()) { + if (data_size > bytes.size() - offset) { throw DiagnosticError(make_compile_error("Invalid compiled template data section", compiled_path)); } program.data_blob.assign(bytes.substr(offset, data_size)); offset += data_size; - program.dependencies.reserve(dependency_count); + require_remaining(bytes, offset, mul_or_throw(dependency_count, kDependencyMinBytes, compiled_path), compiled_path); for (std::uint32_t index = 0; index < dependency_count; ++index) { program.dependencies.push_back(CompiledDependency{read_string(bytes, offset), read_i64(bytes, offset)}); } - program.functions.reserve(function_count); + require_remaining(bytes, offset, mul_or_throw(function_count, kFunctionMinBytes, compiled_path), compiled_path); for (std::uint32_t index = 0; index < function_count; ++index) { CompiledFunction function; function.kind = static_cast(read_u32(bytes, offset)); function.name = read_string(bytes, offset); const std::uint32_t parameter_count = read_u32(bytes, offset); - function.parameters.reserve(parameter_count); + require_remaining(bytes, offset, mul_or_throw(parameter_count, sizeof(std::uint32_t), compiled_path), + compiled_path); for (std::uint32_t parameter_index = 0; parameter_index < parameter_count; ++parameter_index) { function.parameters.push_back(read_string(bytes, offset)); } @@ -225,15 +244,13 @@ const CompiledProgram* CompiledTemplateSerializer::try_load_valid(const std::fil } const FileMetadata cached_metadata = FileMetadataCache::instance().probe(path); - if (!cached_metadata.exists - || cached_metadata.mtime_ticks != CompiledTemplateCache::instance().compiled_mtime(path, settings)) { - CompiledTemplateCache::instance().erase(path, settings); - } else if (is_fresh(*cached, settings)) { + if (cached_metadata.exists + && cached_metadata.mtime_ticks == CompiledTemplateCache::instance().compiled_mtime(path, settings) + && is_fresh(*cached, settings)) { CompiledTemplateCache::instance().mark_validated(path, settings); return cached; - } else { - CompiledTemplateCache::instance().erase(path, settings); } + CompiledTemplateCache::instance().erase(path, settings); } const FileMetadata metadata = FileMetadataCache::instance().probe(path); diff --git a/src/main/cpp/runtime/CompiledTemplateWriter.cpp b/src/main/cpp/runtime/compiled/CompiledTemplateWriter.cpp similarity index 94% rename from src/main/cpp/runtime/CompiledTemplateWriter.cpp rename to src/main/cpp/runtime/compiled/CompiledTemplateWriter.cpp index 2482313..f4a73ef 100644 --- a/src/main/cpp/runtime/CompiledTemplateWriter.cpp +++ b/src/main/cpp/runtime/compiled/CompiledTemplateWriter.cpp @@ -1,4 +1,4 @@ -#include "runtime/CompiledTemplateWriter.h" +#include "runtime/compiled/CompiledTemplateWriter.h" #include "io/OutputWriter.h" @@ -74,7 +74,7 @@ void CompiledTemplateWriter::run() { std::filesystem::create_directories(job.first.parent_path(), error); try { writer.write(job.second, job.first); - } catch (const std::exception&) { + } catch (const std::exception&) { // NOLINT(bugprone-empty-catch) } { std::lock_guard lock(impl_->mutex); diff --git a/src/main/cpp/runtime/Value.cpp b/src/main/cpp/runtime/core/Value.cpp similarity index 99% rename from src/main/cpp/runtime/Value.cpp rename to src/main/cpp/runtime/core/Value.cpp index 42a67c6..765a809 100644 --- a/src/main/cpp/runtime/Value.cpp +++ b/src/main/cpp/runtime/core/Value.cpp @@ -1,4 +1,4 @@ -#include "runtime/Value.h" +#include "runtime/core/Value.h" #include "support/TextUtil.h" diff --git a/src/main/cpp/runtime/VariableStore.cpp b/src/main/cpp/runtime/core/VariableStore.cpp similarity index 97% rename from src/main/cpp/runtime/VariableStore.cpp rename to src/main/cpp/runtime/core/VariableStore.cpp index 941ae2d..bc3c46d 100644 --- a/src/main/cpp/runtime/VariableStore.cpp +++ b/src/main/cpp/runtime/core/VariableStore.cpp @@ -1,4 +1,4 @@ -#include "runtime/VariableStore.h" +#include "runtime/core/VariableStore.h" #include "support/TextUtil.h" diff --git a/src/main/cpp/runtime/BuiltinRegistry.cpp b/src/main/cpp/runtime/expression/BuiltinRegistry.cpp similarity index 98% rename from src/main/cpp/runtime/BuiltinRegistry.cpp rename to src/main/cpp/runtime/expression/BuiltinRegistry.cpp index fa70dab..87ace68 100644 --- a/src/main/cpp/runtime/BuiltinRegistry.cpp +++ b/src/main/cpp/runtime/expression/BuiltinRegistry.cpp @@ -1,4 +1,4 @@ -#include "runtime/BuiltinRegistry.h" +#include "runtime/expression/BuiltinRegistry.h" #include #include @@ -141,7 +141,7 @@ RenderSession::BuiltinSnapshot make_snapshot() { const RenderSession::BuiltinSnapshot& snapshot_for(const RenderSession& session) { if (!session.builtin_snapshot.has_value()) { - const_cast(session).builtin_snapshot = make_snapshot(); + session.builtin_snapshot = make_snapshot(); } return *session.builtin_snapshot; } diff --git a/src/main/cpp/runtime/ExpressionEvaluator.cpp b/src/main/cpp/runtime/expression/ExpressionEvaluator.cpp similarity index 97% rename from src/main/cpp/runtime/ExpressionEvaluator.cpp rename to src/main/cpp/runtime/expression/ExpressionEvaluator.cpp index 499b019..86aaaad 100644 --- a/src/main/cpp/runtime/ExpressionEvaluator.cpp +++ b/src/main/cpp/runtime/expression/ExpressionEvaluator.cpp @@ -1,9 +1,9 @@ -#include "runtime/ExpressionEvaluator.h" +#include "runtime/expression/ExpressionEvaluator.h" #include "config/RuleResolver.h" -#include "runtime/CompiledTemplateExecutor.h" -#include "runtime/IncludeResolver.h" -#include "runtime/LuaExpressionEngine.h" +#include "runtime/compiled/CompiledTemplateExecutor.h" +#include "runtime/resolution/IncludeResolver.h" +#include "runtime/lua/LuaExpressionEngine.h" #include "support/Diagnostic.h" #include @@ -48,7 +48,7 @@ bool compare_with_order(std::strong_ordering ordering, std::string_view op_name) bool contains_value(const Value& right, const Value& left) { if (const auto string_value = right.try_as_string_view()) { - return string_value->find(left.to_string()) != std::string_view::npos; + return string_value->contains(left.to_string()); } if (const Value::Object* object = right.try_as_object()) { return object->contains(left.to_string()); diff --git a/src/main/cpp/runtime/FilterRegistry.cpp b/src/main/cpp/runtime/expression/FilterRegistry.cpp similarity index 98% rename from src/main/cpp/runtime/FilterRegistry.cpp rename to src/main/cpp/runtime/expression/FilterRegistry.cpp index 7e74bd4..f9233b6 100644 --- a/src/main/cpp/runtime/FilterRegistry.cpp +++ b/src/main/cpp/runtime/expression/FilterRegistry.cpp @@ -1,4 +1,4 @@ -#include "runtime/FilterRegistry.h" +#include "runtime/expression/FilterRegistry.h" #include "support/Diagnostic.h" #include "support/TextUtil.h" diff --git a/src/main/cpp/runtime/ValueResolver.cpp b/src/main/cpp/runtime/expression/ValueResolver.cpp similarity index 98% rename from src/main/cpp/runtime/ValueResolver.cpp rename to src/main/cpp/runtime/expression/ValueResolver.cpp index 7ac16ef..25089fb 100644 --- a/src/main/cpp/runtime/ValueResolver.cpp +++ b/src/main/cpp/runtime/expression/ValueResolver.cpp @@ -1,4 +1,4 @@ -#include "runtime/ValueResolver.h" +#include "runtime/expression/ValueResolver.h" #include "support/Diagnostic.h" #include "support/TextUtil.h" @@ -95,7 +95,7 @@ Value ValueResolver::resolve_identifier(const std::string& name, const SourceSpa return normalize_value(*value, settings); } - if (name.find('.') != std::string::npos && !text::starts_with(name, "ARGS")) { + if (name.contains('.') && !text::starts_with(name, "ARGS")) { return resolve_member_path(name, span, settings, session, current_file); } diff --git a/src/main/cpp/runtime/LuaChunkCache.cpp b/src/main/cpp/runtime/lua/LuaChunkCache.cpp similarity index 78% rename from src/main/cpp/runtime/LuaChunkCache.cpp rename to src/main/cpp/runtime/lua/LuaChunkCache.cpp index 256cc41..f01eb71 100644 --- a/src/main/cpp/runtime/LuaChunkCache.cpp +++ b/src/main/cpp/runtime/lua/LuaChunkCache.cpp @@ -1,4 +1,4 @@ -#include "runtime/LuaChunkCache.h" +#include "runtime/lua/LuaChunkCache.h" namespace prebyte { @@ -14,4 +14,8 @@ void LuaChunkCache::store(const LuaChunkKey& key, int registry_reference) { cache_[key] = registry_reference; } +void LuaChunkCache::clear() { + cache_.clear(); +} + } diff --git a/src/main/cpp/runtime/LuaExpressionEngine.cpp b/src/main/cpp/runtime/lua/LuaExpressionEngine.cpp similarity index 95% rename from src/main/cpp/runtime/LuaExpressionEngine.cpp rename to src/main/cpp/runtime/lua/LuaExpressionEngine.cpp index 0c2e6aa..b00fb68 100644 --- a/src/main/cpp/runtime/LuaExpressionEngine.cpp +++ b/src/main/cpp/runtime/lua/LuaExpressionEngine.cpp @@ -1,4 +1,4 @@ -#include "runtime/LuaExpressionEngine.h" +#include "runtime/lua/LuaExpressionEngine.h" #include "support/Diagnostic.h" diff --git a/src/main/cpp/runtime/LuaHelperRegistry.cpp b/src/main/cpp/runtime/lua/LuaHelperRegistry.cpp similarity index 96% rename from src/main/cpp/runtime/LuaHelperRegistry.cpp rename to src/main/cpp/runtime/lua/LuaHelperRegistry.cpp index 914a160..53487b4 100644 --- a/src/main/cpp/runtime/LuaHelperRegistry.cpp +++ b/src/main/cpp/runtime/lua/LuaHelperRegistry.cpp @@ -1,6 +1,6 @@ -#include "runtime/LuaHelperRegistry.h" +#include "runtime/lua/LuaHelperRegistry.h" -#include "runtime/LuaHeaders.h" +#include "runtime/lua/LuaHeaders.h" #include "support/TextUtil.h" diff --git a/src/main/cpp/runtime/LuaRuntime.cpp b/src/main/cpp/runtime/lua/LuaRuntime.cpp similarity index 89% rename from src/main/cpp/runtime/LuaRuntime.cpp rename to src/main/cpp/runtime/lua/LuaRuntime.cpp index e66a6c5..f356344 100644 --- a/src/main/cpp/runtime/LuaRuntime.cpp +++ b/src/main/cpp/runtime/lua/LuaRuntime.cpp @@ -1,6 +1,6 @@ -#include "runtime/LuaRuntime.h" +#include "runtime/lua/LuaRuntime.h" -#include "runtime/LuaHeaders.h" +#include "runtime/lua/LuaHeaders.h" #include #include @@ -41,7 +41,9 @@ LuaRuntime::LuaRuntime() { LuaRuntime::~LuaRuntime() { if (state_ != nullptr) { + chunk_cache_.clear(); lua_close(state_); + state_ = nullptr; } } @@ -103,55 +105,24 @@ void LuaRuntime::handle_instruction_guard(lua_State* state) const { } } -int LuaRuntime::load_chunk(const std::string& source, LuaChunkMode mode, const SourceSpan& span, - RenderSession& session) const { - const LuaChunkKey key{source, mode}; - if (const auto cached = chunk_cache_.find(key)) { - ++session.lua_cache_hits; - return *cached; - } - - ++session.lua_cache_misses; - const std::string wrapped = wrap_source(source, mode); - if (luaL_loadbuffer(state_, wrapped.data(), wrapped.size(), span.file_path.c_str()) != LUA_OK) { - const std::string error = take_error_message(state_); - lua_pop(state_, 1); - lua_gc(state_, LUA_GCCOLLECT, 0); - throw DiagnosticError(make_lua_error(error, span)); - } - - const int reference = luaL_ref(state_, LUA_REGISTRYINDEX); - chunk_cache_.store(key, reference); - return reference; -} - -std::string LuaRuntime::wrap_source(const std::string& source, LuaChunkMode mode) const { - switch (mode) { - case LuaChunkMode::InlineValue: - case LuaChunkMode::Predicate: - return source; - case LuaChunkMode::BlockValue: - return source; - } - return source; -} - void* LuaRuntime::lua_allocator(void* user_data, void* pointer, std::size_t old_size, std::size_t new_size) { auto* runtime = static_cast(user_data); const std::size_t limit = runtime->memory_limit_bytes_; if (new_size == 0) { - if (runtime->memory_bytes_in_use_ >= old_size) { - runtime->memory_bytes_in_use_ -= old_size; - } else { - runtime->memory_bytes_in_use_ = 0; + if (pointer != nullptr) { + std::free(pointer); + if (runtime->memory_bytes_in_use_ >= old_size) { + runtime->memory_bytes_in_use_ -= old_size; + } else { + runtime->memory_bytes_in_use_ = 0; + } } - std::free(pointer); return nullptr; } const std::size_t growth = pointer == nullptr ? new_size : (new_size > old_size ? new_size - old_size : 0); - if (growth > 0) { + if (growth > 0 && limit != std::numeric_limits::max()) { if (growth > limit || runtime->memory_bytes_in_use_ > limit - growth) { runtime->memory_limit_exceeded_ = true; return nullptr; @@ -160,6 +131,7 @@ void* LuaRuntime::lua_allocator(void* user_data, void* pointer, std::size_t old_ void* updated_pointer = pointer == nullptr ? std::malloc(new_size) : std::realloc(pointer, new_size); if (updated_pointer == nullptr) { + runtime->memory_limit_exceeded_ = true; return nullptr; } @@ -167,15 +139,44 @@ void* LuaRuntime::lua_allocator(void* user_data, void* pointer, std::size_t old_ runtime->memory_bytes_in_use_ += new_size; } else if (new_size > old_size) { runtime->memory_bytes_in_use_ += new_size - old_size; - } else if (runtime->memory_bytes_in_use_ >= old_size - new_size) { - runtime->memory_bytes_in_use_ -= old_size - new_size; - } else { - runtime->memory_bytes_in_use_ = 0; + } else if (new_size < old_size) { + if (runtime->memory_bytes_in_use_ >= old_size - new_size) { + runtime->memory_bytes_in_use_ -= old_size - new_size; + } else { + runtime->memory_bytes_in_use_ = 0; + } } return updated_pointer; } +int LuaRuntime::load_chunk(const std::string& source, LuaChunkMode mode, const SourceSpan& span, + RenderSession& session) const { + const LuaChunkKey key{source, mode}; + if (const auto cached = chunk_cache_.find(key)) { + ++session.lua_cache_hits; + return *cached; + } + + ++session.lua_cache_misses; + const std::string wrapped = wrap_source(source, mode); + if (luaL_loadbuffer(state_, wrapped.data(), wrapped.size(), span.file_path.c_str()) != LUA_OK) { + const std::string error = take_error_message(state_); + lua_pop(state_, 1); + lua_gc(state_, LUA_GCCOLLECT, 0); + throw DiagnosticError(make_lua_error(error, span)); + } + + const int reference = luaL_ref(state_, LUA_REGISTRYINDEX); + chunk_cache_.store(key, reference); + return reference; +} + +std::string LuaRuntime::wrap_source(const std::string& source, LuaChunkMode mode) const { + (void)mode; + return source; +} + std::string LuaRuntime::take_error_message(lua_State* state) const { std::string message; if (memory_limit_exceeded_) { diff --git a/src/main/cpp/runtime/LuaSandbox.cpp b/src/main/cpp/runtime/lua/LuaSandbox.cpp similarity index 84% rename from src/main/cpp/runtime/LuaSandbox.cpp rename to src/main/cpp/runtime/lua/LuaSandbox.cpp index 2b8f306..d6699d1 100644 --- a/src/main/cpp/runtime/LuaSandbox.cpp +++ b/src/main/cpp/runtime/lua/LuaSandbox.cpp @@ -1,13 +1,11 @@ -#include "runtime/LuaSandbox.h" +#include "runtime/lua/LuaSandbox.h" -#include "runtime/LuaHeaders.h" - -#include +#include "runtime/lua/LuaHeaders.h" namespace prebyte { LuaSandbox::LuaSandbox(LuaHelperRegistry helper_registry) - : helper_registry_(std::move(helper_registry)) {} + : helper_registry_(helper_registry) {} void LuaSandbox::install(lua_State* state) const { luaL_openlibs(state); diff --git a/src/main/cpp/runtime/LuaValueBridge.cpp b/src/main/cpp/runtime/lua/LuaValueBridge.cpp similarity index 85% rename from src/main/cpp/runtime/LuaValueBridge.cpp rename to src/main/cpp/runtime/lua/LuaValueBridge.cpp index 9cfbbcc..fb614cb 100644 --- a/src/main/cpp/runtime/LuaValueBridge.cpp +++ b/src/main/cpp/runtime/lua/LuaValueBridge.cpp @@ -1,8 +1,11 @@ -#include "runtime/LuaValueBridge.h" +#include "runtime/lua/LuaValueBridge.h" -#include "runtime/LuaHeaders.h" +#include "runtime/lua/LuaHeaders.h" -#include "runtime/BuiltinRegistry.h" +#include +#include + +#include "runtime/expression/BuiltinRegistry.h" namespace prebyte { @@ -73,9 +76,30 @@ bool is_array_like_table(lua_State* state, int index) { ++count; lua_pop(state, 1); } + while (lua_gettop(state) > index) { + lua_pop(state, 1); + } return array_like && max_key == count; } +std::optional table_key_as_string(lua_State* state, int key_index) { + key_index = lua_absindex(state, key_index); + switch (lua_type(state, key_index)) { + case LUA_TSTRING: + return std::string(lua_tostring(state, key_index)); + case LUA_TNUMBER: { + const lua_Integer integer_key = lua_tointeger(state, key_index); + const lua_Number numeric_key = lua_tonumber(state, key_index); + if (numeric_key == static_cast(integer_key)) { + return std::to_string(integer_key); + } + return std::to_string(numeric_key); + } + default: + return std::nullopt; + } +} + void push_value(lua_State* state, const Value& value) { if (value.is_null()) { lua_pushnil(state); @@ -105,7 +129,7 @@ void push_value(lua_State* state, const Value& value) { lua_newtable(state); for (std::size_t index = 0; index < list->size(); ++index) { push_value(state, Value::borrowed_data((*list)[index])); - lua_rawseti(state, -2, static_cast(index + 1)); + lua_rawseti(state, -2, static_cast(index) + 1); } return; } @@ -135,7 +159,7 @@ void push_loop_frame_scalars(lua_State* state, const RenderSession::LoopFrame& f } lua_newtable(state); - lua_pushinteger(state, static_cast(frame.loop_index0 + 1)); + lua_pushinteger(state, static_cast(frame.loop_index0) + 1); lua_setfield(state, -2, "index"); lua_pushinteger(state, static_cast(frame.loop_index0)); lua_setfield(state, -2, "index0"); @@ -216,7 +240,7 @@ Value LuaValueBridge::read_value(lua_State* state, int index) const { index = lua_absindex(state, index); if (is_array_like_table(state, index)) { Value::List list; - const lua_Integer size = lua_rawlen(state, index); + const lua_Unsigned size = lua_rawlen(state, index); list.reserve(static_cast(size)); for (lua_Integer item_index = 1; item_index <= size; ++item_index) { lua_rawgeti(state, index, item_index); @@ -224,17 +248,27 @@ Value LuaValueBridge::read_value(lua_State* state, int index) const { list.push_back(data_from_value(item)); lua_pop(state, 1); } + while (lua_gettop(state) > index) { + lua_pop(state, 1); + } return Value::list(std::move(list)); } + while (lua_gettop(state) > index) { + lua_pop(state, 1); + } + Value::Object object; lua_pushnil(state); while (lua_next(state, index) != 0) { - if (const char* key = lua_tostring(state, -2)) { - object[key] = data_from_value(read_value(state, -1)); + if (const std::optional key = table_key_as_string(state, -2)) { + object[*key] = data_from_value(read_value(state, -1)); } lua_pop(state, 1); } + while (lua_gettop(state) > index) { + lua_pop(state, 1); + } return Value::object(std::move(object)); } default: diff --git a/src/main/cpp/runtime/Engine.cpp b/src/main/cpp/runtime/render/Engine.cpp similarity index 93% rename from src/main/cpp/runtime/Engine.cpp rename to src/main/cpp/runtime/render/Engine.cpp index a4a0b31..30a7166 100644 --- a/src/main/cpp/runtime/Engine.cpp +++ b/src/main/cpp/runtime/render/Engine.cpp @@ -2,9 +2,9 @@ #include "config/RuleResolver.h" #include "io/InputReader.h" -#include "runtime/CompiledTemplateCompiler.h" -#include "runtime/CompiledTemplateSerializer.h" -#include "runtime/EngineRuntime.h" +#include "runtime/compiled/CompiledTemplateCompiler.h" +#include "runtime/compiled/CompiledTemplateSerializer.h" +#include "runtime/render/EngineRuntime.h" #include "support/Diagnostic.h" #include @@ -87,8 +87,8 @@ Engine::Engine() : impl_(std::make_shared()) {} CompiledTemplate Engine::compile(std::string_view source, - std::filesystem::path source_path, - std::filesystem::path logical_path, + const std::filesystem::path& source_path, + const std::filesystem::path& logical_path, const CompileOptions& options) const { CompiledTemplateCompiler compiler; CompiledProgram program = compiler.compile_source(source, source_path, logical_path, make_compile_settings(options)); @@ -127,7 +127,7 @@ std::string Engine::render(const CompiledTemplate& tpl, } void Engine::render_to(const CompiledTemplate& tpl, - ChunkSink sink, + const ChunkSink& sink, const RenderContext& ctx, const RenderOptions& opts) const { if (!tpl.impl_) { diff --git a/src/main/cpp/runtime/Renderer.cpp b/src/main/cpp/runtime/render/Renderer.cpp similarity index 79% rename from src/main/cpp/runtime/Renderer.cpp rename to src/main/cpp/runtime/render/Renderer.cpp index cf02b2c..a609928 100644 --- a/src/main/cpp/runtime/Renderer.cpp +++ b/src/main/cpp/runtime/render/Renderer.cpp @@ -1,10 +1,10 @@ -#include "runtime/Renderer.h" +#include "runtime/render/Renderer.h" -#include "runtime/CompiledTemplateCache.h" -#include "runtime/CompiledTemplateCompiler.h" -#include "runtime/FileMetadataCache.h" -#include "runtime/CompiledTemplateSerializer.h" -#include "runtime/CompiledTemplateWriter.h" +#include "runtime/compiled/CompiledTemplateCache.h" +#include "runtime/compiled/CompiledTemplateCompiler.h" +#include "runtime/cache/FileMetadataCache.h" +#include "runtime/compiled/CompiledTemplateSerializer.h" +#include "runtime/compiled/CompiledTemplateWriter.h" #include "support/TextUtil.h" namespace prebyte { @@ -53,17 +53,17 @@ void Renderer::render_source_to(std::string_view source, const EffectiveSettings CompiledTemplateCompiler compiler; CompiledProgram program = compiler.compile_source(active_source, current_file, current_file, settings); - const CompiledProgram* render_program = &program; - if (!file_backed_source) { - render_program = CompiledTemplateCache::instance().store_inline(active_source, std::move(program), settings); - } - - CompiledTemplateSerializer serializer; if (file_backed_source) { - CompiledTemplateCache::instance().store_in_memory(serializer.compiled_path_for_source(current_file), program, settings); - CompiledTemplateWriter::instance().enqueue(serializer.compiled_path_for_source(current_file), - serializer.serialize(program)); + CompiledTemplateSerializer serializer; + const std::filesystem::path compiled_path = serializer.compiled_path_for_source(current_file); + CompiledTemplateCache::instance().store_in_memory(compiled_path, program, settings); + CompiledTemplateWriter::instance().enqueue(compiled_path, serializer.serialize(program)); + render_program_to(program, settings, current_file, session, sink); + return; } + + const CompiledProgram* render_program = + CompiledTemplateCache::instance().store_inline(active_source, std::move(program), settings); render_program_to(*render_program, settings, current_file, session, sink); } diff --git a/src/main/cpp/runtime/IncludeResolver.cpp b/src/main/cpp/runtime/resolution/IncludeResolver.cpp similarity index 62% rename from src/main/cpp/runtime/IncludeResolver.cpp rename to src/main/cpp/runtime/resolution/IncludeResolver.cpp index 3b84253..04312e9 100644 --- a/src/main/cpp/runtime/IncludeResolver.cpp +++ b/src/main/cpp/runtime/resolution/IncludeResolver.cpp @@ -1,7 +1,7 @@ -#include "runtime/IncludeResolver.h" +#include "runtime/resolution/IncludeResolver.h" -#include "runtime/FileMetadataCache.h" -#include "runtime/CompiledTemplateSerializer.h" +#include "runtime/cache/FileMetadataCache.h" +#include "runtime/compiled/CompiledTemplateSerializer.h" #include "support/Diagnostic.h" #include @@ -81,6 +81,102 @@ bool path_is_directory(const std::filesystem::path& path) { return std::filesystem::is_directory(path, error) && !error; } +constexpr std::size_t kMaxIncludePathLength = 4096; +constexpr std::size_t kMaxIncludePathSeparators = 64; + +void validate_include_path(const std::string& include_path, const RenderSession& session) { + if (include_path.size() > kMaxIncludePathLength) { + throw DiagnosticError(make_include_error("Include path is too long", include_path, session)); + } + + if (std::filesystem::path(include_path).is_absolute()) { + throw DiagnosticError(make_include_error("Absolute include paths are not allowed", include_path, session)); + } + + std::size_t separators = 0; + for (char ch : include_path) { + if (ch == '/' || ch == '\\') { + ++separators; + } + } + if (separators > kMaxIncludePathSeparators) { + throw DiagnosticError(make_include_error("Include path is too deep", include_path, session)); + } +} + +bool is_path_within_root(const std::filesystem::path& path, const std::filesystem::path& root) { + if (root.empty() || path.empty()) { + return false; + } + + std::error_code error; + std::filesystem::path resolved_path = path; + std::filesystem::path resolved_root = root; + if (path_exists(path) || path.is_absolute()) { + const std::filesystem::path canonical_path = std::filesystem::weakly_canonical(path, error); + if (!error) { + resolved_path = canonical_path; + } + } else { + resolved_path = canonical_path(path); + } + if (path_exists(root) || root.is_absolute()) { + const std::filesystem::path canonical_root = std::filesystem::weakly_canonical(root, error); + if (!error) { + resolved_root = canonical_root; + } + } else { + resolved_root = canonical_path(root); + } + + const std::filesystem::path relative = resolved_path.lexically_relative(resolved_root); + if (relative.empty()) { + return resolved_path == resolved_root; + } + if (relative == ".") { + return true; + } + + const std::string relative_generic = relative.generic_string(); + return !(relative_generic == ".." || relative_generic.starts_with("../") || relative_generic.contains("/../")); +} + +std::vector trusted_include_roots(const std::filesystem::path& current_file, + const EffectiveSettings& settings, + const RenderSession& session) { + std::vector roots; + auto add_root = [&](const std::filesystem::path& path) { + if (!path.empty()) { + roots.push_back(canonical_path(path)); + } + }; + + if (session.include_anchor_root.has_value()) { + add_root(*session.include_anchor_root); + } else if (!current_file.empty()) { + add_root(current_file.parent_path()); + } + for (const std::filesystem::path& path : settings.include_paths) { + add_root(path); + } + add_root(settings.include_path); + add_root(shared_include_root()); + return roots; +} + +void validate_resolved_include_target(const std::filesystem::path& physical_path, + const std::filesystem::path& current_file, + const EffectiveSettings& settings, + const RenderSession& session) { + const std::filesystem::path absolute_target = canonical_path(physical_path); + for (const std::filesystem::path& root : trusted_include_roots(current_file, settings, session)) { + if (is_path_within_root(absolute_target, root)) { + return; + } + } + throw DiagnosticError(make_include_error("Include path escapes allowed roots", physical_path, session)); +} + bool try_accept_include(const std::filesystem::path& physical_path, const std::filesystem::path& logical_path, ResolvedIncludeKind kind, RenderSession& session, ResolvedInclude& resolved) { const std::filesystem::path absolute = canonical_path(physical_path); @@ -97,8 +193,11 @@ bool try_accept_include(const std::filesystem::path& physical_path, const std::f } bool try_file_variant(const std::filesystem::path& physical_path, const std::filesystem::path& logical_path, - ResolvedIncludeKind kind, const EffectiveSettings& settings, + ResolvedIncludeKind kind, const std::filesystem::path& current_file, + const EffectiveSettings& settings, RenderSession& session, ResolvedInclude& resolved) { + validate_resolved_include_target(physical_path, current_file, settings, session); + if (kind == ResolvedIncludeKind::Compiled) { CompiledTemplateSerializer serializer; if (const CompiledProgram* compiled = serializer.try_load_valid(physical_path, settings)) { @@ -124,31 +223,32 @@ bool try_file_variant(const std::filesystem::path& physical_path, const std::fil return true; } -bool try_target_path(const std::filesystem::path& logical, const EffectiveSettings& settings, +bool try_target_path(const std::filesystem::path& logical, const std::filesystem::path& current_file, + const EffectiveSettings& settings, RenderSession& session, ResolvedInclude& resolved) { const std::filesystem::path pbc = logical.string() + ".pbc"; - if (try_file_variant(pbc, logical, ResolvedIncludeKind::Compiled, settings, session, resolved)) { + if (try_file_variant(pbc, logical, ResolvedIncludeKind::Compiled, current_file, settings, session, resolved)) { return true; } const std::filesystem::path pbt = logical.string() + ".pbt"; - if (try_file_variant(pbt, logical, ResolvedIncludeKind::Source, settings, session, resolved)) { + if (try_file_variant(pbt, logical, ResolvedIncludeKind::Source, current_file, settings, session, resolved)) { return true; } - if (try_file_variant(logical, logical, ResolvedIncludeKind::Source, settings, session, resolved)) { + if (try_file_variant(logical, logical, ResolvedIncludeKind::Source, current_file, settings, session, resolved)) { return true; } if (path_is_directory(logical)) { const std::filesystem::path index_logical = logical / "index"; - if (try_file_variant(index_logical.string() + ".pbc", index_logical, ResolvedIncludeKind::Compiled, settings, session, resolved)) { + if (try_file_variant(index_logical.string() + ".pbc", index_logical, ResolvedIncludeKind::Compiled, current_file, settings, session, resolved)) { return true; } - if (try_file_variant(index_logical.string() + ".pbt", index_logical, ResolvedIncludeKind::Source, settings, session, resolved)) { + if (try_file_variant(index_logical.string() + ".pbt", index_logical, ResolvedIncludeKind::Source, current_file, settings, session, resolved)) { return true; } - if (try_file_variant(index_logical, index_logical, ResolvedIncludeKind::Source, settings, session, resolved)) { + if (try_file_variant(index_logical, index_logical, ResolvedIncludeKind::Source, current_file, settings, session, resolved)) { return true; } } @@ -157,9 +257,10 @@ bool try_target_path(const std::filesystem::path& logical, const EffectiveSettin } bool try_logical_target(const std::filesystem::path& root, const std::string& include_path, + const std::filesystem::path& current_file, const EffectiveSettings& settings, RenderSession& session, ResolvedInclude& resolved) { - return try_target_path(root / include_path, settings, session, resolved); + return try_target_path(root / include_path, current_file, settings, session, resolved); } std::vector include_roots(const std::string& include_path, @@ -198,6 +299,8 @@ IncludeResolver::CacheKey cache_key_for(const std::string& include_path, ResolvedInclude IncludeResolver::load(const std::string& include_path, const std::filesystem::path& current_file, const EffectiveSettings& settings, RenderSession& session) const { + validate_include_path(include_path, session); + ResolvedInclude resolved; const CacheKey cache_key = cache_key_for(include_path, current_file, settings); @@ -212,7 +315,7 @@ ResolvedInclude IncludeResolver::load(const std::string& include_path, const std resolved.compiled_program = cached.compiled_program; return resolved; } - } else if (try_file_variant(cached.physical_path, cached.logical_path, cached.kind, settings, session, resolved)) { + } else if (try_file_variant(cached.physical_path, cached.logical_path, cached.kind, current_file, settings, session, resolved)) { return resolved; } cache_.erase(it); @@ -220,15 +323,10 @@ ResolvedInclude IncludeResolver::load(const std::string& include_path, const std } if (std::filesystem::path(include_path).is_absolute()) { - if (try_target_path(std::filesystem::path(include_path).lexically_normal(), settings, session, resolved)) { - std::lock_guard lock(cache_mutex_); - cache_[cache_key] = CacheEntry{resolved.path, resolved.logical_path, resolved.kind, resolved.compiled_program, - std::chrono::steady_clock::now() + FileMetadataCache::ttl()}; - return resolved; - } + throw DiagnosticError(make_include_error("Absolute include paths are not allowed", include_path, session)); } else { for (const std::filesystem::path& root : include_roots(include_path, current_file, settings)) { - if (try_logical_target(root, include_path, settings, session, resolved)) { + if (try_logical_target(root, include_path, current_file, settings, session, resolved)) { std::lock_guard lock(cache_mutex_); cache_[cache_key] = CacheEntry{resolved.path, resolved.logical_path, resolved.kind, resolved.compiled_program, std::chrono::steady_clock::now() + FileMetadataCache::ttl()}; diff --git a/src/main/cpp/support/FileUtil.cpp b/src/main/cpp/support/FileUtil.cpp new file mode 100644 index 0000000..cee1379 --- /dev/null +++ b/src/main/cpp/support/FileUtil.cpp @@ -0,0 +1,80 @@ +#include "support/FileUtil.h" + +#include +#include + +#include +#include + +namespace prebyte::file_util { + +namespace { + +int open_flags_read() { +#ifdef _WIN32 + return O_RDONLY | O_BINARY; +#else + return O_RDONLY; +#endif +} + +int open_flags_write() { +#ifdef _WIN32 + return O_WRONLY | O_CREAT | O_TRUNC | O_BINARY; +#else + return O_WRONLY | O_CREAT | O_TRUNC; +#endif +} + +} + +std::string read_text_file(const std::filesystem::path& path) { + const int fd = ::open(path.c_str(), open_flags_read()); + if (fd < 0) { + throw std::runtime_error("Could not open file: " + path.string()); + } + + std::string content; + char buffer[8192]; + while (true) { + const ssize_t nbytes = ::read(fd, buffer, sizeof(buffer)); + if (nbytes < 0) { + ::close(fd); + throw std::runtime_error("Could not read file: " + path.string()); + } + if (nbytes == 0) { + break; + } + content.append(buffer, static_cast(nbytes)); + } + + ::close(fd); + return content; +} + +bool write_text_file(const std::filesystem::path& path, std::string_view content) { + std::error_code error; + std::filesystem::create_directories(path.parent_path(), error); + + const int fd = ::open(path.c_str(), open_flags_write(), 0644); + if (fd < 0) { + return false; + } + + const char* data = content.data(); + std::size_t remaining = content.size(); + while (remaining > 0) { + const ssize_t written = ::write(fd, data, remaining); + if (written <= 0) { + ::close(fd); + return false; + } + data += written; + remaining -= static_cast(written); + } + + ::close(fd); + return true; +} + +} diff --git a/src/main/cpp/template/lexer/TemplateLexer.cpp b/src/main/cpp/template/lexer/TemplateLexer.cpp deleted file mode 100644 index f38d661..0000000 --- a/src/main/cpp/template/lexer/TemplateLexer.cpp +++ /dev/null @@ -1,410 +0,0 @@ -#include "template/lexer/TemplateLexer.h" - -#include "support/Diagnostic.h" -#include "support/TextUtil.h" - -namespace prebyte { - -namespace { - -Diagnostic make_lexer_error(const std::string& message, const std::string& file_path, SourceLocation location) { - Diagnostic diagnostic; - diagnostic.code = "LEX001"; - diagnostic.message = message; - diagnostic.span.file_path = file_path; - diagnostic.span.start = location; - diagnostic.span.end = location; - return diagnostic; -} - -void trim_right_ascii_whitespace(std::string& text) { - while (!text.empty()) { - const char ch = text.back(); - if (ch != ' ' && ch != '\t' && ch != '\n' && ch != '\r') { - break; - } - text.pop_back(); - } -} - -void trim_left_ascii_whitespace(std::string& text) { - std::size_t start = 0; - while (start < text.size()) { - const char ch = text[start]; - if (ch != ' ' && ch != '\t' && ch != '\n' && ch != '\r') { - break; - } - ++start; - } - if (start != 0) { - text.erase(0, start); - } -} - -} - -TemplateLexer::TemplateLexer(std::string_view source, std::string file_path, std::string_view tag_prefix, - std::string_view tag_suffix) - : source_(source), file_path_(std::move(file_path)), tag_prefix_(tag_prefix), tag_suffix_(tag_suffix) {} - -std::vector TemplateLexer::lex() { - while (!is_at_end()) { - if (!inside_tag_) { - lex_text(); - } else { - lex_inside_tag(); - } - } - - if (inside_tag_) { - throw DiagnosticError(make_lexer_error("Unclosed tag", file_path_, current_location())); - } - - add_token(TemplateTokenType::EndOfFile, "", current_location()); - return tokens_; -} - -char TemplateLexer::peek(std::size_t offset) const { - const std::size_t target = index_ + offset; - if (target >= source_.size()) { - return '\0'; - } - return source_[target]; -} - -bool TemplateLexer::is_at_end() const { - return index_ >= source_.size(); -} - -bool TemplateLexer::match_literal(std::string_view literal) const { - return source_.compare(index_, literal.size(), literal) == 0; -} - -char TemplateLexer::advance() { - const char ch = source_[index_++]; - if (ch == '\n') { - ++line_; - column_ = 1; - } else { - ++column_; - } - return ch; -} - -void TemplateLexer::advance_literal(std::string_view literal) { - for (std::size_t i = 0; i < literal.size(); ++i) { - advance(); - } -} - -SourceLocation TemplateLexer::current_location() const { - return SourceLocation{index_, line_, column_}; -} - -SourceSpan TemplateLexer::make_span(SourceLocation start) const { - SourceSpan span; - span.file_path = file_path_; - span.start = start; - span.end = current_location(); - return span; -} - -void TemplateLexer::add_token(TemplateTokenType type, std::string lexeme, SourceLocation start) { - add_token(type, std::move(lexeme), start, false, false); -} - -void TemplateLexer::add_token(TemplateTokenType type, std::string lexeme, SourceLocation start, - bool trim_left, bool trim_right) { - tokens_.push_back(TemplateToken{type, std::move(lexeme), make_span(start), trim_left, trim_right}); -} - -void TemplateLexer::lex_text() { - const SourceLocation start = current_location(); - std::string text; - while (!is_at_end() && !match_literal(tag_prefix_)) { - text.push_back(advance()); - } - - if (trim_next_text_left_) { - trim_left_ascii_whitespace(text); - trim_next_text_left_ = false; - } - - if (!text.empty()) { - add_token(TemplateTokenType::Text, text, start); - } - - if (!is_at_end() && match_literal(tag_prefix_)) { - const SourceLocation tag_start = current_location(); - advance_literal(tag_prefix_); - bool trim_left = false; - if (!is_at_end() && peek() == '-') { - trim_left = true; - advance(); - } - if (trim_left && !tokens_.empty() && tokens_.back().type == TemplateTokenType::Text) { - trim_right_ascii_whitespace(tokens_.back().lexeme); - if (tokens_.back().lexeme.empty()) { - tokens_.pop_back(); - } - } - add_token(TemplateTokenType::TagOpen, std::string(tag_prefix_), tag_start, trim_left, false); - inside_tag_ = true; - } -} - -void TemplateLexer::lex_inside_tag() { - skip_tag_whitespace(); - if (is_at_end()) { - return; - } - - if (match_literal(tag_suffix_)) { - const SourceLocation start = current_location(); - advance_literal(tag_suffix_); - add_token(TemplateTokenType::TagClose, std::string(tag_suffix_), start, false, false); - inside_tag_ = false; - return; - } - - if (peek() == '-' && match_literal(std::string("-") + std::string(tag_suffix_))) { - const SourceLocation start = current_location(); - advance(); - advance_literal(tag_suffix_); - add_token(TemplateTokenType::TagClose, std::string(tag_suffix_), start, false, true); - trim_next_text_left_ = true; - inside_tag_ = false; - return; - } - - const char ch = peek(); - if (text::is_identifier_start(ch)) { - lex_identifier_or_keyword(); - return; - } - if (std::isdigit(static_cast(ch)) != 0) { - lex_number(); - return; - } - if (ch == '"') { - lex_string(); - return; - } - - const SourceLocation start = current_location(); - if (match_literal("&&")) { - advance_literal("&&"); - add_token(TemplateTokenType::AndAnd, "&&", start); - return; - } - if (match_literal("||")) { - advance_literal("||"); - add_token(TemplateTokenType::OrOr, "||", start); - return; - } - if (match_literal("==")) { - advance_literal("=="); - add_token(TemplateTokenType::EqualEqual, "==", start); - return; - } - if (match_literal("<=")) { - advance_literal("<="); - add_token(TemplateTokenType::LessEqual, "<=", start); - return; - } - if (match_literal(">=")) { - advance_literal(">="); - add_token(TemplateTokenType::GreaterEqual, ">=", start); - return; - } - if (match_literal("!=")) { - advance_literal("!="); - add_token(TemplateTokenType::BangEqual, "!=", start); - return; - } - if (ch == '!') { - advance(); - add_token(TemplateTokenType::Bang, "!", start); - return; - } - if (ch == '(') { - advance(); - add_token(TemplateTokenType::LeftParen, "(", start); - return; - } - if (ch == '|') { - advance(); - add_token(TemplateTokenType::Pipe, "|", start); - return; - } - if (ch == '[') { - advance(); - add_token(TemplateTokenType::LeftBracket, "[", start); - return; - } - if (ch == ')') { - advance(); - add_token(TemplateTokenType::RightParen, ")", start); - return; - } - if (ch == ']') { - advance(); - add_token(TemplateTokenType::RightBracket, "]", start); - return; - } - if (ch == '.') { - advance(); - add_token(TemplateTokenType::Dot, ".", start); - return; - } - if (ch == ',') { - advance(); - add_token(TemplateTokenType::Comma, ",", start); - return; - } - if (ch == '=') { - advance(); - add_token(TemplateTokenType::Equal, "=", start); - return; - } - if (ch == '<') { - advance(); - add_token(TemplateTokenType::Less, "<", start); - return; - } - if (ch == '>') { - advance(); - add_token(TemplateTokenType::Greater, ">", start); - return; - } - - throw DiagnosticError(make_lexer_error(std::string("Unexpected character in tag: ") + ch, file_path_, start)); -} - -void TemplateLexer::skip_tag_whitespace() { - while (!is_at_end() && std::isspace(static_cast(peek())) != 0) { - advance(); - } -} - -void TemplateLexer::lex_identifier_or_keyword() { - const SourceLocation start = current_location(); - std::string value; - while (!is_at_end() && text::is_identifier_part(peek())) { - value.push_back(advance()); - } - - if (value == "if") { - add_token(TemplateTokenType::KeywordIf, value, start); - return; - } - if (value == "for") { - add_token(TemplateTokenType::KeywordFor, value, start); - return; - } - if (value == "in") { - add_token(TemplateTokenType::KeywordIn, value, start); - return; - } - if (value == "elseif") { - add_token(TemplateTokenType::KeywordElseIf, value, start); - return; - } - if (value == "else") { - add_token(TemplateTokenType::KeywordElse, value, start); - return; - } - if (value == "endif") { - add_token(TemplateTokenType::KeywordEndIf, value, start); - return; - } - if (value == "endfor") { - add_token(TemplateTokenType::KeywordEndFor, value, start); - return; - } - if (value == "include") { - add_token(TemplateTokenType::KeywordInclude, value, start); - return; - } - if (value == "set") { - add_token(TemplateTokenType::KeywordSet, value, start); - return; - } - if (value == "fn") { - add_token(TemplateTokenType::KeywordFn, value, start); - return; - } - if (value == "endfn") { - add_token(TemplateTokenType::KeywordEndFn, value, start); - return; - } - if (value == "lua") { - add_token(TemplateTokenType::KeywordLua, value, start); - return; - } - if (value == "lua:block") { - add_token(TemplateTokenType::KeywordLuaBlock, value, start); - return; - } - if (value == "endlua") { - add_token(TemplateTokenType::KeywordEndLua, value, start); - return; - } - if (value == "true" || value == "false") { - add_token(TemplateTokenType::Boolean, value, start); - return; - } - if (value == "in") { - add_token(TemplateTokenType::KeywordIn, value, start); - return; - } - - add_token(TemplateTokenType::Identifier, value, start); -} - -void TemplateLexer::lex_string() { - const SourceLocation start = current_location(); - advance(); - std::string value; - - while (!is_at_end() && peek() != '"') { - char ch = advance(); - if (ch == '\\') { - const char escaped = advance(); - switch (escaped) { - case 'n': value.push_back('\n'); break; - case 't': value.push_back('\t'); break; - case '\\': value.push_back('\\'); break; - case '"': value.push_back('"'); break; - default: - throw DiagnosticError(make_lexer_error("Unsupported escape sequence", file_path_, current_location())); - } - continue; - } - value.push_back(ch); - } - - if (is_at_end()) { - throw DiagnosticError(make_lexer_error("Unterminated string literal", file_path_, start)); - } - - advance(); - add_token(TemplateTokenType::String, value, start); -} - -void TemplateLexer::lex_number() { - const SourceLocation start = current_location(); - std::string value; - while (!is_at_end() && std::isdigit(static_cast(peek())) != 0) { - value.push_back(advance()); - } - if (!is_at_end() && peek() == '.' && std::isdigit(static_cast(peek(1))) != 0) { - value.push_back(advance()); - while (!is_at_end() && std::isdigit(static_cast(peek())) != 0) { - value.push_back(advance()); - } - } - add_token(TemplateTokenType::Number, value, start); -} - -} diff --git a/src/main/cpp/template/lexer/TemplateLexerCore.cpp b/src/main/cpp/template/lexer/TemplateLexerCore.cpp new file mode 100644 index 0000000..cb0c8aa --- /dev/null +++ b/src/main/cpp/template/lexer/TemplateLexerCore.cpp @@ -0,0 +1,86 @@ +#include "template/lexer/TemplateLexer.h" + +#include "support/Diagnostic.h" +#include "template/lexer/TemplateLexerInternals.h" + +namespace prebyte { + +TemplateLexer::TemplateLexer(std::string_view source, std::string file_path, std::string_view tag_prefix, + std::string_view tag_suffix) + : source_(source), file_path_(std::move(file_path)), tag_prefix_(tag_prefix), tag_suffix_(tag_suffix) {} + +std::vector TemplateLexer::lex() { + while (!is_at_end()) { + if (!inside_tag_) { + lex_text(); + } else { + lex_inside_tag(); + } + } + + if (inside_tag_) { + throw DiagnosticError(make_lexer_error("Unclosed tag", file_path_, current_location())); + } + + add_token(TemplateTokenType::EndOfFile, "", current_location()); + return tokens_; +} + +char TemplateLexer::peek(std::size_t offset) const { + const std::size_t target = index_ + offset; + if (target >= source_.size()) { + return '\0'; + } + return source_[target]; +} + +bool TemplateLexer::is_at_end() const { + return index_ >= source_.size(); +} + +bool TemplateLexer::match_literal(std::string_view literal) const { + return source_.compare(index_, literal.size(), literal) == 0; +} + +char TemplateLexer::advance() { + if (is_at_end()) { + return '\0'; + } + const char ch = source_[index_++]; + if (ch == '\n') { + ++line_; + column_ = 1; + } else { + ++column_; + } + return ch; +} + +void TemplateLexer::advance_literal(std::string_view literal) { + for (std::size_t i = 0; i < literal.size(); ++i) { + advance(); + } +} + +SourceLocation TemplateLexer::current_location() const { + return SourceLocation{index_, line_, column_}; +} + +SourceSpan TemplateLexer::make_span(SourceLocation start) const { + SourceSpan span; + span.file_path = file_path_; + span.start = start; + span.end = current_location(); + return span; +} + +void TemplateLexer::add_token(TemplateTokenType type, std::string lexeme, SourceLocation start) { + add_token(type, std::move(lexeme), start, false, false); +} + +void TemplateLexer::add_token(TemplateTokenType type, std::string lexeme, SourceLocation start, bool trim_left, + bool trim_right) { + tokens_.push_back(TemplateToken{type, std::move(lexeme), make_span(start), trim_left, trim_right}); +} + +} diff --git a/src/main/cpp/template/lexer/TemplateLexerInternals.cpp b/src/main/cpp/template/lexer/TemplateLexerInternals.cpp new file mode 100644 index 0000000..bf8f913 --- /dev/null +++ b/src/main/cpp/template/lexer/TemplateLexerInternals.cpp @@ -0,0 +1,41 @@ +#include "template/lexer/TemplateLexerInternals.h" + +#include "support/Diagnostic.h" + +namespace prebyte { + +Diagnostic make_lexer_error(const std::string& message, const std::string& file_path, SourceLocation location) { + Diagnostic diagnostic; + diagnostic.code = "LEX001"; + diagnostic.message = message; + diagnostic.span.file_path = file_path; + diagnostic.span.start = location; + diagnostic.span.end = location; + return diagnostic; +} + +void trim_right_ascii_whitespace(std::string& text) { + while (!text.empty()) { + const char ch = text.back(); + if (ch != ' ' && ch != '\t' && ch != '\n' && ch != '\r') { + break; + } + text.pop_back(); + } +} + +void trim_left_ascii_whitespace(std::string& text) { + std::size_t start = 0; + while (start < text.size()) { + const char ch = text[start]; + if (ch != ' ' && ch != '\t' && ch != '\n' && ch != '\r') { + break; + } + ++start; + } + if (start != 0) { + text.erase(0, start); + } +} + +} diff --git a/src/main/cpp/template/lexer/TemplateLexerKeywords.cpp b/src/main/cpp/template/lexer/TemplateLexerKeywords.cpp new file mode 100644 index 0000000..39a58f1 --- /dev/null +++ b/src/main/cpp/template/lexer/TemplateLexerKeywords.cpp @@ -0,0 +1,42 @@ +#include "template/lexer/TemplateLexerKeywords.h" + +namespace prebyte::template_lexer { + +namespace { + +struct KeywordRule { + std::string_view word; + TemplateTokenType type; +}; + +constexpr KeywordRule kKeywordRules[] = { + {"if", TemplateTokenType::KeywordIf}, + {"for", TemplateTokenType::KeywordFor}, + {"in", TemplateTokenType::KeywordIn}, + {"elseif", TemplateTokenType::KeywordElseIf}, + {"else", TemplateTokenType::KeywordElse}, + {"endif", TemplateTokenType::KeywordEndIf}, + {"endfor", TemplateTokenType::KeywordEndFor}, + {"include", TemplateTokenType::KeywordInclude}, + {"set", TemplateTokenType::KeywordSet}, + {"fn", TemplateTokenType::KeywordFn}, + {"endfn", TemplateTokenType::KeywordEndFn}, + {"lua", TemplateTokenType::KeywordLua}, + {"lua:block", TemplateTokenType::KeywordLuaBlock}, + {"endlua", TemplateTokenType::KeywordEndLua}, + {"true", TemplateTokenType::Boolean}, + {"false", TemplateTokenType::Boolean}, +}; + +} // namespace + +std::optional lookup_keyword(std::string_view lexeme) { + for (const KeywordRule& rule : kKeywordRules) { + if (lexeme == rule.word) { + return rule.type; + } + } + return std::nullopt; +} + +} diff --git a/src/main/cpp/template/lexer/TemplateLexerLiterals.cpp b/src/main/cpp/template/lexer/TemplateLexerLiterals.cpp new file mode 100644 index 0000000..5365a70 --- /dev/null +++ b/src/main/cpp/template/lexer/TemplateLexerLiterals.cpp @@ -0,0 +1,83 @@ +#include "template/lexer/TemplateLexer.h" + +#include + +#include "support/Diagnostic.h" +#include "support/TextUtil.h" +#include "template/lexer/TemplateLexerInternals.h" +#include "template/lexer/TemplateLexerKeywords.h" + +namespace prebyte { + +void TemplateLexer::lex_identifier_or_keyword() { + const SourceLocation start = current_location(); + std::string value; + while (!is_at_end() && text::is_identifier_part(peek())) { + value.push_back(advance()); + } + + if (const std::optional keyword = template_lexer::lookup_keyword(value)) { + add_token(*keyword, value, start); + return; + } + + add_token(TemplateTokenType::Identifier, value, start); +} + +void TemplateLexer::lex_string() { + const SourceLocation start = current_location(); + advance(); + std::string value; + + while (!is_at_end() && peek() != '"') { + const char ch = advance(); + if (ch == '\\') { + if (is_at_end()) { + throw DiagnosticError(make_lexer_error("Unterminated string literal", file_path_, start)); + } + const char escaped = advance(); + switch (escaped) { + case 'n': + value.push_back('\n'); + break; + case 't': + value.push_back('\t'); + break; + case '\\': + value.push_back('\\'); + break; + case '"': + value.push_back('"'); + break; + default: + throw DiagnosticError(make_lexer_error("Unsupported escape sequence", file_path_, current_location())); + } + continue; + } + value.push_back(ch); + } + + if (is_at_end()) { + throw DiagnosticError(make_lexer_error("Unterminated string literal", file_path_, start)); + } + + advance(); + add_token(TemplateTokenType::String, value, start); +} + +void TemplateLexer::lex_number() { + const SourceLocation start = current_location(); + std::string value; + while (!is_at_end() && std::isdigit(static_cast(peek())) != 0) { + value.push_back(advance()); + } + if (!is_at_end() && peek() == '.' && std::isdigit(static_cast(peek(1))) != 0) { + value.push_back(advance()); + while (!is_at_end() && std::isdigit(static_cast(peek())) != 0) { + value.push_back(advance()); + } + } + add_token(TemplateTokenType::Number, value, start); +} + +} diff --git a/src/main/cpp/template/lexer/TemplateLexerTag.cpp b/src/main/cpp/template/lexer/TemplateLexerTag.cpp new file mode 100644 index 0000000..93c6a93 --- /dev/null +++ b/src/main/cpp/template/lexer/TemplateLexerTag.cpp @@ -0,0 +1,124 @@ +#include "template/lexer/TemplateLexer.h" + +#include + +#include "support/Diagnostic.h" +#include "support/TextUtil.h" +#include "template/lexer/TemplateLexerInternals.h" + +namespace prebyte { + +namespace { + +struct PunctuationRule { + std::string_view lexeme; + TemplateTokenType type; +}; + +constexpr PunctuationRule kTwoCharPunctuation[] = { + {"&&", TemplateTokenType::AndAnd}, + {"||", TemplateTokenType::OrOr}, + {"==", TemplateTokenType::EqualEqual}, + {"<=", TemplateTokenType::LessEqual}, + {">=", TemplateTokenType::GreaterEqual}, + {"!=", TemplateTokenType::BangEqual}, +}; + +constexpr PunctuationRule kOneCharPunctuation[] = { + {"!", TemplateTokenType::Bang}, + {"(", TemplateTokenType::LeftParen}, + {"|", TemplateTokenType::Pipe}, + {"[", TemplateTokenType::LeftBracket}, + {")", TemplateTokenType::RightParen}, + {"]", TemplateTokenType::RightBracket}, + {".", TemplateTokenType::Dot}, + {",", TemplateTokenType::Comma}, + {"=", TemplateTokenType::Equal}, + {"<", TemplateTokenType::Less}, + {">", TemplateTokenType::Greater}, +}; + +} // namespace + +bool TemplateLexer::try_lex_tag_close() { + if (match_literal(tag_suffix_)) { + const SourceLocation start = current_location(); + advance_literal(tag_suffix_); + add_token(TemplateTokenType::TagClose, std::string(tag_suffix_), start, false, false); + inside_tag_ = false; + return true; + } + + if (peek() == '-' && match_literal(std::string("-") + std::string(tag_suffix_))) { + const SourceLocation start = current_location(); + advance(); + advance_literal(tag_suffix_); + add_token(TemplateTokenType::TagClose, std::string(tag_suffix_), start, false, true); + trim_next_text_left_ = true; + inside_tag_ = false; + return true; + } + + return false; +} + +bool TemplateLexer::try_lex_tag_punctuation() { + const SourceLocation start = current_location(); + + for (const PunctuationRule& rule : kTwoCharPunctuation) { + if (match_literal(rule.lexeme)) { + advance_literal(rule.lexeme); + add_token(rule.type, std::string(rule.lexeme), start); + return true; + } + } + + for (const PunctuationRule& rule : kOneCharPunctuation) { + if (peek() == rule.lexeme.front()) { + advance(); + add_token(rule.type, std::string(rule.lexeme), start); + return true; + } + } + + return false; +} + +void TemplateLexer::skip_tag_whitespace() { + while (!is_at_end() && std::isspace(static_cast(peek())) != 0) { + advance(); + } +} + +void TemplateLexer::lex_inside_tag() { + skip_tag_whitespace(); + if (is_at_end()) { + return; + } + + if (try_lex_tag_close()) { + return; + } + + const char ch = peek(); + if (text::is_identifier_start(ch)) { + lex_identifier_or_keyword(); + return; + } + if (std::isdigit(static_cast(ch)) != 0) { + lex_number(); + return; + } + if (ch == '"') { + lex_string(); + return; + } + if (try_lex_tag_punctuation()) { + return; + } + + const SourceLocation start = current_location(); + throw DiagnosticError(make_lexer_error(std::string("Unexpected character in tag: ") + ch, file_path_, start)); +} + +} diff --git a/src/main/cpp/template/lexer/TemplateLexerText.cpp b/src/main/cpp/template/lexer/TemplateLexerText.cpp new file mode 100644 index 0000000..c3dab88 --- /dev/null +++ b/src/main/cpp/template/lexer/TemplateLexerText.cpp @@ -0,0 +1,45 @@ +#include "template/lexer/TemplateLexer.h" + +#include "template/lexer/TemplateLexerInternals.h" + +namespace prebyte { + +void TemplateLexer::lex_text() { + const SourceLocation start = current_location(); + std::string text; + while (!is_at_end() && !match_literal(tag_prefix_)) { + text.push_back(advance()); + } + + if (trim_next_text_left_) { + trim_left_ascii_whitespace(text); + trim_next_text_left_ = false; + } + + if (!text.empty()) { + add_token(TemplateTokenType::Text, text, start); + } + + if (!is_at_end() && match_literal(tag_prefix_)) { + const SourceLocation tag_start = current_location(); + advance_literal(tag_prefix_); + + bool trim_left = false; + if (!is_at_end() && peek() == '-') { + trim_left = true; + advance(); + } + + if (trim_left && !tokens_.empty() && tokens_.back().type == TemplateTokenType::Text) { + trim_right_ascii_whitespace(tokens_.back().lexeme); + if (tokens_.back().lexeme.empty()) { + tokens_.pop_back(); + } + } + + add_token(TemplateTokenType::TagOpen, std::string(tag_prefix_), tag_start, trim_left, false); + inside_tag_ = true; + } +} + +} diff --git a/src/main/cpp/template/parser/TemplateParser.cpp b/src/main/cpp/template/parser/TemplateParser.cpp deleted file mode 100644 index 5f033e6..0000000 --- a/src/main/cpp/template/parser/TemplateParser.cpp +++ /dev/null @@ -1,617 +0,0 @@ -#include "template/parser/TemplateParser.h" - -#include "support/Diagnostic.h" - -#include - -namespace prebyte { - -namespace { - -constexpr std::string_view kBuiltinNames[] = { - "__TIME__", - "__LINE__", - "__FILE__", - "__FILENAME__", - "__DIR__", - "__EXTENSION__", - "__DATE__", - "__TIMESTAMP__", - "__YEAR__", - "__MONTH__", - "__DAY__", - "__UNIX_EPOCH__", - "__USER__", - "__HOST__", - "__OS__", - "__WORKING_DIR__", - "__UUID__", - "__RANDOM__", -}; - -bool is_builtin_name(std::string_view name) { - for (const std::string_view builtin : kBuiltinNames) { - if (builtin == name) { - return true; - } - } - return false; -} - -bool is_keyword_name(std::string_view name) { - return name == "if" || name == "elseif" || name == "else" || name == "endif" - || name == "for" || name == "in" || name == "endfor" || name == "include" - || name == "set" || name == "lua" || name == "fn" || name == "endfn" - || name == "endlua" || name == "len"; -} - -bool is_reserved_name(std::string_view name) { - return name == "loop" || name == "ARGS" || is_builtin_name(name) || is_keyword_name(name); -} - -bool is_reserved_loop_binding(std::string_view name) { - return is_reserved_name(name); -} - -void apply_trim_flags(TemplateNode& node, const TemplateToken& start, const TemplateToken& end) { - node.trim_left = start.trim_left; - node.trim_right = end.trim_right; -} - -} - -TemplateParser::TemplateParser(std::vector tokens, TemplateParserOptions options) - : tokens_(std::move(tokens)), options_(options) {} - -std::unique_ptr TemplateParser::parse_document() { - auto document = std::make_unique(); - document->children = parse_nodes_until({}); - consume(TemplateTokenType::EndOfFile, "Expected end of file"); - return document; -} - -const TemplateToken& TemplateParser::peek(std::size_t offset) const { - const std::size_t index = current_ + offset; - if (index >= tokens_.size()) { - return tokens_.back(); - } - return tokens_[index]; -} - -bool TemplateParser::is_at_end() const { - return peek().type == TemplateTokenType::EndOfFile; -} - -bool TemplateParser::check(TemplateTokenType type, std::size_t offset) const { - return peek(offset).type == type; -} - -bool TemplateParser::match(TemplateTokenType type) { - if (!check(type)) { - return false; - } - advance(); - return true; -} - -const TemplateToken& TemplateParser::advance() { - if (!is_at_end()) { - ++current_; - } - return tokens_[current_ - 1]; -} - -const TemplateToken& TemplateParser::consume(TemplateTokenType type, const std::string& message) { - if (check(type)) { - return advance(); - } - throw DiagnosticError(make_error(peek(), message)); -} - -bool TemplateParser::is_terminator_ahead(const std::vector& terminators) const { - if (terminators.empty()) { - return false; - } - if (!check(TemplateTokenType::TagOpen) || is_at_end()) { - return false; - } - for (TemplateTokenType type : terminators) { - if (check(type, 1)) { - return true; - } - } - return false; -} - -std::vector TemplateParser::parse_nodes_until(const std::vector& terminators) { - std::vector nodes; - while (!is_at_end()) { - if (is_terminator_ahead(terminators)) { - break; - } - nodes.push_back(parse_node()); - } - return nodes; -} - -TemplateNodePtr TemplateParser::parse_node() { - if (check(TemplateTokenType::Text)) { - const TemplateToken token = advance(); - return std::make_unique(token.lexeme, token.span); - } - if (check(TemplateTokenType::TagOpen)) { - return parse_tag(); - } - - throw DiagnosticError(make_error(peek(), "Expected text or tag")); -} - -TemplateNodePtr TemplateParser::parse_tag() { - if (check(TemplateTokenType::TagOpen) && check(TemplateTokenType::KeywordIf, 1)) { - return parse_if_block(); - } - if (check(TemplateTokenType::TagOpen) && check(TemplateTokenType::KeywordFor, 1)) { - if (!options_.enable_loops) { - throw DiagnosticError(make_error(peek(1), "Loop directives are reserved for a later phase")); - } - return parse_for_block(); - } - if (check(TemplateTokenType::TagOpen) && check(TemplateTokenType::KeywordSet, 1)) { - return parse_set_statement(); - } - if (check(TemplateTokenType::TagOpen) && check(TemplateTokenType::KeywordFn, 1)) { - return parse_function_definition(); - } - if (check(TemplateTokenType::TagOpen) && check(TemplateTokenType::KeywordLua, 1)) { - return parse_lua_expr(); - } - if (check(TemplateTokenType::TagOpen) && check(TemplateTokenType::KeywordLuaBlock, 1)) { - return parse_lua_block(); - } - if (check(TemplateTokenType::TagOpen) && check(TemplateTokenType::Identifier, 1)) { - const std::string directive = peek(1).lexeme; - if (directive == "while") { - throw DiagnosticError(make_error(peek(1), "Loop directives are reserved for a later phase")); - } - } - if (check(TemplateTokenType::TagOpen) && check(TemplateTokenType::KeywordInclude, 1)) { - return parse_include(); - } - if (check(TemplateTokenType::TagOpen) && (check(TemplateTokenType::KeywordElseIf, 1) - || check(TemplateTokenType::KeywordElse, 1) || check(TemplateTokenType::KeywordEndIf, 1) - || check(TemplateTokenType::KeywordEndFor, 1) || check(TemplateTokenType::KeywordEndFn, 1))) { - throw DiagnosticError(make_error(peek(1), "Unexpected control-flow terminator")); - } - return parse_interpolation(); -} - -std::unique_ptr TemplateParser::parse_lua_expr() { - const TemplateToken start = consume(TemplateTokenType::TagOpen, "Expected tag start"); - consume(TemplateTokenType::KeywordLua, "Expected lua keyword"); - const TemplateToken source = consume(TemplateTokenType::String, "Expected Lua string literal"); - const TemplateToken end = consume(TemplateTokenType::TagClose, "Expected tag end after lua expression"); - - SourceSpan span = start.span; - span.end = end.span.end; - return std::make_unique(source.lexeme, span); -} - -std::unique_ptr TemplateParser::parse_if_condition(const std::string& branch_name) { - if (match(TemplateTokenType::KeywordLuaBlock)) { - const TemplateToken start = tokens_[current_ - 1]; - consume(TemplateTokenType::TagClose, "Expected tag end after " + branch_name + " lua:block"); - const std::string source = parse_raw_body_until(TemplateTokenType::KeywordEndLua); - - consume(TemplateTokenType::TagOpen, "Expected tag start before endlua"); - consume(TemplateTokenType::KeywordEndLua, "Expected endlua keyword"); - const TemplateToken end = consume(TemplateTokenType::TagClose, "Expected tag end after endlua"); - - SourceSpan span = start.span; - span.end = end.span.end; - return std::make_unique(source, span); - } - - auto condition = parse_expression(); - consume(TemplateTokenType::TagClose, "Expected tag end after " + branch_name + " expression"); - return condition; -} - -std::unique_ptr TemplateParser::parse_lua_block() { - const TemplateToken start = consume(TemplateTokenType::TagOpen, "Expected tag start"); - consume(TemplateTokenType::KeywordLuaBlock, "Expected lua:block keyword"); - consume(TemplateTokenType::TagClose, "Expected tag end after lua:block"); - - const std::string source = parse_raw_body_until(TemplateTokenType::KeywordEndLua); - - consume(TemplateTokenType::TagOpen, "Expected tag start before endlua"); - consume(TemplateTokenType::KeywordEndLua, "Expected endlua keyword"); - const TemplateToken end = consume(TemplateTokenType::TagClose, "Expected tag end after endlua"); - - SourceSpan span = start.span; - span.end = end.span.end; - return std::make_unique(source, span); -} - -std::unique_ptr TemplateParser::parse_include() { - const TemplateToken start = consume(TemplateTokenType::TagOpen, "Expected tag start"); - consume(TemplateTokenType::KeywordInclude, "Expected include keyword"); - const TemplateToken path = consume(TemplateTokenType::String, "Expected include path string"); - const TemplateToken end = consume(TemplateTokenType::TagClose, "Expected tag end after include"); - - SourceSpan span = start.span; - span.end = end.span.end; - auto node = std::make_unique(path.lexeme, span); - apply_trim_flags(*node, start, end); - return node; -} - -std::unique_ptr TemplateParser::parse_if_block() { - const TemplateToken start = consume(TemplateTokenType::TagOpen, "Expected tag start"); - consume(TemplateTokenType::KeywordIf, "Expected if keyword"); - auto node = std::make_unique(start.span); - - IfBranch if_branch; - if_branch.condition = parse_if_condition("if"); - if_branch.body = parse_nodes_until({TemplateTokenType::KeywordElseIf, TemplateTokenType::KeywordElse, TemplateTokenType::KeywordEndIf}); - node->branches.push_back(std::move(if_branch)); - - while (check(TemplateTokenType::TagOpen) && check(TemplateTokenType::KeywordElseIf, 1)) { - advance(); - advance(); - - IfBranch branch; - branch.condition = parse_if_condition("elseif"); - branch.body = parse_nodes_until({TemplateTokenType::KeywordElseIf, TemplateTokenType::KeywordElse, TemplateTokenType::KeywordEndIf}); - node->branches.push_back(std::move(branch)); - } - - if (check(TemplateTokenType::TagOpen) && check(TemplateTokenType::KeywordElse, 1)) { - advance(); - advance(); - consume(TemplateTokenType::TagClose, "Expected tag end after else"); - - IfBranch else_branch; - else_branch.body = parse_nodes_until({TemplateTokenType::KeywordEndIf}); - node->branches.push_back(std::move(else_branch)); - } - - consume(TemplateTokenType::TagOpen, "Expected tag start before endif"); - consume(TemplateTokenType::KeywordEndIf, "Expected endif keyword"); - const TemplateToken end = consume(TemplateTokenType::TagClose, "Expected tag end after endif"); - node->span.end = end.span.end; - apply_trim_flags(*node, start, end); - - return node; -} - -std::unique_ptr TemplateParser::parse_for_block() { - const TemplateToken start = consume(TemplateTokenType::TagOpen, "Expected tag start"); - consume(TemplateTokenType::KeywordFor, "Expected for keyword"); - - const TemplateToken first = consume(TemplateTokenType::Identifier, "Expected loop binding name after for"); - if (is_reserved_loop_binding(first.lexeme)) { - throw DiagnosticError(make_error(first, "Reserved loop variable name: " + first.lexeme)); - } - - std::optional key_name; - std::string value_name = first.lexeme; - if (match(TemplateTokenType::Comma)) { - key_name = first.lexeme; - const TemplateToken second = consume(TemplateTokenType::Identifier, "Expected second loop binding name after ','"); - if (is_reserved_loop_binding(second.lexeme)) { - throw DiagnosticError(make_error(second, "Reserved loop variable name: " + second.lexeme)); - } - if (*key_name == second.lexeme) { - throw DiagnosticError(make_error(second, "Loop binding names must be distinct")); - } - value_name = second.lexeme; - } - - consume(TemplateTokenType::KeywordIn, "Expected 'in' after loop binding"); - auto iterable = parse_expression(); - consume(TemplateTokenType::TagClose, "Expected tag end after for expression"); - - auto node = std::make_unique(value_name, std::move(iterable), key_name, start.span); - node->body = parse_nodes_until({TemplateTokenType::KeywordElse, TemplateTokenType::KeywordEndFor}); - - if (check(TemplateTokenType::TagOpen) && check(TemplateTokenType::KeywordElse, 1)) { - advance(); - advance(); - consume(TemplateTokenType::TagClose, "Expected tag end after else"); - node->else_body = parse_nodes_until({TemplateTokenType::KeywordEndFor}); - } - - consume(TemplateTokenType::TagOpen, "Expected tag start before endfor"); - consume(TemplateTokenType::KeywordEndFor, "Expected endfor keyword"); - const TemplateToken end = consume(TemplateTokenType::TagClose, "Expected tag end after endfor"); - node->span.end = end.span.end; - apply_trim_flags(*node, start, end); - return node; -} - -std::unique_ptr TemplateParser::parse_set_statement() { - const TemplateToken start = consume(TemplateTokenType::TagOpen, "Expected tag start"); - consume(TemplateTokenType::KeywordSet, "Expected set keyword"); - const TemplateToken name = consume(TemplateTokenType::Identifier, "Expected variable name after set"); - if (is_reserved_loop_binding(name.lexeme)) { - throw DiagnosticError(make_error(name, "Reserved variable name: " + name.lexeme)); - } - consume(TemplateTokenType::Equal, "Expected '=' after set variable name"); - auto expression = parse_expression(); - const TemplateToken end = consume(TemplateTokenType::TagClose, "Expected tag end after set expression"); - - SourceSpan span = start.span; - span.end = end.span.end; - auto node = std::make_unique(name.lexeme, std::move(expression), span); - apply_trim_flags(*node, start, end); - return node; -} - -std::unique_ptr TemplateParser::parse_function_definition() { - const TemplateToken start = consume(TemplateTokenType::TagOpen, "Expected tag start"); - consume(TemplateTokenType::KeywordFn, "Expected fn keyword"); - const TemplateToken name = consume(TemplateTokenType::Identifier, "Expected function name after fn"); - if (is_reserved_name(name.lexeme)) { - throw DiagnosticError(make_error(name, "Reserved function name: " + name.lexeme)); - } - consume(TemplateTokenType::LeftParen, "Expected '(' after function name"); - - std::vector parameters; - std::unordered_set seen_parameters; - if (!check(TemplateTokenType::RightParen)) { - do { - const TemplateToken parameter = consume(TemplateTokenType::Identifier, "Expected parameter name"); - if (is_reserved_name(parameter.lexeme)) { - throw DiagnosticError(make_error(parameter, "Reserved parameter name: " + parameter.lexeme)); - } - if (!seen_parameters.insert(parameter.lexeme).second) { - throw DiagnosticError(make_error(parameter, "Duplicate parameter name: " + parameter.lexeme)); - } - parameters.push_back(parameter.lexeme); - } while (match(TemplateTokenType::Comma)); - } - consume(TemplateTokenType::RightParen, "Expected ')' after function parameters"); - - FunctionMode mode = FunctionMode::Template; - if (match(TemplateTokenType::KeywordLuaBlock)) { - mode = FunctionMode::Lua; - } - - consume(TemplateTokenType::TagClose, mode == FunctionMode::Lua - ? "Expected tag end after function lua:block header" - : "Expected tag end after function header"); - - auto node = std::make_unique(name.lexeme, std::move(parameters), mode, start.span); - if (mode == FunctionMode::Lua) { - node->lua_source = parse_raw_body_until(TemplateTokenType::KeywordEndFn); - } else { - node->body = parse_nodes_until({TemplateTokenType::KeywordEndFn}); - } - - consume(TemplateTokenType::TagOpen, "Expected tag start before endfn"); - consume(TemplateTokenType::KeywordEndFn, "Expected endfn keyword"); - const TemplateToken end = consume(TemplateTokenType::TagClose, "Expected tag end after endfn"); - node->span.end = end.span.end; - apply_trim_flags(*node, start, end); - return node; -} - -std::unique_ptr TemplateParser::parse_interpolation() { - const TemplateToken start = consume(TemplateTokenType::TagOpen, "Expected tag start"); - auto expression = parse_expression(); - const TemplateToken end = consume(TemplateTokenType::TagClose, "Expected tag end after expression"); - - SourceSpan span = start.span; - span.end = end.span.end; - auto node = std::make_unique(std::move(expression), span); - apply_trim_flags(*node, start, end); - return node; -} - -std::unique_ptr TemplateParser::parse_expression() { - return parse_pipe(); -} - -std::unique_ptr TemplateParser::parse_pipe() { - auto expression = parse_or(); - while (match(TemplateTokenType::Pipe)) { - const TemplateToken filter = consume(TemplateTokenType::Identifier, "Expected filter name after '|'"); - std::vector> arguments; - SourceSpan span = expression->span; - span.end = filter.span.end; - if (match(TemplateTokenType::LeftParen)) { - if (!check(TemplateTokenType::RightParen)) { - do { - arguments.push_back(parse_expression()); - } while (match(TemplateTokenType::Comma)); - } - const TemplateToken end = consume(TemplateTokenType::RightParen, "Expected ')' after filter arguments"); - span.end = end.span.end; - } - expression = std::make_unique(std::move(expression), filter.lexeme, std::move(arguments), span); - } - return expression; -} - -std::unique_ptr TemplateParser::parse_or() { - auto expression = parse_and(); - while (match(TemplateTokenType::OrOr)) { - const TemplateToken op = tokens_[current_ - 1]; - auto right = parse_and(); - SourceSpan span = expression->span; - span.end = right->span.end; - expression = std::make_unique(std::move(expression), op.lexeme, std::move(right), span); - } - return expression; -} - -std::unique_ptr TemplateParser::parse_and() { - auto expression = parse_equality(); - while (match(TemplateTokenType::AndAnd)) { - const TemplateToken op = tokens_[current_ - 1]; - auto right = parse_equality(); - SourceSpan span = expression->span; - span.end = right->span.end; - expression = std::make_unique(std::move(expression), op.lexeme, std::move(right), span); - } - return expression; -} - -std::unique_ptr TemplateParser::parse_equality() { - auto expression = parse_comparison(); - while (match(TemplateTokenType::EqualEqual) || match(TemplateTokenType::BangEqual)) { - const TemplateToken op = tokens_[current_ - 1]; - auto right = parse_comparison(); - SourceSpan span = expression->span; - span.end = right->span.end; - expression = std::make_unique(std::move(expression), op.lexeme, std::move(right), span); - } - return expression; -} - -std::unique_ptr TemplateParser::parse_comparison() { - auto expression = parse_unary(); - if (match(TemplateTokenType::Less) || match(TemplateTokenType::Greater) - || match(TemplateTokenType::LessEqual) || match(TemplateTokenType::GreaterEqual) - || match(TemplateTokenType::KeywordIn)) { - const TemplateToken op = tokens_[current_ - 1]; - auto right = parse_unary(); - SourceSpan span = expression->span; - span.end = right->span.end; - expression = std::make_unique(std::move(expression), op.lexeme, std::move(right), span); - if (check(TemplateTokenType::Less) || check(TemplateTokenType::Greater) - || check(TemplateTokenType::LessEqual) || check(TemplateTokenType::GreaterEqual) - || check(TemplateTokenType::KeywordIn)) { - throw DiagnosticError(make_error(peek(), "Chained comparison operators are not supported")); - } - } - return expression; -} - -std::unique_ptr TemplateParser::parse_unary() { - if (match(TemplateTokenType::Bang)) { - const TemplateToken op = tokens_[current_ - 1]; - auto operand = parse_unary(); - SourceSpan span = op.span; - span.end = operand->span.end; - return std::make_unique(op.lexeme, std::move(operand), span); - } - return parse_postfix(); -} - -std::unique_ptr TemplateParser::parse_postfix() { - auto expression = parse_primary(); - - while (true) { - if (match(TemplateTokenType::Dot)) { - const TemplateToken member = consume(TemplateTokenType::Identifier, "Expected member name after '.'"); - SourceSpan span = expression->span; - span.end = member.span.end; - expression = std::make_unique(std::move(expression), member.lexeme, span); - continue; - } - - if (match(TemplateTokenType::LeftParen)) { - if (expression->kind != ExpressionKind::Identifier) { - throw DiagnosticError(make_error(tokens_[current_ - 1], "Function call requires identifier callee")); - } - const std::string name = static_cast(*expression).name; - std::vector> arguments; - if (!check(TemplateTokenType::RightParen)) { - do { - arguments.push_back(parse_expression()); - } while (match(TemplateTokenType::Comma)); - } - const TemplateToken end = consume(TemplateTokenType::RightParen, "Expected ')' after function arguments"); - SourceSpan span = expression->span; - span.end = end.span.end; - expression = std::make_unique(name, std::move(arguments), span); - continue; - } - - if (match(TemplateTokenType::LeftBracket)) { - auto index = parse_expression(); - const TemplateToken end = consume(TemplateTokenType::RightBracket, "Expected ']' after index expression"); - SourceSpan span = expression->span; - span.end = end.span.end; - expression = std::make_unique(std::move(expression), std::move(index), span); - continue; - } - - break; - } - - return expression; -} - -std::unique_ptr TemplateParser::parse_primary() { - if (match(TemplateTokenType::KeywordLua)) { - const TemplateToken start = tokens_[current_ - 1]; - consume(TemplateTokenType::LeftParen, "Expected '(' after lua"); - const TemplateToken source = consume(TemplateTokenType::String, "Expected Lua string literal"); - const TemplateToken end = consume(TemplateTokenType::RightParen, "Expected ')' after Lua string"); - SourceSpan span = start.span; - span.end = end.span.end; - return std::make_unique(source.lexeme, span); - } - if (match(TemplateTokenType::Identifier)) { - const TemplateToken token = tokens_[current_ - 1]; - if (token.lexeme == "len" && match(TemplateTokenType::LeftParen)) { - auto operand = parse_expression(); - const TemplateToken end = consume(TemplateTokenType::RightParen, "Expected ')' after len expression"); - SourceSpan span = token.span; - span.end = end.span.end; - return std::make_unique(std::move(operand), span); - } - return std::make_unique(token.lexeme, token.span); - } - if (match(TemplateTokenType::String)) { - const TemplateToken token = tokens_[current_ - 1]; - return std::make_unique(token.lexeme, token.span); - } - if (match(TemplateTokenType::Number)) { - const TemplateToken token = tokens_[current_ - 1]; - return std::make_unique(std::stod(token.lexeme), token.lexeme, token.span); - } - if (match(TemplateTokenType::Boolean)) { - const TemplateToken token = tokens_[current_ - 1]; - return std::make_unique(token.lexeme == "true", token.span); - } - if (match(TemplateTokenType::LeftParen)) { - const TemplateToken start = tokens_[current_ - 1]; - auto expression = parse_expression(); - const TemplateToken end = consume(TemplateTokenType::RightParen, "Expected ')' after expression"); - SourceSpan span = start.span; - span.end = end.span.end; - return std::make_unique(std::move(expression), span); - } - - throw DiagnosticError(make_error(peek(), "Expected expression")); -} - -std::string TemplateParser::parse_raw_body_until(TemplateTokenType terminator) { - std::string source; - while (!is_at_end()) { - if (check(TemplateTokenType::TagOpen) && check(terminator, 1)) { - break; - } - source += advance().lexeme; - } - - if (source.empty()) { - throw DiagnosticError(make_error(peek(), "Expected raw Lua block body")); - } - - return source; -} - -Diagnostic TemplateParser::make_error(const TemplateToken& token, const std::string& message) const { - Diagnostic diagnostic; - diagnostic.code = "PARSE001"; - diagnostic.message = message; - diagnostic.span = token.span; - diagnostic.snippet = token.lexeme; - return diagnostic; -} - -} diff --git a/src/main/cpp/template/parser/TemplateParserCore.cpp b/src/main/cpp/template/parser/TemplateParserCore.cpp new file mode 100644 index 0000000..794c234 --- /dev/null +++ b/src/main/cpp/template/parser/TemplateParserCore.cpp @@ -0,0 +1,100 @@ +#include "template/parser/TemplateParser.h" + +#include "template/parser/TemplateParserInternals.h" + +namespace prebyte { + +TemplateParser::TemplateParser(std::vector tokens, TemplateParserOptions options) + : tokens_(std::move(tokens)), options_(options) {} + +std::unique_ptr TemplateParser::parse_document() { + auto document = std::make_unique(); + document->children = parse_nodes_until({}); + consume(TemplateTokenType::EndOfFile, "Expected end of file"); + return document; +} + +const TemplateToken& TemplateParser::peek(std::size_t offset) const { + if (tokens_.empty()) { + throw DiagnosticError(make_error(TemplateToken{TemplateTokenType::EndOfFile, "", {}}, "Empty template token stream")); + } + + const std::size_t index = current_ + offset; + if (index >= tokens_.size()) { + return tokens_.back(); + } + return tokens_[index]; +} + +bool TemplateParser::is_at_end() const { + return peek().type == TemplateTokenType::EndOfFile; +} + +bool TemplateParser::check(TemplateTokenType type, std::size_t offset) const { + return peek(offset).type == type; +} + +bool TemplateParser::match(TemplateTokenType type) { + if (!check(type)) { + return false; + } + advance(); + return true; +} + +const TemplateToken& TemplateParser::advance() { + if (tokens_.empty()) { + throw DiagnosticError(make_error(TemplateToken{TemplateTokenType::EndOfFile, "", {}}, "Empty template token stream")); + } + if (current_ >= tokens_.size()) { + return tokens_.back(); + } + + return tokens_[current_++]; +} + +const TemplateToken& TemplateParser::consume(TemplateTokenType type, const std::string& message) { + if (check(type)) { + return advance(); + } + throw DiagnosticError(make_error(peek(), message)); +} + +bool TemplateParser::is_terminator_ahead(const std::vector& terminators) const { + if (terminators.empty()) { + return false; + } + if (!check(TemplateTokenType::TagOpen) || is_at_end()) { + return false; + } + for (TemplateTokenType type : terminators) { + if (check(type, 1)) { + return true; + } + } + return false; +} + +Diagnostic TemplateParser::make_error(const TemplateToken& token, const std::string& message) const { + Diagnostic diagnostic; + diagnostic.code = "PARSE001"; + diagnostic.message = message; + diagnostic.span = token.span; + diagnostic.snippet = token.lexeme; + return diagnostic; +} + +void TemplateParser::push_expression_depth() { + if (expression_depth_ >= kMaxParserExpressionDepth) { + throw DiagnosticError(make_error(peek(), "Expression nesting is too deep")); + } + ++expression_depth_; +} + +void TemplateParser::pop_expression_depth() { + if (expression_depth_ > 0) { + --expression_depth_; + } +} + +} diff --git a/src/main/cpp/template/parser/TemplateParserDirectives.cpp b/src/main/cpp/template/parser/TemplateParserDirectives.cpp new file mode 100644 index 0000000..b4e37c3 --- /dev/null +++ b/src/main/cpp/template/parser/TemplateParserDirectives.cpp @@ -0,0 +1,220 @@ +#include "template/parser/TemplateParser.h" + +#include + +#include "template/parser/TemplateParserInternals.h" + +namespace prebyte { + +std::unique_ptr TemplateParser::parse_lua_expr() { + const TemplateToken start = consume(TemplateTokenType::TagOpen, "Expected tag start"); + consume(TemplateTokenType::KeywordLua, "Expected lua keyword"); + const TemplateToken source = consume(TemplateTokenType::String, "Expected Lua string literal"); + const TemplateToken end = consume(TemplateTokenType::TagClose, "Expected tag end after lua expression"); + + SourceSpan span = start.span; + span.end = end.span.end; + return std::make_unique(source.lexeme, span); +} + +std::unique_ptr TemplateParser::parse_if_condition(const std::string& branch_name) { + if (match(TemplateTokenType::KeywordLuaBlock)) { + const TemplateToken start = tokens_[current_ - 1]; + consume(TemplateTokenType::TagClose, "Expected tag end after " + branch_name + " lua:block"); + const std::string source = parse_raw_body_until(TemplateTokenType::KeywordEndLua); + + consume(TemplateTokenType::TagOpen, "Expected tag start before endlua"); + consume(TemplateTokenType::KeywordEndLua, "Expected endlua keyword"); + const TemplateToken end = consume(TemplateTokenType::TagClose, "Expected tag end after endlua"); + + SourceSpan span = start.span; + span.end = end.span.end; + return std::make_unique(source, span); + } + + auto condition = parse_expression(); + consume(TemplateTokenType::TagClose, "Expected tag end after " + branch_name + " expression"); + return condition; +} + +std::unique_ptr TemplateParser::parse_lua_block() { + const TemplateToken start = consume(TemplateTokenType::TagOpen, "Expected tag start"); + consume(TemplateTokenType::KeywordLuaBlock, "Expected lua:block keyword"); + consume(TemplateTokenType::TagClose, "Expected tag end after lua:block"); + + const std::string source = parse_raw_body_until(TemplateTokenType::KeywordEndLua); + + consume(TemplateTokenType::TagOpen, "Expected tag start before endlua"); + consume(TemplateTokenType::KeywordEndLua, "Expected endlua keyword"); + const TemplateToken end = consume(TemplateTokenType::TagClose, "Expected tag end after endlua"); + + SourceSpan span = start.span; + span.end = end.span.end; + return std::make_unique(source, span); +} + +std::unique_ptr TemplateParser::parse_include() { + const TemplateToken start = consume(TemplateTokenType::TagOpen, "Expected tag start"); + consume(TemplateTokenType::KeywordInclude, "Expected include keyword"); + const TemplateToken path = consume(TemplateTokenType::String, "Expected include path string"); + const TemplateToken end = consume(TemplateTokenType::TagClose, "Expected tag end after include"); + + SourceSpan span = start.span; + span.end = end.span.end; + auto node = std::make_unique(path.lexeme, span); + apply_trim_flags(*node, start, end); + return node; +} + +std::unique_ptr TemplateParser::parse_if_block() { + const TemplateToken start = consume(TemplateTokenType::TagOpen, "Expected tag start"); + consume(TemplateTokenType::KeywordIf, "Expected if keyword"); + auto node = std::make_unique(start.span); + + IfBranch if_branch; + if_branch.condition = parse_if_condition("if"); + if_branch.body = parse_nodes_until({TemplateTokenType::KeywordElseIf, TemplateTokenType::KeywordElse, TemplateTokenType::KeywordEndIf}); + node->branches.push_back(std::move(if_branch)); + + while (check(TemplateTokenType::TagOpen) && check(TemplateTokenType::KeywordElseIf, 1)) { + advance(); + advance(); + + IfBranch branch; + branch.condition = parse_if_condition("elseif"); + branch.body = parse_nodes_until({TemplateTokenType::KeywordElseIf, TemplateTokenType::KeywordElse, TemplateTokenType::KeywordEndIf}); + node->branches.push_back(std::move(branch)); + } + + if (check(TemplateTokenType::TagOpen) && check(TemplateTokenType::KeywordElse, 1)) { + advance(); + advance(); + consume(TemplateTokenType::TagClose, "Expected tag end after else"); + + IfBranch else_branch; + else_branch.body = parse_nodes_until({TemplateTokenType::KeywordEndIf}); + node->branches.push_back(std::move(else_branch)); + } + + consume(TemplateTokenType::TagOpen, "Expected tag start before endif"); + consume(TemplateTokenType::KeywordEndIf, "Expected endif keyword"); + const TemplateToken end = consume(TemplateTokenType::TagClose, "Expected tag end after endif"); + node->span.end = end.span.end; + apply_trim_flags(*node, start, end); + + return node; +} + +std::unique_ptr TemplateParser::parse_for_block() { + const TemplateToken start = consume(TemplateTokenType::TagOpen, "Expected tag start"); + consume(TemplateTokenType::KeywordFor, "Expected for keyword"); + + const TemplateToken first = consume(TemplateTokenType::Identifier, "Expected loop binding name after for"); + if (is_reserved_loop_binding(first.lexeme)) { + throw DiagnosticError(make_error(first, "Reserved loop variable name: " + first.lexeme)); + } + + std::optional key_name; + std::string value_name = first.lexeme; + if (match(TemplateTokenType::Comma)) { + key_name = first.lexeme; + const TemplateToken second = consume(TemplateTokenType::Identifier, "Expected second loop binding name after ','"); + if (is_reserved_loop_binding(second.lexeme)) { + throw DiagnosticError(make_error(second, "Reserved loop variable name: " + second.lexeme)); + } + if (*key_name == second.lexeme) { + throw DiagnosticError(make_error(second, "Loop binding names must be distinct")); + } + value_name = second.lexeme; + } + + consume(TemplateTokenType::KeywordIn, "Expected 'in' after loop binding"); + auto iterable = parse_expression(); + consume(TemplateTokenType::TagClose, "Expected tag end after for expression"); + + auto node = std::make_unique(value_name, std::move(iterable), key_name, start.span); + node->body = parse_nodes_until({TemplateTokenType::KeywordElse, TemplateTokenType::KeywordEndFor}); + + if (check(TemplateTokenType::TagOpen) && check(TemplateTokenType::KeywordElse, 1)) { + advance(); + advance(); + consume(TemplateTokenType::TagClose, "Expected tag end after else"); + node->else_body = parse_nodes_until({TemplateTokenType::KeywordEndFor}); + } + + consume(TemplateTokenType::TagOpen, "Expected tag start before endfor"); + consume(TemplateTokenType::KeywordEndFor, "Expected endfor keyword"); + const TemplateToken end = consume(TemplateTokenType::TagClose, "Expected tag end after endfor"); + node->span.end = end.span.end; + apply_trim_flags(*node, start, end); + return node; +} + +std::unique_ptr TemplateParser::parse_set_statement() { + const TemplateToken start = consume(TemplateTokenType::TagOpen, "Expected tag start"); + consume(TemplateTokenType::KeywordSet, "Expected set keyword"); + const TemplateToken name = consume(TemplateTokenType::Identifier, "Expected variable name after set"); + if (is_reserved_loop_binding(name.lexeme)) { + throw DiagnosticError(make_error(name, "Reserved variable name: " + name.lexeme)); + } + consume(TemplateTokenType::Equal, "Expected '=' after set variable name"); + auto expression = parse_expression(); + const TemplateToken end = consume(TemplateTokenType::TagClose, "Expected tag end after set expression"); + + SourceSpan span = start.span; + span.end = end.span.end; + auto node = std::make_unique(name.lexeme, std::move(expression), span); + apply_trim_flags(*node, start, end); + return node; +} + +std::unique_ptr TemplateParser::parse_function_definition() { + const TemplateToken start = consume(TemplateTokenType::TagOpen, "Expected tag start"); + consume(TemplateTokenType::KeywordFn, "Expected fn keyword"); + const TemplateToken name = consume(TemplateTokenType::Identifier, "Expected function name after fn"); + if (is_reserved_name(name.lexeme)) { + throw DiagnosticError(make_error(name, "Reserved function name: " + name.lexeme)); + } + consume(TemplateTokenType::LeftParen, "Expected '(' after function name"); + + std::vector parameters; + std::unordered_set seen_parameters; + if (!check(TemplateTokenType::RightParen)) { + do { + const TemplateToken parameter = consume(TemplateTokenType::Identifier, "Expected parameter name"); + if (is_reserved_name(parameter.lexeme)) { + throw DiagnosticError(make_error(parameter, "Reserved parameter name: " + parameter.lexeme)); + } + if (!seen_parameters.insert(parameter.lexeme).second) { + throw DiagnosticError(make_error(parameter, "Duplicate parameter name: " + parameter.lexeme)); + } + parameters.push_back(parameter.lexeme); + } while (match(TemplateTokenType::Comma)); + } + consume(TemplateTokenType::RightParen, "Expected ')' after function parameters"); + + FunctionMode mode = FunctionMode::Template; + if (match(TemplateTokenType::KeywordLuaBlock)) { + mode = FunctionMode::Lua; + } + + consume(TemplateTokenType::TagClose, mode == FunctionMode::Lua + ? "Expected tag end after function lua:block header" + : "Expected tag end after function header"); + + auto node = std::make_unique(name.lexeme, std::move(parameters), mode, start.span); + if (mode == FunctionMode::Lua) { + node->lua_source = parse_raw_body_until(TemplateTokenType::KeywordEndFn); + } else { + node->body = parse_nodes_until({TemplateTokenType::KeywordEndFn}); + } + + consume(TemplateTokenType::TagOpen, "Expected tag start before endfn"); + consume(TemplateTokenType::KeywordEndFn, "Expected endfn keyword"); + const TemplateToken end = consume(TemplateTokenType::TagClose, "Expected tag end after endfn"); + node->span.end = end.span.end; + apply_trim_flags(*node, start, end); + return node; +} + +} diff --git a/src/main/cpp/template/parser/TemplateParserExpressions.cpp b/src/main/cpp/template/parser/TemplateParserExpressions.cpp new file mode 100644 index 0000000..04df6ba --- /dev/null +++ b/src/main/cpp/template/parser/TemplateParserExpressions.cpp @@ -0,0 +1,189 @@ +#include "template/parser/TemplateParser.h" + +namespace prebyte { + +std::unique_ptr TemplateParser::parse_expression() { + push_expression_depth(); + auto expression = parse_pipe(); + pop_expression_depth(); + return expression; +} + +std::unique_ptr TemplateParser::parse_pipe() { + auto expression = parse_or(); + while (match(TemplateTokenType::Pipe)) { + const TemplateToken filter = consume(TemplateTokenType::Identifier, "Expected filter name after '|'"); + std::vector> arguments; + SourceSpan span = expression->span; + span.end = filter.span.end; + if (match(TemplateTokenType::LeftParen)) { + if (!check(TemplateTokenType::RightParen)) { + do { + arguments.push_back(parse_expression()); + } while (match(TemplateTokenType::Comma)); + } + const TemplateToken end = consume(TemplateTokenType::RightParen, "Expected ')' after filter arguments"); + span.end = end.span.end; + } + expression = std::make_unique(std::move(expression), filter.lexeme, std::move(arguments), span); + } + return expression; +} + +std::unique_ptr TemplateParser::parse_or() { + auto expression = parse_and(); + while (match(TemplateTokenType::OrOr)) { + const TemplateToken op = tokens_[current_ - 1]; + auto right = parse_and(); + SourceSpan span = expression->span; + span.end = right->span.end; + expression = std::make_unique(std::move(expression), op.lexeme, std::move(right), span); + } + return expression; +} + +std::unique_ptr TemplateParser::parse_and() { + auto expression = parse_equality(); + while (match(TemplateTokenType::AndAnd)) { + const TemplateToken op = tokens_[current_ - 1]; + auto right = parse_equality(); + SourceSpan span = expression->span; + span.end = right->span.end; + expression = std::make_unique(std::move(expression), op.lexeme, std::move(right), span); + } + return expression; +} + +std::unique_ptr TemplateParser::parse_equality() { + auto expression = parse_comparison(); + while (match(TemplateTokenType::EqualEqual) || match(TemplateTokenType::BangEqual)) { + const TemplateToken op = tokens_[current_ - 1]; + auto right = parse_comparison(); + SourceSpan span = expression->span; + span.end = right->span.end; + expression = std::make_unique(std::move(expression), op.lexeme, std::move(right), span); + } + return expression; +} + +std::unique_ptr TemplateParser::parse_comparison() { + auto expression = parse_unary(); + if (match(TemplateTokenType::Less) || match(TemplateTokenType::Greater) + || match(TemplateTokenType::LessEqual) || match(TemplateTokenType::GreaterEqual) + || match(TemplateTokenType::KeywordIn)) { + const TemplateToken op = tokens_[current_ - 1]; + auto right = parse_unary(); + SourceSpan span = expression->span; + span.end = right->span.end; + expression = std::make_unique(std::move(expression), op.lexeme, std::move(right), span); + if (check(TemplateTokenType::Less) || check(TemplateTokenType::Greater) + || check(TemplateTokenType::LessEqual) || check(TemplateTokenType::GreaterEqual) + || check(TemplateTokenType::KeywordIn)) { + throw DiagnosticError(make_error(peek(), "Chained comparison operators are not supported")); + } + } + return expression; +} + +std::unique_ptr TemplateParser::parse_unary() { + if (match(TemplateTokenType::Bang)) { + const TemplateToken op = tokens_[current_ - 1]; + auto operand = parse_unary(); + SourceSpan span = op.span; + span.end = operand->span.end; + return std::make_unique(op.lexeme, std::move(operand), span); + } + return parse_postfix(); +} + +std::unique_ptr TemplateParser::parse_postfix() { + auto expression = parse_primary(); + + while (true) { + if (match(TemplateTokenType::Dot)) { + const TemplateToken member = consume(TemplateTokenType::Identifier, "Expected member name after '.'"); + SourceSpan span = expression->span; + span.end = member.span.end; + expression = std::make_unique(std::move(expression), member.lexeme, span); + continue; + } + + if (match(TemplateTokenType::LeftParen)) { + if (expression->kind != ExpressionKind::Identifier) { + throw DiagnosticError(make_error(tokens_[current_ - 1], "Function call requires identifier callee")); + } + const std::string name = static_cast(*expression).name; + std::vector> arguments; + if (!check(TemplateTokenType::RightParen)) { + do { + arguments.push_back(parse_expression()); + } while (match(TemplateTokenType::Comma)); + } + const TemplateToken end = consume(TemplateTokenType::RightParen, "Expected ')' after function arguments"); + SourceSpan span = expression->span; + span.end = end.span.end; + expression = std::make_unique(name, std::move(arguments), span); + continue; + } + + if (match(TemplateTokenType::LeftBracket)) { + auto index = parse_expression(); + const TemplateToken end = consume(TemplateTokenType::RightBracket, "Expected ']' after index expression"); + SourceSpan span = expression->span; + span.end = end.span.end; + expression = std::make_unique(std::move(expression), std::move(index), span); + continue; + } + + break; + } + + return expression; +} + +std::unique_ptr TemplateParser::parse_primary() { + if (match(TemplateTokenType::KeywordLua)) { + const TemplateToken start = tokens_[current_ - 1]; + consume(TemplateTokenType::LeftParen, "Expected '(' after lua"); + const TemplateToken source = consume(TemplateTokenType::String, "Expected Lua string literal"); + const TemplateToken end = consume(TemplateTokenType::RightParen, "Expected ')' after Lua string"); + SourceSpan span = start.span; + span.end = end.span.end; + return std::make_unique(source.lexeme, span); + } + if (match(TemplateTokenType::Identifier)) { + const TemplateToken token = tokens_[current_ - 1]; + if (token.lexeme == "len" && match(TemplateTokenType::LeftParen)) { + auto operand = parse_expression(); + const TemplateToken end = consume(TemplateTokenType::RightParen, "Expected ')' after len expression"); + SourceSpan span = token.span; + span.end = end.span.end; + return std::make_unique(std::move(operand), span); + } + return std::make_unique(token.lexeme, token.span); + } + if (match(TemplateTokenType::String)) { + const TemplateToken token = tokens_[current_ - 1]; + return std::make_unique(token.lexeme, token.span); + } + if (match(TemplateTokenType::Number)) { + const TemplateToken token = tokens_[current_ - 1]; + return std::make_unique(std::stod(token.lexeme), token.lexeme, token.span); + } + if (match(TemplateTokenType::Boolean)) { + const TemplateToken token = tokens_[current_ - 1]; + return std::make_unique(token.lexeme == "true", token.span); + } + if (match(TemplateTokenType::LeftParen)) { + const TemplateToken start = tokens_[current_ - 1]; + auto expression = parse_expression(); + const TemplateToken end = consume(TemplateTokenType::RightParen, "Expected ')' after expression"); + SourceSpan span = start.span; + span.end = end.span.end; + return std::make_unique(std::move(expression), span); + } + + throw DiagnosticError(make_error(peek(), "Expected expression")); +} + +} diff --git a/src/main/cpp/template/parser/TemplateParserInternals.cpp b/src/main/cpp/template/parser/TemplateParserInternals.cpp new file mode 100644 index 0000000..bce3826 --- /dev/null +++ b/src/main/cpp/template/parser/TemplateParserInternals.cpp @@ -0,0 +1,78 @@ +#include "template/parser/TemplateParserInternals.h" + +namespace prebyte { + +namespace { + +constexpr std::string_view kBuiltinNames[] = { + "__TIME__", + "__LINE__", + "__FILE__", + "__FILENAME__", + "__DIR__", + "__EXTENSION__", + "__DATE__", + "__TIMESTAMP__", + "__YEAR__", + "__MONTH__", + "__DAY__", + "__UNIX_EPOCH__", + "__USER__", + "__HOST__", + "__OS__", + "__WORKING_DIR__", + "__UUID__", + "__RANDOM__", +}; + +constexpr std::string_view kKeywordNames[] = { + "if", + "elseif", + "else", + "endif", + "for", + "in", + "endfor", + "include", + "set", + "lua", + "fn", + "endfn", + "endlua", + "len", +}; + +bool is_builtin_name(std::string_view name) { + for (const std::string_view builtin : kBuiltinNames) { + if (builtin == name) { + return true; + } + } + return false; +} + +bool is_keyword_name(std::string_view name) { + for (const std::string_view keyword : kKeywordNames) { + if (keyword == name) { + return true; + } + } + return false; +} + +} // namespace + +bool is_reserved_name(std::string_view name) { + return name == "loop" || name == "ARGS" || is_builtin_name(name) || is_keyword_name(name); +} + +bool is_reserved_loop_binding(std::string_view name) { + return is_reserved_name(name); +} + +void apply_trim_flags(TemplateNode& node, const TemplateToken& start, const TemplateToken& end) { + node.trim_left = start.trim_left; + node.trim_right = end.trim_right; +} + +} diff --git a/src/main/cpp/template/parser/TemplateParserNodes.cpp b/src/main/cpp/template/parser/TemplateParserNodes.cpp new file mode 100644 index 0000000..d14ec2e --- /dev/null +++ b/src/main/cpp/template/parser/TemplateParserNodes.cpp @@ -0,0 +1,97 @@ +#include "template/parser/TemplateParser.h" + +#include "template/parser/TemplateParserInternals.h" + +namespace prebyte { + +std::vector TemplateParser::parse_nodes_until(const std::vector& terminators) { + std::vector nodes; + while (!is_at_end()) { + if (is_terminator_ahead(terminators)) { + break; + } + nodes.push_back(parse_node()); + } + return nodes; +} + +TemplateNodePtr TemplateParser::parse_node() { + if (check(TemplateTokenType::Text)) { + const TemplateToken token = advance(); + return std::make_unique(token.lexeme, token.span); + } + if (check(TemplateTokenType::TagOpen)) { + return parse_tag(); + } + + throw DiagnosticError(make_error(peek(), "Expected text or tag")); +} + +TemplateNodePtr TemplateParser::parse_tag() { + if (check(TemplateTokenType::TagOpen) && check(TemplateTokenType::KeywordIf, 1)) { + return parse_if_block(); + } + if (check(TemplateTokenType::TagOpen) && check(TemplateTokenType::KeywordFor, 1)) { + if (!options_.enable_loops) { + throw DiagnosticError(make_error(peek(1), "Loop directives are reserved for a later phase")); + } + return parse_for_block(); + } + if (check(TemplateTokenType::TagOpen) && check(TemplateTokenType::KeywordSet, 1)) { + return parse_set_statement(); + } + if (check(TemplateTokenType::TagOpen) && check(TemplateTokenType::KeywordFn, 1)) { + return parse_function_definition(); + } + if (check(TemplateTokenType::TagOpen) && check(TemplateTokenType::KeywordLua, 1)) { + return parse_lua_expr(); + } + if (check(TemplateTokenType::TagOpen) && check(TemplateTokenType::KeywordLuaBlock, 1)) { + return parse_lua_block(); + } + if (check(TemplateTokenType::TagOpen) && check(TemplateTokenType::Identifier, 1)) { + const std::string directive = peek(1).lexeme; + if (directive == "while") { + throw DiagnosticError(make_error(peek(1), "Loop directives are reserved for a later phase")); + } + } + if (check(TemplateTokenType::TagOpen) && check(TemplateTokenType::KeywordInclude, 1)) { + return parse_include(); + } + if (check(TemplateTokenType::TagOpen) && (check(TemplateTokenType::KeywordElseIf, 1) + || check(TemplateTokenType::KeywordElse, 1) || check(TemplateTokenType::KeywordEndIf, 1) + || check(TemplateTokenType::KeywordEndFor, 1) || check(TemplateTokenType::KeywordEndFn, 1))) { + throw DiagnosticError(make_error(peek(1), "Unexpected control-flow terminator")); + } + return parse_interpolation(); +} + +std::unique_ptr TemplateParser::parse_interpolation() { + const TemplateToken start = consume(TemplateTokenType::TagOpen, "Expected tag start"); + auto expression = parse_expression(); + const TemplateToken end = consume(TemplateTokenType::TagClose, "Expected tag end after expression"); + + SourceSpan span = start.span; + span.end = end.span.end; + auto node = std::make_unique(std::move(expression), span); + apply_trim_flags(*node, start, end); + return node; +} + +std::string TemplateParser::parse_raw_body_until(TemplateTokenType terminator) { + std::string source; + while (!is_at_end()) { + if (check(TemplateTokenType::TagOpen) && check(terminator, 1)) { + break; + } + source += advance().lexeme; + } + + if (source.empty()) { + throw DiagnosticError(make_error(peek(), "Expected raw Lua block body")); + } + + return source; +} + +} diff --git a/src/main/include/Engine.h b/src/main/include/Engine.h index 3d32eec..3aa6c11 100644 --- a/src/main/include/Engine.h +++ b/src/main/include/Engine.h @@ -9,7 +9,7 @@ #include #include "config/ConfigTypes.h" -#include "runtime/Value.h" +#include "runtime/core/Value.h" namespace prebyte { @@ -64,8 +64,8 @@ class Engine { Engine(); CompiledTemplate compile(std::string_view source, - std::filesystem::path source_path = {}, - std::filesystem::path logical_path = {}, + const std::filesystem::path& source_path = {}, + const std::filesystem::path& logical_path = {}, const CompileOptions& options = {}) const; CompiledTemplate compile_file(const std::filesystem::path& path, const CompileOptions& options = {}) const; @@ -75,7 +75,7 @@ class Engine { const RenderContext& ctx = {}, const RenderOptions& opts = {}) const; void render_to(const CompiledTemplate& tpl, - ChunkSink sink, + const ChunkSink& sink, const RenderContext& ctx = {}, const RenderOptions& opts = {}) const; diff --git a/src/main/include/config/ConfigTypes.h b/src/main/include/config/ConfigTypes.h index 601f921..cfc621a 100644 --- a/src/main/include/config/ConfigTypes.h +++ b/src/main/include/config/ConfigTypes.h @@ -8,7 +8,7 @@ #include #include -#include "runtime/Value.h" +#include "runtime/core/Value.h" namespace prebyte { @@ -60,7 +60,7 @@ struct EffectiveSettings { std::set forbidden_env_vars; bool error_on_false_input = false; std::size_t lua_instruction_limit = 100000; - std::size_t lua_memory_limit_bytes = 4 * 1024 * 1024; + std::size_t lua_memory_limit_bytes = 4ULL * 1024ULL * 1024ULL; std::size_t max_include_depth = std::numeric_limits::max(); std::size_t max_render_time_ms = std::numeric_limits::max(); std::size_t max_output_size_bytes = std::numeric_limits::max(); diff --git a/src/main/include/config/VariableDefinitionParser.h b/src/main/include/config/VariableDefinitionParser.h index b13a980..863bc1a 100644 --- a/src/main/include/config/VariableDefinitionParser.h +++ b/src/main/include/config/VariableDefinitionParser.h @@ -13,6 +13,8 @@ class VariableDefinitionParser { VariableContext parse(const std::vector& define_args, const std::map& base_variables, const std::set& base_ignore_names) const; + static void clear_import_cache(); + private: void parse_define(const std::string& define_arg, VariableContext& context) const; void import_file(const std::filesystem::path& path, VariableContext& context) const; diff --git a/src/main/include/runtime/FileMetadataCache.h b/src/main/include/runtime/cache/FileMetadataCache.h similarity index 98% rename from src/main/include/runtime/FileMetadataCache.h rename to src/main/include/runtime/cache/FileMetadataCache.h index 64daf99..2045c6e 100644 --- a/src/main/include/runtime/FileMetadataCache.h +++ b/src/main/include/runtime/cache/FileMetadataCache.h @@ -21,6 +21,7 @@ class FileMetadataCache { FileMetadata probe(const std::filesystem::path& path); void remember(const std::filesystem::path& path, FileMetadata metadata); void invalidate(const std::filesystem::path& path); + void clear(); private: struct Entry { diff --git a/src/main/include/runtime/CompiledProgramAnalysis.h b/src/main/include/runtime/compiled/CompiledProgramAnalysis.h similarity index 96% rename from src/main/include/runtime/CompiledProgramAnalysis.h rename to src/main/include/runtime/compiled/CompiledProgramAnalysis.h index 0f62f89..6f4f9e6 100644 --- a/src/main/include/runtime/CompiledProgramAnalysis.h +++ b/src/main/include/runtime/compiled/CompiledProgramAnalysis.h @@ -3,7 +3,7 @@ #include #include "config/ConfigTypes.h" -#include "runtime/CompiledTemplateProgram.h" +#include "runtime/compiled/CompiledTemplateProgram.h" namespace prebyte { diff --git a/src/main/include/runtime/CompiledTemplateCache.h b/src/main/include/runtime/compiled/CompiledTemplateCache.h similarity index 97% rename from src/main/include/runtime/CompiledTemplateCache.h rename to src/main/include/runtime/compiled/CompiledTemplateCache.h index 80f09a6..ba54149 100644 --- a/src/main/include/runtime/CompiledTemplateCache.h +++ b/src/main/include/runtime/compiled/CompiledTemplateCache.h @@ -8,7 +8,7 @@ #include #include "config/ConfigTypes.h" -#include "runtime/CompiledTemplateProgram.h" +#include "runtime/compiled/CompiledTemplateProgram.h" namespace prebyte { @@ -31,6 +31,8 @@ class CompiledTemplateCache { const CompiledProgram* store_inline(std::string_view source, CompiledProgram program, const EffectiveSettings& settings); + void clear(); + private: struct InlineCacheKey { std::string source; diff --git a/src/main/include/runtime/CompiledTemplateCompiler.h b/src/main/include/runtime/compiled/CompiledTemplateCompiler.h similarity index 92% rename from src/main/include/runtime/CompiledTemplateCompiler.h rename to src/main/include/runtime/compiled/CompiledTemplateCompiler.h index 81266d5..c385d51 100644 --- a/src/main/include/runtime/CompiledTemplateCompiler.h +++ b/src/main/include/runtime/compiled/CompiledTemplateCompiler.h @@ -4,7 +4,7 @@ #include #include "config/ConfigTypes.h" -#include "runtime/CompiledTemplateProgram.h" +#include "runtime/compiled/CompiledTemplateProgram.h" #include "template/ast/TemplateNode.h" namespace prebyte { diff --git a/src/main/include/runtime/CompiledTemplateExecutor.h b/src/main/include/runtime/compiled/CompiledTemplateExecutor.h similarity index 88% rename from src/main/include/runtime/CompiledTemplateExecutor.h rename to src/main/include/runtime/compiled/CompiledTemplateExecutor.h index 8e3fedb..8f77ea7 100644 --- a/src/main/include/runtime/CompiledTemplateExecutor.h +++ b/src/main/include/runtime/compiled/CompiledTemplateExecutor.h @@ -7,12 +7,12 @@ #include #include "config/ConfigTypes.h" -#include "runtime/CompiledTemplateProgram.h" -#include "runtime/FilterRegistry.h" -#include "runtime/IncludeResolver.h" -#include "runtime/LuaRuntime.h" -#include "runtime/RenderSession.h" -#include "runtime/ValueResolver.h" +#include "runtime/compiled/CompiledTemplateProgram.h" +#include "runtime/expression/FilterRegistry.h" +#include "runtime/resolution/IncludeResolver.h" +#include "runtime/lua/LuaRuntime.h" +#include "runtime/core/RenderSession.h" +#include "runtime/expression/ValueResolver.h" namespace prebyte { diff --git a/src/main/include/runtime/CompiledTemplateProgram.h b/src/main/include/runtime/compiled/CompiledTemplateProgram.h similarity index 100% rename from src/main/include/runtime/CompiledTemplateProgram.h rename to src/main/include/runtime/compiled/CompiledTemplateProgram.h diff --git a/src/main/include/runtime/CompiledTemplateSerializer.h b/src/main/include/runtime/compiled/CompiledTemplateSerializer.h similarity index 90% rename from src/main/include/runtime/CompiledTemplateSerializer.h rename to src/main/include/runtime/compiled/CompiledTemplateSerializer.h index fe363ca..7a8c6b1 100644 --- a/src/main/include/runtime/CompiledTemplateSerializer.h +++ b/src/main/include/runtime/compiled/CompiledTemplateSerializer.h @@ -5,8 +5,8 @@ #include #include "config/ConfigTypes.h" -#include "runtime/FileMetadataCache.h" -#include "runtime/CompiledTemplateProgram.h" +#include "runtime/cache/FileMetadataCache.h" +#include "runtime/compiled/CompiledTemplateProgram.h" namespace prebyte { diff --git a/src/main/include/runtime/CompiledTemplateWriter.h b/src/main/include/runtime/compiled/CompiledTemplateWriter.h similarity index 100% rename from src/main/include/runtime/CompiledTemplateWriter.h rename to src/main/include/runtime/compiled/CompiledTemplateWriter.h diff --git a/src/main/include/runtime/RenderSession.h b/src/main/include/runtime/core/RenderSession.h similarity index 98% rename from src/main/include/runtime/RenderSession.h rename to src/main/include/runtime/core/RenderSession.h index caf3c54..53626a0 100644 --- a/src/main/include/runtime/RenderSession.h +++ b/src/main/include/runtime/core/RenderSession.h @@ -16,8 +16,8 @@ #include #include "config/ConfigTypes.h" -#include "runtime/CompiledTemplateProgram.h" -#include "runtime/VariableStore.h" +#include "runtime/compiled/CompiledTemplateProgram.h" +#include "runtime/core/VariableStore.h" namespace prebyte { @@ -128,6 +128,7 @@ struct RenderSession { std::vector loop_frames; std::vector include_stack; std::unordered_set include_stack_set; + std::optional include_anchor_root; std::chrono::steady_clock::time_point start_time; mutable std::optional builtin_snapshot; mutable std::shared_ptr lua_runtime; @@ -149,6 +150,7 @@ struct RenderSession { loop_frames.clear(); include_stack.clear(); include_stack_set.clear(); + include_anchor_root.reset(); builtin_snapshot.reset(); function_call_depth = 0; output_bytes_emitted = 0; diff --git a/src/main/include/runtime/Value.h b/src/main/include/runtime/core/Value.h similarity index 100% rename from src/main/include/runtime/Value.h rename to src/main/include/runtime/core/Value.h diff --git a/src/main/include/runtime/VariableStore.h b/src/main/include/runtime/core/VariableStore.h similarity index 97% rename from src/main/include/runtime/VariableStore.h rename to src/main/include/runtime/core/VariableStore.h index bdb9f38..ad76b75 100644 --- a/src/main/include/runtime/VariableStore.h +++ b/src/main/include/runtime/core/VariableStore.h @@ -8,7 +8,7 @@ #include #include -#include "runtime/Value.h" +#include "runtime/core/Value.h" namespace prebyte { diff --git a/src/main/include/runtime/BuiltinRegistry.h b/src/main/include/runtime/expression/BuiltinRegistry.h similarity index 91% rename from src/main/include/runtime/BuiltinRegistry.h rename to src/main/include/runtime/expression/BuiltinRegistry.h index 8ea67cb..0c69898 100644 --- a/src/main/include/runtime/BuiltinRegistry.h +++ b/src/main/include/runtime/expression/BuiltinRegistry.h @@ -4,7 +4,7 @@ #include #include -#include "runtime/RenderSession.h" +#include "runtime/core/RenderSession.h" #include "support/SourceSpan.h" namespace prebyte { diff --git a/src/main/include/runtime/ExpressionEngine.h b/src/main/include/runtime/expression/ExpressionEngine.h similarity index 85% rename from src/main/include/runtime/ExpressionEngine.h rename to src/main/include/runtime/expression/ExpressionEngine.h index 5dc3783..d1ee419 100644 --- a/src/main/include/runtime/ExpressionEngine.h +++ b/src/main/include/runtime/expression/ExpressionEngine.h @@ -3,8 +3,8 @@ #include #include "config/ConfigTypes.h" -#include "runtime/RenderSession.h" -#include "runtime/Value.h" +#include "runtime/core/RenderSession.h" +#include "runtime/core/Value.h" #include "template/ast/Expression.h" namespace prebyte { diff --git a/src/main/include/runtime/ExpressionEvaluator.h b/src/main/include/runtime/expression/ExpressionEvaluator.h similarity index 69% rename from src/main/include/runtime/ExpressionEvaluator.h rename to src/main/include/runtime/expression/ExpressionEvaluator.h index b3a9815..19c2d77 100644 --- a/src/main/include/runtime/ExpressionEvaluator.h +++ b/src/main/include/runtime/expression/ExpressionEvaluator.h @@ -3,12 +3,12 @@ #include #include "config/ConfigTypes.h" -#include "runtime/BuiltinRegistry.h" -#include "runtime/ExpressionEngine.h" -#include "runtime/FilterRegistry.h" -#include "runtime/RenderSession.h" -#include "runtime/Value.h" -#include "runtime/ValueResolver.h" +#include "runtime/expression/BuiltinRegistry.h" +#include "runtime/expression/ExpressionEngine.h" +#include "runtime/expression/FilterRegistry.h" +#include "runtime/core/RenderSession.h" +#include "runtime/core/Value.h" +#include "runtime/expression/ValueResolver.h" #include "template/ast/Expression.h" namespace prebyte { diff --git a/src/main/include/runtime/FilterRegistry.h b/src/main/include/runtime/expression/FilterRegistry.h similarity index 87% rename from src/main/include/runtime/FilterRegistry.h rename to src/main/include/runtime/expression/FilterRegistry.h index 579eba1..b229c57 100644 --- a/src/main/include/runtime/FilterRegistry.h +++ b/src/main/include/runtime/expression/FilterRegistry.h @@ -4,7 +4,7 @@ #include #include -#include "runtime/Value.h" +#include "runtime/core/Value.h" namespace prebyte { diff --git a/src/main/include/runtime/ValueResolver.h b/src/main/include/runtime/expression/ValueResolver.h similarity index 93% rename from src/main/include/runtime/ValueResolver.h rename to src/main/include/runtime/expression/ValueResolver.h index 18b469e..49867ea 100644 --- a/src/main/include/runtime/ValueResolver.h +++ b/src/main/include/runtime/expression/ValueResolver.h @@ -4,9 +4,9 @@ #include #include "config/ConfigTypes.h" -#include "runtime/BuiltinRegistry.h" -#include "runtime/RenderSession.h" -#include "runtime/Value.h" +#include "runtime/expression/BuiltinRegistry.h" +#include "runtime/core/RenderSession.h" +#include "runtime/core/Value.h" #include "support/SourceSpan.h" namespace prebyte { diff --git a/src/main/include/runtime/LuaChunkCache.h b/src/main/include/runtime/lua/LuaChunkCache.h similarity index 96% rename from src/main/include/runtime/LuaChunkCache.h rename to src/main/include/runtime/lua/LuaChunkCache.h index 2f10baa..8bef0b1 100644 --- a/src/main/include/runtime/LuaChunkCache.h +++ b/src/main/include/runtime/lua/LuaChunkCache.h @@ -23,6 +23,7 @@ class LuaChunkCache { public: std::optional find(const LuaChunkKey& key) const; void store(const LuaChunkKey& key, int registry_reference); + void clear(); private: std::map cache_; diff --git a/src/main/include/runtime/LuaExpressionEngine.h b/src/main/include/runtime/lua/LuaExpressionEngine.h similarity index 77% rename from src/main/include/runtime/LuaExpressionEngine.h rename to src/main/include/runtime/lua/LuaExpressionEngine.h index 9e8739d..5ac0c21 100644 --- a/src/main/include/runtime/LuaExpressionEngine.h +++ b/src/main/include/runtime/lua/LuaExpressionEngine.h @@ -1,7 +1,7 @@ #pragma once -#include "runtime/ExpressionEngine.h" -#include "runtime/LuaRuntime.h" +#include "runtime/expression/ExpressionEngine.h" +#include "runtime/lua/LuaRuntime.h" namespace prebyte { diff --git a/src/main/include/runtime/LuaHeaders.h b/src/main/include/runtime/lua/LuaHeaders.h similarity index 100% rename from src/main/include/runtime/LuaHeaders.h rename to src/main/include/runtime/lua/LuaHeaders.h diff --git a/src/main/include/runtime/LuaHelperRegistry.h b/src/main/include/runtime/lua/LuaHelperRegistry.h similarity index 100% rename from src/main/include/runtime/LuaHelperRegistry.h rename to src/main/include/runtime/lua/LuaHelperRegistry.h diff --git a/src/main/include/runtime/LuaRuntime.h b/src/main/include/runtime/lua/LuaRuntime.h similarity index 83% rename from src/main/include/runtime/LuaRuntime.h rename to src/main/include/runtime/lua/LuaRuntime.h index 0396f22..53f1ba4 100644 --- a/src/main/include/runtime/LuaRuntime.h +++ b/src/main/include/runtime/lua/LuaRuntime.h @@ -6,13 +6,13 @@ #include #include "config/ConfigTypes.h" -#include "runtime/LuaHeaders.h" -#include "runtime/LuaChunkCache.h" -#include "runtime/LuaHelperRegistry.h" -#include "runtime/LuaSandbox.h" -#include "runtime/LuaValueBridge.h" -#include "runtime/RenderSession.h" -#include "runtime/Value.h" +#include "runtime/lua/LuaHeaders.h" +#include "runtime/lua/LuaChunkCache.h" +#include "runtime/lua/LuaHelperRegistry.h" +#include "runtime/lua/LuaSandbox.h" +#include "runtime/lua/LuaValueBridge.h" +#include "runtime/core/RenderSession.h" +#include "runtime/core/Value.h" #include "support/SourceSpan.h" namespace prebyte { @@ -27,7 +27,7 @@ class LuaRuntime { const SourceSpan& span) const; private: - static constexpr std::size_t kDefaultMemoryLimitBytes = 4 * 1024 * 1024; + static constexpr std::size_t kDefaultMemoryLimitBytes = 4ULL * 1024ULL * 1024ULL; static constexpr std::size_t kDefaultInstructionLimit = 100000; static constexpr std::size_t kTimeCheckInstructionStep = 1000; diff --git a/src/main/include/runtime/LuaSandbox.h b/src/main/include/runtime/lua/LuaSandbox.h similarity index 85% rename from src/main/include/runtime/LuaSandbox.h rename to src/main/include/runtime/lua/LuaSandbox.h index 24c7902..b91799b 100644 --- a/src/main/include/runtime/LuaSandbox.h +++ b/src/main/include/runtime/lua/LuaSandbox.h @@ -1,6 +1,6 @@ #pragma once -#include "runtime/LuaHelperRegistry.h" +#include "runtime/lua/LuaHelperRegistry.h" struct lua_State; diff --git a/src/main/include/runtime/LuaValueBridge.h b/src/main/include/runtime/lua/LuaValueBridge.h similarity index 84% rename from src/main/include/runtime/LuaValueBridge.h rename to src/main/include/runtime/lua/LuaValueBridge.h index c6480a5..a6da952 100644 --- a/src/main/include/runtime/LuaValueBridge.h +++ b/src/main/include/runtime/lua/LuaValueBridge.h @@ -3,8 +3,8 @@ #include #include "config/ConfigTypes.h" -#include "runtime/RenderSession.h" -#include "runtime/Value.h" +#include "runtime/core/RenderSession.h" +#include "runtime/core/Value.h" struct lua_State; diff --git a/src/main/include/runtime/EngineRuntime.h b/src/main/include/runtime/render/EngineRuntime.h similarity index 68% rename from src/main/include/runtime/EngineRuntime.h rename to src/main/include/runtime/render/EngineRuntime.h index e72aab1..4901d89 100644 --- a/src/main/include/runtime/EngineRuntime.h +++ b/src/main/include/runtime/render/EngineRuntime.h @@ -1,10 +1,10 @@ #pragma once #include "config/RuleResolver.h" -#include "runtime/BuiltinRegistry.h" -#include "runtime/ExpressionEvaluator.h" -#include "runtime/IncludeResolver.h" -#include "runtime/Renderer.h" +#include "runtime/expression/BuiltinRegistry.h" +#include "runtime/expression/ExpressionEvaluator.h" +#include "runtime/resolution/IncludeResolver.h" +#include "runtime/render/Renderer.h" namespace prebyte { diff --git a/src/main/include/runtime/Renderer.h b/src/main/include/runtime/render/Renderer.h similarity index 84% rename from src/main/include/runtime/Renderer.h rename to src/main/include/runtime/render/Renderer.h index 0f3385e..7dda8db 100644 --- a/src/main/include/runtime/Renderer.h +++ b/src/main/include/runtime/render/Renderer.h @@ -7,12 +7,12 @@ #include "config/ConfigTypes.h" #include "config/RuleResolver.h" -#include "runtime/BuiltinRegistry.h" -#include "runtime/CompiledTemplateExecutor.h" -#include "runtime/ExpressionEngine.h" -#include "runtime/ExpressionEvaluator.h" -#include "runtime/IncludeResolver.h" -#include "runtime/RenderSession.h" +#include "runtime/expression/BuiltinRegistry.h" +#include "runtime/compiled/CompiledTemplateExecutor.h" +#include "runtime/expression/ExpressionEngine.h" +#include "runtime/expression/ExpressionEvaluator.h" +#include "runtime/resolution/IncludeResolver.h" +#include "runtime/core/RenderSession.h" namespace prebyte { diff --git a/src/main/include/runtime/IncludeResolver.h b/src/main/include/runtime/resolution/IncludeResolver.h similarity index 93% rename from src/main/include/runtime/IncludeResolver.h rename to src/main/include/runtime/resolution/IncludeResolver.h index c50d6b4..37c3342 100644 --- a/src/main/include/runtime/IncludeResolver.h +++ b/src/main/include/runtime/resolution/IncludeResolver.h @@ -9,8 +9,8 @@ #include "config/ConfigTypes.h" #include "io/InputBuffer.h" -#include "runtime/CompiledTemplateProgram.h" -#include "runtime/RenderSession.h" +#include "runtime/compiled/CompiledTemplateProgram.h" +#include "runtime/core/RenderSession.h" namespace prebyte { diff --git a/src/main/include/support/FileUtil.h b/src/main/include/support/FileUtil.h new file mode 100644 index 0000000..ba3f10b --- /dev/null +++ b/src/main/include/support/FileUtil.h @@ -0,0 +1,12 @@ +#pragma once + +#include +#include +#include + +namespace prebyte::file_util { + +std::string read_text_file(const std::filesystem::path& path); +bool write_text_file(const std::filesystem::path& path, std::string_view content); + +} diff --git a/src/main/include/template/lexer/TemplateLexer.h b/src/main/include/template/lexer/TemplateLexer.h index 8a03fb5..e90477f 100644 --- a/src/main/include/template/lexer/TemplateLexer.h +++ b/src/main/include/template/lexer/TemplateLexer.h @@ -8,6 +8,9 @@ namespace prebyte { +// Scans template source into tokens. Operates in two modes: +// 1. Text mode – copies literal output until an opening tag delimiter is found. +// 2. Tag mode – tokenizes tag contents (keywords, literals, punctuation) until closing delimiter. class TemplateLexer { public: TemplateLexer(std::string_view source, std::string file_path, std::string_view tag_prefix = "{{", @@ -16,6 +19,7 @@ class TemplateLexer { std::vector lex(); private: + // Cursor / location helpers char peek(std::size_t offset = 0) const; bool is_at_end() const; bool match_literal(std::string_view literal) const; @@ -23,11 +27,17 @@ class TemplateLexer { void advance_literal(std::string_view literal); SourceLocation current_location() const; SourceSpan make_span(SourceLocation start) const; + + // Token emission void add_token(TemplateTokenType type, std::string lexeme, SourceLocation start); void add_token(TemplateTokenType type, std::string lexeme, SourceLocation start, bool trim_left, bool trim_right); + + // Mode-specific scanners (implemented in separate translation units) void lex_text(); void lex_inside_tag(); void skip_tag_whitespace(); + bool try_lex_tag_close(); + bool try_lex_tag_punctuation(); void lex_identifier_or_keyword(); void lex_string(); void lex_number(); diff --git a/src/main/include/template/lexer/TemplateLexerInternals.h b/src/main/include/template/lexer/TemplateLexerInternals.h new file mode 100644 index 0000000..19fa184 --- /dev/null +++ b/src/main/include/template/lexer/TemplateLexerInternals.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +#include "support/SourceSpan.h" + +namespace prebyte { + +class Diagnostic; + +Diagnostic make_lexer_error(const std::string& message, const std::string& file_path, SourceLocation location); + +void trim_right_ascii_whitespace(std::string& text); +void trim_left_ascii_whitespace(std::string& text); + +} diff --git a/src/main/include/template/lexer/TemplateLexerKeywords.h b/src/main/include/template/lexer/TemplateLexerKeywords.h new file mode 100644 index 0000000..2a7ee09 --- /dev/null +++ b/src/main/include/template/lexer/TemplateLexerKeywords.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +#include "template/lexer/TemplateToken.h" + +namespace prebyte::template_lexer { + +// Maps a scanned identifier lexeme to a keyword/boolean token type, if any. +std::optional lookup_keyword(std::string_view lexeme); + +} diff --git a/src/main/include/template/parser/TemplateParser.h b/src/main/include/template/parser/TemplateParser.h index d2552ca..e63bd64 100644 --- a/src/main/include/template/parser/TemplateParser.h +++ b/src/main/include/template/parser/TemplateParser.h @@ -10,6 +10,13 @@ namespace prebyte { +// Recursive-descent parser for template tokens produced by TemplateLexer. +// +// Top-level flow: +// parse_document() -> parse_nodes_until() -> parse_node() / parse_tag() +// +// Tags dispatch to directive parsers (if/for/set/fn/...) or fall back to interpolation. +// Expressions use a Pratt-style precedence chain (pipe -> or -> and -> ... -> primary). class TemplateParser { public: explicit TemplateParser(std::vector tokens, TemplateParserOptions options = {}); @@ -17,6 +24,7 @@ class TemplateParser { std::unique_ptr parse_document(); private: + // Token stream cursor const TemplateToken& peek(std::size_t offset = 0) const; bool is_at_end() const; bool check(TemplateTokenType type, std::size_t offset = 0) const; @@ -24,9 +32,16 @@ class TemplateParser { const TemplateToken& advance(); const TemplateToken& consume(TemplateTokenType type, const std::string& message); bool is_terminator_ahead(const std::vector& terminators) const; + Diagnostic make_error(const TemplateToken& token, const std::string& message) const; + + // Document / node structure std::vector parse_nodes_until(const std::vector& terminators); TemplateNodePtr parse_node(); TemplateNodePtr parse_tag(); + std::unique_ptr parse_interpolation(); + std::string parse_raw_body_until(TemplateTokenType terminator); + + // Directives std::unique_ptr parse_include(); std::unique_ptr parse_if_block(); std::unique_ptr parse_for_block(); @@ -35,7 +50,10 @@ class TemplateParser { std::unique_ptr parse_if_condition(const std::string& branch_name); std::unique_ptr parse_lua_expr(); std::unique_ptr parse_lua_block(); - std::unique_ptr parse_interpolation(); + + // Expression precedence chain (lowest to highest binding) + void push_expression_depth(); + void pop_expression_depth(); std::unique_ptr parse_expression(); std::unique_ptr parse_pipe(); std::unique_ptr parse_or(); @@ -45,11 +63,10 @@ class TemplateParser { std::unique_ptr parse_unary(); std::unique_ptr parse_postfix(); std::unique_ptr parse_primary(); - std::string parse_raw_body_until(TemplateTokenType terminator); - Diagnostic make_error(const TemplateToken& token, const std::string& message) const; std::vector tokens_; std::size_t current_ = 0; + std::size_t expression_depth_ = 0; TemplateParserOptions options_; }; diff --git a/src/main/include/template/parser/TemplateParserInternals.h b/src/main/include/template/parser/TemplateParserInternals.h new file mode 100644 index 0000000..27816b4 --- /dev/null +++ b/src/main/include/template/parser/TemplateParserInternals.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +#include "template/ast/TemplateNode.h" +#include "template/lexer/TemplateToken.h" + +namespace prebyte { + +constexpr std::size_t kMaxParserExpressionDepth = 64; + +bool is_reserved_name(std::string_view name); +bool is_reserved_loop_binding(std::string_view name); + +void apply_trim_flags(TemplateNode& node, const TemplateToken& start, const TemplateToken& end); + +} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..0d6bc00 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,34 @@ +# Tests + +Tests are grouped by non-functional requirement (NFR). Shared assets live in `fixtures/`, `harness/`, and `support/`. + +| NFR | Directory | What runs here | +| --- | --- | --- | +| Correctness | `correctness/` | Unit, integration, and property tests (`prebyte_tests`) | +| Fault tolerance | `fault_tolerance/` | Fuzzers, fuzz regressions, sanitizer guidance | +| Security | `security/` | Sandbox and hardening end-to-end tests | +| Concurrency | `concurrency/` | Parallel render and cache stability tests | +| Portability | `portability/` | CLI subprocess tests and packaging smoke tests | +| Performance | `performance/` | Benchmark driver and timing history | + +## Running + +```bash +cmake --build --preset coverage --target prebyte_tests prebyte +ctest --preset coverage +make coverage # correctness + coverage gate +make test # fast dev test run +make # full local validation (same as make all) +make all # full local validation +make start # build CLI only +make sanitize # ASan/UBSan over the full test binary +make tsan # ThreadSanitizer +make msan # MemorySanitizer +make fuzz # libFuzzer targets under fault_tolerance/fuzz/ +make benchmark # performance/BenchmarkMain.cpp +make packaging-smoke +``` + +Packaging smoke (binary tarball, ReqPack, optional Docker): `make packaging-smoke`, `make packaging-smoke-docker`. + +Fixtures for templates, settings, and batch data: `tests/fixtures/`. diff --git a/tests/concurrency/ConcurrencyE2ETests.cpp b/tests/concurrency/ConcurrencyE2ETests.cpp new file mode 100644 index 0000000..077f6e7 --- /dev/null +++ b/tests/concurrency/ConcurrencyE2ETests.cpp @@ -0,0 +1,265 @@ +#include "TestHarness.h" + +#include "PrebyteEngine.h" +#include "app/AppRunner.h" +#include "app/Command.h" +#include "runtime/compiled/CompiledTemplateCompiler.h" +#include "runtime/compiled/CompiledTemplateSerializer.h" +#include "runtime/cache/FileMetadataCache.h" +#include "support/Diagnostic.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr int kThreadCount = 8; +constexpr int kRendersPerThread = 100; + +void write_file(const std::filesystem::path& path, const std::string& content) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream file(path, std::ios::binary); + file << content; +} + +std::filesystem::path concurrency_test_root(const std::string& name) { + const std::filesystem::path root = + std::filesystem::temp_directory_path() / "prebyte-concurrency-e2e" / name; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + return root; +} + +void reset_template_caches() { + prebyte::FileMetadataCache::instance().clear(); +} + +std::string render_fixture_with_app_runner(const std::filesystem::path& input_path, + const std::vector& define_args = {}) { + prebyte::Command command; + command.mode = prebyte::CommandMode::Render; + command.input_path = input_path; + command.define_args = define_args; + + prebyte::AppRunner runner; + return runner.execute(command); +} + +void run_parallel_app_runner_jobs(const std::function& job) { + std::vector threads; + threads.reserve(kThreadCount); + std::atomic failures{0}; + + for (int thread_index = 0; thread_index < kThreadCount; ++thread_index) { + threads.emplace_back([&job, &failures]() { + for (int render_index = 0; render_index < kRendersPerThread; ++render_index) { + try { + static_cast(job()); + } catch (const std::exception&) { + failures.fetch_add(1, std::memory_order_relaxed); + } + } + }); + } + + for (std::thread& thread : threads) { + thread.join(); + } + + REQUIRE_EQ(failures.load(), 0); +} + +void run_parallel_prebyte_jobs(prebyte::Prebyte& engine, const std::function& job) { + std::vector threads; + threads.reserve(kThreadCount); + std::atomic failures{0}; + + for (int thread_index = 0; thread_index < kThreadCount; ++thread_index) { + threads.emplace_back([&engine, &job, &failures]() { + for (int render_index = 0; render_index < kRendersPerThread; ++render_index) { + try { + static_cast(job()); + } catch (const std::exception&) { + failures.fetch_add(1, std::memory_order_relaxed); + } + } + }); + } + + for (std::thread& thread : threads) { + thread.join(); + } + + REQUIRE_EQ(failures.load(), 0); +} + +} + +TEST_CASE(ConcurrencyE2E_app_runner_parallel_fixture_render_with_includes_is_stable) { + reset_template_caches(); + + const std::filesystem::path input_path = "tests/fixtures/render_include_if/input.txt"; + const std::string expected = render_fixture_with_app_runner(input_path, {"name=Ada", "enabled=true"}); + + run_parallel_app_runner_jobs([&]() { + const std::string output = render_fixture_with_app_runner(input_path, {"name=Ada", "enabled=true"}); + if (output != expected) { + throw std::runtime_error("unexpected render output"); + } + return output; + }); +} + +TEST_CASE(ConcurrencyE2E_app_runner_parallel_file_render_with_nested_includes_is_stable) { + reset_template_caches(); + + const std::filesystem::path root = concurrency_test_root("nested-includes"); + write_file(root / "header.txt", "Header {{ name }}\n"); + write_file(root / "body.pbt", "{{ include \"header.txt\" }}\n{{ for item in items }}<{{ item }}>{{ endfor }}\n"); + write_file(root / "main.pbt", "{{ include \"body.pbt\" }}Footer\n"); + write_file(root / "items.yaml", "- Ada\n- Grace\n"); + + const std::string expected = render_fixture_with_app_runner( + root / "main.pbt", + {"name=Ada", "items=@" + (root / "items.yaml").string()}); + REQUIRE(expected.find("Header Ada") != std::string::npos); + REQUIRE(expected.find("") != std::string::npos); + REQUIRE(expected.find("Footer") != std::string::npos); + + run_parallel_app_runner_jobs([&]() { + const std::string output = render_fixture_with_app_runner( + root / "main.pbt", + {"name=Ada", "items=@" + (root / "items.yaml").string()}); + if (output != expected) { + throw std::runtime_error("unexpected render output"); + } + return output; + }); +} + +TEST_CASE(ConcurrencyE2E_app_runner_parallel_render_shares_adjacent_pbc_cache) { + reset_template_caches(); + + const std::filesystem::path root = concurrency_test_root("adjacent-pbc"); + const std::filesystem::path source_path = root / "main.pbt"; + const std::filesystem::path logical_path = root / "main"; + write_file(source_path, "Hello {{ name }} from {{ config.server.host }}\n"); + write_file(root / "config.toml", "[server]\nhost=\"localhost\"\n"); + + prebyte::CompiledTemplateCompiler compiler; + prebyte::EffectiveSettings settings; + const prebyte::CompiledProgram program = + compiler.compile_source("Hello {{ name }} from {{ config.server.host }}\n", source_path, logical_path, settings); + prebyte::CompiledTemplateSerializer serializer; + write_file(serializer.compiled_path_for_source(source_path), serializer.serialize(program)); + + const std::string expected = + render_fixture_with_app_runner(source_path, {"name=Ada", "config=@" + (root / "config.toml").string()}); + + run_parallel_app_runner_jobs([&]() { + const std::string output = + render_fixture_with_app_runner(source_path, {"name=Ada", "config=@" + (root / "config.toml").string()}); + if (output != expected) { + throw std::runtime_error("unexpected render output"); + } + return output; + }); +} + +TEST_CASE(ConcurrencyE2E_app_runner_parallel_structured_import_render_is_stable) { + reset_template_caches(); + + const std::filesystem::path root = concurrency_test_root("structured-imports"); + write_file(root / "user.json", R"({"name":"Ada"})"); + write_file(root / "items.yaml", "- Ada\n- Grace\n"); + write_file(root / "main.pbt", "{{ user.name }}|{{ items[1] }}\n"); + + const std::string expected = render_fixture_with_app_runner( + root / "main.pbt", + {"user=@" + (root / "user.json").string(), "items=@" + (root / "items.yaml").string()}); + REQUIRE_EQ(expected, std::string("Ada|Grace\n")); + + run_parallel_app_runner_jobs([&]() { + const std::string output = render_fixture_with_app_runner( + root / "main.pbt", + {"user=@" + (root / "user.json").string(), "items=@" + (root / "items.yaml").string()}); + if (output != expected) { + throw std::runtime_error("unexpected render output"); + } + return output; + }); +} + +TEST_CASE(ConcurrencyE2E_prebyte_engine_parallel_process_file_with_includes_is_stable) { + reset_template_caches(); + + const std::filesystem::path root = concurrency_test_root("prebyte-file"); + write_file(root / "header.txt", "Header {{ name }}\n"); + write_file(root / "main.pbt", "{{ include \"header.txt\" }}\n{{ if enabled }}Enabled{{ else }}Disabled{{ endif }}\nFooter\n"); + + prebyte::Prebyte engine; + engine.set_variable("name", "Ada"); + engine.set_variable("enabled", "true"); + + const std::string expected = engine.process_file((root / "main.pbt").string()); + REQUIRE_EQ(expected, std::string("Header Ada\n\nEnabled\nFooter\n")); + + run_parallel_prebyte_jobs(engine, [&]() { + const std::string output = engine.process_file((root / "main.pbt").string()); + if (output != expected) { + throw std::runtime_error("unexpected render output"); + } + return output; + }); +} + +TEST_CASE(ConcurrencyE2E_mixed_app_runner_and_prebyte_parallel_renders_are_stable) { + reset_template_caches(); + + const std::filesystem::path root = concurrency_test_root("mixed"); + write_file(root / "main.pbt", "Mixed {{ name }} {{ lua \"return upper(name)\" }}\n"); + + prebyte::Prebyte engine; + engine.set_variable("name", "Ada"); + const std::string expected = engine.process_file((root / "main.pbt").string()); + REQUIRE_EQ(expected, std::string("Mixed Ada ADA\n")); + + std::vector threads; + threads.reserve(kThreadCount); + std::atomic failures{0}; + + for (int thread_index = 0; thread_index < kThreadCount; ++thread_index) { + threads.emplace_back([&, thread_index]() { + for (int render_index = 0; render_index < kRendersPerThread; ++render_index) { + try { + std::string output; + if (thread_index % 2 == 0) { + prebyte::Command command; + command.mode = prebyte::CommandMode::Render; + command.input_path = root / "main.pbt"; + command.define_args = {"name=Ada"}; + prebyte::AppRunner runner; + output = runner.execute(command); + } else { + output = engine.process_file((root / "main.pbt").string()); + } + if (output != expected) { + failures.fetch_add(1, std::memory_order_relaxed); + } + } catch (const std::exception&) { + failures.fetch_add(1, std::memory_order_relaxed); + } + } + }); + } + + for (std::thread& thread : threads) { + thread.join(); + } + + REQUIRE_EQ(failures.load(), 0); +} diff --git a/tests/concurrency/README.md b/tests/concurrency/README.md new file mode 100644 index 0000000..d5c3d5b --- /dev/null +++ b/tests/concurrency/README.md @@ -0,0 +1,7 @@ +# Concurrency + +End-to-end tests for parallel rendering, shared caches, includes, structured imports, and mixed AppRunner/PrebyteEngine usage. + +Unit-level thread-safety checks remain under `../correctness/unit/` (`Engine_concurrent_*`, `PrebyteEngine_concurrent_*`). + +CI gate: `make tsan`. diff --git a/tests/correctness/README.md b/tests/correctness/README.md new file mode 100644 index 0000000..18e7c09 --- /dev/null +++ b/tests/correctness/README.md @@ -0,0 +1,9 @@ +# Correctness + +Verifies expected behaviour: parsing, rendering, settings, imports, roundtrips, and CLI-equivalent in-process flows. + +- `unit/` — focused module tests (lexer, parser, runtime, config, I/O). +- `integration/` — AppRunner, settings behaviour, structured imports, compiled-template roundtrips. +- `property/` — seeded property tests (compile → serialize → render invariants). + +All sources here are linked into `prebyte_tests` and discovered by CTest. diff --git a/tests/integration/AppRunnerFeatureCoverageTests.cpp b/tests/correctness/integration/AppRunnerFeatureCoverageTests.cpp similarity index 96% rename from tests/integration/AppRunnerFeatureCoverageTests.cpp rename to tests/correctness/integration/AppRunnerFeatureCoverageTests.cpp index b424f6c..aba7a07 100644 --- a/tests/integration/AppRunnerFeatureCoverageTests.cpp +++ b/tests/correctness/integration/AppRunnerFeatureCoverageTests.cpp @@ -2,8 +2,8 @@ #include "app/AppRunner.h" #include "app/Command.h" -#include "runtime/CompiledTemplateCompiler.h" -#include "runtime/CompiledTemplateSerializer.h" +#include "runtime/compiled/CompiledTemplateCompiler.h" +#include "runtime/compiled/CompiledTemplateSerializer.h" #include "support/Diagnostic.h" #include diff --git a/tests/integration/AppRunnerIntegrationTests.cpp b/tests/correctness/integration/AppRunnerIntegrationTests.cpp similarity index 99% rename from tests/integration/AppRunnerIntegrationTests.cpp rename to tests/correctness/integration/AppRunnerIntegrationTests.cpp index 85f3200..d64453e 100644 --- a/tests/integration/AppRunnerIntegrationTests.cpp +++ b/tests/correctness/integration/AppRunnerIntegrationTests.cpp @@ -9,8 +9,8 @@ #include "app/AppRunner.h" #include "app/Command.h" #include "io/InputBuffer.h" -#include "runtime/CompiledTemplateCompiler.h" -#include "runtime/CompiledTemplateSerializer.h" +#include "runtime/compiled/CompiledTemplateCompiler.h" +#include "runtime/compiled/CompiledTemplateSerializer.h" #include "support/Diagnostic.h" namespace { diff --git a/tests/correctness/integration/CompiledTemplateRoundtripTests.cpp b/tests/correctness/integration/CompiledTemplateRoundtripTests.cpp new file mode 100644 index 0000000..a13a601 --- /dev/null +++ b/tests/correctness/integration/CompiledTemplateRoundtripTests.cpp @@ -0,0 +1,256 @@ +#include "TestHarness.h" + +#include "app/AppRunner.h" +#include "app/Command.h" +#include "config/RuleResolver.h" +#include "runtime/expression/BuiltinRegistry.h" +#include "runtime/compiled/CompiledTemplateCache.h" +#include "runtime/compiled/CompiledTemplateCompiler.h" +#include "runtime/compiled/CompiledTemplateSerializer.h" +#include "runtime/expression/ExpressionEvaluator.h" +#include "runtime/cache/FileMetadataCache.h" +#include "runtime/resolution/IncludeResolver.h" +#include "runtime/render/Renderer.h" +#include "support/Diagnostic.h" + +#include +#include + +namespace { + +struct RoundtripHarness { + prebyte::RuleResolver rule_resolver; + prebyte::IncludeResolver include_resolver; + prebyte::BuiltinRegistry builtins; + prebyte::ExpressionEvaluator evaluator{builtins}; + prebyte::Renderer renderer{rule_resolver, include_resolver, evaluator}; + prebyte::CompiledTemplateCompiler compiler; + prebyte::CompiledTemplateSerializer serializer; +}; + +void write_file(const std::filesystem::path& path, const std::string& content) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream file(path, std::ios::binary); + file << content; +} + +std::string read_file(const std::filesystem::path& path) { + std::ifstream file(path, std::ios::binary); + return std::string(std::istreambuf_iterator(file), std::istreambuf_iterator()); +} + +std::filesystem::path roundtrip_test_root(const std::string& name) { + const std::filesystem::path root = + std::filesystem::temp_directory_path() / "prebyte-compiled-roundtrip-tests" / name; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + return root; +} + +prebyte::RenderSession make_session(const std::string& name = "Ada", const std::string& enabled = "true") { + prebyte::RenderSession session; + session.variables.set("name", name); + session.variables.set("enabled", enabled); + prebyte::Data::Array items; + items.push_back(prebyte::Data("Ada")); + items.push_back(prebyte::Data("Grace")); + session.variables.set_value("items", prebyte::Value::list(std::move(items))); + return session; +} + +std::string render_direct(RoundtripHarness& harness, const std::string& source, + const std::filesystem::path& current_file, prebyte::RenderSession session, + const prebyte::EffectiveSettings& settings) { + return harness.renderer.render_source(source, settings, current_file, session); +} + +std::string render_serialized_program(RoundtripHarness& harness, const prebyte::CompiledProgram& program, + prebyte::RenderSession session, const prebyte::EffectiveSettings& settings) { + return harness.renderer.render_program(program, settings, program.logical_path, session); +} + +prebyte::CompiledProgram serialize_roundtrip(RoundtripHarness& harness, const prebyte::CompiledProgram& program) { + return harness.serializer.deserialize(harness.serializer.serialize(program)); +} + +void require_roundtrip_matches_direct(RoundtripHarness& harness, const std::string& source, + const std::filesystem::path& current_file, prebyte::RenderSession session, + const prebyte::EffectiveSettings& settings) { + const std::string direct_output = render_direct(harness, source, current_file, session, settings); + + const prebyte::CompiledProgram compiled = + harness.compiler.compile_source(source, current_file, current_file, settings); + const prebyte::CompiledProgram once = serialize_roundtrip(harness, compiled); + const prebyte::CompiledProgram twice = serialize_roundtrip(harness, once); + + const std::string once_output = render_serialized_program(harness, once, make_session(), settings); + const std::string twice_output = render_serialized_program(harness, twice, make_session(), settings); + + REQUIRE_EQ(once_output, direct_output); + REQUIRE_EQ(twice_output, direct_output); +} + +void reset_template_caches(const std::filesystem::path& source_path, const prebyte::EffectiveSettings& settings) { + prebyte::FileMetadataCache::instance().clear(); + prebyte::CompiledTemplateSerializer serializer; + prebyte::CompiledTemplateCache::instance().erase(serializer.compiled_path_for_source(source_path), settings); +} + +std::string app_runner_render_file(const std::filesystem::path& input_path, + const std::vector& define_args = {"name=Ada", "enabled=true"}) { + prebyte::Command command; + command.mode = prebyte::CommandMode::Render; + command.input_path = input_path; + command.define_args = define_args; + + prebyte::AppRunner runner; + return runner.execute(command); +} + +void write_compiled_artifact(const std::filesystem::path& source_path, const std::string& source, + const prebyte::EffectiveSettings& settings) { + prebyte::CompiledTemplateCompiler compiler; + prebyte::CompiledTemplateSerializer serializer; + const prebyte::CompiledProgram program = + compiler.compile_source(source, source_path, source_path, settings); + write_file(serializer.compiled_path_for_source(source_path), serializer.serialize(program)); +} + +} + +TEST_CASE(CompiledTemplateRoundtrip_simple_template_matches_direct_render) { + RoundtripHarness harness; + prebyte::EffectiveSettings settings; + const std::filesystem::path current_file = "roundtrip-simple.pbt"; + const std::string source = "Hello {{ name }}"; + + require_roundtrip_matches_direct(harness, source, current_file, make_session(), settings); +} + +TEST_CASE(CompiledTemplateRoundtrip_control_flow_and_loops_match_direct_render) { + RoundtripHarness harness; + prebyte::EffectiveSettings settings; + settings.include_paths.push_back(roundtrip_test_root("loop-include")); + const std::filesystem::path root = settings.include_paths.back(); + const std::filesystem::path current_file = root / "main.pbt"; + write_file(root / "partial.pbt", "<{{ loop.index }}:{{ item }}>"); + + const std::string source = + "{{ if enabled }}Y{{ for item in items }}{{ include \"partial.pbt\" }}{{ endfor }}{{ else }}N{{ endif }}"; + + require_roundtrip_matches_direct(harness, source, current_file, make_session(), settings); +} + +TEST_CASE(CompiledTemplateRoundtrip_lua_and_functions_match_direct_render) { + RoundtripHarness harness; + prebyte::EffectiveSettings settings; + const std::filesystem::path current_file = "roundtrip-lua.pbt"; + const std::string source = + "{{ fn greet(name) }}Hello {{ name }}{{ endfn }}" + "{{ if lua(\"return starts_with(name, 'Ada')\") }}{{ greet(name) }}{{ else }}bad{{ endif }}"; + + require_roundtrip_matches_direct(harness, source, current_file, make_session(), settings); +} + +TEST_CASE(CompiledTemplateRoundtrip_try_load_valid_reads_adjacent_pbc) { + const std::filesystem::path root = roundtrip_test_root("try-load-valid"); + const std::filesystem::path source_path = root / "sample.pbt"; + const std::string source = "Hello {{ name }}"; + write_file(source_path, source); + + prebyte::EffectiveSettings settings; + write_compiled_artifact(source_path, source, settings); + + reset_template_caches(source_path, settings); + prebyte::CompiledTemplateSerializer serializer; + const prebyte::CompiledProgram* loaded = + serializer.try_load_valid(serializer.compiled_path_for_source(source_path), settings); + REQUIRE(loaded != nullptr); + + RoundtripHarness harness; + const std::string cached_output = + render_serialized_program(harness, *loaded, make_session(), settings); + const std::string direct_output = + render_direct(harness, source, source_path, make_session(), settings); + + REQUIRE_EQ(cached_output, std::string("Hello Ada")); + REQUIRE_EQ(cached_output, direct_output); +} + +TEST_CASE(CompiledTemplateRoundtrip_app_runner_pbc_matches_source_render) { + const std::filesystem::path root = roundtrip_test_root("app-runner-parity"); + const std::filesystem::path source_path = root / "sample.pbt"; + const std::filesystem::path compiled_path = root / "sample.pbc"; + const std::string source = + "{{ include \"header.txt\" }}{{ if enabled }}Enabled{{ else }}Disabled{{ endif }}\nFooter\n"; + write_file(root / "header.txt", "Header {{ name }}\n"); + write_file(source_path, source); + + prebyte::EffectiveSettings settings; + settings.allow_includes = true; + settings.include_paths.push_back(root); + write_compiled_artifact(source_path, source, settings); + REQUIRE(std::filesystem::exists(compiled_path)); + + const std::string from_source = app_runner_render_file(source_path); + const std::string from_compiled = app_runner_render_file(compiled_path); + + REQUIRE_EQ(from_source, std::string("Header Ada\nEnabled\nFooter\n")); + REQUIRE_EQ(from_compiled, from_source); +} + +TEST_CASE(CompiledTemplateRoundtrip_modified_source_recompiles_when_dependency_is_stale) { + const std::filesystem::path root = roundtrip_test_root("dependency-stale"); + const std::filesystem::path source_path = root / "sample.pbt"; + const std::filesystem::path compiled_path = root / "sample.pbc"; + const std::string initial_source = "Hello {{ name }}"; + const std::string updated_source = "Hi {{ name }}"; + write_file(source_path, initial_source); + + prebyte::EffectiveSettings settings; + write_compiled_artifact(source_path, initial_source, settings); + + reset_template_caches(source_path, settings); + REQUIRE_EQ(app_runner_render_file(source_path), std::string("Hello Ada")); + REQUIRE_EQ(app_runner_render_file(compiled_path), std::string("Hello Ada")); + + write_file(source_path, updated_source); + + RoundtripHarness harness; + const prebyte::CompiledProgram stale_program = + harness.serializer.deserialize(read_file(compiled_path), compiled_path); + REQUIRE_EQ(render_serialized_program(harness, stale_program, make_session(), settings), std::string("Hello Ada")); + + reset_template_caches(source_path, settings); + REQUIRE_EQ(app_runner_render_file(source_path), std::string("Hi Ada")); + + write_compiled_artifact(source_path, updated_source, settings); + + reset_template_caches(source_path, settings); + REQUIRE_EQ(app_runner_render_file(source_path), std::string("Hi Ada")); + REQUIRE_EQ(app_runner_render_file(compiled_path), std::string("Hi Ada")); +} + +TEST_CASE(CompiledTemplateRoundtrip_on_disk_bytes_match_in_memory_serialization) { + const std::filesystem::path root = roundtrip_test_root("on-disk-bytes"); + const std::filesystem::path source_path = root / "sample.pbt"; + const std::string source = "{{ for item in items }}{{ item }};{{ endfor }}"; + write_file(source_path, source); + + RoundtripHarness harness; + prebyte::EffectiveSettings settings; + const prebyte::CompiledProgram compiled = + harness.compiler.compile_source(source, source_path, source_path, settings); + const std::string bytes = harness.serializer.serialize(compiled); + write_file(root / "sample.pbc", bytes); + + const prebyte::CompiledProgram loaded_from_disk = + harness.serializer.deserialize(bytes, root / "sample.pbc"); + + const std::string direct_output = render_direct(harness, source, source_path, make_session(), settings); + const std::string disk_output = + render_serialized_program(harness, loaded_from_disk, make_session(), settings); + + REQUIRE_EQ(disk_output, std::string("Ada;Grace;")); + REQUIRE_EQ(disk_output, direct_output); +} diff --git a/tests/correctness/integration/SettingsBehaviorTests.cpp b/tests/correctness/integration/SettingsBehaviorTests.cpp new file mode 100644 index 0000000..573c2a0 --- /dev/null +++ b/tests/correctness/integration/SettingsBehaviorTests.cpp @@ -0,0 +1,488 @@ +#include "TestHarness.h" + +#include "app/AppRunner.h" +#include "app/Command.h" +#include "config/RuleResolver.h" +#include "io/InputBuffer.h" +#include "runtime/expression/BuiltinRegistry.h" +#include "runtime/expression/ExpressionEvaluator.h" +#include "runtime/resolution/IncludeResolver.h" +#include "runtime/render/Renderer.h" +#include "support/Diagnostic.h" + +#include +#include +#include + +namespace { + +struct SettingsHarness { + prebyte::RuleResolver rule_resolver; + prebyte::IncludeResolver include_resolver; + prebyte::BuiltinRegistry builtins; + prebyte::ExpressionEvaluator evaluator{builtins}; + prebyte::Renderer renderer{rule_resolver, include_resolver, evaluator}; +}; + +void write_file(const std::filesystem::path& path, const std::string& content) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream file(path, std::ios::binary); + file << content; +} + +std::filesystem::path settings_test_root(const std::string& name) { + const std::filesystem::path root = + std::filesystem::temp_directory_path() / "prebyte-settings-behavior-tests" / name; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + return root; +} + +prebyte::EffectiveSettings effective_settings_from_rules( + const std::vector& rule_args, const std::filesystem::path& file_path = "inline.pbt") { + prebyte::RuleResolver resolver; + const prebyte::ResolvedConfiguration configuration = + resolver.resolve(prebyte::SettingsData{}, rule_args, {}, {}, false); + return resolver.resolve_for_file(configuration, file_path); +} + +std::string render_with_rules(SettingsHarness& harness, const std::string& source, + const std::vector& rule_args, prebyte::RenderSession session, + const std::filesystem::path& current_file = "inline.pbt") { + const prebyte::EffectiveSettings settings = effective_settings_from_rules(rule_args, current_file); + return harness.renderer.render_source(source, settings, current_file, session); +} + +std::string app_runner_render_inline(const std::string& source, const std::vector& rule_args, + const std::vector& define_args = {}) { + prebyte::Command command; + command.mode = prebyte::CommandMode::Render; + command.inline_input = source; + command.rule_args = rule_args; + command.define_args = define_args; + prebyte::AppRunner runner; + return runner.execute(command); +} + +void expect_app_runner_error(const std::string& source, const std::vector& rule_args, + const std::vector& define_args = {}) { + try { + app_runner_render_inline(source, rule_args, define_args); + throw std::runtime_error("expected DiagnosticError"); + } catch (const prebyte::DiagnosticError&) { + } +} + +void expect_render_error(SettingsHarness& harness, const std::string& source, + const std::vector& rule_args, prebyte::RenderSession session, + const std::filesystem::path& current_file = "inline.pbt") { + REQUIRE_THROWS_AS(render_with_rules(harness, source, rule_args, session, current_file), + prebyte::DiagnosticError); +} + +prebyte::RenderSession session_with_name(const std::string& name = "Ada") { + prebyte::RenderSession session; + session.variables.set("name", name); + session.variables.set("enabled", "false"); + session.variables.set("label", " Ada Lovelace "); + session.variables.set("Name", "CaseSensitive"); + return session; +} + +} + +TEST_CASE(SettingsBehavior_strict_variables_enabled_rejects_missing_variable) { + SettingsHarness harness; + expect_render_error(harness, "{{ missing }}", {"strict_variables=true"}, session_with_name()); +} + +TEST_CASE(SettingsBehavior_strict_variables_disabled_allows_missing_variable) { + SettingsHarness harness; + const std::string output = render_with_rules(harness, "{{ missing }}", {"strict_variables=false"}, + session_with_name()); + REQUIRE(output.empty()); +} + +TEST_CASE(SettingsBehavior_default_variable_value_enabled_fills_missing_variable) { + SettingsHarness harness; + const std::string output = render_with_rules( + harness, "{{ missing }}", {"strict_variables=false", "default_variable_value=Fallback"}, session_with_name()); + REQUIRE_EQ(output, std::string("Fallback")); +} + +TEST_CASE(SettingsBehavior_default_variable_value_disabled_leaves_missing_variable_empty) { + SettingsHarness harness; + const std::string output = + render_with_rules(harness, "{{ missing }}", {"strict_variables=false"}, session_with_name()); + REQUIRE(output.empty()); +} + +TEST_CASE(SettingsBehavior_case_sensitive_variables_enabled_requires_exact_name) { + SettingsHarness harness; + prebyte::RenderSession session; + session.variables.set("Name", "CaseSensitive"); + REQUIRE_EQ(render_with_rules(harness, "{{ Name }}", {"case_sensitive_variables=true"}, session), + std::string("CaseSensitive")); + REQUIRE(render_with_rules(harness, "{{ name }}", {"case_sensitive_variables=true"}, session).empty()); +} + +TEST_CASE(SettingsBehavior_case_sensitive_variables_disabled_ignores_name_case) { + SettingsHarness harness; + prebyte::RenderSession session; + session.variables.set("Name", "Ada"); + REQUIRE_EQ(render_with_rules(harness, "{{ name }}", {"case_sensitive_variables=false"}, session), + std::string("Ada")); +} + +TEST_CASE(SettingsBehavior_custom_variable_delimiters_enabled_change_interpolation) { + SettingsHarness harness; + prebyte::RenderSession session = session_with_name(); + const std::string source = "<< name >>"; + REQUIRE_EQ(render_with_rules(harness, source, + {"variable_prefix=<<", "variable_suffix=>>"}, session), + std::string("Ada")); +} + +TEST_CASE(SettingsBehavior_custom_variable_delimiters_disabled_keep_default_syntax) { + SettingsHarness harness; + prebyte::RenderSession session = session_with_name(); + REQUIRE_EQ(render_with_rules(harness, "<< name >>", {}, session), std::string("<< name >>")); + REQUIRE_EQ(render_with_rules(harness, "{{ name }}", {}, session), std::string("Ada")); +} + +TEST_CASE(SettingsBehavior_trim_enabled_strips_variable_whitespace) { + SettingsHarness harness; + prebyte::RenderSession session = session_with_name(); + REQUIRE_EQ(render_with_rules(harness, "{{ label }}", {"trim=true"}, session), std::string("Ada Lovelace")); +} + +TEST_CASE(SettingsBehavior_trim_disabled_preserves_variable_whitespace) { + SettingsHarness harness; + prebyte::RenderSession session = session_with_name(); + REQUIRE_EQ(render_with_rules(harness, "{{ label }}", {"trim=false"}, session), std::string(" Ada Lovelace ")); +} + +TEST_CASE(SettingsBehavior_max_variable_length_enabled_truncates_values) { + SettingsHarness harness; + prebyte::RenderSession session = session_with_name(); + REQUIRE_EQ(render_with_rules(harness, "{{ label }}", {"max_variable_length=3"}, session), + std::string(" A")); +} + +TEST_CASE(SettingsBehavior_max_variable_length_disabled_keeps_full_values) { + SettingsHarness harness; + prebyte::RenderSession session = session_with_name(); + REQUIRE_EQ(render_with_rules(harness, "{{ label }}", {}, session), std::string(" Ada Lovelace ")); +} + +TEST_CASE(SettingsBehavior_replace_tabs_enabled_expands_tabs_in_template) { + SettingsHarness harness; + prebyte::RenderSession session = session_with_name(); + REQUIRE_EQ(render_with_rules(harness, "A\tB", {"replace_tabs=true", "tab_size=4"}, session), + std::string("A B")); +} + +TEST_CASE(SettingsBehavior_replace_tabs_disabled_keeps_literal_tabs) { + SettingsHarness harness; + prebyte::RenderSession session = session_with_name(); + REQUIRE_EQ(render_with_rules(harness, "A\tB", {"replace_tabs=false"}, session), std::string("A\tB")); +} + +TEST_CASE(SettingsBehavior_allow_includes_enabled_renders_include) { + const std::filesystem::path root = settings_test_root("allow-includes-on"); + write_file(root / "partial.pbt", "Partial {{ name }}"); + write_file(root / "main.pbt", "{{ include \"partial.pbt\" }}"); + + SettingsHarness harness; + prebyte::RenderSession session = session_with_name(); + const std::string output = render_with_rules(harness, "{{ include \"partial.pbt\" }}", + {"allow_includes=true", "include_path=" + root.string()}, session, + root / "main.pbt"); + REQUIRE_EQ(output, std::string("Partial Ada")); +} + +TEST_CASE(SettingsBehavior_allow_includes_disabled_rejects_include) { + const std::filesystem::path root = settings_test_root("allow-includes-off"); + write_file(root / "partial.pbt", "Partial {{ name }}"); + write_file(root / "main.pbt", "{{ include \"partial.pbt\" }}"); + + SettingsHarness harness; + prebyte::RenderSession session = session_with_name(); + expect_render_error(harness, "{{ include \"partial.pbt\" }}", + {"allow_includes=false", "include_path=" + root.string()}, session, root / "main.pbt"); +} + +TEST_CASE(SettingsBehavior_max_include_depth_enabled_limits_nested_includes) { + const std::filesystem::path root = settings_test_root("include-depth-on"); + write_file(root / "level2.pbt", "deep"); + write_file(root / "level1.pbt", "{{ include \"level2.pbt\" }}"); + write_file(root / "main.pbt", "{{ include \"level1.pbt\" }}"); + + SettingsHarness harness; + prebyte::RenderSession session = session_with_name(); + expect_render_error(harness, "{{ include \"level1.pbt\" }}", + {"allow_includes=true", "include_path=" + root.string(), "max_include_depth=0"}, session, + root / "main.pbt"); +} + +TEST_CASE(SettingsBehavior_max_include_depth_disabled_allows_nested_includes) { + const std::filesystem::path root = settings_test_root("include-depth-off"); + write_file(root / "level2.pbt", "deep"); + write_file(root / "level1.pbt", "{{ include \"level2.pbt\" }}"); + write_file(root / "main.pbt", "{{ include \"level1.pbt\" }}"); + + SettingsHarness harness; + prebyte::RenderSession session = session_with_name(); + const std::string output = render_with_rules(harness, "{{ include \"level1.pbt\" }}", + {"allow_includes=true", "include_path=" + root.string()}, session, + root / "main.pbt"); + REQUIRE_EQ(output, std::string("deep")); +} + +TEST_CASE(SettingsBehavior_allow_env_enabled_reads_environment_variable) { + prebyte::test::ScopedEnvironmentVariable allowed_env("PREBYTE_SETTINGS_BEHAVIOR_ENV", "Grace"); + + SettingsHarness harness; + REQUIRE_EQ(render_with_rules(harness, "{{ PREBYTE_SETTINGS_BEHAVIOR_ENV }}", {"allow_env=true"}, + session_with_name()), + std::string("Grace")); +} + +TEST_CASE(SettingsBehavior_allow_env_disabled_hides_environment_variable) { + prebyte::test::ScopedEnvironmentVariable allowed_env("PREBYTE_SETTINGS_BEHAVIOR_ENV", "Grace"); + + SettingsHarness harness; + const std::string output = render_with_rules( + harness, "{{ PREBYTE_SETTINGS_BEHAVIOR_ENV }}", + {"allow_env=false", "strict_variables=false", "default_variable_value=Fallback"}, session_with_name()); + REQUIRE_EQ(output, std::string("Fallback")); +} + +TEST_CASE(SettingsBehavior_forbidden_env_vars_enabled_blocks_listed_variable) { + prebyte::test::ScopedEnvironmentVariable blocked_env("PREBYTE_SETTINGS_BEHAVIOR_BLOCKED", "Secret"); + + SettingsHarness harness; + expect_render_error(harness, "{{ PREBYTE_SETTINGS_BEHAVIOR_BLOCKED }}", + {"allow_env=true", "forbidden_env_vars=PREBYTE_SETTINGS_BEHAVIOR_BLOCKED"}, + session_with_name()); +} + +TEST_CASE(SettingsBehavior_forbidden_env_vars_disabled_allows_environment_variable) { + prebyte::test::ScopedEnvironmentVariable blocked_env("PREBYTE_SETTINGS_BEHAVIOR_BLOCKED", "Secret"); + + SettingsHarness harness; + REQUIRE_EQ(render_with_rules(harness, "{{ PREBYTE_SETTINGS_BEHAVIOR_BLOCKED }}", {"allow_env=true"}, + session_with_name()), + std::string("Secret")); +} + +TEST_CASE(SettingsBehavior_error_on_false_input_enabled_rejects_false_condition) { + expect_app_runner_error("{{ if enabled }}yes{{ else }}no{{ endif }}", + {"error_on_false_input=true"}, + {"enabled=false"}); +} + +TEST_CASE(SettingsBehavior_error_on_false_input_disabled_allows_false_condition) { + REQUIRE_EQ(app_runner_render_inline("{{ if enabled }}yes{{ else }}no{{ endif }}", + {"error_on_false_input=false"}, + {"enabled=false"}), + std::string("no")); +} + +TEST_CASE(SettingsBehavior_max_output_size_bytes_enabled_rejects_large_output) { + expect_app_runner_error("Hello {{ name }}", {"max_output_size_bytes=5"}); +} + +TEST_CASE(SettingsBehavior_max_output_size_bytes_disabled_allows_large_output) { + REQUIRE_EQ(app_runner_render_inline("Hello {{ name }}", {"max_output_size_bytes=100"}, {"name=Ada"}), + std::string("Hello Ada")); +} + +TEST_CASE(SettingsBehavior_max_loop_iteration_enabled_limits_nested_loops) { + const std::string source = + "{{ fn groups() lua:block }}return { {'A', 'B'}, {'C'} }{{ endfn }}" + "{{ for group in groups() }}{{ for item in group }}{{ item }}{{ endfor }}|{{ endfor }}"; + expect_app_runner_error(source, {"max_loop_iteration=1"}); +} + +TEST_CASE(SettingsBehavior_max_loop_iteration_disabled_allows_nested_loops) { + const std::string source = + "{{ fn groups() lua:block }}return { {'A', 'B'}, {'C'} }{{ endfn }}" + "{{ for group in groups() }}{{ for item in group }}{{ item }}{{ endfor }}|{{ endfor }}"; + REQUIRE_EQ(app_runner_render_inline(source, {"max_loop_iteration=100"}), std::string("AB|C|")); +} + +TEST_CASE(SettingsBehavior_max_render_time_ms_enabled_interrupts_long_lua) { + expect_app_runner_error("{{ lua:block }} while true do end return 'x' {{ endlua }}", {"max_render_time_ms=0"}); +} + +TEST_CASE(SettingsBehavior_max_render_time_ms_disabled_allows_short_lua) { + REQUIRE_EQ(app_runner_render_inline("{{ lua \"return 'ok'\" }}", {"max_render_time_ms=1000"}), + std::string("ok")); +} + +TEST_CASE(SettingsBehavior_lua_instruction_limit_enabled_rejects_heavy_script) { + const std::string source = + "{{ lua \"local sum = 0 for i = 1, 1000 do sum = sum + i end return sum\" }}"; + expect_app_runner_error(source, {"lua_instruction_limit=10"}); +} + +TEST_CASE(SettingsBehavior_lua_instruction_limit_disabled_allows_heavy_script) { + const std::string source = + "{{ lua \"local sum = 0 for i = 1, 1000 do sum = sum + i end return sum\" }}"; + REQUIRE_EQ(app_runner_render_inline(source, {"lua_instruction_limit=100000"}), std::string("500500")); +} + +TEST_CASE(SettingsBehavior_lua_memory_limit_bytes_enabled_rejects_large_allocation) { + expect_app_runner_error("{{ lua \"return string.rep('x', 2097152)\" }}", {"lua_memory_limit_bytes=1048576"}); +} + +TEST_CASE(SettingsBehavior_lua_memory_limit_bytes_disabled_allows_small_allocation) { + REQUIRE_EQ(app_runner_render_inline("{{ lua \"return string.rep('x', 8)\" }}", {"lua_memory_limit_bytes=1048576"}), + std::string("xxxxxxxx")); +} + +TEST_CASE(SettingsBehavior_output_encoding_utf16_enabled_writes_bom) { + const std::filesystem::path root = settings_test_root("output-encoding-on"); + const std::filesystem::path output_path = root / "out.txt"; + + prebyte::Command command; + command.mode = prebyte::CommandMode::Render; + command.inline_input = "Hello"; + command.output_path = output_path; + command.rule_args = {"output_encoding=utf-16"}; + + prebyte::AppRunner runner; + runner.run(command); + + const std::string bytes = std::string(prebyte::InputBuffer::from_file(output_path).view()); + REQUIRE_EQ(bytes.size(), static_cast(12)); + REQUIRE_EQ(static_cast(bytes[0]), 0xFFu); + REQUIRE_EQ(static_cast(bytes[1]), 0xFEu); +} + +TEST_CASE(SettingsBehavior_output_encoding_utf8_disabled_writes_plain_text) { + const std::filesystem::path root = settings_test_root("output-encoding-off"); + const std::filesystem::path output_path = root / "out.txt"; + + prebyte::Command command; + command.mode = prebyte::CommandMode::Render; + command.inline_input = "Hello"; + command.output_path = output_path; + command.rule_args = {"output_encoding=utf-8"}; + + prebyte::AppRunner runner; + runner.run(command); + + REQUIRE_EQ(std::string(prebyte::InputBuffer::from_file(output_path).view()), std::string("Hello")); +} + +TEST_CASE(SettingsBehavior_file_rule_enabled_applies_extension_scoped_default) { + const std::filesystem::path file_path = "notes.md"; + const prebyte::EffectiveSettings settings = + effective_settings_from_rules({".md::default_variable_value=Fallback"}, file_path); + SettingsHarness harness; + prebyte::RenderSession session = session_with_name(); + REQUIRE_EQ(harness.renderer.render_source("{{ missing }}", settings, file_path, session), + std::string("Fallback")); +} + +TEST_CASE(SettingsBehavior_file_rule_disabled_keeps_global_default_empty) { + const std::filesystem::path file_path = "notes.md"; + SettingsHarness harness; + const std::string output = render_with_rules( + harness, "{{ missing }}", {"strict_variables=false"}, session_with_name(), file_path); + REQUIRE(output.empty()); +} + +TEST_CASE(SettingsBehavior_combination_trim_and_max_variable_length_apply_in_order) { + SettingsHarness harness; + prebyte::RenderSession session = session_with_name(); + session.variables.set("label", " Ada "); + REQUIRE_EQ(render_with_rules(harness, "{{ label }}", {"trim=true", "max_variable_length=2"}, session), + std::string("Ad")); +} + +TEST_CASE(SettingsBehavior_combination_default_value_and_trim_normalize_missing_variable) { + SettingsHarness harness; + REQUIRE_EQ(render_with_rules(harness, "{{ missing }}", + {"strict_variables=false", "default_variable_value= padded ", "trim=true"}, + session_with_name()), + std::string("padded")); +} + +TEST_CASE(SettingsBehavior_combination_strict_and_case_sensitive_require_exact_missing_name) { + SettingsHarness harness; + prebyte::RenderSession session; + session.variables.set("Name", "Exact"); + expect_render_error(harness, "{{ name }}", {"strict_variables=true", "case_sensitive_variables=true"}, session); + REQUIRE_EQ(render_with_rules(harness, "{{ Name }}", {"strict_variables=false", "case_sensitive_variables=true"}, + session), + std::string("Exact")); +} + +TEST_CASE(SettingsBehavior_combination_allow_env_and_forbidden_env_vars_block_only_listed_names) { + prebyte::test::ScopedEnvironmentVariable allowed_env("PREBYTE_SETTINGS_BEHAVIOR_ALLOWED", "Ada"); + prebyte::test::ScopedEnvironmentVariable blocked_env("PREBYTE_SETTINGS_BEHAVIOR_BLOCKED", "Secret"); + + SettingsHarness harness; + const std::vector rules = {"allow_env=true", "forbidden_env_vars=PREBYTE_SETTINGS_BEHAVIOR_BLOCKED"}; + REQUIRE_EQ(render_with_rules(harness, "{{ PREBYTE_SETTINGS_BEHAVIOR_ALLOWED }}", rules, session_with_name()), + std::string("Ada")); + expect_render_error(harness, "{{ PREBYTE_SETTINGS_BEHAVIOR_BLOCKED }}", rules, session_with_name()); +} + +TEST_CASE(SettingsBehavior_combination_allow_includes_and_max_include_depth_limit_together) { + const std::filesystem::path root = settings_test_root("include-combo"); + write_file(root / "level2.pbt", "deep"); + write_file(root / "level1.pbt", "{{ include \"level2.pbt\" }}"); + write_file(root / "main.pbt", "{{ include \"level1.pbt\" }}"); + + SettingsHarness harness; + prebyte::RenderSession session = session_with_name(); + const std::vector strict_rules = { + "allow_includes=true", + "include_path=" + root.string(), + "max_include_depth=1", + }; + expect_render_error(harness, "{{ include \"level1.pbt\" }}", strict_rules, session, root / "main.pbt"); + + const std::vector relaxed_rules = { + "allow_includes=true", + "include_path=" + root.string(), + "max_include_depth=2", + }; + REQUIRE_EQ(render_with_rules(harness, "{{ include \"level1.pbt\" }}", relaxed_rules, session, root / "main.pbt"), + std::string("deep")); +} + +TEST_CASE(SettingsBehavior_combination_replace_tabs_and_tab_size_control_expansion_width) { + SettingsHarness harness; + prebyte::RenderSession session = session_with_name(); + REQUIRE_EQ(render_with_rules(harness, "X\tY", {"replace_tabs=true", "tab_size=2"}, session), + std::string("X Y")); + REQUIRE_EQ(render_with_rules(harness, "X\tY", {"replace_tabs=true", "tab_size=6"}, session), + std::string("X Y")); +} + +TEST_CASE(SettingsBehavior_combination_custom_delimiters_require_both_prefix_and_suffix) { + SettingsHarness harness; + prebyte::RenderSession session = session_with_name(); + REQUIRE_EQ(render_with_rules(harness, "{{ name }}", {"variable_prefix=<<", "variable_suffix=>>"}, session), + std::string("{{ name }}")); + REQUIRE_EQ(render_with_rules(harness, "<< name >>", {"variable_prefix=<<", "variable_suffix=>>"}, session), + std::string("Ada")); +} + +TEST_CASE(SettingsBehavior_combination_error_on_false_input_and_default_value_are_independent) { + const std::vector defines = {"enabled=false"}; + REQUIRE_EQ(app_runner_render_inline("{{ if enabled }}yes{{ else }}{{ missing }}{{ endif }}", + {"error_on_false_input=false", "strict_variables=false", + "default_variable_value=Fallback"}, + defines), + std::string("Fallback")); + expect_app_runner_error("{{ if enabled }}yes{{ else }}{{ missing }}{{ endif }}", + {"error_on_false_input=true", "strict_variables=false", + "default_variable_value=Fallback"}, + defines); +} diff --git a/tests/correctness/integration/StructuredImportE2ETests.cpp b/tests/correctness/integration/StructuredImportE2ETests.cpp new file mode 100644 index 0000000..52bdbeb --- /dev/null +++ b/tests/correctness/integration/StructuredImportE2ETests.cpp @@ -0,0 +1,104 @@ +#include "TestHarness.h" + +#include "app/AppRunner.h" +#include "app/Command.h" + +#include +#include + +namespace { + +void write_file(const std::filesystem::path& path, const std::string& content) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream file(path, std::ios::binary); + file << content; +} + +std::filesystem::path structured_import_root(const std::string& name) { + const std::filesystem::path root = + std::filesystem::temp_directory_path() / "prebyte-structured-import-e2e" / name; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + return root; +} + +std::string render_with_import(const std::string& template_source, const std::vector& define_args) { + prebyte::Command command; + command.mode = prebyte::CommandMode::Render; + command.inline_input = template_source; + command.define_args = define_args; + + prebyte::AppRunner runner; + return runner.execute(command); +} + +} + +TEST_CASE(StructuredImportE2E_json_named_import_renders_member_and_index_access) { + const std::filesystem::path root = structured_import_root("json"); + const std::filesystem::path data_path = root / "user.json"; + write_file(data_path, R"({"name":"Ada","items":["A","B"]})"); + + const std::string output = render_with_import("{{ data.name }}|{{ data.items[1] }}", + {"data=@" + data_path.string()}); + REQUIRE_EQ(output, std::string("Ada|B")); +} + +TEST_CASE(StructuredImportE2E_yaml_named_import_renders_mapping_and_list_access) { + const std::filesystem::path root = structured_import_root("yaml"); + const std::filesystem::path data_path = root / "catalog.yaml"; + write_file(data_path, "name: Ada\nitems:\n - A\n - B\n"); + + const std::string output = render_with_import("{{ data.name }}|{{ data.items[1] }}", + {"data=@" + data_path.string()}); + REQUIRE_EQ(output, std::string("Ada|B")); +} + +TEST_CASE(StructuredImportE2E_toml_named_import_renders_nested_table_access) { + const std::filesystem::path root = structured_import_root("toml"); + const std::filesystem::path data_path = root / "config.toml"; + write_file(data_path, "[server]\nhost=\"localhost\"\nport=8080\n"); + + const std::string output = render_with_import("{{ data.server.host }}:{{ data.server.port }}", + {"data=@" + data_path.string()}); + REQUIRE_EQ(output, std::string("localhost:8080")); +} + +TEST_CASE(StructuredImportE2E_ini_named_import_renders_section_member_access) { + const std::filesystem::path root = structured_import_root("ini"); + const std::filesystem::path data_path = root / "config.ini"; + write_file(data_path, "[server]\nhost = localhost\nport = 8080\n"); + + const std::string output = render_with_import("{{ data.server.host }}:{{ data.server.port }}", + {"data=@" + data_path.string()}); + REQUIRE_EQ(output, std::string("localhost:8080")); +} + +TEST_CASE(StructuredImportE2E_env_named_import_renders_key_access) { + const std::filesystem::path root = structured_import_root("env"); + const std::filesystem::path data_path = root / "app.env"; + write_file(data_path, "NAME=Ada\nROLE=admin\n"); + + const std::string output = + render_with_import("{{ data.NAME }}:{{ data.ROLE }}", {"data=@" + data_path.string()}); + REQUIRE_EQ(output, std::string("Ada:admin")); +} + +TEST_CASE(StructuredImportE2E_yaml_list_root_import_renders_index_access) { + const std::filesystem::path root = structured_import_root("yaml-list-root"); + const std::filesystem::path data_path = root / "items.yaml"; + write_file(data_path, "- Ada\n- Grace\n"); + + const std::string output = render_with_import("{{ items[0] }}|{{ items[1] }}", {"items=@" + data_path.string()}); + REQUIRE_EQ(output, std::string("Ada|Grace")); +} + +TEST_CASE(StructuredImportE2E_lua_reads_named_structured_import) { + const std::filesystem::path root = structured_import_root("lua-json"); + const std::filesystem::path data_path = root / "user.json"; + write_file(data_path, R"({"name":"Ada"})"); + + const std::string output = render_with_import( + "{{ data.name }} {{ lua \"return data.name\" }}", {"data=@" + data_path.string()}); + REQUIRE_EQ(output, std::string("Ada Ada")); +} diff --git a/tests/correctness/property/RenderPropertyTests.cpp b/tests/correctness/property/RenderPropertyTests.cpp new file mode 100644 index 0000000..9583490 --- /dev/null +++ b/tests/correctness/property/RenderPropertyTests.cpp @@ -0,0 +1,369 @@ +#include "TestHarness.h" + +#include "app/AppRunner.h" +#include "app/Command.h" +#include "config/RuleResolver.h" +#include "datatypes/Data.h" +#include "runtime/expression/BuiltinRegistry.h" +#include "runtime/compiled/CompiledTemplateCache.h" +#include "runtime/compiled/CompiledTemplateCompiler.h" +#include "runtime/compiled/CompiledTemplateSerializer.h" +#include "runtime/expression/ExpressionEvaluator.h" +#include "runtime/cache/FileMetadataCache.h" +#include "runtime/resolution/IncludeResolver.h" +#include "runtime/render/Renderer.h" +#include "support/Diagnostic.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr std::uint32_t kPropertySeedBase = 0x50726F70; // "Prop" + +struct PropertyHarness { + prebyte::RuleResolver rule_resolver; + prebyte::IncludeResolver include_resolver; + prebyte::BuiltinRegistry builtins; + prebyte::ExpressionEvaluator evaluator{builtins}; + prebyte::Renderer renderer{rule_resolver, include_resolver, evaluator}; + prebyte::CompiledTemplateCompiler compiler; + prebyte::CompiledTemplateSerializer serializer; +}; + +std::size_t property_iterations() { + if (const char* env = std::getenv("PREBYTE_PBT_ITERATIONS")) { + return static_cast(std::stoul(env)); + } + return 250; +} + +[[noreturn]] void property_fail(std::size_t iteration, std::string_view property, const std::string& source, + const std::string& detail) { + std::ostringstream stream; + stream << "property=" << property << " iteration=" << iteration << ' ' << detail << " template='" << source + << '\''; + throw prebyte::test::AssertionFailure(stream.str()); +} + +void write_file(const std::filesystem::path& path, const std::string& content) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream file(path, std::ios::binary); + file << content; +} + +std::filesystem::path property_test_root(const std::string& name) { + const std::filesystem::path root = + std::filesystem::temp_directory_path() / "prebyte-render-property-tests" / name; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + return root; +} + +std::string random_token(std::mt19937& rng, std::size_t max_len) { + static constexpr char kChars[] = "abcdefghijklmnopqrstuvwxyz0123456789_-"; + std::uniform_int_distribution length_dist(1, static_cast(max_len)); + std::uniform_int_distribution char_dist(0, static_cast(sizeof(kChars) - 2)); + const int length = length_dist(rng); + std::string token; + token.reserve(static_cast(length)); + for (int index = 0; index < length; ++index) { + token.push_back(kChars[char_dist(rng)]); + } + return token; +} + +prebyte::RenderSession make_property_session(std::mt19937& rng) { + prebyte::RenderSession session; + session.variables.set("name", random_token(rng, 12)); + session.variables.set("enabled", (rng() % 2) == 0 ? "true" : "false"); + session.variables.set("count", std::to_string(rng() % 100)); + + prebyte::Data::Array items; + const std::size_t item_count = 1 + (rng() % 4); + for (std::size_t index = 0; index < item_count; ++index) { + items.push_back(prebyte::Data(random_token(rng, 8))); + } + session.variables.set_value("items", prebyte::Value::list(std::move(items))); + return session; +} + +class TemplateGenerator { +public: + explicit TemplateGenerator(std::mt19937& rng, bool allow_includes = false) : rng_(rng), allow_includes_(allow_includes) {} + + std::string generate() { return generate_block(3); } + +private: + int pick(int upper_exclusive) { + std::uniform_int_distribution dist(0, upper_exclusive - 1); + return dist(rng_); + } + + std::string generate_literal() { + static constexpr char kChars[] = " abcdefghijklmnopqrstuvwxyz\n\t-|"; + std::uniform_int_distribution length_dist(0, 10); + std::uniform_int_distribution char_dist(0, static_cast(sizeof(kChars) - 2)); + const int length = length_dist(rng_); + std::string literal; + literal.reserve(static_cast(length)); + for (int index = 0; index < length; ++index) { + literal.push_back(kChars[char_dist(rng_)]); + } + return literal; + } + + std::string variable_reference() { + switch (pick(6)) { + case 0: + return "name"; + case 1: + return "enabled"; + case 2: + return "count"; + case 3: + return "items[0]"; + case 4: + return "items[1]"; + default: + return "item"; + } + } + + std::string interpolation() { + switch (pick(5)) { + case 0: + return "{{ " + variable_reference() + " }}"; + case 1: + return "{{ " + variable_reference() + " | upper }}"; + case 2: + return "{{ " + variable_reference() + " | lower }}"; + case 3: + return "{{ len(items) }}"; + default: + return "{{ len(name) }}"; + } + } + + std::string generate_block(int depth) { + if (depth <= 0) { + return pick(2) == 0 ? generate_literal() : interpolation(); + } + + switch (pick(allow_includes_ ? 7 : 6)) { + case 0: + case 1: + return generate_literal(); + case 2: + return interpolation(); + case 3: + return "{{ if enabled }}" + generate_block(depth - 1) + "{{ else }}" + generate_block(depth - 1) + + "{{ endif }}"; + case 4: + return "{{ for item in items }}" + generate_block(depth - 1) + "{{ else }}empty{{ endfor }}"; + case 5: + return "{{ set tag = name }}" + generate_block(depth - 1) + "{{ tag }}"; + default: + return "{{ include \"partial.pbt\" }}" + generate_block(depth - 1); + } + } + + std::mt19937& rng_; + bool allow_includes_; +}; + +std::string render_direct(PropertyHarness& harness, const std::string& source, + const std::filesystem::path& current_file, prebyte::RenderSession& session, + const prebyte::EffectiveSettings& settings) { + return harness.renderer.render_source(source, settings, current_file, session); +} + +std::string render_program(PropertyHarness& harness, const prebyte::CompiledProgram& program, + prebyte::RenderSession& session, const prebyte::EffectiveSettings& settings) { + return harness.renderer.render_program(program, settings, program.logical_path, session); +} + +prebyte::CompiledProgram serialize_roundtrip(PropertyHarness& harness, const prebyte::CompiledProgram& program) { + return harness.serializer.deserialize(harness.serializer.serialize(program)); +} + +std::string app_runner_render(const std::filesystem::path& input_path, + const std::filesystem::path& items_path) { + prebyte::Command command; + command.mode = prebyte::CommandMode::Render; + command.input_path = input_path; + command.define_args = {"name=Ada", "enabled=true", "count=7", "items=@" + items_path.string()}; + + prebyte::AppRunner runner; + return runner.execute(command); +} + +} + +TEST_CASE(RenderProperty_compile_serialize_roundtrip_matches_direct_render) { + PropertyHarness harness; + prebyte::EffectiveSettings settings; + settings.strict_variables = false; + const std::filesystem::path current_file = "property/main.pbt"; + const std::size_t iterations = property_iterations(); + + for (std::size_t iteration = 0; iteration < iterations; ++iteration) { + std::mt19937 rng(kPropertySeedBase ^ static_cast(iteration)); + TemplateGenerator generator(rng); + const std::string source = generator.generate(); + prebyte::RenderSession session = make_property_session(rng); + + const std::string direct = render_direct(harness, source, current_file, session, settings); + const prebyte::CompiledProgram compiled = + harness.compiler.compile_source(source, current_file, current_file, settings); + const prebyte::CompiledProgram roundtrip = serialize_roundtrip(harness, compiled); + const std::string via_roundtrip = render_program(harness, roundtrip, session, settings); + + if (direct != via_roundtrip) { + property_fail(iteration, "compile_serialize_roundtrip", source, + "direct='" + direct + "' roundtrip='" + via_roundtrip + "'"); + } + } +} + +TEST_CASE(RenderProperty_double_serialization_is_byte_stable) { + PropertyHarness harness; + prebyte::EffectiveSettings settings; + const std::filesystem::path current_file = "property/bytes.pbt"; + const std::size_t iterations = property_iterations(); + + for (std::size_t iteration = 0; iteration < iterations; ++iteration) { + std::mt19937 rng(kPropertySeedBase ^ static_cast(iteration + 10000)); + TemplateGenerator generator(rng); + const std::string source = generator.generate(); + + const prebyte::CompiledProgram compiled = + harness.compiler.compile_source(source, current_file, current_file, settings); + const std::string once = harness.serializer.serialize(compiled); + const prebyte::CompiledProgram loaded = harness.serializer.deserialize(once, current_file); + const std::string twice = harness.serializer.serialize(loaded); + + if (once != twice) { + property_fail(iteration, "double_serialization_bytes", source, "serialized bytes changed after roundtrip"); + } + } +} + +TEST_CASE(RenderProperty_render_is_idempotent_for_compiled_program) { + PropertyHarness harness; + prebyte::EffectiveSettings settings; + const std::filesystem::path current_file = "property/idempotent.pbt"; + const std::size_t iterations = property_iterations(); + + for (std::size_t iteration = 0; iteration < iterations; ++iteration) { + std::mt19937 rng(kPropertySeedBase ^ static_cast(iteration + 20000)); + TemplateGenerator generator(rng); + const std::string source = generator.generate(); + prebyte::RenderSession session = make_property_session(rng); + + const prebyte::CompiledProgram compiled = + harness.compiler.compile_source(source, current_file, current_file, settings); + const std::string first = render_program(harness, compiled, session, settings); + const std::string second = render_program(harness, compiled, session, settings); + + if (first != second) { + property_fail(iteration, "render_idempotent", source, + "first='" + first + "' second='" + second + "'"); + } + } +} + +TEST_CASE(RenderProperty_recompile_produces_same_render_output) { + PropertyHarness harness; + prebyte::EffectiveSettings settings; + const std::filesystem::path current_file = "property/recompile.pbt"; + const std::size_t iterations = property_iterations(); + + for (std::size_t iteration = 0; iteration < iterations; ++iteration) { + std::mt19937 rng(kPropertySeedBase ^ static_cast(iteration + 30000)); + TemplateGenerator generator(rng); + const std::string source = generator.generate(); + prebyte::RenderSession session = make_property_session(rng); + + const prebyte::CompiledProgram first = + harness.compiler.compile_source(source, current_file, current_file, settings); + const prebyte::CompiledProgram second = + harness.compiler.compile_source(source, current_file, current_file, settings); + const std::string first_output = render_program(harness, first, session, settings); + const std::string second_output = render_program(harness, second, session, settings); + + if (first_output != second_output) { + property_fail(iteration, "recompile_same_output", source, + "first='" + first_output + "' second='" + second_output + "'"); + } + } +} + +TEST_CASE(RenderProperty_include_templates_roundtrip_matches_direct_render) { + PropertyHarness harness; + const std::filesystem::path root = property_test_root("includes"); + const std::filesystem::path current_file = root / "main.pbt"; + write_file(root / "partial.pbt", "<{{ loop.index }}:{{ item }}>\n"); + + prebyte::EffectiveSettings settings; + settings.allow_includes = true; + settings.include_paths.push_back(root); + const std::size_t iterations = property_iterations(); + + for (std::size_t iteration = 0; iteration < iterations; ++iteration) { + std::mt19937 rng(kPropertySeedBase ^ static_cast(iteration + 40000)); + TemplateGenerator generator(rng, true); + const std::string source = generator.generate(); + prebyte::RenderSession session = make_property_session(rng); + + const std::string direct = render_direct(harness, source, current_file, session, settings); + const prebyte::CompiledProgram compiled = + harness.compiler.compile_source(source, current_file, current_file, settings); + const prebyte::CompiledProgram roundtrip = serialize_roundtrip(harness, compiled); + const std::string via_roundtrip = render_program(harness, roundtrip, session, settings); + + if (direct != via_roundtrip) { + property_fail(iteration, "include_roundtrip", source, + "direct='" + direct + "' roundtrip='" + via_roundtrip + "'"); + } + } +} + +TEST_CASE(RenderProperty_app_runner_source_matches_adjacent_pbc) { + const std::filesystem::path root = property_test_root("app-runner-pbc"); + + PropertyHarness harness; + prebyte::EffectiveSettings settings; + const std::size_t iterations = property_iterations() / 5; + + for (std::size_t iteration = 0; iteration < iterations; ++iteration) { + const std::filesystem::path iteration_root = root / std::to_string(iteration); + const std::filesystem::path source_path = iteration_root / "main.pbt"; + write_file(iteration_root / "items.yaml", "- one\n- two\n"); + + std::mt19937 rng(kPropertySeedBase ^ static_cast(iteration + 50000)); + TemplateGenerator generator(rng); + const std::string source = generator.generate(); + write_file(source_path, source); + + const prebyte::CompiledProgram compiled = + harness.compiler.compile_source(source, source_path, source_path, settings); + const std::filesystem::path compiled_path = harness.serializer.compiled_path_for_source(source_path); + write_file(compiled_path, harness.serializer.serialize(compiled)); + + prebyte::FileMetadataCache::instance().clear(); + prebyte::CompiledTemplateCache::instance().erase(compiled_path, settings); + + const std::string from_source = app_runner_render(source_path, iteration_root / "items.yaml"); + const std::string from_pbc = app_runner_render(compiled_path, iteration_root / "items.yaml"); + + if (from_source != from_pbc) { + property_fail(iteration, "app_runner_pbc_parity", source, + "source='" + from_source + "' pbc='" + from_pbc + "'"); + } + } +} diff --git a/tests/correctness/unit/BatchProcessorTests.cpp b/tests/correctness/unit/BatchProcessorTests.cpp new file mode 100644 index 0000000..ca3e61a --- /dev/null +++ b/tests/correctness/unit/BatchProcessorTests.cpp @@ -0,0 +1,179 @@ +#include "TestHarness.h" + +#include "app/BatchProcessor.h" +#include "app/Command.h" +#include "io/InputBuffer.h" +#include "support/Diagnostic.h" + +#include +#include + +namespace { + +void write_file(const std::filesystem::path& path, const std::string& content) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream file(path, std::ios::binary); + file << content; +} + +std::filesystem::path batch_test_root(const std::string& name) { + const std::filesystem::path root = std::filesystem::temp_directory_path() / "prebyte-batch-tests" / name; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + return root; +} + +prebyte::Command make_batch_command(const std::filesystem::path& root, const std::string& batch_json, + const std::string& template_source = "{{ value }}") { + write_file(root / "template.txt", template_source); + write_file(root / "data.json", batch_json); + + prebyte::Command command; + command.mode = prebyte::CommandMode::Render; + command.input_path = root / "template.txt"; + command.batch_path = root / "data.json"; + return command; +} + +} + +TEST_CASE(BatchProcessor_rejects_invalid_batch_configuration) { + const std::filesystem::path root = batch_test_root("invalid-config"); + write_file(root / "template.txt", "{{ value }}"); + write_file(root / "data.json", R"([{"value":"one"}])"); + + prebyte::BatchProcessor processor; + + prebyte::Command missing_batch; + missing_batch.mode = prebyte::CommandMode::Render; + missing_batch.input_path = root / "template.txt"; + REQUIRE_THROWS_AS(processor.execute(missing_batch), prebyte::DiagnosticError); + + prebyte::Command stdin_without_template; + stdin_without_template.mode = prebyte::CommandMode::Render; + stdin_without_template.batch_from_stdin = true; + REQUIRE_THROWS_AS(processor.execute(stdin_without_template), prebyte::DiagnosticError); + + prebyte::Command missing_batch_file = make_batch_command(root, R"([{"value":"one"}])"); + missing_batch_file.batch_path = root / "missing.json"; + REQUIRE_THROWS_AS(processor.execute(missing_batch_file), prebyte::DiagnosticError); +} + +TEST_CASE(BatchProcessor_rejects_invalid_batch_payloads) { + prebyte::BatchProcessor processor; + + REQUIRE_THROWS_AS(processor.execute(make_batch_command(batch_test_root("bad-json"), "{ not json")), + std::runtime_error); + REQUIRE_THROWS_AS(processor.execute(make_batch_command(batch_test_root("empty-array"), "[]")), + prebyte::DiagnosticError); + REQUIRE_THROWS_AS(processor.execute(make_batch_command(batch_test_root("empty-object"), "{}")), + prebyte::DiagnosticError); + REQUIRE_THROWS_AS(processor.execute(make_batch_command(batch_test_root("scalar-root"), R"("nope")")), + prebyte::DiagnosticError); + REQUIRE_THROWS_AS( + processor.execute(make_batch_command(batch_test_root("array-non-object"), R"(["bad"])")), + prebyte::DiagnosticError); + REQUIRE_THROWS_AS( + processor.execute(make_batch_command(batch_test_root("object-non-object"), R"({"entry":"bad"})")), + prebyte::DiagnosticError); +} + +TEST_CASE(BatchProcessor_rejects_invalid_output_targets) { + const std::filesystem::path root = batch_test_root("invalid-output"); + prebyte::Command command = make_batch_command(root, R"([{"value":"one"},{"value":"two"}])"); + command.output_path = root / "single.txt"; + + prebyte::BatchProcessor processor; + REQUIRE_THROWS_AS(processor.execute(command), prebyte::DiagnosticError); + + prebyte::Command bad_override = make_batch_command(batch_test_root("bad-override"), + R"([{"$output":42,"value":"one"}])"); + REQUIRE_THROWS_AS(processor.execute(bad_override), prebyte::DiagnosticError); +} + +TEST_CASE(BatchProcessor_execute_combines_stdout_output) { + const std::filesystem::path root = batch_test_root("stdout"); + prebyte::Command command = make_batch_command(root, R"([{"value":"one"},{"value":"two"}])"); + + prebyte::BatchProcessor processor; + REQUIRE_EQ(processor.execute(command), std::string("onetwo")); +} + +TEST_CASE(BatchProcessor_execute_writes_single_output_file) { + const std::filesystem::path root = batch_test_root("single-file"); + prebyte::Command command = make_batch_command(root, R"([{"value":"only"}])"); + command.output_path = root / "result.txt"; + + prebyte::BatchProcessor processor; + REQUIRE(processor.execute(command).empty()); + REQUIRE_EQ(std::string(prebyte::InputBuffer::from_file(root / "result.txt").view()), std::string("only")); +} + +TEST_CASE(BatchProcessor_execute_writes_directory_outputs) { + const std::filesystem::path root = batch_test_root("directory-output"); + prebyte::Command command = make_batch_command(root, R"([{"value":"one"},{"value":"two"}])"); + command.output_path = root / "out/"; + + prebyte::BatchProcessor processor; + REQUIRE(processor.execute(command).empty()); + REQUIRE_EQ(std::string(prebyte::InputBuffer::from_file(root / "out" / "0.txt").view()), std::string("one")); + REQUIRE_EQ(std::string(prebyte::InputBuffer::from_file(root / "out" / "1.txt").view()), std::string("two")); +} + +TEST_CASE(BatchProcessor_execute_honors_output_override_and_object_keys) { + const std::filesystem::path root = batch_test_root("output-names"); + prebyte::Command command = make_batch_command(root, + R"([ + {"$output":"custom.txt","value":"one"}, + {"_output":"second.txt","value":"two"}, + {"value":"three"} + ])"); + command.output_path = root / "out/"; + + prebyte::BatchProcessor processor; + processor.execute(command); + + REQUIRE_EQ(std::string(prebyte::InputBuffer::from_file(root / "out" / "custom.txt").view()), std::string("one")); + REQUIRE_EQ(std::string(prebyte::InputBuffer::from_file(root / "out" / "second.txt").view()), std::string("two")); + REQUIRE_EQ(std::string(prebyte::InputBuffer::from_file(root / "out" / "2.txt").view()), std::string("three")); + + prebyte::Command object_keys = make_batch_command(batch_test_root("object-keys"), + R"({"first.txt":{"value":"alpha"},"second.txt":{"value":"beta"}})"); + object_keys.output_path = root / "object-out/"; + processor.execute(object_keys); + + REQUIRE_EQ(std::string(prebyte::InputBuffer::from_file(root / "object-out" / "first.txt").view()), + std::string("alpha")); + REQUIRE_EQ(std::string(prebyte::InputBuffer::from_file(root / "object-out" / "second.txt").view()), + std::string("beta")); +} + +TEST_CASE(BatchProcessor_execute_applies_scalar_and_structured_variables) { + const std::filesystem::path root = batch_test_root("variables"); + prebyte::Command command = make_batch_command( + root, + R"([{ + "name":"Ada", + "active":true, + "count":2, + "user":{"role":"admin"}, + "tags":["a","b"] + }])", + R"({{ name }}|{{ active }}|{{ count }}|{{ user.role }}|{{ tags[0] }})"); + + prebyte::BatchProcessor processor; + REQUIRE_EQ(processor.execute(command), std::string("Ada|true|2|admin|a")); +} + +TEST_CASE(BatchProcessor_execute_includes_benchmark_suffix) { + const std::filesystem::path root = batch_test_root("benchmark"); + prebyte::Command command = make_batch_command(root, R"([{"value":"timed"}])"); + command.benchmark = true; + + prebyte::BatchProcessor processor; + const std::string output = processor.execute(command); + REQUIRE(output.find("timed") == 0); + REQUIRE(output.find("\n[benchmark] ") != std::string::npos); + REQUIRE(output.find("lua_cache_hits=") != std::string::npos); + REQUIRE(output.find("lua_cache_misses=") != std::string::npos); +} diff --git a/tests/unit/BuiltinRegistryTests.cpp b/tests/correctness/unit/BuiltinRegistryTests.cpp similarity index 98% rename from tests/unit/BuiltinRegistryTests.cpp rename to tests/correctness/unit/BuiltinRegistryTests.cpp index 416a558..84d6a2d 100644 --- a/tests/unit/BuiltinRegistryTests.cpp +++ b/tests/correctness/unit/BuiltinRegistryTests.cpp @@ -1,6 +1,6 @@ #include "TestHarness.h" -#include "runtime/BuiltinRegistry.h" +#include "runtime/expression/BuiltinRegistry.h" #include diff --git a/tests/unit/CommandParserTests.cpp b/tests/correctness/unit/CommandParserTests.cpp similarity index 100% rename from tests/unit/CommandParserTests.cpp rename to tests/correctness/unit/CommandParserTests.cpp diff --git a/tests/correctness/unit/CompiledTemplateCacheTests.cpp b/tests/correctness/unit/CompiledTemplateCacheTests.cpp new file mode 100644 index 0000000..61ab565 --- /dev/null +++ b/tests/correctness/unit/CompiledTemplateCacheTests.cpp @@ -0,0 +1,166 @@ +#include "TestHarness.h" + +#include "runtime/compiled/CompiledTemplateCache.h" +#include "runtime/compiled/CompiledTemplateCompiler.h" +#include "runtime/compiled/CompiledTemplateSerializer.h" +#include "runtime/cache/FileMetadataCache.h" + +#include +#include +#include + +namespace { + +prebyte::EffectiveSettings make_settings(std::string prefix = "{{", std::string suffix = "}}") { + prebyte::EffectiveSettings settings; + settings.variable_prefix = std::move(prefix); + settings.variable_suffix = std::move(suffix); + return settings; +} + +prebyte::CompiledProgram compile_source(const std::string& source, const prebyte::EffectiveSettings& settings) { + prebyte::CompiledTemplateCompiler compiler; + return compiler.compile_source(source, "inline.pbt", "inline.pbt", settings); +} + +std::filesystem::path cache_test_root(const std::string& name) { + const std::filesystem::path root = + std::filesystem::temp_directory_path() / "prebyte-compiled-template-cache-tests" / name; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + return root; +} + +void erase_cache_entry(const std::filesystem::path& compiled_path, const prebyte::EffectiveSettings& settings) { + prebyte::CompiledTemplateCache::instance().erase(compiled_path, settings); +} + +} + +TEST_CASE(CompiledTemplateCache_inline_find_misses_before_store) { + const prebyte::EffectiveSettings settings = make_settings(); + const std::string source = "Hello {{ name }}\n"; + + REQUIRE(prebyte::CompiledTemplateCache::instance().find_inline(source, settings) == nullptr); +} + +TEST_CASE(CompiledTemplateCache_inline_store_and_find_return_same_program) { + const prebyte::EffectiveSettings settings = make_settings(); + const std::string source = "Hello {{ name }}\n"; + prebyte::CompiledProgram program = compile_source(source, settings); + + const prebyte::CompiledProgram* stored = + prebyte::CompiledTemplateCache::instance().store_inline(source, std::move(program), settings); + const prebyte::CompiledProgram* found = + prebyte::CompiledTemplateCache::instance().find_inline(source, settings); + + REQUIRE(stored != nullptr); + REQUIRE(found == stored); +} + +TEST_CASE(CompiledTemplateCache_inline_partitions_by_effective_settings) { + const std::string source = "Hello {{ name }}\n"; + const prebyte::EffectiveSettings default_settings = make_settings(); + const prebyte::EffectiveSettings custom_settings = make_settings("[[", "]]"); + + prebyte::CompiledProgram default_program = compile_source(source, default_settings); + prebyte::CompiledProgram custom_program = compile_source(source, custom_settings); + + const prebyte::CompiledProgram* default_stored = prebyte::CompiledTemplateCache::instance().store_inline( + source, std::move(default_program), default_settings); + const prebyte::CompiledProgram* custom_stored = prebyte::CompiledTemplateCache::instance().store_inline( + source, std::move(custom_program), custom_settings); + + REQUIRE(prebyte::CompiledTemplateCache::instance().find_inline(source, default_settings) == default_stored); + REQUIRE(prebyte::CompiledTemplateCache::instance().find_inline(source, custom_settings) == custom_stored); + REQUIRE(default_stored != custom_stored); +} + +TEST_CASE(CompiledTemplateCache_file_store_loaded_and_find_roundtrip) { + const std::filesystem::path root = cache_test_root("file-roundtrip"); + const std::filesystem::path compiled_path = root / "template.pbc"; + const prebyte::EffectiveSettings settings = make_settings(); + erase_cache_entry(compiled_path, settings); + + prebyte::CompiledProgram program = compile_source("Cached {{ name }}\n", settings); + const prebyte::CompiledProgram* stored = prebyte::CompiledTemplateCache::instance().store_loaded( + compiled_path, program, settings, 42); + const prebyte::CompiledProgram* found = + prebyte::CompiledTemplateCache::instance().find(compiled_path, settings); + + REQUIRE(stored != nullptr); + REQUIRE(found == stored); + REQUIRE_EQ(prebyte::CompiledTemplateCache::instance().compiled_mtime(compiled_path, settings), 42); +} + +TEST_CASE(CompiledTemplateCache_erase_removes_file_entry) { + const std::filesystem::path root = cache_test_root("erase-file"); + const std::filesystem::path compiled_path = root / "template.pbc"; + const prebyte::EffectiveSettings settings = make_settings(); + erase_cache_entry(compiled_path, settings); + + prebyte::CompiledProgram program = compile_source("Hello\n", settings); + prebyte::CompiledTemplateCache::instance().store_loaded(compiled_path, program, settings, 1); + REQUIRE(prebyte::CompiledTemplateCache::instance().find(compiled_path, settings) != nullptr); + + prebyte::CompiledTemplateCache::instance().erase(compiled_path, settings); + REQUIRE(prebyte::CompiledTemplateCache::instance().find(compiled_path, settings) == nullptr); +} + +TEST_CASE(CompiledTemplateCache_store_in_memory_is_recently_validated) { + const std::filesystem::path root = cache_test_root("in-memory"); + const std::filesystem::path compiled_path = root / "template.pbc"; + const prebyte::EffectiveSettings settings = make_settings(); + erase_cache_entry(compiled_path, settings); + + prebyte::CompiledProgram program = compile_source("Hello\n", settings); + prebyte::CompiledTemplateCache::instance().store_in_memory(compiled_path, program, settings); + REQUIRE(prebyte::CompiledTemplateCache::instance().recently_validated(compiled_path, settings)); +} + +TEST_CASE(CompiledTemplateCache_store_loaded_is_recently_validated) { + const std::filesystem::path root = cache_test_root("loaded-validated"); + const std::filesystem::path compiled_path = root / "template.pbc"; + const prebyte::EffectiveSettings settings = make_settings(); + erase_cache_entry(compiled_path, settings); + + prebyte::CompiledProgram program = compile_source("Hello\n", settings); + prebyte::CompiledTemplateCache::instance().store_loaded(compiled_path, program, settings, 7); + REQUIRE(prebyte::CompiledTemplateCache::instance().recently_validated(compiled_path, settings)); +} + +TEST_CASE(CompiledTemplateCache_mark_validated_refreshes_ttl_after_expiry) { + const std::filesystem::path root = cache_test_root("validated-ttl"); + const std::filesystem::path compiled_path = root / "template.pbc"; + const prebyte::EffectiveSettings settings = make_settings(); + erase_cache_entry(compiled_path, settings); + + prebyte::CompiledProgram program = compile_source("Hello\n", settings); + prebyte::CompiledTemplateCache::instance().store_loaded(compiled_path, program, settings, 7); + REQUIRE(prebyte::CompiledTemplateCache::instance().recently_validated(compiled_path, settings)); + + std::this_thread::sleep_for(prebyte::FileMetadataCache::ttl() + std::chrono::milliseconds(50)); + REQUIRE(!prebyte::CompiledTemplateCache::instance().recently_validated(compiled_path, settings)); + + prebyte::CompiledTemplateCache::instance().mark_validated(compiled_path, settings); + REQUIRE(prebyte::CompiledTemplateCache::instance().recently_validated(compiled_path, settings)); +} + +TEST_CASE(CompiledTemplateCache_serializer_try_load_valid_uses_cached_program) { + const std::filesystem::path root = cache_test_root("serializer-cache"); + const std::filesystem::path source_path = root / "template.pbt"; + const prebyte::EffectiveSettings settings = make_settings(); + + prebyte::CompiledTemplateCompiler compiler; + prebyte::CompiledTemplateSerializer serializer; + const prebyte::CompiledProgram program = + compiler.compile_source("Hello {{ name }}\n", source_path, source_path, settings); + const std::filesystem::path compiled_path = serializer.compiled_path_for_source(source_path); + erase_cache_entry(compiled_path, settings); + + prebyte::CompiledTemplateCache::instance().store_loaded(compiled_path, program, settings, 99); + const prebyte::CompiledProgram* loaded = serializer.try_load_valid(compiled_path, settings); + + REQUIRE(loaded != nullptr); + REQUIRE_EQ(loaded->logical_path.string(), source_path.string()); +} diff --git a/tests/correctness/unit/CompiledTemplateWriterTests.cpp b/tests/correctness/unit/CompiledTemplateWriterTests.cpp new file mode 100644 index 0000000..c8c08db --- /dev/null +++ b/tests/correctness/unit/CompiledTemplateWriterTests.cpp @@ -0,0 +1,69 @@ +#include "TestHarness.h" + +#include "io/InputBuffer.h" +#include "runtime/compiled/CompiledTemplateWriter.h" + +#include +#include +#include + +namespace { + +std::filesystem::path writer_test_root(const std::string& name) { + const std::filesystem::path root = + std::filesystem::temp_directory_path() / "prebyte-compiled-template-writer-tests" / name; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + return root; +} + +bool wait_for_file(const std::filesystem::path& path, const std::string& expected, std::chrono::milliseconds timeout) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + std::error_code error; + if (std::filesystem::exists(path, error) && !error) { + const std::string actual = std::string(prebyte::InputBuffer::from_file(path).view()); + if (actual == expected) { + return true; + } + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + return false; +} + +} + +TEST_CASE(CompiledTemplateWriter_enqueue_writes_file_asynchronously) { + const std::filesystem::path root = writer_test_root("async-write"); + const std::filesystem::path output_path = root / "nested" / "template.pbc"; + const std::string bytes = "PBC1-test-bytes"; + + prebyte::CompiledTemplateWriter::instance().enqueue(output_path, bytes); + REQUIRE(wait_for_file(output_path, bytes, std::chrono::seconds(2))); +} + +TEST_CASE(CompiledTemplateWriter_ignores_empty_output_path) { + prebyte::CompiledTemplateWriter::instance().enqueue({}, "ignored"); +} + +TEST_CASE(CompiledTemplateWriter_deduplicates_pending_path_enqueue) { + const std::filesystem::path root = writer_test_root("dedupe"); + const std::filesystem::path output_path = root / "template.pbc"; + + prebyte::CompiledTemplateWriter::instance().enqueue(output_path, "first"); + prebyte::CompiledTemplateWriter::instance().enqueue(output_path, "second"); + + REQUIRE(wait_for_file(output_path, "first", std::chrono::seconds(2))); + const std::string actual = std::string(prebyte::InputBuffer::from_file(output_path).view()); + REQUIRE_EQ(actual, std::string("first")); +} + +TEST_CASE(CompiledTemplateWriter_creates_parent_directories) { + const std::filesystem::path root = writer_test_root("mkdirs"); + const std::filesystem::path output_path = root / "deep" / "nested" / "template.pbc"; + const std::string bytes = "compiled"; + + prebyte::CompiledTemplateWriter::instance().enqueue(output_path, bytes); + REQUIRE(wait_for_file(output_path, bytes, std::chrono::seconds(2))); +} diff --git a/tests/correctness/unit/CoverageSupportTests.cpp b/tests/correctness/unit/CoverageSupportTests.cpp new file mode 100644 index 0000000..ddfdcd7 --- /dev/null +++ b/tests/correctness/unit/CoverageSupportTests.cpp @@ -0,0 +1,90 @@ +#include "TestHarness.h" + +#include "config/ProfileMerger.h" +#include "config/SettingsLoader.h" +#include "runtime/cache/FileMetadataCache.h" +#include "runtime/resolution/IncludeResolver.h" +#include "support/Diagnostic.h" + +#include +#include + +namespace { + +std::filesystem::path resolver_test_root(const std::string& name) { + const std::filesystem::path root = std::filesystem::temp_directory_path() / "prebyte-resolver-tests" / name; + std::error_code error; + std::filesystem::remove_all(root, error); + std::filesystem::create_directories(root); + return root; +} + +void write_file(const std::filesystem::path& path, const std::string& content) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream file(path, std::ios::binary); + file << content; +} + +} + +TEST_CASE(ProfileMerger_merges_profiles_and_rejects_unknown_names) { + prebyte::SettingsData settings; + settings.variables["name"] = "Ada"; + prebyte::ProfileConfig profile; + profile.variables["mode"] = "debug"; + profile.include_paths.push_back("profile/includes"); + profile.ignore_names.push_back("secret"); + profile.rules["strict_variables"] = "true"; + profile.file_rules.push_back( + prebyte::FileRule{prebyte::RuleMatchKind::Extension, ".txt", "default_variable_value", "Fallback"}); + settings.profiles["dev"] = profile; + + prebyte::ProfileMerger merger; + const prebyte::SettingsData merged = merger.merge(settings, {"dev"}); + + REQUIRE_EQ(merged.variables.at("name"), std::string("Ada")); + REQUIRE_EQ(merged.variables.at("mode"), std::string("debug")); + REQUIRE_EQ(merged.include_paths.back().string(), std::string("profile/includes")); + REQUIRE_EQ(merged.ignore_names.back(), std::string("secret")); + REQUIRE_EQ(merged.rules.at("strict_variables"), std::string("true")); + REQUIRE_EQ(merged.file_rules.back().value, std::string("Fallback")); + REQUIRE_THROWS_AS(merger.merge(settings, {"missing"}), prebyte::DiagnosticError); +} + +TEST_CASE(FileMetadataCache_clear_and_reuse_probe_results) { + const std::filesystem::path root = resolver_test_root("metadata-cache"); + const std::filesystem::path path = root / "exists.txt"; + write_file(path, "hello"); + + prebyte::FileMetadataCache::instance().clear(); + const prebyte::FileMetadata first = prebyte::FileMetadataCache::instance().probe(path); + const prebyte::FileMetadata second = prebyte::FileMetadataCache::instance().probe(path); + + REQUIRE(first.exists); + REQUIRE(second.exists); + REQUIRE_EQ(first.mtime_ticks, second.mtime_ticks); + + prebyte::FileMetadataCache::instance().clear(); + REQUIRE(!prebyte::FileMetadataCache::instance().probe("").exists); +} + +TEST_CASE(IncludeResolver_rejects_overlong_and_overdeep_paths) { + const std::filesystem::path root = resolver_test_root("include-limits"); + write_file(root / "main.txt", "Hello\n"); + + prebyte::IncludeResolver resolver; + prebyte::RenderSession session; + prebyte::EffectiveSettings settings; + settings.allow_includes = true; + settings.include_paths.push_back(root); + + const std::string long_path(5000, 'a'); + REQUIRE_THROWS_AS(resolver.load(long_path, root / "main.txt", settings, session), prebyte::DiagnosticError); + + std::string deep_path; + for (int index = 0; index < 100; ++index) { + deep_path += "../"; + } + deep_path += "main.txt"; + REQUIRE_THROWS_AS(resolver.load(deep_path, root / "main.txt", settings, session), prebyte::DiagnosticError); +} diff --git a/tests/unit/DataTests.cpp b/tests/correctness/unit/DataTests.cpp similarity index 100% rename from tests/unit/DataTests.cpp rename to tests/correctness/unit/DataTests.cpp diff --git a/tests/unit/EngineFunctionTests.cpp b/tests/correctness/unit/EngineFunctionTests.cpp similarity index 96% rename from tests/unit/EngineFunctionTests.cpp rename to tests/correctness/unit/EngineFunctionTests.cpp index 321bec4..1841849 100644 --- a/tests/unit/EngineFunctionTests.cpp +++ b/tests/correctness/unit/EngineFunctionTests.cpp @@ -1,8 +1,8 @@ #include "TestHarness.h" #include "Engine.h" -#include "runtime/CompiledTemplateCompiler.h" -#include "runtime/CompiledTemplateSerializer.h" +#include "runtime/compiled/CompiledTemplateCompiler.h" +#include "runtime/compiled/CompiledTemplateSerializer.h" #include "support/Diagnostic.h" #include diff --git a/tests/unit/EngineTests.cpp b/tests/correctness/unit/EngineTests.cpp similarity index 100% rename from tests/unit/EngineTests.cpp rename to tests/correctness/unit/EngineTests.cpp diff --git a/tests/unit/ExpressionEvaluatorTests.cpp b/tests/correctness/unit/ExpressionEvaluatorTests.cpp similarity index 52% rename from tests/unit/ExpressionEvaluatorTests.cpp rename to tests/correctness/unit/ExpressionEvaluatorTests.cpp index abedf6c..03d153a 100644 --- a/tests/unit/ExpressionEvaluatorTests.cpp +++ b/tests/correctness/unit/ExpressionEvaluatorTests.cpp @@ -1,11 +1,13 @@ #include "TestHarness.h" -#include "runtime/BuiltinRegistry.h" -#include "runtime/CompiledTemplateCompiler.h" -#include "runtime/CompiledTemplateSerializer.h" -#include "runtime/ExpressionEvaluator.h" -#include "runtime/LuaExpressionEngine.h" +#include "runtime/expression/BuiltinRegistry.h" +#include "runtime/compiled/CompiledTemplateCompiler.h" +#include "runtime/compiled/CompiledTemplateSerializer.h" +#include "runtime/expression/ExpressionEvaluator.h" +#include "runtime/lua/LuaExpressionEngine.h" #include "support/Diagnostic.h" +#include "template/lexer/TemplateLexer.h" +#include "template/parser/TemplateParser.h" #include @@ -34,6 +36,27 @@ void register_functions(prebyte::RenderSession& session, const prebyte::Compiled } } +prebyte::Value evaluate_expression(const std::string& source, prebyte::RenderSession& session, + prebyte::EffectiveSettings settings = {}) { + const std::string wrapped = "{{ " + source + " }}"; + prebyte::TemplateLexer lexer(wrapped, "inline"); + prebyte::TemplateParser parser(lexer.lex()); + const auto document = parser.parse_document(); + const prebyte::InterpolationNode* interpolation = nullptr; + for (const auto& child : document->children) { + if (child->kind == prebyte::TemplateNodeKind::Interpolation) { + interpolation = static_cast(child.get()); + break; + } + } + if (interpolation == nullptr || interpolation->expression == nullptr) { + throw std::runtime_error("Expected interpolation expression in: " + source); + } + prebyte::BuiltinRegistry builtins; + prebyte::ExpressionEvaluator evaluator(builtins); + return evaluator.evaluate(*interpolation->expression, settings, session, "inline"); +} + } TEST_CASE(ExpressionEvaluator_evaluate_template_function_call_directly) { @@ -133,3 +156,76 @@ TEST_CASE(LuaExpressionEngine_initializes_runtime_and_evaluates) { REQUIRE(session.lua_runtime != nullptr); REQUIRE_EQ(value.to_string(), std::string("42")); } + +TEST_CASE(ExpressionEvaluator_evaluates_literals_member_access_and_index) { + prebyte::RenderSession session; + session.variables.set("name", "Ada"); + session.variables.set("user.name", "Ada"); + session.variables.set_value("items", prebyte::Value::list(prebyte::Data::Array{ + prebyte::Data("Ada"), + prebyte::Data("Grace"), + })); + + REQUIRE(evaluate_expression("(true)", session).to_bool()); + REQUIRE_EQ(evaluate_expression("name", session).to_string(), std::string("Ada")); + REQUIRE_EQ(evaluate_expression("user.name", session).to_string(), std::string("Ada")); + REQUIRE_EQ(evaluate_expression("items[0]", session).to_string(), std::string("Ada")); + REQUIRE_EQ(evaluate_expression("len(items)", session).to_string(), std::string("2")); +} + +TEST_CASE(ExpressionEvaluator_evaluates_args_index_and_grouped_expression) { + prebyte::RenderSession session; + session.args = {"first", "second"}; + + REQUIRE_EQ(evaluate_expression("ARGS[0]", session).to_string(), std::string("first")); + REQUIRE(evaluate_expression("(true)", session).to_bool()); +} + +TEST_CASE(ExpressionEvaluator_evaluates_comparisons_equality_and_in) { + prebyte::RenderSession session; + session.variables.set("count", "2"); + session.variables.set("label", "Ada"); + session.variables.set_value("items", prebyte::Value::list(prebyte::Data::Array{ + prebyte::Data("Ada"), + prebyte::Data("Grace"), + })); + session.variables.set_value("user", prebyte::Value::object(prebyte::Data::Map{ + {"name", prebyte::Data("Ada")}, + })); + + REQUIRE(evaluate_expression("count == 2", session).to_bool()); + REQUIRE(evaluate_expression("count != 3", session).to_bool()); + REQUIRE(evaluate_expression("count < 3", session).to_bool()); + REQUIRE(evaluate_expression("count <= 2", session).to_bool()); + REQUIRE(evaluate_expression("count > 1", session).to_bool()); + REQUIRE(evaluate_expression("count >= 2", session).to_bool()); + REQUIRE(evaluate_expression("\"a\" in label", session).to_bool()); + REQUIRE(evaluate_expression("\"Ada\" in items", session).to_bool()); + REQUIRE(evaluate_expression("\"name\" in user", session).to_bool()); +} + +TEST_CASE(ExpressionEvaluator_evaluates_unary_not_and_filters) { + prebyte::RenderSession session; + session.variables.set("name", " ada "); + + REQUIRE(evaluate_expression("!false", session).to_bool()); + REQUIRE_EQ(evaluate_expression("name | trim | upper", session).to_string(), std::string("ADA")); +} + +TEST_CASE(ExpressionEvaluator_evaluates_lua_call_expression) { + prebyte::BuiltinRegistry builtins; + prebyte::ExpressionEvaluator evaluator(builtins); + prebyte::EffectiveSettings settings; + prebyte::RenderSession session; + prebyte::LuaCallExpr expression("return 7"); + REQUIRE_EQ(evaluator.evaluate(expression, settings, session, "inline").to_string(), std::string("7")); +} + +TEST_CASE(ExpressionEvaluator_rejects_structured_comparisons_and_invalid_args) { + prebyte::RenderSession session; + session.variables.set_value("left", prebyte::Value::object(prebyte::Data::Map{{"a", prebyte::Data(1)}})); + session.variables.set_value("right", prebyte::Value::object(prebyte::Data::Map{{"a", prebyte::Data(2)}})); + + REQUIRE_THROWS_AS(evaluate_expression("left == right", session), prebyte::DiagnosticError); + REQUIRE_THROWS_AS(evaluate_expression("ARGS", session), prebyte::DiagnosticError); +} diff --git a/tests/correctness/unit/FileParserTests.cpp b/tests/correctness/unit/FileParserTests.cpp new file mode 100644 index 0000000..9651c37 --- /dev/null +++ b/tests/correctness/unit/FileParserTests.cpp @@ -0,0 +1,134 @@ +#include "TestHarness.h" + +#include "parser/FileParser.h" + +#include +#include +#include +#include + +namespace { + +void write_parser_file(const std::filesystem::path& path, const std::string& content) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream file(path, std::ios::binary); + file << content; +} + +std::filesystem::path file_parser_test_root(const std::string& name) { + const std::filesystem::path root = std::filesystem::temp_directory_path() / "prebyte-file-parser-tests" / name; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + return root; +} + +void require_runtime_error_with_prefix(const std::function& action, std::string_view prefix) { + try { + action(); + REQUIRE(false); + } catch (const std::runtime_error& error) { + REQUIRE(std::string(error.what()).find(std::string(prefix)) == 0); + } +} + +} + +TEST_CASE(FileParser_rejects_empty_missing_and_unsupported_paths) { + prebyte::FileParser parser; + const std::filesystem::path root = file_parser_test_root("invalid-paths"); + + REQUIRE_THROWS_AS(parser.parse(""), std::runtime_error); + REQUIRE_THROWS_AS(parser.parse((root / "missing.json").string()), std::runtime_error); + write_parser_file(root / "notes.txt", "plain text"); + REQUIRE_THROWS_AS(parser.parse((root / "notes.txt").string()), std::runtime_error); +} + +TEST_CASE(FileParser_parses_supported_extensions) { + const std::filesystem::path root = file_parser_test_root("supported-extensions"); + write_parser_file(root / "data.json", R"({"name":"Ada","items":[1,2]})"); + write_parser_file(root / "settings.yaml", "name: Ada\n"); + write_parser_file(root / "profile.yml", "name: Grace\n"); + write_parser_file(root / "server.ini", "[server]\nhost = localhost\n"); + write_parser_file(root / "legacy.cfg", "name = Linus\n"); + write_parser_file(root / "sample.env", "NAME=Alan\n"); + write_parser_file(root / "config.toml", "name = \"Katherine\"\n"); + + prebyte::FileParser parser; + + REQUIRE_EQ(parser.parse((root / "data.json").string()).as_map().at("name").as_string(), std::string("Ada")); + REQUIRE_EQ(parser.parse((root / "settings.yaml").string()).as_map().at("name").as_string(), std::string("Ada")); + REQUIRE_EQ(parser.parse((root / "profile.yml").string()).as_map().at("name").as_string(), std::string("Grace")); + REQUIRE_EQ(parser.parse((root / "server.ini").string()).as_map().at("server").as_map().at("host").as_string(), + std::string("localhost")); + REQUIRE_EQ(parser.parse((root / "legacy.cfg").string()).as_map().at("name").as_string(), std::string("Linus")); + REQUIRE_EQ(parser.parse((root / "sample.env").string()).as_map().at("NAME").as_string(), std::string("Alan")); + REQUIRE_EQ(parser.parse((root / "config.toml").string()).as_map().at("name").as_string(), std::string("Katherine")); +} + +TEST_CASE(FileParser_rejects_invalid_content_for_all_formats) { + const std::filesystem::path root = file_parser_test_root("invalid-content"); + write_parser_file(root / "broken.json", R"({"name":"Ada" trailing)"); + write_parser_file(root / "broken.yaml", "missing colon line\n"); + write_parser_file(root / "broken.yml", "missing colon line\n"); + write_parser_file(root / "broken.ini", "; comment only\n"); + write_parser_file(root / "broken.cfg", "plain text without section or equals\n"); + write_parser_file(root / "broken.env", "INVALID LINE WITHOUT EQUALS\n"); + write_parser_file(root / "broken.toml", "broken line without equals\n"); + + prebyte::FileParser parser; + const std::string cannot_parse_prefix = "Cannot parse file with the selected parser:"; + + for (const char* filename : + {"broken.json", "broken.yaml", "broken.yml", "broken.ini", "broken.cfg", "broken.env", "broken.toml"}) { + require_runtime_error_with_prefix( + [&]() { (void)parser.parse((root / filename).string()); }, cannot_parse_prefix); + } +} + +TEST_CASE(FileParser_rejects_valid_content_with_wrong_extension) { + const std::filesystem::path root = file_parser_test_root("wrong-extension"); + write_parser_file(root / "data.txt", R"({"name":"Ada"})"); + write_parser_file(root / "settings.txt", "name: Ada\n"); + write_parser_file(root / "profile.txt", "name: Grace\n"); + write_parser_file(root / "server.txt", "[server]\nhost = localhost\n"); + write_parser_file(root / "legacy.txt", "name = Linus\n"); + write_parser_file(root / "sample.txt", "NAME=Alan\n"); + write_parser_file(root / "config.txt", "name = \"Katherine\"\n"); + + prebyte::FileParser parser; + const std::string unsupported_prefix = "Unsupported file format:"; + + for (const char* filename : + {"data.txt", "settings.txt", "profile.txt", "server.txt", "legacy.txt", "sample.txt", "config.txt"}) { + require_runtime_error_with_prefix( + [&]() { (void)parser.parse((root / filename).string()); }, unsupported_prefix); + } +} + +TEST_CASE(FileParser_rejects_additional_invalid_payloads) { + const std::filesystem::path root = file_parser_test_root("additional-invalid"); + write_parser_file(root / "empty-object-trailing.json", "{} trailing"); + write_parser_file(root / "unclosed-string.json", R"({"name":"x})"); + write_parser_file(root / "deep-toml.toml", [&]() { + std::string toml = "["; + for (int index = 0; index < 200; ++index) { + if (index != 0) { + toml.push_back('.'); + } + toml += "section"; + } + toml += "]\nvalue = 1"; + return toml; + }()); + write_parser_file(root / "empty.ini", "\n\n"); + write_parser_file(root / "empty.cfg", "# only comments\n"); + + prebyte::FileParser parser; + const std::string cannot_parse_prefix = "Cannot parse file with the selected parser:"; + + for (const char* filename : + {"empty-object-trailing.json", "unclosed-string.json", "deep-toml.toml", "empty.ini", "empty.cfg"}) { + require_runtime_error_with_prefix( + [&]() { (void)parser.parse((root / filename).string()); }, cannot_parse_prefix); + } +} diff --git a/tests/unit/FilterRegistryTests.cpp b/tests/correctness/unit/FilterRegistryTests.cpp similarity index 97% rename from tests/unit/FilterRegistryTests.cpp rename to tests/correctness/unit/FilterRegistryTests.cpp index 7f12782..ee225cb 100644 --- a/tests/unit/FilterRegistryTests.cpp +++ b/tests/correctness/unit/FilterRegistryTests.cpp @@ -1,6 +1,6 @@ #include "TestHarness.h" -#include "runtime/FilterRegistry.h" +#include "runtime/expression/FilterRegistry.h" #include "support/Diagnostic.h" TEST_CASE(FilterRegistry_apply_string_filters_and_default) { diff --git a/tests/unit/FunctionRendererTests.cpp b/tests/correctness/unit/FunctionRendererTests.cpp similarity index 96% rename from tests/unit/FunctionRendererTests.cpp rename to tests/correctness/unit/FunctionRendererTests.cpp index b202e02..2502dff 100644 --- a/tests/unit/FunctionRendererTests.cpp +++ b/tests/correctness/unit/FunctionRendererTests.cpp @@ -1,11 +1,11 @@ #include "TestHarness.h" #include "config/RuleResolver.h" -#include "runtime/BuiltinRegistry.h" -#include "runtime/CompiledTemplateCompiler.h" -#include "runtime/CompiledTemplateSerializer.h" -#include "runtime/IncludeResolver.h" -#include "runtime/Renderer.h" +#include "runtime/expression/BuiltinRegistry.h" +#include "runtime/compiled/CompiledTemplateCompiler.h" +#include "runtime/compiled/CompiledTemplateSerializer.h" +#include "runtime/resolution/IncludeResolver.h" +#include "runtime/render/Renderer.h" #include "support/Diagnostic.h" #include diff --git a/tests/unit/IoTests.cpp b/tests/correctness/unit/IoTests.cpp similarity index 100% rename from tests/unit/IoTests.cpp rename to tests/correctness/unit/IoTests.cpp diff --git a/tests/correctness/unit/LuaRuntimeTests.cpp b/tests/correctness/unit/LuaRuntimeTests.cpp new file mode 100644 index 0000000..ade9305 --- /dev/null +++ b/tests/correctness/unit/LuaRuntimeTests.cpp @@ -0,0 +1,351 @@ +#include "TestHarness.h" + +#include "datatypes/Data.h" +#include "runtime/lua/LuaRuntime.h" +#include "support/Diagnostic.h" +#include "support/SourceSpan.h" + +#include +#include +#include + +namespace { + +prebyte::SourceSpan make_span(const char* file_path = "runtime.txt", std::size_t line = 5) { + prebyte::SourceSpan span; + span.file_path = file_path; + span.start.line = line; + return span; +} + +prebyte::Value execute_lua(prebyte::LuaRuntime& runtime, + const std::string& source, + prebyte::RenderSession& session, + const prebyte::EffectiveSettings& settings, + prebyte::LuaChunkMode mode = prebyte::LuaChunkMode::Predicate, + const std::filesystem::path& current_file = "runtime.txt", + const prebyte::SourceSpan& span = make_span()) { + return runtime.execute(source, mode, settings, session, current_file, span); +} + +void expect_lua_diagnostic(const auto& callable, + const std::string& expected_message_substring, + const std::string& expected_code = "LUA001") { + try { + callable(); + throw std::runtime_error("expected DiagnosticError"); + } catch (const prebyte::DiagnosticError& error) { + REQUIRE_EQ(error.diagnostic().code, expected_code); + REQUIRE(error.diagnostic().message.find(expected_message_substring) != std::string::npos); + } +} + +} + +TEST_CASE(LuaRuntime_execute_returns_scalars_and_nil) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + prebyte::EffectiveSettings settings; + + REQUIRE_EQ(execute_lua(runtime, "return 42", session, settings).to_string(), std::string("42")); + REQUIRE_EQ(execute_lua(runtime, "return 'hello'", session, settings).to_string(), std::string("hello")); + REQUIRE(execute_lua(runtime, "return true", session, settings).to_bool()); + REQUIRE(!execute_lua(runtime, "return false", session, settings).to_bool()); + REQUIRE(execute_lua(runtime, "return nil", session, settings).is_null()); +} + +TEST_CASE(LuaRuntime_execute_reads_session_variables_args_and_vars) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + session.variables.set("name", "Ada"); + session.variables.set_value("count", prebyte::Value(3.0)); + session.args = {"alpha", "beta"}; + + prebyte::EffectiveSettings settings; + + const prebyte::Value value = execute_lua( + runtime, + "return name .. '|' .. tostring(count) .. '|' .. vars.name .. '|' .. ARGS[0] .. '|' .. ARGS[1]", + session, + settings); + + REQUIRE_EQ(value.to_string(), std::string("Ada|3.0|Ada|alpha|beta")); +} + +TEST_CASE(LuaRuntime_execute_exposes_registered_helpers) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + session.variables.set("name", " Ada Lovelace "); + + prebyte::EffectiveSettings settings; + + REQUIRE_EQ(execute_lua(runtime, "return upper(name)", session, settings).to_string(), std::string(" ADA LOVELACE ")); + REQUIRE_EQ(execute_lua(runtime, "return lower(upper(name))", session, settings).to_string(), std::string(" ada lovelace ")); + REQUIRE_EQ(execute_lua(runtime, "return trim(name)", session, settings).to_string(), std::string("Ada Lovelace")); + REQUIRE(execute_lua(runtime, "return starts_with(name, ' Ada')", session, settings).to_bool()); + REQUIRE(execute_lua(runtime, "return ends_with(name, 'lace ')", session, settings).to_bool()); + REQUIRE_EQ(execute_lua(runtime, "return upper(123)", session, settings).to_string(), std::string("123")); +} + +TEST_CASE(LuaRuntime_execute_exposes_builtins_and_strict_variables) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + prebyte::EffectiveSettings settings; + settings.strict_variables = true; + + const prebyte::Value value = execute_lua(runtime, + "return __FILE__ .. '|' .. __LINE__ .. '|' .. tostring(strict_variables)", + session, + settings, + prebyte::LuaChunkMode::Predicate, + "runtime.txt", + make_span("runtime.txt", 12)); + + REQUIRE_EQ(value.to_string(), std::string("runtime.txt|12|true")); +} + +TEST_CASE(LuaRuntime_execute_returns_structured_values) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + prebyte::EffectiveSettings settings; + + const prebyte::Value value = execute_lua( + runtime, + R"(return { + tags = { "admin", "editor" }, + user = { name = "Ada", active = true }, + total = 2 + })", + session, + settings); + + REQUIRE(value.is_object()); + REQUIRE_EQ(value.member("total")->to_string(), std::string("2")); + REQUIRE_EQ(value.member("user")->member("name")->to_string(), std::string("Ada")); + REQUIRE(value.member("user")->member("active")->to_bool()); + REQUIRE_EQ(value.member("tags")->length(), static_cast(2)); +} + +TEST_CASE(LuaRuntime_execute_supports_all_chunk_modes) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + prebyte::EffectiveSettings settings; + + const std::string source = "return 'mode-ok'"; + + REQUIRE_EQ(execute_lua(runtime, source, session, settings, prebyte::LuaChunkMode::InlineValue).to_string(), + std::string("mode-ok")); + REQUIRE_EQ(execute_lua(runtime, source, session, settings, prebyte::LuaChunkMode::Predicate).to_string(), + std::string("mode-ok")); + REQUIRE_EQ(execute_lua(runtime, source, session, settings, prebyte::LuaChunkMode::BlockValue).to_string(), + std::string("mode-ok")); +} + +TEST_CASE(LuaRuntime_chunk_cache_counts_hits_and_misses) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + prebyte::EffectiveSettings settings; + + const std::string source = "return 7"; + + REQUIRE_EQ(session.lua_cache_hits, static_cast(0)); + REQUIRE_EQ(session.lua_cache_misses, static_cast(0)); + + REQUIRE_EQ(execute_lua(runtime, source, session, settings).to_string(), std::string("7")); + REQUIRE_EQ(session.lua_cache_hits, static_cast(0)); + REQUIRE_EQ(session.lua_cache_misses, static_cast(1)); + + REQUIRE_EQ(execute_lua(runtime, source, session, settings).to_string(), std::string("7")); + REQUIRE_EQ(session.lua_cache_hits, static_cast(1)); + REQUIRE_EQ(session.lua_cache_misses, static_cast(1)); + + REQUIRE_EQ(execute_lua(runtime, "return 8", session, settings).to_string(), std::string("8")); + REQUIRE_EQ(session.lua_cache_hits, static_cast(1)); + REQUIRE_EQ(session.lua_cache_misses, static_cast(2)); + + REQUIRE_EQ(execute_lua(runtime, source, session, settings, prebyte::LuaChunkMode::BlockValue).to_string(), + std::string("7")); + REQUIRE_EQ(session.lua_cache_hits, static_cast(1)); + REQUIRE_EQ(session.lua_cache_misses, static_cast(3)); +} + +TEST_CASE(LuaRuntime_execute_isolates_lua_globals_between_runs) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + prebyte::EffectiveSettings settings; + + const std::string source = "counter = (counter or 0) + 1; return counter"; + + REQUIRE_EQ(execute_lua(runtime, source, session, settings).to_string(), std::string("1")); + REQUIRE_EQ(execute_lua(runtime, source, session, settings).to_string(), std::string("1")); +} + +TEST_CASE(LuaRuntime_execute_recovers_after_runtime_error) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + prebyte::EffectiveSettings settings; + + expect_lua_diagnostic([&]() { static_cast(execute_lua(runtime, "error('boom')", session, settings)); }, + "boom"); + + REQUIRE_EQ(execute_lua(runtime, "return 'recovered'", session, settings).to_string(), std::string("recovered")); +} + +TEST_CASE(LuaRuntime_execute_rejects_invalid_lua_syntax) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + prebyte::EffectiveSettings settings; + + expect_lua_diagnostic([&]() { static_cast(execute_lua(runtime, "return )", session, settings)); }, ")"); +} + +TEST_CASE(LuaRuntime_execute_rejects_missing_variable_access) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + prebyte::EffectiveSettings settings; + + expect_lua_diagnostic([&]() { static_cast(execute_lua(runtime, "return missing_field.name", session, settings)); }, + "missing_field"); +} + +TEST_CASE(LuaRuntime_execute_rejects_bad_helper_arity) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + prebyte::EffectiveSettings settings; + + expect_lua_diagnostic([&]() { static_cast(execute_lua(runtime, "return starts_with('Ada')", session, settings)); }, + "bad argument #2"); +} + +TEST_CASE(LuaRuntime_execute_rejects_bad_helper_argument_types) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + prebyte::EffectiveSettings settings; + + expect_lua_diagnostic([&]() { static_cast(execute_lua(runtime, "return upper({})", session, settings)); }, + "bad argument #1"); +} + +TEST_CASE(LuaRuntime_execute_enforces_instruction_limit) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + prebyte::EffectiveSettings settings; + settings.lua_instruction_limit = 10; + settings.max_render_time_ms = std::numeric_limits::max(); + + expect_lua_diagnostic( + [&]() { + static_cast(execute_lua(runtime, + "local sum = 0 for i = 1, 1000 do sum = sum + i end return sum", + session, + settings)); + }, + "Lua instruction limit exceeded"); + + REQUIRE_EQ(execute_lua(runtime, "return 1", session, settings).to_string(), std::string("1")); +} + +TEST_CASE(LuaRuntime_execute_enforces_memory_limit) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + prebyte::EffectiveSettings settings; + settings.lua_memory_limit_bytes = 4096; + + expect_lua_diagnostic( + [&]() { static_cast(execute_lua(runtime, "return string.rep('x', 100000)", session, settings)); }, + "Lua memory limit exceeded"); + + settings.lua_memory_limit_bytes = 4 * 1024 * 1024; + REQUIRE_EQ(execute_lua(runtime, "return 'small'", session, settings).to_string(), std::string("small")); +} + +TEST_CASE(LuaRuntime_execute_enforces_render_time_limit) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + prebyte::EffectiveSettings settings; + settings.max_render_time_ms = 0; + session.start_time = std::chrono::steady_clock::now() - std::chrono::milliseconds(1); + + expect_lua_diagnostic( + [&]() { static_cast(execute_lua(runtime, "while true do end return 'x'", session, settings)); }, + "Render time limit exceeded"); +} + +TEST_CASE(LuaRuntime_sandbox_removes_dangerous_globals) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + prebyte::EffectiveSettings settings; + + REQUIRE(execute_lua(runtime, "return os == nil", session, settings).to_bool()); + REQUIRE(execute_lua(runtime, "return io == nil", session, settings).to_bool()); + REQUIRE(execute_lua(runtime, "return debug == nil", session, settings).to_bool()); + REQUIRE(execute_lua(runtime, "return package == nil", session, settings).to_bool()); + REQUIRE(execute_lua(runtime, "return require == nil", session, settings).to_bool()); + REQUIRE(execute_lua(runtime, "return dofile == nil", session, settings).to_bool()); + REQUIRE(execute_lua(runtime, "return loadfile == nil", session, settings).to_bool()); + + expect_lua_diagnostic([&]() { static_cast(execute_lua(runtime, "return os.execute('id')", session, settings)); }, + "attempt to index a nil value"); +} + +TEST_CASE(LuaRuntime_sandbox_blocks_loadfile_and_require) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + prebyte::EffectiveSettings settings; + + expect_lua_diagnostic( + [&]() { static_cast(execute_lua(runtime, "return loadfile('runtime.txt')", session, settings)); }, + "attempt to call a nil value"); + + expect_lua_diagnostic([&]() { static_cast(execute_lua(runtime, "return require('os')", session, settings)); }, + "attempt to call a nil value"); +} + +TEST_CASE(LuaRuntime_sandbox_still_allows_lua_load_for_safe_chunks) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + prebyte::EffectiveSettings settings; + + REQUIRE_EQ(execute_lua(runtime, "return load('return 41')()", session, settings).to_string(), std::string("41")); +} + +TEST_CASE(LuaRuntime_execute_allows_safe_standard_library_features) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + prebyte::EffectiveSettings settings; + + REQUIRE_EQ(execute_lua(runtime, "return string.upper('ada')", session, settings).to_string(), std::string("ADA")); + REQUIRE_EQ(execute_lua(runtime, "return table.concat({'a', 'b'}, '-')", session, settings).to_string(), + std::string("a-b")); + REQUIRE_EQ(execute_lua(runtime, "return math.max(2, 5)", session, settings).to_string(), std::string("5")); +} + +TEST_CASE(LuaRuntime_execute_honors_strict_variables_false) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + prebyte::EffectiveSettings settings; + settings.strict_variables = false; + + REQUIRE(!execute_lua(runtime, "return strict_variables", session, settings).to_bool()); +} + +TEST_CASE(LuaRuntime_execute_reports_span_in_syntax_errors) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + prebyte::EffectiveSettings settings; + + try { + static_cast(execute_lua(runtime, + "return (", + session, + settings, + prebyte::LuaChunkMode::Predicate, + "broken.lua", + make_span("broken.lua", 99))); + throw std::runtime_error("expected DiagnosticError"); + } catch (const prebyte::DiagnosticError& error) { + REQUIRE_EQ(error.diagnostic().code, std::string("LUA001")); + REQUIRE_EQ(error.diagnostic().span.file_path, std::string("broken.lua")); + REQUIRE_EQ(error.diagnostic().span.start.line, static_cast(99)); + } +} diff --git a/tests/correctness/unit/LuaValueBridgeTests.cpp b/tests/correctness/unit/LuaValueBridgeTests.cpp new file mode 100644 index 0000000..57ad515 --- /dev/null +++ b/tests/correctness/unit/LuaValueBridgeTests.cpp @@ -0,0 +1,252 @@ +#include "TestHarness.h" + +#include "datatypes/Data.h" +#include "runtime/lua/LuaHeaders.h" +#include "runtime/lua/LuaRuntime.h" +#include "runtime/lua/LuaValueBridge.h" +#include "support/SourceSpan.h" + +namespace { + +struct LuaStateGuard { + lua_State* state = luaL_newstate(); + + ~LuaStateGuard() { + if (state != nullptr) { + lua_close(state); + } + } +}; + +prebyte::SourceSpan make_span(std::size_t line = 7) { + prebyte::SourceSpan span; + span.file_path = "bridge.txt"; + span.start.line = line; + return span; +} + +prebyte::Value read_lua_value(prebyte::LuaValueBridge& bridge, lua_State* state, const auto& push_value) { + push_value(state); + const prebyte::Value value = bridge.read_value(state, -1); + lua_pop(state, 1); + return value; +} + +} + +TEST_CASE(LuaValueBridge_read_value_converts_lua_scalars_and_nil) { + prebyte::LuaValueBridge bridge; + LuaStateGuard guard; + + REQUIRE(read_lua_value(bridge, guard.state, [](lua_State* state) { lua_pushnil(state); }).is_null()); + REQUIRE(read_lua_value(bridge, guard.state, [](lua_State* state) { lua_pushboolean(state, 1); }).to_bool()); + REQUIRE(!read_lua_value(bridge, guard.state, [](lua_State* state) { lua_pushboolean(state, 0); }).to_bool()); + REQUIRE_EQ(read_lua_value(bridge, guard.state, [](lua_State* state) { lua_pushnumber(state, 3.5); }).to_string(), + std::string("3.5")); + REQUIRE_EQ(read_lua_value(bridge, guard.state, + [](lua_State* state) { lua_pushlstring(state, "hello", 5); }).to_string(), + std::string("hello")); +} + +TEST_CASE(LuaValueBridge_read_value_converts_array_like_tables) { + prebyte::LuaValueBridge bridge; + LuaStateGuard guard; + + const prebyte::Value value = read_lua_value(bridge, guard.state, [](lua_State* state) { + lua_newtable(state); + lua_pushlstring(state, "Ada", 3); + lua_rawseti(state, -2, 1); + lua_pushlstring(state, "Grace", 5); + lua_rawseti(state, -2, 2); + }); + + REQUIRE(value.is_list()); + REQUIRE_EQ(value.length(), static_cast(2)); + REQUIRE_EQ(value.index(0)->to_string(), std::string("Ada")); + REQUIRE_EQ(value.index(1)->to_string(), std::string("Grace")); +} + +TEST_CASE(LuaValueBridge_read_value_converts_object_tables) { + prebyte::LuaValueBridge bridge; + LuaStateGuard guard; + + const prebyte::Value value = read_lua_value(bridge, guard.state, [](lua_State* state) { + lua_newtable(state); + lua_pushboolean(state, 1); + lua_setfield(state, -2, "active"); + lua_pushnumber(state, 2); + lua_setfield(state, -2, "count"); + lua_pushlstring(state, "Ada", 3); + lua_setfield(state, -2, "name"); + }); + + REQUIRE(value.is_object()); + REQUIRE(value.member("active")->to_bool()); + REQUIRE_EQ(value.member("count")->to_string(), std::string("2")); + REQUIRE_EQ(value.member("name")->to_string(), std::string("Ada")); +} + +TEST_CASE(LuaValueBridge_read_value_treats_sparse_tables_as_objects) { + prebyte::LuaValueBridge bridge; + LuaStateGuard guard; + + const prebyte::Value sparse = read_lua_value(bridge, guard.state, [](lua_State* state) { + lua_newtable(state); + lua_pushlstring(state, "Grace", 5); + lua_rawseti(state, -2, 2); + }); + + REQUIRE(sparse.is_object()); + REQUIRE(!sparse.is_list()); + REQUIRE(sparse.member("2").has_value()); + REQUIRE_EQ(sparse.member("2")->to_string(), std::string("Grace")); +} + +TEST_CASE(LuaValueBridge_read_value_converts_numeric_object_keys_to_strings) { + prebyte::LuaValueBridge bridge; + LuaStateGuard guard; + + const prebyte::Value value = read_lua_value(bridge, guard.state, [](lua_State* state) { + lua_newtable(state); + lua_pushlstring(state, "kept", 4); + lua_setfield(state, -2, "kept"); + lua_pushinteger(state, 42); + lua_pushlstring(state, "hidden", 6); + lua_rawset(state, -3); + }); + + REQUIRE(value.is_object()); + REQUIRE(value.member("kept").has_value()); + REQUIRE(value.member("42").has_value()); + REQUIRE_EQ(value.member("42")->to_string(), std::string("hidden")); + REQUIRE(!value.member("hidden").has_value()); +} + +TEST_CASE(LuaValueBridge_read_value_converts_unsupported_lua_types_to_empty_string) { + prebyte::LuaValueBridge bridge; + LuaStateGuard guard; + + const prebyte::Value value = read_lua_value(bridge, guard.state, [](lua_State* state) { + lua_pushcfunction(state, [](lua_State*) -> int { return 0; }); + }); + + REQUIRE(!value.is_null()); + REQUIRE_EQ(value.to_string(), std::string()); +} + +TEST_CASE(LuaValueBridge_push_context_exposes_variables_args_vars_and_settings) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + session.variables.set("name", "Ada"); + session.variables.set_value("count", prebyte::Value(2.0)); + session.args = {"first", "second"}; + + prebyte::EffectiveSettings settings; + settings.strict_variables = true; + + const prebyte::Value value = runtime.execute( + "return name .. '|' .. tostring(count) .. '|' .. vars.name .. '|' .. ARGS[0] .. '|' .. ARGS[1] .. '|' .. " + "tostring(strict_variables) .. '|' .. __LINE__ .. '|' .. __FILE__", + prebyte::LuaChunkMode::Predicate, + settings, + session, + "bridge.txt", + make_span(9)); + + REQUIRE_EQ(value.to_string(), std::string("Ada|2.0|Ada|first|second|true|9|bridge.txt")); +} + +TEST_CASE(LuaValueBridge_round_trips_nested_structured_values) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + prebyte::Data::Map user; + user["name"] = prebyte::Data("Ada"); + user["active"] = prebyte::Data(true); + prebyte::Data::Array tags; + tags.push_back(prebyte::Data("admin")); + tags.push_back(prebyte::Data("editor")); + session.variables.set_value("user", prebyte::Value::object(user)); + session.variables.set_value("tags", prebyte::Value::list(tags)); + + const prebyte::Value from_lua = runtime.execute( + R"(return { + user = { name = user.name, active = user.active }, + tags = { tags[1], tags[2] }, + total = 2 + })", + prebyte::LuaChunkMode::Predicate, + prebyte::EffectiveSettings{}, + session, + "bridge.txt", + make_span()); + + REQUIRE(from_lua.is_object()); + REQUIRE(from_lua.member("total")->to_string() == std::string("2")); + REQUIRE(from_lua.member("user")->member("name")->to_string() == std::string("Ada")); + REQUIRE(from_lua.member("user")->member("active")->to_bool()); + REQUIRE(from_lua.member("tags")->is_list()); + REQUIRE_EQ(from_lua.member("tags")->length(), static_cast(2)); + REQUIRE_EQ(from_lua.member("tags")->index(0)->to_string(), std::string("admin")); + REQUIRE_EQ(from_lua.member("tags")->index(1)->to_string(), std::string("editor")); +} + +TEST_CASE(LuaValueBridge_read_value_rejects_non_integer_array_keys) { + prebyte::LuaValueBridge bridge; + LuaStateGuard guard; + + const prebyte::Value mixed = read_lua_value(bridge, guard.state, [](lua_State* state) { + lua_newtable(state); + lua_pushlstring(state, "Ada", 3); + lua_rawseti(state, -2, 1); + lua_pushlstring(state, "extra", 5); + lua_setfield(state, -2, "name"); + }); + + REQUIRE(mixed.is_object()); + REQUIRE(mixed.member("1").has_value()); + REQUIRE(mixed.member("name").has_value()); + + const prebyte::Value zero_indexed = read_lua_value(bridge, guard.state, [](lua_State* state) { + lua_newtable(state); + lua_pushlstring(state, "zero", 4); + lua_rawseti(state, -2, 0); + }); + + REQUIRE(zero_indexed.is_object()); + REQUIRE(zero_indexed.member("0").has_value()); +} + +TEST_CASE(LuaValueBridge_read_value_converts_float_table_keys) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + + const prebyte::Value value = runtime.execute(R"(return { [1.5] = "half" })", prebyte::LuaChunkMode::Predicate, + prebyte::EffectiveSettings{}, session, "bridge.txt", make_span()); + + REQUIRE(value.is_object()); + REQUIRE(value.member(std::to_string(1.5)).has_value()); + REQUIRE_EQ(value.member(std::to_string(1.5))->to_string(), std::string("half")); +} + +TEST_CASE(LuaValueBridge_push_context_respects_strict_variables_flag) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + + prebyte::EffectiveSettings strict_off; + strict_off.strict_variables = false; + + const prebyte::Value value = runtime.execute("return strict_variables", prebyte::LuaChunkMode::Predicate, + strict_off, session, "bridge.txt", make_span()); + + REQUIRE(!value.to_bool()); +} + +TEST_CASE(LuaValueBridge_execute_returns_lua_nil_as_null_value) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + + const prebyte::Value value = runtime.execute("return nil", prebyte::LuaChunkMode::Predicate, + prebyte::EffectiveSettings{}, session, "bridge.txt", make_span()); + + REQUIRE(value.is_null()); +} diff --git a/tests/unit/ParserTests.cpp b/tests/correctness/unit/ParserTests.cpp similarity index 85% rename from tests/unit/ParserTests.cpp rename to tests/correctness/unit/ParserTests.cpp index 067e05e..4e843c2 100644 --- a/tests/unit/ParserTests.cpp +++ b/tests/correctness/unit/ParserTests.cpp @@ -3,6 +3,8 @@ #include "parser/EnvParser.h" #include "parser/IniParser.h" #include "parser/JsonParser.h" +#include "parser/TomlParser.h" +#include "parser/YamlParser.h" #include #include @@ -140,3 +142,31 @@ TEST_CASE(EnvParser_invalid_lines_and_wrong_extension_fail) { REQUIRE(!parser.can_parse(invalid)); REQUIRE(!parser.can_parse(wrong_ext)); } + +TEST_CASE(YamlParser_parse_string_mappings_lists_and_scalars) { + prebyte::YamlParser parser; + const prebyte::Data mapping = parser.parse_string(R"( +name: Ada +active: true +score: 42 +roles: + - admin + - editor +)"); + REQUIRE_EQ(mapping.as_map().at("name").as_string(), std::string("Ada")); + REQUIRE(mapping.as_map().at("active").as_bool()); + REQUIRE_EQ(mapping.as_map().at("score").as_int(), 42); + REQUIRE_EQ(mapping.as_map().at("roles").as_array().size(), static_cast(2)); +} + +TEST_CASE(YamlParser_can_parse_valid_and_reject_invalid_files) { + const std::filesystem::path root = parser_test_root("yaml-file"); + const std::filesystem::path path = root / "settings.yaml"; + const std::filesystem::path wrong_ext = root / "settings.txt"; + write_parser_file(path, "name: Ada\n"); + write_parser_file(wrong_ext, "name: Ada\n"); + + prebyte::YamlParser parser; + REQUIRE(parser.can_parse(path)); + REQUIRE(!parser.can_parse(wrong_ext)); +} diff --git a/tests/unit/PrebyteEngineTests.cpp b/tests/correctness/unit/PrebyteEngineTests.cpp similarity index 98% rename from tests/unit/PrebyteEngineTests.cpp rename to tests/correctness/unit/PrebyteEngineTests.cpp index 59718a9..3d73f47 100644 --- a/tests/unit/PrebyteEngineTests.cpp +++ b/tests/correctness/unit/PrebyteEngineTests.cpp @@ -2,8 +2,8 @@ #include "PrebyteEngine.h" #include "io/InputBuffer.h" -#include "runtime/CompiledTemplateCompiler.h" -#include "runtime/CompiledTemplateSerializer.h" +#include "runtime/compiled/CompiledTemplateCompiler.h" +#include "runtime/compiled/CompiledTemplateSerializer.h" #include "support/Diagnostic.h" #include diff --git a/tests/unit/RendererTests.cpp b/tests/correctness/unit/RendererTests.cpp similarity index 99% rename from tests/unit/RendererTests.cpp rename to tests/correctness/unit/RendererTests.cpp index 8f11929..cc4e0f3 100644 --- a/tests/unit/RendererTests.cpp +++ b/tests/correctness/unit/RendererTests.cpp @@ -2,11 +2,11 @@ #include "datatypes/Data.h" #include "config/RuleResolver.h" -#include "runtime/BuiltinRegistry.h" -#include "runtime/CompiledTemplateCompiler.h" -#include "runtime/CompiledTemplateSerializer.h" -#include "runtime/IncludeResolver.h" -#include "runtime/Renderer.h" +#include "runtime/expression/BuiltinRegistry.h" +#include "runtime/compiled/CompiledTemplateCompiler.h" +#include "runtime/compiled/CompiledTemplateSerializer.h" +#include "runtime/resolution/IncludeResolver.h" +#include "runtime/render/Renderer.h" #include "support/Diagnostic.h" #include diff --git a/tests/unit/RuleResolverTests.cpp b/tests/correctness/unit/RuleResolverTests.cpp similarity index 100% rename from tests/unit/RuleResolverTests.cpp rename to tests/correctness/unit/RuleResolverTests.cpp diff --git a/tests/unit/SettingsLoaderTests.cpp b/tests/correctness/unit/SettingsLoaderTests.cpp similarity index 100% rename from tests/unit/SettingsLoaderTests.cpp rename to tests/correctness/unit/SettingsLoaderTests.cpp diff --git a/tests/unit/TemplateLexerTests.cpp b/tests/correctness/unit/TemplateLexerTests.cpp similarity index 93% rename from tests/unit/TemplateLexerTests.cpp rename to tests/correctness/unit/TemplateLexerTests.cpp index 264ae38..e60d9a1 100644 --- a/tests/unit/TemplateLexerTests.cpp +++ b/tests/correctness/unit/TemplateLexerTests.cpp @@ -51,3 +51,8 @@ TEST_CASE(TemplateLexer_fail_on_unclosed_tag_with_function_header) { prebyte::TemplateLexer lexer("{{ fn greet(name)", "inline"); REQUIRE_THROWS_AS(lexer.lex(), prebyte::DiagnosticError); } + +TEST_CASE(TemplateLexer_fail_on_truncated_string_escape) { + prebyte::TemplateLexer lexer("{{-\"\\", "inline"); + REQUIRE_THROWS_AS(lexer.lex(), prebyte::DiagnosticError); +} diff --git a/tests/unit/TemplateParserTests.cpp b/tests/correctness/unit/TemplateParserTests.cpp similarity index 96% rename from tests/unit/TemplateParserTests.cpp rename to tests/correctness/unit/TemplateParserTests.cpp index a342c0b..a4377cd 100644 --- a/tests/unit/TemplateParserTests.cpp +++ b/tests/correctness/unit/TemplateParserTests.cpp @@ -248,3 +248,19 @@ TEST_CASE(TemplateParser_reject_duplicate_function_parameter_name) { prebyte::TemplateParser parser(lexer.lex(), prebyte::TemplateParserOptions{.enable_loops = true}); REQUIRE_THROWS_AS(parser.parse_document(), prebyte::DiagnosticError); } + +TEST_CASE(TemplateParser_reject_deeply_nested_expression) { + std::string source = "{{ "; + for (int index = 0; index < 100; ++index) { + source.push_back('('); + } + source += "1"; + for (int index = 0; index < 100; ++index) { + source.push_back(')'); + } + source += " }}"; + + prebyte::TemplateLexer lexer(source, "inline"); + prebyte::TemplateParser parser(lexer.lex()); + REQUIRE_THROWS_AS(parser.parse_document(), prebyte::DiagnosticError); +} diff --git a/tests/correctness/unit/TomlParserTests.cpp b/tests/correctness/unit/TomlParserTests.cpp new file mode 100644 index 0000000..7d98d29 --- /dev/null +++ b/tests/correctness/unit/TomlParserTests.cpp @@ -0,0 +1,74 @@ +#include "TestHarness.h" + +#include "parser/TomlParser.h" + +#include +#include + +namespace { + +void write_parser_file(const std::filesystem::path& path, const std::string& content) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream file(path, std::ios::binary); + file << content; +} + +std::filesystem::path parser_test_root(const std::string& name) { + const std::filesystem::path root = std::filesystem::temp_directory_path() / "prebyte-toml-parser-tests" / name; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + return root; +} + +} + +TEST_CASE(TomlParser_reject_deeply_nested_table_path) { + std::string toml = "["; + for (int index = 0; index < 200; ++index) { + if (index != 0) { + toml.push_back('.'); + } + toml += "section"; + } + toml += "]\nvalue = 1"; + + prebyte::TomlParser parser; + REQUIRE_THROWS_AS(parser.parse_string(toml), std::runtime_error); +} + +TEST_CASE(TomlParser_parse_string_sections_values_and_arrays) { + prebyte::TomlParser parser; + const prebyte::Data data = parser.parse_string(R"( +name = Ada +enabled = true +count = 2 +ratio = 3.5 +tags = [ "a", "b" ] +[server] +host = "localhost" +port = 8080 +[database.credentials] +user = "ada" +)"); + + REQUIRE_EQ(data.as_map().at("name").as_string(), std::string("Ada")); + REQUIRE(data.as_map().at("enabled").as_bool()); + REQUIRE_EQ(data.as_map().at("count").as_int(), 2); + REQUIRE_EQ(data.as_map().at("tags").as_array().size(), static_cast(2)); + REQUIRE_EQ(data.as_map().at("server").as_map().at("host").as_string(), std::string("localhost")); + REQUIRE_EQ(data.as_map().at("database").as_map().at("credentials").as_map().at("user").as_string(), + std::string("ada")); +} + +TEST_CASE(TomlParser_can_parse_valid_and_reject_invalid_files) { + const std::filesystem::path root = parser_test_root("toml-file"); + const std::filesystem::path path = root / "settings.toml"; + const std::filesystem::path wrong_ext = root / "settings.txt"; + write_parser_file(path, "name = Ada\n"); + write_parser_file(wrong_ext, "name = Ada\n"); + + prebyte::TomlParser parser; + REQUIRE(parser.can_parse(path)); + REQUIRE(!parser.can_parse(wrong_ext)); + REQUIRE_THROWS_AS(parser.parse_string("broken line"), std::runtime_error); +} diff --git a/tests/correctness/unit/ValueResolverTests.cpp b/tests/correctness/unit/ValueResolverTests.cpp new file mode 100644 index 0000000..cfe20f5 --- /dev/null +++ b/tests/correctness/unit/ValueResolverTests.cpp @@ -0,0 +1,420 @@ +#include "TestHarness.h" + +#include "datatypes/Data.h" +#include "runtime/expression/BuiltinRegistry.h" +#include "runtime/expression/ValueResolver.h" +#include "support/Diagnostic.h" +#include "support/SourceSpan.h" + +#include + +namespace { + +prebyte::SourceSpan make_span(const char* file_path = "resolver.txt", std::size_t line = 3) { + prebyte::SourceSpan span; + span.file_path = file_path; + span.start.line = line; + return span; +} + +prebyte::Value resolve_name(prebyte::ValueResolver& resolver, + const std::string& name, + prebyte::RenderSession& session, + const prebyte::EffectiveSettings& settings = {}, + const std::filesystem::path& current_file = "resolver.txt") { + return resolver.resolve_identifier(name, make_span(), settings, session, current_file); +} + +prebyte::Value resolve_member(prebyte::ValueResolver& resolver, + const prebyte::Value& base, + std::string_view member, + const prebyte::EffectiveSettings& settings = {}) { + return resolver.resolve_member(base, member, make_span(), settings); +} + +prebyte::Value resolve_index(prebyte::ValueResolver& resolver, + const prebyte::Value& base, + const prebyte::Value& index, + const prebyte::EffectiveSettings& settings = {}) { + return resolver.resolve_index(base, index, make_span(), settings); +} + +void expect_runtime_error(const auto& callable, + const std::string& expected_message_substring, + const std::string& expected_code = "RUNTIME001") { + try { + callable(); + throw std::runtime_error("expected DiagnosticError"); + } catch (const prebyte::DiagnosticError& error) { + REQUIRE_EQ(error.diagnostic().code, expected_code); + REQUIRE(error.diagnostic().message.find(expected_message_substring) != std::string::npos); + } +} + +prebyte::Value make_user_object() { + prebyte::Data::Map address; + address["city"] = prebyte::Data("London"); + prebyte::Data::Map user; + user["name"] = prebyte::Data("Ada"); + user["address"] = prebyte::Data(std::move(address)); + return prebyte::Value::object(user); +} + +} + +TEST_CASE(ValueResolver_resolve_identifier_reads_variables_and_builtins) { + prebyte::BuiltinRegistry builtins; + prebyte::ValueResolver resolver(builtins); + prebyte::RenderSession session; + session.variables.set("name", "Ada"); + + REQUIRE_EQ(resolve_name(resolver, "name", session).to_string(), std::string("Ada")); + REQUIRE_EQ(resolve_name(resolver, "__LINE__", session, {}, "resolver.txt").to_string(), std::string("3")); +} + +TEST_CASE(ValueResolver_resolve_identifier_reads_render_args) { + prebyte::BuiltinRegistry builtins; + prebyte::ValueResolver resolver(builtins); + prebyte::RenderSession session; + session.args = {"alpha", "beta"}; + + REQUIRE_EQ(resolve_name(resolver, "ARGS[0]", session).to_string(), std::string("alpha")); + REQUIRE_EQ(resolve_name(resolver, "ARGS[1]", session).to_string(), std::string("beta")); +} + +TEST_CASE(ValueResolver_resolve_identifier_resolves_member_paths) { + prebyte::BuiltinRegistry builtins; + prebyte::ValueResolver resolver(builtins); + prebyte::RenderSession session; + session.variables.set_value("user", make_user_object()); + + REQUIRE_EQ(resolve_name(resolver, "user.name", session).to_string(), std::string("Ada")); + REQUIRE_EQ(resolve_name(resolver, "user.address.city", session).to_string(), std::string("London")); +} + +TEST_CASE(ValueResolver_resolve_identifier_prefers_scoped_values_over_variables) { + prebyte::BuiltinRegistry builtins; + prebyte::ValueResolver resolver(builtins); + prebyte::RenderSession session; + session.variables.set("name", "Global"); + + session.set_local_value("name", prebyte::Value(std::string("Local"))); + REQUIRE_EQ(resolve_name(resolver, "name", session).to_string(), std::string("Local")); + + prebyte::RenderSession::LoopFrame frame; + frame.binding_name_0 = "name"; + frame.binding_value_0 = prebyte::Value(std::string("Loop")); + frame.loop_index0 = 0; + frame.loop_size = 2; + session.push_loop_frame(std::move(frame)); + REQUIRE_EQ(resolve_name(resolver, "name", session).to_string(), std::string("Loop")); +} + +TEST_CASE(ValueResolver_resolve_identifier_reads_loop_metadata) { + prebyte::BuiltinRegistry builtins; + prebyte::ValueResolver resolver(builtins); + prebyte::RenderSession session; + + prebyte::RenderSession::LoopFrame frame; + frame.binding_name_0 = "item"; + frame.binding_value_0 = prebyte::Value("Ada"); + frame.loop_index0 = 1; + frame.loop_size = 3; + session.push_loop_frame(std::move(frame)); + + REQUIRE_EQ(resolve_name(resolver, "loop.index", session).to_string(), std::string("2")); + REQUIRE_EQ(resolve_name(resolver, "loop.index0", session).to_string(), std::string("1")); + REQUIRE(!resolve_name(resolver, "loop.first", session).to_bool()); + REQUIRE(!resolve_name(resolver, "loop.last", session).to_bool()); +} + +TEST_CASE(ValueResolver_resolve_identifier_honors_case_sensitive_variables) { + prebyte::BuiltinRegistry builtins; + prebyte::ValueResolver resolver(builtins); + prebyte::RenderSession session; + session.variables.set("Name", "Ada"); + + prebyte::EffectiveSettings sensitive; + sensitive.case_sensitive_variables = true; + REQUIRE_EQ(resolve_name(resolver, "Name", session, sensitive).to_string(), std::string("Ada")); + REQUIRE(resolve_name(resolver, "name", session, sensitive).is_null()); + + prebyte::EffectiveSettings insensitive; + insensitive.case_sensitive_variables = false; + REQUIRE_EQ(resolve_name(resolver, "name", session, insensitive).to_string(), std::string("Ada")); +} + +TEST_CASE(ValueResolver_resolve_identifier_returns_empty_string_for_ignored_names) { + prebyte::BuiltinRegistry builtins; + prebyte::ValueResolver resolver(builtins); + prebyte::RenderSession session; + session.variables.set("secret", "Hidden"); + session.ignore_names.insert("secret"); + + const prebyte::Value value = resolve_name(resolver, "secret", session); + REQUIRE(!value.is_null()); + REQUIRE_EQ(value.to_string(), std::string()); +} + +TEST_CASE(ValueResolver_resolve_identifier_applies_trim_and_max_length) { + prebyte::BuiltinRegistry builtins; + prebyte::ValueResolver resolver(builtins); + prebyte::RenderSession session; + session.variables.set("label", " Ada "); + + prebyte::EffectiveSettings settings; + settings.trim = true; + settings.has_max_variable_length = true; + settings.max_variable_length = 3; + + REQUIRE_EQ(resolve_name(resolver, "label", session, settings).to_string(), std::string("Ada")); + REQUIRE_EQ(resolver.normalize_string(" Hello World ", settings), std::string("Hel")); +} + +TEST_CASE(ValueResolver_resolve_identifier_reads_allowed_environment_variables) { + prebyte::test::ScopedEnvironmentVariable allowed_env("PREBYTE_VALUE_RESOLVER_ENV", "Grace"); + + prebyte::BuiltinRegistry builtins; + prebyte::ValueResolver resolver(builtins); + prebyte::RenderSession session; + + prebyte::EffectiveSettings settings; + settings.allow_env = true; + + REQUIRE_EQ(resolve_name(resolver, "PREBYTE_VALUE_RESOLVER_ENV", session, settings).to_string(), + std::string("Grace")); +} + +TEST_CASE(ValueResolver_resolve_identifier_uses_default_value_when_not_strict) { + prebyte::BuiltinRegistry builtins; + prebyte::ValueResolver resolver(builtins); + prebyte::RenderSession session; + + prebyte::EffectiveSettings settings; + settings.strict_variables = false; + settings.default_variable_value = "Fallback"; + + REQUIRE_EQ(resolve_name(resolver, "missing", session, settings).to_string(), std::string("Fallback")); + + settings.default_variable_value = " padded "; + settings.trim = true; + REQUIRE_EQ(resolve_name(resolver, "missing", session, settings).to_string(), std::string("padded")); +} + +TEST_CASE(ValueResolver_resolve_identifier_returns_empty_value_without_default) { + prebyte::BuiltinRegistry builtins; + prebyte::ValueResolver resolver(builtins); + prebyte::RenderSession session; + + prebyte::EffectiveSettings settings; + settings.strict_variables = false; + + REQUIRE(resolve_name(resolver, "missing", session, settings).is_null()); + REQUIRE_EQ(resolve_name(resolver, "missing", session, settings).to_string(), std::string()); +} + +TEST_CASE(ValueResolver_resolve_identifier_rejects_unknown_variables_when_strict) { + prebyte::BuiltinRegistry builtins; + prebyte::ValueResolver resolver(builtins); + prebyte::RenderSession session; + + prebyte::EffectiveSettings settings; + settings.strict_variables = true; + + expect_runtime_error([&]() { static_cast(resolve_name(resolver, "missing", session, settings)); }, + "Unknown variable: missing"); +} + +TEST_CASE(ValueResolver_resolve_identifier_rejects_invalid_args_references) { + prebyte::BuiltinRegistry builtins; + prebyte::ValueResolver resolver(builtins); + prebyte::RenderSession session; + session.args = {"only"}; + + expect_runtime_error([&]() { static_cast(resolve_name(resolver, "ARGS", session)); }, + "ARGS must be accessed as ARGS[index]"); + + expect_runtime_error([&]() { static_cast(resolve_name(resolver, "ARGS[x]", session)); }, + "Invalid ARGS index"); + + expect_runtime_error([&]() { static_cast(resolve_name(resolver, "ARGS[-1]", session)); }, + "Invalid ARGS index"); + + expect_runtime_error([&]() { static_cast(resolve_name(resolver, "ARGS[9]", session)); }, + "ARGS index out of range"); +} + +TEST_CASE(ValueResolver_resolve_identifier_rejects_forbidden_environment_variables) { + prebyte::test::ScopedEnvironmentVariable blocked_env("PREBYTE_VALUE_RESOLVER_BLOCKED", "Secret"); + + prebyte::BuiltinRegistry builtins; + prebyte::ValueResolver resolver(builtins); + prebyte::RenderSession session; + + prebyte::EffectiveSettings settings; + settings.allow_env = true; + settings.forbidden_env_vars.insert("PREBYTE_VALUE_RESOLVER_BLOCKED"); + + expect_runtime_error( + [&]() { static_cast(resolve_name(resolver, "PREBYTE_VALUE_RESOLVER_BLOCKED", session, settings)); }, + "Access to forbidden environment variable"); +} + +TEST_CASE(ValueResolver_resolve_identifier_rejects_invalid_member_paths) { + prebyte::BuiltinRegistry builtins; + prebyte::ValueResolver resolver(builtins); + prebyte::RenderSession session; + session.variables.set_value("user", make_user_object()); + + expect_runtime_error([&]() { static_cast(resolve_name(resolver, ".name", session)); }, "Invalid member access"); + expect_runtime_error([&]() { static_cast(resolve_name(resolver, "user.", session)); }, "Invalid member access"); + expect_runtime_error([&]() { static_cast(resolve_name(resolver, "user..city", session)); }, + "Invalid member access"); +} + +TEST_CASE(ValueResolver_resolve_identifier_reports_missing_nested_members_when_strict) { + prebyte::BuiltinRegistry builtins; + prebyte::ValueResolver resolver(builtins); + prebyte::RenderSession session; + session.variables.set_value("user", make_user_object()); + + prebyte::EffectiveSettings settings; + settings.strict_variables = true; + + expect_runtime_error([&]() { static_cast(resolve_name(resolver, "user.missing", session, settings)); }, + "Unknown variable: user.missing"); + expect_runtime_error( + [&]() { static_cast(resolve_name(resolver, "user.address.country", session, settings)); }, + "Unknown variable: user.address.country"); +} + +TEST_CASE(ValueResolver_resolve_identifier_rejects_member_access_on_non_object_paths) { + prebyte::BuiltinRegistry builtins; + prebyte::ValueResolver resolver(builtins); + prebyte::RenderSession session; + session.variables.set_value("items", prebyte::Value::list(prebyte::Data::Array{prebyte::Data("Ada")})); + + expect_runtime_error([&]() { static_cast(resolve_name(resolver, "items.name", session)); }, + "Cannot access member 'name' on non-object value"); +} + +TEST_CASE(ValueResolver_resolve_member_reads_object_fields) { + prebyte::BuiltinRegistry builtins; + prebyte::ValueResolver resolver(builtins); + const prebyte::Value user = make_user_object(); + + REQUIRE_EQ(resolve_member(resolver, user, "name").to_string(), std::string("Ada")); +} + +TEST_CASE(ValueResolver_resolve_member_handles_missing_and_invalid_access) { + prebyte::BuiltinRegistry builtins; + prebyte::ValueResolver resolver(builtins); + const prebyte::Value user = make_user_object(); + const prebyte::Value list = prebyte::Value::list(prebyte::Data::Array{prebyte::Data("Ada")}); + + prebyte::EffectiveSettings strict; + strict.strict_variables = true; + expect_runtime_error([&]() { static_cast(resolve_member(resolver, user, "missing", strict)); }, + "Unknown variable: missing"); + + prebyte::EffectiveSettings lenient; + lenient.strict_variables = false; + lenient.default_variable_value = "Fallback"; + REQUIRE_EQ(resolve_member(resolver, user, "missing", lenient).to_string(), std::string("Fallback")); + REQUIRE_EQ(resolve_member(resolver, prebyte::Value(), "missing", lenient).to_string(), std::string("Fallback")); + + expect_runtime_error([&]() { static_cast(resolve_member(resolver, list, "name", strict)); }, + "Cannot access member 'name' on non-object value"); +} + +TEST_CASE(ValueResolver_resolve_index_reads_list_and_object_entries) { + prebyte::BuiltinRegistry builtins; + prebyte::ValueResolver resolver(builtins); + + const prebyte::Value list = prebyte::Value::list(prebyte::Data::Array{ + prebyte::Data("Ada"), + prebyte::Data("Grace"), + }); + const prebyte::Value object = prebyte::Value::object(prebyte::Data::Map{ + {"name", prebyte::Data("Ada")}, + {"2", prebyte::Data("two")}, + }); + + REQUIRE_EQ(resolve_index(resolver, list, prebyte::Value(0.0)).to_string(), std::string("Ada")); + REQUIRE_EQ(resolve_index(resolver, list, prebyte::Value(1.0)).to_string(), std::string("Grace")); + REQUIRE_EQ(resolve_index(resolver, object, prebyte::Value(std::string("name"))).to_string(), std::string("Ada")); + REQUIRE_EQ(resolve_index(resolver, object, prebyte::Value(2.0)).to_string(), std::string("two")); +} + +TEST_CASE(ValueResolver_resolve_index_handles_missing_entries) { + prebyte::BuiltinRegistry builtins; + prebyte::ValueResolver resolver(builtins); + + const prebyte::Value list = prebyte::Value::list(prebyte::Data::Array{prebyte::Data("Ada")}); + const prebyte::Value object = prebyte::Value::object(prebyte::Data::Map{{"name", prebyte::Data("Ada")}}); + + prebyte::EffectiveSettings lenient; + lenient.strict_variables = false; + lenient.default_variable_value = "Fallback"; + + REQUIRE_EQ(resolve_index(resolver, list, prebyte::Value(9.0), lenient).to_string(), std::string("Fallback")); + REQUIRE_EQ(resolve_index(resolver, object, prebyte::Value(std::string("missing")), lenient).to_string(), + std::string("Fallback")); + REQUIRE_EQ(resolve_index(resolver, prebyte::Value(), prebyte::Value(0.0), lenient).to_string(), + std::string("Fallback")); + + prebyte::EffectiveSettings strict; + strict.strict_variables = true; + expect_runtime_error([&]() { static_cast(resolve_index(resolver, list, prebyte::Value(9.0), strict)); }, + "Unknown variable: [index]"); + expect_runtime_error( + [&]() { static_cast(resolve_index(resolver, object, prebyte::Value(std::string("missing")), strict)); }, + "Unknown variable: missing"); +} + +TEST_CASE(ValueResolver_resolve_index_rejects_invalid_indexes_and_targets) { + prebyte::BuiltinRegistry builtins; + prebyte::ValueResolver resolver(builtins); + + const prebyte::Value list = prebyte::Value::list(prebyte::Data::Array{prebyte::Data("Ada")}); + const prebyte::Value object = prebyte::Value::object(prebyte::Data::Map{{"name", prebyte::Data("Ada")}}); + const prebyte::Value scalar = prebyte::Value(std::string("plain")); + + expect_runtime_error([&]() { static_cast(resolve_index(resolver, list, prebyte::Value(-1.0))); }, + "List index must be non-negative integer"); + expect_runtime_error([&]() { static_cast(resolve_index(resolver, list, prebyte::Value(std::string("x")))); }, + "List index must be non-negative integer"); + expect_runtime_error( + [&]() { static_cast(resolve_index(resolver, object, prebyte::Value::list(prebyte::Data::Array{}))); }, + "Object key must be scalar value"); + expect_runtime_error( + [&]() { static_cast(resolve_index(resolver, object, prebyte::Value::object(prebyte::Data::Map{}))); }, + "Object key must be scalar value"); + expect_runtime_error([&]() { static_cast(resolve_index(resolver, scalar, prebyte::Value(0.0))); }, + "Cannot index non-container value"); +} + +TEST_CASE(ValueResolver_resolve_identifier_prefers_builtins_over_variables) { + prebyte::BuiltinRegistry builtins; + prebyte::ValueResolver resolver(builtins); + prebyte::RenderSession session; + session.variables.set("__LINE__", "999"); + + REQUIRE_EQ(resolve_name(resolver, "__LINE__", session, {}, "resolver.txt").to_string(), std::string("3")); +} + +TEST_CASE(ValueResolver_resolve_identifier_skips_env_when_not_allowed) { + prebyte::test::ScopedEnvironmentVariable allowed_env("PREBYTE_VALUE_RESOLVER_HIDDEN", "Secret"); + + prebyte::BuiltinRegistry builtins; + prebyte::ValueResolver resolver(builtins); + prebyte::RenderSession session; + + prebyte::EffectiveSettings settings; + settings.allow_env = false; + settings.strict_variables = false; + settings.default_variable_value = "Fallback"; + + REQUIRE_EQ(resolve_name(resolver, "PREBYTE_VALUE_RESOLVER_HIDDEN", session, settings).to_string(), + std::string("Fallback")); +} diff --git a/tests/correctness/unit/ValueTests.cpp b/tests/correctness/unit/ValueTests.cpp new file mode 100644 index 0000000..9b1c242 --- /dev/null +++ b/tests/correctness/unit/ValueTests.cpp @@ -0,0 +1,92 @@ +#include "TestHarness.h" + +#include "datatypes/Data.h" +#include "runtime/core/Value.h" + +TEST_CASE(Value_string_falsey_tokens_to_bool_false) { + REQUIRE(!prebyte::Value(std::string("false")).to_bool()); + REQUIRE(!prebyte::Value(std::string("0")).to_bool()); + REQUIRE(!prebyte::Value(std::string("off")).to_bool()); + REQUIRE(!prebyte::Value(std::string(" no ")).to_bool()); + REQUIRE(!prebyte::Value(std::string()).to_bool()); +} + +TEST_CASE(Value_string_truthy_tokens_to_bool_true) { + REQUIRE(prebyte::Value(std::string("true")).to_bool()); + REQUIRE(prebyte::Value(std::string("1")).to_bool()); + REQUIRE(prebyte::Value(std::string("Ada")).to_bool()); + REQUIRE(prebyte::Value(std::string(" yes ")).to_bool()); +} + +TEST_CASE(Value_object_truthiness_depends_on_members) { + prebyte::Data::Map user; + user["name"] = prebyte::Data("Ada"); + + REQUIRE(prebyte::Value::object(user).to_bool()); + REQUIRE(!prebyte::Value::object({}).to_bool()); +} + +TEST_CASE(Value_list_truthiness_depends_on_items) { + prebyte::Data::Array items; + items.push_back(prebyte::Data("Ada")); + + REQUIRE(prebyte::Value::list(items).to_bool()); + REQUIRE(!prebyte::Value::list({}).to_bool()); +} + +TEST_CASE(Value_length_follows_len_semantics) { + prebyte::Data::Map user; + user["name"] = prebyte::Data("Ada"); + + prebyte::Data::Array items; + items.push_back(prebyte::Data("Ada")); + items.push_back(prebyte::Data("Grace")); + + REQUIRE_EQ(prebyte::Value(std::string("Ada")).length(), static_cast(3)); + REQUIRE_EQ(prebyte::Value::object(user).length(), static_cast(1)); + REQUIRE_EQ(prebyte::Value::list(items).length(), static_cast(2)); + REQUIRE_EQ(prebyte::Value().length(), static_cast(0)); + REQUIRE_EQ(prebyte::Value(true).length(), static_cast(0)); + REQUIRE_EQ(prebyte::Value(42.0).length(), static_cast(0)); +} + +TEST_CASE(Value_borrowed_data_and_scalar_helpers) { + const prebyte::Data string_data("hello"); + const prebyte::Value borrowed = prebyte::Value::borrowed_data(string_data); + REQUIRE_EQ(borrowed.to_string(), std::string("hello")); + REQUIRE(borrowed.to_bool()); + REQUIRE_EQ(borrowed.length(), static_cast(5)); + + const prebyte::Data int_data(7); + const prebyte::Value borrowed_int = prebyte::Value::borrowed_data(int_data); + REQUIRE(borrowed_int.to_bool()); + REQUIRE_EQ(borrowed_int.try_as_number().value(), 7.0); + + REQUIRE(prebyte::Value(1.0).compare_scalar(prebyte::Value(2.0)).value() == std::strong_ordering::less); + REQUIRE(prebyte::Value(3.0).compare_scalar(prebyte::Value(3.0)).value() == std::strong_ordering::equal); + REQUIRE(prebyte::Value(std::string("b")).compare_scalar(prebyte::Value(std::string("a"))).value() + == std::strong_ordering::greater); + REQUIRE(prebyte::Value(1.0).equals(prebyte::Value(1.0))); +} + +TEST_CASE(Value_member_index_and_collection_views) { + prebyte::Data::Map object; + object["name"] = prebyte::Data("Ada"); + prebyte::Data::Array list; + list.push_back(prebyte::Data("Ada")); + list.push_back(prebyte::Data("Grace")); + + const prebyte::Value object_value = prebyte::Value::object(object); + const prebyte::Value list_value = prebyte::Value::list(list); + + REQUIRE(object_value.member("name").has_value()); + REQUIRE_EQ(object_value.member("name")->to_string(), std::string("Ada")); + REQUIRE(list_value.index(1).has_value()); + REQUIRE_EQ(list_value.index(1)->to_string(), std::string("Grace")); + REQUIRE_EQ(object_value.object_items().size(), static_cast(1)); + REQUIRE_EQ(list_value.list_items().size(), static_cast(2)); + + std::string output; + prebyte::Value(std::string("hello")).append_to(output); + REQUIRE_EQ(output, std::string("hello")); +} diff --git a/tests/unit/VariableDefinitionParserTests.cpp b/tests/correctness/unit/VariableDefinitionParserTests.cpp similarity index 99% rename from tests/unit/VariableDefinitionParserTests.cpp rename to tests/correctness/unit/VariableDefinitionParserTests.cpp index 491e63c..62a9d09 100644 --- a/tests/unit/VariableDefinitionParserTests.cpp +++ b/tests/correctness/unit/VariableDefinitionParserTests.cpp @@ -2,7 +2,7 @@ #include "config/VariableDefinitionParser.h" -#include "runtime/FileMetadataCache.h" +#include "runtime/cache/FileMetadataCache.h" #include #include diff --git a/tests/unit/VariableStoreTests.cpp b/tests/correctness/unit/VariableStoreTests.cpp similarity index 97% rename from tests/unit/VariableStoreTests.cpp rename to tests/correctness/unit/VariableStoreTests.cpp index b9f4623..dab08f9 100644 --- a/tests/unit/VariableStoreTests.cpp +++ b/tests/correctness/unit/VariableStoreTests.cpp @@ -1,6 +1,6 @@ #include "TestHarness.h" -#include "runtime/VariableStore.h" +#include "runtime/core/VariableStore.h" #include #include diff --git a/tests/fault_tolerance/README.md b/tests/fault_tolerance/README.md new file mode 100644 index 0000000..516f476 --- /dev/null +++ b/tests/fault_tolerance/README.md @@ -0,0 +1,19 @@ +# Fault tolerance + +Stress and regression coverage for malformed input, parser limits, and runtime edge cases. + +- `fuzz/` — libFuzzer targets, curated seeds, generated corpus (`corpus/` is gitignored), and `fuzz/regression/` replay inputs. +- `regression/` — deterministic tests for previously found fuzzer crashes and limit violations. +- `sanitizers/` — documentation for ASan/UBSan, TSan, and MSan runs (see README there). + +Run fuzzers: `make fuzz` or `scripts/ci/run_fuzzers.sh` (includes regression replay at the end). + +Import a new crash input: + +```bash +python3 scripts/ci/import_fuzz_regression.py --target fuzz_template_lexer --crash /path/to/crash +``` + +Replay regression corpus only: `make fuzz-regression` or `scripts/ci/run_fuzz_regression.sh`. + +Run sanitizers: `make sanitize`, `make tsan`, `make msan` (re-execute the full `prebyte_tests` binary under instrumentation). diff --git a/tests/fault_tolerance/fuzz/AppRunnerFuzz.cpp b/tests/fault_tolerance/fuzz/AppRunnerFuzz.cpp new file mode 100644 index 0000000..41ea883 --- /dev/null +++ b/tests/fault_tolerance/fuzz/AppRunnerFuzz.cpp @@ -0,0 +1,216 @@ +#include "app/AppRunner.h" +#include "app/Command.h" +#include "support/FuzzRuntimeReset.h" +#include "support/Diagnostic.h" +#include "support/FuzzTempDir.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr std::size_t kMaxTemplateBytes = 256 * 1024; +constexpr const char* kSettingsExtensions[] = {".yaml", ".json", ".toml", ".ini"}; +constexpr const char* kExplainTopics[] = {"rule", "rules", "ignore", "profile", "truthiness", "lua", "args", "unknown"}; + +void write_file(const std::filesystem::path& path, const std::string& content) { + std::error_code error; + std::filesystem::create_directories(path.parent_path(), error); + std::ofstream file(path, std::ios::binary); + file << content; +} + +void seed_support_files(const std::filesystem::path& root) { + write_file(root / "header.txt", "Header {{ name }}\n"); + write_file(root / "partial.pbt", "<{{ loop.index }}:{{ item }}>\n"); + write_file(root / "nested" / "child.pbt", "Child {{ name }}\n"); + write_file(root / "cycle_a.pbt", "{{ include \"cycle_b.pbt\" }}"); + write_file(root / "cycle_b.pbt", "{{ include \"cycle_a.pbt\" }}"); +} + +std::string random_rule_arg(FuzzedDataProvider& provider, const std::filesystem::path& root) { + switch (provider.ConsumeIntegralInRange(0, 10)) { + case 0: + return std::string("strict_variables=") + (provider.ConsumeBool() ? "true" : "false"); + case 1: + return std::string("allow_includes=") + (provider.ConsumeBool() ? "true" : "false"); + case 2: + return "max_include_depth=" + std::to_string(provider.ConsumeIntegralInRange(0, 8)); + case 3: + return "max_render_time_ms=" + std::to_string(provider.ConsumeIntegralInRange(0, 50)); + case 4: + return "max_output_size_bytes=" + std::to_string(provider.ConsumeIntegralInRange(64, 8192)); + case 5: + return "max_loop_iteration=" + std::to_string(provider.ConsumeIntegralInRange(1, 100)); + case 6: + return "lua_instruction_limit=" + std::to_string(provider.ConsumeIntegralInRange(1000, 50000)); + case 7: + return "lua_memory_limit_bytes=" + std::to_string(provider.ConsumeIntegralInRange(512 * 1024, 2 * 1024 * 1024)); + case 8: + return std::string("output_encoding=") + (provider.ConsumeBool() ? "utf-16" : "utf-8"); + case 9: + return std::string("error_on_false_input=") + (provider.ConsumeBool() ? "true" : "false"); + case 10: + default: + return "include_path=" + root.string(); + } +} + +std::string random_define_arg(FuzzedDataProvider& provider) { + const std::string name = provider.ConsumeRandomLengthString(12); + if (name.empty()) { + return "name=Ada"; + } + return name + "=" + provider.ConsumeRandomLengthString(24); +} + +prebyte::CommandMode pick_mode(FuzzedDataProvider& provider) { + switch (provider.ConsumeIntegralInRange(0, 15)) { + case 0: + case 1: + case 2: + case 3: + case 4: + case 5: + case 6: + case 7: + case 8: + case 9: + return prebyte::CommandMode::Render; + case 10: + return prebyte::CommandMode::ListRules; + case 11: + return prebyte::CommandMode::ListVars; + case 12: + return prebyte::CommandMode::ListProfiles; + case 13: + return prebyte::CommandMode::ListIgnores; + case 14: + return prebyte::CommandMode::Explain; + case 15: + return prebyte::CommandMode::Help; + default: + return prebyte::CommandMode::Version; + } +} + +void populate_cli_overrides(FuzzedDataProvider& provider, prebyte::Command& command, + const std::filesystem::path& root) { + const std::size_t rule_count = provider.ConsumeIntegralInRange(0, 4); + for (std::size_t index = 0; index < rule_count; ++index) { + command.rule_args.push_back(random_rule_arg(provider, root)); + } + + const std::size_t define_count = provider.ConsumeIntegralInRange(0, 3); + for (std::size_t index = 0; index < define_count; ++index) { + command.define_args.push_back(random_define_arg(provider)); + } + + if (provider.ConsumeBool()) { + command.profile_names.push_back(provider.ConsumeRandomLengthString(12)); + } + if (provider.ConsumeBool()) { + command.ignore_names.push_back(provider.ConsumeRandomLengthString(12)); + } + if (provider.ConsumeBool()) { + command.include_paths.push_back(root); + } + if (provider.ConsumeBool()) { + command.include_paths.push_back(root / "nested"); + } + + const std::size_t render_arg_count = provider.ConsumeIntegralInRange(0, 2); + for (std::size_t index = 0; index < render_arg_count; ++index) { + command.render_args.push_back(provider.ConsumeRandomLengthString(16)); + } + + command.benchmark = provider.ConsumeBool(); + command.debug = provider.ConsumeBool(); +} + +std::optional maybe_write_settings(FuzzedDataProvider& provider, + const std::filesystem::path& root) { + if (!provider.ConsumeBool()) { + return std::nullopt; + } + + const int extension_index = provider.ConsumeIntegralInRange(0, 3); + const std::string content = provider.ConsumeRandomLengthString(512); + const std::filesystem::path path = + root / (std::string("settings") + kSettingsExtensions[extension_index]); + write_file(path, content.empty() ? "name = Ada\n" : content); + return path; +} + +} + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { + if (size == 0) { + return 0; + } + + FuzzedDataProvider provider(data, size); + fuzz_reset_runtime_state(); + + const prebyte::CommandMode mode = pick_mode(provider); + const bool use_inline_input = provider.ConsumeBool(); + const std::string partial_name = provider.ConsumeRandomLengthString(48); + const std::string partial_source = provider.ConsumeRandomLengthString(512); + + prebyte::Command command; + command.mode = mode; + + FuzzTempDir temp_dir; + const std::filesystem::path root = temp_dir.path(); + populate_cli_overrides(provider, command, root); + command.settings_path = maybe_write_settings(provider, root); + + if (mode == prebyte::CommandMode::Explain) { + command.explain_topic = kExplainTopics[provider.ConsumeIntegralInRange(0, 7)]; + } + + const bool write_output_file = provider.ConsumeBool(); + const std::string template_source = provider.ConsumeRemainingBytesAsString(); + + if (template_source.size() > kMaxTemplateBytes) { + return 0; + } + + seed_support_files(root); + + if (!partial_name.empty()) { + write_file(root / partial_name, partial_source.empty() ? "Partial {{ name }}\n" : partial_source); + } + + if (mode == prebyte::CommandMode::Render) { + if (template_source.empty() && !use_inline_input) { + write_file(root / "main.pbt", "Hello {{ name }}\n"); + } else if (!template_source.empty()) { + write_file(root / "main.pbt", template_source); + } + + const std::filesystem::path template_path = root / "main.pbt"; + command.input_path = template_path; + + if (use_inline_input && !template_source.empty()) { + command.inline_input = template_source; + } + + if (write_output_file) { + command.output_path = root / "output.txt"; + } + } + + try { + prebyte::AppRunner runner; + (void)runner.execute(command); + } catch (const prebyte::DiagnosticError&) { + } catch (const std::exception&) { + } + + return 0; +} diff --git a/tests/fault_tolerance/fuzz/BatchRenderFuzz.cpp b/tests/fault_tolerance/fuzz/BatchRenderFuzz.cpp new file mode 100644 index 0000000..45fb98e --- /dev/null +++ b/tests/fault_tolerance/fuzz/BatchRenderFuzz.cpp @@ -0,0 +1,210 @@ +#include "app/BatchProcessor.h" +#include "app/Command.h" +#include "support/FuzzRuntimeReset.h" +#include "support/Diagnostic.h" +#include "support/FuzzTempDir.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr std::size_t kMaxTemplateBytes = 256 * 1024; +constexpr std::size_t kMaxBatchJsonBytes = 64 * 1024; +constexpr const char* kSettingsExtensions[] = {".yaml", ".json", ".toml", ".ini"}; + +enum class OutputRoute { + Stdout, + SingleFile, + Directory, +}; + +void write_file(const std::filesystem::path& path, const std::string& content) { + std::error_code error; + std::filesystem::create_directories(path.parent_path(), error); + std::ofstream file(path, std::ios::binary); + file << content; +} + +void seed_support_files(const std::filesystem::path& root) { + write_file(root / "header.txt", "Header {{ name }}\n"); + write_file(root / "partial.pbt", "<{{ item }}>"); + write_file(root / "nested" / "child.pbt", "Child {{ name }}\n"); +} + +OutputRoute pick_output_route(FuzzedDataProvider& provider) { + switch (provider.ConsumeIntegralInRange(0, 2)) { + case 0: + return OutputRoute::Stdout; + case 1: + return OutputRoute::SingleFile; + case 2: + default: + return OutputRoute::Directory; + } +} + +std::string random_rule_arg(FuzzedDataProvider& provider, const std::filesystem::path& root) { + switch (provider.ConsumeIntegralInRange(0, 8)) { + case 0: + return std::string("strict_variables=") + (provider.ConsumeBool() ? "true" : "false"); + case 1: + return std::string("allow_includes=") + (provider.ConsumeBool() ? "true" : "false"); + case 2: + return "max_include_depth=" + std::to_string(provider.ConsumeIntegralInRange(0, 8)); + case 3: + return "max_output_size_bytes=" + std::to_string(provider.ConsumeIntegralInRange(64, 8192)); + case 4: + return "max_loop_iteration=" + std::to_string(provider.ConsumeIntegralInRange(1, 100)); + case 5: + return "lua_instruction_limit=" + std::to_string(provider.ConsumeIntegralInRange(1000, 50000)); + case 6: + return "lua_memory_limit_bytes=" + std::to_string(provider.ConsumeIntegralInRange(512 * 1024, 2 * 1024 * 1024)); + case 7: + return std::string("trim=") + (provider.ConsumeBool() ? "true" : "false"); + case 8: + default: + return "include_path=" + root.string(); + } +} + +std::string random_define_arg(FuzzedDataProvider& provider) { + const std::string name = provider.ConsumeRandomLengthString(12); + if (name.empty()) { + return "prefix=Batch"; + } + return name + "=" + provider.ConsumeRandomLengthString(24); +} + +void populate_cli_overrides(FuzzedDataProvider& provider, prebyte::Command& command, + const std::filesystem::path& root) { + const std::size_t rule_count = provider.ConsumeIntegralInRange(0, 3); + for (std::size_t index = 0; index < rule_count; ++index) { + command.rule_args.push_back(random_rule_arg(provider, root)); + } + + const std::size_t define_count = provider.ConsumeIntegralInRange(0, 2); + for (std::size_t index = 0; index < define_count; ++index) { + command.define_args.push_back(random_define_arg(provider)); + } + + if (provider.ConsumeBool()) { + command.profile_names.push_back(provider.ConsumeRandomLengthString(12)); + } + if (provider.ConsumeBool()) { + command.ignore_names.push_back(provider.ConsumeRandomLengthString(12)); + } + if (provider.ConsumeBool()) { + command.include_paths.push_back(root); + } + if (provider.ConsumeBool()) { + command.render_args.push_back(provider.ConsumeRandomLengthString(16)); + } + + command.benchmark = provider.ConsumeBool(); + command.debug = provider.ConsumeBool(); +} + +std::optional maybe_write_settings(FuzzedDataProvider& provider, + const std::filesystem::path& root) { + if (!provider.ConsumeBool()) { + return std::nullopt; + } + + const int extension_index = provider.ConsumeIntegralInRange(0, 3); + const std::string content = provider.ConsumeRandomLengthString(512); + const std::filesystem::path path = + root / (std::string("settings") + kSettingsExtensions[extension_index]); + write_file(path, content.empty() ? "name = Ada\n" : content); + return path; +} + +std::string fallback_batch_json() { + return R"([{"name":"Ada","value":"one"},{"name":"Grace","value":"two"}])"; +} + +std::string normalize_batch_json(std::string batch_json) { + if (batch_json.empty()) { + return fallback_batch_json(); + } + if (batch_json.size() > kMaxBatchJsonBytes) { + batch_json.resize(kMaxBatchJsonBytes); + } + return batch_json; +} + +std::string normalize_template_source(std::string template_source) { + if (template_source.empty()) { + return "{{ name }}|{{ value }}|{{ greeting }}"; + } + if (template_source.size() > kMaxTemplateBytes) { + template_source.resize(kMaxTemplateBytes); + } + return template_source; +} + +void apply_output_route(OutputRoute route, prebyte::Command& command, const std::filesystem::path& root) { + switch (route) { + case OutputRoute::Stdout: + command.output_path = std::nullopt; + break; + case OutputRoute::SingleFile: + command.output_path = root / "out.txt"; + break; + case OutputRoute::Directory: + command.output_path = root / "out/"; + break; + } +} + +} + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { + if (size == 0) { + return 0; + } + + FuzzedDataProvider provider(data, size); + fuzz_reset_runtime_state(); + + const OutputRoute output_route = pick_output_route(provider); + const bool use_pbt_extension = provider.ConsumeBool(); + const std::string partial_name = provider.ConsumeRandomLengthString(48); + const std::string partial_source = provider.ConsumeRandomLengthString(512); + const std::string batch_json = normalize_batch_json(provider.ConsumeRandomLengthString(4096)); + + FuzzTempDir temp_dir; + const std::filesystem::path root = temp_dir.path(); + seed_support_files(root); + + prebyte::Command command; + command.mode = prebyte::CommandMode::Render; + populate_cli_overrides(provider, command, root); + command.settings_path = maybe_write_settings(provider, root); + apply_output_route(output_route, command, root); + + const std::string template_source = normalize_template_source(provider.ConsumeRemainingBytesAsString()); + const std::filesystem::path template_path = + root / (use_pbt_extension ? "template.pbt" : "template.txt"); + write_file(template_path, template_source); + write_file(root / "data.json", batch_json); + command.input_path = template_path; + command.batch_path = root / "data.json"; + + if (!partial_name.empty()) { + write_file(root / partial_name, partial_source.empty() ? "Partial {{ name }}\n" : partial_source); + } + + try { + prebyte::BatchProcessor processor; + (void)processor.execute(command); + } catch (const prebyte::DiagnosticError&) { + } catch (const std::exception&) { + } + + return 0; +} diff --git a/tests/fault_tolerance/fuzz/CompiledTemplateSerializerFuzz.cpp b/tests/fault_tolerance/fuzz/CompiledTemplateSerializerFuzz.cpp new file mode 100644 index 0000000..a976f84 --- /dev/null +++ b/tests/fault_tolerance/fuzz/CompiledTemplateSerializerFuzz.cpp @@ -0,0 +1,20 @@ +#include "runtime/compiled/CompiledTemplateSerializer.h" +#include "support/Diagnostic.h" + +#include +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { + const std::string_view input(reinterpret_cast(data), size); + + try { + prebyte::CompiledTemplateSerializer serializer; + (void)serializer.deserialize(input); + } catch (const prebyte::DiagnosticError&) { + } catch (const std::exception&) { + } + + return 0; +} diff --git a/tests/fault_tolerance/fuzz/EnvParserFuzz.cpp b/tests/fault_tolerance/fuzz/EnvParserFuzz.cpp new file mode 100644 index 0000000..b5caa0a --- /dev/null +++ b/tests/fault_tolerance/fuzz/EnvParserFuzz.cpp @@ -0,0 +1,17 @@ +#include "parser/EnvParser.h" + +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { + const std::string input(reinterpret_cast(data), size); + + try { + prebyte::EnvParser parser; + (void)parser.parse_string(input); + } catch (const std::exception&) { + } + + return 0; +} diff --git a/tests/fault_tolerance/fuzz/FileParserFuzz.cpp b/tests/fault_tolerance/fuzz/FileParserFuzz.cpp new file mode 100644 index 0000000..80e84d3 --- /dev/null +++ b/tests/fault_tolerance/fuzz/FileParserFuzz.cpp @@ -0,0 +1,56 @@ +#include "parser/FileParser.h" +#include "support/FuzzTempDir.h" + +#include +#include +#include +#include +#include + +namespace { + +constexpr const char* kExtensions[] = {".json", ".yaml", ".yml", ".ini", ".cfg", ".env", ".toml", ".txt"}; + +void try_parse_path(const std::string& file_path) { + try { + prebyte::FileParser parser; + (void)parser.parse(file_path); + } catch (const std::exception&) { + } +} + +void write_file(const std::filesystem::path& path, const std::string& content) { + std::ofstream file(path, std::ios::binary); + file << content; +} + +} + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { + if (size == 0) { + try_parse_path(""); + return 0; + } + + FuzzedDataProvider provider(data, size); + const int route = provider.ConsumeIntegralInRange(0, 4); + + if (route == 0) { + try_parse_path(""); + return 0; + } + + FuzzTempDir temp_dir; + if (route == 1) { + try_parse_path((temp_dir.path() / "missing.json").string()); + return 0; + } + + const int extension_index = provider.ConsumeIntegralInRange(0, 7); + const std::string content = provider.ConsumeRemainingBytesAsString(); + const std::filesystem::path path = + temp_dir.path() / ("input" + std::string(kExtensions[extension_index])); + write_file(path, content); + try_parse_path(path.string()); + return 0; +} diff --git a/tests/fault_tolerance/fuzz/GeneratePbcSeed.cpp b/tests/fault_tolerance/fuzz/GeneratePbcSeed.cpp new file mode 100644 index 0000000..1742593 --- /dev/null +++ b/tests/fault_tolerance/fuzz/GeneratePbcSeed.cpp @@ -0,0 +1,28 @@ +#include "config/ConfigTypes.h" +#include "runtime/compiled/CompiledTemplateCompiler.h" +#include "runtime/compiled/CompiledTemplateSerializer.h" + +#include +#include + +int main(int argc, char** argv) { + if (argc != 2) { + std::cerr << "Usage: generate_fuzz_pbc_seed \n"; + return 1; + } + + prebyte::EffectiveSettings settings; + prebyte::CompiledTemplateCompiler compiler; + const prebyte::CompiledProgram program = compiler.compile_source( + "Hello {{ name }}\n{{ if enabled }}Yes{{ else }}No{{ endif }}\n", + "seed.pbt", + "seed.pbt", + settings); + + prebyte::CompiledTemplateSerializer serializer; + const std::string bytes = serializer.serialize(program); + + std::ofstream output(argv[1], std::ios::binary); + output.write(bytes.data(), static_cast(bytes.size())); + return output.good() ? 0 : 1; +} diff --git a/tests/fault_tolerance/fuzz/IncludeResolverFuzz.cpp b/tests/fault_tolerance/fuzz/IncludeResolverFuzz.cpp new file mode 100644 index 0000000..c082b04 --- /dev/null +++ b/tests/fault_tolerance/fuzz/IncludeResolverFuzz.cpp @@ -0,0 +1,79 @@ +#include "config/ConfigTypes.h" +#include "support/FuzzRuntimeReset.h" +#include "runtime/resolution/IncludeResolver.h" +#include "runtime/core/RenderSession.h" +#include "support/Diagnostic.h" +#include "support/FuzzTempDir.h" + +#include +#include +#include +#include +#include + +namespace { + +void write_file(const std::filesystem::path& path, const std::string& content) { + std::error_code error; + std::filesystem::create_directories(path.parent_path(), error); + std::ofstream file(path, std::ios::binary); + file << content; +} + +void seed_filesystem(const std::filesystem::path& root) { + write_file(root / "main.txt", "{{ include \"header.txt\" }}\n"); + write_file(root / "header.txt", "Header {{ name }}\n"); + write_file(root / "partial.md", "Partial content\n"); + write_file(root / "nested" / "child.txt", "Child {{ name }}\n"); + write_file(root / "nested" / "index.pbt", "Index {{ name }}\n"); + write_file(root / "cycle_a.txt", "{{ include \"cycle_b.txt\" }}"); + write_file(root / "cycle_b.txt", "{{ include \"cycle_a.txt\" }}"); + write_file(root / "escape.txt", "{{ include \"../../../etc/passwd\" }}"); +} + +} + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { + FuzzedDataProvider provider(data, size); + + fuzz_reset_runtime_state(); + + const std::string include_path = provider.ConsumeRandomLengthString(128); + const std::string current_file = provider.ConsumeRandomLengthString(128); + const std::string extra_name = provider.ConsumeRandomLengthString(64); + const std::string extra_content = provider.ConsumeRandomLengthString(512); + const std::size_t max_include_depth = provider.ConsumeIntegralInRange(0, 8); + + FuzzTempDir temp_dir; + const std::filesystem::path root = temp_dir.path(); + seed_filesystem(root); + + if (!extra_name.empty()) { + write_file(root / extra_name, extra_content); + } + + prebyte::EffectiveSettings settings; + settings.allow_includes = true; + settings.max_include_depth = max_include_depth; + settings.include_paths.push_back(root); + settings.include_paths.push_back(root / "nested"); + settings.include_paths.push_back(root / "alt"); + + const std::filesystem::path current_path = + current_file.empty() ? root / "main.txt" : root / current_file; + + prebyte::IncludeResolver resolver; + prebyte::RenderSession session; + + try { + prebyte::ResolvedInclude resolved = resolver.load(include_path, current_path, settings, session); + if (resolved.kind == prebyte::ResolvedIncludeKind::Source) { + (void)resolved.source.view(); + } + resolver.pop(session); + } catch (const prebyte::DiagnosticError&) { + } catch (const std::exception&) { + } + + return 0; +} diff --git a/tests/fault_tolerance/fuzz/IniParserFuzz.cpp b/tests/fault_tolerance/fuzz/IniParserFuzz.cpp new file mode 100644 index 0000000..dbf500f --- /dev/null +++ b/tests/fault_tolerance/fuzz/IniParserFuzz.cpp @@ -0,0 +1,17 @@ +#include "parser/IniParser.h" + +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { + const std::string input(reinterpret_cast(data), size); + + try { + prebyte::IniParser parser; + (void)parser.parse_string(input); + } catch (const std::exception&) { + } + + return 0; +} diff --git a/tests/fault_tolerance/fuzz/JsonParserFuzz.cpp b/tests/fault_tolerance/fuzz/JsonParserFuzz.cpp new file mode 100644 index 0000000..37cd71c --- /dev/null +++ b/tests/fault_tolerance/fuzz/JsonParserFuzz.cpp @@ -0,0 +1,17 @@ +#include "parser/JsonParser.h" + +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { + const std::string input(reinterpret_cast(data), size); + + try { + prebyte::JsonParser parser; + (void)parser.parse_string(input); + } catch (const std::exception&) { + } + + return 0; +} diff --git a/tests/fault_tolerance/fuzz/LuaChunkFuzz.cpp b/tests/fault_tolerance/fuzz/LuaChunkFuzz.cpp new file mode 100644 index 0000000..6bd199f --- /dev/null +++ b/tests/fault_tolerance/fuzz/LuaChunkFuzz.cpp @@ -0,0 +1,71 @@ +#include "config/ConfigTypes.h" +#include "runtime/lua/LuaRuntime.h" +#include "support/Diagnostic.h" +#include "support/FuzzTempDir.h" +#include "support/SourceSpan.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +prebyte::LuaChunkMode chunk_mode_for_index(int index) { + switch (index) { + case 0: + return prebyte::LuaChunkMode::InlineValue; + case 1: + return prebyte::LuaChunkMode::Predicate; + case 2: + default: + return prebyte::LuaChunkMode::BlockValue; + } +} + +} + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { + if (size == 0) { + return 0; + } + + FuzzedDataProvider provider(data, size); + const int mode_index = provider.ConsumeIntegralInRange(0, 2); + prebyte::EffectiveSettings settings; + settings.lua_instruction_limit = provider.ConsumeIntegralInRange(1000, 100000); + settings.lua_memory_limit_bytes = provider.ConsumeIntegralInRange(512 * 1024, 4 * 1024 * 1024); + settings.max_render_time_ms = provider.ConsumeBool() ? 0 : std::numeric_limits::max(); + const bool seed_session = provider.ConsumeBool(); + const std::string source = provider.ConsumeRemainingBytesAsString(); + + if (source.empty()) { + return 0; + } + + prebyte::RenderSession session; + if (seed_session) { + session.variables.set("name", "Ada"); + session.variables.set_value("count", prebyte::Value(2.0)); + session.args = {"alpha", "beta"}; + if (settings.max_render_time_ms == 0) { + session.start_time = std::chrono::steady_clock::now() - std::chrono::milliseconds(1); + } + } + + prebyte::SourceSpan span; + span.file_path = "fuzz.lua"; + span.start.line = 1; + + try { + prebyte::LuaRuntime runtime; + (void)runtime.execute(source, chunk_mode_for_index(mode_index), settings, session, "fuzz.lua", span); + } catch (const prebyte::DiagnosticError&) { + } catch (const std::exception&) { + } + + return 0; +} diff --git a/tests/fault_tolerance/fuzz/LuaSandboxFuzz.cpp b/tests/fault_tolerance/fuzz/LuaSandboxFuzz.cpp new file mode 100644 index 0000000..f852247 --- /dev/null +++ b/tests/fault_tolerance/fuzz/LuaSandboxFuzz.cpp @@ -0,0 +1,122 @@ +#include "app/AppRunner.h" +#include "app/Command.h" +#include "support/FuzzRuntimeReset.h" +#include "support/Diagnostic.h" + +#include +#include +#include +#include +#include + +namespace { + +constexpr const char* kEscapeSnippets[] = { + "return os.execute('id')", + "return require('os')", + "return loadfile('secret.txt')", + "return dofile('secret.txt')", + "return io.open('/etc/passwd')", + "return debug.getinfo(1)", + "return package.loaded", + "return getmetatable(_G)", + "local proxy = setmetatable({}, {__index = os}); return proxy.execute('id')", + "local chunk = load('return os.execute(\"id\")'); return chunk()", + "return rawget(_G, 'package')", + "return (_G)._G.os", +}; + +constexpr const char* kSafeSnippets[] = { + "return load('return 41')()", + "return string.upper('ada')", + "return table.concat({'a', 'b'}, '-')", + "return math.max(2, 5)", + "return os == nil", + "return require == nil", +}; + +std::string inline_lua(const std::string& lua_source) { + return std::string("{{ lua \"") + lua_source + "\" }}"; +} + +std::string block_lua(const std::string& lua_source) { + return std::string("{{ lua:block }}") + lua_source + "{{ endlua }}"; +} + +std::string if_lua_condition(const std::string& lua_source) { + return std::string("{{ if lua:block }}") + lua_source + "{{ endlua }}yes{{ else }}no{{ endif }}"; +} + +std::string function_lua(const std::string& lua_source) { + return std::string("{{ fn probe() lua:block }}") + lua_source + "{{ endfn }}{{ probe() }}"; +} + +void run_template(const std::string& template_source, const std::vector& rule_args) { + prebyte::Command command; + command.mode = prebyte::CommandMode::Render; + command.inline_input = template_source; + command.rule_args = rule_args; + + prebyte::AppRunner runner; + (void)runner.execute(command); +} + +std::vector limit_rules(FuzzedDataProvider& provider) { + std::vector rule_args; + switch (provider.ConsumeIntegralInRange(0, 3)) { + case 1: + rule_args.push_back("lua_instruction_limit=10"); + break; + case 2: + rule_args.push_back("lua_memory_limit_bytes=4096"); + break; + case 3: + rule_args.push_back("max_render_time_ms=0"); + break; + case 0: + default: + break; + } + return rule_args; +} + +} + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { + if (size == 0) { + return 0; + } + + FuzzedDataProvider provider(data, size); + fuzz_reset_runtime_state(); + + const int route = provider.ConsumeIntegralInRange(0, 4); + const int escape_index = provider.ConsumeIntegralInRange(0, static_cast(sizeof(kEscapeSnippets) / sizeof(kEscapeSnippets[0])) - 1); + const int safe_index = provider.ConsumeIntegralInRange(0, static_cast(sizeof(kSafeSnippets) / sizeof(kSafeSnippets[0])) - 1); + const std::vector rule_args = limit_rules(provider); + + try { + switch (route) { + case 0: + run_template(block_lua(kEscapeSnippets[escape_index]), rule_args); + break; + case 1: + run_template(if_lua_condition(kEscapeSnippets[escape_index]), rule_args); + break; + case 2: + run_template(function_lua(kEscapeSnippets[escape_index]), rule_args); + break; + case 3: + run_template(inline_lua(kSafeSnippets[safe_index]), rule_args); + break; + case 4: + default: + run_template(block_lua(kSafeSnippets[safe_index]), rule_args); + break; + } + } catch (const prebyte::DiagnosticError&) { + } catch (const std::exception&) { + } + + return 0; +} diff --git a/tests/fault_tolerance/fuzz/RenderPbtFuzz.cpp b/tests/fault_tolerance/fuzz/RenderPbtFuzz.cpp new file mode 100644 index 0000000..48ac0b6 --- /dev/null +++ b/tests/fault_tolerance/fuzz/RenderPbtFuzz.cpp @@ -0,0 +1,131 @@ +#include "config/ConfigTypes.h" +#include "config/RuleResolver.h" +#include "datatypes/Data.h" +#include "runtime/expression/BuiltinRegistry.h" +#include "runtime/expression/ExpressionEvaluator.h" +#include "support/FuzzRuntimeReset.h" +#include "runtime/resolution/IncludeResolver.h" +#include "runtime/render/Renderer.h" +#include "support/Diagnostic.h" +#include "support/FuzzTempDir.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr std::size_t kMaxTemplateBytes = 256 * 1024; + +void write_file(const std::filesystem::path& path, const std::string& content) { + std::error_code error; + std::filesystem::create_directories(path.parent_path(), error); + std::ofstream file(path, std::ios::binary); + file << content; +} + +void seed_support_files(const std::filesystem::path& root) { + write_file(root / "header.txt", "Header {{ name }}\n"); + write_file(root / "partial.pbt", "<{{ loop.index }}:{{ item }}>\n"); + write_file(root / "nested" / "child.pbt", "Child {{ name }}\n"); + write_file(root / "cycle_a.pbt", "{{ include \"cycle_b.pbt\" }}"); + write_file(root / "cycle_b.pbt", "{{ include \"cycle_a.pbt\" }}"); +} + +prebyte::EffectiveSettings make_settings(FuzzedDataProvider& provider) { + prebyte::EffectiveSettings settings; + settings.strict_variables = provider.ConsumeBool(); + settings.allow_includes = provider.ConsumeBool(); + settings.replace_tabs = provider.ConsumeBool(); + settings.trim = provider.ConsumeBool(); + settings.max_include_depth = provider.ConsumeIntegralInRange(0, 8); + settings.lua_instruction_limit = provider.ConsumeIntegralInRange(1000, 100000); + settings.lua_memory_limit_bytes = provider.ConsumeIntegralInRange(512 * 1024, 4 * 1024 * 1024); + settings.max_output_size_bytes = provider.ConsumeIntegralInRange(1024, 4 * 1024 * 1024); + settings.max_loop_iteration = provider.ConsumeIntegralInRange(1, 1000); + settings.max_render_time_ms = + provider.ConsumeBool() ? 0 : std::numeric_limits::max(); + return settings; +} + +void seed_session(FuzzedDataProvider& provider, prebyte::RenderSession& session, + const prebyte::EffectiveSettings& settings) { + session.variables.set("name", provider.ConsumeRandomLengthString(32)); + session.variables.set("enabled", provider.ConsumeBool() ? "true" : "false"); + session.variables.set("fromSettings", provider.ConsumeRandomLengthString(16)); + session.args = {provider.ConsumeRandomLengthString(16), provider.ConsumeRandomLengthString(16)}; + + prebyte::Data::Array items; + items.push_back(prebyte::Data(provider.ConsumeRandomLengthString(8))); + items.push_back(prebyte::Data(provider.ConsumeRandomLengthString(8))); + session.variables.set_value("items", prebyte::Value::list(std::move(items))); + + prebyte::Data::Array groups; + prebyte::Data::Map group_map; + group_map["featured"] = prebyte::Data(true); + groups.push_back(prebyte::Data(std::move(group_map))); + session.variables.set_value("groups", prebyte::Value::list(std::move(groups))); + + if (settings.max_render_time_ms == 0) { + session.start_time = std::chrono::steady_clock::now() - std::chrono::milliseconds(1); + } +} + +} + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { + if (size == 0) { + return 0; + } + + FuzzedDataProvider provider(data, size); + fuzz_reset_runtime_state(); + + const prebyte::EffectiveSettings settings = make_settings(provider); + const bool seed_session_data = provider.ConsumeBool(); + const std::string partial_name = provider.ConsumeRandomLengthString(48); + const std::string partial_source = provider.ConsumeRandomLengthString(512); + + prebyte::RenderSession session; + if (seed_session_data) { + seed_session(provider, session, settings); + } + + const std::string template_source = provider.ConsumeRemainingBytesAsString(); + + if (template_source.empty() || template_source.size() > kMaxTemplateBytes) { + return 0; + } + + FuzzTempDir temp_dir; + const std::filesystem::path root = temp_dir.path(); + seed_support_files(root); + + const std::filesystem::path template_path = root / "main.pbt"; + write_file(template_path, template_source); + + if (!partial_name.empty()) { + write_file(root / partial_name, partial_source.empty() ? "Partial {{ name }}\n" : partial_source); + } + + prebyte::EffectiveSettings active_settings = settings; + active_settings.include_paths = {root, root / "nested"}; + + prebyte::RuleResolver rule_resolver; + prebyte::IncludeResolver include_resolver; + prebyte::BuiltinRegistry builtins; + prebyte::ExpressionEvaluator evaluator(builtins); + prebyte::Renderer renderer(rule_resolver, include_resolver, evaluator); + + try { + (void)renderer.render_source(template_source, active_settings, template_path, session); + } catch (const prebyte::DiagnosticError&) { + } catch (const std::exception&) { + } + + return 0; +} diff --git a/tests/fault_tolerance/fuzz/SettingsLoaderFuzz.cpp b/tests/fault_tolerance/fuzz/SettingsLoaderFuzz.cpp new file mode 100644 index 0000000..340ea6e --- /dev/null +++ b/tests/fault_tolerance/fuzz/SettingsLoaderFuzz.cpp @@ -0,0 +1,42 @@ +#include "config/SettingsLoader.h" +#include "support/Diagnostic.h" +#include "support/FuzzTempDir.h" + +#include +#include +#include +#include +#include + +namespace { + +constexpr const char* kSettingsExtensions[] = {".yaml", ".json", ".toml", ".ini"}; + +std::filesystem::path write_settings_file(const std::filesystem::path& directory, const std::string& extension, + const std::string& content) { + const std::filesystem::path path = directory / ("settings" + extension); + std::ofstream file(path, std::ios::binary); + file << content; + return path; +} + +} + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { + FuzzedDataProvider provider(data, size); + const int extension_index = provider.ConsumeIntegralInRange(0, 3); + const std::string content = provider.ConsumeRemainingBytesAsString(); + + FuzzTempDir temp_dir; + const std::filesystem::path path = + write_settings_file(temp_dir.path(), kSettingsExtensions[extension_index], content); + + try { + prebyte::SettingsLoader loader; + (void)loader.load(path); + } catch (const prebyte::DiagnosticError&) { + } catch (const std::exception&) { + } + + return 0; +} diff --git a/tests/fault_tolerance/fuzz/StructuredImportFuzz.cpp b/tests/fault_tolerance/fuzz/StructuredImportFuzz.cpp new file mode 100644 index 0000000..4907ef5 --- /dev/null +++ b/tests/fault_tolerance/fuzz/StructuredImportFuzz.cpp @@ -0,0 +1,83 @@ +#include "app/AppRunner.h" +#include "app/Command.h" +#include "support/Diagnostic.h" +#include "support/FuzzFileUtil.h" +#include "support/FuzzRuntimeReset.h" +#include "support/FuzzTempDir.h" + +#include +#include +#include +#include + +namespace { + +constexpr std::size_t kMaxDataBytes = 64 * 1024; +constexpr std::size_t kMaxTemplateBytes = 8 * 1024; + +struct ImportFormatSpec { + const char* extension; + const char* variable_name; + const char* fallback_data; + const char* fallback_template; +}; + +constexpr ImportFormatSpec kImportFormats[] = { + {".json", "data", R"({"name":"Ada","items":["A","B"]})", "{{ data.name }}|{{ data.items[1] }}"}, + {".yaml", "data", "name: Ada\nitems:\n - A\n - B\n", "{{ data.name }}|{{ data.items[1] }}"}, + {".toml", "data", "[server]\nhost=\"localhost\"\nport=8080\n", "{{ data.server.host }}:{{ data.server.port }}"}, + {".ini", "data", "[server]\nhost = localhost\nport = 8080\n", "{{ data.server.host }}:{{ data.server.port }}"}, + {".env", "data", "NAME=Ada\nROLE=admin\n", "{{ data.NAME }}:{{ data.ROLE }}"}, +}; + +std::string fallback_or_custom(const std::string& custom, const char* fallback) { + return custom.empty() ? std::string(fallback) : custom; +} + +} + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { + if (size == 0) { + return 0; + } + + FuzzedDataProvider provider(data, size); + fuzz_reset_runtime_state(); + + const int format_index = provider.ConsumeIntegralInRange(0, 4); + const ImportFormatSpec& format = kImportFormats[format_index]; + const bool use_custom_template = provider.ConsumeBool(); + (void)provider.ConsumeBool(); // legacy flag: lua import access (covered by other fuzz targets) + const bool apply_strict_rule = provider.ConsumeBool(); + const bool strict_variables = provider.ConsumeBool(); + const std::string custom_template = use_custom_template ? provider.ConsumeRandomLengthString(kMaxTemplateBytes) : std::string(); + const std::string data_content = provider.ConsumeRemainingBytesAsString(); + + if (data_content.size() > kMaxDataBytes) { + return 0; + } + + FuzzTempDir temp_dir; + const std::filesystem::path root = temp_dir.path(); + const std::filesystem::path data_path = root / ("import" + std::string(format.extension)); + fuzz_write_file(data_path, fallback_or_custom(data_content, format.fallback_data)); + + std::string template_source = fallback_or_custom(custom_template, format.fallback_template); + + prebyte::Command command; + command.mode = prebyte::CommandMode::Render; + command.inline_input = template_source; + command.define_args = {std::string(format.variable_name) + "=@" + data_path.string()}; + if (apply_strict_rule) { + command.rule_args.push_back(std::string("strict_variables=") + (strict_variables ? "true" : "false")); + } + + try { + prebyte::AppRunner runner; + (void)runner.execute(command); + } catch (const prebyte::DiagnosticError&) { + } catch (const std::exception&) { + } + + return 0; +} diff --git a/tests/fault_tolerance/fuzz/TemplateLexerFuzz.cpp b/tests/fault_tolerance/fuzz/TemplateLexerFuzz.cpp new file mode 100644 index 0000000..d468b1d --- /dev/null +++ b/tests/fault_tolerance/fuzz/TemplateLexerFuzz.cpp @@ -0,0 +1,19 @@ +#include "support/Diagnostic.h" +#include "template/lexer/TemplateLexer.h" + +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { + const std::string input(reinterpret_cast(data), size); + + try { + prebyte::TemplateLexer lexer(input, "fuzz.txt"); + (void)lexer.lex(); + } catch (const prebyte::DiagnosticError&) { + } catch (const std::exception&) { + } + + return 0; +} diff --git a/tests/fault_tolerance/fuzz/TemplateParserFuzz.cpp b/tests/fault_tolerance/fuzz/TemplateParserFuzz.cpp new file mode 100644 index 0000000..e3c867f --- /dev/null +++ b/tests/fault_tolerance/fuzz/TemplateParserFuzz.cpp @@ -0,0 +1,21 @@ +#include "support/Diagnostic.h" +#include "template/lexer/TemplateLexer.h" +#include "template/parser/TemplateParser.h" + +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { + const std::string input(reinterpret_cast(data), size); + + try { + prebyte::TemplateLexer lexer(input, "fuzz.txt"); + prebyte::TemplateParser parser(lexer.lex(), prebyte::TemplateParserOptions{.enable_loops = true}); + (void)parser.parse_document(); + } catch (const prebyte::DiagnosticError&) { + } catch (const std::exception&) { + } + + return 0; +} diff --git a/tests/fault_tolerance/fuzz/TomlParserFuzz.cpp b/tests/fault_tolerance/fuzz/TomlParserFuzz.cpp new file mode 100644 index 0000000..1212aa8 --- /dev/null +++ b/tests/fault_tolerance/fuzz/TomlParserFuzz.cpp @@ -0,0 +1,17 @@ +#include "parser/TomlParser.h" + +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { + const std::string input(reinterpret_cast(data), size); + + try { + prebyte::TomlParser parser; + (void)parser.parse_string(input); + } catch (const std::exception&) { + } + + return 0; +} diff --git a/tests/fault_tolerance/fuzz/YamlParserFuzz.cpp b/tests/fault_tolerance/fuzz/YamlParserFuzz.cpp new file mode 100644 index 0000000..99c375c --- /dev/null +++ b/tests/fault_tolerance/fuzz/YamlParserFuzz.cpp @@ -0,0 +1,17 @@ +#include "parser/YamlParser.h" + +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { + const std::string input(reinterpret_cast(data), size); + + try { + prebyte::YamlParser parser; + (void)parser.parse_string(input); + } catch (const std::exception&) { + } + + return 0; +} diff --git a/tests/fault_tolerance/fuzz/regression/README.md b/tests/fault_tolerance/fuzz/regression/README.md new file mode 100644 index 0000000..c2f16d5 --- /dev/null +++ b/tests/fault_tolerance/fuzz/regression/README.md @@ -0,0 +1,17 @@ +# Fuzzer regression corpus + +Deterministic crash inputs replayed on every fuzz-regression run. Import new crashes with: + +```bash +python3 scripts/ci/import_fuzz_regression.py \ + --target fuzz_template_lexer \ + --crash /path/to/crash-input +``` + +Replay all entries: + +```bash +./scripts/ci/run_fuzz_regression.sh +``` + +Or via `make fuzz-regression` after a fuzz build. diff --git a/tests/fault_tolerance/fuzz/regression/fuzz_compiled_template_serializer/empty_input.txt b/tests/fault_tolerance/fuzz/regression/fuzz_compiled_template_serializer/empty_input.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/fault_tolerance/fuzz/regression/fuzz_template_lexer/truncated_string_escape.txt b/tests/fault_tolerance/fuzz/regression/fuzz_template_lexer/truncated_string_escape.txt new file mode 100644 index 0000000..c830d7a --- /dev/null +++ b/tests/fault_tolerance/fuzz/regression/fuzz_template_lexer/truncated_string_escape.txt @@ -0,0 +1 @@ +{{-"\ diff --git a/tests/fault_tolerance/fuzz/seeds/.gitignore b/tests/fault_tolerance/fuzz/seeds/.gitignore new file mode 100644 index 0000000..7394b0f --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/.gitignore @@ -0,0 +1,4 @@ +# libFuzzer writes SHA1-named corpus entries into whatever directory is passed +# as the corpus path. Ignore those auto-generated artifacts here; curated seeds +# keep human-readable names (.pbt, .json, settings.yaml, etc.). +**/[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f] diff --git a/tests/fault_tolerance/fuzz/seeds/app_runner/batch_interpolation.pbt b/tests/fault_tolerance/fuzz/seeds/app_runner/batch_interpolation.pbt new file mode 100644 index 0000000..98f76c6 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/app_runner/batch_interpolation.pbt @@ -0,0 +1 @@ +{{ greeting }} {{ name }}! diff --git a/tests/fault_tolerance/fuzz/seeds/app_runner/comparison_and_in.pbt b/tests/fault_tolerance/fuzz/seeds/app_runner/comparison_and_in.pbt new file mode 100644 index 0000000..5872553 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/app_runner/comparison_and_in.pbt @@ -0,0 +1 @@ +{{ if price >= min_price && sku in allowed }}ok{{ endif }} diff --git a/tests/fault_tolerance/fuzz/seeds/app_runner/filter_chain.pbt b/tests/fault_tolerance/fuzz/seeds/app_runner/filter_chain.pbt new file mode 100644 index 0000000..c21fc75 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/app_runner/filter_chain.pbt @@ -0,0 +1 @@ +{{ name | replace("a", "b") }} diff --git a/tests/fault_tolerance/fuzz/seeds/app_runner/for_loop.pbt b/tests/fault_tolerance/fuzz/seeds/app_runner/for_loop.pbt new file mode 100644 index 0000000..f625ec7 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/app_runner/for_loop.pbt @@ -0,0 +1 @@ +{{ for item in items }}{{ item }}{{ else }}empty{{ endfor }} diff --git a/tests/fault_tolerance/fuzz/seeds/app_runner/function_definition.pbt b/tests/fault_tolerance/fuzz/seeds/app_runner/function_definition.pbt new file mode 100644 index 0000000..affec24 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/app_runner/function_definition.pbt @@ -0,0 +1 @@ +{{ fn greet(name) }}Hello {{ name }}{{ endfn }}{{ greet("Ada") }} diff --git a/tests/fault_tolerance/fuzz/seeds/app_runner/function_tokens.pbt b/tests/fault_tolerance/fuzz/seeds/app_runner/function_tokens.pbt new file mode 100644 index 0000000..59c8430 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/app_runner/function_tokens.pbt @@ -0,0 +1 @@ +{{ fn greet(name) }}x{{ endfn }}{{ greet("Ada") }} diff --git a/tests/fault_tolerance/fuzz/seeds/app_runner/if_else.pbt b/tests/fault_tolerance/fuzz/seeds/app_runner/if_else.pbt new file mode 100644 index 0000000..de6727c --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/app_runner/if_else.pbt @@ -0,0 +1 @@ +Hello {{ if enabled }}Yes{{ else }}No{{ endif }} diff --git a/tests/fault_tolerance/fuzz/seeds/app_runner/include_and_if.pbt b/tests/fault_tolerance/fuzz/seeds/app_runner/include_and_if.pbt new file mode 100644 index 0000000..4219e4b --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/app_runner/include_and_if.pbt @@ -0,0 +1,3 @@ +{{ include "header.txt" }} +{{ if enabled }}Enabled{{ else }}Disabled{{ endif }} +Footer diff --git a/tests/fault_tolerance/fuzz/seeds/app_runner/include_partial.pbt b/tests/fault_tolerance/fuzz/seeds/app_runner/include_partial.pbt new file mode 100644 index 0000000..3538bb5 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/app_runner/include_partial.pbt @@ -0,0 +1,3 @@ +Start +{{ include "partial.md" }} +End diff --git a/tests/fault_tolerance/fuzz/seeds/app_runner/lua_block.pbt b/tests/fault_tolerance/fuzz/seeds/app_runner/lua_block.pbt new file mode 100644 index 0000000..3bc4bf0 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/app_runner/lua_block.pbt @@ -0,0 +1,3 @@ +{{ lua:block }} +return "Hello " .. name +{{ endlua }} diff --git a/tests/fault_tolerance/fuzz/seeds/app_runner/lua_condition.pbt b/tests/fault_tolerance/fuzz/seeds/app_runner/lua_condition.pbt new file mode 100644 index 0000000..97eb8b0 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/app_runner/lua_condition.pbt @@ -0,0 +1 @@ +{{ if enabled && lua("return upper(name) == 'ADA'") }}ok{{ else }}bad{{ endif }} diff --git a/tests/fault_tolerance/fuzz/seeds/app_runner/lua_expression.pbt b/tests/fault_tolerance/fuzz/seeds/app_runner/lua_expression.pbt new file mode 100644 index 0000000..82528d5 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/app_runner/lua_expression.pbt @@ -0,0 +1 @@ +{{ lua "return 42" }} {{ if lua("return true") }}ok{{ endif }} diff --git a/tests/fault_tolerance/fuzz/seeds/app_runner/lua_function_def.pbt b/tests/fault_tolerance/fuzz/seeds/app_runner/lua_function_def.pbt new file mode 100644 index 0000000..21665d3 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/app_runner/lua_function_def.pbt @@ -0,0 +1 @@ +{{ fn pick() lua:block }}return { name = "Ada" }{{ endfn }} diff --git a/tests/fault_tolerance/fuzz/seeds/app_runner/lua_inline.pbt b/tests/fault_tolerance/fuzz/seeds/app_runner/lua_inline.pbt new file mode 100644 index 0000000..f710246 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/app_runner/lua_inline.pbt @@ -0,0 +1 @@ +{{ lua "return upper(name)" }} diff --git a/tests/fault_tolerance/fuzz/seeds/app_runner/member_and_index.pbt b/tests/fault_tolerance/fuzz/seeds/app_runner/member_and_index.pbt new file mode 100644 index 0000000..3fc425d --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/app_runner/member_and_index.pbt @@ -0,0 +1 @@ +{{ user.name }} {{ items[0] }} {{ ARGS[0] }} diff --git a/tests/fault_tolerance/fuzz/seeds/app_runner/nested_if_for.pbt b/tests/fault_tolerance/fuzz/seeds/app_runner/nested_if_for.pbt new file mode 100644 index 0000000..4ed1c17 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/app_runner/nested_if_for.pbt @@ -0,0 +1 @@ +{{ if groups }}{{ for group in groups }}{{ if group.featured }}x{{ elseif group.archived }}y{{ else }}z{{ endif }}{{ endfor }}{{ endif }} diff --git a/tests/fault_tolerance/fuzz/seeds/app_runner/render_include_if_header.pbt b/tests/fault_tolerance/fuzz/seeds/app_runner/render_include_if_header.pbt new file mode 100644 index 0000000..68dc3ba --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/app_runner/render_include_if_header.pbt @@ -0,0 +1 @@ +Header for {{ name }} diff --git a/tests/fault_tolerance/fuzz/seeds/app_runner/set_statement.pbt b/tests/fault_tolerance/fuzz/seeds/app_runner/set_statement.pbt new file mode 100644 index 0000000..512c601 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/app_runner/set_statement.pbt @@ -0,0 +1 @@ +{{ set title = user.name | trim | upper }} diff --git a/tests/fault_tolerance/fuzz/seeds/app_runner/settings.json b/tests/fault_tolerance/fuzz/seeds/app_runner/settings.json new file mode 100644 index 0000000..9e621eb --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/app_runner/settings.json @@ -0,0 +1 @@ +{"variables":{"name":"Ada"},"rules":{"trim":"true"}} diff --git a/tests/fault_tolerance/fuzz/seeds/app_runner/settings.yaml b/tests/fault_tolerance/fuzz/seeds/app_runner/settings.yaml new file mode 100644 index 0000000..fe3348d --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/app_runner/settings.yaml @@ -0,0 +1,2 @@ +name = Ada +mode = debug diff --git a/tests/fault_tolerance/fuzz/seeds/app_runner/simple_interpolation.pbt b/tests/fault_tolerance/fuzz/seeds/app_runner/simple_interpolation.pbt new file mode 100644 index 0000000..cd775cb --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/app_runner/simple_interpolation.pbt @@ -0,0 +1 @@ +Hello {{ name }} diff --git a/tests/fault_tolerance/fuzz/seeds/app_runner/trim_markers.pbt b/tests/fault_tolerance/fuzz/seeds/app_runner/trim_markers.pbt new file mode 100644 index 0000000..bb18e40 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/app_runner/trim_markers.pbt @@ -0,0 +1 @@ +A {{- greet() -}} B diff --git a/tests/fault_tolerance/fuzz/seeds/batch_render/batch_array.json b/tests/fault_tolerance/fuzz/seeds/batch_render/batch_array.json new file mode 100644 index 0000000..eba4003 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/batch_render/batch_array.json @@ -0,0 +1,10 @@ +[ + {"greeting": "Hello", "name": "Ada"}, + {"greeting": "Hello", "name": "Grace"}, + {"greeting": "Hello", "name": "Linus"}, + {"greeting": "Hello", "name": "Alan"}, + {"greeting": "Hello", "name": "Katherine"}, + {"greeting": "Hello", "name": "Dennis"}, + {"greeting": "Hello", "name": "Margaret"}, + {"greeting": "Hello", "name": "Ken"} +] diff --git a/tests/fault_tolerance/fuzz/seeds/batch_render/batch_object.json b/tests/fault_tolerance/fuzz/seeds/batch_render/batch_object.json new file mode 100644 index 0000000..f0280d3 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/batch_render/batch_object.json @@ -0,0 +1 @@ +{"first.txt":{"value":"alpha"},"second.txt":{"value":"beta"}} diff --git a/tests/fault_tolerance/fuzz/seeds/batch_render/batch_output_override.json b/tests/fault_tolerance/fuzz/seeds/batch_render/batch_output_override.json new file mode 100644 index 0000000..e2254f7 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/batch_render/batch_output_override.json @@ -0,0 +1 @@ +[{"$output":"custom.txt","value":"one"},{"value":"two"}] diff --git a/tests/fault_tolerance/fuzz/seeds/batch_render/batch_structured.json b/tests/fault_tolerance/fuzz/seeds/batch_render/batch_structured.json new file mode 100644 index 0000000..aa75e06 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/batch_render/batch_structured.json @@ -0,0 +1 @@ +[{"name":"Ada","active":true,"count":2,"user":{"role":"admin"},"tags":["a","b"]}] diff --git a/tests/fault_tolerance/fuzz/seeds/batch_render/template_greeting.txt b/tests/fault_tolerance/fuzz/seeds/batch_render/template_greeting.txt new file mode 100644 index 0000000..98f76c6 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/batch_render/template_greeting.txt @@ -0,0 +1 @@ +{{ greeting }} {{ name }}! diff --git a/tests/fault_tolerance/fuzz/seeds/batch_render/template_structured.pbt b/tests/fault_tolerance/fuzz/seeds/batch_render/template_structured.pbt new file mode 100644 index 0000000..2751fb8 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/batch_render/template_structured.pbt @@ -0,0 +1 @@ +{{ name }}|{{ active }}|{{ count }}|{{ user.role }}|{{ tags[0] }} diff --git a/tests/fault_tolerance/fuzz/seeds/batch_render/template_value.pbt b/tests/fault_tolerance/fuzz/seeds/batch_render/template_value.pbt new file mode 100644 index 0000000..40cf72a --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/batch_render/template_value.pbt @@ -0,0 +1 @@ +{{ value }} diff --git a/tests/fault_tolerance/fuzz/seeds/env/sample.env b/tests/fault_tolerance/fuzz/seeds/env/sample.env new file mode 100644 index 0000000..763b12f --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/env/sample.env @@ -0,0 +1,2 @@ +NAME=Ada +CITY=Berlin diff --git a/tests/fault_tolerance/fuzz/seeds/env/spaced_values.env b/tests/fault_tolerance/fuzz/seeds/env/spaced_values.env new file mode 100644 index 0000000..cef479a --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/env/spaced_values.env @@ -0,0 +1,4 @@ +# comment + NAME = Ada +EMPTY= + VALUE = spaced value diff --git a/tests/fault_tolerance/fuzz/seeds/file_parser/cfg_values b/tests/fault_tolerance/fuzz/seeds/file_parser/cfg_values new file mode 100644 index 0000000..579a286 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/file_parser/cfg_values @@ -0,0 +1 @@ +name=Ada diff --git a/tests/fault_tolerance/fuzz/seeds/file_parser/empty_path b/tests/fault_tolerance/fuzz/seeds/file_parser/empty_path new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/tests/fault_tolerance/fuzz/seeds/file_parser/empty_path differ diff --git a/tests/fault_tolerance/fuzz/seeds/file_parser/env_values b/tests/fault_tolerance/fuzz/seeds/file_parser/env_values new file mode 100644 index 0000000..a34e1c0 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/file_parser/env_values @@ -0,0 +1,2 @@ +NAME=Ada +COUNT=2 diff --git a/tests/fault_tolerance/fuzz/seeds/file_parser/ini_section b/tests/fault_tolerance/fuzz/seeds/file_parser/ini_section new file mode 100644 index 0000000..480bd52 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/file_parser/ini_section @@ -0,0 +1,2 @@ +[section] +name=Ada diff --git a/tests/fault_tolerance/fuzz/seeds/file_parser/json_object b/tests/fault_tolerance/fuzz/seeds/file_parser/json_object new file mode 100644 index 0000000..5d6b0a0 Binary files /dev/null and b/tests/fault_tolerance/fuzz/seeds/file_parser/json_object differ diff --git a/tests/fault_tolerance/fuzz/seeds/file_parser/missing_file b/tests/fault_tolerance/fuzz/seeds/file_parser/missing_file new file mode 100644 index 0000000..6b2aaa7 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/file_parser/missing_file @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/fault_tolerance/fuzz/seeds/file_parser/toml_table b/tests/fault_tolerance/fuzz/seeds/file_parser/toml_table new file mode 100644 index 0000000..e14c227 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/file_parser/toml_table @@ -0,0 +1 @@ +name = "Ada" diff --git a/tests/fault_tolerance/fuzz/seeds/file_parser/unsupported_txt b/tests/fault_tolerance/fuzz/seeds/file_parser/unsupported_txt new file mode 100644 index 0000000..a12739f --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/file_parser/unsupported_txt @@ -0,0 +1 @@ +plain text content \ No newline at end of file diff --git a/tests/fault_tolerance/fuzz/seeds/file_parser/yaml_mapping b/tests/fault_tolerance/fuzz/seeds/file_parser/yaml_mapping new file mode 100644 index 0000000..8c2eabf --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/file_parser/yaml_mapping @@ -0,0 +1,2 @@ +name: Ada +count: 2 diff --git a/tests/fault_tolerance/fuzz/seeds/file_parser/yml_flag b/tests/fault_tolerance/fuzz/seeds/file_parser/yml_flag new file mode 100644 index 0000000..f03044e --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/file_parser/yml_flag @@ -0,0 +1 @@ +enabled: true diff --git a/tests/fault_tolerance/fuzz/seeds/include/cycle_a.txt b/tests/fault_tolerance/fuzz/seeds/include/cycle_a.txt new file mode 100644 index 0000000..dedda3a --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/include/cycle_a.txt @@ -0,0 +1 @@ +cycle_a.txt diff --git a/tests/fault_tolerance/fuzz/seeds/include/escape_traversal.txt b/tests/fault_tolerance/fuzz/seeds/include/escape_traversal.txt new file mode 100644 index 0000000..2f73c9a --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/include/escape_traversal.txt @@ -0,0 +1 @@ +../../../etc/passwd diff --git a/tests/fault_tolerance/fuzz/seeds/include/header.txt b/tests/fault_tolerance/fuzz/seeds/include/header.txt new file mode 100644 index 0000000..4688bee --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/include/header.txt @@ -0,0 +1 @@ +header.txt diff --git a/tests/fault_tolerance/fuzz/seeds/include/nested_child.txt b/tests/fault_tolerance/fuzz/seeds/include/nested_child.txt new file mode 100644 index 0000000..4423686 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/include/nested_child.txt @@ -0,0 +1 @@ +nested/child.txt diff --git a/tests/fault_tolerance/fuzz/seeds/include/relative_partial.txt b/tests/fault_tolerance/fuzz/seeds/include/relative_partial.txt new file mode 100644 index 0000000..5a51092 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/include/relative_partial.txt @@ -0,0 +1 @@ +../partial.md diff --git a/tests/fault_tolerance/fuzz/seeds/ini/sections.ini b/tests/fault_tolerance/fuzz/seeds/ini/sections.ini new file mode 100644 index 0000000..e885180 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/ini/sections.ini @@ -0,0 +1,5 @@ +; comment +name = Ada +[server] +host = localhost +port = 8080 diff --git a/tests/fault_tolerance/fuzz/seeds/json/batch_array.json b/tests/fault_tolerance/fuzz/seeds/json/batch_array.json new file mode 100644 index 0000000..eba4003 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/json/batch_array.json @@ -0,0 +1,10 @@ +[ + {"greeting": "Hello", "name": "Ada"}, + {"greeting": "Hello", "name": "Grace"}, + {"greeting": "Hello", "name": "Linus"}, + {"greeting": "Hello", "name": "Alan"}, + {"greeting": "Hello", "name": "Katherine"}, + {"greeting": "Hello", "name": "Dennis"}, + {"greeting": "Hello", "name": "Margaret"}, + {"greeting": "Hello", "name": "Ken"} +] diff --git a/tests/fault_tolerance/fuzz/seeds/json/empty_structures.json b/tests/fault_tolerance/fuzz/seeds/json/empty_structures.json new file mode 100644 index 0000000..b90a935 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/json/empty_structures.json @@ -0,0 +1 @@ +{"empty_obj":{},"empty_arr":[]} diff --git a/tests/fault_tolerance/fuzz/seeds/json/escapes.json b/tests/fault_tolerance/fuzz/seeds/json/escapes.json new file mode 100644 index 0000000..0aeb8fd --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/json/escapes.json @@ -0,0 +1 @@ +{"line":"a\nb","quote":"\"","slash":"\\"} diff --git a/tests/fault_tolerance/fuzz/seeds/json/mixed_array.json b/tests/fault_tolerance/fuzz/seeds/json/mixed_array.json new file mode 100644 index 0000000..f824c6e --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/json/mixed_array.json @@ -0,0 +1 @@ +{"tags":["x","y","z"],"values":[true,false,null,42,"text"]} diff --git a/tests/fault_tolerance/fuzz/seeds/json/nested_object.json b/tests/fault_tolerance/fuzz/seeds/json/nested_object.json new file mode 100644 index 0000000..b2e2138 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/json/nested_object.json @@ -0,0 +1 @@ +{"user":{"name":"Ada","roles":["admin","editor"]},"settings":{"debug":false,"retries":3}} diff --git a/tests/fault_tolerance/fuzz/seeds/json/nested_values.json b/tests/fault_tolerance/fuzz/seeds/json/nested_values.json new file mode 100644 index 0000000..ccb9956 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/json/nested_values.json @@ -0,0 +1 @@ +{"name":"Ada","count":2,"ratio":3.5,"active":true,"enabled":false,"missing":null,"empty_obj":{},"empty_arr":[],"meta":{"line":"a\nb","quote":"\"","slash":"\\"},"tags":["x","y"]} diff --git a/tests/fault_tolerance/fuzz/seeds/json/null_and_booleans.json b/tests/fault_tolerance/fuzz/seeds/json/null_and_booleans.json new file mode 100644 index 0000000..dc64174 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/json/null_and_booleans.json @@ -0,0 +1 @@ +{"active":true,"enabled":false,"missing":null} diff --git a/tests/fault_tolerance/fuzz/seeds/json/numbers.json b/tests/fault_tolerance/fuzz/seeds/json/numbers.json new file mode 100644 index 0000000..ebd9186 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/json/numbers.json @@ -0,0 +1 @@ +{"count":2,"ratio":3.5,"zero":0,"negative":-1} diff --git a/tests/fault_tolerance/fuzz/seeds/json/simple_file.json b/tests/fault_tolerance/fuzz/seeds/json/simple_file.json new file mode 100644 index 0000000..2e6c616 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/json/simple_file.json @@ -0,0 +1 @@ +{"name":"Ada","items":[1,2]} diff --git a/tests/fault_tolerance/fuzz/seeds/json/simple_object.json b/tests/fault_tolerance/fuzz/seeds/json/simple_object.json new file mode 100644 index 0000000..d37c580 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/json/simple_object.json @@ -0,0 +1 @@ +{"greeting": "Hello", "name": "Ada"} diff --git a/tests/fault_tolerance/fuzz/seeds/lua_chunk/args_access b/tests/fault_tolerance/fuzz/seeds/lua_chunk/args_access new file mode 100644 index 0000000..583cdba --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/lua_chunk/args_access @@ -0,0 +1 @@ +return ARGS[0] \ No newline at end of file diff --git a/tests/fault_tolerance/fuzz/seeds/lua_chunk/helper_call b/tests/fault_tolerance/fuzz/seeds/lua_chunk/helper_call new file mode 100644 index 0000000..b4b2802 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/lua_chunk/helper_call @@ -0,0 +1 @@ +return upper(name) \ No newline at end of file diff --git a/tests/fault_tolerance/fuzz/seeds/lua_chunk/return_scalar b/tests/fault_tolerance/fuzz/seeds/lua_chunk/return_scalar new file mode 100644 index 0000000..e71fa1a --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/lua_chunk/return_scalar @@ -0,0 +1 @@ +return 42 \ No newline at end of file diff --git a/tests/fault_tolerance/fuzz/seeds/lua_chunk/sparse_table b/tests/fault_tolerance/fuzz/seeds/lua_chunk/sparse_table new file mode 100644 index 0000000..3ea74d2 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/lua_chunk/sparse_table @@ -0,0 +1 @@ +return { [2] = "Grace" } \ No newline at end of file diff --git a/tests/fault_tolerance/fuzz/seeds/lua_chunk/string_rep b/tests/fault_tolerance/fuzz/seeds/lua_chunk/string_rep new file mode 100644 index 0000000..34e39db --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/lua_chunk/string_rep @@ -0,0 +1 @@ +return string.rep("x", 128) \ No newline at end of file diff --git a/tests/fault_tolerance/fuzz/seeds/lua_chunk/sum_loop b/tests/fault_tolerance/fuzz/seeds/lua_chunk/sum_loop new file mode 100644 index 0000000..dc756b6 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/lua_chunk/sum_loop @@ -0,0 +1 @@ +local sum = 0 for i = 1, 100 do sum = sum + i end return sum \ No newline at end of file diff --git a/tests/fault_tolerance/fuzz/seeds/lua_sandbox/blocked_loadfile b/tests/fault_tolerance/fuzz/seeds/lua_sandbox/blocked_loadfile new file mode 100644 index 0000000..b018e06 Binary files /dev/null and b/tests/fault_tolerance/fuzz/seeds/lua_sandbox/blocked_loadfile differ diff --git a/tests/fault_tolerance/fuzz/seeds/lua_sandbox/blocked_metatable_proxy b/tests/fault_tolerance/fuzz/seeds/lua_sandbox/blocked_metatable_proxy new file mode 100644 index 0000000..20ea353 Binary files /dev/null and b/tests/fault_tolerance/fuzz/seeds/lua_sandbox/blocked_metatable_proxy differ diff --git a/tests/fault_tolerance/fuzz/seeds/lua_sandbox/blocked_os_execute b/tests/fault_tolerance/fuzz/seeds/lua_sandbox/blocked_os_execute new file mode 100644 index 0000000..662217a Binary files /dev/null and b/tests/fault_tolerance/fuzz/seeds/lua_sandbox/blocked_os_execute differ diff --git a/tests/fault_tolerance/fuzz/seeds/lua_sandbox/blocked_require b/tests/fault_tolerance/fuzz/seeds/lua_sandbox/blocked_require new file mode 100644 index 0000000..07c7e21 Binary files /dev/null and b/tests/fault_tolerance/fuzz/seeds/lua_sandbox/blocked_require differ diff --git a/tests/fault_tolerance/fuzz/seeds/lua_sandbox/limit_instruction_loop b/tests/fault_tolerance/fuzz/seeds/lua_sandbox/limit_instruction_loop new file mode 100644 index 0000000..70bedc3 Binary files /dev/null and b/tests/fault_tolerance/fuzz/seeds/lua_sandbox/limit_instruction_loop differ diff --git a/tests/fault_tolerance/fuzz/seeds/lua_sandbox/limit_time_loop b/tests/fault_tolerance/fuzz/seeds/lua_sandbox/limit_time_loop new file mode 100644 index 0000000..bc6dc57 Binary files /dev/null and b/tests/fault_tolerance/fuzz/seeds/lua_sandbox/limit_time_loop differ diff --git a/tests/fault_tolerance/fuzz/seeds/lua_sandbox/safe_load_chunk b/tests/fault_tolerance/fuzz/seeds/lua_sandbox/safe_load_chunk new file mode 100644 index 0000000..572d2f7 Binary files /dev/null and b/tests/fault_tolerance/fuzz/seeds/lua_sandbox/safe_load_chunk differ diff --git a/tests/fault_tolerance/fuzz/seeds/lua_sandbox/safe_string_upper b/tests/fault_tolerance/fuzz/seeds/lua_sandbox/safe_string_upper new file mode 100644 index 0000000..b7e0b42 Binary files /dev/null and b/tests/fault_tolerance/fuzz/seeds/lua_sandbox/safe_string_upper differ diff --git a/tests/fault_tolerance/fuzz/seeds/render_pbt/batch_interpolation.pbt b/tests/fault_tolerance/fuzz/seeds/render_pbt/batch_interpolation.pbt new file mode 100644 index 0000000..98f76c6 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/render_pbt/batch_interpolation.pbt @@ -0,0 +1 @@ +{{ greeting }} {{ name }}! diff --git a/tests/fault_tolerance/fuzz/seeds/render_pbt/comparison_and_in.pbt b/tests/fault_tolerance/fuzz/seeds/render_pbt/comparison_and_in.pbt new file mode 100644 index 0000000..5872553 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/render_pbt/comparison_and_in.pbt @@ -0,0 +1 @@ +{{ if price >= min_price && sku in allowed }}ok{{ endif }} diff --git a/tests/fault_tolerance/fuzz/seeds/render_pbt/filter_chain.pbt b/tests/fault_tolerance/fuzz/seeds/render_pbt/filter_chain.pbt new file mode 100644 index 0000000..c21fc75 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/render_pbt/filter_chain.pbt @@ -0,0 +1 @@ +{{ name | replace("a", "b") }} diff --git a/tests/fault_tolerance/fuzz/seeds/render_pbt/for_loop.pbt b/tests/fault_tolerance/fuzz/seeds/render_pbt/for_loop.pbt new file mode 100644 index 0000000..f625ec7 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/render_pbt/for_loop.pbt @@ -0,0 +1 @@ +{{ for item in items }}{{ item }}{{ else }}empty{{ endfor }} diff --git a/tests/fault_tolerance/fuzz/seeds/render_pbt/function_definition.pbt b/tests/fault_tolerance/fuzz/seeds/render_pbt/function_definition.pbt new file mode 100644 index 0000000..affec24 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/render_pbt/function_definition.pbt @@ -0,0 +1 @@ +{{ fn greet(name) }}Hello {{ name }}{{ endfn }}{{ greet("Ada") }} diff --git a/tests/fault_tolerance/fuzz/seeds/render_pbt/function_tokens.pbt b/tests/fault_tolerance/fuzz/seeds/render_pbt/function_tokens.pbt new file mode 100644 index 0000000..59c8430 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/render_pbt/function_tokens.pbt @@ -0,0 +1 @@ +{{ fn greet(name) }}x{{ endfn }}{{ greet("Ada") }} diff --git a/tests/fault_tolerance/fuzz/seeds/render_pbt/if_else.pbt b/tests/fault_tolerance/fuzz/seeds/render_pbt/if_else.pbt new file mode 100644 index 0000000..de6727c --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/render_pbt/if_else.pbt @@ -0,0 +1 @@ +Hello {{ if enabled }}Yes{{ else }}No{{ endif }} diff --git a/tests/fault_tolerance/fuzz/seeds/render_pbt/include_and_if.pbt b/tests/fault_tolerance/fuzz/seeds/render_pbt/include_and_if.pbt new file mode 100644 index 0000000..4219e4b --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/render_pbt/include_and_if.pbt @@ -0,0 +1,3 @@ +{{ include "header.txt" }} +{{ if enabled }}Enabled{{ else }}Disabled{{ endif }} +Footer diff --git a/tests/fault_tolerance/fuzz/seeds/render_pbt/include_partial.pbt b/tests/fault_tolerance/fuzz/seeds/render_pbt/include_partial.pbt new file mode 100644 index 0000000..3538bb5 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/render_pbt/include_partial.pbt @@ -0,0 +1,3 @@ +Start +{{ include "partial.md" }} +End diff --git a/tests/fault_tolerance/fuzz/seeds/render_pbt/lua_block.pbt b/tests/fault_tolerance/fuzz/seeds/render_pbt/lua_block.pbt new file mode 100644 index 0000000..3bc4bf0 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/render_pbt/lua_block.pbt @@ -0,0 +1,3 @@ +{{ lua:block }} +return "Hello " .. name +{{ endlua }} diff --git a/tests/fault_tolerance/fuzz/seeds/render_pbt/lua_condition.pbt b/tests/fault_tolerance/fuzz/seeds/render_pbt/lua_condition.pbt new file mode 100644 index 0000000..97eb8b0 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/render_pbt/lua_condition.pbt @@ -0,0 +1 @@ +{{ if enabled && lua("return upper(name) == 'ADA'") }}ok{{ else }}bad{{ endif }} diff --git a/tests/fault_tolerance/fuzz/seeds/render_pbt/lua_expression.pbt b/tests/fault_tolerance/fuzz/seeds/render_pbt/lua_expression.pbt new file mode 100644 index 0000000..82528d5 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/render_pbt/lua_expression.pbt @@ -0,0 +1 @@ +{{ lua "return 42" }} {{ if lua("return true") }}ok{{ endif }} diff --git a/tests/fault_tolerance/fuzz/seeds/render_pbt/lua_function_def.pbt b/tests/fault_tolerance/fuzz/seeds/render_pbt/lua_function_def.pbt new file mode 100644 index 0000000..21665d3 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/render_pbt/lua_function_def.pbt @@ -0,0 +1 @@ +{{ fn pick() lua:block }}return { name = "Ada" }{{ endfn }} diff --git a/tests/fault_tolerance/fuzz/seeds/render_pbt/lua_inline.pbt b/tests/fault_tolerance/fuzz/seeds/render_pbt/lua_inline.pbt new file mode 100644 index 0000000..f710246 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/render_pbt/lua_inline.pbt @@ -0,0 +1 @@ +{{ lua "return upper(name)" }} diff --git a/tests/fault_tolerance/fuzz/seeds/render_pbt/member_and_index.pbt b/tests/fault_tolerance/fuzz/seeds/render_pbt/member_and_index.pbt new file mode 100644 index 0000000..3fc425d --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/render_pbt/member_and_index.pbt @@ -0,0 +1 @@ +{{ user.name }} {{ items[0] }} {{ ARGS[0] }} diff --git a/tests/fault_tolerance/fuzz/seeds/render_pbt/nested_if_for.pbt b/tests/fault_tolerance/fuzz/seeds/render_pbt/nested_if_for.pbt new file mode 100644 index 0000000..4ed1c17 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/render_pbt/nested_if_for.pbt @@ -0,0 +1 @@ +{{ if groups }}{{ for group in groups }}{{ if group.featured }}x{{ elseif group.archived }}y{{ else }}z{{ endif }}{{ endfor }}{{ endif }} diff --git a/tests/fault_tolerance/fuzz/seeds/render_pbt/render_include_if_header.pbt b/tests/fault_tolerance/fuzz/seeds/render_pbt/render_include_if_header.pbt new file mode 100644 index 0000000..68dc3ba --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/render_pbt/render_include_if_header.pbt @@ -0,0 +1 @@ +Header for {{ name }} diff --git a/tests/fault_tolerance/fuzz/seeds/render_pbt/set_statement.pbt b/tests/fault_tolerance/fuzz/seeds/render_pbt/set_statement.pbt new file mode 100644 index 0000000..512c601 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/render_pbt/set_statement.pbt @@ -0,0 +1 @@ +{{ set title = user.name | trim | upper }} diff --git a/tests/fault_tolerance/fuzz/seeds/render_pbt/simple_interpolation.pbt b/tests/fault_tolerance/fuzz/seeds/render_pbt/simple_interpolation.pbt new file mode 100644 index 0000000..cd775cb --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/render_pbt/simple_interpolation.pbt @@ -0,0 +1 @@ +Hello {{ name }} diff --git a/tests/fault_tolerance/fuzz/seeds/render_pbt/trim_markers.pbt b/tests/fault_tolerance/fuzz/seeds/render_pbt/trim_markers.pbt new file mode 100644 index 0000000..bb18e40 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/render_pbt/trim_markers.pbt @@ -0,0 +1 @@ +A {{- greet() -}} B diff --git a/tests/fault_tolerance/fuzz/seeds/settings/settings_ini.ini b/tests/fault_tolerance/fuzz/seeds/settings/settings_ini.ini new file mode 100644 index 0000000..b90f05d --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/settings/settings_ini.ini @@ -0,0 +1,4 @@ +name = Ada +[rules] +trim = true +max_include_depth = 2 diff --git a/tests/fault_tolerance/fuzz/seeds/settings/settings_json.json b/tests/fault_tolerance/fuzz/seeds/settings/settings_json.json new file mode 100644 index 0000000..40492d7 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/settings/settings_json.json @@ -0,0 +1,21 @@ +{ + "variables": {"name": "Ada", "empty": null}, + "include_paths": ["includes/a", "includes/b"], + "ignore": ["secret", "token"], + "rules": {"trim": "true", "default_variable_value": null}, + "file_rules": { + ".md": {"trim": "true"}, + "README.md": {"default_variable_value": "Fallback"} + }, + "profiles": { + "dev": { + "variables": {"mode": "debug"}, + "include_paths": ["profile/includes"], + "ignore": ["profile-secret"], + "rules": {"strict_variables": "true"}, + "file_rules": { + ".txt": {"default_variable_value": "ProfileFallback"} + } + } + } +} diff --git a/tests/fault_tolerance/fuzz/seeds/settings/settings_toml.toml b/tests/fault_tolerance/fuzz/seeds/settings/settings_toml.toml new file mode 100644 index 0000000..463a82e --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/settings/settings_toml.toml @@ -0,0 +1,6 @@ +[rules] +trim = true +max_include_depth = 2 + +[profiles.dev.rules] +strict_variables = true diff --git a/tests/fault_tolerance/fuzz/seeds/settings/settings_yaml.yaml b/tests/fault_tolerance/fuzz/seeds/settings/settings_yaml.yaml new file mode 100644 index 0000000..ba5f1d7 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/settings/settings_yaml.yaml @@ -0,0 +1,11 @@ +variables: + greeting: Hello + name: Grace +ignore: + - secret +rules: + trim: "true" +profiles: + dev: + rules: + strict_variables: "true" diff --git a/tests/fault_tolerance/fuzz/seeds/structured_import/env_app_import b/tests/fault_tolerance/fuzz/seeds/structured_import/env_app_import new file mode 100644 index 0000000..41b1128 Binary files /dev/null and b/tests/fault_tolerance/fuzz/seeds/structured_import/env_app_import differ diff --git a/tests/fault_tolerance/fuzz/seeds/structured_import/ini_server_import b/tests/fault_tolerance/fuzz/seeds/structured_import/ini_server_import new file mode 100644 index 0000000..6a58616 Binary files /dev/null and b/tests/fault_tolerance/fuzz/seeds/structured_import/ini_server_import differ diff --git a/tests/fault_tolerance/fuzz/seeds/structured_import/json_user_import b/tests/fault_tolerance/fuzz/seeds/structured_import/json_user_import new file mode 100644 index 0000000..43cf944 Binary files /dev/null and b/tests/fault_tolerance/fuzz/seeds/structured_import/json_user_import differ diff --git a/tests/fault_tolerance/fuzz/seeds/structured_import/toml_server_import b/tests/fault_tolerance/fuzz/seeds/structured_import/toml_server_import new file mode 100644 index 0000000..51e14bd Binary files /dev/null and b/tests/fault_tolerance/fuzz/seeds/structured_import/toml_server_import differ diff --git a/tests/fault_tolerance/fuzz/seeds/structured_import/yaml_catalog_import b/tests/fault_tolerance/fuzz/seeds/structured_import/yaml_catalog_import new file mode 100644 index 0000000..0bfa5ef Binary files /dev/null and b/tests/fault_tolerance/fuzz/seeds/structured_import/yaml_catalog_import differ diff --git a/tests/fault_tolerance/fuzz/seeds/template/batch_interpolation.txt b/tests/fault_tolerance/fuzz/seeds/template/batch_interpolation.txt new file mode 100644 index 0000000..98f76c6 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/template/batch_interpolation.txt @@ -0,0 +1 @@ +{{ greeting }} {{ name }}! diff --git a/tests/fault_tolerance/fuzz/seeds/template/comparison_and_in.txt b/tests/fault_tolerance/fuzz/seeds/template/comparison_and_in.txt new file mode 100644 index 0000000..5872553 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/template/comparison_and_in.txt @@ -0,0 +1 @@ +{{ if price >= min_price && sku in allowed }}ok{{ endif }} diff --git a/tests/fault_tolerance/fuzz/seeds/template/filter_chain.txt b/tests/fault_tolerance/fuzz/seeds/template/filter_chain.txt new file mode 100644 index 0000000..c21fc75 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/template/filter_chain.txt @@ -0,0 +1 @@ +{{ name | replace("a", "b") }} diff --git a/tests/fault_tolerance/fuzz/seeds/template/for_loop.txt b/tests/fault_tolerance/fuzz/seeds/template/for_loop.txt new file mode 100644 index 0000000..f625ec7 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/template/for_loop.txt @@ -0,0 +1 @@ +{{ for item in items }}{{ item }}{{ else }}empty{{ endfor }} diff --git a/tests/fault_tolerance/fuzz/seeds/template/function_definition.txt b/tests/fault_tolerance/fuzz/seeds/template/function_definition.txt new file mode 100644 index 0000000..affec24 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/template/function_definition.txt @@ -0,0 +1 @@ +{{ fn greet(name) }}Hello {{ name }}{{ endfn }}{{ greet("Ada") }} diff --git a/tests/fault_tolerance/fuzz/seeds/template/function_tokens.txt b/tests/fault_tolerance/fuzz/seeds/template/function_tokens.txt new file mode 100644 index 0000000..59c8430 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/template/function_tokens.txt @@ -0,0 +1 @@ +{{ fn greet(name) }}x{{ endfn }}{{ greet("Ada") }} diff --git a/tests/fault_tolerance/fuzz/seeds/template/if_else.txt b/tests/fault_tolerance/fuzz/seeds/template/if_else.txt new file mode 100644 index 0000000..de6727c --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/template/if_else.txt @@ -0,0 +1 @@ +Hello {{ if enabled }}Yes{{ else }}No{{ endif }} diff --git a/tests/fault_tolerance/fuzz/seeds/template/include_and_if.txt b/tests/fault_tolerance/fuzz/seeds/template/include_and_if.txt new file mode 100644 index 0000000..4219e4b --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/template/include_and_if.txt @@ -0,0 +1,3 @@ +{{ include "header.txt" }} +{{ if enabled }}Enabled{{ else }}Disabled{{ endif }} +Footer diff --git a/tests/fault_tolerance/fuzz/seeds/template/include_partial.txt b/tests/fault_tolerance/fuzz/seeds/template/include_partial.txt new file mode 100644 index 0000000..3538bb5 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/template/include_partial.txt @@ -0,0 +1,3 @@ +Start +{{ include "partial.md" }} +End diff --git a/tests/fault_tolerance/fuzz/seeds/template/lua_block.txt b/tests/fault_tolerance/fuzz/seeds/template/lua_block.txt new file mode 100644 index 0000000..3bc4bf0 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/template/lua_block.txt @@ -0,0 +1,3 @@ +{{ lua:block }} +return "Hello " .. name +{{ endlua }} diff --git a/tests/fault_tolerance/fuzz/seeds/template/lua_condition.txt b/tests/fault_tolerance/fuzz/seeds/template/lua_condition.txt new file mode 100644 index 0000000..97eb8b0 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/template/lua_condition.txt @@ -0,0 +1 @@ +{{ if enabled && lua("return upper(name) == 'ADA'") }}ok{{ else }}bad{{ endif }} diff --git a/tests/fault_tolerance/fuzz/seeds/template/lua_expression.txt b/tests/fault_tolerance/fuzz/seeds/template/lua_expression.txt new file mode 100644 index 0000000..82528d5 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/template/lua_expression.txt @@ -0,0 +1 @@ +{{ lua "return 42" }} {{ if lua("return true") }}ok{{ endif }} diff --git a/tests/fault_tolerance/fuzz/seeds/template/lua_function_def.txt b/tests/fault_tolerance/fuzz/seeds/template/lua_function_def.txt new file mode 100644 index 0000000..21665d3 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/template/lua_function_def.txt @@ -0,0 +1 @@ +{{ fn pick() lua:block }}return { name = "Ada" }{{ endfn }} diff --git a/tests/fault_tolerance/fuzz/seeds/template/lua_inline.txt b/tests/fault_tolerance/fuzz/seeds/template/lua_inline.txt new file mode 100644 index 0000000..f710246 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/template/lua_inline.txt @@ -0,0 +1 @@ +{{ lua "return upper(name)" }} diff --git a/tests/fault_tolerance/fuzz/seeds/template/member_and_index.txt b/tests/fault_tolerance/fuzz/seeds/template/member_and_index.txt new file mode 100644 index 0000000..3fc425d --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/template/member_and_index.txt @@ -0,0 +1 @@ +{{ user.name }} {{ items[0] }} {{ ARGS[0] }} diff --git a/tests/fault_tolerance/fuzz/seeds/template/nested_if_for.txt b/tests/fault_tolerance/fuzz/seeds/template/nested_if_for.txt new file mode 100644 index 0000000..4ed1c17 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/template/nested_if_for.txt @@ -0,0 +1 @@ +{{ if groups }}{{ for group in groups }}{{ if group.featured }}x{{ elseif group.archived }}y{{ else }}z{{ endif }}{{ endfor }}{{ endif }} diff --git a/tests/fault_tolerance/fuzz/seeds/template/set_statement.txt b/tests/fault_tolerance/fuzz/seeds/template/set_statement.txt new file mode 100644 index 0000000..512c601 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/template/set_statement.txt @@ -0,0 +1 @@ +{{ set title = user.name | trim | upper }} diff --git a/tests/fault_tolerance/fuzz/seeds/template/simple_interpolation.txt b/tests/fault_tolerance/fuzz/seeds/template/simple_interpolation.txt new file mode 100644 index 0000000..cd775cb --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/template/simple_interpolation.txt @@ -0,0 +1 @@ +Hello {{ name }} diff --git a/tests/fault_tolerance/fuzz/seeds/template/trim_markers.txt b/tests/fault_tolerance/fuzz/seeds/template/trim_markers.txt new file mode 100644 index 0000000..bb18e40 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/template/trim_markers.txt @@ -0,0 +1 @@ +A {{- greet() -}} B diff --git a/tests/fault_tolerance/fuzz/seeds/toml/nested_tables.toml b/tests/fault_tolerance/fuzz/seeds/toml/nested_tables.toml new file mode 100644 index 0000000..eb995a2 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/toml/nested_tables.toml @@ -0,0 +1,7 @@ +[database] +host = "db.local" +port = 5432 + +[database.credentials] +user = "ada" +password = "secret" diff --git a/tests/fault_tolerance/fuzz/seeds/toml/server_config.toml b/tests/fault_tolerance/fuzz/seeds/toml/server_config.toml new file mode 100644 index 0000000..0e382e0 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/toml/server_config.toml @@ -0,0 +1,3 @@ +[server] +host="localhost" +port=8080 diff --git a/tests/fault_tolerance/fuzz/seeds/yaml/list_items.yaml b/tests/fault_tolerance/fuzz/seeds/yaml/list_items.yaml new file mode 100644 index 0000000..2afc087 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/yaml/list_items.yaml @@ -0,0 +1,3 @@ +- Ada +- Grace +- Linus diff --git a/tests/fault_tolerance/fuzz/seeds/yaml/profile_merge.yaml b/tests/fault_tolerance/fuzz/seeds/yaml/profile_merge.yaml new file mode 100644 index 0000000..8b011d2 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/yaml/profile_merge.yaml @@ -0,0 +1,14 @@ +variables: + greeting: Hello + name: Grace +ignore: + - base_ignore +profiles: + friendly: + ignore: + - friendly_ignore + variables: + greeting: Hi + strict: + rules: + strict_variables: true diff --git a/tests/fault_tolerance/fuzz/seeds/yaml/simple_mapping.yaml b/tests/fault_tolerance/fuzz/seeds/yaml/simple_mapping.yaml new file mode 100644 index 0000000..7a87783 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/yaml/simple_mapping.yaml @@ -0,0 +1,7 @@ +name: Ada +roles: + - admin + - editor +meta: + active: true + score: 42 diff --git a/tests/fault_tolerance/fuzz/support/FuzzFileUtil.h b/tests/fault_tolerance/fuzz/support/FuzzFileUtil.h new file mode 100644 index 0000000..66a7475 --- /dev/null +++ b/tests/fault_tolerance/fuzz/support/FuzzFileUtil.h @@ -0,0 +1,10 @@ +#pragma once + +#include "support/FileUtil.h" + +#include +#include + +inline void fuzz_write_file(const std::filesystem::path& path, const std::string& content) { + prebyte::file_util::write_text_file(path, content); +} diff --git a/tests/fault_tolerance/fuzz/support/FuzzRuntimeReset.h b/tests/fault_tolerance/fuzz/support/FuzzRuntimeReset.h new file mode 100644 index 0000000..1b6a4a2 --- /dev/null +++ b/tests/fault_tolerance/fuzz/support/FuzzRuntimeReset.h @@ -0,0 +1,11 @@ +#pragma once + +#include "config/VariableDefinitionParser.h" +#include "runtime/compiled/CompiledTemplateCache.h" +#include "runtime/cache/FileMetadataCache.h" + +inline void fuzz_reset_runtime_state() { + prebyte::FileMetadataCache::instance().clear(); + prebyte::VariableDefinitionParser::clear_import_cache(); + prebyte::CompiledTemplateCache::instance().clear(); +} diff --git a/tests/fault_tolerance/fuzz/support/FuzzTempDir.h b/tests/fault_tolerance/fuzz/support/FuzzTempDir.h new file mode 100644 index 0000000..9d10eb8 --- /dev/null +++ b/tests/fault_tolerance/fuzz/support/FuzzTempDir.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include + +class FuzzTempDir { +public: + FuzzTempDir() { + path_ = root_directory() / std::to_string(next_sequence()); + std::filesystem::create_directories(path_); + } + + ~FuzzTempDir() { + std::error_code error; + std::filesystem::remove_all(path_, error); + } + + FuzzTempDir(const FuzzTempDir&) = delete; + FuzzTempDir& operator=(const FuzzTempDir&) = delete; + + const std::filesystem::path& path() const { + return path_; + } + +private: + static std::filesystem::path root_directory() { + static const std::filesystem::path root = []() { + const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count(); + const std::filesystem::path path = std::filesystem::temp_directory_path() + / ("prebyte-fuzz-root-" + std::to_string(static_cast(stamp))); + std::filesystem::create_directories(path); + return path; + }(); + return root; + } + + static std::uint64_t next_sequence() { + static std::atomic counter{0}; + return counter.fetch_add(1, std::memory_order_relaxed); + } + + std::filesystem::path path_; +}; diff --git a/tests/fault_tolerance/regression/FuzzerRegressionTests.cpp b/tests/fault_tolerance/regression/FuzzerRegressionTests.cpp new file mode 100644 index 0000000..65aadd2 --- /dev/null +++ b/tests/fault_tolerance/regression/FuzzerRegressionTests.cpp @@ -0,0 +1,232 @@ +#include "TestHarness.h" + +#include "config/ConfigTypes.h" +#include "parser/TomlParser.h" +#include "runtime/compiled/CompiledTemplateCompiler.h" +#include "runtime/compiled/CompiledTemplateSerializer.h" +#include "runtime/cache/FileMetadataCache.h" +#include "runtime/resolution/IncludeResolver.h" +#include "runtime/lua/LuaRuntime.h" +#include "support/Diagnostic.h" +#include "template/lexer/TemplateLexer.h" +#include "template/parser/TemplateParser.h" + +#include +#include +#include + +namespace { + +void append_u32(std::string& out, std::uint32_t value) { + for (int shift = 0; shift < 32; shift += 8) { + out.push_back(static_cast((value >> shift) & 0xffu)); + } +} + +void append_string(std::string& out, std::string_view value) { + append_u32(out, static_cast(value.size())); + out.append(value.data(), value.size()); +} + +std::string make_compiled_header_with_counts(std::uint32_t template_count) { + std::string bytes; + bytes.append("PBC1", 4); + append_u32(bytes, 8); + append_string(bytes, "logical.pbt"); + append_string(bytes, "source.pbt"); + append_string(bytes, "{{"); + append_string(bytes, "}}"); + append_u32(bytes, 0); + append_u32(bytes, 4); + append_u32(bytes, 0); + append_u32(bytes, template_count); + append_u32(bytes, 0); + append_u32(bytes, 0); + append_u32(bytes, 0); + append_u32(bytes, 0); + return bytes; +} + +std::string serialize_minimal_program() { + prebyte::EffectiveSettings settings; + prebyte::CompiledTemplateCompiler compiler; + const prebyte::CompiledProgram program = + compiler.compile_source("Hello {{ name }}\n", "seed.pbt", "seed.pbt", settings); + prebyte::CompiledTemplateSerializer serializer; + return serializer.serialize(program); +} + +std::filesystem::path resolver_test_root(const std::string& name) { + const std::filesystem::path root = std::filesystem::temp_directory_path() / "prebyte-fuzzer-regression" / name; + std::error_code error; + std::filesystem::remove_all(root, error); + std::filesystem::create_directories(root); + return root; +} + +void write_file(const std::filesystem::path& path, const std::string& content) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream file(path, std::ios::binary); + file << content; +} + +std::string nested_toml_array(int depth) { + std::string value; + for (int index = 0; index < depth; ++index) { + value.push_back('['); + } + value += "1"; + for (int index = 0; index < depth; ++index) { + value.push_back(']'); + } + return "items = " + value + "\n"; +} + +void expect_diagnostic_message(const auto& callable, const std::string& expected_message) { + try { + callable(); + throw std::runtime_error("expected DiagnosticError"); + } catch (const prebyte::DiagnosticError& error) { + REQUIRE(error.diagnostic().message.find(expected_message) != std::string::npos); + } +} + +} + +TEST_CASE(FuzzerRegression_template_lexer_rejects_truncated_string_escape) { + prebyte::TemplateLexer lexer("{{-\"\\", "inline"); + REQUIRE_THROWS_AS(lexer.lex(), prebyte::DiagnosticError); +} + +TEST_CASE(FuzzerRegression_template_parser_rejects_deep_expression_nesting) { + std::string source = "{{ "; + for (int index = 0; index < 100; ++index) { + source.push_back('('); + } + source += "1"; + for (int index = 0; index < 100; ++index) { + source.push_back(')'); + } + source += " }}"; + + prebyte::TemplateLexer lexer(source, "inline"); + prebyte::TemplateParser parser(lexer.lex()); + expect_diagnostic_message([&] { (void)parser.parse_document(); }, "Expression nesting is too deep"); +} + +TEST_CASE(FuzzerRegression_toml_parser_rejects_deep_table_path) { + std::string toml = "["; + for (int index = 0; index < 200; ++index) { + if (index != 0) { + toml.push_back('.'); + } + toml += "section"; + } + toml += "]\nvalue = 1"; + + prebyte::TomlParser parser; + REQUIRE_THROWS_AS(parser.parse_string(toml), std::runtime_error); +} + +TEST_CASE(FuzzerRegression_toml_parser_rejects_deep_array_nesting) { + prebyte::TomlParser parser; + REQUIRE_THROWS_AS(parser.parse_string(nested_toml_array(65)), std::runtime_error); + parser.parse_string(nested_toml_array(32)); +} + +TEST_CASE(FuzzerRegression_compiled_template_serializer_rejects_truncated_blob) { + prebyte::CompiledTemplateSerializer serializer; + + REQUIRE_THROWS_AS(serializer.deserialize("", "bad.pbc"), prebyte::DiagnosticError); + REQUIRE_THROWS_AS(serializer.deserialize("PBC", "bad.pbc"), prebyte::DiagnosticError); + + const std::string valid = serialize_minimal_program(); + REQUIRE_THROWS_AS(serializer.deserialize(valid.substr(0, valid.size() / 2), "bad.pbc"), + prebyte::DiagnosticError); +} + +TEST_CASE(FuzzerRegression_compiled_template_serializer_rejects_oversized_string_length) { + prebyte::CompiledTemplateSerializer serializer; + std::string bytes; + bytes.append("PBC1", 4); + append_u32(bytes, 8); + append_u32(bytes, 1000); + + REQUIRE_THROWS_AS(serializer.deserialize(bytes, "bad.pbc"), prebyte::DiagnosticError); +} + +TEST_CASE(FuzzerRegression_compiled_template_serializer_rejects_oversized_section_count) { + prebyte::CompiledTemplateSerializer serializer; + const std::string bytes = make_compiled_header_with_counts(1000000); + + expect_diagnostic_message([&] { (void)serializer.deserialize(bytes, "bad.pbc"); }, + "Invalid compiled template section size"); +} + +TEST_CASE(FuzzerRegression_include_resolver_rejects_overlong_and_overdeep_paths) { + const std::filesystem::path root = resolver_test_root("include-limits"); + write_file(root / "main.txt", "Hello\n"); + + prebyte::IncludeResolver resolver; + prebyte::RenderSession session; + session.include_anchor_root = root / "nested"; + prebyte::EffectiveSettings settings; + settings.allow_includes = true; + settings.include_paths.push_back(root); + + const std::string long_path(5000, 'a'); + expect_diagnostic_message([&] { (void)resolver.load(long_path, root / "nested" / "main.txt", settings, session); }, + "Include path is too long"); + + std::string deep_path; + for (int index = 0; index < 100; ++index) { + deep_path += "../"; + } + deep_path += "main.txt"; + expect_diagnostic_message([&] { (void)resolver.load(deep_path, root / "nested" / "main.txt", settings, session); }, + "Include path is too deep"); +} + +TEST_CASE(FuzzerRegression_include_resolver_rejects_traversal_outside_allowed_roots) { + const std::filesystem::path root = resolver_test_root("include-traversal"); + const std::filesystem::path outside = root.parent_path() / (root.filename().string() + "-outside"); + write_file(outside / "secret.txt", "LEAKED\n"); + write_file(root / "nested" / "main.txt", + "{{ include \"../../" + (root.filename().string() + "-outside") + "/secret.txt\" }}"); + + prebyte::IncludeResolver resolver; + prebyte::RenderSession session; + session.include_anchor_root = root / "nested"; + prebyte::EffectiveSettings settings; + settings.allow_includes = true; + + expect_diagnostic_message( + [&] { (void)resolver.load("../../" + (root.filename().string() + "-outside") + "/secret.txt", + root / "nested" / "main.txt", settings, session); }, + "escapes allowed roots"); +} + +TEST_CASE(FuzzerRegression_file_metadata_cache_treats_empty_path_as_missing) { + prebyte::FileMetadataCache::instance().clear(); + const prebyte::FileMetadata metadata = prebyte::FileMetadataCache::instance().probe(""); + REQUIRE(!metadata.exists); +} + +TEST_CASE(FuzzerRegression_lua_runtime_reads_sparse_lua_table_without_crashing) { + prebyte::LuaRuntime runtime; + prebyte::RenderSession session; + prebyte::EffectiveSettings settings; + + const prebyte::Value value = runtime.execute( + R"(return { [2] = "Grace" })", + prebyte::LuaChunkMode::InlineValue, + settings, + session, + "runtime.txt", + {}); + + REQUIRE(value.is_object()); + REQUIRE(!value.is_list()); + REQUIRE(value.member("2").has_value()); + REQUIRE_EQ(value.member("2")->to_string(), std::string("Grace")); +} diff --git a/tests/fault_tolerance/sanitizers/README.md b/tests/fault_tolerance/sanitizers/README.md new file mode 100644 index 0000000..97e9114 --- /dev/null +++ b/tests/fault_tolerance/sanitizers/README.md @@ -0,0 +1,13 @@ +# Sanitizer runs + +Sanitizer coverage is not a separate source tree. The full `prebyte_tests` binary is rebuilt and executed under Clang instrumentation: + +| Sanitizer | Local command | CI job | +| --- | --- | --- | +| Address + UndefinedBehavior | `make sanitize` | `asan-ubsan-linux-x86_64` | +| Thread | `make tsan` | `tsan-linux-x86_64` | +| Memory | `make msan` | `msan-linux-x86_64` | + +Entry point: `scripts/ci/run_sanitize_tests.sh`. + +Correctness tests under `tests/correctness/`, plus security, concurrency, portability, and regression tests, all participate in these runs. diff --git a/tests/TestHarness.cpp b/tests/harness/TestHarness.cpp similarity index 100% rename from tests/TestHarness.cpp rename to tests/harness/TestHarness.cpp diff --git a/tests/TestHarness.h b/tests/harness/TestHarness.h similarity index 100% rename from tests/TestHarness.h rename to tests/harness/TestHarness.h diff --git a/tests/TestMain.cpp b/tests/harness/TestMain.cpp similarity index 100% rename from tests/TestMain.cpp rename to tests/harness/TestMain.cpp diff --git a/tests/BenchmarkMain.cpp b/tests/performance/BenchmarkMain.cpp similarity index 96% rename from tests/BenchmarkMain.cpp rename to tests/performance/BenchmarkMain.cpp index 2cd5cee..537964a 100644 --- a/tests/BenchmarkMain.cpp +++ b/tests/performance/BenchmarkMain.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -164,7 +165,10 @@ void append_section(std::vector& lines, const std::string& title, c } int main() { - const std::filesystem::path history_path = "tests/benchmarks/history.md"; + const char* history_override = std::getenv("PREBYTE_BENCHMARK_HISTORY"); + const std::filesystem::path history_path = history_override != nullptr && *history_override != '\0' + ? std::filesystem::path(history_override) + : std::filesystem::path("tests/performance/history.md"); std::filesystem::create_directories(history_path.parent_path()); const auto now = std::chrono::system_clock::now(); diff --git a/tests/performance/README.md b/tests/performance/README.md new file mode 100644 index 0000000..d74ea06 --- /dev/null +++ b/tests/performance/README.md @@ -0,0 +1,7 @@ +# Performance + +- `BenchmarkMain.cpp` — internal render and batch timing cases. +- `history.md` — append-only timing history from `make benchmark`. +- `baselines.txt` — per-case upper bounds for `make benchmark-gate`. + +Cross-engine comparison (Go `text/template`, Rust Askama) lives in `tools/benchmark_compare/` and is not part of CI. diff --git a/tests/performance/baselines.txt b/tests/performance/baselines.txt new file mode 100644 index 0000000..b84ca97 --- /dev/null +++ b/tests/performance/baselines.txt @@ -0,0 +1,10 @@ +# Maximum allowed render time per benchmark case (microseconds). +# CI fails when a case exceeds its limit. Update after intentional perf work. + +simple-variable=500000 +if-include=800000 +profile-merge=800000 +lua-inline=900000 +lua-repeated=1200000 +lua-condition=900000 +batch-variable=3000000 diff --git a/tests/benchmarks/history.md b/tests/performance/history.md similarity index 100% rename from tests/benchmarks/history.md rename to tests/performance/history.md diff --git a/tests/portability/README.md b/tests/portability/README.md new file mode 100644 index 0000000..2fe715a --- /dev/null +++ b/tests/portability/README.md @@ -0,0 +1,9 @@ +# Portability + +Validates the shipped CLI binary via real subprocesses (not in-process AppRunner calls). + +- `cli/CliBinaryE2ETests.cpp` — help, version, render, batch, list/explain modes, diagnostics on stderr. + +Requires `prebyte` to be built (`add_dependencies(prebyte_tests prebyte)`). Override binary path with `PREBYTE_CLI_BINARY` if needed. + +Multi-platform build coverage: CI `build-test` matrix (Linux/macOS/Windows × x86_64/ARM64). diff --git a/tests/portability/cli/CliBinaryE2ETests.cpp b/tests/portability/cli/CliBinaryE2ETests.cpp new file mode 100644 index 0000000..8f82b03 --- /dev/null +++ b/tests/portability/cli/CliBinaryE2ETests.cpp @@ -0,0 +1,280 @@ +#include "TestHarness.h" + +#include "CliProcess.h" + +#include "io/InputBuffer.h" +#include "support/Version.h" + +#include +#include + +namespace { + +void write_file(const std::filesystem::path& path, const std::string& content) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream file(path, std::ios::binary); + file << content; +} + +std::filesystem::path cli_test_root(const std::string& name) { + const std::filesystem::path root = + std::filesystem::temp_directory_path() / "prebyte-cli-binary-e2e" / name; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + return root; +} + +std::string read_file(const std::filesystem::path& path) { + return std::string(prebyte::InputBuffer::from_file(path).view()); +} + +} + +TEST_CASE(CliBinaryE2E_help_exits_zero_and_prints_usage) { + const prebyte::test::ProcessResult result = prebyte::test::run_cli({"--help"}); + + REQUIRE_EQ(result.exit_code, 0); + REQUIRE(result.stdout_text.find("prebyte [input] [options]") != std::string::npos); + REQUIRE(result.stdout_text.find("list rules|vars|profiles|ignore|ignores") != std::string::npos); + REQUIRE(result.stderr_text.empty()); +} + +TEST_CASE(CliBinaryE2E_version_exits_zero_and_prints_version) { + const prebyte::test::ProcessResult result = prebyte::test::run_cli({"--version"}); + + REQUIRE_EQ(result.exit_code, 0); + REQUIRE(result.stdout_text.find(std::string(prebyte::VERSION)) != std::string::npos); + REQUIRE(result.stderr_text.empty()); +} + +TEST_CASE(CliBinaryE2E_render_simple_fixture_to_stdout) { + const prebyte::test::ProcessResult result = prebyte::test::run_cli( + {"tests/fixtures/render_simple/input.txt", "-Dname=Ada"}); + + REQUIRE_EQ(result.exit_code, 0); + REQUIRE_EQ(result.stdout_text, std::string("Hello Ada\n")); + REQUIRE(result.stderr_text.empty()); +} + +TEST_CASE(CliBinaryE2E_render_include_fixture_with_define_flags) { + const prebyte::test::ProcessResult result = prebyte::test::run_cli( + {"tests/fixtures/render_include_if/input.txt", "-Dname=Ada", "-Denabled=true"}); + + REQUIRE_EQ(result.exit_code, 0); + REQUIRE(result.stdout_text.find("Header for Ada") != std::string::npos); + REQUIRE(result.stdout_text.find("Enabled") != std::string::npos); + REQUIRE(result.stdout_text.find("Footer") != std::string::npos); +} + +TEST_CASE(CliBinaryE2E_render_writes_output_file) { + const std::filesystem::path root = cli_test_root("output-file"); + const std::filesystem::path output_path = root / "out.txt"; + + const prebyte::test::ProcessResult result = prebyte::test::run_cli( + {"tests/fixtures/render_simple/input.txt", "-Dname=Grace", "-o", output_path.string()}); + + REQUIRE_EQ(result.exit_code, 0); + REQUIRE(result.stdout_text.empty()); + REQUIRE(std::filesystem::exists(output_path)); + REQUIRE_EQ(read_file(output_path), std::string("Hello Grace\n")); +} + +TEST_CASE(CliBinaryE2E_list_rules_applies_settings_and_profile_flags) { + const prebyte::test::ProcessResult result = prebyte::test::run_cli( + {"list", "rules", "-s", "tests/fixtures/settings_profile_merge/settings.yaml", "-p", "friendly", "-r", + "trim=true", "-r", ".md::default_variable_value=Fallback"}); + + REQUIRE_EQ(result.exit_code, 0); + REQUIRE(result.stdout_text.find("trim=true") != std::string::npos); + REQUIRE(result.stdout_text.find("extension:.md::default_variable_value=Fallback") != std::string::npos); +} + +TEST_CASE(CliBinaryE2E_list_vars_applies_settings_profile_and_defines) { + const prebyte::test::ProcessResult result = prebyte::test::run_cli( + {"list", "vars", "-s", "tests/fixtures/settings_profile_merge/settings.yaml", "-p", "friendly", + "-Dname=Ada"}); + + REQUIRE_EQ(result.exit_code, 0); + REQUIRE(result.stdout_text.find("greeting=Hi") != std::string::npos); + REQUIRE(result.stdout_text.find("name=Ada") != std::string::npos); +} + +TEST_CASE(CliBinaryE2E_explain_lua_topic_prints_helpers_and_limits) { + const prebyte::test::ProcessResult result = prebyte::test::run_cli({"--explain", "lua"}); + + REQUIRE_EQ(result.exit_code, 0); + REQUIRE(result.stdout_text.find("upper(value)") != std::string::npos); + REQUIRE(result.stdout_text.find("lua_instruction_limit=100000") != std::string::npos); +} + +TEST_CASE(CliBinaryE2E_unknown_argument_exits_nonzero_with_diagnostic) { + const prebyte::test::ProcessResult result = prebyte::test::run_cli({"--wat"}); + + REQUIRE(result.exit_code != 0); + REQUIRE(result.stderr_text.find("error[CLI001]") != std::string::npos); + REQUIRE(result.stderr_text.find("Unknown argument") != std::string::npos); +} + +TEST_CASE(CliBinaryE2E_strict_variable_failure_exits_nonzero) { + const prebyte::test::ProcessResult result = + prebyte::test::run_cli({"-r", "strict_variables=true", "tests/fixtures/render_simple/input.txt"}); + + REQUIRE(result.exit_code != 0); + REQUIRE(result.stderr_text.find("error[") != std::string::npos); +} + +TEST_CASE(CliBinaryE2E_allow_includes_disabled_exits_nonzero) { + const std::filesystem::path root = cli_test_root("includes-disabled"); + write_file(root / "partial.pbt", "Partial\n"); + write_file(root / "main.pbt", "{{ include \"partial.pbt\" }}"); + + const prebyte::test::ProcessResult result = + prebyte::test::run_cli({(root / "main.pbt").string(), "-r", "allow_includes=false"}); + + REQUIRE(result.exit_code != 0); + REQUIRE(result.stderr_text.find("error[") != std::string::npos); +} + +TEST_CASE(CliBinaryE2E_list_profiles_via_subprocess) { + const prebyte::test::ProcessResult result = prebyte::test::run_cli( + {"list", "profiles", "-s", "tests/fixtures/settings_profile_merge/settings.yaml"}); + + REQUIRE_EQ(result.exit_code, 0); + REQUIRE(result.stdout_text.find("friendly") != std::string::npos); +} + +TEST_CASE(CliBinaryE2E_render_with_settings_file_via_subprocess) { + const prebyte::test::ProcessResult result = prebyte::test::run_cli( + {"tests/fixtures/settings_profile_merge/input.txt", "-s", + "tests/fixtures/settings_profile_merge/settings.yaml", "-p", "friendly", "-Dname=Ada"}); + + REQUIRE_EQ(result.exit_code, 0); + REQUIRE(result.stdout_text.find("Ada") != std::string::npos); +} + +TEST_CASE(CliBinaryE2E_render_named_structured_imports_via_cli) { + const std::filesystem::path root = cli_test_root("structured-imports"); + const std::filesystem::path template_path = root / "input.pbt"; + const std::filesystem::path user_path = root / "user.json"; + write_file(template_path, "{{ user.name }}\n"); + write_file(user_path, R"({"name":"Ada"})"); + + const prebyte::test::ProcessResult result = prebyte::test::run_cli( + {template_path.string(), "-Duser=@" + user_path.string()}); + + REQUIRE_EQ(result.exit_code, 0); + REQUIRE_EQ(result.stdout_text, std::string("Ada\n")); +} + +TEST_CASE(CliBinaryE2E_batch_render_from_file_writes_stdout) { + const std::filesystem::path root = cli_test_root("batch-stdout"); + write_file(root / "template.txt", "Hello {{ name }}!\n"); + write_file(root / "data.json", R"([{"name":"Ada"},{"name":"Grace"}])"); + + const prebyte::test::ProcessResult result = prebyte::test::run_cli( + {(root / "template.txt").string(), "--batch", (root / "data.json").string()}); + + REQUIRE_EQ(result.exit_code, 0); + REQUIRE_EQ(result.stdout_text, std::string("Hello Ada!\nHello Grace!\n")); +} + +TEST_CASE(CliBinaryE2E_batch_render_writes_directory_outputs) { + const std::filesystem::path root = cli_test_root("batch-directory"); + write_file(root / "template.txt", "{{ value }}"); + write_file(root / "data.json", R"({"first.txt":{"value":"one"},"second.txt":{"value":"two"}})"); + + const prebyte::test::ProcessResult result = prebyte::test::run_cli( + {(root / "template.txt").string(), "--batch", (root / "data.json").string(), "-o", + (root / "out").string()}); + + REQUIRE_EQ(result.exit_code, 0); + REQUIRE_EQ(read_file(root / "out" / "first.txt"), std::string("one")); + REQUIRE_EQ(read_file(root / "out" / "second.txt"), std::string("two")); +} + +TEST_CASE(CliBinaryE2E_render_args_are_exposed_as_args_index) { + const std::filesystem::path root = cli_test_root("render-args"); + const std::filesystem::path template_path = root / "template.txt"; + write_file(template_path, "{{ ARGS[0] }}|{{ ARGS[1] }}\n"); + + const prebyte::test::ProcessResult result = + prebyte::test::run_cli({template_path.string(), "alpha", "beta"}); + + REQUIRE_EQ(result.exit_code, 0); + REQUIRE_EQ(result.stdout_text, std::string("alpha|beta\n")); +} + +TEST_CASE(CliBinaryE2E_benchmark_flag_appends_timing_suffix) { + const prebyte::test::ProcessResult result = prebyte::test::run_cli( + {"tests/fixtures/render_simple/input.txt", "-Dname=Ada", "--benchmark"}); + + REQUIRE_EQ(result.exit_code, 0); + REQUIRE(result.stdout_text.find("Hello Ada\n") == 0); + REQUIRE(result.stdout_text.find("\n[benchmark] ") != std::string::npos); + REQUIRE(result.stdout_text.find("lua_cache_hits=") != std::string::npos); +} + +TEST_CASE(CliBinaryE2E_list_ignores_merges_settings_profiles_and_cli) { + const prebyte::test::ProcessResult result = prebyte::test::run_cli( + {"list", "ignores", "-s", "tests/fixtures/settings_profile_merge/settings.yaml", "-p", "friendly", + "-i", "cli_only"}); + + REQUIRE_EQ(result.exit_code, 0); + REQUIRE(result.stdout_text.find("base_ignore") != std::string::npos); + REQUIRE(result.stdout_text.find("friendly_ignore") != std::string::npos); + REQUIRE(result.stdout_text.find("cli_only") != std::string::npos); +} + +TEST_CASE(CliBinaryE2E_render_from_stdin_with_render_args) { + const prebyte::test::ProcessResult result = prebyte::test::run_cli( + {"--", "alpha", "beta"}, prebyte::test::cli_working_directory(), {}, + "Hello {{ ARGS[0] }}|{{ ARGS[1] }}\n"); + + REQUIRE_EQ(result.exit_code, 0); + REQUIRE_EQ(result.stdout_text, std::string("Hello alpha|beta\n")); +} + +TEST_CASE(CliBinaryE2E_utf16_output_writes_bom_and_little_endian_units) { + const std::filesystem::path root = cli_test_root("utf16-output"); + const std::filesystem::path output_path = root / "out.txt"; + + const prebyte::test::ProcessResult result = prebyte::test::run_cli( + {"tests/fixtures/render_simple/input.txt", "-Dname=A", "-r", "output_encoding=utf-16", "-o", + output_path.string()}); + + REQUIRE_EQ(result.exit_code, 0); + const std::string bytes = read_file(output_path); + REQUIRE_EQ(bytes.size(), static_cast(18)); + REQUIRE_EQ(static_cast(bytes[0]), 0xFFu); + REQUIRE_EQ(static_cast(bytes[1]), 0xFEu); + REQUIRE_EQ(static_cast(bytes[2]), 0x48u); + REQUIRE_EQ(static_cast(bytes[3]), 0x00u); + REQUIRE_EQ(static_cast(bytes[16]), 0x0Au); + REQUIRE_EQ(static_cast(bytes[17]), 0x00u); +} + +TEST_CASE(CliBinaryE2E_include_path_allows_parent_relative_includes) { + const std::filesystem::path root = cli_test_root("include-path"); + write_file(root / "partial.pbt", "Partial\n"); + write_file(root / "nested" / "main.pbt", "{{ include \"../partial.pbt\" }}"); + + const prebyte::test::ProcessResult result = prebyte::test::run_cli( + {(root / "nested" / "main.pbt").string(), "-r", "allow_includes=true", "-I", root.string()}); + + REQUIRE_EQ(result.exit_code, 0); + REQUIRE_EQ(result.stdout_text, std::string("Partial\n")); +} + +TEST_CASE(CliBinaryE2E_traversal_outside_allowed_roots_exits_nonzero) { + const std::filesystem::path root = cli_test_root("traversal-escape"); + const std::filesystem::path outside = root.parent_path() / (root.filename().string() + "-outside"); + write_file(outside / "secret.txt", "LEAKED\n"); + write_file(root / "nested" / "main.pbt", + "{{ include \"../../" + (root.filename().string() + "-outside") + "/secret.txt\" }}"); + + const prebyte::test::ProcessResult result = prebyte::test::run_cli( + {(root / "nested" / "main.pbt").string(), "-r", "allow_includes=true"}); + + REQUIRE(result.exit_code != 0); + REQUIRE(result.stderr_text.find("escapes allowed roots") != std::string::npos); +} diff --git a/tests/portability/packaging/PackagingSmokeTests.cpp b/tests/portability/packaging/PackagingSmokeTests.cpp new file mode 100644 index 0000000..9637393 --- /dev/null +++ b/tests/portability/packaging/PackagingSmokeTests.cpp @@ -0,0 +1,85 @@ +#include "TestHarness.h" + +#include "CliProcess.h" +#include "support/Version.h" + +#include +#include +#include +#include +#include + +#ifndef _WIN32 +#include +#endif + +namespace { + +std::string shell_quote(const std::string& value) { + std::string quoted = "'"; + for (char ch : value) { + if (ch == '\'') { + quoted += "'\\''"; + } else { + quoted.push_back(ch); + } + } + quoted.push_back('\''); + return quoted; +} + +int run_packaging_smoke(const std::vector& checks) { + const std::filesystem::path repo_root = prebyte::test::cli_working_directory(); + const std::filesystem::path binary = prebyte::test::cli_binary_path(); + + std::ostringstream command; + command << "python3 " << shell_quote((repo_root / "scripts" / "ci" / "smoke_packaging.py").string()) + << " --binary " << shell_quote(binary.string()); + for (const std::string& check : checks) { + command << " --checks " << shell_quote(check); + } + + const int status = std::system(command.str().c_str()); + if (status == -1) { + throw std::runtime_error("failed to execute packaging smoke script"); + } +#ifdef _WIN32 + return status; +#else + if (WIFEXITED(status)) { + return WEXITSTATUS(status); + } +#endif + throw std::runtime_error("packaging smoke script terminated abnormally"); +} + +void require_packaging_smoke(const std::vector& checks) { + const int exit_code = run_packaging_smoke(checks); + if (exit_code != 0) { + throw prebyte::test::AssertionFailure("packaging smoke script failed with exit code " + + std::to_string(exit_code)); + } +} + +} + +TEST_CASE(PackagingSmoke_binary_release_archive_runs) { + require_packaging_smoke({"binary"}); +} + +#ifndef _WIN32 +TEST_CASE(PackagingSmoke_reqpack_archive_runs) { + require_packaging_smoke({"reqpack"}); +} + +TEST_CASE(PackagingSmoke_reqpack_index_lists_package) { + require_packaging_smoke({"reqpack", "index"}); +} +#endif + +TEST_CASE(PackagingSmoke_docker_image_runs_cli) { + if (std::getenv("PREBYTE_SMOKE_DOCKER") == nullptr) { + return; + } + require_packaging_smoke({"docker"}); +} diff --git a/tests/portability/packaging/README.md b/tests/portability/packaging/README.md new file mode 100644 index 0000000..2e5e5ba --- /dev/null +++ b/tests/portability/packaging/README.md @@ -0,0 +1,15 @@ +# Packaging smoke tests + +End-to-end checks that release artifacts produced after a build actually run. + +- `PackagingSmokeTests.cpp` — invokes `scripts/ci/smoke_packaging.py` against the built `prebyte` binary. +- Binary tarball (`package_binary.py`) on all platforms. +- ReqPack archive + repository index (`package_reqpack.py`, `build_reqpack_index.py`) on Linux/macOS. +- Optional Docker image smoke when `PREBYTE_SMOKE_DOCKER=1`. + +Run locally: + +```bash +make packaging-smoke +make packaging-smoke-docker # requires Docker +``` diff --git a/tests/security/IncludeSecurityE2ETests.cpp b/tests/security/IncludeSecurityE2ETests.cpp new file mode 100644 index 0000000..6195d06 --- /dev/null +++ b/tests/security/IncludeSecurityE2ETests.cpp @@ -0,0 +1,147 @@ +#include "TestHarness.h" + +#include "app/AppRunner.h" +#include "app/Command.h" +#include "support/Diagnostic.h" + +#include +#include +#include + +namespace { + +void write_file(const std::filesystem::path& path, const std::string& content) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream file(path, std::ios::binary); + file << content; +} + +std::filesystem::path security_test_root(const std::string& name) { + const std::filesystem::path root = + std::filesystem::temp_directory_path() / "prebyte-include-security-e2e" / name; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + return root; +} + +std::string render_file(const std::filesystem::path& input_path, + const std::vector& rule_args = {}, + const std::vector& include_paths = {}) { + prebyte::Command command; + command.mode = prebyte::CommandMode::Render; + command.input_path = input_path; + command.rule_args = rule_args; + command.include_paths = include_paths; + prebyte::AppRunner runner; + return runner.execute(command); +} + +void expect_render_file_error(const std::filesystem::path& input_path, + const std::vector& rule_args = {}, + const std::string& message_fragment = {}) { + try { + static_cast(render_file(input_path, rule_args)); + throw std::runtime_error("expected DiagnosticError"); + } catch (const prebyte::DiagnosticError& error) { + if (!message_fragment.empty() + && error.diagnostic().message.find(message_fragment) == std::string::npos) { + throw std::runtime_error("unexpected diagnostic: " + error.diagnostic().message); + } + } +} + +} + +TEST_CASE(IncludeSecurityE2E_safe_relative_include_within_template_root_works) { + const std::filesystem::path root = security_test_root("safe-relative"); + write_file(root / "header.txt", "Header\n"); + write_file(root / "main.pbt", "{{ include \"header.txt\" }}Body\n"); + + REQUIRE_EQ(render_file(root / "main.pbt", {"allow_includes=true"}), std::string("Header\nBody\n")); +} + +TEST_CASE(IncludeSecurityE2E_sibling_relative_include_works) { + const std::filesystem::path root = security_test_root("sibling-relative"); + write_file(root / "partial.pbt", "Partial\n"); + write_file(root / "nested" / "main.pbt", "{{ include \"../partial.pbt\" }}"); + + REQUIRE_EQ(render_file(root / "nested" / "main.pbt", {"allow_includes=true"}, {root}), std::string("Partial\n")); +} + +TEST_CASE(IncludeSecurityE2E_traversal_outside_allowed_roots_is_rejected) { + const std::filesystem::path root = security_test_root("traversal-escape"); + const std::filesystem::path outside = root.parent_path() / (root.filename().string() + "-outside"); + write_file(outside / "secret.txt", "LEAKED\n"); + write_file(root / "nested" / "main.pbt", + "{{ include \"../../" + (root.filename().string() + "-outside") + "/secret.txt\" }}"); + + expect_render_file_error(root / "nested" / "main.pbt", {"allow_includes=true"}, "escapes allowed roots"); +} + +TEST_CASE(IncludeSecurityE2E_direct_include_cycle_is_rejected) { + const std::filesystem::path root = security_test_root("direct-cycle"); + write_file(root / "cycle_a.pbt", "{{ include \"cycle_b.pbt\" }}"); + write_file(root / "cycle_b.pbt", "{{ include \"cycle_a.pbt\" }}"); + + expect_render_file_error(root / "cycle_a.pbt", {"allow_includes=true"}, "Include cycle detected"); +} + +TEST_CASE(IncludeSecurityE2E_self_include_cycle_is_rejected) { + const std::filesystem::path root = security_test_root("self-cycle"); + write_file(root / "main.pbt", "start{{ include \"main.pbt\" }}"); + + expect_render_file_error(root / "main.pbt", {"allow_includes=true"}, "Include cycle detected"); +} + +TEST_CASE(IncludeSecurityE2E_indirect_cycle_inside_conditional_branch_is_rejected) { + const std::filesystem::path root = security_test_root("conditional-cycle"); + write_file(root / "main.pbt", "{{ if enabled }}{{ include \"partial.pbt\" }}{{ endif }}"); + write_file(root / "partial.pbt", "{{ if enabled }}{{ include \"main.pbt\" }}{{ endif }}"); + + prebyte::Command command; + command.mode = prebyte::CommandMode::Render; + command.input_path = root / "main.pbt"; + command.rule_args = {"allow_includes=true"}; + command.define_args = {"enabled=true"}; + + prebyte::AppRunner runner; + REQUIRE_THROWS_AS(runner.execute(command), prebyte::DiagnosticError); +} + +TEST_CASE(IncludeSecurityE2E_allow_includes_disabled_blocks_every_include) { + const std::filesystem::path root = security_test_root("includes-disabled"); + write_file(root / "partial.pbt", "Partial\n"); + write_file(root / "main.pbt", "{{ include \"partial.pbt\" }}"); + + expect_render_file_error(root / "main.pbt", {"allow_includes=false"}); +} + +TEST_CASE(IncludeSecurityE2E_max_include_depth_limits_nested_chain) { + const std::filesystem::path root = security_test_root("max-depth"); + write_file(root / "level2.pbt", "deep"); + write_file(root / "level1.pbt", "{{ include \"./level2.pbt\" }}"); + write_file(root / "main.pbt", "{{ include \"./level1.pbt\" }}"); + + expect_render_file_error(root / "main.pbt", {"allow_includes=true", "max_include_depth=1"}); +} + +TEST_CASE(IncludeSecurityE2E_missing_include_path_is_rejected) { + const std::filesystem::path root = security_test_root("missing-include"); + write_file(root / "main.pbt", "{{ include \"missing.pbt\" }}"); + + expect_render_file_error(root / "main.pbt", {"allow_includes=true"}, "Include not found"); +} + +TEST_CASE(IncludeSecurityE2E_absolute_include_path_is_rejected) { + const std::filesystem::path root = security_test_root("absolute-outside"); + write_file(root / "main.pbt", "{{ include \"/etc/passwd\" }}"); + + expect_render_file_error(root / "main.pbt", {"allow_includes=true"}, "Absolute include paths are not allowed"); +} + +TEST_CASE(IncludeSecurityE2E_overlong_include_path_is_rejected) { + const std::filesystem::path root = security_test_root("overlong-path"); + write_file(root / "main.pbt", "{{ include \"" + std::string(5000, 'a') + "\" }}"); + + expect_render_file_error(root / "main.pbt", {"allow_includes=true"}, "Include path is too long"); +} diff --git a/tests/security/LuaSandboxE2ETests.cpp b/tests/security/LuaSandboxE2ETests.cpp new file mode 100644 index 0000000..43a10d7 --- /dev/null +++ b/tests/security/LuaSandboxE2ETests.cpp @@ -0,0 +1,102 @@ +#include "TestHarness.h" + +#include "app/AppRunner.h" +#include "app/Command.h" +#include "support/Diagnostic.h" + +namespace { + +std::string render_inline_lua(const std::string& template_source, const std::vector& rule_args = {}) { + prebyte::Command command; + command.mode = prebyte::CommandMode::Render; + command.inline_input = template_source; + command.rule_args = rule_args; + prebyte::AppRunner runner; + return runner.execute(command); +} + +void expect_render_error(const std::string& template_source, const std::vector& rule_args = {}) { + try { + render_inline_lua(template_source, rule_args); + throw std::runtime_error("expected DiagnosticError"); + } catch (const prebyte::DiagnosticError&) { + } +} + +std::string inline_lua(const std::string& lua_source) { + return std::string("{{ lua \"") + lua_source + "\" }}"; +} + +std::string block_lua(const std::string& lua_source) { + return std::string("{{ lua:block }}") + lua_source + "{{ endlua }}"; +} + +} + +TEST_CASE(LuaSandboxE2E_removed_globals_are_nil_in_inline_lua) { + REQUIRE_EQ(render_inline_lua(inline_lua("return os == nil")), std::string("true")); + REQUIRE_EQ(render_inline_lua(inline_lua("return io == nil")), std::string("true")); + REQUIRE_EQ(render_inline_lua(inline_lua("return debug == nil")), std::string("true")); + REQUIRE_EQ(render_inline_lua(inline_lua("return package == nil")), std::string("true")); + REQUIRE_EQ(render_inline_lua(inline_lua("return require == nil")), std::string("true")); + REQUIRE_EQ(render_inline_lua(inline_lua("return dofile == nil")), std::string("true")); + REQUIRE_EQ(render_inline_lua(inline_lua("return loadfile == nil")), std::string("true")); +} + +TEST_CASE(LuaSandboxE2E_removed_globals_are_nil_in_lua_block) { + REQUIRE_EQ(render_inline_lua(block_lua("return os == nil")), std::string("true")); + REQUIRE_EQ(render_inline_lua(block_lua("return io == nil")), std::string("true")); + REQUIRE_EQ(render_inline_lua(block_lua("return require == nil")), std::string("true")); +} + +TEST_CASE(LuaSandboxE2E_os_execute_require_and_loadfile_fail_in_templates) { + expect_render_error(inline_lua("return os.execute('id')")); + expect_render_error(inline_lua("return require('os')")); + expect_render_error(inline_lua("return loadfile('secret.txt')")); + expect_render_error(inline_lua("return dofile('secret.txt')")); + expect_render_error(block_lua("return io.open('/etc/passwd')")); + expect_render_error(block_lua("return debug.getinfo(1)")); +} + +TEST_CASE(LuaSandboxE2E_metatable_escape_attempts_stay_sandboxed) { + REQUIRE_EQ(render_inline_lua(inline_lua("return getmetatable(_G) == false")), std::string("true")); + expect_render_error(inline_lua("local proxy = setmetatable({}, {__index = os}); return proxy.execute('id')")); + expect_render_error(block_lua("local chunk = load('return os.execute(\"id\")'); return chunk()")); + REQUIRE_EQ(render_inline_lua(inline_lua("return rawget(_G, 'package') == nil")), std::string("true")); +} + +TEST_CASE(LuaSandboxE2E_safe_load_and_standard_library_still_work) { + REQUIRE_EQ(render_inline_lua(inline_lua("return load('return 41')()")), std::string("41")); + REQUIRE_EQ(render_inline_lua(block_lua("return string.upper('ada')")), std::string("ADA")); + REQUIRE_EQ(render_inline_lua(block_lua("return table.concat({'a', 'b'}, '-')")), std::string("a-b")); + REQUIRE_EQ(render_inline_lua(block_lua("return math.max(2, 5)")), std::string("5")); +} + +TEST_CASE(LuaSandboxE2E_instruction_memory_and_time_limits_apply_via_app_runner) { + expect_render_error(inline_lua("local sum = 0 for i = 1, 1000 do sum = sum + i end return sum"), + {"lua_instruction_limit=10"}); + expect_render_error(inline_lua("return string.rep('x', 2097152)"), {"lua_memory_limit_bytes=1048576"}); + expect_render_error(block_lua("while true do end return 'x'"), {"max_render_time_ms=0"}); +} + +TEST_CASE(LuaSandboxE2E_lua_condition_in_if_stays_sandboxed) { + prebyte::Command command; + command.mode = prebyte::CommandMode::Render; + command.inline_input = + "{{ if lua:block }}return os == nil{{ endlua }}sandboxed{{ else }}escaped{{ endif }}"; + command.define_args = {"enabled=true"}; + + prebyte::AppRunner runner; + REQUIRE_EQ(runner.execute(command), std::string("sandboxed")); +} + +TEST_CASE(LuaSandboxE2E_lua_function_definition_stays_sandboxed) { + prebyte::Command command; + command.mode = prebyte::CommandMode::Render; + command.inline_input = + "{{ fn probe() lua:block }}return require == nil and os == nil{{ endfn }}" + "{{ if probe() }}ok{{ else }}bad{{ endif }}"; + + prebyte::AppRunner runner; + REQUIRE_EQ(runner.execute(command), std::string("ok")); +} diff --git a/tests/security/README.md b/tests/security/README.md new file mode 100644 index 0000000..d398f2e --- /dev/null +++ b/tests/security/README.md @@ -0,0 +1,8 @@ +# Security + +Targeted tests for sandboxing, escape attempts, and unsafe capability blocking. + +- `LuaSandboxE2ETests.cpp` — Lua global removal, `os`/`require`/`loadfile` blocks, metatable escapes, resource limits via AppRunner. +- `IncludeSecurityE2ETests.cpp` — include cycles, disabled includes, depth limits, missing/absolute/overlong paths. + +Fuzz coverage for the same surfaces: `../fault_tolerance/fuzz/LuaSandboxFuzz.cpp`, `../fault_tolerance/fuzz/IncludeResolverFuzz.cpp`. diff --git a/tests/support/CliProcess.cpp b/tests/support/CliProcess.cpp new file mode 100644 index 0000000..53090c1 --- /dev/null +++ b/tests/support/CliProcess.cpp @@ -0,0 +1,348 @@ +#include "CliProcess.h" + +#include +#include +#include +#include + +#ifndef _WIN32 +#include +#include +#include +#else +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif + +namespace prebyte::test { + +namespace { + +std::string read_pipe_data(int fd) { + std::string output; + std::array buffer{}; + while (true) { + const ssize_t bytes_read = ::read(fd, buffer.data(), buffer.size()); + if (bytes_read <= 0) { + break; + } + output.append(buffer.data(), static_cast(bytes_read)); + } + return output; +} + +#ifndef _WIN32 + +void apply_extra_env(const std::map& extra_env) { + for (const auto& [name, value] : extra_env) { + ::setenv(name.c_str(), value.c_str(), 1); + } +} + +ProcessResult run_cli_posix(const std::filesystem::path& executable, const std::vector& args, + const std::filesystem::path& working_directory, + const std::map& extra_env, + const std::string& stdin_text) { + int stdout_pipe[2]{-1, -1}; + int stderr_pipe[2]{-1, -1}; + int stdin_pipe[2]{-1, -1}; + if (::pipe(stdout_pipe) != 0 || ::pipe(stderr_pipe) != 0 + || (!stdin_text.empty() && ::pipe(stdin_pipe) != 0)) { + throw std::runtime_error("failed to create subprocess pipes"); + } + + const pid_t child_pid = ::fork(); + if (child_pid < 0) { + throw std::runtime_error("failed to fork subprocess"); + } + + if (child_pid == 0) { + apply_extra_env(extra_env); + if (!working_directory.empty()) { + std::error_code error; + std::filesystem::current_path(working_directory, error); + } + + ::dup2(stdout_pipe[1], STDOUT_FILENO); + ::dup2(stderr_pipe[1], STDERR_FILENO); + if (!stdin_text.empty()) { + ::dup2(stdin_pipe[0], STDIN_FILENO); + } + ::close(stdout_pipe[0]); + ::close(stdout_pipe[1]); + ::close(stderr_pipe[0]); + ::close(stderr_pipe[1]); + if (!stdin_text.empty()) { + ::close(stdin_pipe[0]); + ::close(stdin_pipe[1]); + } + + std::vector argv_storage; + argv_storage.reserve(args.size() + 1); + argv_storage.push_back(executable.string()); + for (const std::string& arg : args) { + argv_storage.push_back(arg); + } + + std::vector argv_ptrs; + argv_ptrs.reserve(argv_storage.size() + 1); + for (std::string& arg : argv_storage) { + argv_ptrs.push_back(arg.data()); + } + argv_ptrs.push_back(nullptr); + + ::execv(executable.c_str(), argv_ptrs.data()); + _exit(127); + } + + ::close(stdout_pipe[1]); + ::close(stderr_pipe[1]); + if (!stdin_text.empty()) { + ::close(stdin_pipe[0]); + if (!stdin_text.empty()) { + const ssize_t bytes_written = + ::write(stdin_pipe[1], stdin_text.data(), static_cast(stdin_text.size())); + if (bytes_written < 0) { + throw std::runtime_error("failed to write subprocess stdin"); + } + } + ::close(stdin_pipe[1]); + } + + ProcessResult result; + result.stdout_text = read_pipe_data(stdout_pipe[0]); + result.stderr_text = read_pipe_data(stderr_pipe[0]); + ::close(stdout_pipe[0]); + ::close(stderr_pipe[0]); + + int status = 0; + if (::waitpid(child_pid, &status, 0) < 0) { + throw std::runtime_error("failed to wait for subprocess"); + } + if (WIFEXITED(status)) { + result.exit_code = WEXITSTATUS(status); + } else { + result.exit_code = -1; + } + return result; +} + +#else + +std::wstring to_wide(const std::string& text) { + if (text.empty()) { + return {}; + } + const int required = + ::MultiByteToWideChar(CP_UTF8, 0, text.c_str(), static_cast(text.size()), nullptr, 0); + if (required <= 0) { + throw std::runtime_error("failed to convert text to wide string"); + } + std::wstring wide(static_cast(required), L'\0'); + ::MultiByteToWideChar(CP_UTF8, 0, text.c_str(), static_cast(text.size()), wide.data(), required); + return wide; +} + +std::string quote_windows_argument(const std::string& arg) { + if (arg.empty()) { + return "\"\""; + } + bool needs_quotes = false; + for (char ch : arg) { + if (ch == ' ' || ch == '\t' || ch == '"') { + needs_quotes = true; + break; + } + } + if (!needs_quotes) { + return arg; + } + + std::string quoted = "\""; + std::size_t backslashes = 0; + for (char ch : arg) { + if (ch == '\\') { + ++backslashes; + continue; + } + if (ch == '"') { + quoted.append(backslashes * 2 + 1, '\\'); + backslashes = 0; + quoted.push_back('"'); + continue; + } + if (backslashes != 0) { + quoted.append(backslashes, '\\'); + backslashes = 0; + } + quoted.push_back(ch); + } + if (backslashes != 0) { + quoted.append(backslashes * 2, '\\'); + } + quoted.push_back('"'); + return quoted; +} + +std::wstring build_windows_command_line(const std::filesystem::path& executable, + const std::vector& args) { + std::string command = quote_windows_argument(executable.string()); + for (const std::string& arg : args) { + command.push_back(' '); + command += quote_windows_argument(arg); + } + return to_wide(command); +} + +std::string read_windows_pipe(HANDLE handle) { + std::string output; + std::array buffer{}; + while (true) { + DWORD bytes_read = 0; + if (!::ReadFile(handle, buffer.data(), static_cast(buffer.size()), &bytes_read, nullptr) + || bytes_read == 0) { + break; + } + output.append(buffer.data(), bytes_read); + } + return output; +} + +ProcessResult run_cli_windows(const std::filesystem::path& executable, const std::vector& args, + const std::filesystem::path& working_directory, + const std::map& extra_env, + const std::string& stdin_text) { + SECURITY_ATTRIBUTES security_attributes{}; + security_attributes.nLength = sizeof(security_attributes); + security_attributes.bInheritHandle = TRUE; + + HANDLE stdout_read = nullptr; + HANDLE stdout_write = nullptr; + HANDLE stderr_read = nullptr; + HANDLE stderr_write = nullptr; + HANDLE stdin_read = nullptr; + HANDLE stdin_write = nullptr; + if (!::CreatePipe(&stdout_read, &stdout_write, &security_attributes, 0) + || !::CreatePipe(&stderr_read, &stderr_write, &security_attributes, 0) + || (!stdin_text.empty() && !::CreatePipe(&stdin_read, &stdin_write, &security_attributes, 0))) { + throw std::runtime_error("failed to create subprocess pipes"); + } + + ::SetHandleInformation(stdout_read, HANDLE_FLAG_INHERIT, 0); + ::SetHandleInformation(stderr_read, HANDLE_FLAG_INHERIT, 0); + if (!stdin_text.empty()) { + ::SetHandleInformation(stdin_write, HANDLE_FLAG_INHERIT, 0); + } + + STARTUPINFOW startup_info{}; + startup_info.cb = sizeof(startup_info); + startup_info.dwFlags = STARTF_USESTDHANDLES; + startup_info.hStdOutput = stdout_write; + startup_info.hStdError = stderr_write; + startup_info.hStdInput = stdin_text.empty() ? ::GetStdHandle(STD_INPUT_HANDLE) : stdin_read; + + PROCESS_INFORMATION process_info{}; + const std::wstring command_line = build_windows_command_line(executable, args); + std::vector mutable_command_line(command_line.begin(), command_line.end()); + mutable_command_line.push_back(L'\0'); + + std::wstring environment_block; + if (!extra_env.empty()) { + for (const auto& [name, value] : extra_env) { + environment_block += to_wide(name + '=' + value); + environment_block.push_back(L'\0'); + } + environment_block.push_back(L'\0'); + } + + const BOOL created = ::CreateProcessW( + to_wide(executable.string()).c_str(), + mutable_command_line.data(), + nullptr, + nullptr, + TRUE, + 0, + environment_block.empty() ? nullptr : environment_block.data(), + working_directory.empty() ? nullptr : to_wide(working_directory.string()).c_str(), + &startup_info, + &process_info); + ::CloseHandle(stdout_write); + ::CloseHandle(stderr_write); + if (!stdin_text.empty()) { + ::CloseHandle(stdin_read); + } + + if (!created) { + ::CloseHandle(stdout_read); + ::CloseHandle(stderr_read); + throw std::runtime_error("failed to create subprocess"); + } + + ProcessResult result; + if (!stdin_text.empty()) { + DWORD bytes_written = 0; + if (!::WriteFile(stdin_write, stdin_text.data(), static_cast(stdin_text.size()), &bytes_written, nullptr)) { + ::CloseHandle(stdout_read); + ::CloseHandle(stderr_read); + ::CloseHandle(stdin_write); + ::CloseHandle(process_info.hThread); + ::CloseHandle(process_info.hProcess); + throw std::runtime_error("failed to write subprocess stdin"); + } + ::CloseHandle(stdin_write); + } + + result.stdout_text = read_windows_pipe(stdout_read); + result.stderr_text = read_windows_pipe(stderr_read); + ::CloseHandle(stdout_read); + ::CloseHandle(stderr_read); + + ::WaitForSingleObject(process_info.hProcess, INFINITE); + DWORD exit_code = 1; + ::GetExitCodeProcess(process_info.hProcess, &exit_code); + result.exit_code = static_cast(exit_code); + + ::CloseHandle(process_info.hThread); + ::CloseHandle(process_info.hProcess); + return result; +} + +#endif + +} + +std::filesystem::path cli_binary_path() { + if (const char* override_path = std::getenv("PREBYTE_CLI_BINARY")) { + return override_path; + } +#ifdef PREBYTE_CLI_BINARY + return PREBYTE_CLI_BINARY; +#else + return "prebyte"; +#endif +} + +std::filesystem::path cli_working_directory() { + if (const char* override_path = std::getenv("PREBYTE_CLI_WORKDIR")) { + return override_path; + } +#ifdef PREBYTE_CLI_WORKDIR + return PREBYTE_CLI_WORKDIR; +#else + return std::filesystem::current_path(); +#endif +} + +ProcessResult run_cli(const std::vector& args, const std::filesystem::path& working_directory, + const std::map& extra_env, const std::string& stdin_text) { + const std::filesystem::path executable = cli_binary_path(); +#ifndef _WIN32 + return run_cli_posix(executable, args, working_directory, extra_env, stdin_text); +#else + return run_cli_windows(executable, args, working_directory, extra_env, stdin_text); +#endif +} + +} diff --git a/tests/support/CliProcess.h b/tests/support/CliProcess.h new file mode 100644 index 0000000..08028cc --- /dev/null +++ b/tests/support/CliProcess.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace prebyte::test { + +struct ProcessResult { + int exit_code = -1; + std::string stdout_text; + std::string stderr_text; +}; + +std::filesystem::path cli_binary_path(); +std::filesystem::path cli_working_directory(); + +ProcessResult run_cli(const std::vector& args, + const std::filesystem::path& working_directory = cli_working_directory(), + const std::map& extra_env = {}, + const std::string& stdin_text = {}); + +} diff --git a/tests/unit/ValueTests.cpp b/tests/unit/ValueTests.cpp deleted file mode 100644 index d2efb2b..0000000 --- a/tests/unit/ValueTests.cpp +++ /dev/null @@ -1,51 +0,0 @@ -#include "TestHarness.h" - -#include "datatypes/Data.h" -#include "runtime/Value.h" - -TEST_CASE(Value_string_falsey_tokens_to_bool_false) { - REQUIRE(!prebyte::Value(std::string("false")).to_bool()); - REQUIRE(!prebyte::Value(std::string("0")).to_bool()); - REQUIRE(!prebyte::Value(std::string("off")).to_bool()); - REQUIRE(!prebyte::Value(std::string(" no ")).to_bool()); - REQUIRE(!prebyte::Value(std::string()).to_bool()); -} - -TEST_CASE(Value_string_truthy_tokens_to_bool_true) { - REQUIRE(prebyte::Value(std::string("true")).to_bool()); - REQUIRE(prebyte::Value(std::string("1")).to_bool()); - REQUIRE(prebyte::Value(std::string("Ada")).to_bool()); - REQUIRE(prebyte::Value(std::string(" yes ")).to_bool()); -} - -TEST_CASE(Value_object_truthiness_depends_on_members) { - prebyte::Data::Map user; - user["name"] = prebyte::Data("Ada"); - - REQUIRE(prebyte::Value::object(user).to_bool()); - REQUIRE(!prebyte::Value::object({}).to_bool()); -} - -TEST_CASE(Value_list_truthiness_depends_on_items) { - prebyte::Data::Array items; - items.push_back(prebyte::Data("Ada")); - - REQUIRE(prebyte::Value::list(items).to_bool()); - REQUIRE(!prebyte::Value::list({}).to_bool()); -} - -TEST_CASE(Value_length_follows_len_semantics) { - prebyte::Data::Map user; - user["name"] = prebyte::Data("Ada"); - - prebyte::Data::Array items; - items.push_back(prebyte::Data("Ada")); - items.push_back(prebyte::Data("Grace")); - - REQUIRE_EQ(prebyte::Value(std::string("Ada")).length(), static_cast(3)); - REQUIRE_EQ(prebyte::Value::object(user).length(), static_cast(1)); - REQUIRE_EQ(prebyte::Value::list(items).length(), static_cast(2)); - REQUIRE_EQ(prebyte::Value().length(), static_cast(0)); - REQUIRE_EQ(prebyte::Value(true).length(), static_cast(0)); - REQUIRE_EQ(prebyte::Value(42.0).length(), static_cast(0)); -} diff --git a/tools/benchmark_compare/README.md b/tools/benchmark_compare/README.md index cd592bd..734f62e 100644 --- a/tools/benchmark_compare/README.md +++ b/tools/benchmark_compare/README.md @@ -86,6 +86,6 @@ mode:casemicroseconds_per_render_or_entry ## Internal history -`make benchmark` appends single-render and batch CLI timings to `tests/benchmarks/history.md` via `tests/BenchmarkMain.cpp`. +`make benchmark` appends single-render and batch CLI timings to `tests/performance/history.md` via `tests/performance/BenchmarkMain.cpp`. `make compare-benchmark` appends cross-engine reports to `tools/benchmark_compare/history.md`. diff --git a/tools/benchmark_compare/bench_prebyte.cpp b/tools/benchmark_compare/bench_prebyte.cpp index ea54c05..192dd3d 100644 --- a/tools/benchmark_compare/bench_prebyte.cpp +++ b/tools/benchmark_compare/bench_prebyte.cpp @@ -24,14 +24,14 @@ #include "io/InputBuffer.h" #include "io/InputReader.h" #include "parser/JsonParser.h" -#include "runtime/BuiltinRegistry.h" -#include "runtime/CompiledTemplateCompiler.h" -#include "runtime/CompiledTemplateSerializer.h" -#include "runtime/ExpressionEvaluator.h" -#include "runtime/IncludeResolver.h" -#include "runtime/Renderer.h" -#include "runtime/RenderSession.h" -#include "runtime/VariableStore.h" +#include "runtime/expression/BuiltinRegistry.h" +#include "runtime/compiled/CompiledTemplateCompiler.h" +#include "runtime/compiled/CompiledTemplateSerializer.h" +#include "runtime/expression/ExpressionEvaluator.h" +#include "runtime/resolution/IncludeResolver.h" +#include "runtime/render/Renderer.h" +#include "runtime/core/RenderSession.h" +#include "runtime/core/VariableStore.h" namespace {