diff --git a/src/Renci.SshNet/ScpClient.cs b/src/Renci.SshNet/ScpClient.cs
index 4afe4920..07422753 100644
--- a/src/Renci.SshNet/ScpClient.cs
+++ b/src/Renci.SshNet/ScpClient.cs
@@ -564,9 +564,27 @@ namespace Renci.SshNet
/// Local directory information.
/// is or empty.
/// is .
- /// File or directory with the specified path does not exist on the remote host, or the server sent an invalid or unexpected SCP record.
+ ///
+ /// File or directory with the specified path does not exist on the remote host, the server sent an invalid
+ /// or unexpected SCP record, or the server sent a file or directory name that is not a valid local name.
+ ///
/// The secure copy execution request was rejected by the server.
/// Client is not connected.
+ ///
+ ///
+ /// Each file and directory name sent by the server must be a plain name that refers to an entry
+ /// directly inside the directory being downloaded into. The download is aborted with an
+ /// if a name contains a directory separator or another character that is
+ /// not valid in a local file name, refers to the current or parent directory, or would be resolved by
+ /// the local file system to a different name or location, so that the names sent by the server cannot
+ /// refer to a location outside of .
+ ///
+ ///
+ /// Existing files in are overwritten by files with the same name, and
+ /// symbolic links and junctions that already exist within it are followed. If the download fails, the
+ /// files received so far, including part of the file being received, are left in place.
+ ///
+ ///
public void Download(string directoryName, DirectoryInfo directoryInfo)
{
ArgumentException.ThrowIfNullOrEmpty(directoryName);
@@ -946,30 +964,61 @@ namespace Renci.SshNet
}
///
- /// Ensures that a file or directory name received from the remote host in the SCP
- /// protocol stream is a plain local name that cannot redirect a write outside of the
- /// caller-supplied destination directory.
+ /// Combines a file or directory name received from the remote host with a local directory,
+ /// ensuring that the result is a direct child of that directory with exactly that name.
///
+ /// The local directory, as returned by .
/// The file or directory name as sent by the server.
- ///
- /// is empty, refers to the current or parent directory, or
- /// contains a character that is not valid in a local file name (such as a directory
- /// separator).
- ///
- internal static void EnsureValidLocalName(string name)
+ ///
+ /// The full path of the child, or if is empty, refers
+ /// to the current or parent directory, contains a character that is not valid in a local file name
+ /// (such as a directory separator), or the local file system would resolve it to a different name or
+ /// location (for example, Windows removes trailing dots and spaces, so "..." refers to
+ /// itself).
+ ///
+ internal static string? ResolveChild(string directory, string name)
{
- // A legitimate SCP server only ever sends a single, plain path component in a
- // C (file) or D (directory) record. Reject anything that is empty, refers to the
- // current/parent directory, or carries a directory separator, drive qualifier or
- // other character that is not valid in a local file name. Path.GetInvalidFileNameChars()
- // is platform-aware: on Windows it includes '\', '/' and ':'; on Unix it includes '/'.
- if (string.IsNullOrEmpty(name) ||
- name.Equals(".", StringComparison.Ordinal) ||
- name.Equals("..", StringComparison.Ordinal) ||
- name.IndexOfAny(InvalidLocalNameChars) >= 0)
+ if (string.IsNullOrEmpty(name) || name.IndexOfAny(InvalidLocalNameChars) >= 0)
{
- throw new ScpException($"The server sent a file or directory name (\"{name}\") that is not a valid local name.");
+ return null;
}
+
+ var path = Path.GetFullPath(Path.Combine(directory, name));
+
+ if (!string.Equals(Path.GetDirectoryName(path), directory, PathHelpers.StringComparison) ||
+ !string.Equals(Path.GetFileName(path), name, PathHelpers.StringComparison))
+ {
+ return null;
+ }
+
+ return path;
+ }
+
+ ///
+ /// Returns the full path of a local directory without a trailing directory separator, unless
+ /// the directory is a root.
+ ///
+ /// The path of the directory.
+ /// The normalized full path.
+ internal static string NormalizeDirectory(string path)
+ {
+ var fullPath = Path.GetFullPath(path);
+ var root = Path.GetPathRoot(fullPath);
+
+ if (root is not null && fullPath.Length > root.Length)
+ {
+ fullPath = fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ }
+
+ return fullPath;
+ }
+
+ [DoesNotReturn]
+ private void SendInvalidLocalNameErrorAndThrow(IChannel channel)
+ {
+ SendErrorConfirmation(channel, "File or directory name is not a valid local name.");
+
+ throw new ScpException("The server sent a file or directory name that is not a valid local name.");
}
///
@@ -1044,8 +1093,8 @@ namespace Renci.SshNet
var modifiedUtc = DateTime.UtcNow;
var accessedUtc = modifiedUtc;
- var startDirectoryFullName = directoryInfo.FullName;
- var currentDirectoryFullName = startDirectoryFullName;
+ var destination = NormalizeDirectory(directoryInfo.FullName);
+ var currentDirectoryFullName = destination;
var directoryCounter = 0;
while (true)
@@ -1054,6 +1103,11 @@ namespace Renci.SshNet
if (message == "E")
{
+ if (directoryCounter <= 0)
+ {
+ SendInvalidRecordErrorAndThrow(channel);
+ }
+
SendSuccessConfirmation(channel); // Send reply
directoryCounter--;
@@ -1065,7 +1119,7 @@ namespace Renci.SshNet
var currentDirectoryParent = new DirectoryInfo(currentDirectoryFullName).Parent;
- Debug.Assert(currentDirectoryParent is not null, $"Should be {directoryCounter.ToString(CultureInfo.InvariantCulture)} levels deeper than {startDirectoryFullName}.");
+ Debug.Assert(currentDirectoryParent is not null, $"Should be {directoryCounter.ToString(CultureInfo.InvariantCulture)} levels deeper than {destination}.");
currentDirectoryFullName = currentDirectoryParent.FullName;
@@ -1075,31 +1129,35 @@ namespace Renci.SshNet
var match = DirectoryInfoRegex.Match(message);
if (match.Success)
{
- SendSuccessConfirmation(channel); // Send reply
-
// Read directory
var filename = match.Result("${filename}");
- DirectoryInfo newDirectoryInfo;
if (directoryCounter > 0)
{
- // The server-supplied name is combined into a local path; ensure it
- // cannot escape the destination directory.
- EnsureValidLocalName(filename);
+ var path = ResolveChild(currentDirectoryFullName, filename);
- newDirectoryInfo = Directory.CreateDirectory(Path.Combine(currentDirectoryFullName, filename));
+ if (path is null)
+ {
+ SendInvalidLocalNameErrorAndThrow(channel);
+ }
+
+ SendSuccessConfirmation(channel); // Send reply
+
+ var newDirectoryInfo = Directory.CreateDirectory(path);
newDirectoryInfo.LastAccessTimeUtc = accessedUtc;
newDirectoryInfo.LastWriteTimeUtc = modifiedUtc;
+
+ currentDirectoryFullName = path;
}
else
{
+ SendSuccessConfirmation(channel); // Send reply
+
// Don't create directory for first level
- newDirectoryInfo = directoryInfo;
+ currentDirectoryFullName = destination;
}
directoryCounter++;
-
- currentDirectoryFullName = newDirectoryInfo.FullName;
continue;
}
@@ -1109,15 +1167,18 @@ namespace Renci.SshNet
{
var fileName = match.Result("${filename}");
+ var path = ResolveChild(currentDirectoryFullName, fileName);
+
+ if (path is null)
+ {
+ SendInvalidLocalNameErrorAndThrow(channel);
+ }
+
+ var fileInfo = new FileInfo(path);
+
// Read file
SendSuccessConfirmation(channel); // Send reply
- // The server-supplied name is combined into a local path; ensure it
- // cannot escape the destination directory.
- EnsureValidLocalName(fileName);
-
- var fileInfo = new FileInfo(Path.Combine(currentDirectoryFullName, fileName));
-
DownloadFileContent(channel, input, fileInfo, fileName, length, modifiedUtc, accessedUtc);
if (directoryCounter == 0)
@@ -1142,5 +1203,31 @@ namespace Renci.SshNet
SendInvalidRecordErrorAndThrow(channel);
}
}
+
+ // https://github.com/dotnet/runtime/blob/a1115d3990ce45403dd80d96e39c43066e9ece65/src/libraries/Common/src/System/IO/PathInternal.CaseSensitivity.cs
+ private static class PathHelpers
+ {
+ internal static StringComparison StringComparison
+ {
+ get
+ {
+ return IsCaseSensitive ?
+ StringComparison.Ordinal :
+ StringComparison.OrdinalIgnoreCase;
+ }
+ }
+
+ internal static bool IsCaseSensitive
+ {
+ get
+ {
+#if NET
+ return !(OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() || OperatingSystem.IsIOS() || OperatingSystem.IsTvOS());
+#else
+ return Path.DirectorySeparatorChar != '\\';
+#endif
+ }
+ }
+ }
}
}
diff --git a/test/Renci.SshNet.IntegrationTests/ScpTests.cs b/test/Renci.SshNet.IntegrationTests/ScpTests.cs
index 5e6704d5..e98dc013 100644
--- a/test/Renci.SshNet.IntegrationTests/ScpTests.cs
+++ b/test/Renci.SshNet.IntegrationTests/ScpTests.cs
@@ -2070,6 +2070,61 @@ namespace Renci.SshNet.IntegrationTests
}
}
+ ///
+ /// A recursive download must never write outside of the destination directory when the
+ /// remote tree contains a directory named "...". It is an ordinary name on the (Linux)
+ /// server, but Windows path normalization resolves it to the current directory.
+ ///
+ [TestMethod]
+ public void Scp_Download_DirectoryInfo_DirectoryNamedThreeDots_StaysInsideDestination()
+ {
+ var remoteDirectory = "/tmp/sshnet-scp-dots-" + Guid.NewGuid().ToString("N");
+
+ using (var client = new SftpClient(_connectionInfoFactory.Create()))
+ {
+ client.Connect();
+ client.CreateDirectory(remoteDirectory);
+ client.CreateDirectory(remoteDirectory + "/...");
+ client.WriteAllText(remoteDirectory + "/.../inner.txt", "inner");
+ client.WriteAllText(remoteDirectory + "/outer.txt", "outer");
+ }
+
+ var localRoot = Path.Combine(Path.GetTempPath(), "sshnet-scp-dots-" + Guid.NewGuid().ToString("N"));
+ var destination = Directory.CreateDirectory(Path.Combine(localRoot, "download"));
+
+ try
+ {
+ using (var client = new ScpClient(_connectionInfoFactory.Create(), _remotePathTransformation))
+ {
+ client.Connect();
+
+ if (Path.DirectorySeparatorChar == '\\')
+ {
+ _ = Assert.ThrowsExactly(() => client.Download(remoteDirectory, destination));
+ }
+ else
+ {
+ client.Download(remoteDirectory, destination);
+ Assert.AreEqual("inner", File.ReadAllText(Path.Combine(destination.FullName, "...", "inner.txt")));
+ Assert.AreEqual("outer", File.ReadAllText(Path.Combine(destination.FullName, "outer.txt")));
+ }
+ }
+
+ // Nothing should be written outside of the destination directory on any platform.
+ CollectionAssert.AreEqual(new[] { destination.FullName }, Directory.GetFileSystemEntries(localRoot));
+ }
+ finally
+ {
+ using (var client = new SshClient(_connectionInfoFactory.Create()))
+ {
+ client.Connect();
+ _ = client.RunCommand("rm -rf '" + remoteDirectory + "'");
+ }
+
+ Directory.Delete(localRoot, recursive: true);
+ }
+ }
+
///
/// A file whose name contains the text of a D record must be downloaded as a file, with its
/// content written verbatim rather than interpreted as SCP records.
diff --git a/test/Renci.SshNet.Tests/Classes/ScpClientTest_Download_InvalidRecord.cs b/test/Renci.SshNet.Tests/Classes/ScpClientTest_Download_InvalidRecord.cs
index dd348445..9573bd68 100644
--- a/test/Renci.SshNet.Tests/Classes/ScpClientTest_Download_InvalidRecord.cs
+++ b/test/Renci.SshNet.Tests/Classes/ScpClientTest_Download_InvalidRecord.cs
@@ -85,6 +85,26 @@ namespace Renci.SshNet.Tests.Classes
Assert.IsEmpty(Directory.GetFileSystemEntries(Destination));
}
+ [TestMethod]
+ public void DownloadDirectoryInfo_EndDirectoryBeforeAnyDirectory_ShouldThrowScpException()
+ {
+ // Without the check, the E record would move the download to the parent of the destination,
+ // and the following file would overwrite the existing file there.
+ var existing = Path.Combine(DestinationParent, "existing.txt");
+ File.WriteAllText(existing, "original");
+
+ var transcript = new ScpServerTranscript()
+ .Line("E")
+ .File("existing.txt", "data");
+
+ var result = DownloadDirectory(transcript);
+
+ AssertRejected(result, expectedSuccessStatuses: 1);
+ Assert.AreEqual("original", File.ReadAllText(existing));
+ AssertNothingOutsideDestination(expectedOutside: "a/b/existing.txt");
+ Assert.IsEmpty(Directory.GetFileSystemEntries(Destination));
+ }
+
[TestMethod]
[DataRow("Xjunk")]
[DataRow("C0644 99999999999999999999 f")]
diff --git a/test/Renci.SshNet.Tests/Classes/ScpClientTest_Download_PathAndDirectoryInfo_Names.cs b/test/Renci.SshNet.Tests/Classes/ScpClientTest_Download_PathAndDirectoryInfo_Names.cs
new file mode 100644
index 00000000..acbaeebb
--- /dev/null
+++ b/test/Renci.SshNet.Tests/Classes/ScpClientTest_Download_PathAndDirectoryInfo_Names.cs
@@ -0,0 +1,262 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+using Renci.SshNet.Common;
+using Renci.SshNet.Tests.Common;
+
+namespace Renci.SshNet.Tests.Classes
+{
+ ///
+ /// Verifies how a recursive download handles the file and directory names sent by the server:
+ /// plain names are created as-is, and names that are not a plain local name, or that the local
+ /// file system would resolve to a different name or location, are rejected.
+ ///
+ ///
+ /// Each name is checked both in a C (file) record and in a nested D (directory) record; the name
+ /// in the first D record is ignored, as the destination itself is used for it.
+ ///
+ [TestClass]
+ public class ScpClientTest_Download_PathAndDirectoryInfo_Names : ScpClientTranscriptTestBase
+ {
+ [TestMethod]
+ [DataRow("file.txt")]
+ [DataRow(".hidden")]
+ [DataRow("файл.txt")]
+ [DataRow("~")]
+ public void PlainName_ShouldBeDownloaded(string name)
+ {
+ var result = DownloadDirectory(new ScpServerTranscript()
+ .Line("D0755 0 remote")
+ .Line("D0755 0 " + name)
+ .File(name, "content")
+ .Line("E")
+ .Line("E"));
+
+ Assert.IsNull(result.Exception, result.Exception?.ToString());
+ CollectionAssert.AreEquivalent(new[] { name, name + "/" + name }, ListEntries(Destination));
+ }
+
+ [TestMethod]
+ [OSCondition(ConditionMode.Exclude, OperatingSystems.Windows, IgnoreMessage = "These are ordinary names on Unix-like platforms.")]
+ [DataRow("...")]
+ [DataRow(".. ")]
+ [DataRow("file.")]
+ [DataRow("file ")]
+ [DataRow("CON")]
+ [DataRow("PROGRA~1")]
+ [DataRow("name\\with\\backslashes")]
+ [DataRow("file.txt:stream")]
+ [DataRow("C:file.txt")]
+ [DataRow("\\\\?\\C:\\file.txt")]
+ [DataRow("\\\\.\\C:\\file.txt")]
+ [DataRow("\\??\\C:\\file.txt")]
+ public void OrdinaryUnixName_ShouldBeDownloaded(string name)
+ {
+ var result = DownloadDirectory(new ScpServerTranscript()
+ .Line("D0755 0 remote")
+ .Line("D0755 0 " + name)
+ .File(name, "content")
+ .Line("E")
+ .Line("E"));
+
+ Assert.IsNull(result.Exception, result.Exception?.ToString());
+ CollectionAssert.AreEquivalent(new[] { name, name + "/" + name }, ListEntries(Destination));
+ }
+
+ [TestMethod]
+ [OSCondition(OperatingSystems.Windows, IgnoreMessage = "The \\\\?\\ prefix only exists on Windows.")]
+ public void PlainName_LiteralPathDestination_OnWindows_ShouldBeDownloaded()
+ {
+ var result = Download("sub", isDirectory: true, LiteralPath(Destination));
+
+ Assert.IsNull(result.Exception, result.Exception?.ToString());
+ CollectionAssert.AreEquivalent(new[] { "sub", "sub/inner.txt" }, ListEntries(Destination));
+ }
+
+ [TestMethod]
+ [DataRow(".")]
+ [DataRow("..")]
+ [DataRow("sub/child")]
+ [DataRow("../escaped")]
+ [DataRow("/etc/passwd")]
+ [DataRow("nul\0byte")]
+ public void InvalidName_ShouldThrowScpException(string name)
+ {
+ foreach (var isDirectory in new[] { false, true })
+ {
+ var result = Download(name, isDirectory);
+
+ AssertNothingOutsideDestination(context: name);
+ Assert.IsInstanceOfType(result.Exception, result.Exception?.ToString());
+ Assert.Contains("not a valid local name", result.Exception.Message, StringComparison.Ordinal);
+ Assert.IsEmpty(Directory.GetFileSystemEntries(Destination));
+
+ // The record with the invalid name is reported to the server instead of being acknowledged:
+ // the only success statuses are for the exec request and the first D record.
+ Assert.AreEqual(2, result.SentData.Count(data => data.Length == 1 && data[0] == 0));
+ Assert.IsTrue(result.SentData.Any(data => data.Length == 1 && data[0] == 1), "No error status was sent to the server.");
+ }
+ }
+
+ [TestMethod]
+ [OSCondition(OperatingSystems.Windows, IgnoreMessage = "These names are only invalid, or changed by path normalization, on Windows.")]
+ [DataRow("..\\escaped")]
+ [DataRow("C:\\Windows\\file.txt")]
+ [DataRow("C:file.txt")]
+ [DataRow("file.txt:stream")]
+ [DataRow("\\\\?\\C:\\file.txt")]
+ [DataRow("\\\\.\\C:\\file.txt")]
+ [DataRow("\\??\\C:\\file.txt")]
+ [DataRow("\\\\server\\share\\file.txt")]
+ [DataRow("...")] // Path normalization removes trailing dots and spaces.
+ [DataRow(".. ")]
+ [DataRow(" ..")]
+ [DataRow(". ")]
+ [DataRow("file.")]
+ [DataRow("file ")]
+ public void InvalidName_OnWindows_ShouldThrowScpException(string name)
+ {
+ foreach (var isDirectory in new[] { false, true })
+ {
+ var result = Download(name, isDirectory);
+
+ AssertNothingOutsideDestination(context: name);
+ Assert.IsInstanceOfType(result.Exception, result.Exception?.ToString());
+ Assert.Contains("not a valid local name", result.Exception.Message, StringComparison.Ordinal);
+ Assert.IsEmpty(Directory.GetFileSystemEntries(Destination));
+ Assert.AreEqual(2, result.SentData.Count(data => data.Length == 1 && data[0] == 0));
+ Assert.IsTrue(result.SentData.Any(data => data.Length == 1 && data[0] == 1), "No error status was sent to the server.");
+ }
+ }
+
+ [TestMethod]
+ [OSCondition(OperatingSystems.Windows, IgnoreMessage = "The \\\\?\\ prefix only exists on Windows.")]
+ [DataRow(".")]
+ [DataRow("..")]
+ [DataRow("...")]
+ [DataRow("file.")]
+ [DataRow("file ")]
+ public void NameChangedByNormalization_LiteralPathDestination_OnWindows_ShouldStayWithinDestination(string name)
+ {
+ // Path.GetFullPath does not normalize a path with the \\?\ prefix, so these names reach the
+ // file system as they are: "." and ".." are refused with an IOException, and the others are
+ // created with exactly that name. Either way, nothing may be written outside the destination.
+ foreach (var isDirectory in new[] { false, true })
+ {
+ var result = Download(name, isDirectory, LiteralPath(Destination));
+
+ AssertNothingOutsideDestination(context: name);
+ Assert.IsTrue(result.Exception is null or ScpException or IOException, result.Exception?.ToString());
+
+ ClearDestination();
+ }
+ }
+
+ [TestMethod]
+ [OSCondition(OperatingSystems.Windows, IgnoreMessage = "Device names are only reserved on Windows.")]
+ [DataRow("CON")]
+ [DataRow("con.txt")]
+ [DataRow("CON .txt")]
+ [DataRow("NUL")]
+ [DataRow("COM1")]
+ [DataRow("LPT1")]
+ public void ReservedDeviceName_OnWindows_ShouldBeRejectedOrWrittenToDestination(string name)
+ {
+ // Where Windows treats the name as a device in any directory, Path.GetFullPath resolves it to
+ // a device path (such as \\.\CON), which is not a child of the destination and is rejected.
+ // Where it does not (as in recent versions of Windows), it is an ordinary name.
+ foreach (var isDirectory in new[] { false, true })
+ {
+ var result = Download(name, isDirectory);
+
+ AssertNothingOutsideDestination(context: name);
+
+ if (result.Exception is null)
+ {
+ // Read through a literal path, so that the name cannot be redirected to a device.
+ var file = isDirectory ? Path.Combine(Destination, name, "inner.txt") : Path.Combine(Destination, name);
+ Assert.AreEqual("content", File.ReadAllText(LiteralPath(file)));
+ }
+ else
+ {
+ Assert.IsInstanceOfType(result.Exception, result.Exception.ToString());
+ Assert.Contains("not a valid local name", result.Exception.Message, StringComparison.Ordinal);
+ }
+
+ ClearDestination();
+ }
+ }
+
+ [TestMethod]
+ [OSCondition(OperatingSystems.Windows, IgnoreMessage = "8.3 short names only exist on Windows.")]
+ public void ShortNameOfExistingDirectory_OnWindows_ShouldStayWithinDestination()
+ {
+ var existing = Directory.CreateDirectory(Path.Combine(Destination, "LongDirectoryName"));
+
+ var buffer = new char[1024];
+ var length = GetShortPathName(existing.FullName, buffer, (uint)buffer.Length);
+ var shortName = length is 0 or > 1024 ? existing.Name : Path.GetFileName(new string(buffer, 0, (int)length));
+
+ if (string.Equals(shortName, existing.Name, StringComparison.OrdinalIgnoreCase))
+ {
+ Assert.Inconclusive("8.3 short names are not generated on this volume.");
+ }
+
+ var result = Download(shortName, isDirectory: true);
+
+ // .NET Framework's Path.GetFullPath expands a short name, so the resolved name differs from
+ // the name that was sent and is rejected. Elsewhere, the short name is used as-is, and the
+ // file system resolves it to the existing directory. Either way, the download must stay
+ // within the destination.
+ if (result.Exception is null)
+ {
+ Assert.AreEqual("content", File.ReadAllText(Path.Combine(existing.FullName, "inner.txt")));
+ }
+ else
+ {
+ Assert.IsInstanceOfType(result.Exception);
+ Assert.Contains("not a valid local name", result.Exception.Message, StringComparison.Ordinal);
+ }
+
+ AssertNothingOutsideDestination();
+ }
+
+ [TestMethod]
+ public void DestinationWithTrailingSeparator_ShouldBeDownloaded()
+ {
+ var result = Download("sub", isDirectory: true, Destination + Path.DirectorySeparatorChar);
+
+ Assert.IsNull(result.Exception, result.Exception?.ToString());
+ CollectionAssert.AreEquivalent(new[] { "sub", "sub/inner.txt" }, ListEntries(Destination));
+ }
+
+#pragma warning disable SYSLIB1054 // Use LibraryImport; not available on .NET Framework, and only used by a Windows-only test.
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "GetShortPathNameW")]
+ private static extern uint GetShortPathName(string longPath, [Out] char[] shortPath, uint bufferLength);
+#pragma warning restore SYSLIB1054
+
+ ///
+ /// Downloads a remote directory containing either a file named , or a
+ /// directory named that contains a file named inner.txt.
+ ///
+ private ScpTranscriptResult Download(string name, bool isDirectory, string destinationPath = null)
+ {
+ var transcript = new ScpServerTranscript().Line("D0755 0 remote");
+
+ if (isDirectory)
+ {
+ _ = transcript.Line("D0755 0 " + name).File("inner.txt", "content").Line("E");
+ }
+ else
+ {
+ _ = transcript.File(name, "content");
+ }
+
+ return DownloadDirectory(transcript.Line("E"), destinationPath);
+ }
+ }
+}
diff --git a/test/Renci.SshNet.Tests/Classes/ScpClientTest_EnsureValidLocalName.cs b/test/Renci.SshNet.Tests/Classes/ScpClientTest_EnsureValidLocalName.cs
deleted file mode 100644
index 866ce823..00000000
--- a/test/Renci.SshNet.Tests/Classes/ScpClientTest_EnsureValidLocalName.cs
+++ /dev/null
@@ -1,102 +0,0 @@
-using Microsoft.VisualStudio.TestTools.UnitTesting;
-
-using Renci.SshNet.Common;
-
-namespace Renci.SshNet.Tests.Classes
-{
- ///
- /// Tests for , which guards the recursive
- /// download against server-supplied SCP file/directory names that would write outside the
- /// caller-supplied destination directory.
- ///
- [TestClass]
- public class ScpClientTest_EnsureValidLocalName
- {
- [TestMethod]
- public void PlainFileName_DoesNotThrow()
- {
- ScpClient.EnsureValidLocalName("owned.txt");
- ScpClient.EnsureValidLocalName("2024-report.tar.gz");
- ScpClient.EnsureValidLocalName("file with spaces.dat");
- ScpClient.EnsureValidLocalName("...");
- }
-
- [TestMethod]
- public void UnicodeFileName_DoesNotThrow()
- {
- ScpClient.EnsureValidLocalName("файл.txt");
- }
-
- [TestMethod]
- public void Empty_ThrowsScpException()
- {
- _ = Assert.ThrowsExactly(() => ScpClient.EnsureValidLocalName(string.Empty));
- }
-
- [TestMethod]
- public void CurrentDirectory_ThrowsScpException()
- {
- _ = Assert.ThrowsExactly(() => ScpClient.EnsureValidLocalName("."));
- }
-
- [TestMethod]
- public void ParentDirectory_ThrowsScpException()
- {
- _ = Assert.ThrowsExactly(() => ScpClient.EnsureValidLocalName(".."));
- }
-
- [TestMethod]
- public void ForwardSlashPath_ThrowsScpException()
- {
- // '/' is an invalid file name character on every platform.
- _ = Assert.ThrowsExactly(() => ScpClient.EnsureValidLocalName("sub/child"));
- _ = Assert.ThrowsExactly(() => ScpClient.EnsureValidLocalName("../escaped/owned.txt"));
- }
-
- [TestMethod]
- public void RootedUnixPath_ThrowsScpException()
- {
- // Contains '/', so it is rejected regardless of platform.
- _ = Assert.ThrowsExactly(() => ScpClient.EnsureValidLocalName("/tmp/sshnet-owned.txt"));
- }
-
- [TestMethod]
- public void NullCharacter_ThrowsScpException()
- {
- // NUL is an invalid file name character on every platform.
- _ = Assert.ThrowsExactly(() => ScpClient.EnsureValidLocalName("safe\0evil"));
- }
-
- [TestMethod]
- [OSCondition(OperatingSystems.Windows, IgnoreMessage = "'\\' is only a path separator (and invalid file name char) on Windows.")]
- public void BackslashPath_OnWindows_ThrowsScpException()
- {
- _ = Assert.ThrowsExactly(() => ScpClient.EnsureValidLocalName("..\\escaped\\owned.txt"));
- _ = Assert.ThrowsExactly(() => ScpClient.EnsureValidLocalName("sub\\child"));
- }
-
- [TestMethod]
- [OSCondition(OperatingSystems.Windows, IgnoreMessage = "':' is only an invalid file name char on Windows.")]
- public void DriveQualifiedPath_OnWindows_ThrowsScpException()
- {
- _ = Assert.ThrowsExactly(() => ScpClient.EnsureValidLocalName("C:\\Windows\\System32\\evil.dll"));
- }
-
- [TestMethod]
- [OSCondition(OperatingSystems.Windows, IgnoreMessage = "':' is only an invalid file name char on Windows.")]
- public void AlternateDataStreamName_OnWindows_ThrowsScpException()
- {
- // NTFS alternate data stream syntax: writes a hidden stream of "safe.txt".
- _ = Assert.ThrowsExactly(() => ScpClient.EnsureValidLocalName("safe.txt:evil"));
- }
-
- [TestMethod]
- [OSCondition(ConditionMode.Exclude, OperatingSystems.Windows, IgnoreMessage = "'\\' is a valid file name byte only on Unix-like platforms.")]
- public void BackslashName_OnUnix_DoesNotThrow()
- {
- // On Unix, '\' is an ordinary file name byte and cannot traverse directories,
- // so a name containing it must remain accepted (no behaviour change).
- ScpClient.EnsureValidLocalName("name\\with\\backslashes.txt");
- }
- }
-}
diff --git a/test/Renci.SshNet.Tests/Classes/ScpClientTest_ResolveChild_RootDirectory.cs b/test/Renci.SshNet.Tests/Classes/ScpClientTest_ResolveChild_RootDirectory.cs
new file mode 100644
index 00000000..842f4cce
--- /dev/null
+++ b/test/Renci.SshNet.Tests/Classes/ScpClientTest_ResolveChild_RootDirectory.cs
@@ -0,0 +1,35 @@
+using System.IO;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Renci.SshNet.Tests.Classes
+{
+ ///
+ /// Tests for and
+ /// with a root directory as the destination,
+ /// which a download test cannot use. Other cases are covered by
+ /// .
+ ///
+ [TestClass]
+ public class ScpClientTest_ResolveChild_RootDirectory
+ {
+ [TestMethod]
+ public void RootDirectory_ShouldReturnDirectChild()
+ {
+ var root = ScpClient.NormalizeDirectory(Path.GetPathRoot(Path.GetTempPath()));
+
+ Assert.AreEqual(Path.Combine(root, "file.txt"), ScpClient.ResolveChild(root, "file.txt"));
+ }
+
+ [TestMethod]
+ [DataRow(".")]
+ [DataRow("..")]
+ public void RootDirectory_DotOrDotDot_ShouldBeRejected(string name)
+ {
+ // "." and ".." resolve to the root itself, which has no parent directory.
+ var root = ScpClient.NormalizeDirectory(Path.GetPathRoot(Path.GetTempPath()));
+
+ Assert.IsNull(ScpClient.ResolveChild(root, name));
+ }
+ }
+}