Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions source/Handlebars.Test/IssueTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1270,6 +1270,49 @@ public void Issue601_DefaultInterfaceMemberPropertyIsEnumerated()
Assert.Contains($"OtherStr={data.OtherStr};", result);
}

// Issue: https://github.com/Handlebars-Net/Handlebars.Net/issues/660
// A writer-based helper used as a subexpression must hand its captured output to the
// outer helper as a plain System.String, not an opaque internal wrapper type — otherwise
// reflection-based/typed argument binders (including third-party ones this library can't
// patch) can't consume it at all.
[Fact]
public void Issue660_SubexpressionResultIsPlainString()
{
var handlebars = Handlebars.Create();
handlebars.RegisterHelper("inner", (writer, context, arguments) => writer.WriteSafeString("ab"));

object? captured = null;
handlebars.RegisterHelper("outer", (writer, context, arguments) =>
{
captured = arguments[0];
writer.Write(captured);
});

handlebars.Compile("{{outer (inner)}}")(new { });

Assert.IsType<string>(captured);
Assert.Equal("ab", captured);
}

// Mirrors the reporter's Append(string value, string append) helper: a naive binder that
// direct-casts an argument to string must not throw just because that argument came from
// a subexpression instead of template data.
[Fact]
public void Issue660_SubexpressionResultIsCastableToTypedStringParameter()
{
var handlebars = Handlebars.Create();
handlebars.RegisterHelper("inner", (writer, context, arguments) => writer.WriteSafeString("a"));
handlebars.RegisterHelper("outer", (writer, context, arguments) =>
{
string value = (string) arguments[0]!;
writer.Write(value + "b");
});

var result = handlebars.Compile("{{outer (inner)}}")(new { });

Assert.Equal("ab", result);
}

private static void RegisterStringEqualityBlockHelper(IHandlebars handlebars)
{
handlebars.RegisterHelper("StringEqualityBlockHelper", (output, options, context, arguments) =>
Expand Down
12 changes: 2 additions & 10 deletions source/Handlebars/Compiler/Translation/Expression/PartialBinder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,6 @@ 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)
Expand Down Expand Up @@ -55,8 +49,7 @@ protected override Expression VisitPartialExpression(PartialExpression pex)
bindingContext = bindingContext.Call(o => o.CreateChildContext(value, partialTemplate));
}

var partialNameObj = Arg<object>(pex.PartialName);
var partialName = Call(() => ToPartialName(partialNameObj));
var partialName = Cast<string>(pex.PartialName);
var configuration = Arg(CompilationContext.Configuration);
var isBlock = Arg(pex.IsBlock);
var indent = Arg(pex.Indent);
Expand Down Expand Up @@ -90,8 +83,7 @@ out _
bindingContext = bindingContext.Call(o => o.CreateChildContext(value, partialTemplate));
}

var partialNameObj = Arg<object>(pex.PartialName);
var partialName = Call(() => ToPartialName(partialNameObj));
var partialName = Cast<string>(pex.PartialName);
var configuration = Arg(CompilationContext.Configuration);
var isBlock = Arg(pex.IsBlock);
var indent = Arg(pex.Indent);
Expand Down
6 changes: 0 additions & 6 deletions source/Handlebars/HandlebarsExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,6 @@ public static void WriteSafeString(this in EncodedTextWriter writer, object? val
return;
}

if (value is SafeString safe)
{
writer.WriteSafeString(safe.Value);
return;
}

var current = writer.SuppressEncoding;
try
{
Expand Down
2 changes: 0 additions & 2 deletions source/Handlebars/HandlebarsUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,6 @@ public static bool IsFalsy([NotNullWhen(false)] object? value, bool includeZero)
return !b;
case string s:
return s == string.Empty;
case SafeString safe:
return safe.Value == string.Empty;
case JsonElement element:
return IsFalsyJsonElement(element, includeZero);
}
Expand Down
9 changes: 5 additions & 4 deletions source/Handlebars/Helpers/HelperExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ in Arguments arguments

descriptor.Invoke(output, options, context, arguments);

// 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(writer.ToString());
// Mark the captured output — which already has the correct encoding applied by the
// EncodedTextWriter — so it is not encoded a second time when written elsewhere, without
// wrapping it in a type that would leak into helper argument binding as something other
// than a plain string.
return SafeStrings.Mark(writer.ToString());
}
}
}
3 changes: 1 addition & 2 deletions source/Handlebars/IO/EncodedTextWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,9 @@ public void Write<T>(T? value)
case Substring substring when substring.Length == 0:
return;

case string v: Write(v, true); return;
case string v: Write(v, !SafeStrings.IsSafe(v)); 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);
Expand Down
30 changes: 30 additions & 0 deletions source/Handlebars/IO/SafeStrings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.Runtime.CompilerServices;

namespace HandlebarsDotNet.IO
{
/// <summary>
/// Tracks which <see cref="string"/> instances already went through the encoding pipeline
/// (e.g. captured output of a subexpression helper) so they are not encoded a second time
/// when written elsewhere. Marking is by object reference, not value, so it never affects
/// any string a caller didn't obtain from this exact pipeline — and critically, the marked
/// value stays a plain <see cref="string"/> the whole way through, so it round-trips safely
/// through helper argument binding, reflection-based helpers, and any other consumer that
/// only knows how to handle <see cref="string"/>.
/// </summary>
internal static class SafeStrings
{
private static readonly ConditionalWeakTable<string, object> Marked = new();
private static readonly object Sentinel = new();

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static string Mark(string value)
{
if (value.Length == 0) return value;
Marked.GetValue(value, _ => Sentinel);
return value;
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsSafe(string value) => value.Length == 0 || Marked.TryGetValue(value, out _);
}
}
17 changes: 0 additions & 17 deletions source/Handlebars/SafeString.cs

This file was deleted.

Loading