Преглед на файлове

Update ScpClient to work with revent versions of OpenSSH (#625)

Update ScpClient to work with versions of OpenSSH that include fix for CVE-2018-20685.
Gert Driesen преди 5 години
родител
ревизия
e9e9b4e463

BIN
src/References/How the SCP protocol works.pdf


+ 177 - 0
src/Renci.SshNet.Tests/Classes/Common/PosixPathTest_CreateAbsoluteOrRelativeFilePath.cs

@@ -0,0 +1,177 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Renci.SshNet.Common;
+using System;
+
+namespace Renci.SshNet.Tests.Classes.Common
+{
+    [TestClass]
+    public class PosixPathTest_CreateAbsoluteOrRelativeFilePath
+    {
+        [TestMethod]
+        public void Path_Null()
+        {
+            const string path = null;
+
+            try
+            {
+                PosixPath.CreateAbsoluteOrRelativeFilePath(path);
+                Assert.Fail();
+            }
+            catch (ArgumentNullException ex)
+            {
+                Assert.IsNull(ex.InnerException);
+                Assert.AreEqual("path", ex.ParamName);
+            }
+        }
+
+        [TestMethod]
+        public void Path_Empty()
+        {
+            var path = string.Empty;
+
+            try
+            {
+                PosixPath.CreateAbsoluteOrRelativeFilePath(path);
+                Assert.Fail();
+            }
+            catch (ArgumentException ex)
+            {
+                Assert.IsNull(ex.InnerException);
+                Assert.AreEqual(string.Format("The path is a zero-length string.{0}Parameter name: {1}", Environment.NewLine, ex.ParamName), ex.Message);
+                Assert.AreEqual("path", ex.ParamName);
+            }
+        }
+
+        [TestMethod]
+        public void Path_TrailingForwardSlash()
+        {
+            var path = "/abc/";
+
+            var actual = PosixPath.CreateAbsoluteOrRelativeFilePath(path);
+
+            Assert.IsNotNull(actual);
+            Assert.AreEqual("/abc", actual.Directory);
+            Assert.IsNull(actual.File);
+        }
+
+        [TestMethod]
+        public void Path_FileWithoutNoDirectory()
+        {
+            var path = "abc.log";
+
+            var actual = PosixPath.CreateAbsoluteOrRelativeFilePath(path);
+
+            Assert.IsNotNull(actual);
+            Assert.AreEqual(".", actual.Directory);
+            Assert.AreSame(path, actual.File);
+        }
+
+        [TestMethod]
+        public void Path_FileInRootDirectory()
+        {
+            var path = "/abc.log";
+
+            var actual = PosixPath.CreateAbsoluteOrRelativeFilePath(path);
+
+            Assert.IsNotNull(actual);
+            Assert.AreEqual("/", actual.Directory);
+            Assert.AreEqual("abc.log", actual.File);
+        }
+
+        [TestMethod]
+        public void Path_RootDirectoryOnly()
+        {
+            var path = "/";
+
+            var actual = PosixPath.CreateAbsoluteOrRelativeFilePath(path);
+
+            Assert.IsNotNull(actual);
+            Assert.AreEqual("/", actual.Directory);
+            Assert.IsNull(actual.File);
+        }
+
+        [TestMethod]
+        public void Path_FileInNonRootDirectory()
+        {
+            var path = "/home/sshnet/xyz";
+
+            var actual = PosixPath.CreateAbsoluteOrRelativeFilePath(path);
+
+            Assert.IsNotNull(actual);
+            Assert.AreEqual("/home/sshnet", actual.Directory);
+            Assert.AreEqual("xyz", actual.File);
+        }
+
+        [TestMethod]
+        public void Path_BackslashIsNotConsideredDirectorySeparator()
+        {
+            var path = "/home\\abc.log";
+
+            var actual = PosixPath.CreateAbsoluteOrRelativeFilePath(path);
+
+            Assert.IsNotNull(actual);
+            Assert.AreEqual("/", actual.Directory);
+            Assert.AreEqual("home\\abc.log", actual.File);
+        }
+
+        [TestMethod]
+        public void Path_ColonIsNotConsideredPathSeparator()
+        {
+            var path = "/home:abc.log";
+
+            var actual = PosixPath.CreateAbsoluteOrRelativeFilePath(path);
+
+            Assert.IsNotNull(actual);
+            Assert.AreEqual("/", actual.Directory);
+            Assert.AreEqual("home:abc.log", actual.File);
+        }
+
+        [TestMethod]
+        public void Path_LeadingWhitespace()
+        {
+            var path = "  / \tabc";
+
+            var actual = PosixPath.CreateAbsoluteOrRelativeFilePath(path);
+
+            Assert.IsNotNull(actual);
+            Assert.AreEqual("  ", actual.Directory);
+            Assert.AreEqual(" \tabc", actual.File);
+        }
+
+        [TestMethod]
+        public void Path_TrailingWhitespace()
+        {
+            var path = "/abc \t ";
+
+            var actual = PosixPath.CreateAbsoluteOrRelativeFilePath(path);
+
+            Assert.IsNotNull(actual);
+            Assert.AreEqual("/", actual.Directory);
+            Assert.AreEqual("abc \t ", actual.File);
+        }
+
+        [TestMethod]
+        public void Path_OnlyWhitespace()
+        {
+            var path = " ";
+
+            var actual = PosixPath.CreateAbsoluteOrRelativeFilePath(path);
+
+            Assert.IsNotNull(actual);
+            Assert.AreEqual(".", actual.Directory);
+            Assert.AreSame(path, actual.File);
+        }
+
+        [TestMethod]
+        public void Path_FileNameOnlyWhitespace()
+        {
+            var path = "/home/\t ";
+
+            var actual = PosixPath.CreateAbsoluteOrRelativeFilePath(path);
+
+            Assert.IsNotNull(actual);
+            Assert.AreEqual("/home", actual.Directory);
+            Assert.AreEqual("\t ", actual.File);
+        }
+    }
+}

+ 159 - 0
src/Renci.SshNet.Tests/Classes/Common/PosixPathTest_GetDirectoryName.cs

@@ -0,0 +1,159 @@
+using System;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Renci.SshNet.Common;
+
+namespace Renci.SshNet.Tests.Classes.Common
+{
+    [TestClass]
+    public class PosixPathTest_GetDirectoryName
+    {
+        [TestMethod]
+        public void Path_Null()
+        {
+            const string path = null;
+
+            try
+            {
+                PosixPath.GetDirectoryName(path);
+                Assert.Fail();
+            }
+            catch (ArgumentNullException ex)
+            {
+                Assert.IsNull(ex.InnerException);
+                Assert.AreEqual("path", ex.ParamName);
+            }
+        }
+
+        [TestMethod]
+        public void Path_Empty()
+        {
+            var path = string.Empty;
+
+            var actual = PosixPath.GetDirectoryName(path);
+
+            Assert.IsNotNull(actual);
+            Assert.AreEqual(".", actual);
+        }
+
+        [TestMethod]
+        public void Path_TrailingForwardSlash()
+        {
+            var path = "/abc/";
+
+            var actual = PosixPath.GetDirectoryName(path);
+
+            Assert.IsNotNull(actual);
+            Assert.AreEqual("/abc", actual);
+        }
+
+        [TestMethod]
+        public void Path_FileWithoutNoDirectory()
+        {
+            var path = "abc.log";
+
+            var actual = PosixPath.GetDirectoryName(path);
+
+            Assert.IsNotNull(actual);
+            Assert.AreEqual(".", actual);
+        }
+
+        [TestMethod]
+        public void Path_FileInRootDirectory()
+        {
+            var path = "/abc.log";
+
+            var actual = PosixPath.GetDirectoryName(path);
+
+            Assert.IsNotNull(actual);
+            Assert.AreEqual("/", actual);
+        }
+
+        [TestMethod]
+        public void Path_RootDirectoryOnly()
+        {
+            var path = "/";
+
+            var actual = PosixPath.GetDirectoryName(path);
+
+            Assert.IsNotNull(actual);
+            Assert.AreEqual("/", actual);
+        }
+
+        [TestMethod]
+        public void Path_FileInNonRootDirectory()
+        {
+            var path = "/home/sshnet/xyz";
+
+            var actual = PosixPath.GetDirectoryName(path);
+
+            Assert.IsNotNull(actual);
+            Assert.AreEqual("/home/sshnet", actual);
+        }
+
+        [TestMethod]
+        public void Path_BackslashIsNotConsideredDirectorySeparator()
+        {
+            var path = "/home\\abc.log";
+
+            var actual = PosixPath.GetDirectoryName(path);
+
+            Assert.IsNotNull(actual);
+            Assert.AreEqual("/", actual);
+        }
+
+        [TestMethod]
+        public void Path_ColonIsNotConsideredPathSeparator()
+        {
+            var path = "/home:abc.log";
+
+            var actual = PosixPath.GetDirectoryName(path);
+
+            Assert.IsNotNull(actual);
+            Assert.AreEqual("/", actual);
+        }
+
+        [TestMethod]
+        public void Path_LeadingWhitespace()
+        {
+            var path = "  / \tabc";
+
+            var actual = PosixPath.GetDirectoryName(path);
+
+            Assert.IsNotNull(actual);
+            Assert.AreEqual("  ", actual);
+        }
+
+        [TestMethod]
+        public void Path_TrailingWhitespace()
+        {
+            var path = "/abc \t ";
+
+            var actual = PosixPath.GetDirectoryName(path);
+
+            Assert.IsNotNull(actual);
+            Assert.AreEqual("/", actual);
+        }
+
+        [TestMethod]
+        public void Path_OnlyWhitespace()
+        {
+            var path = " ";
+
+            var actual = PosixPath.GetDirectoryName(path);
+
+            Assert.IsNotNull(actual);
+            Assert.AreEqual(".", actual);
+        }
+
+        [TestMethod]
+        public void Path_FileNameOnlyWhitespace()
+        {
+            var path = "/home/\t ";
+
+            var actual = PosixPath.GetDirectoryName(path);
+
+            Assert.IsNotNull(actual);
+            Assert.AreEqual("/home", actual);
+        }
+    }
+}

+ 3 - 1
src/Renci.SshNet.Tests/Classes/Common/PosixPathTest_GetFileName.cs

@@ -17,8 +17,10 @@ namespace Renci.SshNet.Tests.Classes.Common
                 PosixPath.GetFileName(path);
                 Assert.Fail();
             }
-            catch (NullReferenceException)
+            catch (ArgumentNullException ex)
             {
+                Assert.IsNull(ex.InnerException);
+                Assert.AreEqual("path", ex.ParamName);
             }
         }
 

+ 3 - 4
src/Renci.SshNet.Tests/Classes/ScpClientTest_Upload_DirectoryInfoAndPath_SendExecRequestReturnsFalse.cs

@@ -1,6 +1,5 @@
 using System;
 using System.Collections.Generic;
-using System.Globalization;
 using System.IO;
 using Microsoft.VisualStudio.TestTools.UnitTesting;
 using Moq;
@@ -25,7 +24,7 @@ namespace Renci.SshNet.Tests.Classes
 
             _connectionInfo = new ConnectionInfo("host", 22, "user", new PasswordAuthenticationMethod("user", "pwd"));
             _directoryInfo = new DirectoryInfo("source");
-            _path = "/home/sshnet/" + random.Next().ToString(CultureInfo.InvariantCulture);
+            _path = "/home/sshnet/" + random.Next().ToString();
             _transformedPath = random.Next().ToString();
             _uploadingRegister = new List<ScpUploadEventArgs>();
         }
@@ -48,7 +47,7 @@ namespace Renci.SshNet.Tests.Classes
                                          .Setup(p => p.Transform(_path))
                                          .Returns(_transformedPath);
             _channelSessionMock.InSequence(sequence)
-                               .Setup(p => p.SendExecRequest(string.Format("scp -rt {0}", _transformedPath)))
+                               .Setup(p => p.SendExecRequest(string.Format("scp -r -p -d -t {0}", _transformedPath)))
                                .Returns(false);
             _channelSessionMock.InSequence(sequence).Setup(p => p.Dispose());
             _pipeStreamMock.As<IDisposable>().InSequence(sequence).Setup(p => p.Dispose());
@@ -87,7 +86,7 @@ namespace Renci.SshNet.Tests.Classes
         [TestMethod]
         public void SendExecREquestOnChannelSessionShouldBeInvokedOnce()
         {
-            _channelSessionMock.Verify(p => p.SendExecRequest(string.Format("scp -rt {0}", _transformedPath)), Times.Once);
+            _channelSessionMock.Verify(p => p.SendExecRequest(string.Format("scp -r -p -d -t {0}", _transformedPath)), Times.Once);
         }
 
         [TestMethod]

+ 10 - 6
src/Renci.SshNet.Tests/Classes/ScpClientTest_Upload_FileInfoAndPath_SendExecRequestReturnsFalse.cs

@@ -13,7 +13,9 @@ namespace Renci.SshNet.Tests.Classes
         private ConnectionInfo _connectionInfo;
         private ScpClient _scpClient;
         private FileInfo _fileInfo;
-        private string _path;
+        private string _remoteDirectory;
+        private string _remoteFile;
+        private string _remotePath;
         private string _transformedPath;
         private string _fileName;
         private IList<ScpUploadEventArgs> _uploadingRegister;
@@ -36,7 +38,9 @@ namespace Renci.SshNet.Tests.Classes
             _fileName = CreateTemporaryFile(new byte[] { 1 });
             _connectionInfo = new ConnectionInfo("host", 22, "user", new PasswordAuthenticationMethod("user", "pwd"));
             _fileInfo = new FileInfo(_fileName);
-            _path = "/home/sshnet/" + random.Next();
+            _remoteDirectory = "/home/sshnet";
+            _remoteFile = random.Next().ToString();
+            _remotePath = _remoteDirectory + "/" + _remoteFile;
             _transformedPath = random.Next().ToString();
             _uploadingRegister = new List<ScpUploadEventArgs>();
         }
@@ -56,10 +60,10 @@ namespace Renci.SshNet.Tests.Classes
             _sessionMock.InSequence(sequence).Setup(p => p.CreateChannelSession()).Returns(_channelSessionMock.Object);
             _channelSessionMock.InSequence(sequence).Setup(p => p.Open());
             _remotePathTransformationMock.InSequence(sequence)
-                                         .Setup(p => p.Transform(_path))
+                                         .Setup(p => p.Transform(_remoteDirectory))
                                          .Returns(_transformedPath);
             _channelSessionMock.InSequence(sequence)
-                               .Setup(p => p.SendExecRequest(string.Format("scp -t {0}", _transformedPath)))
+                               .Setup(p => p.SendExecRequest(string.Format("scp -t -d {0}", _transformedPath)))
                                .Returns(false);
             _channelSessionMock.InSequence(sequence).Setup(p => p.Dispose());
             _pipeStreamMock.As<IDisposable>().InSequence(sequence).Setup(p => p.Dispose());
@@ -78,7 +82,7 @@ namespace Renci.SshNet.Tests.Classes
         {
             try
             {
-                _scpClient.Upload(_fileInfo, _path);
+                _scpClient.Upload(_fileInfo, _remotePath);
                 Assert.Fail();
             }
             catch (SshException ex)
@@ -98,7 +102,7 @@ namespace Renci.SshNet.Tests.Classes
         [TestMethod]
         public void SendExecRequestOnChannelSessionShouldBeInvokedOnce()
         {
-            _channelSessionMock.Verify(p => p.SendExecRequest(string.Format("scp -t {0}", _transformedPath)), Times.Once);
+            _channelSessionMock.Verify(p => p.SendExecRequest(string.Format("scp -t -d {0}", _transformedPath)), Times.Once);
         }
 
         [TestMethod]

+ 15 - 11
src/Renci.SshNet.Tests/Classes/ScpClientTest_Upload_FileInfoAndPath_Success.cs

@@ -1,6 +1,5 @@
 using System;
 using System.Collections.Generic;
-using System.Globalization;
 using System.IO;
 using System.Linq;
 using System.Text;
@@ -16,7 +15,9 @@ namespace Renci.SshNet.Tests.Classes
         private ConnectionInfo _connectionInfo;
         private ScpClient _scpClient;
         private FileInfo _fileInfo;
-        private string _path;
+        private string _remoteDirectory;
+        private string _remoteFile;
+        private string _remotePath;
         private string _transformedPath;
         private int _bufferSize;
         private byte[] _fileContent;
@@ -44,7 +45,9 @@ namespace Renci.SshNet.Tests.Classes
             _fileName = CreateTemporaryFile(_fileContent);
             _connectionInfo = new ConnectionInfo("host", 22, "user", new PasswordAuthenticationMethod("user", "pwd"));
             _fileInfo = new FileInfo(_fileName);
-            _path = "/home/sshnet/" + random.Next().ToString(CultureInfo.InvariantCulture);
+            _remoteDirectory = "/home/sshnet";
+            _remoteFile = random.Next().ToString();
+            _remotePath = _remoteDirectory + "/" + _remoteFile;
             _transformedPath = random.Next().ToString();
             _uploadingRegister = new List<ScpUploadEventArgs>();
         }
@@ -64,17 +67,18 @@ namespace Renci.SshNet.Tests.Classes
             _sessionMock.InSequence(sequence).Setup(p => p.CreateChannelSession()).Returns(_channelSessionMock.Object);
             _channelSessionMock.InSequence(sequence).Setup(p => p.Open());
             _remotePathTransformationMock.InSequence(sequence)
-                                         .Setup(p => p.Transform(_path))
+                                         .Setup(p => p.Transform(_remoteDirectory))
                                          .Returns(_transformedPath);
             _channelSessionMock.InSequence(sequence)
-                               .Setup(p => p.SendExecRequest(string.Format("scp -t {0}", _transformedPath)))
+                               .Setup(p => p.SendExecRequest(string.Format("scp -t -d {0}", _transformedPath)))
                                .Returns(true);
             _pipeStreamMock.InSequence(sequence).Setup(p => p.ReadByte()).Returns(0);
             _channelSessionMock.InSequence(sequence).Setup(p => p.SendData(It.IsAny<byte[]>()));
             _pipeStreamMock.InSequence(sequence).Setup(p => p.ReadByte()).Returns(0);
             _channelSessionMock.InSequence(sequence)
-                .Setup(p => p.SendData(It.Is<byte[]>(b => b.SequenceEqual(CreateData(
-                    string.Format("C0644 {0} {1}\n", _fileInfo.Length, string.Empty))))));
+                .Setup(p => p.SendData(It.Is<byte[]>(b => b.SequenceEqual(
+                    CreateData(string.Format("C0644 {0} {1}\n", _fileInfo.Length, _remoteFile),
+                    _connectionInfo.Encoding)))));
             _pipeStreamMock.InSequence(sequence).Setup(p => p.ReadByte()).Returns(0);
             _channelSessionMock.InSequence(sequence)
                 .Setup(
@@ -104,13 +108,13 @@ namespace Renci.SshNet.Tests.Classes
 
         protected override void Act()
         {
-            _scpClient.Upload(_fileInfo, _path);
+            _scpClient.Upload(_fileInfo, _remotePath);
         }
 
         [TestMethod]
         public void SendExecRequestOnChannelSessionShouldBeInvokedOnce()
         {
-            _channelSessionMock.Verify(p => p.SendExecRequest(string.Format("scp -t {0}", _transformedPath)), Times.Once);
+            _channelSessionMock.Verify(p => p.SendExecRequest(string.Format("scp -t -d {0}", _transformedPath)), Times.Once);
         }
 
         [TestMethod]
@@ -143,9 +147,9 @@ namespace Renci.SshNet.Tests.Classes
             Assert.AreEqual(_fileSize, uploading.Uploaded);
         }
 
-        private static IEnumerable<byte> CreateData(string command)
+        private static IEnumerable<byte> CreateData(string command, Encoding encoding)
         {
-            return Encoding.Default.GetBytes(command);
+            return encoding.GetBytes(command);
         }
 
         private static byte[] CreateContent(int length)

+ 10 - 7
src/Renci.SshNet.Tests/Classes/ScpClientTest_Upload_StreamAndPath_SendExecRequestReturnsFalse.cs

@@ -1,6 +1,5 @@
 using System;
 using System.Collections.Generic;
-using System.Globalization;
 using System.IO;
 using Microsoft.VisualStudio.TestTools.UnitTesting;
 using Moq;
@@ -14,7 +13,9 @@ namespace Renci.SshNet.Tests.Classes
         private ConnectionInfo _connectionInfo;
         private ScpClient _scpClient;
         private Stream _source;
-        private string _path;
+        private string _remoteDirectory;
+        private string _remoteFile;
+        private string _remotePath;
         private string _transformedPath;
         private IList<ScpUploadEventArgs> _uploadingRegister;
         private SshException _actualException;
@@ -34,7 +35,9 @@ namespace Renci.SshNet.Tests.Classes
 
             _connectionInfo = new ConnectionInfo("host", 22, "user", new PasswordAuthenticationMethod("user", "pwd"));
             _source = new MemoryStream();
-            _path = "/home/sshnet/" + random.Next().ToString(CultureInfo.InvariantCulture);
+            _remoteDirectory = "/home/sshnet";
+            _remoteFile = random.Next().ToString();
+            _remotePath = _remoteDirectory + "/" + _remoteFile;
             _transformedPath = random.Next().ToString();
             _uploadingRegister = new List<ScpUploadEventArgs>();
         }
@@ -54,10 +57,10 @@ namespace Renci.SshNet.Tests.Classes
             _sessionMock.InSequence(sequence).Setup(p => p.CreateChannelSession()).Returns(_channelSessionMock.Object);
             _channelSessionMock.InSequence(sequence).Setup(p => p.Open());
             _remotePathTransformationMock.InSequence(sequence)
-                                         .Setup(p => p.Transform(_path))
+                                         .Setup(p => p.Transform(_remoteDirectory))
                                          .Returns(_transformedPath);
             _channelSessionMock.InSequence(sequence)
-                               .Setup(p => p.SendExecRequest(string.Format("scp -t {0}", _transformedPath)))
+                               .Setup(p => p.SendExecRequest(string.Format("scp -t -d {0}", _transformedPath)))
                                .Returns(false);
             _channelSessionMock.InSequence(sequence).Setup(p => p.Dispose());
             _pipeStreamMock.As<IDisposable>().InSequence(sequence).Setup(p => p.Dispose());
@@ -76,7 +79,7 @@ namespace Renci.SshNet.Tests.Classes
         {
             try
             {
-                _scpClient.Upload(_source, _path);
+                _scpClient.Upload(_source, _remotePath);
                 Assert.Fail();
             }
             catch (SshException ex)
@@ -96,7 +99,7 @@ namespace Renci.SshNet.Tests.Classes
         [TestMethod]
         public void SendExecRequestOnChannelSessionShouldBeInvokedOnce()
         {
-            _channelSessionMock.Verify(p => p.SendExecRequest(string.Format("scp -t {0}", _transformedPath)), Times.Once);
+            _channelSessionMock.Verify(p => p.SendExecRequest(string.Format("scp -t -d {0}", _transformedPath)), Times.Once);
         }
 
         [TestMethod]

+ 3 - 1
src/Renci.SshNet.Tests/Renci.SshNet.Tests.csproj

@@ -173,6 +173,8 @@
     <Compile Include="Classes\Common\ExtensionsTest_Pad.cs" />
     <Compile Include="Classes\Common\ExtensionsTest_TrimLeadingZeros.cs" />
     <Compile Include="Classes\Common\PackTest.cs" />
+    <Compile Include="Classes\Common\PosixPathTest_CreateAbsoluteOrRelativeFilePath.cs" />
+    <Compile Include="Classes\Common\PosixPathTest_GetDirectoryName.cs" />
     <Compile Include="Classes\Common\PosixPathTest_GetFileName.cs" />
     <Compile Include="Classes\ConnectionInfoTest_Authenticate_Failure.cs" />
     <Compile Include="Classes\ConnectionInfoTest_Authenticate_Success.cs" />
@@ -731,4 +733,4 @@
   <Target Name="AfterBuild">
   </Target>
   -->
-</Project>
+</Project>

+ 71 - 1
src/Renci.SshNet/Common/PosixPath.cs

@@ -4,6 +4,49 @@ namespace Renci.SshNet.Common
 {
     internal class PosixPath
     {
+        public string Directory { get; private set; }
+        public string File { get; private set; }
+
+        public static PosixPath CreateAbsoluteOrRelativeFilePath(string path)
+        {
+            if (path == null)
+            {
+                throw new ArgumentNullException("path");
+            }
+
+            var posixPath = new PosixPath();
+
+            var pathEnd = path.LastIndexOf('/');
+            if (pathEnd == -1)
+            {
+                if (path.Length == 0)
+                {
+                    throw new ArgumentException("The path is a zero-length string.", "path");
+                }
+
+                posixPath.Directory = ".";
+                posixPath.File = path;
+            }
+            else if (pathEnd == 0)
+            {
+                posixPath.Directory = "/";
+                if (path.Length > 1)
+                {
+                    posixPath.File = path.Substring(pathEnd + 1);
+                }
+            }
+            else
+            {
+                posixPath.Directory = path.Substring(0, pathEnd);
+                if (pathEnd < path.Length - 1)
+                {
+                    posixPath.File = path.Substring(pathEnd + 1);
+                }
+            }
+
+            return posixPath;
+        }
+
         /// <summary>
         /// Gets the file name part of a given POSIX path.
         /// </summary>
@@ -18,11 +61,14 @@ namespace Renci.SshNet.Common
         /// is returned.
         /// </para>
         /// <para>
-        /// If path has a trailing slash, but <see cref="GetFileName(string)"/> return a zero-length string.
+        /// If path has a trailing slash, <see cref="GetFileName(string)"/> return a zero-length string.
         /// </para>
         /// </remarks>
         public static string GetFileName(string path)
         {
+            if (path == null)
+                throw new ArgumentNullException("path");
+
             var pathEnd = path.LastIndexOf('/');
             if (pathEnd == -1)
                 return path;
@@ -30,5 +76,29 @@ namespace Renci.SshNet.Common
                 return string.Empty;
             return path.Substring(pathEnd + 1);
         }
+
+        /// <summary>
+        /// Gets the directory name part of a given POSIX path.
+        /// </summary>
+        /// <param name="path">The POSIX path to get the directory name for.</param>
+        /// <returns>
+        /// The directory part of the specified <paramref name="path"/>, or <c>.</c> if <paramref name="path"/>
+        /// does not contain any directory information.
+        /// </returns>
+        /// <exception cref="ArgumentNullException"><paramref name="path"/> is <c>null</c>.</exception>
+        public static string GetDirectoryName(string path)
+        {
+            if (path == null)
+                throw new ArgumentNullException("path");
+
+            var pathEnd = path.LastIndexOf('/');
+            if (pathEnd == -1)
+                return ".";
+            if (pathEnd == 0)
+                return "/";
+            if (pathEnd == path.Length - 1)
+                return path.Substring(0, pathEnd);
+            return path.Substring(0, pathEnd);
+        }
     }
 }

+ 57 - 14
src/Renci.SshNet/ScpClient.NET.cs

@@ -20,15 +20,16 @@ namespace Renci.SshNet
         /// <param name="fileInfo">The file system info.</param>
         /// <param name="path">A relative or absolute path for the remote file.</param>
         /// <exception cref="ArgumentNullException"><paramref name="fileInfo" /> is <c>null</c>.</exception>
-        /// <exception cref="ArgumentException"><paramref name="path"/> is <c>null</c> or empty.</exception>
+        /// <exception cref="ArgumentNullException"><paramref name="path" /> is <c>null</c>.</exception>
+        /// <exception cref="ArgumentException"><paramref name="path"/> is a zero-length <see cref="string"/>.</exception>
         /// <exception cref="ScpException">A directory with the specified path exists on the remote host.</exception>
         /// <exception cref="SshException">The secure copy execution request was rejected by the server.</exception>
         public void Upload(FileInfo fileInfo, string path)
         {
             if (fileInfo == null)
                 throw new ArgumentNullException("fileInfo");
-            if (string.IsNullOrEmpty(path))
-                throw new ArgumentException("path");
+
+            var posixPath = PosixPath.CreateAbsoluteOrRelativeFilePath(path);
 
             using (var input = ServiceFactory.CreatePipeStream())
             using (var channel = Session.CreateChannelSession())
@@ -36,7 +37,9 @@ namespace Renci.SshNet
                 channel.DataReceived += (sender, e) => input.Write(e.Data, 0, e.Data.Length);
                 channel.Open();
 
-                if (!channel.SendExecRequest(string.Format("scp -t {0}", _remotePathTransformation.Transform(path))))
+                // Pass only the directory part of the path to the server, and use the (hidden) -d option to signal
+                // that we expect the target to be a directory.
+                if (!channel.SendExecRequest(string.Format("scp -t -d {0}", _remotePathTransformation.Transform(posixPath.Directory))))
                 {
                     throw SecureExecutionRequestRejectedException();
                 }
@@ -45,7 +48,7 @@ namespace Renci.SshNet
                 using (var source = fileInfo.OpenRead())
                 {
                     UploadTimes(channel, input, fileInfo);
-                    UploadFileModeAndName(channel, input, source.Length, string.Empty);
+                    UploadFileModeAndName(channel, input, source.Length, posixPath.File);
                     UploadFileContent(channel, input, source, fileInfo.Name);
                 }
             }
@@ -56,16 +59,19 @@ namespace Renci.SshNet
         /// </summary>
         /// <param name="directoryInfo">The directory info.</param>
         /// <param name="path">A relative or absolute path for the remote directory.</param>
-        /// <exception cref="ArgumentNullException">fileSystemInfo</exception>
-        /// <exception cref="ArgumentException"><paramref name="path"/> is <c>null</c> or empty.</exception>
-        /// <exception cref="ScpException"><paramref name="path"/> exists on the remote host, and is not a directory.</exception>
+        /// <exception cref="ArgumentNullException"><paramref name="directoryInfo"/> is <c>null</c>.</exception>
+        /// <exception cref="ArgumentNullException"><paramref name="path"/> is <c>null</c>.</exception>
+        /// <exception cref="ArgumentException"><paramref name="path"/> is a zero-length string.</exception>
+        /// <exception cref="ScpException"><paramref name="path"/> does not exist on the remote host, is not a directory or the user does not have the required permission.</exception>
         /// <exception cref="SshException">The secure copy execution request was rejected by the server.</exception>
         public void Upload(DirectoryInfo directoryInfo, string path)
         {
             if (directoryInfo == null)
                 throw new ArgumentNullException("directoryInfo");
-            if (string.IsNullOrEmpty(path))
-                throw new ArgumentException("path");
+            if (path == null)
+                throw new ArgumentNullException("path");
+            if (path.Length == 0)
+                throw new ArgumentException("The path cannot be a zero-length string.", "path");
 
             using (var input = ServiceFactory.CreatePipeStream())
             using (var channel = Session.CreateChannelSession())
@@ -73,15 +79,18 @@ namespace Renci.SshNet
                 channel.DataReceived += (sender, e) => input.Write(e.Data, 0, e.Data.Length);
                 channel.Open();
 
-                // start recursive upload
-                if (!channel.SendExecRequest(string.Format("scp -rt {0}", _remotePathTransformation.Transform(path))))
+                // start copy with the following options:
+                // -p preserve modification and access times
+                // -r copy directories recursively
+                // -d expect path to be a directory
+                // -t copy to remote
+                if (!channel.SendExecRequest(string.Format("scp -r -p -d -t {0}", _remotePathTransformation.Transform(path))))
                 {
                     throw SecureExecutionRequestRejectedException();
                 }
+
                 CheckReturnCode(input);
 
-                UploadTimes(channel, input, directoryInfo);
-                UploadDirectoryModeAndName(channel, input, ".");
                 UploadDirectoryContent(channel, input, directoryInfo);
             }
         }
@@ -311,5 +320,39 @@ namespace Renci.SshNet
                 SendErrorConfirmation(channel, string.Format("\"{0}\" is not valid protocol message.", message));
             }
         }
+
+        /// <summary>
+        /// Return a value indicating whether the specified path is a valid SCP file path.
+        /// </summary>
+        /// <param name="path">The path to verify.</param>
+        /// <returns>
+        /// <see langword="true"/> if <paramref name="path"/> is a valid SCP file path; otherwise, <see langword="false"/>.
+        /// </returns>
+        /// <remarks>
+        /// To match OpenSSH behavior (introduced as a result of CVE-2018-20685), a file path is considered
+        /// invalid in any of the following conditions:
+        /// <list type="bullet">
+        ///   <item>
+        ///     <description><paramref name="path"/> is a zero-length string.</description>
+        ///   </item>
+        ///   <item>
+        ///     <description><paramref name="path"/> is &quot;<c>.</c>&quot;.</description>
+        ///   </item>
+        ///   <item>
+        ///     <description><paramref name="path"/> is &quot;<c>..</c>&quot;.</description>
+        ///   </item>
+        ///   <item>
+        ///     <description><paramref name="path"/> contains a forward slash (/).</description>
+        ///   </item>
+        /// </list>
+        /// </remarks>
+        private static bool IsValidScpFilePath(string path)
+        {
+            return path != null &&
+                   path.Length != 0 &&
+                   path != "." &&
+                   path != ".." &&
+                   path.IndexOf('/') == -1;
+        }
     }
 }

+ 9 - 7
src/Renci.SshNet/ScpClient.cs

@@ -202,28 +202,30 @@ namespace Renci.SshNet
         /// </summary>
         /// <param name="source">The <see cref="Stream"/> to upload.</param>
         /// <param name="path">A relative or absolute path for the remote file.</param>
+        /// <exception cref="ArgumentNullException"><paramref name="path" /> is <c>null</c>.</exception>
+        /// <exception cref="ArgumentException"><paramref name="path"/> is a zero-length <see cref="string"/>.</exception>
         /// <exception cref="ScpException">A directory with the specified path exists on the remote host.</exception>
         /// <exception cref="SshException">The secure copy execution request was rejected by the server.</exception>
         public void Upload(Stream source, string path)
         {
+            var posixPath = PosixPath.CreateAbsoluteOrRelativeFilePath(path);
+
             using (var input = ServiceFactory.CreatePipeStream())
             using (var channel = Session.CreateChannelSession())
             {
                 channel.DataReceived += (sender, e) => input.Write(e.Data, 0, e.Data.Length);
                 channel.Open();
 
-                // pass the full path to ensure the server does not create the directory part
-                // as a file in case the directory does not exist
-                if (!channel.SendExecRequest(string.Format("scp -t {0}", _remotePathTransformation.Transform(path))))
+                // Pass only the directory part of the path to the server, and use the (hidden) -d option to signal
+                // that we expect the target to be a directory.
+                if (!channel.SendExecRequest(string.Format("scp -t -d {0}", _remotePathTransformation.Transform(posixPath.Directory))))
                 {
                     throw SecureExecutionRequestRejectedException();
                 }
                 CheckReturnCode(input);
 
-                // specify a zero-length file name to avoid creating a file with absolute
-                // path '<path>/<filename part of path>' if directory '<path>' already exists
-                UploadFileModeAndName(channel, input, source.Length, string.Empty);
-                UploadFileContent(channel, input, source, PosixPath.GetFileName(path));
+                UploadFileModeAndName(channel, input, source.Length, posixPath.File);
+                UploadFileContent(channel, input, source, posixPath.File);
             }
         }