diff --git a/.github/workflows/fuzz-nightly.yml b/.github/workflows/fuzz-nightly.yml new file mode 100644 index 0000000..80ec514 --- /dev/null +++ b/.github/workflows/fuzz-nightly.yml @@ -0,0 +1,110 @@ +name: Fuzz nightly + +# libFuzzer over the byte-facing parsers (traceparent header, PayPal decimal +# amounts, config ${VAR} expansion, request-path matching). Nightly, not +# per-PR: fuzzing pays off through accumulated wall-clock and a GROWING +# corpus, so the corpus is carried between runs via the Actions cache and +# each run extends it. +# +# Deliberately NOT the vcpkg/GCC production toolchain: libFuzzer requires +# Clang, and rebuilding the vcpkg world under a second compiler would blow +# any nightly budget. tests/fuzz/CMakeLists.txt is a standalone project that +# compiles the parser TUs directly (std-only; nlohmann/json from the distro +# package is the single third-party header dependency) — configure+build is +# minutes. Rationale in docs/TESTING.md, "Fuzzing". +# +# A crash/OOM/timeout fails the job; the reproducer input is uploaded as the +# `fuzz-reproducers` artifact. Triage: download it and re-run +# ./build-fuzz/ + +on: + schedule: + - cron: '41 2 * * *' # daily, off the top of the hour + workflow_dispatch: + inputs: + seconds_per_target: + description: 'Fuzzing time per target (seconds)' + required: false + default: '120' + +permissions: + contents: read + +concurrency: + group: fuzz-nightly + cancel-in-progress: false + +jobs: + fuzz: + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Install clang + nlohmann-json + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends clang nlohmann-json3-dev + clang++ --version + + # The corpus grows across runs — that accumulation IS the value of a + # nightly fuzzer. actions/cache entries are immutable, so every run + # saves under a fresh key (run_id) and the next run restores the newest + # one via the key prefix. + - name: Restore corpus + uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: fuzz-corpus + key: fuzz-corpus-v1-${{ github.run_id }} + restore-keys: | + fuzz-corpus-v1- + + - name: Configure + build fuzzers (standalone, no vcpkg) + run: | + cmake -S tests/fuzz -B build-fuzz \ + -DCMAKE_CXX_COMPILER=clang++ + cmake --build build-fuzz -j"$(nproc)" + + - name: Run fuzz targets + env: + UBSAN_OPTIONS: print_stacktrace=1 + SECONDS_PER_TARGET: ${{ github.event.inputs.seconds_per_target || '120' }} + run: | + set -uo pipefail + status=0 + for t in fuzz_traceparent fuzz_decimal_cents fuzz_config_expand fuzz_path_match; do + echo "::group::$t (${SECONDS_PER_TARGET}s)" + mkdir -p "fuzz-corpus/$t" "fuzz-reproducers/$t" + # First dir is read-write (new inputs land there and get cached); + # the checked-in seed corpus is the read-only second dir. + if ! "./build-fuzz/$t" \ + -max_total_time="$SECONDS_PER_TARGET" \ + -timeout=10 \ + -rss_limit_mb=2048 \ + -print_final_stats=1 \ + -artifact_prefix="fuzz-reproducers/$t/" \ + "fuzz-corpus/$t" "tests/fuzz/corpus/$t"; then + echo "::error title=$t crashed::reproducer input uploaded in the fuzz-reproducers artifact (dir $t/)" + status=1 + fi + echo "::endgroup::" + done + exit "$status" + + # Save even on a crash — the corpus that found a bug is the most + # valuable one to keep (default cache post-save skips failed jobs). + - name: Save corpus + if: always() + uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: fuzz-corpus + key: fuzz-corpus-v1-${{ github.run_id }} + + - name: Upload crash reproducers + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: fuzz-reproducers + path: fuzz-reproducers/ + if-no-files-found: ignore + retention-days: 30 diff --git a/CLAUDE.md b/CLAUDE.md index 6ab6ebc..6f2e407 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,6 +132,9 @@ all three images keyless with cosign by digest and attests the SBOMs automerged — bumps need both sha384 SRI hashes re-pinned by hand, recipe in the comment at the pin); the nightly `swagger-sri-guard` job in gates-nightly.yml re-downloads the pinned files and fails on pin/hash drift. +`fuzz-nightly.yml` runs libFuzzer over the byte-facing parsers +(`tests/fuzz/`, standalone Clang build — no vcpkg/app_core; keep those +parser TUs std-only, see docs/TESTING.md "Fuzzing"). An opt-in rendered-artifact gate for forks that render documents ships as `scripts/check-artifact.py` + `scripts/render-artifacts.sh` + a mandatory diff --git a/CMakeLists.txt b/CMakeLists.txt index e27a25b..aa87dfd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -344,6 +344,19 @@ if(E2E_SOURCES) set_tests_properties(e2e_bucket PROPERTIES LABELS "e2e") endif() +# ── libFuzzer harnesses (opt-in; Clang only) ──────────────────────────────── +# OFF by default and completely inert when OFF — the normal (GCC) build is +# untouched. tests/fuzz/CMakeLists.txt hard-errors on a non-Clang compiler, +# and its normal entry point is a STANDALONE configure into build-fuzz that +# needs no vcpkg at all (the harnesses compile the parser TUs directly +# instead of linking app_core) — see the comment there and docs/TESTING.md, +# "Fuzzing". This option exists for a vcpkg-under-Clang tree that wants the +# fuzzers inside the main build graph. +option(ENABLE_FUZZERS "Build the libFuzzer harnesses in tests/fuzz (requires Clang)" OFF) +if(ENABLE_FUZZERS) + add_subdirectory(tests/fuzz) +endif() + # Installation install(TARGETS ${PROJECT_NAME} ${PROJECT_NAME}_worker RUNTIME DESTINATION bin diff --git a/docs/INDEX.md b/docs/INDEX.md index 2ff9116..b225f7b 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -21,7 +21,7 @@ question instead of grepping the tree. | [`EXAMPLES.md`](EXAMPLES.md) | End-to-end CRUD walkthrough for adding your own resource: migration → DTO → repository → controller → tests | | [`CONVENTIONS.md`](CONVENTIONS.md) | Canonical "add a domain entity" checklist (what `new-resource.sh` follows) + what NOT to abstract | | [`CONFIG.md`](CONFIG.md) | Single table mapping every JSON key ↔ env var ↔ default | -| [`TESTING.md`](TESTING.md) | Test buckets (unit/integration/api/e2e), day-to-day loops incl. the `.devcontainer` zero-setup inner loop, coverage, the disabled-race note | +| [`TESTING.md`](TESTING.md) | Test buckets (unit/integration/api/e2e), day-to-day loops incl. the `.devcontainer` zero-setup inner loop, coverage, nightly libFuzzer targets (`tests/fuzz/`), the disabled-race note | | [`ORGS.md`](ORGS.md) | Multi-tenancy starter kit (`scripts/add-orgs.sh`): two role layers, fail-closed org context, claim/switch semantics, deny-by-default matrix | | [`UPSTREAM.md`](UPSTREAM.md) | Fork↔template sync: `scripts/sync-upstream.sh` (three-way tarball patching for degit forks, `.template-version` stamp), git merge for full-history forks, the backport-candidate discipline for giving generic fixes back | | [`BENCHMARKS.md`](BENCHMARKS.md) | How to measure latency/throughput/footprint (`make bench` + presets) + a results template | diff --git a/docs/TESTING.md b/docs/TESTING.md index b685126..c6cfaee 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -63,6 +63,55 @@ number reflects the DB/cache/auth/jobs code too — not just unit-reachable line The integration and e2e buckets need Postgres + Redis (`make up` first); without them those buckets are skipped and the reported coverage drops accordingly. +## Fuzzing + +The parsers that eat **external bytes** have libFuzzer harnesses in +`tests/fuzz/`, run nightly by `.github/workflows/fuzz-nightly.yml` +(cron + `workflow_dispatch`, ~120 s per target): + +| Target | Parser under test | External input it models | +|---|---|---| +| `fuzz_traceparent` | `Observability::Trace::parse_traceparent` | `traceparent` header, straight off the network on every request | +| `fuzz_decimal_cents` | `Billing::detail::parse_decimal_to_cents` (+ round-trip via `cents_to_decimal_string`) | amount strings in PayPal API/webhook bodies | +| `fuzz_config_expand` | `Config::detail::expand_string` / `substitute_env_placeholders` | `${VAR}` placeholders in config files (fixed, cleared env for determinism) | +| `fuzz_path_match` | `Utils::Strings::path_is_public` + CSV split/merge, `Api::normalize_path_for_metrics` | request paths and public-path CSV config | + +Harnesses check invariants, not just "no crash": accepted traceparents must be +canonical and format→parse round-trip; accepted amounts must be non-negative +and survive a cents→string→cents round trip; path normalization must stay +rooted and idempotent. + +**Toolchain decision (why a separate build dir).** libFuzzer requires Clang; +CI's production toolchain is GCC + vcpkg, and rebuilding the vcpkg dependency +world under a second compiler is a non-starter for a nightly. So the fuzzers +deliberately do **not** link `app_core`: each harness compiles the specific +parser TU directly, and those TUs are kept std-only for exactly this reason +(`Trace.cpp`, `billing/PayPalParse.cpp`, `utils/Strings.cpp`; +`utils/ConfigExpand.cpp` additionally needs header-only nlohmann/json, taken +from the distro package). If you move a fuzzed parser into a TU with heavier +includes, the fuzz build breaks — that's the tripwire telling you to keep the +byte-facing surface dependency-free. + +Run locally (needs clang; never the normal build dir): + +```bash +cmake -S tests/fuzz -B build-fuzz -DCMAKE_CXX_COMPILER=clang++ +cmake --build build-fuzz -j +./build-fuzz/fuzz_traceparent tests/fuzz/corpus/fuzz_traceparent # + libFuzzer flags +``` + +Seed corpora live in `tests/fuzz/corpus//` (valid + boundary inputs). +The nightly job carries a **growing** corpus between runs via the Actions +cache and treats the seeds as a read-only starting set. A crash fails the job +and uploads the reproducer input as the `fuzz-reproducers` artifact; triage by +re-running `./build-fuzz/ `. + +Not fuzzed (documented limitation): everything behind the network seam — +`verify_webhook_signature` beyond its pure header/JSON handling +(`find_header_ci` is compiled into the decimal target's TU but the +curl-touching flow is not), `parse_capture_response` (nlohmann-based, lives in +the curl TU), and anything needing Drogon types. + ## Known gaps (be honest about these before you rely on them) - **No behavioral coverage** for Kafka messaging, SMTP delivery (the Mailer is diff --git a/src/api/Middleware.cpp b/src/api/Middleware.cpp index ba5cb72..4056b4f 100644 --- a/src/api/Middleware.cpp +++ b/src/api/Middleware.cpp @@ -32,6 +32,7 @@ #include "api/RequestUtils.hpp" #include "observability/Observability.hpp" #include "observability/Trace.hpp" +#include "observability/TraceOtel.hpp" #include "security/ApiKeys.hpp" #include "security/Auth.hpp" #include "security/Csrf.hpp" diff --git a/src/api/PathNormalize.hpp b/src/api/PathNormalize.hpp new file mode 100644 index 0000000..7bc5560 --- /dev/null +++ b/src/api/PathNormalize.hpp @@ -0,0 +1,122 @@ +/** + * @file PathNormalize.hpp + * @brief Request-path normalization + UUID-shape validation — the drogon-free + * half of RequestUtils.hpp. + * @details Split out so the fuzz_path_match libFuzzer harness (tests/fuzz) + * can include it without (and therefore + * without the vcpkg dependency world). RequestUtils.hpp includes + * this header, so existing includers see the exact same names in + * the exact same namespaces. + */ + +#pragma once + +#include +#include +#include +#include + +namespace Api { + +namespace detail { + +/// True if @p s is a canonical 8-4-4-4-12 lowercase/uppercase-hex UUID. +inline bool is_uuid_segment(std::string_view s) { + if (s.size() != 36) + return false; + for (size_t i = 0; i < 36; ++i) { + const char c = s[i]; + if (i == 8 || i == 13 || i == 18 || i == 23) { + if (c != '-') + return false; + } else if (!std::isxdigit(static_cast(c))) { + return false; + } + } + return true; +} + +} // namespace detail + +/** + * @brief Validate UUID format (8-4-4-4-12 hex) + */ +inline bool is_valid_uuid(const std::string& str) { + return detail::is_uuid_segment(str); +} + +/** + * @brief Normalize a request path for metric/trace cardinality AND log + * redaction. Replaces UUID segments with ":id" and the single-use + * token after the account confirm/reset/change-email routes with + * ":token". + * @details Two jobs in one: (a) raw ids/tokens would mint a new Prometheus + * label and Jaeger operation per entity (cardinality blow-up); + * (b) the account tokens are credentials — logging the raw path + * would drop password-reset tokens into the access log. A manual + * segment scan (no std::regex) keeps this cheap on the hot path. + */ +inline std::string normalize_path_for_metrics(const std::string& path) { + // Split into segments, rewrite, rejoin. Empty input / "/" returns as-is. + std::vector segs; + size_t i = 0; + while (i < path.size()) { + if (path[i] == '/') { + ++i; + continue; + } + size_t j = path.find('/', i); + if (j == std::string::npos) + j = path.size(); + segs.emplace_back(path.data() + i, j - i); + i = j; + } + + // Optional API version segment (/api/v/... — see ADR 0006), so the token + // routes are detected whether or not a version is present. + auto is_version_seg = [](const auto& s) { + if (s.size() < 2 || s[0] != 'v') + return false; + for (size_t k = 1; k < s.size(); ++k) + if (s[k] < '0' || s[k] > '9') + return false; + return true; + }; + const size_t base = (segs.size() >= 2 && is_version_seg(segs[1])) ? 2 : 1; // index of after /api[/vN] + + // The account token routes: /api[/vN]/account// where verb is one + // of the token-bearing apply endpoints (the *-request / *-resend variants + // are single segments and won't match this shape). + const size_t token_idx = base + 2; + const bool account_token_route = segs.size() == base + 3 && segs[0] == "api" && segs[base] == "account" && + (segs[base + 1] == "confirm" || segs[base + 1] == "reset-password" || + segs[base + 1] == "change-email" || segs[base + 1] == "join-from-invite"); + + std::string out; + out.reserve(path.size()); + for (size_t k = 0; k < segs.size(); ++k) { + out += '/'; + if (account_token_route && k == token_idx) { + out += ":token"; + continue; + } + // Bucket id-shaped segments so per-id paths don't explode metric + // cardinality: uuids (e.g. /api/admin/users/) AND all-digit ids + // (e.g. /api/admin/roles/5 — integer PKs added with the roles routes). + bool all_digits = !segs[k].empty(); + for (char c : segs[k]) + if (c < '0' || c > '9') { + all_digits = false; + break; + } + if (all_digits || detail::is_uuid_segment(segs[k])) + out += ":id"; + else + out.append(segs[k].data(), segs[k].size()); + } + if (out.empty()) + out = "/"; + return out; +} + +} // namespace Api diff --git a/src/api/RequestUtils.hpp b/src/api/RequestUtils.hpp index f8e1aba..cddf5d7 100644 --- a/src/api/RequestUtils.hpp +++ b/src/api/RequestUtils.hpp @@ -5,45 +5,22 @@ * live in an anonymous namespace inside Api.hpp, which is an ODR * trap for inline callers (internal-linkage entities referenced from * inline functions make the definitions differ across TUs). + * + * The drogon-free half (is_valid_uuid, normalize_path_for_metrics) + * lives in PathNormalize.hpp, re-exported here — the + * fuzz_path_match harness (tests/fuzz) includes that header + * without pulling . */ #pragma once -#include #include -#include -#include #include -namespace Api { - -namespace detail { - -/// True if @p s is a canonical 8-4-4-4-12 lowercase/uppercase-hex UUID. -inline bool is_uuid_segment(std::string_view s) { - if (s.size() != 36) - return false; - for (size_t i = 0; i < 36; ++i) { - const char c = s[i]; - if (i == 8 || i == 13 || i == 18 || i == 23) { - if (c != '-') - return false; - } else if (!std::isxdigit(static_cast(c))) { - return false; - } - } - return true; -} +#include "api/PathNormalize.hpp" -} // namespace detail - -/** - * @brief Validate UUID format (8-4-4-4-12 hex) - */ -inline bool is_valid_uuid(const std::string& str) { - return detail::is_uuid_segment(str); -} +namespace Api { /** * @brief Parse a query-param string to int, returning @p def on empty/invalid. @@ -88,78 +65,4 @@ inline PageParams parse_page_params(const drogon::HttpRequestPtr& req, int defau return p; } -/** - * @brief Normalize a request path for metric/trace cardinality AND log - * redaction. Replaces UUID segments with ":id" and the single-use - * token after the account confirm/reset/change-email routes with - * ":token". - * @details Two jobs in one: (a) raw ids/tokens would mint a new Prometheus - * label and Jaeger operation per entity (cardinality blow-up); - * (b) the account tokens are credentials — logging the raw path - * would drop password-reset tokens into the access log. A manual - * segment scan (no std::regex) keeps this cheap on the hot path. - */ -inline std::string normalize_path_for_metrics(const std::string& path) { - // Split into segments, rewrite, rejoin. Empty input / "/" returns as-is. - std::vector segs; - size_t i = 0; - while (i < path.size()) { - if (path[i] == '/') { - ++i; - continue; - } - size_t j = path.find('/', i); - if (j == std::string::npos) - j = path.size(); - segs.emplace_back(path.data() + i, j - i); - i = j; - } - - // Optional API version segment (/api/v/... — see ADR 0006), so the token - // routes are detected whether or not a version is present. - auto is_version_seg = [](const auto& s) { - if (s.size() < 2 || s[0] != 'v') - return false; - for (size_t k = 1; k < s.size(); ++k) - if (s[k] < '0' || s[k] > '9') - return false; - return true; - }; - const size_t base = (segs.size() >= 2 && is_version_seg(segs[1])) ? 2 : 1; // index of after /api[/vN] - - // The account token routes: /api[/vN]/account// where verb is one - // of the token-bearing apply endpoints (the *-request / *-resend variants - // are single segments and won't match this shape). - const size_t token_idx = base + 2; - const bool account_token_route = segs.size() == base + 3 && segs[0] == "api" && segs[base] == "account" && - (segs[base + 1] == "confirm" || segs[base + 1] == "reset-password" || - segs[base + 1] == "change-email" || segs[base + 1] == "join-from-invite"); - - std::string out; - out.reserve(path.size()); - for (size_t k = 0; k < segs.size(); ++k) { - out += '/'; - if (account_token_route && k == token_idx) { - out += ":token"; - continue; - } - // Bucket id-shaped segments so per-id paths don't explode metric - // cardinality: uuids (e.g. /api/admin/users/) AND all-digit ids - // (e.g. /api/admin/roles/5 — integer PKs added with the roles routes). - bool all_digits = !segs[k].empty(); - for (char c : segs[k]) - if (c < '0' || c > '9') { - all_digits = false; - break; - } - if (all_digits || detail::is_uuid_segment(segs[k])) - out += ":id"; - else - out.append(segs[k].data(), segs[k].size()); - } - if (out.empty()) - out = "/"; - return out; -} - } // namespace Api diff --git a/src/billing/PayPalClient.cpp b/src/billing/PayPalClient.cpp index 97fd00e..de86235 100644 --- a/src/billing/PayPalClient.cpp +++ b/src/billing/PayPalClient.cpp @@ -3,13 +3,16 @@ * @brief Bodies for src/billing/PayPalClient.hpp — compiled once into * app_core. This is the only billing TU that sees libcurl; the * header no longer exposes it to including TUs. + * + * The pure detail:: parser helpers (parse_decimal_to_cents, + * cents_to_decimal_string, find_header_ci, url_encode_segment) live + * in PayPalParse.cpp — a std-only TU the fuzz_decimal_cents harness + * (tests/fuzz) compiles without curl/spdlog/json. */ #include "billing/PayPalClient.hpp" -#include #include -#include #include #include @@ -25,85 +28,6 @@ using json = nlohmann::json; namespace detail { -std::int64_t parse_decimal_to_cents(const std::string& s) { - if (s.empty()) - throw std::runtime_error("paypal: empty amount string"); - if (s.front() == '+' || s.front() == '-') - throw std::runtime_error("paypal: signed amount not accepted: '" + s + "'"); - - const auto dot = s.find('.'); - const std::string int_part = (dot == std::string::npos) ? s : s.substr(0, dot); - std::string frac_part = (dot == std::string::npos) ? std::string() : s.substr(dot + 1); - - if (int_part.empty()) - throw std::runtime_error("paypal: malformed amount '" + s + "'"); - if (int_part.size() > 15) - throw std::runtime_error("paypal: amount '" + s + "' out of range"); - for (char c : int_part) - if (!std::isdigit(static_cast(c))) - throw std::runtime_error("paypal: malformed amount '" + s + "'"); - - if (dot != std::string::npos) { - if (frac_part.empty()) - throw std::runtime_error("paypal: malformed amount '" + s + "' (trailing '.')"); - if (frac_part.size() > 2) - throw std::runtime_error("paypal: amount '" + s + "' has more than 2 fractional digits"); - for (char c : frac_part) - if (!std::isdigit(static_cast(c))) - throw std::runtime_error("paypal: malformed amount '" + s + "'"); - } - while (frac_part.size() < 2) - frac_part.push_back('0'); - - const std::int64_t whole = std::stoll(int_part); - const std::int64_t frac = std::stoll(frac_part); - return whole * 100 + frac; -} - -std::string cents_to_decimal_string(std::int64_t cents) { - if (cents < 0) - throw std::runtime_error("paypal: negative amount_cents"); - const std::int64_t whole = cents / 100; - const std::int64_t frac = cents % 100; - std::ostringstream oss; - oss << whole << '.' << (frac < 10 ? "0" : "") << frac; - return oss.str(); -} - -std::string find_header_ci(const std::map& headers, const std::string& name) { - for (const auto& kv : headers) { - if (kv.first.size() != name.size()) - continue; - bool match = true; - for (std::size_t i = 0; i < name.size(); ++i) { - if (std::tolower(static_cast(kv.first[i])) != - std::tolower(static_cast(name[i]))) { - match = false; - break; - } - } - if (match) - return kv.second; - } - return {}; -} - -std::string url_encode_segment(const std::string& s) { - static const char* hex = "0123456789ABCDEF"; - std::string out; - for (unsigned char c : s) { - if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || - c == '.' || c == '~') - out.push_back(static_cast(c)); - else { - out.push_back('%'); - out.push_back(hex[c >> 4]); - out.push_back(hex[c & 0xF]); - } - } - return out; -} - /// libcurl write callback appending into a std::string — internal to this /// TU; nothing outside PayPalClient's own request paths takes its address. static std::size_t curl_write_cb(char* ptr, std::size_t size, std::size_t nmemb, void* userdata) { diff --git a/src/billing/PayPalParse.cpp b/src/billing/PayPalParse.cpp new file mode 100644 index 0000000..31f19b5 --- /dev/null +++ b/src/billing/PayPalParse.cpp @@ -0,0 +1,104 @@ +/** + * @file PayPalParse.cpp + * @brief The pure byte-facing parser helpers of Billing::detail — split out + * of PayPalClient.cpp so this TU is std-only (no curl/spdlog/json): + * the fuzz_decimal_cents libFuzzer harness in tests/fuzz compiles it + * directly, with no vcpkg dependencies. Contracts are documented on + * the declarations in PayPalClient.hpp; keep network-touching or + * json-touching code (describe_error_body, the curl callback) in + * PayPalClient.cpp. + */ + +#include +#include +#include +#include + +#include "billing/PayPalClient.hpp" + +namespace Billing { + +namespace detail { + +std::int64_t parse_decimal_to_cents(const std::string& s) { + if (s.empty()) + throw std::runtime_error("paypal: empty amount string"); + if (s.front() == '+' || s.front() == '-') + throw std::runtime_error("paypal: signed amount not accepted: '" + s + "'"); + + const auto dot = s.find('.'); + const std::string int_part = (dot == std::string::npos) ? s : s.substr(0, dot); + std::string frac_part = (dot == std::string::npos) ? std::string() : s.substr(dot + 1); + + if (int_part.empty()) + throw std::runtime_error("paypal: malformed amount '" + s + "'"); + if (int_part.size() > 15) + throw std::runtime_error("paypal: amount '" + s + "' out of range"); + for (char c : int_part) + if (!std::isdigit(static_cast(c))) + throw std::runtime_error("paypal: malformed amount '" + s + "'"); + + if (dot != std::string::npos) { + if (frac_part.empty()) + throw std::runtime_error("paypal: malformed amount '" + s + "' (trailing '.')"); + if (frac_part.size() > 2) + throw std::runtime_error("paypal: amount '" + s + "' has more than 2 fractional digits"); + for (char c : frac_part) + if (!std::isdigit(static_cast(c))) + throw std::runtime_error("paypal: malformed amount '" + s + "'"); + } + while (frac_part.size() < 2) + frac_part.push_back('0'); + + const std::int64_t whole = std::stoll(int_part); + const std::int64_t frac = std::stoll(frac_part); + return whole * 100 + frac; +} + +std::string cents_to_decimal_string(std::int64_t cents) { + if (cents < 0) + throw std::runtime_error("paypal: negative amount_cents"); + const std::int64_t whole = cents / 100; + const std::int64_t frac = cents % 100; + std::ostringstream oss; + oss << whole << '.' << (frac < 10 ? "0" : "") << frac; + return oss.str(); +} + +std::string find_header_ci(const std::map& headers, const std::string& name) { + for (const auto& kv : headers) { + if (kv.first.size() != name.size()) + continue; + bool match = true; + for (std::size_t i = 0; i < name.size(); ++i) { + if (std::tolower(static_cast(kv.first[i])) != + std::tolower(static_cast(name[i]))) { + match = false; + break; + } + } + if (match) + return kv.second; + } + return {}; +} + +std::string url_encode_segment(const std::string& s) { + static const char* hex = "0123456789ABCDEF"; + std::string out; + for (unsigned char c : s) { + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || + c == '.' || c == '~') + out.push_back(static_cast(c)); + else { + out.push_back('%'); + out.push_back(hex[c >> 4]); + out.push_back(hex[c & 0xF]); + } + } + return out; +} + +} // namespace detail + +} // namespace Billing diff --git a/src/observability/Trace.cpp b/src/observability/Trace.cpp index dfb8ecd..f894f7c 100644 --- a/src/observability/Trace.cpp +++ b/src/observability/Trace.cpp @@ -5,6 +5,11 @@ * helpers (file-local) and the thread-local ambient traceparent. The * attribute keys and the TraceContext struct stay in the header; every * contract is documented on the declarations there. + * + * std-only on purpose (see the header note): the OTel-coupled + * to_remote_span_context lives in TraceOtel.cpp, so this TU can be + * compiled straight into the fuzz_traceparent libFuzzer harness + * (tests/fuzz) with no third-party dependencies at all. */ #include "observability/Trace.hpp" @@ -16,8 +21,6 @@ #include #include -#include - namespace Observability::Trace { namespace { @@ -43,31 +46,6 @@ std::string to_lower_ascii(std::string_view s) { return out; } -/** - * @brief Hex string → fixed-size byte buffer (for TraceId/SpanId). - * @return false if the input length doesn't match or has a non-hex char. - */ -bool hex_to_bytes(std::string_view hex, uint8_t* out, size_t n) { - if (hex.size() != n * 2) - return false; - auto nibble = [](char c) -> int { - if (c >= '0' && c <= '9') - return c - '0'; - if (c >= 'a' && c <= 'f') - return c - 'a' + 10; - if (c >= 'A' && c <= 'F') - return c - 'A' + 10; - return -1; - }; - for (size_t i = 0; i < n; ++i) { - const int hi = nibble(hex[2 * i]), lo = nibble(hex[2 * i + 1]); - if (hi < 0 || lo < 0) - return false; - out[i] = static_cast((hi << 4) | lo); - } - return true; -} - std::string to_hex(const uint8_t* bytes, size_t n) { static const char* hex = "0123456789abcdef"; std::string out; @@ -165,19 +143,6 @@ TraceContext extract_or_generate(std::string_view traceparent_header) { return generate_context(); } -std::optional to_remote_span_context(const TraceContext& t) { - uint8_t tid[16], sid[8], flags[1]; - if (!detail::hex_to_bytes(t.trace_id, tid, 16) || !detail::hex_to_bytes(t.parent_id, sid, 8) || - !detail::hex_to_bytes(t.flags, flags, 1)) { - return std::nullopt; - } - return opentelemetry::trace::SpanContext( - opentelemetry::trace::TraceId(opentelemetry::nostd::span(tid)), - opentelemetry::trace::SpanId(opentelemetry::nostd::span(sid)), - opentelemetry::trace::TraceFlags(flags[0]), - /*is_remote=*/true); -} - std::string& current_traceparent_ref() { thread_local std::string tp; return tp; diff --git a/src/observability/Trace.hpp b/src/observability/Trace.hpp index 75cbeff..1f508e1 100644 --- a/src/observability/Trace.hpp +++ b/src/observability/Trace.hpp @@ -19,6 +19,12 @@ * Bodies (the parser, hex/random helpers and the thread-local * ambient traceparent) live in Trace.cpp (compiled once into * app_core; ADR 0003 as amended 2026-08-22). + * + * This header (and Trace.cpp) is deliberately std-only — the one + * OpenTelemetry-coupled helper (to_remote_span_context) lives in + * TraceOtel.hpp/.cpp so the parser can be compiled standalone by + * the libFuzzer harness in tests/fuzz (fuzz_traceparent) without + * the vcpkg dependency world. Keep new OTel types out of here. */ #pragma once @@ -27,8 +33,6 @@ #include #include -#include - namespace Observability::Trace { inline constexpr const char* kTraceIdAttr = "_trace_id"; @@ -63,12 +67,8 @@ TraceContext generate_context(); */ TraceContext extract_or_generate(std::string_view traceparent_header); -/** - * @brief Build a remote OTel SpanContext from a parsed W3C traceparent, so - * our server span JOINS the caller's distributed trace instead of - * starting an unrelated root. - */ -std::optional to_remote_span_context(const TraceContext& t); +// to_remote_span_context (TraceContext -> remote OTel SpanContext) lives in +// TraceOtel.hpp — it is the only OTel-coupled piece of this module. // --------------------------------------------------------------------------- // Ambient "current request" traceparent. diff --git a/src/observability/TraceOtel.cpp b/src/observability/TraceOtel.cpp new file mode 100644 index 0000000..0ee3722 --- /dev/null +++ b/src/observability/TraceOtel.cpp @@ -0,0 +1,59 @@ +/** + * @file TraceOtel.cpp + * @brief Body for src/observability/TraceOtel.hpp — compiled once into + * app_core. Deliberately the only Trace TU that touches OpenTelemetry + * (see the header note); the hex_to_bytes helper lives here because + * this is its only caller. + */ + +#include "observability/TraceOtel.hpp" + +#include +#include + +#include + +namespace Observability::Trace { + +namespace { + +/** + * @brief Hex string → fixed-size byte buffer (for TraceId/SpanId). + * @return false if the input length doesn't match or has a non-hex char. + */ +bool hex_to_bytes(std::string_view hex, uint8_t* out, size_t n) { + if (hex.size() != n * 2) + return false; + auto nibble = [](char c) -> int { + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + return -1; + }; + for (size_t i = 0; i < n; ++i) { + const int hi = nibble(hex[2 * i]), lo = nibble(hex[2 * i + 1]); + if (hi < 0 || lo < 0) + return false; + out[i] = static_cast((hi << 4) | lo); + } + return true; +} + +} // namespace + +std::optional to_remote_span_context(const TraceContext& t) { + uint8_t tid[16], sid[8], flags[1]; + if (!hex_to_bytes(t.trace_id, tid, 16) || !hex_to_bytes(t.parent_id, sid, 8) || !hex_to_bytes(t.flags, flags, 1)) { + return std::nullopt; + } + return opentelemetry::trace::SpanContext( + opentelemetry::trace::TraceId(opentelemetry::nostd::span(tid)), + opentelemetry::trace::SpanId(opentelemetry::nostd::span(sid)), + opentelemetry::trace::TraceFlags(flags[0]), + /*is_remote=*/true); +} + +} // namespace Observability::Trace diff --git a/src/observability/TraceOtel.hpp b/src/observability/TraceOtel.hpp new file mode 100644 index 0000000..f2cabbd --- /dev/null +++ b/src/observability/TraceOtel.hpp @@ -0,0 +1,29 @@ +/** + * @file TraceOtel.hpp + * @brief The one OpenTelemetry-coupled Trace helper: parsed W3C traceparent + * -> remote OTel SpanContext. + * @details Split out of Trace.hpp so that header (and Trace.cpp, the + * traceparent parser) stays std-only — the fuzz_traceparent harness + * in tests/fuzz compiles Trace.cpp directly, with no vcpkg + * dependencies. Include THIS header only where a SpanContext is + * actually built (the HTTP tracing advice, the job worker). + */ + +#pragma once + +#include + +#include + +#include "observability/Trace.hpp" + +namespace Observability::Trace { + +/** + * @brief Build a remote OTel SpanContext from a parsed W3C traceparent, so + * our server span JOINS the caller's distributed trace instead of + * starting an unrelated root. + */ +std::optional to_remote_span_context(const TraceContext& t); + +} // namespace Observability::Trace diff --git a/src/utils/Config.cpp b/src/utils/Config.cpp index c5a49c6..748c549 100644 --- a/src/utils/Config.cpp +++ b/src/utils/Config.cpp @@ -1,20 +1,22 @@ /** * @file Config.cpp * @brief Bodies for src/utils/Config.hpp — compiled once into app_core: file - * load/parse, the ${VAR} / ${VAR:-default} placeholder expansion, the - * dot-path walker and the global-instance lifecycle. The typed - * template accessors (get / require / get_optional) stay in the - * header; every contract is documented on the declarations there. + * load/parse, the dot-path walker and the global-instance lifecycle. + * The ${VAR} / ${VAR:-default} placeholder expansion lives in + * ConfigExpand.cpp (std+json only, shared with the fuzz harness); the + * typed template accessors (get / require / get_optional) stay in the + * header. Every contract is documented on the declarations there. */ #include "utils/Config.hpp" -#include #include #include #include #include +#include "utils/ConfigExpand.hpp" + namespace Config { AppConfig::AppConfig(const std::string& config_file) { @@ -34,62 +36,7 @@ void AppConfig::load_from_file(const std::string& file_path) { throw std::runtime_error("Failed to parse config file: " + std::string(e.what())); } - substitute_env_placeholders(config_data); -} - -std::string AppConfig::expand_string(const std::string& s) { - std::string out; - out.reserve(s.size()); - size_t i = 0; - while (i < s.size()) { - if (i + 1 < s.size() && s[i] == '$' && s[i + 1] == '{') { - size_t end = s.find('}', i + 2); - if (end == std::string::npos) { - out.append(s, i, std::string::npos); - break; - } - std::string expr = s.substr(i + 2, end - i - 2); - std::string var_name; - std::string default_value; - bool has_default = false; - auto sep = expr.find(":-"); - if (sep != std::string::npos) { - var_name = expr.substr(0, sep); - default_value = expr.substr(sep + 2); - has_default = true; - } else { - var_name = expr; - } - const char* env_value = var_name.empty() ? nullptr : std::getenv(var_name.c_str()); - if (env_value != nullptr) { - out.append(env_value); - } else if (has_default) { - out.append(default_value); - } - // else: leave the placeholder unexpanded? No — drop it silently - // (matches POSIX shell behavior for unset-without-default). - i = end + 1; - } else { - out.push_back(s[i++]); - } - } - return out; -} - -void AppConfig::substitute_env_placeholders(json& node) { - if (node.is_string()) { - const auto& raw = node.get_ref(); - if (raw.find("${") != std::string::npos) { - node = expand_string(raw); - } - } else if (node.is_object()) { - for (auto it = node.begin(); it != node.end(); ++it) { - substitute_env_placeholders(it.value()); - } - } else if (node.is_array()) { - for (auto& v : node) - substitute_env_placeholders(v); - } + detail::substitute_env_placeholders(config_data); } const json* AppConfig::find_nested_node(const std::string& key) const { diff --git a/src/utils/Config.hpp b/src/utils/Config.hpp index c3b914d..a850b6c 100644 --- a/src/utils/Config.hpp +++ b/src/utils/Config.hpp @@ -175,17 +175,11 @@ class AppConfig { } } - /** - * @brief Expand ${VAR} and ${VAR:-default} placeholders in a single string. - * @details Simple POSIX-shell-style substitution. Unmatched placeholders - * are replaced with empty string (or their default clause). - */ - static std::string expand_string(const std::string& s); - - /** - * @brief Recursively walk JSON and expand placeholders in every string value. - */ - static void substitute_env_placeholders(json& node); + // The ${VAR} / ${VAR:-default} placeholder expansion (formerly private + // statics here) lives in utils/ConfigExpand.hpp as free functions — + // load_from_file calls Config::detail::substitute_env_placeholders, and + // the fuzz_config_expand harness (tests/fuzz) compiles that TU without + // this header's spdlog dependency. /** * @brief Resolve a dot-separated path to the node it names. diff --git a/src/utils/ConfigExpand.cpp b/src/utils/ConfigExpand.cpp new file mode 100644 index 0000000..d8932ac --- /dev/null +++ b/src/utils/ConfigExpand.cpp @@ -0,0 +1,70 @@ +/** + * @file ConfigExpand.cpp + * @brief Bodies for src/utils/ConfigExpand.hpp — compiled once into + * app_core, and compiled directly into the fuzz_config_expand + * libFuzzer harness (tests/fuzz). Keep this TU free of anything + * beyond std + nlohmann/json (see the header note). + */ + +#include "utils/ConfigExpand.hpp" + +#include + +namespace Config::detail { + +std::string expand_string(const std::string& s) { + std::string out; + out.reserve(s.size()); + size_t i = 0; + while (i < s.size()) { + if (i + 1 < s.size() && s[i] == '$' && s[i + 1] == '{') { + size_t end = s.find('}', i + 2); + if (end == std::string::npos) { + out.append(s, i, std::string::npos); + break; + } + std::string expr = s.substr(i + 2, end - i - 2); + std::string var_name; + std::string default_value; + bool has_default = false; + auto sep = expr.find(":-"); + if (sep != std::string::npos) { + var_name = expr.substr(0, sep); + default_value = expr.substr(sep + 2); + has_default = true; + } else { + var_name = expr; + } + const char* env_value = var_name.empty() ? nullptr : std::getenv(var_name.c_str()); + if (env_value != nullptr) { + out.append(env_value); + } else if (has_default) { + out.append(default_value); + } + // else: leave the placeholder unexpanded? No — drop it silently + // (matches POSIX shell behavior for unset-without-default). + i = end + 1; + } else { + out.push_back(s[i++]); + } + } + return out; +} + +void substitute_env_placeholders(nlohmann::json& node) { + if (node.is_string()) { + const auto& raw = node.get_ref(); + if (raw.find("${") != std::string::npos) { + node = expand_string(raw); + } + } else if (node.is_object()) { + for (auto it = node.begin(); it != node.end(); ++it) { + substitute_env_placeholders(it.value()); + } + } else if (node.is_array()) { + for (auto& v : node) + substitute_env_placeholders(v); + } +} + +} // namespace Config::detail diff --git a/src/utils/ConfigExpand.hpp b/src/utils/ConfigExpand.hpp new file mode 100644 index 0000000..d4f949c --- /dev/null +++ b/src/utils/ConfigExpand.hpp @@ -0,0 +1,34 @@ +/** + * @file ConfigExpand.hpp + * @brief ${VAR} / ${VAR:-default} env-placeholder expansion — the pure + * parsing half of config loading. + * @details Free functions (they never touch AppConfig state; they were + * private statics on the class before the fuzzing work) so the + * fuzz_config_expand libFuzzer harness in tests/fuzz can compile + * ConfigExpand.cpp directly — this header pulls only and + * nlohmann/json, not spdlog (which Config.hpp needs for its get + * template and which would otherwise ride along into the fuzz + * build). AppConfig::load_from_file is the production caller. + */ + +#pragma once + +#include + +#include + +namespace Config::detail { + +/** + * @brief Expand ${VAR} and ${VAR:-default} placeholders in a single string. + * @details Simple POSIX-shell-style substitution. Unmatched placeholders + * are replaced with empty string (or their default clause). + */ +std::string expand_string(const std::string& s); + +/** + * @brief Recursively walk JSON and expand placeholders in every string value. + */ +void substitute_env_placeholders(nlohmann::json& node); + +} // namespace Config::detail diff --git a/src/worker_main.cpp b/src/worker_main.cpp index f2813da..bbbf231 100644 --- a/src/worker_main.cpp +++ b/src/worker_main.cpp @@ -34,6 +34,7 @@ #include "jobs/Jobs.hpp" #include "observability/Observability.hpp" #include "observability/Trace.hpp" +#include "observability/TraceOtel.hpp" #include "utils/Config.hpp" #include "utils/Strings.hpp" diff --git a/tests/fuzz/CMakeLists.txt b/tests/fuzz/CMakeLists.txt new file mode 100644 index 0000000..4856648 --- /dev/null +++ b/tests/fuzz/CMakeLists.txt @@ -0,0 +1,83 @@ +# tests/fuzz — libFuzzer harnesses for the byte-facing parsers. +# +# Design decision (documented in docs/TESTING.md, "Fuzzing"): the fuzzers do +# NOT link app_core. app_core drags the whole vcpkg dependency world (Drogon, +# pqxx, OTel, ...) which is built with GCC in CI — libFuzzer requires Clang, +# and rebuilding ~29 vcpkg packages under a second toolchain would blow any +# nightly budget. Instead each harness compiles the specific src/ TU that +# carries the parser under test; those TUs are kept std-only (or std+nlohmann +# for the config expander) exactly so this works: +# +# fuzz_traceparent <- src/observability/Trace.cpp (std only) +# fuzz_decimal_cents <- src/billing/PayPalParse.cpp (std only) +# fuzz_path_match <- src/utils/Strings.cpp (std only; +# normalize_path_for_metrics is header-only) +# fuzz_config_expand <- src/utils/ConfigExpand.cpp (std + nlohmann) +# +# nlohmann/json is the one third-party dependency and is header-only — the +# nightly job takes it from the distro package (nlohmann-json3-dev), no vcpkg. +# +# Two ways to configure: +# * standalone (what .github/workflows/fuzz-nightly.yml does — no vcpkg +# toolchain file, minutes not hours): +# cmake -S tests/fuzz -B build-fuzz -DCMAKE_CXX_COMPILER=clang++ +# cmake --build build-fuzz -j +# * embedded, from the top-level project: -DENABLE_FUZZERS=ON (requires the +# full vcpkg toolchain to already be configured under Clang). +# +# Either way a non-Clang compiler is a hard configure error below. + +cmake_minimum_required(VERSION 3.21) + +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + project(cpp_api_template_fuzz CXX) +endif() + +if(NOT CMAKE_CXX_COMPILER_ID MATCHES "Clang") + message(FATAL_ERROR + "libFuzzer needs Clang; the compiler here is '${CMAKE_CXX_COMPILER_ID}'. " + "Configure a dedicated fuzz build dir instead of the normal (GCC) one:\n" + " cmake -S tests/fuzz -B build-fuzz -DCMAKE_CXX_COMPILER=clang++\n" + " cmake --build build-fuzz -j") +endif() + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +get_filename_component(FUZZ_REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) + +# fuzzer = libFuzzer driver + coverage feedback; address+undefined make the +# "don't crash, no UB" oracle actually bite. No -fsanitize-recover: the first +# fault aborts and libFuzzer writes the reproducer input. +set(FUZZ_SAN_FLAGS -fsanitize=fuzzer,address,undefined) + +function(add_fuzz_target name) + add_executable(${name} ${name}.cpp ${ARGN}) + target_include_directories(${name} PRIVATE ${FUZZ_REPO_ROOT}/src) + target_compile_options(${name} PRIVATE + ${FUZZ_SAN_FLAGS} + -fno-sanitize-recover=all + -fno-omit-frame-pointer + -g + -O1) + target_link_options(${name} PRIVATE ${FUZZ_SAN_FLAGS}) +endfunction() + +add_fuzz_target(fuzz_traceparent ${FUZZ_REPO_ROOT}/src/observability/Trace.cpp) +add_fuzz_target(fuzz_decimal_cents ${FUZZ_REPO_ROOT}/src/billing/PayPalParse.cpp) +add_fuzz_target(fuzz_path_match ${FUZZ_REPO_ROOT}/src/utils/Strings.cpp) +add_fuzz_target(fuzz_config_expand ${FUZZ_REPO_ROOT}/src/utils/ConfigExpand.cpp) + +# nlohmann/json for the config expander: reuse the project's target when +# embedded; standalone, take the distro/homebrew package (config mode), or +# fall back to a bare include dir. +if(NOT TARGET nlohmann_json::nlohmann_json) + find_package(nlohmann_json CONFIG QUIET) +endif() +if(TARGET nlohmann_json::nlohmann_json) + target_link_libraries(fuzz_config_expand PRIVATE nlohmann_json::nlohmann_json) +else() + find_path(NLOHMANN_JSON_INCLUDE_DIR nlohmann/json.hpp REQUIRED) + target_include_directories(fuzz_config_expand PRIVATE ${NLOHMANN_JSON_INCLUDE_DIR}) +endif() diff --git a/tests/fuzz/corpus/fuzz_config_expand/double_dollar b/tests/fuzz/corpus/fuzz_config_expand/double_dollar new file mode 100644 index 0000000..4e18385 --- /dev/null +++ b/tests/fuzz/corpus/fuzz_config_expand/double_dollar @@ -0,0 +1 @@ +$${FUZZ_SET_VAR} \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_config_expand/empty_expr b/tests/fuzz/corpus/fuzz_config_expand/empty_expr new file mode 100644 index 0000000..e38d748 --- /dev/null +++ b/tests/fuzz/corpus/fuzz_config_expand/empty_expr @@ -0,0 +1 @@ +${} \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_config_expand/empty_name_with_default b/tests/fuzz/corpus/fuzz_config_expand/empty_name_with_default new file mode 100644 index 0000000..4419e0f --- /dev/null +++ b/tests/fuzz/corpus/fuzz_config_expand/empty_name_with_default @@ -0,0 +1 @@ +${:-only-default} \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_config_expand/empty_var b/tests/fuzz/corpus/fuzz_config_expand/empty_var new file mode 100644 index 0000000..2eea66e --- /dev/null +++ b/tests/fuzz/corpus/fuzz_config_expand/empty_var @@ -0,0 +1 @@ +${FUZZ_EMPTY_VAR} \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_config_expand/mixed b/tests/fuzz/corpus/fuzz_config_expand/mixed new file mode 100644 index 0000000..893f33a --- /dev/null +++ b/tests/fuzz/corpus/fuzz_config_expand/mixed @@ -0,0 +1 @@ +prefix ${FUZZ_SET_VAR} middle ${FUZZ_UNSET_VAR:-d} suffix \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_config_expand/nested_placeholder b/tests/fuzz/corpus/fuzz_config_expand/nested_placeholder new file mode 100644 index 0000000..270b6c7 --- /dev/null +++ b/tests/fuzz/corpus/fuzz_config_expand/nested_placeholder @@ -0,0 +1 @@ +${A:-${B}} \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_config_expand/plain b/tests/fuzz/corpus/fuzz_config_expand/plain new file mode 100644 index 0000000..83ef66b --- /dev/null +++ b/tests/fuzz/corpus/fuzz_config_expand/plain @@ -0,0 +1 @@ +plain text, no placeholders \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_config_expand/set_var b/tests/fuzz/corpus/fuzz_config_expand/set_var new file mode 100644 index 0000000..d1e1425 --- /dev/null +++ b/tests/fuzz/corpus/fuzz_config_expand/set_var @@ -0,0 +1 @@ +${FUZZ_SET_VAR} \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_config_expand/unset_var b/tests/fuzz/corpus/fuzz_config_expand/unset_var new file mode 100644 index 0000000..6a1aa2f --- /dev/null +++ b/tests/fuzz/corpus/fuzz_config_expand/unset_var @@ -0,0 +1 @@ +${FUZZ_UNSET_VAR} \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_config_expand/unset_with_default b/tests/fuzz/corpus/fuzz_config_expand/unset_with_default new file mode 100644 index 0000000..1e2fbf9 --- /dev/null +++ b/tests/fuzz/corpus/fuzz_config_expand/unset_with_default @@ -0,0 +1 @@ +${FUZZ_UNSET_VAR:-fallback} \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_config_expand/unterminated b/tests/fuzz/corpus/fuzz_config_expand/unterminated new file mode 100644 index 0000000..97277ec --- /dev/null +++ b/tests/fuzz/corpus/fuzz_config_expand/unterminated @@ -0,0 +1 @@ +${ \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_decimal_cents/empty_int_part b/tests/fuzz/corpus/fuzz_decimal_cents/empty_int_part new file mode 100644 index 0000000..6c1608f --- /dev/null +++ b/tests/fuzz/corpus/fuzz_decimal_cents/empty_int_part @@ -0,0 +1 @@ +.99 \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_decimal_cents/int_part_too_long b/tests/fuzz/corpus/fuzz_decimal_cents/int_part_too_long new file mode 100644 index 0000000..4a7d725 --- /dev/null +++ b/tests/fuzz/corpus/fuzz_decimal_cents/int_part_too_long @@ -0,0 +1 @@ +1234567890123456 \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_decimal_cents/integer_only b/tests/fuzz/corpus/fuzz_decimal_cents/integer_only new file mode 100644 index 0000000..3cacc0b --- /dev/null +++ b/tests/fuzz/corpus/fuzz_decimal_cents/integer_only @@ -0,0 +1 @@ +12 \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_decimal_cents/max_int_part b/tests/fuzz/corpus/fuzz_decimal_cents/max_int_part new file mode 100644 index 0000000..0e7aa27 --- /dev/null +++ b/tests/fuzz/corpus/fuzz_decimal_cents/max_int_part @@ -0,0 +1 @@ +999999999999999.99 \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_decimal_cents/negative b/tests/fuzz/corpus/fuzz_decimal_cents/negative new file mode 100644 index 0000000..4284c27 --- /dev/null +++ b/tests/fuzz/corpus/fuzz_decimal_cents/negative @@ -0,0 +1 @@ +-1.00 \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_decimal_cents/non_digit b/tests/fuzz/corpus/fuzz_decimal_cents/non_digit new file mode 100644 index 0000000..b667a7a --- /dev/null +++ b/tests/fuzz/corpus/fuzz_decimal_cents/non_digit @@ -0,0 +1 @@ +1a.2b \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_decimal_cents/one_cent b/tests/fuzz/corpus/fuzz_decimal_cents/one_cent new file mode 100644 index 0000000..d1c6331 --- /dev/null +++ b/tests/fuzz/corpus/fuzz_decimal_cents/one_cent @@ -0,0 +1 @@ +0.01 \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_decimal_cents/one_frac_digit b/tests/fuzz/corpus/fuzz_decimal_cents/one_frac_digit new file mode 100644 index 0000000..627b54f --- /dev/null +++ b/tests/fuzz/corpus/fuzz_decimal_cents/one_frac_digit @@ -0,0 +1 @@ +12.3 \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_decimal_cents/plus_sign b/tests/fuzz/corpus/fuzz_decimal_cents/plus_sign new file mode 100644 index 0000000..aceca20 --- /dev/null +++ b/tests/fuzz/corpus/fuzz_decimal_cents/plus_sign @@ -0,0 +1 @@ ++5 \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_decimal_cents/three_frac_digits b/tests/fuzz/corpus/fuzz_decimal_cents/three_frac_digits new file mode 100644 index 0000000..92f5771 --- /dev/null +++ b/tests/fuzz/corpus/fuzz_decimal_cents/three_frac_digits @@ -0,0 +1 @@ +12.345 \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_decimal_cents/trailing_dot b/tests/fuzz/corpus/fuzz_decimal_cents/trailing_dot new file mode 100644 index 0000000..949045f --- /dev/null +++ b/tests/fuzz/corpus/fuzz_decimal_cents/trailing_dot @@ -0,0 +1 @@ +12. \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_decimal_cents/two_frac_digits b/tests/fuzz/corpus/fuzz_decimal_cents/two_frac_digits new file mode 100644 index 0000000..2e0c11f --- /dev/null +++ b/tests/fuzz/corpus/fuzz_decimal_cents/two_frac_digits @@ -0,0 +1 @@ +12.34 \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_path_match/account_token_route b/tests/fuzz/corpus/fuzz_path_match/account_token_route new file mode 100644 index 0000000..33f9004 --- /dev/null +++ b/tests/fuzz/corpus/fuzz_path_match/account_token_route @@ -0,0 +1,2 @@ + +/api/v1/account/confirm/0af7651916cd43dd8448eb211c80319c \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_path_match/bare_star b/tests/fuzz/corpus/fuzz_path_match/bare_star new file mode 100644 index 0000000..7a65400 --- /dev/null +++ b/tests/fuzz/corpus/fuzz_path_match/bare_star @@ -0,0 +1,2 @@ +* +/anything \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_path_match/degenerate_csv b/tests/fuzz/corpus/fuzz_path_match/degenerate_csv new file mode 100644 index 0000000..17e23f5 --- /dev/null +++ b/tests/fuzz/corpus/fuzz_path_match/degenerate_csv @@ -0,0 +1,2 @@ + , ,, +/ \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_path_match/digit_segment b/tests/fuzz/corpus/fuzz_path_match/digit_segment new file mode 100644 index 0000000..0e74b9b --- /dev/null +++ b/tests/fuzz/corpus/fuzz_path_match/digit_segment @@ -0,0 +1,2 @@ + +/api/v1/admin/roles/5 \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_path_match/double_slashes b/tests/fuzz/corpus/fuzz_path_match/double_slashes new file mode 100644 index 0000000..77ade25 --- /dev/null +++ b/tests/fuzz/corpus/fuzz_path_match/double_slashes @@ -0,0 +1,2 @@ + +//double//slashes// \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_path_match/empty_path b/tests/fuzz/corpus/fuzz_path_match/empty_path new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/fuzz/corpus/fuzz_path_match/empty_path @@ -0,0 +1 @@ + diff --git a/tests/fuzz/corpus/fuzz_path_match/extra_csv_star b/tests/fuzz/corpus/fuzz_path_match/extra_csv_star new file mode 100644 index 0000000..f9dc7bd --- /dev/null +++ b/tests/fuzz/corpus/fuzz_path_match/extra_csv_star @@ -0,0 +1,2 @@ +/api/v1/webhooks/*, /api/v1/feed +/api/v1/webhooks/paypal \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_path_match/public_exact b/tests/fuzz/corpus/fuzz_path_match/public_exact new file mode 100644 index 0000000..f26185a --- /dev/null +++ b/tests/fuzz/corpus/fuzz_path_match/public_exact @@ -0,0 +1,2 @@ + +/api/v1/auth/login \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_path_match/uuid_segment b/tests/fuzz/corpus/fuzz_path_match/uuid_segment new file mode 100644 index 0000000..7866eab --- /dev/null +++ b/tests/fuzz/corpus/fuzz_path_match/uuid_segment @@ -0,0 +1,2 @@ + +/api/v1/admin/users/0af76519-16cd-43dd-8448-eb211c80319c \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_path_match/versioned_token b/tests/fuzz/corpus/fuzz_path_match/versioned_token new file mode 100644 index 0000000..233c2c1 --- /dev/null +++ b/tests/fuzz/corpus/fuzz_path_match/versioned_token @@ -0,0 +1,2 @@ + +/api/v99/account/reset-password/tok123 \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_traceparent/all_zero_parent_id b/tests/fuzz/corpus/fuzz_traceparent/all_zero_parent_id new file mode 100644 index 0000000..67fcaef --- /dev/null +++ b/tests/fuzz/corpus/fuzz_traceparent/all_zero_parent_id @@ -0,0 +1 @@ +00-0af7651916cd43dd8448eb211c80319c-0000000000000000-01 \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_traceparent/all_zero_trace_id b/tests/fuzz/corpus/fuzz_traceparent/all_zero_trace_id new file mode 100644 index 0000000..a67f4a9 --- /dev/null +++ b/tests/fuzz/corpus/fuzz_traceparent/all_zero_trace_id @@ -0,0 +1 @@ +00-00000000000000000000000000000000-b7ad6b7169203331-01 \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_traceparent/five_components b/tests/fuzz/corpus/fuzz_traceparent/five_components new file mode 100644 index 0000000..40ad542 --- /dev/null +++ b/tests/fuzz/corpus/fuzz_traceparent/five_components @@ -0,0 +1 @@ +00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01-extra \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_traceparent/junk b/tests/fuzz/corpus/fuzz_traceparent/junk new file mode 100644 index 0000000..6a08323 --- /dev/null +++ b/tests/fuzz/corpus/fuzz_traceparent/junk @@ -0,0 +1 @@ +not-a-traceparent \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_traceparent/only_dashes b/tests/fuzz/corpus/fuzz_traceparent/only_dashes new file mode 100644 index 0000000..65bcf70 --- /dev/null +++ b/tests/fuzz/corpus/fuzz_traceparent/only_dashes @@ -0,0 +1 @@ +---- \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_traceparent/truncated b/tests/fuzz/corpus/fuzz_traceparent/truncated new file mode 100644 index 0000000..47eac5e --- /dev/null +++ b/tests/fuzz/corpus/fuzz_traceparent/truncated @@ -0,0 +1 @@ +00-0af7651916cd43dd8448eb211c80319c-b7ad6b71 \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_traceparent/unknown_version b/tests/fuzz/corpus/fuzz_traceparent/unknown_version new file mode 100644 index 0000000..e0edf9a --- /dev/null +++ b/tests/fuzz/corpus/fuzz_traceparent/unknown_version @@ -0,0 +1 @@ +ff-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01 \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_traceparent/valid_sampled b/tests/fuzz/corpus/fuzz_traceparent/valid_sampled new file mode 100644 index 0000000..ec7705b --- /dev/null +++ b/tests/fuzz/corpus/fuzz_traceparent/valid_sampled @@ -0,0 +1 @@ +00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01 \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_traceparent/valid_unsampled b/tests/fuzz/corpus/fuzz_traceparent/valid_unsampled new file mode 100644 index 0000000..627aa1e --- /dev/null +++ b/tests/fuzz/corpus/fuzz_traceparent/valid_unsampled @@ -0,0 +1 @@ +00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-00 \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_traceparent/valid_uppercase b/tests/fuzz/corpus/fuzz_traceparent/valid_uppercase new file mode 100644 index 0000000..6898e11 --- /dev/null +++ b/tests/fuzz/corpus/fuzz_traceparent/valid_uppercase @@ -0,0 +1 @@ +00-0AF7651916CD43DD8448EB211C80319C-B7AD6B7169203331-01 \ No newline at end of file diff --git a/tests/fuzz/fuzz_config_expand.cpp b/tests/fuzz/fuzz_config_expand.cpp new file mode 100644 index 0000000..2b187c1 --- /dev/null +++ b/tests/fuzz/fuzz_config_expand.cpp @@ -0,0 +1,45 @@ +/** + * @file fuzz_config_expand.cpp + * @brief libFuzzer harness for Config::detail::expand_string / + * substitute_env_placeholders — the ${VAR} / ${VAR:-default} + * expansion runs over every string in the config file, which in a + * fork may embed operator-supplied text. Oracle: never crash/UB on + * arbitrary bytes, both on a bare string and recursively through a + * JSON document. The environment is cleared and re-seeded in + * LLVMFuzzerInitialize so runs are deterministic and the fuzzer can + * actually hit the set / empty / unset branches. Compiled with + * src/utils/ConfigExpand.cpp (std + nlohmann/json only — no spdlog). + */ + +#include +#include +#include +#include + +#include + +#include "utils/ConfigExpand.hpp" + +extern "C" int LLVMFuzzerInitialize(int* /*argc*/, char*** /*argv*/) { +#ifdef __linux__ + clearenv(); // determinism: no host env leaks into expansion results +#endif + setenv("FUZZ_SET_VAR", "fuzz-value", 1); + setenv("FUZZ_EMPTY_VAR", "", 1); + return 0; +} + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + const std::string s(reinterpret_cast(data), size); + + (void)Config::detail::expand_string(s); + + // The production path: expansion walks a parsed JSON document + // recursively (objects, arrays, mixed leaf types). + nlohmann::json j; + j["top"] = s; + j["nested"]["inner"] = s; + j["arr"] = nlohmann::json::array({s, 42, nullptr, true}); + Config::detail::substitute_env_placeholders(j); + return 0; +} diff --git a/tests/fuzz/fuzz_decimal_cents.cpp b/tests/fuzz/fuzz_decimal_cents.cpp new file mode 100644 index 0000000..360fd4e --- /dev/null +++ b/tests/fuzz/fuzz_decimal_cents.cpp @@ -0,0 +1,42 @@ +/** + * @file fuzz_decimal_cents.cpp + * @brief libFuzzer harness for Billing::detail::parse_decimal_to_cents — the + * money parser fed by PayPal API/webhook response bodies. Oracle: + * arbitrary bytes either parse to a non-negative cents value that + * round-trips exactly through cents_to_decimal_string, or throw the + * documented std::runtime_error. Any OTHER escape (a different + * exception type, a crash, UB) is a finding. Compiled with + * src/billing/PayPalParse.cpp only (std-only TU, no curl). + */ + +#include +#include +#include +#include + +#include "billing/PayPalClient.hpp" + +namespace { + +void check(bool ok) { + if (!ok) + __builtin_trap(); +} + +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + namespace D = Billing::detail; + const std::string s(reinterpret_cast(data), size); + + try { + const std::int64_t cents = D::parse_decimal_to_cents(s); + // The whole point of the hand-rolled parser: money never goes + // negative and never loses precision on a round trip. + check(cents >= 0); + check(D::parse_decimal_to_cents(D::cents_to_decimal_string(cents)) == cents); + } catch (const std::runtime_error&) { + // Documented rejection path — everything malformed lands here. + } + return 0; +} diff --git a/tests/fuzz/fuzz_path_match.cpp b/tests/fuzz/fuzz_path_match.cpp new file mode 100644 index 0000000..9f14cc9 --- /dev/null +++ b/tests/fuzz/fuzz_path_match.cpp @@ -0,0 +1,46 @@ +/** + * @file fuzz_path_match.cpp + * @brief libFuzzer harness for the request-path matchers every middleware + * runs on raw request paths: Utils::Strings::path_is_public (+ the + * CSV split/merge that builds its set) and + * Api::normalize_path_for_metrics. Input layout: bytes up to the + * first '\n' are an extra public-paths CSV (exercises + * split_csv_vec/merge_csv_sets on hostile config), the rest is the + * request path. Oracle: never crash/UB; normalization always yields + * a rooted path and is idempotent (a normalized path re-normalizes + * to itself — if it didn't, metrics labels would drift). Compiled + * with src/utils/Strings.cpp + the header-only PathNormalize.hpp. + */ + +#include +#include +#include + +#include "api/PathNormalize.hpp" +#include "utils/Strings.hpp" + +namespace { + +void check(bool ok) { + if (!ok) + __builtin_trap(); +} + +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + namespace S = Utils::Strings; + const std::string input(reinterpret_cast(data), size); + + const auto nl = input.find('\n'); + const std::string extra_csv = (nl == std::string::npos) ? std::string() : input.substr(0, nl); + const std::string path = (nl == std::string::npos) ? input : input.substr(nl + 1); + + const auto public_paths = S::merge_csv_sets(S::kDefaultPublicPathsCsv, extra_csv); + (void)S::path_is_public(public_paths, path); + + const std::string norm = Api::normalize_path_for_metrics(path); + check(!norm.empty() && norm.front() == '/'); + check(Api::normalize_path_for_metrics(norm) == norm); + return 0; +} diff --git a/tests/fuzz/fuzz_traceparent.cpp b/tests/fuzz/fuzz_traceparent.cpp new file mode 100644 index 0000000..1c9c0e5 --- /dev/null +++ b/tests/fuzz/fuzz_traceparent.cpp @@ -0,0 +1,58 @@ +/** + * @file fuzz_traceparent.cpp + * @brief libFuzzer harness for Observability::Trace::parse_traceparent — the + * W3C `traceparent` header arrives verbatim off the network on every + * request. Oracle: never crash/UB on arbitrary bytes; anything the + * parser ACCEPTS must be canonical (documented component sizes, + * lowercase hex) and must round-trip through format_traceparent. + * Compiled with src/observability/Trace.cpp only (std-only TU — see + * tests/fuzz/CMakeLists.txt for the no-vcpkg rationale). + */ + +#include +#include +#include + +#include "observability/Trace.hpp" + +namespace { + +void check(bool ok) { + if (!ok) + __builtin_trap(); // surfaced by libFuzzer as a crash with this input +} + +bool is_lower_hex(std::string_view s) { + for (char c : s) + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) + return false; + return true; +} + +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + namespace T = Observability::Trace; + const std::string_view header(reinterpret_cast(data), size); + + if (auto parsed = T::parse_traceparent(header)) { + // Accepted output is canonical: sizes per the W3C format, lowercase + // hex, IDs not all-zero. + check(parsed->trace_id.size() == 32 && is_lower_hex(parsed->trace_id)); + check(parsed->parent_id.size() == 16 && is_lower_hex(parsed->parent_id)); + check(parsed->flags.size() == 2 && is_lower_hex(parsed->flags)); + check(parsed->trace_id.find_first_not_of('0') != std::string::npos); + check(parsed->parent_id.find_first_not_of('0') != std::string::npos); + + // format -> parse round trip reproduces the same components. + const auto again = T::parse_traceparent(T::format_traceparent(*parsed)); + check(again.has_value()); + check(again->trace_id == parsed->trace_id && again->parent_id == parsed->parent_id && + again->flags == parsed->flags); + } + + // The production entry point must always yield a usable context. + const auto ctx = T::extract_or_generate(header); + check(ctx.trace_id.size() == 32 && ctx.parent_id.size() == 16 && ctx.flags.size() == 2); + return 0; +}