From 2d2dbf2a1bfafb67e176586c582634f522890cf1 Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Wed, 4 Feb 2026 11:59:00 +1000 Subject: [PATCH] Fix no symlink buffer on symlink open Fixes the response handler to raise the `STATUS_STOPPED_ON_SYMLINK` when trying to open a symlink but the server does not return the reparse buffer. This behaviour has been seen on server implementations like macOS. The SMBDirEntry symlink type tests also treat this as having no valid target. --- CHANGELOG.md | 3 +++ src/smbclient/_os.py | 26 ++++++++++++++++---------- src/smbprotocol/connection.py | 15 ++++++++++++--- tests/test_connection.py | 34 +++++++++++++++++++++++++++++++++- 4 files changed, 64 insertions(+), 14 deletions(-) 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