# -*- coding: utf-8 -*-

# Copyright (C) 2010-2023 by Mike Gabriel <mike.gabriel@das-netzwerkteam.de>

# The Python X2Go sFTPServer code was originally written by Richard Murri,
# for further information see his website: http://www.richardmurri.com

# Python X2Go is free software; you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# Python X2Go 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
# along with this program; if not, write to the
# Free Software Foundation, Inc.,
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA.

"""\
For sharing local folders via sFTP/sshfs Python X2Go implements its own sFTP
server (as end point of reverse forwarding tunnel requests). Thus, Python X2Go
does not need a locally installed SSH daemon on the client side machine.

The Python X2Go sFTP server code was originally written by Richard Murri,
for further information see his website: http://www.richardmurri.com

"""
__NAME__ = "x2gosftpserver-pylib"

__package__ = 'x2go'
__name__    = 'x2go.sftpserver'

import os
import shutil
import copy
import threading
import paramiko
import gevent

# Python X2Go modules
from . import rforward
from . import defaults
from . import log

class _SSHServer(paramiko.ServerInterface):
    """\
    Implementation of a basic SSH server that is supposed
    to run with its sFTP server implementation.


    """
    def __init__(self, auth_key=None, session_instance=None, logger=None, loglevel=log.loglevel_DEFAULT, *args, **kwargs):
        """\
        Initialize a new sFTP server interface.

        :param auth_key: Server key that the client has to authenticate against
        :type auth_key: ``paramiko.RSAKey`` instance
        :param session_instance: the calling :class:`x2go.session.X2GoSession` instance
        :type session_instance: :class:`x2go.session.X2GoSession` instance
        :param logger: you can pass an :class:`x2go.log.X2GoLogger` object to the :class:`x2go.xserver.X2GoClientXConfig` constructor
        :type logger: ``obj``
        :param loglevel: if no :class:`x2go.log.X2GoLogger` object has been supplied a new one will be
            constructed with the given loglevel
        :type loglevel: ``int``

        """
        if logger is None:
            self.logger = log.X2GoLogger(loglevel=loglevel)
        else:
            self.logger = copy.deepcopy(logger)
        self.logger.tag = __NAME__

        self.current_local_user = defaults.CURRENT_LOCAL_USER
        self.auth_key = auth_key
        self.session_instance = session_instance
        paramiko.ServerInterface.__init__(self, *args, **kwargs)
        logger('initializing internal SSH server for handling incoming sFTP requests, allowing connections for user ,,%s\'\' only' % self.current_local_user, loglevel=log.loglevel_DEBUG)

    def check_channel_request(self, kind, chanid):
        """\
        Only allow session requests.

        :param kind: request type
        :type kind: ``str``
        :param chanid: channel id (unused)
        :type chanid: ``any``
        :returns: returns a Paramiko/SSH return code
        :rtype: ``int``

        """
        self.logger('detected a channel request for sFTP', loglevel=log.loglevel_DEBUG_SFTPXFER)
        if kind == 'session':
            return paramiko.OPEN_SUCCEEDED
        return paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED

    def check_auth_publickey(self, username, key):
        """\
        Ensure proper authentication.

        :param username: username of incoming authentication request
        :type username: ``str``
        :param key: incoming SSH key to be used for authentication
        :type key: ``paramiko.RSAKey`` instance
        :returns: returns a Paramiko/SSH return code
        :rtype: ``int``

        """
        self.logger('sFTP server %s: username is %s' % (self, self.current_local_user), loglevel=log.loglevel_DEBUG)
        if username == self.current_local_user:
            if type(key) == paramiko.RSAKey and key == self.auth_key:
                self.logger('sFTP server %s: publickey auth (type: %s) has been successful' % (self, key.get_name()), loglevel=log.loglevel_INFO)
                return paramiko.AUTH_SUCCESSFUL
        self.logger('sFTP server %s: publickey (type: %s) auth failed' % (self, key.get_name()), loglevel=log.loglevel_WARN)
        return paramiko.AUTH_FAILED

    def get_allowed_auths(self, username):
        """\
        Only allow public key authentication.

        :param username: username of incoming authentication request
        :type username: ``str``
        :returns: statically returns ``publickey`` as auth mechanism
        :rtype: ``str``

        """
        self.logger('sFTP client asked for support auth methods, answering: publickey', loglevel=log.loglevel_DEBUG_SFTPXFER)
        return 'publickey'


class _SFTPHandle(paramiko.SFTPHandle):
    """\
    Represents a handle to an open file.


    """
    def stat(self):
        """\
        Create an SFTPAttributes object from an existing stat object (an object returned by os.stat).


        :returns: new ``SFTPAttributes`` object with the same attribute fields.

        :rtype: ``obj``

        """
        try:
            return paramiko.SFTPAttributes.from_stat(os.fstat(self.readfile.fileno()))
        except OSError as e:
            return paramiko.SFTPServer.convert_errno(e.errno)


class _SFTPServerInterface(paramiko.SFTPServerInterface):
    """\
    sFTP server implementation.


    """
    def __init__(self, server, chroot=None, logger=None, loglevel=log.loglevel_DEFAULT, server_event=None, *args, **kwargs):
        """\
        Make user information accessible as well as set chroot jail directory.

        :param server: a ``paramiko.ServerInterface`` instance to use with this SFTP server interface
        :type server: ``paramiko.ServerInterface`` instance
        :param chroot: chroot environment for this SFTP interface
        :type chroot: ``str``
        :param logger: you can pass an :class:`x2go.log.X2GoLogger` object to the :class:`x2go.xserver.X2GoClientXConfig` constructor
        :type logger: ``obj``
        :param loglevel: if no :class:`x2go.log.X2GoLogger` object has been supplied a new one will be
            constructed with the given loglevel
        :type loglevel: ``int``
        :param server_event: a ``threading.Event`` instance that can signal SFTP session termination
        :type server_event: ``threading.Event`` instance

        """
        if logger is None:
            self.logger = log.X2GoLogger(loglevel=loglevel)
        else:
            self.logger = copy.deepcopy(logger)
        self.logger.tag = __NAME__
        self.server_event = server_event

        self.logger('sFTP server: initializing new channel...', loglevel=log.loglevel_DEBUG)
        self.CHROOT = chroot or '/tmp'

    def _realpath(self, path):
        """\
        Enforce the chroot jail. On Windows systems the drive letter is incorporated in the
        chroot path name (/windrive/<drive_letter>/path/to/file/or/folder).

        :param path: path name within chroot
        :type path: ``str``
        :returns: real path name (including drive letter on Windows systems)
        :rtype: ``str``

        """
        if defaults.X2GOCLIENT_OS == 'Windows' and path.startswith('/windrive'):
            _path_components = path.split('/')
            _drive = _path_components[2]
            _tail_components = (len(_path_components) > 3) and _path_components[3:] or ''
            _tail = os.path.normpath('/'.join(_tail_components))
            path = os.path.join('%s:' % _drive, '/', _tail)
        else:
            path = self.CHROOT + self.canonicalize(path)
            path = path.replace('//', '/')
        return path

    def list_folder(self, path):
        """\
        List the contents of a folder.

        :param path: path to folder
        :type path: ``str``
        :returns: returns the folder contents, on failure returns a Paramiko/SSH return code
        :rtype: ``dict`` or ``int``

        """
        path = self._realpath(path)
        self.logger('sFTP server: listing files in folder: %s' % path, loglevel=log.loglevel_DEBUG_SFTPXFER)

        try:
            out = []
            flist = os.listdir(path)
            for fname in flist:

                try:
                    attr = paramiko.SFTPAttributes.from_stat(os.lstat(os.path.join(path, fname)))
                    attr.filename = fname
                    self.logger('sFTP server %s: file attributes ok: %s' % (self, fname), loglevel=log.loglevel_DEBUG_SFTPXFER)
                    out.append(attr)
                except OSError as e:
                    self.logger('sFTP server %s: encountered error processing attributes of file %s: %s' % (self, fname, str(e)), loglevel=log.loglevel_DEBUG_SFTPXFER)

            self.logger('sFTP server: folder list is : %s' % str([ a.filename for a in out ]), loglevel=log.loglevel_DEBUG_SFTPXFER)
            return out
        except OSError as e:
            self.logger('sFTP server %s: encountered error: %s' % (self, str(e)), loglevel=log.loglevel_DEBUG_SFTPXFER)
            return paramiko.SFTPServer.convert_errno(e.errno)

    def stat(self, path):
        """\
        Stat on a file.

        :param path: path to file/folder
        :type path: ``str``
        :returns: returns the file's stat output, on failure: returns a Paramiko/SSH return code
        :rtype: ``class`` or ``int``

        """
        path = self._realpath(path)
        self.logger('sFTP server %s: calling stat on path: %s' % (self, path), loglevel=log.loglevel_DEBUG_SFTPXFER)
        try:
            return paramiko.SFTPAttributes.from_stat(os.stat(path))
        except OSError as e:
            self.logger('sFTP server %s: encountered error: %s' % (self, str(e)), loglevel=log.loglevel_DEBUG_SFTPXFER)
            return paramiko.SFTPServer.convert_errno(e.errno)

    def lstat(self, path):
        """\
        LStat on a file.

        :param path: path to folder
        :type path: ``str``
        :returns: returns the file's lstat output, on failure: returns a Paramiko/SSH return code
        :rtype: ``class`` or ``int``

        """
        path = self._realpath(path)
        self.logger('sFTP server: calling lstat on path: %s' % path, loglevel=log.loglevel_DEBUG_SFTPXFER)
        try:
            return paramiko.SFTPAttributes.from_stat(os.lstat(path))
        except OSError as e:
            self.logger('sFTP server %s: encountered error: %s' % (self, str(e)), loglevel=log.loglevel_DEBUG_SFTPXFER)
            return paramiko.SFTPServer.convert_errno(e.errno)

    def open(self, path, flags, attr):
        """\
        Open a file for reading, writing, appending etc.

        :param path: path to file
        :type path: ``str``
        :param flags: file flags
        :type flags: ``str``
        :param attr: file attributes
        :type attr: ``class``
        :returns: file handle/object for remote file, on failure: returns a Paramiko/SSH return code
        :rtype: :class:`x2go.sftpserver._SFTPHandle` instance or ``int``

        """
        path = self._realpath(path)
        self.logger('sFTP server %s: opening file: %s' % (self, path), loglevel=log.loglevel_DEBUG_SFTPXFER)
        try:
            binary_flag = getattr(os, 'O_BINARY',  0)
            flags |= binary_flag
            mode = getattr(attr, 'st_mode', None)
            if mode is not None:
                fd = os.open(path, flags, mode)
            else:
                # os.open() defaults to 0777 which is
                # an odd default mode for files
                fd = os.open(path, flags, 0o666)
        except OSError as e:
            self.logger('sFTP server %s: encountered error: %s' % (self, str(e)), loglevel=log.loglevel_DEBUG_SFTPXFER)
            return paramiko.SFTPServer.convert_errno(e.errno)
        if (flags & os.O_CREAT) and (attr is not None):
            attr._flags &= ~attr.FLAG_PERMISSIONS
            paramiko.SFTPServer.set_file_attr(path, attr)
        if flags & os.O_WRONLY:
            if flags & os.O_APPEND:
                fstr = 'ab'
            else:
                fstr = 'wb'
        elif flags & os.O_RDWR:
            if flags & os.O_APPEND:
                fstr = 'a+b'
            else:
                fstr = 'r+b'
        else:
            # O_RDONLY (== 0)
            fstr = 'rb'
        try:
            f = os.fdopen(fd, fstr)
        except OSError as e:
            self.logger('sFTP server %s: encountered error: %s' % (self, str(e)), loglevel=log.loglevel_DEBUG_SFTPXFER)
            return paramiko.SFTPServer.convert_errno(e.errno)
        fobj = _SFTPHandle(flags)
        fobj.filename = path
        fobj.readfile = f
        fobj.writefile = f
        return fobj

    def remove(self, path):
        """\
        Remove a file.

        :param path: path to file
        :type path: ``str``
        :returns: returns Paramiko/SSH return code
        :rtype: ``int``

        """
        path = self._realpath(path)
        os.remove(path)
        self.logger('sFTP server %s: removing file: %s' % (self, path), loglevel=log.loglevel_DEBUG_SFTPXFER)
        return paramiko.SFTP_OK

    def rename(self, oldpath, newpath):
        """\
        Rename/move a file.

        :param oldpath: old path/location/file name
        :type oldpath: ``str``
        :param newpath: new path/location/file name
        :type newpath: ``str``
        :returns: returns Paramiko/SSH return code
        :rtype: ``int``

        """
        self.logger('sFTP server %s: renaming path from %s to %s' % (self, oldpath, newpath), loglevel=log.loglevel_DEBUG_SFTPXFER)
        oldpath = self._realpath(oldpath)
        newpath = self._realpath(newpath)
        try:
            shutil.move(oldpath, newpath)
        except OSError as e:
            self.logger('sFTP server %s: encountered error: %s' % (self, str(e)), loglevel=log.loglevel_DEBUG_SFTPXFER)
            return paramiko.SFTPServer.convert_errno(e.errno)
        return paramiko.SFTP_OK

    def mkdir(self, path, attr):
        """\
        Make a directory.

        :param path: path to new folder
        :type path: ``str``
        :param attr: file attributes
        :type attr: ``class``
        :returns: returns Paramiko/SSH return code
        :rtype: ``int``

        """
        self.logger('sFTP server: creating new dir (perms: %s): %s' % (attr.st_mode, path), loglevel=log.loglevel_DEBUG_SFTPXFER)
        path = self._realpath(path)
        try:
            os.mkdir(path, attr.st_mode)
        except OSError as e:
            self.logger('sFTP server %s: encountered error: %s' % (self, str(e)), loglevel=log.loglevel_DEBUG_SFTPXFER)
            return paramiko.SFTPServer.convert_errno(e.errno)
        return paramiko.SFTP_OK

    def rmdir(self, path):
        """\
        Remove a directory (if needed recursively).

        :param path: folder to be removed
        :type path: ``str``
        :returns: returns Paramiko/SSH return code
        :rtype: ``int``

        """
        self.logger('sFTP server %s: removing dir: %s' % (self, path), loglevel=log.loglevel_DEBUG_SFTPXFER)
        path = self._realpath(path)
        try:
            shutil.rmtree(path)
        except OSError as e:
            self.logger('sFTP server %s: encountered error: %s' % (self, str(e)), loglevel=log.loglevel_DEBUG_SFTPXFER)
            return paramiko.SFTPServer.convert_errno(e.errno)
        return paramiko.SFTP_OK

    def chattr(self, path, attr):
        """\
        Change file attributes.

        :param path: path of file/folder
        :type path: ``str``
        :param attr: new file attributes
        :type attr: ``class``
        :returns: returns Paramiko/SSH return code
        :rtype: ``int``

        """
        self.logger('sFTP server %s: modifying attributes of path: %s' % (self, path), loglevel=log.loglevel_DEBUG_SFTPXFER)
        path = self._realpath(path)
        try:
            if attr.st_mode is not None:
                os.chmod(path, attr.st_mode)
            if attr.st_uid is not None:
                os.chown(path, attr.st_uid, attr.st_gid)
        except OSError as e:
            self.logger('sFTP server %s: encountered error: %s' % (self, str(e)), loglevel=log.loglevel_DEBUG_SFTPXFER)
            return paramiko.SFTPServer.convert_errno(e.errno)
        return paramiko.SFTP_OK

    def symlink(self, target_path, path):
        """\
        Create a symbolic link.

        :param target_path: link shall point to this path
        :type target_path: ``str``
        :param path: link location
        :type path: ``str``
        :returns: returns Paramiko/SSH return code
        :rtype: ``int``

        """
        self.logger('sFTP server %s: creating symlink from: %s to target: %s' % (self, path, target_path), loglevel=log.loglevel_DEBUG_SFTPXFER)
        path = self._realpath(path)
        if target_path.startswith('/'):
            target_path = self._realpath(target_path)
        try:
            os.symlink(target_path, path)
        except OSError as e:
            self.logger('sFTP server %s: encountered error: %s' % (self, str(e)), loglevel=log.loglevel_DEBUG_SFTPXFER)
            return paramiko.SFTPServer.convert_errno(e.errno)
        return paramiko.SFTP_OK

    def readlink(self, path):
        """\
        Read the target of a symbolic link.

        :param path: path of symbolic link
        :type path: ``str``
        :returns: target location of the symbolic link, on failure: returns a Paramiko/SSH return code
        :rtype: ``str`` or ``int``

        """
        path = self._realpath(path)
        try:
            return os.readlink(path)
        except OSError as e:
            self.logger('sFTP server %s: encountered error: %s' % (self, str(e)), loglevel=log.loglevel_DEBUG_SFTPXFER)
            return paramiko.SFTPServer.convert_errno(e.errno)

    def session_ended(self):
        """\
        Tidy up when the sFTP session has ended.


        """
        if self.server_event is not None:
            self.logger('sFTP server %s: session has ended' % self, loglevel=log.loglevel_DEBUG_SFTPXFER)
            self.server_event.set()


class X2GoRevFwTunnelToSFTP(rforward.X2GoRevFwTunnel):
    """\
    A reverse fowarding tunnel with an sFTP server at its endpoint. This blend of a Paramiko/SSH
    reverse forwarding tunnel is used to provide access to local X2Go client folders
    from within the the remote X2Go server session.


    """
    def __init__(self, server_port, ssh_transport, auth_key=None, session_instance=None, logger=None, loglevel=log.loglevel_DEFAULT):
        """\
        Start a Paramiko/SSH reverse forwarding tunnel, that has an sFTP server listening at
        the endpoint of the tunnel.

        :param server_port: the TCP/IP port on the X2Go server (starting point of the tunnel),
            normally some number above 30000
        :type server_port: ``int``
        :param ssh_transport: the :class:`x2go.session.X2GoSession`'s Paramiko/SSH transport instance
        :type ssh_transport: ``paramiko.Transport`` instance
        :param auth_key: Paramiko/SSH RSAkey object that has to be authenticated against by
            the remote sFTP client
        :type auth_key: ``paramiko.RSAKey`` instance
        :param logger: you can pass an :class:`x2go.log.X2GoLogger` object to the
            :class:`x2go.sftpserver.X2GoRevFwTunnelToSFTP` constructor
        :type logger: :class:`x2go.log.X2GoLogger` instance
        :param loglevel: if no :class:`x2go.log.X2GoLogger` object has been supplied a new one will be
            constructed with the given loglevel
        :type loglevel: ``int``

        """
        self.ready = False
        if logger is None:
            self.logger = log.X2GoLogger(loglevel=loglevel)
        else:
            self.logger = copy.deepcopy(logger)
        self.logger.tag = __NAME__

        self.server_port = server_port
        self.ssh_transport = ssh_transport
        self.session_instance = session_instance
        if type(auth_key) is not paramiko.RSAKey:
            auth_key = None
        self.auth_key = auth_key

        self.open_channels = {}
        self.incoming_channel = threading.Condition()

        threading.Thread.__init__(self)
        self.daemon = True
        self._accept_channels = True

    def run(self):
        """\
        This method gets run once an :class:`x2go.sftpserver.X2GoRevFwTunnelToSFTP` has been started with its
        :func:`start()` method. Use :func:`X2GoRevFwTunnelToSFTP.stop_thread() <x2go.sftpserver.X2GoRevFwTunnelToSFTP.stop_thread()>` to stop the
        reverse forwarding tunnel again (refer also to its pause() and resume() method).

        :func:`X2GoRevFwTunnelToSFTP.run() <x2go.rforward.X2GoRevFwTunnelToSFTP.run()>` waits for notifications of an appropriate incoming
        Paramiko/SSH channel (issued by :func:`X2GoRevFwTunnelToSFTP.notify() <x2go.rforward.X2GoRevFwTunnelToSFTP.notify()>`). Appropriate in
        this context means, that its starting point on the X2Go server matches the class's
        property ``server_port``.

        Once a new incoming channel gets announced by the :func:`notify()` method, a new
        :class:`x2go.sftpserver.X2GoRevFwSFTPChannelThread` instance will be initialized. As a data stream handler,
        the function :func:`x2go_rev_forward_sftpchannel_handler()` will be used.

        The channel will last till the connection gets dropped on the X2Go server side or
        until the tunnel gets paused by an :func:`X2GoRevFwTunnelToSFTP.pause() <x2go.rforward.X2GoRevFwTunnelToSFTP.pause()>` call or
        stopped via the ``X2GoRevFwTunnelToSFTP.stop_thread()`` method.


        """
        self._request_port_forwarding()
        self._keepalive = True
        self.ready = True
        while self._keepalive:

            self.incoming_channel.acquire()

            self.logger('waiting for incoming sFTP channel on X2Go server port: [localhost]:%s' % self.server_port, loglevel=log.loglevel_DEBUG)
            self.incoming_channel.wait()
            if self._keepalive:
                self.logger('Detected incoming sFTP channel on X2Go server port: [localhost]:%s' % self.server_port, loglevel=log.loglevel_DEBUG)
                _chan = self.ssh_transport.accept()
                self.logger('sFTP channel %s for server port [localhost]:%s is up' % (_chan, self.server_port), loglevel=log.loglevel_DEBUG)
            else:
                self.logger('closing down rev forwarding sFTP tunnel on remote end [localhost]:%s' % self.server_port, loglevel=log.loglevel_DEBUG)

            self.incoming_channel.release()
            if self._accept_channels and self._keepalive:
                _new_chan_thread = X2GoRevFwSFTPChannelThread(_chan,
                                                              target=x2go_rev_forward_sftpchannel_handler,
                                                              kwargs={
                                                               'chan': _chan,
                                                               'auth_key': self.auth_key,
                                                               'logger': self.logger,
                                                              }
                                                             )
                _new_chan_thread.start()
                self.open_channels['[%s]:%s' % _chan.origin_addr] = _new_chan_thread
        self.ready = False


def x2go_rev_forward_sftpchannel_handler(chan=None, auth_key=None, logger=None):
    """\
    Handle incoming sFTP channels that got setup by an :class:`x2go.sftpserver.X2GoRevFwTunnelToSFTP` instance.

    The channel (and the corresponding connections) close either ...

        - ... if the connecting application closes the connection and thus, drops
          the sFTP channel, or
        - ... if the :class:`x2go.sftpserver.X2GoRevFwTunnelToSFTP` parent thread gets paused. The call
          of :func:`X2GoRevFwTunnelToSFTP.pause() <x2go.rforward.X2GoRevFwTunnelToSFTP.pause()>` on the instance can be used to shut down all incoming
          tunneled SSH connections associated to this :class:`x2go.sftpserver.X2GoRevFwTunnelToSFTP` instance
          from within a Python X2Go application.

    :param chan: an incoming sFTP channel (Default value = None)
    :type chan: paramiko.Channel instance
    :param auth_key: Paramiko/SSH RSAkey object that has to be authenticated against by
        the remote sFTP client (Default value = None)
    :type auth_key: ``paramiko.RSAKey`` instance
    :param logger: you must pass an :class:`x2go.log.X2GoLogger` object to this handler method (Default value = None)
    :type logger: ``X2GoLogger`` instance

    """
    if logger is None:
        def _dummy_logger(msg, l):
            pass
        logger = _dummy_logger

    if auth_key is None:
        logger('sFTP channel %s closed because of missing authentication key' % chan, loglevel=log.loglevel_DEBUG)
        return

    # set up server
    t = paramiko.Transport(chan)
    t.daemon = True
    t.load_server_moduli()
    t.add_server_key(defaults.RSAHostKey)

    # set up sftp handler, server and event
    event = threading.Event()
    t.set_subsystem_handler('sftp', paramiko.SFTPServer, sftp_si=_SFTPServerInterface, chroot='/', logger=logger, server_event=event)
    logger('registered sFTP subsystem handler', loglevel=log.loglevel_DEBUG_SFTPXFER)
    server = _SSHServer(auth_key=auth_key, logger=logger)

    # start ssh server session
    t.start_server(server=server, event=event)

    while t.is_active():
        gevent.sleep(1)

    t.stop_thread()
    logger('sFTP channel %s closed down' % chan, loglevel=log.loglevel_DEBUG)


class X2GoRevFwSFTPChannelThread(rforward.X2GoRevFwChannelThread): pass
"""A clone of :class:`x2go.rforward.X2GoRevFwChannelThread`."""
