Skip to content

Commit 7dde512

Browse files
committed
Add a RemotePathTransformation property to ScpClient that can be used to control if and how a remote path should be transformed before passed on to the scp command.
This allows for a custom transformation (escaping/quoting) - that implements IRemotePathTransformation - to be plugged into ScpClient. Out of the box, we only provide two implementations that are exposed through the RemotePathTransformation (enum-like) class: Quote and None. Fixes issue #256.
1 parent 5128b16 commit 7dde512

11 files changed

+366
-7
lines changed

src/Renci.SshNet.NET35/Renci.SshNet.NET35.csproj

+16-1
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,9 @@
308308
<Compile Include="..\Renci.SshNet\IForwardedPort.cs">
309309
<Link>IForwardedPort.cs</Link>
310310
</Compile>
311+
<Compile Include="..\Renci.SshNet\IRemotePathTransformation.cs">
312+
<Link>IRemotePathTransformation.cs</Link>
313+
</Compile>
311314
<Compile Include="..\Renci.SshNet\IServiceFactory.cs">
312315
<Link>IServiceFactory.cs</Link>
313316
</Compile>
@@ -578,6 +581,18 @@
578581
<Compile Include="..\Renci.SshNet\ProxyTypes.cs">
579582
<Link>ProxyTypes.cs</Link>
580583
</Compile>
584+
<Compile Include="..\Renci.SshNet\RemotePathEscapeTransformation.cs">
585+
<Link>RemotePathEscapeTransformation.cs</Link>
586+
</Compile>
587+
<Compile Include="..\Renci.SshNet\RemotePathNoneTransformation.cs">
588+
<Link>RemotePathNoneTransformation.cs</Link>
589+
</Compile>
590+
<Compile Include="..\Renci.SshNet\RemotePathQuoteTransformation.cs">
591+
<Link>RemotePathQuoteTransformation.cs</Link>
592+
</Compile>
593+
<Compile Include="..\Renci.SshNet\RemotePathTransformation.cs">
594+
<Link>RemotePathTransformation.cs</Link>
595+
</Compile>
581596
<Compile Include="..\Renci.SshNet\ScpClient.cs">
582597
<Link>ScpClient.cs</Link>
583598
</Compile>
@@ -956,7 +971,7 @@
956971
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
957972
<ProjectExtensions>
958973
<VisualStudio>
959-
<UserProperties ProjectLinkerExcludeFilter="\\?desktop(\\.*)?$;\\?silverlight(\\.*)?$;\.desktop;\.silverlight;\.xaml;^service references(\\.*)?$;\.clientconfig;^web references(\\.*)?$" ProjectLinkReference="2f5f8c90-0bd1-424f-997c-7bc6280919d1" />
974+
<UserProperties ProjectLinkReference="2f5f8c90-0bd1-424f-997c-7bc6280919d1" ProjectLinkerExcludeFilter="\\?desktop(\\.*)?$;\\?silverlight(\\.*)?$;\.desktop;\.silverlight;\.xaml;^service references(\\.*)?$;\.clientconfig;^web references(\\.*)?$" />
960975
</VisualStudio>
961976
</ProjectExtensions>
962977
<!-- To modify your build process, add your task inside one of the targets below and uncomment it.

src/Renci.SshNet.Tests/Classes/Common/ExtensionsTest_ShellQuote.cs

+9
Original file line numberDiff line numberDiff line change
@@ -116,5 +116,14 @@ public void SequenceOfLineFeeds()
116116

117117
Assert.AreEqual("'one\n\ntwo'", actual);
118118
}
119+
120+
public void SequenceOfSingleQuoteAndExclamationMark()
121+
{
122+
var value = "/var/would be 'kewl'!/not?";
123+
124+
var actual = value.ShellQuote();
125+
126+
Assert.AreEqual("'/var/would be '\"'\"'kewl'\"'\"\\!'/not?'", actual);
127+
}
119128
}
120129
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace Renci.SshNet
2+
{
3+
/// <summary>
4+
/// Represents a transformation that can be applied to a remote path.
5+
/// </summary>
6+
public interface IRemotePathTransformation
7+
{
8+
/// <summary>
9+
/// Transforms the specified remote path.
10+
/// </summary>
11+
/// <param name="path">The path to transform.</param>
12+
/// <returns>
13+
/// The transformed path.
14+
/// </returns>
15+
string Transform(string path);
16+
}
17+
18+
19+
}

src/Renci.SshNet/IServiceFactory.cs

+10
Original file line numberDiff line numberDiff line change
@@ -62,5 +62,15 @@ internal partial interface IServiceFactory
6262
ISftpFileReader CreateSftpFileReader(string fileName, ISftpSession sftpSession, uint bufferSize);
6363

6464
ISftpResponseFactory CreateSftpResponseFactory();
65+
66+
/// <summary>
67+
/// Creates an <see cref="IRemotePathTransformation"/> that quotes a path in a way to be suitable
68+
/// to be used with a shell.
69+
/// </summary>
70+
/// <returns>
71+
/// An <see cref="IRemotePathTransformation"/> that quotes a path in a way to be suitable to be used
72+
/// with a shell.
73+
/// </returns>
74+
IRemotePathTransformation CreateRemotePathQuoteTransformation();
6575
}
6676
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
namespace Renci.SshNet
2+
{
3+
/// <summary>
4+
/// Performs no transformation.
5+
/// </summary>
6+
internal class RemotePathNoneTransformation : IRemotePathTransformation
7+
{
8+
/// <summary>
9+
/// Returns the specified path without applying a transformation.
10+
/// </summary>
11+
/// <param name="path">The path to transform.</param>
12+
/// <returns>
13+
/// The specified path as is.
14+
/// </returns>
15+
public string Transform(string path)
16+
{
17+
return path;
18+
}
19+
}
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
using System;
2+
using System.Text;
3+
4+
namespace Renci.SshNet
5+
{
6+
/// <summary>
7+
/// Quotes a path in a way to be suitable to be used with a shell.
8+
/// </summary>
9+
internal class RemotePathQuoteTransformation : IRemotePathTransformation
10+
{
11+
/// <summary>
12+
/// Quotes a path in a way to be suitable to be used with a shell.
13+
/// </summary>
14+
/// <param name="path">The path to transform.</param>
15+
/// <returns>
16+
/// A quoted path.
17+
/// </returns>
18+
/// <exception cref="ArgumentNullException"><paramref name="path"/> is <c>null</c>.</exception>
19+
/// <remarks>
20+
/// <para>
21+
/// If <paramref name="path"/> contains a single-quote, that character is embedded
22+
/// in quotation marks (eg. "'"). Sequences of single-quotes are grouped in a single
23+
/// pair of quotation marks.
24+
/// </para>
25+
/// <para>
26+
/// If a shell command contains an exclamation mark (!), the C-Shell interprets it as a
27+
/// meta-character for history substitution. This even works inside single-quotes or
28+
/// quotation marks, unless escaped with a backslash (\).
29+
/// </para>
30+
/// <para>
31+
/// References:
32+
/// <list type="bullet">
33+
/// <item>
34+
/// <description><a href="http://pubs.opengroup.org/onlinepubs/7908799/xcu/chap2.html">Shell Command Language</a></description>
35+
/// </item>
36+
/// <item>
37+
/// <description><a href="https://earthsci.stanford.edu/computing/unix/shell/specialchars.php">Unix C-Shell special characters and their uses</a></description>
38+
/// </item>
39+
/// <item>
40+
/// <description><a href="https://docstore.mik.ua/orelly/unix3/upt/ch27_13.htm">Differences Between Bourne and C Shell Quoting</a></description>
41+
/// </item>
42+
/// </list>
43+
/// </para>
44+
/// </remarks>
45+
/// <returns>
46+
/// The transformed path.
47+
/// </returns>
48+
/// <exception cref="ArgumentNullException"><paramref name="path"/> is <c>null</c>.</exception>
49+
public string Transform(string path)
50+
{
51+
if (path == null)
52+
{
53+
throw new ArgumentNullException("path");
54+
}
55+
56+
// result is at least value and (likely) leading/trailing single-quotes
57+
var sb = new StringBuilder(path.Length + 2);
58+
var state = ShellQuoteState.Unquoted;
59+
60+
foreach (var c in path)
61+
{
62+
switch (c)
63+
{
64+
case '\'':
65+
// embed a single-quote in quotes
66+
switch (state)
67+
{
68+
case ShellQuoteState.Unquoted:
69+
// Start quoted string
70+
sb.Append('"');
71+
break;
72+
case ShellQuoteState.Quoted:
73+
// Continue quoted string
74+
break;
75+
case ShellQuoteState.SingleQuoted:
76+
// Close single-quoted string
77+
sb.Append('\'');
78+
// Start quoted string
79+
sb.Append('"');
80+
break;
81+
}
82+
state = ShellQuoteState.Quoted;
83+
break;
84+
case '!':
85+
// In C-Shell, an exclamatation point can only be protected from shell interpretation
86+
// when escaped by a backslash
87+
// Source:
88+
// https://earthsci.stanford.edu/computing/unix/shell/specialchars.php
89+
90+
switch (state)
91+
{
92+
case ShellQuoteState.Unquoted:
93+
sb.Append('\\');
94+
break;
95+
case ShellQuoteState.Quoted:
96+
// Close quoted string
97+
sb.Append('"');
98+
sb.Append('\\');
99+
break;
100+
case ShellQuoteState.SingleQuoted:
101+
// Close single quoted string
102+
sb.Append('\'');
103+
sb.Append('\\');
104+
break;
105+
}
106+
state = ShellQuoteState.Unquoted;
107+
break;
108+
default:
109+
switch (state)
110+
{
111+
case ShellQuoteState.Unquoted:
112+
// Start single-quoted string
113+
sb.Append('\'');
114+
break;
115+
case ShellQuoteState.Quoted:
116+
// Close quoted string
117+
sb.Append('"');
118+
// Start single-quoted string
119+
sb.Append('\'');
120+
break;
121+
case ShellQuoteState.SingleQuoted:
122+
// Continue single-quoted string
123+
break;
124+
}
125+
state = ShellQuoteState.SingleQuoted;
126+
break;
127+
}
128+
129+
sb.Append(c);
130+
}
131+
132+
switch (state)
133+
{
134+
case ShellQuoteState.Unquoted:
135+
break;
136+
case ShellQuoteState.Quoted:
137+
// Close quoted string
138+
sb.Append('"');
139+
break;
140+
case ShellQuoteState.SingleQuoted:
141+
// Close single-quoted string
142+
sb.Append('\'');
143+
break;
144+
}
145+
146+
if (sb.Length == 0)
147+
{
148+
sb.Append("''");
149+
}
150+
151+
return sb.ToString();
152+
}
153+
154+
private enum ShellQuoteState
155+
{
156+
Unquoted = 1,
157+
SingleQuoted = 2,
158+
Quoted = 3
159+
}
160+
}
161+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
namespace Renci.SshNet
2+
{
3+
/// <summary>
4+
/// Allow access to built-in remote path transformations.
5+
/// </summary>
6+
public static class RemotePathTransformation
7+
{
8+
private static readonly IRemotePathTransformation QuoteTransformation = new RemotePathQuoteTransformation();
9+
private static readonly IRemotePathTransformation NoneTransformation = new RemotePathNoneTransformation();
10+
11+
/// <summary>
12+
/// Quotes a path in a way to be suitable to be used with a shell.
13+
/// </summary>
14+
/// <remarks>
15+
/// <para>
16+
/// If the path contains a single-quote, that character is embedded in quotation marks.
17+
/// Sequences of single-quotes are grouped in a single pair of quotation marks.
18+
/// </para>
19+
/// <para>
20+
/// An exclamation mark (!) is escaped with a backslash, because the C shell would otherwise
21+
/// interprete it as a meta-character for history substitution. It does this even if it's
22+
/// enclosed in single-quotes or quotation marks, unless escaped with a backslash (\).
23+
/// </para>
24+
/// <para>
25+
/// All other character are enclosed in single-quotes, and grouped in a single pair of
26+
/// single quotes where contiguous.
27+
/// </para>
28+
/// </remarks>
29+
/// <example>
30+
/// <list type="table">
31+
/// <listheader>
32+
/// <term>Original</term>
33+
/// <term>Quoted</term>
34+
/// </listheader>
35+
/// <item>
36+
/// <term>/var/log/auth.log</term>
37+
/// <term>'/var/log/auth.log'</term>
38+
/// </item>
39+
/// <item>
40+
/// <term>/var/mp3/Guns N' Roses</term>
41+
/// <term>'/var/mp3/Guns N'"'"' Roses'</term>
42+
/// </item>
43+
/// <item>
44+
/// <term>/var/garbage!/temp</term>
45+
/// <term>'/var/garbage\!/temp'</term>
46+
/// </item>
47+
/// <item>
48+
/// <term>/var/garbage!/temp</term>
49+
/// <term>'/var/garbage'\!'/temp'</term>
50+
/// </item>
51+
/// <item>
52+
/// <term>/var/would be 'kewl'!/not?</term>
53+
/// <term>'/var/would be '"'"'kewl'"'"\!'/not?'</term>
54+
/// </item>
55+
/// <item>
56+
/// <term>!ignore!</term>
57+
/// <term>\!'ignore'\!</term>
58+
/// </item>
59+
/// <item>
60+
/// <term></term>
61+
/// <term>''</term>
62+
/// </item>
63+
/// </list>
64+
/// </example>
65+
public static IRemotePathTransformation Quote
66+
{
67+
get { return QuoteTransformation; }
68+
}
69+
70+
/// <summary>
71+
/// Performs no transformation.
72+
/// </summary>
73+
/// <remarks>
74+
/// This transformation should be used for servers that do not support escape sequences in paths
75+
/// or paths enclosed in quotes, or would preserve the escape characters or quotes in the path that
76+
/// is handed off to the IO layer. This is recommended for servers that are not shell-based.
77+
/// </remarks>
78+
public static IRemotePathTransformation None
79+
{
80+
get { return NoneTransformation; }
81+
}
82+
}
83+
}

src/Renci.SshNet/Renci.SshNet.csproj

+4
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@
153153
<Compile Include="IClientAuthentication.cs" />
154154
<Compile Include="IConnectionInfo.cs" />
155155
<Compile Include="IForwardedPort.cs" />
156+
<Compile Include="IRemotePathTransformation.cs" />
156157
<Compile Include="IServiceFactory.cs" />
157158
<Compile Include="IServiceFactory.NET.cs" />
158159
<Compile Include="ISession.cs" />
@@ -162,6 +163,9 @@
162163
<Compile Include="Messages\Transport\KeyExchangeEcdhInitMessage.cs" />
163164
<Compile Include="Messages\Transport\KeyExchangeEcdhReplyMessage.cs" />
164165
<Compile Include="Netconf\INetConfSession.cs" />
166+
<Compile Include="RemotePathNoneTransformation.cs" />
167+
<Compile Include="RemotePathQuoteTransformation.cs" />
168+
<Compile Include="RemotePathTransformation.cs" />
165169
<Compile Include="Security\Cryptography\HMACMD5.cs" />
166170
<Compile Include="Security\Cryptography\HMACSHA1.cs" />
167171
<Compile Include="Security\Cryptography\HMACSHA256.cs" />

0 commit comments

Comments
 (0)