diff options
Diffstat (limited to 'lib/private/Files/Storage/Local.php')
-rw-r--r-- | lib/private/Files/Storage/Local.php | 381 |
1 files changed, 203 insertions, 178 deletions
diff --git a/lib/private/Files/Storage/Local.php b/lib/private/Files/Storage/Local.php index 944b0b69959..260f9218a88 100644 --- a/lib/private/Files/Storage/Local.php +++ b/lib/private/Files/Storage/Local.php @@ -1,54 +1,25 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author aler9 <46489434+aler9@users.noreply.github.com> - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Boris Rybalkin <ribalkin@gmail.com> - * @author Brice Maron <brice@bmaron.net> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author J0WI <J0WI@users.noreply.github.com> - * @author Jakob Sack <mail@jakobsack.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Klaas Freitag <freitag@owncloud.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Michael Gapczynski <GapczynskiM@gmail.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Sjors van der Pluijm <sjors@desjors.nl> - * @author Stefan Weil <sw@weilnetz.de> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Tigran Mkrtchyan <tigran.mkrtchyan@desy.de> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @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 <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OC\Files\Storage; use OC\Files\Filesystem; +use OC\Files\Storage\Wrapper\Encryption; use OC\Files\Storage\Wrapper\Jail; use OCP\Constants; use OCP\Files\ForbiddenException; use OCP\Files\GenericFileException; +use OCP\Files\IMimeTypeDetector; use OCP\Files\Storage\IStorage; -use OCP\ILogger; +use OCP\Files\StorageNotAvailableException; +use OCP\IConfig; +use OCP\Server; +use OCP\Util; +use Psr\Log\LoggerInterface; /** * for local filestore, we only have to map the paths @@ -60,11 +31,21 @@ class Local extends \OC\Files\Storage\Common { protected $realDataDir; - public function __construct($arguments) { - if (!isset($arguments['datadir']) || !is_string($arguments['datadir'])) { + private IConfig $config; + + private IMimeTypeDetector $mimeTypeDetector; + + private $defUMask; + + protected bool $unlinkOnTruncate; + + protected bool $caseInsensitive = false; + + public function __construct(array $parameters) { + if (!isset($parameters['datadir']) || !is_string($parameters['datadir'])) { throw new \InvalidArgumentException('No data directory set for local storage'); } - $this->datadir = str_replace('//', '/', $arguments['datadir']); + $this->datadir = str_replace('//', '/', $parameters['datadir']); // some crazy code uses a local storage on root... if ($this->datadir === '/') { $this->realDataDir = $this->datadir; @@ -72,27 +53,41 @@ class Local extends \OC\Files\Storage\Common { $realPath = realpath($this->datadir) ?: $this->datadir; $this->realDataDir = rtrim($realPath, '/') . '/'; } - if (substr($this->datadir, -1) !== '/') { + if (!str_ends_with($this->datadir, '/')) { $this->datadir .= '/'; } $this->dataDirLength = strlen($this->realDataDir); + $this->config = Server::get(IConfig::class); + $this->mimeTypeDetector = Server::get(IMimeTypeDetector::class); + $this->defUMask = $this->config->getSystemValue('localstorage.umask', 0022); + $this->caseInsensitive = $this->config->getSystemValueBool('localstorage.case_insensitive', false); + + // support Write-Once-Read-Many file systems + $this->unlinkOnTruncate = $this->config->getSystemValueBool('localstorage.unlink_on_truncate', false); + + if (isset($parameters['isExternal']) && $parameters['isExternal'] && !$this->stat('')) { + // data dir not accessible or available, can happen when using an external storage of type Local + // on an unmounted system mount point + throw new StorageNotAvailableException('Local storage path does not exist "' . $this->getSourcePath('') . '"'); + } } public function __destruct() { } - public function getId() { + public function getId(): string { return 'local::' . $this->datadir; } - public function mkdir($path) { + public function mkdir(string $path): bool { $sourcePath = $this->getSourcePath($path); + $oldMask = umask($this->defUMask); $result = @mkdir($sourcePath, 0777, true); - chmod($sourcePath, 0755); + umask($oldMask); return $result; } - public function rmdir($path) { + public function rmdir(string $path): bool { if (!$this->isDeletable($path)) { return false; } @@ -112,17 +107,18 @@ class Local extends \OC\Files\Storage\Common { * @var \SplFileInfo $file */ $file = $it->current(); - clearstatcache(true, $this->getSourcePath($file)); + clearstatcache(true, $file->getRealPath()); if (in_array($file->getBasename(), ['.', '..'])) { $it->next(); continue; - } elseif ($file->isDir()) { - rmdir($file->getPathname()); } elseif ($file->isFile() || $file->isLink()) { unlink($file->getPathname()); + } elseif ($file->isDir()) { + rmdir($file->getPathname()); } $it->next(); } + unset($it); // Release iterator and thereby its potential directory lock (e.g. in case of VirtualBox shared folders) clearstatcache(true, $this->getSourcePath($path)); return rmdir($this->getSourcePath($path)); } catch (\UnexpectedValueException $e) { @@ -130,45 +126,58 @@ class Local extends \OC\Files\Storage\Common { } } - public function opendir($path) { + public function opendir(string $path) { return opendir($this->getSourcePath($path)); } - public function is_dir($path) { - if (substr($path, -1) == '/') { + public function is_dir(string $path): bool { + if ($this->caseInsensitive && !$this->file_exists($path)) { + return false; + } + if (str_ends_with($path, '/')) { $path = substr($path, 0, -1); } return is_dir($this->getSourcePath($path)); } - public function is_file($path) { + public function is_file(string $path): bool { + if ($this->caseInsensitive && !$this->file_exists($path)) { + return false; + } return is_file($this->getSourcePath($path)); } - public function stat($path) { + public function stat(string $path): array|false { $fullPath = $this->getSourcePath($path); clearstatcache(true, $fullPath); + if (!file_exists($fullPath)) { + return false; + } $statResult = @stat($fullPath); if (PHP_INT_SIZE === 4 && $statResult && !$this->is_dir($path)) { $filesize = $this->filesize($path); $statResult['size'] = $filesize; $statResult[7] = $filesize; } + if (is_array($statResult)) { + $statResult['full_path'] = $fullPath; + } return $statResult; } - /** - * @inheritdoc - */ - public function getMetaData($path) { - $stat = $this->stat($path); + public function getMetaData(string $path): ?array { + try { + $stat = $this->stat($path); + } catch (ForbiddenException $e) { + return null; + } if (!$stat) { return null; } $permissions = Constants::PERMISSION_SHARE; $statPermissions = $stat['mode']; - $isDir = ($statPermissions & 0x4000) === 0x4000; + $isDir = ($statPermissions & 0x4000) === 0x4000 && !($statPermissions & 0x8000); if ($statPermissions & 0x0100) { $permissions += Constants::PERMISSION_READ; } @@ -180,15 +189,14 @@ class Local extends \OC\Files\Storage\Common { } if (!($path === '' || $path === '/')) { // deletable depends on the parents unix permissions - $fullPath = $this->getSourcePath($path); - $parent = dirname($fullPath); + $parent = dirname($stat['full_path']); if (is_writable($parent)) { $permissions += Constants::PERMISSION_DELETE; } } $data = []; - $data['mimetype'] = $isDir ? 'httpd/unix-directory' : \OC::$server->getMimeTypeDetector()->detectPath($path); + $data['mimetype'] = $isDir ? 'httpd/unix-directory' : $this->mimeTypeDetector->detectPath($path); $data['mtime'] = $stat['mtime']; if ($data['mtime'] === false) { $data['mtime'] = time(); @@ -206,7 +214,7 @@ class Local extends \OC\Files\Storage\Common { return $data; } - public function filetype($path) { + public function filetype(string $path): string|false { $filetype = filetype($this->getSourcePath($path)); if ($filetype == 'link') { $filetype = filetype(realpath($this->getSourcePath($path))); @@ -214,8 +222,8 @@ class Local extends \OC\Files\Storage\Common { return $filetype; } - public function filesize($path) { - if ($this->is_dir($path)) { + public function filesize(string $path): int|float|false { + if (!$this->is_file($path)) { return 0; } $fullPath = $this->getSourcePath($path); @@ -226,19 +234,29 @@ class Local extends \OC\Files\Storage\Common { return filesize($fullPath); } - public function isReadable($path) { + public function isReadable(string $path): bool { return is_readable($this->getSourcePath($path)); } - public function isUpdatable($path) { + public function isUpdatable(string $path): bool { return is_writable($this->getSourcePath($path)); } - public function file_exists($path) { - return file_exists($this->getSourcePath($path)); + public function file_exists(string $path): bool { + if ($this->caseInsensitive) { + $fullPath = $this->getSourcePath($path); + $parentPath = dirname($fullPath); + if (!is_dir($parentPath)) { + return false; + } + $content = scandir($parentPath, SCANDIR_SORT_NONE); + return is_array($content) && array_search(basename($fullPath), $content) !== false; + } else { + return file_exists($this->getSourcePath($path)); + } } - public function filemtime($path) { + public function filemtime(string $path): int|false { $fullPath = $this->getSourcePath($path); clearstatcache(true, $fullPath); if (!$this->file_exists($path)) { @@ -251,18 +269,20 @@ class Local extends \OC\Files\Storage\Common { return filemtime($fullPath); } - public function touch($path, $mtime = null) { + public function touch(string $path, ?int $mtime = null): bool { // sets the modification time of the file to the given value. // If mtime is nil the current time is set. // note that the access time of the file always changes to the current time. - if ($this->file_exists($path) and !$this->isUpdatable($path)) { + if ($this->file_exists($path) && !$this->isUpdatable($path)) { return false; } + $oldMask = umask($this->defUMask); if (!is_null($mtime)) { $result = @touch($this->getSourcePath($path), $mtime); } else { $result = @touch($this->getSourcePath($path)); } + umask($oldMask); if ($result) { clearstatcache(true, $this->getSourcePath($path)); } @@ -270,15 +290,21 @@ class Local extends \OC\Files\Storage\Common { return $result; } - public function file_get_contents($path) { + public function file_get_contents(string $path): string|false { return file_get_contents($this->getSourcePath($path)); } - public function file_put_contents($path, $data) { - return file_put_contents($this->getSourcePath($path), $data); + public function file_put_contents(string $path, mixed $data): int|float|false { + $oldMask = umask($this->defUMask); + if ($this->unlinkOnTruncate) { + $this->unlink($path); + } + $result = file_put_contents($this->getSourcePath($path), $data); + umask($oldMask); + return $result; } - public function unlink($path) { + public function unlink(string $path): bool { if ($this->is_dir($path)) { return $this->rmdir($path); } elseif ($this->is_file($path)) { @@ -288,80 +314,97 @@ class Local extends \OC\Files\Storage\Common { } } - private function treeContainsBlacklistedFile(string $path): bool { + private function checkTreeForForbiddenItems(string $path): void { $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path)); foreach ($iterator as $file) { /** @var \SplFileInfo $file */ if (Filesystem::isFileBlacklisted($file->getBasename())) { - return true; + throw new ForbiddenException('Invalid path: ' . $file->getPathname(), false); } } - - return false; } - public function rename($path1, $path2) { - $srcParent = dirname($path1); - $dstParent = dirname($path2); + public function rename(string $source, string $target): bool { + $srcParent = dirname($source); + $dstParent = dirname($target); if (!$this->isUpdatable($srcParent)) { - \OCP\Util::writeLog('core', 'unable to rename, source directory is not writable : ' . $srcParent, ILogger::ERROR); + Server::get(LoggerInterface::class)->error('unable to rename, source directory is not writable : ' . $srcParent, ['app' => 'core']); return false; } if (!$this->isUpdatable($dstParent)) { - \OCP\Util::writeLog('core', 'unable to rename, destination directory is not writable : ' . $dstParent, ILogger::ERROR); + Server::get(LoggerInterface::class)->error('unable to rename, destination directory is not writable : ' . $dstParent, ['app' => 'core']); return false; } - if (!$this->file_exists($path1)) { - \OCP\Util::writeLog('core', 'unable to rename, file does not exists : ' . $path1, ILogger::ERROR); + if (!$this->file_exists($source)) { + Server::get(LoggerInterface::class)->error('unable to rename, file does not exists : ' . $source, ['app' => 'core']); return false; } - if ($this->is_dir($path2)) { - $this->rmdir($path2); - } elseif ($this->is_file($path2)) { - $this->unlink($path2); + if ($this->file_exists($target)) { + if ($this->is_dir($target)) { + $this->rmdir($target); + } elseif ($this->is_file($target)) { + $this->unlink($target); + } } - if ($this->is_dir($path1)) { - // we can't move folders across devices, use copy instead - $stat1 = stat(dirname($this->getSourcePath($path1))); - $stat2 = stat(dirname($this->getSourcePath($path2))); - if ($stat1['dev'] !== $stat2['dev']) { - $result = $this->copy($path1, $path2); - if ($result) { - $result &= $this->rmdir($path1); - } - return $result; - } + if ($this->is_dir($source)) { + $this->checkTreeForForbiddenItems($this->getSourcePath($source)); + } - if ($this->treeContainsBlacklistedFile($this->getSourcePath($path1))) { - throw new ForbiddenException('Invalid path', false); + if (@rename($this->getSourcePath($source), $this->getSourcePath($target))) { + if ($this->caseInsensitive) { + if (mb_strtolower($target) === mb_strtolower($source) && !$this->file_exists($target)) { + return false; + } } + return true; } - return rename($this->getSourcePath($path1), $this->getSourcePath($path2)); + return $this->copy($source, $target) && $this->unlink($source); } - public function copy($path1, $path2) { - if ($this->is_dir($path1)) { - return parent::copy($path1, $path2); + public function copy(string $source, string $target): bool { + if ($this->is_dir($source)) { + return parent::copy($source, $target); } else { - return copy($this->getSourcePath($path1), $this->getSourcePath($path2)); + $oldMask = umask($this->defUMask); + if ($this->unlinkOnTruncate) { + $this->unlink($target); + } + $result = copy($this->getSourcePath($source), $this->getSourcePath($target)); + umask($oldMask); + if ($this->caseInsensitive) { + if (mb_strtolower($target) === mb_strtolower($source) && !$this->file_exists($target)) { + return false; + } + } + return $result; } } - public function fopen($path, $mode) { - return fopen($this->getSourcePath($path), $mode); + public function fopen(string $path, string $mode) { + $sourcePath = $this->getSourcePath($path); + if (!file_exists($sourcePath) && $mode === 'r') { + return false; + } + $oldMask = umask($this->defUMask); + if (($mode === 'w' || $mode === 'w+') && $this->unlinkOnTruncate) { + $this->unlink($path); + } + $result = @fopen($sourcePath, $mode); + umask($oldMask); + return $result; } - public function hash($type, $path, $raw = false) { + public function hash(string $type, string $path, bool $raw = false): string|false { return hash_file($type, $this->getSourcePath($path), $raw); } - public function free_space($path) { + public function free_space(string $path): int|float|false { $sourcePath = $this->getSourcePath($path); // using !is_dir because $sourcePath might be a part file or // non-existing file, so we'd still want to use the parent dir @@ -370,31 +413,22 @@ class Local extends \OC\Files\Storage\Common { // disk_free_space doesn't work on files $sourcePath = dirname($sourcePath); } - $space = @disk_free_space($sourcePath); + $space = (function_exists('disk_free_space') && is_dir($sourcePath)) ? disk_free_space($sourcePath) : false; if ($space === false || is_null($space)) { return \OCP\Files\FileInfo::SPACE_UNKNOWN; } - return $space; + return Util::numericToNumber($space); } - public function search($query) { + public function search(string $query): array { return $this->searchInDir($query); } - public function getLocalFile($path) { - return $this->getSourcePath($path); - } - - public function getLocalFolder($path) { + public function getLocalFile(string $path): string|false { return $this->getSourcePath($path); } - /** - * @param string $query - * @param string $dir - * @return array - */ - protected function searchInDir($query, $dir = '') { + protected function searchInDir(string $query, string $dir = ''): array { $files = []; $physicalDir = $this->getSourcePath($dir); foreach (scandir($physicalDir) as $item) { @@ -413,14 +447,7 @@ class Local extends \OC\Files\Storage\Common { return $files; } - /** - * check if a file or folder has been updated since $time - * - * @param string $path - * @param int $time - * @return bool - */ - public function hasUpdated($path, $time) { + public function hasUpdated(string $path, int $time): bool { if ($this->file_exists($path)) { return $this->filemtime($path) > $time; } else { @@ -431,18 +458,16 @@ class Local extends \OC\Files\Storage\Common { /** * Get the source path (on disk) of a given path * - * @param string $path - * @return string * @throws ForbiddenException */ - public function getSourcePath($path) { + public function getSourcePath(string $path): string { if (Filesystem::isFileBlacklisted($path)) { - throw new ForbiddenException('Invalid path', false); + throw new ForbiddenException('Invalid path: ' . $path, false); } $fullPath = $this->datadir . $path; $currentPath = $path; - $allowSymlinks = \OC::$server->getConfig()->getSystemValue('localstorage.allowsymlinks', false); + $allowSymlinks = $this->config->getSystemValueBool('localstorage.allowsymlinks', false); if ($allowSymlinks || $currentPath === '') { return $fullPath; } @@ -450,6 +475,7 @@ class Local extends \OC\Files\Storage\Common { $realPath = realpath($pathToResolve); while ($realPath === false) { // for non existing files check the parent directory $currentPath = dirname($currentPath); + /** @psalm-suppress TypeDoesNotContainType Let's be extra cautious and still check for empty string */ if ($currentPath === '' || $currentPath === '.') { return $fullPath; } @@ -462,29 +488,20 @@ class Local extends \OC\Files\Storage\Common { return $fullPath; } - \OCP\Util::writeLog('core', "Following symlinks is not allowed ('$fullPath' -> '$realPath' not inside '{$this->realDataDir}')", ILogger::ERROR); + Server::get(LoggerInterface::class)->error("Following symlinks is not allowed ('$fullPath' -> '$realPath' not inside '{$this->realDataDir}')", ['app' => 'core']); throw new ForbiddenException('Following symlinks is not allowed', false); } - /** - * {@inheritdoc} - */ - public function isLocal() { + public function isLocal(): bool { return true; } - /** - * get the ETag for a file or folder - * - * @param string $path - * @return string - */ - public function getETag($path) { + public function getETag(string $path): string|false { return $this->calculateEtag($path, $this->stat($path)); } - private function calculateEtag(string $path, array $stat): string { - if ($stat['mode'] & 0x4000) { // is_dir + private function calculateEtag(string $path, array $stat): string|false { + if ($stat['mode'] & 0x4000 && !($stat['mode'] & 0x8000)) { // is_dir & not socket return parent::getETag($path); } else { if ($stat === false) { @@ -509,20 +526,28 @@ class Local extends \OC\Files\Storage\Common { } } - /** - * @param IStorage $sourceStorage - * @param string $sourceInternalPath - * @param string $targetInternalPath - * @param bool $preserveMtime - * @return bool - */ - public function copyFromStorage(IStorage $sourceStorage, $sourceInternalPath, $targetInternalPath, $preserveMtime = false) { - if ($sourceStorage->instanceOfStorage(Local::class)) { - if ($sourceStorage->instanceOfStorage(Jail::class)) { + private function canDoCrossStorageMove(IStorage $sourceStorage): bool { + /** @psalm-suppress UndefinedClass,InvalidArgument */ + return $sourceStorage->instanceOfStorage(Local::class) + // Don't treat ACLStorageWrapper like local storage where copy can be done directly. + // Instead, use the slower recursive copying in php from Common::copyFromStorage with + // more permissions checks. + && !$sourceStorage->instanceOfStorage('OCA\GroupFolders\ACL\ACLStorageWrapper') + // Same for access control + && !$sourceStorage->instanceOfStorage(\OCA\FilesAccessControl\StorageWrapper::class) + // when moving encrypted files we have to handle keys and the target might not be encrypted + && !$sourceStorage->instanceOfStorage(Encryption::class); + } + + public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath, bool $preserveMtime = false): bool { + if ($this->canDoCrossStorageMove($sourceStorage)) { + // resolve any jailed paths + while ($sourceStorage->instanceOfStorage(Jail::class)) { /** * @var \OC\Files\Storage\Wrapper\Jail $sourceStorage */ $sourceInternalPath = $sourceStorage->getUnjailedPath($sourceInternalPath); + $sourceStorage = $sourceStorage->getUnjailedStorage(); } /** * @var \OC\Files\Storage\Local $sourceStorage @@ -534,19 +559,15 @@ class Local extends \OC\Files\Storage\Common { } } - /** - * @param IStorage $sourceStorage - * @param string $sourceInternalPath - * @param string $targetInternalPath - * @return bool - */ - public function moveFromStorage(IStorage $sourceStorage, $sourceInternalPath, $targetInternalPath) { - if ($sourceStorage->instanceOfStorage(Local::class)) { - if ($sourceStorage->instanceOfStorage(Jail::class)) { + public function moveFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { + if ($this->canDoCrossStorageMove($sourceStorage)) { + // resolve any jailed paths + while ($sourceStorage->instanceOfStorage(Jail::class)) { /** * @var \OC\Files\Storage\Wrapper\Jail $sourceStorage */ $sourceInternalPath = $sourceStorage->getUnjailedPath($sourceInternalPath); + $sourceStorage = $sourceStorage->getUnjailedStorage(); } /** * @var \OC\Files\Storage\Local $sourceStorage @@ -558,10 +579,14 @@ class Local extends \OC\Files\Storage\Common { } } - public function writeStream(string $path, $stream, int $size = null): int { + public function writeStream(string $path, $stream, ?int $size = null): int { + /** @var int|false $result We consider here that returned size will never be a float because we write less than 4GB */ $result = $this->file_put_contents($path, $stream); + if (is_resource($stream)) { + fclose($stream); + } if ($result === false) { - throw new GenericFileException("Failed write steam to $path"); + throw new GenericFileException("Failed write stream to $path"); } else { return $result; } |