Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 16 additions & 10 deletions src/smbclient/_os.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand All @@ -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],
)
Expand All @@ -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
15 changes: 12 additions & 3 deletions src/smbprotocol/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()]
Expand All @@ -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)
Expand Down
34 changes: 33 additions & 1 deletion tests/test_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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