diff --git a/php/Torrent.php b/php/Torrent.php index 6fbe9d97b..7cc3aeca3 100644 --- a/php/Torrent.php +++ b/php/Torrent.php @@ -746,7 +746,11 @@ public function scandir( $dir ) if( $item != '.' && $item != '..') { $path = $dir . DIRECTORY_SEPARATOR . $item; - if(is_link($path) && (strpos(realpath( $path ),$this->basedir.'/')===0)) + // A link back into the folder would be read twice, and one out + // of the top directory would be read, and served once seeded, + // from wherever it points. + if(is_link($path) && ((strpos(realpath( $path ),$this->basedir.'/')===0) || + !self::isInsideTopDirectory(realpath( $path )))) continue; if(is_dir( $path )) $paths = array_merge( self::scandir( $path ), $paths ); @@ -757,6 +761,22 @@ public function scandir( $dir ) return $paths; } + /** Whether $real, a resolved path, is inside $topDirectory + * @param string|false resolved path + * @return boolean + */ + static private function isInsideTopDirectory( $real ) + { + global $topDirectory; + if(!isset($topDirectory) || (FileUtil::addslash($topDirectory) === '/')) + return(true); + if($real === false) + return(false); + $top = realpath($topDirectory); + $top = FileUtil::addslash(($top === false) ? $topDirectory : $top); + return(strpos(FileUtil::addslash($real), $top) === 0); + } + /** Helper to pack data hash * @param string data * @return string packed data hash diff --git a/plugins/autotools/check.php b/plugins/autotools/check.php index 2a1df696f..3d28de75c 100644 --- a/plugins/autotools/check.php +++ b/plugins/autotools/check.php @@ -70,7 +70,10 @@ if( $dest_path != $source_path ) { $files = $is_multy ? rtScanFiles( $source_path, '/.*/' ) : array( $base_name ); - if( $at->skipsMove( $files ) || rtTakenDestination( $files, $dest_path ) != '' ) + // The move follows links, so the destination has to stay inside the top + // directory once they are followed, not only as the settings wrote it. + if( !rTorrent::isInsideTopDirectory( $dest_path ) || + $at->skipsMove( $files ) || rtTakenDestination( $files, $dest_path ) != '' ) $dest_path = $source_path; } diff --git a/plugins/autotools/move.php b/plugins/autotools/move.php index d2f2630e5..5b7841e7d 100644 --- a/plugins/autotools/move.php +++ b/plugins/autotools/move.php @@ -127,6 +127,12 @@ function operationOnTorrentFiles($torrent,&$base_path,$base_file,$is_multy_file, $dest_path.=FileUtil::addslash($label); if($at->addName && ($name!='')) $dest_path.=FileUtil::addslash($name); + // Checked here as well as in check.php: the older and the + // copy and link hooks run this script alone. + $target = $dest_path.($is_multi ? rtAddTailSlash( $base_name ) : ''); + if(!rTorrent::isInsideTopDirectory($target)) + Debug( "outside the top directory: ".$target ); + else if(operationOnTorrentFiles($torrent,$base_path,$base_name,$is_multi,$dest_path,$fileop_type)) { // if($fileop_type=="Move") diff --git a/plugins/autotools/util_rt.php b/plugins/autotools/util_rt.php index c923d1af7..a0149b1ad 100644 --- a/plugins/autotools/util_rt.php +++ b/plugins/autotools/util_rt.php @@ -2,6 +2,7 @@ require_once( "../../php/xmlrpc.php" ); require_once( "../../php/Torrent.php" ); +require_once( "../../php/rtorrent.php" ); //------------------------------------------------------------------------------ // Debug stub diff --git a/plugins/autotools/watch.php b/plugins/autotools/watch.php index 1f322fde8..9295b48b7 100644 --- a/plugins/autotools/watch.php +++ b/plugins/autotools/watch.php @@ -33,7 +33,10 @@ function Debug( $str ) $auto_start = $at->watch_start; $path_to_watch = rtAddTailSlash( trim( $at->path_to_watch ) ); Debug( "path_to_watch : ".$path_to_watch ); - if( $path_to_watch == '' || $path_to_watch == '/' ) + // The scan, the touch and the load follow links, so the directory has + // to stay inside the top directory once they are followed. + if( $path_to_watch == '' || $path_to_watch == '/' || + !rTorrent::isInsideTopDirectory( $path_to_watch ) ) $is_ok = false; } else $is_ok = false; @@ -59,6 +62,13 @@ function Debug( $str ) Debug( "torrent file : ".$torrent_file ); Debug( "save data to : ".$dest_path ); + // A link inside the watch directory is followed by the scan. + if( !rTorrent::isInsideTopDirectory( $torrent_file ) ) + { + Debug( "outside the top directory: ".$torrent_file ); + continue; + } + $is_ok = true; // rTorrent store info about all added torrents, // so we must change $torrent_file's time to trick rTorrent. @@ -69,6 +79,12 @@ function Debug( $str ) $is_ok = false; } + if( $is_ok && !rTorrent::isInsideTopDirectory( $dest_path ) ) + { + Debug( "outside the top directory: ".$dest_path ); + $is_ok = false; + } + if( $is_ok && !rtMkDir( $dest_path, 0777 ) ) { Debug( "can't create ".$dest_path ); diff --git a/plugins/create/action.php b/plugins/create/action.php index 101a10d11..d8c25a1f9 100644 --- a/plugins/create/action.php +++ b/plugins/create/action.php @@ -149,7 +149,10 @@ static public function getTrackerDomain($announce) $path_edit = trim($_REQUEST['path_edit']); if(is_dir($path_edit)) $path_edit = FileUtil::addslash($path_edit); - if(rTorrentSettings::get()->correctDirectory($path_edit)) + // What is hashed here is served once it is seeded, so it has to + // stay inside the top directory once links are followed. + if(rTorrentSettings::get()->correctDirectory($path_edit) && + rTorrent::isInsideTopDirectory($path_edit)) { $rt = recentTrackers::load(); $trackers = array(); diff --git a/plugins/httprpc/action.php b/plugins/httprpc/action.php index 6f1f36fff..c0d715fc2 100644 --- a/plugins/httprpc/action.php +++ b/plugins/httprpc/action.php @@ -570,8 +570,15 @@ function makeSimpleCall($cmds,$hash) { if($ss[$ndx][0]=='n') $v = floatval($v); - if( ($ss[$ndx]=="sdirectory") && !rTorrentSettings::get()->correctDirectory($v) ) - continue; + // A download added without a directory of its own is written + // there through any link along it, so it has to stay inside the + // top directory once they are followed, not only as written. + if( $ss[$ndx]=="sdirectory" ) + { + require_once( '../../php/rtorrent.php' ); + if( !rTorrentSettings::get()->correctDirectory($v) || !rTorrent::isInsideTopDirectory($v) ) + continue; + } $socketAlloc = rTorrentSettings::get()->getSocketAllocCategory($ss[$ndx]); if($ss[$ndx]=="ndht") $cmd = new rXMLRPCCommand('dht',(($v==0) ? "disable" : "auto")); diff --git a/plugins/unpack/unpack.php b/plugins/unpack/unpack.php index fc3edb090..002ffee00 100644 --- a/plugins/unpack/unpack.php +++ b/plugins/unpack/unpack.php @@ -1,5 +1,6 @@ addName && ($name!='')) $outPath.=FileUtil::addslash($name); + if(!rTorrent::isInsideTopDirectory($outPath)) + { + self::log("[Auto] Not unpacked: [$outPath] is outside the top directory"); + return; + } if($unpackToTemp) { $randTempDirectory = FileUtil::addslash( FileUtil::getTempFilename('unpack') ); @@ -337,6 +343,12 @@ public function startTask( $hash, $outPath, $mode, $fileno ) if($outPath=='') $outPath = dirname($filename); + if(!rTorrent::isInsideTopDirectory($outPath)) + { + self::log("[Manual] Not unpacked: [$outPath] is outside the top directory"); + $ret["errors"] = array("The directory is outside the top directory. Unpack failed."); + return($ret); + } $commands = array(); $arh = Utility::getExternal( ($mode == "zip") ? 'unzip' : 'unrar' ); @@ -419,6 +431,12 @@ public function startTask( $hash, $outPath, $mode, $fileno ) $outPath.=FileUtil::addslash($label); if($this->addName && ($tname!='')) $outPath.=FileUtil::addslash($tname); + if(!rTorrent::isInsideTopDirectory($outPath)) + { + self::log("[Manual] Not unpacked: [$outPath] is outside the top directory"); + $ret["errors"] = array("The directory is outside the top directory. Unpack failed."); + return($ret); + } $commands[] = escapeshellarg($rootPath.'/plugins/unpack/un'.$mode.$postfix.'.sh')." ". escapeshellarg($arh)." ". diff --git a/tests/plugins/autotools/AutoToolsTopDirectoryTest.php b/tests/plugins/autotools/AutoToolsTopDirectoryTest.php new file mode 100644 index 000000000..04f838eac --- /dev/null +++ b/tests/plugins/autotools/AutoToolsTopDirectoryTest.php @@ -0,0 +1,274 @@ +base !== null) + $this->removeTree($this->base); + } + + private function removeTree($path) + { + if (is_link($path) || !is_dir($path)) { @unlink($path); return; } + foreach (glob($path . '/{,.}[!.]*', GLOB_BRACE) as $entry) + $this->removeTree($entry); + @rmdir($path); + } + + private function makeDir($dir) + { + $mask = umask(0); + try { + if (!is_dir($dir) && !mkdir($dir, 0700, true)) + throw new RuntimeException('Could not create fixture directory ' . $dir); + } finally { + umask($mask); + } + } + + private function repoRoot() + { + return realpath(__DIR__ . '/../../..'); + } + + private function bstr($s) + { + return strlen($s) . ':' . $s; + } + + /** A torrent of the files $names, called $root. */ + private function torrentBytes($names, $root) + { + $files = ''; + foreach ($names as $n) + $files .= 'd' . $this->bstr('length') . 'i12e' . $this->bstr('path') . 'l' . $this->bstr($n) . 'ee'; + $info = 'd' . $this->bstr('files') . 'l' . $files . 'e' . $this->bstr('name') . $this->bstr($root) + . $this->bstr('piece length') . 'i32768e' . $this->bstr('pieces') . $this->bstr(str_repeat("\x01", 20)) . 'e'; + return 'd' . $this->bstr('announce') . $this->bstr('http://tracker.invalid/announce') + . $this->bstr('info') . $info . 'e'; + } + + private function prelude() + { + return '$_SERVER[\'REMOTE_USER\'] = ' . var_export(self::USER, true) . ';' + . ' $_ENV[\'RU_PROFILE_PATH\'] = ' . var_export($this->base . '/profile', true) . ";\n" + . 'require_once(' . var_export($this->repoRoot() . '/conf/config.php', true) . ");\n" + . '$scgi_host = "127.0.0.1"; $scgi_port = 1; $rpcTimeOut = 1; $rpcLogCalls = false;' . "\n" + . '$topDirectory = ' . var_export($this->top . '/', true) . ";\n"; + } + + private function runPhp($file, $args = array()) + { + $mask = umask(022); + try { + $run = PhpProcess::run(array_merge(PhpProcess::sameIni(), + array('-d', 'display_errors=stderr', $file), $args)); + } finally { + umask($mask); + } + return array('exit' => $run['timedOut'] ? 'timed out' : $run['code'], + 'printed' => $run['out'], 'errors' => $run['err']); + } + + private function runScript($which, $args) + { + $driver = $this->base . '/drive-' . $which . '.php'; + file_put_contents($driver, "prelude() + . 'require(' . var_export($this->repoRoot() . '/plugins/autotools/' . $which . '.php', true) . ");\n"); + return $this->runPhp($driver, $args); + } + + /** A top directory with a download directory, and a directory outside it. */ + private function makeTree() + { + if ($this->base !== null) + $this->removeTree($this->base); + $this->base = sys_get_temp_dir() . '/rutorrent-autotools-top-' . uniqid('', true); + $this->makeDir($this->base . '/profile/settings'); + $this->makeDir($this->base . '/session'); + $this->makeDir($this->base . '/top/dl'); + $this->makeDir($this->base . '/outside'); + $this->base = realpath($this->base); + $this->top = $this->base . '/top'; + } + + /** Stores the plugin settings the scripts read. */ + private function seed($settings) + { + $set = ''; + foreach ($settings as $k => $v) + $set .= '$at->' . $k . ' = ' . var_export($v, true) . '; '; + $seed = $this->base . '/seed.php'; + file_put_contents($seed, "prelude() + . 'require_once(' . var_export($this->repoRoot() . '/php/settings.php', true) . ");\n" + . 'require_once(' . var_export($this->repoRoot() . '/plugins/autotools/autotools.php', true) . ");\n" + . '$s = rTorrentSettings::get();' . "\n" + . '$s->directory = ' . var_export($this->top . '/dl', true) . ";\n" + . '$s->session = ' . var_export($this->base . '/session', true) . ";\n" + . '$s->linkExist = true; $s->iVersion = 0x1015; $s->store();' . "\n" + . '$at = rAutoTools::load(); ' . $set . '$at->store();' . "\n" + . 'echo "seeded";' . "\n"); + $seeded = $this->runPhp($seed); + if ($seeded['printed'] !== 'seeded') + throw new Exception('the plugin settings did not take: ' . $seeded['printed'] . $seeded['errors']); + } + + /** + * A finished download "mytorrent" of two files in the download + * directory, with check.php and move.php run on it as rtorrent runs them + * for $operation. Reports the directory check.php named and where the + * files are. + */ + private function finish($finished, $operation = 'Move') + { + $names = array('1.bin', '2.bin'); + $this->makeDir($this->top . '/dl/mytorrent'); + foreach ($names as $n) + file_put_contents($this->top . '/dl/mytorrent/' . $n, 'download ' . $n); + file_put_contents($this->base . '/session/' . self::HASH . '.torrent', $this->torrentBytes($names, 'mytorrent')); + $this->seed(array('enable_move' => 1, 'fileop_type' => $operation, 'path_to_finished' => $finished, + 'automove_filter' => '/.*/', 'addLabel' => 0, 'addName' => 0, 'skip_move_for_files' => '')); + + $basePath = $this->top . '/dl/mytorrent'; + $check = ($operation === 'Move') + ? $this->runScript('check', array($basePath, 'mytorrent', '1', '', 'mytorrent', self::USER)) + : array('printed' => ''); + $move = $this->runScript('move', array(self::HASH, $basePath, 'mytorrent', '1', '', 'mytorrent', self::USER)); + $outside = array(); + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->base . '/outside', + FilesystemIterator::SKIP_DOTS)) as $f) + $outside[] = substr($f->getPathname(), strlen($this->base . '/outside/')); + sort($outside); + return array( + 'named' => rtrim($check['printed'], '/'), + 'atSource' => is_file($basePath . '/1.bin') && is_file($basePath . '/2.bin'), + 'outside' => $outside, + 'move' => $move, + ); + } + + public function testAFinishedDirectoryThatIsALinkOutIsNotMovedTo() + { + $this->makeTree(); + symlink($this->base . '/outside', $this->top . '/fin'); + $r = $this->finish($this->top . '/fin'); + $this->assertEquals($this->top . '/dl/mytorrent', $r['named'], + 'check.php leaves the download where its data is'); + $this->assertTrue($r['atSource'], 'the data stays where it is'); + $this->assertEquals(array(), $r['outside'], 'and nothing is written outside the top directory'); + } + + public function testADownloadsNameThatIsALinkOutIsNotMovedTo() + { + $this->makeTree(); + $this->makeDir($this->top . '/fin'); + symlink($this->base . '/outside', $this->top . '/fin/mytorrent'); + $r = $this->finish($this->top . '/fin'); + $this->assertEquals($this->top . '/dl/mytorrent', $r['named'], + 'check.php leaves the download where its data is when its own name in the finished directory is a link out'); + $this->assertEquals(array(), $r['outside'], 'and nothing is written outside the top directory'); + } + + public function testACopyThroughALinkOutIsNotMade() + { + $this->makeTree(); + symlink($this->base . '/outside', $this->top . '/fin'); + $r = $this->finish($this->top . '/fin', 'Copy'); + $this->assertEquals(array(), $r['outside'], 'a copy is not made outside the top directory'); + $this->assertTrue($r['atSource'], 'and the data stays where it is'); + } + + public function testAFinishedDirectoryInsideIsMovedTo() + { + $this->makeTree(); + $this->makeDir($this->top . '/fin'); + $r = $this->finish($this->top . '/fin'); + $this->assertEquals($this->top . '/fin/mytorrent', $r['named'], 'check.php names the finished directory'); + $this->assertTrue(is_file($this->top . '/fin/mytorrent/1.bin') && is_file($this->top . '/fin/mytorrent/2.bin'), + 'and the data is moved there: ' . $r['move']['errors']); + } + + /** + * watch.php with rtorrent unreachable: each torrent it takes up is + * touched, its load fails and it is renamed to .fail, so a file left as + * it was is one watch.php did not take up. + */ + private function watch($watched) + { + $this->seed(array('enable_watch' => 1, 'path_to_watch' => $watched, 'watch_start' => 0)); + return $this->runScript('watch', array(self::USER)); + } + + public function testAWatchDirectoryThatIsALinkOutIsNotScanned() + { + $this->makeTree(); + file_put_contents($this->base . '/outside/x.torrent', 'not ours'); + touch($this->base . '/outside/x.torrent', 1000000000); + symlink($this->base . '/outside', $this->top . '/watch'); + $this->watch($this->top . '/watch'); + clearstatcache(); + $this->assertTrue(is_file($this->base . '/outside/x.torrent') && filemtime($this->base . '/outside/x.torrent') === 1000000000, + 'a torrent outside the top directory is neither touched nor renamed'); + } + + /** + * A torrent in a subdirectory of the watch directory is loaded into the + * same subdirectory of the download directory, which watch.php makes + * first; a link out there must not have it make directories outside. + */ + public function testADataDirectoryThroughALinkOutIsNotMade() + { + $this->makeTree(); + $this->makeDir($this->top . '/watch/sub/deeper'); + file_put_contents($this->top . '/watch/sub/deeper/y.torrent', 'ours'); + symlink($this->base . '/outside', $this->top . '/dl/sub'); + $this->watch($this->top . '/watch'); + clearstatcache(); + $this->assertTrue(!file_exists($this->base . '/outside/deeper'), + 'no directory is made outside the top directory for its data'); + } + + public function testALinkOutInsideTheWatchDirectoryIsNotFollowed() + { + $this->makeTree(); + $this->makeDir($this->top . '/watch'); + file_put_contents($this->base . '/outside/x.torrent', 'not ours'); + touch($this->base . '/outside/x.torrent', 1000000000); + symlink($this->base . '/outside', $this->top . '/watch/sub'); + file_put_contents($this->top . '/watch/mine.torrent', 'ours'); + $this->watch($this->top . '/watch'); + clearstatcache(); + $this->assertTrue(is_file($this->base . '/outside/x.torrent') && filemtime($this->base . '/outside/x.torrent') === 1000000000, + 'a torrent reached through a link out of the watch directory is neither touched nor renamed'); + $this->assertTrue(is_file($this->top . '/watch/mine.torrent.fail'), + 'while one inside it is taken up (and, with rtorrent unreachable, set aside)'); + } +} diff --git a/tests/plugins/create/CreateTopDirectoryTest.php b/tests/plugins/create/CreateTopDirectoryTest.php new file mode 100644 index 000000000..010d83020 --- /dev/null +++ b/tests/plugins/create/CreateTopDirectoryTest.php @@ -0,0 +1,138 @@ +base = sys_get_temp_dir() . '/rutorrent-create-top-' . uniqid('', true); + @mkdir($this->base . '/profile/settings', 0700, true); + @mkdir($this->base . '/top/pack', 0777, true); + @mkdir($this->base . '/top/other', 0777, true); + @mkdir($this->base . '/outside', 0777, true); + $this->base = realpath($this->base); + $this->top = $this->base . '/top'; + file_put_contents($this->base . '/outside/secret.bin', 'outside'); + file_put_contents($this->top . '/pack/mine.txt', 'mine'); + file_put_contents($this->top . '/other/shared.txt', 'shared'); + symlink($this->base . '/outside', $this->top . '/out'); + symlink($this->base . '/outside/secret.bin', $this->top . '/pack/leak.bin'); + symlink($this->top . '/other/shared.txt', $this->top . '/pack/shared.txt'); + } + + public function tearDown() + { + $this->removeTree($this->base); + unset($GLOBALS['topDirectory']); + } + + private function removeTree($path) + { + if (is_link($path) || !is_dir($path)) { + @unlink($path); + return; + } + foreach (glob($path . '/{,.}[!.]*', GLOB_BRACE) as $entry) { + $this->removeTree($entry); + } + @rmdir($path); + } + + /** Posts "create" to action.php for $path and returns its answer. */ + private function create($path) + { + $root = realpath(__DIR__ . '/../../..'); + RtorrentProbeFixture::warmSettingsCache($this->base . '/profile'); + $driver = $this->base . '/drive-create.php'; + file_put_contents($driver, "base . '/profile', true) . ";\n" + . 'require_once(' . var_export($root . '/conf/config.php', true) . ");\n" + . '$topDirectory = ' . var_export($this->top . '/', true) . ";\n" + . '$tempDirectory = ' . var_export($this->base . '/', true) . ";\n" + . '$_REQUEST = array("cmd" => "create", "path_edit" => ' . var_export($path, true) + . ', "piece_size" => "16", "start_seeding" => "0", "private" => "0", "trackers" => "",' + . ' "comment" => "", "source" => "");' . "\n" + . 'chdir(' . var_export($root . '/plugins/create', true) . ");\n" + . 'require(' . var_export($root . '/plugins/create/action.php', true) . ");\n"); + $output = array(); + exec(escapeshellarg(PHP_BINARY) . ' -d error_reporting=0 ' . escapeshellarg($driver) . ' 2>&1', $output); + $printed = implode("\n", $output); + $answer = json_decode($printed, true); + if (!is_array($answer) || !array_key_exists('errors', $answer)) { + throw new Exception('action.php printed: ' . $printed); + } + return $answer; + } + + private function refused($answer) + { + return count($answer['errors']) > 0 && strpos($answer['errors'][0], 'Incorrect directory') === 0; + } + + public function testAPathThroughALinkOutIsRefused() + { + $a = $this->create($this->top . '/out/secret.bin'); + $this->assertTrue($this->refused($a), 'a file reached through a link out is not hashed: ' . json_encode($a['errors'])); + } + + public function testAFolderThatIsALinkOutIsRefused() + { + $a = $this->create($this->top . '/out'); + $this->assertTrue($this->refused($a), 'nor a folder that is a link out: ' . json_encode($a['errors'])); + } + + public function testAPathInsideIsAccepted() + { + $a = $this->create($this->top . '/pack/mine.txt'); + $this->assertTrue(!$this->refused($a), 'a file inside the top directory is hashed: ' . json_encode($a['errors'])); + } + + /** The names of the files a torrent built from $dir holds. */ + private function folderNames($dir) + { + $GLOBALS['topDirectory'] = $this->top . '/'; + $torrent = new Torrent($dir, array(), 16); + $names = array(); + foreach ($torrent->info['files'] as $file) { + $names[] = implode('/', $file['path']); + } + sort($names); + return $names; + } + + public function testALinkOutInsideAFolderIsLeftOut() + { + $this->assertEquals(array('mine.txt', 'shared.txt'), $this->folderNames($this->top . '/pack'), + 'a folder\'s link to a file outside the top directory is not hashed, ' + . 'and its link to a file elsewhere inside it is'); + } + + public function testATopDirectoryOfRootLeavesNothingOut() + { + $GLOBALS['topDirectory'] = '/'; + $torrent = new Torrent($this->top . '/pack', array(), 16); + $names = array(); + foreach ($torrent->info['files'] as $file) { + $names[] = implode('/', $file['path']); + } + sort($names); + $this->assertEquals(array('leak.bin', 'mine.txt', 'shared.txt'), $names, + 'with "/" as the top directory nothing is outside it'); + } +} diff --git a/tests/plugins/httprpc/HttprpcSetDirectoryTest.php b/tests/plugins/httprpc/HttprpcSetDirectoryTest.php new file mode 100644 index 000000000..df0ada27b --- /dev/null +++ b/tests/plugins/httprpc/HttprpcSetDirectoryTest.php @@ -0,0 +1,119 @@ +base = sys_get_temp_dir() . '/rutorrent-httprpc-setdir-' . uniqid('', true); + @mkdir($this->base . '/profile/settings', 0700, true); + @mkdir($this->base . '/top/plain', 0777, true); + @mkdir($this->base . '/outside', 0777, true); + $this->base = realpath($this->base); + $this->top = $this->base . '/top'; + symlink($this->base . '/outside', $this->top . '/out'); + symlink($this->top . '/plain', $this->top . '/in'); + symlink($this->base . '/nowhere', $this->top . '/dangle'); + } + + public function tearDown() + { + if ($this->daemon !== null) { + $this->daemon->stop(); + $this->daemon = null; + } + $this->removeTree($this->base); + } + + private function removeTree($path) + { + if (is_link($path) || !is_dir($path)) { + @unlink($path); + return; + } + foreach (glob($path . '/*') as $entry) { + $this->removeTree($entry); + } + @rmdir($path); + } + + /** Sets the default directory through action.php; true when rtorrent was asked to. */ + private function setDirectory($directory) + { + $root = realpath(__DIR__ . '/../../..'); + if ($this->daemon !== null) { + $this->daemon->stop(); + } + RtorrentProbeFixture::warmSettingsCache($this->base . '/profile'); + $this->daemon = new FakeRtorrentDaemon(function ($body, $methods) { + return array(0); + }, $this->base . '/calls.log'); + + $driver = $this->base . '/drive-setdir.php'; + file_put_contents($driver, "base . '/profile', true) . ";\n" + . 'require_once(' . var_export($root . '/conf/config.php', true) . ");\n" + . '$scgi_host = "127.0.0.1";' . "\n" + . '$scgi_port = ' . $this->daemon->port() . ";\n" + . '$rpcTimeOut = 10;' . "\n" + . '$rpcLogCalls = false;' . "\n" + . '$topDirectory = ' . var_export($this->top . '/', true) . ";\n" + . '$HTTP_RAW_POST_DATA = ' . var_export('mode=setsettings&s=sdirectory&v=' + . rawurlencode($this->top . $directory), true) . ";\n" + . 'chdir(' . var_export($root . '/plugins/httprpc', true) . ");\n" + . 'require(' . var_export($root . '/plugins/httprpc/action.php', true) . ");\n"); + + $output = array(); + exec(escapeshellarg(PHP_BINARY) . ' -d error_reporting=0 ' . + escapeshellarg($driver) . ' 2>&1', $output); + return $this->daemon->received('directory.default.set'); + } + + public function testALinkOutOfTheTopDirectoryIsNotSet() + { + $this->assertTrue(!$this->setDirectory('/out'), + 'a directory that resolves outside the top directory is not set: ' . json_encode($this->daemon->calls())); + } + + public function testANewDirectoryUnderALinkOutIsNotSet() + { + $this->assertTrue(!$this->setDirectory('/out/new'), + 'nor one that does not exist yet under a link out: ' . json_encode($this->daemon->calls())); + } + + public function testADanglingLinkIsNotSet() + { + $this->assertTrue(!$this->setDirectory('/dangle'), + 'nor a link that does not resolve: ' . json_encode($this->daemon->calls())); + } + + public function testALinkThatStaysInsideIsSet() + { + $this->assertTrue($this->setDirectory('/in'), + 'a link that resolves inside the top directory is set: ' . json_encode($this->daemon->calls())); + } + + public function testADirectoryInsideIsSet() + { + $this->assertTrue($this->setDirectory('/plain/new'), + 'a directory inside the top directory is set: ' . json_encode($this->daemon->calls())); + } +} diff --git a/tests/plugins/unpack/UnpackTopDirectoryTest.php b/tests/plugins/unpack/UnpackTopDirectoryTest.php new file mode 100644 index 000000000..c28a6ba4d --- /dev/null +++ b/tests/plugins/unpack/UnpackTopDirectoryTest.php @@ -0,0 +1,197 @@ +base = sys_get_temp_dir() . '/rutorrent-unpack-top-' . uniqid('', true); + @mkdir($this->base . '/profile/settings', 0700, true); + @mkdir($this->base . '/top/plain', 0777, true); + @mkdir($this->base . '/outside/held', 0777, true); + $this->base = realpath($this->base); + $this->top = $this->base . '/top'; + file_put_contents($this->top . '/plain/a.zip', 'zip'); + file_put_contents($this->base . '/outside/held/b.zip', 'zip'); + symlink($this->base . '/outside', $this->top . '/out'); + symlink($this->top . '/plain', $this->top . '/in'); + } + + public function tearDown() + { + if ($this->daemon !== null) { + $this->daemon->stop(); + $this->daemon = null; + } + $this->removeTree($this->base); + } + + private function removeTree($path) + { + if (is_link($path) || !is_dir($path)) { + @unlink($path); + return; + } + foreach (glob($path . '/*') as $entry) { + $this->removeTree($entry); + } + @rmdir($path); + } + + /** + * Drives $call against an rUnpack whose output directory setting is $path, + * with rtorrent answering f.get_frozen_path with $frozen. Returns what the + * call returned and what unpack logged. + */ + private function drive($path, $call, $frozen = '', $replies = null) + { + $root = realpath(__DIR__ . '/../../..'); + if ($this->daemon !== null) { + $this->daemon->stop(); + } + RtorrentProbeFixture::warmSettingsCache($this->base . '/profile'); + $this->daemon = new FakeRtorrentDaemon(($replies === null) ? array(array($frozen)) : $replies, + $this->base . '/calls.log'); + $log = $this->base . '/unpack.log'; + @unlink($log); + + $driver = $this->base . '/drive-unpack.php'; + file_put_contents($driver, "base . '/profile', true) . ";\n" + . 'require_once(' . var_export($root . '/conf/config.php', true) . ");\n" + . '$scgi_host = "127.0.0.1"; $scgi_port = ' . $this->daemon->port() . ';' + . ' $rpcTimeOut = 10; $rpcLogCalls = false;' . "\n" + . '$topDirectory = ' . var_export($this->top . '/', true) . ";\n" + . '$log_file = ' . var_export($log, true) . ";\n" + . 'chdir(' . var_export($root . '/plugins/unpack', true) . ");\n" + . 'require_once(' . var_export($root . '/plugins/unpack/unpack.php', true) . ");\n" + . '$unpack_debug_enabled = "dry-run";' . "\n" + . '$up = new rUnpack(); $up->path = ' . var_export($path, true) . ";\n" + . '$ret = ' . $call . ";\n" + . 'echo json_encode($ret);' . "\n"); + + $output = array(); + exec(escapeshellarg(PHP_BINARY) . ' -d error_reporting=0 ' . + escapeshellarg($driver) . ' 2>&1', $output); + return array( + 'ret' => json_decode(implode("\n", $output), true), + 'printed' => implode("\n", $output), + 'log' => is_file($log) ? file_get_contents($log) : '', + ); + } + + private function silent($path, $basename) + { + return $this->drive($path, '$up->startSilentTask(' . var_export($basename, true) + . ', ' . var_export(dirname($basename), true) . ', "", "a", "' . self::HASH . '")'); + } + + private function manual($dir, $frozen) + { + return $this->drive('', '$up->startTask("' . self::HASH . '", ' . var_export($dir, true) + . ', "zip", 0)', $frozen); + } + + /** + * A manual unpack of the whole download: rtorrent reports its base path, + * label and name, then its files. + */ + private function whole($dir, $basePath) + { + return $this->drive('', '$up->startTask("' . self::HASH . '", ' . var_export($dir, true) + . ', "", 0)', '', array(array($basePath, '', 'a'), array(basename($basePath)))); + } + + private function started($r) + { + return strpos($r['log'], self::STARTED) !== false; + } + + public function testAutoUnpackThroughALinkOutIsNotStarted() + { + $r = $this->silent($this->top . '/out', $this->top . '/plain/a.zip'); + $this->assertTrue(!$this->started($r), + 'an output directory that resolves outside the top directory is not unpacked into: ' . $r['log']); + } + + public function testAutoUnpackIntoTheDownloadsOwnDirectoryThroughALinkOutIsNotStarted() + { + $r = $this->silent('', $this->top . '/out/held/b.zip'); + $this->assertTrue(!$this->started($r), + 'nor is a download\'s own directory reached through a link out: ' . $r['log']); + } + + public function testAutoUnpackThroughALinkThatStaysInsideIsStarted() + { + $r = $this->silent($this->top . '/in', $this->top . '/plain/a.zip'); + $this->assertTrue($this->started($r), + 'a link that resolves inside the top directory is unpacked into: ' . $r['log']); + } + + public function testAutoUnpackIntoTheDownloadsOwnDirectoryIsStarted() + { + $r = $this->silent('', $this->top . '/plain/a.zip'); + $this->assertTrue($this->started($r), + 'with no output directory set, the download\'s own directory is used: ' . $r['log']); + } + + public function testManualUnpackThroughALinkOutIsRefused() + { + $r = $this->manual($this->top . '/out', $this->top . '/plain/a.zip'); + $this->assertTrue(!$this->started($r), + 'a manual unpack into a link out is not started: ' . $r['log']); + $this->assertTrue(is_array($r['ret']) && count($r['ret']['errors']) > 0 + && ($r['ret']['errors'] !== array('Dry-run operation, no real task start')), + 'and is answered with an error of its own: ' . $r['printed']); + } + + public function testManualUnpackIntoTheDownloadsOwnDirectoryThroughALinkOutIsRefused() + { + $r = $this->manual('', $this->top . '/out/held/b.zip'); + $this->assertTrue(!$this->started($r), + 'nor one into a download\'s own directory reached through a link out: ' . $r['log']); + } + + public function testManualUnpackOfAWholeDownloadThroughALinkOutIsRefused() + { + $r = $this->whole($this->top . '/out', $this->top . '/plain/a.zip'); + $this->assertTrue(!$this->started($r), + 'a whole download unpacked into a link out is not started: ' . $r['log']); + } + + public function testManualUnpackOfAWholeDownloadInsideIsStarted() + { + $r = $this->whole($this->top . '/plain/new', $this->top . '/plain/a.zip'); + $this->assertTrue($this->started($r), + 'a whole download unpacked inside the top directory is started: ' . $r['log']); + } + + public function testManualUnpackInsideIsStarted() + { + $r = $this->manual($this->top . '/plain/new', $this->top . '/plain/a.zip'); + $this->assertTrue($this->started($r), + 'a manual unpack inside the top directory is started: ' . $r['log']); + } +}