On asyncssh 2.24.0, when connecting to a Windows OpenSSH server and reading a file larger than 100 KB, SFTPClientFile.read() silently truncates data to 100 KB when used in the following pattern:
async with sftp.open(path, "rb") as f:
data = await f.read()
Specifying an explicit block size fixes the truncation:
async with sftp.open(p, "rb", block_size=65536) as f:
data = await f.read()
Possible Root Cause
In SFTPClientFile.read():
if self.read_len and size > min(self.read_len, max_read_len):
data = await _SFTPFileReader(...).run() # re-requests short reads
else:
data, _ = await self._handler.read(handle, offset, size) # single request
self._offset = offset + len(data) # len(data) never checked vs size
The else branch issues one FXP_READ and accepts whatever length comes back. The protocol permits a server to return fewer bytes than requested (a short read ≠ EOF), so a client must loop. _SFTPFileReader does (if count and count < size: re-request); the single-request branch does not.
With the default block_size=-1, read_len = max_read_len, so the branch condition reduces to size > max_read_len. Every file smaller than max_read_len therefore takes the non-looping path — i.e. the common case is the unsafe one.
Repro script: repro.py
Disclaimer: Claude Opus 4.8 did the root cause analysis and created the repro script. I am not 100% certain this is the root cause, but it seems valid.
On asyncssh 2.24.0, when connecting to a Windows OpenSSH server and reading a file larger than 100 KB,
SFTPClientFile.read()silently truncates data to 100 KB when used in the following pattern:Specifying an explicit block size fixes the truncation:
Possible Root Cause
In
SFTPClientFile.read():The
elsebranch issues oneFXP_READand accepts whatever length comes back. The protocol permits a server to return fewer bytes than requested (a short read ≠ EOF), so a client must loop._SFTPFileReaderdoes (if count and count < size: re-request); the single-request branch does not.With the default
block_size=-1,read_len = max_read_len, so the branch condition reduces tosize > max_read_len. Every file smaller thanmax_read_lentherefore takes the non-looping path — i.e. the common case is the unsafe one.Repro script: repro.py
Disclaimer: Claude Opus 4.8 did the root cause analysis and created the repro script. I am not 100% certain this is the root cause, but it seems valid.