diff --git a/apps/dav/lib/Capabilities.php b/apps/dav/lib/Capabilities.php index 3bd15d095e17..efdb92871af0 100644 --- a/apps/dav/lib/Capabilities.php +++ b/apps/dav/lib/Capabilities.php @@ -30,6 +30,7 @@ public function getCapabilities() { return [ 'dav' => [ 'chunking' => '1.0', + 'zsync' => '1.0', ] ]; } diff --git a/apps/dav/lib/Connector/Sabre/Directory.php b/apps/dav/lib/Connector/Sabre/Directory.php index e051e78d1f88..c8fc4cd21a86 100644 --- a/apps/dav/lib/Connector/Sabre/Directory.php +++ b/apps/dav/lib/Connector/Sabre/Directory.php @@ -51,6 +51,7 @@ use OC\Files\Mount\MoveableMount; use Sabre\DAV\IFile; use OCA\DAV\Upload\FutureFile; +use OCA\DAV\Upload\FutureFileZsync; use Sabre\DAV\IQuota; use Sabre\HTTP\URLUtil; @@ -147,7 +148,7 @@ public function createFile($name, $data = null) { ) { throw new SabreForbidden(); } - } else if (FutureFile::isFutureFile()) { + } else if (FutureFile::isFutureFile() or FutureFileZsync::isFutureFile()) { // Future file (chunked upload) requires fileinfo $info = $this->fileView->getFileInfo($this->path . '/' . $name); } else { diff --git a/apps/dav/lib/Files/ZsyncPlugin.php b/apps/dav/lib/Files/ZsyncPlugin.php new file mode 100644 index 000000000000..4af4e848ae97 --- /dev/null +++ b/apps/dav/lib/Files/ZsyncPlugin.php @@ -0,0 +1,148 @@ + + * + * @copyright Copyright (c) 2017, 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\DAV\Files; + +use OC\AppFramework\Http; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\DAV\PropFind; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; +use OC\Files\View; +use \Exception; + +class ZsyncPlugin extends ServerPlugin { + + // namespace + const ZSYNC_PROPERTYNAME = '{http://owncloud.org/ns}zsync'; + + /** @var OC\Files\View */ + private $view; + + public function __construct(View $view) { + $this->view = $view; + $this->view->mkdir('files_zsync'); + } + + /** + * Initializes the plugin and registers event handlers + * + * @param Server $server + * @return void + */ + function initialize(Server $server) { + $server->on('method:GET', [$this, 'httpGet'], 90); + $server->on('method:DELETE', [$this, 'httpDelete'], 90); + $server->on('propFind', [$this, 'handleGetProperties']); + } + + /** + * Intercepts GET requests on file urls ending with ?zsync. + * + * @param RequestInterface $request + * @param ResponseInterface $response + */ + function httpGet(RequestInterface $request, ResponseInterface $response) { + + $queryParams = $request->getQueryParameters(); + if (!array_key_exists('zsync', $queryParams)) { + return true; + } + + $path = ltrim($request->getPath(), '/'); + /* remove files/$user */ + $path = implode('/', array_slice(explode('/', $path), 2)); + /* If basefile not found this is an error */ + if (!$this->view->file_exists('files/'.$path)) { + $response->setStatus(Http::STATUS_NOT_FOUND); + return false; + } + + $info = $this->view->getFileInfo('files/'.$path); + $zsyncMetadataFile = 'files_zsync/'.$info->getId(); + if ($this->view->file_exists($zsyncMetadataFile)) { + $content = $this->view->file_get_contents($zsyncMetadataFile); + $response->setStatus(Http::STATUS_OK); + $response->setBody($content); + } else { + $response->setStatus(Http::STATUS_NOT_FOUND); + } + + return false; + } + + /** + * Intercepts DELETE requests on file urls ending with ?zsync. + * + * @param RequestInterface $request + * @param ResponseInterface $response + */ + function httpDelete(RequestInterface $request, ResponseInterface $response) { + + $queryParams = $request->getQueryParameters(); + if (!array_key_exists('zsync', $queryParams)) { + return true; + } + + $path = ltrim($request->getPath(), '/'); + /* remove files/$user */ + $path = implode('/', array_slice(explode('/', $path), 2)); + /* If basefile not found this is an error */ + if (!$this->view->file_exists('files/'.$path)) { + $response->setStatus(Http::STATUS_NOT_FOUND); + return false; + } + + $info = $this->view->getFileInfo('files/'.$path); + $zsyncMetadataFile = 'files_zsync/'.$info->getId(); + if ($this->view->file_exists($zsyncMetadataFile)) { + $this->view->unlink($zsyncMetadataFile); + $response->setStatus(Http::STATUS_OK); + } else { + $response->setStatus(Http::STATUS_NOT_FOUND); + } + + return false; + } + + /** + * Adds zsync property if metadata exists + * + * @param PropFind $propFind + * @param \Sabre\DAV\INode $node + * @return void + */ + public function handleGetProperties(PropFind $propFind, \Sabre\DAV\INode $node) { + if ($node instanceof \OCA\DAV\Connector\Sabre\File) { + if (!$this->view->is_file('files/'.$node->getPath())) + return; + $info = $this->view->getFileInfo('files/'.$node->getPath()); + $zsyncMetadataFile = 'files_zsync/'.$info->getId(); + if ($this->view->file_exists($zsyncMetadataFile)) { + $propFind->handle(self::ZSYNC_PROPERTYNAME, function() use ($node) { + return 'true'; + }); + } + } + } +} diff --git a/apps/dav/lib/HookManager.php b/apps/dav/lib/HookManager.php index d48d8cccb8de..fe6824902ae5 100644 --- a/apps/dav/lib/HookManager.php +++ b/apps/dav/lib/HookManager.php @@ -27,8 +27,10 @@ use OCA\DAV\CardDAV\SyncService; use OCP\IL10N; use OCP\IUser; +use OCP\User; use OCP\IUserManager; use OCP\Util; +use OC\Files\View; class HookManager { @@ -85,6 +87,23 @@ public function setup() { 'changeUser', $this, 'changeUser'); + + Util::connectHook('OC_Filesystem', + 'post_copy', + $this, + 'copyZsyncMetadata'); + Util::connectHook('OC_Filesystem', + 'write', + $this, + 'deleteZsyncMetadata'); + Util::connectHook('OC_Filesystem', + 'delete', + $this, + 'deleteZsyncMetadata'); + Util::connectHook('\OCP\Versions', + 'rollback', + $this, + 'deleteZsyncMetadata'); } public function postCreateUser($params) { @@ -144,4 +163,66 @@ public function firstLogin(IUser $user = null) { } } } + + public function deleteZsyncMetadata($params) { + $view = new View('/'.User::getUser()); + $path = $params[\OC\Files\Filesystem::signal_param_path]; + $path = 'files/' . ltrim($path, '/'); + + /* if a file then just delete zsync metadata for file */ + if ($view->is_file($path)) { + $info = $view->getFileInfo($path); + if ($view->file_exists('files_zsync/'.$info->getId())) + $view->unlink('files_zsync/'.$info->getId()); + } else if ($view->is_dir($path)) { + /* if a folder then iteratively delete all zsync metadata for all files in folder, including subdirs */ + $array[] = $path; + while (count($array)) { + $current = array_pop($array); + $handle = $view->opendir($current); + while (($entry = readdir($handle)) !== false) { + if($entry[0]!='.' and $view->is_dir($current.'/'.$entry)) { + $array[] = $current.'/'.$entry; + } else if ($view->is_file($current.'/'.$entry)) { + $info = $view->getFileInfo($current.'/'.$entry); + if ($view->file_exists('files_zsync/'.$info->getId())) + $view->unlink('files_zsync/'.$info->getId()); + } + } + } + } + } + + public function copyZsyncMetadata($params) { + $view = new View('/'.User::getUser()); + $from = $params[\OC\Files\Filesystem::signal_param_oldpath]; + $from = 'files/' . ltrim($from, '/'); + $to = $params[\OC\Files\Filesystem::signal_param_newpath]; + $to = 'files/' . ltrim($to, '/'); + + /* if a file then just copy zsync metadata for file */ + if ($view->is_file($from)) { + $info_from = $view->getFileInfo($from); + $info_to = $view->getFileInfo($to); + if ($view->file_exists('files_zsync/'.$info_from->getId())) + $view->copy('files_zsync/'.$info_from->getId(), 'files_zsync/'.$info_to->getId()); + } else if ($view->is_dir($from)) { + /* if a folder then iteratively copy all zsync metadata for all files in folder, including subdirs */ + $array[] = [$from, $to]; + while (count($array)) { + list($from_current, $to_current) = array_pop($array); + $handle = $view->opendir($from_current); + while (($entry = readdir($handle)) !== false) { + if($entry[0]!='.' and $view->is_dir($from_current.'/'.$entry)) { + $array[] = [$from_current.'/'.$entry, $to_current.'/'.$entry]; + } else if ($view->is_file($from_current.'/'.$entry)) { + $info_from = $view->getFileInfo($from_current.'/'.$entry); + $info_to = $view->getFileInfo($to_current.'/'.$entry); + if ($view->file_exists('files_zsync/'.$info_from->getId())) + $view->copy('files_zsync/'.$info_from->getId(), 'files_zsync/'.$info_to->getId()); + } + } + } + } + } } diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index 7fd765b5c0b8..c193faf63049 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -42,10 +42,12 @@ use OCA\DAV\DAV\PublicAuth; use OCA\DAV\Connector\Sabre\QuotaPlugin; use OCA\DAV\Files\BrowserErrorPagePlugin; +use OCA\DAV\Files\ZsyncPlugin; use OCA\DAV\DAV\FileCustomPropertiesBackend; use OCA\DAV\DAV\MiscCustomPropertiesBackend; use OCA\DAV\SystemTag\SystemTagPlugin; use OCA\DAV\Upload\ChunkingPlugin; +use OCA\DAV\Upload\ChunkingPluginZsync; use OCP\IRequest; use OCP\SabrePluginEvent; use Sabre\CardDAV\VCFExportPlugin; @@ -54,6 +56,7 @@ use OCA\DAV\AppInfo\PluginManager; use OCA\DAV\Connector\Sabre\MaintenancePlugin; use OCA\DAV\Connector\Sabre\ValidateRequestPlugin; +use OC\Files\View; class Server { @@ -168,6 +171,15 @@ public function __construct(IRequest $request, $baseUri) { $userSession = \OC::$server->getUserSession(); $user = $userSession->getUser(); if (!is_null($user)) { + $view = new View('/'.$user->getUID()); + $this->server->addPlugin( + new ChunkingPluginZsync($view) + ); + + $this->server->addPlugin( + new ZsyncPlugin($view) + ); + $view = \OC\Files\Filesystem::getView(); $this->server->addPlugin( new FilesPlugin( diff --git a/apps/dav/lib/Upload/AssemblyStream.php b/apps/dav/lib/Upload/AssemblyStream.php index cb757106ae99..e67417eb9cb7 100644 --- a/apps/dav/lib/Upload/AssemblyStream.php +++ b/apps/dav/lib/Upload/AssemblyStream.php @@ -37,22 +37,22 @@ class AssemblyStream implements \Icewind\Streams\File { /** @var resource */ - private $context; + protected $context; /** @var IFile[] */ - private $nodes; + protected $nodes; /** @var int */ - private $pos = 0; + protected $pos = 0; /** @var array */ - private $sortedNodes; + protected $sortedNodes; /** @var int */ - private $size; + protected $size; /** @var resource */ - private $currentStream = null; + protected $currentStream = null; /** * @param string $path @@ -78,6 +78,9 @@ public function stream_open($path, $mode, $options, &$opened_path) { foreach($this->nodes as $node) { $size = $node->getSize(); $name = $node->getName(); + // ignore .zsync metadata file + if (!strcmp($name,".zsync")) + continue; $this->sortedNodes[$name] = ['node' => $node, 'start' => $start, 'end' => $start + $size]; $start += $size; $this->size = $start; @@ -233,7 +236,7 @@ protected function loadContext($name) { * * @throws \BadMethodCallException */ - public static function wrap(array $nodes) { + public static function wrap(array $nodes, IFile $backingFile = null, $length = null) { $context = stream_context_create([ 'assembly' => [ 'nodes' => $nodes] @@ -253,7 +256,7 @@ public static function wrap(array $nodes) { * @param $pos * @return IFile | null */ - private function getNodeForPosition($pos) { + protected function getNodeForPosition($pos) { foreach($this->sortedNodes as $node) { if ($pos >= $node['start'] && $pos < $node['end']) { return [$node['node'], $pos - $node['start']]; @@ -266,7 +269,7 @@ private function getNodeForPosition($pos) { * @param IFile $node * @return resource */ - private function getStream(IFile $node) { + protected function getStream(IFile $node) { $data = $node->get(); if (is_resource($data)) { return $data; diff --git a/apps/dav/lib/Upload/AssemblyStreamZsync.php b/apps/dav/lib/Upload/AssemblyStreamZsync.php new file mode 100644 index 000000000000..a933d3ec054d --- /dev/null +++ b/apps/dav/lib/Upload/AssemblyStreamZsync.php @@ -0,0 +1,220 @@ + + * + * @copyright Copyright (c) 2017, 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\DAV\Upload; + +use Sabre\DAV\IFile; + +/** + * Class AssemblyStreamZsync + * + * The assembly stream is a virtual stream that wraps multiple chunks. + * Reading from the stream transparently accessed the underlying chunks and + * give a representation as if they were already merged together. + * + * @package OCA\DAV\Upload + */ +class AssemblyStreamZsync extends AssemblyStream { + + /** @var array */ + private $backingFile = null; + + /** @var array */ + private $backingNode = null; + + /** @var array */ + private $currentNode = null; + + /** @var int */ + private $next = 0; + + /** + * @param string $path + * @param string $mode + * @param int $options + * @param string &$opened_path + * @return bool + */ + public function stream_open($path, $mode, $options, &$opened_path) { + $this->loadContext('assembly'); + + // sort the nodes + $nodes = $this->nodes; + // http://stackoverflow.com/a/10985500 + @usort($nodes, function(IFile $a, IFile $b) { + return strnatcmp($a->getName(), $b->getName()); + }); + $this->nodes = $nodes; + + // build additional information + $this->sortedNodes = []; + foreach($this->nodes as $node) { + $size = $node->getSize(); + $name = $node->getName(); + // ignore .zsync metadata file + if (!strcmp($name,".zsync")) + continue; + if ($size == 0) + continue; + $this->sortedNodes[$name] = ['node' => $node, 'start' => (int)$name, 'end' => (int)$name + $size]; + } + + $this->backingNode = ["node" => $this->backingFile, "start" => 0, "end" => $this->backingFile->getSize()]; + $this->currentNode = $this->backingNode; + return true; + } + + /** + * @param int $count + * @return string + */ + public function stream_read($count) { + // we're done if we've reached the end of where we need to be + if ($this->pos >= $this->size) + return; + + // change the node/stream when we've reached the point we need to be at + if ($this->currentStream === null || $this->pos == $this->next) { + list($node, $posInNode) = $this->getNodeForPosition($this->pos); + $this->currentStream = $this->getStream($node['node']); + fseek($this->currentStream, $posInNode); + $this->currentNode = $node; + } + + // get the next byte offset when we need to change node/stream again + if ($this->pos == $this->next) + $this->next = $this->getNextNodeStart($this->pos); + + // don't read beyond the expected file size + if ($count + $this->pos >= $this->size) + $count = $this->size - $this->pos; + + // don't read beyond the next marker + if ($count + $this->pos >= $this->next) + $count = $this->next - $this->pos; + + // read the data + $data = fread($this->currentStream, $count); + if (isset($data[$count - 1])) { + // we read the full count + $read = $count; + } else { + // reaching end of stream, which happens less often so strlen is ok + $read = strlen($data); + } + + // update position + $this->pos += $read; + + // if we couldn't read as much as expected we are done + if ($read != $count) { + $this->pos = $this->size; + } + + // ensure we close the last stream or else we'll cause a locking issue + if ($this->pos == $this->size) { + $this->currentStream = null; + } + + return $data; + } + + /** + * Load the source from the stream context and return the context options + * + * @param string $name + * @return array + * @throws \Exception + */ + protected function loadContext($name) { + $context = stream_context_get_options($this->context); + if (isset($context[$name])) { + $context = $context[$name]; + } else { + throw new \BadMethodCallException('Invalid context, "' . $name . '" options not set'); + } + if (isset($context['nodes']) and is_array($context['nodes'])) { + $this->nodes = $context['nodes']; + } else { + throw new \BadMethodCallException('Invalid context, nodes not set'); + } + if (isset($context['backingFile'])) { + $this->backingFile = $context['backingFile']; + } else { + throw new \BadMethodCallException('Invalid context, backingFile not set'); + } + if (isset($context['fileLength'])) { + $this->size = $context['fileLength']; + } else { + throw new \BadMethodCallException('Invalid context, fileLength not set'); + } + + return $context; + } + + /** + * @param IFile[] $nodes + * @param IFile $backingFile + * @param $fileLength + * @return resource + * + * @throws \BadMethodCallException + */ + public static function wrap(array $nodes, IFile $backingFile = null, $fileLength = null) { + $context = stream_context_create([ + 'assembly' => [ + 'nodes' => $nodes, + 'backingFile' => $backingFile, + 'fileLength' => $fileLength + ] + ]); + stream_wrapper_register('assembly', '\OCA\DAV\Upload\AssemblyStreamZsync'); + try { + $wrapped = fopen('assembly://', 'r', null, $context); + } catch (\BadMethodCallException $e) { + stream_wrapper_unregister('assembly'); + throw $e; + } + stream_wrapper_unregister('assembly'); + return $wrapped; + } + + protected function getNextNodeStart($current) { + foreach($this->sortedNodes as $node) { + if ($current >= $node['start'] && $current < $node['end']) + return $node['end']; + if ($current < $node['start']) + return $node['start']; + } + return $this->currentNode['end']; + } + + /** + * @param $pos + */ + protected function getNodeForPosition($pos) { + foreach($this->sortedNodes as $node) { + if ($pos >= $node['start'] && $pos < $node['end']) { + return [$node, 0]; + } + } + return [$this->backingNode, $pos]; + } +} diff --git a/apps/dav/lib/Upload/ChunkingPluginZsync.php b/apps/dav/lib/Upload/ChunkingPluginZsync.php new file mode 100644 index 000000000000..6a71468371c5 --- /dev/null +++ b/apps/dav/lib/Upload/ChunkingPluginZsync.php @@ -0,0 +1,170 @@ + + * + * @copyright Copyright (c) 2017, 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\DAV\Upload; + + +use OCA\DAV\Connector\Sabre\File; +use Sabre\DAV\Exception\BadRequest; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\DAV\Exception\NotFound; +use OC\Files\View; + +class ChunkingPluginZsync extends ServerPlugin { + + /** @var Server */ + private $server; + /** @var FutureFileZsync */ + private $sourceNode; + /** @var OC\Files\View */ + private $view; + + public function __construct(View $view) { + $this->view = $view; + $this->view->mkdir('files_zsync'); + } + + /** + * @inheritdoc + */ + function initialize(Server $server) { + $server->on('beforeMove', [$this, 'beforeMove']); + $this->server = $server; + } + + /** + * @param string $sourcePath source path + * @param string $destination destination path + */ + function beforeMove($sourcePath, $destination) { + $this->sourceNode = $this->server->tree->getNodeForPath($sourcePath); + if (!$this->sourceNode instanceof FutureFileZsync) { + // skip handling as the source is not a chunked FutureFileZsync + return; + } + + $this->verifySize(); + return $this->performMove($sourcePath, $destination); + } + + /** + * Handles the temporary copy of the zsync metadata file + * + * Will not execute on external storage. + * + * @param string $path metadata path + * @param string $destination destination path + */ + private function preMoveZsync($path, $destination) { + try { + $node = $this->server->tree->getNodeForPath($destination); + } catch (NotFound $e) { + $node = $this->server->tree->getNodeForPath(dirname($destination)); + } + + // Disable if external storage used. + if (strpos($node->getDavPermissions(), 'M') === false) { + $zsyncMetadataNode = $this->server->tree->getNodeForPath($path); + $zsyncMetadataHandle = $zsyncMetadataNode->get(); + + // get .zsync contents before its deletion + $zsyncMetadata = ''; + while (!feof($zsyncMetadataHandle)) { + $zsyncMetadata .= fread($zsyncMetadataHandle, $zsyncMetadataNode->getSize()); + } + fclose($zsyncMetadataHandle); + + if ($this->server->tree->nodeExists($destination)) { + // set backingFile which is needed by AssemblyStreamZsync + $backingFile = $this->server->tree->getNodeForPath($destination); + $this->sourceNode->setBackingFile($backingFile); + } + + $fileLength = $this->server->httpRequest->getHeader('OC-Total-File-Length'); + $this->sourceNode->setFileLength($fileLength); + + return $zsyncMetadata; + } + } + + /** + * Handles the creation of the zsync metadata file + * + * @param string &$zsyncMetadata actual metadata + * @param string $destination destination path + */ + private function postMoveZsync(&$zsyncMetadata, $destination) { + if (!$zsyncMetadata) + return; + $destination = implode('/', array_slice(explode('/', $destination), 2)); + $info = $this->view->getFileInfo('files/'.$destination); + $zsyncMetadataFile = 'files_zsync/'.$info->getId(); + $this->view->file_put_contents($zsyncMetadataFile, $zsyncMetadata); + } + + /** + * Move handler for future file. + * + * This overrides the default move behavior to prevent Sabre + * to delete the target file before moving. Because deleting would + * lose the file id and metadata. + * + * @param string $path source path + * @param string $destination destination path + * @return bool|void false to stop handling, void to skip this handler + */ + public function performMove($path, $destination) { + $response = $this->server->httpResponse; + $response->setHeader('Content-Length', '0'); + $this->server->tree->nodeExists($destination) ? $response->setStatus(204) : $response->setStatus(201); + + // copy the zsync metadata file contents, before it gets removed. + $zsyncMetadataPath = dirname($path).'/.zsync'; + $zsyncMetadata = $this->preMoveZsync($zsyncMetadataPath, $destination); + + // do a move manually, skipping Sabre's default "delete" for existing nodes + $this->server->tree->move($path, $destination); + + // create the zsync metadata file + $this->postMoveZsync($zsyncMetadata, $destination); + + // trigger all default events (copied from CorePlugin::move) + $this->server->emit('afterMove', [$path, $destination]); + $this->server->emit('afterUnbind', [$path]); + $this->server->emit('afterBind', [$destination]); + + return false; + } + + /** + * @throws BadRequest + */ + private function verifySize() { + $expectedSize = $this->server->httpRequest->getHeader('OC-Total-Length'); + if ($expectedSize === null) { + return; + } + $actualSize = $this->sourceNode->getSize(); + if ((int)$expectedSize !== $actualSize) { + throw new BadRequest("Chunks on server do not sum up to $expectedSize but to $actualSize"); + } + } +} diff --git a/apps/dav/lib/Upload/FutureFile.php b/apps/dav/lib/Upload/FutureFile.php index 9ef8b98b5c2f..11574a1a79b0 100644 --- a/apps/dav/lib/Upload/FutureFile.php +++ b/apps/dav/lib/Upload/FutureFile.php @@ -36,9 +36,9 @@ class FutureFile implements \Sabre\DAV\IFile { /** @var Directory */ - private $root; + protected $root; /** @var string */ - private $name; + protected $name; static public function getFutureFileName() { return '.file'; diff --git a/apps/dav/lib/Upload/FutureFileZsync.php b/apps/dav/lib/Upload/FutureFileZsync.php new file mode 100644 index 000000000000..f78fb6b4ef65 --- /dev/null +++ b/apps/dav/lib/Upload/FutureFileZsync.php @@ -0,0 +1,82 @@ + + * + * @copyright Copyright (c) 2017, 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\DAV\Upload; + +use OCA\DAV\Connector\Sabre\Directory; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\IFile; + +/** + * Class FutureFileZsync + * + * The FutureFileZsync is a SabreDav IFile which connects the chunked upload directory + * with the AssemblyStreamZsync, who does the final assembly job + * + * @package OCA\DAV\Upload + */ +class FutureFileZsync extends FutureFile { + + /** @var IFile */ + private $backingFile = null; + /** @var string */ + private $fileLength = 0; + + static public function getFutureFileName() { + return '.file.zsync'; + } + + static public function isFutureFile() { + $davUploadsTarget = '/dav/uploads'; + + // Check if pathinfo starts with dav uploads target and basename is future file basename + if (isset($_SERVER['PATH_INFO']) + && pathinfo($_SERVER['PATH_INFO'], PATHINFO_BASENAME) === FutureFileZsync::getFutureFileName() + && (strpos($_SERVER['PATH_INFO'], $davUploadsTarget) === 0)) { + return true; + } + + return false; + } + + /** + * @inheritdoc + */ + function get() { + $nodes = $this->root->getChildren(); + return $this->root->childExists('.zsync') && $this->backingFile && $this->fileLength ? + AssemblyStreamZsync::wrap($nodes, $this->backingFile, $this->fileLength) : + AssemblyStream::wrap($nodes); + } + + /** + * @param IFile $file + */ + function setBackingFile(IFile $file) { + $this->backingFile = $file; + } + + /** + * @param string $fileLength + */ + function setFileLength($fileLength) { + $this->fileLength = $fileLength; + } +} diff --git a/apps/dav/lib/Upload/UploadFolder.php b/apps/dav/lib/Upload/UploadFolder.php index 19e511f260d6..931e9a057dc2 100644 --- a/apps/dav/lib/Upload/UploadFolder.php +++ b/apps/dav/lib/Upload/UploadFolder.php @@ -46,12 +46,16 @@ function getChild($name) { if ($name === FutureFile::getFutureFileName()) { return new FutureFile($this->node, FutureFile::getFutureFileName()); } + if ($name === FutureFileZsync::getFutureFileName()) { + return new FutureFileZsync($this->node, FutureFileZsync::getFutureFileName()); + } return $this->node->getChild($name); } function getChildren() { $children = $this->node->getChildren(); $children[] = new FutureFile($this->node, FutureFile::getFutureFileName()); + $children[] = new FutureFileZsync($this->node, FutureFileZsync::getFutureFileName()); return $children; } @@ -59,6 +63,9 @@ function childExists($name) { if ($name === FutureFile::getFutureFileName()) { return true; } + if ($name === FutureFileZsync::getFutureFileName()) { + return true; + } return $this->node->childExists($name); } diff --git a/apps/dav/tests/unit/DAV/HookManagerZsyncTest.php b/apps/dav/tests/unit/DAV/HookManagerZsyncTest.php new file mode 100644 index 000000000000..ab9757af1829 --- /dev/null +++ b/apps/dav/tests/unit/DAV/HookManagerZsyncTest.php @@ -0,0 +1,316 @@ + + * + * @copyright Copyright (c) 2017, 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\DAV\Tests\unit\DAV; + +use Test\TestCase; +use OCP\Files\NotFoundException; +use OC\L10N\L10N; +use OCA\DAV\CalDAV\BirthdayService; +use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\CardDAV\CardDavBackend; +use OCA\DAV\CardDAV\SyncService; +use OCA\DAV\HookManager; +use OCP\IUser; +use OCP\IUserManager; + +/** + * Class Test_Files_zsynchooks + * this class provide basic files zsync hooks test + * + * @group DB + */ +class HookManagerZsyncTest extends TestCase { + + const TEST_ZSYNC_HOOKS_USER = 'test-files-user'; + const USERS_ZSYNC_ROOT = '/test-files-user/files_zsync'; + + /** + * @var \OC\Files\View + */ + private $rootView; + + public static function setUpBeforeClass() { + parent::setUpBeforeClass(); + + $application = new \OCA\Files_Sharing\AppInfo\Application(); + $application->registerMountProviders(); + + // create test user + self::loginHelper(self::TEST_ZSYNC_HOOKS_USER, true); + } + + public static function tearDownAfterClass() { + // cleanup test user + $user = \OC::$server->getUserManager()->get(self::TEST_ZSYNC_HOOKS_USER); + if ($user !== null) { $user->delete(); } + + parent::tearDownAfterClass(); + } + + protected function setUp() { + parent::setUp(); + + $config = \OC::$server->getConfig(); + $mockConfig = $this->createMock('\OCP\IConfig'); + $mockConfig->expects($this->any()) + ->method('getSystemValue') + ->will($this->returnCallback(function ($key, $default) use ($config) { + if ($key === 'filesystem_check_changes') { + return \OC\Files\Cache\Watcher::CHECK_ONCE; + } else { + return $config->getSystemValue($key, $default); + } + })); + $this->overwriteService('AllConfig', $mockConfig); + + // clear hooks + \OC_Hook::clear(); + \OC::registerShareHooks(); + + $l10n = $this->getMockBuilder('OC\L10N\L10N') + ->disableOriginalConstructor()->getMock(); + + $user = $this->getMockBuilder(IUser::class) + ->disableOriginalConstructor() + ->getMock(); + + /** @var IUserManager | \PHPUnit_Framework_MockObject_MockObject $userManager */ + $userManager = $this->getMockBuilder(IUserManager::class) + ->disableOriginalConstructor() + ->getMock(); + + /** @var SyncService | \PHPUnit_Framework_MockObject_MockObject $syncService */ + $syncService = $this->getMockBuilder(SyncService::class) + ->disableOriginalConstructor() + ->getMock(); + + /** @var CalDavBackend | \PHPUnit_Framework_MockObject_MockObject $cal */ + $cal = $this->getMockBuilder(CalDavBackend::class) + ->disableOriginalConstructor() + ->getMock(); + + /** @var CardDavBackend | \PHPUnit_Framework_MockObject_MockObject $card */ + $card = $this->getMockBuilder(CardDavBackend::class) + ->disableOriginalConstructor() + ->getMock(); + + $hm = new HookManager($userManager, $syncService, $cal, $card, $l10n); + $hm->setup(); + + self::loginHelper(self::TEST_ZSYNC_HOOKS_USER); + $this->rootView = new \OC\Files\View(); + if (!$this->rootView->file_exists(self::USERS_ZSYNC_ROOT)) { + $this->rootView->mkdir(self::USERS_ZSYNC_ROOT); + } + } + + protected function tearDown() { + $this->restoreService('AllConfig'); + + if ($this->rootView) { + $this->rootView->deleteAll(self::TEST_ZSYNC_HOOKS_USER . '/files/'); + $this->rootView->deleteAll(self::TEST_ZSYNC_HOOKS_USER . '/files_zsync/'); + } + + \OC_Hook::clear(); + + parent::tearDown(); + } + + /** + * @param string $user + * @param bool $create + */ + public static function loginHelper($user, $create = false) { + + if ($create) { + \OC::$server->getUserManager()->createUser($user, $user); + } + + $storage = new \ReflectionClass('\OCA\Files_Sharing\SharedStorage'); + $isInitialized = $storage->getProperty('initialized'); + $isInitialized->setAccessible(true); + $isInitialized->setValue($storage, false); + $isInitialized->setAccessible(false); + + \OC_Util::tearDownFS(); + \OC_User::setUserId(''); + \OC\Files\Filesystem::tearDown(); + \OC_User::setUserId($user); + \OC_Util::setupFS($user); + \OC::$server->getUserFolder($user); + } + + public function testRenameFile() { + \OC\Files\Filesystem::file_put_contents("test.txt", "test file"); + + $info = $this->rootView->getFileInfo(self::TEST_ZSYNC_HOOKS_USER.'/files/test.txt'); + $z1 = self::USERS_ZSYNC_ROOT . '/' . $info->getId(); + + $this->rootView->file_put_contents($z1, 'zsync'); + + \OC\Files\Filesystem::rename("test.txt", "test.txt.renamed"); + + $this->assertTrue($this->rootView->file_exists($z1)); + } + + public function testDeleteFile() { + \OC\Files\Filesystem::file_put_contents("test.txt", "test file"); + + $info = $this->rootView->getFileInfo(self::TEST_ZSYNC_HOOKS_USER.'/files/test.txt'); + $z1 = self::USERS_ZSYNC_ROOT . '/' . $info->getId(); + + $this->rootView->file_put_contents($z1, 'zsync'); + + \OC\Files\Filesystem::unlink("test.txt"); + + $this->assertFalse($this->rootView->file_exists($z1)); + } + + public function testCopyFile() { + \OC\Files\Filesystem::file_put_contents("test.txt", "test file"); + + $info = $this->rootView->getFileInfo(self::TEST_ZSYNC_HOOKS_USER.'/files/test.txt'); + $z1 = self::USERS_ZSYNC_ROOT . '/' . $info->getId(); + + $this->rootView->file_put_contents($z1, 'zsync'); + + \OC\Files\Filesystem::copy("test.txt", "test.txt.copied"); + + $info = $this->rootView->getFileInfo(self::TEST_ZSYNC_HOOKS_USER.'/files/test.txt.copied'); + $z1Copied = self::USERS_ZSYNC_ROOT . '/' . $info->getId(); + + $this->assertTrue($this->rootView->file_exists($z1)); + $this->assertTrue($this->rootView->file_exists($z1Copied)); + } + + public function testRenameFolder() { + \OC\Files\Filesystem::mkdir("test/sub/sub"); + \OC\Files\Filesystem::file_put_contents("test/test.txt", "test file1"); + \OC\Files\Filesystem::file_put_contents("test/sub/test.txt", "test file2"); + \OC\Files\Filesystem::file_put_contents("test/sub/sub/test.txt", "test file3"); + + $info = $this->rootView->getFileInfo(self::TEST_ZSYNC_HOOKS_USER.'/files/test/test.txt'); + $z1 = self::USERS_ZSYNC_ROOT . '/' . $info->getId(); + $info = $this->rootView->getFileInfo(self::TEST_ZSYNC_HOOKS_USER.'/files/test/sub/test.txt'); + $z2 = self::USERS_ZSYNC_ROOT . '/' . $info->getId(); + $info = $this->rootView->getFileInfo(self::TEST_ZSYNC_HOOKS_USER.'/files/test/sub/sub/test.txt'); + $z3 = self::USERS_ZSYNC_ROOT . '/' . $info->getId(); + + $this->rootView->file_put_contents($z1, 'zsync'); + $this->rootView->file_put_contents($z2, 'zsync'); + $this->rootView->file_put_contents($z3, 'zsync'); + + \OC\Files\Filesystem::rename("test", "test.renamed"); + + $this->assertTrue($this->rootView->file_exists($z1)); + $this->assertTrue($this->rootView->file_exists($z2)); + $this->assertTrue($this->rootView->file_exists($z3)); + } + + public function testDeleteFolder() { + \OC\Files\Filesystem::mkdir("test/sub/sub"); + \OC\Files\Filesystem::file_put_contents("test/test.txt", "test file1"); + \OC\Files\Filesystem::file_put_contents("test/sub/test.txt", "test file2"); + \OC\Files\Filesystem::file_put_contents("test/sub/sub/test.txt", "test file3"); + + $info = $this->rootView->getFileInfo(self::TEST_ZSYNC_HOOKS_USER.'/files/test/test.txt'); + $z1 = self::USERS_ZSYNC_ROOT . '/' . $info->getId(); + $info = $this->rootView->getFileInfo(self::TEST_ZSYNC_HOOKS_USER.'/files/test/sub/test.txt'); + $z2 = self::USERS_ZSYNC_ROOT . '/' . $info->getId(); + $info = $this->rootView->getFileInfo(self::TEST_ZSYNC_HOOKS_USER.'/files/test/sub/sub/test.txt'); + $z3 = self::USERS_ZSYNC_ROOT . '/' . $info->getId(); + + $this->rootView->file_put_contents($z1, 'zsync'); + $this->rootView->file_put_contents($z2, 'zsync'); + $this->rootView->file_put_contents($z3, 'zsync'); + + \OC\Files\Filesystem::rmdir("test"); + + $this->assertFalse($this->rootView->file_exists($z1)); + $this->assertFalse($this->rootView->file_exists($z2)); + $this->assertFalse($this->rootView->file_exists($z3)); + } + + public function testCopyFolder() { + \OC\Files\Filesystem::mkdir("test/sub/sub"); + \OC\Files\Filesystem::file_put_contents("test/test.txt", "test file1"); + \OC\Files\Filesystem::file_put_contents("test/sub/test.txt", "test file2"); + \OC\Files\Filesystem::file_put_contents("test/sub/sub/test.txt", "test file3"); + + $info = $this->rootView->getFileInfo(self::TEST_ZSYNC_HOOKS_USER.'/files/test/test.txt'); + $z1 = self::USERS_ZSYNC_ROOT . '/' . $info->getId(); + $info = $this->rootView->getFileInfo(self::TEST_ZSYNC_HOOKS_USER.'/files/test/sub/test.txt'); + $z2 = self::USERS_ZSYNC_ROOT . '/' . $info->getId(); + $info = $this->rootView->getFileInfo(self::TEST_ZSYNC_HOOKS_USER.'/files/test/sub/sub/test.txt'); + $z3 = self::USERS_ZSYNC_ROOT . '/' . $info->getId(); + + $this->rootView->file_put_contents($z1, 'zsync'); + $this->rootView->file_put_contents($z2, 'zsync'); + $this->rootView->file_put_contents($z3, 'zsync'); + + \OC\Files\Filesystem::copy("test", "test.copied"); + + $info = $this->rootView->getFileInfo(self::TEST_ZSYNC_HOOKS_USER.'/files/test.copied/test.txt'); + $z1 = self::USERS_ZSYNC_ROOT . '/' . $info->getId(); + $info = $this->rootView->getFileInfo(self::TEST_ZSYNC_HOOKS_USER.'/files/test.copied/sub/test.txt'); + $z2 = self::USERS_ZSYNC_ROOT . '/' . $info->getId(); + $info = $this->rootView->getFileInfo(self::TEST_ZSYNC_HOOKS_USER.'/files/test.copied/sub/sub/test.txt'); + $z3 = self::USERS_ZSYNC_ROOT . '/' . $info->getId(); + + $this->assertTrue($this->rootView->file_exists($z1)); + $this->assertTrue($this->rootView->file_exists($z2)); + $this->assertTrue($this->rootView->file_exists($z3)); + } + + public function testCopyFileNotExist() { + \OCP\Util::writeLog('testCopyFileNotExist', '', \OCP\Util::ERROR); + + \OC\Files\Filesystem::mkdir("test/sub/sub"); + \OC\Files\Filesystem::file_put_contents("test/test.txt", "test file1"); + \OC\Files\Filesystem::file_put_contents("test/sub/test.txt", "test file2"); + \OC\Files\Filesystem::file_put_contents("test/sub/sub/test.txt", "test file3"); + + $info = $this->rootView->getFileInfo(self::TEST_ZSYNC_HOOKS_USER.'/files/test/test.txt'); + $z1 = self::USERS_ZSYNC_ROOT . '/' . $info->getId(); + $info = $this->rootView->getFileInfo(self::TEST_ZSYNC_HOOKS_USER.'/files/test/sub/test.txt'); + $z2 = self::USERS_ZSYNC_ROOT . '/' . $info->getId(); + $info = $this->rootView->getFileInfo(self::TEST_ZSYNC_HOOKS_USER.'/files/test/sub/sub/test.txt'); + $z3 = self::USERS_ZSYNC_ROOT . '/' . $info->getId(); + + $this->rootView->file_put_contents($z1, 'zsync'); + $this->rootView->file_put_contents($z3, 'zsync'); + + \OC\Files\Filesystem::copy("test", "test.copied"); + + $info = $this->rootView->getFileInfo(self::TEST_ZSYNC_HOOKS_USER.'/files/test.copied/test.txt'); + $z1 = self::USERS_ZSYNC_ROOT . '/' . $info->getId(); + $info = $this->rootView->getFileInfo(self::TEST_ZSYNC_HOOKS_USER.'/files/test.copied/sub/test.txt'); + $z2 = self::USERS_ZSYNC_ROOT . '/' . $info->getId(); + $info = $this->rootView->getFileInfo(self::TEST_ZSYNC_HOOKS_USER.'/files/test.copied/sub/sub/test.txt'); + $z3 = self::USERS_ZSYNC_ROOT . '/' . $info->getId(); + + $this->assertTrue($this->rootView->file_exists($z1)); + $this->assertFalse($this->rootView->file_exists($z2)); + $this->assertTrue($this->rootView->file_exists($z3)); + } +} diff --git a/apps/dav/tests/unit/DAV/ZsyncPluginTest.php b/apps/dav/tests/unit/DAV/ZsyncPluginTest.php new file mode 100644 index 000000000000..c934e46ab9f5 --- /dev/null +++ b/apps/dav/tests/unit/DAV/ZsyncPluginTest.php @@ -0,0 +1,156 @@ + + * + * @copyright Copyright (c) 2017, 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\DAV\Tests\unit\DAV; + +use OCA\DAV\Files\ZsyncPlugin; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use Test\TestCase; +use OCP\IRequest; +use OCP\AppFramework\Http; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; +use Sabre\DAV\Server; +use OC\Files\View; + +class ZsyncPluginTest extends TestCase { + /** @var \OCP\IUserSession */ + private $userSession; + /** @var ZsyncPlugin */ + private $plugin; + /** @var \OC\Files\View */ + private $view; + /** @var ResponseInterface | \PHPUnit_Framework_MockObject_MockObject */ + private $response; + /** @var RequestInterface | \PHPUnit_Framework_MockObject_MockObject */ + private $request; + + public function setUp() { + $this->view = $this->getMockBuilder('\OC\Files\View') + ->disableOriginalConstructor() + ->getMock(); + $this->view->expects($this->once()) + ->method('mkdir') + ->with('files_zsync'); + + $this->request = $this->getMockBuilder('Sabre\HTTP\RequestInterface')->getMock(); + $this->response = $this->getMockBuilder('Sabre\HTTP\ResponseInterface')->getMock(); + $this->server = $this->getMockBuilder('Sabre\DAV\Server')->getMock(); + $this->plugin = new ZsyncPlugin($this->view); + + $this->plugin->initialize($this->server); + } + + /** + * @dataProvider providesQueryParams + * @param $param + */ + public function testQueryParams($param) { + $this->request->expects($this->exactly(2))->method('getQueryParameters')->willReturn($param); + $this->assertTrue($this->plugin->httpGet($this->request, $this->response)); + $this->assertTrue($this->plugin->httpDelete($this->request, $this->response)); + } + + public function providesQueryParams() { + return [ + [[]], + [['1']], + [['foo' => 'bar']], + ]; + } + + + public function testShowRouteWithExistsFile() { + $this->view->expects($this->any())->method('file_exists')->willReturn(true); + + $fileInfo = $this->createMock('\OC\Files\FileInfo'); + $fileInfo->expects($this->exactly(2))->method('getId')->willReturn('13124'); + $this->view->expects($this->exactly(2))->method('getFileInfo')->with('files/target')->willReturn($fileInfo); + + $this->view->expects($this->once())->method('file_get_contents')->willReturn('bar'); + + $this->request->expects($this->exactly(2))->method('getQueryParameters')->willReturn(['zsync' => true]); + $this->request->expects($this->exactly(2))->method('getPath')->willReturn('files/testuser1/target'); + + $this->response->expects($this->exactly(2))->method('setStatus')->with(Http::STATUS_OK); + $this->response->expects($this->once())->method('setBody')->with('bar'); + + $this->assertFalse($this->plugin->httpGet($this->request, $this->response)); + $this->assertFalse($this->plugin->httpDelete($this->request, $this->response)); + } + + + public function testShowRouteWithMissingBaseFile() { + $this->view->expects($this->any())->method('file_exists')->willReturn(false); + + $this->request->expects($this->exactly(2))->method('getQueryParameters')->willReturn(['zsync' => true]); + $this->request->expects($this->exactly(2))->method('getPath')->willReturn('files/testuser1/target'); + + $this->response->expects($this->exactly(2))->method('setStatus')->with(Http::STATUS_NOT_FOUND); + + $this->assertFalse($this->plugin->httpGet($this->request, $this->response)); + $this->assertFalse($this->plugin->httpDelete($this->request, $this->response)); + } + + public function testShowRouteWithMissingZsyncFile() { + $this->view->expects($this->exactly(4))->method('file_exists') + ->withConsecutive(['files/target'], ['files_zsync/13124'], ['files/target'], ['files_zsync/13124']) + ->willReturnOnConsecutiveCalls(true, false, true, false); + + $fileInfo = $this->createMock('\OC\Files\FileInfo'); + $fileInfo->expects($this->exactly(2))->method('getId')->willReturn('13124'); + $this->view->expects($this->exactly(2))->method('getFileInfo')->with('files/target')->willReturn($fileInfo); + + $this->request->expects($this->exactly(2))->method('getQueryParameters')->willReturn(['zsync' => true]); + $this->request->expects($this->exactly(2))->method('getPath')->willReturn('files/testuser1/target'); + + $this->response->expects($this->exactly(2))->method('setStatus')->with(Http::STATUS_NOT_FOUND); + + $this->assertFalse($this->plugin->httpGet($this->request, $this->response)); + $this->assertFalse($this->plugin->httpDelete($this->request, $this->response)); + } + + public function testGetPropertyWithExistsFile() { + $propFind = $this->getMockBuilder('\Sabre\DAV\PropFind')->disableOriginalConstructor()->getMock(); + $node = $this->getMockBuilder('\OCA\DAV\Connector\Sabre\File')->disableOriginalConstructor()->getMock(); + $node->expects($this->any())->method('getPath')->willReturn('target'); + + $this->view->expects($this->once())->method('is_file')->with('files/target')->willReturn(true); + $fileInfo = $this->createMock('\OC\Files\FileInfo'); + $fileInfo->expects($this->once())->method('getId')->willReturn('13124'); + $this->view->expects($this->once())->method('getFileInfo')->with('files/target')->willReturn($fileInfo); + $this->view->expects($this->once())->method('file_exists')->with('files_zsync/13124')->willReturn(true); + + $this->assertNull($this->plugin->handleGetProperties($propFind, $node)); + } + + public function testGetPropertyWithNotExistsFile() { + $propFind = $this->getMockBuilder('\Sabre\DAV\PropFind')->disableOriginalConstructor()->getMock(); + $node = $this->getMockBuilder('\OCA\DAV\Connector\Sabre\File')->disableOriginalConstructor()->getMock(); + $node->expects($this->any())->method('getPath')->willReturn('target'); + + $this->view->expects($this->once())->method('is_file')->with('files/target')->willReturn(false); + + $this->assertNull($this->plugin->handleGetProperties($propFind, $node)); + } + +} diff --git a/apps/dav/tests/unit/Upload/AssemblyStreamZsyncTest.php b/apps/dav/tests/unit/Upload/AssemblyStreamZsyncTest.php new file mode 100644 index 000000000000..8e9ef2eb53ea --- /dev/null +++ b/apps/dav/tests/unit/Upload/AssemblyStreamZsyncTest.php @@ -0,0 +1,161 @@ + + * + * @copyright Copyright (c) 2017, 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\DAV\Tests\unit\Upload; + +use OC\Files\View; +use OCA\DAV\Connector\Sabre\File; + +class AssemblyStreamZsyncTest extends \Test\TestCase { + + /** + * @dataProvider providesNodes() + */ + public function testGetContents($expected, $nodes, $backingFile, $length) { + $stream = \OCA\DAV\Upload\AssemblyStreamZsync::wrap($nodes, $backingFile, $length); + $content = stream_get_contents($stream); + + $this->assertEquals($expected, $content); + } + + /** + * @dataProvider providesNodes() + */ + public function testGetContentsFread($expected, $nodes, $backingFile, $length) { + $stream = \OCA\DAV\Upload\AssemblyStreamZsync::wrap($nodes, $backingFile, $length); + + $content = ''; + while (!feof($stream)) { + $content .= fread($stream, 3); + } + + $this->assertEquals($expected, $content); + } + + function providesNodes() { + $data512k = $this->makeData(512*1024); + $data16k = $this->makeData(16*1024); + $data8k = $this->makeData(8*1024); + $data4k = $this->makeData(4*1024); + $dataLess8k = $this->makeData((8*1024)-1); + + $tonofnodes = []; + $tonofdata = ""; + $start = 0; + for ($i = 0; $i < 101; $i++) { + $thisdata = rand(0,100); // variable length and content + $tonofdata .= $thisdata; + array_push($tonofnodes, $this->buildNode($start,$thisdata)); + $start += strlen($thisdata); + } + array_push($tonofnodes, $this->buildNode('.zsync','zsync metadata')); + + $file4k = $this->buildNode('file4k', $data4k); + $file8k = $this->buildNode('file8k', $data8k); + $file512k = $this->buildNode('file512k', $data512k); + + return[ + 'one node zero bytes 4k backing 4k length' => [ + $data4k, [ + $this->buildNode('0', '') + ], $file4k, 4096], + 'one node zero bytes 4k backing 0 length' => [ + '', [ + $this->buildNode('0', '') + ], $file4k, 0], + 'one node with one byte offset' => [ + $data4k[0].'123456789', [ + $this->buildNode('1', '1234567890') + ], $file4k, 10], + 'two nodes multiple splices' => [ + substr($data8k, 0, 1024). + $data4k. + substr($data8k, 5120, 214). + substr($data4k, -1521). + substr($data8k, 6855), + [ + $this->buildNode('1024', $data4k), + $this->buildNode('5334', substr($data4k, -1521)) + ], $file8k, 8*1024], + 'two nodes with smaller length' => [ + substr($data512k, 0, 4). + $data8k. + substr($data512k, 8196, 7164), + [ + $this->buildNode('16352', $dataLess8k), + $this->buildNode('4', $data8k) + ], $file512k, 15*1024], + 'two nodes with large gaps' => [ + substr($data512k, 0, 4). + $data8k. + substr($data512k, (8*1024)+4, (128*1024)-((8*1024)+4)). + $data16k. + substr($data512k, (8*1024)+4 + (128*1024)-((8*1024)+4) + (16*1024), + (512*1024)-((8*1024)+4 + (128*1024)-((8*1024)+4) + (16*1024))), + [ + $this->buildNode(128*1024, $data16k), + $this->buildNode('4', $data8k) + ], $file512k, 512*1024], + 'a ton of nodes' => [ + $tonofdata, $tonofnodes, $this->buildNode('empty', ''), strlen($tonofdata) + ], + 'a backing file that is smaller than expected, creating a hole (30k-31k)' => [ + substr($data512k, 0, 12*1024). + $data16k. + substr($data512k, (16+12)*1024, 2*1024), + [ + $this->buildNode(12*1024, $data16k), + $this->buildNode(31*1024, $data16k) + ], $this->buildNode('file30k', substr($data512k, 0, 30*1024)), 32*1024] + ]; + } + + function makeData($count) { + $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $charactersLength = strlen($characters); + $data = ''; + for ($i = 0; $i < $count; $i++) { + $data .= $characters[rand(0, $charactersLength - 1)]; + } + return $data; + } + + private function buildNode($name, $data) { + $node = $this->getMockBuilder('\Sabre\DAV\File') + ->setMethods(['getName', 'get', 'getSize']) + ->getMockForAbstractClass(); + + $node->expects($this->any()) + ->method('getName') + ->willReturn($name); + + $node->expects($this->any()) + ->method('get') + ->willReturn($data); + + $node->expects($this->any()) + ->method('getSize') + ->willReturn(strlen($data)); + + return $node; + } +} + diff --git a/apps/dav/tests/unit/Upload/ChunkingPluginZsyncTest.php b/apps/dav/tests/unit/Upload/ChunkingPluginZsyncTest.php new file mode 100644 index 000000000000..bef13b3fbaba --- /dev/null +++ b/apps/dav/tests/unit/Upload/ChunkingPluginZsyncTest.php @@ -0,0 +1,291 @@ + + * + * @copyright Copyright (c) 2017, 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\DAV\Tests\unit\Upload; + + +use OCA\DAV\Upload\ChunkingPluginZsync; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; +use Test\TestCase; +use OCA\DAV\Upload\FutureFileZsync; +use OCA\DAV\Connector\Sabre\Directory; +use OC\Files\View; +use Sabre\DAV\Exception\NotFound; + +class ChunkingPluginZsyncTest extends TestCase { + const TEST_CHUNKING_USER1 = "test-chunking-user1"; + + /** + * @var \Sabre\DAV\Server | \PHPUnit_Framework_MockObject_MockObject + */ + private $server; + + /** + * @var \Sabre\DAV\Tree | \PHPUnit_Framework_MockObject_MockObject + */ + private $tree; + + /** + * @var OC\Files\View | \PHPUnit_Framework_MockObject_MockObject + */ + private $view; + + /** + * @var ChunkingPluginZsync + */ + private $plugin; + /** @var RequestInterface | \PHPUnit_Framework_MockObject_MockObject */ + private $request; + /** @var ResponseInterface | \PHPUnit_Framework_MockObject_MockObject */ + private $response; + + public function setUp() { + parent::setUp(); + + $this->server = $this->getMockBuilder('\Sabre\DAV\Server') + ->disableOriginalConstructor() + ->getMock(); + $this->tree = $this->getMockBuilder('\Sabre\DAV\Tree') + ->disableOriginalConstructor() + ->getMock(); + $this->view = $this->getMockBuilder('\OC\Files\View') + ->disableOriginalConstructor() + ->getMock(); + $this->view->expects($this->once()) + ->method('mkdir') + ->with('files_zsync'); + + $this->server->tree = $this->tree; + $this->plugin = new ChunkingPluginZsync($this->view); + $this->request = $this->createMock(RequestInterface::class); + $this->response = $this->createMock(ResponseInterface::class); + $this->server->httpRequest = $this->request; + $this->server->httpResponse = $this->response; + + $this->plugin->initialize($this->server); + } + + public function testBeforeMoveFutureFileSkip() { + $node = $this->createMock(Directory::class); + + $this->tree->expects($this->any()) + ->method('getNodeForPath') + ->with('source') + ->willReturn($node); + $this->response->expects($this->never()) + ->method('setStatus'); + + $this->assertNull($this->plugin->beforeMove('source', 'target')); + } + + public function testBeforeMoveFutureFileSkipNonExistingBackingFile() { + $sourceNode = $this->createMock(FutureFileZsync::class); + $sourceNode->expects($this->once()) + ->method('getSize') + ->willReturn(4); + + $node = $this->createMock(\OCA\DAV\Connector\Sabre\Node::class); + + $target = 'files/'.self::TEST_CHUNKING_USER1.'/target'; + + $node->expects($this->once()) + ->method('getDavPermissions') + ->willReturn(''); + + $targetNode = $this->createMock(\Sabre\DAV\IFile::class); + + $stream = fopen('php://memory', 'w+'); + fwrite($stream, 'bar'); + rewind($stream); + + $targetNode->expects($this->once()) + ->method('get') + ->willReturn($stream); + $targetNode->expects($this->any()) + ->method('getSize') + ->willReturn(3); + + $this->tree->expects($this->exactly(4)) + ->method('getNodeForPath') + ->withConsecutive( + ['source/.file.zsync'], + [$target], + [dirname($target)], + ['source/.zsync']) + ->willReturnOnConsecutiveCalls( + $sourceNode, + $this->throwException(new NotFound), + $node, + $targetNode); + + $this->tree->expects($this->exactly(2)) + ->method('nodeExists') + ->with($target) + ->willReturn(false); + + $this->response->expects($this->once()) + ->method('setStatus') + ->with(201); + + $this->request->expects($this->exactly(2)) + ->method('getHeader') + ->withConsecutive( + ['OC-Total-Length'], + ['OC-Total-File-Length']) + ->willReturn('4'); + + $fileInfo = $this->createMock('\OC\Files\FileInfo'); + $fileInfo->expects($this->once()) + ->method('getId') + ->willReturn('13124'); + + $this->view->expects($this->once()) + ->method('getFileInfo') + ->with('files/target') + ->willReturn($fileInfo); + + $this->assertFalse($this->plugin->beforeMove('source/.file.zsync', $target)); + } + + public function testBeforeMoveFutureFileMoveItWithZsync() { + $sourceNode = $this->createMock(FutureFileZsync::class); + $sourceNode->expects($this->once()) + ->method('getSize') + ->willReturn(4); + + $node = $this->createMock(\OCA\DAV\Connector\Sabre\Node::class); + + $target = 'files/'.self::TEST_CHUNKING_USER1.'/target'; + + $node->expects($this->once()) + ->method('getDavPermissions') + ->willReturn(''); + + $targetNode = $this->createMock(\Sabre\DAV\IFile::class); + + $stream = fopen('php://memory', 'w+'); + fwrite($stream, 'bar'); + rewind($stream); + + $targetNode->expects($this->once()) + ->method('get') + ->willReturn($stream); + $targetNode->expects($this->any()) + ->method('getSize') + ->willReturn(3); + + $this->tree->expects($this->exactly(5)) + ->method('getNodeForPath') + ->withConsecutive( + ['source/.file.zsync'], + [$target], + [dirname($target)], + ['source/.zsync'], + [$target]) + ->willReturnOnConsecutiveCalls( + $sourceNode, + $this->throwException(new NotFound), + $node, + $targetNode, + $sourceNode); + + $this->tree->expects($this->exactly(2)) + ->method('nodeExists') + ->with($target) + ->willReturn(true); + + $this->response->expects($this->once()) + ->method('setStatus') + ->with(204); + + $this->request->expects($this->exactly(2)) + ->method('getHeader') + ->withConsecutive( + ['OC-Total-Length'], + ['OC-Total-File-Length']) + ->willReturn('4'); + + $fileInfo = $this->createMock('\OC\Files\FileInfo'); + $fileInfo->expects($this->once()) + ->method('getId') + ->willReturn('13124'); + + $this->view->expects($this->once()) + ->method('getFileInfo') + ->with('files/target') + ->willReturn($fileInfo); + + $this->assertFalse($this->plugin->beforeMove('source/.file.zsync', $target)); + } + + /** + * @expectedException \Sabre\DAV\Exception\BadRequest + * @expectedExceptionMessage Chunks on server do not sum up to 4 but to 3 + */ + public function testBeforeMoveSizeIsWrong() { + $sourceNode = $this->createMock(FutureFileZsync::class); + $sourceNode->expects($this->once()) + ->method('getSize') + ->willReturn(3); + + $this->tree->expects($this->exactly(1)) + ->method('getNodeForPath') + ->with('source') + ->willReturn($sourceNode); + + $this->request->expects($this->once()) + ->method('getHeader') + ->with('OC-Total-Length') + ->willReturn('4'); + + $this->assertFalse($this->plugin->beforeMove('source', 'target')); + } + + public function testBeforeMoveSizeIsNull() { + $sourceNode = $this->createMock(FutureFileZsync::class); + $node = $this->createMock(\OCA\DAV\Connector\Sabre\Node::class); + + $target = 'files/'.self::TEST_CHUNKING_USER1.'/target'; + + $node->expects($this->once()) + ->method('getDavPermissions') + ->willReturn('M'); + + $this->tree->expects($this->exactly(3)) + ->method('getNodeForPath') + ->withConsecutive( + ['source/.file.zsync'], + [$target], + [dirname($target)]) + ->willReturnOnConsecutiveCalls( + $sourceNode, + $this->throwException(new NotFound), + $node); + + $this->request->expects($this->once()) + ->method('getHeader') + ->with('OC-Total-Length') + ->willReturn(null); + + $this->assertFalse($this->plugin->beforeMove('source/.file.zsync', $target)); + } + +} diff --git a/apps/dav/tests/unit/Upload/FutureFileZsyncTest.php b/apps/dav/tests/unit/Upload/FutureFileZsyncTest.php new file mode 100644 index 000000000000..86c87bb139bc --- /dev/null +++ b/apps/dav/tests/unit/Upload/FutureFileZsyncTest.php @@ -0,0 +1,122 @@ + + * + * @copyright Copyright (c) 2017, 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\DAV\Tests\unit\Upload; + +class FutureFileTestZsync extends \Test\TestCase { + + public function testGetContentType() { + $f = $this->mockFutureFile(); + $this->assertEquals('application/octet-stream', $f->getContentType()); + } + + public function testGetETag() { + $f = $this->mockFutureFile(); + $this->assertEquals('1234567890', $f->getETag()); + } + + public function testGetName() { + $f = $this->mockFutureFile(); + $this->assertEquals('foo.txt', $f->getName()); + } + + public function testGetLastModified() { + $f = $this->mockFutureFile(); + $this->assertEquals(12121212, $f->getLastModified()); + } + + public function testGetSize() { + $f = $this->mockFutureFile(); + $this->assertEquals(0, $f->getSize()); + } + + public function testGet() { + $f = $this->mockFutureFile(); + $stream = $f->get(); + $this->assertTrue(is_resource($stream)); + } + + public function testGetZsync() { + $file = $this->createMock('Sabre\DAV\IFile'); + $f = $this->mockFutureFile(); + $f->setBackingFile($file); + $f->setFileLength(1231); + $stream = $f->get(); + $this->assertTrue(is_resource($stream)); + } + + public function testDelete() { + $d = $this->getMockBuilder('OCA\DAV\Connector\Sabre\Directory') + ->disableOriginalConstructor() + ->setMethods(['delete']) + ->getMock(); + + $d->expects($this->once()) + ->method('delete'); + + $f = new \OCA\DAV\Upload\FutureFileZsync($d, 'foo.txt'); + $f->delete(); + } + + /** + * @expectedException Sabre\DAV\Exception\Forbidden + */ + public function testPut() { + $f = $this->mockFutureFile(); + $f->put(''); + } + + /** + * @expectedException Sabre\DAV\Exception\Forbidden + */ + public function testSetName() { + $f = $this->mockFutureFile(); + $f->setName(''); + } + + /** + * @return \OCA\DAV\Upload\FutureFile + */ + private function mockFutureFile() { + $d = $this->getMockBuilder('OCA\DAV\Connector\Sabre\Directory') + ->disableOriginalConstructor() + ->setMethods(['getETag', 'getLastModified', 'getChildren', 'childExists']) + ->getMock(); + + $d->expects($this->any()) + ->method('getETag') + ->willReturn('1234567890'); + + $d->expects($this->any()) + ->method('getLastModified') + ->willReturn(12121212); + + $d->expects($this->any()) + ->method('getChildren') + ->willReturn([]); + + $d->expects($this->any()) + ->method('childExists') + ->willReturn(true); + + return new \OCA\DAV\Upload\FutureFileZsync($d, 'foo.txt'); + } +} +