diff --git a/src/ClamavValidator/ClamavValidator.php b/src/ClamavValidator/ClamavValidator.php index 6da9f56..1824063 100755 --- a/src/ClamavValidator/ClamavValidator.php +++ b/src/ClamavValidator/ClamavValidator.php @@ -11,6 +11,11 @@ use Socket\Raw\Factory as SocketFactory; use Symfony\Component\HttpFoundation\File\UploadedFile; +/** + * @deprecated Use {@see \Sunspikes\ClamavValidator\Rules\ClamAv} validation rule instead. + * + * Clamav Validator + */ class ClamavValidator extends Validator { /** diff --git a/src/ClamavValidator/ClamavValidatorServiceProvider.php b/src/ClamavValidator/ClamavValidatorServiceProvider.php index d57160b..3524c0e 100755 --- a/src/ClamavValidator/ClamavValidatorServiceProvider.php +++ b/src/ClamavValidator/ClamavValidatorServiceProvider.php @@ -4,6 +4,7 @@ use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; +use Sunspikes\ClamavValidator\Rules\ClamAv; class ClamavValidatorServiceProvider extends ServiceProvider { @@ -13,7 +14,7 @@ class ClamavValidatorServiceProvider extends ServiceProvider * @var array */ protected $rules = [ - 'clamav', + 'clamav' => ClamAv::class, ]; /** @@ -28,21 +29,12 @@ public function boot() $this->publishes([ __DIR__ . '/../../config/clamav.php' => $this->app->configPath('clamav.php'), ], 'config'); + $this->publishes([ __DIR__ . '/../lang' => method_exists($this->app, 'langPath') ? $this->app->langPath().'/vendor/clamav-validator' : $this->app->resourcePath('lang/vendor/clamav-validator'), ], 'lang'); - $this->app['validator'] - ->resolver(function ($translator, $data, $rules, $messages, $customAttributes = []) { - return new ClamavValidator( - $translator, - $data, - $rules, - $messages, - $customAttributes - ); - }); $this->addNewRules(); } @@ -57,32 +49,32 @@ public function getRules(): array return $this->rules; } - /** * Add new rules to the validator. */ protected function addNewRules() { - foreach ($this->getRules() as $rule) { - $this->extendValidator($rule); + foreach ($this->getRules() as $token => $rule) { + $this->extendValidator($token, $rule); } } - /** * Extend the validator with new rules. * + * @param string $token * @param string $rule + * * @return void */ - protected function extendValidator(string $rule) + protected function extendValidator(string $token, string $rule) { - $method = Str::studly($rule); $translation = $this->app['translator']->get('clamav-validator::validation'); + $this->app['validator']->extend( - $rule, - ClamavValidator::class . '@validate' . $method, - $translation[$rule] ?? [] + $token, + $rule . '@validate', + $translation[$token] ?? [] ); } diff --git a/src/ClamavValidator/Rules/ClamAv.php b/src/ClamavValidator/Rules/ClamAv.php new file mode 100644 index 0000000..994bd9c --- /dev/null +++ b/src/ClamavValidator/Rules/ClamAv.php @@ -0,0 +1,138 @@ +validateFileWithClamAv($file); + } + + return $result; + } + + return $this->validateFileWithClamAv($value); + } + + /** + * Validate the single uploaded file for virus/malware with ClamAV. + * + * @param $value mixed + * + * @return bool + * @throws ClamavValidatorException + */ + protected function validateFileWithClamAv($value): bool + { + $file = $this->getFilePath($value); + if (! is_readable($file)) { + throw ClamavValidatorException::forNonReadableFile($file); + } + + try { + $socket = $this->getClamavSocket(); + $scanner = $this->createQuahogScannerClient($socket); + $result = $scanner->scanResourceStream(fopen($file, 'rb')); + } catch (Exception $exception) { + if (Config::get('clamav.client_exceptions')) { + throw ClamavValidatorException::forClientException($exception); + } + return false; + } + + if ($result->isError()) { + if (Config::get('clamav.client_exceptions')) { + throw ClamavValidatorException::forScanResult($result); + } + return false; + } + + // Check if scan result is clean + return $result->isOk(); + } + + /** + * Guess the ClamAV socket. + * + * @return string + */ + protected function getClamavSocket(): string + { + $preferredSocket = Config::get('clamav.preferred_socket'); + + if ($preferredSocket === 'unix_socket') { + $unixSocket = Config::get('clamav.unix_socket'); + if (file_exists($unixSocket)) { + return 'unix://' . $unixSocket; + } + } + + // We use the tcp_socket as fallback as well + return Config::get('clamav.tcp_socket'); + } + + /** + * Return the file path from the passed object. + * + * @param mixed $file + * @return string + */ + protected function getFilePath($file): string + { + // if were passed an instance of UploadedFile, return the path + if ($file instanceof UploadedFile) { + return $file->getRealPath(); + } + + // if we're passed a PHP file upload array, return the "tmp_name" + if (is_array($file) && null !== Arr::get($file, 'tmp_name')) { + return $file['tmp_name']; + } + + // fallback: we were likely passed a path already + return $file; + } + + /** + * Create a new quahog ClamAV scanner client. + * + * @param string $socket + * @return QuahogClient + */ + protected function createQuahogScannerClient(string $socket): QuahogClient + { + // Create a new client socket instance + $client = (new SocketFactory())->createClient($socket, Config::get('clamav.socket_connect_timeout')); + + return new QuahogClient($client, Config::get('clamav.socket_read_timeout'), PHP_NORMAL_READ); + } +} diff --git a/tests/ClamavValidatorServiceProviderTest.php b/tests/ClamavValidatorServiceProviderTest.php index e5f4da5..2d1fbec 100755 --- a/tests/ClamavValidatorServiceProviderTest.php +++ b/tests/ClamavValidatorServiceProviderTest.php @@ -36,18 +36,18 @@ public function testBoot() Facade::setFacadeApplication($container); - $sp = new ClamavValidatorServiceProvider($container); - $sp->boot(); + $serviceProvider = new ClamavValidatorServiceProvider($container); + $serviceProvider->boot(); $validator = $factory->make([], []); foreach ($validator->extensions as $rule => $class_and_method) { - $this->assertTrue(in_array($rule, $sp->getRules())); - $this->assertEquals(ClamavValidator::class .'@validate' . Str::studly($rule), $class_and_method); + // Ensure rule exists in service provider ~ that validator has installed it + $this->assertArrayHasKey($rule, $serviceProvider->getRules()); + // Ensure that validation rule's validate method can be invoked... list($class, $method) = Str::parseCallback($class_and_method); - $this->assertTrue(method_exists($class, $method)); } } diff --git a/tests/ClamavValidatorTest.php b/tests/ClamavValidatorTest.php index e23ad18..ff15f88 100755 --- a/tests/ClamavValidatorTest.php +++ b/tests/ClamavValidatorTest.php @@ -2,30 +2,26 @@ namespace Sunspikes\Tests\ClamavValidator; +use Illuminate\Container\Container; use Illuminate\Support\Facades\Config; use Mockery; -use Illuminate\Contracts\Translation\Translator; -use Sunspikes\ClamavValidator\ClamavValidator; use Sunspikes\ClamavValidator\ClamavValidatorException; use PHPUnit\Framework\TestCase; +use Sunspikes\Tests\ClamavValidator\Helpers\ValidatorHelper; class ClamavValidatorTest extends TestCase { - protected $translator; + use ValidatorHelper; + protected $clean_data; protected $virus_data; protected $error_data; protected $rules; - protected $messages; protected $multiple_files_all_clean; protected $multiple_files_some_with_virus; protected function setUp(): void { - $this->translator = Mockery::mock(Translator::class); - $this->translator->shouldReceive('get')->with('validation.custom.file.clamav')->andReturn('error'); - $this->translator->shouldReceive('get')->withAnyArgs()->andReturn(null); - $this->translator->shouldReceive('get')->with('validation.attributes')->andReturn([]); $this->clean_data = [ 'file' => $this->getTempPath(__DIR__ . '/files/test1.txt') ]; @@ -48,7 +44,6 @@ protected function setUp(): void $this->getTempPath(__DIR__ . '/files/test4.txt'), ] ]; - $this->messages = ['clamav' => ':attribute contains virus.']; } private function setConfig(array $opts = []): void @@ -70,6 +65,9 @@ private function setConfig(array $opts = []): void protected function tearDown(): void { chmod($this->error_data['file'], 0644); + + Container::getInstance()->flush(); + Mockery::close(); } @@ -77,11 +75,9 @@ public function testValidatesSkipped() { $this->setConfig(['skip' => true]); - $validator = new ClamavValidator( - $this->translator, + $validator = $this->makeValidator( $this->clean_data, ['file' => 'clamav'], - $this->messages ); $this->assertTrue($validator->passes()); @@ -91,11 +87,9 @@ public function testValidatesSkippedForBoolValidatedConfigValues() { $this->setConfig(['skip' => '1']); - $validator = new ClamavValidator( - $this->translator, + $validator = $this->makeValidator( $this->clean_data, ['file' => 'clamav'], - $this->messages ); $this->assertTrue($validator->passes()); @@ -105,11 +99,9 @@ public function testValidatesClean() { $this->setConfig(); - $validator = new ClamavValidator( - $this->translator, + $validator = $this->makeValidator( $this->clean_data, ['file' => 'clamav'], - $this->messages ); $this->assertTrue($validator->passes()); @@ -119,12 +111,10 @@ public function testValidatesCleanMultiFile() { $this->setConfig(); - $validator = new ClamavValidator( - $this->translator, + $validator = $this->makeValidator( $this->multiple_files_all_clean, ['files' => 'clamav'], - $this->messages - ); + ); $this->assertTrue($validator->passes()); } @@ -133,11 +123,9 @@ public function testValidatesVirus() { $this->setConfig(); - $validator = new ClamavValidator( - $this->translator, + $validator = $this->makeValidator( $this->virus_data, ['file' => 'clamav'], - $this->messages ); $this->assertTrue($validator->fails()); @@ -147,12 +135,10 @@ public function testValidatesVirusMultiFile() { $this->setConfig(); - $validator = new ClamavValidator( - $this->translator, - $this->multiple_files_some_with_virus, - ['files' => 'clamav'], - $this->messages - ); + $validator = $this->makeValidator( + $this->multiple_files_some_with_virus, + ['files' => 'clamav'], + ); $this->assertTrue($validator->fails()); } @@ -163,11 +149,9 @@ public function testCannotValidateNonReadable() $this->expectException(ClamavValidatorException::class); - $validator = new ClamavValidator( - $this->translator, + $validator = $this->makeValidator( $this->error_data, ['file' => 'clamav'], - $this->messages ); chmod($this->error_data['file'], 0000); @@ -179,11 +163,9 @@ public function testFailsValidationOnError() { $this->setConfig(['error' => true]); - $validator = new ClamavValidator( - $this->translator, + $validator = $this->makeValidator( $this->clean_data, ['file' => 'clamav'], - $this->messages ); $this->assertTrue($validator->fails()); @@ -195,28 +177,11 @@ public function testThrowsExceptionOnValidationError() $this->expectException(ClamavValidatorException::class); - $validator = new ClamavValidator( - $this->translator, + $validator = $this->makeValidator( $this->clean_data, ['file' => 'clamav'], - $this->messages ); $this->assertTrue($validator->fails()); } - - /** - * Move to temp dir, so that clamav can access the file - * - * @param $file - * @return string - */ - private function getTempPath($file): string - { - $tempPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . basename($file); - copy($file, $tempPath); - chmod($tempPath, 0644); - - return $tempPath; - } } diff --git a/tests/Helpers/ValidatorHelper.php b/tests/Helpers/ValidatorHelper.php new file mode 100644 index 0000000..690a448 --- /dev/null +++ b/tests/Helpers/ValidatorHelper.php @@ -0,0 +1,115 @@ +makeMockedTranslator(); + $messages = !empty($messages) + ? $messages + : $this->defaultErrorMessages(); + + // Create new Laravel Validator factory instance and install extensions (custom rules) + $factory = new Factory($translator, Container::getInstance()); + + foreach ($this->rules() as $token => $rule) { + $factory->extend( + $token, + $rule . '@validate', + $messages + ); + } + + return $factory->make($data, $rules); + } + + /** + * Returns validation rules to installed in validator + * + * @return array + */ + protected function rules(): array + { + return [ + 'clamav' => ClamAv::class, + ]; + } + + /** + * Returns a new mocked {@see Translator} instance + * + * @return Translator|Translator&Mockery\LegacyMockInterface|Translator&Mockery\MockInterface|Mockery\LegacyMockInterface|Mockery\MockInterface + */ + protected function makeMockedTranslator() + { + $translator = Mockery::mock(Translator::class); + + $translator + ->shouldReceive('get') + ->with('validation.custom.file.clamav') + ->andReturn('error'); + + $translator + ->shouldReceive('get') + ->withAnyArgs() + ->andReturn(null); + + $translator + ->shouldReceive('get') + ->with('validation.attributes') + ->andReturn([]); + + return $translator; + } + + /** + * Returns a set of default error messages + * + * @return string[] + */ + protected function defaultErrorMessages(): array + { + return [ + 'clamav' => ':attribute contains virus.' + ]; + } + + /** + * Move to temp dir, so that clamav can access the file + * + * @param string $file + * + * @return string + */ + protected function getTempPath($file): string + { + $tempPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . basename($file); + copy($file, $tempPath); + chmod($tempPath, 0644); + + return $tempPath; + } +}