Skip to content

Commit 8686789

Browse files
author
Chirag Aggarwal
committed
fix: coerce string CLI inputs for Boolean params
CLI args arrive as strings via getopt. When a param's validator is `Boolean` (loose mode), strings like "true"/"false"/"1"/"0" pass validation but remain strings. If the action callback declares a `bool` parameter, PHP's implicit string-to-bool cast turns any non-empty string except "0" into `true` -- so `--commit=false` silently becomes `true` and the action runs as if `--commit=true` had been passed. Validators in utopia-php are pure (validate only, never mutate), so the coercion has to happen here at the dispatch boundary in `CLI::getParams()`. After validation, if the param's validator (unwrapping `Nullable`) is `Boolean` and the value is still a string, we run it through `filter_var(..., FILTER_VALIDATE_BOOLEAN)` before handing it to the callback. Bool defaults and non-string inputs pass through untouched, so this is a safe, additive change for existing callers.
1 parent 8d1955b commit 8686789

2 files changed

Lines changed: 135 additions & 1 deletion

File tree

src/CLI/CLI.php

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
use Utopia\DI\Container;
88
use Utopia\Servers\Hook;
99
use Utopia\Validator;
10+
use Utopia\Validator\Boolean;
11+
use Utopia\Validator\Nullable;
1012

1113
class CLI
1214
{
@@ -302,7 +304,7 @@ protected function getParams(Hook $hook): array
302304

303305
$this->validate($key, $param, $value);
304306

305-
$params[$this->camelCaseIt($key)] = $value;
307+
$params[$this->camelCaseIt($key)] = $this->coerce($param['validator'], $value);
306308
}
307309

308310
foreach ($hook->getDependencies() as $dependency) {
@@ -410,6 +412,45 @@ protected function validate(string $key, array $param, $value): void
410412
}
411413
}
412414

415+
/**
416+
* Coerce string CLI inputs to native PHP types based on the param's validator.
417+
*
418+
* CLI args arrive as strings via getopt. When the validator is `Boolean`
419+
* (loose mode), strings like "true"/"false"/"1"/"0" pass validation but
420+
* remain strings. If the action callback declares a `bool` parameter, PHP's
421+
* implicit string-to-bool cast turns any non-empty string except "0" into
422+
* `true` -- so `--commit=false` silently becomes `true`. Validators in
423+
* utopia-php are pure (validate only, never mutate), so the coercion has
424+
* to happen here at the dispatch boundary.
425+
*
426+
* Only string inputs are coerced; bool defaults are passed through
427+
* untouched.
428+
*
429+
* @param Validator|callable $validator
430+
* @param mixed $value
431+
* @return mixed
432+
*/
433+
protected function coerce(Validator|callable $validator, mixed $value): mixed
434+
{
435+
if (! is_string($value)) {
436+
return $value;
437+
}
438+
439+
if (\is_callable($validator)) {
440+
$validator = $validator();
441+
}
442+
443+
while ($validator instanceof Nullable) {
444+
$validator = $validator->getValidator();
445+
}
446+
447+
if ($validator instanceof Boolean) {
448+
return \filter_var($value, FILTER_VALIDATE_BOOLEAN);
449+
}
450+
451+
return $value;
452+
}
453+
413454
public function setContainer(Container $container): self
414455
{
415456
$this->parentContainer = $container;

tests/CLI/CLITest.php

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
use Utopia\CLI\CLI;
88
use Utopia\DI\Container;
99
use Utopia\Validator\ArrayList;
10+
use Utopia\Validator\Boolean;
11+
use Utopia\Validator\Nullable;
1012
use Utopia\Validator\Text;
1113

1214
class CLITest extends TestCase
@@ -289,6 +291,97 @@ public function testMatch()
289291
$this->assertEquals(null, $cli->match());
290292
}
291293

294+
/**
295+
* @return iterable<string, array{0: string, 1: bool}>
296+
*/
297+
public static function looseBooleanValuesProvider(): iterable
298+
{
299+
yield '"false" string' => ['false', false];
300+
yield '"true" string' => ['true', true];
301+
yield '"0" string' => ['0', false];
302+
yield '"1" string' => ['1', true];
303+
}
304+
305+
/**
306+
* Regression: --flag=false used to arrive as the literal string "false",
307+
* which PHP's implicit string-to-bool cast turned into `true` at the
308+
* `bool $flag` parameter boundary. The CLI dispatcher now coerces string
309+
* inputs whose validator is `Boolean` to a real PHP bool.
310+
*
311+
* @dataProvider looseBooleanValuesProvider
312+
*/
313+
public function testBooleanParamCoercesStringInput(string $input, bool $expected): void
314+
{
315+
$captured = null;
316+
317+
$cli = new CLI(new Generic(), ['test.php', 'build', '--commit='.$input]);
318+
319+
$cli
320+
->task('build')
321+
->param('commit', false, new Boolean(true), 'Commit changes', true)
322+
->action(function (bool $commit) use (&$captured) {
323+
$captured = $commit;
324+
});
325+
326+
$cli->run();
327+
328+
$this->assertSame($expected, $captured);
329+
}
330+
331+
public function testBooleanParamUsesDefaultWhenOmitted(): void
332+
{
333+
$captured = null;
334+
335+
$cli = new CLI(new Generic(), ['test.php', 'build']);
336+
337+
$cli
338+
->task('build')
339+
->param('commit', false, new Boolean(true), 'Commit changes', true)
340+
->action(function (bool $commit) use (&$captured) {
341+
$captured = $commit;
342+
});
343+
344+
$cli->run();
345+
346+
$this->assertFalse($captured);
347+
}
348+
349+
public function testBooleanParamCoercionUnwrapsNullable(): void
350+
{
351+
$captured = 'untouched';
352+
353+
$cli = new CLI(new Generic(), ['test.php', 'build', '--commit=false']);
354+
355+
$cli
356+
->task('build')
357+
->param('commit', null, new Nullable(new Boolean(true)), 'Commit changes', true)
358+
->action(function (bool $commit) use (&$captured) {
359+
$captured = $commit;
360+
});
361+
362+
$cli->run();
363+
364+
$this->assertFalse($captured);
365+
}
366+
367+
public function testNonBooleanValidatorPassesValueThroughUnchanged(): void
368+
{
369+
$captured = null;
370+
371+
$cli = new CLI(new Generic(), ['test.php', 'build', '--name=false']);
372+
373+
$cli
374+
->task('build')
375+
->param('name', '', new Text(64), 'A name')
376+
->action(function (string $name) use (&$captured) {
377+
$captured = $name;
378+
});
379+
380+
$cli->run();
381+
382+
$this->assertSame('false', $captured);
383+
}
384+
292385
public function testEscaping()
293386
{
294387
ob_start();

0 commit comments

Comments
 (0)