diff --git a/lib/FtpConnection.js b/lib/FtpConnection.js index 828e1a8..3e6f23e 100644 --- a/lib/FtpConnection.js +++ b/lib/FtpConnection.js @@ -3,7 +3,7 @@ var net = require('net'); var util = require('util'); var events = require('events'); -var pathModule = require('path'); +var path = require('path'); var fsModule = require('fs'); var StatMode = require('stat-mode'); var dateformat = require('dateformat'); @@ -11,7 +11,6 @@ var dateformat = require('dateformat'); var glob = require('./glob'); var starttls = require('./starttls'); var Constants = require('./Constants'); -var PassiveListener = require('./PassiveListener'); var pathEscape = require('./helpers/pathEscape'); var withCwd = require('./helpers/withCwd'); @@ -23,24 +22,49 @@ var EventEmitter = events.EventEmitter; // Use LOG for brevity. var LOG = Constants.LOG_LEVELS; var DOES_NOT_REQUIRE_AUTH = Constants.DOES_NOT_REQUIRE_AUTH; + var REQUIRES_CONFIGURED_DATA = Constants.REQUIRES_CONFIGURED_DATA; function FtpConnection(properties) { - EventEmitter.call(this); var self = this; + + EventEmitter.call(this); + Object.keys(properties).forEach(function(key) { self[key] = properties[key]; }); + + self.socket.setTimeout(0); + self.socket.setNoDelay(); + + self.socket.on('data', self._onData.bind(self)); + self.socket.on('end', endHandler); + self.socket.on('error', errorHandler); + self.socket.on('close', self._onClose.bind(self)); + + self.respond('220 FTP server (nodeftpd) ready'); + self._logIf(LOG.INFO, 'Accepted a new client connection'); + + function endHandler() { + self._logIf(LOG.DEBUG, 'Client connection ended'); + } + + function errorHandler(err) { + self._logIf(LOG.ERROR, 'Client connection error: ' + util.inspect(err)); + self._closeCommandConnection(true); + } + } + util.inherits(FtpConnection, EventEmitter); // TODO: rename this to writeLine? FtpConnection.prototype.respond = function(message, callback) { - return this._writeText(this.socket, message + '\r\n', callback); + this._writeText(this.socket, message + '\r\n', callback); }; FtpConnection.prototype._logIf = function(verbosity, message) { - return this.server._logIf(verbosity, message, this); + this.server._logIf(verbosity, message, this); }; // We don't want to use setEncoding because it screws up TLS, but we @@ -55,179 +79,117 @@ FtpConnection.prototype._writeText = function(socket, data, callback) { return socket.write(data, 'utf8', callback); }; -FtpConnection.prototype._authenticated = function() { - return !!this.username; -}; - -FtpConnection.prototype._closeDataConnections = function() { +FtpConnection.prototype._closeDataConnections = function(destroy) { if (this.dataSocket) { - // TODO: should the second arg be false here? - this._closeSocket(this.dataSocket, true); + this._closeSocket(this.dataSocket, destroy); this.dataSocket = null; } if (this.pasv) { this.pasv.close(); this.pasv = null; } + this.dataConfigured = false; +}; + +FtpConnection.prototype._closeCommandConnection = function(destroy) { + if (this.socket) { + this._closeSocket(this.socket, destroy); + this.socket = null; + } }; FtpConnection.prototype._createPassiveServer = function() { var self = this; - return net.createServer(function(psocket) { + return net.createServer(function(socket) { // This is simply a connection listener. // TODO: Should we keep track of *all* connections, or enforce just one? - self._logIf(LOG.INFO, 'Passive data event: connect'); + self._logIf(LOG.INFO, 'Passive data event: connect on port ' + this.address().port); if (self.secure) { self._logIf(LOG.INFO, 'Upgrading passive connection to TLS'); - starttls.starttlsServer(psocket, self.server.options.tlsOptions, function(err, cleartext) { + starttls.starttlsServer(socket, self.server.options.tlsOptions, function(err, secureSocket) { if (err) { self._logIf(LOG.ERROR, 'Error upgrading passive connection to TLS:' + util.inspect(err)); - self._closeSocket(psocket, true); + self._closeSocket(socket, true); self.dataConfigured = false; - } else if (!cleartext.authorized) { - if (self.server.options.allowUnauthorizedTls) { - self._logIf(LOG.INFO, 'Allowing unauthorized passive connection (allowUnauthorizedTls is on)'); - switchToSecure(); - } else { - self._logIf(LOG.INFO, 'Closing unauthorized passive connection (allowUnauthorizedTls is off)'); - self._closeSocket(self.socket, true); - self.dataConfigured = false; - } - } else { - switchToSecure(); + return; } - function switchToSecure() { + if (secureSocket.authorized || self.server.options.allowUnauthorizedTls) { self._logIf(LOG.INFO, 'Secure passive connection started'); // TODO: Check for existing dataSocket. - self.dataSocket = cleartext; - setupPassiveListener(); + self._setupDataSocket(secureSocket); + return; } + + self._logIf(LOG.INFO, 'Closing disallowed unauthorized secure passive connection (allowUnauthorizedTls is off)'); + self._closeSocket(socket, true); + self.dataConfigured = false; }); } else { // TODO: Check for existing dataSocket. - self.dataSocket = psocket; - setupPassiveListener(); + self._setupDataSocket(socket); } + }); +}; + +FtpConnection.prototype._setupDataSocket = function(socket) { + var self = this; - function setupPassiveListener() { - if (self.dataListener) { - self.dataListener.emit('ready'); + function allOver(socket, ename) { + var port = socket.address().port; + return function(err) { + if (err) { + self._logIf(LOG.ERROR, 'Data event: ' + ename + ' due to error: ' + util.inspect(err) + ' on port ' + port); } else { - self._logIf(LOG.WARN, 'Passive connection initiated, but no data listener'); + self._logIf(LOG.DEBUG, 'Data event: ' + ename + ' on port ' + port); } + self.dataSocket = null; + }; + } - // Responses are not guaranteed to have an 'end' event - // (https://github.com/joyent/node/issues/728), but we want to set - // dataSocket to null as soon as possible, so we handle both events. - self.dataSocket.on('close', allOver('close')); - self.dataSocket.on('end', allOver('end')); - function allOver(ename) { - return function(err) { - self._logIf( - (err ? LOG.ERROR : LOG.DEBUG), - 'Passive data event: ' + ename + (err ? ' due to error' : '') - ); - self.dataSocket = null; - }; + // Responses are not guaranteed to have an 'end' event + // (https://github.com/joyent/node/issues/728), but we want to set + // dataSocket to null as soon as possible, so we handle both events. + self.dataSocket = socket + .on('close', allOver(socket, 'close')) + .on('end', allOver(socket, 'end')) + .on('error', function(err) { + self._logIf(LOG.ERROR, 'Data connection error: ' + err); + if (self.dataSocket) { + self._closeSocket(self.dataSocket, true); + self.dataSocket = null; } + self.dataConfigured = false; + }); - self.dataSocket.on('error', function(err) { - self._logIf(LOG.ERROR, 'Passive data event: error: ' + err); - // TODO: Can we can rely on self.dataSocket having been closed? - self.dataSocket = null; - self.dataConfigured = false; - }); - } - }); + (self.pasv || socket).emit('ready', socket); }; FtpConnection.prototype._whenDataReady = function(callback) { var self = this; - if (self.dataListener) { - // how many data connections are allowed? - // should still be listening since we created a server, right? - if (self.dataSocket) { - self._logIf(LOG.DEBUG, 'A data connection exists'); - callback(self.dataSocket); - } else { - self._logIf(LOG.DEBUG, 'Currently no data connection; expecting client to connect to pasv server shortly...'); - self.dataListener.once('ready', function() { - self._logIf(LOG.DEBUG, '...client has connected now'); - callback(self.dataSocket); - }); - } - } else { - // Do we need to open the data connection? - if (self.dataSocket) { // There really shouldn't be an existing connection - self._logIf(LOG.DEBUG, 'Using existing non-passive dataSocket'); - callback(self.dataSocket); - } else { - self._initiateData(function(sock) { - callback(sock); - }); - } - } -}; - -FtpConnection.prototype._initiateData = function(callback) { - var self = this; - - if (self.dataSocket) { - return callback(self.dataSocket); - } - - var sock = net.connect(self.dataPort, self.dataHost || self.socket.remoteAddress); - sock.on('connect', function() { - self.dataSocket = sock; - callback(sock); - }); - sock.on('end', allOver); - sock.on('close', allOver); - function allOver(err) { - self.dataSocket = null; - self._logIf( - err ? LOG.ERROR : LOG.DEBUG, - 'Non-passive data connection ended' + (err ? 'due to error: ' + util.inspect(err) : '') - ); + var socket = self.dataSocket; + if (socket) { + self._logIf(LOG.DEBUG, 'Using existing ' + (self.pasv ? '' : 'non-') + 'passive connection'); + callback(socket); + return; } - sock.on('error', function(err) { - self._closeSocket(sock, true); - self._logIf(LOG.ERROR, 'Data connection error: ' + util.inspect(err)); - self.dataSocket = null; - self.dataConfigured = false; - }); -}; - -FtpConnection.prototype._onError = function(err) { - this._logIf(LOG.ERROR, 'Client connection error: ' + util.inspect(err)); - this._closeSocket(this.socket, true); -}; + self._logIf(LOG.DEBUG, 'Currently no data connection; setting up ' + (self.pasv ? '' : 'non-') + 'passive connection to client'); -FtpConnection.prototype._onEnd = function() { - this._logIf(LOG.DEBUG, 'Client connection ended'); + (self.pasv || net.connect(self.dataPort, self.dataHost || self.socket.remoteAddress, function() { + self._setupDataSocket(this); + })).once('ready', callback); }; FtpConnection.prototype._onClose = function(hadError) { // I feel like some of this might be redundant since we probably close some // of these sockets elsewhere, but it is fine to call _closeSocket more than // once. - if (this.dataSocket) { - this._closeSocket(this.dataSocket, hadError); - this.dataSocket = null; - } - if (this.socket) { - this._closeSocket(this.socket, hadError); - this.socket = null; - } - if (this.pasv) { - this.pasv.close(); - this.pasv = null; - } + this._closeDataConnections(hadError); + this._closeCommandConnection(hadError); // TODO: LOG.DEBUG? this._logIf(LOG.INFO, 'Client connection closed'); }; @@ -269,7 +231,7 @@ FtpConnection.prototype._onData = function(data) { // be permitted over a secure connection. See RFC4217 regarding error code. if (!self.secure && self.server.options.tlsOnly) { self.respond('522 Protection level not sufficient; send AUTH TLS'); - } else if (self._authenticated()) { + } else if (self.username) { checkData(); } else { self.respond('530 Not logged in.'); @@ -293,13 +255,11 @@ FtpConnection.prototype._onData = function(data) { // Specify the user's account (superfluous) FtpConnection.prototype._command_ACCT = function() { this.respond('202 Command not implemented, superfluous at this site.'); - return this; }; // Allocate storage space (superfluous) FtpConnection.prototype._command_ALLO = function() { this.respond('202 Command not implemented, superfluous at this site.'); - return this; }; FtpConnection.prototype._command_AUTH = function(commandArg) { @@ -311,48 +271,41 @@ FtpConnection.prototype._command_AUTH = function(commandArg) { self.respond('234 Honored', function() { self._logIf(LOG.INFO, 'Establishing secure connection...'); - starttls.starttlsServer(self.socket, self.server.options.tlsOptions, function(err, cleartext) { + starttls.starttlsServer(self.socket, self.server.options.tlsOptions, function(err, secureSocket) { if (err) { self._logIf(LOG.ERROR, 'Error upgrading connection to TLS: ' + util.inspect(err)); self._closeSocket(self.socket, true); - } else if (!cleartext.authorized) { - self._logIf(LOG.INFO, 'Secure socket not authorized: ' + util.inspect(cleartext.authorizationError)); - if (self.server.options.allowUnauthorizedTls) { - self._logIf(LOG.INFO, 'Allowing unauthorized connection (allowUnauthorizedTls is on)'); - switchToSecure(); - } else { - self._logIf(LOG.INFO, 'Closing unauthorized connection (allowUnauthorizedTls is off)'); - self._closeSocket(self.socket, true); - } - } else { - switchToSecure(); + return; } - function switchToSecure() { + if (secureSocket.authorized || self.server.options.allowUnauthorizedTls) { self._logIf(LOG.INFO, 'Secure connection started'); - self.socket = cleartext; + self.socket = secureSocket; self.socket.on('data', function(data) { self._onData(data); }); self.secure = true; + return; } + + self._logIf(LOG.INFO, 'Closing unauthorized connection (allowUnauthorizedTls is off)'); + self._closeSocket(self.socket, true); }); }); }; // Change working directory to parent directory FtpConnection.prototype._command_CDUP = function() { - var pathServer = pathModule.dirname(this.cwd); + var pathServer = path.dirname(this.cwd); var pathEscaped = pathEscape(pathServer); this.cwd = pathServer; this.respond('250 Directory changed to "' + pathEscaped + '"'); - return this; }; // Change working directory FtpConnection.prototype._command_CWD = function(pathRequest) { var pathServer = withCwd(this.cwd, pathRequest); - var pathFs = pathModule.join(this.root, pathServer); + var pathFs = path.join(this.root, pathServer); var pathEscaped = pathEscape(pathServer); this.fs.stat(pathFs, function(err, stats) { if (err) { @@ -366,14 +319,13 @@ FtpConnection.prototype._command_CWD = function(pathRequest) { this.respond('250 CWD successful. "' + pathEscaped + '" is current directory'); } }.bind(this)); - return this; }; FtpConnection.prototype._command_DELE = function(commandArg) { var self = this; var filename = withCwd(self.cwd, commandArg); - self.fs.unlink(pathModule.join(self.root, filename), function(err) { + self.fs.unlink(path.join(self.root, filename), function(err) { if (err) { self._logIf(LOG.ERROR, 'Error deleting file: ' + filename + ', ' + err); // write error to socket @@ -412,16 +364,16 @@ FtpConnection.prototype._command_OPTS = function(commandArg) { // Print the file modification time FtpConnection.prototype._command_MDTM = function(file) { + var self = this; file = withCwd(this.cwd, file); - file = pathModule.join(this.root, file); + file = path.join(this.root, file); this.fs.stat(file, function(err, stats) { if (err) { - this.respond('550 File unavailable'); + self.respond('550 File unavailable'); } else { - this.respond('213 ' + dateformat(stats.mtime, 'yyyymmddhhMMss')); + self.respond('213 ' + dateformat(stats.mtime, 'yyyymmddhhMMss')); } - }.bind(this)); - return this; + }); }; FtpConnection.prototype._command_LIST = function(commandArg) { @@ -460,7 +412,7 @@ FtpConnection.prototype._LIST = function(commandArg, detailed, cmd) { var dir = withCwd(self.cwd, dirname); glob.setMaxStatsAtOnce(self.server.options.maxStatsAtOnce); - glob.glob(pathModule.join(self.root, dir), self.fs, function(err, files) { + glob.glob(path.join(self.root, dir), self.fs, function(err, files) { if (err) { self._logIf(LOG.ERROR, 'Error sending file list, reading directory: ' + err); self.respond('550 Not a directory'); @@ -486,46 +438,46 @@ FtpConnection.prototype._LIST = function(commandArg, detailed, cmd) { // We're not doing a detailed listing, so we don't need to get username // and group name. fileInfos = files; - return finished(); + return finish(); } // Now we need to get username and group name for each file from user/group ids. fileInfos = []; var CONC = self.server.options.maxStatsAtOnce; - var j = 0; - for (var i = 0; i < files.length && i < CONC; ++i) { - handleFile(i); + var total = files.length; + for (var i = 0; i < CONC; ++i) { + handleFile(); } - j = --i; - function handleFile(ii) { - if (i >= files.length) { - return i === files.length + j ? finished() : null; + function handleFile() { + if (fileInfos.length === total) { + return finish(); + } + + if (files.length === 0) { + return; } - self.server.getUsernameFromUid(files[ii].stats.uid, function(e1, uname) { - self.server.getGroupFromGid(files[ii].stats.gid, function(e2, gname) { + var file = files.shift(); + self.server.getUsernameFromUid(file.stats.uid, function(e1, uname) { + self.server.getGroupFromGid(file.stats.gid, function(e2, gname) { if (e1 || e2) { self._logIf(LOG.WARN, 'Error getting user/group name for file: ' + util.inspect(e1 || e2)); - fileInfos.push({ - file: files[ii], - uname: null, - gname: null, - }); - } else { - fileInfos.push({ - file: files[ii], - uname: uname, - gname: gname, - }); + uname = null; + gname = null; } - handleFile(++i); + fileInfos.push({ + file: file, + uname: uname, + gname: gname, + }); + handleFile(); }); }); } - function finished() { + function finish() { // Sort file names. if (!self.server.options.dontSortFilenames) { if (self.server.options.filenameSortMap !== false) { @@ -569,29 +521,26 @@ FtpConnection.prototype._listFiles = function(fileInfos, detailed, cmd) { }; m = '226 Transfer OK'; var END_MSGS = { - LIST: m, NLST: m, STAT: '213 End of status', + LIST: m, NLST: m, STAT: '213 End of status', ERROR: '550 Error listing files', }; self.respond(BEGIN_MSGS[cmd], function() { if (cmd === 'STAT') { - whenReady(self.socket); + writeFileList(self.socket); } else { - self._whenDataReady(whenReady); + self._whenDataReady(writeFileList); } - function whenReady(listconn) { + function writeFileList(socket) { if (fileInfos.length === 0) { return success(); } function success(err) { - if (err) { - self.respond('550 Error listing files'); - } else { - self.respond(END_MSGS[cmd]); - } + self.respond(END_MSGS[err && 'ERROR' || cmd]); + if (cmd !== 'STAT') { - self._closeSocket(listconn); + self._closeSocket(socket); } } @@ -605,7 +554,6 @@ FtpConnection.prototype._listFiles = function(fileInfos, detailed, cmd) { if (!detailed) { file = fileInfo; - line += file.name + '\r\n'; } else { file = fileInfo.file; var s = file.stats; @@ -619,11 +567,10 @@ FtpConnection.prototype._listFiles = function(fileInfos, detailed, cmd) { line += leftPad(s.size.toString(), 12) + ' '; var d = new Date(s.mtime); line += leftPad(dateformat(d, 'mmm dd HH:MM'), 12) + ' '; - line += file.name; - line += '\r\n'; } + line += file.name + '\r\n'; self._writeText( - listconn, + socket, line, (i === fileInfos.length - 1 ? success : undefined) ); @@ -636,7 +583,7 @@ FtpConnection.prototype._listFiles = function(fileInfos, detailed, cmd) { FtpConnection.prototype._command_MKD = function(pathRequest) { var pathServer = withCwd(this.cwd, pathRequest); var pathEscaped = pathEscape(pathServer); - var pathFs = pathModule.join(this.root, pathServer); + var pathFs = path.join(this.root, pathServer); this.fs.mkdir(pathFs, 0755, function(err) { if (err) { this._logIf(LOG.ERROR, 'MKD ' + pathRequest + ': ' + err); @@ -645,13 +592,11 @@ FtpConnection.prototype._command_MKD = function(pathRequest) { this.respond('257 "' + pathEscaped + '" directory created'); } }.bind(this)); - return this; }; // Perform a no-op (used to keep-alive connection) FtpConnection.prototype._command_NOOP = function() { this.respond('200 OK'); - return this; }; FtpConnection.prototype._command_PORT = function(x, y) { @@ -668,8 +613,8 @@ FtpConnection.prototype._PORT = function(commandArg, command) { var host; var port; - self.dataConfigured = false; - self._closeDataConnections(); + // TODO: should the arg be false here? + self._closeDataConnections(true); if (command === 'PORT') { m = commandArg.match(/^([0-9]{1,3}),([0-9]{1,3}),([0-9]{1,3}),([0-9]{1,3}),([0-9]{1,3}),([0-9]{1,3})$/); @@ -741,25 +686,21 @@ FtpConnection.prototype._PASV = function(commandArg, command) { // not sure whether the spec limits to 1 data connection at a time ... if (self.dataSocket) { self._closeSocket(self.dataSocket, true); + self.dataSocket = null; } - if (self.dataListener) { - self._logIf(LOG.DEBUG, 'Telling client that they can connect now'); - self._writePASVReady(command); - } else { - self._logIf(LOG.DEBUG, 'Setting up listener for passive connections'); - self._setupNewPASV(commandArg, command); - } - - self.dataConfigured = true; + self._setupPASV(commandArg, command); }; FtpConnection.prototype._writePASVReady = function(command) { var self = this; - var a = self.pasv.address(); + self.dataConfigured = true; + + self._logIf(LOG.DEBUG, 'Telling client that it can connect now'); + var host = self.server.host; - var port = a.port; + var port = self.pasv.address().port; if (command === 'PASV') { var i1 = (port / 256) | 0; var i2 = port % 256; @@ -769,116 +710,86 @@ FtpConnection.prototype._writePASVReady = function(command) { } }; -FtpConnection.prototype._setupNewPASV = function(commandArg, command) { +// Keep trying ports in the range supplied until either: +// (i) It works +// (ii) We get an error that's not just EADDRINUSE +// (iii) We run out of ports to try. +FtpConnection.prototype._setupPASV = function(commandArg, command) { var self = this; - var pasv = self._createPassiveServer(); - var portRangeErrorHandler; - - function normalErrorHandler(e) { - self._logIf(LOG.WARN, 'Error with passive data listener: ' + util.inspect(e)); - self.respond('421 Server was unable to open passive connection listener'); - self.dataConfigured = false; - self.dataListener = null; - self.dataSocket = null; - self.pasv = null; - } - - if (self.server.options.pasvPortRangeStart != null && self.server.options.pasvPortRangeEnd != null) { - // Keep trying ports in the range supplied until either: - // (i) It works - // (ii) We get an error that's not just EADDRINUSE - // (iii) We run out of ports to try. - var i = self.server.options.pasvPortRangeStart; - pasv.listen(i); - portRangeErrorHandler = function(e) { - if (e.code === 'EADDRINUSE' && i < self.server.options.pasvPortRangeEnd) { - pasv.listen(++i); - } else { - self._logIf(LOG.DEBUG, 'Passing on error from portRangeErrorHandler to normalErrorHandler:' + JSON.stringify(e)); - normalErrorHandler(e); - } - }; - pasv.on('error', portRangeErrorHandler); - } else { - pasv.listen(0); - pasv.on('error', normalErrorHandler); + if (self.pasv) { + self._writePASVReady(command); + return; } - // Once we're successfully listening, tell the client - pasv.on('listening', function() { - self.pasv = pasv; + self._logIf(LOG.DEBUG, 'Setting up listener for passive connections'); - if (portRangeErrorHandler) { - pasv.removeListener('error', portRangeErrorHandler); - pasv.addListener('error', normalErrorHandler); - } + var firstPort = this.server.options.pasvPortRangeStart; + var lastPort = this.server.options.pasvPortRangeEnd; - self._logIf(LOG.DEBUG, 'Passive data connection beginning to listen'); + this._createPassiveServer() + .on('error', function(e) { + if (self.pasv === null && e.code === 'EADDRINUSE' && e.port < (lastPort || firstPort || 0)) { + this.listen(++e.port); + return; + } - var port = pasv.address().port; - self.dataListener = new PassiveListener(); - self._logIf(LOG.DEBUG, 'Passive data connection listening on port ' + port); - self._writePASVReady(command); - }); - pasv.on('close', function() { - self.pasv = null; - self.dataListener = null; - self._logIf(LOG.DEBUG, 'Passive data listener closed'); - }); + self._logIf(LOG.WARN, 'Passive Error with passive data listener: ' + util.inspect(e)); + self.respond('421 Server was unable to open passive connection listener'); + self.dataConfigured = false; + self.dataSocket = null; + self.pasv = null; + }) + + // Once we're successfully listening, tell the client + .on('listening', function() { + self._logIf(LOG.DEBUG, 'Passive data connection listening on port ' + this.address().port); + self.pasv = this; + self._writePASVReady(command); + }) + + .on('close', function() { + self.pasv = null; + self._logIf(LOG.DEBUG, 'Passive data listener closed'); + }) + + .listen(firstPort || lastPort || 0); }; FtpConnection.prototype._command_PBSZ = function(commandArg) { - var self = this; - - if (!self.server.options.tlsOptions) { - return self.respond('202 Not supported'); + if (this.secure) { + this.pbszReceived = true; } - // Protection Buffer Size (RFC 2228) - if (!self.secure) { - self.respond('503 Secure connection not established'); - } else if (parseInt(commandArg, 10) !== 0) { + this.respond( + !this.server.options.tlsOptions ? '202 Not supported' : + !this.secure ? '503 Secure connection not established' /* Protection Buffer Size (RFC 2228) */ : + parseInt(commandArg, 10) !== 0 ? '200 buffer too big, PBSZ=0' : // RFC 2228 specifies that a 200 reply must be sent specifying a more // satisfactory PBSZ size (0 in our case, since we're using TLS). // Doubt that this will do any good if the client was already confused // enough to send a non-zero value, but ok... - self.pbszReceived = true; - self.respond('200 buffer too big, PBSZ=0'); - } else { - self.pbszReceived = true; - self.respond('200 OK'); - } + '200 OK' + ); }; FtpConnection.prototype._command_PROT = function(commandArg) { - var self = this; - - if (!self.server.options.tlsOptions) { - return self.respond('202 Not supported'); - } - - if (!self.pbszReceived) { - self.respond('503 No PBSZ command received'); - } else if (commandArg === 'S' || commandArg === 'E' || commandArg === 'C') { - self.respond('536 Not supported'); - } else if (commandArg === 'P') { - self.respond('200 OK'); - } else { - // Don't even recognize this one... - self.respond('504 Not recognized'); - } + this.respond( + !this.server.options.tlsOptions ? '202 Not supported' : + !this.pbszReceived ? '503 No PBSZ command received' : + (commandArg === 'S' || commandArg === 'E' || commandArg === 'C') ? '536 Not supported' : + commandArg !== 'P' ? '504 Not recognized' /* Don't even recognize this one... */ : + '200 OK' + ); }; // Print the current working directory. FtpConnection.prototype._command_PWD = function(commandArg) { - var pathEscaped = pathEscape(this.cwd); - if (commandArg === '') { - this.respond('257 "' + pathEscaped + '" is current directory'); - } else { - this.respond('501 Syntax error in parameters or arguments.'); - } - return this; + this.respond( + commandArg === '' + ? '257 "' + pathEscape(this.cwd) + '" is current directory' + : '501 Syntax error in parameters or arguments.' + ); }; FtpConnection.prototype._command_QUIT = function() { @@ -889,13 +800,13 @@ FtpConnection.prototype._command_QUIT = function() { if (err) { self._logIf(LOG.ERROR, "Error writing 'Goodbye' message following QUIT"); } - self._closeSocket(self.socket, true); - self._closeDataConnections(); + // TODO: should the arg be false here? + self._onClose(true); }); }; FtpConnection.prototype._command_RETR = function(commandArg) { - var filename = pathModule.join(this.root, withCwd(this.cwd, commandArg)); + var filename = path.join(this.root, withCwd(this.cwd, commandArg)); if (this.server.options.useReadFile) { this._RETR_usingReadFile(commandArg, filename); @@ -939,7 +850,7 @@ FtpConnection.prototype._RETR_usingCreateReadStream = function(commandArg, filen } } else { afterOk(function() { - self._whenDataReady(function(pasvconn) { + self._whenDataReady(function(socket) { var readLength = 0; var now = new Date(); var rs = self.fs.createReadStream(null, {fd: fd}); @@ -979,7 +890,7 @@ FtpConnection.prototype._RETR_usingCreateReadStream = function(commandArg, filen self.respond('226 Closing data connection, sent ' + readLength + ' bytes'); }); - rs.pipe(pasvconn); + rs.pipe(socket); rs.resume(); }); }); @@ -1023,11 +934,9 @@ FtpConnection.prototype._RETR_usingReadFile = function(commandArg, filename) { } } else { afterOk(function() { - self._whenDataReady(function(pasvconn) { - contents = {filename: filename, data: contents}; - self.emit('file:retr:contents', contents); - contents = contents.data; - pasvconn.write(contents); + self._whenDataReady(function(socket) { + self.emit('file:retr:contents', {filename: filename, data: contents}); + socket.write(contents); var contentLength = contents.length; self.respond('226 Closing data connection, sent ' + contentLength + ' bytes'); self.emit('file:retr', 'close', { @@ -1041,7 +950,10 @@ FtpConnection.prototype._RETR_usingReadFile = function(commandArg, filename) { duration: new Date() - startTime, errorState: false, }); - self._closeSocket(pasvconn); + if (self.dataSocket) { + self._closeSocket(self.dataSocket); + self.dataSocket = null; + } }); }); } @@ -1051,7 +963,7 @@ FtpConnection.prototype._RETR_usingReadFile = function(commandArg, filename) { // Remove a directory FtpConnection.prototype._command_RMD = function(pathRequest) { var pathServer = withCwd(this.cwd, pathRequest); - var pathFs = pathModule.join(this.root, pathServer); + var pathFs = path.join(this.root, pathServer); this.fs.rmdir(pathFs, function(err) { if (err) { this._logIf(LOG.ERROR, 'RMD ' + pathRequest + ': ' + err); @@ -1060,7 +972,6 @@ FtpConnection.prototype._command_RMD = function(pathRequest) { this.respond('250 "' + pathServer + '" directory removed'); } }.bind(this)); - return this; }; FtpConnection.prototype._command_RNFR = function(commandArg) { @@ -1073,7 +984,7 @@ FtpConnection.prototype._command_RNFR = function(commandArg) { FtpConnection.prototype._command_RNTO = function(commandArg) { var self = this; var fileto = withCwd(self.cwd, commandArg); - self.fs.rename(pathModule.join(self.root, self.filefrom), pathModule.join(self.root, fileto), function(err) { + self.fs.rename(path.join(self.root, self.filefrom), path.join(self.root, fileto), function(err) { if (err) { self._logIf(LOG.ERROR, 'Error renaming file from ' + self.filefrom + ' to ' + fileto); self.respond('550 Rename failed' + (err.code === 'ENOENT' ? '; file does not exist' : '')); @@ -1087,7 +998,7 @@ FtpConnection.prototype._command_SIZE = function(commandArg) { var self = this; var filename = withCwd(self.cwd, commandArg); - self.fs.stat(pathModule.join(self.root, filename), function(err, s) { + self.fs.stat(path.join(self.root, filename), function(err, s) { if (err) { self._logIf(LOG.ERROR, "Error getting size of file '" + filename + "' "); self.respond('450 Failed to get size of file'); @@ -1098,11 +1009,11 @@ FtpConnection.prototype._command_SIZE = function(commandArg) { }; FtpConnection.prototype._command_TYPE = function(commandArg) { - if (commandArg === 'I' || commandArg === 'A') { - this.respond('200 OK'); - } else { - this.respond('202 Not supported'); - } + this.respond( + commandArg === 'I' || commandArg === 'A' + ? '200 OK' + : '202 Not supported' + ); }; FtpConnection.prototype._command_SYST = function() { @@ -1124,7 +1035,7 @@ FtpConnection.prototype._STOR_usingCreateWriteStream = function(filename, initia var self = this; var wStreamFlags = {flags: flag || 'w', mode: 0644}; - var storeStream = self.fs.createWriteStream(pathModule.join(self.root, filename), wStreamFlags); + var storeStream = self.fs.createWriteStream(path.join(self.root, filename), wStreamFlags); var notErr = true; // Adding for event metadata for file upload (STOR) var startTime = new Date(); @@ -1136,7 +1047,13 @@ FtpConnection.prototype._STOR_usingCreateWriteStream = function(filename, initia }); } - self._whenDataReady(handleUpload); + self._whenDataReady(function(socket) { + socket.on('error', function(err) { + notErr = false; + self._logIf(LOG.ERROR, 'Data connection error: ' + util.inspect(err)); + }); + socket.pipe(storeStream); + }); storeStream.on('open', function() { self._logIf(LOG.DEBUG, 'File opened/created: ' + filename); @@ -1167,6 +1084,7 @@ FtpConnection.prototype._STOR_usingCreateWriteStream = function(filename, initia notErr = false; if (self.dataSocket) { self._closeSocket(self.dataSocket, true); + self.dataSocket = null; } self.respond('426 Connection closed; transfer aborted'); }); @@ -1187,16 +1105,10 @@ FtpConnection.prototype._STOR_usingCreateWriteStream = function(filename, initia notErr ? self.respond('226 Closing data connection') : true; if (self.dataSocket) { self._closeSocket(self.dataSocket); + self.dataSocket = null; } }); - function handleUpload(dataSocket) { - dataSocket.pipe(storeStream); - dataSocket.on('error', function(err) { - notErr = false; - self._logIf(LOG.ERROR, 'Data connection error: ' + util.inspect(err)); - }); - } }; FtpConnection.prototype._STOR_usingWriteFile = function(filename, flag) { @@ -1214,14 +1126,13 @@ FtpConnection.prototype._STOR_usingWriteFile = function(filename, flag) { }); self.respond('150 Ok to send data', function() { - self._whenDataReady(handleUpload); + self._whenDataReady(function(socket) { + socket.on('data', dataHandler); + socket.once('close', closeHandler); + socket.once('error', errorHandler); + }); }); - function handleUpload() { - self.dataSocket.on('data', dataHandler); - self.dataSocket.once('close', closeHandler); - self.dataSocket.once('error', errorHandler); - } function dataHandler(buf) { if (self.server.options.uploadMaxSlurpSize != null && @@ -1233,6 +1144,7 @@ FtpConnection.prototype._STOR_usingWriteFile = function(filename, flag) { if (!self.fs.createWriteStream) { if (self.dataSocket) { self._closeSocket(self.dataSocket, true); + self.dataSocket = null; } self.respond('552 Requested file action aborted; file too big'); return; @@ -1269,7 +1181,7 @@ FtpConnection.prototype._STOR_usingWriteFile = function(filename, flag) { var wOptions = {flag: flag || 'w', mode: 0644}; var contents = {filename: filename, data: slurpBuf.slice(0, totalBytes)}; self.emit('file:stor:contents', contents); - self.fs.writeFile(pathModule.join(self.root, filename), contents.data, wOptions, function(err) { + self.fs.writeFile(path.join(self.root, filename), contents.data, wOptions, function(err) { self.emit('file:stor', 'close', { user: self.username, file: filename, @@ -1299,7 +1211,7 @@ FtpConnection.prototype._STOR_usingWriteFile = function(filename, flag) { } function errorHandler() { - erroredOut = true; + erroredOut = true; // TODO RWO: should we not log the error and emit error and close the connection? } }; @@ -1332,7 +1244,6 @@ FtpConnection.prototype._command_USER = function(username) { } ); } - return this; }; // Specify a password for login @@ -1389,7 +1300,6 @@ FtpConnection.prototype._command_PASS = function(password) { } ); } - return this; }; FtpConnection.prototype._closeSocket = function(socket, shouldDestroy) { @@ -1398,6 +1308,7 @@ FtpConnection.prototype._closeSocket = function(socket, shouldDestroy) { if (shouldDestroy || this.server.options.destroySockets) { // Don't call destroy() more than once. if (!socket.destroyed) { + this._logIf(LOG.DEBUG, 'Closing socket on port ' + socket.address().port); socket.destroy(); } } else { diff --git a/lib/FtpServer.js b/lib/FtpServer.js index 2ff206e..495af91 100644 --- a/lib/FtpServer.js +++ b/lib/FtpServer.js @@ -31,10 +31,10 @@ function FtpServer(host, options) { self.getRoot = options.getRoot; self.getUsernameFromUid = options.getUsernameFromUid || function(uid, c) { - c(null, 'ftp'); + process.nextTick(c, null, 'ftp'); }; self.getGroupFromGid = options.getGroupFromGid || function(gid, c) { - c(null, 'ftp'); + process.nextTick(c, null, 'ftp'); }; self.debugging = options.logLevel || 0; self.useWriteFile = options.useWriteFile; @@ -71,7 +71,6 @@ FtpServer.prototype._onConnection = function(socket) { allowedCommands: allowedCommands, // subset of allowed commands for this server dataPort: 20, dataHost: null, - dataListener: null, // for incoming passive connections dataSocket: null, // the actual data socket // True if the client has sent a PORT/PASV command, and // we haven't experienced a problem with the configuration @@ -94,25 +93,6 @@ FtpServer.prototype._onConnection = function(socket) { this.emit('client:connected', conn); // pass client info so they can listen for client-specific events - socket.setTimeout(0); - socket.setNoDelay(); - - this._logIf(LOG.INFO, 'Accepted a new client connection'); - conn.respond('220 FTP server (nodeftpd) ready'); - - socket.on('data', function(buf) { - conn._onData(buf); - }); - socket.on('end', function() { - conn._onEnd(); - }); - socket.on('error', function(err) { - conn._onError(err); - }); - // `close` will always be called once (directly after `end` or `error`) - socket.on('close', function(hadError) { - conn._onClose(hadError); - }); }; ['listen', 'close'].forEach(function(fname) { diff --git a/lib/glob.js b/lib/glob.js index e6805aa..05781c5 100644 --- a/lib/glob.js +++ b/lib/glob.js @@ -10,46 +10,43 @@ function setMaxStatsAtOnce(n) { // unique directory to be listed. So this can be pretty simple. function statList(fsm, list, callback) { - if (list.length === 0) { - return callback(null, []); - } - - var stats = []; var total = list.length; + var finished = false; + var stats = []; + for (var i = 0; i < CONC; ++i) { handleFile(); } - var erroredOut = false; - function handleFile() { - if (erroredOut) { - return; + if (stats.length === total) { + return finish(null); } + if (list.length === 0) { - if (stats.length === total) { - finished(); - } return; } var path = list.shift(); fsm.stat(path, function(err, st) { if (err) { - erroredOut = true; - callback(err); - } else { - stats.push({ - name: PathModule.basename(path), - stats: st, - }); - handleFile(); + return finish(err); } + + stats.push({ + name: PathModule.basename(path), + stats: st, + }); + handleFile(); }); } - function finished() { - callback(null, stats); + function finish(err) { + if (finished) { + return; + } + finished = true; + callback(err, stats); } } @@ -92,136 +89,118 @@ function glob(path, fsm, callback, noWildcards) { if (w === path.length) { // There are no wildcards. fsm.readdir(path, function(err, contents) { if (err) { - if (err.code === 'ENOTDIR') { - statList(fsm, [path], function(err, list) { - if (err) { - return callback(err); - } - if (list.length !== 1) { - throw new Error('Internal error in glob.js'); - } - callback(null, list); - }); - } else if (err.code === 'ENOENT') { + if (err.code === 'ENOENT') { callback(null, []); - } else { + return; + } + + if (err.code !== 'ENOTDIR') { callback(err); + return; } - } else { - statList( - fsm, - contents.map(function(p) { - return PathModule.join(path, p); - }), - function(err, list) { - if (err) { - callback(err); - } else { - callback(null, list); - } - } - ); + + contents = ['']; } + + var list = contents.map(function(p) { + return PathModule.join(path, p); + }); + + statList(fsm, list, callback); }); return; - } else { - // Check that there is no '/' after the first wildcard. + } - var i; - for (i = w; i < path.length; ++i) { - if (path.charAt(i) === '/') { - return callback(null, []); - } + var i; + + // Check that there is no '/' after the first wildcard. + for (i = w; i < path.length; ++i) { + if (path.charAt(i) === '/') { + return callback(null, []); } + } - var base = ''; - var pattern; - for (i = w; i >= 0; --i) { - if (path.charAt(i) === '/') { - base = path.substr(0, i + 1); - break; - } + var base = ''; + var pattern; + for (i = w; i >= 0; --i) { + if (path.charAt(i) === '/') { + base = path.substr(0, i + 1); + break; } - pattern = path.substr(i === 0 ? 0 : i + 1); + } + pattern = path.substr(i === 0 ? 0 : i + 1); - // Remove any leading/trailing slashes which might still - // be present if the path contains multiple slashes. - for (i = 0; i < pattern.length && pattern.charAt(i) === '/'; ++i) { + // Remove any leading/trailing slashes which might still + // be present if the path contains multiple slashes. + for (i = 0; i < pattern.length && pattern.charAt(i) === '/'; ++i) { - } - if (i > 0) { - pattern = pattern.substr(i); - } - for (i = base.length - 1; i > 0 && base.charAt(i) === '/'; --i) { + } + if (i > 0) { + pattern = pattern.substr(i); + } + for (i = base.length - 1; i > 0 && base.charAt(i) === '/'; --i) { - } - if (i !== base.length - 1) { - base = base.substr(0, i + 1); - } + } + if (i !== base.length - 1) { + base = base.substr(0, i + 1); + } + + // We now have the base path in 'base' (possibly the empty string) + // and the wildcard filename pattern in 'pattern'. - // We now have the base path in 'base' (possibly the empty string) - // and the wildcard filename pattern in 'pattern'. - - readTheDir(false); - function readTheDir(listingSingleDir) { - fsm.readdir(base, function(err, contents) { - if (err) { - if (err.code === 'ENOTDIR' || err.code === 'ENOENT') { - callback(null, []); - } else { - callback(err); - } + readTheDir(false); + function readTheDir(listingSingleDir) { + fsm.readdir(base, function(err, contents) { + if (err) { + if (err.code === 'ENOTDIR' || err.code === 'ENOENT') { + callback(null, []); } else { - var matches; - if (!listingSingleDir) { - matches = contents.filter(function(n) { - return matchPattern(pattern, n); - }); - } else { - matches = contents; - } - - // Special case. If we have exactly one match, and it's a directory, then list - // the contents of that directory. (There's no reason why anyone should want - // to identify mutliple directories using wildcards and then list all of their - // contents over FTP!) - if (!listingSingleDir && matches.length === 1) { - var dir = PathModule.join(base, matches[0]); - fsm.stat(dir, function(err, st) { - if (err) { - return callback(err); - } - - if (!st.isDirectory()) { - doTheNormalThing(); - } else { - base = dir; - readTheDir(/*listingSingleDir=*/true); - } - }); - } else { - doTheNormalThing(); - } - - function doTheNormalThing() { - statList( - fsm, - matches.map(function(p) { - return PathModule.join(base, p); - }), - function(err, list) { - if (err) { - callback(err); - } else { - callback(null, list); - } - } - ); - } + callback(err); } - }); - } + } else { + var matches; + if (!listingSingleDir) { + matches = contents.filter(function(n) { + return matchPattern(pattern, n); + }); + } else { + matches = contents; + } + + // Special case. If we have exactly one match, and it's a directory, then list + // the contents of that directory. (There's no reason why anyone should want + // to identify mutliple directories using wildcards and then list all of their + // contents over FTP!) + if (!listingSingleDir && matches.length === 1) { + var dir = PathModule.join(base, matches[0]); + fsm.stat(dir, function(err, st) { + if (err) { + return callback(err); + } + + if (!st.isDirectory()) { + doTheNormalThing(); + } else { + base = dir; + readTheDir(/*listingSingleDir=*/true); + } + }); + } else { + doTheNormalThing(); + } + + function doTheNormalThing() { + statList( + fsm, + matches.map(function(p) { + return PathModule.join(base, p); + }), + callback + ); + } + } + }); } } diff --git a/lib/helpers/withCwd.js b/lib/helpers/withCwd.js index 3eed41c..1942198 100644 --- a/lib/helpers/withCwd.js +++ b/lib/helpers/withCwd.js @@ -1,14 +1,11 @@ -var pathModule = require('path'); +var path = require('path'); -function withCwd(cwd, path) { - var firstChar = (path || '').charAt(0); - cwd = cwd || pathModule.sep; - path = path || ''; - if (firstChar === '/' || firstChar === pathModule.sep) { - cwd = pathModule.sep; +function withCwd(cwd, p) { + p = p || ''; + if (!cwd || p.charAt(0) in ['/', path.sep]) { + cwd = path.sep; } - path = pathModule.join(pathModule.sep, cwd, path); - return path; + return path.join(path.sep, cwd, p); } module.exports = withCwd; diff --git a/lib/starttls.js b/lib/starttls.js index ede6b39..77f53bb 100644 --- a/lib/starttls.js +++ b/lib/starttls.js @@ -32,23 +32,24 @@ function starttlsClient(socket, options, callback) { return starttls(socket, options, callback, false); } -function starttls(socket, options, callback, isServer) { - var sslcontext; - - var opts = {}; - Object.keys(options).forEach(function(key) { - opts[key] = options[key]; +function clone(o) { + var c = {}; + Object.keys(o).forEach(function(key) { + c[key] = o[key]; }); + return c; +} + +function starttls(socket, options, callback, isServer) { + var opts = clone(options); if (!opts.ciphers) { opts.ciphers = RECOMMENDED_CIPHERS; } socket.removeAllListeners('data'); - if (tls.createSecureContext) { - sslcontext = tls.createSecureContext(opts); - } else { - sslcontext = crypto.createCredentials(opts); - } + var sslcontext = tls.createSecureContext + ? tls.createSecureContext(opts) + : crypto.createCredentials(opts); var pair = tls.createSecurePair(sslcontext, isServer); var cleartext = pipe(pair, socket); diff --git a/test/lib/common.js b/test/lib/common.js index e41847b..3edb0b2 100644 --- a/test/lib/common.js +++ b/test/lib/common.js @@ -73,7 +73,7 @@ var common = module.exports = { }); connection.on('command:pass', function(pass, success, failure) { if (pass === customOptions.pass) { - success(username); + success(username, customOptions.fs); } else { failure(); } diff --git a/test/list.js b/test/list.js index 31e0773..0a624b9 100644 --- a/test/list.js +++ b/test/list.js @@ -1,4 +1,5 @@ var common = require('./lib/common'); +var fs = require('fs'); describe('LIST command', function() { 'use strict'; @@ -6,81 +7,149 @@ describe('LIST command', function() { var client; var server; - beforeEach(function(done) { - server = common.server(); - client = common.client(done); - }); + describe('regular cases', function() { - function unslashRgx(rgx) { - return String(rgx).replace(/^\/|\/$/g, ''); - } - - it('should return "-" as first character for files', function(done) { - client.list('/', function(error, listing) { - error.should.equal(false); - listing = common.splitResponseLines(listing, / data\d*\.txt$/); - listing.should.have.lengthOf(6); - listing[0].should.startWith('-'); - done(); + beforeEach(function(done) { + server = common.server(); + client = common.client(done); }); - }); - it('should return "d" as first character for directories', function(done) { - client.list('/', function(error, listing) { - error.should.equal(false); - listing = common.splitResponseLines(listing, / usr$/); - listing.should.have.lengthOf(1); - listing[0].should.startWith('d'); - done(); + function unslashRgx(rgx) { + return String(rgx).replace(/^\/|\/$/g, ''); + } + + it('should return "-" as first character for files', function(done) { + client.list('/', function(error, listing) { + error.should.equal(false); + listing = common.splitResponseLines(listing, / data\d*\.txt$/); + listing.should.have.lengthOf(6); + listing[0].should.startWith('-'); + done(); + }); }); - }); - it('should list files similar to ls -l', function(done) { - client.list('/usr', function(error, listing) { - error.should.equal(false); - listing = common.splitResponseLines(listing); - listing.should.have.lengthOf(1); - var lsLongRgx = [ - /($# file modes: ___|)[d-]([r-][w-][x-]){3}/, - /($# ?¿?¿? inodes?: |)\d+/, - /($# owner name: ___|)\S+/, - /($# owner group: __|)\S+/, - /($# size in bytes: |)\d+/, - /($# month: ________|)[A-Z][a-z]{2}/, - /($# day of month: _|)\d{1,2}/, - /($# time or year: _|)([\d ]\d:|19|[2-9]\d)\d{2}/, - /($# file name: ____|)[\S\s]+/, - ].map(unslashRgx).join('\\s+'); - lsLongRgx = new RegExp(lsLongRgx, ''); - var match = (lsLongRgx.exec(listing[0]) || [false]); - match[0].should.equal(listing[0]); - done(); + it('should return "d" as first character for directories', function(done) { + client.list('/', function(error, listing) { + error.should.equal(false); + listing = common.splitResponseLines(listing, / usr$/); + listing.should.have.lengthOf(1); + listing[0].should.startWith('d'); + done(); + }); }); - }); - it('should list a single file', function(done) { - var filename = 'data.txt'; - client.list('/' + filename, function(error, listing) { - error.should.equal(false); - listing = common.splitResponseLines(listing, ' ' + filename); - listing.should.have.lengthOf(1); - listing[0].should.startWith('-'); - done(); + it('should list files similar to ls -l', function(done) { + client.list('/usr', function(error, listing) { + error.should.equal(false); + listing = common.splitResponseLines(listing); + listing.should.have.lengthOf(1); + var lsLongRgx = [ + /($# file modes: ___|)[d-]([r-][w-][x-]){3}/, + /($# ?¿?¿? inodes?: |)\d+/, + /($# owner name: ___|)\S+/, + /($# owner group: __|)\S+/, + /($# size in bytes: |)\d+/, + /($# month: ________|)[A-Z][a-z]{2}/, + /($# day of month: _|)\d{1,2}/, + /($# time or year: _|)([\d ]\d:|19|[2-9]\d)\d{2}/, + /($# file name: ____|)[\S\s]+/, + ].map(unslashRgx).join('\\s+'); + lsLongRgx = new RegExp(lsLongRgx, ''); + var match = (lsLongRgx.exec(listing[0]) || [false]); + match[0].should.equal(listing[0]); + done(); + }); }); - }); - it('should list a subdirectory', function(done) { - client.list('/usr', function(error, listing) { - error.should.equal(false); - listing = common.splitResponseLines(listing); - listing.should.have.lengthOf(1); - listing[0].should.startWith('d'); - listing[0].should.endWith(' local'); - done(); + it('should list a single file', function(done) { + var filename = 'data.txt'; + client.list('/' + filename, function(error, listing) { + error.should.equal(false); + listing = common.splitResponseLines(listing, ' ' + filename); + listing.should.have.lengthOf(1); + listing[0].should.startWith('-'); + done(); + }); + }); + + it('should list a subdirectory', function(done) { + client.list('/usr', function(error, listing) { + error.should.equal(false); + listing = common.splitResponseLines(listing); + listing.should.have.lengthOf(1); + listing[0].should.startWith('d'); + listing[0].should.endWith(' local'); + done(); + }); + }); + + afterEach(function() { + server.close(); }); }); - afterEach(function() { - server.close(); + describe('corner case', function() { + 'use strict'; + + var files; + + beforeEach(function(done) { + server = common.server({ + fs: { + stat: function(path, callback) { + process.nextTick( + callback, + undefined /* err */, + new fs.Stats(0,32768 /* file mode */,0,0,0,0,0,0,0,43 /* size */,0,0,0,0) + ); + }, + readdir: function(path, callback) { + process.nextTick(callback, undefined, files); + }, + }, + }); + client = common.client(done); + }); + + it('supports directories without files', function(done) { + files = []; + client.list('/', function(error, listing) { + error.should.equal(false); + listing.should.equal(''); + done(); + }); + }); + + it('supports directories with only a few files', function(done) { + files = ['a']; + client.list('/', function(error, listing) { + error.should.equal(false); + listing = common.splitResponseLines(listing); + listing.should.have.lengthOf(1); + done(); + }); + }); + + it('supports directories with many files', function(done) { + function ArrayWithStrings(n) { + return Array.apply(null, Array(n)).map(function(x, i) { + return i.toString(); + }); + } + files = ArrayWithStrings(6000); + client.list('/', function(error, listing) { + error.should.equal(false); + listing = common.splitResponseLines(listing); + listing.should.have.lengthOf(files.length); + done(); + }); + }); + + afterEach(function() { + server.close(); + }); }); + + }); +