diff --git a/Class/ParameterParser.vb b/Class/ParameterParser.vb index 779fa98..9dee7a5 100644 --- a/Class/ParameterParser.vb +++ b/Class/ParameterParser.vb @@ -1,92 +1,131 @@ -' ----------------------------------------------------------------------------- -' Copyright (c) svtica. All rights reserved. -' File: ParameterParser.vb -' Author: LiteTask contributors -' Date: 2026-04-25 -' Purpose: Parse "key=value" parameter strings used by TaskRunner. Supports -' cmd-style quoting (key="value with spaces") while remaining -' backward-compatible with the original whitespace-delimited form. -' ----------------------------------------------------------------------------- -Namespace LiteTask - Public Module ParameterParser - - ''' - ''' Parses a parameter string of the form `key=value [key2=value2 ...]`. - ''' Values may be quoted with double quotes to include spaces: - ''' key="value with spaces" - ''' Tokens without an `=` are skipped, as are `=value` fragments. - ''' Last occurrence wins for duplicate keys. - ''' - Public Function Parse(parameters As String) As Dictionary(Of String, String) - Dim result As New Dictionary(Of String, String) - If String.IsNullOrEmpty(parameters) Then Return result - - Dim i As Integer = 0 - Dim len As Integer = parameters.Length - - While i < len - ' Skip leading whitespace - While i < len AndAlso Char.IsWhiteSpace(parameters(i)) - i += 1 - End While - If i >= len Then Exit While - - ' Read key: up to '=' or whitespace - Dim keyStart As Integer = i - While i < len AndAlso parameters(i) <> "="c AndAlso Not Char.IsWhiteSpace(parameters(i)) - i += 1 - End While - - ' No '=' for this token: skip token entirely (preserves original behavior - ' where bare words without '=' were dropped). - If i >= len OrElse parameters(i) <> "="c Then - Continue While - End If - - Dim key As String = parameters.Substring(keyStart, i - keyStart) - i += 1 ' consume '=' - - ' Empty key (`=value`) is skipped to match original behavior. - If String.IsNullOrEmpty(key) Then - ' Advance past the value so we don't reparse it as a new token - SkipValue(parameters, i) - Continue While - End If - - Dim value As String = ReadValue(parameters, i) - result(key) = value - End While - - Return result - End Function - - Private Function ReadValue(s As String, ByRef i As Integer) As String - Dim len As Integer = s.Length - If i >= len Then Return String.Empty - - If s(i) = """"c Then - ' Quoted value: read until next '"' or end of string. - i += 1 - Dim valueStart As Integer = i - While i < len AndAlso s(i) <> """"c - i += 1 - End While - Dim value As String = s.Substring(valueStart, i - valueStart) - If i < len Then i += 1 ' consume closing quote - Return value - End If - - ' Unquoted value: read until whitespace. - Dim unquotedStart As Integer = i - While i < len AndAlso Not Char.IsWhiteSpace(s(i)) - i += 1 - End While - Return s.Substring(unquotedStart, i - unquotedStart) - End Function - - Private Sub SkipValue(s As String, ByRef i As Integer) - ReadValue(s, i) - End Sub - - End Module -End Namespace +' ----------------------------------------------------------------------------- +' Copyright (c) svtica. All rights reserved. +' File: ParameterParser.vb +' Author: LiteTask contributors +' Date: 2026-04-25 +' Purpose: Parse parameter strings used by TaskRunner. Supports three forms +' that can be mixed in the same string: +' key=value +' key="value with spaces" +' -Name Value (PowerShell-style) +' -Name "Value with spaces" (PowerShell-style) +' -Switch (no value -> stored as Nothing) +' ----------------------------------------------------------------------------- +Namespace LiteTask + Public Module ParameterParser + + ''' + ''' Parses a parameter string. Supports both `key=value` and + ''' PowerShell-style `-Name Value` / `-Switch` syntax in the same + ''' input. Returns an ordered dictionary mapping each parameter name + ''' to its value. A switch parameter (no value provided) is stored + ''' as Nothing so callers can distinguish it from an empty string. + ''' Tokens without an `=` and that don't start with `-Letter` are + ''' skipped to preserve the original lenient behavior. + ''' Last occurrence wins for duplicate keys. + ''' + Public Function Parse(parameters As String) As Dictionary(Of String, Object) + Dim result As New Dictionary(Of String, Object)(StringComparer.OrdinalIgnoreCase) + If String.IsNullOrEmpty(parameters) Then Return result + + Dim i As Integer = 0 + Dim len As Integer = parameters.Length + + While i < len + ' Skip leading whitespace + While i < len AndAlso Char.IsWhiteSpace(parameters(i)) + i += 1 + End While + If i >= len Then Exit While + + ' PowerShell-style: -Name [Value] (a dash followed by a letter) + If parameters(i) = "-"c AndAlso i + 1 < len AndAlso IsNameStart(parameters(i + 1)) Then + i += 1 ' consume '-' + Dim keyStart As Integer = i + While i < len AndAlso Not Char.IsWhiteSpace(parameters(i)) AndAlso parameters(i) <> "="c + i += 1 + End While + Dim psKey As String = parameters.Substring(keyStart, i - keyStart) + + ' Allow `-Name=Value` as a convenience + If i < len AndAlso parameters(i) = "="c Then + i += 1 + result(psKey) = ReadValue(parameters, i) + Continue While + End If + + ' Skip whitespace between name and value + Dim valueScan As Integer = i + While valueScan < len AndAlso Char.IsWhiteSpace(parameters(valueScan)) + valueScan += 1 + End While + + ' If next non-whitespace is another -Name, this is a switch. + If valueScan >= len OrElse + (parameters(valueScan) = "-"c AndAlso valueScan + 1 < len AndAlso IsNameStart(parameters(valueScan + 1))) Then + result(psKey) = Nothing + ' Don't consume the next token; loop will pick it up. + Else + i = valueScan + result(psKey) = ReadValue(parameters, i) + End If + Continue While + End If + + ' key=value form + Dim keyStartEq As Integer = i + While i < len AndAlso parameters(i) <> "="c AndAlso Not Char.IsWhiteSpace(parameters(i)) + i += 1 + End While + + If i >= len OrElse parameters(i) <> "="c Then + ' Bare token without '=': skip to preserve original lenient behavior. + Continue While + End If + + Dim key As String = parameters.Substring(keyStartEq, i - keyStartEq) + i += 1 ' consume '=' + + If String.IsNullOrEmpty(key) Then + SkipValue(parameters, i) + Continue While + End If + + result(key) = ReadValue(parameters, i) + End While + + Return result + End Function + + Private Function IsNameStart(c As Char) As Boolean + Return Char.IsLetter(c) OrElse c = "_"c + End Function + + Private Function ReadValue(s As String, ByRef i As Integer) As String + Dim len As Integer = s.Length + If i >= len Then Return String.Empty + + If s(i) = """"c Then + i += 1 + Dim valueStart As Integer = i + While i < len AndAlso s(i) <> """"c + i += 1 + End While + Dim value As String = s.Substring(valueStart, i - valueStart) + If i < len Then i += 1 ' consume closing quote + Return value + End If + + Dim unquotedStart As Integer = i + While i < len AndAlso Not Char.IsWhiteSpace(s(i)) + i += 1 + End While + Return s.Substring(unquotedStart, i - unquotedStart) + End Function + + Private Sub SkipValue(s As String, ByRef i As Integer) + ReadValue(s, i) + End Sub + + End Module +End Namespace diff --git a/Class/TaskRunner.vb b/Class/TaskRunner.vb index 4c509f1..1fe090b 100644 --- a/Class/TaskRunner.vb +++ b/Class/TaskRunner.vb @@ -96,12 +96,27 @@ Namespace LiteTask Try _logger.LogInfo($"Executing Batch task: {taskAction.Name}") + ' Trailing args appended to `cmd.exe /c "target.bat" ` so the + ' script receives them as %1..%* (works for both code paths). + Dim batchArgs As String = If(String.IsNullOrWhiteSpace(taskAction.Parameters), + String.Empty, + " " & taskAction.Parameters.Trim()) + If credential IsNot Nothing Then ' Parse UNC paths from batch content Dim batchContent = File.ReadAllText(taskAction.Target) Dim uncPaths = ParseUNCPaths(batchContent) Dim envPassVar = $"BATCH_PASS_{Guid.NewGuid().ToString("N")}" + ' Write the augmented script (net use prelude + original body) to a + ' temp .bat so we can invoke it as `cmd.exe /c "tempfile" `. + ' The previous implementation streamed the script via stdin to + ' `cmd /c -`, which cannot receive %1..%* — task parameters were + ' silently dropped. + Dim tempDir = Path.Combine(Application.StartupPath, "LiteTaskData", "temp") + Directory.CreateDirectory(tempDir) + Dim tempBatFile = Path.Combine(tempDir, $"batch_{Guid.NewGuid():N}.bat") + Try ' Set up credentials Using securePass As New SecureString() @@ -135,22 +150,16 @@ Namespace LiteTask modifiedContent.AppendLine($"net use {uncPath} /delete") Next - ' Execute using PsExec - Using ms As New MemoryStream() - Using writer As New StreamWriter(ms) - Await writer.WriteAsync(modifiedContent.ToString()) - Await writer.FlushAsync() - ms.Position = 0 + Await File.WriteAllTextAsync(tempBatFile, modifiedContent.ToString()) - Dim psExecPath = Path.Combine(_toolManager._toolsPath, "PsExec64.exe") - Dim fullCommand = $"{baseArgs} -p ""%{envPassVar}%"" cmd.exe /c -" + Dim psExecPath = Path.Combine(_toolManager._toolsPath, "PsExec64.exe") + Dim fullCommand = $"{baseArgs} -p ""%{envPassVar}%"" cmd.exe /c ""{tempBatFile}""{batchArgs}" - Using process As New Process With { + Using process As New Process With { .StartInfo = New ProcessStartInfo With { .FileName = psExecPath, .Arguments = fullCommand, .UseShellExecute = False, - .RedirectStandardInput = True, .RedirectStandardOutput = True, .RedirectStandardError = True, .CreateNoWindow = True, @@ -158,20 +167,25 @@ Namespace LiteTask .StandardErrorEncoding = Encoding.UTF8 } } - Return Await RunProcess(process) - End Using - End Using + Return Await RunProcess(process) End Using Finally Environment.SetEnvironmentVariable(envPassVar, Nothing, EnvironmentVariableTarget.Process) + If File.Exists(tempBatFile) Then + Try + File.Delete(tempBatFile) + Catch ex As Exception + _logger.LogWarning($"Failed to delete temp batch file {tempBatFile}: {ex.Message}") + End Try + End If End Try Else ' Execute without credentials Using process As New Process With { .StartInfo = New ProcessStartInfo With { .FileName = "cmd.exe", - .Arguments = $"/c ""{taskAction.Target}""", + .Arguments = $"/c ""{taskAction.Target}""{batchArgs}", .UseShellExecute = False, .RedirectStandardOutput = True, .RedirectStandardError = True, @@ -535,15 +549,18 @@ Namespace LiteTask Dim sqlContent = File.ReadAllText(taskAction.Target) Dim sqlInfo = ExtractSqlInfo(sqlContent) Dim sqlConfig = _xmlManager.GetSqlConfiguration() + ' The task's Parameters field uses the same `key=value` / `-Name value` + ' grammar as PowerShell tasks. ParseSqlParameters parses T-SQL EXEC + ' syntax inside the .sql file content, not the form field. Dim parameters = If(Not String.IsNullOrEmpty(taskAction.Parameters), - ParseSqlParameters(taskAction.Parameters), - New Dictionary(Of String, String)) + ParameterParser.Parse(taskAction.Parameters), + New Dictionary(Of String, Object)(StringComparer.OrdinalIgnoreCase)) - Dim server = If(parameters.ContainsKey("server"), parameters("server"), + Dim server = If(parameters.ContainsKey("server"), CStr(parameters("server")), If(Not String.IsNullOrEmpty(sqlInfo.ServerName), sqlInfo.ServerName, sqlConfig("DefaultServer"))) - Dim database = If(parameters.ContainsKey("database"), parameters("database"), + Dim database = If(parameters.ContainsKey("database"), CStr(parameters("database")), If(Not String.IsNullOrEmpty(sqlInfo.DatabaseName), sqlInfo.DatabaseName, sqlConfig("DefaultDatabase"))) @@ -659,7 +676,11 @@ Namespace LiteTask If Not String.IsNullOrEmpty(taskAction.Parameters) Then _logger.LogInfo($"Adding parameters: {taskAction.Parameters}") For Each param In ParseParameters(taskAction.Parameters) - powerShell.AddParameter(param.Key, param.Value) + If param.Value Is Nothing Then + powerShell.AddParameter(param.Key) + Else + powerShell.AddParameter(param.Key, param.Value) + End If Next End If @@ -802,14 +823,14 @@ Namespace LiteTask - Private Function ParseParameters(parameters As String) As Dictionary(Of String, String) + Private Function ParseParameters(parameters As String) As Dictionary(Of String, Object) Try Dim result = ParameterParser.Parse(parameters) _logger.LogInfo($"Parsed {result.Count} parameters successfully") Return result Catch ex As Exception _logger.LogError($"Error parsing parameters: {ex.Message}") - Return New Dictionary(Of String, String) + Return New Dictionary(Of String, Object) End Try End Function diff --git a/Tests/ParameterParserTests.vb b/Tests/ParameterParserTests.vb index 7cc1748..9a34410 100644 --- a/Tests/ParameterParserTests.vb +++ b/Tests/ParameterParserTests.vb @@ -1,101 +1,151 @@ -' ----------------------------------------------------------------------------- -' Copyright (c) svtica. All rights reserved. -' File: ParameterParserTests.vb -' Author: LiteTask contributors -' Date: 2026-04-25 -' Purpose: Unit tests for LiteTask.ParameterParser covering the original -' unquoted form and the new cmd-style quoting. -' ----------------------------------------------------------------------------- -Imports Microsoft.VisualStudio.TestTools.UnitTesting -Imports LiteTask.LiteTask - - -Public Class ParameterParserTests - - - Public Sub Parse_SinglePair_ReturnsKeyAndValue() - Dim result = ParameterParser.Parse("key=value") - Assert.AreEqual(1, result.Count) - Assert.AreEqual("value", result("key")) - End Sub - - - Public Sub Parse_QuotedValueWithSpaces_KeepsValueIntact() - Dim result = ParameterParser.Parse("key=""value with spaces""") - Assert.AreEqual(1, result.Count) - Assert.AreEqual("value with spaces", result("key")) - End Sub - - - Public Sub Parse_MultiplePairs_AllParsed() - Dim result = ParameterParser.Parse("a=1 b=2 c=3") - Assert.AreEqual(3, result.Count) - Assert.AreEqual("1", result("a")) - Assert.AreEqual("2", result("b")) - Assert.AreEqual("3", result("c")) - End Sub - - - Public Sub Parse_MixedQuotedAndUnquoted_BothHandled() - Dim result = ParameterParser.Parse("a=1 b=""hello world"" c=3") - Assert.AreEqual(3, result.Count) - Assert.AreEqual("1", result("a")) - Assert.AreEqual("hello world", result("b")) - Assert.AreEqual("3", result("c")) - End Sub - - - Public Sub Parse_EmptyQuotedValue_YieldsEmptyString() - Dim result = ParameterParser.Parse("a=1 b="""" c=3") - Assert.AreEqual(3, result.Count) - Assert.AreEqual("", result("b")) - End Sub - - - Public Sub Parse_EmptyUnquotedValue_YieldsEmptyString() - Dim result = ParameterParser.Parse("a= b=2") - Assert.AreEqual(2, result.Count) - Assert.AreEqual("", result("a")) - Assert.AreEqual("2", result("b")) - End Sub - - - Public Sub Parse_NullOrEmpty_ReturnsEmptyDictionary() - Assert.AreEqual(0, ParameterParser.Parse(Nothing).Count) - Assert.AreEqual(0, ParameterParser.Parse("").Count) - Assert.AreEqual(0, ParameterParser.Parse(" ").Count) - End Sub - - - Public Sub Parse_TokenWithoutEquals_IsSkipped() - Dim result = ParameterParser.Parse("a=1 garbage b=2") - Assert.AreEqual(2, result.Count) - Assert.AreEqual("1", result("a")) - Assert.AreEqual("2", result("b")) - Assert.IsFalse(result.ContainsKey("garbage")) - End Sub - - - Public Sub Parse_QuotedValueFollowedByMore_ContinuesParsing() - Dim result = ParameterParser.Parse("path=""C:\Program Files\App"" mode=fast") - Assert.AreEqual(2, result.Count) - Assert.AreEqual("C:\Program Files\App", result("path")) - Assert.AreEqual("fast", result("mode")) - End Sub - - - Public Sub Parse_DuplicateKey_LastWriteWins() - Dim result = ParameterParser.Parse("a=1 a=2 a=3") - Assert.AreEqual(1, result.Count) - Assert.AreEqual("3", result("a")) - End Sub - - - Public Sub Parse_LeadingTrailingWhitespace_Tolerated() - Dim result = ParameterParser.Parse(" a=1 b=2 ") - Assert.AreEqual(2, result.Count) - Assert.AreEqual("1", result("a")) - Assert.AreEqual("2", result("b")) - End Sub - -End Class +' ----------------------------------------------------------------------------- +' Copyright (c) svtica. All rights reserved. +' File: ParameterParserTests.vb +' Author: LiteTask contributors +' Date: 2026-04-25 +' Purpose: Unit tests for LiteTask.ParameterParser covering the historical +' `key=value` form, cmd-style quoting, and PowerShell-style +' `-Name Value` / switch syntax. +' ----------------------------------------------------------------------------- +Imports Microsoft.VisualStudio.TestTools.UnitTesting +Imports LiteTask.LiteTask + + +Public Class ParameterParserTests + + + Public Sub Parse_SinglePair_ReturnsKeyAndValue() + Dim result = ParameterParser.Parse("key=value") + Assert.AreEqual(1, result.Count) + Assert.AreEqual("value", result("key")) + End Sub + + + Public Sub Parse_QuotedValueWithSpaces_KeepsValueIntact() + Dim result = ParameterParser.Parse("key=""value with spaces""") + Assert.AreEqual(1, result.Count) + Assert.AreEqual("value with spaces", result("key")) + End Sub + + + Public Sub Parse_MultiplePairs_AllParsed() + Dim result = ParameterParser.Parse("a=1 b=2 c=3") + Assert.AreEqual(3, result.Count) + Assert.AreEqual("1", result("a")) + Assert.AreEqual("2", result("b")) + Assert.AreEqual("3", result("c")) + End Sub + + + Public Sub Parse_MixedQuotedAndUnquoted_BothHandled() + Dim result = ParameterParser.Parse("a=1 b=""hello world"" c=3") + Assert.AreEqual(3, result.Count) + Assert.AreEqual("1", result("a")) + Assert.AreEqual("hello world", result("b")) + Assert.AreEqual("3", result("c")) + End Sub + + + Public Sub Parse_EmptyQuotedValue_YieldsEmptyString() + Dim result = ParameterParser.Parse("a=1 b="""" c=3") + Assert.AreEqual(3, result.Count) + Assert.AreEqual("", result("b")) + End Sub + + + Public Sub Parse_EmptyUnquotedValue_YieldsEmptyString() + Dim result = ParameterParser.Parse("a= b=2") + Assert.AreEqual(2, result.Count) + Assert.AreEqual("", result("a")) + Assert.AreEqual("2", result("b")) + End Sub + + + Public Sub Parse_NullOrEmpty_ReturnsEmptyDictionary() + Assert.AreEqual(0, ParameterParser.Parse(Nothing).Count) + Assert.AreEqual(0, ParameterParser.Parse("").Count) + Assert.AreEqual(0, ParameterParser.Parse(" ").Count) + End Sub + + + Public Sub Parse_TokenWithoutEquals_IsSkipped() + Dim result = ParameterParser.Parse("a=1 garbage b=2") + Assert.AreEqual(2, result.Count) + Assert.AreEqual("1", result("a")) + Assert.AreEqual("2", result("b")) + Assert.IsFalse(result.ContainsKey("garbage")) + End Sub + + + Public Sub Parse_QuotedValueFollowedByMore_ContinuesParsing() + Dim result = ParameterParser.Parse("path=""C:\Program Files\App"" mode=fast") + Assert.AreEqual(2, result.Count) + Assert.AreEqual("C:\Program Files\App", result("path")) + Assert.AreEqual("fast", result("mode")) + End Sub + + + Public Sub Parse_DuplicateKey_LastWriteWins() + Dim result = ParameterParser.Parse("a=1 a=2 a=3") + Assert.AreEqual(1, result.Count) + Assert.AreEqual("3", result("a")) + End Sub + + + Public Sub Parse_LeadingTrailingWhitespace_Tolerated() + Dim result = ParameterParser.Parse(" a=1 b=2 ") + Assert.AreEqual(2, result.Count) + Assert.AreEqual("1", result("a")) + Assert.AreEqual("2", result("b")) + End Sub + + + Public Sub Parse_PowerShellStyle_NameValue_Pairs() + Dim result = ParameterParser.Parse("-WindowHours 96 -EmailTo ""user@example.com""") + Assert.AreEqual(2, result.Count) + Assert.AreEqual("96", result("WindowHours")) + Assert.AreEqual("user@example.com", result("EmailTo")) + End Sub + + + Public Sub Parse_PowerShellStyle_SwitchBetweenParams_StoredAsNothing() + Dim result = ParameterParser.Parse("-Force -WindowHours 96") + Assert.AreEqual(2, result.Count) + Assert.IsTrue(result.ContainsKey("Force")) + Assert.IsNull(result("Force")) + Assert.AreEqual("96", result("WindowHours")) + End Sub + + + Public Sub Parse_PowerShellStyle_TrailingSwitch_StoredAsNothing() + Dim result = ParameterParser.Parse("-WindowHours 96 -Verbose") + Assert.AreEqual(2, result.Count) + Assert.AreEqual("96", result("WindowHours")) + Assert.IsNull(result("Verbose")) + End Sub + + + Public Sub Parse_PowerShellStyle_NameEqualsValue_Accepted() + Dim result = ParameterParser.Parse("-Mode=fast -Count=3") + Assert.AreEqual(2, result.Count) + Assert.AreEqual("fast", result("Mode")) + Assert.AreEqual("3", result("Count")) + End Sub + + + Public Sub Parse_MixedStyles_BothParsed() + Dim result = ParameterParser.Parse("alpha=one -Force -Beta ""two three""") + Assert.AreEqual(3, result.Count) + Assert.AreEqual("one", result("alpha")) + Assert.IsNull(result("Force")) + Assert.AreEqual("two three", result("Beta")) + End Sub + + + Public Sub Parse_KeyLookup_IsCaseInsensitive() + Dim result = ParameterParser.Parse("-WindowHours 96") + Assert.AreEqual("96", result("windowhours")) + Assert.AreEqual("96", result("WINDOWHOURS")) + End Sub + +End Class