Skip to content

XmlLayout - Faster XML encoding with INoAllocationStringValueRenderer#6094

Merged
snakefoot merged 1 commit intoNLog:devfrom
snakefoot:xmlhelper_safer
Feb 18, 2026
Merged

XmlLayout - Faster XML encoding with INoAllocationStringValueRenderer#6094
snakefoot merged 1 commit intoNLog:devfrom
snakefoot:xmlhelper_safer

Conversation

@snakefoot
Copy link
Contributor

@snakefoot snakefoot commented Feb 17, 2026

Followup to #5774 with faster XML encoding, by reducing enumeration of StringBuilders.

Also optimized CData-encoding by reducing the number of StringBuilder-Index-Lookups.

Also improved xml-encoding when outputting Xml-Attribute-Values for correctness.

@coderabbitai
Copy link

coderabbitai bot commented Feb 17, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Xml escaping/sanitization was refactored to support optional incremental writes into a provided StringBuilder; XmlEncodeLayoutRendererWrapper prefers a non-allocating string-value renderer and routes rendering through builder-aware escape/CDAT A helpers; XML element/attribute rendering and unit tests were updated accordingly.

Changes

Cohort / File(s) Summary
XML Helper Core Refactor
src/NLog/Internal/XmlHelper.cs
Reworked invalid-char removal and escaping to support optional StringBuilder consumers: added builder-aware RemoveInvalidXmlChars/CreateValidXmlString, EscapeXmlWhenNeeded overloads, EscapeXmlString that writes into a destination builder, RequiresXmlEscape, and builder-aware CDATA helpers.
Layout Renderer Optimization
src/NLog/LayoutRenderers/Wrappers/XmlEncodeLayoutRendererWrapper.cs
Added _stringValueRenderer and InitializeLayoutRenderer(); RenderInnerAndTransform now prefers the non-allocating string-value path and uses builder-aware APIs (EscapeXmlWhenNeeded, EscapeCDataWhenNeeded) with early-return when possible.
ValueType Layout Visibility
src/NLog/Layouts/Typed/ValueTypeLayoutInfo.cs
Changed ResolveStringValueMethod(Layout) visibility from private static to internal static so the renderer can resolve non-allocating string-value renderers.
XML Attribute & Element Escaping
src/NLog/Layouts/XML/XmlAttribute.cs, src/NLog/Layouts/XML/XmlElementBase.cs
XmlAttribute: added direct non-encoded render path, pre-escape for simple string values, and centralized object-to-string conversion into builder-aware EscapeXmlWhenNeeded. XmlElementBase: switched to EscapeXmlWhenNeeded for conditional, builder-based escaping.
Tests: Encoding & CDATA
tests/NLog.UnitTests/LayoutRenderers/Wrappers/XmlEncodeTests.cs, tests/NLog.UnitTests/Layouts/XmlLayoutTests.cs
Expanded tests: added cases for forbidden/surrogate/unprintable char handling and nested-CDATA scenarios (XmlEncodeBadStringTest1, XmlEncodeCDataTest1, XmlEncodeCDataTest2); updated XmlLayout tests to expect XML-escaped property values.

Sequence Diagram(s)

sequenceDiagram
    participant Renderer as XmlEncodeLayoutRendererWrapper
    participant Helper as XmlHelper
    participant Builder as StringBuilder

    Renderer->>Renderer: InitializeLayoutRenderer() (resolve _stringValueRenderer)
    Renderer->>Renderer: RenderInnerAndTransform(event)
    alt string-value renderer available
        Renderer->>Renderer: Obtain string value (no allocation)
        Renderer->>Builder: Append raw text
        Renderer->>Helper: EscapeXmlWhenNeeded(Builder, startPos, xmlEncodeNewlines, removedInvalidXmlChars?)
    else fallback to layout-render path
        Renderer->>Builder: Inner.Render(logEvent, Builder)
        Renderer->>Helper: EscapeXmlWhenNeeded(Builder, startPos, xmlEncodeNewlines)
    end

    Helper->>Helper: RequiresXmlEscape scan(text segment)
    alt invalid XML chars found
        Helper->>Builder: RemoveInvalidXmlChars(text, Builder)
    end
    alt escaping needed
        Helper->>Builder: EscapeXmlString(text, xmlEncodeNewlines, startIndex, Builder)
    end

    Renderer->>Builder: Append attributes / CDATA markers as needed
    Renderer->>Renderer: Continue composing output
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐇 I nibble broken marks and stitch them neat,

Builders hum softly, no extra heap to meet.
CDATA patched, surrogates tucked in bed,
Streams stay lean while safe tags nod their head.
A rabbit hops off — the XML sleeps fed.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main objective of the PR—faster XML encoding using INoAllocationStringValueRenderer—which aligns with the primary changes across XmlHelper and related XML rendering components.
Description check ✅ Passed The PR description accurately describes the changeset, detailing performance optimizations for XML encoding through StringBuilder reduction and CDATA improvements.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
tests/NLog.UnitTests/LayoutRenderers/Wrappers/XmlEncodeTests.cs (1)

55-112: Good coverage for invalid XML characters, but consider adding a surrogate pair test.

The test validates that all non-surrogate, non-forbidden characters survive encoding. However, it explicitly skips surrogates (line 74–77), which means valid surrogate pairs (e.g., emoji like "\ud83d\ude00") are not tested. Given the surrogate handling issue in RemoveInvalidXmlIfNeeded, adding a test with valid surrogate pairs would be valuable to prevent regressions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/NLog.UnitTests/LayoutRenderers/Wrappers/XmlEncodeTests.cs` around lines
55 - 112, Add a new test (or extend XmlEncodeBadStringTest1) that includes valid
surrogate pairs to ensure RemoveInvalidXmlIfNeeded preserves them: create a
string containing known valid surrogate pair(s) (e.g., "\ud83d\ude00" or other
emoji), push it into the ScopeContext property "foo" and render with
SimpleLayout using "${xml-encode:${scopeproperty:foo}}" and
"${xml-encode:${scopeproperty:foo}:CDataEncode=true}", then assert the output is
non-empty and that the surrogate pair characters remain valid XML characters
(use System.Xml.XmlConvert.IsXmlChar on each resulting char or verify the
original codepoint survives). Reference the existing XmlEncodeBadStringTest1,
ScopeContext.PushProperty, SimpleLayout rendering, and the
RemoveInvalidXmlIfNeeded behavior when adding the assertions.
src/NLog/Internal/XmlHelper.cs (1)

176-194: String-based EscapeXmlWhenNeeded — minor dead code observation.

The else branch at line 187 (EscapeXmlString(text, xmlEncodeNewlines, startIndex, sb)) appears unreachable: we only enter the if (startIndex >= 0) block when text contains at least one XML escape char (<>&'"\r\n), all of which are valid XML chars. Therefore RemoveInvalidXmlIfNeeded will always append at least that char, ensuring orgLength != sb.Length.

Not a functional issue — just a note for code clarity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NLog/Internal/XmlHelper.cs` around lines 176 - 194, The code in
EscapeXmlWhenNeeded has an unreachable else branch after
RemoveInvalidXmlIfNeeded: since startIndex >= 0 guarantees at least one
XML-escape character is present, RemoveInvalidXmlIfNeeded will always increase
sb.Length, so the branch that calls EscapeXmlString(text, xmlEncodeNewlines,
startIndex, sb) when orgLength == sb.Length is dead; simplify by removing that
else branch and always calling EscapeXmlWhenNeeded(sb, orgLength,
xmlEncodeNewlines) when startIndex >= 0 (or adjust the logic to only call
RemoveInvalidXmlIfNeeded and then process the sb), cleaning up the unused path
and related comments while keeping references to EscapeXmlWhenNeeded,
RemoveInvalidXmlIfNeeded and EscapeXmlString intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/NLog/Internal/XmlHelper.cs`:
- Around line 134-147: CreateValidXmlString is stripping valid UTF-16 surrogate
pairs because it checks characters one-by-one with XmlConvertIsXmlChar; change
the loop in CreateValidXmlString to detect high-surrogate + low-surrogate pairs
(use char.IsHighSurrogate(text[i]) && i + 1 < text.Length &&
char.IsLowSurrogate(text[i+1])), compute the Unicode scalar with
char.ConvertToUtf32(text, i) and call XmlConvertIsXmlChar on that codepoint, and
if valid append both chars to sb and advance i by one; otherwise fall back to
the existing single-char XmlConvertIsXmlChar check and append. Ensure you
reference the variables/text parameters used in the method (text, startIndex,
sb) and update only the loop logic to preserve existing builder behavior.
- Around line 85-99: RemoveInvalidXmlIfNeeded and CreateValidXmlString currently
drop valid surrogate pairs because they call XmlConvertIsXmlChar per-char;
update both methods to detect high-surrogate/low-surrogate pairs while iterating
(use Char.IsHighSurrogate/Char.IsLowSurrogate semantics) and, when a high
surrogate is followed by a low surrogate, treat the pair as a single valid unit
(append both to the StringBuilder or skip advancing appropriately) instead of
discarding them; ensure the loop in RemoveInvalidXmlIfNeeded checks the next
character before delegating to CreateValidXmlString and that
CreateValidXmlString appends the full surrogate pair when valid, advancing the
index by 2 in that case so emoji and supplementary Unicode are preserved for
callers like EscapeXmlWhenNeeded, EscapeCDataWhenNeeded and EscapeCData.

---

Nitpick comments:
In `@src/NLog/Internal/XmlHelper.cs`:
- Around line 176-194: The code in EscapeXmlWhenNeeded has an unreachable else
branch after RemoveInvalidXmlIfNeeded: since startIndex >= 0 guarantees at least
one XML-escape character is present, RemoveInvalidXmlIfNeeded will always
increase sb.Length, so the branch that calls EscapeXmlString(text,
xmlEncodeNewlines, startIndex, sb) when orgLength == sb.Length is dead; simplify
by removing that else branch and always calling EscapeXmlWhenNeeded(sb,
orgLength, xmlEncodeNewlines) when startIndex >= 0 (or adjust the logic to only
call RemoveInvalidXmlIfNeeded and then process the sb), cleaning up the unused
path and related comments while keeping references to EscapeXmlWhenNeeded,
RemoveInvalidXmlIfNeeded and EscapeXmlString intact.

In `@tests/NLog.UnitTests/LayoutRenderers/Wrappers/XmlEncodeTests.cs`:
- Around line 55-112: Add a new test (or extend XmlEncodeBadStringTest1) that
includes valid surrogate pairs to ensure RemoveInvalidXmlIfNeeded preserves
them: create a string containing known valid surrogate pair(s) (e.g.,
"\ud83d\ude00" or other emoji), push it into the ScopeContext property "foo" and
render with SimpleLayout using "${xml-encode:${scopeproperty:foo}}" and
"${xml-encode:${scopeproperty:foo}:CDataEncode=true}", then assert the output is
non-empty and that the surrogate pair characters remain valid XML characters
(use System.Xml.XmlConvert.IsXmlChar on each resulting char or verify the
original codepoint survives). Reference the existing XmlEncodeBadStringTest1,
ScopeContext.PushProperty, SimpleLayout rendering, and the
RemoveInvalidXmlIfNeeded behavior when adding the assertions.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/NLog/Internal/XmlHelper.cs (1)

176-194: The else branch (line 187) is unreachable when startIndex >= 0.

When startIndex >= 0, at least one XML escape char exists in text. Since all escape chars (<, >, &, ', ", \r, \n) are valid XML characters, RemoveInvalidXmlIfNeeded will always preserve at least one char in the builder, so orgLength != sb.Length is always true. The else branch calling EscapeXmlString directly (bypassing invalid-char removal) can never execute in this code path.

This doesn't cause a runtime issue, but it's misleading — a future reader might assume both branches are reachable and that the else path intentionally skips sanitization.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NLog/Internal/XmlHelper.cs` around lines 176 - 194, When startIndex >= 0
the branch that calls EscapeXmlString(...) is unreachable because
RemoveInvalidXmlIfNeeded(...) will always change sb.Length for inputs containing
escape chars; remove that misleading branch and simplify
EscapeXmlWhenNeeded(string,text,...) so that after creating sb and recording
orgLength you call RemoveInvalidXmlIfNeeded(text, sb) and then always invoke
EscapeXmlWhenNeeded(sb, orgLength, xmlEncodeNewlines) (dropping the
EscapeXmlString call), then return sb.ToString() or string.Empty as before;
update references to methods EscapeXmlWhenNeeded, RemoveInvalidXmlIfNeeded,
EscapeXmlString, sb, and orgLength accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/NLog/Internal/XmlHelper.cs`:
- Around line 85-99: RemoveInvalidXmlIfNeeded incorrectly iterates chars and
treats lone halves of valid surrogate pairs as invalid; change its loop to an
index-based scan like RemoveInvalidXmlChars using XmlConvertIsXmlChar and
XmlConvertIsXmlSurrogatePair so you advance by 2 when you encounter a valid
surrogate pair and by 1 otherwise, and only call CreateValidXmlString when you
find a truly invalid code point (passing the current index and builder). Ensure
the final fast-path still appends the full text to builder and returns text when
no invalid code points are found.
- Around line 134-147: CreateValidXmlString currently treats chars individually
and therefore drops valid UTF-16 surrogate pairs; update it to detect
high-surrogate/low-surrogate pairs and treat them as a single valid character:
when char.IsHighSurrogate(ch) and i+1 < text.Length and
char.IsLowSurrogate(text[i+1]) then validate the full pair (ensure
XmlConvertIsXmlChar for both or otherwise use XmlConvert.IsXmlChar with the
combined scalar), append both chars to sb and increment i to skip the low
surrogate; if a lone low surrogate or invalid pair is encountered, skip it. Keep
the existing behavior of appending into the provided builder (and returning
sb.ToString() only when builder is null). Ensure this fix covers callers
RemoveInvalidXmlIfNeeded and EscapeCData which delegate here.

---

Nitpick comments:
In `@src/NLog/Internal/XmlHelper.cs`:
- Around line 176-194: When startIndex >= 0 the branch that calls
EscapeXmlString(...) is unreachable because RemoveInvalidXmlIfNeeded(...) will
always change sb.Length for inputs containing escape chars; remove that
misleading branch and simplify EscapeXmlWhenNeeded(string,text,...) so that
after creating sb and recording orgLength you call
RemoveInvalidXmlIfNeeded(text, sb) and then always invoke
EscapeXmlWhenNeeded(sb, orgLength, xmlEncodeNewlines) (dropping the
EscapeXmlString call), then return sb.ToString() or string.Empty as before;
update references to methods EscapeXmlWhenNeeded, RemoveInvalidXmlIfNeeded,
EscapeXmlString, sb, and orgLength accordingly.

@snakefoot snakefoot force-pushed the xmlhelper_safer branch 2 times, most recently from debba41 to 2e31025 Compare February 17, 2026 20:40
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/NLog/Internal/XmlHelper.cs (2)

138-163: Recursive restart from startPos re-scans already-verified chars; pass i instead.

In the !XmlConvertIsXmlChar branch (Line 149), the recursive call passes startPos rather than i. Chars from startPos..i-1 were already confirmed to be valid XML chars that require no escaping, so re-scanning them is pure waste. Since XML special chars are single-char tokens (unlike the ]]> sliding window in EscapeCDataWhenNeeded, which legitimately needs an orgLength restart), it's safe to pass i here.

♻️ Proposed change
-            EscapeXmlWhenNeeded(builder, startPos, xmlEncodeNewlines, removedInvalidXmlChars: true);
+            EscapeXmlWhenNeeded(builder, i, xmlEncodeNewlines, removedInvalidXmlChars: true);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NLog/Internal/XmlHelper.cs` around lines 138 - 163, In
EscapeXmlWhenNeeded, the recursive restart in the branch that handles non-XML
chars incorrectly passes startPos causing previously-verified chars to be
re-scanned; change the recursive call EscapeXmlWhenNeeded(builder, startPos,
xmlEncodeNewlines, removedInvalidXmlChars: true) to pass the current index i
instead (EscapeXmlWhenNeeded(builder, i, xmlEncodeNewlines,
removedInvalidXmlChars: true)) so scanning resumes at the first unprocessed
character; locate this inside the !XmlConvertIsXmlChar branch where
RemoveInvalidXmlChars(text, builder) is called and update the parameter to i to
avoid redundant work.

165-188: Unnecessary O(n) append+truncate in the common (no-invalid-chars) path; fragile ReferenceEquals contract.

Two related points:

  1. Double-scan cost: When startIndex >= 0 and the text contains no invalid XML chars (by far the common case for well-formed log messages), RemoveInvalidXmlChars(text, sb) eagerly appends the full text to sb (Line 172), only for sb.Length = orgLength to immediately undo it (Line 175) before EscapeXmlString does its own O(n) scan. A cheap HasInvalidXmlChars sentinel would save the redundant allocation/copy.

  2. Fragile sentinel: Line 173 relies on RemoveInvalidXmlChars returning the exact same reference (ReferenceEquals) to signal "nothing was changed". This invariant isn't documented. If RemoveInvalidXmlChars is ever changed to return a copy even for clean input, the logic silently falls into the wrong branch. A brief inline comment would make this safe-to-maintain.

♻️ Minimal improvement: document the ReferenceEquals contract
+            // RemoveInvalidXmlChars returns the original reference when nothing was changed;
+            // ReferenceEquals is the sentinel for "no invalid chars found".
             var validText = RemoveInvalidXmlChars(text, sb);
             if (ReferenceEquals(validText, text))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NLog/Internal/XmlHelper.cs` around lines 165 - 188, EscapeXmlWhenNeeded
currently does an unnecessary append+truncate and relies on ReferenceEquals from
RemoveInvalidXmlChars to detect "no change"; implement an explicit check to
avoid the double O(n) work and the fragile contract: add or use a
HasInvalidXmlChars(text) sentinel (or change RemoveInvalidXmlChars to return a
bool/tuple) and in EscapeXmlWhenNeeded first check that sentinel when startIndex
>= 0 — if no invalid chars, call EscapeXmlString(text, xmlEncodeNewlines,
startIndex, sb) directly (no append/trim); if there are invalid chars, call
RemoveInvalidXmlChars(text, sb) and then EscapeXmlWhenNeeded(sb, orgLength,
xmlEncodeNewlines, removedInvalidXmlChars: true) as before; remove the
ReferenceEquals check and/or add a short comment documenting the intended
contract if you keep it.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/NLog/Internal/XmlHelper.cs`:
- Around line 138-163: In EscapeXmlWhenNeeded, the recursive restart in the
branch that handles non-XML chars incorrectly passes startPos causing
previously-verified chars to be re-scanned; change the recursive call
EscapeXmlWhenNeeded(builder, startPos, xmlEncodeNewlines,
removedInvalidXmlChars: true) to pass the current index i instead
(EscapeXmlWhenNeeded(builder, i, xmlEncodeNewlines, removedInvalidXmlChars:
true)) so scanning resumes at the first unprocessed character; locate this
inside the !XmlConvertIsXmlChar branch where RemoveInvalidXmlChars(text,
builder) is called and update the parameter to i to avoid redundant work.
- Around line 165-188: EscapeXmlWhenNeeded currently does an unnecessary
append+truncate and relies on ReferenceEquals from RemoveInvalidXmlChars to
detect "no change"; implement an explicit check to avoid the double O(n) work
and the fragile contract: add or use a HasInvalidXmlChars(text) sentinel (or
change RemoveInvalidXmlChars to return a bool/tuple) and in EscapeXmlWhenNeeded
first check that sentinel when startIndex >= 0 — if no invalid chars, call
EscapeXmlString(text, xmlEncodeNewlines, startIndex, sb) directly (no
append/trim); if there are invalid chars, call RemoveInvalidXmlChars(text, sb)
and then EscapeXmlWhenNeeded(sb, orgLength, xmlEncodeNewlines,
removedInvalidXmlChars: true) as before; remove the ReferenceEquals check and/or
add a short comment documenting the intended contract if you keep it.

@snakefoot snakefoot force-pushed the xmlhelper_safer branch 4 times, most recently from a820c08 to 5b8632a Compare February 17, 2026 21:15
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/NLog.UnitTests/LayoutRenderers/Wrappers/XmlEncodeTests.cs (1)

105-115: XmlEncodeBadStringTest1 CDATA path should also validate CDATA structure.

The assertions only check that each output char is a valid XML char — which passes even when the CDATA wrapper is completely absent (because <, !, [, C, D, A, T, > are all valid XML chars). This means the critical bug in EscapeCData (missing <![CDATA[...]]> wrapper when input contains invalid XML chars) is not caught here.

✅ Suggested additional assertions
             var goodString = l.Render(LogEventInfo.CreateNullEvent());
             Assert.NotEmpty(goodString);
+            Assert.StartsWith("<![CDATA[", goodString);
+            Assert.EndsWith("]]>", goodString);
             foreach (char c in goodString)
             {
                 Assert.True(System.Xml.XmlConvert.IsXmlChar(c), $"Invalid char {Convert.ToInt32(c)} was not removed");
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/NLog.UnitTests/LayoutRenderers/Wrappers/XmlEncodeTests.cs` around lines
105 - 115, The test XmlEncodeBadStringTest1 currently only validates that output
chars are legal XML chars; update it to also assert that when CDataEncode=true
the rendered string is wrapped in a proper CDATA section and the inner content
is the escaped value: verify the rendered string begins with "<![CDATA[" and
ends with "]]>" (using the SimpleLayout l =
"${xml-encode:${scopeproperty:foo}:CDataEncode=true}" and the rendered
goodString), and additionally assert the inner substring (between the wrappers)
does not contain the forbidden sequence "]]>" (or that any original invalid
chars were properly escaped/removed by EscapeCData). Ensure these assertions run
inside the existing ScopeContext.PushProperty block that sets "foo" to badString
so the test fails if the CDATA wrapper is missing or malformed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/NLog/Internal/XmlHelper.cs`:
- Around line 551-555: EscapeCData currently appends emptyCData ("<![CDATA[]]>")
to builder but always returns that non-empty string; change the return to follow
the existing pattern (return builder is null ? result : string.Empty) so that
when builder is provided the method returns string.Empty. Specifically in
EscapeCData update the early-exit branch that defines emptyCData and calls
builder?.Append(emptyCData) to return builder is null ? emptyCData :
string.Empty instead of always returning emptyCData.
- Around line 574-577: EscapeCData currently skips adding the <![CDATA[ ... ]]>
wrapper when RemoveInvalidXmlChars found invalid chars and appended sanitized
text to the StringBuilder (the path calling EscapeCDataWhenNeeded(builder,
orgLength, removedInvalidXmlChars: true)); update EscapeCData (or
EscapeCDataWhenNeeded) so that in the branch where builder != null and
removedInvalidXmlChars is true you insert the opening "<![CDATA[" at the
original orgLength position and append the closing "]]>" after processing
(ensuring EscapeCDataWhenNeeded still handles internal "]]>" sequences), or
alternatively make EscapeCDataWhenNeeded itself add these wrappers when
removedInvalidXmlChars==true so
XmlEncodeLayoutRendererWrapper.RenderInnerAndTransform receives a proper CDATA
section instead of bare sanitized text.

---

Nitpick comments:
In `@tests/NLog.UnitTests/LayoutRenderers/Wrappers/XmlEncodeTests.cs`:
- Around line 105-115: The test XmlEncodeBadStringTest1 currently only validates
that output chars are legal XML chars; update it to also assert that when
CDataEncode=true the rendered string is wrapped in a proper CDATA section and
the inner content is the escaped value: verify the rendered string begins with
"<![CDATA[" and ends with "]]>" (using the SimpleLayout l =
"${xml-encode:${scopeproperty:foo}:CDataEncode=true}" and the rendered
goodString), and additionally assert the inner substring (between the wrappers)
does not contain the forbidden sequence "]]>" (or that any original invalid
chars were properly escaped/removed by EscapeCData). Ensure these assertions run
inside the existing ScopeContext.PushProperty block that sets "foo" to badString
so the test fails if the CDATA wrapper is missing or malformed.

Comment on lines +551 to +555
{
var emptyCData = "<![CDATA[]]>";
builder?.Append(emptyCData);
return emptyCData;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

EscapeCData returns "<![CDATA[]]>" instead of string.Empty when builder is provided and text is empty.

Every other return path in this method follows return builder is null ? result : string.Empty, but the early-exit for empty/null text always returns the non-empty string.

🔧 Proposed fix
         if (string.IsNullOrEmpty(text))
         {
             var emptyCData = "<![CDATA[]]>";
             builder?.Append(emptyCData);
-            return emptyCData;
+            return builder is null ? emptyCData : string.Empty;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{
var emptyCData = "<![CDATA[]]>";
builder?.Append(emptyCData);
return emptyCData;
}
{
var emptyCData = "<![CDATA[]]>";
builder?.Append(emptyCData);
return builder is null ? emptyCData : string.Empty;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NLog/Internal/XmlHelper.cs` around lines 551 - 555, EscapeCData currently
appends emptyCData ("<![CDATA[]]>") to builder but always returns that non-empty
string; change the return to follow the existing pattern (return builder is null
? result : string.Empty) so that when builder is provided the method returns
string.Empty. Specifically in EscapeCData update the early-exit branch that
defines emptyCData and calls builder?.Append(emptyCData) to return builder is
null ? emptyCData : string.Empty instead of always returning emptyCData.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/NLog/Internal/XmlHelper.cs (1)

138-163: Surrogate pairs take the expensive fallback path in the StringBuilder overload.

When removedInvalidXmlChars is false (which is the case for direct callers like XmlEncodeLayoutRendererWrapper and XmlAttribute), a valid high surrogate at line 144 fails XmlConvertIsXmlChar, entering the expensive extract→truncate→RemoveInvalidXmlChars→recurse path. RemoveInvalidXmlChars handles surrogate pairs correctly so correctness is preserved, but you could add a surrogate-pair short-circuit here (as done in RemoveInvalidXmlChars at line 99) to avoid the allocation for texts containing emoji/supplementary characters.

Optional: add surrogate-pair fast path
                 var chr = builder[i];
                 if (!XmlConvertIsXmlChar(chr) && !removedInvalidXmlChars)
                 {
+                    if (i + 1 < builderLength && XmlConvertIsXmlSurrogatePair(builder[i + 1], chr))
+                    {
+                        ++i;    // valid surrogate pair — skip low surrogate
+                        continue;
+                    }
                     var text = builder.ToString(i, builderLength - i);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NLog/Internal/XmlHelper.cs` around lines 138 - 163, EscapeXmlWhenNeeded
currently treats a valid high surrogate as invalid (calling the expensive
extract→RemoveInvalidXmlChars→recurse path) because XmlConvertIsXmlChar returns
false for lone surrogates; update EscapeXmlWhenNeeded to short‑circuit when
encountering a high surrogate followed by a low surrogate by detecting
char.IsHighSurrogate(chr) && i+1 < builderLength &&
char.IsLowSurrogate(builder[i+1]) and simply advance i past the pair (continue
loop) so surrogate pairs are handled cheaply; keep the existing behavior for
other invalid chars and still call RemoveInvalidXmlChars/ EscapeXmlString when
necessary, referencing the EscapeXmlWhenNeeded, XmlConvertIsXmlChar,
RemoveInvalidXmlChars and RequiresXmlEscape symbols.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/NLog/Internal/XmlHelper.cs`:
- Around line 548-555: The EscapeCData method returns "<![CDATA[]]>" for
empty/null input even when a StringBuilder is supplied, breaking the established
convention of returning the result only when builder is null; change the
early-empty branch in EscapeCData so it appends emptyCData to builder (if
non-null) but returns string.Empty when builder is provided, and returns the
emptyCData string only when builder is null — this makes EscapeCData's return
behavior consistent with other paths and callers like
XmlEncodeLayoutRendererWrapper.

---

Nitpick comments:
In `@src/NLog/Internal/XmlHelper.cs`:
- Around line 138-163: EscapeXmlWhenNeeded currently treats a valid high
surrogate as invalid (calling the expensive
extract→RemoveInvalidXmlChars→recurse path) because XmlConvertIsXmlChar returns
false for lone surrogates; update EscapeXmlWhenNeeded to short‑circuit when
encountering a high surrogate followed by a low surrogate by detecting
char.IsHighSurrogate(chr) && i+1 < builderLength &&
char.IsLowSurrogate(builder[i+1]) and simply advance i past the pair (continue
loop) so surrogate pairs are handled cheaply; keep the existing behavior for
other invalid chars and still call RemoveInvalidXmlChars/ EscapeXmlString when
necessary, referencing the EscapeXmlWhenNeeded, XmlConvertIsXmlChar,
RemoveInvalidXmlChars and RequiresXmlEscape symbols.

@sonarqubecloud
Copy link

@sonarqubecloud
Copy link

@snakefoot snakefoot merged commit ab355c7 into NLog:dev Feb 18, 2026
6 of 7 checks passed
@snakefoot snakefoot added this to the 6.1.1 milestone Feb 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant