diff --git a/src/Runner.Worker/Dap/TemplateTokenYamlAdapter.cs b/src/Runner.Worker/Dap/TemplateTokenYamlAdapter.cs new file mode 100644 index 000000000..3436d9169 --- /dev/null +++ b/src/Runner.Worker/Dap/TemplateTokenYamlAdapter.cs @@ -0,0 +1,223 @@ +using System; +using System.Globalization; +using System.IO; +using GitHub.DistributedTask.ObjectTemplating; +using GitHub.DistributedTask.ObjectTemplating.Tokens; +using GitHub.Runner.Sdk; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; + +namespace GitHub.Runner.Worker.Dap +{ + /// + /// Adapts a YamlDotNet as a DT + /// so a DOM + /// can be serialized back to YAML preserving its pre-evaluation form + /// (basic ${{ }} expressions are written through verbatim). + /// + /// Used by the DAP execution view to surface user-authored step + /// parameters (env:, with:, run:, ...) without + /// any expression substitution. + /// + internal sealed class TemplateTokenYamlAdapter : IObjectWriter + { + private readonly IEmitter _emitter; + + public TemplateTokenYamlAdapter(IEmitter emitter) + { + ArgUtil.NotNull(emitter, nameof(emitter)); + _emitter = emitter; + } + + public void WriteStart() + { + _emitter.Emit(new StreamStart()); + _emitter.Emit(new DocumentStart(null, null, true)); + } + + public void WriteEnd() + { + _emitter.Emit(new DocumentEnd(true)); + _emitter.Emit(new StreamEnd()); + } + + public void WriteNull() => + _emitter.Emit(new Scalar(null, null, "null", ScalarStyle.Plain, true, false)); + + public void WriteBoolean(bool value) => + _emitter.Emit(new Scalar(null, null, value ? "true" : "false", ScalarStyle.Plain, true, false)); + + public void WriteNumber(double value) => + _emitter.Emit(new Scalar(null, null, value.ToString("R", CultureInfo.InvariantCulture), ScalarStyle.Plain, true, false)); + + public void WriteString(string value) + { + if (value == null) + { + WriteNull(); + return; + } + // Multi-line strings render as block literal so embedded + // newlines survive the YAML round trip. + var style = value.IndexOf('\n') >= 0 ? ScalarStyle.Literal : ScalarStyle.Any; + _emitter.Emit(new Scalar(null, null, value, style, true, true)); + } + + public void WriteSequenceStart() => + _emitter.Emit(new SequenceStart(null, null, true, SequenceStyle.Any)); + + public void WriteSequenceEnd() => + _emitter.Emit(new SequenceEnd()); + + public void WriteMappingStart() => + _emitter.Emit(new MappingStart(null, null, true, MappingStyle.Any)); + + public void WriteMappingEnd() => + _emitter.Emit(new MappingEnd()); + + /// + /// Serialize a TemplateToken to a YAML fragment ready to embed + /// under a parent key. Each non-empty line is prefixed by + /// spaces. Trailing newlines and + /// the YAML stream start/document markers are stripped, so the + /// caller controls line breaks. + /// + /// + /// Empty mappings render as {} and empty sequences as + /// [] via YamlDotNet's flow style fallback for empty + /// collections. + /// + internal static string Serialize(TemplateToken token, int indentSpaces) + { + if (indentSpaces < 0) + { + throw new ArgumentOutOfRangeException(nameof(indentSpaces)); + } + + using var sw = new StringWriter(CultureInfo.InvariantCulture); + // Force LF line breaks; YamlDotNet's Emitter calls WriteLine, + // which would otherwise produce CRLF on Windows and corrupt + // both the document-end stripping below and the per-line + // indentation pass that follows. + sw.NewLine = "\n"; + var emitter = new Emitter(sw); + var adapter = new TemplateTokenYamlAdapter(emitter); + adapter.WriteStart(); + WriteToken(adapter, token); + adapter.WriteEnd(); + + string raw = sw.ToString(); + // Strip YAML document markers. The Emitter most commonly elides + // these for our use (DocumentStart isImplicit=true), but emits + // them for some scalar edge cases (e.g. empty strings) and may + // emit them on their own line for collection roots under some + // settings. Strip both shapes defensively so callers never see + // a leaked marker leak into the embedded fragment. + if (raw.StartsWith("--- ", StringComparison.Ordinal)) + { + raw = raw.Substring(4); + } + else if (raw.StartsWith("---\n", StringComparison.Ordinal)) + { + raw = raw.Substring(4); + } + const string DocEndMarker = "\n..."; + if (raw.EndsWith(DocEndMarker + "\n", StringComparison.Ordinal)) + { + raw = raw.Substring(0, raw.Length - DocEndMarker.Length - 1); + } + else if (raw.EndsWith(DocEndMarker, StringComparison.Ordinal)) + { + raw = raw.Substring(0, raw.Length - DocEndMarker.Length); + } + raw = raw.TrimEnd('\n'); + + if (indentSpaces == 0) + { + return raw; + } + + // Re-indent every non-empty line. Empty lines remain empty + // so YAML block-literal blank lines stay valid. + var pad = new string(' ', indentSpaces); + var sb = new System.Text.StringBuilder(raw.Length + indentSpaces * 4); + int i = 0; + while (i < raw.Length) + { + int end = raw.IndexOf('\n', i); + int lineEnd = end < 0 ? raw.Length : end; + if (lineEnd > i) + { + sb.Append(pad); + sb.Append(raw, i, lineEnd - i); + } + if (end < 0) + { + break; + } + sb.Append('\n'); + i = end + 1; + } + return sb.ToString(); + } + + /// + /// Mirrors 's recursive walk, with one + /// behavioural change: is emitted + /// via ToDisplayString() instead of ToString(). + /// + /// + /// The workflow parser tokenizes a mixed scalar like + /// ${{ runner.os }}-primes as a single + /// whose internal expression is + /// format('{0}-primes', runner.os). ToString() emits + /// the normalized form verbatim; ToDisplayString() reverses + /// the format(...) rewrite so the user sees the original + /// authored form. Other token kinds delegate to the same writer + /// calls would make. + /// + private static void WriteToken(IObjectWriter writer, TemplateToken token) + { + switch (token?.Type ?? TokenType.Null) + { + case TokenType.Null: + writer.WriteNull(); + break; + case TokenType.Boolean: + writer.WriteBoolean(((BooleanToken)token).Value); + break; + case TokenType.Number: + writer.WriteNumber(((NumberToken)token).Value); + break; + case TokenType.String: + writer.WriteString(token.ToString()); + break; + case TokenType.BasicExpression: + writer.WriteString(((BasicExpressionToken)token).ToDisplayString()); + break; + case TokenType.InsertExpression: + writer.WriteString(token.ToString()); + break; + case TokenType.Mapping: + writer.WriteMappingStart(); + foreach (var pair in (MappingToken)token) + { + WriteToken(writer, pair.Key); + WriteToken(writer, pair.Value); + } + writer.WriteMappingEnd(); + break; + case TokenType.Sequence: + writer.WriteSequenceStart(); + foreach (var item in (SequenceToken)token) + { + WriteToken(writer, item); + } + writer.WriteSequenceEnd(); + break; + default: + throw new NotSupportedException($"Unexpected token type '{token.GetType()}'."); + } + } + } +} diff --git a/src/Test/L0/Worker/TemplateTokenYamlAdapterL0.cs b/src/Test/L0/Worker/TemplateTokenYamlAdapterL0.cs new file mode 100644 index 000000000..8406bae66 --- /dev/null +++ b/src/Test/L0/Worker/TemplateTokenYamlAdapterL0.cs @@ -0,0 +1,191 @@ +using GitHub.DistributedTask.ObjectTemplating.Tokens; +using GitHub.Runner.Worker.Dap; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class TemplateTokenYamlAdapterL0 + { + private static StringToken Str(string s) => new(null, null, null, s); + private static BooleanToken Bool(bool b) => new(null, null, null, b); + private static NumberToken Num(double n) => new(null, null, null, n); + private static BasicExpressionToken Expr(string s) => new(null, null, null, s); + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_StringScalar() + { + Assert.Equal("hello", TemplateTokenYamlAdapter.Serialize(Str("hello"), 0)); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_BooleanScalar() + { + Assert.Equal("true", TemplateTokenYamlAdapter.Serialize(Bool(true), 0)); + Assert.Equal("false", TemplateTokenYamlAdapter.Serialize(Bool(false), 0)); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_NumberScalar() + { + Assert.Equal("10", TemplateTokenYamlAdapter.Serialize(Num(10), 0)); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_NullToken_RendersAsNull() + { + Assert.Equal("null", TemplateTokenYamlAdapter.Serialize(null, 0)); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_PreservesBasicExpression() + { + var token = Expr("runner.os"); + string yaml = TemplateTokenYamlAdapter.Serialize(token, 0); + Assert.Contains("${{ runner.os }}", yaml); + Assert.DoesNotContain("Linux", yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_PreservesCompositeExpressionInStringToken() + { + // A StringToken constructed directly with the literal text + // round-trips unchanged. (The workflow parser does NOT produce + // a StringToken for this input — see + // Serialize_ReversesFormatRewriteForCompositeExpression — but + // direct StringToken construction must still preserve the + // literal verbatim.) + var token = Str("${{ runner.os }}-primes"); + string yaml = TemplateTokenYamlAdapter.Serialize(token, 0); + Assert.Contains("${{ runner.os }}-primes", yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_ReversesFormatRewriteForCompositeExpression() + { + // The workflow parser tokenizes a mixed scalar like + // `${{ runner.os }}-primes` as a single BasicExpressionToken + // whose internal expression is `format('{0}-primes', runner.os)`. + // The adapter must surface the author-facing form, not the + // parser's normalized rewrite. + var token = Expr("format('{0}-primes', runner.os)"); + string yaml = TemplateTokenYamlAdapter.Serialize(token, 0); + Assert.Contains("${{ runner.os }}-primes", yaml); + Assert.DoesNotContain("format(", yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_NestedMapping() + { + var inner = new MappingToken(null, null, null); + inner.Add(Str("b"), Num(1)); + inner.Add(Str("c"), Expr("x")); + var outer = new MappingToken(null, null, null); + outer.Add(Str("a"), inner); + + string yaml = TemplateTokenYamlAdapter.Serialize(outer, 0); + + Assert.Contains("a:", yaml); + Assert.Contains("b: 1", yaml); + Assert.Contains("c: ${{ x }}", yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_EmptyMapping() + { + var token = new MappingToken(null, null, null); + string yaml = TemplateTokenYamlAdapter.Serialize(token, 0); + Assert.Equal("{}", yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_EmptySequence() + { + var token = new SequenceToken(null, null, null); + string yaml = TemplateTokenYamlAdapter.Serialize(token, 0); + Assert.Equal("[]", yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_MultilineString_UsesBlockScalar() + { + var token = Str("line1\nline2\nline3"); + string yaml = TemplateTokenYamlAdapter.Serialize(token, 0); + // Block-literal indicator `|` appears for multi-line scalars. + Assert.Contains("|", yaml); + Assert.Contains("line1", yaml); + Assert.Contains("line2", yaml); + Assert.Contains("line3", yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_IndentLevel_PrefixesNonEmptyLines() + { + var map = new MappingToken(null, null, null); + map.Add(Str("k1"), Str("v1")); + map.Add(Str("k2"), Str("v2")); + + string yaml = TemplateTokenYamlAdapter.Serialize(map, indentSpaces: 4); + + foreach (var line in yaml.Split('\n')) + { + if (line.Length > 0) + { + Assert.StartsWith(" ", line); + } + } + Assert.Contains("k1: v1", yaml); + Assert.Contains("k2: v2", yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_NoTrailingNewline() + { + var token = Str("hello"); + string yaml = TemplateTokenYamlAdapter.Serialize(token, 0); + Assert.False(yaml.EndsWith("\n")); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_AlwaysUsesLfLineBreaks() + { + // Regression: YamlDotNet's Emitter calls WriteLine, which on + // Windows produces CRLF (the host's Environment.NewLine). + // Serialize must force LF so the rendered view round-trips + // regardless of platform. + var map = new MappingToken(null, null, null); + map.Add(Str("k1"), Str("v1")); + map.Add(Str("k2"), Num(2)); + map.Add(Str("k3"), Bool(true)); + string yaml = TemplateTokenYamlAdapter.Serialize(map, indentSpaces: 2); + Assert.DoesNotContain("\r", yaml); + } + } +}