1<?php
2/**
3 * @author Ilja Neumann <ineumann@owncloud.com>
4 *
5 * @copyright Copyright (c) 2018, ownCloud GmbH
6 * @license AGPL-3.0
7 *
8 * This code is free software: you can redistribute it and/or modify
9 * it under the terms of the GNU Affero General Public License, version 3,
10 * as published by the Free Software Foundation.
11 *
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU Affero General Public License for more details.
16 *
17 * You should have received a copy of the GNU Affero General Public License, version 3,
18 * along with this program.  If not, see <http://www.gnu.org/licenses/>
19 *
20 */
21
22namespace OCA\Files\Command;
23
24use OC\Files\FileInfo;
25use OC\Files\Storage\FailedStorage;
26use OC\Files\Storage\Wrapper\Checksum;
27use OCA\Files_Sharing\ISharedStorage;
28use OCP\Files\IRootFolder;
29use OCP\Files\Node;
30use OCP\Files\NotFoundException;
31use OCP\Files\Storage\IStorage;
32use OCP\Files\StorageNotAvailableException;
33use OCP\IUser;
34use OCP\IUserManager;
35use Symfony\Component\Console\Command\Command;
36use Symfony\Component\Console\Input\InputInterface;
37use Symfony\Component\Console\Input\InputOption;
38use Symfony\Component\Console\Output\OutputInterface;
39
40/**
41 * Recomputes checksums for all files and compares them to filecache
42 * entries. Provides repair option on mismatch.
43 *
44 * @package OCA\Files\Command
45 */
46class VerifyChecksums extends Command {
47	public const EXIT_NO_ERRORS = 0;
48	public const EXIT_CHECKSUM_ERRORS = 1;
49	public const EXIT_INVALID_ARGS = 2;
50
51	/**
52	 * @var IRootFolder
53	 */
54	private $rootFolder;
55	/**
56	 * @var IUserManager
57	 */
58	private $userManager;
59
60	private $exitStatus = self::EXIT_NO_ERRORS;
61
62	/**
63	 * VerifyChecksums constructor.
64	 *
65	 * @param IRootFolder $rootFolder
66	 * @param IUserManager $userManager
67	 */
68	public function __construct(IRootFolder $rootFolder, IUserManager $userManager) {
69		parent::__construct(null);
70		$this->rootFolder = $rootFolder;
71		$this->userManager = $userManager;
72	}
73
74	protected function configure() {
75		$this
76			->setName('files:checksums:verify')
77			->setDescription('Get all checksums in filecache and compares them by recalculating the checksum of the file.')
78			->addOption('repair', 'r', InputOption::VALUE_NONE, 'Repair filecache-entry with mismatched checksums.')
79			->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'Specific user to check')
80			->addOption('path', 'p', InputOption::VALUE_REQUIRED, 'Path to check relative to user folder, i.e, relative to /john/files. e.g tree/apple', '');
81	}
82
83	/**
84	 * @param InputInterface $input
85	 * @param OutputInterface $output
86	 * @return int
87	 * @throws NotFoundException
88	 * @throws \OCP\Files\InvalidPathException
89	 * @throws \OCP\Files\StorageNotAvailableException
90	 */
91	public function execute(InputInterface $input, OutputInterface $output) {
92		$pathOption = $input->getOption('path');
93		$userName = $input->getOption('user');
94
95		$scanUserFunction = function (IUser $user) use ($input, $output) {
96			$output->writeln('<info>Starting to verify checksums for '.$user->getUID().'</info>');
97			$userFolder = $this->rootFolder->getUserFolder($user->getUID())->getParent();
98			$this->verifyChecksumsForFolder($userFolder, $input, $output);
99		};
100
101		if ($userName) {
102			if (!$this->userManager->userExists($userName)) {
103				$output->writeln("<error>User \"$userName\" does not exist</error>");
104				$this->exitStatus = self::EXIT_INVALID_ARGS;
105				return $this->exitStatus;
106			}
107			if (!$pathOption) {
108				$scanUserFunction($this->userManager->get($userName));
109			} else {
110				try {
111					$userFolder = $this->rootFolder->getUserFolder($userName);
112					$node = $userFolder->get($pathOption);
113				} catch (NotFoundException $ex) {
114					$output->writeln("<error>Path \"{$ex->getMessage()}\" not found.</error>");
115					$this->exitStatus = self::EXIT_INVALID_ARGS;
116					return $this->exitStatus;
117				}
118				if ($node === FileInfo::TYPE_FILE) {
119					$this->verifyChecksumsForFile($node, $input, $output);
120				} else {
121					$this->verifyChecksumsForFolder($node, $input, $output);
122				}
123			}
124		} else {
125			if ($pathOption) {
126				$output->writeln("<error>Please provide user when path is provided as argument</error>");
127				$this->exitStatus = self::EXIT_INVALID_ARGS;
128				return $this->exitStatus;
129			}
130			$output->writeln('<info>This operation might take quite some time.</info>');
131			$this->userManager->callForAllUsers($scanUserFunction);
132			$output->writeln('<info>Operation successfully completed</info>');
133		}
134
135		return $this->exitStatus;
136	}
137
138	/**
139	 * Verifies checksum of a file
140	 *
141	 * @param Node $file
142	 * @param InputInterface $input
143	 * @param OutputInterface $output
144	 * @throws NotFoundException
145	 * @throws \OCP\Files\InvalidPathException
146	 * @throws \OCP\Files\StorageNotAvailableException
147	 */
148	private function verifyChecksumsForFile($file, InputInterface $input, OutputInterface $output) {
149		$path = $file->getInternalPath();
150		$currentChecksums = $file->getChecksum();
151		$storage = $file->getStorage();
152		$storageId = $storage->getId();
153
154		if ($storage->instanceOfStorage(ISharedStorage::class) || $storage->instanceOfStorage(FailedStorage::class)) {
155			return;
156		}
157
158		try {
159			$fileExistsOnDisk = self::fileExistsOnDisk($file);
160		} catch (StorageNotAvailableException $e) {
161			$output->writeln("Skipping $storageId/$path => Storage is not available", OutputInterface::VERBOSITY_VERBOSE);
162			return;
163		}
164
165		if (!$fileExistsOnDisk) {
166			$output->writeln("Skipping $storageId/$path => File is in file-cache but doesn't exist on storage/disk", OutputInterface::VERBOSITY_VERBOSE);
167			return;
168		}
169
170		if (!$file->isReadable()) {
171			$output->writeln("Skipping $storageId/$path => File not readable", OutputInterface::VERBOSITY_VERBOSE);
172			return;
173		}
174
175		// Files without calculated checksum can't cause checksum errors
176		if (empty($currentChecksums)) {
177			$output->writeln("Skipping $storageId/$path => No Checksum", OutputInterface::VERBOSITY_VERBOSE);
178			return;
179		}
180
181		$output->writeln("Checking $storageId/$path => $currentChecksums", OutputInterface::VERBOSITY_VERBOSE);
182		$actualChecksums = self::calculateActualChecksums($path, $file->getStorage());
183		if ($actualChecksums !== $currentChecksums) {
184			$output->writeln(
185				"<info>Mismatch for $storageId/$path:\n Filecache:\t$currentChecksums\n Actual:\t$actualChecksums</info>"
186			);
187
188			$this->exitStatus = self::EXIT_CHECKSUM_ERRORS;
189
190			if ($input->getOption('repair')) {
191				$output->writeln("<info>Repairing $path</info>");
192				$this->updateChecksumsForNode($file, $actualChecksums);
193				$this->exitStatus = self::EXIT_NO_ERRORS;
194			}
195		}
196	}
197
198	/**
199	 * Verifies checksums of a folder and its children
200	 *
201	 * @param Node $folder
202	 * @param InputInterface $input
203	 * @param OutputInterface $output
204	 * @throws NotFoundException
205	 * @throws \OCP\Files\InvalidPathException
206	 * @throws \OCP\Files\StorageNotAvailableException
207	 */
208	private function verifyChecksumsForFolder($folder, InputInterface $input, OutputInterface $output) {
209		$folderQueue = [$folder];
210		while ($currentFolder = \array_pop($folderQueue)) {
211			'@phan-var \OCP\Files\Folder $currentFolder';
212			$currentFolderPath = $currentFolder->getPath();
213			try {
214				$nodes = $currentFolder->getDirectoryListing();
215			} catch (NotFoundException $e) {
216				$nodes = [];
217				$output->writeln("Skipping $currentFolderPath => Directory could not be found");
218			} catch (StorageNotAvailableException $e) {
219				$nodes = [];
220				$output->writeln("Skipping $currentFolderPath => Storage is not available");
221			} catch (\Exception $e) {
222				$nodes = [];
223				$output->writeln("Skipping $currentFolderPath => " . $e->getMessage());
224			}
225			foreach ($nodes as $node) {
226				if ($node->getType() === FileInfo::TYPE_FOLDER) {
227					$folderQueue[] = $node;
228				} else {
229					$this->verifyChecksumsForFile($node, $input, $output);
230				}
231			}
232			/* Force garbage collector to clear memory */
233			unset($nodes);
234		}
235	}
236
237	/**
238	 * @param Node $node
239	 * @param $correctChecksum
240	 * @throws NotFoundException
241	 * @throws \OCP\Files\InvalidPathException
242	 * @throws \OCP\Files\StorageNotAvailableException
243	 */
244	private function updateChecksumsForNode(Node $node, $correctChecksum) {
245		$storage = $node->getStorage();
246		$cache = $storage->getCache();
247		$cache->update(
248			$node->getId(),
249			['checksum' => $correctChecksum]
250		);
251	}
252
253	/**
254	 *
255	 * @param Node $node
256	 * @return bool
257	 */
258	private static function fileExistsOnDisk(Node $node) {
259		$statResult = @$node->stat();
260		return \is_array($statResult) && isset($statResult['size']) && $statResult['size'] !== false;
261	}
262
263	/**
264	 * @param $path
265	 * @param IStorage $storage
266	 * @return string
267	 * @throws \OCP\Files\StorageNotAvailableException
268	 */
269	private static function calculateActualChecksums($path, IStorage $storage) {
270		return \sprintf(
271			Checksum::CHECKSUMS_DB_FORMAT,
272			$storage->hash('sha1', $path),
273			$storage->hash('md5', $path),
274			$storage->hash('adler32', $path)
275		);
276	}
277}
278