#nullable enable using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Net; using System.Text.RegularExpressions; using System.Threading; using Renci.SshNet.Channels; using Renci.SshNet.Common; namespace Renci.SshNet { /// /// Provides SCP client functionality. /// /// /// /// More information on the SCP protocol is available here: https://github.com/net-ssh/net-scp/blob/master/lib/net/scp.rb. /// /// /// Known issues in OpenSSH: /// /// /// Recursive download (-prf) does not deal well with specific UTF-8 and newline characters. /// Recursive update does not support empty path for uploading to home directory. /// /// /// /// public partial class ScpClient : BaseClient { private const string FileInfoPattern = @"C(?\d{4}) (?\d+) (?.+)"; private const string DirectoryInfoPattern = @"D(?\d{4}) (?\d+) (?.+)"; private const string TimestampPattern = @"T(?\d+) 0 (?\d+) 0"; #if NET private static readonly Regex FileInfoRegex = GetFileInfoRegex(); private static readonly Regex DirectoryInfoRegex = GetDirectoryInfoRegex(); private static readonly Regex TimestampRegex = GetTimestampRegex(); [GeneratedRegex(FileInfoPattern)] private static partial Regex GetFileInfoRegex(); [GeneratedRegex(DirectoryInfoPattern)] private static partial Regex GetDirectoryInfoRegex(); [GeneratedRegex(TimestampPattern)] private static partial Regex GetTimestampRegex(); #else private static readonly Regex FileInfoRegex = new Regex(FileInfoPattern, RegexOptions.Compiled); private static readonly Regex DirectoryInfoRegex = new Regex(DirectoryInfoPattern, RegexOptions.Compiled); private static readonly Regex TimestampRegex = new Regex(TimestampPattern, RegexOptions.Compiled); #endif private static readonly byte[] SuccessConfirmationCode = { 0 }; private static readonly byte[] ErrorConfirmationCode = { 1 }; private IRemotePathTransformation _remotePathTransformation; private TimeSpan _operationTimeout; /// /// Gets or sets the operation timeout. /// /// /// The timeout to wait until an operation completes. The default value is negative /// one (-1) milliseconds, which indicates an infinite time-out period. /// public TimeSpan OperationTimeout { get { return _operationTimeout; } set { value.EnsureValidTimeout(nameof(OperationTimeout)); _operationTimeout = value; } } /// /// Gets or sets the size of the buffer. /// /// /// The size of the buffer. The default buffer size is 16384 bytes. /// public uint BufferSize { get; set; } /// /// Gets or sets the transformation to apply to remote paths. /// /// /// The transformation to apply to remote paths. The default is . /// /// is . /// /// /// This transformation is applied to the remote file or directory path that is passed to the /// scp command. /// /// /// See for the transformations that are supplied /// out-of-the-box with SSH.NET. /// /// public IRemotePathTransformation RemotePathTransformation { get { return _remotePathTransformation; } set { ThrowHelper.ThrowIfNull(value); _remotePathTransformation = value; } } /// /// Occurs when downloading file. /// public event EventHandler? Downloading; /// /// Occurs when uploading file. /// public event EventHandler? Uploading; /// /// Initializes a new instance of the class. /// /// The connection info. /// is . public ScpClient(ConnectionInfo connectionInfo) : this(connectionInfo, ownsConnectionInfo: false) { } /// /// Initializes a new instance of the class. /// /// Connection host. /// Connection port. /// Authentication username. /// Authentication password. /// is . /// is invalid, or is or contains only whitespace characters. /// is not within and . [SuppressMessage("Microsoft.Reliability", "CA2000:DisposeObjectsBeforeLosingScope", Justification = "Disposed in Dispose(bool) method.")] public ScpClient(string host, int port, string username, string password) : this(new PasswordConnectionInfo(host, port, username, password), ownsConnectionInfo: true) { } /// /// Initializes a new instance of the class. /// /// Connection host. /// Authentication username. /// Authentication password. /// is . /// is invalid, or is or contains only whitespace characters. public ScpClient(string host, string username, string password) : this(host, ConnectionInfo.DefaultPort, username, password) { } /// /// Initializes a new instance of the class. /// /// Connection host. /// Connection port. /// Authentication username. /// Authentication private key file(s) . /// is . /// is invalid, -or- is or contains only whitespace characters. /// is not within and . [SuppressMessage("Microsoft.Reliability", "CA2000:DisposeObjectsBeforeLosingScope", Justification = "Disposed in Dispose(bool) method.")] public ScpClient(string host, int port, string username, params IPrivateKeySource[] keyFiles) : this(new PrivateKeyConnectionInfo(host, port, username, keyFiles), ownsConnectionInfo: true) { } /// /// Initializes a new instance of the class. /// /// Connection host. /// Authentication username. /// Authentication private key file(s) . /// is . /// is invalid, -or- is or contains only whitespace characters. public ScpClient(string host, string username, params IPrivateKeySource[] keyFiles) : this(host, ConnectionInfo.DefaultPort, username, keyFiles) { } /// /// Initializes a new instance of the class. /// /// The connection info. /// Specified whether this instance owns the connection info. /// is . /// /// If is , then the /// connection info will be disposed when this instance is disposed. /// private ScpClient(ConnectionInfo connectionInfo, bool ownsConnectionInfo) : this(connectionInfo, ownsConnectionInfo, new ServiceFactory()) { } /// /// Initializes a new instance of the class. /// /// The connection info. /// Specified whether this instance owns the connection info. /// The factory to use for creating new services. /// is . /// is . /// /// If is , then the /// connection info will be disposed when this instance is disposed. /// internal ScpClient(ConnectionInfo connectionInfo, bool ownsConnectionInfo, IServiceFactory serviceFactory) : base(connectionInfo, ownsConnectionInfo, serviceFactory) { OperationTimeout = Timeout.InfiniteTimeSpan; BufferSize = 1024 * 16; _remotePathTransformation = serviceFactory.CreateRemotePathDoubleQuoteTransformation(); } /// /// Uploads the specified stream to the remote host. /// /// The to upload. /// A relative or absolute path for the remote file. /// is . /// is a zero-length . /// A directory with the specified path exists on the remote host. /// The secure copy execution request was rejected by the server. /// Client is not connected. public void Upload(Stream source, string path) { if (Session is null) { throw new SshConnectionException("Client not connected."); } var posixPath = PosixPath.CreateAbsoluteOrRelativeFilePath(path); using (var input = ServiceFactory.CreatePipeStream()) using (var channel = Session.CreateChannelSession()) { channel.DataReceived += (sender, e) => input.Write(e.Data.Array!, e.Data.Offset, e.Data.Count); channel.Closed += (sender, e) => input.Dispose(); channel.Open(); // 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); UploadFileModeAndName(channel, input, source.Length, posixPath.File); UploadFileContent(channel, input, source, posixPath.File); } } /// /// Uploads the specified file to the remote host. /// /// The file system info. /// A relative or absolute path for the remote file. /// is . /// is . /// is a zero-length . /// A directory with the specified path exists on the remote host. /// The secure copy execution request was rejected by the server. /// Client is not connected. public void Upload(FileInfo fileInfo, string path) { ThrowHelper.ThrowIfNull(fileInfo); if (Session is null) { throw new SshConnectionException("Client not connected."); } var posixPath = PosixPath.CreateAbsoluteOrRelativeFilePath(path); using (var input = ServiceFactory.CreatePipeStream()) using (var channel = Session.CreateChannelSession()) { channel.DataReceived += (sender, e) => input.Write(e.Data.Array!, e.Data.Offset, e.Data.Count); channel.Closed += (sender, e) => input.Dispose(); channel.Open(); // 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($"scp -t -d {_remotePathTransformation.Transform(posixPath.Directory)}")) { throw SecureExecutionRequestRejectedException(); } CheckReturnCode(input); using (var source = fileInfo.OpenRead()) { UploadTimes(channel, input, fileInfo); UploadFileModeAndName(channel, input, source.Length, posixPath.File); UploadFileContent(channel, input, source, fileInfo.Name); } } } /// /// Uploads the specified directory to the remote host. /// /// The directory info. /// A relative or absolute path for the remote directory. /// is . /// is . /// is a zero-length string. /// does not exist on the remote host, is not a directory or the user does not have the required permission. /// The secure copy execution request was rejected by the server. /// Client is not connected. public void Upload(DirectoryInfo directoryInfo, string path) { ThrowHelper.ThrowIfNull(directoryInfo); ThrowHelper.ThrowIfNullOrEmpty(path); if (Session is null) { throw new SshConnectionException("Client not connected."); } using (var input = ServiceFactory.CreatePipeStream()) using (var channel = Session.CreateChannelSession()) { channel.DataReceived += (sender, e) => input.Write(e.Data.Array!, e.Data.Offset, e.Data.Count); channel.Closed += (sender, e) => input.Dispose(); channel.Open(); // 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($"scp -r -p -d -t {_remotePathTransformation.Transform(path)}")) { throw SecureExecutionRequestRejectedException(); } CheckReturnCode(input); UploadDirectoryContent(channel, input, directoryInfo); } } /// /// Downloads the specified file from the remote host to local file. /// /// Remote host file name. /// Local file information. /// is . /// is or empty. /// exists on the remote host, and is not a regular file. /// The secure copy execution request was rejected by the server. /// Client is not connected. public void Download(string filename, FileInfo fileInfo) { ThrowHelper.ThrowIfNullOrEmpty(filename); ThrowHelper.ThrowIfNull(fileInfo); if (Session is null) { throw new SshConnectionException("Client not connected."); } using (var input = ServiceFactory.CreatePipeStream()) using (var channel = Session.CreateChannelSession()) { channel.DataReceived += (sender, e) => input.Write(e.Data.Array!, e.Data.Offset, e.Data.Count); channel.Closed += (sender, e) => input.Dispose(); channel.Open(); // Send channel command request if (!channel.SendExecRequest($"scp -pf {_remotePathTransformation.Transform(filename)}")) { throw SecureExecutionRequestRejectedException(); } // Send reply SendSuccessConfirmation(channel); InternalDownload(channel, input, fileInfo); } } /// /// Downloads the specified directory from the remote host to local directory. /// /// Remote host directory name. /// Local directory information. /// is or empty. /// is . /// File or directory with the specified path does not exist on the remote host. /// The secure copy execution request was rejected by the server. /// Client is not connected. public void Download(string directoryName, DirectoryInfo directoryInfo) { ThrowHelper.ThrowIfNullOrEmpty(directoryName); ThrowHelper.ThrowIfNull(directoryInfo); if (Session is null) { throw new SshConnectionException("Client not connected."); } using (var input = ServiceFactory.CreatePipeStream()) using (var channel = Session.CreateChannelSession()) { channel.DataReceived += (sender, e) => input.Write(e.Data.Array!, e.Data.Offset, e.Data.Count); channel.Closed += (sender, e) => input.Dispose(); channel.Open(); // Send channel command request if (!channel.SendExecRequest($"scp -prf {_remotePathTransformation.Transform(directoryName)}")) { throw SecureExecutionRequestRejectedException(); } // Send reply SendSuccessConfirmation(channel); InternalDownload(channel, input, directoryInfo); } } /// /// Downloads the specified file from the remote host to the stream. /// /// A relative or absolute path for the remote file. /// The to download the remote file to. /// is or contains only whitespace characters. /// is . /// exists on the remote host, and is not a regular file. /// The secure copy execution request was rejected by the server. /// Client is not connected. public void Download(string filename, Stream destination) { ThrowHelper.ThrowIfNullOrWhiteSpace(filename); ThrowHelper.ThrowIfNull(destination); if (Session is null) { throw new SshConnectionException("Client not connected."); } using (var input = ServiceFactory.CreatePipeStream()) using (var channel = Session.CreateChannelSession()) { channel.DataReceived += (sender, e) => input.Write(e.Data.Array!, e.Data.Offset, e.Data.Count); channel.Closed += (sender, e) => input.Dispose(); channel.Open(); // Send channel command request if (!channel.SendExecRequest(string.Concat("scp -f ", _remotePathTransformation.Transform(filename)))) { throw SecureExecutionRequestRejectedException(); } SendSuccessConfirmation(channel); // Send reply var message = ReadString(input); var match = FileInfoRegex.Match(message); if (match.Success) { // Read file SendSuccessConfirmation(channel); // Send reply var length = long.Parse(match.Result("${length}"), CultureInfo.InvariantCulture); var fileName = match.Result("${filename}"); InternalDownload(channel, input, destination, fileName, length); } else { SendErrorConfirmation(channel, string.Format("\"{0}\" is not valid protocol message.", message)); } } } private static void SendData(IChannel channel, byte[] buffer, int length) { channel.SendData(buffer, 0, length); } private static void SendData(IChannel channel, byte[] buffer) { channel.SendData(buffer); } private static int ReadByte(Stream stream) { var b = stream.ReadByte(); if (b == -1) { throw new SshException("Stream has been closed."); } return b; } private static SshException SecureExecutionRequestRejectedException() { throw new SshException("Secure copy execution request was rejected by the server. Please consult the server logs."); } /// /// Sets mode, size and name of file being upload. /// /// The channel to perform the upload in. /// A from which any feedback from the server can be read. /// The size of the content to upload. /// The name of the file, without path, to which the content is to be uploaded. /// /// /// When the SCP transfer is already initiated for a file, a zero-length should /// be specified for . This prevents the server from uploading the /// content to a file with path <file path>/ if there's /// already a directory with this path, and allows us to receive an error response. /// /// private void UploadFileModeAndName(IChannelSession channel, Stream input, long fileSize, string serverFileName) { SendData(channel, string.Format("C0644 {0} {1}\n", fileSize, serverFileName)); CheckReturnCode(input); } /// /// Uploads the content of a file. /// /// The channel to perform the upload in. /// A from which any feedback from the server can be read. /// The content to upload. /// The name of the remote file, without path, to which the content is uploaded. /// /// is only used for raising the event. /// private void UploadFileContent(IChannelSession channel, Stream input, Stream source, string remoteFileName) { var totalLength = source.Length; var buffer = new byte[BufferSize]; var read = source.Read(buffer, 0, buffer.Length); long totalRead = 0; while (read > 0) { SendData(channel, buffer, read); totalRead += read; RaiseUploadingEvent(remoteFileName, totalLength, totalRead); read = source.Read(buffer, 0, buffer.Length); } SendSuccessConfirmation(channel); CheckReturnCode(input); } private void RaiseDownloadingEvent(string filename, long size, long downloaded) { Downloading?.Invoke(this, new ScpDownloadEventArgs(filename, size, downloaded)); } private void RaiseUploadingEvent(string filename, long size, long uploaded) { Uploading?.Invoke(this, new ScpUploadEventArgs(filename, size, uploaded)); } private static void SendSuccessConfirmation(IChannel channel) { SendData(channel, SuccessConfirmationCode); } private void SendErrorConfirmation(IChannel channel, string message) { SendData(channel, ErrorConfirmationCode); SendData(channel, string.Concat(message, "\n")); } /// /// Checks the return code. /// /// The output stream. private void CheckReturnCode(Stream input) { var b = ReadByte(input); if (b > 0) { var errorText = ReadString(input); throw new ScpException(errorText); } } private void SendData(IChannel channel, string command) { channel.SendData(ConnectionInfo.Encoding.GetBytes(command)); } /// /// Read a LF-terminated string from the . /// /// The to read from. /// /// The string without trailing LF. /// private string ReadString(Stream stream) { var hasError = false; var buffer = new List(); var b = ReadByte(stream); if (b is 1 or 2) { hasError = true; b = ReadByte(stream); } while (b != SshNet.Session.LineFeed) { buffer.Add((byte)b); b = ReadByte(stream); } var readBytes = buffer.ToArray(); if (hasError) { throw new ScpException(ConnectionInfo.Encoding.GetString(readBytes, 0, readBytes.Length)); } return ConnectionInfo.Encoding.GetString(readBytes, 0, readBytes.Length); } /// /// Uploads the and /// of the next file or directory to upload. /// /// The channel to perform the upload in. /// A from which any feedback from the server can be read. /// The file or directory to upload. private void UploadTimes(IChannelSession channel, Stream input, FileSystemInfo fileOrDirectory) { #if NET var zeroTime = DateTime.UnixEpoch; #else var zeroTime = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); #endif var modificationSeconds = (long)(fileOrDirectory.LastWriteTimeUtc - zeroTime).TotalSeconds; var accessSeconds = (long)(fileOrDirectory.LastAccessTimeUtc - zeroTime).TotalSeconds; SendData(channel, string.Format(CultureInfo.InvariantCulture, "T{0} 0 {1} 0\n", modificationSeconds, accessSeconds)); CheckReturnCode(input); } /// /// Upload the files and subdirectories in the specified directory. /// /// The channel to perform the upload in. /// A from which any feedback from the server can be read. /// The directory to upload. private void UploadDirectoryContent(IChannelSession channel, Stream input, DirectoryInfo directoryInfo) { // Upload files var files = directoryInfo.GetFiles(); foreach (var file in files) { using (var source = file.OpenRead()) { UploadTimes(channel, input, file); UploadFileModeAndName(channel, input, source.Length, file.Name); UploadFileContent(channel, input, source, file.Name); } } // Upload directories var directories = directoryInfo.GetDirectories(); foreach (var directory in directories) { UploadTimes(channel, input, directory); UploadDirectoryModeAndName(channel, input, directory.Name); UploadDirectoryContent(channel, input, directory); } // Mark upload of current directory complete SendData(channel, "E\n"); CheckReturnCode(input); } /// /// Sets mode and name of the directory being upload. /// private void UploadDirectoryModeAndName(IChannelSession channel, Stream input, string directoryName) { SendData(channel, string.Format("D0755 0 {0}\n", directoryName)); CheckReturnCode(input); } private void InternalDownload(IChannel channel, Stream input, Stream output, string filename, long length) { var buffer = new byte[Math.Min(length, BufferSize)]; var needToRead = length; do { var read = input.Read(buffer, 0, (int)Math.Min(needToRead, BufferSize)); output.Write(buffer, 0, read); RaiseDownloadingEvent(filename, length, length - needToRead); needToRead -= read; } while (needToRead > 0); output.Flush(); // Raise one more time when file downloaded RaiseDownloadingEvent(filename, length, length - needToRead); // Send confirmation byte after last data byte was read SendSuccessConfirmation(channel); CheckReturnCode(input); } private void InternalDownload(IChannelSession channel, Stream input, FileSystemInfo fileSystemInfo) { var modifiedTime = DateTime.Now; var accessedTime = DateTime.Now; var startDirectoryFullName = fileSystemInfo.FullName; var currentDirectoryFullName = startDirectoryFullName; var directoryCounter = 0; while (true) { var message = ReadString(input); if (message == "E") { SendSuccessConfirmation(channel); // Send reply directoryCounter--; if (directoryCounter == 0) { break; } var currentDirectoryParent = new DirectoryInfo(currentDirectoryFullName).Parent; Debug.Assert(currentDirectoryParent is not null, $"Should be {directoryCounter.ToString(CultureInfo.InvariantCulture)} levels deeper than {startDirectoryFullName}."); currentDirectoryFullName = currentDirectoryParent.FullName; continue; } var match = DirectoryInfoRegex.Match(message); if (match.Success) { SendSuccessConfirmation(channel); // Send reply // Read directory var filename = match.Result("${filename}"); DirectoryInfo newDirectoryInfo; if (directoryCounter > 0) { newDirectoryInfo = Directory.CreateDirectory(Path.Combine(currentDirectoryFullName, filename)); newDirectoryInfo.LastAccessTime = accessedTime; newDirectoryInfo.LastWriteTime = modifiedTime; } else { // Don't create directory for first level newDirectoryInfo = (DirectoryInfo)fileSystemInfo; } directoryCounter++; currentDirectoryFullName = newDirectoryInfo.FullName; continue; } match = FileInfoRegex.Match(message); if (match.Success) { // Read file SendSuccessConfirmation(channel); // Send reply var length = long.Parse(match.Result("${length}"), CultureInfo.InvariantCulture); var fileName = match.Result("${filename}"); if (fileSystemInfo is not FileInfo fileInfo) { fileInfo = new FileInfo(Path.Combine(currentDirectoryFullName, fileName)); } using (var output = fileInfo.OpenWrite()) { InternalDownload(channel, input, output, fileName, length); } fileInfo.LastAccessTime = accessedTime; fileInfo.LastWriteTime = modifiedTime; if (directoryCounter == 0) { break; } continue; } match = TimestampRegex.Match(message); if (match.Success) { // Read timestamp SendSuccessConfirmation(channel); // Send reply var mtime = long.Parse(match.Result("${mtime}"), CultureInfo.InvariantCulture); var atime = long.Parse(match.Result("${atime}"), CultureInfo.InvariantCulture); #if NET var zeroTime = DateTime.UnixEpoch; #else var zeroTime = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); #endif modifiedTime = zeroTime.AddSeconds(mtime); accessedTime = zeroTime.AddSeconds(atime); continue; } SendErrorConfirmation(channel, string.Format("\"{0}\" is not valid protocol message.", message)); } } } }