diff --git a/bin/cfg b/bin/cfg index 78e84c5..6f138cf 100755 --- a/bin/cfg +++ b/bin/cfg @@ -22,7 +22,62 @@ foreach ($autoloadFiles as $autoloadFile) { } } -$version = '0.3'; +// Pre-parse site options so they can be passed after positional args. +// The input helper library does not handle this case reliably. +$preparsedSiteOptions = []; +$rawArgv = $_SERVER['argv'] ?? $GLOBALS['argv'] ?? null; +if (is_array($rawArgv) && !empty($rawArgv)) { + $filteredArgv = [$rawArgv[0]]; + for ($idx = 1; $idx < count($rawArgv); $idx++) { + $arg = $rawArgv[$idx]; + + if ($arg === '-s' || $arg === '--site') { + $preparsedSiteOptions[] = [ + 'name' => 'site', + 'value' => $rawArgv[$idx + 1] ?? '', + ]; + if (isset($rawArgv[$idx + 1])) $idx++; + continue; + } + if (str_starts_with($arg, '--site=')) { + $preparsedSiteOptions[] = [ + 'name' => 'site', + 'value' => substr($arg, strlen('--site=')), + ]; + continue; + } + + if ($arg === '--directory-name' || $arg === '--directoryName') { + $preparsedSiteOptions[] = [ + 'name' => 'directoryName', + 'value' => $rawArgv[$idx + 1] ?? '', + ]; + if (isset($rawArgv[$idx + 1])) $idx++; + continue; + } + if (str_starts_with($arg, '--directory-name=')) { + $preparsedSiteOptions[] = [ + 'name' => 'directoryName', + 'value' => substr($arg, strlen('--directory-name=')), + ]; + continue; + } + if (str_starts_with($arg, '--directoryName=')) { + $preparsedSiteOptions[] = [ + 'name' => 'directoryName', + 'value' => substr($arg, strlen('--directoryName=')), + ]; + continue; + } + + $filteredArgv[] = $arg; + } + + $_SERVER['argv'] = $filteredArgv; + $GLOBALS['argv'] = $filteredArgv; +} + +$version = '0.4'; $actions = [ 'show', 'write', 'help' ]; $settings = ['key' => '', 'value' => '']; @@ -60,6 +115,16 @@ $collection = (new Input\InputCollection()) ->description('Path where the config/ directory of the package conf files is located, defaults to the working dir') ) // }}} + ->add( Input\InputTypeFactory::build('LongOption')->name('site')->short('s') // {{{ + ->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED) + ->description('Site/instance directory below config/ (target: config//.conf.php), e.g. owner_xyz') + ) // }}} + + ->add( Input\InputTypeFactory::build('LongOption')->name('directoryName') // {{{ + ->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED) + ->description('Alias for --site. Accepts owner_xyz or config/owner_xyz (also supports --directory-name)') + ) // }}} + ->add( Input\InputTypeFactory::build('Argument')->name('action') // {{{ ->flags(AbstractInputType::FLAG_REQUIRED) ->description( @@ -141,8 +206,18 @@ $usage = Cli\manpage( basename(__FILE__), $version, $collection, Colour::FG_GREEN, Colour::FG_WHITE, [ 'Examples' => - 'cfg show VeruA db:host'.PHP_EOL. - 'cfg write VeruA \'db:host="newHost"\''.PHP_EOL + 'cfg write myCEESV \'auth:projectId="218523"\'' + .PHP_EOL + .'# writes local config: config/myCEESV.conf.php' + .PHP_EOL + .'cfg write myCEESV \'auth:projectId="218523"\' --directory-name=owner_xyz' + .PHP_EOL + .'# writes instance config: config/owner_xyz/myCEESV.conf.php' + .PHP_EOL + .'cfg show myCEESV auth:projectId --site=owner_xyz' + .PHP_EOL + .'# reads merged config including config/owner_xyz/myCEESV.conf.php' + .PHP_EOL ] ).PHP_EOL; @@ -181,6 +256,7 @@ echo $argv->find('setting')['value'].PHP_EOL; // var_dump($cfg); $appPath = $argv->find('appPath'); if (!$appPath) $appPath = getcwd().'/'; +$appPath = rtrim($appPath, '/').'/'; /* $it = new RecursiveDirectoryIterator($appPath); @@ -194,7 +270,62 @@ foreach(new RecursiveIteratorIterator($it) as $file) */ $mode = ($argv->find('mode') == '') ? null : $argv->find('mode'); $cfg = (new Settings([], $mode))->appPath($appPath)->prefix($prefix); -if ($pkgPath = $argv->find('pkgPath')) $cfg->pkgPath($pkgPath); +// pkgPath points to package defaults (e.g. .default.conf.php) +if ($pkgPath = $argv->find('pkgPath')) $cfg->pkgPath(rtrim($pkgPath, '/').'/'); + +$site = null; +$siteFlag = 0x01; +// Only one site selector may be provided to avoid ambiguous targets. +$providedSiteOptions = []; +foreach ($preparsedSiteOptions as $option) { + $providedSiteOptions[$option['name']][] = $option['value'] ?? ''; +} +foreach (['site', 'directoryName'] as $optionName) { + $parsedValue = $argv->find($optionName); + if ($parsedValue !== null && $parsedValue !== '' && $parsedValue !== false) { + $providedSiteOptions[$optionName][] = $parsedValue; + } +} + +if (count($providedSiteOptions) > 1) { + fwrite(STDERR, 'Please use only one of --site or --directoryName (alias: --directory-name).'.PHP_EOL); + exit(1); +} +if (!empty($providedSiteOptions)) { + $optionName = array_key_first($providedSiteOptions); + $siteInput = trim((string)end($providedSiteOptions[$optionName])); + + if ($optionName === 'directoryName') { + // 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 --$optionName 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 --$optionName: '$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 --$optionName.".PHP_EOL); + exit(1); + } + } + + $site = $siteInput; + // Reuse util-settings site resolution: config//.conf.php + $cfg->site($site); +} try { if (is_readable($cfg->buildFileName('default'))) { @@ -238,27 +369,37 @@ case 'show': case 'write': $path = ($settings['key'] !== '') ? explode(':', $settings['key']) : []; - var_dump($path); - + $setting2write = $settings['value']; - + while ( ! empty($path)) { $setting2write = [array_pop($path) => $setting2write]; } - if (is_readable($file = $cfg->buildFileName())) + + $writeType = ($site !== null) ? $siteFlag : null; + $file = $cfg->buildFileName($writeType); + if (is_readable($file)) { $setting2write = array_replace_recursive(require($file), $setting2write); copy($file, "$file.bak"); } + + $targetDir = dirname($file); + if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true) && !is_dir($targetDir)) { + fwrite(STDERR, "Can not create directory: $targetDir".PHP_EOL); + exit(1); + } + $writeCfg = $cfg->create($setting2write); // var_dump($writeCfg->toArray()); try { - (new SettingsWriter($writeCfg))->write(); + (new SettingsWriter($writeCfg, '', $writeType))->write(); echo "Written modified settings to: $file".PHP_EOL; } catch (\Exception $e) { - echo $e->getMessage().PHP_EOL; + fwrite(STDERR, $e->getMessage().PHP_EOL); + exit(1); } break; diff --git a/src/SettingsWriter.php b/src/SettingsWriter.php index d6fc41c..6246a9f 100644 --- a/src/SettingsWriter.php +++ b/src/SettingsWriter.php @@ -22,6 +22,8 @@ declare(strict_types=1); namespace rabe\Util; +use ErrorException; + /** * Write Settings into a File * @author Norbert.e.Wagner dev@norb.me @@ -45,13 +47,16 @@ class SettingsWriter * * @param Settings $settings an object of type settings * @param String $name The name midfix for the settings File - */ - public function __construct( Settings $settings, $name='' ) + * @param String|int|null $type Optional type for Settings::buildFileName(), e.g. site flag + */ + public function __construct( Settings $settings, $name='', $type=null ) { $this->settings = $settings; - - $file = $settings->buildFileName( $name ); - + + $file = ( $type === null ) + ? $settings->buildFileName( $name ) + : $settings->buildFileName( $type ); + if ( ! $this->handle = fopen( $file, 'w' ) ) { throw new ErrorException( "Can not open File: $file" ); diff --git a/tests/CfgTest.php b/tests/CfgTest.php new file mode 100644 index 0000000..508a212 --- /dev/null +++ b/tests/CfgTest.php @@ -0,0 +1,307 @@ +tmpDir = rtrim(sys_get_temp_dir(), '/').'/util-settings-cfg-'.bin2hex(random_bytes(8)); + mkdir($this->tmpDir, 0775, true); + mkdir($this->tmpDir.'/config', 0775, true); + + file_put_contents( + $this->tmpDir.'/config/myCEESV.default.conf.php', + " 'prod',\n\t'auth' => [\n\t\t'projectId' => '',\n\t\t'clientId' => '',\n\t],\n];\n" + ); + } + + protected function tearDown(): void + { + $this->removeDir($this->tmpDir); + } + + public function testWriteWithoutDirectoryNameUsesLocalConfigFile(): void + { + $result = $this->runCfg([ + '-a', + $this->tmpDir, + 'write', + 'myCEESV', + 'auth:projectId="218523"', + ]); + + $this->assertSame(0, $result['code'], $result['output']); + + $localFile = $this->tmpDir.'/config/myCEESV.conf.php'; + $this->assertFileExists($localFile); + + $cfg = require $localFile; + $this->assertSame('218523', $cfg['auth']['projectId']); + } + + public function testWriteWithDirectoryNameCreatesAndWritesSiteConfig(): void + { + $result = $this->runCfg([ + '-a', + $this->tmpDir, + 'write', + 'myCEESV', + 'auth:projectId="218523"', + '--directory-name', + 'owner_xyz', + ]); + + $this->assertSame(0, $result['code'], $result['output']); + + $siteFile = $this->tmpDir.'/config/owner_xyz/myCEESV.conf.php'; + $this->assertFileExists($siteFile); + $this->assertFileDoesNotExist($this->tmpDir.'/config/myCEESV.conf.php'); + + $cfg = require $siteFile; + $this->assertSame('218523', $cfg['auth']['projectId']); + } + + public function testWriteWithDirectoryNameMergesIntoExistingSiteConfig(): void + { + $firstWrite = $this->runCfg([ + '-a', + $this->tmpDir, + 'write', + 'myCEESV', + 'auth:projectId="218523"', + '--directory-name', + 'owner_xyz', + ]); + $this->assertSame(0, $firstWrite['code'], $firstWrite['output']); + + $secondWrite = $this->runCfg([ + '-a', + $this->tmpDir, + 'write', + 'myCEESV', + 'auth:clientId="service-9999-qual@myceesv.ch"', + '--directory-name', + 'owner_xyz', + ]); + $this->assertSame(0, $secondWrite['code'], $secondWrite['output']); + + $siteFile = $this->tmpDir.'/config/owner_xyz/myCEESV.conf.php'; + $cfg = require $siteFile; + + $this->assertSame('218523', $cfg['auth']['projectId']); + $this->assertSame('service-9999-qual@myceesv.ch', $cfg['auth']['clientId']); + } + + public function testWriteWithDirectoryNameUsingConfigPrefixIsNormalized(): void + { + $result = $this->runCfg([ + '-a', + $this->tmpDir, + 'write', + 'myCEESV', + 'auth:projectId="218523"', + '--directory-name=config/owner_xyz', + ]); + + $this->assertSame(0, $result['code'], $result['output']); + $this->assertFileExists($this->tmpDir.'/config/owner_xyz/myCEESV.conf.php'); + $this->assertFileDoesNotExist($this->tmpDir.'/config/config/owner_xyz/myCEESV.conf.php'); + } + + public function testWriteWithSiteOptionWritesToSiteConfig(): void + { + $result = $this->runCfg([ + '-a', + $this->tmpDir, + 'write', + 'myCEESV', + 'auth:projectId="218523"', + '--site=owner_xyz', + ]); + + $this->assertSame(0, $result['code'], $result['output']); + $this->assertFileExists($this->tmpDir.'/config/owner_xyz/myCEESV.conf.php'); + } + + public function testWriteWithDirectoryNameCamelCaseOptionWritesToSiteConfig(): void + { + $result = $this->runCfg([ + '-a', + $this->tmpDir, + 'write', + 'myCEESV', + 'auth:projectId="218523"', + '--directoryName=owner_xyz', + ]); + + $this->assertSame(0, $result['code'], $result['output']); + $this->assertFileExists($this->tmpDir.'/config/owner_xyz/myCEESV.conf.php'); + } + + public function testWriteToExistingSiteConfigCreatesBackup(): void + { + $firstWrite = $this->runCfg([ + '-a', + $this->tmpDir, + 'write', + 'myCEESV', + 'auth:projectId="first"', + '--directory-name=owner_xyz', + ]); + $this->assertSame(0, $firstWrite['code'], $firstWrite['output']); + + $secondWrite = $this->runCfg([ + '-a', + $this->tmpDir, + 'write', + 'myCEESV', + 'auth:projectId="second"', + '--directory-name=owner_xyz', + ]); + $this->assertSame(0, $secondWrite['code'], $secondWrite['output']); + + $siteFile = $this->tmpDir.'/config/owner_xyz/myCEESV.conf.php'; + $backupFile = $siteFile.'.bak'; + $this->assertFileExists($backupFile); + + $current = require $siteFile; + $backup = require $backupFile; + $this->assertSame('second', $current['auth']['projectId']); + $this->assertSame('first', $backup['auth']['projectId']); + } + + public function testShowWithSiteReturnsSiteSpecificValue(): void + { + $this->runCfg([ + '-a', + $this->tmpDir, + 'write', + 'myCEESV', + 'auth:projectId="218523"', + '--directory-name=owner_xyz', + ]); + + $show = $this->runCfg([ + '-a', + $this->tmpDir, + 'show', + 'myCEESV', + 'auth:projectId', + '--site=owner_xyz', + ]); + + $this->assertSame(0, $show['code'], $show['output']); + $this->assertStringContainsString('218523', $show['output']); + } + + public function testDirectoryNameRejectsTraversal(): void + { + $result = $this->runCfg([ + '-a', + $this->tmpDir, + 'write', + 'myCEESV', + 'auth:projectId="218523"', + '--directory-name=../owner_xyz', + ]); + + $this->assertSame(1, $result['code']); + $this->assertStringContainsString('Invalid directory in --directoryName', $result['output']); + } + + public function testDirectoryNameRejectsInvalidSegment(): void + { + $result = $this->runCfg([ + '-a', + $this->tmpDir, + 'write', + 'myCEESV', + 'auth:projectId="218523"', + '--directory-name=owner xyz', + ]); + + $this->assertSame(1, $result['code']); + $this->assertStringContainsString("Invalid directory name segment 'owner xyz' in --directoryName.", $result['output']); + } + + public function testDirectoryNameRejectsEmptyValue(): void + { + $result = $this->runCfg([ + '-a', + $this->tmpDir, + 'write', + 'myCEESV', + 'auth:projectId="218523"', + '--directory-name=', + ]); + + $this->assertSame(1, $result['code']); + $this->assertStringContainsString('Option --directoryName is empty.', $result['output']); + } + + public function testUsingSiteAndDirectoryNameTogetherReturnsError(): void + { + $result = $this->runCfg([ + '-a', + $this->tmpDir, + 'write', + 'myCEESV', + 'auth:projectId="218523"', + '--site', + 'owner_xyz', + '--directory-name', + 'owner_xyz', + ]); + + $this->assertSame(1, $result['code']); + $this->assertStringContainsString('Please use only one of --site or --directoryName (alias: --directory-name).', $result['output']); + } + + private function runCfg(array $args): array + { + $script = realpath(__DIR__.'/../bin/cfg'); + $this->assertNotFalse($script); + + $command = escapeshellarg(PHP_BINARY).' '.escapeshellarg($script); + foreach ($args as $arg) { + $command .= ' '.escapeshellarg($arg); + } + $command .= ' 2>&1'; + + $outputLines = []; + $exitCode = 0; + exec($command, $outputLines, $exitCode); + + return [ + 'code' => $exitCode, + 'output' => implode(PHP_EOL, $outputLines), + ]; + } + + private function removeDir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $dir.'/'.$entry; + if (is_dir($path)) { + $this->removeDir($path); + } else { + unlink($path); + } + } + + rmdir($dir); + } +} diff --git a/tests/SettingsTest.php b/tests/SettingsTest.php index 64ed411..c895bba 100644 --- a/tests/SettingsTest.php +++ b/tests/SettingsTest.php @@ -211,6 +211,22 @@ class SettingsTest extends TestCase $this->assertEquals($path.$expected, $cfg->buildFileName($type)); } + + public function testBuildFileNameWithPrefixAndSite(): void + { + $cfg = new Settings(); + $cfg->appPath('./')->prefix('myCEESV')->site('owner_xyz'); + + $this->assertEquals('./config/owner_xyz/myCEESV.conf.php', $cfg->buildFileName(0x01)); + } + + public function testBuildFileNameWithPrefixAndNestedSite(): void + { + $cfg = new Settings(); + $cfg->appPath('./')->prefix('myCEESV')->site('owner_xyz/sub_a'); + + $this->assertEquals('./config/owner_xyz/sub_a/myCEESV.conf.php', $cfg->buildFileName(0x01)); + } public function fileNameData() {