Created
April 19, 2016 19:28
-
-
Save fubar-coder/32a1a6d859d12a9961e1a7e897a27900 to your computer and use it in GitHub Desktop.
Modifications to allow PASV to use predefined ports (by Guillaume Pelletier)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?xml version="1.0" encoding="utf-8" ?> | |
<configuration> | |
<startup> | |
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5" /> | |
</startup> | |
<system.diagnostics> | |
<trace autoflush="true"/> | |
<sources> | |
<source name="System.Net" maxdatasize="1024"> | |
<listeners> | |
<!--add name="MyTraceFile" /--> | |
</listeners> | |
</source> | |
</sources> | |
<!-- | |
<sharedListeners> | |
<add name="MyTraceFile" type="System.Diagnostics.TextWriterTraceListener" initializeData="System.Net.trace.log"/> | |
</sharedListeners> | |
--> | |
<switches> | |
<add name="System.Net" value="Verbose"/> | |
</switches> | |
</system.diagnostics> | |
<appSettings> | |
<add key="port" value="8110"/> | |
<add key="pasvPorts" value="8108,8109"/> | |
</appSettings> | |
</configuration> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//----------------------------------------------------------------------- | |
// <copyright file="FtpServer.cs" company="Fubar Development Junker"> | |
// Copyright (c) Fubar Development Junker. All rights reserved. | |
// </copyright> | |
// <author>Mark Junker</author> | |
//----------------------------------------------------------------------- | |
using System; | |
using System.Collections.Generic; | |
using System.Reflection; | |
using System.Runtime.CompilerServices; | |
using System.Text; | |
using System.Threading; | |
using System.Threading.Tasks; | |
using FubarDev.FtpServer.AccountManagement; | |
using FubarDev.FtpServer.CommandExtensions; | |
using FubarDev.FtpServer.CommandHandlers; | |
using FubarDev.FtpServer.FileSystem; | |
using FubarDev.FtpServer.Utilities; | |
using JetBrains.Annotations; | |
using Sockets.Plugin; | |
using Sockets.Plugin.Abstractions; | |
namespace FubarDev.FtpServer | |
{ | |
/// <summary> | |
/// The portable FTP server | |
/// </summary> | |
public sealed class FtpServer : IDisposable | |
{ | |
private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); | |
private readonly ConcurrentHashSet<FtpConnection> _connections = new ConcurrentHashSet<FtpConnection>(); | |
private bool _stopped; | |
private ConfiguredTaskAwaitable _listenerTask; | |
private IFtpLog _log; | |
private Queue<int> _pasvPorts; | |
/// <summary> | |
/// Initializes a new instance of the <see cref="FtpServer"/> class. | |
/// </summary> | |
/// <param name="fileSystemClassFactory">The <see cref="IFileSystemClassFactory"/> to use to create the <see cref="IUnixFileSystem"/> for the logged in user.</param> | |
/// <param name="membershipProvider">The <see cref="IMembershipProvider"/> used to validate a login attempt</param> | |
/// <param name="commsInterface">The <see cref="ICommsInterface"/> that identifies the public IP address (required for <code>PASV</code> and <code>EPSV</code>)</param> | |
public FtpServer([NotNull] IFileSystemClassFactory fileSystemClassFactory, [NotNull] IMembershipProvider membershipProvider, [NotNull] ICommsInterface commsInterface) | |
: this(fileSystemClassFactory, membershipProvider, commsInterface, 21, new AssemblyFtpCommandHandlerFactory(typeof(FtpServer).GetTypeInfo().Assembly)) | |
{ | |
} | |
/// <summary> | |
/// Initializes a new instance of the <see cref="FtpServer"/> class. | |
/// </summary> | |
/// <param name="fileSystemClassFactory">The <see cref="IFileSystemClassFactory"/> to use to create the <see cref="IUnixFileSystem"/> for the logged in user.</param> | |
/// <param name="membershipProvider">The <see cref="IMembershipProvider"/> used to validate a login attempt</param> | |
/// <param name="serverAddress">The public IP address (required for <code>PASV</code> and <code>EPSV</code>)</param> | |
public FtpServer([NotNull] IFileSystemClassFactory fileSystemClassFactory, [NotNull] IMembershipProvider membershipProvider, [NotNull] string serverAddress) | |
: this(fileSystemClassFactory, membershipProvider, serverAddress, 21, null, new AssemblyFtpCommandHandlerFactory(typeof(FtpServer).GetTypeInfo().Assembly)) | |
{ | |
} | |
/// <summary> | |
/// Initializes a new instance of the <see cref="FtpServer"/> class. | |
/// </summary> | |
/// <param name="fileSystemClassFactory">The <see cref="IFileSystemClassFactory"/> to use to create the <see cref="IUnixFileSystem"/> for the logged in user.</param> | |
/// <param name="membershipProvider">The <see cref="IMembershipProvider"/> used to validate a login attempt</param> | |
/// <param name="commsInterface">The <see cref="ICommsInterface"/> that identifies the public IP address (required for <code>PASV</code> and <code>EPSV</code>)</param> | |
/// <param name="port">The port of the FTP server (usually 21)</param> | |
/// <param name="handlerFactory">The handler factories to create <see cref="FtpCommandHandler"/> and <see cref="FtpCommandHandlerExtension"/> instances for new <see cref="FtpConnection"/> objects</param> | |
public FtpServer([NotNull] IFileSystemClassFactory fileSystemClassFactory, [NotNull] IMembershipProvider membershipProvider, [NotNull] ICommsInterface commsInterface, int port, [NotNull, ItemNotNull] IFtpCommandHandlerFactory handlerFactory) | |
: this(fileSystemClassFactory, membershipProvider, commsInterface.IpAddress, port, null, handlerFactory) | |
{ | |
} | |
/// <summary> | |
/// Initializes a new instance of the <see cref="FtpServer"/> class. | |
/// </summary> | |
/// <param name="fileSystemClassFactory">The <see cref="IFileSystemClassFactory"/> to use to create the <see cref="IUnixFileSystem"/> for the logged in user.</param> | |
/// <param name="membershipProvider">The <see cref="IMembershipProvider"/> used to validate a login attempt</param> | |
/// <param name="serverAddress">The public IP address (required for <code>PASV</code> and <code>EPSV</code>)</param> | |
/// <param name="port">The port of the FTP server (usually 21)</param> | |
/// <param name="pasvPorts">The passive port of the FTP server when prefixed (ie constrained server)</param> | |
/// <param name="handlerFactory">The handler factories to create <see cref="FtpCommandHandler"/> and <see cref="FtpCommandHandlerExtension"/> instances for new <see cref="FtpConnection"/> objects</param> | |
public FtpServer([NotNull] IFileSystemClassFactory fileSystemClassFactory, [NotNull] IMembershipProvider membershipProvider, [NotNull] string serverAddress, int port, IEnumerable<int> pasvPorts, [NotNull, ItemNotNull] IFtpCommandHandlerFactory handlerFactory) | |
{ | |
ServerAddress = serverAddress; | |
DefaultEncoding = Encoding.UTF8; | |
OperatingSystem = "UNIX"; | |
FileSystemClassFactory = fileSystemClassFactory; | |
MembershipProvider = membershipProvider; | |
Port = port; | |
CommandsHandlerFactory = handlerFactory; | |
BackgroundTransferWorker = new BackgroundTransferWorker(this); | |
BackgroundTransferWorker.Start(_cancellationTokenSource); | |
_pasvPorts = pasvPorts == null ? null : new Queue<int>(pasvPorts); | |
} | |
/// <summary> | |
/// This event is raised when the connection is ready to be configured | |
/// </summary> | |
public event EventHandler<ConnectionEventArgs> ConfigureConnection; | |
/// <summary> | |
/// Gets or sets the returned operating system (default: UNIX) | |
/// </summary> | |
[NotNull] | |
public string OperatingSystem { get; set; } | |
/// <summary> | |
/// Gets the FTP server statistics | |
/// </summary> | |
[NotNull] | |
public FtpServerStatistics Statistics { get; } = new FtpServerStatistics(); | |
/// <summary> | |
/// Gets the list of <see cref="IFtpCommandHandlerFactory"/> implementations to | |
/// create <see cref="FtpCommandHandler"/> instances for new <see cref="FtpConnection"/> objects. | |
/// </summary> | |
[NotNull] | |
public IFtpCommandHandlerFactory CommandsHandlerFactory { get; } | |
/// <summary> | |
/// Gets the public IP address (required for <code>PASV</code> and <code>EPSV</code>) | |
/// </summary> | |
[NotNull] | |
public string ServerAddress { get; } | |
/// <summary> | |
/// Gets the port on which the FTP server is listening for incoming connections | |
/// </summary> | |
public int Port { get; } | |
/// <summary> | |
/// Gets or sets the default text encoding for textual data | |
/// </summary> | |
[NotNull] | |
public Encoding DefaultEncoding { get; set; } | |
/// <summary> | |
/// Gets the <see cref="IFileSystemClassFactory"/> to use to create the <see cref="IUnixFileSystem"/> for the logged-in user. | |
/// </summary> | |
[NotNull] | |
public IFileSystemClassFactory FileSystemClassFactory { get; } | |
/// <summary> | |
/// Gets the <see cref="IMembershipProvider"/> used to validate a login attempt | |
/// </summary> | |
[NotNull] | |
public IMembershipProvider MembershipProvider { get; } | |
/// <summary> | |
/// Gets or sets the login manager used to create new <see cref="IFtpLog"/> objects. | |
/// </summary> | |
[CanBeNull] | |
public IFtpLogManager LogManager { get; set; } | |
private BackgroundTransferWorker BackgroundTransferWorker { get; } | |
/// <summary> | |
/// Starts the FTP server in the background | |
/// </summary> | |
public void Start() | |
{ | |
if (_stopped) | |
throw new InvalidOperationException("Cannot start a previously stopped FTP server"); | |
_listenerTask = ExecuteServerListener(_cancellationTokenSource.Token).ConfigureAwait(false); | |
} | |
/// <summary> | |
/// Stops the FTP server | |
/// </summary> | |
/// <remarks> | |
/// The FTP server cannot be started again after it was stopped. | |
/// </remarks> | |
public void Stop() | |
{ | |
_cancellationTokenSource.Cancel(true); | |
_stopped = true; | |
} | |
/// <summary> | |
/// Get the background transfer states for all active <see cref="IBackgroundTransfer"/> objects. | |
/// </summary> | |
/// <returns>The background transfer states for all active <see cref="IBackgroundTransfer"/> objects</returns> | |
[NotNull] | |
[ItemNotNull] | |
public IReadOnlyCollection<Tuple<string, BackgroundTransferStatus>> GetBackgroundTaskStates() | |
{ | |
return BackgroundTransferWorker.GetStates(); | |
} | |
/// <summary> | |
/// Enqueue a new <see cref="IBackgroundTransfer"/> for the given <paramref name="connection"/> | |
/// </summary> | |
/// <param name="backgroundTransfer">The background transfer to enqueue</param> | |
/// <param name="connection">The connection to enqueue the background transfer for</param> | |
public void EnqueueBackgroundTransfer([NotNull] IBackgroundTransfer backgroundTransfer, [CanBeNull] FtpConnection connection) | |
{ | |
var entry = new BackgroundTransferEntry(backgroundTransfer, connection?.Log); | |
BackgroundTransferWorker.Enqueue(entry); | |
} | |
/// <inheritdoc/> | |
public void Dispose() | |
{ | |
if (!_stopped) | |
Stop(); | |
try | |
{ | |
_listenerTask.GetAwaiter().GetResult(); | |
} | |
catch (TaskCanceledException) | |
{ | |
// Ignorieren - alles ist OK | |
} | |
BackgroundTransferWorker.Dispose(); | |
_cancellationTokenSource.Dispose(); | |
_connections.Dispose(); | |
} | |
internal int PeekPasvPort(TimeSpan timeout) | |
{ | |
if (_pasvPorts == null) | |
return 0; | |
lock (_pasvPorts) | |
{ | |
if (_pasvPorts.Count == 0) | |
{ | |
do | |
{ | |
if (Monitor.Wait(_pasvPorts, timeout) == false) | |
return -1; | |
} while (_pasvPorts.Count == 0); | |
} | |
return _pasvPorts.Dequeue(); | |
} | |
} | |
internal void PushPasvPort(int port) | |
{ | |
if (_pasvPorts != null) | |
{ | |
lock (_pasvPorts) | |
{ | |
_pasvPorts.Enqueue(port); | |
if (_pasvPorts.Count == 1 ) | |
{ | |
// wake up any blocked dequeue | |
Monitor.PulseAll(_pasvPorts); | |
} | |
} | |
} | |
} | |
private void OnConfigureConnection(FtpConnection connection) | |
{ | |
ConfigureConnection?.Invoke(this, new ConnectionEventArgs(connection)); | |
} | |
private async Task ExecuteServerListener(CancellationToken cancellationToken) | |
{ | |
_log = LogManager?.CreateLog(typeof(FtpServer)); | |
using (var listener = new TcpSocketListener(0)) | |
{ | |
listener.ConnectionReceived = ConnectionReceived; | |
try | |
{ | |
await listener.StartListeningAsync(Port); | |
try | |
{ | |
for (; ;) | |
{ | |
cancellationToken.ThrowIfCancellationRequested(); | |
await Task.Delay(100, cancellationToken); | |
} | |
} | |
finally | |
{ | |
await listener.StopListeningAsync(); | |
foreach (var connection in _connections.ToArray()) | |
connection.Close(); | |
} | |
} | |
catch (Exception ex) | |
{ | |
_log?.Fatal(ex, "{0}", ex.Message); | |
} | |
} | |
} | |
private void ConnectionReceived(object sender, TcpSocketListenerConnectEventArgs args) | |
{ | |
var connection = new FtpConnection(this, args.SocketClient, DefaultEncoding); | |
Statistics.ActiveConnections += 1; | |
Statistics.TotalConnections += 1; | |
connection.Closed += ConnectionOnClosed; | |
_connections.Add(connection); | |
connection.Log = LogManager?.CreateLog(connection); | |
OnConfigureConnection(connection); | |
connection.Start(); | |
} | |
private void ConnectionOnClosed(object sender, EventArgs eventArgs) | |
{ | |
if (_stopped) | |
return; | |
var connection = (FtpConnection)sender; | |
_connections.Remove(connection); | |
Statistics.ActiveConnections -= 1; | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//----------------------------------------------------------------------- | |
// <copyright file="PasvCommandHandler.cs" company="Fubar Development Junker"> | |
// Copyright (c) Fubar Development Junker. All rights reserved. | |
// </copyright> | |
// <author>Mark Junker</author> | |
//----------------------------------------------------------------------- | |
using System; | |
using System.Collections.Generic; | |
using System.Threading; | |
using System.Threading.Tasks; | |
using Sockets.Plugin; | |
namespace FubarDev.FtpServer.CommandHandlers | |
{ | |
/// <summary> | |
/// The command handler for the <code>PASV</code> (4.1.2.) and <code>EPSV</code> commands | |
/// </summary> | |
public class PasvCommandHandler : FtpCommandHandler | |
{ | |
/// <summary> | |
/// Initializes a new instance of the <see cref="PasvCommandHandler"/> class. | |
/// </summary> | |
/// <param name="connection">The connection this command handler is created for</param> | |
public PasvCommandHandler(FtpConnection connection) | |
: base(connection, "PASV", "EPSV") | |
{ | |
} | |
/// <inheritdoc/> | |
public override IEnumerable<IFeatureInfo> GetSupportedFeatures() | |
{ | |
yield return new GenericFeatureInfo("EPSV"); | |
} | |
/// <inheritdoc/> | |
public override async Task<FtpResponse> Process(FtpCommand command, CancellationToken cancellationToken) | |
{ | |
if (Data.PassiveSocketClient != null) | |
{ | |
await Data.PassiveSocketClient.DisconnectAsync(); | |
Data.PassiveSocketClient.Dispose(); | |
Data.PassiveSocketClient = null; | |
} | |
if (Data.TransferTypeCommandUsed != null && !string.Equals(command.Name, Data.TransferTypeCommandUsed, StringComparison.OrdinalIgnoreCase)) | |
return new FtpResponse(500, $"Cannot use {command.Name} when {Data.TransferTypeCommandUsed} was used before."); | |
int port; | |
bool peeked = true ; | |
var isEpsv = string.Equals(command.Name, "EPSV", StringComparison.OrdinalIgnoreCase); | |
if (isEpsv) | |
{ | |
if (string.IsNullOrEmpty(command.Argument) || string.Equals(command.Argument, "ALL", StringComparison.OrdinalIgnoreCase)) | |
{ | |
// peek the first available port - wait 5 second. return -1 if no port available | |
port = Server.PeekPasvPort(TimeSpan.FromSeconds(5)); | |
} | |
else | |
{ | |
port = Convert.ToInt32(command.Argument, 10); | |
peeked = false; | |
} | |
} | |
else | |
{ | |
// peek the first available port -wait 5 second. return -1 if no port available | |
port = Server.PeekPasvPort(TimeSpan.FromSeconds(5)); | |
} | |
Data.TransferTypeCommandUsed = command.Name; | |
if (port < 0) | |
{ | |
// we do not found any available port | |
return new FtpResponse(425, $"Can't open data connection."); | |
} | |
var sem = new SemaphoreSlim(0, 1); | |
var listener = new TcpSocketListener(); | |
listener.ConnectionReceived += (sender, args) => | |
{ | |
Data.PassiveSocketClient = args.SocketClient; | |
sem.Release(); | |
}; | |
await listener.StartListeningAsync(port); | |
var localPort = listener.LocalPort; | |
if (isEpsv || Server.ServerAddress.Contains(":")) | |
{ | |
var listenerAddress = new Address(localPort); | |
await Connection.WriteAsync(new FtpResponse(229, $"Entering Extended Passive Mode ({listenerAddress})."), cancellationToken); | |
} | |
else | |
{ | |
var listenerAddress = new Address(Server.ServerAddress, localPort); | |
await Connection.WriteAsync(new FtpResponse(227, $"Entering Passive Mode ({listenerAddress})."), cancellationToken); | |
} | |
await sem.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); | |
await listener.StopListeningAsync(); | |
listener.Dispose(); | |
if (peeked) | |
{ | |
// set the port as available. | |
Server.PushPasvPort(port); | |
} | |
return null; | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
////#define USE_FTPS_IMPLICIT | |
using System; | |
using System.IO; | |
using System.Security.Cryptography.X509Certificates; | |
using System.Text; | |
using FubarDev.FtpServer; | |
using FubarDev.FtpServer.AccountManagement; | |
using FubarDev.FtpServer.AccountManagement.Anonymous; | |
using FubarDev.FtpServer.AuthTls; | |
using FubarDev.FtpServer.FileSystem.DotNet; | |
using TestFtpServer.Logging; | |
using System.Collections.Generic; | |
using System.Configuration; | |
namespace TestFtpServer | |
{ | |
class Program | |
{ | |
#if USE_FTPS_IMPLICIT | |
const int defaultPort = 990; | |
#else | |
const int defaultPort = 21; | |
#endif | |
const string portKey = "port"; | |
const string portListKey = "pasvPorts"; | |
const char portListSep = ','; | |
private static void Main() | |
{ | |
// Load server certificate | |
var cert = new X509Certificate2("test.pfx"); | |
AuthTlsCommandHandler.ServerCertificate = cert; | |
// Only allow anonymous login | |
var membershipProvider = new AnonymousMembershipProvider(new NoValidation()); | |
// Use the .NET file system | |
var fsProvider = new DotNetFileSystemProvider(Path.Combine(Path.GetTempPath(), "TestFtpServer")); | |
// Use all commands from the FtpServer assembly and the one(s) from the AuthTls assembly | |
var commandFactory = new AssemblyFtpCommandHandlerFactory(typeof(FtpServer).Assembly, typeof(AuthTlsCommandHandler).Assembly); | |
// Set the ports using App Config | |
IEnumerable<int> pasvPort = null; | |
string str = ConfigurationManager.AppSettings[portListKey]; | |
if( str != null ) | |
{ | |
pasvPort = str.ParsePorts(portListSep); | |
} | |
int Port = defaultPort; | |
str = ConfigurationManager.AppSettings[portKey]; | |
if (str != null) | |
{ | |
int tmp; | |
if (int.TryParse(str,out tmp)) | |
{ | |
Port = tmp; | |
} | |
} | |
// Initialize the FTP server | |
using (var ftpServer = new FtpServer(fsProvider, membershipProvider, "127.0.0.1", Port, pasvPort, commandFactory) | |
{ | |
DefaultEncoding = Encoding.ASCII, | |
LogManager = new FtpLogManager(), | |
}) | |
{ | |
#if USE_FTPS_IMPLICIT | |
// Use an implicit SSL connection (without the AUTHTLS command) | |
ftpServer.ConfigureConnection += (s, e) => | |
{ | |
var sslStream = new FixedSslStream(e.Connection.OriginalStream); | |
sslStream.AuthenticateAsServer(cert); | |
e.Connection.SocketStream = sslStream; | |
}; | |
#endif | |
// Create the default logger | |
var log = ftpServer.LogManager != null ? ftpServer.LogManager.CreateLog(typeof(Program)) : null ; | |
try | |
{ | |
// Start the FTP server | |
ftpServer.Start(); | |
Console.WriteLine("Press ENTER/RETURN to close the test application."); | |
Console.ReadLine(); | |
// Stop the FTP server | |
ftpServer.Stop(); | |
} | |
catch (Exception ex) | |
{ | |
if( log!= null)log.Error(ex, "Error during main FTP server loop"); | |
} | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// <copyright file="StringExtensions.cs" company="Fubar Development Junker"> | |
// Copyright (c) Fubar Development Junker. All rights reserved. | |
// </copyright> | |
using System; | |
using System.Globalization; | |
using JetBrains.Annotations; | |
using System.Collections.Generic; | |
namespace FubarDev.FtpServer | |
{ | |
/// <summary> | |
/// Extension methods for <see cref="string"/> | |
/// </summary> | |
public static class StringExtensions | |
{ | |
/// <summary> | |
/// Try to parse a timestamp from the parameter <paramref name="timestamp"/>. | |
/// </summary> | |
/// <param name="timestamp">The time stamp to parse</param> | |
/// <param name="timezone">The time zone of the timestamp (must always be <code>UTC</code>)</param> | |
/// <param name="result">The parsed timestamp</param> | |
/// <returns><code>true</code> when timestamp and timezone were valid.</returns> | |
public static bool TryParseTimestamp([NotNull] this string timestamp, [NotNull] string timezone, out DateTimeOffset result) | |
{ | |
if (timestamp.Length != 12 && timestamp.Length != 14) | |
return false; | |
if (timezone != "UTC") | |
return false; | |
var format = "yyyyMMddHHmm" + (timestamp.Length == 14 ? "ss" : string.Empty); | |
result = DateTimeOffset.ParseExact(timestamp, format, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); | |
return true; | |
} | |
/// <summary> | |
/// Parse a list of int. | |
/// </summary> | |
/// <param name="ports">The list of int as ascii separated value </param> | |
/// <param name="sep">the separator</param> | |
/// <returns>The parsed list - ommit the invalid number </returns> | |
public static IEnumerable<int> ParsePorts([NotNull] this string ports, char sep) | |
{ | |
int tmp; | |
foreach (string s in ports.Split(sep)) | |
{ | |
if (int.TryParse(s, out tmp)) | |
{ | |
yield return tmp; | |
} | |
} | |
} | |
[NotNull] | |
internal static string ChompFromEnd([NotNull] this string input, out string token) | |
{ | |
var pos = input.LastIndexOf(' '); | |
if (pos == -1) | |
{ | |
token = input; | |
return string.Empty; | |
} | |
var remaining = input.Substring(0, pos); | |
token = input.Substring(pos + 1); | |
return remaining; | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment