diff --git a/src/Runner.Worker/Dap/YamlScalarFormatter.cs b/src/Runner.Worker/Dap/YamlScalarFormatter.cs new file mode 100644 index 000000000..15fa3f380 --- /dev/null +++ b/src/Runner.Worker/Dap/YamlScalarFormatter.cs @@ -0,0 +1,63 @@ +using System; +using System.Globalization; +using System.IO; +using GitHub.Runner.Sdk; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; + +namespace GitHub.Runner.Worker.Dap +{ + /// + /// Formats a single string as a quote-safe YAML scalar by routing it + /// through YamlDotNet's . The returned text is + /// safe to splice into a hand-emitted YAML document fragment. + /// + /// Caller responsibility: this only handles the scalar value; it does + /// not emit a key, indent, or trailing newline. + /// + internal static class YamlScalarFormatter + { + /// + /// Return formatted as a YAML scalar: + /// plain, single-quoted, or double-quoted as the emitter chooses, + /// with no surrounding document markers or trailing newline. + /// + public static string Format(string value) + { + ArgUtil.NotNull(value, nameof(value)); + + using var sw = new StringWriter(CultureInfo.InvariantCulture); + // Force LF line breaks; YamlDotNet's Emitter calls WriteLine, + // which would otherwise produce CRLF on Windows and break + // both our document-end stripping below and downstream + // consumers that assume a single line-break convention. + sw.NewLine = "\n"; + var emitter = new Emitter(sw); + emitter.Emit(new StreamStart()); + emitter.Emit(new DocumentStart(null, null, true)); + emitter.Emit(new Scalar(null, null, value, ScalarStyle.Any, true, true)); + emitter.Emit(new DocumentEnd(true)); + emitter.Emit(new StreamEnd()); + + string raw = sw.ToString(); + // Strip YAML document markers. Emitter elides these for most + // scalars but emits "--- " (with space) for some edge cases + // (e.g. empty strings). Defensively handle "---\n" too. + if (raw.StartsWith("--- ", StringComparison.Ordinal)) + { + raw = raw.Substring(4); + } + else if (raw.StartsWith("---\n", StringComparison.Ordinal)) + { + raw = raw.Substring(4); + } + raw = raw.TrimEnd('\n'); + const string DocEndMarker = "\n..."; + if (raw.EndsWith(DocEndMarker, StringComparison.Ordinal)) + { + raw = raw.Substring(0, raw.Length - DocEndMarker.Length); + } + return raw.TrimEnd('\n'); + } + } +} diff --git a/src/Test/L0/Worker/YamlScalarFormatterL0.cs b/src/Test/L0/Worker/YamlScalarFormatterL0.cs new file mode 100644 index 000000000..e4c441035 --- /dev/null +++ b/src/Test/L0/Worker/YamlScalarFormatterL0.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using GitHub.Runner.Worker.Dap; +using Xunit; +using YamlDotNet.Serialization; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class YamlScalarFormatterL0 + { + private static readonly IDeserializer Deserializer = new DeserializerBuilder().Build(); + + // Embed the formatter output inside a minimal YAML mapping and + // round-trip through YamlDotNet, asserting the parsed value equals + // the original input. Decouples assertions from the emitter's + // quoting choices (plain vs single- vs double-quoted). + private static void AssertRoundTrips(string value) + { + string scalar = YamlScalarFormatter.Format(value); + string yaml = $"k: {scalar}\n"; + + Dictionary doc; + try + { + doc = Deserializer.Deserialize>(yaml); + } + catch (Exception ex) + { + throw new Xunit.Sdk.XunitException( + $"Formatted scalar did not round-trip as valid YAML.\nInput: '{value}'\nFormatted: '{scalar}'\nFull YAML:\n{yaml}\nError: {ex.Message}"); + } + Assert.NotNull(doc); + Assert.True(doc.ContainsKey("k"), $"missing key in parsed doc. Formatted: '{scalar}'"); + Assert.Equal(value, doc["k"] as string); + } + + [Theory] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + [InlineData("hello")] + [InlineData("with: colon")] + [InlineData("with#hash")] + [InlineData(" leading")] + [InlineData("trailing ")] + [InlineData("a\"b")] + [InlineData("a\\b")] + [InlineData("@at")] + [InlineData("*star")] + [InlineData("&")] + [InlineData("?question")] + [InlineData("!exclaim")] + [InlineData("- dash")] + [InlineData("{brace}")] + [InlineData("[bracket]")] + public void Format_RoundTripsThroughYamlDeserializer(string value) + { + // The formatter must produce output that, embedded under a key, + // parses back to exactly the input. The emitter is free to + // pick plain, single-quoted, or double-quoted style. + AssertRoundTrips(value); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Format_PlainAscii_NoQuotingNeeded() + { + // Sanity check that the simple case stays plain. + Assert.Equal("hello", YamlScalarFormatter.Format("hello")); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Format_NoTrailingNewline() + { + Assert.False(YamlScalarFormatter.Format("hello").EndsWith("\n")); + Assert.False(YamlScalarFormatter.Format("with: colon").EndsWith("\n")); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Format_NoDocumentMarkers() + { + // The emitter wraps the scalar in a document; the formatter + // must strip both `--- ` (with space) and `---\n` (on its + // own line) prefixes plus the `\n...` suffix. + Assert.DoesNotContain("---", YamlScalarFormatter.Format("hello")); + Assert.DoesNotContain("...", YamlScalarFormatter.Format("hello")); + // Empty string is one of the cases where the emitter does + // produce a document marker by default. + Assert.DoesNotContain("---", YamlScalarFormatter.Format("")); + Assert.DoesNotContain("...", YamlScalarFormatter.Format("")); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Format_AlwaysUsesLfLineBreaks() + { + // Regression: YamlDotNet's Emitter calls WriteLine, which on + // Windows produces CRLF (the host's Environment.NewLine). + // Format must force LF so the output round-trips regardless + // of platform. + Assert.DoesNotContain('\r', YamlScalarFormatter.Format("hello")); + Assert.DoesNotContain('\r', YamlScalarFormatter.Format("with: colon")); + Assert.DoesNotContain('\r', YamlScalarFormatter.Format("")); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Format_NullValue_Throws() + { + Assert.Throws(() => YamlScalarFormatter.Format(null)); + } + } +}