Mercurial > projects > ldc
diff tango/tango/net/ftp/FtpClient.d @ 132:1700239cab2e trunk
[svn r136] MAJOR UNSTABLE UPDATE!!!
Initial commit after moving to Tango instead of Phobos.
Lots of bugfixes...
This build is not suitable for most things.
author | lindquist |
---|---|
date | Fri, 11 Jan 2008 17:57:40 +0100 |
parents | |
children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tango/tango/net/ftp/FtpClient.d Fri Jan 11 17:57:40 2008 +0100 @@ -0,0 +1,1922 @@ +/******************************************************************************* + + copyright: Copyright (c) 2006 UWB. All rights reserved + + license: BSD style: $(LICENSE) + + version: Initial release: June 2006 + + author: UWB + +*******************************************************************************/ + +module tango.net.ftp.FtpClient; + +private import tango.net.Socket; + +private import tango.net.ftp.Telnet; + +private import tango.time.Clock; + +private import tango.io.Conduit, + tango.io.GrowBuffer, + tango.io.FileConduit; + +private import tango.time.chrono.Gregorian; + +private import Text = tango.text.Util; + +private import Ascii = tango.text.Ascii; + +private import Regex = tango.text.Regex; + +private import Integer = tango.text.convert.Integer; + +private import Timestamp = tango.text.convert.TimeStamp; + + +/// An FTP progress delegate. +/// +/// You may need to add the restart position to this, and use SIZE to determine +/// percentage completion. This only represents the number of bytes +/// transferred. +/// +/// Params: +/// pos = the current offset into the stream +alias void delegate(in size_t pos) FtpProgress; + +/// The format of data transfer. +enum FtpFormat +{ + /// Indicates ASCII NON PRINT format (line ending conversion to CRLF.) + ascii, + /// Indicates IMAGE format (8 bit binary octets.) + image, +} + +/// A server response, consisting of a code and a potentially multi-line message. +struct FtpResponse +{ + /// The response code. + /// + /// The digits in the response code can be used to determine status + /// programatically. + /// + /// First Digit (status): + /// 1xx = a positive, but preliminary, reply + /// 2xx = a positive reply indicating completion + /// 3xx = a positive reply indicating incomplete status + /// 4xx = a temporary negative reply + /// 5xx = a permanent negative reply + /// + /// Second Digit (subject): + /// x0x = condition based on syntax + /// x1x = informational + /// x2x = connection + /// x3x = authentication/process + /// x5x = file system + char[3] code = "000"; + + /// The message from the server. + /// + /// With some responses, the message may contain parseable information. + /// For example, this is true of the 257 response. + char[] message = null; +} + +/// Active or passive connection mode. +enum FtpConnectionType +{ + /// Active - server connects to client on open port. + active, + /// Passive - server listens for a connection from the client. + passive, +} + +/// Detail about the data connection. +/// +/// This is used to properly send PORT and PASV commands. +struct FtpConnectionDetail +{ + /// The type to be used. + FtpConnectionType type = FtpConnectionType.passive; + + /// The address to give the server. + Address address = null; + + /// The address to actually listen on. + Address listen = null; +} + +/// A supported feature of an FTP server. +struct FtpFeature +{ + /// The command which is supported, e.g. SIZE. + char[] command = null; + /// Parameters for this command; e.g. facts for MLST. + char[] params = null; +} + +/// The type of a file in an FTP listing. +enum FtpFileType +{ + /// An unknown file or type (no type fact.) + unknown, + /// A regular file, or similar. + file, + /// The current directory (e.g. ., but not necessarily.) + cdir, + /// A parent directory (usually "..".) + pdir, + /// Any other type of directory. + dir, + /// Another type of file. Consult the "type" fact. + other, +} + +/// Information about a file in an FTP listing. +struct FtpFileInfo +{ + /// The filename. + char[] name = null; + /// Its type. + FtpFileType type = FtpFileType.unknown; + /// Size in bytes (8 bit octets), or -1 if not available. + long size = -1; + /// Modification time, if available. + Time modify = Time.max; + /// Creation time, if available (not often.) + Time create = Time.max; + /// The file's mime type, if known. + char[] mime = null; + /// An associative array of all facts returned by the server, lowercased. + char[][char[]] facts; +} + +/// A connection to an FTP server. +/// +/// Example: +/// ---------- +/// auto ftp = new FTPConnection("hostname", "user", "pass",21); +/// +/// ftp.mkdir("test"); +/// ftp.close(); +/// ---------- +/// +/// Standards: RFC 959, RFC 2228, RFC 2389, RFC 2428 +/// +/// Bugs: +/// Does not support several uncommon FTP commands and responses. + + +class FTPConnection : Telnet +{ + /// Supported features (if known.) + /// + /// This will be empty if not known, or else contain at least FEAT. + public FtpFeature[] supported_features = null; + + /// Data connection information. + protected FtpConnectionDetail data_info; + + /// The last-set restart position. + /// + /// This is only used when a local file is used for a RETR or STOR. + protected size_t restart_pos = 0; + + /// error handler + protected void exception (char[] msg) + { + throw new FTPException ("Exception: " ~ msg); + } + + /// ditto + protected void exception (FtpResponse r) + { + throw new FTPException (r); + } + + /// Construct an FTPConnection without connecting immediately. + public this() + { + } + + /// Connect to an FTP server with a username and password. + /// + /// Params: + /// hostname = the hostname or IP address to connect to + /// port = the port number to connect to + /// username = username to be sent + /// password = password to be sent, if requested + public this(char[] hostname, char[] username, char[] password, int port = 21) + { + this.connect(hostname, username, password,port); + } + + /// Connect to an FTP server with a username and password. + /// + /// Params: + /// hostname = the hostname or IP address to connect to + /// port = the port number to connect to + /// username = username to be sent + /// password = password to be sent, if requested + public void connect(char[] hostname, char[] username, char[] password, int port = 21) + in + { + // We definitely need a hostname and port. + assert (hostname.length > 0); + assert (port > 0); + } + body + { + // Close any active connection. + + if (this.socket !is null) + this.close(); + + + // Connect to whichever FTP server responds first. + this.findAvailableServer(hostname, port); + + this.socket.blocking = false; + + scope (failure) + { + this.close(); + } + + // The welcome message should always be a 220. 120 and 421 are considered errors. + this.readResponse("220"); + + if (username.length == 0) + return; + + // Send the username. Anything but 230, 331, or 332 is basically an error. + this.sendCommand("USER", username); + auto response = this.readResponse(); + + // 331 means username okay, please proceed with password. + if (response.code == "331") + { + this.sendCommand("PASS", password); + response = this.readResponse(); + } + + // We don't support ACCT (332) so we should get a 230 here. + if (response.code != "230" && response.code != "202") + { + + exception (response); + } + + } + + /// Close the connection to the server. + public void close() + { + assert (this.socket !is null); + + // Don't even try to close it if it's not open. + if (this.socket !is null) + { + try + { + this.sendCommand("QUIT"); + this.readResponse("221"); + } + // Ignore if the above could not be completed. + catch (FTPException) + { + } + + // Shutdown the socket... + this.socket.shutdown(SocketShutdown.BOTH); + this.socket.detach(); + + // Clear out everything. + delete this.supported_features; + delete this.socket; + } + } + + /// Set the connection to use passive mode for data tranfers. + /// + /// This is the default. + public void setPassive() + { + this.data_info.type = FtpConnectionType.passive; + + delete this.data_info.address; + delete this.data_info.listen; + } + + /// Set the connection to use active mode for data transfers. + /// + /// This may not work behind firewalls. + /// + /// Params: + /// ip = the ip address to use + /// port = the port to use + /// listen_ip = the ip to listen on, or null for any + /// listen_port = the port to listen on, or 0 for the same port + public void setActive(char[] ip, ushort port, char[] listen_ip = null, ushort listen_port = 0) + in + { + assert (ip.length > 0); + assert (port > 0); + } + body + { + this.data_info.type = FtpConnectionType.active; + this.data_info.address = new IPv4Address(ip, port); + + // A local-side port? + if (listen_port == 0) + listen_port = port; + + // Any specific IP to listen on? + if (listen_ip == null) + this.data_info.listen = new IPv4Address(IPv4Address.ADDR_ANY, listen_port); + else + this.data_info.listen = new IPv4Address(listen_ip, listen_port); + } + + + /// Change to the specified directory. + public void cd(char[] dir) + in + { + assert (dir.length > 0); + } + body + { + this.sendCommand("CWD", dir); + this.readResponse("250"); + } + + /// Change to the parent of this directory. + public void cdup() + { + this.sendCommand("CDUP"); + this.readResponse("200"); + } + + /// Determine the current directory. + /// + /// Returns: the current working directory + public char[] cwd() + { + this.sendCommand("PWD"); + auto response = this.readResponse("257"); + + return this.parse257(response); + } + + /// Change the permissions of a file. + /// + /// This is a popular feature of most FTP servers, but not explicitly outlined + /// in the spec. It does not work on, for example, Windows servers. + /// + /// Params: + /// path = the path to the file to chmod + /// mode = the desired mode; expected in octal (0777, 0644, etc.) + public void chmod(char[] path, int mode) + in + { + assert (path.length > 0); + assert (mode >= 0 && (mode >> 16) == 0); + } + body + { + char[] tmp = "000"; + // Convert our octal parameter to a string. + Integer.format(tmp, cast(long) mode, Integer.Style.Octal); + this.sendCommand("SITE CHMOD", tmp, path); + this.readResponse("200"); + } + + /// Remove a file or directory. + /// + /// Params: + /// path = the path to the file or directory to delete + public void del(char[] path) + in + { + assert (path.length > 0); + } + body + { + this.sendCommand("DELE", path); + auto response = this.readResponse(); + + // Try it as a directory, then...? + if (response.code != "250") + this.rm(path); + } + + /// Remove a directory. + /// + /// Params: + /// path = the directory to delete + public void rm(char[] path) + in + { + assert (path.length > 0); + } + body + { + this.sendCommand("RMD", path); + this.readResponse("250"); + } + + /// Rename/move a file or directory. + /// + /// Params: + /// old_path = the current path to the file + /// new_path = the new desired path + public void rename(char[] old_path, char[] new_path) + in + { + assert (old_path.length > 0); + assert (new_path.length > 0); + } + body + { + // Rename from... rename to. Pretty simple. + this.sendCommand("RNFR", old_path); + this.readResponse("350"); + + this.sendCommand("RNTO", new_path); + this.readResponse("250"); + } + + /// Determine the size in bytes of a file. + /// + /// This size is dependent on the current type (ASCII or IMAGE.) + /// + /// Params: + /// path = the file to retrieve the size of + /// format = what format the size is desired in + public size_t size(char[] path, FtpFormat format = FtpFormat.image) + in + { + assert (path.length > 0); + } + body + { + this.type(format); + + this.sendCommand("SIZE", path); + auto response = this.readResponse("213"); + + // Only try to parse the numeric bytes of the response. + size_t end_pos = 0; + while (end_pos < response.message.length) + { + if (response.message[end_pos] < '0' || response.message[end_pos] > '9') + break; + end_pos++; + } + + return toInt(response.message[0 .. end_pos]); + } + + /// Send a command and process the data socket. + /// + /// This opens the data connection and checks for the appropriate response. + /// + /// Params: + /// command = the command to send (e.g. STOR) + /// parameters = any arguments to send + /// + /// Returns: the data socket + public Socket processDataCommand(char[] command, char[][] parameters ...) + { + // Create a connection. + Socket data = this.getDataSocket(); + scope (failure) + { + // Close the socket, whether we were listening or not. + data.shutdown(SocketShutdown.BOTH); + data.detach(); + } + + // Tell the server about it. + this.sendCommand(command, parameters); + + // We should always get a 150/125 response. + auto response = this.readResponse(); + if (response.code != "150" && response.code != "125") + exception (response); + + // We might need to do this for active connections. + this.prepareDataSocket(data); + + return data; + } + + /// Clean up after the data socket and process the response. + /// + /// This closes the socket and reads the 226 response. + /// + /// Params: + /// data = the data socket + public void finishDataCommand(Socket data) + { + // Close the socket. This tells the server we're done (EOF.) + data.shutdown(SocketShutdown.BOTH); + data.detach(); + + // We shouldn't get a 250 in STREAM mode. + this.readResponse("226"); + } + + /// Get a data socket from the server. + /// + /// This sends PASV/PORT as necessary. + /// + /// Returns: the data socket or a listener + protected Socket getDataSocket() + { + // What type are we using? + switch (this.data_info.type) + { + default: + exception ("unknown connection type"); + + // Passive is complicated. Handle it in another member. + case FtpConnectionType.passive: + return this.connectPassive(); + + // Active is simpler, but not as fool-proof. + case FtpConnectionType.active: + IPv4Address data_addr = cast(IPv4Address) this.data_info.address; + + // Start listening. + Socket listener = new Socket(AddressFamily.INET, SocketType.STREAM, ProtocolType.TCP); + listener.bind(this.data_info.listen); + listener.listen(32); + + // Use EPRT if we know it's supported. + if (this.is_supported("EPRT")) + { + char[64] tmp = void; + + this.sendCommand("EPRT", Text.layout(tmp, "|1|%0|%1|", data_addr.toAddrString, data_addr.toPortString)); + // this.sendCommand("EPRT", format("|1|%s|%s|", data_addr.toAddrString(), data_addr.toPortString())); + this.readResponse("200"); + } + else + { + int h1, h2, h3, h4, p1, p2; + h1 = (data_addr.addr() >> 24) % 256; + h2 = (data_addr.addr() >> 16) % 256; + h3 = (data_addr.addr() >> 8_) % 256; + h4 = (data_addr.addr() >> 0_) % 256; + p1 = (data_addr.port() >> 8_) % 256; + p2 = (data_addr.port() >> 0_) % 256; + + // low overhead method to format a numerical string + char[64] tmp = void; + char[20] foo = void; + auto str = Text.layout (tmp, "%0,%1,%2,%3,%4,%5", + Integer.format(foo[0..3], h1), + Integer.format(foo[3..6], h2), + Integer.format(foo[6..9], h3), + Integer.format(foo[9..12], h4), + Integer.format(foo[12..15], p1), + Integer.format(foo[15..18], p2)); + + // This formatting is weird. + // this.sendCommand("PORT", format("%d,%d,%d,%d,%d,%d", h1, h2, h3, h4, p1, p2)); + + this.sendCommand("PORT", str); + this.readResponse("200"); + } + + return listener; + } + assert (false); + } + + /// Prepare a data socket for use. + /// + /// This modifies the socket in some cases. + /// + /// Params: + /// data = the data listener socket + protected void prepareDataSocket(inout Socket data) + { + switch (this.data_info.type) + { + default: + exception ("unknown connection type"); + + case FtpConnectionType.active: + Socket new_data = null; + + SocketSet set = new SocketSet(); + scope (exit) + delete set; + + // At end_time, we bail. + Time end_time = Clock.now + this.timeout; + + while (Clock.now < end_time) + { + set.reset(); + set.add(data); + + // Can we accept yet? + int code = Socket.select(set, null, null, this.timeout); + if (code == -1 || code == 0) + break; + + new_data = data.accept(); + break; + } + + if (new_data is null) + throw new FTPException("CLIENT: No connection from server", "420"); + + // We don't need the listener anymore. + data.shutdown(SocketShutdown.BOTH); + data.detach(); + + // This is the actual socket. + data = new_data; + break; + + case FtpConnectionType.passive: + break; + } + } + + /// Send a PASV and initiate a connection. + /// + /// Returns: a connected socket + public Socket connectPassive() + { + Address connect_to = null; + + // SPSV, which is just a port number. + if (this.is_supported("SPSV")) + { + this.sendCommand("SPSV"); + auto response = this.readResponse("227"); + + // Connecting to the same host. + IPv4Address remote = cast(IPv4Address) this.socket.remoteAddress(); + assert (remote !is null); + + uint address = remote.addr(); + uint port = toInt(response.message); + + connect_to = new IPv4Address(address, cast(ushort) port); + } + // Extended passive mode (IP v6, etc.) + else if (this.is_supported("EPSV")) + { + this.sendCommand("EPSV"); + auto response = this.readResponse("229"); + + // Try to pull out the (possibly not parenthesized) address. + auto r = Regex.search(response.message, `\([^0-9][^0-9][^0-9](\d+)[^0-9]\)`); + if (r is null) + throw new FTPException("CLIENT: Unable to parse address", "501"); + + IPv4Address remote = cast(IPv4Address) this.socket.remoteAddress(); + assert (remote !is null); + + uint address = remote.addr(); + uint port = toInt(r.match(1)); + + connect_to = new IPv4Address(address, cast(ushort) port); + } + else + { + this.sendCommand("PASV"); + auto response = this.readResponse("227"); + + // Try to pull out the (possibly not parenthesized) address. + auto r = Regex.search(response.message, `(\d+),\s*(\d+),\s*(\d+),\s*(\d+),\s*(\d+)(,\s*(\d+))?`); + if (r is null) + throw new FTPException("CLIENT: Unable to parse address", "501"); + + // Now put it into something std.socket will understand. + char[] address = r.match(1)~"."~r.match(2)~"."~r.match(3)~"."~r.match(4); + uint port = (toInt(r.match(5)) << 8) + (r.match(7).length > 0 ? toInt(r.match(7)) : 0); + + // Okay, we've got it! + connect_to = new IPv4Address(address, port); + } + + scope (exit) + delete connect_to; + + // This will throw an exception if it cannot connect. + auto sock = new Socket(AddressFamily.INET, SocketType.STREAM, ProtocolType.TCP); + sock.connect (connect_to); + return sock; + } + + /// Change the type of data transfer. + /// + /// ASCII mode implies that line ending conversion should be made. + /// Only NON PRINT is supported. + /// + /// Params: + /// type = FtpFormat.ascii or FtpFormat.image + public void type(FtpFormat format) + { + if (format == FtpFormat.ascii) + this.sendCommand("TYPE", "A"); + else + this.sendCommand("TYPE", "I"); + + this.readResponse("200"); + } + + /// Store a local file on the server. + /// + /// Calling this function will change the current data transfer format. + /// + /// Params: + /// path = the path to the remote file + /// local_file = the path to the local file + /// progress = a delegate to call with progress information + /// format = what format to send the data in + + public void put(char[] path, char[] local_file, FtpProgress progress = null, FtpFormat format = FtpFormat.image) + in + { + assert (path.length > 0); + assert (local_file.length > 0); + } + body + { + // Open the file for reading... + auto file = new FileConduit(local_file); + scope (exit) + { + file.detach(); + delete file; + } + + // Seek to the correct place, if specified. + if (this.restart_pos > 0) + { + file.seek(this.restart_pos); + this.restart_pos = 0; + } + else + { + // Allocate space for the file, if we need to. + this.allocate(file.length); + } + + // Now that it's open, we do what we always do. + this.put(path, file, progress, format); + } + + /// Store data from a stream on the server. + /// + /// Calling this function will change the current data transfer format. + /// + /// Params: + /// path = the path to the remote file + /// stream = data to store, or null for a blank file + /// progress = a delegate to call with progress information + /// format = what format to send the data in + public void put(char[] path, InputStream stream = null, FtpProgress progress = null, FtpFormat format = FtpFormat.image) + in + { + assert (path.length > 0); + } + body + { + // Change to the specified format. + this.type(format); + + // Okay server, we want to store something... + Socket data = this.processDataCommand("STOR", path); + + // Send the stream over the socket! + if (stream !is null) + this.sendStream(data, stream, progress); + + this.finishDataCommand(data); + } + + /// Append data to a file on the server. + /// + /// Calling this function will change the current data transfer format. + /// + /// Params: + /// path = the path to the remote file + /// stream = data to append to the file + /// progress = a delegate to call with progress information + /// format = what format to send the data in + public void append(char[] path, InputStream stream, FtpProgress progress = null, FtpFormat format = FtpFormat.image) + in + { + assert (path.length > 0); + assert (stream !is null); + } + body + { + // Change to the specified format. + this.type(format); + + // Okay server, we want to store something... + Socket data = this.processDataCommand("APPE", path); + + // Send the stream over the socket! + this.sendStream(data, stream, progress); + + this.finishDataCommand(data); + } + + /// Seek to a byte offset for the next transfer. + /// + /// Params: + /// offset = the number of bytes to seek forward + public void restartSeek(size_t offset) + { + char[16] tmp; + this.sendCommand("REST", Integer.format (tmp, cast(long) offset)); + this.readResponse("350"); + + // Set this for later use. + this.restart_pos = offset; + } + + /// Allocate space for a file. + /// + /// After calling this, append() or put() should be the next command. + /// + /// Params: + /// bytes = the number of bytes to allocate + public void allocate(long bytes) + in + { + assert (bytes > 0); + } + body + { + char[16] tmp; + this.sendCommand("ALLO", Integer.format(tmp, bytes)); + auto response = this.readResponse(); + + // For our purposes 200 and 202 are both fine. + if (response.code != "200" && response.code != "202") + exception (response); + } + + /// Retrieve a remote file's contents into a local file. + /// + /// Calling this function will change the current data transfer format. + /// + /// Params: + /// path = the path to the remote file + /// local_file = the path to the local file + /// progress = a delegate to call with progress information + /// format = what format to read the data in + public void get(char[] path, char[] local_file, FtpProgress progress = null, FtpFormat format = FtpFormat.image) + in + { + assert (path.length > 0); + assert (local_file.length > 0); + } + body + { + FileConduit file = null; + + // We may either create a new file... + if (this.restart_pos == 0) + file = new FileConduit (local_file, FileConduit.ReadWriteCreate); + // Or open an existing file, and seek to the specified position (read: not end, necessarily.) + else + { + file = new FileConduit (local_file, FileConduit.ReadWriteExisting); + file.seek(this.restart_pos); + + this.restart_pos = 0; + } + + scope (exit) + { + file.detach(); + delete file; + } + + // Now that it's open, we do what we always do. + this.get(path, file, progress, format); + } + + /// Retrieve a remote file's contents into a local file. + /// + /// Calling this function will change the current data transfer format. + /// + /// Params: + /// path = the path to the remote file + /// stream = stream to write the data to + /// progress = a delegate to call with progress information + /// format = what format to read the data in + public void get(char[] path, OutputStream stream, FtpProgress progress = null, FtpFormat format = FtpFormat.image) + in + { + assert (path.length > 0); + assert (stream !is null); + } + body + { + // Change to the specified format. + this.type(format); + + // Okay server, we want to get this file... + Socket data = this.processDataCommand("RETR", path); + + // Read the stream in from the socket! + this.readStream(data, stream, progress); + + this.finishDataCommand(data); + } + + /// Get information about a single file. + /// + /// Return an FtpFileInfo struct about the specified path. + /// This may not work consistently on directories (but should.) + /// + /// Params: + /// path = the file or directory to get information about + /// + /// Returns: the file information + public FtpFileInfo getFileInfo(char[] path) + in + { + assert (path.length > 0); + } + body + { + // Start assuming the MLST didn't work. + bool mlst_success = false; + FtpResponse response; + + // Check if MLST might be supported... + if (this.isSupported("MLST")) + { + this.sendCommand("MLST", path); + response = this.readResponse(); + + // If we know it was supported for sure, this is an error. + if (this.is_supported("MLST")) + exception (response); + // Otherwise, it probably means we need to try a LIST. + else + mlst_success = response.code == "250"; + } + + // Okay, we got the MLST response... parse it. + if (mlst_success) + { + char[][] lines = Text.splitLines (response.message); + + // We need at least 3 lines - first and last and header/footer lines. + // Note that more than 3 could be returned; e.g. multiple lines about the one file. + if (lines.length <= 2) + throw new FTPException("CLIENT: Bad MLST response from server", "501"); + + // Return the first line's information. + return parseMlstLine(lines[1]); + } + else + { + // Send a list command. This may list the contents of a directory, even. + FtpFileInfo[] temp = this.sendListCommand(path); + + // If there wasn't at least one line, the file didn't exist? + // We should have already handled that. + if (temp.length < 1) + throw new FTPException("CLIENT: Bad LIST response from server", "501"); + + // If there are multiple lines, try to return the correct one. + if (temp.length != 1) + foreach (FtpFileInfo info; temp) + { + if (info.type == FtpFileType.cdir) + return info; + } + + // Okay then, the first line. Best we can do? + return temp[0]; + } + } + + /// Get a listing of a directory's contents. + /// + /// Don't end path in a /. Blank means the current directory. + /// + /// Params: + /// path = the directory to list + /// + /// Returns: an array of the contents + public FtpFileInfo[] ls(char[] path = "") // default to current dir + in + { + assert (path.length == 0 || path[path.length - 1] != '/'); + } + body + { + FtpFileInfo[] dir; + + // We'll try MLSD (which is so much better) first... but it may fail. + bool mlsd_success = false; + Socket data = null; + + // Try it if it could/might/maybe is supported. + if (this.isSupported("MLST")) + { + mlsd_success = true; + + // Since this is a data command, processDataCommand handles + // checking the response... just catch its Exception. + try + { + if (path.length > 0) + data = this.processDataCommand("MLSD", path); + else + data = this.processDataCommand("MLSD"); + } + catch (FTPException) + mlsd_success = false; + } + + // If it passed, parse away! + if (mlsd_success) + { + auto listing = new GrowBuffer; + this.readStream(data, listing); + this.finishDataCommand(data); + + // Each line is something in that directory. + char[][] lines = Text.splitLines (cast(char[]) listing.slice()); + scope (exit) + delete lines; + + foreach (char[] line; lines) + { + // Parse each line exactly like MLST does. + FtpFileInfo info = this.parseMlstLine(line); + if (info.name.length > 0) + dir ~= info; + } + + return dir; + } + // Fall back to LIST. + else + return this.sendListCommand(path); + } + + /// Send a LIST command to determine a directory's content. + /// + /// The format of a LIST response is not guaranteed. If available, + /// MLSD should be used instead. + /// + /// Params: + /// path = the file or directory to list + /// + /// Returns: an array of the contents + protected FtpFileInfo[] sendListCommand(char[] path) + { + FtpFileInfo[] dir; + Socket data = null; + + if (path.length > 0) + data = this.processDataCommand("LIST", path); + else + data = this.processDataCommand("LIST"); + + // Read in the stupid non-standardized response. + auto listing = new GrowBuffer; + this.readStream(data, listing); + this.finishDataCommand(data); + + // Split out the lines. Most of the time, it's one-to-one. + char[][] lines = Text.splitLines (cast(char[]) listing.slice()); + scope (exit) + delete lines; + + foreach (char[] line; lines) + { + // If there are no spaces, or if there's only one... skip the line. + // This is probably like a "total 8" line. + if (Text.locate(line, ' ') == Text.locatePrior(line, ' ')) + continue; + + // Now parse the line, or try to. + FtpFileInfo info = this.parseListLine(line); + if (info.name.length > 0) + dir ~= info; + } + + return dir; + } + + /// Parse a LIST response line. + /// + /// The format here isn't even specified, so we have to try to detect + /// commmon ones. + /// + /// Params: + /// line = the line to parse + /// + /// Returns: information about the file + protected FtpFileInfo parseListLine(char[] line) + { + FtpFileInfo info; + size_t pos = 0; + + // Convenience function to parse a word from the line. + char[] parse_word() + { + size_t start = 0, end = 0; + + // Skip whitespace before. + while (pos < line.length && line[pos] == ' ') + pos++; + + start = pos; + while (pos < line.length && line[pos] != ' ') + pos++; + end = pos; + + // Skip whitespace after. + while (pos < line.length && line[pos] == ' ') + pos++; + + return line[start .. end]; + } + + // We have to sniff this... :/. + switch (! Text.contains ("0123456789", line[0])) + { + // Not a number; this is UNIX format. + case true: + // The line must be at least 20 characters long. + if (line.length < 20) + return info; + + // The first character tells us what it is. + if (line[0] == 'd') + info.type = FtpFileType.dir; + else if (line[0] == '-') + info.type = FtpFileType.file; + else + info.type = FtpFileType.unknown; + + // Parse out the mode... rwxrwxrwx = 777. + char[] unix_mode = "0000".dup; + void read_mode(int digit) + { + for (pos = 1 + digit * 3; pos <= 3 + digit * 3; pos++) + { + if (line[pos] == 'r') + unix_mode[digit + 1] |= 4; + else if (line[pos] == 'w') + unix_mode[digit + 1] |= 2; + else if (line[pos] == 'x') + unix_mode[digit + 1] |= 1; + } + } + + // This makes it easier, huh? + read_mode(0); + read_mode(1); + read_mode(2); + + info.facts["UNIX.mode"] = unix_mode; + + // Links, owner, group. These are hard to translate to MLST facts. + parse_word(); + parse_word(); + parse_word(); + + // Size in bytes, this one is good. + info.size = toLong(parse_word()); + + // Make sure we still have enough space. + if (pos + 13 >= line.length) + return info; + + // Not parsing date for now. It's too weird (last 12 months, etc.) + pos += 13; + + info.name = line[pos .. line.length]; + break; + + // A number; this is DOS format. + case false: + // We need some data here, to parse. + if (line.length < 18) + return info; + + // The order is 1 MM, 2 DD, 3 YY, 4 HH, 5 MM, 6 P + auto r = Regex.search(line, `(\d\d)-(\d\d)-(\d\d)\s+(\d\d):(\d\d)(A|P)M`); + if (r is null) + return info; + + if (Timestamp.dostime (r.match(0), info.modify) is 0) + info.modify = Time.max; + + pos = r.match(0).length; + delete r; + + // This will either be <DIR>, or a number. + char[] dir_or_size = parse_word(); + + if (dir_or_size.length < 0) + return info; + else if (dir_or_size[0] == '<') + info.type = FtpFileType.dir; + else + info.size = toLong(dir_or_size); + + info.name = line[pos .. line.length]; + break; + + // Something else, not supported. + default: + throw new FTPException("CLIENT: Unsupported LIST format", "501"); + } + + // Try to fix the type? + if (info.name == ".") + info.type = FtpFileType.cdir; + else if (info.name == "..") + info.type = FtpFileType.pdir; + + return info; + } + + /// Parse an MLST/MLSD response line. + /// + /// The format here is very rigid, and has facts followed by a filename. + /// + /// Params: + /// line = the line to parse + /// + /// Returns: information about the file + protected FtpFileInfo parseMlstLine(char[] line) + { + FtpFileInfo info; + + // After this loop, filename_pos will be location of space + 1. + size_t filename_pos = 0; + while (filename_pos < line.length && line[filename_pos++] != ' ') + continue; + + if (filename_pos == line.length) + throw new FTPException("CLIENT: Bad syntax in MLSx response", "501"); + + info.name = line[filename_pos .. line.length]; + + // Everything else is frosting on top. + if (filename_pos > 1) + { + char[][] temp_facts = Text.delimit(line[0 .. filename_pos - 1], ";"); + + // Go through each fact and parse them into the array. + foreach (char[] fact; temp_facts) + { + int pos = Text.locate(fact, '='); + if (pos == fact.length) + continue; + + info.facts[Ascii.toLower(fact[0 .. pos])] = fact[pos + 1 .. fact.length]; + } + + // Do we have a type? + if ("type" in info.facts) + { + // Some reflection might be nice here. + switch (Ascii.toLower(info.facts["type"])) + { + case "file": + info.type = FtpFileType.file; + break; + + case "cdir": + info.type = FtpFileType.cdir; + break; + + case "pdir": + info.type = FtpFileType.pdir; + break; + + case "dir": + info.type = FtpFileType.dir; + break; + + default: + info.type = FtpFileType.other; + } + } + + // Size, mime, etc... + if ("size" in info.facts) + info.size = toLong(info.facts["size"]); + if ("media-type" in info.facts) + info.mime = info.facts["media-type"]; + + // And the two dates. + if ("modify" in info.facts) + info.modify = this.parseTimeval(info.facts["modify"]); + if ("create" in info.facts) + info.create = this.parseTimeval(info.facts["create"]); + } + + return info; + } + + /// Parse a timeval from an FTP response. + /// + /// This is basically an ISO 8601 date, but even more rigid. + /// + /// Params: + /// timeval = the YYYYMMDDHHMMSS date + /// + /// Returns: a d_time representing the same date + + protected Time parseTimeval(char[] timeval) + { + if (timeval.length < 14) + throw new FTPException("CLIENT: Unable to parse timeval", "501"); + + return Gregorian.generic.toTime (Integer.atoi (timeval[0..4]), + Integer.atoi (timeval[4..6]), + Integer.atoi (timeval[6..8]), + Integer.atoi (timeval[8..10]), + Integer.atoi (timeval[10..12]), + Integer.atoi (timeval[12..14])); + } + + /// Get the modification time of a file. + /// + /// Not supported by a lot of servers. + /// + /// Params: + /// path = the file or directory in question + /// + /// Returns: a d_time representing the mtime + public Time filemtime(char[] path) + in + { + assert (path.length > 0); + } + body + { + this.sendCommand("MDTM", path); + auto response = this.readResponse("213"); + + // The whole response should be a timeval. + return this.parseTimeval(response.message); + } + + /// Create a directory. + /// + /// Depending on server model, a cwd with the same path may not work. + /// Use the return value instead to escape this problem. + /// + /// Params: + /// path = the directory to create + /// + /// Returns: the path to the directory created + public char[] mkdir(char[] path) + in + { + assert (path.length > 0); + } + body + { + this.sendCommand("MKD", path); + auto response = this.readResponse("257"); + + return this.parse257(response); + } + + /// Get supported features from the server. + /// + /// This may not be supported, in which case the list will remain empty. + /// Otherwise, it will contain at least FEAT. + public void getFeatures() + { + this.sendCommand("FEAT"); + auto response = this.readResponse(); + + // 221 means FEAT is supported, and a list follows. Otherwise we don't know... + if (response.code != "211") + delete this.supported_features; + else + { + char[][] lines = Text.splitLines (response.message); + + // There are two more lines than features, but we also have FEAT. + this.supported_features = new FtpFeature[lines.length - 1]; + this.supported_features[0].command = "FEAT"; + + for (size_t i = 1; i < lines.length - 1; i++) + { + size_t pos = Text.locate(lines[i], ' '); + + this.supported_features[i].command = lines[i][0 .. pos]; + if (pos < lines[i].length - 1) + this.supported_features[i].params = lines[i][pos + 1 .. lines[i].length]; + } + + delete lines; + } + } + + /// Check if a specific feature might be supported. + /// + /// Example: + /// ---------- + /// if (ftp.isSupported("SIZE")) + /// size = ftp.size("example.txt"); + /// ---------- + /// + /// Params: + /// command = the command in question + public bool isSupported(char[] command) + in + { + assert (command.length > 0); + } + body + { + if (this.supported_features.length == 0) + return true; + + // Search through the list for the feature. + foreach (FtpFeature feat; this.supported_features) + { + if (Ascii.icompare(feat.command, command) == 0) + return true; + } + + return false; + } + + /// Check if a specific feature is known to be supported. + /// + /// Example: + /// ---------- + /// if (ftp.is_supported("SIZE")) + /// size = ftp.size("example.txt"); + /// ---------- + /// + /// Params: + /// command = the command in question + public bool is_supported(char[] command) + { + if (this.supported_features.length == 0) + return false; + + return this.isSupported(command); + } + + /// Send a site-specific command. + /// + /// The command might be WHO, for example, returning a list of users online. + /// These are typically heavily server-specific. + /// + /// Params: + /// command = the command to send (after SITE) + /// parameters = any additional parameters to send + /// (each will be prefixed by a space) + public FtpResponse siteCommand(char[] command, char[][] parameters ...) + in + { + assert (command.length > 0); + } + body + { + // Because of the way sendCommand() works, we have to tweak this a bit. + char[][] temp_params = new char[][parameters.length + 1]; + temp_params[0] = command; + temp_params[1 .. temp_params.length][] = parameters; + + this.sendCommand("SITE", temp_params); + auto response = this.readResponse(); + + // Check to make sure it didn't fail. + if (response.code[0] != '2') + exception (response); + + return response; + } + + /// Send a NOOP, typically used to keep the connection alive. + public void noop() + { + this.sendCommand("NOOP"); + this.readResponse("200"); + } + + /// Send the stream to the server. + /// + /// Params: + /// data = the socket to write to + /// stream = the stream to read from + /// progress = a delegate to call with progress information + + protected void sendStream(Socket data, InputStream stream, FtpProgress progress = null) + in + { + assert (data !is null); + assert (stream !is null); + } + body + { + // Set up a SocketSet so we can use select() - it's pretty efficient. + SocketSet set = new SocketSet(); + scope (exit) + delete set; + + // At end_time, we bail. + Time end_time = Clock.now + this.timeout; + + // This is the buffer the stream data is stored in. + ubyte[8 * 1024] buf; + size_t buf_size = 0, buf_pos = 0; + int delta = 0; + + size_t pos = 0; + bool completed = false; + while (!completed && Clock.now < end_time) + { + set.reset(); + set.add(data); + + // Can we write yet, can we write yet? + int code = Socket.select(null, set, null, this.timeout); + if (code == -1 || code == 0) + break; + + if (buf_size - buf_pos <= 0) + { + if ((buf_size = stream.read(buf)) is stream.Eof) + buf_size = 0, completed = true; + buf_pos = 0; + } + + // Send the chunk (or as much of it as possible!) + delta = data.send(buf[buf_pos .. buf_size]); + if (delta == data.ERROR) + break; + + buf_pos += delta; + + pos += delta; + if (progress !is null) + progress(pos); + + // Give it more time as long as data is going through. + if (delta != 0) + end_time = Clock.now + this.timeout; + } + + // Did all the data get sent? + if (!completed) + throw new FTPException("CLIENT: Timeout when sending data", "420"); + } + + /// Reads from the server to a stream until EOF. + /// + /// Params: + /// data = the socket to read from + /// stream = the stream to write to + /// progress = a delegate to call with progress information + protected void readStream(Socket data, OutputStream stream, FtpProgress progress = null) + in + { + assert (data !is null); + assert (stream !is null); + } + body + { + // Set up a SocketSet so we can use select() - it's pretty efficient. + SocketSet set = new SocketSet(); + scope (exit) + delete set; + + // At end_time, we bail. + Time end_time = Clock.now + this.timeout; + + // This is the buffer the stream data is stored in. + ubyte[8 * 1024] buf; + int buf_size = 0; + + bool completed = false; + size_t pos; + while (Clock.now < end_time) + { + set.reset(); + set.add(data); + + // Can we read yet, can we read yet? + int code = Socket.select(set, null, null, this.timeout); + if (code == -1 || code == 0) + break; + + buf_size = data.receive(buf); + if (buf_size == data.ERROR) + break; + + if (buf_size == 0) + { + completed = true; + break; + } + + stream.write(buf[0 .. buf_size]); + + pos += buf_size; + if (progress !is null) + progress(pos); + + // Give it more time as long as data is going through. + end_time = Clock.now + this.timeout; + } + + // Did all the data get received? + if (!completed) + throw new FTPException("CLIENT: Timeout when reading data", "420"); + } + + /// Parse a 257 response (which begins with a quoted path.) + /// + /// Params: + /// response = the response to parse + /// + /// Returns: the path in the response + + protected char[] parse257(FtpResponse response) + { + char[] path = new char[response.message.length]; + size_t pos = 1, len = 0; + + // Since it should be quoted, it has to be at least 3 characters in length. + if (response.message.length <= 2) + exception (response); + + assert (response.message[0] == '"'); + + // Trapse through the response... + while (pos < response.message.length) + { + if (response.message[pos] == '"') + { + // An escaped quote, keep going. False alarm. + if (response.message[++pos] == '"') + path[len++] = response.message[pos]; + else + break; + } + else + path[len++] = response.message[pos]; + + pos++; + } + + // Okay, done! That wasn't too hard. + path.length = len; + return path; + } + + /// Send a command to the FTP server. + /// + /// Does not get/wait for the response. + /// + /// Params: + /// command = the command to send + /// ... = additional parameters to send (a space will be prepended to each) + public void sendCommand(char[] command, char[][] parameters ...) + { + assert (this.socket !is null); + + + char [] socketCommand = command ; + + // Send the command, parameters, and then a CRLF. + + foreach (char[] param; parameters) + { + socketCommand ~= " " ~ param; + + } + + socketCommand ~= "\r\n"; + + debug(FtpDebug) + { + Stdout.formatln("[sendCommand] Sending command '{0}'",socketCommand ); + } + this.sendData(socketCommand); + } + + /// Read in response lines from the server, expecting a certain code. + /// + /// Params: + /// expected_code = the code expected from the server + /// + /// Returns: the response from the server + /// + /// Throws: FTPException if code does not match + public FtpResponse readResponse(char[] expected_code) + { + debug (FtpDebug ) { Stdout.formatln("[readResponse] Expected Response {0}",expected_code )(); } + auto response = this.readResponse(); + debug (FtpDebug ) { Stdout.formatln("[readResponse] Actual Response {0}",response.code)(); } + + if (response.code != expected_code) + exception (response); + + + + return response; + } + + /// Read in the response line(s) from the server. + /// + /// Returns: the response from the server + public FtpResponse readResponse() + { + assert (this.socket !is null); + + // Pick a time at which we stop reading. It can't take too long, but it could take a bit for the whole response. + Time end_time = Clock.now + this.timeout * 10; + + FtpResponse response; + char[] single_line = null; + + // Danger, Will Robinson, don't fall into an endless loop from a malicious server. + while (Clock.now < end_time) + { + single_line = this.readLine(); + + + // This is the first line. + if (response.message.length == 0) + { + // The first line must have a code and then a space or hyphen. + if (single_line.length <= 4) + { + response.code[] = "500"; + break; + } + + // The code is the first three characters. + response.code[] = single_line[0 .. 3]; + response.message = single_line[4 .. single_line.length]; + } + // This is either an extra line, or the last line. + else + { + response.message ~= "\n"; + + // If the line starts like "123-", that is not part of the response message. + if (single_line.length > 4 && single_line[0 .. 3] == response.code) + response.message ~= single_line[4 .. single_line.length]; + // If it starts with a space, that isn't either. + else if (single_line.length > 2 && single_line[0] == ' ') + response.message ~= single_line[1 .. single_line.length]; + else + response.message ~= single_line; + } + + // We're done if the line starts like "123 ". Otherwise we're not. + if (single_line.length > 4 && single_line[0 .. 3] == response.code && single_line[3] == ' ') + break; + } + + return response; + } + + /// convert text to integer + private int toInt (char[] s) + { + return cast(int) toLong (s); + } + + /// convert text to integer + private long toLong (char[] s) + { + return Integer.parse (s); + } +} + + + +/// An exception caused by an unexpected FTP response. +/// +/// Even after such an exception, the connection may be in a usable state. +/// Use the response code to determine more information about the error. +/// +/// Standards: RFC 959, RFC 2228, RFC 2389, RFC 2428 +class FTPException: Exception +{ + /// The three byte response code. + char[3] response_code = "000"; + + /// Construct an FTPException based on a message and code. + /// + /// Params: + /// message = the exception message + /// code = the code (5xx for fatal errors) + this (char[] message, char[3] code = "420") + { + this.response_code[] = code; + super(message); + } + + /// Construct an FTPException based on a response. + /// + /// Params: + /// r = the server response + this (FtpResponse r) + { + this.response_code[] = r.code; + super(r.message); + } + + /// A string representation of the error. + char[] toString() + { + char[] buffer = new char[this.msg.length + 4]; + + buffer[0 .. 3] = this.response_code; + buffer[3] = ' '; + buffer[4 .. buffer.length] = this.msg; + + return buffer; + } +} + + +debug (UnitTest ) +{ + import tango.io.Stdout; + + unittest + { + + try + { + /+ + + TODO: Fix this + + + auto ftp = new FTPConnection("ftp.gnu.org","anonymous","anonymous"); + auto dirList = ftp.ls(); // get list for current dir + + foreach ( entry;dirList ) + { + + Stdout("File :")(entry.name)("\tSize :")(entry.size).newline; + + } + + ftp.cd("gnu/windows/emacs"); + + + dirList = ftp.ls(); + + foreach ( entry;dirList ) + { + + Stdout("File :")(entry.name)("\tSize :")(entry.size).newline; + + } + + + size_t size = ftp.size("emacs-21.3-barebin-i386.tar.gz"); + + void progress( size_t pos ) + { + + Stdout.formatln("Byte {0} of {1}",pos,size); + + } + + + ftp.get("emacs-21.3-barebin-i386.tar.gz","emacs.tgz", &progress); + +/ + } + catch( Object o ) + { + assert( false ); + } + } +}