diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 91c9599..a45caec 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -6,9 +6,28 @@ jobs: tests: runs-on: ubuntu-latest + services: + sftp: + image: atmoz/sftp + ports: + - 2222:22 + env: + # user:password:uid:gid:writable directory inside the chroot + SFTP_USERS: user:password:::upload + strategy: + # run all phpseclib/PHP combinations even if one of them fails + fail-fast: false matrix: php: [8.0, 8.1] + phpseclib: ['^2.0.35', '^3.0.57', '^4.0.2'] + exclude: + - php: 8.0 + phpseclib: '^4.0.2' + include: + # phpseclib v2 has open security advisories, which Composer blocks by default + - phpseclib: '^2.0.35' + advisories-block: '0' steps: - name: Checkout code @@ -24,7 +43,26 @@ jobs: run: composer validate - name: Install dependencies - run: composer install --prefer-dist --no-progress --no-interaction --no-suggest + env: + COMPOSER_POLICY_ADVISORIES_BLOCK: ${{ matrix.advisories-block || '1' }} + run: | + composer require phpseclib/phpseclib:"${{ matrix.phpseclib }}" --no-update + composer update --prefer-dist --no-progress --no-interaction + + - name: Wait for SFTP server + run: | + for i in $(seq 30); do + ssh-keyscan -p 2222 127.0.0.1 > /dev/null 2>&1 && exit 0 + sleep 1 + done + echo "SFTP server did not start" >&2 + exit 1 - name: Run test suite + env: + SFTP_HOST: 127.0.0.1 + SFTP_PORT: 2222 + SFTP_USER: user + SFTP_PASSWORD: password + SFTP_ROOT: /upload run: php vendor/bin/codecept run diff --git a/composer.json b/composer.json index fb193a5..f394889 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ "php": "^8.0", "codeception/codeception": "^5.0", "codeception/module-filesystem": "^3.0", - "phpseclib/phpseclib": "^2.0.35" + "phpseclib/phpseclib": "^2.0.35 || ^3.0.57 || ^4.0.2" }, "require-dev": { "ext-ftp": "*" diff --git a/src/Codeception/Module/FTP.php b/src/Codeception/Module/FTP.php index 3afb55b..b8109f1 100644 --- a/src/Codeception/Module/FTP.php +++ b/src/Codeception/Module/FTP.php @@ -6,6 +6,9 @@ use Codeception\TestInterface; use Codeception\Exception\ModuleException; +use phpseclib\Net\SFTP AS SFTPv2; +use phpseclib3\Net\SFTP AS SFTPv3; +use phpseclib4\Net\SFTP AS SFTPv4; /** * @@ -24,13 +27,11 @@ * * SFTP * * Connection uses php build in FTP client for FTP, - * connection to SFTP uses [phpseclib](https://phpseclib.sourceforge.net/) pulled in using composer. + * connection to SFTP uses [phpseclib](https://github.com/phpseclib/phpseclib) pulled in using composer. * - * For SFTP, add [phpseclib](https://phpseclib.sourceforge.net/) to require list. + * For SFTP, add [phpseclib](https://github.com/phpseclib/phpseclib) to require list. * ``` - * "require": { - * "phpseclib/phpseclib": "^2.0.14" - * } + * composer require phpseclib/phpseclib * ``` * * ## Status @@ -93,7 +94,7 @@ class FTP extends Filesystem { /** * FTP/SFTP connection handler - * @var null|bool|\Net_SFTP|\phpseclib\Net\SFTP|resource + * @var null|bool|SFTPv2|SFTPv3|SFTPv4|resource */ protected $ftp = null; @@ -352,7 +353,7 @@ public function deleteFile(string $filename): void */ public function deleteDir(string $dirname): void { - $this->delete($this->absolutizePath($dirname)); + $this->delete($this->absolutizePath($dirname), true); } /** @@ -516,7 +517,7 @@ private function _closeConnection(): void */ private function _listFiles(string $path): array { - $files = $this->isSFTP() ? @$this->ftp->nlist($path) : @ftp_nlist($this->ftp, $path); + $files = $this->isSFTP() ? $this->sftp('nlist', $path) : @ftp_nlist($this->ftp, $path); if ($files === false) { $this->fail("couldn't list files"); @@ -530,7 +531,7 @@ private function _listFiles(string $path): array */ private function _directory(): string { - $pwd = $this->isSFTP() ? @$this->ftp->pwd() : @ftp_pwd($this->ftp); + $pwd = $this->isSFTP() ? $this->sftp('pwd') : @ftp_pwd($this->ftp); if (!$pwd) { $this->fail("couldn't get current directory"); @@ -544,7 +545,7 @@ private function _directory(): string */ private function _changeDirectory(string $path): void { - $changed = $this->isSFTP() ? @$this->ftp->chdir($path) : @ftp_chdir($this->ftp, $path); + $changed = $this->isSFTP() ? $this->sftp('chdir', $path) : @ftp_chdir($this->ftp, $path); if (!$changed) { $this->fail("couldn't change directory {$path}"); @@ -565,7 +566,7 @@ private function _openFile(string $filename): void $tmp_file = $this->config['tmp'] . "/ftp_data_file.tmp"; if ($this->isSFTP()) { - $downloaded = @$this->ftp->get($filename, $tmp_file); + $downloaded = $this->sftp('get', $filename, $tmp_file); } else { $downloaded = @ftp_get($this->ftp, $tmp_file, $filename, FTP_BINARY); } @@ -602,9 +603,9 @@ private function _writeToFile(string $filename, string $contents): void // Upload the file to server if ($this->isSFTP()) { - $flag = defined('NET_SFTP_LOCAL_FILE') ? NET_SFTP_LOCAL_FILE : \phpseclib\Net\SFTP::SOURCE_LOCAL_FILE; + $flag = $this->ftp::SOURCE_LOCAL_FILE; - $uploaded = @$this->ftp->put($filename, $tmp_file, $flag); + $uploaded = $this->sftp('put', $filename, $tmp_file, $flag); } else { $uploaded = ftp_put($this->ftp, $filename, $tmp_file, FTP_BINARY); } @@ -619,7 +620,7 @@ private function _writeToFile(string $filename, string $contents): void */ private function makeDirectory(string $path): void { - $created = $this->isSFTP() ? @$this->ftp->mkdir($path, true) : @ftp_mkdir($this->ftp, $path); + $created = $this->isSFTP() ? $this->sftp('mkdir', $path, -1, true) : @ftp_mkdir($this->ftp, $path); if (!$created) { $this->fail("couldn't make directory {$path}"); @@ -633,7 +634,7 @@ private function makeDirectory(string $path): void */ private function renameDirectory(string $path, string $rename): void { - $renamed = $this->isSFTP() ? @$this->ftp->rename($path, $rename) : @ftp_rename($this->ftp, $path, $rename); + $renamed = $this->isSFTP() ? $this->sftp('rename', $path, $rename) : @ftp_rename($this->ftp, $path, $rename); if (!$renamed) { $this->fail("couldn't rename directory {$path} to {$rename}"); @@ -647,7 +648,7 @@ private function renameDirectory(string $path, string $rename): void */ private function delete(string $filename, bool $isDir = false): void { - $deleted = $this->isSFTP() ? @$this->ftp->delete($filename, $isDir) : @$this->ftpDelete($filename); + $deleted = $this->isSFTP() ? $this->sftp('delete', $filename, $isDir) : @$this->ftpDelete($filename); if (!$deleted) { $this->fail("couldn't delete {$filename}"); @@ -685,7 +686,7 @@ private function ftpDelete(string $directory): bool private function clearDirectory(string $path): void { $this->debug("Clear directory: {$path}"); - $this->delete($path); + $this->delete($path, true); $this->makeDirectory($path); } @@ -694,7 +695,7 @@ private function clearDirectory(string $path): void */ private function size(string $filename): int { - $size = $this->isSFTP() ? (int)@$this->ftp->size($filename) : @ftp_size($this->ftp, $filename); + $size = $this->isSFTP() ? (int)$this->sftp('filesize', $filename) : @ftp_size($this->ftp, $filename); if ($size < 0) { $this->fail("couldn't get the file size for {$filename}"); @@ -709,7 +710,7 @@ private function size(string $filename): int private function modified(string $filename): int { if ($this->isSFTP()) { - $info = @$this->ftp->lstat($filename); + $info = $this->sftp('lstat', $filename); if ($info) { return $info['mtime']; } @@ -722,10 +723,12 @@ private function modified(string $filename): int protected function sftpConnect(string $user, string $password): void { - if (class_exists('Net_SFTP')) { - $this->ftp = new \Net_SFTP($this->config['host'], $this->config['port'], $this->config['timeout']); - } elseif (class_exists(\phpseclib\Net\SFTP::class)) { - $this->ftp = new \phpseclib\Net\SFTP($this->config['host'], $this->config['port'], $this->config['timeout']); + if (class_exists(SFTPv4::class)) { + $this->ftp = new SFTPv4($this->config['host'], $this->config['port'], $this->config['timeout']); + } elseif (class_exists(SFTPv3::class)) { + $this->ftp = new SFTPv3($this->config['host'], $this->config['port'], $this->config['timeout']); + } elseif (class_exists(SFTPv2::class)) { + $this->ftp = new SFTPv2($this->config['host'], $this->config['port'], $this->config['timeout']); } else { throw new ModuleException('FTP', 'phpseclib/phpseclib library is not installed'); } @@ -733,18 +736,19 @@ protected function sftpConnect(string $user, string $password): void if (!empty($this->config['key'])) { $keyFile = file_get_contents($this->config['key']); - if (class_exists('Crypt_RSA')) { - $password = new \Crypt_RSA(); + if (class_exists(\phpseclib4\Crypt\PublicKeyLoader::class)) { + $password = \phpseclib4\Crypt\PublicKeyLoader::load($keyFile); + } elseif (class_exists(\phpseclib3\Crypt\PublicKeyLoader::class)) { + $password = \phpseclib3\Crypt\PublicKeyLoader::load($keyFile); } elseif (class_exists(\phpseclib\Crypt\RSA::class)) { $password = new \phpseclib\Crypt\RSA(); + $password->loadKey($keyFile); } else { throw new ModuleException('FTP', 'phpseclib/phpseclib library is not installed'); } - - $password->loadKey($keyFile); } - if (!$this->ftp->login($user, $password)) { + if (!$this->sftp('login', $user, $password)) { $this->fail('failed to authenticate user'); } } @@ -768,6 +772,25 @@ protected function ftpConnect(string $user, string $password): void } } + /** + * Calls a method on the phpseclib SFTP connection. + * + * phpseclib v2/v3 return false on failure, v4 throws exceptions and + * returns void for many methods. Normalize to false on failure and + * true for void results. + */ + private function sftp(string $method, mixed ...$args): mixed + { + try { + $result = @$this->ftp->{$method}(...$args); + } catch (\Exception $e) { + $this->debug("SFTP {$method}() failed: {$e->getMessage()}"); + return false; + } + + return $result ?? true; + } + protected function isSFTP(): bool { return strtolower($this->config['type']) === 'sftp'; diff --git a/tests/unit/Codeception/Module/SFTPTest.php b/tests/unit/Codeception/Module/SFTPTest.php index 6213066..41d5fed 100644 --- a/tests/unit/Codeception/Module/SFTPTest.php +++ b/tests/unit/Codeception/Module/SFTPTest.php @@ -8,35 +8,43 @@ final class SFTPTest extends TestCase { - protected array $config = [ - 'host' => '127.0.0.1', - 'port' => 22, - 'tmp' => 'temp', - 'user' => 'user', - 'password' => 'password', - 'type' => 'sftp' - ]; - protected ?FTP $module = null; + /** + * Writable directory on the SFTP server the test is run in + */ + protected string $root = '/'; + public function _setUp() { + $this->root = getenv('SFTP_ROOT') ?: '/'; + $container = Stub::make('Codeception\Lib\ModuleContainer'); $this->module = new FTP($container); - $this->module->_setConfig($this->config); + $this->module->_setConfig([ + 'host' => getenv('SFTP_HOST') ?: '127.0.0.1', + 'port' => (int)(getenv('SFTP_PORT') ?: 22), + 'tmp' => sys_get_temp_dir(), + 'user' => getenv('SFTP_USER') ?: 'user', + 'password' => getenv('SFTP_PASSWORD') ?: 'password', + 'type' => 'sftp' + ]); } public function testFlow() { - $this->markTestSkipped('Requires SFTP server'); + if (!getenv('SFTP_HOST')) { + $this->markTestSkipped('Requires SFTP server, set SFTP_HOST to run this test'); + } $this->module->_before(Stub::makeEmpty('\Codeception\Test\Test')); - self::assertEquals('/', $this->module->grabDirectory()); + $this->module->amInPath($this->root); + self::assertEquals($this->root, $this->module->grabDirectory()); $this->module->makeDir('TESTING'); $this->module->amInPath('TESTING'); - self::assertEquals('/TESTING', $this->module->grabDirectory()); + self::assertEquals(rtrim($this->root, '/') . '/TESTING', $this->module->grabDirectory()); $files = $this->module->grabFileList(); $this->module->writeToFile('test_ftp_123.txt', 'some data added here'); @@ -78,9 +86,9 @@ public function testFlow() $files = $this->module->grabFileList(); self::assertNotContains('test_ftp_123.txt', $files); - $this->module->amInPath('/'); + $this->module->amInPath($this->root); - self::assertEquals('/', $this->module->grabDirectory()); + self::assertEquals($this->root, $this->module->grabDirectory()); $this->module->renameDir('TESTING', 'TESTING_NEW'); @@ -90,7 +98,7 @@ public function testFlow() $this->module->makeDir('TESTING'); $this->module->amInPath('TESTING'); $this->module->writeToFile('test_ftp_123.txt', 'some data added here'); - $this->module->amInPath('/'); + $this->module->amInPath($this->root); self::assertGreaterThan(0, $this->module->grabFileCount('TESTING')); $this->module->cleanDir('TESTING'); self::assertEquals(0, $this->module->grabFileCount('TESTING'));