Skip to content

Commit d0901fe

Browse files
committed
Add quote func
1 parent 76584e6 commit d0901fe

2 files changed

Lines changed: 159 additions & 0 deletions

File tree

src/functions.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -972,6 +972,35 @@ function timestamp(): string
972972
return (new \DateTime('now', new \DateTimeZone('UTC')))->format(\DateTime::ISO8601);
973973
}
974974

975+
/**
976+
* Quotes a string for safe use as a shell argument using ANSI-C $'...' syntax.
977+
* Safe characters (alphanumeric, `/.-+@:=,%`) are returned unquoted.
978+
*
979+
* ```php
980+
* run('git log --format=' . quote($format));
981+
* run('echo ' . quote("it's a test")); // echo $'it\'s a test'
982+
* ```
983+
*/
984+
function quote(string $arg): string
985+
{
986+
if ($arg === '') {
987+
return "\$''";
988+
}
989+
if (preg_match('/^[\w\/.\-+@:=,%]+$/', $arg)) {
990+
return $arg;
991+
}
992+
return "\$'" . strtr($arg, [
993+
'\\' => '\\\\',
994+
"'" => "\\'",
995+
"\f" => '\\f',
996+
"\n" => '\\n',
997+
"\r" => '\\r',
998+
"\t" => '\\t',
999+
"\v" => '\\v',
1000+
"\0" => '\\0',
1001+
]) . "'";
1002+
}
1003+
9751004
/**
9761005
* Example usage:
9771006
* ```php

tests/src/QuoteTest.php

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
<?php
2+
/* (c) Anton Medvedev <anton@medv.io>
3+
*
4+
* For the full copyright and license information, please view the LICENSE
5+
* file that was distributed with this source code.
6+
*/
7+
8+
namespace Deployer;
9+
10+
use PHPUnit\Framework\Attributes\DataProvider;
11+
use PHPUnit\Framework\TestCase;
12+
13+
class QuoteTest extends TestCase
14+
{
15+
public function testEmptyString()
16+
{
17+
self::assertEquals("\$''", quote(''));
18+
}
19+
20+
#[DataProvider('safeStringsProvider')]
21+
public function testSafeStringsPassThrough(string $input)
22+
{
23+
self::assertEquals($input, quote($input));
24+
}
25+
26+
public static function safeStringsProvider(): array
27+
{
28+
return [
29+
'simple word' => ['hello'],
30+
'path' => ['/usr/local/bin'],
31+
'dotfile' => ['.env'],
32+
'with dash' => ['my-app'],
33+
'with plus' => ['c++'],
34+
'with at' => ['user@host'],
35+
'with colon' => ['host:port'],
36+
'with equals' => ['key=value'],
37+
'with comma' => ['a,b'],
38+
'with percent' => ['100%'],
39+
'mixed safe chars' => ['/home/user/.config/my-app@2.0:main,alt+debug=1'],
40+
'digits' => ['12345'],
41+
'underscore' => ['foo_bar'],
42+
];
43+
}
44+
45+
#[DataProvider('unsafeStringsProvider')]
46+
public function testUnsafeStrings(string $input, string $expected)
47+
{
48+
self::assertEquals($expected, quote($input));
49+
}
50+
51+
public static function unsafeStringsProvider(): array
52+
{
53+
return [
54+
'space' => ['hello world', "\$'hello world'"],
55+
'single quote' => ["it's", "\$'it\\'s'"],
56+
'double quote' => ['say "hi"', "\$'say \"hi\"'"],
57+
'backslash' => ['back\\slash', "\$'back\\\\slash'"],
58+
'newline' => ["line1\nline2", "\$'line1\\nline2'"],
59+
'tab' => ["col1\tcol2", "\$'col1\\tcol2'"],
60+
'carriage return' => ["line1\rline2", "\$'line1\\rline2'"],
61+
'form feed' => ["page\fbreak", "\$'page\\fbreak'"],
62+
'vertical tab' => ["vert\vtab", "\$'vert\\vtab'"],
63+
'null byte' => ["null\0byte", "\$'null\\0byte'"],
64+
'semicolon' => ['cmd; rm -rf /', "\$'cmd; rm -rf /'"],
65+
'pipe' => ['a | b', "\$'a | b'"],
66+
'ampersand' => ['a & b', "\$'a & b'"],
67+
'dollar' => ['$HOME', "\$'\$HOME'"],
68+
'backtick' => ['`whoami`', "\$'`whoami`'"],
69+
'parens' => ['$(cmd)', "\$'\$(cmd)'"],
70+
'glob star' => ['*.txt', "\$'*.txt'"],
71+
'question mark' => ['file?.txt', "\$'file?.txt'"],
72+
'brackets' => ['[abc]', "\$'[abc]'"],
73+
'curly braces' => ['{a,b}', "\$'{a,b}'"],
74+
'hash' => ['#comment', "\$'#comment'"],
75+
'exclamation' => ['!event', "\$'!event'"],
76+
'tilde' => ['~user', "\$'~user'"],
77+
'angle brackets' => ['a > b < c', "\$'a > b < c'"],
78+
];
79+
}
80+
81+
public function testMultipleEscapes()
82+
{
83+
self::assertEquals("\$'it\\'s a\\nnew\\\\line'", quote("it's a\nnew\\line"));
84+
}
85+
86+
public function testAllSpecialCharsAtOnce()
87+
{
88+
$input = "'\\\f\n\r\t\v\0";
89+
$expected = "\$'\\'\\\\\\f\\n\\r\\t\\v\\0'";
90+
self::assertEquals($expected, quote($input));
91+
}
92+
93+
public function testUnicodeContent()
94+
{
95+
self::assertEquals("\$'héllo wörld'", quote('héllo wörld'));
96+
}
97+
98+
public function testJsonString()
99+
{
100+
$json = json_encode(['foo' => "bar's"]);
101+
self::assertEquals("\$'{\"foo\":\"bar\\'s\"}'", quote($json));
102+
}
103+
104+
public function testSingleCharSafe()
105+
{
106+
self::assertEquals('a', quote('a'));
107+
}
108+
109+
public function testSingleCharUnsafe()
110+
{
111+
self::assertEquals("\$' '", quote(' '));
112+
}
113+
114+
public function testOnlyBackslashes()
115+
{
116+
self::assertEquals("\$'\\\\\\\\'", quote('\\\\'));
117+
}
118+
119+
public function testOnlyQuotes()
120+
{
121+
self::assertEquals("\$'\\'\\'\\''", quote("'''"));
122+
}
123+
124+
public function testShellInjectionAttempts()
125+
{
126+
self::assertEquals("\$'; DROP TABLE users;--'", quote('; DROP TABLE users;--'));
127+
self::assertEquals("\$'`rm -rf /`'", quote('`rm -rf /`'));
128+
self::assertEquals("\$'\$(cat /etc/passwd)'", quote('$(cat /etc/passwd)'));
129+
}
130+
}

0 commit comments

Comments
 (0)