using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Runtime.CompilerServices; using GitHub.Runner.Common.Util; using GitHub.Runner.Sdk; using GitHub.Runner.Worker; using Moq; using Xunit; using DTWebApi = GitHub.DistributedTask.WebApi; namespace GitHub.Runner.Common.Tests.Worker { public abstract class FileCommandTestBase where T : IFileCommandExtension, new() { protected void TestDirectoryNotFound() { using (var hostContext = Setup()) { var stateFile = Path.Combine(_rootDirectory, "directory-not-found", "env"); _fileCmdExtension.ProcessCommand(_executionContext.Object, stateFile, null); Assert.Equal(0, _issues.Count); Assert.Equal(0, _store.Count); } } protected void TestNotFound() { using (var hostContext = Setup()) { var stateFile = Path.Combine(_rootDirectory, "file-not-found"); _fileCmdExtension.ProcessCommand(_executionContext.Object, stateFile, null); Assert.Equal(0, _issues.Count); Assert.Equal(0, _store.Count); } } protected void TestEmptyFile() { using (var hostContext = Setup()) { var stateFile = Path.Combine(_rootDirectory, "empty-file"); var content = new List(); TestUtil.WriteContent(stateFile, content); _fileCmdExtension.ProcessCommand(_executionContext.Object, stateFile, null); Assert.Equal(0, _issues.Count); Assert.Equal(0, _store.Count); } } protected void TestSimple() { using (var hostContext = Setup()) { var stateFile = Path.Combine(_rootDirectory, "simple"); var content = new List { "MY_KEY=MY VALUE", }; TestUtil.WriteContent(stateFile, content); _fileCmdExtension.ProcessCommand(_executionContext.Object, stateFile, null); Assert.Equal(0, _issues.Count); Assert.Equal(1, _store.Count); Assert.Equal("MY VALUE", _store["MY_KEY"]); } } protected void TestSimple_SkipEmptyLines() { using (var hostContext = Setup()) { var stateFile = Path.Combine(_rootDirectory, "simple"); var content = new List { string.Empty, "MY_KEY=my value", string.Empty, "MY_KEY_2=my second value", string.Empty, }; TestUtil.WriteContent(stateFile, content); _fileCmdExtension.ProcessCommand(_executionContext.Object, stateFile, null); Assert.Equal(0, _issues.Count); Assert.Equal(2, _store.Count); Assert.Equal("my value", _store["MY_KEY"]); Assert.Equal("my second value", _store["MY_KEY_2"]); } } protected void TestSimple_EmptyValue() { using (var hostContext = Setup()) { var stateFile = Path.Combine(_rootDirectory, "simple-empty-value"); var content = new List { "MY_KEY=", }; TestUtil.WriteContent(stateFile, content); _fileCmdExtension.ProcessCommand(_executionContext.Object, stateFile, null); Assert.Equal(0, _issues.Count); Assert.Equal(1, _store.Count); Assert.Equal(string.Empty, _store["MY_KEY"]); } } protected void TestSimple_MultipleValues() { using (var hostContext = Setup()) { var stateFile = Path.Combine(_rootDirectory, "simple"); var content = new List { "MY_KEY=my value", "MY_KEY_2=", "MY_KEY_3=my third value", }; TestUtil.WriteContent(stateFile, content); _fileCmdExtension.ProcessCommand(_executionContext.Object, stateFile, null); Assert.Equal(0, _issues.Count); Assert.Equal(3, _store.Count); Assert.Equal("my value", _store["MY_KEY"]); Assert.Equal(string.Empty, _store["MY_KEY_2"]); Assert.Equal("my third value", _store["MY_KEY_3"]); } } protected void TestSimple_SpecialCharacters() { using (var hostContext = Setup()) { var stateFile = Path.Combine(_rootDirectory, "simple"); var content = new List { "MY_KEY==abc", "MY_KEY_2=def=ghi", "MY_KEY_3=jkl=", }; TestUtil.WriteContent(stateFile, content); _fileCmdExtension.ProcessCommand(_executionContext.Object, stateFile, null); Assert.Equal(0, _issues.Count); Assert.Equal(3, _store.Count); Assert.Equal("=abc", _store["MY_KEY"]); Assert.Equal("def=ghi", _store["MY_KEY_2"]); Assert.Equal("jkl=", _store["MY_KEY_3"]); } } protected void TestHeredoc() { using (var hostContext = Setup()) { var stateFile = Path.Combine(_rootDirectory, "heredoc"); var content = new List { "MY_KEY< { "MY_KEY< { string.Empty, "MY_KEY< { "MY_KEY_1< { $"MY_KEY_1<<{eof}", $"hello", $"one", $"{eof}", $"MY_KEY_2<<{eof}", $"hello=two", $"{eof}", $"MY_KEY_3<<{eof}", $" {eof}", $"{eof}", $"MY_KEY_4<<{eof}", $"{eof} {eof}", $"{eof}", }; TestUtil.WriteContent(stateFile, content); _fileCmdExtension.ProcessCommand(_executionContext.Object, stateFile, null); Assert.Equal(0, _issues.Count); Assert.Equal(4, _store.Count); Assert.Equal($"hello{BREAK}one", _store["MY_KEY_1"]); Assert.Equal($"hello=two", _store["MY_KEY_2"]); Assert.Equal($" {eof}", _store["MY_KEY_3"]); Assert.Equal($"{eof} {eof}", _store["MY_KEY_4"]); } } protected void TestHeredoc_EqualBeforeMultilineIndicator() { using var hostContext = Setup(); var stateFile = Path.Combine(_rootDirectory, "heredoc"); // Define a hypothetical injectable payload that just happens to contain the '=' character. string contrivedGitHubIssueTitle = "Issue 999: Better handling for the `=` character"; // The docs recommend using randomly-generated EOF markers. // Here's a randomly-generated base64 EOF marker that just happens to contain an '=' character. ('=' is a padding character in base64.) // see https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#multiline-strings string randomizedEOF = "khkIhPxsVA=="; var content = new List { // In a real world scenario, "%INJECT%" might instead be something like "${{ github.event.issue.title }}" $"PREFIX_%INJECT%<<{randomizedEOF}".Replace("%INJECT%", contrivedGitHubIssueTitle), "RandomDataThatJustHappensToContainAnEquals=Character", randomizedEOF, }; TestUtil.WriteContent(stateFile, content); var ex = Assert.Throws(() => _fileCmdExtension.ProcessCommand(_executionContext.Object, stateFile, null)); Assert.StartsWith("Invalid format", ex.Message); } protected void TestHeredoc_MissingNewLine() { using (var hostContext = Setup()) { var stateFile = Path.Combine(_rootDirectory, "heredoc"); string content = "MY_KEY<(() => _fileCmdExtension.ProcessCommand(_executionContext.Object, stateFile, null)); Assert.Contains("Matching delimiter not found", ex.Message); } } protected void TestHeredoc_MissingNewLineMultipleLines() { using (var hostContext = Setup()) { var stateFile = Path.Combine(_rootDirectory, "heredoc"); string multilineFragment = @"line one line two line three"; // Note that the final EOF does not appear on it's own line. string content = $"MY_KEY<(() => _fileCmdExtension.ProcessCommand(_executionContext.Object, stateFile, null)); Assert.Contains("EOF marker missing new line", ex.Message); } } protected void TestHeredoc_PreservesNewline() { using (var hostContext = Setup()) { var newline = "\n"; var stateFile = Path.Combine(_rootDirectory, "heredoc"); var content = new List { "MY_KEY<>(); var hostContext = new TestHostContext(this, name); // Trace _trace = hostContext.GetTrace(); // Directory for test data var workDirectory = hostContext.GetDirectory(WellKnownDirectory.Work); ArgUtil.NotNullOrEmpty(workDirectory, nameof(workDirectory)); Directory.CreateDirectory(workDirectory); _rootDirectory = Path.Combine(workDirectory, nameof(T)); Directory.CreateDirectory(_rootDirectory); // Execution context _executionContext = new Mock(); _executionContext.Setup(x => x.Global) .Returns(new GlobalContext { EnvironmentVariables = new Dictionary(VarUtil.EnvironmentVariableKeyComparer), WriteDebug = true, }); _executionContext.Setup(x => x.AddIssue(It.IsAny(), It.IsAny())) .Callback((DTWebApi.Issue issue, ExecutionContextLogOptions logOptions) => { var resolvedMessage = issue.Message; if (logOptions.WriteToLog && !string.IsNullOrEmpty(logOptions.LogMessageOverride)) { resolvedMessage = logOptions.LogMessageOverride; } _issues.Add(new(issue, resolvedMessage)); _trace.Info($"Issue '{issue.Type}': {resolvedMessage}"); }); _executionContext.Setup(x => x.Write(It.IsAny(), It.IsAny())) .Callback((string tag, string message) => { _trace.Info($"{tag}{message}"); }); _store = PostSetup(); _fileCmdExtension = new T(); _fileCmdExtension.Initialize(hostContext); return hostContext; } protected abstract IDictionary PostSetup(); protected static readonly string BREAK = Environment.NewLine; protected IFileCommandExtension _fileCmdExtension { get; private set; } protected Mock _executionContext { get; private set; } protected List> _issues { get; private set; } protected IDictionary _store { get; private set; } protected string _rootDirectory { get; private set; } protected ITraceWriter _trace { get; private set; } } }