<?php

/**
 * SFTP Stream Wrapper
 *
 * Creates an sftp:// protocol handler that can be used with, for example, fopen(), dir(), etc.
 *
 * PHP version 8.1+
 *
 * @author    Jim Wigginton <terrafrost@php.net>
 * @copyright 2013-2026 Jim Wigginton
 * @license   http://www.opensource.org/licenses/mit-license.html  MIT License
 * @link      https://phpseclib.com/
 */

declare(strict_types=1);

namespace phpseclib4\Net\SFTP;

use phpseclib4\Crypt\Common\PrivateKey;
use phpseclib4\Exception\FileSystemException;
use phpseclib4\Net\{SFTP, SSH2};
use phpseclib4\Net\SSH2\MessageType as SSH2MessageType;

/**
 * SFTP Stream Wrapper
 *
 * @author  Jim Wigginton <terrafrost@php.net>
 */
class Stream
{
    /**
     * SFTP instances
     *
     * Rather than re-create the connection we re-use instances if possible
     */
    public static array $instances;

    /**
     * SFTP instance
     */
    private SFTP $sftp;

    /**
     * Path
     */
    private string $path;

    /**
     * Mode
     */
    private string $mode;

    /**
     * Position
     */
    private int $pos;

    /**
     * Size
     */
    private ?int $size;

    /**
     * Directory entries
     */
    private array $entries;

    /**
     * EOF flag
     */
    private bool $eof;

    /**
     * Context resource
     *
     * Technically this needs to be publicly accessible so PHP can set it directly
     *
     * @var resource
     */
    public $context;

    /**
     * Notification callback function
     *
     * @var callable
     */
    private \Closure|string|array $notification;

    /**
     * Registers this class as a URL wrapper.
     *
     * @param string $protocol The wrapper name to be registered.
     * @return bool True on success, false otherwise.
     */
    public static function register(string $protocol = 'sftp'): bool
    {
        if (in_array($protocol, stream_get_wrappers(), true)) {
            return false;
        }
        return stream_wrapper_register($protocol, get_called_class());
    }

    /**
     * The Constructor
     *
     * @psalm-suppress PossiblyUnusedMethod
     */
    public function __construct()
    {
        if (defined('NET_SFTP_STREAM_LOGGING')) {
            echo "__construct()\r\n";
        }
    }

    /**
     * Path Parser
     *
     * Extract a path from a URI and actually connect to an SSH server if appropriate
     *
     * If "notification" is set as a context parameter the message code for successful login is
     * SSHMsg::USERAUTH_SUCCESS. For a failed login it's SSHMsg::USERAUTH_FAILURE.
     */
    protected function parse_path(string $path): ?string
    {
        $orig = $path;
        $url = parse_url($path) + ['port' => 22];

        /**
         * @var string $scheme
         * @var string $host
         * @var int $port
         * @var string $user
         * @var string $pass
         * @var string $query
         * @var string $fragment
         */
        $keys = ['scheme', 'host', 'port', 'user', 'pass', 'path', 'query', 'fragment'];
        foreach ($keys as $key) {
            if (isset($url[$key])) {
                $$key = $url[$key];
            }
        }

        if (isset($query)) {
            $path .= '?' . $query;
        } elseif (preg_match('/(\?|\?#)$/', $orig)) {
            $path .= '?';
        }
        if (isset($fragment)) {
            $path .= '#' . $fragment;
        } elseif ($orig[-1] == '#') {
            $path .= '#';
        }

        if (!isset($host)) {
            return null;
        }

        if (isset($this->context)) {
            $context = stream_context_get_params($this->context);
            if (isset($context['notification']) && is_callable($context['notification'])) {
                $this->notification = $context['notification'];
            }
        }

        if (preg_match('/^{[a-z0-9]+}$/i', $host)) {
            $host = SSH2::getConnectionByResourceId($host);
            if ($host === null) {
                return null;
            }
            $this->sftp = $host;
        } else {
            $context = isset($this->context) ?
                stream_context_get_options($this->context) :
                stream_context_get_options(stream_context_get_default());
            if (isset($context[$scheme]['session'])) {
                $sftp = $context[$scheme]['session'];
            }
            if (isset($context[$scheme]['sftp'])) {
                $sftp = $context[$scheme]['sftp'];
            }
            if (isset($sftp) && $sftp instanceof SFTP) {
                $this->sftp = $sftp;
                return $path;
            }
            if (isset($context[$scheme]['username'])) {
                $user = $context[$scheme]['username'];
            }
            if (isset($context[$scheme]['password'])) {
                $pass = $context[$scheme]['password'];
            }
            if (isset($context[$scheme]['privkey']) && $context[$scheme]['privkey'] instanceof PrivateKey) {
                $pass = $context[$scheme]['privkey'];
            }

            if (!isset($user) || !isset($pass)) {
                return null;
            }

            // casting $pass to a string is necessary in the event that it's a \phpseclib4\Crypt\RSA object
            if (isset(self::$instances[$host][$port][$user][(string) $pass])) {
                $this->sftp = self::$instances[$host][$port][$user][(string) $pass];
            } else {
                $this->sftp = new SFTP($host, $port);
                $this->sftp->disableStatCache();
                if (isset($this->notification) && is_callable($this->notification)) {
                    /* if !is_callable($this->notification) we could do this:

                       user_error('fopen(): failed to call user notifier', E_USER_WARNING);

                       the ftp wrapper gives errors like that when the notifier isn't callable.
                       i've opted not to do that, however, since the ftp wrapper gives the line
                       on which the fopen occurred as the line number - not the line that the
                       user_error is on.
                    */
                    call_user_func($this->notification, STREAM_NOTIFY_CONNECT, STREAM_NOTIFY_SEVERITY_INFO, '', 0, 0, 0);
                    call_user_func($this->notification, STREAM_NOTIFY_AUTH_REQUIRED, STREAM_NOTIFY_SEVERITY_INFO, '', 0, 0, 0);
                    if (!$this->sftp->login($user, $pass)) {
                        call_user_func($this->notification, STREAM_NOTIFY_AUTH_RESULT, STREAM_NOTIFY_SEVERITY_ERR, 'Login Failure', SSH2MessageType::USERAUTH_FAILURE, 0, 0);
                        return null;
                    }
                    call_user_func($this->notification, STREAM_NOTIFY_AUTH_RESULT, STREAM_NOTIFY_SEVERITY_INFO, 'Login Success', SSH2MessageType::USERAUTH_SUCCESS, 0, 0);
                } else {
                    if (!$this->sftp->login($user, $pass)) {
                        return null;
                    }
                }
                self::$instances[$host][$port][$user][(string) $pass] = $this->sftp;
            }
        }

        return $path;
    }

    /**
     * Opens file or URL
     */
    private function _stream_open(string $path, string $mode): bool
    {
        $path = $this->parse_path($path);

        if (!isset($path)) {
            return false;
        }
        $this->path = $path;

        try {
            $this->size = $this->sftp->filesize($path);
        } catch (\Exception) {
            $this->size = null;
        }
        $this->mode = preg_replace('#[bt]$#', '', $mode);
        $this->eof = false;

        if (!isset($this->size)) {
            if ($this->mode[0] == 'r') {
                return false;
            } else {
                try {
                    $this->sftp->touch($path);
                } catch (\Exception $e) {
                    if (isset($this->notification) && is_callable($this->notification)) {
                        call_user_func($this->notification, STREAM_NOTIFY_FAILURE, STREAM_NOTIFY_SEVERITY_ERR, $e->getMessage(), $e->getCode(), 0, 0);
                    }
                    return false;
                }
                $this->size = 0;
            }
        } else {
            switch ($this->mode[0]) {
                case 'x':
                    return false;
                case 'w':
                    try {
                        $this->sftp->truncate($path, 0);
                    } catch (\Exception $e) {
                        if (isset($this->notification) && is_callable($this->notification)) {
                            call_user_func($this->notification, STREAM_NOTIFY_FAILURE, STREAM_NOTIFY_SEVERITY_ERR, $e->getMessage(), $e->getCode(), 0, 0);
                        }
                        return false;
                    }
                    $this->size = 0;
            }
        }

        $this->pos = $this->mode[0] != 'a' ? 0 : $this->size;

        return true;
    }

    /**
     * Read from stream
     *
     * @return string|false
     */
    private function _stream_read(int $count): string|bool
    {
        switch ($this->mode) {
            case 'w':
            case 'a':
            case 'x':
            case 'c':
                return false;
        }

        // commented out because some files - eg. /dev/urandom - will say their size is 0 when in fact it's kinda infinite
        //if ($this->pos >= $this->size) {
        //    $this->eof = true;
        //    return false;
        //}

        try {
            $result = $this->sftp->get($this->path, null, $this->pos, $count);
        } catch (\Exception $e) {
            if (isset($this->notification) && is_callable($this->notification)) {
                call_user_func($this->notification, STREAM_NOTIFY_FAILURE, STREAM_NOTIFY_SEVERITY_ERR, $e->getMessage(), $e->getCode(), 0, 0);
            }
            return false;
        }
        if (isset($this->notification) && is_callable($this->notification)) {
            // seems that PHP calls stream_read in 8k chunks
            call_user_func($this->notification, STREAM_NOTIFY_PROGRESS, STREAM_NOTIFY_SEVERITY_INFO, '', 0, strlen($result), $this->size);
        }

        $this->pos += strlen($result);

        return $result;
    }

    /**
     * Write to stream
     *
     * @return int|false
     */
    private function _stream_write(string $data): int|bool
    {
        switch ($this->mode) {
            case 'r':
                return false;
        }

        try {
            $this->sftp->put($this->path, $data, SFTP::SOURCE_STRING, $this->pos);
        } catch (\Exception $e) {
            if (isset($this->notification) && is_callable($this->notification)) {
                call_user_func($this->notification, STREAM_NOTIFY_FAILURE, STREAM_NOTIFY_SEVERITY_ERR, $e->getMessage(), $e->getCode(), 0, 0);
            }
            return false;
        }
        if (isset($this->notification) && is_callable($this->notification)) {
            // seems that PHP splits up strings into 8k blocks before calling stream_write
            call_user_func($this->notification, STREAM_NOTIFY_PROGRESS, STREAM_NOTIFY_SEVERITY_INFO, '', 0, strlen($data), strlen($data));
        }

        $this->pos += strlen($data);
        if ($this->pos > $this->size) {
            $this->size = $this->pos;
        }
        $this->eof = false;
        return strlen($data);
    }

    /**
     * Retrieve the current position of a stream
     */
    private function _stream_tell(): int
    {
        return $this->pos;
    }

    /**
     * Tests for end-of-file on a file pointer
     *
     * In my testing there are four classes functions that normally effect the pointer:
     * fseek, fputs  / fwrite, fgets / fread and ftruncate.
     *
     * Only fgets / fread, however, results in feof() returning true. do fputs($fp, 'aaa') on a blank file and feof()
     * will return false. do fread($fp, 1) and feof() will then return true. do fseek($fp, 10) on ablank file and feof()
     * will return false. do fread($fp, 1) and feof() will then return true.
     */
    private function _stream_eof(): bool
    {
        return $this->eof;
    }

    /**
     * Seeks to specific location in a stream
     */
    private function _stream_seek(int $offset, int $whence): bool
    {
        switch ($whence) {
            case SEEK_SET:
                if ($offset < 0) {
                    return false;
                }
                break;
            case SEEK_CUR:
                $offset += $this->pos;
                break;
            case SEEK_END:
                $offset += $this->size;
        }

        $this->pos = $offset;
        $this->eof = false;
        return true;
    }

    /**
     * Change stream options
     */
    private function _stream_metadata(string $path, int $option, int|string|array $var): bool
    {
        $path = $this->parse_path($path);
        if (!isset($path)) {
            return false;
        }

        try {
            switch ($option) {
                case STREAM_META_TOUCH:
                    $time = $var[0] ?? null;
                    $atime = $var[1] ?? null;
                    $this->sftp->touch($path, $time, $atime);
                    return true;
                case STREAM_META_OWNER_NAME:
                    if ($this->getNegotiatedVersion() >= 4) {
                        $this->sftp->chown($path, $var);
                        return true;
                    }
                    return false;
                case STREAM_META_GROUP_NAME:
                    if ($this->getNegotiatedVersion() >= 4) {
                        $this->sftp->chgrp($path, $var);
                        return true;
                    }
                    return false;
                case STREAM_META_OWNER:
                    if ($this->getNegotiatedVersion() < 4) {
                        $this->sftp->chown($path, $var);
                        return true;
                    }
                    return false;
                case STREAM_META_GROUP:
                    if ($this->getNegotiatedVersion() < 4) {
                        $this->sftp->chgrp($path, $var);
                        return true;
                    }
                    return false;
                case STREAM_META_ACCESS:
                    $this->sftp->chmod($path, $var);
                    return true;
                default:
                    return false;
            }
        } catch (\Exception $e) {
            if (isset($this->notification) && is_callable($this->notification)) {
                call_user_func($this->notification, STREAM_NOTIFY_FAILURE, STREAM_NOTIFY_SEVERITY_ERR, $e->getMessage(), $e->getCode(), 0, 0);
            }
            return false;
        }
    }

    /**
     * Retrieve the underlying resource
     *
     * @return resource|false
     */
    private function _stream_cast(int $cast_as)
    {
        return is_resource($this->sftp->fsock) ? $this->sftp->fsock : false;
    }

    /**
     * Advisory file locking
     */
    private function _stream_lock(int $operation): bool
    {
        return false;
    }

    /**
     * Renames a file or directory
     *
     * Attempts to rename oldname to newname, moving it between directories if necessary.
     * If newname exists, it will be overwritten.  This is a departure from what \phpseclib4\Net\SFTP
     * does.
     */
    private function _rename(string $path_from, string $path_to): bool
    {
        $path1 = parse_url($path_from);
        $path2 = parse_url($path_to);
        unset($path1['path'], $path2['path']);
        if ($path1 != $path2) {
            return false;
        }

        $path_from = $this->parse_path($path_from);
        $path_to = parse_url($path_to);
        if (!isset($path_from)) {
            return false;
        }

        $path_to = $path_to['path'];
        // "It is an error if there already exists a file with the name specified by newpath."
        //  -- http://tools.ietf.org/html/draft-ietf-secsh-filexfer-02#section-6.5
        try {
            $this->sftp->rename($path_from, $path_to);
        } catch (FileSystemException $e) {
            if (!$this->sftp->file_exists($path_to)) {
                if (isset($this->notification) && is_callable($this->notification)) {
                    call_user_func($this->notification, STREAM_NOTIFY_FAILURE, STREAM_NOTIFY_SEVERITY_ERR, $e->getMessage(), $e->getCode(), 0, 0);
                }
                return false;
            }
            try {
                $this->sftp->delete($path_to, true);
                $this->sftp->rename($path_from, $path_to);
            } catch (FileSystemException $e) {
                if (isset($this->notification) && is_callable($this->notification)) {
                    call_user_func($this->notification, STREAM_NOTIFY_FAILURE, STREAM_NOTIFY_SEVERITY_ERR, $e->getMessage(), $e->getCode(), 0, 0);
                }
                return false;
            }
        }

        return true;
    }

    /**
     * Open directory handle
     *
     * nlist() is the best that this function is realistically going to be able to do. When an SFTP client sends
     * a SSH_FXP_READDIR packet you don't generally get info on just one file but on multiple files. Quoting
     * the SFTP specs:
     *
     *    The SSH_FXP_NAME response has the following format:
     *
     *        uint32     id
     *        uint32     count
     *        repeats count times:
     *                string     filename
     *                string     longname
     *                ATTRS      attrs
     */
    private function _dir_opendir(string $path, int $options): bool
    {
        $path = $this->parse_path($path);
        if (!isset($path)) {
            return false;
        }
        $this->pos = 0;
        try {
            $this->entries = $this->sftp->nlist($path);
        } catch (FileSystemException $e) {
            if (isset($this->notification) && is_callable($this->notification)) {
                call_user_func($this->notification, STREAM_NOTIFY_FAILURE, STREAM_NOTIFY_SEVERITY_ERR, $e->getMessage(), $e->getCode(), 0, 0);
            }
            return false;
        }
        return true;
    }

    /**
     * Read entry from directory handle
     */
    private function _dir_readdir()
    {
        if (isset($this->entries[$this->pos])) {
            return $this->entries[$this->pos++];
        }
        return false;
    }

    /**
     * Rewind directory handle
     */
    private function _dir_rewinddir(): bool
    {
        $this->pos = 0;
        return true;
    }

    /**
     * Close directory handle
     */
    private function _dir_closedir(): bool
    {
        return true;
    }

    /**
     * Create a directory
     *
     * Only valid $options is STREAM_MKDIR_RECURSIVE
     */
    private function _mkdir(string $path, int $mode, int $options): bool
    {
        $path = $this->parse_path($path);
        if (!isset($path)) {
            return false;
        }

        try {
            $this->sftp->mkdir($path, $mode, boolval($options & STREAM_MKDIR_RECURSIVE));
            return true;
        } catch (FileSystemException $e) {
            if (isset($this->notification) && is_callable($this->notification)) {
                call_user_func($this->notification, STREAM_NOTIFY_FAILURE, STREAM_NOTIFY_SEVERITY_ERR, $e->getMessage(), $e->getCode(), 0, 0);
            }
            return false;
        }
    }

    /**
     * Removes a directory
     *
     * Only valid $options is STREAM_MKDIR_RECURSIVE per <http://php.net/streamwrapper.rmdir>, however,
     * <http://php.net/rmdir>  does not have a $recursive parameter as mkdir() does so I don't know how
     * STREAM_MKDIR_RECURSIVE is supposed to be set. Also, when I try it out with rmdir() I get 8 as
     * $options. What does 8 correspond to?
     */
    private function _rmdir(string $path, int $options): bool
    {
        $path = $this->parse_path($path);
        if (!isset($path)) {
            return false;
        }

        try {
            $this->sftp->rmdir($path);
            return true;
        } catch (FileSystemException $e) {
            if (isset($this->notification) && is_callable($this->notification)) {
                call_user_func($this->notification, STREAM_NOTIFY_FAILURE, STREAM_NOTIFY_SEVERITY_ERR, $e->getMessage(), $e->getCode(), 0, 0);
            }
            return false;
        }
    }

    /**
     * Flushes the output
     *
     * See <http://php.net/fflush>. Always returns true because \phpseclib4\Net\SFTP doesn't cache stuff before writing
     */
    private function _stream_flush(): bool
    {
        return true;
    }

    /**
     * Retrieve information about a file resource
     */
    private function _stream_stat(): array|bool
    {
        try {
            return $this->sftp->stat($this->path);
        } catch (FileSystemException $e) {
            if (isset($this->notification) && is_callable($this->notification)) {
                call_user_func($this->notification, STREAM_NOTIFY_FAILURE, STREAM_NOTIFY_SEVERITY_ERR, $e->getMessage(), $e->getCode(), 0, 0);
            }
            return false;
        }
    }

    /**
     * Delete a file
     */
    private function _unlink(string $path): bool
    {
        $path = $this->parse_path($path);
        if (!isset($path)) {
            return false;
        }

        try {
            $this->sftp->delete($path, false);
            return true;
        } catch (FileSystemException $e) {
            if (isset($this->notification) && is_callable($this->notification)) {
                call_user_func($this->notification, STREAM_NOTIFY_FAILURE, STREAM_NOTIFY_SEVERITY_ERR, $e->getMessage(), $e->getCode(), 0, 0);
            }
            return false;
        }
    }

    /**
     * Retrieve information about a file
     *
     * Ignores the STREAM_URL_STAT_QUIET flag because the entirety of \phpseclib4\Net\SFTP\Stream is quiet by default
     * might be worthwhile to reconstruct bits 12-16 (ie. the file type) if mode doesn't have them but we'll
     * cross that bridge when and if it's reached
     */
    private function _url_stat(string $path, int $flags): array|bool
    {
        $path = $this->parse_path($path);
        if (!isset($path)) {
            return false;
        }

        try {
            return $flags & STREAM_URL_STAT_LINK ? $this->sftp->lstat($path) : $this->sftp->stat($path);
        } catch (FileSystemException $e) {
            if (isset($this->notification) && is_callable($this->notification)) {
                call_user_func($this->notification, STREAM_NOTIFY_FAILURE, STREAM_NOTIFY_SEVERITY_ERR, $e->getMessage(), $e->getCode(), 0, 0);
            }
            return false;
        }
    }

    /**
     * Truncate stream
     */
    private function _stream_truncate(int $new_size): bool
    {
        try {
            $this->sftp->truncate($this->path, $new_size);
            $this->eof = false;
            $this->size = $new_size;
            return true;
        } catch (FileSystemException $e) {
            if (isset($this->notification) && is_callable($this->notification)) {
                call_user_func($this->notification, STREAM_NOTIFY_FAILURE, STREAM_NOTIFY_SEVERITY_ERR, $e->getMessage(), $e->getCode(), 0, 0);
            }
            return false;
        }
    }

    /**
     * Change stream options
     *
     * STREAM_OPTION_WRITE_BUFFER isn't supported for the same reason stream_flush isn't.
     * The other two aren't supported because of limitations in \phpseclib4\Net\SFTP.
     */
    private function _stream_set_option(int $option, int $arg1, int $arg2): bool
    {
        return false;
    }

    /**
     * Close a resource
     */
    private function _stream_close(): void
    {
    }

    /**
     * __call Magic Method
     *
     * When you're utilizing an SFTP stream you're not calling the methods in this class directly - PHP is calling them for you.
     * Which kinda begs the question... what methods is PHP calling and what parameters is it passing to them? This function
     * lets you figure that out.
     *
     * If NET_SFTP_STREAM_LOGGING is defined all calls will be output on the screen and then (regardless of whether or not
     * NET_SFTP_STREAM_LOGGING is enabled) the parameters will be passed through to the appropriate method.
     */
    public function __call(string $name, array $arguments)
    {
        if (defined('NET_SFTP_STREAM_LOGGING')) {
            echo $name . '(';
            $last = count($arguments) - 1;
            foreach ($arguments as $i => $argument) {
                var_export($argument);
                if ($i != $last) {
                    echo ',';
                }
            }
            echo ")\r\n";
        }
        $name = '_' . $name;
        if (!method_exists($this, $name)) {
            return false;
        }
        return $this->$name(...$arguments);
    }
}
