Task: #53687 Refactor CLI input validation into option validators, add validator coverage, and refactor cfg CLI tests
This commit is contained in:
parent
480d97c804
commit
1c851632c3
2 changed files with 380 additions and 287 deletions
247
bin/cfg
247
bin/cfg
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue