• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

move-elevator / composer-translation-validator / 28712148884

04 Jul 2026 04:15PM UTC coverage: 99.762% (-0.2%) from 100.0%
28712148884

Pull #137

github

konradmichalik
fix: parse PHP translation files via AST instead of executing them
Pull Request #137: fix: parse PHP translation files via AST instead of executing them

35 of 41 new or added lines in 1 file covered. (85.37%)

2519 of 2525 relevant lines covered (99.76%)

9.88 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.94
/src/Parser/PhpParser.php
1
<?php
2

3
declare(strict_types=1);
4

5
/*
6
 * This file is part of the "composer-translation-validator" Composer plugin.
7
 *
8
 * (c) 2025-2026 Konrad Michalik <km@move-elevator.de>
9
 *
10
 * For the full copyright and license information, please view the LICENSE
11
 * file that was distributed with this source code.
12
 */
13

14
namespace MoveElevator\ComposerTranslationValidator\Parser;
15

16
use PhpParser\Node\Expr\{Array_, ConstFetch, UnaryMinus};
17
use PhpParser\Node\Scalar\{Float_, Int_, String_};
18
use PhpParser\Node\Stmt\Return_;
19
use PhpParser\{Node, NodeFinder, ParserFactory};
20
use RuntimeException;
21
use Throwable;
22

23
use function array_key_exists;
24
use function dirname;
25
use function is_array;
26
use function is_float;
27
use function is_int;
28
use function is_string;
29
use function sprintf;
30
use function strtolower;
31

32
/**
33
 * PhpParser.
34
 *
35
 * @author Konrad Michalik <km@move-elevator.de>
36
 * @license GPL-3.0-or-later
37
 */
38
class PhpParser extends AbstractParser implements ParserInterface
39
{
40
    /** @var array<int|string, mixed> */
41
    private array $translations = [];
42

43
    public function __construct(string $filePath)
23✔
44
    {
45
        parent::__construct($filePath);
23✔
46

47
        try {
48
            $this->loadTranslations();
21✔
49
        } catch (Throwable $e) {
2✔
50
            throw new RuntimeException(sprintf('Failed to parse PHP file "%s": %s', $filePath, $e->getMessage()), 0, $e);
2✔
51
        }
52
    }
53

54
    /**
55
     * @return array<int, string>|null
56
     */
57
    public function extractKeys(): ?array
5✔
58
    {
59
        if (empty($this->translations)) {
5✔
60
            return [];
1✔
61
        }
62

63
        $extract = static function (
4✔
64
            array $data,
4✔
65
            string $prefix = '',
4✔
66
        ) use (&$extract): array {
4✔
67
            $keys = [];
4✔
68
            foreach ($data as $key => $value) {
4✔
69
                $fullKey = '' === $prefix
4✔
70
                    ? $key
4✔
71
                    : $prefix.'.'.$key;
3✔
72
                if (is_array($value)) {
4✔
73
                    $extracted = $extract($value, $fullKey);
3✔
74
                    foreach ($extracted as $k) {
3✔
75
                        $keys[] = $k;
3✔
76
                    }
77
                } else {
78
                    $keys[] = $fullKey;
4✔
79
                }
80
            }
81

82
            return $keys;
4✔
83
        };
4✔
84

85
        return $extract($this->translations);
4✔
86
    }
87

88
    public function getContentByKey(string $key): ?string
6✔
89
    {
90
        // Note: the $attribute parameter is required by ParserInterface
91
        // but is not used for PHP files, since PHP has no source/target concept.
92
        $parts = explode('.', $key);
6✔
93
        $value = $this->translations;
6✔
94

95
        foreach ($parts as $part) {
6✔
96
            if (is_array($value) && array_key_exists($part, $value)) {
6✔
97
                $value = $value[$part];
6✔
98
            } else {
99
                return null;
1✔
100
            }
101
        }
102

103
        return is_string($value) ? $value : null;
5✔
104
    }
105

106
    /**
107
     * @return array<int, string>
108
     */
109
    public static function getSupportedFileExtensions(): array
39✔
110
    {
111
        return ['php'];
39✔
112
    }
113

114
    public function getLanguage(): string
4✔
115
    {
116
        $fileName = $this->getFileName();
4✔
117

118
        // Laravel pattern: en/messages.php, de/auth.php
119
        $directory = basename(dirname($this->filePath));
4✔
120
        if (preg_match('/^[a-z]{2}(?:[-_][A-Z]{2})?$/', $directory)) {
4✔
121
            return $directory;
2✔
122
        }
123

124
        // Symfony pattern: messages.en.php, validators.de.php
125
        if (preg_match('/\.([a-z]{2}(?:[-_][A-Z]{2})?)\.php$/i', $fileName, $matches)) {
2✔
126
            return $matches[1];
1✔
127
        }
128

129
        return '';
1✔
130
    }
131

132
    private function loadTranslations(): void
21✔
133
    {
134
        $source = file_get_contents($this->filePath);
21✔
135
        if (false === $source) {
21✔
NEW
136
            throw new RuntimeException('Failed to read PHP translation file');
×
137
        }
138

139
        // Parse the file into an AST instead of executing it via include.
140
        // This prevents arbitrary code execution when validating untrusted
141
        // translation files (e.g. from third-party packages or pull requests).
142
        $ast = (new ParserFactory())->createForHostVersion()->parse($source);
21✔
143
        if (null === $ast) {
21✔
NEW
144
            throw new RuntimeException('PHP translation file could not be parsed');
×
145
        }
146

147
        $return = (new NodeFinder())->findFirstInstanceOf($ast, Return_::class);
21✔
148
        if (!$return instanceof Return_ || !$return->expr instanceof Array_) {
21✔
149
            throw new RuntimeException('PHP translation file must return an array');
1✔
150
        }
151

152
        $this->translations = $this->evaluateArray($return->expr);
20✔
153
    }
154

155
    /**
156
     * Safely evaluates an array literal node into a PHP array.
157
     *
158
     * Only scalar literals (string, int, float, bool, null) and nested arrays
159
     * are supported. Any other expression (function calls, variables, constants)
160
     * is rejected to guarantee no code is executed.
161
     *
162
     * @return array<int|string, mixed>
163
     */
164
    private function evaluateArray(Array_ $array): array
20✔
165
    {
166
        $result = [];
20✔
167
        foreach ($array->items as $item) {
20✔
168
            if (null === $item->key) {
19✔
NEW
169
                throw new RuntimeException('PHP translation file must use string or integer keys');
×
170
            }
171

172
            $key = $this->evaluateScalar($item->key);
19✔
173
            if (!is_string($key) && !is_int($key)) {
19✔
NEW
174
                throw new RuntimeException('PHP translation file must use string or integer keys');
×
175
            }
176

177
            $result[$key] = $item->value instanceof Array_
19✔
178
                ? $this->evaluateArray($item->value)
13✔
179
                : $this->evaluateScalar($item->value);
19✔
180
        }
181

182
        return $result;
19✔
183
    }
184

185
    private function evaluateScalar(Node $node): string|int|float|bool|null
19✔
186
    {
187
        if ($node instanceof String_) {
19✔
188
            return $node->value;
19✔
189
        }
190

191
        if ($node instanceof Int_) {
3✔
192
            return $node->value;
2✔
193
        }
194

195
        if ($node instanceof Float_) {
3✔
196
            return $node->value;
1✔
197
        }
198

199
        if ($node instanceof UnaryMinus) {
3✔
200
            $operand = $this->evaluateScalar($node->expr);
1✔
201
            if (is_int($operand) || is_float($operand)) {
1✔
202
                return -$operand;
1✔
203
            }
NEW
204
            throw new RuntimeException('PHP translation file contains an unsupported expression');
×
205
        }
206

207
        if ($node instanceof ConstFetch) {
2✔
208
            return match (strtolower($node->name->toString())) {
1✔
209
                'true' => true,
1✔
NEW
210
                'false' => false,
×
211
                'null' => null,
1✔
212
                default => throw new RuntimeException('PHP translation file contains an unsupported constant'),
1✔
213
            };
214
        }
215

216
        throw new RuntimeException('PHP translation file contains an unsupported expression');
1✔
217
    }
218
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc