diff --git a/include/xrpl/basics/StructuredLogging.h b/include/xrpl/basics/StructuredLogging.h index cf4d4a3888..a247b81f74 100644 --- a/include/xrpl/basics/StructuredLogging.h +++ b/include/xrpl/basics/StructuredLogging.h @@ -27,6 +27,58 @@ concept HasToString = requires(std::remove_cvref_t const& t) { { to_string(t) } -> std::convertible_to; }; +/** + * @brief Escape a string for safe embedding in a JSON value. + * + * Escapes backslash, double-quote, and control characters (U+0000-U+001F) + * according to RFC 8259 section 7. + */ +inline void +appendEscapedJsonString(std::string& dest, std::string_view sv) +{ + dest.reserve(dest.size() + sv.size() + 2); + dest += '"'; + for (char const c : sv) + { + switch (c) + { + case '"': + dest += "\\\""; + break; + case '\\': + dest += "\\\\"; + break; + case '\b': + dest += "\\b"; + break; + case '\f': + dest += "\\f"; + break; + case '\n': + dest += "\\n"; + break; + case '\r': + dest += "\\r"; + break; + case '\t': + dest += "\\t"; + break; + default: + if (static_cast(c) < 0x20) + { + fmt::format_to( + std::back_inserter(dest), "\\u{:04x}", static_cast(c)); + } + else + { + dest += c; + } + break; + } + } + dest += '"'; +} + /** * @brief Append a value formatted as a JSON fragment to @p dest. * @@ -58,11 +110,11 @@ appendJsonValue(std::string& dest, T const& value) } else if constexpr (HasToString) { - fmt::format_to(std::back_inserter(dest), "\"{}\"", to_string(value)); + appendEscapedJsonString(dest, to_string(value)); } else { - fmt::format_to(std::back_inserter(dest), "\"{}\"", value); + appendEscapedJsonString(dest, fmt::format("{}", value)); } } @@ -78,7 +130,8 @@ appendJsonField(std::string& dest, std::string_view key, T const& value) { if (!dest.empty()) dest += ','; - fmt::format_to(std::back_inserter(dest), "\"{}\":", key); + appendEscapedJsonString(dest, key); + dest += ':'; appendJsonValue(dest, value); } @@ -111,15 +164,22 @@ class JsonLoggingPatternBuilder { std::string fields_; - static constexpr std::string_view kSUFFIX = ", \"message\": %v }"; + static constexpr std::string_view kMESSAGE_FIELD = "\"message\": %v"; static std::string extractFields(std::string_view pattern) { if (!pattern.empty() && pattern.front() == '{') pattern.remove_prefix(1); - if (auto pos = pattern.rfind(kSUFFIX); pos != std::string_view::npos) - pattern = pattern.substr(0, pos); + // Strip the trailing message field: `"message": %v }` or `"message": %v }` + if (auto pos = pattern.rfind(kMESSAGE_FIELD); pos != std::string_view::npos) + { + // Also strip the leading ", " separator if present + auto end = pos; + if (end >= 2 && pattern.substr(end - 2, 2) == ", ") + end -= 2; + pattern = pattern.substr(0, end); + } return std::string(pattern); } @@ -143,7 +203,13 @@ public: [[nodiscard]] std::string build() const { - return "{" + fields_ + std::string(kSUFFIX); + std::string result = "{"; + result += fields_; + if (!fields_.empty()) + result += ", "; + result += kMESSAGE_FIELD; + result += " }"; + return result; } }; diff --git a/src/tests/libxrpl/basics/StructuredLogging.cpp b/src/tests/libxrpl/basics/StructuredLogging.cpp index 925a19ebcb..adcb2e892b 100644 --- a/src/tests/libxrpl/basics/StructuredLogging.cpp +++ b/src/tests/libxrpl/basics/StructuredLogging.cpp @@ -61,12 +61,106 @@ TEST(AppendJsonValue, appends_to_existing) EXPECT_EQ(dest, "prefix:99"); } +// -- detail::appendEscapedJsonString ------------------------------------------ + +TEST(AppendEscapedJsonString, plain_string) +{ + std::string dest; + detail::appendEscapedJsonString(dest, "hello"); + EXPECT_EQ(dest, "\"hello\""); +} + +TEST(AppendEscapedJsonString, escapes_double_quote) +{ + std::string dest; + detail::appendEscapedJsonString(dest, R"(say "hi")"); + EXPECT_EQ(dest, R"("say \"hi\"")"); +} + +TEST(AppendEscapedJsonString, escapes_backslash) +{ + std::string dest; + detail::appendEscapedJsonString(dest, R"(a\b)"); + EXPECT_EQ(dest, R"("a\\b")"); +} + +TEST(AppendEscapedJsonString, escapes_control_characters) +{ + std::string dest; + detail::appendEscapedJsonString(dest, "line1\nline2\ttab"); + EXPECT_EQ(dest, R"("line1\nline2\ttab")"); +} + +TEST(AppendEscapedJsonString, escapes_all_named_controls) +{ + std::string dest; + detail::appendEscapedJsonString(dest, "\b\f\r"); + EXPECT_EQ(dest, R"("\b\f\r")"); +} + +TEST(AppendEscapedJsonString, escapes_low_control_char_as_unicode) +{ + std::string dest; + std::string input(1, '\x01'); + detail::appendEscapedJsonString(dest, input); + EXPECT_EQ(dest, R"("\u0001")"); +} + +TEST(AppendEscapedJsonString, empty_string) +{ + std::string dest; + detail::appendEscapedJsonString(dest, ""); + EXPECT_EQ(dest, R"("")"); +} + +// -- appendJsonValue with escaping ------------------------------------------- + +TEST(AppendJsonValue, string_with_quotes_escaped) +{ + std::string dest; + std::string const val = R"(say "hi")"; + detail::appendJsonValue(dest, val); + EXPECT_EQ(dest, R"("say \"hi\"")"); +} + +TEST(AppendJsonValue, string_with_backslash_escaped) +{ + std::string dest; + std::string const val = R"(path\to\file)"; + detail::appendJsonValue(dest, val); + EXPECT_EQ(dest, R"("path\\to\\file")"); +} + +TEST(AppendJsonValue, string_with_newline_escaped) +{ + std::string dest; + std::string const val = "line1\nline2"; + detail::appendJsonValue(dest, val); + EXPECT_EQ(dest, R"("line1\nline2")"); +} + +// -- appendJsonField with key escaping ---------------------------------------- + +TEST(AppendJsonField, key_with_quotes_escaped) +{ + std::string dest; + detail::appendJsonField(dest, R"(my"key)", 42); + EXPECT_EQ(dest, R"("my\"key":42)"); +} + +TEST(AppendJsonField, key_with_backslash_escaped) +{ + std::string dest; + detail::appendJsonField(dest, R"(a\b)", true); + EXPECT_EQ(dest, R"("a\\b":true)"); +} + // -- buildJsonPattern -------------------------------------------------------- TEST(BuildJsonPattern, no_params) { auto const pattern = buildJsonPattern(""); - EXPECT_EQ(pattern, "{, \"message\": %v }"); + EXPECT_EQ(pattern, "{\"message\": %v }"); } TEST(BuildJsonPattern, single_string_field)