Mercurial > projects > ldc
view 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 source
/******************************************************************************* 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 ); } } }