Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.95% covered (success)
98.95%
94 / 95
92.31% covered (success)
92.31%
12 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Validator
98.95% covered (success)
98.95%
94 / 95
92.31% covered (success)
92.31%
12 / 13
55
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validate
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 validateWithSpec
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
10
 validateInputFiles
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 validateOutputFiles
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 validateOutputGlobs
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 validateShellFeatures
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 validateArgv
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 validateLiteralOrAllow
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 validateAllow
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 validateOptions
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 isType
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
9
 isRelative
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace Shellbox\Command;
4
5use Shellbox\Shellbox;
6use Shellbox\ShellboxError;
7
8class 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}