Skip to content

Commit 145b91f

Browse files
authored
Merge pull request #52 from ChiragAgg5k/fix/loose-bool-string-coercion
2 parents 8d1955b + a6ae1eb commit 145b91f

2 files changed

Lines changed: 164 additions & 1 deletion

File tree

src/CLI/CLI.php

Lines changed: 47 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,50 @@ 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. Strings that `filter_var` doesn't recognise (including the
428+
* empty string, which bypasses `validate()` for optional params) are
429+
* passed through unchanged so callers that use `''` as a sentinel for
430+
* "not set" keep working.
431+
*
432+
* @param Validator|callable $validator
433+
* @param mixed $value
434+
* @return mixed
435+
*/
436+
protected function coerce(Validator|callable $validator, mixed $value): mixed
437+
{
438+
if (! is_string($value) || $value === '') {
439+
return $value;
440+
}
441+
442+
if (\is_callable($validator)) {
443+
$validator = $validator();
444+
}
445+
446+
while ($validator instanceof Nullable) {
447+
$validator = $validator->getValidator();
448+
}
449+
450+
if (! ($validator instanceof Boolean)) {
451+
return $value;
452+
}
453+
454+
$coerced = \filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
455+
456+
return $coerced === null ? $value : $coerced;
457+
}
458+
413459
public function setContainer(Container $container): self
414460
{
415461
$this->parentContainer = $container;

tests/CLI/CLITest.php

Lines changed: 117 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,121 @@ 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+
/**
368+
* Empty-string params bypass `validate()` when optional, so they reach
369+
* `coerce()` un-validated. We must NOT silently turn them into `false`
370+
* (callers like Cloud's `Patch.php` use `''` as a "not set" sentinel and
371+
* later resolve it to `null`/three-state).
372+
*/
373+
public function testBooleanParamPreservesEmptyStringSentinel(): void
374+
{
375+
$captured = 'untouched';
376+
377+
$cli = new CLI(new Generic(), ['test.php', 'build']);
378+
379+
$cli
380+
->task('build')
381+
->param('commit', '', new Boolean(true), 'Commit changes', true)
382+
->action(function ($commit) use (&$captured) {
383+
$captured = $commit;
384+
});
385+
386+
$cli->run();
387+
388+
$this->assertSame('', $captured);
389+
}
390+
391+
public function testNonBooleanValidatorPassesValueThroughUnchanged(): void
392+
{
393+
$captured = null;
394+
395+
$cli = new CLI(new Generic(), ['test.php', 'build', '--name=false']);
396+
397+
$cli
398+
->task('build')
399+
->param('name', '', new Text(64), 'A name')
400+
->action(function (string $name) use (&$captured) {
401+
$captured = $name;
402+
});
403+
404+
$cli->run();
405+
406+
$this->assertSame('false', $captured);
407+
}
408+
292409
public function testEscaping()
293410
{
294411
ob_start();

0 commit comments

Comments
 (0)