Lines 100.00% 18 / 18
Functions and Methods 100.00% 5 / 5
Classes and Traits 100.00% 1 / 1
Name Lines Functions and Methods CRAP Classes and Traits
ConfigDumpCommand 100.00% 18 / 18 100.00% 5 / 5 16 100.00% 1 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 configure 100.00% 2 / 2 100.00% 1 / 1 1
 execute 100.00% 8 / 8 100.00% 1 / 1 3
 maskSecrets 100.00% 6 / 6 100.00% 1 / 1 8
 isEmptyValue 100.00% 1 / 1 100.00% 1 / 1 3
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Runtime\Console;
6
7use Stewart\Contracts\Exception\StewartException;
8use Stewart\Runtime\Config\ConfigLoader;
9use Stewart\Support\Secret\SecretName;
10use Stewart\Support\Url\UrlRedactor;
11use Symfony\Component\Console\Attribute\AsCommand;
12use Symfony\Component\Console\Command\Command;
13use Symfony\Component\Console\Input\InputInterface;
14use Symfony\Component\Console\Input\InputOption;
15use Symfony\Component\Console\Output\OutputInterface;
16use Symfony\Component\Yaml\Yaml;
17
18#[AsCommand(name: self::NAME, description: 'Show the effective configuration after the environment is applied')]
19final class ConfigDumpCommand extends StewartCommand
20{
21    public const string NAME = 'config:dump';
22
23    // Broad on purpose: app options are free-form and can hold API keys.
24    private const string MASK = '***';
25
26    private const int YAML_INLINE_DEPTH = 10;
27
28    public function __construct(private readonly ConfigLoader $config)
29    {
30        parent::__construct();
31    }
32
33    protected function configure(): void
34    {
35        parent::configure();
36        $this->addOption('show-secrets', null, InputOption::VALUE_NONE, 'Print credentials instead of masking them');
37    }
38
39    protected function execute(InputInterface $input, OutputInterface $output): int
40    {
41        try {
42            $processed = $this->config->buildValidatedConfigTree($this->findConfigOption($input));
43        } catch (StewartException $e) {
44            $output->writeln('<error>' . $e->getMessage() . '</error>');
45
46            return Command::FAILURE;
47        }
48
49        $this->writeMissingConfigFileNotice($output, $this->config->findMissingDefaultFile($this->findConfigOption($input)));
50        $values = $input->getOption('show-secrets') === true ? $processed : $this->maskSecrets($processed);
51
52        $output->write(Yaml::dump($values, self::YAML_INLINE_DEPTH, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE));
53
54        return Command::SUCCESS;
55    }
56
57    /**
58     * @param array<array-key, mixed> $values
59     * @return array<array-key, mixed>
60     */
61    private function maskSecrets(array $values): array
62    {
63        foreach ($values as $key => $value) {
64            $values[$key] = match (true) {
65                SecretName::looksSecret((string) $key) && !self::isEmptyValue($value) => self::MASK,
66                \is_array($value) => $this->maskSecrets($value),
67                // URL credentials (e.g. a reverse proxy) sit under keys the pattern does not match.
68                \is_string($value) && UrlRedactor::looksLikeUrl($value) => UrlRedactor::redactCredentials($value),
69                default => $value,
70            };
71        }
72
73        return $values;
74    }
75
76    private static function isEmptyValue(mixed $value): bool
77    {
78        return $value === null || $value === '' || $value === [];
79    }
80}