Repository: emc-isilon/pike (language: python)

Issue:
pike.exceptions.TimeoutError: <exception str() failed>
for some reason the timeout error can't be printed.

py3.10, timeout on connect


Discussion / hints:

Fix patch:
diff --git a/pike/__init__.py b/pike/__init__.py
index e7e1e0ee..652273ac 100644
--- a/pike/__init__.py
+++ b/pike/__init__.py
@@ -27,19 +27,26 @@ def __add__(self, *args, **kwargs):
 
     array.array = newarray
 
+# main entry point
+from pike.test import TreeConnect as TreeConnect
+
 __all__ = [
     "auth",
     "core",
     "crypto",
     "digest",
+    "io",
     "kerberos",
     "model",
     "netbios",
-    "ntlm" "nttime",
+    "ntlm",
+    "nttime",
     "ntstatus",
+    "path",
     "smb2",
     "test",
     "transport",
+    "TreeConnect",
 ]
 # __version__ is defined by setuptools_scm using git tag
 # https://github.com/pypa/setuptools_scm/
diff --git a/pike/exceptions.py b/pike/exceptions.py
index 3cde1530..2c1306d6 100644
--- a/pike/exceptions.py
+++ b/pike/exceptions.py
@@ -2,6 +2,8 @@
 Exceptions used by multiple modules are defined here
 """
 
+from . import core
+
 
 class TimeoutError(Exception):
     """Future completion timed out"""
diff --git a/pike/model.py b/pike/model.py
index 52ffc98f..95196c5e 100644
--- a/pike/model.py
+++ b/pike/model.py
@@ -1864,6 +1864,9 @@ def write_request(self, file, offset, buffer=None, remaining_bytes=0, flags=0):
                 UnicodeWarning,
             )
             buffer = buffer.encode("ascii")
+        if sys.version_info < (3,) and isinstance(buffer, memoryview):
+            # explicit bytes-cast required on py2
+            buffer = buffer.tobytes()
         if buffer is not None and not isinstance(
             buffer, (array.array, bytes, memoryview, bytearray)
         ):
@@ -2189,6 +2192,43 @@ def __init__(self, session, path, smb_res):
             self.encrypt_data = True
         self.session._trees[self.tree_id] = self
 
+    def close(self):
+        if self.tree_id is not None:
+            self.session.first_channel().tree_disconnect(self)
+            self.tree_id = None
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        self.close()
+
+    def __fspath__(self):
+        """
+        Return the string path representation of the Tree.
+        """
+        return r"\\{}\{}".format(
+            self.session.first_channel().connection.hostname,
+            self.path.rpartition("\\")[2],
+        )
+
+    def __add__(self, other):
+        """
+        Concatenate from the string representation of the Tree.
+        """
+        return self.__fspath__() + other
+
+    def __truediv__(self, key):
+        """
+        Return a PikePath with the given key joined.
+        """
+        from . import path
+
+        return path.PikePath(self, key)
+
+    if sys.version_info < (3,):
+        __div__ = __truediv__
+
 
 class RelatedOpen(object):
     """
diff --git a/pike/path.py b/pike/path.py
new file mode 100644
index 00000000..c72e61e8
--- /dev/null
+++ b/pike/path.py
@@ -0,0 +1,744 @@
+"""
+Path-like interface for working with a Tree object
+"""
+from __future__ import absolute_import
+
+import datetime
+import io
+import os
+import sys
+import warnings
+
+try:
+    from pathlib import PureWindowsPath, _WindowsFlavour
+except ImportError:
+    from pathlib2 import PureWindowsPath, _WindowsFlavour
+
+from . import model
+from . import ntstatus
+from . import nttime
+from . import smb2
+
+
+MAX_SYMLINK_RECURSE = 10
+
+NOT_FOUND_STATUSES = (
+    ntstatus.STATUS_OBJECT_NAME_NOT_FOUND,
+    ntstatus.STATUS_OBJECT_PATH_NOT_FOUND,
+)
+
+
+class _PikeFlavour(_WindowsFlavour):
+    """
+    Implement pike-specific overrides of WindowsFlavour semantics.
+
+    Handle the fact that ``_drv`` may be a :py:class:`pike.model.Tree` instance.
+    """
+
+    def _fspath(self, p):
+        if sys.version_info < (3,):
+            return p.__fspath__()
+        return os.fspath(p)
+
+    def casefold(self, s):
+        if isinstance(s, model.Tree):
+            s = self._fspath(s)
+        return s.lower()
+
+    def casefold_parts(self, parts):
+        return [self.casefold(rp) for rp in parts]
+
+
+_pike_flavour = _PikeFlavour()
+
+
+class PikePath(PureWindowsPath):
+    """
+    ``Path`` subclass for :py:class:`pike.model.Tree` instances.
+
+    ``PikePath`` should be either directly instantiated by passing a
+    :py:class:`~pike.model.Tree` or by performing typical ``Path`` extension
+    (truediv) on a :py:class:`~pike.model.Tree` or
+    :py:class:`pike.TreeConnect` instance.
+
+    .. code-block:: python
+        :caption: example.py
+
+        import pike.path
+        import pike.test
+
+        with pike.TreeConnect() as tc:
+            pth = pike.path.PikePath(tc.tree)
+            assert (tc / "subdir") == (pth / "subdir")
+            assert (tc.tree / "subdir") == (pth / "subdir")
+
+    A ``PikePath`` is bound to a specific connection, session, and tree. If the
+    underlying transport is no longer valid, then the path operations will not
+    work.
+
+    An existing path, valid or invalid, and be "rehomed" to a different
+    ``PikePath`` by calling :py:func:`~pike.path.PikePath.join_from_root`,
+    returning a new instance with the path part replaced by that of the passed
+    argument.
+
+    .. code-block:: python
+        :caption: example.py
+
+        import pike
+        import pike.path
+
+        with pike.TreeConnect(), pike.TreeConnect() as tc1, tc2:
+            tc1_subdir = tc1 / "subdir"
+            tc2_subdir = pike.path.PikePath(tc2.tree).join_from_root(tc1_subdir)
+            assert tc1_subdir != tc2_subdir
+
+    Unlike ``WindowsPath``, ``PikePath`` will prefer to raise
+    :py:class:`pike.model.ResponseError` exceptions instead of ``OSError``.
+    """
+
+    _flavour = _pike_flavour
+
+    @classmethod
+    def _parse_args(cls, args):
+        """
+        Override _parse_args to ensure a Tree is returned as drv
+        """
+        if sys.version_info < (3,):
+            # On python 2, explicitly convert future.types.newstr to native unicode type.
+            from future.types import newstr
+
+            # ensure that `newstr` instances are passed as real `unicode`
+            # to avoid TypeError: can't intern subclass of string
+            args = tuple(unicode(pc) if isinstance(pc, newstr) else pc for pc in args)
+        drv, root, parts = super(PikePath, cls)._parse_args(args)
+        if args:
+            if isinstance(args[0], model.Tree):
+                drv = args[0]
+            if isinstance(args[0], cls):
+                drv = args[0]._drv
+        return drv, root, parts
+
+    @property
+    def _channel(self):
+        """
+        :rtype: pike.model.Channel
+        :return: Channel bound to the associated tree
+        """
+        return self._drv.session.first_channel()
+
+    @property
+    def drive(self):
+        """
+        :rtype: pike.model.Tree
+        :return: Tree forming the root of the share / path
+        """
+        return super(PikePath, self).drive
+
+    _tree = drive
+
+    @property
+    def _path(self):
+        """
+        :rtype: str
+        :return: SMB path relative to the root
+        """
+        if self._drv or self._root:
+            return self._flavour.join(self._parts[1:])
+        return str(self)
+
+    def join_from_root(self, path):
+        """
+        Rehome an existing absolute path for use by this PikePath's Tree.
+
+        :rtype: PikePath
+        :return: a new :py:class:`~pike.path.PikePath` by joining ``path`` to
+            the root of this instance.
+        """
+        if isinstance(path, type(self)) and (path.is_absolute()):
+            return self.joinpath(*path._parts[1:])
+        else:
+            path = type(self)("\\", path)
+        return self / path
+
+    @classmethod
+    def cwd(cls):
+        raise NotImplementedError("No concept of cwd for {!r}".format(cls))
+
+    @classmethod
+    def home(cls):
+        raise NotImplementedError("No concept of home for {!r}".format(cls))
+
+    @staticmethod
+    def _follow_link(link, reparse_data):
+        """Return the next target for link by following reparse_data."""
+        if reparse_data.unparsed_path_length > 0:
+            prefix = link.join_from_root(
+                link._path[: -reparse_data.unparsed_path_length // 2]
+            ).parent
+            unparsed_portion = link._path[-reparse_data.unparsed_path_length // 2 :]
+        else:
+            prefix = link.parent
+            unparsed_portion = ""
+        return prefix / (reparse_data.substitute_name + unparsed_portion)
+
+    def _create(self, *args, **kwargs):
+        """
+        Call Channel.create with this Tree and path following symlinks.
+
+        :param _follow: if False, raise symlink errors instead of following
+
+        Special kwarg, __RC_DEPTH, is used to track the recursion depth and
+        will raise OSError if called more than MAX_SYMLINK_RECURSE times.
+
+        Remaining args and kwargs are passed to create directly.
+        """
+        depth = kwargs.pop("__RC_DEPTH", 0)
+        if depth > MAX_SYMLINK_RECURSE:
+            raise OSError("Maximum level of symbolic links exceeded")
+        resolved_path = self
+        if ".." in self.parts or "." in self.parts:
+            resolved_path = self.resolve_relative()
+        follow = kwargs.pop("_follow", True)
+        try:
+            return self._channel.create(
+                self._tree, resolved_path._path, *args, **kwargs
+            ).result()
+        except model.ResponseError as re:
+            if re.response.status != ntstatus.STATUS_STOPPED_ON_SYMLINK or not follow:
+                raise
+            kwargs["__RC_DEPTH"] = depth + 1
+            return self._follow_link(
+                link=resolved_path,
+                reparse_data=re.response[0][0].error_data,
+            )._create(*args, **kwargs)
+
+    def stat(
+        self,
+        file_information_class=smb2.FILE_BASIC_INFORMATION,
+        info_type=smb2.SMB2_0_INFO_FILE,
+        options=0,
+    ):
+        """
+        Perform SMB2 QUERY_INFO request for the given ``file_information_class``.
+
+        :type file_information_class: smb2.FileInformationClass
+        :param file_information_class: the file information class constant
+            describing the type of information to retrieve
+        :type info_type: smb2.InfoType
+        :param info_type: the SMB2 query info type
+        :type options: smb2.CreateOptions
+        :param options: options passed to CREATE when opening the file
+        :return: single file info object corresponding to the
+            file_information_class requested
+        """
+        with self._create(
+            access=smb2.FILE_READ_ATTRIBUTES,
+            disposition=smb2.FILE_OPEN,
+            options=options,
+        ) as handle:
+            return self._channel.query_file_info(
+                handle, file_information_class, info_type, first_result_only=True
+            )
+
+    def lstat(
+        self,
+        file_information_class=smb2.FILE_BASIC_INFORMATION,
+        info_type=smb2.SMB2_0_INFO_FILE,
+        options=0,
+    ):
+        """
+        Perform SMB2 QUERY_INFO request for the given ``file_information_class``.
+
+        If this path represents a symlink, open the link itself, rather than the
+        target. (Intermediate links will not be followed first.)
+
+        :type file_information_class: smb2.FileInformationClass
+        :param file_information_class: the file information class constant
+            describing the type of information to retrieve
+        :type info_type: smb2.InfoType
+        :param info_type: the SMB2 query info type
+        :type options: smb2.CreateOptions
+        :param options: options passed to CREATE when opening the file
+        :return: single file info object corresponding to the
+            file_information_class requested
+        """
+        options = options | smb2.FILE_OPEN_REPARSE_POINT
+        return self.stat(
+            file_information_class=file_information_class,
+            info_type=info_type,
+            options=options,
+        )
+
+    def chmod(self, mode):
+        raise NotImplementedError("ACL modification is not supported")
+
+    lchmod = chmod
+
+    def exists(self, options=0):
+        """
+        :type options: smb2.CreateOptions
+        :param options: options passed to CREATE when opening the file
+        :return: True if this path exists
+        """
+        try:
+            with self._create(
+                access=smb2.FILE_READ_ATTRIBUTES,
+                disposition=smb2.FILE_OPEN,
+                options=options,
+            ) as handle:
+                return True
+        except model.ResponseError as re:
+            if re.response.status not in NOT_FOUND_STATUSES + (
+                ntstatus.STATUS_FILE_IS_A_DIRECTORY,
+                ntstatus.STATUS_NOT_A_DIRECTORY,
+            ):
+                raise
+        return False
+
+    def expanduser(self):
+        raise NotImplementedError("expanduser() is not supported")
+
+    def group(self):
+        raise NotImplementedError("group() is not supported")
+
+    def is_dir(self):
+        """
+        :return: True if this path is a directory
+        """
+        return self.exists(options=smb2.FILE_DIRECTORY_FILE)
+
+    def is_file(self):
+        """
+        :return: True if this path is a normal file
+        """
+        return self.exists(options=smb2.FILE_NON_DIRECTORY_FILE)
+
+    def is_symlink(self):
+        """
+        :return: True if this path is a reparse point
+        """
+        try:
+            with self._create(
+                access=smb2.FILE_READ_ATTRIBUTES,
+                disposition=smb2.FILE_OPEN,
+                _follow=False,
+            ) as handle:
+                pass
+        except model.ResponseError as re:
+            if re.response.status == ntstatus.STATUS_STOPPED_ON_SYMLINK:
+                return True
+            if re.response.status not in NOT_FOUND_STATUSES:
+                raise
+        return False
+
+    def is_mount(self):
+        """
+        Note: all PikePath instances are considered to be mounts
+
+        :return: True if this path is a mount point
+        """
+        return True  # all paths are on SMB mount
+
+    def is_socket(self):
+        """
+        Note: no PikePath instances are considered to be sockets
+
+        :return: True if this path is a socket
+        """
+        return False
+
+    def is_fifo(self):
+        """
+        Note: no PikePath instances are considered to be fifo
+
+        :return: True if this path is a pipe
+        """
+        return False  # might be supported some day
+
+    def is_block_device(self):
+        """
+        Note: no PikePath instances are considered to be block devices
+
+        :return: True if this path is a block device
+        """
+        return False
+
+    def is_char_device(self):
+        """
+        Note: no PikePath instances are considered to be char devices
+
+        :return: True if this path is a char device
+        """
+        return False
+
+    def glob(self, pattern):
+        """
+        Iterate over this subtree and yield all existing files matching pattern.
+
+        Files of any kind, including directories, that match the given relative
+        pattern will be yielded with the exception of special entries "." and ".."
+
+        The globbing is handled on the server side, so all ``pattern`` values
+        must be valid according to [MS-CIFS] 2.2.1.1.3 Wildcards:
+        https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-cifs/dc92d939-ec45-40c8-96e5-4c4091e4ab43
+
+        The recursive glob "**" is not supported.
+
+        :param pattern: file search pattern
+        :rtype: Iterator[:py:class:`~pike.path.PikePath`]
+        """
+        if "**" in pattern:
+            raise ValueError(
+                "recursive glob, '**', is not supported by {!r}".format(type(self))
+            )
+        with self._create(
+            access=smb2.GENERIC_READ,
+            disposition=smb2.FILE_OPEN,
+            options=smb2.FILE_DIRECTORY_FILE,
+        ) as handle:
+            for item in handle.enum_directory(
+                file_information_class=smb2.FILE_NAMES_INFORMATION, file_name=pattern
+            ):
+                if item.file_name in (".", ".."):
+                    continue
+                yield self / item.file_name
+
+    def iterdir(self):
+        """
+        Iterate over this subtree and yield all existing files.
+
+        The special entries "." and ".." will not be yielded.
+
+        :rtype: Iterator[:py:class:`~pike.path.PikePath`]
+        """
+
+        for item in self.glob("*"):
+            yield item
+
+    def link_to(self, target):
+        raise NotImplementedError("hardlinks are not supported over SMB")
+
+    def mkdir(self, mode=None, parents=False, exist_ok=False):
+        """
+        Create a new directory at this given path.
+
+        :param mode: The mode of the created file. NOT SUPPORTED
+        :param parents: if True, create all parent directories if they do not exist
+        :param exist_ok: if True, don't raise if the directory exists
+        """
+        if mode is not None:
+            warnings.warn("`mode` argument is not handled at this time", stacklevel=2)
+        try:
+            with self._create(
+                access=smb2.GENERIC_WRITE,
+                disposition=smb2.FILE_CREATE,
+                options=smb2.FILE_DIRECTORY_FILE,
+            ) as handle:
+                return
+        except model.ResponseError as re:
+            if re.response.status == ntstatus.STATUS_OBJECT_NAME_COLLISION and exist_ok:
+                return
+            if not parents or self.parent == self:
+                raise
+            self.parent.mkdir(parents=True, exist_ok=True)
+            self.mkdir(mode, parents=False, exist_ok=exist_ok)
+
+    def open(self, mode="r", buffering=-1, encoding=None, errors=None, newline=None):
+        """
+        Open the file pointed by this path and return a file object, as
+        the built-in open() function does.
+
+        Where possible, the semantics are designed to match a local open file.
+
+        Lease/oplock based buffering is not supported at this time. To avoid
+        reading stale data, or locally buffering writes, pass ``buffering=0``.
+        """
+        buffer_class = io.BufferedReader
+        access = 0
+        disposition = 0
+        mode = mode.lower()
+        if ("r" in mode, "w" in mode, "x" in mode, "a" in mode).count(True) > 1:
+            raise ValueError("must have exactly one of create/read/write/append mode")
+
+        # File Access bits
+        if "r" in mode or "+" in mode:
+            access |= smb2.GENERIC_READ
+        if "a" in mode or "x" in mode or "w" in mode or "+" in mode:
+            access |= smb2.GENERIC_WRITE
+            buffer_class = io.BufferedWriter
+        if "+" in mode:
+            buffer_class = io.BufferedRandom
+
+        # Create disposition bits
+        if "r" in mode:
+            disposition = smb2.FILE_OPEN
+        elif "x" in mode:
+            disposition = smb2.FILE_CREATE
+        elif "a" in mode:
+            disposition = smb2.FILE_OPEN_IF
+        elif "w" in mode:
+            disposition = smb2.FILE_SUPERSEDE
+
+        handle = self._create(
+            access=access,
+            disposition=disposition,
+            options=smb2.FILE_NON_DIRECTORY_FILE,
+        )
+        if "a" in mode:
+            handle.seek(0, io.SEEK_END)
+        if "b" in mode and buffering == 0:
+            return handle
+        if buffering == 0:
+            warnings.warn("Buffering cannot be disabled for text-mode streams")
+        buffer_size = buffering if buffering > 0 else smb2.BYTES_PER_CREDIT
+        buffered_io = buffer_class(handle, buffer_size=buffer_size)
+        if "b" in mode:
+            return buffered_io
+        return io.TextIOWrapper(
+            buffered_io, encoding=encoding, errors=errors, newline=newline
+        )
+
+    def read_bytes(self):
+        """
+        Return the binary contents of the pointed-to file as a bytes object.
+
+        :rtype: bytes
+        """
+        with self.open("rb") as f:
+            return f.read()
+
+    def read_text(self, encoding=None, errors=None):
+        """
+        Return the decoded contents of the pointed-to file as a string.
+
+        :rtype: str
+        """
+        with self.open("r") as f:
+            return f.read()
+
+    def readlink(self):
+        """
+        Return the :py:class:`~pike.path.PikePath` to which the symbolic link points.
+
+        :rtype: PikePath
+        """
+        with self._create(
+            access=smb2.FILE_READ_ATTRIBUTES,
+            disposition=smb2.FILE_OPEN,
+            options=smb2.FILE_NON_DIRECTORY_FILE | smb2.FILE_OPEN_REPARSE_POINT,
+        ) as handle:
+            # XXX: not making additional tree connects here, so absolute links
+            # will only work across the same share
+            return self._follow_link(
+                link=self,
+                reparse_data=handle.get_symlink()[0][0][0],
+            )
+
+    def rename(self, target, replace=True):
+        """
+        Rename the object at this path to ``target``.
+
+        :param target: new name
+        :param replace: if True, pass ``replace_if_exists`` in ``FileRenameInformation``
+        :rtype: PikePath
+        :return: path pointing to target
+        """
+        if not isinstance(target, type(self)):
+            target = self.join_from_root(target)
+        with self._create(
+            access=smb2.DELETE,
+            disposition=smb2.FILE_OPEN,
+        ) as handle:
+            with handle.set_file_info(smb2.FileRenameInformation) as file_info:
+                file_info.replace_if_exists = replace
+                file_info.file_name = target._path
+            return target
+
+    def replace(self, target):
+        """
+        Same as rename, but with replace=True
+        """
+        return self.rename(target, replace=True)
+
+    def resolve_relative(self):
+        """Remove "." and ".." components from the path."""
+        path_components = []
+        for pc in self.parts[1:]:
+            if pc == ".":
+                continue
+            if pc == "..":
+                path_components.pop()
+                continue
+            path_components.append(pc)
+        return self.join_from_root(type(self)(*path_components))
+
+    def resolve(self, strict=True):
+        """
+        Follow all symbolic links in the path and return the
+        :py:class:`~pike.path.PikePath` at the end of the chain.
+
+        Similar to ``readlink``, however this calls relies on handling the
+        SymbolicLinkErrorResponse during create, rather than explicitly
+        reading the link target via IOCTL_GET_REPARSE_POINT.
+        """
+        with self._create(
+            access=smb2.FILE_READ_ATTRIBUTES,
+            disposition=smb2.FILE_OPEN,
+        ) as handle:
+            return self.join_from_root(handle.create_request.name)
+
+    def rglob(self, pattern):
+        raise NotImplementedError("rglob is not supported by {!r}".format(type(self)))
+
+    def rmdir(self, missing_ok=False):
+        """
+        Remove the directory at this path.
+
+        The directory must be empty.
+
+        :param missing_ok: if True, don't raise if the directory doesn't exist
+        """
+        self.unlink(missing_ok=missing_ok, options=smb2.FILE_DIRECTORY_FILE)
+
+    def samefile(self, otherpath):
+        """
+        Determine if this path points to the same file as another path.
+
+        This uses ``FILE_INTERNAL_INFORMATION`` to perform the check. If the
+        server doesn't support this info class, NotImplementedError is raised.
+
+        If either path doesn't exist, return False
+
+        :type otherpath: PikePath
+        :param otherpath: the other path to compare to
+        :return: True if otherpath points to the same file
+        """
+        try:
+            my_id = self.stat(file_information_class=smb2.FILE_INTERNAL_INFORMATION)
+        except model.ResponseError as re:
+            if re.response.status in NOT_FOUND_STATUSES:
+                return False
+            raise
+        if my_id == 0:
+            raise NotImplementedError(
+                "remote filesystem does not return unique file index"
+            )
+        if not isinstance(otherpath, type(self)):
+            otherpath = self.join_from_root(otherpath)
+        try:
+            other_id = otherpath.stat(
+                file_information_class=smb2.FILE_INTERNAL_INFORMATION
+            )
+        except model.ResponseError as re:
+            if re.response.status in NOT_FOUND_STATUSES:
+                return False
+            raise
+        return my_id.index_number == other_id.index_number
+
+    def relative_root(self):
+        """
+        Return a PureWindowsPath which joined to this path would be the root.
+        """
+        path_components = []
+        for pc in self.parts[1:]:
+            if pc == "..":
+                path_components.pop()
+                continue
+            path_components.append("..")
+        return PureWindowsPath(*path_components)
+
+    def symlink_to(self, target, target_is_directory=False):
+        """
+        Make this path a symlink pointing to the given path.
+
+        Note the order of arguments (self, target) is the reverse of os.symlink's.
+
+        Does not work for absolute links (across servers).
+
+        :type target: PikePath
+        :param target: The link's target
+        :param target_is_directory: must be True if the target is a directory
+        """
+        if not isinstance(target, type(self)):
+            target = self.join_from_root(target)
+        try:
+            substitute_path = target.relative_to(self.parent)._path
+        except ValueError:
+            # no common subpath, so relpath to the share root
+            substitute_path = str(self.parent.relative_root() / target._path)
+
+        options = smb2.FILE_DIRECTORY_FILE if target_is_directory else 0
+        with self._create(
+            access=smb2.GENERIC_WRITE,
+            attributes=smb2.FILE_ATTRIBUTE_REPARSE_POINT,
+            disposition=smb2.FILE_SUPERSEDE,
+            options=options | smb2.FILE_OPEN_REPARSE_POINT,
+        ) as handle:
+            handle.set_symlink(substitute_path, flags=smb2.SYMLINK_FLAG_RELATIVE)
+
+    def touch(self, mode=None, exist_ok=True):
+        """
+        Create a new file or update modification time at this given path.
+
+        :param mode: The mode of the created file. NOT SUPPORTED
+        :param exist_ok: if True, don't raise if the file exists
+        """
+        if mode is not None:
+            warnings.warn("`mode` argument is not handled at this time", stacklevel=2)
+        disposition = smb2.FILE_OPEN_IF if exist_ok else smb2.FILE_CREATE
+        with self._create(
+            access=smb2.GENERIC_WRITE,
+            disposition=disposition,
+            options=smb2.FILE_NON_DIRECTORY_FILE,
+        ) as handle:
+            with handle.set_file_info(smb2.FileBasicInformation) as file_info:
+                file_info.change_time = nttime.NtTime(datetime.datetime.now())
+
+    def unlink(self, missing_ok=False, options=smb2.FILE_NON_DIRECTORY_FILE):
+        """
+        Remove the file at this path.
+
+        :param missing_ok: if True, don't raise if the file doesn't exist
+        :param options: options passed to CREATE when opening the file
+        """
+        try:
+            with self._create(
+                access=smb2.DELETE,
+                disposition=smb2.FILE_OPEN,
+                options=options | smb2.FILE_DELETE_ON_CLOSE,
+                _follow=False,
+            ) as handle:
+                pass
+        except model.ResponseError as re:
+            if re.response.status in NOT_FOUND_STATUSES and missing_ok:
+                return
+            elif re.response.status == ntstatus.STATUS_STOPPED_ON_SYMLINK:
+                return self.unlink(
+                    missing_ok=missing_ok,
+                    options=options | smb2.FILE_OPEN_REPARSE_POINT,
+                )
+            raise
+
+    def write_bytes(self, data):
+        """
+        Open the file pointed to in bytes mode, write data to it, and close the file.
+
+        :type data: bytes
+        :param data: bytes to write to the file
+        :rtype: int
+        :return: number of bytes written
+        """
+        with self.open("wb") as f:
+            return f.write(data)
+
+    def write_text(self, data, encoding=None, errors=None):
+        """
+        Open the file pointed to in text mode, write data to it, and close the file.
+
+        :type data: str
+        :param data: unicode string to write to the file
+        :rtype: int
+        :return: number of bytes written
+        """
+        with self.open("w") as f:
+            return f.write(data)
diff --git a/pike/smb2.py b/pike/smb2.py
index 96e21c74..146a3935 100644
--- a/pike/smb2.py
+++ b/pike/smb2.py
@@ -2378,7 +2378,15 @@ def _decode(self, cur):
             else:
                 with cur.bounded(cur, end):
                     while cur < end:
-                        info_cls(self).decode(cur)
+                        try:
+                            info_cls(self).decode(cur)
+                        except core.BufferOverrun:
+                            if len(self) <= 1:
+                                raise
+                            # remove the overflowed entry and exit loop
+                            self._entries.pop()
+                            break
+
         else:
             Information(self, end).decode(cur)
 
diff --git a/setup.py b/setup.py
index 7d0b9ca2..2530db11 100755
--- a/setup.py
+++ b/setup.py
@@ -118,6 +118,7 @@ def run_setup(with_extensions):
         install_requires=[
             'enum34~=1.1.6;  python_version ~= "2.7"',
             "attrs >= 19.3",
+            'pathlib2~=2.3.5;  python_version ~= "2.7"',
             "pycryptodome",
             "future",
             "six",
diff --git a/tox.ini b/tox.ini
index 890fd8df..2af013c2 100644
--- a/tox.ini
+++ b/tox.ini
@@ -23,6 +23,8 @@ deps =
 
 [pytest]
 testpaths = tests
+markers =
+    nosamba: test doesn't work on samba
 #addopts = -p no:warnings
 
 [flake8]


Test patch:
diff --git a/.github/workflows/test_samba.yml b/.github/workflows/test_samba.yml
index d6957993..b263a348 100644
--- a/.github/workflows/test_samba.yml
+++ b/.github/workflows/test_samba.yml
@@ -41,4 +41,4 @@ jobs:
         PIKE_PORT: ${{ job.services.samba.ports[445] }}
         PIKE_SHARE: pike
         PIKE_CREDS: pike%GiThubCI123
-      run: tox
+      run: tox -- -m 'not nosamba'
diff --git a/pike/pytest_support.py b/pike/pytest_support.py
index 90bb6c48..3a46f49a 100644
--- a/pike/pytest_support.py
+++ b/pike/pytest_support.py
@@ -3,8 +3,27 @@
 # All rights reserved.
 # See file LICENSE for licensing information.
 #
+"""
+Pike/pytest integration
 
-"""Pike/pytest integration."""
+Available markers:
+
+    - ``require_dialect(min_dialect, max_dialect)``: raise a
+      :py:class:`~pike.test.DialectMissing` exception if the target server does
+      not support a dialect in the given range. This exception will be raised
+      after Negotiate.
+    - ``require_capability(global_capabilities)``: raise a
+      :py:class:`~pike.test.CapabilityMissing` exception if the target server
+      does not support all of the OR'd global capability flags.  This exception
+      will be raised after Negotiate.
+    - ``require_share_capability(share_capabilities)``: raise a
+      :py:class:`~pike.test.ShareCapabilityMissing` exception if the target
+      share does not support all of the OR'd share capability flags.  This
+      exception will be raised after Tree Connect.
+"""
+
+import uuid
+import time
 
 import pytest
 
@@ -56,6 +75,20 @@ def get_mark_value(item, mark_name, default):
 
 @pytest.fixture
 def pike_TreeConnect(request):
+    """
+    Function-scope fixture returns a wrapper function around
+    :py:class:`pike.TreeConnect`.
+
+    The wrapper-function must be called to establish the connection and the
+    returned object may be used as a contextmanager.
+
+    The wrapper-function will pass through any ``require_dialect``,
+    ``require_capabilities``, and ``require_share_capabilities`` marks on the
+    test item and raise an exception if the target server doesn't support the
+    required values.
+
+    :return: Callable[..., pike.TreeConnect]
+    """
     marks = (
         ("require_dialect", (0, float("inf"))),
         ("require_capabilities", 0),
@@ -74,4 +107,27 @@ def TreeConnect(*args, **kwargs):
             kwargs["require_dialect"] = (kwargs["require_dialect"], float("inf"))
         return pike.test.TreeConnect(*args, **kwargs)
 
+    # carry the docstring to the wrapper function
+    TreeConnect.__doc__ = pike.TreeConnect.__doc__
+
     return TreeConnect
+
+
+@pytest.fixture
+def pike_tmp_path(pike_TreeConnect):
+    """
+    Function-scope fixture reaturns a :py:class:`~pike.path.PikePath` rooted at
+    a timestamped subdirectory of the share.
+
+    The temporary directory is not deleted during tearDown and should be
+    removed by the test case itself.
+
+    A function's :py:func:`pike_TreeConnect` fixture will access the same
+    share, however, via a separate SMB2 session.
+    """
+    with pike_TreeConnect() as tc:
+        test_root = tc / "pike_{}_{}".format(
+            time.strftime("%Y-%m-%d_%H%M%S"), str(uuid.uuid4())[:5]
+        )
+        test_root.mkdir()
+        yield test_root
diff --git a/pike/test/__init__.py b/pike/test/__init__.py
index 806d1b96..c68f4fbf 100644
--- a/pike/test/__init__.py
+++ b/pike/test/__init__.py
@@ -64,16 +64,46 @@ class ShareCapabilityMissing(TestRequirementNotMet):
 
 
 class Options(enum.Enum):
+    """
+    Provide default Client and Test options sourced from the following
+    Environment Variables.
+    """
+
     PIKE_LOGLEVEL = "PIKE_LOGLEVEL"
+    """Set the console log level by name (``DEBUG``, ``INFO``, etc)"""
     PIKE_TRACE = "PIKE_TRACE"
+    """If 'yes' log full request/response payloads at DEBUG level"""
     PIKE_SERVER = "PIKE_SERVER"
+    """SMB host name or IP address"""
     PIKE_PORT = "PIKE_PORT"
+    """SMB port (default: {})""".format(model.default_port)
     PIKE_CREDS = "PIKE_CREDS"
+    """
+    Percent-delimited server credentials for NTLM authentication:
+    ``DOMAIN\\User%Passwd``.
+
+    If credentials are not specified and kerberos is available, attempt to use
+    kerberos authentication.
+    """
     PIKE_SHARE = "PIKE_SHARE"
+    """Share name to connect to"""
     PIKE_SIGN = "PIKE_SIGN"
+    """If 'yes' require packets to be signed"""
     PIKE_ENCRYPT = "PIKE_ENCRYPT"
+    """If 'yes' require packets to be encrypted (SMB3-only)"""
     PIKE_MIN_DIALECT = "PIKE_MIN_DIALECT"
+    """
+    Minimum dialect to negotiate (``DIALECT_SMB2_1``, ``DIALECT_SMB3_0``, etc).
+
+    See :py:class:`pike.smb2.Dialect`
+    """
     PIKE_MAX_DIALECT = "PIKE_MAX_DIALECT"
+    """
+    Maximum dialect to negotiate (``DIALECT_SMB3_0``, ``DIALECT_SMB3_1_1``,
+    etc).
+
+    See :py:class:`pike.smb2.Dialect`
+    """
 
     @classmethod
     def option(cls, name, default=None):
@@ -91,7 +121,15 @@ def option(cls, name, default=None):
 
     @classmethod
     def booloption(cls, name, default="no"):
-        table = {"yes": True, "true": True, "no": False, "false": False, "": False}
+        table = {
+            "yes": True,
+            "true": True,
+            "1": True,
+            "no": False,
+            "false": False,
+            "": False,
+            "0": False,
+        }
         return table[cls.option(name, default=default).lower()]
 
     @classmethod
@@ -112,7 +150,7 @@ def server(cls):
 
     @classmethod
     def port(cls):
-        return int(cls.option(cls.PIKE_PORT, default="445"))
+        return int(cls.option(cls.PIKE_PORT, default=model.default_port))
 
     @classmethod
     def creds(cls):
@@ -156,7 +194,36 @@ def default_client(signing=None):
 @attr.s
 class TreeConnect(object):
     """
-    Combines a Client, Connection, Channel, and Tree for simple access to an SMB share.
+    Combines a :py:class:`~pike.model.Client`,
+    :py:class:`~pike.model.Connection`, :py:class:`~pike.model.Channel`, and
+    :py:class:`~pike.model.Tree` for simple access to an SMB share.
+
+    May be used as a contextmanager: entering the context will establish the
+    connection, session, and tree, and exiting the context will cleanly
+    disconnect (if possible). Errors on disconnect are ignored.
+
+    Any values not specified in the constructor will be drawn from the
+    associated environment variables. At minimum the following should be
+    specified explicitly or in the environment:
+
+      * ``server`` (``PIKE_SERVER``) - server or hostname to connect to
+      * ``creds`` (``PIKE_CREDS``) - percent-delimited credential string
+      * ``share`` (``PIKE_SHARE``) - share name to connect to
+
+    All other arguments are optional.
+
+    The ``TreeConnect`` object can be used with the division operator, ``/``,
+    which returns a :py:class:`~pike.path.PikePath` instance representing the
+    path extended from the share root. The tree connection will be established
+    before returning the :py:class:`~pike.path.PikePath` if is is not already
+    connected.
+
+    :vartype conn: pike.model.Connection
+    :ivar conn: the Connection object, or None if not connected
+    :vartype chan: pike.model.Channel
+    :ivar chan: the Channel object, or None if no session is established
+    :vartype tree: pike.model.Tree
+    :ivar tree: the Tree object, or None if no tree is connected
     """
 
     _client = attr.ib(default=None)
@@ -369,6 +436,26 @@ def __getitem__(self, item):
         """
         return tuple(self)[item]
 
+    # proxy Path-like interface onto the tree
+    @property
+    def __fspath__(self):
+        """
+        proxy to Tree.__fspath__, establishing the session and tree if needed.
+        """
+        tree = self.tree or self().tree
+        return tree.__fspath__
+
+    @property
+    def __truediv__(self):
+        """
+        proxy to Tree.__truediv__, establishing the session and tree if needed.
+        """
+        tree = self.tree or self().tree
+        return tree.__truediv__
+
+    if sys.version_info < (3,):
+        __div__ = __truediv__
+
 
 class PikeTest(unittest.TestCase):
     init_done = False
diff --git a/pike/test/invalid_session.py b/pike/test/invalid_session.py
index ab8fb5a7..8b89ab57 100644
--- a/pike/test/invalid_session.py
+++ b/pike/test/invalid_session.py
@@ -30,10 +30,13 @@ def test_logoff(self):
         # Set invalid session id
         req.session_id = 0xFFFFFFFFFFFFFFFF
 
-        with self.assert_error(nt.STATUS_USER_SESSION_DELETED):
-            conn.transceive(req.parent)[0]
+        try:
+            with self.assert_error(nt.STATUS_USER_SESSION_DELETED):
+                conn.transceive(req.parent)[0]
 
-        conn.close()
+            conn.close()
+        except EOFError:
+            pass  # sometimes server might close the connection
 
     def open_file(self, filename):
         self.chan, self.tree = self.tree_connect()
diff --git a/tests/test_path.py b/tests/test_path.py
new file mode 100644
index 00000000..9fe20917
--- /dev/null
+++ b/tests/test_path.py
@@ -0,0 +1,319 @@
+"""
+Tests for pike path-like interface
+"""
+from __future__ import absolute_import, unicode_literals
+from builtins import str
+
+import io
+import os
+import uuid
+
+import pytest
+
+import pike.io
+import pike.model as model
+import pike.ntstatus as ntstatus
+from pike.path import PikePath
+import pike.smb2 as smb2
+
+
+ACCESS_RWD = smb2.GENERIC_READ | smb2.GENERIC_WRITE | smb2.DELETE
+
+
+def test_stat_root(pike_tmp_path):
+    print(pike_tmp_path.stat())
+
+
+def test_exists(pike_tmp_path):
+    unique_id = str(uuid.uuid4())
+    fname = pike_tmp_path / unique_id
+    assert not fname.exists()
+    with pike_tmp_path._channel.create(
+        pike_tmp_path._tree,
+        fname._path,
+        access=ACCESS_RWD,
+        options=smb2.FILE_DELETE_ON_CLOSE,
+    ).result() as fh:
+        assert fname.exists()
+    assert not fname.exists()
+
+
+def test_is_file(pike_tmp_path):
+    unique_id = str(uuid.uuid4())
+    fname = pike_tmp_path / unique_id
+    assert not fname.exists()
+    assert not fname.is_file()
+    assert not fname.is_symlink()
+    fname.touch()
+    assert fname.exists()
+    assert fname.is_file()
+    assert not fname.is_dir()
+    assert not fname.is_symlink()
+    fname.unlink()
+
+
+def test_is_dir(pike_tmp_path):
+    unique_id = str(uuid.uuid4())
+    dname = pike_tmp_path / unique_id
+    assert not dname.exists()
+    assert not dname.is_dir()
+    assert not dname.is_symlink()
+    dname.mkdir()
+    assert dname.exists()
+    assert dname.is_dir()
+    assert not dname.is_file()
+    assert not dname.is_symlink()
+    dname.rmdir()
+
+
+def test_glob(pike_tmp_path):
+    a_files = ["a1", "a2", "a3"]
+    b_files = ["b1", "b2", "b3"]
+    for f in a_files + b_files:
+        (pike_tmp_path / f).touch()
+
+    a_glob = list(pike_tmp_path.glob("a*"))
+    assert len(a_glob) == len(a_files)
+    for f in a_glob:
+        assert f.name in a_files
+
+    b_glob = list(pike_tmp_path.glob("b*"))
+    assert len(b_glob) == len(b_files)
+    for f in b_glob:
+        assert f.name in b_files
+
+    glob2 = list(pike_tmp_path.glob("*2"))
+    assert len(glob2) == 2
+    for f in glob2:
+        assert f.name in ("a2", "b2")
+
+
+def test_glob_invalid(pike_tmp_path):
+    with pytest.raises(ValueError):
+        list(pike_tmp_path.glob("**/foo"))
+
+    with pytest.raises(NotImplementedError):
+        pike_tmp_path.rglob("foo")
+
+
+def test_iterdir(pike_tmp_path):
+    filedata = {name: str(uuid.uuid4()) for name in ["f1", "f2", "f3", "f4"]}
+    for f, data in filedata.items():
+        (pike_tmp_path / f).write_text(data)
+    for fpth in pike_tmp_path.iterdir():
+        assert fpth.name in filedata
+        assert fpth.read_text() == filedata[fpth.name]
+
+
+def test_mkdir(pike_tmp_path):
+    pth = pike_tmp_path / "foo" / "bar" / "baz"
+    pth.mkdir(parents=True, exist_ok=False)
+    pth.mkdir(exist_ok=True)
+    while pth != pike_tmp_path:
+        pth.rmdir()
+        pth = pth.parent
+
+
+def test_write_read(pike_tmp_path):
+    test_bytes = b"hola pika" * (1024 * 1024)
+    pth = pike_tmp_path / "bar.txt"
+    pth.write_bytes(test_bytes)
+    assert pth.read_bytes() == test_bytes
+
+
+def test_open_w_r_a_x(pike_tmp_path):
+    buf = "this is a test test test\nfoo\n\n"
+    pth = pike_tmp_path / "foo.txt"
+    with pth.open("w") as f:
+        assert isinstance(f.buffer, io.BufferedWriter)
+        assert f.writable()
+        assert not f.readable()
+        assert f.buffer.raw.create_request.create_disposition == smb2.FILE_SUPERSEDE
+        f.write(buf)
+    assert pth.read_bytes() == buf.encode("utf-8")
+    with pth.open("r") as f:
+        assert isinstance(f.buffer, io.BufferedReader)
+        assert f.readable()
+        assert not f.writable()
+        assert f.buffer.raw.create_request.create_disposition == smb2.FILE_OPEN
+        assert f.read() == buf
+    with pth.open("a") as f:
+        assert isinstance(f.buffer, io.BufferedWriter)
+        assert f.writable()
+        assert not f.readable()
+        assert f.buffer.raw.create_request.create_disposition == smb2.FILE_OPEN_IF
+        assert f.tell() == f.buffer.raw.end_of_file
+        f.write("appended")
+    assert pth.read_text().endswith("appended")
+    with model.pike_status(ntstatus.STATUS_OBJECT_NAME_COLLISION):
+        f = pth.open("x")
+    pth.unlink()
+    with pth.open("x") as f:
+        f.write("exclusive access")
+    assert pth.read_text() == "exclusive access"
+
+
+@pytest.mark.parametrize("primary_mode", ("r", "w", "a", "rb", "wb", "ab"))
+@pytest.mark.parametrize(
+    "buffering", (pytest.param(-1, id="buffered"), pytest.param(0, id="raw"))
+)
+def test_open_plus(pike_tmp_path, primary_mode, buffering):
+    buf = "this is a read/write test case\n"
+    pth = pike_tmp_path / "foo.txt"
+    if "r" in primary_mode:
+        pth.touch()
+    with pth.open(primary_mode + "+", buffering=buffering) as f:
+        if "b" in primary_mode:
+            buf = buf.encode("utf-8")
+        if buffering == 0 and "b" in primary_mode:
+            assert isinstance(f, pike.io.Open)
+        elif "b" in primary_mode:
+            assert isinstance(f, io.BufferedRandom)
+        else:
+            assert isinstance(f, io.TextIOWrapper)
+            assert isinstance(f.buffer, io.BufferedRandom)
+        assert f.readable()
+        assert f.writable()
+        f.write(buf)
+        f.seek(0)
+        assert f.read() == buf
+
+
+def test_relative_to(pike_tmp_path):
+    pth = pike_tmp_path / "foo"
+    assert str(pth.relative_to(pike_tmp_path)) == "foo"
+
+
+def test_rename(pike_tmp_path):
+    orig_file = pike_tmp_path / "orig"
+    new_file = pike_tmp_path / "new"
+
+    orig_file.touch()
+    assert orig_file.is_file()
+    assert not new_file.exists()
+
+    orig_file.rename(new_file)
+    assert not orig_file.exists()
+    assert new_file.is_file()
+
+
+def test_parents(pike_tmp_path):
+    pcs = ["foo", "bar", "baz"]
+    pth = pike_tmp_path.joinpath(*pcs)
+    parents = pth.parents
+    for ix, pc in enumerate(reversed(pcs[:-1])):
+        print(parents[ix])
+        print(parents[ix].exists())
+        assert parents[ix].name == pc
+
+
+def test_join_from_root(pike_tmp_path, pike_TreeConnect):
+    with pike_TreeConnect() as tc2:
+        pth = pike_tmp_path / "rehome.txt"
+        with pth.open("w") as f:
+            f.write("foo the bar\n")
+            # now attempt to open the file from the second connection
+            with model.pike_status(ntstatus.STATUS_SHARING_VIOLATION):
+                from_root = PikePath(tc2.tree).join_from_root(pth)
+                print(from_root.read_text())
+
+
+@pytest.mark.nosamba
+def test_symlink(pike_tmp_path):
+    buf = "this is my content\n"
+    subdir = pike_tmp_path / "subdir"
+    subdir.mkdir()
+    target = subdir / "target"
+    target.write_text(buf)
+    link = pike_tmp_path / "link"
+    link.symlink_to(target)
+    assert link.read_text() == buf
+    assert link.resolve() == target
+    assert link.readlink()._path == target._path
+    link.unlink()
+    assert not link.exists()
+    assert target.exists()
+
+
+@pytest.mark.nosamba
+def test_symlink_dir(pike_tmp_path):
+    buf = "this is my content\n"
+    subdir = pike_tmp_path / "subdir"
+    subdir.mkdir()
+    target = subdir / "target"
+    target.write_text(buf)
+    link = pike_tmp_path / "link"
+    link.symlink_to(subdir)
+    assert (link / target.name).read_text() == buf
+    assert (link / target.name).resolve() == target
+    assert link.resolve() == subdir
+    assert link.readlink()._path == subdir._path
+
+
+@pytest.mark.nosamba
+def test_symlink_relative(pike_tmp_path):
+    buf = "this is my content\n"
+    subdir = pike_tmp_path / "subdir"
+    subdir.mkdir()
+    subdir_a2 = pike_tmp_path / "subdir2" / "a1" / "a2"
+    subdir_a2.mkdir(parents=True)
+    target = subdir / "target"
+    target.write_text(buf)
+    link = subdir_a2 / "link"
+    link.symlink_to(target)
+    assert link.read_text() == buf
+    assert link.resolve() == target
+    assert (
+        link.readlink()._path == link.parent._path + "\\..\\..\\..\\..\\" + target._path
+    )
+
+
+@pytest.mark.nosamba
+def test_symlink_chain(pike_tmp_path):
+    buf = "this is my content\n"
+    subdir = pike_tmp_path / "subdir"
+    subdir.mkdir()
+    subdir_a1 = pike_tmp_path / "subdir2" / "a1"
+    subdir_a1.mkdir(parents=True)
+    subdir_a2 = subdir_a1 / "a2"
+    subdir_a2.mkdir()
+    subdir_a3 = subdir_a2 / "a3"
+    subdir_a3.mkdir()
+    target = subdir / "target"
+    target.write_text(buf)
+    link = subdir_a3 / "link"
+    link.symlink_to(subdir)
+    link2 = subdir_a1 / "link2"
+    link2.symlink_to(link)
+    link = subdir_a2 / "link3"
+    link.symlink_to(link2)
+    link2 = subdir_a3 / "link4"
+    link2.symlink_to(link)
+    link = subdir_a1 / "link5"
+    link.symlink_to(link2)
+    assert (link / target.name).read_text() == buf
+    assert (link / target.name).resolve() == target
+
+
+def test_samefile(pike_tmp_path, pike_TreeConnect):
+    f1 = pike_tmp_path / "f1"
+    with pike_TreeConnect() as tc2:
+        f1_2 = PikePath(tc2.tree).join_from_root(f1)
+        assert f1_2.drive != f1.drive
+        # when the file doesn't exist, it is not considered samefile
+        assert not f1.samefile(f1_2)
+        f1.touch()
+        assert f1.samefile(f1_2)
+        assert f1_2.samefile(f1)
+
+
+def test_unlink(pike_tmp_path):
+    fname = pike_tmp_path / str(uuid.uuid4())
+    with model.pike_status(ntstatus.STATUS_OBJECT_NAME_NOT_FOUND):
+        fname.unlink()
+    fname.unlink(missing_ok=True)
+    fname.touch()
+    assert fname.is_file()
+    fname.unlink()
+    assert not fname.exists()
+    fname.unlink(missing_ok=True)