From 7a27e5c7042554eee1c7a919d53e0d59370950b9 Mon Sep 17 00:00:00 2001 From: Ilja Neumann Date: Wed, 4 Apr 2018 17:55:31 +0200 Subject: [PATCH 1/3] VerifyChecksum Command #30951 --- apps/files/appinfo/info.xml | 1 + apps/files/lib/Command/VerifyChecksums.php | 205 ++++++++++++ .../tests/Command/VerifyChecksumsTest.php | 315 ++++++++++++++++++ .../Files/Storage/Wrapper/Checksum.php | 19 +- 4 files changed, 534 insertions(+), 6 deletions(-) create mode 100644 apps/files/lib/Command/VerifyChecksums.php create mode 100644 apps/files/tests/Command/VerifyChecksumsTest.php diff --git a/apps/files/appinfo/info.xml b/apps/files/appinfo/info.xml index a31ae5052855..18ff63002592 100644 --- a/apps/files/appinfo/info.xml +++ b/apps/files/appinfo/info.xml @@ -27,6 +27,7 @@ OCA\Files\Command\Scan OCA\Files\Command\DeleteOrphanedFiles OCA\Files\Command\TransferOwnership + OCA\Files\Command\VerifyChecksums diff --git a/apps/files/lib/Command/VerifyChecksums.php b/apps/files/lib/Command/VerifyChecksums.php new file mode 100644 index 000000000000..227a1aeba415 --- /dev/null +++ b/apps/files/lib/Command/VerifyChecksums.php @@ -0,0 +1,205 @@ + + * + * @copyright Copyright (c) 2018, ownCloud GmbH + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Files\Command; + + +use OC\Files\FileInfo; +use OC\Files\Storage\Wrapper\Checksum; +use OCP\Files\IRootFolder; +use OCP\Files\Node; +use OCP\Files\NotFoundException; +use OCP\Files\Storage\IStorage; +use OCP\IUser; +use OCP\IUserManager; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + + +/** + * Recomputes checksums for all files and compares them to filecache + * entries. Provides repair option on mismatch. + * + * @package OCA\Files\Command + */ +class VerifyChecksums extends Command { + + + const EXIT_NO_ERRORS = 0; + const EXIT_CHECKSUM_ERRORS = 1; + const EXIT_INVALID_ARGS = 2; + + /** + * @var IRootFolder + */ + private $rootFolder; + + /** + * @var IUserManager + */ + private $userManager; + + private $exitStatus = self::EXIT_NO_ERRORS; + + /** + * VerifyChecksums constructor. + * + * @param IRootFolder $rootFolder + * @param IUserManager $userManager + */ + public function __construct(IRootFolder $rootFolder, IUserManager $userManager) { + parent::__construct(null); + $this->rootFolder = $rootFolder; + $this->userManager = $userManager; + } + + protected function configure() { + $this + ->setName('files:checksums:verify') + ->setDescription('Get all checksums in filecache and compares them by recalculating the checksum of the file.') + ->addOption('repair', 'r', InputOption::VALUE_NONE, 'Repair filecache-entry with mismatched checksums.') + ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'Specific user to check') + ->addOption('path', 'p', InputOption::VALUE_REQUIRED, 'Path to check relative to data e.g /john/files/', ''); + } + + public function execute(InputInterface $input, OutputInterface $output) { + $pathOption = $input->getOption('path'); + $userName = $input->getOption('user'); + + if (!$pathOption && !$userName) { + $output->writeln('This operation might take very long.'); + } + + if ($pathOption && $userName) { + $output->writeln('Please use either path or user exclusively'); + $this->exitStatus = self::EXIT_INVALID_ARGS; + + } + + $walkFunction = function (Node $node) use ($input, $output) { + $path = $node->getInternalPath(); + $currentChecksums = $node->getChecksum(); + + // Files without calculated checksum can't cause checksum errors + if (empty($currentChecksums)) { + $output->writeln("Skipping $path => No Checksum", OutputInterface::VERBOSITY_VERBOSE); + return; + } + + $output->writeln("Checking $path => $currentChecksums", OutputInterface::VERBOSITY_VERBOSE); + $actualChecksums = self::calculateActualChecksums($path, $node->getStorage()); + + if ($actualChecksums !== $currentChecksums) { + $output->writeln( + "Mismatch for $path:\n Filecache:\t$currentChecksums\n Actual:\t$actualChecksums" + ); + + $this->exitStatus = self::EXIT_CHECKSUM_ERRORS; + + if ($input->getOption('repair')) { + $output->writeln("Repairing $path"); + $this->updateChecksumsForNode($node, $actualChecksums); + $this->exitStatus = self::EXIT_NO_ERRORS; + } + } + }; + + $scanUserFunction = function(IUser $user) use ($input, $output, $walkFunction) { + $userFolder = $this->rootFolder->getUserFolder($user->getUID())->getParent(); + $this->walkNodes($userFolder->getDirectoryListing(), $walkFunction); + }; + + if ($userName && $this->userManager->userExists($userName)) { + $scanUserFunction($this->userManager->get($userName)); + } else if ($userName && !$this->userManager->userExists($userName)) { + $output->writeln("User \"$userName\" does not exist"); + $this->exitStatus = self::EXIT_INVALID_ARGS; + } else if ($input->getOption('path')) { + + try { + $node = $this->rootFolder->get($input->getOption('path')); + } catch (NotFoundException $ex) { + $output->writeln("Path \"{$ex->getMessage()}\" not found."); + $this->exitStatus = self::EXIT_INVALID_ARGS; + return $this->exitStatus; + } + + $this->walkNodes([$node], $walkFunction); + + } else { + $this->userManager->callForAllUsers($scanUserFunction); + } + + + return $this->exitStatus; + } + + + /** + * Recursive walk nodes + * + * @param Node[] $nodes + * @param \Closure $callBack + */ + private function walkNodes(array $nodes, \Closure $callBack) { + foreach ($nodes as $node) { + if ($node->getType() === FileInfo::TYPE_FOLDER) { + $this->walkNodes($node->getDirectoryListing(), $callBack); + } else { + $callBack($node); + } + } + } + + /** + * @param Node $node + * @param $correctChecksum + * @throws NotFoundException + * @throws \OCP\Files\InvalidPathException + * @throws \OCP\Files\StorageNotAvailableException + */ + private function updateChecksumsForNode(Node $node, $correctChecksum) { + $storage = $node->getStorage(); + $cache = $storage->getCache(); + $cache->update( + $node->getId(), + ['checksum' => $correctChecksum] + ); + } + + /** + * @param $path + * @param IStorage $storage + * @return string + * @throws \OCP\Files\StorageNotAvailableException + */ + private static function calculateActualChecksums($path, IStorage $storage) { + return sprintf( + Checksum::CHECKSUMS_DB_FORMAT, + $storage->hash('sha1', $path), + $storage->hash('md5', $path), + $storage->hash('adler32', $path) + ); + } +} + diff --git a/apps/files/tests/Command/VerifyChecksumsTest.php b/apps/files/tests/Command/VerifyChecksumsTest.php new file mode 100644 index 000000000000..ac124b990715 --- /dev/null +++ b/apps/files/tests/Command/VerifyChecksumsTest.php @@ -0,0 +1,315 @@ +rootFolder = \OC::$server->getRootFolder(); + + $this->user1 = $this->createRandomUser(1); + $this->user2 = $this->createRandomUser(2); + + + $this->testFiles = [ + $this->createFileForUser($this->user1, 'dir/nested/somefile.txt', 'Hello World!'), + $this->createFileForUser($this->user1, 'dir/nested/subdir/hallo2.txt', 'ewfwfwefwef'), + $this->createFileForUser($this->user1, 'dir/nested/somefile2.txt', '1337'), + $this->createFileForUser($this->user1, 'otherdir/bar.txt', 'Bye!'), + $this->createFileForUser($this->user1, 'rootfile.doc', 'efwefwfwefwffw'), + $this->createFileForUser($this->user1, 'song.mp3', 'sdfsfdsdfsdfsfsfsffs'), + $this->createFileForUser($this->user2, 'sdvsdvs/secrets/bar/baz/moo.txt', 'JhonnyCache'), + $this->createFileForUser($this->user2, 'bling.tif', 'ewfwefwf242'), + $this->createFileForUser($this->user2, 'pong.tif', '12'), + $this->createFileForUser($this->user2, 'welcome.doc', 'efwefwfwefwffw'), + ]; + + $this->cmd = new CommandTester( + new VerifyChecksums( + $this->rootFolder, + \OC::$server->getUserManager() + ) + ); + } + + /** + * @param string $uid + * @param $path + * @param $content + * @return array + * @throws \OCP\Files\NotPermittedException + */ + private function createFileForUser($uid, $path, $content) { + + $userFolder = \OC::$server->getUserFolder($uid); + + $parts = explode('/', ltrim($path, '/')); + $fileName = array_pop($parts); + $dirPath = $parts; + + $currentDir = ''; + foreach ($dirPath as $subDir) { + if (!empty($subDir)) { + $currentDir = "$currentDir/$subDir"; + if (!$userFolder->nodeExists($currentDir)) { + $userFolder->newFolder("$currentDir"); + } + } + } + + $f = $userFolder->newFile("$currentDir/$fileName"); + $f->putContent($content); + + + + return [ + 'file' => $f, + 'expectedChecksums' => function() use ($content) { + return sprintf( + Checksum::CHECKSUMS_DB_FORMAT, + hash('sha1', $content), + hash('md5', $content), + hash('adler32', $content) + ); + }, + ]; + } + + + /** + * @param int $number + * @return bool|IUser + */ + private function createRandomUser($number) { + $userName = $this->getUniqueID("$number-verifycheksums"); + $user = $this->createUser($userName); + $this->loginAsUser($userName); + + return $user->getUID(); + } + + + private function breakChecksum(File &$f) { + $cache = $f->getStorage()->getCache(); + $cache->update($f->getId(), ['checksum' => self::BROKEN_CHECKSUM_STRING]); + $this->refreshFileInfo($f); + } + + private function refreshFileInfo(File &$f) { + $f = $this->rootFolder->get($f->getPath()); + } + + private function assertChecksumsAreCorrect(array $files) { + foreach ($files as $key => $file) { + /** @var File $f */ + $f = $files[$key]['file']; + $expectedChecksums = $files[$key]['expectedChecksums']; + $this->refreshFileInfo($files[$key]['file']); + $this->assertSame( + $expectedChecksums(), + $f->getChecksum() + ); + } + } + + + public function testNoBrokenChecksums() { + $this->cmd->execute([]); + $exitCode = $this->cmd->getStatusCode(); + + $this->assertEquals(VerifyChecksums::EXIT_NO_ERRORS, $exitCode, 'Wrong exit code'); + $this->assertChecksumsAreCorrect($this->testFiles); + } + + public function testBrokenChecksumsAreNotRepairedWithoutArguments() { + /** @var File $file1 */ + $file1 = $this->testFiles[0]['file']; + /** @var File $file2 */ + $file2 = $this->testFiles[6]['file']; + + $this->breakChecksum($file1); + $this->breakChecksum($file2); + + $this->cmd->execute([]); + $this->cmd->execute([]); + + + $exitCode = $this->cmd->getStatusCode(); + $this->assertEquals(VerifyChecksums::EXIT_CHECKSUM_ERRORS, $exitCode, 'Wrong exit code'); + + + $this->assertEquals(self::BROKEN_CHECKSUM_STRING, $file1->getChecksum()); + $this->assertEquals(self::BROKEN_CHECKSUM_STRING, $file2->getChecksum()); + } + + /** + * @depends testBrokenChecksumsAreNotRepairedWithoutArguments + */ + public function testFilesWithBrokenChecksumsAreDisplayed() { + /** @var File $file1 */ + $file1 = $this->testFiles[4]['file']; + /** @var File $file2 */ + $file2 = $this->testFiles[7]['file']; + + $this->breakChecksum($file1); + $this->breakChecksum($file2); + + $this->cmd->execute([]); + + $output = $this->cmd->getDisplay(); + + $this->assertContains("Mismatch for {$file1->getInternalPath()}", $output); + $this->assertContains("Mismatch for {$file2->getInternalPath()}", $output); + $this->assertContains(self::BROKEN_CHECKSUM_STRING, $output); + $this->assertContains($this->testFiles[4]['expectedChecksums'](), $output); + $this->assertContains($this->testFiles[7]['expectedChecksums'](), $output); + } + + /** + * @depends testNoBrokenChecksums + */ + public function testBrokenChecksumsResultInErrorExitCode() { + + /** @var File $file1 */ + $file1 = $this->testFiles[0]['file']; + + $this->breakChecksum($file1); + $this->cmd->execute([]); + + $this->assertEquals(self::BROKEN_CHECKSUM_STRING, $file1->getChecksum()); + + $exitCode = $this->cmd->getStatusCode(); + $this->assertEquals(VerifyChecksums::EXIT_CHECKSUM_ERRORS, $exitCode, 'Wrong exit code'); + + } + + /** + * @depends testBrokenChecksumsAreNotRepairedWithoutArguments + */ + public function testBrokenFilesAreRepairedWithRepairArgument() { + /** @var File $file1 */ + $file1 = $this->testFiles[1]['file']; + /** @var File $file2 */ + $file2 = $this->testFiles[7]['file']; + + $this->breakChecksum($file1); + $this->breakChecksum($file2); + + $this->cmd->execute(['-r' => null]); + + $this->assertChecksumsAreCorrect([ + $this->testFiles[1], + $this->testFiles[7] + ]); + + $exitCode = $this->cmd->getStatusCode(); + $this->assertEquals(VerifyChecksums::EXIT_NO_ERRORS, $exitCode, "Wrong exit code"); + } + + /** + * @depends testBrokenFilesAreRepairedWithRepairArgument + */ + public function testOnlyFilesInGivenPathArgumentAreRepaired() { + + /** @var File $file1 */ + $file1 = $this->testFiles[0]['file']; + /** @var File $file2 */ + $file2 = $this->testFiles[1]['file']; + /** @var File $file3 */ + $file3 = $this->testFiles[2]['file']; + /** @var File $file4 */ + $file4 = $this->testFiles[3]['file']; + + $this->breakChecksum($file1); + $this->breakChecksum($file2); + $this->breakChecksum($file3); + $this->breakChecksum($file4); + + $this->cmd->execute([ + '-r' => null, + '-p' => "{$this->user1}/files/dir/nested" + ]); + + $this->assertChecksumsAreCorrect([ + $this->testFiles[0], + $this->testFiles[1], + $this->testFiles[2], + ]); + + $this->assertEquals(self::BROKEN_CHECKSUM_STRING, $file4->getChecksum()); + } + + public function testOnlyFilesOfAGivenUserAreRepaired() { + /** @var File $file1 */ + $file1 = $this->testFiles[0]['file']; + /** @var File $file2 */ + $file2 = $this->testFiles[6]['file']; + + $this->breakChecksum($file1); + $this->breakChecksum($file2); + + $this->cmd->execute([ + '-r' => null, + '-u' => $this->user1 + ]); + + $this->assertChecksumsAreCorrect([ + $this->testFiles[0], + ]); + + $this->assertEquals(self::BROKEN_CHECKSUM_STRING, $file2->getChecksum()); + } + + public function testAllFilesCanBeRepaired() { + foreach ($this->testFiles as $testFile) { + $this->breakChecksum($testFile['file']); + } + + $this->cmd->execute(['-r' => null]); + + $this->assertChecksumsAreCorrect($this->testFiles); + } +} diff --git a/lib/private/Files/Storage/Wrapper/Checksum.php b/lib/private/Files/Storage/Wrapper/Checksum.php index 7868768be1cf..bfdbe3b460f7 100644 --- a/lib/private/Files/Storage/Wrapper/Checksum.php +++ b/lib/private/Files/Storage/Wrapper/Checksum.php @@ -37,6 +37,8 @@ */ class Checksum extends Wrapper { + /** Format of checksum field in filecache */ + const CHECKSUMS_DB_FORMAT = 'SHA1:%s MD5:%s ADLER32:%s'; const NOT_REQUIRED = 0; /** Calculate checksum on write (to be stored in oc_filecache) */ @@ -133,16 +135,21 @@ public function onClose() { /** * @param $path - * Format like "SHA1:abc MD5:def ADLER32:ghi" - * @return string + * @return string Format like "SHA1:abc MD5:def ADLER32:ghi" */ private static function getChecksumsInDbFormat($path) { - $checksumString = ''; - foreach (ChecksumStream::getChecksums($path) as $algo => $checksum) { - $checksumString .= sprintf('%s:%s ', strtoupper($algo), $checksum); + $checksums = ChecksumStream::getChecksums($path); + + if (empty($checksums)) { + return ''; } - return rtrim($checksumString); + return sprintf( + self::CHECKSUMS_DB_FORMAT, + $checksums['sha1'], + $checksums['md5'], + $checksums['adler32'] + ); } /** From 82abf809aeba57a75bb9c318b7e260bd84397c65 Mon Sep 17 00:00:00 2001 From: Ilja Neumann Date: Tue, 10 Apr 2018 11:38:12 +0200 Subject: [PATCH 2/3] Reset checksum only if size, mtimie,etag or storagemtime changes --- lib/private/Files/Cache/Scanner.php | 10 ++++++++-- tests/lib/Files/ViewTest.php | 28 ---------------------------- 2 files changed, 8 insertions(+), 30 deletions(-) diff --git a/lib/private/Files/Cache/Scanner.php b/lib/private/Files/Cache/Scanner.php index a06c44186776..bf76d42492b6 100644 --- a/lib/private/Files/Cache/Scanner.php +++ b/lib/private/Files/Cache/Scanner.php @@ -205,8 +205,14 @@ public function scanFile($file, $reuseExisting = 0, $parentId = -1, $cacheData = $fileId = -1; } if (!empty($newData)) { - // Reset the checksum if the data has changed - $newData['checksum'] = ''; + // Only reset checksum on file change + foreach (array_intersect_key($newData, $data) as $key => $value) { + if (in_array($key, ['size', 'storage_mtime', 'mtime', 'etag']) && $data[$key] != $newData[$key]) { + $newData['checksum'] = ''; + } + } + + $data['fileid'] = $this->addToCache($file, $newData, $fileId); } if (isset($cacheData['size'])) { diff --git a/tests/lib/Files/ViewTest.php b/tests/lib/Files/ViewTest.php index 29da1174e182..9a4f1376c536 100644 --- a/tests/lib/Files/ViewTest.php +++ b/tests/lib/Files/ViewTest.php @@ -2603,34 +2603,6 @@ public function testGetDirectoryContentMimeFilter($filter, $expected) { $this->assertEquals($expected, $files); } - public function testFilePutContentsClearsChecksum() { - $storage = new Temporary([]); - $scanner = $storage->getScanner(); - $storage->file_put_contents('foo.txt', 'bar'); - Filesystem::mount($storage, [], '/test/'); - $scanner->scan(''); - - $calledReadEvent = []; - \OC::$server->getEventDispatcher()->addListener('file.afterread', function ($event) use (&$calledReadEvent) { - $calledReadEvent[] = 'file.afterread'; - $calledReadEvent[] = $event; - }); - $view = new View('/test/foo.txt'); - $view->putFileInfo('.', ['checksum' => '42']); - - $this->assertEquals('bar', $view->file_get_contents('')); - $fh = tmpfile(); - fwrite($fh, 'fooo'); - rewind($fh); - $view->file_put_contents('', $fh); - $this->assertEquals('fooo', $view->file_get_contents('')); - $this->assertEquals('file.afterread', $calledReadEvent[0]); - $this->assertArrayHasKey('path', $calledReadEvent[1]); - $this->assertInstanceOf(GenericEvent::class, $calledReadEvent[1]); - $data = $view->getFileInfo('.'); - $this->assertEquals('', $data->getChecksum()); - } - public function testDeleteGhostFile() { $storage = new Temporary([]); $scanner = $storage->getScanner(); From 3f905a622e6bef629b82956faa7ce9342c2f0fe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 11 Apr 2018 10:46:10 +0200 Subject: [PATCH 3/3] Update checksums in NoopScanner - necessary to fix checksums on objectstore --- lib/private/Files/ObjectStore/NoopScanner.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/private/Files/ObjectStore/NoopScanner.php b/lib/private/Files/ObjectStore/NoopScanner.php index dc65e924b9d5..a1182233a22b 100644 --- a/lib/private/Files/ObjectStore/NoopScanner.php +++ b/lib/private/Files/ObjectStore/NoopScanner.php @@ -30,6 +30,7 @@ class NoopScanner extends Scanner { public function __construct(Storage $storage) { + $this->storage = $storage; //we don't need the storage, so do nothing here } @@ -55,6 +56,15 @@ public function scanFile($file, $reuseExisting = 0, $parentId = -1, $cacheData = * @return array with the meta data of the scanned file or folder */ public function scan($path, $recursive = self::SCAN_RECURSIVE, $reuse = -1, $lock = true) { + // we only update the checksums - still returning no data + $meta = $this->storage->getMetaData($path); + if (isset($meta['checksum'])) { + $this->storage->getCache()->put( + $path, + ['checksum' => $meta['checksum']] + ); + } + return []; }