From 96e1f17aa9e5f7397726fd478f08c229cc727d3c Mon Sep 17 00:00:00 2001 From: Alexey 'Cluster' Avdyukhin Date: Tue, 11 Apr 2017 07:32:55 +0300 Subject: FTP server (seriously!) and many fixes --- FtpServer/DebugLogHandler.cs | 74 +++ FtpServer/FileSystemHelper.cs | 66 +++ FtpServer/IAuthHandler.cs | 45 ++ FtpServer/IFileSystemHandler.cs | 202 +++++++ FtpServer/ILogHandler.cs | 25 + FtpServer/NesMiniAuthHandler.cs | 41 ++ FtpServer/NesMiniFileSystemHandler.cs | 317 ++++++++++ FtpServer/Server.cs | 190 ++++++ FtpServer/Session.cs | 1045 +++++++++++++++++++++++++++++++++ 9 files changed, 2005 insertions(+) create mode 100644 FtpServer/DebugLogHandler.cs create mode 100644 FtpServer/FileSystemHelper.cs create mode 100644 FtpServer/IAuthHandler.cs create mode 100644 FtpServer/IFileSystemHandler.cs create mode 100644 FtpServer/ILogHandler.cs create mode 100644 FtpServer/NesMiniAuthHandler.cs create mode 100644 FtpServer/NesMiniFileSystemHandler.cs create mode 100644 FtpServer/Server.cs create mode 100644 FtpServer/Session.cs (limited to 'FtpServer') diff --git a/FtpServer/DebugLogHandler.cs b/FtpServer/DebugLogHandler.cs new file mode 100644 index 00000000..292d5eee --- /dev/null +++ b/FtpServer/DebugLogHandler.cs @@ -0,0 +1,74 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Net; + +namespace mooftpserv +{ + /// + /// Default log handler. + /// + public class DebugLogHandler : ILogHandler + { + private IPEndPoint peer; + + public DebugLogHandler() + { + } + + private DebugLogHandler(IPEndPoint peer) + { + this.peer = peer; + } + + public ILogHandler Clone(IPEndPoint peer) + { + return new DebugLogHandler(peer); + } + + private void Write(string format, params object[] args) + { + Debug.WriteLine(String.Format("{0}: {1}", peer, String.Format(format, args))); + } + + public void NewControlConnection() + { + Write("new control connection"); + } + + public void ClosedControlConnection() + { + Write("closed control connection"); + } + + public void ReceivedCommand(string verb, string arguments) + { +#if VERY_DEBUG + string argtext = (arguments == null || arguments == "" ? "" : ' ' + arguments); + Write("received command: {0}{1}", verb, argtext); +#endif + } + + public void SentResponse(uint code, string description) + { +#if VERY_DEBUG + Write("sent response: {0} {1}", code, description); +#endif + } + + public void NewDataConnection(IPEndPoint remote, IPEndPoint local, bool passive) + { +#if VERY_DEBUG + Write("new data connection: {0} <-> {1} ({2})", remote, local, (passive ? "passive" : "active")); +#endif + } + + public void ClosedDataConnection(IPEndPoint remote, IPEndPoint local, bool passive) + { +#if VERY_DEBUG + Write("closed data connection: {0} <-> {1} ({2})", remote, local, (passive ? "passive" : "active")); +#endif + } + } +} + diff --git a/FtpServer/FileSystemHelper.cs b/FtpServer/FileSystemHelper.cs new file mode 100644 index 00000000..36cc4979 --- /dev/null +++ b/FtpServer/FileSystemHelper.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; + +namespace mooftpserv +{ + public class FileSystemHelper + { + /// Handles TVFS path resolution, similar to Path.GetFullPath(Path.Combine(basePath, path)) + public static string ResolvePath(string basePath, string path) + { + // CF is missing String.IsNullOrWhiteSpace + if (path == null || path.Trim() == "") + return null; + + // first, make a complete unix path + string fullPath; + if (path[0] == '/') { + fullPath = path; + } else { + fullPath = basePath; + if (!fullPath.EndsWith("/")) + fullPath += "/"; + fullPath += path; + } + + // then, remove ".." and "." + List tokens = new List(fullPath.Split('/')); + for (int i = 0; i < tokens.Count; ++i) { + if (tokens[i] == "") { + if (i == 0 || i == tokens.Count - 1) { + continue; // ignore, start and end should be empty tokens + } else { + tokens.RemoveAt(i); + --i; + } + } else if (tokens[i] == "..") { + if (i < 2) { + // cannot go higher than root, just remove the token + tokens.RemoveAt(i); + --i; + } else { + tokens.RemoveRange(i - 1, 2); + i -= 2; + } + } else if (i < tokens.Count - 1 && tokens[i].EndsWith(@"\")) { + int slashes = 0; + for (int c = tokens[i].Length - 1; c >= 0 && tokens[i][c] == '\\'; --c) + ++slashes; + + if (slashes % 2 != 0) { + // the slash was actually escaped, merge tokens + tokens[i] += ("/" + tokens[i + 1]); + ++i; + } + } + } + + if (tokens.Count > 1) + return String.Join("/", tokens.ToArray()); + else + return "/"; + } + + } +} + diff --git a/FtpServer/IAuthHandler.cs b/FtpServer/IAuthHandler.cs new file mode 100644 index 00000000..24621c87 --- /dev/null +++ b/FtpServer/IAuthHandler.cs @@ -0,0 +1,45 @@ +using System; +using System.Net; + +namespace mooftpserv +{ + /// + /// Interface for a class managing user authentication and allowing connections. + /// + public interface IAuthHandler + { + /// + /// Make a new instance for a new session with the given peer. + /// Each FTP session uses a separate, cloned instance. + /// + IAuthHandler Clone(IPEndPoint peer); + + /// + /// Check the given login. Note that the method can be called in three ways: + /// - user and pass are null: anonymous authentication + /// - pass is null: login only with username (e.g. "anonymous") + /// - both are non-null: login with user and password + /// + /// + /// The username, or null. + /// + /// + /// The password, or null. + /// + bool AllowLogin(string user, string pass); + + /// + /// Check if a control connection from the peer should be allowed. + /// + bool AllowControlConnection(); + + /// + /// Check if the PORT command of the peer with the given + /// target endpoint should be allowed. + /// + /// The argument given by the peer in the PORT command. + /// + bool AllowActiveDataConnection(IPEndPoint target); + } +} + diff --git a/FtpServer/IFileSystemHandler.cs b/FtpServer/IFileSystemHandler.cs new file mode 100644 index 00000000..c84e8426 --- /dev/null +++ b/FtpServer/IFileSystemHandler.cs @@ -0,0 +1,202 @@ +using System; +using System.Net; +using System.IO; + +namespace mooftpserv +{ + /// + /// File system entry as returned by List. + /// + public struct FileSystemEntry + { + public string Name; + public bool IsDirectory; + public long Size; + public DateTime LastModifiedTimeUtc; + } + + /// + /// Wrapper that either contains a value or an error string. + /// + public class ResultOrError + { + private T result; + private string error; + + private ResultOrError(T result, string error) + { + this.result = result; + this.error = error; + } + + public static ResultOrError MakeResult(T result) + { + return new ResultOrError(result, null); + } + + public static ResultOrError MakeError(string error) + { + if (error == null) + throw new ArgumentNullException(); + return new ResultOrError(default(T), error.Replace(Environment.NewLine, " ")); + } + + public bool HasError + { + get { return error != null; } + } + + public string Error + { + get { return error; } + } + + public T Result + { + get + { + if (HasError) + throw new InvalidOperationException(String.Format("No result available, error: {0}", error)); + return result; + } + } + }; + + /// + /// Interface for file system access from FTP. + /// + public interface IFileSystemHandler + { + /// + /// Make a new instance for a new session with the given peer. + /// Each FTP session uses a separate, cloned instance. + /// + IFileSystemHandler Clone(IPEndPoint peer); + + /// + /// PWD: Returns the path of the current working directory. + /// + /// + /// The absolute path of the current directory or an error string. + /// + ResultOrError GetCurrentDirectory(); + + /// + /// CWD: Changes the current directory. + /// CDUP: Changes to parent directory (called with "..") + /// + /// + /// The new absolute path or an error string. + /// + /// + /// A relative or absolute path to which to change. + /// + ResultOrError ChangeDirectory(string path); + + /// + /// MKD: Create a directory. + /// + /// + /// The absolute path of the created directory or an error string. + /// + /// + /// A relative or absolute path for the new directory. + /// + ResultOrError CreateDirectory(string path); + + /// + /// RMD: Remove a directory. + /// + /// + /// A bool or an error string. The bool is not actually used. + /// + /// + /// A relative or absolute path for the directory. + /// + ResultOrError RemoveDirectory(string path); + + /// + /// RETR: Open a stream for reading the specified file. + /// + /// + /// An opened stream for reading from the file, or an error string. + /// + /// + /// A relative or absolute path for the file. + /// + ResultOrError ReadFile(string path); + + /// + /// STOR: Open a stream for writing to the specified file. + /// If the file exists, it should be overwritten. + /// + /// + /// An opened stream for writing to the file, or an error string. + /// + /// + /// A relative or absolute path for the file. + /// + ResultOrError WriteFile(string path); + ResultOrError WriteFileFinalize(string path, Stream stream); + + /// + /// DELE: Deletes a file. + /// + /// + /// A bool or an error string. The bool is not actually used. + /// + /// + /// A relative or absolute path for the file. + /// + ResultOrError RemoveFile(string path); + + /// + /// RNFR, RNTO: Renames or moves a file or directory. + /// + /// + /// A bool or an error string. The bool is not actually used. + /// + /// + /// The relative or absolute path of an existing file or directory. + /// + /// + /// A relative or absolute non-existing path to which the file will be renamed or moved. + /// + ResultOrError RenameFile(string fromPath, string toPath); + + /// + /// LIST: Return a list of files and folders in a directory, or for a file (like 'ls'). + /// + /// + /// The relative or absolute path of an existing directory or file. + /// Can be null or empty to return the current directory. + /// + /// + /// An array of file system entries or an error string. + /// + ResultOrError ListEntries(string path); + + /// + /// SIZE: Gets the size of a file in bytes. + /// + /// + /// The file size, or -1 on error. + /// + /// + /// A relative or absolute path. + /// + ResultOrError GetFileSize(string path); + + /// + /// MDTM: Gets the last modified timestamp of a file. + /// + /// + /// The last modified time in UTC, or an error string. + /// + /// + /// A relative or absolute path. + /// + ResultOrError GetLastModifiedTimeUtc(string path); + } +} + diff --git a/FtpServer/ILogHandler.cs b/FtpServer/ILogHandler.cs new file mode 100644 index 00000000..87b7c9ad --- /dev/null +++ b/FtpServer/ILogHandler.cs @@ -0,0 +1,25 @@ +using System; +using System.Net; + +namespace mooftpserv +{ + /// + /// Interface for a logger. Methods should be self-explanatory. + /// + public interface ILogHandler + { + /// + /// Make a new instance for a new session with the given peer. + /// Each FTP session uses a separate, cloned instance. + /// + ILogHandler Clone(IPEndPoint peer); + + void NewControlConnection(); + void ClosedControlConnection(); + void ReceivedCommand(string verb, string arguments); + void SentResponse(uint code, string description); + void NewDataConnection(IPEndPoint remote, IPEndPoint local, bool passive); + void ClosedDataConnection(IPEndPoint remote, IPEndPoint local, bool passive); + } +} + diff --git a/FtpServer/NesMiniAuthHandler.cs b/FtpServer/NesMiniAuthHandler.cs new file mode 100644 index 00000000..3da4c2b7 --- /dev/null +++ b/FtpServer/NesMiniAuthHandler.cs @@ -0,0 +1,41 @@ +using System; +using System.Net; + +namespace mooftpserv +{ + public class NesMiniAuthHandler : IAuthHandler + { + private IPEndPoint peer; + + public NesMiniAuthHandler() + { + } + + private NesMiniAuthHandler(IPEndPoint peer) + { + this.peer = peer; + } + + public IAuthHandler Clone(IPEndPoint peer) + { + return new NesMiniAuthHandler(peer); + } + + public bool AllowLogin(string user, string pass) + { + return (user == "root" && pass == "clover"); + } + + public bool AllowControlConnection() + { + return true; + } + + public bool AllowActiveDataConnection(IPEndPoint port) + { + // only allow active connections to the same peer as the control connection + return peer.Address.Equals(port.Address); + } + } +} + diff --git a/FtpServer/NesMiniFileSystemHandler.cs b/FtpServer/NesMiniFileSystemHandler.cs new file mode 100644 index 00000000..63b8b1cb --- /dev/null +++ b/FtpServer/NesMiniFileSystemHandler.cs @@ -0,0 +1,317 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.IO; +using com.clusterrr.clovershell; + +namespace mooftpserv +{ + /// + /// Default file system handler. Allows access to the whole file system. Supports drives on Windows. + /// + public class NesMiniFileSystemHandler : IFileSystemHandler + { + // list of supported operating systems + private enum OS { WinNT, WinCE, Unix }; + + // currently used operating system + private OS os; + // current path as TVFS or unix-like + private string currentPath; + // clovershell + private ClovershellConnection clovershell; + + public NesMiniFileSystemHandler(ClovershellConnection clovershell, string startPath) + { + os = OS.Unix; + this.currentPath = startPath; + this.clovershell = clovershell; + } + + public NesMiniFileSystemHandler(ClovershellConnection clovershell) + : this(clovershell, "/") + { + } + + private NesMiniFileSystemHandler(string path, OS os, ClovershellConnection clovershell) + { + this.currentPath = path; + this.os = os; + this.clovershell = clovershell; + } + + public IFileSystemHandler Clone(IPEndPoint peer) + { + return new NesMiniFileSystemHandler(currentPath, os, clovershell); + } + + public ResultOrError GetCurrentDirectory() + { + return MakeResult(currentPath); + } + + public ResultOrError ChangeDirectory(string path) + { + string newPath = ResolvePath(path); + currentPath = newPath; + return MakeResult(newPath); + } + + public ResultOrError ChangeToParentDirectory() + { + return ChangeDirectory(".."); + } + + public ResultOrError CreateDirectory(string path) + { + string newPath = ResolvePath(path); + try + { + foreach (var c in newPath) + if ((int)c > 255) throw new Exception("Invalid characters in directory name"); + var newpath = DecodePath(newPath); + clovershell.ExecuteSimple("mkdir \"" + newpath + "\""); + } + catch (Exception ex) + { + return MakeError(ex.Message); + } + + return MakeResult(newPath); + } + + public ResultOrError RemoveDirectory(string path) + { + string newPath = ResolvePath(path); + + try + { + var rpath = DecodePath(newPath); + clovershell.ExecuteSimple("rm -rf \"" + rpath + "\""); + } + catch (Exception ex) + { + return MakeError(ex.Message); + } + + return MakeResult(true); + } + + public ResultOrError ReadFile(string path) + { + string newPath = ResolvePath(path); + try + { + var data = new MemoryStream(); + clovershell.Execute("cat \"" + newPath + "\"", null, data, null, 1000, true); + data.Seek(0, SeekOrigin.Begin); + return MakeResult(data); + } + catch (Exception ex) + { + return MakeError(ex.Message); + } + } + + public ResultOrError WriteFile(string path) + { + string newPath = ResolvePath(path); + try + { + foreach (var c in newPath) + if ((int)c > 255) throw new Exception("Invalid characters in directory name"); + return MakeResult(new MemoryStream()); + } + catch (Exception ex) + { + return MakeError(ex.Message); + } + } + + public ResultOrError WriteFileFinalize(string path, Stream str) + { + string newPath = ResolvePath(path); + try + { + str.Seek(0, SeekOrigin.Begin); + clovershell.Execute("cat > \"" + newPath + "\"", str, null, null, 1000, true); + str.Dispose(); + return MakeResult(true); + } + catch (Exception ex) + { + return MakeError(ex.Message); + } + } + + public ResultOrError RemoveFile(string path) + { + string newPath = ResolvePath(path); + + try + { + clovershell.ExecuteSimple("rm -rf \"" + newPath + "\"", 1000, true); + } + catch (Exception ex) + { + return MakeError(ex.Message); + } + + return MakeResult(true); + } + + public ResultOrError RenameFile(string fromPath, string toPath) + { + fromPath = ResolvePath(fromPath); + toPath = ResolvePath(toPath); + try + { + clovershell.ExecuteSimple("mv \"" + fromPath + "\" \"" + toPath + "\"", 1000, true); + } + catch (Exception ex) + { + return MakeError(ex.Message); + } + + return MakeResult(true); + } + + public ResultOrError ListEntries(string path) + { + string newPath = ResolvePath(path); + List result = new List(); + try + { + var lines = clovershell.ExecuteSimple("ls -lep \"" + newPath + "\"", 1000, true) + .Split(new char[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + if (line.StartsWith("total")) continue; + FileSystemEntry entry = new FileSystemEntry(); + entry.Name = line.Substring(69).Trim(); + entry.IsDirectory = entry.Name.EndsWith("/"); + if (entry.IsDirectory) entry.Name = entry.Name.Substring(0, entry.Name.Length - 1); + entry.Size = long.Parse(line.Substring(29, 15).Trim()); + // Who cares? There is no time source on NES Mini + //DateTime.Parse(line.Substring(44, 25).Trim()); + entry.LastModifiedTimeUtc = DateTime.MinValue; + result.Add(entry); + } + } + catch (Exception ex) + { + return MakeError(ex.Message); + } + + return MakeResult(result.ToArray()); + } + + public ResultOrError GetFileSize(string path) + { + string newPath = ResolvePath(path); + List result = new List(); + try + { + var lines = clovershell.ExecuteSimple("ls -le \"" + newPath + "\"", 1000, true) + .Split(new char[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + return MakeResult(long.Parse(line.Substring(29, 15).Trim())); + } + } + catch (Exception ex) + { + return MakeError(ex.Message); + } + return MakeResult(0); + } + + public ResultOrError GetLastModifiedTimeUtc(string path) + { + /* + string newPath = ResolvePath(path); + List result = new List(); + try + { + var lines = clovershell.ExecuteSimple("ls -le \"" + newPath + "\"", 1000, true) + .Split(new char[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + MakeResult(DateTime.Parse(line.Substring(45, 25).Trim())); + } + } + catch (Exception ex) + { + return MakeError(ex.Message); + } + */ + return MakeResult(DateTime.MinValue); + } + + private string ResolvePath(string path) + { + if (path == null) return currentPath; + if (path.Contains(" -> ")) + path = path.Substring(path.IndexOf(" -> ") + 4); + return FileSystemHelper.ResolvePath(currentPath, path); + } + + private string EncodePath(string path) + { + if (os == OS.WinNT) + return "/" + path[0] + (path.Length > 2 ? path.Substring(2).Replace(@"\", "/") : ""); + else if (os == OS.WinCE) + return path.Replace(@"\", "/"); + else + return path; + } + + private string DecodePath(string path) + { + if (path == null || path == "" || path[0] != '/') + return null; + + if (os == OS.WinNT) + { + // some error checking for the drive layer + if (path == "/") + return null; // should have been caught elsewhere + + if (path.Length > 1 && path[1] == '/') + return null; + + if (path.Length > 2 && path[2] != '/') + return null; + + if (path.Length < 4) // e.g. "/C/" + return path[1] + @":\"; + else + return path[1] + @":\" + path.Substring(3).Replace("/", @"\"); + } + else if (os == OS.WinCE) + { + return path.Replace("/", @"\"); + } + else + { + return path; + } + } + + /// + /// Shortcut for ResultOrError.MakeResult() + /// + private ResultOrError MakeResult(T result) + { + return ResultOrError.MakeResult(result); + } + + /// + /// Shortcut for ResultOrError.MakeError() + /// + private ResultOrError MakeError(string error) + { + return ResultOrError.MakeError(error); + } + } +} diff --git a/FtpServer/Server.cs b/FtpServer/Server.cs new file mode 100644 index 00000000..b544f8e4 --- /dev/null +++ b/FtpServer/Server.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; + +namespace mooftpserv +{ + /// + /// Main FTP server class. Manages the server socket, creates sessions. + /// Can be used to configure the server. + /// + public class Server + { + // default buffer size for send/receive buffers + private const int DEFAULT_BUFFER_SIZE = 64 * 1024; + // default port for the server socket + private const int DEFAULT_PORT = 21; + + private IPEndPoint endpoint; + private int bufferSize = DEFAULT_BUFFER_SIZE; + private IAuthHandler authHandler = null; + private IFileSystemHandler fsHandler = null; + private ILogHandler logHandler = null; + private TcpListener socket = null; + private List sessions; + + public Server() + { + this.endpoint = new IPEndPoint(GetDefaultAddress(), DEFAULT_PORT); + this.sessions = new List(); + } + + /// + /// Gets or sets the local end point on which the server will listen. + /// Has to be an IPv4 endpoint. + /// The default value is IPAdress.Any and port 21, except on WinCE, + /// where the first non-loopback IPv4 address will be used. + /// + public IPEndPoint LocalEndPoint + { + get { return endpoint; } + set { endpoint = value; } + } + + /// + /// Gets or sets the local IP address on which the server will listen. + /// Has to be an IPv4 address. + /// If none is set, IPAddress.Any will be used, except on WinCE, + /// where the first non-loopback IPv4 address will be used. + /// + public IPAddress LocalAddress + { + get { return endpoint.Address; } + set { endpoint.Address = value; } + } + + /// + /// Gets or sets the local port on which the server will listen. + /// The default value is 21. Note that on Linux, only root can open ports < 1024. + /// + public int LocalPort + { + get { return endpoint.Port; } + set { endpoint.Port = value; } + } + + /// + /// Gets or sets the size of the send/receive buffer to be used by each session/connection. + /// The default value is 64k. + /// + public int BufferSize + { + get { return bufferSize; } + set { bufferSize = value; } + } + + /// + /// Gets or sets the auth handler that is used to check user credentials. + /// If none is set, a DefaultAuthHandler will be created when the server starts. + /// + public IAuthHandler AuthHandler + { + get { return authHandler; } + set { authHandler = value; } + } + + /// + /// Gets or sets the file system handler that implements file system access for FTP commands. + /// If none is set, a DefaultFileSystemHandler is created when the server starts. + /// + public IFileSystemHandler FileSystemHandler + { + get { return fsHandler; } + set { fsHandler = value; } + } + + /// + /// Gets or sets the log handler. Can be null to disable logging. + /// The default value is null. + /// + public ILogHandler LogHandler + { + get { return logHandler; } + set { logHandler = value; } + } + + /// + /// Run the server. The method will not return until Stop() is called. + /// + public void Run() + { + //if (authHandler == null) + // authHandler = new DefaultAuthHandler(); + + //if (fsHandler == null) + // fsHandler = new DefaultFileSystemHandler(); + + if (socket == null) + socket = new TcpListener(endpoint); + + socket.Start(); + + // listen for new connections + try { + while (true) + { + Socket peer = socket.AcceptSocket(); + + IPEndPoint peerPort = (IPEndPoint) peer.RemoteEndPoint; + Session session = new Session(peer, bufferSize, + authHandler.Clone(peerPort), + fsHandler.Clone(peerPort), + logHandler.Clone(peerPort)); + + session.Start(); + sessions.Add(session); + + // purge old sessions + for (int i = sessions.Count - 1; i >= 0; --i) + { + if (!sessions[i].IsOpen) { + sessions.RemoveAt(i); + --i; + } + } + } + } catch (SocketException) { + // ignore, Stop() will probably cause this exception + } finally { + // close all running connections + foreach (Session s in sessions) { + s.Stop(); + } + } + } + + /// + /// Stop the server. + /// + public void Stop() + { + if (socket == null) return; + socket.Stop(); + } + + /// + /// Get the default address, which is IPAddress.Any everywhere except on WinCE, + /// where all local addresses are enumerated and the first non-loopback IP is used. + /// + private IPAddress GetDefaultAddress() + { + // on WinCE, 0.0.0.0 does not work because for accepted sockets, + // LocalEndPoint would also say 0.0.0.0 instead of the real IP +#if WindowsCE + IPHostEntry host = Dns.GetHostEntry(Dns.GetHostName()); + IPAddress bindIp = IPAddress.Loopback; + foreach (IPAddress ip in host.AddressList) { + if (ip.AddressFamily == AddressFamily.InterNetwork && !IPAddress.IsLoopback(ip)) { + return ip; + } + } + + return IPAddress.Loopback; +#else + return IPAddress.Any; +#endif + } + } +} diff --git a/FtpServer/Session.cs b/FtpServer/Session.cs new file mode 100644 index 00000000..068156d5 --- /dev/null +++ b/FtpServer/Session.cs @@ -0,0 +1,1045 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; + +namespace mooftpserv +{ + /// + /// FTP session/connection. Does all the heavy lifting of the FTP protocol. + /// Reads commands, sends replies, manages data connections, and so on. + /// Each session creates its own thread. + /// + class Session + { + // transfer data type, ascii or binary + enum DataType { ASCII, IMAGE }; + + // buffer size to use for reading commands from the control connection + private static int CMD_BUFFER_SIZE = 4096; + // version from AssemblyInfo + private static string LIB_VERSION = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version.ToString(2); + // monthnames for LIST command, since DateTime returns localized names + private static string[] MONTHS = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; + // response text for initial response. preceeded by application name and version number. + private static string[] HELLO_TEXT = { "What can I do for you?", "Good day, sir or madam.", "Hey ho let's go!", "The poor man's FTP server." }; + // response text for general ok messages + private static string[] OK_TEXT = { "Sounds good.", "Success!", "Alright, I'll do it...", "Consider it done." }; + // Result for FEAT command + private static string[] FEATURES = { "MDTM", "PASV", "SIZE", "TVFS", "UTF8" }; + + // local EOL flavor + private static byte[] localEolBytes = Encoding.ASCII.GetBytes(Environment.NewLine); + // FTP-mandated EOL flavor (= CRLF) + private static byte[] remoteEolBytes = Encoding.ASCII.GetBytes("\r\n"); + // on Windows, no ASCII conversion is necessary (CRLF == CRLF) + private static bool noAsciiConv = (localEolBytes == remoteEolBytes); + + // socket for the control connection + private Socket controlSocket; + // buffer size to use for sending/receiving with data connections + private int dataBufferSize; + // auth handler, checks user credentials + private IAuthHandler authHandler; + // file system handler, implements file system access for the FTP commands + private IFileSystemHandler fsHandler; + // log handler, used for diagnostic logging output. can be null. + private ILogHandler logHandler; + // Session thread, the control and data connections are processed in this thread + private Thread thread; + + // .NET CF does not have Thread.IsAlive, so this flag replaces it + private bool threadAlive = false; + // Random Number Generator for OK and HELLO texts + private Random randomTextIndex; + // flag for whether the user has successfully logged in + private bool loggedIn = false; + // name of the logged in user, also used to remember the username when waiting for the PASS command + private string loggedInUser = null; + // argument of pending RNFR command, when waiting for an RNTO command + private string renameFromPath = null; + + // remote data port. null when PASV is used. + private IPEndPoint dataPort = null; + // socket for data connections + private Socket dataSocket = null; + // .NET CF does not have Socket.Bound, so this flag replaces it + private bool dataSocketBound = false; + // buffer for reading from the control connection + private byte[] cmdRcvBuffer; + // number of bytes in the cmdRcvBuffer + private int cmdRcvBytes; + // buffer for sending/receiving with data connections + private byte[] dataBuffer; + // data type of the session, can be changed by the client + private DataType transferDataType = DataType.ASCII; + + /// + /// Creates a new session, which can afterwards be started with Start(). + /// + public Session(Socket socket, int bufferSize, IAuthHandler authHandler, IFileSystemHandler fileSystemHandler, ILogHandler logHandler) + { + this.controlSocket = socket; + this.dataBufferSize = bufferSize; + this.authHandler = authHandler; + this.fsHandler = fileSystemHandler; + this.logHandler = logHandler; + + this.cmdRcvBuffer = new byte[CMD_BUFFER_SIZE]; + this.cmdRcvBytes = 0; + this.dataBuffer = new byte[dataBufferSize + 1]; // +1 for partial EOL + this.randomTextIndex = new Random(); + + this.thread = new Thread(new ThreadStart(this.Work)); + } + + /// + /// Indicates whether the session is still open + /// + public bool IsOpen + { + get { return threadAlive; } + } + + /// + /// Start the session in a new thread + /// + public void Start() + { + if (!threadAlive) { + this.thread.Start(); + threadAlive = true; + } + } + + /// + /// Stop the session + /// + public void Stop() + { + if (threadAlive) { + threadAlive = false; + thread.Abort(); + } + + if (controlSocket.Connected) + controlSocket.Close(); + + if (dataSocket != null && dataSocket.Connected) + dataSocket.Close(); + } + + /// + /// Main method of the session thread. + /// Reads commands and executes them. + /// + private void Work() + { + if (logHandler != null) + logHandler.NewControlConnection(); + + try { + if (!authHandler.AllowControlConnection()) { + Respond(421, "Control connection refused."); + // first flush, then close + controlSocket.Shutdown(SocketShutdown.Both); + controlSocket.Close(); + return; + } + + Respond(220, String.Format("This is mooftpserv v{0}. {1}", LIB_VERSION, GetRandomText(HELLO_TEXT))); + + // allow anonymous login? + if (authHandler.AllowLogin(null, null)) { + loggedIn = true; + } + + while (controlSocket.Connected) { + string verb; + string args; + if (!ReadCommand(out verb, out args)) { + if (controlSocket.Connected) { + // assume clean disconnect if there are no buffered bytes + if (cmdRcvBytes != 0) + Respond(500, "Failed to read command, closing connection."); + controlSocket.Close(); + } + break; + } else if (verb.Trim() == "") { + // ignore empty lines + continue; + } + + try { + if (loggedIn) + ProcessCommand(verb, args); + else if (verb == "QUIT") { // QUIT should always be allowed + Respond(221, "Bye."); + // first flush, then close + controlSocket.Shutdown(SocketShutdown.Both); + controlSocket.Close(); + } else { + HandleAuth(verb, args); + } + } catch (Exception ex) { + Respond(500, ex); + } + } + } catch (Exception) { + // catch any uncaught stuff, the server should not throw anything + } finally { + if (controlSocket.Connected) + controlSocket.Close(); + + if (logHandler != null) + logHandler.ClosedControlConnection(); + + threadAlive = false; + } + } + + /// + /// Process an FTP command. + /// + private void ProcessCommand(string verb, string arguments) + { + switch (verb) { + case "SYST": + { + Respond(215, "UNIX emulated by mooftpserv"); + break; + } + case "QUIT": + { + Respond(221, "Bye."); + // first flush, then close + controlSocket.Shutdown(SocketShutdown.Both); + controlSocket.Close(); + break; + } + case "USER": + { + Respond(230, "You are already logged in."); + break; + } + case "PASS": + { + Respond(230, "You are already logged in."); + break; + } + case "FEAT": + { + Respond(211, "Features:\r\n " + String.Join("\r\n ", FEATURES), true); + Respond(211, "Features done."); + break; + } + case "OPTS": + { + // Windows Explorer uses lowercase args + if (arguments != null && arguments.ToUpper() == "UTF8 ON") + Respond(200, "Always in UTF8 mode."); + else + Respond(504, "Unknown option."); + break; + } + case "TYPE": + { + if (arguments == "A" || arguments == "A N") { + transferDataType = DataType.ASCII; + Respond(200, "Switching to ASCII mode."); + } else if (arguments == "I") { + transferDataType = DataType.IMAGE; + Respond(200, "Switching to BINARY mode."); + } else { + Respond(500, "Unknown TYPE arguments."); + } + break; + } + case "PORT": + { + IPEndPoint port = ParseAddress(arguments); + if (port == null) { + Respond(500, "Invalid host-port format."); + break; + } + + if (!authHandler.AllowActiveDataConnection(port)) { + Respond(500, "PORT arguments refused."); + break; + } + + dataPort = port; + CreateDataSocket(false); + Respond(200, GetRandomText(OK_TEXT)); + break; + } + case "PASV": + { + dataPort = null; + + try { + CreateDataSocket(true); + } catch (Exception ex) { + Respond(500, ex); + break; + } + + string port = FormatAddress((IPEndPoint) dataSocket.LocalEndPoint); + Respond(227, String.Format("Switched to passive mode ({0})", port)); + break; + } + case "XPWD": + case "PWD": + { + ResultOrError ret = fsHandler.GetCurrentDirectory(); + if (ret.HasError) + Respond(500, ret.Error); + else + Respond(257, EscapePath(ret.Result)); + break; + } + case "XCWD": + case "CWD": + { + ResultOrError ret = fsHandler.ChangeDirectory(arguments); + if (ret.HasError) + Respond(550, ret.Error); + else + Respond(200, GetRandomText(OK_TEXT)); + break; + } + case "XCUP": + case "CDUP": + { + ResultOrError ret = fsHandler.ChangeDirectory(".."); + if (ret.HasError) + Respond(550, ret.Error); + else + Respond(200, GetRandomText(OK_TEXT)); + break; + } + case "XMKD": + case "MKD": + { + ResultOrError ret = fsHandler.CreateDirectory(arguments); + if (ret.HasError) + Respond(550, ret.Error); + else + Respond(257, EscapePath(ret.Result)); + break; + } + case "XRMD": + case "RMD": + { + ResultOrError ret = fsHandler.RemoveDirectory(arguments); + if (ret.HasError) + Respond(550, ret.Error); + else + Respond(250, GetRandomText(OK_TEXT)); + break; + } + case "RETR": + { + ResultOrError ret = fsHandler.ReadFile(arguments); + if (ret.HasError) { + Respond(550, ret.Error); + break; + } + + SendData(ret.Result); + break; + } + case "STOR": + { + ResultOrError ret = fsHandler.WriteFile(arguments); + if (ret.HasError) { + Respond(550, ret.Error); + break; + } + ReceiveData(ret.Result); + var ret2 = fsHandler.WriteFileFinalize(arguments, ret.Result); + if (ret2.HasError) + { + Respond(550, ret.Error); + break; + } + break; + } + case "DELE": + { + ResultOrError ret = fsHandler.RemoveFile(arguments); + if (ret.HasError) + Respond(550, ret.Error); + else + Respond(250, GetRandomText(OK_TEXT)); + break; + } + case "RNFR": + { + if (arguments == null || arguments.Trim() == "") { + Respond(500, "Empty path is invalid."); + break; + } + + renameFromPath = arguments; + Respond(350, "Waiting for target path."); + break; + } + case "RNTO": + { + if (renameFromPath == null) { + Respond(503, "Use RNFR before RNTO."); + break; + } + + ResultOrError ret = fsHandler.RenameFile(renameFromPath, arguments); + renameFromPath = null; + if (ret.HasError) + Respond(550, ret.Error); + else + Respond(250, GetRandomText(OK_TEXT)); + break; + } + case "MDTM": + { + ResultOrError ret = fsHandler.GetLastModifiedTimeUtc(arguments); + if (ret.HasError) + Respond(550, ret.Error); + else + Respond(213, FormatTime(EnsureUnixTime(ret.Result))); + break; + } + case "SIZE": + { + ResultOrError ret = fsHandler.GetFileSize(arguments); + if (ret.HasError) + Respond(550, ret.Error); + else + Respond(213, ret.Result.ToString()); + break; + } + case "LIST": + { + // apparently browsers like to pass arguments to LIST + // assuming they are passed through to the UNIX ls command + arguments = RemoveLsArgs(arguments); + + ResultOrError ret = fsHandler.ListEntries(arguments); + if (ret.HasError) { + Respond(500, ret.Error); + break; + } + + SendData(MakeStream(FormatDirList(ret.Result))); + break; + } + case "STAT": + { + if (arguments == null || arguments.Trim() == "") { + Respond(504, "Not implemented for these arguments."); + break; + } + + arguments = RemoveLsArgs(arguments); + + ResultOrError ret = fsHandler.ListEntries(arguments); + if (ret.HasError) { + Respond(500, ret.Error); + break; + } + + Respond(213, "Status:\r\n" + FormatDirList(ret.Result), true); + Respond(213, "Status done."); + break; + } + case "NLST": + { + // remove common arguments, we do not support any of them + arguments = RemoveLsArgs(arguments); + + ResultOrError ret = fsHandler.ListEntries(arguments); + if (ret.HasError) + { + Respond(500, ret.Error); + break; + } + + SendData(MakeStream(FormatNLST(ret.Result))); + break; + } + case "NOOP": + { + Respond(200, GetRandomText(OK_TEXT)); + break; + } + default: + { + Respond(500, "Unknown command."); + break; + } + } + } + + /// + /// Read a command from the control connection. + /// + /// + /// True if a command was read. + /// + /// + /// Will receive the verb of the command. + /// + /// + /// Will receive the arguments of the command, or null. + /// + private bool ReadCommand(out string verb, out string args) + { + verb = null; + args = null; + + int endPos = -1; + // can there already be a command in the buffer? + if (cmdRcvBytes > 0) + Array.IndexOf(cmdRcvBuffer, (byte)'\n', 0, cmdRcvBytes); + + try { + // read data until a newline is found + do { + int freeBytes = cmdRcvBuffer.Length - cmdRcvBytes; + int bytes = controlSocket.Receive(cmdRcvBuffer, cmdRcvBytes, freeBytes, SocketFlags.None); + if (bytes <= 0) + break; + + cmdRcvBytes += bytes; + + // search \r\n + endPos = Array.IndexOf(cmdRcvBuffer, (byte)'\r', 0, cmdRcvBytes); + if (endPos != -1 && (cmdRcvBytes <= endPos + 1 || cmdRcvBuffer[endPos + 1] != (byte)'\n')) + endPos = -1; + } while (endPos == -1 && cmdRcvBytes < cmdRcvBuffer.Length); + } catch (SocketException) { + // in case the socket is closed or has some other error while reading + return false; + } + + if (endPos == -1) + return false; + + string command = DecodeString(cmdRcvBuffer, endPos); + + // remove the command from the buffer + cmdRcvBytes -= (endPos + 2); + Array.Copy(cmdRcvBuffer, endPos + 2, cmdRcvBuffer, 0, cmdRcvBytes); + + // CF is missing a limited String.Split + string[] tokens = command.Split(' '); + verb = tokens[0].ToUpper(); // commands are case insensitive + args = (tokens.Length > 1 ? String.Join(" ", tokens, 1, tokens.Length - 1) : null); + + if (logHandler != null) + logHandler.ReceivedCommand(verb, args); + + return true; + } + + /// + /// Send a response on the control connection + /// + private void Respond(uint code, string desc, bool moreFollows) + { + string response = code.ToString(); + if (desc != null) + response += (moreFollows ? '-' : ' ') + desc; + + if (!response.EndsWith("\r\n")) + response += "\r\n"; + + byte[] sendBuffer = EncodeString(response); + controlSocket.Send(sendBuffer); + + if (logHandler != null) + logHandler.SentResponse(code, desc); + } + + /// + /// Send a response on the control connection + /// + private void Respond(uint code, string desc) + { + Respond(code, desc, false); + } + + /// + /// Send a response on the control connection, with an exception as text + /// + private void Respond(uint code, Exception ex) + { + Respond(code, ex.Message.Replace(Environment.NewLine, " ")); + } + + /// + /// Process FTP commands when the user is not yet logged in. + /// Mostly handles the login commands USER and PASS. + /// + private void HandleAuth(string verb, string args) + { + if (verb == "USER" && args != null) { + if (authHandler.AllowLogin(args, null)) { + Respond(230, "Login successful."); + loggedIn = true; + } else { + loggedInUser = args; + Respond(331, "Password please."); + } + } else if (verb == "PASS") { + if (loggedInUser != null) { + if (authHandler.AllowLogin(loggedInUser, args)) { + Respond(230, "Login successful."); + loggedIn = true; + } else { + loggedInUser = null; + Respond(530, "Login failed, please try again."); + } + } else { + Respond(530, "No USER specified."); + } + } else { + Respond(530, "Please login first."); + } + } + + /// + /// Read from the given stream and send the data over a data connection + /// + private void SendData(Stream stream) + { + try { + bool passive = (dataPort == null); + using (Socket socket = OpenDataConnection()) { + if (socket == null) + return; + + IPEndPoint remote = (IPEndPoint) socket.RemoteEndPoint; + IPEndPoint local = (IPEndPoint) socket.LocalEndPoint; + + if (logHandler != null) + logHandler.NewDataConnection(remote, local, passive); + + try { + while (true) { + int bytes = stream.Read(dataBuffer, 0, dataBufferSize); + if (bytes <= 0) { + break; + } + + if (transferDataType == DataType.IMAGE || noAsciiConv) { + // TYPE I -> just pass through + socket.Send(dataBuffer, bytes, SocketFlags.None); + } else { + // TYPE A -> convert local EOL style to CRLF + + // if the buffer ends with a potential partial EOL, + // try to read the rest of the EOL + // (i assume that the EOL has max. two bytes) + if (localEolBytes.Length == 2 && + dataBuffer[bytes - 1] == localEolBytes[0]) { + if (stream.Read(dataBuffer, bytes, 1) == 1) + ++bytes; + } + + byte[] convBuffer = null; + int convBytes = ConvertAsciiBytes(dataBuffer, bytes, true, out convBuffer); + socket.Send(convBuffer, convBytes, SocketFlags.None); + } + } + + // flush socket before closing (done by using-statement) + socket.Shutdown(SocketShutdown.Send); + Respond(226, "Transfer complete."); + } catch (Exception ex) { + Respond(500, ex); + return; + } finally { + if (logHandler != null) + logHandler.ClosedDataConnection(remote, local, passive); + } + } + } finally { + stream.Close(); + } + } + + /// + /// Read from a data connection and write to the given stream + /// + private void ReceiveData(Stream stream) + { + try { + bool passive = (dataPort == null); + using (Socket socket = OpenDataConnection()) { + if (socket == null) + return; + + IPEndPoint remote = (IPEndPoint) socket.RemoteEndPoint; + IPEndPoint local = (IPEndPoint) socket.LocalEndPoint; + + if (logHandler != null) + logHandler.NewDataConnection(remote, local, passive); + + try { + while (true) { + // fill up the in-memory buffer before writing to disk + int totalBytes = 0; + while (totalBytes < dataBufferSize) { + int freeBytes = dataBufferSize - totalBytes; + int newBytes = socket.Receive(dataBuffer, totalBytes, freeBytes, SocketFlags.None); + + if (newBytes > 0) { + totalBytes += newBytes; + } else if (newBytes < 0) { + Respond(500, String.Format("Transfer failed: Receive() returned {0}", newBytes)); + return; + } else { + // end of data + break; + } + } + + // end of data + if (totalBytes == 0) + break; + + if (transferDataType == DataType.IMAGE || noAsciiConv) { + // TYPE I -> just pass through + stream.Write(dataBuffer, 0, totalBytes); + } else { + // TYPE A -> convert CRLF to local EOL style + + // if the buffer ends with a potential partial CRLF, + // try to read the LF + if (dataBuffer[totalBytes - 1] == remoteEolBytes[0]) { + if (socket.Receive(dataBuffer, totalBytes, 1, SocketFlags.None) == 1) + ++totalBytes; + } + + byte[] convBuffer = null; + int convBytes = ConvertAsciiBytes(dataBuffer, totalBytes, false, out convBuffer); + stream.Write(convBuffer, 0, convBytes); + } + } + + socket.Shutdown(SocketShutdown.Receive); + Respond(226, "Transfer complete."); + } catch (Exception ex) { + Respond(500, ex); + return; + } finally { + if (logHandler != null) + logHandler.ClosedDataConnection(remote, local, passive); + } + } + } finally { + //stream.Close(); + } + } + + /// + /// Create a socket for a data connection. + /// + /// + /// If true, the socket will be bound to a local port for the PASV command. + /// Otherwise the socket can be used for connecting to the address given in a PORT command. + /// + private void CreateDataSocket(bool listen) + { + if (dataSocket != null) + dataSocket.Close(); + + dataSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.IP); + + if (listen) { + IPAddress serverIP = ((IPEndPoint) controlSocket.LocalEndPoint).Address; + dataSocket.Bind(new IPEndPoint(serverIP, 0)); + dataSocketBound = true; // CF is missing Socket.IsBound + dataSocket.Listen(1); + } + } + + /// + /// Opens an active or passive data connection and returns the socket + /// or null if there was no preceding PORT or PASV command or in case or error. + /// + private Socket OpenDataConnection() + { + if (dataPort == null && !dataSocketBound) { + Respond(425, "No data port configured, use PORT or PASV."); + return null; + } + + Respond(150, "Opening data connection."); + + try { + if (dataPort != null) { + // active mode + dataSocket.Connect(dataPort); + dataPort = null; + return dataSocket; + } else { + // passive mode + Socket socket = dataSocket.Accept(); + dataSocket.Close(); + dataSocketBound = false; + return socket; + } + } catch (Exception ex) { + Respond(500, String.Format("Failed to open data connection: {0}", ex.Message.Replace(Environment.NewLine, " "))); + return null; + } + } + + /// + /// Convert between different EOL flavors. + /// + /// + /// The number of bytes in the resultBuffer. + /// + /// + /// The input buffer whose data will be converted. + /// + /// + /// The number of bytes in the input buffer. + /// + /// + /// If true, the conversion will be made from local to FTP flavor, + /// otherwise from FTP to local flavor. + /// + /// + /// The resulting buffer with the converted text. + /// Can be the same reference as the input buffer if there is nothing to convert. + /// + private int ConvertAsciiBytes(byte[] buffer, int len, bool localToRemote, out byte[] resultBuffer) + { + byte[] fromBytes = (localToRemote ? localEolBytes : remoteEolBytes); + byte[] toBytes = (localToRemote ? remoteEolBytes : localEolBytes); + resultBuffer = null; + + int startIndex = 0; + int resultLen = 0; + int searchLen; + while ((searchLen = len - startIndex) > 0) { + // search for the first byte of the EOL sequence + int eolIndex = Array.IndexOf(buffer, fromBytes[0], startIndex, searchLen); + + // shortcut if there is no EOL in the whole buffer + if (eolIndex == -1 && startIndex == 0) { + resultBuffer = buffer; + return len; + } + + // allocate to worst-case size + if (resultBuffer == null) + resultBuffer = new byte[len * 2]; + + if (eolIndex == -1) { + Array.Copy(buffer, startIndex, resultBuffer, resultLen, searchLen); + resultLen += searchLen; + break; + } else { + // compare the rest of the EOL + int matchBytes = 1; + for (int i = 1; i < fromBytes.Length && eolIndex + i < len; ++i) { + if (buffer[eolIndex + i] == fromBytes[i]) + ++matchBytes; + } + + if (matchBytes == fromBytes.Length) { + // found an EOL to convert + int copyLen = eolIndex - startIndex; + if (copyLen > 0) { + Array.Copy(buffer, startIndex, resultBuffer, resultLen, copyLen); + resultLen += copyLen; + } + Array.Copy(toBytes, 0, resultBuffer, resultLen, toBytes.Length); + resultLen += toBytes.Length; + startIndex += copyLen + fromBytes.Length; + } else { + int copyLen = (eolIndex - startIndex) + 1; + Array.Copy(buffer, startIndex, resultBuffer, resultLen, copyLen); + resultLen += copyLen; + startIndex += copyLen; + } + } + } + + return resultLen; + } + + /// + /// Parse the argument of a PORT command into an IPEndPoint + /// + private IPEndPoint ParseAddress(string address) + { + string[] tokens = address.Split(','); + byte[] bytes = new byte[tokens.Length]; + for (int i = 0; i < tokens.Length; ++i) { + try { + // CF is missing TryParse + bytes[i] = byte.Parse(tokens[i]); + } catch (Exception) { + return null; + } + } + + long ip = bytes[0] | bytes[1] << 8 | bytes[2] << 16 | bytes[3] << 24; + int port = bytes[4] << 8 | bytes[5]; + return new IPEndPoint(ip, port); + } + + /// + /// Format an IPEndPoint so that it can be used in a response for a PASV command + /// + private string FormatAddress(IPEndPoint address) + { + byte[] ip = address.Address.GetAddressBytes(); + int port = address.Port; + + return String.Format("{0},{1},{2},{3},{4},{5}", + ip[0], ip[1], ip[2], ip[3], + (port & 0xFF00) >> 8, port & 0x00FF); + } + + /// + /// Formats a list of file system entries for a response to a LIST or STAT command + /// + private string FormatDirList(FileSystemEntry[] list) + { + int maxSizeChars = 0; + foreach (FileSystemEntry entry in list) { + maxSizeChars = Math.Max(maxSizeChars, entry.Size.ToString().Length); + } + + DateTime sixMonthsAgo = EnsureUnixTime(DateTime.Now.ToUniversalTime().AddMonths(-6)); + + StringBuilder result = new StringBuilder(); + foreach (FileSystemEntry entry in list) { + char dirflag = (entry.IsDirectory ? 'd' : '-'); + string size = entry.Size.ToString().PadLeft(maxSizeChars); + DateTime time = EnsureUnixTime(entry.LastModifiedTimeUtc); + string timestr = MONTHS[time.Month - 1]; + if (time < sixMonthsAgo) + timestr += time.ToString(" dd yyyy"); + else + timestr += time.ToString(" dd hh:mm"); + + result.AppendFormat("{0}rwxr--r-- 1 owner group {1} {2} {3}\r\n", + dirflag, size, timestr, entry.Name); + } + + return result.ToString(); + } + + /// + /// Formats a list of file system entries for a response to an NLST command + /// + private string FormatNLST(FileSystemEntry[] list) + { + StringBuilder sb = new StringBuilder(); + foreach (FileSystemEntry entry in list) { + sb.Append(entry.Name); + sb.Append("\r\n"); + } + return sb.ToString(); + } + + /// + /// Format a timestamp for a reponse to a MDTM command + /// + private string FormatTime(DateTime time) + { + return time.ToString("yyyyMMddHHmmss"); + } + + /// + /// Restrict the year in a timestamp to >= 1970 + /// + private DateTime EnsureUnixTime(DateTime time) + { + // the server claims to be UNIX, so there should be + // no timestamps before 1970. + // e.g. FileZilla does not handle them correctly. + + int yearDiff = time.Year - 1970; + if (yearDiff < 0) + return time.AddYears(-yearDiff); + else + return time; + } + + /// + /// Escape a path for a response to a PWD command + /// + private string EscapePath(string path) + { + // double-quotes in paths are escaped by doubling them + return '"' + path.Replace("\"", "\"\"") + '"'; + } + + /// + /// Remove "-a" or "-l" from the arguments for a LIST or STAT command + /// + private string RemoveLsArgs(string args) + { + if (args != null && (args.StartsWith("-a") || args.StartsWith("-l"))) { + if (args.Length == 2) + return null; + else if (args.Length > 3 && args[2] == ' ') + return args.Substring(3); + } + + return args; + } + + /// + /// Convert a string to a list of UTF8 bytes + /// + private byte[] EncodeString(string data) + { + return Encoding.UTF8.GetBytes(data); + } + + /// + /// Convert a list of UTF8 bytes to a string + /// + private string DecodeString(byte[] data, int len) + { + return Encoding.UTF8.GetString(data, 0, len); + } + + /// + /// Convert a list of UTF8 bytes to a string + /// + private string DecodeString(byte[] data) + { + return DecodeString(data, data.Length); + } + + /// + /// Fill a stream with the given string as UTF8 bytes + /// + private Stream MakeStream(string data) + { + return new MemoryStream(EncodeString(data)); + } + + /// + /// Return a randomly selected text from the given list + /// + private string GetRandomText(string[] texts) + { + int index = randomTextIndex.Next(0, texts.Length); + return texts[index]; + } + } +} -- cgit v1.2.3