Skip to content

Commit 4191dfe

Browse files
author
Christian Benthake
committed
Add validation for options and arguments
1 parent 7bf58da commit 4191dfe

5 files changed

Lines changed: 169 additions & 0 deletions

File tree

src/Definition/Pattern.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PHPSu\ShellCommandBuilder\Definition;
6+
7+
use PHPSu\ShellCommandBuilder\Exception\ShellBuilderException;
8+
9+
final class Pattern
10+
{
11+
// @see https://github.com/ruby/ruby/blob/master/lib/shellwords.rb#L82
12+
public const SHELLWORD_PATTERN = <<<REGEXP
13+
/\G\s*(?>([^\s\\\\\'\"]+)|'([^\']*)'|"((?:[^\"\\\\]|\\\\.)*)"|(\\\\.?)|(\S))(\s|\z)?/m
14+
REGEXP;
15+
// @see https://github.com/jimmycuadra/rust-shellwords/blob/master/src/lib.rs#L104
16+
public const METACHAR_PATTERN = /** @lang PhpRegExp */ '/\\\\([$`"\\\\\n])/';
17+
public const ESCAPE_PATTERN = /** @lang PhpRegExp */ '/\\\\(.)/';
18+
19+
/**
20+
* Splitting an input into an array of shell words.
21+
* The pattern being used is based on a combination of the ruby and rust implementation of the same functionality
22+
* It derives from the original UNIX Bourne documentation
23+
*
24+
* @psalm-pure
25+
* @param string $input
26+
* @return array<string>
27+
* @throws ShellBuilderException
28+
*/
29+
public static function split(string $input): array
30+
{
31+
$words = [];
32+
$field = '';
33+
$matches = [];
34+
preg_match_all(self::SHELLWORD_PATTERN, $input . ' ', $matches, PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL);
35+
/** @var array<int, null|string> $match */
36+
foreach ($matches as $match) {
37+
if ($match[5] ?? false) {
38+
throw new ShellBuilderException('The given input has mismatching Quotes');
39+
}
40+
$doubleQuoted = '';
41+
if (isset($match[3])) {
42+
$doubleQuoted = preg_replace(self::METACHAR_PATTERN, '$1', $match[3]);
43+
}
44+
$escaped = '';
45+
if (isset($match[4])) {
46+
$escaped = preg_replace(self::ESCAPE_PATTERN, '$1', $match[4]);
47+
$escaped .= '';
48+
}
49+
$field .= implode('', [$match[1], $match[2] ?? '', $doubleQuoted, $escaped]);
50+
$seperator = $match[6] ?? '';
51+
if ($seperator !== '') {
52+
$words[] = $field;
53+
$field = '';
54+
}
55+
}
56+
return $words;
57+
}
58+
}

src/Literal/ShellWord.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace PHPSu\ShellCommandBuilder\Literal;
66

7+
use PHPSu\ShellCommandBuilder\Definition\Pattern;
78
use PHPSu\ShellCommandBuilder\Exception\ShellBuilderException;
89
use PHPSu\ShellCommandBuilder\ShellInterface;
910

@@ -50,9 +51,13 @@ class ShellWord implements ShellInterface
5051
* The constructor is protected, you must choose one of the children
5152
* @param string $argument
5253
* @param string|ShellInterface $value
54+
* @throws ShellBuilderException
5355
*/
5456
protected function __construct(string $argument, $value = '')
5557
{
58+
if (!empty($argument) && !$this->validShellWord($argument)) {
59+
throw new ShellBuilderException('A Shell Argument has to be a valid Shell word and cannot contain e.g whitespace');
60+
}
5661
$this->argument = $argument;
5762
$this->value = $value;
5863
}
@@ -89,6 +94,17 @@ protected function validate(): void
8994
}
9095
}
9196

97+
/**
98+
* @psalm-pure
99+
* @param string $word
100+
* @return bool
101+
* @throws ShellBuilderException
102+
*/
103+
private function validShellWord(string $word): bool
104+
{
105+
return count(Pattern::split($word)) === 1;
106+
}
107+
92108
private function prepare(): void
93109
{
94110
$this->validate();

src/ShellBuilder.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ final class ShellBuilder implements ShellInterface
2222
/** @var bool */
2323
private $asynchronously = false;
2424

25+
/**
26+
* This is a shortcut for quicker fluid access to the shell builder
27+
* @return static
28+
*/
29+
public static function new(): self
30+
{
31+
return new ShellBuilder();
32+
}
33+
2534
public function __construct(int $groupType = GroupType::NO_GROUP)
2635
{
2736
$this->groupType = $groupType;

tests/Definiton/PatternTest.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PHPSu\ShellCommandBuilder\Tests\Definiton;
6+
7+
use PHPSu\ShellCommandBuilder\Definition\Pattern;
8+
use PHPSu\ShellCommandBuilder\Exception\ShellBuilderException;
9+
use PHPUnit\Framework\TestCase;
10+
11+
final class PatternTest extends TestCase
12+
{
13+
public function testSplitNothingSpecial(): void
14+
{
15+
$this->assertEquals(['a', 'b', 'c', 'd'], Pattern::split('a b c d'));
16+
}
17+
18+
public function testSplitQuotedStrings(): void
19+
{
20+
$this->assertEquals(['a', 'b b', 'a'], Pattern::split('a "b b" a'));
21+
}
22+
23+
public function testSplitSingleQuotedStrings(): void
24+
{
25+
$this->assertEquals(["a", "'b' c", "d"], Pattern::split('a "\'b\' c" d'));
26+
}
27+
28+
public function testSplitFileName(): void
29+
{
30+
$this->assertEquals(['/home/user/dev/hallo welt.txt'], Pattern::split('/home/user/dev/hallo\ welt.txt'));
31+
}
32+
33+
public function testSplitDoubleQuotedStrings(): void
34+
{
35+
$this->assertEquals(["a", "\"b\" c", "d"], Pattern::split('a "\"b\" c" d'));
36+
}
37+
38+
public function testSplitEscapedSpaceStrings(): void
39+
{
40+
$this->assertEquals(["a", "b c", "d"], Pattern::split('a b\ c d'));
41+
}
42+
43+
public function testBadDoubleQuotes(): void
44+
{
45+
$this->expectException(ShellBuilderException::class);
46+
$this->expectExceptionMessage('The given input has mismatching Quotes');
47+
Pattern::split("a \"b c d e");
48+
}
49+
50+
public function testBadSingleQuotes(): void
51+
{
52+
$this->expectException(ShellBuilderException::class);
53+
$this->expectExceptionMessage('The given input has mismatching Quotes');
54+
Pattern::split("a 'b c d e");
55+
}
56+
57+
public function testBadMultipleQuotes(): void
58+
{
59+
$this->expectException(ShellBuilderException::class);
60+
$this->expectExceptionMessage('The given input has mismatching Quotes');
61+
Pattern::split("one '\"\"\"");
62+
}
63+
64+
public function testSplitTrailingWhitespace(): void
65+
{
66+
$this->assertEquals(["a", "b", "c", "d"], Pattern::split('a b c d '));
67+
}
68+
69+
public function testMultibyte(): void
70+
{
71+
// currently multibyte is not escaped - make sure escaping happens before
72+
$this->assertEquals(["あい", "あい"], Pattern::split('あい あい'));
73+
}
74+
75+
public function testSplitPercentSign(): void
76+
{
77+
$this->assertEquals(["abc", "%foo bar%"], Pattern::split('abc \'%foo bar%\''));
78+
}
79+
}

tests/ShellBuilderTest.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,4 +463,11 @@ public function testRsyncCommandWithSubCommandAsArgument(): void
463463
->addArgument('./var/storage/')->addToBuilder();
464464
$this->assertEquals($result, (string)$rsync);
465465
}
466+
467+
public function testCreateCommandWithBadArgument(): void
468+
{
469+
$this->expectException(ShellBuilderException::class);
470+
$this->expectExceptionMessage('A Shell Argument has to be a valid Shell word and cannot contain e.g whitespace');
471+
ShellBuilder::new()->createCommand('this is not a valid command');
472+
}
466473
}

0 commit comments

Comments
 (0)