From 6b17ce45fa8959016e941856a3df23b57af8dc95 Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Sat, 20 Jun 2026 08:31:05 -0400 Subject: [PATCH] fix: preserve safe-string flag when helper output crosses subexpression boundary (issue #543) When a helper written with WriteSafeString was used as a subexpression argument, ReturnInvoke captured the output as a plain string, losing the "already-encoded" signal. The outer helper then re-encoded the content. Introduce an internal SafeString wrapper that ReturnInvoke now returns. EncodedTextWriter.Write, WriteSafeString(object), HandlebarsUtils.IsFalsy, and PartialBinder all understand SafeString, so the value flows through without double-encoding while dynamic partial lookups continue to work. Co-Authored-By: Claude Sonnet 4.6 --- .../Handlebars.Test/Issues/Issue543Tests.cs | 26 +++++++++++++++++++ .../Translation/Expression/PartialBinder.cs | 20 +++++++++----- source/Handlebars/HandlebarsExtensions.cs | 6 +++++ source/Handlebars/HandlebarsUtils.cs | 2 ++ source/Handlebars/Helpers/HelperExtensions.cs | 13 ++++++---- source/Handlebars/IO/EncodedTextWriter.cs | 1 + source/Handlebars/SafeString.cs | 17 ++++++++++++ 7 files changed, 74 insertions(+), 11 deletions(-) create mode 100644 source/Handlebars.Test/Issues/Issue543Tests.cs create mode 100644 source/Handlebars/SafeString.cs diff --git a/source/Handlebars.Test/Issues/Issue543Tests.cs b/source/Handlebars.Test/Issues/Issue543Tests.cs new file mode 100644 index 00000000..6c1ae250 --- /dev/null +++ b/source/Handlebars.Test/Issues/Issue543Tests.cs @@ -0,0 +1,26 @@ +using Xunit; + +namespace HandlebarsDotNet.Test +{ + public class Issue543Tests + { + [Fact] + public void Subexpression_WriteSafeString_NotDoubleEncoded() + { + var h = Handlebars.Create(); + h.RegisterHelper("func2", (writer, ctx, args) => writer.WriteSafeString("bold")); + h.RegisterHelper("func1", (writer, ctx, args) => writer.WriteSafeString($"{args[0]}")); + var result = h.Compile("{{func1 (func2)}}")(new { }); + Assert.Equal("bold", result); + } + + [Fact] + public void Standalone_WriteSafeString_NotEncoded() + { + var h = Handlebars.Create(); + h.RegisterHelper("func2", (writer, ctx, args) => writer.WriteSafeString("bold")); + var result = h.Compile("{{func2}}")(new { }); + Assert.Equal("bold", result); + } + } +} diff --git a/source/Handlebars/Compiler/Translation/Expression/PartialBinder.cs b/source/Handlebars/Compiler/Translation/Expression/PartialBinder.cs index 57467832..354be2ad 100644 --- a/source/Handlebars/Compiler/Translation/Expression/PartialBinder.cs +++ b/source/Handlebars/Compiler/Translation/Expression/PartialBinder.cs @@ -12,6 +12,12 @@ internal class PartialBinder : HandlebarsExpressionVisitor { private static string SpecialPartialBlockName = "@partial-block"; + private static string ToPartialName(object value) + { + if (value is SafeString safe) return safe.Value; + return (string) value; + } + private CompilationContext CompilationContext { get; } public PartialBinder(CompilationContext compilationContext) @@ -46,7 +52,8 @@ protected override Expression VisitPartialExpression(PartialExpression pex) bindingContext = bindingContext.Call(o => o.CreateChildContext(value, partialTemplate)); } - var partialName = Cast(pex.PartialName); + var partialNameObj = Arg(pex.PartialName); + var partialName = Call(() => ToPartialName(partialNameObj)); var configuration = Arg(CompilationContext.Configuration); var templateDelegate = FunctionBuilder.Compile( new [] @@ -54,8 +61,8 @@ protected override Expression VisitPartialExpression(PartialExpression pex) Call(() => InvokePartialWithFallback(partialName, bindingContext, writer, (ICompiledHandlebarsConfiguration) configuration) ).Expression - }, - CompilationContext, + }, + CompilationContext, out _ ); @@ -67,18 +74,19 @@ out _ { var bindingContext = CompilationContext.Args.BindingContext; var writer = CompilationContext.Args.EncodedWriter; - + if (pex.Argument != null || partialBlockTemplate != null) { var value = pex.Argument != null ? Arg(FunctionBuilder.Reduce(pex.Argument, CompilationContext, out _)) : bindingContext.Property(o => o.Value); - + var partialTemplate = Arg(partialBlockTemplate); bindingContext = bindingContext.Call(o => o.CreateChildContext(value, partialTemplate)); } - var partialName = Cast(pex.PartialName); + var partialNameObj = Arg(pex.PartialName); + var partialName = Call(() => ToPartialName(partialNameObj)); var configuration = Arg(CompilationContext.Configuration); return Call(() => diff --git a/source/Handlebars/HandlebarsExtensions.cs b/source/Handlebars/HandlebarsExtensions.cs index 9411b4c5..e6d00268 100644 --- a/source/Handlebars/HandlebarsExtensions.cs +++ b/source/Handlebars/HandlebarsExtensions.cs @@ -28,6 +28,12 @@ public static void WriteSafeString(this in EncodedTextWriter writer, object valu return; } + if (value is SafeString safe) + { + writer.WriteSafeString(safe.Value); + return; + } + var current = writer.SuppressEncoding; try { diff --git a/source/Handlebars/HandlebarsUtils.cs b/source/Handlebars/HandlebarsUtils.cs index db1c453f..90c15f22 100644 --- a/source/Handlebars/HandlebarsUtils.cs +++ b/source/Handlebars/HandlebarsUtils.cs @@ -32,6 +32,8 @@ public static bool IsFalsy(object value) return !b; case string s: return s == string.Empty; + case SafeString safe: + return safe.Value == string.Empty; } if (IsNumber(value)) diff --git a/source/Handlebars/Helpers/HelperExtensions.cs b/source/Handlebars/Helpers/HelperExtensions.cs index e369d986..3fb45f57 100644 --- a/source/Handlebars/Helpers/HelperExtensions.cs +++ b/source/Handlebars/Helpers/HelperExtensions.cs @@ -7,11 +7,11 @@ public static class HelperExtensions { [MethodImpl(MethodImplOptions.AggressiveInlining)] public static object ReturnInvoke( - this THelperDescriptor descriptor, - in TOptions options, - in Context context, + this THelperDescriptor descriptor, + in TOptions options, + in Context context, in Arguments arguments - ) where THelperDescriptor: class, IHelperDescriptor + ) where THelperDescriptor: class, IHelperDescriptor where TOptions : struct, IHelperOptions { var configuration = options.Frame.Configuration; @@ -20,7 +20,10 @@ in Arguments arguments descriptor.Invoke(output, options, context, arguments); - return output.ToString(); + // Return a SafeString so the captured output — which already has the correct + // encoding applied by the EncodedTextWriter — is not encoded a second time + // when it is passed as an argument to an outer helper. + return new SafeString(output.ToString()); } } } \ No newline at end of file diff --git a/source/Handlebars/IO/EncodedTextWriter.cs b/source/Handlebars/IO/EncodedTextWriter.cs index 8bb0f102..a275aaaf 100644 --- a/source/Handlebars/IO/EncodedTextWriter.cs +++ b/source/Handlebars/IO/EncodedTextWriter.cs @@ -127,6 +127,7 @@ public void Write(T value) case string v: Write(v, true); return; case StringBuilder v: Write(v, true); return; case Substring v: Write(v, true); return; + case SafeString safe: Write(safe.Value, false); return; default: WriteFormatted(value); diff --git a/source/Handlebars/SafeString.cs b/source/Handlebars/SafeString.cs new file mode 100644 index 00000000..cd5fa4c5 --- /dev/null +++ b/source/Handlebars/SafeString.cs @@ -0,0 +1,17 @@ +namespace HandlebarsDotNet +{ + /// + /// Wraps a string value that has already been HTML-encoded (or is intentionally unencoded HTML). + /// When written to an , the content is passed through without + /// additional encoding. This is the return-value counterpart of + /// . + /// + internal sealed class SafeString + { + public readonly string Value; + + public SafeString(string value) => Value = value; + + public override string ToString() => Value; + } +}