diff --git a/CHANGELOG.md b/CHANGELOG.md index 507c39c..78837aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ * Drop support for Python 3.8, minimum version is now 3.9 * Exposed `auth_protocol` and `require_signing` to the various `smbclient` kwargs. This aligns the kwargs with the ones that can be provided to `register_session` directly +* Optimize `SMBDirEntry.is_symlink()`, returned by `smbclient.scandir()` to no longer require any extra SMB calls, this object is returned by APIs such as ` +* Raise exception when receiving an SMB `STATUS_STOPPED_ON_SYMLINK` response that contains no reparse buffer data + * Some SMB servers like macOS do not return this information ## 1.15.0 - 2024-11-12 diff --git a/src/smbclient/_os.py b/src/smbclient/_os.py index 44c9676..f85ed41 100644 --- a/src/smbclient/_os.py +++ b/src/smbclient/_os.py @@ -1473,17 +1473,14 @@ def is_symlink(self): The result is cached on the 'smcblient.DirEntry' object. Call 'smcblient.path.islink()' to fetch up-to-date information. - On the first, uncached call, only files or directories that are reparse points requires another SMB call. The - result is cached for subsequent calls. - :return: Whether the path is a symbolic link. """ if self._dir_info.file_attributes & FileAttributes.FILE_ATTRIBUTE_REPARSE_POINT: - # While a symlink is a reparse point, all reparse points aren't symlinks. We need to get the reparse tag - # to use as our check. Unlike WIN32_FILE_DATA scanned locally, we don't get the reparse tag in the original - # query result. We need to do a separate stat call to get this information. - lstat = self.stat(follow_symlinks=False) - return lstat.st_reparse_tag == ReparseTags.IO_REPARSE_TAG_SYMLINK + # While a symlink is a reparse point, all reparse points aren't symlinks. Th + # FileIdFullDirectoryInformation uses the ea_size field to store the reparse tag + # if the file is a reparse point. + reparse_tag = self._dir_info.ea_size + return reparse_tag == ReparseTags.IO_REPARSE_TAG_SYMLINK else: return False @@ -1519,6 +1516,12 @@ def stat(self, follow_symlinks=True): def from_path(cls, path, follow_symlinks=True, **kwargs): file_stat = stat(path, follow_symlinks=follow_symlinks, **kwargs) + ea_size = 0 + if file_stat.st_file_attributes & FileAttributes.FILE_ATTRIBUTE_REPARSE_POINT: + # The dir entry info expects the ea_size field to contain the + # reparse tag for reparse points. + ea_size = file_stat.st_reparse_tag + # This is only used in shutil copytree so just recreate the dir info # from the stat result as best as we can. dir_info = SMBDirEntryInformation( @@ -1529,7 +1532,7 @@ def from_path(cls, path, follow_symlinks=True, **kwargs): end_of_file=file_stat.st_size, allocation_size=file_stat.st_size, # Not part of the normal stat data file_attributes=file_stat.st_file_attributes, - ea_size=0, # Not part of the standard stat data + ea_size=ea_size, file_id=file_stat.st_ino, file_name=path.split("\\")[-1], ) @@ -1542,6 +1545,9 @@ def _link_target_type_check(self, check): try: return check(self.stat(follow_symlinks=True).st_mode) except OSError as err: - if err.errno == errno.ENOENT: # Missing target, broken symlink just return False + # ENOENT == Missing target, broken symlink + # STATUS_STOPPED_ON_SYMLINK == Server does not return symlink target info + # In both cases we just treat the link as not being of the target type. + if err.errno == errno.ENOENT or err.ntstatus == NtStatus.STATUS_STOPPED_ON_SYMLINK: return False raise diff --git a/src/smbprotocol/connection.py b/src/smbprotocol/connection.py index b1c06e0..e88c72d 100644 --- a/src/smbprotocol/connection.py +++ b/src/smbprotocol/connection.py @@ -1044,6 +1044,18 @@ def receive(self, request, wait=True, timeout=None, resolve_symlinks=True): related_requests = [self.outstanding_requests[i] for i in request.related_ids] [r.response_event.wait() for r in related_requests] + # Check if the response has the reparse buffer present. + exp = SMBResponseException(response) + reparse_buffer = next( + iter(e for e in exp.error_details if isinstance(e, SMB2SymbolicLinkErrorResponse)), None + ) + if reparse_buffer is None: + # Some SMB server don't return a buffer, only thing we + # can do is raise the exception. + message_id = request.message["message_id"].get_value() + self.outstanding_requests.pop(message_id, None) + raise exp + # Now create a new request with the new path the symlink points to. session = self.session_table[request.session_id] tree = session.tree_connect_table[request.message["tree_id"].get_value()] @@ -1053,9 +1065,6 @@ def receive(self, request, wait=True, timeout=None, resolve_symlinks=True): original_path = tree_share_name + to_text( old_create["buffer_path"].get_value(), encoding="utf-16-le" ) - - exp = SMBResponseException(response) - reparse_buffer = next(e for e in exp.error_details if isinstance(e, SMB2SymbolicLinkErrorResponse)) new_path = reparse_buffer.resolve_path(original_path)[len(tree_share_name) :] new_open = Open(tree, new_path) diff --git a/tests/test_connection.py b/tests/test_connection.py index 08bd6dd..9cc442a 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -29,10 +29,17 @@ SMB2TransformHeader, SMB3NegotiateRequest, ) -from smbprotocol.exceptions import SMBConnectionClosed, SMBException +from smbprotocol.exceptions import ( + NtStatus, + SMB2ErrorResponse, + SMBConnectionClosed, + SMBException, + StoppedOnSymlink, +) from smbprotocol.header import Smb2Flags, SMB2HeaderResponse from smbprotocol.ioctl import SMB2IOCTLRequest from smbprotocol.session import Session +from smbprotocol.tree import TreeConnect @pytest.fixture @@ -1334,3 +1341,28 @@ def test_disconnect_already_disconnected(self, connected_session): connection.disconnect() connection.disconnect() + + def test_stopped_on_symlink_no_buffer(self, connected_session, monkeypatch): + connection, session = connected_session + + with monkeypatch.context() as m: + m.setattr(connection.transport, "send", lambda d: None) + request = connection.send(SMB2Echo(), sid=session.session_id) + + assert len(connection.outstanding_requests) == 1 + + error_resp = SMB2ErrorResponse() + error_resp["error_data"] = [] + + response_header = SMB2HeaderResponse() + response_header["message_id"] = request.message["message_id"].get_value() + response_header["status"] = NtStatus.STATUS_STOPPED_ON_SYMLINK + response_header["data"] = error_resp.pack() + + request.response = response_header + request.response_event.set() + + with pytest.raises(StoppedOnSymlink): + connection.receive(request) + + assert len(connection.outstanding_requests) == 0