openclaw-windows-node/tests/OpenClaw.Shared.Tests/ShellQuotingTests.cs
Scott Hanselman 8c1bd38f3f refactor: extract ShellQuoting helper, add comprehensive tests
Extract duplicated metacharacter quoting logic from LocalCommandRunner
and SystemCapability into a shared ShellQuoting static class, following
the established GatewayUrlHelper pattern.

- ShellQuoting.NeedsQuoting: single source of truth for metachar detection
- ShellQuoting.QuoteForShell: shell-aware quoting (cmd vs PowerShell)
- ShellQuoting.FormatExecCommand: display formatting for gateway
- IsShellMetachar: idiomatic switch expression instead of duplicated
  multi-case switch blocks
- 43 new unit tests covering all metacharacters, escaping edge cases,
  null handling, and both shell modes

Co-authored-by: danedane <1402819+danedane@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 18:26:06 -07:00

180 lines
5.7 KiB
C#

using Xunit;
using OpenClaw.Shared;
namespace OpenClaw.Shared.Tests;
/// <summary>
/// Unit tests for ShellQuoting — argument quoting and metacharacter detection.
/// </summary>
public class ShellQuotingTests
{
// ── NeedsQuoting ────────────────────────────────────────────────
[Theory]
[InlineData("hello")]
[InlineData("--flag")]
[InlineData("C:\\Windows\\System32")]
[InlineData("123")]
public void NeedsQuoting_PlainArgs_ReturnsFalse(string arg)
{
Assert.False(ShellQuoting.NeedsQuoting(arg));
}
[Theory]
[InlineData("hello world", ' ')]
[InlineData("a\tb", '\t')]
[InlineData("say \"hi\"", '"')]
[InlineData("it's", '\'')]
[InlineData("a&b", '&')]
[InlineData("a|b", '|')]
[InlineData("a;b", ';')]
[InlineData("a<b", '<')]
[InlineData("a>b", '>')]
[InlineData("(cmd)", '(')]
[InlineData("a^b", '^')]
[InlineData("100%", '%')]
[InlineData("!bang", '!')]
[InlineData("$var", '$')]
[InlineData("`tick", '`')]
[InlineData("*.txt", '*')]
[InlineData("file?.log", '?')]
[InlineData("[0]", '[')]
[InlineData("{a,b}", '{')]
[InlineData("~user", '~')]
public void NeedsQuoting_ShellMetachars_ReturnsTrue(string arg, char _)
{
Assert.True(ShellQuoting.NeedsQuoting(arg));
}
// ── QuoteForShell (cmd.exe) ─────────────────────────────────────
[Fact]
public void QuoteForShell_Cmd_EmptyArg_ReturnsEmptyDoubleQuotes()
{
Assert.Equal("\"\"", ShellQuoting.QuoteForShell("", isCmd: true));
}
[Fact]
public void QuoteForShell_Cmd_PlainArg_ReturnsUnquoted()
{
Assert.Equal("hello", ShellQuoting.QuoteForShell("hello", isCmd: true));
}
[Fact]
public void QuoteForShell_Cmd_ArgWithSpaces_WrapsInDoubleQuotes()
{
Assert.Equal("\"hello world\"", ShellQuoting.QuoteForShell("hello world", isCmd: true));
}
[Fact]
public void QuoteForShell_Cmd_ArgWithInnerDoubleQuotes_DoublesQuotes()
{
// cmd.exe escapes " by doubling: say "hi" → "say ""hi"""
Assert.Equal("\"say \"\"hi\"\"\"", ShellQuoting.QuoteForShell("say \"hi\"", isCmd: true));
}
[Fact]
public void QuoteForShell_Cmd_ArgWithPipe_WrapsInDoubleQuotes()
{
Assert.Equal("\"a|b\"", ShellQuoting.QuoteForShell("a|b", isCmd: true));
}
[Fact]
public void QuoteForShell_Cmd_ArgWithAmpersand_WrapsInDoubleQuotes()
{
Assert.Equal("\"a&b\"", ShellQuoting.QuoteForShell("a&b", isCmd: true));
}
// ── QuoteForShell (PowerShell) ──────────────────────────────────
[Fact]
public void QuoteForShell_PS_EmptyArg_ReturnsEmptySingleQuotes()
{
Assert.Equal("''", ShellQuoting.QuoteForShell("", isCmd: false));
}
[Fact]
public void QuoteForShell_PS_PlainArg_ReturnsUnquoted()
{
Assert.Equal("hello", ShellQuoting.QuoteForShell("hello", isCmd: false));
}
[Fact]
public void QuoteForShell_PS_ArgWithSpaces_WrapsInSingleQuotes()
{
Assert.Equal("'hello world'", ShellQuoting.QuoteForShell("hello world", isCmd: false));
}
[Fact]
public void QuoteForShell_PS_ArgWithInnerSingleQuotes_DoublesQuotes()
{
// PowerShell escapes ' by doubling: it's → 'it''s'
Assert.Equal("'it''s'", ShellQuoting.QuoteForShell("it's", isCmd: false));
}
[Fact]
public void QuoteForShell_PS_ArgWithDollarSign_WrapsInSingleQuotes()
{
// Single quotes prevent PS variable expansion
Assert.Equal("'$HOME'", ShellQuoting.QuoteForShell("$HOME", isCmd: false));
}
[Fact]
public void QuoteForShell_PS_ArgWithBacktick_WrapsInSingleQuotes()
{
Assert.Equal("'`n'", ShellQuoting.QuoteForShell("`n", isCmd: false));
}
// ── FormatExecCommand ───────────────────────────────────────────
[Fact]
public void FormatExecCommand_SimpleArgs_JoinsWithSpaces()
{
var result = ShellQuoting.FormatExecCommand(new[] { "echo", "hello" });
Assert.Equal("echo hello", result);
}
[Fact]
public void FormatExecCommand_EmptyArg_UsesEmptyQuotes()
{
var result = ShellQuoting.FormatExecCommand(new[] { "cmd", "" });
Assert.Equal("cmd \"\"", result);
}
[Fact]
public void FormatExecCommand_ArgWithSpaces_QuotesIt()
{
var result = ShellQuoting.FormatExecCommand(new[] { "echo", "hello world" });
Assert.Equal("echo \"hello world\"", result);
}
[Fact]
public void FormatExecCommand_ArgWithMetachars_QuotesIt()
{
var result = ShellQuoting.FormatExecCommand(new[] { "cmd", "/C", "a&b" });
Assert.Equal("cmd /C \"a&b\"", result);
}
[Fact]
public void FormatExecCommand_ArgWithInnerQuotes_EscapesWithBackslash()
{
// FormatExecCommand uses backslash escaping (gateway display convention)
var result = ShellQuoting.FormatExecCommand(new[] { "echo", "say \"hi\"" });
Assert.Equal("echo \"say \\\"hi\\\"\"", result);
}
// ── Null handling ───────────────────────────────────────────────
[Fact]
public void QuoteForShell_NullArg_Cmd_ReturnsEmptyQuotes()
{
Assert.Equal("\"\"", ShellQuoting.QuoteForShell(null!, isCmd: true));
}
[Fact]
public void QuoteForShell_NullArg_PS_ReturnsEmptySingleQuotes()
{
Assert.Equal("''", ShellQuoting.QuoteForShell(null!, isCmd: false));
}
}