Files
Rob Hague fa98d59384 Use the read buffer in UploadFile for the SFTP write packets (#1798)
* Use the read buffer in UploadFile for the SFTP write packets

In SftpClient.UploadFile, a buffer is allocated to read from the given stream, and for
each read, another array is allocated for the SFTP write packet (which consists of that
data prepended with headers). This change effectively leaves space at the start of the
buffer for the headers such that it can be used to assemble the packets without that
per-packet array allocation.

There are cleaner/more general ways to do this (e.g. for all packet types, leave space
for the SSH headers as well), but this gets the most impact for about as much effort as
I can be bothered with.

* Rent from pool
2026-06-26 18:20:53 +02:00

348 lines
14 KiB
C#

using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using Renci.SshNet.Common;
using Renci.SshNet.Sftp;
namespace Renci.SshNet.Tests.Classes.Sftp
{
[TestClass]
public class SftpFileStreamTest
{
[TestMethod]
[DataRow(false)]
[DataRow(true)]
public async Task BadFileMode_ThrowsArgumentOutOfRangeException(bool isAsync)
{
ArgumentOutOfRangeException ex;
if (isAsync)
{
ex = await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
SftpFileStream.OpenAsync(new Mock<ISftpSession>().Object, "file.txt", mode: 0, FileAccess.Read, bufferSize: 1024, CancellationToken.None));
}
else
{
ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
SftpFileStream.Open(new Mock<ISftpSession>().Object, "file.txt", mode: 0, FileAccess.Read, bufferSize: 1024));
}
Assert.AreEqual("mode", ex.ParamName);
}
[TestMethod]
[DataRow(false)]
[DataRow(true)]
public async Task BadFileAccess_ThrowsArgumentOutOfRangeException(bool isAsync)
{
ArgumentOutOfRangeException ex;
if (isAsync)
{
ex = await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
SftpFileStream.OpenAsync(new Mock<ISftpSession>().Object, "file.txt", FileMode.Open, access: 0, bufferSize: 1024, CancellationToken.None));
}
else
{
ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
SftpFileStream.Open(new Mock<ISftpSession>().Object, "file.txt", FileMode.Open, access: 0, bufferSize: 1024));
}
Assert.AreEqual("access", ex.ParamName);
}
[TestMethod]
[DataRow(FileMode.Append, FileAccess.Read, false)]
[DataRow(FileMode.Append, FileAccess.Read, true)]
[DataRow(FileMode.Append, FileAccess.ReadWrite, false)]
[DataRow(FileMode.Append, FileAccess.ReadWrite, true)]
[DataRow(FileMode.Create, FileAccess.Read, false)]
[DataRow(FileMode.Create, FileAccess.Read, true)]
[DataRow(FileMode.CreateNew, FileAccess.Read, false)]
[DataRow(FileMode.CreateNew, FileAccess.Read, true)]
[DataRow(FileMode.Truncate, FileAccess.Read, false)]
[DataRow(FileMode.Truncate, FileAccess.Read, true)]
public async Task InvalidModeAccessCombination_ThrowsArgumentException(FileMode mode, FileAccess access, bool isAsync)
{
ArgumentException ex;
if (isAsync)
{
ex = await Assert.ThrowsAsync<ArgumentException>(() =>
SftpFileStream.OpenAsync(new Mock<ISftpSession>().Object, "file.txt", mode, access, bufferSize: 1024, CancellationToken.None));
}
else
{
ex = Assert.Throws<ArgumentException>(() =>
SftpFileStream.Open(new Mock<ISftpSession>().Object, "file.txt", mode, access, bufferSize: 1024));
}
Assert.AreEqual("access", ex.ParamName);
}
[TestMethod]
public void ReadWithWriteAccess_ThrowsNotSupportedException()
{
var sessionMock = new Mock<ISftpSession>();
sessionMock.Setup(s => s.CalculateOptimalWriteLength(It.IsAny<uint>(), It.IsAny<byte[]>())).Returns<uint, byte[]>((x, _) => x);
sessionMock.Setup(s => s.IsOpen).Returns(true);
SetupRemoteSize(sessionMock, 128);
var s = SftpFileStream.Open(sessionMock.Object, "file.txt", FileMode.Create, FileAccess.Write, bufferSize: 1024);
Assert.IsFalse(s.CanRead);
Assert.Throws<NotSupportedException>(() => _ = s.Read(new byte[4], 0, 4));
Assert.Throws<NotSupportedException>(() => _ = s.ReadByte());
Assert.Throws<NotSupportedException>(() => _ = s.ReadAsync(new byte[4], 0, 4).GetAwaiter().GetResult());
Assert.Throws<NotSupportedException>(() => _ = s.EndRead(s.BeginRead(new byte[4], 0, 4, null, null)));
#if NET
Assert.Throws<NotSupportedException>(() => _ = s.Read(new byte[4]));
Assert.Throws<NotSupportedException>(() => _ = s.ReadAsync(new byte[4]).AsTask().GetAwaiter().GetResult());
#endif
Assert.Throws<NotSupportedException>(() => s.CopyTo(Stream.Null));
Assert.Throws<NotSupportedException>(() => s.CopyToAsync(Stream.Null).GetAwaiter().GetResult());
}
[TestMethod]
public void WriteWithReadAccess_ThrowsNotSupportedException()
{
var sessionMock = new Mock<ISftpSession>();
sessionMock.Setup(s => s.CalculateOptimalWriteLength(It.IsAny<uint>(), It.IsAny<byte[]>())).Returns<uint, byte[]>((x, _) => x);
sessionMock.Setup(s => s.IsOpen).Returns(true);
var s = SftpFileStream.Open(sessionMock.Object, "file.txt", FileMode.Open, FileAccess.Read, bufferSize: 1024);
Assert.IsFalse(s.CanWrite);
Assert.Throws<NotSupportedException>(() => s.Write(new byte[4], 0, 4));
Assert.Throws<NotSupportedException>(() => s.WriteByte(0xf));
Assert.Throws<NotSupportedException>(() => s.WriteAsync(new byte[4], 0, 4).GetAwaiter().GetResult());
Assert.Throws<NotSupportedException>(() => s.EndWrite(s.BeginWrite(new byte[4], 0, 4, null, null)));
#if NET
Assert.Throws<NotSupportedException>(() => s.Write(new byte[4]));
Assert.Throws<NotSupportedException>(() => s.WriteAsync(new byte[4]).AsTask().GetAwaiter().GetResult());
#endif
Assert.Throws<NotSupportedException>(() => s.SetLength(1024));
}
[TestMethod]
[DataRow(-1, SeekOrigin.Begin)]
[DataRow(-1, SeekOrigin.Current)]
[DataRow(-1000, SeekOrigin.End)]
public void SeekBeforeBeginning_ThrowsIOException(long offset, SeekOrigin origin)
{
var sessionMock = new Mock<ISftpSession>();
sessionMock.Setup(s => s.CalculateOptimalReadLength(It.IsAny<uint>())).Returns<uint>(x => x);
sessionMock.Setup(s => s.CalculateOptimalWriteLength(It.IsAny<uint>(), It.IsAny<byte[]>())).Returns<uint, byte[]>((x, _) => x);
sessionMock.Setup(s => s.IsOpen).Returns(true);
SetupRemoteSize(sessionMock, 128);
var s = SftpFileStream.Open(sessionMock.Object, "file.txt", FileMode.Open, FileAccess.Read, bufferSize: 1024);
Assert.Throws<IOException>(() => s.Seek(offset, origin));
}
private static void SetupRemoteSize(Mock<ISftpSession> sessionMock, long size)
{
sessionMock.Setup(s => s.RequestFStat(It.IsAny<byte[]>())).Returns(new SftpFileAttributes(
default, default, size: size, default, default, default, default
));
}
// Operations which should cause writes to be flushed because they depend on
// the remote file being up to date.
// Most of these are already implicitly covered by integration tests and may
// not be so valuable here.
[TestMethod]
public void Flush_SendsBufferedWrites()
{
TestSendsBufferedWrites(s => s.Flush());
}
[TestMethod]
public void Read_SendsBufferedWrites()
{
TestSendsBufferedWrites(s => _ = s.Read(new byte[16], 0, 16));
}
[TestMethod]
public void Seek_SendsBufferedWrites()
{
TestSendsBufferedWrites(s => _ = s.Seek(-1, SeekOrigin.Current));
}
[TestMethod]
public void SetPosition_SendsBufferedWrites()
{
TestSendsBufferedWrites(s => s.Position++);
}
[TestMethod]
public void SetLength_SendsBufferedWrites()
{
TestSendsBufferedWrites(s => s.SetLength(256));
}
[TestMethod]
public void GetLength_SendsBufferedWrites()
{
TestSendsBufferedWrites(s => _ = s.Length);
}
[TestMethod]
public void Dispose_SendsBufferedWrites()
{
TestSendsBufferedWrites(s => s.Dispose());
}
private void TestSendsBufferedWrites(Action<SftpFileStream> flushAction)
{
var sessionMock = new Mock<ISftpSession>();
sessionMock.Setup(s => s.CalculateOptimalReadLength(It.IsAny<uint>())).Returns<uint>(x => x);
sessionMock.Setup(s => s.CalculateOptimalWriteLength(It.IsAny<uint>(), It.IsAny<byte[]>())).Returns<uint, byte[]>((x, _) => x);
sessionMock.Setup(s => s.IsOpen).Returns(true);
sessionMock
.Setup(s => s.RequestReadAsync(It.IsAny<byte[]>(), It.IsAny<ulong>(), It.IsAny<uint>(), It.IsAny<CancellationToken>()))
.Returns<byte[], ulong, uint, CancellationToken>((_, _, _, _) => Task.FromResult(new ReadOnlyMemoryOwner(new(0, usePool: true))));
SetupRemoteSize(sessionMock, 0);
var s = SftpFileStream.Open(sessionMock.Object, "file.txt", FileMode.OpenOrCreate, FileAccess.ReadWrite, bufferSize: 1024);
// Buffer some data
byte[] newData = "Some new bytes"u8.ToArray();
s.Write(newData, 0, newData.Length);
byte[] newData2 = "Some more bytes"u8.ToArray();
s.Write(newData2, 0, newData2.Length);
// The written data does not exceed bufferSize so we do not expect
// it to have been sent.
sessionMock.Verify(s => s.RequestWrite(
It.IsAny<byte[]>(),
It.IsAny<ulong>(),
It.IsAny<byte[]>(),
It.IsAny<int>(),
It.IsAny<int>(),
It.IsAny<AutoResetEvent>()),
Times.Never);
// Whatever is called here should trigger the bytes to be sent
flushAction(s);
VerifyRequestWrite(sessionMock, newData.Concat(newData2), serverOffset: 0);
}
[TestMethod]
public void Dispose()
{
var sessionMock = new Mock<ISftpSession>();
sessionMock.Setup(s => s.CalculateOptimalWriteLength(It.IsAny<uint>(), It.IsAny<byte[]>())).Returns<uint, byte[]>((x, _) => x);
sessionMock.Setup(s => s.IsOpen).Returns(true);
var s = SftpFileStream.Open(sessionMock.Object, "file.txt", FileMode.Create, FileAccess.ReadWrite, bufferSize: 1024);
Assert.IsTrue(s.CanRead);
Assert.IsTrue(s.CanWrite);
s.Dispose();
sessionMock.Verify(p => p.RequestClose(It.IsAny<byte[]>()), Times.Once);
Assert.IsFalse(s.CanRead);
Assert.IsFalse(s.CanSeek);
Assert.IsFalse(s.CanWrite);
Assert.Throws<ObjectDisposedException>(() => s.Read(new byte[16], 0, 16));
Assert.Throws<ObjectDisposedException>(() => s.ReadByte());
Assert.Throws<ObjectDisposedException>(() => s.Write(new byte[16], 0, 16));
Assert.Throws<ObjectDisposedException>(() => s.WriteByte(0xf));
Assert.Throws<ObjectDisposedException>(() => s.CopyTo(Stream.Null));
Assert.Throws<ObjectDisposedException>(s.Flush);
Assert.Throws<ObjectDisposedException>(() => s.Seek(0, SeekOrigin.Begin));
Assert.Throws<ObjectDisposedException>(() => s.SetLength(128));
Assert.Throws<ObjectDisposedException>(() => _ = s.Length);
// Test no-op second dispose
s.Dispose();
sessionMock.Verify(p => p.RequestClose(It.IsAny<byte[]>()), Times.Once);
}
[TestMethod]
public void FstatFailure_DisablesSeek()
{
TestFstatFailure(fstat => fstat.Throws<SftpPermissionDeniedException>());
}
[TestMethod]
public void FstatSizeNotReturned_DisablesSeek()
{
TestFstatFailure(fstat => fstat.Returns(SftpFileAttributes.FromBytes([0, 0, 0, 0])));
}
private void TestFstatFailure(Action<Moq.Language.Flow.ISetup<ISftpSession, SftpFileAttributes>> fstatSetup)
{
var sessionMock = new Mock<ISftpSession>();
sessionMock.Setup(s => s.CalculateOptimalReadLength(It.IsAny<uint>())).Returns<uint>(x => x);
sessionMock.Setup(s => s.CalculateOptimalWriteLength(It.IsAny<uint>(), It.IsAny<byte[]>())).Returns<uint, byte[]>((x, _) => x);
sessionMock.Setup(p => p.SessionLoggerFactory).Returns(NullLoggerFactory.Instance);
sessionMock.Setup(s => s.IsOpen).Returns(true);
sessionMock
.Setup(s => s.RequestReadAsync(It.IsAny<byte[]>(), It.IsAny<ulong>(), It.IsAny<uint>(), It.IsAny<CancellationToken>()))
.Returns<byte[], ulong, uint, CancellationToken>((_, _, _, _) => Task.FromResult(new ReadOnlyMemoryOwner(new(0, usePool: true))));
fstatSetup(sessionMock.Setup(s => s.RequestFStat(It.IsAny<byte[]>())));
var s = SftpFileStream.Open(sessionMock.Object, "file.txt", FileMode.Open, FileAccess.ReadWrite, bufferSize: 1024);
Assert.IsFalse(s.CanSeek);
Assert.IsTrue(s.CanRead);
Assert.IsTrue(s.CanWrite);
Assert.Throws<NotSupportedException>(() => s.Position);
Assert.Throws<NotSupportedException>(() => s.Length);
Assert.Throws<NotSupportedException>(() => s.Seek(0, SeekOrigin.Begin));
Assert.Throws<NotSupportedException>(() => s.SetLength(1024));
// Reads and writes still succeed.
_ = s.Read(new byte[16], 0, 16);
s.Write(new byte[16], 0, 16);
s.Flush();
}
private static void VerifyRequestWrite(Mock<ISftpSession> sessionMock, ReadOnlyMemory<byte> newData, int serverOffset)
{
sessionMock.Verify(s => s.RequestWrite(
/* handle: */ It.IsAny<byte[]>(),
/* serverOffset: */ (ulong)serverOffset,
/* data: */ It.Is<byte[]>(x => IndexOf(x, newData) >= 0),
/* offset: */ It.IsAny<int>(),
/* length: */ newData.Length,
/* wait: */ It.IsAny<AutoResetEvent>()),
Times.Once);
}
private static int IndexOf(byte[] searchSpace, ReadOnlyMemory<byte> searchValue)
{
// Needed in a (non-local) function because expression lambdas can't contain spans
return searchSpace.AsSpan().IndexOf(searchValue.Span);
}
}
}