From 8aaf8b35dda885216239362c3558874a85aec528 Mon Sep 17 00:00:00 2001 From: W0rma Date: Tue, 3 Nov 2026 07:28:06 +0100 Subject: [PATCH 1/9] Remove support for phpseclib v1 composer.json only allows phpseclib ^2.0, so the PEAR-style Net_SFTP, Crypt_RSA and NET_SFTP_LOCAL_FILE code paths are unreachable. --- src/Codeception/Module/FTP.php | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/Codeception/Module/FTP.php b/src/Codeception/Module/FTP.php index 3afb55b..e27c878 100644 --- a/src/Codeception/Module/FTP.php +++ b/src/Codeception/Module/FTP.php @@ -93,7 +93,7 @@ class FTP extends Filesystem { /** * FTP/SFTP connection handler - * @var null|bool|\Net_SFTP|\phpseclib\Net\SFTP|resource + * @var null|bool|\phpseclib\Net\SFTP|resource */ protected $ftp = null; @@ -602,7 +602,7 @@ 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 = \phpseclib\Net\SFTP::SOURCE_LOCAL_FILE; $uploaded = @$this->ftp->put($filename, $tmp_file, $flag); } else { @@ -722,9 +722,7 @@ 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)) { + if (class_exists(\phpseclib\Net\SFTP::class)) { $this->ftp = new \phpseclib\Net\SFTP($this->config['host'], $this->config['port'], $this->config['timeout']); } else { throw new ModuleException('FTP', 'phpseclib/phpseclib library is not installed'); @@ -733,9 +731,7 @@ 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(); - } elseif (class_exists(\phpseclib\Crypt\RSA::class)) { + if (class_exists(\phpseclib\Crypt\RSA::class)) { $password = new \phpseclib\Crypt\RSA(); } else { throw new ModuleException('FTP', 'phpseclib/phpseclib library is not installed'); From f7ac1f8b4b1577f2d421412548cf835c3ec36343 Mon Sep 17 00:00:00 2001 From: W0rma Date: Tue, 3 Nov 2026 07:33:12 +0100 Subject: [PATCH 2/9] Use SFTP::filesize() instead of size() size() was removed in phpseclib v4, filesize() is available in v2, v3 and v4. --- src/Codeception/Module/FTP.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Codeception/Module/FTP.php b/src/Codeception/Module/FTP.php index e27c878..b9aca3a 100644 --- a/src/Codeception/Module/FTP.php +++ b/src/Codeception/Module/FTP.php @@ -694,7 +694,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->ftp->filesize($filename) : @ftp_size($this->ftp, $filename); if ($size < 0) { $this->fail("couldn't get the file size for {$filename}"); From cae2fe7416f5209bd1dd543723cfe513db275db6 Mon Sep 17 00:00:00 2001 From: W0rma Date: Tue, 3 Nov 2026 07:33:12 +0100 Subject: [PATCH 3/9] Resolve SOURCE_LOCAL_FILE from the SFTP connection class The constant is defined on phpseclib\Net\SFTP (v2), phpseclib3\Net\SFTP (v3) and phpseclib4\Net\SFTP (v4), so read it from the instantiated class. --- src/Codeception/Module/FTP.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Codeception/Module/FTP.php b/src/Codeception/Module/FTP.php index b9aca3a..112866a 100644 --- a/src/Codeception/Module/FTP.php +++ b/src/Codeception/Module/FTP.php @@ -602,7 +602,7 @@ private function _writeToFile(string $filename, string $contents): void // Upload the file to server if ($this->isSFTP()) { - $flag = \phpseclib\Net\SFTP::SOURCE_LOCAL_FILE; + $flag = $this->ftp::SOURCE_LOCAL_FILE; $uploaded = @$this->ftp->put($filename, $tmp_file, $flag); } else { From feaf090620e650bb3afaa51f1a89f24d9ab16225 Mon Sep 17 00:00:00 2001 From: W0rma Date: Tue, 3 Nov 2026 07:34:42 +0100 Subject: [PATCH 4/9] Pass recursive flag to SFTP::mkdir() as third argument The signature is mkdir($dir, $mode = -1, $recursive = false) in phpseclib v2, v3 and v4, so `true` was passed as $mode and directories were never created recursively. With strict_types and phpseclib v4's typed $mode parameter this also causes a TypeError. --- src/Codeception/Module/FTP.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Codeception/Module/FTP.php b/src/Codeception/Module/FTP.php index 112866a..38d5ef5 100644 --- a/src/Codeception/Module/FTP.php +++ b/src/Codeception/Module/FTP.php @@ -619,7 +619,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->ftp->mkdir($path, -1, true) : @ftp_mkdir($this->ftp, $path); if (!$created) { $this->fail("couldn't make directory {$path}"); From 286bde56930a638290818835b840a9ce1a43461b Mon Sep 17 00:00:00 2001 From: W0rma Date: Tue, 3 Nov 2026 07:50:08 +0100 Subject: [PATCH 5/9] Link to phpseclib GitHub repository instead of SourceForge --- src/Codeception/Module/FTP.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Codeception/Module/FTP.php b/src/Codeception/Module/FTP.php index 38d5ef5..0c5d0a8 100644 --- a/src/Codeception/Module/FTP.php +++ b/src/Codeception/Module/FTP.php @@ -24,9 +24,9 @@ * * 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" From 0991cc91bf693b911c82141901b448a2cf3a627c Mon Sep 17 00:00:00 2001 From: W0rma Date: Tue, 3 Nov 2026 08:01:24 +0100 Subject: [PATCH 6/9] Add support for phpseclib v3 and v4 --- .github/workflows/main.yml | 14 ++++++- composer.json | 2 +- src/Codeception/Module/FTP.php | 67 ++++++++++++++++++++++++---------- 3 files changed, 61 insertions(+), 22 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 91c9599..65ffd0b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,6 +9,14 @@ jobs: strategy: 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 +32,11 @@ 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: Run test suite 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 0c5d0a8..252d446 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; /** * @@ -28,9 +31,7 @@ * * 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|\phpseclib\Net\SFTP|resource + * @var null|bool|SFTPv2|SFTPv3|SFTPv4|resource */ protected $ftp = null; @@ -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); } @@ -604,7 +605,7 @@ private function _writeToFile(string $filename, string $contents): void if ($this->isSFTP()) { $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, -1, 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}"); @@ -694,7 +695,7 @@ private function clearDirectory(string $path): void */ private function size(string $filename): int { - $size = $this->isSFTP() ? (int)@$this->ftp->filesize($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,8 +723,12 @@ private function modified(string $filename): int protected function sftpConnect(string $user, string $password): void { - if (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'); } @@ -731,16 +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(\phpseclib\Crypt\RSA::class)) { + 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'); } } @@ -764,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'; From 48a06d70f8fefe9d7d930054db8bff4018fd949c Mon Sep 17 00:00:00 2001 From: W0rma Date: Tue, 3 Nov 2026 08:07:11 +0100 Subject: [PATCH 7/9] Delete directories recursively in deleteDir() and cleanDir() for SFTP Without the recursive flag phpseclib removes the path as a file, which fails for directories. The FTP implementation already deletes recursively. --- src/Codeception/Module/FTP.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Codeception/Module/FTP.php b/src/Codeception/Module/FTP.php index 252d446..b8109f1 100644 --- a/src/Codeception/Module/FTP.php +++ b/src/Codeception/Module/FTP.php @@ -353,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); } /** @@ -686,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); } From 043cce2dc79dee65059536a17954ad399a52ec4a Mon Sep 17 00:00:00 2001 From: W0rma Date: Tue, 3 Nov 2026 08:07:54 +0100 Subject: [PATCH 8/9] Run SFTP tests against an SFTP server in CI The SFTP test was always skipped. Start an atmoz/sftp service container in CI and configure the test via SFTP_* environment variables. The test is still skipped if SFTP_HOST is not set. --- .github/workflows/main.yml | 24 +++++++++++++ tests/unit/Codeception/Module/SFTPTest.php | 40 +++++++++++++--------- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 65ffd0b..e503934 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -6,6 +6,15 @@ 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: matrix: php: [8.0, 8.1] @@ -38,5 +47,20 @@ jobs: 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/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')); From 5e28878d87d38dbae95d8c4b56dfdc51309e44fc Mon Sep 17 00:00:00 2001 From: W0rma Date: Tue, 3 Nov 2026 08:13:08 +0100 Subject: [PATCH 9/9] Don't cancel remaining CI jobs when one job fails --- .github/workflows/main.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e503934..a45caec 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,6 +16,8 @@ jobs: 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']