#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));
}
}
}
}