Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
98.95% |
94 / 95 |
|
92.31% |
12 / 13 |
CRAP | |
0.00% |
0 / 1 |
Validator | |
98.95% |
94 / 95 |
|
92.31% |
12 / 13 |
55 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
validate | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
4.03 | |||
validateWithSpec | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
10 | |||
validateInputFiles | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
validateOutputFiles | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
validateOutputGlobs | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
validateShellFeatures | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
validateArgv | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
validateLiteralOrAllow | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
6 | |||
validateAllow | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
6 | |||
validateOptions | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
isType | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
9 | |||
isRelative | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace Shellbox\Command; |
4 | |
5 | use Shellbox\Shellbox; |
6 | use Shellbox\ShellboxError; |
7 | |
8 | class Validator { |
9 | /** @var array */ |
10 | private $config; |
11 | |
12 | /** |
13 | * @var array Things that are in BoxedCommand::getClientData() but are not |
14 | * "options" for validation purposes. |
15 | */ |
16 | private static $nonOptionDataKeys = [ |
17 | 'routeName', |
18 | 'inputFiles', |
19 | 'outputFiles', |
20 | 'outputGlobs', |
21 | 'command' |
22 | ]; |
23 | |
24 | /** |
25 | * @param array $config |
26 | */ |
27 | public function __construct( $config ) { |
28 | $this->config = $config; |
29 | } |
30 | |
31 | /** |
32 | * Validate a command |
33 | * |
34 | * @param BoxedCommand $command |
35 | * @throws ValidationError |
36 | */ |
37 | public function validate( BoxedCommand $command ) { |
38 | $route = $command->getRouteName(); |
39 | $allowedRoutes = $this->config['allowedRoutes'] ?? null; |
40 | if ( $allowedRoutes !== null && !in_array( $route, $allowedRoutes, true ) ) { |
41 | throw new ValidationError( "The route \"$route\" is not in the list of allowed routes" ); |
42 | } |
43 | |
44 | $routeSpecs = $this->config['routeSpecs'] ?? []; |
45 | if ( !isset( $routeSpecs[$route] ) ) { |
46 | return; |
47 | } |
48 | $this->validateWithSpec( $command, $routeSpecs[$route] ); |
49 | } |
50 | |
51 | /** |
52 | * Validate a command against a given route spec |
53 | * |
54 | * @param BoxedCommand $command |
55 | * @param array $spec |
56 | * @throws ValidationError |
57 | */ |
58 | private function validateWithSpec( BoxedCommand $command, $spec ) { |
59 | foreach ( $spec as $target => $targetSpec ) { |
60 | switch ( $target ) { |
61 | case 'inputFiles': |
62 | $this->validateInputFiles( $targetSpec, $command->getInputFiles() ); |
63 | break; |
64 | |
65 | case 'outputFiles': |
66 | $this->validateOutputFiles( $targetSpec, $command->getOutputFiles() ); |
67 | break; |
68 | |
69 | case 'outputGlobs': |
70 | $this->validateOutputGlobs( $targetSpec, $command->getOutputGlobs() ); |
71 | break; |
72 | |
73 | case 'shellFeatures': |
74 | $this->validateShellFeatures( |
75 | $targetSpec, $command->getSyntaxInfo()->getFeatureList() ); |
76 | break; |
77 | |
78 | case 'argv': |
79 | $this->validateArgv( $targetSpec, $command->getSyntaxInfo()->getLiteralArgv() ); |
80 | break; |
81 | |
82 | case 'options': |
83 | $options = array_filter( $command->getClientData() ); |
84 | foreach ( self::$nonOptionDataKeys as $key ) { |
85 | unset( $options[$key] ); |
86 | } |
87 | $this->validateOptions( $targetSpec, $options ); |
88 | break; |
89 | |
90 | default: |
91 | throw new ValidationError( "Unknown validation target \"$target\"" ); |
92 | } |
93 | } |
94 | } |
95 | |
96 | /** |
97 | * Validate input files |
98 | * |
99 | * @param array $spec |
100 | * @param InputFile[] $files |
101 | * @throws ValidationError |
102 | */ |
103 | private function validateInputFiles( $spec, $files ) { |
104 | foreach ( $files as $fileName => $file ) { |
105 | if ( !isset( $spec[$fileName] ) ) { |
106 | throw new ValidationError( "Unexpected input file \"$fileName\"" ); |
107 | } |
108 | } |
109 | } |
110 | |
111 | /** |
112 | * Validate output files |
113 | * |
114 | * @param array $spec |
115 | * @param OutputFile[] $files |
116 | * @throws ValidationError |
117 | */ |
118 | private function validateOutputFiles( $spec, $files ) { |
119 | foreach ( $files as $fileName => $file ) { |
120 | if ( !isset( $spec[$fileName] ) ) { |
121 | throw new ValidationError( "Unexpected output file \"$fileName\"" ); |
122 | } |
123 | } |
124 | } |
125 | |
126 | /** |
127 | * Validate output globs |
128 | * |
129 | * @param array $spec |
130 | * @param OutputGlob[] $globs |
131 | * @throws ValidationError |
132 | */ |
133 | private function validateOutputGlobs( $spec, $globs ) { |
134 | foreach ( $globs as $glob ) { |
135 | $globName = $glob->getPrefix() . '*.' . $glob->getExtension(); |
136 | if ( !isset( $spec[$globName] ) ) { |
137 | throw new ValidationError( "Unexpected glob \"$globName\"" ); |
138 | } |
139 | } |
140 | } |
141 | |
142 | /** |
143 | * Validate the shell feature list |
144 | * |
145 | * @param array $spec |
146 | * @param string[] $features |
147 | * @throws ValidationError |
148 | */ |
149 | private function validateShellFeatures( $spec, $features ) { |
150 | $disallowed = array_diff( $features, array_values( $spec ) ); |
151 | if ( $disallowed ) { |
152 | throw new ValidationError( "Command uses unexpected shell feature: " . |
153 | implode( ', ', $disallowed ) ); |
154 | } |
155 | } |
156 | |
157 | /** |
158 | * Validate the argv specification |
159 | * |
160 | * @param array $spec |
161 | * @param string[]|null $argv |
162 | * @throws ValidationError |
163 | */ |
164 | private function validateArgv( $spec, $argv ) { |
165 | if ( $argv === null ) { |
166 | throw new ValidationError( "argv may only contain literal strings" ); |
167 | } |
168 | foreach ( $spec as $i => $argSpec ) { |
169 | $this->validateLiteralOrAllow( $argSpec, "argv[$i]", $argv[$i] ?? null ); |
170 | } |
171 | } |
172 | |
173 | /** |
174 | * Validate a spec node which may either be a scalar value specifying the |
175 | * expected value, or an array with the key "allow" and the value being a |
176 | * type or array of types. |
177 | * |
178 | * @param mixed $expected The spec node |
179 | * @param string $name The name of the thing being validated, for error messages |
180 | * @param mixed $value The actual value |
181 | * @throws ValidationError |
182 | */ |
183 | private function validateLiteralOrAllow( $expected, $name, $value ) { |
184 | if ( !is_array( $expected ) ) { |
185 | if ( $expected !== $value ) { |
186 | throw new ValidationError( |
187 | "$name does not match the expected value \"$expected\"" ); |
188 | } |
189 | } else { |
190 | foreach ( $expected as $restrictionName => $restrictionValue ) { |
191 | switch ( $restrictionName ) { |
192 | case 'allow': |
193 | $this->validateAllow( $restrictionValue, $name, $value ); |
194 | break; |
195 | |
196 | default: |
197 | throw new ValidationError( |
198 | "Unknown configured restriction type \"$restrictionName\"" ); |
199 | } |
200 | } |
201 | } |
202 | } |
203 | |
204 | /** |
205 | * Confirm that the value is the allowed type or types |
206 | * |
207 | * @param string|string[] $allowedTypes |
208 | * @param string $name The name of the thing being validated, for error messages |
209 | * @param mixed $value |
210 | * @throws ValidationError |
211 | */ |
212 | private function validateAllow( $allowedTypes, $name, $value ) { |
213 | if ( is_array( $allowedTypes ) ) { |
214 | $pass = false; |
215 | foreach ( $allowedTypes as $type ) { |
216 | if ( $this->isType( $type, $value ) ) { |
217 | $pass = true; |
218 | break; |
219 | } |
220 | } |
221 | if ( !$pass ) { |
222 | throw new ValidationError( "$name must be one of: " . |
223 | implode( ', ', $allowedTypes ) ); |
224 | } |
225 | } else { |
226 | if ( !$this->isType( $allowedTypes, $value ) ) { |
227 | throw new ValidationError( "$name must be of type $allowedTypes" ); |
228 | } |
229 | } |
230 | } |
231 | |
232 | /** |
233 | * Validate an array of command options |
234 | * |
235 | * @param array $spec The spec node |
236 | * @param array $options |
237 | * @throws ValidationError |
238 | */ |
239 | private function validateOptions( $spec, $options ) { |
240 | foreach ( $options as $name => $value ) { |
241 | if ( !isset( $spec[$name] ) ) { |
242 | throw new ValidationError( "unexpected option $name" ); |
243 | } |
244 | $this->validateLiteralOrAllow( $spec[$name], $name, $value ); |
245 | } |
246 | } |
247 | |
248 | /** |
249 | * Verify that the value is of the given type. The known types are: |
250 | * |
251 | * - any: always passes |
252 | * - literal: any non-null value |
253 | * - float: a float |
254 | * - integer: an integer |
255 | * - relative: a string containing a valid relative path name with no |
256 | * path traversal or components which would be invalid in Windows. |
257 | * |
258 | * @param string $type |
259 | * @param mixed $value |
260 | * @return bool |
261 | * @throws ValidationError |
262 | */ |
263 | private function isType( $type, $value ) { |
264 | if ( $type === 'any' ) { |
265 | return true; |
266 | } elseif ( $type === 'literal' ) { |
267 | if ( $value === null ) { |
268 | return false; |
269 | } |
270 | } elseif ( $type === 'float' ) { |
271 | if ( !is_float( $value ) ) { |
272 | return false; |
273 | } |
274 | } elseif ( $type === 'integer' ) { |
275 | if ( !is_int( $value ) ) { |
276 | return false; |
277 | } |
278 | } elseif ( $type === 'relative' ) { |
279 | return $this->isRelative( $value ); |
280 | } else { |
281 | throw new ValidationError( "unknown validation type \"$type\"" ); |
282 | } |
283 | return true; |
284 | } |
285 | |
286 | /** |
287 | * Check if a given string is a valid relative path. |
288 | * |
289 | * @param string $path |
290 | * @return bool |
291 | */ |
292 | private function isRelative( $path ) { |
293 | try { |
294 | Shellbox::normalizePath( $path ); |
295 | } catch ( ShellboxError $e ) { |
296 | return false; |
297 | } |
298 | return true; |
299 | } |
300 | } |