using System; using System.Collections.Generic; using System.IO; using System.Runtime.CompilerServices; using GitHub.DistributedTask.Pipelines.ContextData; using GitHub.DistributedTask.WebApi; using GitHub.Runner.Worker; using GitHub.Runner.Worker.Container; using Moq; using Xunit; using Pipelines = GitHub.DistributedTask.Pipelines; namespace GitHub.Runner.Common.Tests.Worker { public sealed class ActionCommandManagerL0 { private ActionCommandManager _commandManager; private Mock _ec; private Mock _extensionManager; private Mock _pipelineDirectoryManager; [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] public void EnablePluginInternalCommand() { using (TestHostContext hc = CreateTestContext()) { _ec.Setup(x => x.Write(It.IsAny(), It.IsAny())) .Returns((string tag, string line) => { hc.GetTrace().Info($"{tag} {line}"); return 1; }); _ec.Setup(x => x.AddIssue(It.IsAny(), It.IsAny())) .Callback((Issue issue, string message) => { hc.GetTrace().Info($"{issue.Type} {issue.Message} {message ?? string.Empty}"); }); _commandManager.EnablePluginInternalCommand(); Assert.True(_commandManager.TryProcessCommand(_ec.Object, "##[internal-set-repo-path repoFullName=actions/runner;workspaceRepo=true]somepath", null)); _pipelineDirectoryManager.Verify(x => x.UpdateRepositoryDirectory(_ec.Object, "actions/runner", "somepath", true), Times.Once); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] public void DisablePluginInternalCommand() { using (TestHostContext hc = CreateTestContext()) { _ec.Setup(x => x.Write(It.IsAny(), It.IsAny())) .Returns((string tag, string line) => { hc.GetTrace().Info($"{tag} {line}"); return 1; }); _ec.Setup(x => x.AddIssue(It.IsAny(), It.IsAny())) .Callback((Issue issue, string message) => { hc.GetTrace().Info($"{issue.Type} {issue.Message} {message ?? string.Empty}"); }); _commandManager.EnablePluginInternalCommand(); Assert.True(_commandManager.TryProcessCommand(_ec.Object, "##[internal-set-repo-path repoFullName=actions/runner;workspaceRepo=true]somepath", null)); _commandManager.DisablePluginInternalCommand(); Assert.False(_commandManager.TryProcessCommand(_ec.Object, "##[internal-set-repo-path repoFullName=actions/runner;workspaceRepo=true]somepath", null)); _pipelineDirectoryManager.Verify(x => x.UpdateRepositoryDirectory(_ec.Object, "actions/runner", "somepath", true), Times.Once); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] public void StopProcessCommand() { using (TestHostContext hc = CreateTestContext()) { _ec.Setup(x => x.ExpressionValues).Returns(GetExpressionValues()); _ec.Setup(x => x.Write(It.IsAny(), It.IsAny())) .Returns((string tag, string line) => { hc.GetTrace().Info($"{tag} {line}"); return 1; }); _ec.Setup(x => x.AddIssue(It.IsAny(), It.IsAny())) .Callback((Issue issue, string message) => { hc.GetTrace().Info($"{issue.Type} {issue.Message} {message ?? string.Empty}"); }); _ec.Object.Global.EnvironmentVariables = new Dictionary(); Assert.True(_commandManager.TryProcessCommand(_ec.Object, "##[stop-commands]stopToken", null)); Assert.False(_commandManager.TryProcessCommand(_ec.Object, "##[set-env name=foo]bar", null)); Assert.True(_commandManager.TryProcessCommand(_ec.Object, "##[stopToken]", null)); Assert.True(_commandManager.TryProcessCommand(_ec.Object, "##[set-env name=foo]bar", null)); } } [Theory] [InlineData("stop-commands", "1")] [InlineData("", "1")] [InlineData("set-env", "1")] [InlineData("stop-commands", "true")] [InlineData("", "true")] [InlineData("set-env", "true")] [Trait("Level", "L0")] [Trait("Category", "Worker")] public void StopProcessCommand__AllowsInvalidStopTokens__IfEnvVarIsSet(string invalidToken, string allowUnsupportedStopCommandTokens) { using (TestHostContext hc = CreateTestContext()) { _ec.Object.Global.EnvironmentVariables = new Dictionary(); var expressionValues = new DictionaryContextData { ["env"] = #if OS_WINDOWS new DictionaryContextData{ { Constants.Variables.Actions.AllowUnsupportedStopCommandTokens, new StringContextData(allowUnsupportedStopCommandTokens) }} #else new CaseSensitiveDictionaryContextData { { Constants.Variables.Actions.AllowUnsupportedStopCommandTokens, new StringContextData(allowUnsupportedStopCommandTokens) } } #endif }; _ec.Setup(x => x.ExpressionValues).Returns(expressionValues); _ec.Setup(x => x.JobTelemetry).Returns(new List()); Assert.True(_commandManager.TryProcessCommand(_ec.Object, $"::stop-commands::{invalidToken}", null)); } } [Theory] [InlineData("stop-commands")] [InlineData("")] [InlineData("set-env")] [Trait("Level", "L0")] [Trait("Category", "Worker")] public void StopProcessCommand__FailOnInvalidStopTokens(string invalidToken) { using (TestHostContext hc = CreateTestContext()) { _ec.Object.Global.EnvironmentVariables = new Dictionary(); _ec.Setup(x => x.ExpressionValues).Returns(GetExpressionValues()); _ec.Setup(x => x.JobTelemetry).Returns(new List()); Assert.Throws(() => _commandManager.TryProcessCommand(_ec.Object, $"::stop-commands::{invalidToken}", null)); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] public void StopProcessCommandAcceptsValidToken() { var validToken = "randomToken"; using (TestHostContext hc = CreateTestContext()) { _ec.Setup(x => x.ExpressionValues).Returns(GetExpressionValues()); Assert.True(_commandManager.TryProcessCommand(_ec.Object, $"::stop-commands::{validToken}", null)); Assert.False(_commandManager.TryProcessCommand(_ec.Object, "##[set-env name=foo]bar", null)); Assert.True(_commandManager.TryProcessCommand(_ec.Object, $"::{validToken}::", null)); Assert.True(_commandManager.TryProcessCommand(_ec.Object, "##[set-env name=foo]bar", null)); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] public void StopProcessCommandMasksValidTokenForEntireRun() { var validToken = "randomToken"; using (TestHostContext hc = CreateTestContext()) { _ec.Setup(x => x.ExpressionValues).Returns(GetExpressionValues()); Assert.True(_commandManager.TryProcessCommand(_ec.Object, $"::stop-commands::{validToken}", null)); Assert.False(_commandManager.TryProcessCommand(_ec.Object, "##[set-env name=foo]bar", null)); Assert.Equal("***", hc.SecretMasker.MaskSecrets(validToken)); Assert.True(_commandManager.TryProcessCommand(_ec.Object, $"::{validToken}::", null)); Assert.True(_commandManager.TryProcessCommand(_ec.Object, "##[set-env name=foo]bar", null)); Assert.Equal("***", hc.SecretMasker.MaskSecrets(validToken)); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] public void EchoProcessCommand() { using (TestHostContext hc = CreateTestContext()) { _ec.Setup(x => x.Write(It.IsAny(), It.IsAny())) .Returns((string tag, string line) => { hc.GetTrace().Info($"{tag} {line}"); return 1; }); Assert.False(_ec.Object.EchoOnActionCommand); Assert.True(_commandManager.TryProcessCommand(_ec.Object, "::echo::on", null)); Assert.True(_ec.Object.EchoOnActionCommand); Assert.True(_commandManager.TryProcessCommand(_ec.Object, "::echo::off", null)); Assert.False(_ec.Object.EchoOnActionCommand); Assert.True(_commandManager.TryProcessCommand(_ec.Object, "::echo::ON", null)); Assert.True(_ec.Object.EchoOnActionCommand); Assert.True(_commandManager.TryProcessCommand(_ec.Object, "::echo::Off ", null)); Assert.False(_ec.Object.EchoOnActionCommand); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] public void EchoProcessCommandDebugOn() { using (TestHostContext hc = CreateTestContext()) { // Set up a few things // 1. Job request message (with ACTIONS_STEP_DEBUG = true) TaskOrchestrationPlanReference plan = new TaskOrchestrationPlanReference(); TimelineReference timeline = new TimelineReference(); Guid jobId = Guid.NewGuid(); string jobName = "some job name"; var jobRequest = new Pipelines.AgentJobRequestMessage(plan, timeline, jobId, jobName, jobName, null, null, null, new Dictionary(), new List(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List(), null, null, null, null); jobRequest.Resources.Repositories.Add(new Pipelines.RepositoryResource() { Alias = Pipelines.PipelineConstants.SelfAlias, Id = "github", Version = "sha1" }); jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData(); jobRequest.Variables["ACTIONS_STEP_DEBUG"] = "true"; // Some service dependencies var jobServerQueue = new Mock(); jobServerQueue.Setup(x => x.QueueTimelineRecordUpdate(It.IsAny(), It.IsAny())); hc.SetSingleton(jobServerQueue.Object); var configurationStore = new Mock(); configurationStore.Setup(x => x.GetSettings()).Returns(new RunnerSettings()); hc.SetSingleton(configurationStore.Object); var pagingLogger = new Mock(); hc.EnqueueInstance(pagingLogger.Object); // Initialize the job (to exercise logic that sets EchoOnActionCommand) var ec = new Runner.Worker.ExecutionContext(); ec.Initialize(hc); ec.InitializeJob(jobRequest, System.Threading.CancellationToken.None); ec.Complete(); Assert.True(ec.EchoOnActionCommand); Assert.True(_commandManager.TryProcessCommand(ec, "::echo::off", null)); Assert.False(ec.EchoOnActionCommand); Assert.True(_commandManager.TryProcessCommand(ec, "::echo::on", null)); Assert.True(ec.EchoOnActionCommand); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] public void IssueCommandInvalidColumns() { using (TestHostContext hc = CreateTestContext()) { _ec.Setup(x => x.Write(It.IsAny(), It.IsAny())) .Returns((string tag, string line) => { hc.GetTrace().Info($"{tag} {line}"); return 1; }); var registeredCommands = new HashSet(new string[1] { "warning" }); ActionCommand command; // Columns when lines are different ActionCommand.TryParseV2("::warning line=1,endLine=2,col=1,endColumn=2::this is a warning", registeredCommands, out command); Assert.Equal("1", command.Properties["col"]); IssueCommandExtension.ValidateLinesAndColumns(command, _ec.Object); Assert.False(command.Properties.ContainsKey("col")); // No lines with columns ActionCommand.TryParseV2("::warning col=1,endColumn=2::this is a warning", registeredCommands, out command); Assert.Equal("1", command.Properties["col"]); Assert.Equal("2", command.Properties["endColumn"]); IssueCommandExtension.ValidateLinesAndColumns(command, _ec.Object); Assert.False(command.Properties.ContainsKey("col")); Assert.False(command.Properties.ContainsKey("endColumn")); // No line with endLine ActionCommand.TryParseV2("::warning endLine=1::this is a warning", registeredCommands, out command); Assert.Equal("1", command.Properties["endLine"]); IssueCommandExtension.ValidateLinesAndColumns(command, _ec.Object); Assert.Equal(command.Properties["endLine"], command.Properties["line"]); // No column with endColumn ActionCommand.TryParseV2("::warning line=1,endColumn=2::this is a warning", registeredCommands, out command); Assert.Equal("2", command.Properties["endColumn"]); IssueCommandExtension.ValidateLinesAndColumns(command, _ec.Object); Assert.Equal(command.Properties["endColumn"], command.Properties["col"]); // Empty Strings ActionCommand.TryParseV2("::warning line=,endLine=3::this is a warning", registeredCommands, out command); IssueCommandExtension.ValidateLinesAndColumns(command, _ec.Object); Assert.Equal(command.Properties["line"], command.Properties["endLine"]); // Nonsensical line values ActionCommand.TryParseV2("::warning line=4,endLine=3::this is a warning", registeredCommands, out command); IssueCommandExtension.ValidateLinesAndColumns(command, _ec.Object); Assert.False(command.Properties.ContainsKey("line")); Assert.False(command.Properties.ContainsKey("endLine")); /// Nonsensical column values ActionCommand.TryParseV2("::warning line=1,endLine=1,col=3,endColumn=2::this is a warning", registeredCommands, out command); IssueCommandExtension.ValidateLinesAndColumns(command, _ec.Object); Assert.False(command.Properties.ContainsKey("col")); Assert.False(command.Properties.ContainsKey("endColumn")); // Valid ActionCommand.TryParseV2("::warning line=1,endLine=1,col=1,endColumn=2::this is a warning", registeredCommands, out command); IssueCommandExtension.ValidateLinesAndColumns(command, _ec.Object); Assert.Equal("1", command.Properties["line"]); Assert.Equal("1", command.Properties["endLine"]); Assert.Equal("1", command.Properties["col"]); Assert.Equal("2", command.Properties["endColumn"]); // Backwards compatibility ActionCommand.TryParseV2("::warning line=1,col=1,file=test.txt::this is a warning", registeredCommands, out command); IssueCommandExtension.ValidateLinesAndColumns(command, _ec.Object); Assert.Equal("1", command.Properties["line"]); Assert.False(command.Properties.ContainsKey("endLine")); Assert.Equal("1", command.Properties["col"]); Assert.False(command.Properties.ContainsKey("endColumn")); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] public void EchoProcessCommandInvalid() { using (TestHostContext hc = CreateTestContext()) { _ec.Setup(x => x.Write(It.IsAny(), It.IsAny())) .Returns((string tag, string line) => { hc.GetTrace().Info($"{tag} {line}"); return 1; }); // Echo commands below are considered "processed", but are invalid // 1. Invalid echo value Assert.True(_commandManager.TryProcessCommand(_ec.Object, "::echo::invalid", null)); Assert.Equal(TaskResult.Failed, _ec.Object.CommandResult); Assert.False(_ec.Object.EchoOnActionCommand); // 2. No value Assert.True(_commandManager.TryProcessCommand(_ec.Object, "::echo::", null)); Assert.Equal(TaskResult.Failed, _ec.Object.CommandResult); Assert.False(_ec.Object.EchoOnActionCommand); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] public void AddMatcherTranslatesFilePath() { using (TestHostContext hc = CreateTestContext()) { // Create a problem matcher config file var hostDirectory = hc.GetDirectory(WellKnownDirectory.Temp); var hostFile = Path.Combine(hostDirectory, "my-matcher.json"); Directory.CreateDirectory(hostDirectory); var content = @" { ""problemMatcher"": [ { ""owner"": ""my-matcher"", ""pattern"": [ { ""regexp"": ""^ERROR: (.+)$"", ""message"": 1 } ] } ] }"; File.WriteAllText(hostFile, content); // Setup translation info var container = new ContainerInfo(); var containerDirectory = "/some-container-directory"; var containerFile = Path.Combine(containerDirectory, "my-matcher.json"); container.AddPathTranslateMapping(hostDirectory, containerDirectory); // Act _commandManager.TryProcessCommand(_ec.Object, $"::add-matcher::{containerFile}", container); // Assert _ec.Verify(x => x.AddMatchers(It.IsAny()), Times.Once); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] public void AddMaskWithMultilineValue() { using (TestHostContext hc = CreateTestContext()) { // Act _commandManager.TryProcessCommand(_ec.Object, $"::add-mask::abc%0Ddef%0Aghi%0D%0Ajkl", null); _commandManager.TryProcessCommand(_ec.Object, $"::add-mask:: %0D %0A %0D%0A %0D", null); // Assert Assert.Equal("***", hc.SecretMasker.MaskSecrets("abc")); Assert.Equal("***", hc.SecretMasker.MaskSecrets("def")); Assert.Equal("***", hc.SecretMasker.MaskSecrets("ghi")); Assert.Equal("***", hc.SecretMasker.MaskSecrets("jkl")); Assert.Equal("***", hc.SecretMasker.MaskSecrets("abc\rdef\nghi\r\njkl")); Assert.Equal("", hc.SecretMasker.MaskSecrets("")); Assert.Equal(" ", hc.SecretMasker.MaskSecrets(" ")); Assert.Equal(" ", hc.SecretMasker.MaskSecrets(" ")); Assert.Equal(" ", hc.SecretMasker.MaskSecrets(" ")); Assert.Equal(" ", hc.SecretMasker.MaskSecrets(" ")); } } private TestHostContext CreateTestContext([CallerMemberName] string testName = "") { var hostContext = new TestHostContext(this, testName); // Mock extension manager _extensionManager = new Mock(); var commands = new IActionCommandExtension[] { new AddMatcherCommandExtension(), new EchoCommandExtension(), new InternalPluginSetRepoPathCommandExtension(), new SetEnvCommandExtension(), new WarningCommandExtension(), new AddMaskCommandExtension(), }; foreach (var command in commands) { command.Initialize(hostContext); } _extensionManager.Setup(x => x.GetExtensions()) .Returns(new List(commands)); hostContext.SetSingleton(_extensionManager.Object); // Mock pipeline directory manager _pipelineDirectoryManager = new Mock(); hostContext.SetSingleton(_pipelineDirectoryManager.Object); // Execution context _ec = new Mock(); _ec.SetupAllProperties(); _ec.Setup(x => x.Global).Returns(new GlobalContext()); _ec.Object.Global.Variables = new Variables( hostContext, new Dictionary() ); // Command manager _commandManager = new ActionCommandManager(); _commandManager.Initialize(hostContext); return hostContext; } private DictionaryContextData GetExpressionValues() { return new DictionaryContextData { ["env"] = #if OS_WINDOWS new DictionaryContextData() #else new CaseSensitiveDictionaryContextData() #endif }; } } }