Task: #53687 Refactor CLI input validation into option validators, add validator coverage, and refactor cfg CLI tests

This commit is contained in:
Alejandro Sosa 2026-03-30 13:57:29 +02:00
commit 1c851632c3
2 changed files with 380 additions and 287 deletions

247
bin/cfg
View file

@ -22,40 +22,6 @@ foreach ($autoloadFiles as $autoloadFile) {
}
}
function readJsonInputFile(string $path): array
{
if (!is_readable($path))
{
throw new \RuntimeException("Input file is not readable: $path");
}
$content = file_get_contents($path);
if ($content === false)
{
throw new \RuntimeException("Can not read input file: $path");
}
try
{
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
}
catch (\JsonException $ex)
{
throw new \RuntimeException(sprintf(
'Input JSON is invalid (%s): %s',
(string)$ex->getCode(),
$ex->getMessage()
));
}
if (!is_array($data))
{
throw new \RuntimeException('Input JSON must decode to an object/array');
}
return $data;
}
function verboseLog(bool $enabled, string $message): void
{
if ($enabled) fwrite(STDERR, "[verbose] $message".PHP_EOL);
@ -80,9 +46,49 @@ $collection = (new Input\InputCollection())
) // }}}
->add( Input\InputTypeFactory::build('LongOption')->name('in')->short('i') // {{{
->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED)
->description('Path to a JSON data file to read (for write)')
) // }}}
->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED)
->description('Path to a JSON data file to read (for write)')
->validator(new Input\Validator(
function (AbstractInputType $input, AbstractInputHandler $context)
{
$path = $context->find('in');
if ($path === null || $path === '') {
return null;
}
if (!is_readable($path))
{
throw new \Exception("Input file is not readable: $path");
}
$content = file_get_contents($path);
if ($content === false)
{
throw new \Exception("Can not read input file: $path");
}
try
{
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
}
catch (\JsonException $ex)
{
throw new \Exception(sprintf(
'Input JSON is invalid (%s): %s',
(string)$ex->getCode(),
$ex->getMessage()
));
}
if (!is_array($data))
{
throw new \Exception('Input JSON must decode to an object/array');
}
return $data;
}
))
) // }}}
->add( Input\InputTypeFactory::build('LongOption')->name('mode')->short('m') // {{{
->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED)
@ -100,9 +106,58 @@ $collection = (new Input\InputCollection())
) // }}}
->add( Input\InputTypeFactory::build('LongOption')->name('siteDir') // {{{
->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED)
->description('Site/instance directory below config/. Accepts owner_xyz or config/owner_xyz')
) // }}}
->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED)
->description('Site/instance directory below config/. Accepts owner_xyz or config/owner_xyz')
->validator(new Input\Validator(
function (AbstractInputType $input, AbstractInputHandler $context)
{
$siteInput = $context->find('siteDir');
if ($siteInput === null || $siteInput === false) {
return null;
}
$siteInput = trim((string)$siteInput);
$appPath = $context->find('appPath');
if (!$appPath) {
$appPath = getcwd().'/';
}
$appPath = rtrim($appPath, '/').'/';
// Accept both "owner_xyz" and "config/owner_xyz" and normalize to site key.
if (str_starts_with($siteInput, $appPath.'config/'))
{
$siteInput = substr($siteInput, strlen($appPath.'config/'));
}
elseif (str_starts_with($siteInput, 'config/'))
{
$siteInput = substr($siteInput, strlen('config/'));
}
$siteInput = trim($siteInput, '/');
if ($siteInput === '')
{
throw new \Exception('Option --siteDir is empty.');
}
// Block directory traversal and hidden-dot segments.
if (str_contains($siteInput, '..') || preg_match('~(^|/)\.(?:/|$)~', $siteInput))
{
throw new \Exception("Invalid directory in --siteDir: '$siteInput'.");
}
// Allow only predictable path segments for instance directories.
foreach (explode('/', $siteInput) as $part)
{
if ($part === '' || !preg_match('/^[A-Za-z0-9._-]+$/', $part))
{
throw new \Exception("Invalid directory name segment '$part' in --siteDir.");
}
}
return $siteInput;
}
))
) // }}}
->add( Input\InputTypeFactory::build('Argument')->name('action') // {{{
->flags(AbstractInputType::FLAG_REQUIRED)
@ -146,15 +201,23 @@ $collection = (new Input\InputCollection())
->description(
'the settings you want to work on. With action "write" you can pass the value to set as JSON after an equal sign.'
)
->validator(new Input\Validator(
function (AbstractInputType $input, AbstractInputHandler $context)
{
$setting = $context->find('setting');
$action = $context->find('action');
->validator(new Input\Validator(
function (AbstractInputType $input, AbstractInputHandler $context)
{
$setting = $context->find('setting');
$action = $context->find('action');
$inputPayload = $context->find('in');
if ($setting === null || $setting === '') {
return ['key' => '', 'value' => null];
}
if ($action === 'write')
{
if (($setting !== null && $setting !== '') && $inputPayload) {
throw new \Exception('Please use either SETTING or --in, not both.');
}
}
if ($setting === null || $setting === '') {
return ['key' => '', 'value' => null];
}
$setting = explode('=', $setting);
$settings['key'] = $setting[0];
@ -274,47 +337,12 @@ if ($pkgPath = $argv->find('pkgPath')) $cfg->pkgPath(rtrim($pkgPath, '/').'/');
$site = null;
$siteFlag = 0x01;
$siteInput = $argv->find('siteDir');
if ($siteInput !== null && $siteInput !== false)
$site = $argv->find('siteDir');
if ($site !== null && $site !== false)
{
$siteInput = trim((string)$siteInput);
// Accept both "owner_xyz" and "config/owner_xyz" and normalize to site key.
if (str_starts_with($siteInput, $appPath.'config/'))
{
$siteInput = substr($siteInput, strlen($appPath.'config/'));
}
elseif (str_starts_with($siteInput, 'config/'))
{
$siteInput = substr($siteInput, strlen('config/'));
}
$siteInput = trim($siteInput, '/');
if ($siteInput === '')
{
fwrite(STDERR, 'Option --siteDir is empty.'.PHP_EOL);
exit(1);
}
// Block directory traversal and hidden-dot segments.
if (str_contains($siteInput, '..') || preg_match('~(^|/)\.(?:/|$)~', $siteInput))
{
fwrite(STDERR, "Invalid directory in --siteDir: '$siteInput'.".PHP_EOL);
exit(1);
}
// Allow only predictable path segments for instance directories.
foreach (explode('/', $siteInput) as $part)
{
if ($part === '' || !preg_match('/^[A-Za-z0-9._-]+$/', $part))
{
fwrite(STDERR, "Invalid directory name segment '$part' in --siteDir.".PHP_EOL);
exit(1);
}
}
$site = $siteInput;
// Reuse util-settings site resolution: config/<site>/<prefix>.conf.php
$cfg->site($site);
verboseLog($verbose, "siteDir resolved to '$site'");
// Reuse util-settings site resolution: config/<site>/<prefix>.conf.php
$cfg->site($site);
verboseLog($verbose, "siteDir resolved to '$site'");
}
try
@ -361,38 +389,21 @@ case 'show':
echo $out.PHP_EOL;
break;
case 'write':
$inputFile = $argv->find('in');
// Write needs exactly one payload source:
// either SETTING (key=value) or --in (JSON file).
// This avoids ambiguous input precedence and empty writes.
if ($inputFile && $settings['key'] !== '')
{
fwrite(STDERR, 'Please use either SETTING or --in, not both.'.PHP_EOL);
exit(1);
}
if (!$inputFile && $settings['key'] === '')
{
fwrite(STDERR, 'Nothing to write: provide SETTING or --in.'.PHP_EOL);
exit(1);
}
$path = ($settings['key'] !== '') ? explode(':', $settings['key']) : [];
verboseLog($verbose, 'write source: '.($inputFile ? "--in ($inputFile)" : 'SETTING'));
if ($inputFile)
{
try
case 'write':
$inputPayload = $argv->find('in');
if (!$inputPayload && $settings['key'] === '')
{
fwrite(STDERR, 'Nothing to write: provide SETTING or --in.'.PHP_EOL);
exit(1);
}
$path = ($settings['key'] !== '') ? explode(':', $settings['key']) : [];
verboseLog($verbose, 'write source: '.($inputPayload ? '--in' : 'SETTING'));
if ($inputPayload)
{
$setting2write = readJsonInputFile($inputFile);
$setting2write = $inputPayload;
}
catch (\Throwable $e)
else
{
fwrite(STDERR, $e->getMessage().PHP_EOL);
exit(1);
}
}
else
{
$setting2write = $settings['value'];
}
verboseLog($verbose, 'write path: '.json_encode($path));
@ -402,7 +413,7 @@ case 'write':
$setting2write = [array_pop($path) => $setting2write];
}
$writeType = ($site !== null) ? $siteFlag : null;
$writeType = ($site !== null && $site !== false) ? $siteFlag : null;
$file = $cfg->buildFileName($writeType);
verboseLog($verbose, "write target: $file");
if (is_readable($file))