using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Net; using System.Text.RegularExpressions; 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 Message = "filename"; private static readonly Regex FileInfoRe = new Regex(@"C(?\d{4}) (?\d+) (?.+)"); private static readonly byte[] SuccessConfirmationCode = { 0 }; private static readonly byte[] ErrorConfirmationCode = { 1 }; private IRemotePathTransformation _remotePathTransformation; /// /// 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; set; } /// /// 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 null. /// /// /// 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 { if (value is null) { throw new ArgumentNullException(nameof(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 null. public ScpClient(ConnectionInfo connectionInfo) : this(connectionInfo, ownsConnectionInfo: false) { } /// /// Initializes a new instance of the class. /// /// Connection host. /// Connection port. /// Authentication username. /// Authentication password. /// is null. /// is invalid, or is null 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 null. /// is invalid, or is null 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 null. /// is invalid, -or- is null 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 null. /// is invalid, -or- is null 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 null. /// /// If is true, 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 null. /// is null. /// /// If is true, 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 = SshNet.Session.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 null. /// 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. 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 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); } } /// /// 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 null or contains only whitespace characters. /// is null. /// exists on the remote host, and is not a regular file. /// The secure copy execution request was rejected by the server. public void Download(string filename, Stream destination) { if (string.IsNullOrWhiteSpace(filename)) { throw new ArgumentException(Message); } if (destination is null) { throw new ArgumentNullException(nameof(destination)); } using (var input = ServiceFactory.CreatePipeStream()) using (var channel = Session.CreateChannelSession()) { channel.DataReceived += (sender, e) => input.Write(e.Data, 0, e.Data.Length); 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 = FileInfoRe.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)); } } } /// /// 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 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 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)); } 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; } /// /// 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); } private static SshException SecureExecutionRequestRejectedException() { throw new SshException("Secure copy execution request was rejected by the server. Please consult the server logs."); } } }