diff --git a/.gitignore b/.gitignore index 3c28a27..d29deb5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /bin/coveralls /bin/phpunit /bin/test-reporter +/bin/validate-json diff --git a/.travis.yml b/.travis.yml index 9f38c0a..11329fe 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,9 +7,14 @@ php: - 5.5 - 5.4 +matrix: + allow_failures: + - php: hhvm + fast_finish: true + install: - composer install --dev --prefer-source script: - bin/phpunit - - bin/psecio-parse scan src tests + - bin/psecio-parse scan src tests bin/psecio-parse diff --git a/README.md b/README.md index e9dd836..ca09f19 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ You can also get a listing of the current checks being done with the `rules` com psecio-parse rules -### Managing rules to run +### Managing the rules to run There are several ways to control which rules are run. You can specifically include rules using the `--include-rules` option, specifically exclude them with `--exclude-rules`, turn them on and @@ -114,6 +114,42 @@ To disable the use of annotations, use the `--disable-annotations` option. See the `examples` directory for some examples of the use of annotations for *Parse*. +### Using a configuration file + +Specify the name of your configuration file with the `--configuration` option. If no +filename is given the default `psecio-parse.json` is used. + +To ignore the default configuration file use the `--no-configuration` option. + +#### Configuration file format +```json +{ + "paths": [ + "path/to/scan" + ], + "ignore-paths": [ + "path/to/ignore" + ], + "extensions": [ + "php", + "phps", + "phtml", + "php5" + ], + "whitelist-rules": [ + "rule-name" + ], + "blacklist-rules": [ + "rule-name" + ], + "disable-annotations": false, + "format": "dots|progress|lines|debug|xml" +} +``` + +See example configurations for scanning parse itself [here](psecio-parse.json.dist). See the +json schema used to validate configuration files [here](src/Conf/schema.json). + The Checks ---------- Here's the current list of checks: diff --git a/composer.json b/composer.json index d79e11f..9dffcc3 100644 --- a/composer.json +++ b/composer.json @@ -14,14 +14,16 @@ ], "require":{ "php": ">=5.4", + "justinrainbow/json-schema": "^4.0", "nikic/php-parser": "^1.0|^2.0", "symfony/console": "~2.5|~3.1", - "symfony/event-dispatcher": "~2.4|~3.1" + "symfony/event-dispatcher": "~2.4|~3.1", + "symfony/finder": "^3.1" }, "require-dev": { - "phpunit/phpunit": "4.2.*", + "phpunit/phpunit": "~4.2", "codeclimate/php-test-reporter": "dev-master", - "mockery/mockery": "0.9.*", + "mockery/mockery": ">=0.9.3", "akamon/mockery-callable-mock": "~1.0" }, "autoload": { diff --git a/psecio-parse.json.dist b/psecio-parse.json.dist new file mode 100644 index 0000000..6adba54 --- /dev/null +++ b/psecio-parse.json.dist @@ -0,0 +1,8 @@ +{ + "paths": [ + "src", + "tests", + "bin/psecio-parse" + ], + "format": "progress" +} diff --git a/src/Command/ScanCommand.php b/src/Command/ScanCommand.php index cfe8d8a..684f7c1 100644 --- a/src/Command/ScanCommand.php +++ b/src/Command/ScanCommand.php @@ -8,20 +8,14 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\EventDispatcher\EventDispatcher; -use Symfony\Component\Console\Helper\ProgressBar; +use Psecio\Parse\Conf\ConfFactory; +use Psecio\Parse\Subscriber\SubscriberFactory; use Psecio\Parse\Subscriber\ExitCodeCatcher; -use Psecio\Parse\Subscriber\ConsoleDots; -use Psecio\Parse\Subscriber\ConsoleProgressBar; -use Psecio\Parse\Subscriber\ConsoleLines; -use Psecio\Parse\Subscriber\ConsoleDebug; -use Psecio\Parse\Subscriber\ConsoleReport; -use Psecio\Parse\Subscriber\Xml; use Psecio\Parse\Event\Events; use Psecio\Parse\Event\MessageEvent; use Psecio\Parse\RuleFactory; -use Psecio\Parse\RuleInterface; -use Psecio\Parse\Scanner; use Psecio\Parse\CallbackVisitor; +use Psecio\Parse\Scanner; use Psecio\Parse\FileIterator; use Psecio\Parse\DocComment\DocCommentFactory; use RuntimeException; @@ -41,43 +35,55 @@ protected function configure() ->addArgument( 'path', InputArgument::OPTIONAL|InputArgument::IS_ARRAY, - 'Paths to scan', - [] + 'Path to scan' ) ->addOption( 'format', 'f', InputOption::VALUE_REQUIRED, - 'Output format (progress, dots or xml)', - 'progress' + 'Output format (progress, dots, lines, debug or xml)' ) ->addOption( 'ignore-paths', 'i', InputOption::VALUE_REQUIRED, - 'Comma-separated list of paths to ignore', - '' + 'Comma-separated list of paths to ignore' ) ->addOption( 'extensions', 'x', InputOption::VALUE_REQUIRED, - 'Comma-separated list of file extensions to parse', - 'php,phps,phtml,php5' + 'Comma-separated list of file extensions to parse (default: php,phps,phtml,php5)' ) ->addOption( 'whitelist-rules', 'w', InputOption::VALUE_REQUIRED, - 'Comma-separated list of rules to use', - '' + 'Comma-separated list of rules to whitelist' ) ->addOption( 'blacklist-rules', 'b', InputOption::VALUE_REQUIRED, - 'Comma-separated list of rules to skip', - '' + 'Comma-separated list of rules to blacklist' + ) + ->addOption( + 'disable-annotations', + 'd', + InputOption::VALUE_NONE, + 'Skip all annotation-based rule toggles' + ) + ->addOption( + 'configuration', + 'c', + InputOption::VALUE_REQUIRED, + 'Read configuration from file' + ) + ->addOption( + 'no-configuration', + null, + InputOption::VALUE_NONE, + 'Ignore default configuration file' ) ->addOption( 'disable-annotations', @@ -95,95 +101,39 @@ protected function configure() * * @param InputInterface $input Input object * @param OutputInterface $output Output object - * @throws RuntimeException If output format is not valid * @return void */ protected function execute(InputInterface $input, OutputInterface $output) { + $conf = (new ConfFactory)->createConf($input, $confFileName); $dispatcher = new EventDispatcher; + (new SubscriberFactory($conf->getFormat(), $output))->addSubscribersTo($dispatcher); + $exitCode = new ExitCodeCatcher; $dispatcher->addSubscriber($exitCode); - $fileIterator = new FileIterator( - $input->getArgument('path'), - $this->parseCsv($input->getOption('ignore-paths')), - $this->parseCsv($input->getOption('extensions')) - ); - - $format = strtolower($input->getOption('format')); - switch ($format) { - case 'dots': - case 'progress': - $output->writeln("Parse: A PHP Security Scanner\n"); - if ($output->isVeryVerbose()) { - $dispatcher->addSubscriber( - new ConsoleDebug($output) - ); - } elseif ($output->isVerbose()) { - $dispatcher->addSubscriber( - new ConsoleLines($output) - ); - } elseif ('progress' == $format && $output->isDecorated()) { - $dispatcher->addSubscriber( - new ConsoleProgressBar(new ProgressBar($output, count($fileIterator))) - ); - } else { - $dispatcher->addSubscriber( - new ConsoleDots($output) - ); - } - $dispatcher->addSubscriber(new ConsoleReport($output)); - break; - case 'xml': - $dispatcher->addSubscriber(new Xml($output)); - break; - default: - throw new RuntimeException("Unknown output format '{$input->getOption('format')}'"); + if ($confFileName) { + $dispatcher->dispatch(Events::DEBUG, new MessageEvent("Reading configurations from $confFileName")); } - $ruleFactory = new RuleFactory( - $this->parseCsv($input->getOption('whitelist-rules')), - $this->parseCsv($input->getOption('blacklist-rules')) - ); - - $ruleCollection = $ruleFactory->createRuleCollection(); + $rules = (new RuleFactory($conf->getRuleWhitelist(), $conf->getRuleBlacklist()))->createRuleCollection(); - $ruleNames = implode(',', array_map( - function (RuleInterface $rule) { - return $rule->getName(); - }, - $ruleCollection->toArray() - )); - - $dispatcher->dispatch(Events::DEBUG, new MessageEvent("Using ruleset $ruleNames")); + $dispatcher->dispatch(Events::DEBUG, new MessageEvent("Using ruleset: $rules")); $docCommentFactory = new DocCommentFactory(); $scanner = new Scanner( $dispatcher, new CallbackVisitor( - $ruleCollection, + $rules, $docCommentFactory, !$input->getOption('disable-annotations') ) ); - $scanner->scan($fileIterator); - return $exitCode->getExitCode(); - } + $scanner->scan(new FileIterator($conf->getPaths(), $conf->getIgnorePaths(), $conf->getExtensions())); - /** - * Parse comma-separated values from string - * - * Using array_filter ensures that an empty array is returned when an empty - * string is parsed. - * - * @param string $string - * @return array - */ - public function parseCsv($string) - { - return array_filter(explode(',', $string)); + return $exitCode->getExitCode(); } } diff --git a/src/Conf/ConfFactory.php b/src/Conf/ConfFactory.php new file mode 100644 index 0000000..1347e7f --- /dev/null +++ b/src/Conf/ConfFactory.php @@ -0,0 +1,52 @@ +getConfFileInfo($input)) { + $confFileName = $confFileInfo->getFilename(); + $conf = new DualConf($conf, new JsonConf((new File($confFileInfo))->getContents())); + } + + return new DualConf($conf, new DefaultConf); + } + + /** + * Get info on configuration file to use + * + * @param InputInterface $input + * @return SplFileInfo|void + */ + private function getConfFileInfo(InputInterface $input) + { + if ($filename = $input->getOption('configuration')) { + return new SplFileInfo($filename); + } + + if (!$input->getOption('no-configuration')) { + $confFileInfo = new SplFileInfo('psecio-parse.json'); + if ($confFileInfo->isReadable()) { + return $confFileInfo; + } + } + } +} diff --git a/src/Conf/Configuration.php b/src/Conf/Configuration.php new file mode 100644 index 0000000..e536b70 --- /dev/null +++ b/src/Conf/Configuration.php @@ -0,0 +1,58 @@ +primary = $primary; + $this->secondary = $secondary; + } + + /** + * Get output format identifier + * + * @return string + */ + public function getFormat() + { + return strtolower($this->primary->getFormat() ?: $this->secondary->getFormat()); + } + + /** + * Get list of paths to scan + * + * @return string[] + */ + public function getPaths() + { + return $this->primary->getPaths() ?: $this->secondary->getPaths(); + } + + /** + * Get list of paths to ignore + * + * @return string[] + */ + public function getIgnorePaths() + { + return $this->primary->getIgnorePaths() ?: $this->secondary->getIgnorePaths(); + } + + /** + * Get list of extensions to scan + * + * @return string[] + */ + public function getExtensions() + { + return $this->primary->getExtensions() ?: $this->secondary->getExtensions(); + } + + /** + * Get list of whitelisted rules + * + * @return string[] + */ + public function getRuleWhitelist() + { + return $this->primary->getRuleWhitelist() ?: $this->secondary->getRuleWhitelist(); + } + + /** + * Get list of blacklisted rules + * + * @return string[] + */ + public function getRuleBlacklist() + { + return $this->primary->getRuleBlacklist() ?: $this->secondary->getRuleBlacklist(); + } + + /** + * Check if annotations should be disabled + * + * @return boolean + */ + public function disableAnnotations() + { + return $this->primary->disableAnnotations() || $this->secondary->disableAnnotations(); + } +} diff --git a/src/Conf/JsonConf.php b/src/Conf/JsonConf.php new file mode 100644 index 0000000..3ef9793 --- /dev/null +++ b/src/Conf/JsonConf.php @@ -0,0 +1,98 @@ +data = json_decode($json); + + $retriever = new UriRetriever; + $schema = $retriever->retrieve('file://' . realpath(__DIR__ . '/schema.json')); + + $validator = new Validator; + $validator->check($this->data, $schema); + + foreach ($validator->getErrors() as $error) { + throw new RuntimeException("Invalid configuration for {$error['property']}\n{$error['message']}"); + } + } + + public function getFormat() + { + return $this->read('format', ''); + } + + public function getPaths() + { + $finder = (new Finder())->directories(); + foreach ($this->read('paths', []) as $path) { + $finder->in($path); + } + $out = []; + foreach ($finder as $path) { + $out[] = $path; + } + return $out; + } + + public function getIgnorePaths() + { + return $this->read('ignore-paths', []); + } + + public function getExtensions() + { + return $this->read('extensions', []); + } + + public function getRuleWhitelist() + { + return $this->read('whitelist-rules', []); + } + + public function getRuleBlacklist() + { + return $this->read('blacklist-rules', []); + } + + public function disableAnnotations() + { + return $this->read('disable-annotations', false); + } + + /** + * Read configuration options + * + * @param string $property Name of property to read + * @param mixed $default Returned if otion is not set + * @return mixed + */ + private function read($property, $default) + { + if (property_exists($this->data, $property)) { + return $this->data->$property; + } + return $default; + } +} diff --git a/src/Conf/UserConf.php b/src/Conf/UserConf.php new file mode 100644 index 0000000..6517cec --- /dev/null +++ b/src/Conf/UserConf.php @@ -0,0 +1,75 @@ +input = $input; + } + + public function getFormat() + { + return $this->input->getOption('format'); + } + + public function getPaths() + { + return $this->input->getArgument('path'); + } + + public function getIgnorePaths() + { + return $this->parseCsv($this->input->getOption('ignore-paths')); + } + + public function getExtensions() + { + return $this->parseCsv($this->input->getOption('extensions')); + } + + public function getRuleWhitelist() + { + return $this->parseCsv($this->input->getOption('whitelist-rules')); + } + + public function getRuleBlacklist() + { + return $this->parseCsv($this->input->getOption('blacklist-rules')); + } + + public function disableAnnotations() + { + return $this->input->getOption('disable-annotations'); + } + + /** + * Parse comma-separated values from string + * + * Using array_filter ensures that an empty array is returned when an empty + * string is parsed. + * + * @param string $string + * @return array + */ + private function parseCsv($string) + { + return array_filter(explode(',', $string)); + } +} diff --git a/src/Conf/schema.json b/src/Conf/schema.json new file mode 100644 index 0000000..e7279cc --- /dev/null +++ b/src/Conf/schema.json @@ -0,0 +1,57 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Configurations", + "description": "Configurations for the psecio-parse security scanner", + "type": "object", + "properties": { + "paths": { + "description": "List of paths to scan", + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "ignore-paths": { + "description": "List of paths to ignore", + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "extensions": { + "description": "List of file extensions to scan", + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "whitelist-rules": { + "description": "List of rules to whitelist", + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "blacklist-rules": { + "description": "List of rules to blacklist", + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "disable-annotations": { + "description": "Flag if all annotation-based rule toggles should be ignored", + "type": "boolean" + }, + "format": { + "description": "Output format", + "type": "string" + } + }, + "additionalProperties": false +} diff --git a/src/Event/Events.php b/src/Event/Events.php index 406a5f7..1d53625 100644 --- a/src/Event/Events.php +++ b/src/Event/Events.php @@ -9,6 +9,9 @@ interface Events { /** * A scan.start event is fired when the scan starts + * + * The event listener receives an \Psecio\Parse\Event\MessageEvent instance + * with the number of files to scan as message */ const SCAN_START = 'scan.start'; diff --git a/src/RuleCollection.php b/src/RuleCollection.php index 3844d80..40bcaab 100644 --- a/src/RuleCollection.php +++ b/src/RuleCollection.php @@ -111,4 +111,19 @@ public function toArray() { return $this->rules; } + + /** + * Get a comma separated list of rules in collection + * + * @return string + */ + public function __toString() + { + return implode(',', array_map( + function (RuleInterface $rule) { + return $rule->getName(); + }, + $this->toArray() + )); + } } diff --git a/src/Scanner.php b/src/Scanner.php index 7e3f5d0..f345099 100644 --- a/src/Scanner.php +++ b/src/Scanner.php @@ -79,7 +79,7 @@ public function onNodeFailure(RuleInterface $rule, Node $node, File $file) */ public function scan(FileIterator $fileIterator) { - $this->dispatcher->dispatch(self::SCAN_START); + $this->dispatcher->dispatch(self::SCAN_START, new Event\MessageEvent(count($fileIterator))); foreach ($fileIterator as $file) { $this->dispatcher->dispatch(self::FILE_OPEN, new Event\FileEvent($file)); diff --git a/src/Subscriber/Subscriber.php b/src/Subscriber/BaseSubscriber.php similarity index 92% rename from src/Subscriber/Subscriber.php rename to src/Subscriber/BaseSubscriber.php index 793f772..0c211ea 100644 --- a/src/Subscriber/Subscriber.php +++ b/src/Subscriber/BaseSubscriber.php @@ -14,7 +14,7 @@ * * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ -class Subscriber implements EventSubscriberInterface, Events +abstract class BaseSubscriber implements EventSubscriberInterface, Events { /** * Returns an array of event names this subscriber wants to listen to @@ -37,9 +37,10 @@ public static function getSubscribedEvents() /** * Empty on scan start method * + * @param MessageEvent $event * @return void */ - public function onScanStart() + public function onScanStart(MessageEvent $event) { } diff --git a/src/Subscriber/ConsoleDebug.php b/src/Subscriber/Console/Debug.php similarity index 83% rename from src/Subscriber/ConsoleDebug.php rename to src/Subscriber/Console/Debug.php index 8398f99..a99ae33 100644 --- a/src/Subscriber/ConsoleDebug.php +++ b/src/Subscriber/Console/Debug.php @@ -1,13 +1,13 @@ write("[DEBUG] Starting scan\n"); $this->startTime = microtime(true); } diff --git a/src/Subscriber/ConsoleDots.php b/src/Subscriber/Console/Dots.php similarity index 90% rename from src/Subscriber/ConsoleDots.php rename to src/Subscriber/Console/Dots.php index cd86cae..031cfb4 100644 --- a/src/Subscriber/ConsoleDots.php +++ b/src/Subscriber/Console/Dots.php @@ -1,18 +1,17 @@ fileCount = 0; } diff --git a/src/Subscriber/Console/Header.php b/src/Subscriber/Console/Header.php new file mode 100644 index 0000000..d3bf399 --- /dev/null +++ b/src/Subscriber/Console/Header.php @@ -0,0 +1,24 @@ +writeln("Parse: A PHP Security Scanner\n"); + $this->setOutput($output); + } +} diff --git a/src/Subscriber/ConsoleLines.php b/src/Subscriber/Console/Lines.php similarity index 92% rename from src/Subscriber/ConsoleLines.php rename to src/Subscriber/Console/Lines.php index 8503d13..1005d1d 100644 --- a/src/Subscriber/ConsoleLines.php +++ b/src/Subscriber/Console/Lines.php @@ -1,6 +1,6 @@ progressBar = $progressBar; + parent::__construct($output); + $this->progressBar = $progressBar ?: new ProgressBar($output); $this->progressBar->setFormat( $this->progressBar->getMaxSteps() ? self::FORMAT_STEPS_KNOWN : self::FORMAT_STEPS_UNKNOWN ); @@ -40,11 +44,12 @@ public function __construct(ProgressBar $progressBar) /** * Reset progress bar on scan start * + * @param MessageEvent $event * @return void */ - public function onScanStart() + public function onScanStart(MessageEvent $event) { - $this->progressBar->start(); + $this->progressBar->start((int)$event->getMessage()); } /** diff --git a/src/Subscriber/ConsoleReport.php b/src/Subscriber/Console/Report.php similarity index 93% rename from src/Subscriber/ConsoleReport.php rename to src/Subscriber/Console/Report.php index 0312745..4c43856 100644 --- a/src/Subscriber/ConsoleReport.php +++ b/src/Subscriber/Console/Report.php @@ -1,15 +1,18 @@ fileCount = 0; $this->issues = []; diff --git a/src/Subscriber/ExitCodeCatcher.php b/src/Subscriber/ExitCodeCatcher.php index f626d51..19b71e8 100644 --- a/src/Subscriber/ExitCodeCatcher.php +++ b/src/Subscriber/ExitCodeCatcher.php @@ -8,7 +8,7 @@ /** * Capture the exit status code of a scan */ -class ExitCodeCatcher extends Subscriber +class ExitCodeCatcher extends BaseSubscriber { /** * @var integer Suggested exit code diff --git a/src/Subscriber/OutputTrait.php b/src/Subscriber/OutputTrait.php index 30fcfa0..4c23fa7 100644 --- a/src/Subscriber/OutputTrait.php +++ b/src/Subscriber/OutputTrait.php @@ -20,6 +20,16 @@ trait OutputTrait * @param OutputInterface $output */ public function __construct(OutputInterface $output) + { + $this->setOutput($output); + } + + /** + * Register output interface + * + * @param OutputInterface $output + */ + public function setOutput(OutputInterface $output) { $this->output = $output; } diff --git a/src/Subscriber/SubscriberFactory.php b/src/Subscriber/SubscriberFactory.php new file mode 100644 index 0000000..1afa86c --- /dev/null +++ b/src/Subscriber/SubscriberFactory.php @@ -0,0 +1,159 @@ + [ + self::VERBOSITY_VERBOSE => self::FORMAT_LINES, + self::VERBOSITY_DEBUG => self::FORMAT_DEBUG + ], + self::FORMAT_DOTS => [ + self::VERBOSITY_VERBOSE => self::FORMAT_LINES, + self::VERBOSITY_DEBUG => self::FORMAT_DEBUG + ], + self::FORMAT_LINES => [ + self::VERBOSITY_VERBOSE => self::FORMAT_LINES, + self::VERBOSITY_DEBUG => self::FORMAT_DEBUG + ], + self::FORMAT_DEBUG => [ + self::VERBOSITY_VERBOSE => self::FORMAT_DEBUG, + self::VERBOSITY_DEBUG => self::FORMAT_DEBUG + ], + self::FORMAT_XML => [ + self::VERBOSITY_VERBOSE => self::FORMAT_XML, + self::VERBOSITY_DEBUG => self::FORMAT_XML + ] + ]; + + /** + * @var array Maps subscribers to format identifiers + */ + private $subscriberMap = [ + self::FORMAT_PROGRESS => [ + '\Psecio\Parse\Subscriber\Console\Progress', + '\Psecio\Parse\Subscriber\Console\Report' + ], + self::FORMAT_DOTS => [ + '\Psecio\Parse\Subscriber\Console\Dots', + '\Psecio\Parse\Subscriber\Console\Report' + ], + self::FORMAT_LINES => [ + '\Psecio\Parse\Subscriber\Console\Lines', + '\Psecio\Parse\Subscriber\Console\Report' + ], + self::FORMAT_DEBUG => [ + '\Psecio\Parse\Subscriber\Console\Debug', + '\Psecio\Parse\Subscriber\Console\Report' + ], + self::FORMAT_XML => [ + '\Psecio\Parse\Subscriber\Xml' + ] + ]; + + /** + * @var string Format identifier + */ + private $format; + + /** + * @var OutputInterface Output object + */ + private $output; + + /** + * Set requested format and output object + * + * @param string $format Requested format + * @param OutputInterface $output Output object + * @throws RuntimeException If format is not valid + */ + public function __construct($format, OutputInterface $output) + { + if (!isset($this->formatTransitions[$format])) { + throw new RuntimeException("Unknown output format '{$format}'"); + } + + if ($output->isVeryVerbose()) { + $format = $this->formatTransitions[$format][self::VERBOSITY_DEBUG]; + } elseif ($output->isVerbose()) { + $format = $this->formatTransitions[$format][self::VERBOSITY_VERBOSE]; + } + + if (self::FORMAT_PROGRESS == $format && !$output->isDecorated()) { + $format = self::FORMAT_DOTS; + } + + $this->format = $format; + $this->output = $output; + } + + /** + * Get format identifier + * + * @return string + */ + public function getFormat() + { + return $this->format; + } + + /** + * Add subscribers to event dispatcher + * + * @param EventDispatcherInterface $dispatcher + * @return void + */ + public function addSubscribersTo(EventDispatcherInterface $dispatcher) + { + foreach ($this->subscriberMap[$this->getFormat()] as $classname) { + $dispatcher->addSubscriber(new $classname($this->output)); + } + } +} diff --git a/src/Subscriber/Xml.php b/src/Subscriber/Xml.php index 9a78e4b..8b719ce 100644 --- a/src/Subscriber/Xml.php +++ b/src/Subscriber/Xml.php @@ -6,11 +6,12 @@ use XMLWriter; use Psecio\Parse\Event\IssueEvent; use Psecio\Parse\Event\ErrorEvent; +use Psecio\Parse\Event\MessageEvent; /** * Xml generating event subscriber */ -class Xml extends Subscriber +class Xml extends BaseSubscriber { use OutputTrait; @@ -22,9 +23,10 @@ class Xml extends Subscriber /** * Create document at scan start * + * @param MessageEvent $event * @return void */ - public function onScanStart() + public function onScanStart(MessageEvent $event) { $this->xmlWriter = new XMLWriter; $this->xmlWriter->openMemory(); diff --git a/tests/Command/ScanCommandTest.php b/tests/Command/ScanCommandTest.php index a1359ac..6d384a6 100644 --- a/tests/Command/ScanCommandTest.php +++ b/tests/Command/ScanCommandTest.php @@ -78,25 +78,6 @@ public function testExceptionOnUnknownFormat() $this->executeCommand(['--format' => 'this-format-does-not-exist']); } - public function testParseCsv() - { - $this->assertSame( - ['php', 'phps'], - (new ScanCommand)->parseCsv('php,phps'), - 'parsing comma separated values should work' - ); - $this->assertSame( - ['php', 'phps'], - array_values((new ScanCommand)->parseCsv('php,,phps')), - 'multiple commas should be skipped while parsing csv' - ); - $this->assertSame( - [], - (new ScanCommand)->parseCsv(''), - 'parsing an empty string should return an empty array' - ); - } - private function executeCommand(array $input, array $options = array()) { $application = new Application; diff --git a/tests/Conf/ConfFactoryTest.php b/tests/Conf/ConfFactoryTest.php new file mode 100644 index 0000000..3167253 --- /dev/null +++ b/tests/Conf/ConfFactoryTest.php @@ -0,0 +1,79 @@ +setExpectedException('RuntimeException'); + (new ConfFactory)->createConf( + m::mock('Symfony\Component\Console\Input\InputInterface') + ->shouldReceive('getOption') + ->with('configuration') + ->andReturn('this-file-does-not-exist') + ->mock() + ); + } + + public function testCustomConfFile() + { + $filename = sys_get_temp_dir() . '/' . uniqid('psecio-parse') . '.json'; + file_put_contents($filename, '{"extensions": ["foobar"]}'); + + $conf = (new ConfFactory)->createConf( + m::mock('Symfony\Component\Console\Input\InputInterface') + ->shouldReceive('getOption')->with('configuration')->andReturn($filename) + ->shouldReceive('getOption')->with('extensions')->andReturn('') + ->mock() + ); + $this->assertSame(['foobar'], $conf->getExtensions()); + + unlink($filename); + } + + public function testDefaultConfFile() + { + $cwd = getcwd(); + chdir(sys_get_temp_dir()); + + $filename = sys_get_temp_dir() . '/psecio-parse.json'; + file_put_contents($filename, '{"extensions": ["foobar"]}'); + + $confUsingDefaultFile = (new ConfFactory)->createConf( + m::mock('Symfony\Component\Console\Input\InputInterface') + ->shouldReceive('getOption')->with('configuration')->andReturn('') + ->shouldReceive('getOption')->with('no-configuration')->andReturn(false) + ->shouldReceive('getOption')->with('extensions')->andReturn('') + ->mock() + ); + $this->assertSame(['foobar'], $confUsingDefaultFile->getExtensions()); + + $confIgnoringDefaultFile = (new ConfFactory)->createConf( + m::mock('Symfony\Component\Console\Input\InputInterface') + ->shouldReceive('getOption')->with('configuration')->andReturn('') + ->shouldReceive('getOption')->with('no-configuration')->andReturn(true) + ->shouldReceive('getOption')->with('extensions')->andReturn('') + ->mock() + ); + $this->assertTrue(in_array('php', $confIgnoringDefaultFile->getExtensions())); + + unlink($filename); + + $confDefaultFileMissing = (new ConfFactory)->createConf( + m::mock('Symfony\Component\Console\Input\InputInterface') + ->shouldReceive('getOption')->with('configuration')->andReturn('') + ->shouldReceive('getOption')->with('no-configuration')->andReturn(false) + ->shouldReceive('getOption')->with('extensions')->andReturn('') + ->mock() + ); + $this->assertTrue(in_array('php', $confDefaultFileMissing->getExtensions())); + + chdir($cwd); + } +} diff --git a/tests/Conf/DefaultConfTest.php b/tests/Conf/DefaultConfTest.php new file mode 100644 index 0000000..209f477 --- /dev/null +++ b/tests/Conf/DefaultConfTest.php @@ -0,0 +1,18 @@ +assertSame('progress', $conf->getFormat()); + $this->assertSame([], $conf->getPaths()); + $this->assertSame([], $conf->getIgnorePaths()); + $this->assertSame(['php', 'phps', 'phtml', 'php5'], $conf->getExtensions()); + $this->assertSame([], $conf->getRuleWhitelist()); + $this->assertSame([], $conf->getRuleBlacklist()); + $this->assertFalse($conf->disableAnnotations()); + } +} diff --git a/tests/Conf/DualConfTest.php b/tests/Conf/DualConfTest.php new file mode 100644 index 0000000..6b22850 --- /dev/null +++ b/tests/Conf/DualConfTest.php @@ -0,0 +1,41 @@ +shouldReceive($method)->andReturn($first)->mock(), + m::mock('Psecio\Parse\Conf\Configuration')->shouldReceive($method)->andReturn($second)->mock() + ); + $this->assertSame($expected, $conf->$method()); + } +} diff --git a/tests/Conf/JsonConfTest.php b/tests/Conf/JsonConfTest.php new file mode 100644 index 0000000..c61c35f --- /dev/null +++ b/tests/Conf/JsonConfTest.php @@ -0,0 +1,49 @@ +setExpectedException('RuntimeException'); + new JsonConf('this-is-not-a-valid-json-string'); + } + + public function testExceptionSchemaViolation() + { + $this->setExpectedException('RuntimeException'); + new JsonConf('{"undefined": "this property is not definied in the schema"}'); + } + + public function configurationProvider() + { + return [ + ['{}', 'getFormat', ''], + ['{}', 'getPaths', []], + ['{}', 'getIgnorePaths', []], + ['{}', 'getExtensions', []], + ['{}', 'getRuleWhitelist', []], + ['{}', 'getRuleBlacklist', []], + ['{}', 'disableAnnotations', false], + ['{"format":"dots"}', 'getFormat', 'dots'], + ['{"paths":["path"]}', 'getPaths', ['path']], + ['{"ignore-paths":["path"]}', 'getIgnorePaths', ['path']], + ['{"extensions":["php"]}', 'getExtensions', ['php']], + ['{"whitelist-rules":["rule"]}', 'getRuleWhitelist', ['rule']], + ['{"blacklist-rules":["rule"]}', 'getRuleBlacklist', ['rule']], + ['{"disable-annotations": true}', 'disableAnnotations', true], + ]; + } + + /** + * @dataProvider configurationProvider + */ + public function testConfiguration($json, $method, $expected) + { + $this->assertSame( + $expected, + (new JsonConf($json))->$method() + ); + } +} diff --git a/tests/Conf/UserConfTest.php b/tests/Conf/UserConfTest.php new file mode 100644 index 0000000..135d929 --- /dev/null +++ b/tests/Conf/UserConfTest.php @@ -0,0 +1,92 @@ +shouldReceive('getOption') + ->with('format') + ->andReturn('foobar') + ->mock() + ); + $this->assertSame('foobar', $conf->getFormat()); + } + + public function testPaths() + { + $conf = new UserConf( + m::mock('Symfony\Component\Console\Input\InputInterface') + ->shouldReceive('getArgument') + ->with('path') + ->andReturn(['path']) + ->mock() + ); + $this->assertSame(['path'], $conf->getPaths()); + } + + public function testIgnorePaths() + { + $conf = new UserConf( + m::mock('Symfony\Component\Console\Input\InputInterface') + ->shouldReceive('getOption') + ->with('ignore-paths') + ->andReturn('path1,path2') + ->mock() + ); + $this->assertSame(['path1', 'path2'], $conf->getIgnorePaths()); + } + + public function testExtensions() + { + $conf = new UserConf( + m::mock('Symfony\Component\Console\Input\InputInterface') + ->shouldReceive('getOption') + ->with('extensions') + ->andReturn('php,,phps') + ->mock() + ); + $this->assertSame(['php', 'phps'], array_values($conf->getExtensions())); + } + + public function testRuleWhitelist() + { + $conf = new UserConf( + m::mock('Symfony\Component\Console\Input\InputInterface') + ->shouldReceive('getOption') + ->with('whitelist-rules') + ->andReturn('') + ->mock() + ); + $this->assertSame([], $conf->getRuleWhitelist()); + } + + public function testRuleBlacklist() + { + $conf = new UserConf( + m::mock('Symfony\Component\Console\Input\InputInterface') + ->shouldReceive('getOption') + ->with('blacklist-rules') + ->andReturn('rule') + ->mock() + ); + $this->assertSame(['rule'], $conf->getRuleBlacklist()); + } + + public function testDisableAnnotations() + { + $conf = new UserConf( + m::mock('Symfony\Component\Console\Input\InputInterface') + ->shouldReceive('getOption') + ->with('disable-annotations') + ->andReturn(true) + ->mock() + ); + $this->assertTrue($conf->disableAnnotations()); + } +} diff --git a/tests/RuleCollectionTest.php b/tests/RuleCollectionTest.php index fb18c80..fbc9111 100644 --- a/tests/RuleCollectionTest.php +++ b/tests/RuleCollectionTest.php @@ -116,4 +116,14 @@ public function testExceptionInRemove() $this->setExpectedException('RuntimeException'); (new RuleCollection)->remove('does-not-exist'); } + + public function testTostring() + { + $ruleA = m::mock('\Psecio\Parse\RuleInterface')->shouldReceive('getName')->andReturn('a')->mock(); + $ruleB = m::mock('\Psecio\Parse\RuleInterface')->shouldReceive('getName')->andReturn('b')->mock(); + $this->assertSame( + 'a,b', + (string)(new RuleCollection([$ruleA, $ruleB])) + ); + } } diff --git a/tests/ScannerTest.php b/tests/ScannerTest.php index fa7bf7e..32f3c80 100644 --- a/tests/ScannerTest.php +++ b/tests/ScannerTest.php @@ -41,12 +41,12 @@ public function testErrorOnPhpsFile() m::mock('\PhpParser\NodeTraverser')->shouldReceive('traverse', 'addVisitor')->mock() ); - $scanner->scan( - m::mock('\Psecio\Parse\FileIterator') - ->shouldReceive('getIterator') - ->andReturn(new \ArrayIterator([$file])) - ->mock() - ); + $fileIterator = m::mock('\Psecio\Parse\FileIterator') + ->shouldReceive('getIterator')->andReturn(new \ArrayIterator([$file])) + ->shouldReceive('count')->andReturn(1) + ->mock(); + + $scanner->scan($fileIterator); } public function testErrorOnParseException() @@ -65,18 +65,21 @@ public function testErrorOnParseException() ->shouldReceive('addVisitor')->shouldReceive('traverse')->mock() ); - $scanner->scan( - m::mock('\Psecio\Parse\FileIterator') - ->shouldReceive('getIterator') - ->andReturn(new \ArrayIterator([$file])) - ->mock() - ); + $fileIterator = m::mock('\Psecio\Parse\FileIterator') + ->shouldReceive('getIterator')->andReturn(new \ArrayIterator([$file])) + ->shouldReceive('count')->andReturn(1) + ->mock(); + + $scanner->scan($fileIterator); } private function createErrorDispatcherMock() { $dispatcher = m::mock('\Symfony\Component\EventDispatcher\EventDispatcherInterface'); - $dispatcher->shouldReceive('dispatch')->ordered()->once()->with(Scanner::SCAN_START); + $dispatcher->shouldReceive('dispatch')->ordered()->once()->with( + Scanner::SCAN_START, + m::type('\Psecio\Parse\Event\MessageEvent') + ); $dispatcher->shouldReceive('dispatch')->ordered()->once()->with( Scanner::FILE_OPEN, m::type('\Psecio\Parse\Event\FileEvent') diff --git a/tests/Subscriber/SubscriberTest.php b/tests/Subscriber/BaseSubscriberTest.php similarity index 71% rename from tests/Subscriber/SubscriberTest.php rename to tests/Subscriber/BaseSubscriberTest.php index 618b9b8..15ec30d 100644 --- a/tests/Subscriber/SubscriberTest.php +++ b/tests/Subscriber/BaseSubscriberTest.php @@ -4,20 +4,20 @@ use Mockery as m; -class SubscriberTest extends \PHPUnit_Framework_TestCase +class BaseSubscriberTest extends \PHPUnit_Framework_TestCase { public function testSubscription() { $this->assertInternalType( 'array', - Subscriber::getSubscribedEvents() + BaseSubscriber::getSubscribedEvents() ); } public function testEmptyMethods() { - $subscriber = new Subscriber; - $this->assertNull($subscriber->onScanStart()); + $subscriber = m::mock('\Psecio\Parse\Subscriber\BaseSubscriber[]'); + $this->assertNull($subscriber->onScanStart(m::mock('\Psecio\Parse\Event\MessageEvent'))); $this->assertNull($subscriber->onScanComplete()); $this->assertNull($subscriber->onFileOpen(m::mock('\Psecio\Parse\Event\FileEvent'))); $this->assertNull($subscriber->onFileClose()); diff --git a/tests/Subscriber/ConsoleDebugTest.php b/tests/Subscriber/Console/DebugTest.php similarity index 77% rename from tests/Subscriber/ConsoleDebugTest.php rename to tests/Subscriber/Console/DebugTest.php index 104168c..014b4de 100644 --- a/tests/Subscriber/ConsoleDebugTest.php +++ b/tests/Subscriber/Console/DebugTest.php @@ -1,16 +1,17 @@ shouldReceive('writeln')->once()->with("/Parse/"); $output->shouldReceive('write')->ordered()->once()->with("[DEBUG] Starting scan\n"); $output->shouldReceive('write')->ordered()->once()->with("[DEBUG] debug message\n"); $output->shouldReceive('write')->ordered()->once()->with("/\[DEBUG\] Scan completed in \d+\.\d+ seconds/"); @@ -19,10 +20,10 @@ public function testOutput() $messageEvent = m::mock('\Psecio\Parse\Event\MessageEvent'); $messageEvent->shouldReceive('getMessage')->andReturn('debug message'); - $console = new ConsoleDebug($output); + $console = new Debug($output); // Should write debug start - $console->onScanStart(); + $console->onScanStart(m::mock('\Psecio\Parse\Event\MessageEvent')); // Writes debug message $console->onDebug($messageEvent); diff --git a/tests/Subscriber/ConsoleDotsTest.php b/tests/Subscriber/Console/DotsTest.php similarity index 83% rename from tests/Subscriber/ConsoleDotsTest.php rename to tests/Subscriber/Console/DotsTest.php index 298d973..58d412e 100644 --- a/tests/Subscriber/ConsoleDotsTest.php +++ b/tests/Subscriber/Console/DotsTest.php @@ -1,25 +1,26 @@ shouldReceive('writeln')->once()->with("/Parse/"); $output->shouldReceive('write')->ordered()->once()->with("."); $output->shouldReceive('write')->ordered()->once()->with("E"); $output->shouldReceive('write')->ordered()->once()->with("\n"); $output->shouldReceive('write')->ordered()->once()->with("I"); - $console = new ConsoleDots($output); + $console = new Dots($output); $console->setLineLength(2); - $console->onScanStart(); + $console->onScanStart(m::mock('\Psecio\Parse\Event\MessageEvent')); // Writes a dot as a file is scanned $console->onFileOpen(m::mock('\Psecio\Parse\Event\FileEvent')); diff --git a/tests/Subscriber/ConsoleLinesTest.php b/tests/Subscriber/Console/LinesTest.php similarity index 88% rename from tests/Subscriber/ConsoleLinesTest.php rename to tests/Subscriber/Console/LinesTest.php index 14e8243..bfb98f7 100644 --- a/tests/Subscriber/ConsoleLinesTest.php +++ b/tests/Subscriber/Console/LinesTest.php @@ -1,16 +1,17 @@ shouldReceive('writeln')->once()->with("/Parse/"); $output->shouldReceive('write')->ordered()->once()->with("[PARSE] /path/to/file\n"); $output->shouldReceive('write')->ordered()->once()->with("[PARSE] /path/to/file\n"); $output->shouldReceive('write')->ordered()->once()->with("[ERROR] message in /path/to/file\n"); @@ -32,9 +33,9 @@ public function testOutput() $issueEvent->shouldReceive('getRule->getName')->andReturn('Rule'); $issueEvent->shouldReceive('getFile->getPath')->andReturn('path'); - $console = new ConsoleLines($output); + $console = new Lines($output); - $console->onScanStart(); + $console->onScanStart(m::mock('\Psecio\Parse\Event\MessageEvent')); // File open writes [PARSE] line $console->onFileOpen($fileEvent); diff --git a/tests/Subscriber/ConsoleProgressBarTest.php b/tests/Subscriber/Console/ProgressTest.php similarity index 58% rename from tests/Subscriber/ConsoleProgressBarTest.php rename to tests/Subscriber/Console/ProgressTest.php index 7b05714..de9b85b 100644 --- a/tests/Subscriber/ConsoleProgressBarTest.php +++ b/tests/Subscriber/Console/ProgressTest.php @@ -1,10 +1,10 @@ shouldReceive('advance')->ordered()->once(); $bar->shouldReceive('finish')->ordered()->once(); - $console = new ConsoleProgressBar($bar); - $console->onScanStart(); + $output = m::mock('\Symfony\Component\Console\Output\OutputInterface'); + $output->shouldReceive('writeln')->once()->with("/Parse/"); + + $console = new Progress($output, $bar); + $console->onScanStart( + m::mock('\Psecio\Parse\Event\MessageEvent')->shouldReceive('getMessage')->mock() + ); $console->onFileClose(); $console->onScanComplete(); } diff --git a/tests/Subscriber/ConsoleReportTest.php b/tests/Subscriber/Console/ReportTest.php similarity index 88% rename from tests/Subscriber/ConsoleReportTest.php rename to tests/Subscriber/Console/ReportTest.php index 62362ec..4c58cc4 100644 --- a/tests/Subscriber/ConsoleReportTest.php +++ b/tests/Subscriber/Console/ReportTest.php @@ -1,14 +1,14 @@ shouldReceive('writeln') ->once() @@ -16,7 +16,7 @@ public function testPassReport() ->mock() ); - $report->onScanStart(); + $report->onScanStart(m::mock('\Psecio\Parse\Event\MessageEvent')); $report->onFileOpen(m::mock('\Psecio\Parse\Event\FileEvent')); $report->onFileOpen(m::mock('\Psecio\Parse\Event\FileEvent')); $report->onScanComplete(); @@ -41,7 +41,7 @@ public function testFailureReport() FAILURES! Scanned: 0, Errors: 1, Issues: 1."; - $report = new ConsoleReport( + $report = new Report( m::mock('\Symfony\Component\Console\Output\OutputInterface') ->shouldReceive('writeln') ->once() @@ -49,7 +49,7 @@ public function testFailureReport() ->mock() ); - $report->onScanStart(); + $report->onScanStart(m::mock('\Psecio\Parse\Event\MessageEvent')); $errorEvent = m::mock('\Psecio\Parse\Event\ErrorEvent'); $errorEvent->shouldReceive('getMessage')->once()->andReturn('error description'); diff --git a/tests/Subscriber/SubscriberFactoryTest.php b/tests/Subscriber/SubscriberFactoryTest.php new file mode 100644 index 0000000..84cbd66 --- /dev/null +++ b/tests/Subscriber/SubscriberFactoryTest.php @@ -0,0 +1,84 @@ +setExpectedException('RuntimeException'); + new SubscriberFactory( + 'invalid-format-identifier', + m::mock('Symfony\Component\Console\Output\OutputInterface') + ); + } + + public function testDebugTransition() + { + $factory = new SubscriberFactory( + SubscriberFactory::FORMAT_DOTS, + m::mock('Symfony\Component\Console\Output\OutputInterface') + ->shouldReceive('isVeryVerbose') + ->andReturn(true) + ->mock() + ); + $this->assertSame( + SubscriberFactory::FORMAT_DEBUG, + $factory->getFormat() + ); + } + + public function testVerboseTransition() + { + $factory = new SubscriberFactory( + SubscriberFactory::FORMAT_DOTS, + m::mock('Symfony\Component\Console\Output\OutputInterface') + ->shouldReceive('isVeryVerbose')->andReturn(false) + ->shouldReceive('isVerbose')->andReturn(true) + ->mock() + ); + $this->assertSame( + SubscriberFactory::FORMAT_LINES, + $factory->getFormat() + ); + } + + public function testNoAnsiTransition() + { + $factory = new SubscriberFactory( + SubscriberFactory::FORMAT_PROGRESS, + m::mock('Symfony\Component\Console\Output\OutputInterface') + ->shouldReceive('isVeryVerbose')->andReturn(false) + ->shouldReceive('isVerbose')->andReturn(false) + ->shouldReceive('isDecorated')->andReturn(false) + ->mock() + ); + $this->assertSame( + SubscriberFactory::FORMAT_DOTS, + $factory->getFormat() + ); + } + + public function testAddSubscribersToDispatcher() + { + $factory = new SubscriberFactory( + SubscriberFactory::FORMAT_XML, + m::mock('Symfony\Component\Console\Output\OutputInterface') + ->shouldReceive('isVeryVerbose')->andReturn(false) + ->shouldReceive('isVerbose')->andReturn(false) + ->shouldReceive('isDecorated')->andReturn(true) + ->mock() + ); + $factory->addSubscribersTo( + m::mock('Symfony\Component\EventDispatcher\EventDispatcherInterface') + ->shouldReceive('addSubscriber') + ->once() + ->mock() + ); + } +} diff --git a/tests/Subscriber/XmlTest.php b/tests/Subscriber/XmlTest.php index e76bbb3..53d8f01 100644 --- a/tests/Subscriber/XmlTest.php +++ b/tests/Subscriber/XmlTest.php @@ -33,7 +33,7 @@ public function testGenerateXml() $xml = new Xml($output); - $xml->onScanStart(); + $xml->onScanStart(m::mock('\Psecio\Parse\Event\MessageEvent')); $errorEvent = m::mock('\Psecio\Parse\Event\ErrorEvent'); $errorEvent->shouldReceive('getMessage')->once()->andReturn('error description');