diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..f05df50 --- /dev/null +++ b/.babelrc @@ -0,0 +1,31 @@ +{ + "plugins": [ + "transform-class-properties", + "syntax-trailing-function-commas", + "transform-object-rest-spread", + "transform-async-to-generator", + "transform-exponentiation-operator", + "check-es2015-constants", + "transform-es2015-arrow-functions", + "transform-es2015-block-scoped-functions", + "transform-es2015-block-scoping", + "transform-es2015-classes", + "transform-es2015-computed-properties", + "transform-es2015-destructuring", + "transform-es2015-for-of", + "transform-es2015-function-name", + "transform-es2015-literals", + "transform-es2015-modules-commonjs", + "transform-es2015-object-super", + "transform-es2015-parameters", + "transform-es2015-shorthand-properties", + "transform-es2015-spread", + "transform-es2015-sticky-regex", + "transform-es2015-template-literals", + "transform-es2015-typeof-symbol", + "transform-es2015-unicode-regex", + "transform-regenerator", + "transform-flow-strip-types", + "syntax-flow", + ] +} diff --git a/.eslintignore b/.eslintignore index 8d5c8e5..bfb478e 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,5 @@ _* +!__tests__ +lib/ +fixture/ node_modules/ diff --git a/.eslintrc b/.eslintrc index 544521f..371bae3 100644 --- a/.eslintrc +++ b/.eslintrc @@ -3,118 +3,116 @@ "node": true }, "rules": { - "no-cond-assign": 1, // disallow assignment in conditional expressions - "no-console": 0, // disallow use of console: should use nuclide-logging instead - "no-constant-condition": 1, // disallow use of constant expressions in conditions - "comma-dangle": [ // disallow trailing commas in object and array literals + "no-cond-assign": 1, // disallow assignment in conditional expressions + "no-console": 0, // disallow use of console: should use nuclide-logging instead + "no-constant-condition": 1, // disallow use of constant expressions in conditions + "comma-dangle": [ // disallow trailing commas in object and array literals 1, "always-multiline" ], - "no-control-regex": 1, // disallow control characters in regular expressions - "no-debugger": 1, // disallow use of debugger - "no-dupe-keys": 1, // disallow duplicate keys when creating object literals - "no-dupe-args": 1, // disallow duplicate arguments in functions - "no-duplicate-case": 1, // disallow a duplicate case label - "no-empty": 0, // disallow empty statements - "no-empty-character-class": 1, // disallow the use of empty character classes in regular expressions - "no-ex-assign": 1, // disallow assigning to the exception in a catch block - "no-extra-boolean-cast": 1, // disallow double-negation boolean casts in a boolean context - "no-extra-semi": 1, // disallow unnecessary semicolons - "no-func-assign": 1, // disallow overwriting functions written as function declarations - "no-inner-declarations": 0, // disallow function or variable declarations in nested blocks - "no-invalid-regexp": 1, // disallow invalid regular expression strings in the RegExp constructor - "no-negated-in-lhs": 1, // disallow negation of the left operand of an in expression - "no-obj-calls": 1, // disallow the use of object properties of the global object (Math and JSON) as functions - "no-regex-spaces": 1, // disallow multiple spaces in a regular expression literal - "no-reserved-keys": 0, // disallow reserved words being used as object literal keys - "no-sparse-arrays": 1, // disallow sparse arrays - "no-unreachable": 1, // disallow unreachable statements after a return, throw, continue, or break statement - "use-isnan": 1, // disallow comparisons with the value NaN - "valid-jsdoc": 0, // Ensure JSDoc comments are valid - "valid-typeof": 1, // Ensure that the results of typeof are compared against a valid string + "no-control-regex": 1, // disallow control characters in regular expressions + "no-debugger": 1, // disallow use of debugger + "no-dupe-keys": 1, // disallow duplicate keys when creating object literals + "no-dupe-args": 1, // disallow duplicate arguments in functions + "no-duplicate-case": 1, // disallow a duplicate case label + "no-empty": 0, // disallow empty statements + "no-empty-character-class": 1, // disallow the use of empty character classes in regular expressions + "no-ex-assign": 1, // disallow assigning to the exception in a catch block + "no-extra-boolean-cast": 1, // disallow double-negation boolean casts in a boolean context + "no-extra-semi": 1, // disallow unnecessary semicolons + "no-func-assign": 1, // disallow overwriting functions written as function declarations + "no-inner-declarations": 0, // disallow function or variable declarations in nested blocks + "no-invalid-regexp": 1, // disallow invalid regular expression strings in the RegExp constructor + "no-negated-in-lhs": 1, // disallow negation of the left operand of an in expression + "no-obj-calls": 1, // disallow the use of object properties of the global object (Math and JSON) as functions + "no-regex-spaces": 1, // disallow multiple spaces in a regular expression literal + "no-reserved-keys": 0, // disallow reserved words being used as object literal keys + "no-sparse-arrays": 1, // disallow sparse arrays + "no-unreachable": 1, // disallow unreachable statements after a return, throw, continue, or break statement + "use-isnan": 1, // disallow comparisons with the value NaN + "valid-jsdoc": 0, // Ensure JSDoc comments are valid + "valid-typeof": 1, // Ensure that the results of typeof are compared against a valid string - // Best Practices - // These are rules designed to prevent you from making mistakes. They either prescribe a better way of doing something or help you avoid footguns. + // Best Practices (designed to prevent you from making mistakes) - "block-scoped-var": 0, // treat var statements as if they were block scoped - "complexity": 0, // specify the maximum cyclomatic complexity allowed in a program - "consistent-return": 0, // require return statements to either always or never specify values - "curly": 1, // specify curly brace conventions for all control statements - "default-case": 0, // require default case in switch statements - "dot-notation": 0, // dot notation encouraged except for foreign properties that cannot be renamed (i.e., Closure Compiler rules) - "eqeqeq": [1, "allow-null"], // require the use of === and !== - "guard-for-in": 1, // make sure for-in loops have an if statement - "no-alert": 1, // disallow the use of alert, confirm, and prompt - "no-caller": 1, // disallow use of arguments.caller or arguments.callee - "no-div-regex": 1, // disallow division operators explicitly at beginning of regular expression - "no-else-return": 0, // disallow else after a return in an if - "no-empty-label": 1, // disallow use of labels for anything other then loops and switches - "no-eq-null": 0, // disallow comparisons to null without a type-checking operator - "no-eval": 1, // disallow use of eval() - "no-extend-native": 1, // disallow adding to native types - "no-extra-bind": 1, // disallow unnecessary function binding - "no-fallthrough": 1, // disallow fallthrough of case statements - "no-floating-decimal": 1, // disallow the use of leading or trailing decimal points in numeric literals - "no-implied-eval": 1, // disallow use of eval()-like methods - "no-labels": 1, // disallow use of labeled statements - "no-iterator": 1, // disallow usage of __iterator__ property - "no-lone-blocks": 1, // disallow unnecessary nested blocks - "no-loop-func": 0, // disallow creation of functions within loops - "no-multi-str": 0, // disallow use of multiline strings - "no-native-reassign": 0, // disallow reassignments of native objects - "no-new": 1, // disallow use of new operator when not part of the assignment or comparison - "no-new-func": 1, // disallow use of new operator for Function object - "no-new-wrappers": 1, // disallows creating new instances of String,Number, and Boolean - "no-octal": 1, // disallow use of octal literals - "no-octal-escape": 1, // disallow use of octal escape sequences in string literals, such as var foo = "Copyright \251"; - "no-proto": 1, // disallow usage of __proto__ property - "no-redeclare": 1, // disallow declaring the same variable more then once - "no-return-assign": 1, // disallow use of assignment in return statement - "no-script-url": 1, // disallow use of javascript: urls. - "no-self-compare": 1, // disallow comparisons where both sides are exactly the same - "no-sequences": 1, // disallow use of comma operator - "no-unused-expressions": 0, // disallow usage of expressions in statement position - "no-void": 1, // disallow use of void operator - "no-warning-comments": 0, // disallow usage of configurable warning terms in comments - "no-with": 1, // disallow use of the with statement - "radix": 1, // require use of the second argument for parseInt() - "vars-on-top": 0, // requires to declare all vars on top of their containing scope - "wrap-iife": 0, // require immediate function invocation to be wrapped in parentheses - "yoda": 1, // require or disallow Yoda conditions - "strict": 0, // this rule conflicts with 'use-babel' so we'll just disable it + "block-scoped-var": 0, // treat var statements as if they were block scoped + "complexity": 0, // specify the maximum cyclomatic complexity allowed in a program + "consistent-return": 0, // require return statements to either always or never specify values + "curly": 1, // specify curly brace conventions for all control statements + "default-case": 0, // require default case in switch statements + "dot-notation": 0, // dot notation encouraged except for foreign properties that cannot be renamed (i.e., Closure Compiler rules) + "eqeqeq": [1, "allow-null"], // require the use of === and !== + "guard-for-in": 1, // make sure for-in loops have an if statement + "no-alert": 1, // disallow the use of alert, confirm, and prompt + "no-caller": 1, // disallow use of arguments.caller or arguments.callee + "no-div-regex": 1, // disallow division operators explicitly at beginning of regular expression + "no-else-return": 0, // disallow else after a return in an if + "no-empty-label": 1, // disallow use of labels for anything other then loops and switches + "no-eq-null": 0, // disallow comparisons to null without a type-checking operator + "no-eval": 1, // disallow use of eval() + "no-extend-native": 1, // disallow adding to native types + "no-extra-bind": 1, // disallow unnecessary function binding + "no-fallthrough": 1, // disallow fallthrough of case statements + "no-floating-decimal": 1, // disallow the use of leading or trailing decimal points in numeric literals + "no-implied-eval": 1, // disallow use of eval()-like methods + "no-labels": 1, // disallow use of labeled statements + "no-iterator": 1, // disallow usage of __iterator__ property + "no-lone-blocks": 1, // disallow unnecessary nested blocks + "no-loop-func": 0, // disallow creation of functions within loops + "no-multi-str": 0, // disallow use of multiline strings + "no-native-reassign": 0, // disallow reassignments of native objects + "no-new": 1, // disallow use of new operator when not part of the assignment or comparison + "no-new-func": 1, // disallow use of new operator for Function object + "no-new-wrappers": 1, // disallows creating new instances of String,Number, and Boolean + "no-octal": 1, // disallow use of octal literals + "no-octal-escape": 1, // disallow use of octal escape sequences in string literals, such as var foo = "Copyright \251"; + "no-proto": 1, // disallow usage of __proto__ property + "no-redeclare": 1, // disallow declaring the same variable more then once + "no-return-assign": 1, // disallow use of assignment in return statement + "no-script-url": 1, // disallow use of javascript: urls. + "no-self-compare": 1, // disallow comparisons where both sides are exactly the same + "no-sequences": 1, // disallow use of comma operator + "no-unused-expressions": 0, // disallow usage of expressions in statement position + "no-void": 1, // disallow use of void operator + "no-warning-comments": 0, // disallow usage of configurable warning terms in comments e.g. TODO or FIXME + "no-with": 1, // disallow use of the with statement + "radix": 1, // require use of the second argument for parseInt() + "vars-on-top": 0, // requires to declare all vars on top of their containing scope + "wrap-iife": 0, // require immediate function invocation to be wrapped in parentheses + "yoda": 1, // require or disallow Yoda conditions + "strict": 0, // this rule conflicts with 'use-babel' so we'll just disable it // Variables - // These rules have to do with variable declarations. - "no-catch-shadow": 1, // disallow the catch clause parameter name being the same as a variable in the outer scope (off by default in the node environment) - "no-delete-var": 1, // disallow deletion of variables - "no-label-var": 1, // disallow labels that share a name with a variable - "no-shadow": 0, // disallow declaration of variables already declared in the outer scope - "no-shadow-restricted-names": 1, // disallow shadowing of names such as arguments - "no-undef": 1, // disallow undeclared variables - "no-undefined": 0, // disallow use of undefined variable - "no-undef-init": 0, // disallow use of undefined when initializing variables - "no-unused-vars": 1, // disallow declaration of variables that are not used in the code - "no-use-before-define": 0, // disallow use of variables before they are defined + "no-catch-shadow": 1, // disallow the catch clause parameter name being the same as a variable in the outer scope (off by default in the node environment) + "no-delete-var": 1, // disallow deletion of variables + "no-label-var": 1, // disallow labels that share a name with a variable + "no-shadow": 0, // disallow declaration of variables already declared in the outer scope + "no-shadow-restricted-names": 1, // disallow shadowing of names such as arguments + "no-undef": 1, // disallow undeclared variables + "no-undefined": 0, // disallow use of undefined variable + "no-undef-init": 0, // disallow use of undefined when initializing variables + "no-unused-vars": 1, // disallow declaration of variables that are not used in the code + "no-use-before-define": 0, // disallow use of variables before they are defined // Node.js - // These rules are specific to JavaScript running on Node.js. - "handle-callback-err": 1, // enforces error handling in callbacks - "no-mixed-requires": 1, // disallow mixing regular variable and require declarations - "no-new-require": 1, // disallow use of new operator with the require function - "no-path-concat": 1, // disallow string concatenation with __dirname and __filename - "no-process-exit": 0, // disallow process.exit() (on by default in the node environment) - "no-restricted-modules": 1, // restrict usage of specified node modules - "no-sync": 0, // disallow use of synchronous methods + "handle-callback-err": 1, // enforces error handling in callbacks + "no-mixed-requires": 1, // disallow mixing regular variable and require declarations + "no-new-require": 1, // disallow use of new operator with the require function + "no-path-concat": 1, // disallow string concatenation with __dirname and __filename + "no-process-exit": 0, // disallow process.exit() + "no-restricted-modules": 1, // restrict usage of specified node modules + "no-sync": 0, // disallow use of synchronous methods - // Stylistic Issues - // These rules are purely matters of style and are quite subjective. + // Stylistic (these rules are purely matters of style and are quite subjective) - "key-spacing": 0, - "comma-spacing": 0, - "no-multi-spaces": 0, + "key-spacing": 1, // require space after colon `{a: 1}` + "comma-spacing": 1, // require space after comma `var a, b;` + "no-multi-spaces": 1, // don't allow more spaces than necessary "brace-style": [ // enforce one true brace style - 1, "1tbs", {"allowSingleLine": false} + 1, "1tbs", { + "allowSingleLine": false + } ], "camelcase": [ // require camel case names 1, {"properties": "never"} @@ -148,8 +146,12 @@ "space-before-function-paren": [ // disallow a space before function parenthesis 1, "never" ], - "object-curly-spacing": [1, "never"], // disallow spaces inside of curly braces in object literals - "array-bracket-spacing": [1, "never"],// disallow spaces inside of curly braces in object literals + "object-curly-spacing": [ // disallow spaces inside of curly braces in object literals + 1, "never" + ], + "array-bracket-spacing": [ // disallow spaces inside of curly braces in array literals + 1, "never" + ], "space-in-parens": 1, // require or disallow spaces inside parentheses "space-infix-ops": 1, // require spaces around operators "space-return-throw-case": 1, // require a space after return, throw, and case @@ -158,20 +160,19 @@ "one-var": [1, "never"], // allow just one var statement per function "wrap-regex": 0, // require regex literals to be wrapped in parentheses - // Legacy - // The following rules are included for compatibility with JSHint and JSLint. While the names of the rules may not match up with the JSHint/JSLint counterpart, the functionality is the same. + // Legacy (included for compatibility with JSHint and JSLint. While the names of the rules may not match up with the JSHint/JSLint counterpart, the functionality is the same) - "max-depth": 0, // specify the maximum depth that blocks can be nested - //"max-len": [ // specify the maximum length of a line in your program [warning level, max line length, number of characters to treat a tab as] + "max-depth": 0, // specify the maximum depth that blocks can be nested + //"max-len": [ // specify the maximum length of a line in your program [warning level, max line length, number of characters to treat a tab as] // 1, 100, 2, { // "ignoreUrls": true, // "ignorePattern": "^\\s*(import\\s[^{]+from|(var|const|let)\\s[^{]+=\\s*require\\s*\\()" // } //], - "max-params": 0, // limits the number of parameters that can be used in the function declaration. - "max-statements": 0, // specify the maximum number of statement allowed in a function - "no-bitwise": 0, // disallow use of bitwise operators - "no-plusplus": 0 // disallow use of unary operators, ++ and -- + "max-params": 0, // limits the number of parameters that can be used in the function declaration. + "max-statements": 0, // specify the maximum number of statement allowed in a function + "no-bitwise": 0, // disallow use of bitwise operators + "no-plusplus": 0 // disallow use of unary operators, ++ and -- } } diff --git a/.flowconfig b/.flowconfig new file mode 100644 index 0000000..9d629f7 --- /dev/null +++ b/.flowconfig @@ -0,0 +1,8 @@ +[ignore] +.*node_modules/babel.* + +[include] + +[libs] + +[options] diff --git a/.gitignore b/.gitignore index dbf0821..57a6da3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -node_modules/* \ No newline at end of file +_* +!__tests__ +node_modules/ diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..4d32f4f --- /dev/null +++ b/.npmignore @@ -0,0 +1,6 @@ +_* +.* +fixture/ +src/ +testcert/ +test.js diff --git a/ftpd.js b/ftpd.js new file mode 100644 index 0000000..c0588f8 --- /dev/null +++ b/ftpd.js @@ -0,0 +1,4 @@ +var Constants = require('./lib/Constants').default; + +exports.FtpServer = require('./lib/FtpServer').default; +exports.LOG_LEVELS = Constants.LOG_LEVELS; diff --git a/lib/.gitignore b/lib/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/lib/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/lib/ftpd.js b/lib/ftpd.js deleted file mode 100644 index 5b17259..0000000 --- a/lib/ftpd.js +++ /dev/null @@ -1,1671 +0,0 @@ -/** - * TODO: - * - Implement Full RFC 959 - * - Implement Full RFC 2228 [PBSZ and PROT implemented already] - * - Implement RFC 3659 - * - * - passive command is for server to determine which port it listens on and report that to the client - * - doesn't necessarily mean it needs to be listening (i guess), but i assume it actually SHOULD be listening - * - it keeps listening for subsequent connections - * - * - what sort of security should i enforce? should i require the same IP for data and control connections? - * - maybe just for milesplit's use? - */ -/* eslint-disable no-octal */ -var net = require('net'); -var util = require('util'); -var events = require('events'); -var pathModule = require('path'); -var fsModule = require('fs'); -var StatMode = require('stat-mode'); -var dateformat = require('dateformat'); - -var glob = require('./glob'); -var starttls = require('./starttls'); - -var LOG_ERROR = 0; -var LOG_WARN = 1; -var LOG_INFO = 2; -var LOG_DEBUG = 3; -var LOG_TRACE = 4; - -exports.LOG_LEVELS = { - LOG_ERROR: LOG_ERROR, - LOG_WARN: LOG_WARN, - LOG_INFO: LOG_INFO, - LOG_DEBUG: LOG_DEBUG, - LOG_TRACE: LOG_TRACE, -}; - -var EventEmitter = events.EventEmitter; - -function pathEscape(text) { - // Rules for quoting: RFC 959 -> Appendix II -> Directory Commands - // (http://www.w3.org/Protocols/rfc959/A2_DirectoryCommands.html) - // -> Reply Codes -> search for "embedded double-quotes" - text = text.replace(/"/g, '""'); - return text; -} - -function withCwd(cwd, path) { - var firstChar = (path || '').charAt(0); - cwd = cwd || pathModule.sep; - path = path || ''; - if (firstChar === '/' || firstChar === pathModule.sep) { - cwd = pathModule.sep; - } - path = pathModule.join(pathModule.sep, cwd, path); - return path; -} - -// Currently used for stripping options from beginning of argument to LIST and NLST. -function stripOptions(str) { - var IN_SPACE = 0; - var IN_DASH = 1; - var state = IN_SPACE; - for (var i = 0; i < str.length; ++i) { - var c = str.charAt(i); - if (state === IN_SPACE) { - if (c === ' ' || c === '\t') { - - } else if (c === '-') { - state = IN_DASH; - } else { - return str.substr(i); - } - } else if (state === IN_DASH && (c === ' ' || c === '\t')) { - state = IN_SPACE; - } - } - return ''; -} - -function PassiveListener() { - EventEmitter.call(this); -} -util.inherits(PassiveListener, EventEmitter); - -function FtpServer(host, options) { - var self = this; - EventEmitter.call(self); - - self.host = host; - - self.options = options; - - if (!self.options.maxStatsAtOnce) { - self.options.maxStatsAtOnce = 5; - } - - if (!options.getInitialCwd) { - throw new Error("'getInitialCwd' option of FtpServer must be set"); - } - if (!options.getRoot) { - throw new Error("'getRoot' option of FtpServer must be set"); - } - self.getInitialCwd = options.getInitialCwd; - self.getRoot = options.getRoot; - - self.getUsernameFromUid = options.getUsernameFromUid || function(uid, c) { - c(null, 'ftp'); - }; - self.getGroupFromGid = options.getGroupFromGid || function(gid, c) { - c(null, 'ftp'); - }; - self.debugging = options.logLevel || 0; - self.useWriteFile = options.useWriteFile; - self.useReadFile = options.useReadFile; - self.uploadMaxSlurpSize = options.uploadMaxSlurpSize || 0; - - self.server = net.createServer(); - self.server.on('connection', function(socket) { - self._onConnection(socket); - }); - self.server.on('error', function(err) { - self.emit('error', err); - }); - self.server.on('close', function() { - self.emit('close'); - }); -} -util.inherits(FtpServer, EventEmitter); - -FtpServer.prototype._onConnection = function(socket) { - // build an index for the allowable commands for this server - var allowedCommands = null; - if (this.options.allowedCommands) { - allowedCommands = {}; - this.options.allowedCommands.forEach(function(c) { - allowedCommands[c.trim().toUpperCase()] = true; - }); - } - - var conn = new FtpConnection({ - server: this, - socket: socket, - pasv: null, // passive listener server - 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 - // it specified. (This can therefore be true even if there - // is not currently an open data connection.) - dataConfigured: false, - mode: 'ascii', - filefrom: '', - username: null, - filename: '', - fs: null, - cwd: null, - root: null, - hasQuit: false, - - // State for handling TLS upgrades. - secure: false, - pbszReceived: false, - }); - - 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) { - FtpServer.prototype[fname] = function() { - return this.server[fname].apply(this.server, arguments); - }; -}); - -FtpServer.prototype._logIf = function(verbosity, message, conn) { - if (verbosity > this.debugging) { - return; - } - // TODO: Move this to FtpConnection.prototype._logIf. - var peerAddr = (conn && conn.socket && conn.socket.remoteAddress); - if (peerAddr) { - message = '<' + peerAddr + '> ' + message; - } - if (verbosity === LOG_ERROR) { - message = 'ERROR: ' + message; - } else if (verbosity === LOG_WARN) { - message = 'WARNING: ' + message; - } - console.log(message); - var isError = (verbosity === LOG_ERROR); - if (isError && this.debugging === LOG_TRACE) { - console.trace('Trace follows'); - } -}; - -function FtpConnection(properties) { - EventEmitter.call(this); - var self = this; - Object.keys(properties).forEach(function(key) { - self[key] = properties[key]; - }); -} -util.inherits(FtpConnection, EventEmitter); - -// TODO: rename this to writeLine? -FtpConnection.prototype.respond = function(message, callback) { - return this._writeText(this.socket, message + '\r\n', callback); -}; - -FtpConnection.prototype._logIf = function(verbosity, message) { - return this.server._logIf(verbosity, message, this); -}; - -// We don't want to use setEncoding because it screws up TLS, but we -// also don't want to explicitly specify ASCII encoding for every call to 'write' -// with a string argument. -FtpConnection.prototype._writeText = function(socket, data, callback) { - if (!socket.writable) { - this._logIf(LOG_DEBUG, 'Attempted writing to a closed socket:\n>> ' + data.trim()); - return; - } - this._logIf(LOG_TRACE, '>> ' + data.trim()); - return socket.write(data, 'utf8', callback); -}; - -FtpConnection.prototype._authenticated = function() { - return !!this.username; -}; - -FtpConnection.prototype._closeDataConnections = function() { - if (this.dataSocket) { - // TODO: should the second arg be false here? - this._closeSocket(this.dataSocket, true); - this.dataSocket = null; - } - if (this.pasv) { - this.pasv.close(); - this.pasv = null; - } -}; - -FtpConnection.prototype._createPassiveServer = function() { - var self = this; - - return net.createServer(function(psocket) { - // 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'); - - if (self.secure) { - self._logIf(LOG_INFO, 'Upgrading passive connection to TLS'); - starttls.starttlsServer(psocket, self.server.options.tlsOptions, function(err, cleartext) { - if (err) { - self._logIf(LOG_ERROR, 'Error upgrading passive connection to TLS:' + util.inspect(err)); - self._closeSocket(psocket, 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(); - } - - function switchToSecure() { - self._logIf(LOG_INFO, 'Secure passive connection started'); - // TODO: Check for existing dataSocket. - self.dataSocket = cleartext; - setupPassiveListener(); - } - }); - } else { - // TODO: Check for existing dataSocket. - self.dataSocket = psocket; - setupPassiveListener(); - } - - function setupPassiveListener() { - if (self.dataListener) { - self.dataListener.emit('ready'); - } else { - self._logIf(LOG_WARN, 'Passive connection initiated, but no data listener'); - } - - // 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; - }; - } - - 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; - }); - } - }); -}; - -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) : '') - ); - } - - 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); -}; - -FtpConnection.prototype._onEnd = function() { - this._logIf(LOG_DEBUG, 'Client connection ended'); -}; - -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; - } - // TODO: LOG_DEBUG? - this._logIf(LOG_INFO, 'Client connection closed'); -}; - -// Whitelist of commands which don't require authentication. -// All other commands sent by unauthorized users will be rejected by default. -var DOES_NOT_REQUIRE_AUTH = { }; -[ - 'AUTH', 'FEAT', 'NOOP', 'PASS', 'PBSZ', 'PROT', 'QUIT', - 'TYPE', 'SYST', 'USER', -].forEach(function(c) { - DOES_NOT_REQUIRE_AUTH[c] = true; -}); - -// Commands which can't be issued until a PASV/PORT command has been sent -// without an intervening data connection error. -var REQUIRES_CONFIGURED_DATA = { }; -[ - 'LIST', 'NLST', 'RETR', 'STOR', -].forEach(function(c) { - REQUIRES_CONFIGURED_DATA[c] = true; -}); - -FtpConnection.prototype._onData = function(data) { - var self = this; - - if (self.hasQuit) { - return; - } - - data = data.toString('utf-8').trim(); - self._logIf(LOG_TRACE, '<< ' + data); - // Don't want to include passwords in logs. - self._logIf(LOG_INFO, 'FTP command: ' + - data.replace(/^PASS [\s\S]*$/i, 'PASS ***') - ); - - var command; - var commandArg; - var index = data.indexOf(' '); - if (index !== -1) { - var parts = data.split(' '); - command = parts.shift().toUpperCase(); - commandArg = parts.join(' ').trim(); - } else { - command = data.toUpperCase(); - commandArg = ''; - } - - var m = '_command_' + command; - if (self[m]) { - if (self.allowedCommands != null && self.allowedCommands[command] !== true) { - self.respond('502 ' + command + ' not implemented.'); - } else if (DOES_NOT_REQUIRE_AUTH[command]) { - self[m](commandArg, command); - } else { - // If 'tlsOnly' option is set, all commands which require user authentication will only - // 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()) { - checkData(); - } else { - self.respond('530 Not logged in.'); - } - } - - function checkData() { - if (REQUIRES_CONFIGURED_DATA[command] && !self.dataConfigured) { - self.respond('425 Data connection not configured; send PASV or PORT'); - return; - } - - self[m](commandArg, command); - } - } else { - self.respond('502 Command not implemented.'); - } - self.previousCommand = command; -}; - -/** - * Specify the user's account (superfluous) - * @return {FtpConnection} this - */ -FtpConnection.prototype._command_ACCT = function() { - this.respond('202 Command not implemented, superfluous at this site.'); - return this; -}; - -/** - * Allocate storage space (superfluous) - * @return {FtpConnection} this - */ -FtpConnection.prototype._command_ALLO = function() { - this.respond('202 Command not implemented, superfluous at this site.'); - return this; -}; - -FtpConnection.prototype._command_AUTH = function(commandArg) { - var self = this; - - if (!self.server.options.tlsOptions || commandArg !== 'TLS') { - return self.respond('502 Command not implemented'); - } - - self.respond('234 Honored', function() { - self._logIf(LOG_INFO, 'Establishing secure connection...'); - starttls.starttlsServer(self.socket, self.server.options.tlsOptions, function(err, cleartext) { - 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(); - } - - function switchToSecure() { - self._logIf(LOG_INFO, 'Secure connection started'); - self.socket = cleartext; - self.socket.on('data', function(data) { - self._onData(data); - }); - self.secure = true; - } - }); - }); -}; - -/** - * Change working directory to parent directory - * @return {FtpConnection} this - */ -FtpConnection.prototype._command_CDUP = function() { - var pathServer = pathModule.dirname(this.cwd); - var pathEscaped = pathEscape(pathServer); - this.cwd = pathServer; - this.respond('250 Directory changed to "' + pathEscaped + '"'); - return this; -}; - -/** - * Change working directory - * @param {string} pathRequest - * @return {FtpConnection} this - */ -FtpConnection.prototype._command_CWD = function(pathRequest) { - var pathServer = withCwd(this.cwd, pathRequest); - var pathFs = pathModule.join(this.root, pathServer); - var pathEscaped = pathEscape(pathServer); - this.fs.stat(pathFs, function(err, stats) { - if (err) { - this._logIf(LOG_ERROR, 'CWD ' + pathRequest + ': ' + err); - this.respond('550 Directory not found.'); - } else if (!stats.isDirectory()) { - this._logIf(LOG_WARN, 'Attempt to CWD to non-directory'); - this.respond('550 Not a directory'); - } else { - this.cwd = pathServer; - 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) { - if (err) { - self._logIf(LOG_ERROR, 'Error deleting file: ' + filename + ', ' + err); - // write error to socket - self.respond('550 Permission denied'); - } else { - self.respond('250 File deleted'); - } - }); -}; - -FtpConnection.prototype._command_FEAT = function() { - // Get the feature list implemented by the server. (RFC 2389) - this.respond( - '211-Features\r\n' + - ' SIZE\r\n' + - ' UTF8\r\n' + - ' MDTM\r\n' + - (!this.server.options.tlsOptions ? '' : - ' AUTH TLS\r\n' + - ' PBSZ\r\n' + - ' UTF8\r\n' + - ' PROT\r\n' - ) + - '211 end' - ); -}; - -FtpConnection.prototype._command_OPTS = function(commandArg) { - // http://tools.ietf.org/html/rfc2389#section-4 - if (commandArg.toUpperCase() === 'UTF8 ON') { - this.respond('200 OK'); - } else { - this.respond('451 Not supported'); - } -}; - -/** - * Print the file modification time - * @param {string} file - * @return {FtpConnection} this - */ -FtpConnection.prototype._command_MDTM = function(file) { - file = withCwd(this.cwd, file); - file = pathModule.join(this.root, file); - this.fs.stat(file, function(err, stats) { - if (err) { - this.respond('550 File unavailable'); - } else { - this.respond('213 ' + dateformat(stats.mtime, 'yyyymmddhhMMss')); - } - }.bind(this)); - return this; -}; - -FtpConnection.prototype._command_LIST = function(commandArg) { - this._LIST(commandArg, true/*detailed*/, 'LIST'); -}; -FtpConnection.prototype._command_NLST = function(commandArg) { - this._LIST(commandArg, false/*!detailed*/, 'NLST'); -}; - -FtpConnection.prototype._command_STAT = function(commandArg) { - if (commandArg) { - this._LIST(commandArg, true/*detailed*/, 'STAT'); - } else { - this.respond('211 FTP Server Status OK'); - } -}; - -FtpConnection.prototype._LIST = function(commandArg, detailed, cmd) { - /* - Normally the server responds with a mark using code 150. It then stops accepting new connections, attempts to send the contents of the directory over the data connection, and closes the data connection. Finally it - - accepts the LIST or NLST request with code 226 if the entire directory was successfully transmitted; - rejects the LIST or NLST request with code 425 if no TCP connection was established; - rejects the LIST or NLST request with code 426 if the TCP connection was established but then broken by the client or by network failure; or - rejects the LIST or NLST request with code 451 if the server had trouble reading the directory from disk. - - The server may reject the LIST or NLST request (with code 450 or 550) without first responding with a mark. In this case the server does not touch the data connection. - */ - - var self = this; - - // LIST may be passed options (-a in particular). We just ignore any of these. - // (In the particular case of -a, we show hidden files anyway.) - var dirname = stripOptions(commandArg); - var dir = withCwd(self.cwd, dirname); - - glob.setMaxStatsAtOnce(self.server.options.maxStatsAtOnce); - glob.glob(pathModule.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'); - return; - } - - if (self.server.options.hideDotFiles) { - files = files.filter(function(file) { - if (file.name && file.name[0] !== '.') { - return true; - } - }); - } - - self._logIf(LOG_INFO, 'Directory has ' + files.length + ' files'); - if (files.length === 0) { - return self._listFiles([], detailed, cmd); - } - - var fileInfos; // To contain list of files with info for each. - - if (!detailed) { - // We're not doing a detailed listing, so we don't need to get username - // and group name. - fileInfos = files; - return finished(); - } - - // 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); - } - j = --i; - - function handleFile(ii) { - if (i >= files.length) { - return i === files.length + j ? finished() : null; - } - - self.server.getUsernameFromUid(files[ii].stats.uid, function(e1, uname) { - self.server.getGroupFromGid(files[ii].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, - }); - } - handleFile(++i); - }); - }); - } - - function finished() { - // Sort file names. - if (!self.server.options.dontSortFilenames) { - if (self.server.options.filenameSortMap !== false) { - var sm = ( - self.server.options.filenameSortMap || - function(x) { - return x.toUpperCase(); - } - ); - for (var i = 0; i < fileInfos.length; ++i) { - fileInfos[i]._s = sm(detailed ? fileInfos[i].file.name : fileInfos[i].name); - } - } - - var sf = (self.server.options.filenameSortFunc || - function(x, y) { - return x.localeCompare(y); - }); - fileInfos = fileInfos.sort(function(x, y) { - if (self.server.options.filenameSortMap !== false) { - return sf(x._s, y._s); - } else if (detailed) { - return sf(x.file.name, y.file.name); - } else { - return sf(x.name, y.name); - } - }); - } - - self._listFiles(fileInfos, detailed, cmd); - } - }, self.server.options.noWildcards); -}; - -function leftPad(text, width) { - var out = ''; - for (var j = text.length; j < width; j++) { - out += ' '; - } - out += text; - return out; -} - -FtpConnection.prototype._listFiles = function(fileInfos, detailed, cmd) { - var self = this; - - var m = '150 Here comes the directory listing'; - var BEGIN_MSGS = { - LIST: m, NLST: m, STAT: '213-Status follows', - }; - m = '226 Transfer OK'; - var END_MSGS = { - LIST: m, NLST: m, STAT: '213 End of status', - }; - - self.respond(BEGIN_MSGS[cmd], function() { - if (cmd === 'STAT') { - whenReady(self.socket); - } else { - self._whenDataReady(whenReady); - } - - function whenReady(listconn) { - if (fileInfos.length === 0) { - return success(); - } - - function success(err) { - if (err) { - self.respond('550 Error listing files'); - } else { - self.respond(END_MSGS[cmd]); - } - if (cmd !== 'STAT') { - self._closeSocket(listconn); - } - } - - self._logIf(LOG_DEBUG, 'Sending file list'); - - for (var i = 0; i < fileInfos.length; ++i) { - var fileInfo = fileInfos[i]; - - var line = ''; - var file; - - if (!detailed) { - file = fileInfo; - line += file.name + '\r\n'; - } else { - file = fileInfo.file; - var s = file.stats; - var allModes = (new StatMode({mode: s.mode})).toString(); - var rwxModes = allModes.substr(1, 9); - line += (s.isDirectory() ? 'd' : '-') + rwxModes; - // ^-- Clients don't need to know about special files and pipes - line += ' 1 ' + - (fileInfo.uname || 'ftp') + ' ' + - (fileInfo.gname === null ? 'ftp' : fileInfo.gname) + ' '; - 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'; - } - self._writeText( - listconn, - line, - (i === fileInfos.length - 1 ? success : undefined) - ); - } - } - }); -}; - -/** - * Create a directory - * @param {string} pathRequest - * @return {FtpConnection} this - */ -FtpConnection.prototype._command_MKD = function(pathRequest) { - var pathServer = withCwd(this.cwd, pathRequest); - var pathEscaped = pathEscape(pathServer); - var pathFs = pathModule.join(this.root, pathServer); - this.fs.mkdir(pathFs, 0755, function(err) { - if (err) { - this._logIf(LOG_ERROR, 'MKD ' + pathRequest + ': ' + err); - this.respond('550 "' + pathEscaped + '" directory NOT created'); - } else { - this.respond('257 "' + pathEscaped + '" directory created'); - } - }.bind(this)); - return this; -}; - -/** - * Perform a no-op (used to keep-alive connection) - * @return {FtpConnection} this - */ -FtpConnection.prototype._command_NOOP = function() { - this.respond('200 OK'); - return this; -}; - -FtpConnection.prototype._command_PORT = function(x, y) { - this._PORT(x, y); -}; -FtpConnection.prototype._command_EPRT = function(x, y) { - this._PORT(x, y); -}; -FtpConnection.prototype._PORT = function(commandArg, command) { - var self = this; - var m; - - self.dataConfigured = false; - - 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})$/); - if (!m) { - self.respond('501 Bad argument to PORT'); - return; - } - - var host = m[1] + '.' + m[2] + '.' + m[3] + '.' + m[4]; - var port = (parseInt(m[5], 10) << 8) + parseInt(m[6], 10); - if (isNaN(port)) { - // The value should never be NaN because the relevant groups in the regex matche 1-3 digits. - throw new Error('Impossible NaN in FtpConnection.prototype._PORT'); - } - } else { // EPRT - if (commandArg.length >= 3 && commandArg.charAt(0) === '|' && - commandArg.charAt(2) === '|' && commandArg.charAt(1) === '2') { - // Only IPv4 is supported. - self.respond('522 Server cannot handle IPv6 EPRT commands, use (1)'); - return; - } - - m = commandArg.match(/^\|1\|([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})\|([0-9]{1,5})/); - if (!m) { - self.respond('501 Bad Argument to EPRT'); - return; - } - - var r = parseInt(m[2], 10); - if (isNaN(r)) { - // The value should never be NaN because the relevant group in the regex matches 1-5 digits. - throw new Error('Impossible NaN in FtpConnection.prototype._PORT (2)'); - } - if (r > 65535 || r <= 0) { - self.respond('501 Bad argument to EPRT (invalid port number)'); - return; - } - - host = m[1]; - port = r; - } - - self.dataConfigured = true; - self.dataHost = host; - self.dataPort = port; - self._logIf(LOG_DEBUG, 'self.dataHost, self.dataPort set to ' + self.dataHost + ':' + self.dataPort); - self.respond('200 OK'); -}; - -FtpConnection.prototype._command_PASV = function(x, y) { - this._PASV(x, y); -}; -FtpConnection.prototype._command_EPSV = function(x, y) { - this._PASV(x, y); -}; -FtpConnection.prototype._PASV = function(commandArg, command) { - var self = this; - - self.dataConfigured = false; - - if (command === 'EPSV' && commandArg && commandArg !== '1') { - self.respond('202 Not supported'); - return; - } - - // not sure whether the spec limits to 1 data connection at a time ... - if (self.dataSocket) { - self._closeSocket(self.dataSocket, true); - } - - 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; -}; - -FtpConnection.prototype._writePASVReady = function(command) { - var self = this; - - var a = self.pasv.address(); - var host = self.server.host; - var port = a.port; - if (command === 'PASV') { - var i1 = (port / 256) | 0; - var i2 = port % 256; - self.respond('227 Entering Passive Mode (' + host.split('.').join(',') + ',' + i1 + ',' + i2 + ')'); - } else { // EPASV - self.respond('229 Entering Extended Passive Mode (|||' + port + '|)'); - } -}; - -FtpConnection.prototype._setupNewPASV = 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); - } - - // Once we're successfully listening, tell the client - pasv.on('listening', function() { - self.pasv = pasv; - - if (portRangeErrorHandler) { - pasv.removeListener('error', portRangeErrorHandler); - pasv.addListener('error', normalErrorHandler); - } - - self._logIf(LOG_DEBUG, 'Passive data connection beginning to listen'); - - 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'); - }); -}; - -FtpConnection.prototype._command_PBSZ = function(commandArg) { - var self = this; - - if (!self.server.options.tlsOptions) { - return self.respond('202 Not supported'); - } - - // Protection Buffer Size (RFC 2228) - if (!self.secure) { - self.respond('503 Secure connection not established'); - } else if (parseInt(commandArg, 10) !== 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'); - } -}; - -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'); - } -}; - -/** - * Print the current working directory. - * @param {string} commandArg must always be empty - * @return {FtpConnection} this - */ -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; -}; - -FtpConnection.prototype._command_QUIT = function() { - var self = this; - - self.hasQuit = true; - self.respond('221 Goodbye', function(err) { - if (err) { - self._logIf(LOG_ERROR, "Error writing 'Goodbye' message following QUIT"); - } - self._closeSocket(self.socket, true); - self._closeDataConnections(); - }); -}; - -FtpConnection.prototype._command_RETR = function(commandArg) { - var filename = pathModule.join(this.root, withCwd(this.cwd, commandArg)); - - if (this.server.options.useReadFile) { - this._RETR_usingReadFile(commandArg, filename); - } else { - this._RETR_usingCreateReadStream(commandArg, filename); - } -}; - -FtpConnection.prototype._RETR_usingCreateReadStream = function(commandArg, filename) { - var self = this; - var startTime = new Date(); - - self.emit('file:retr', 'open', { - user: self.username, - file: filename, - sTime: startTime, - }); - - function afterOk(callback) { - self.respond('150 Opening ' + self.mode.toUpperCase() + ' mode data connection', callback); - } - - - self.fs.open(filename, 'r', function(err, fd) { - if (err) { - self.emit('file:retr', 'error', { - user: self.username, - file: filename, - filesize: 0, - sTime: startTime, - eTime: new Date(), - duration: new Date() - startTime, - errorState: true, - error: err, - }); - if (err.code === 'ENOENT') { - self.respond('550 Not Found'); - } else { // Who knows what's going on here... - self.respond('550 Not Accessible'); - self._logIf(LOG_ERROR, "Error at read of '" + filename + "' other than ENOENT " + err); - } - } else { - afterOk(function() { - self._whenDataReady(function(pasvconn) { - var readLength = 0; - var now = new Date(); - var rs = self.fs.createReadStream(null, {fd: fd}); - rs.pause(); - rs.once('error', function(err) { - self.emit('file:retr', 'close', { - user: self.username, - file: filename, - filesize: 0, - sTime: startTime, - eTime: now, - duration: now - startTime, - errorState: true, - error: err, - }); - }); - - rs.on('data', function(buffer) { - readLength += buffer.length; - }); - - rs.on('end', function() { - var now = new Date(); - self.emit('file:retr', 'close', { - user: self.username, - file: filename, - filesize: 0, - sTime: startTime, - eTime: now, - duration: now - startTime, - errorState: false, - }); - self.respond('226 Closing data connection, sent ' + readLength + ' bytes'); - }); - - rs.pipe(pasvconn); - rs.resume(); - }); - }); - } - }); -}; - -FtpConnection.prototype._RETR_usingReadFile = function(commandArg, filename) { - var self = this; - var startTime = new Date(); - - self.emit('file:retr', 'open', { - user: self.username, - file: filename, - sTime: startTime, - }); - - function afterOk(callback) { - self.respond('150 Opening ' + self.mode.toUpperCase() + ' mode data connection', callback); - } - - self.fs.readFile(filename, function(err, contents) { - if (err) { - self.emit('file:retr', 'error', { - user: self.username, - file: filename, - filesize: 0, - sTime: startTime, - eTime: new Date(), - duration: new Date() - startTime, - errorState: true, - error: err, - }); - if (err.code === 'ENOENT') { - self.respond('550 Not Found'); - } else { // Who knows what's going on here... - self.respond('550 Not Accessible'); - self._logIf(LOG_ERROR, "Error at read of '" + filename + "' other than ENOENT " + err); - } - } else { - afterOk(function() { - self._whenDataReady(function(pasvconn) { - contents = {filename: filename, data: contents}; - self.emit('file:retr:contents', contents); - contents = contents.data; - pasvconn.write(contents); - var contentLength = contents.length; - self.respond('226 Closing data connection, sent ' + contentLength + ' bytes'); - self.emit('file:retr', 'close', { - user: self.username, - file: filename, - filesize: contentLength, - sTime: startTime, - eTime: new Date(), - duration: new Date() - startTime, - errorState: false, - }); - self._closeSocket(pasvconn); - }); - }); - } - }); -}; - -/** - * Remove a directory - * @param {string} pathRequest - * @return {FtpConnection} this - */ -FtpConnection.prototype._command_RMD = function(pathRequest) { - var pathServer = withCwd(this.cwd, pathRequest); - var pathFs = pathModule.join(this.root, pathServer); - this.fs.rmdir(pathFs, function(err) { - if (err) { - this._logIf(LOG_ERROR, 'RMD ' + pathRequest + ': ' + err); - this.respond('550 Delete operation failed'); - } else { - this.respond('250 "' + pathServer + '" directory removed'); - } - }.bind(this)); - return this; -}; - -FtpConnection.prototype._command_RNFR = function(commandArg) { - var self = this; - self.filefrom = withCwd(self.cwd, commandArg); - self._logIf(LOG_DEBUG, 'Rename from ' + self.filefrom); - self.respond('350 Ready for destination name'); -}; - -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) { - 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' : '')); - } else { - self.respond('250 File renamed successfully'); - } - }); -}; - -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) { - if (err) { - self._logIf(LOG_ERROR, "Error getting size of file '" + filename + "' "); - self.respond('450 Failed to get size of file'); - return; - } - self.respond('213 ' + s.size + ''); - }); -}; - -FtpConnection.prototype._command_TYPE = function(commandArg) { - if (commandArg === 'I' || commandArg === 'A') { - this.respond('200 OK'); - } else { - this.respond('202 Not supported'); - } -}; - -FtpConnection.prototype._command_SYST = function() { - this.respond('215 UNIX Type: I'); -}; - -FtpConnection.prototype._command_STOR = function(commandArg) { - var filename = withCwd(this.cwd, commandArg); - - if (this.server.options.useWriteFile) { - this._STOR_usingWriteFile(filename, 'w'); - } else { - this._STOR_usingCreateWriteStream(filename, null, 'w'); - } -}; - -// 'initialBuffers' argument is set when this is called from _STOR_usingWriteFile. -FtpConnection.prototype._STOR_usingCreateWriteStream = function(filename, initialBuffers, flag) { - var self = this; - - var wStreamFlags = {flags: flag || 'w', mode: 0644}; - var storeStream = self.fs.createWriteStream(pathModule.join(self.root, filename), wStreamFlags); - var notErr = true; - // Adding for event metadata for file upload (STOR) - var startTime = new Date(); - var uploadSize = 0; - - if (initialBuffers) { - //todo: handle back-pressure - initialBuffers.forEach(function(b) { - storeStream.write(b); - }); - } - - self._whenDataReady(handleUpload); - - storeStream.on('open', function() { - self._logIf(LOG_DEBUG, 'File opened/created: ' + filename); - self._logIf(LOG_DEBUG, 'Told client ok to send file data'); - // Adding event emitter for upload start time - self.emit('file:stor', 'open', { - user: self.username, - file: filename, - time: startTime, - }); - - self.respond('150 Ok to send data'); - }); - - storeStream.on('error', function() { - self.emit('file:stor', 'error', { - user: self.username, - file: filename, - filesize: uploadSize, - sTime: startTime, - eTime: new Date(), - duration: new Date() - startTime, - errorState: !notErr, - }); - storeStream.end(); - notErr = false; - if (self.dataSocket) { - self._closeSocket(self.dataSocket, true); - } - self.respond('426 Connection closed; transfer aborted'); - }); - - storeStream.on('finish', function() { - // Adding event emitter for completed upload. - self.emit('file:stor', 'close', { - user: self.username, - file: filename, - filesize: uploadSize, - sTime: startTime, - eTime: new Date(), - duration: new Date() - startTime, - errorState: !notErr, - }); - notErr ? self.respond('226 Closing data connection') : true; - if (self.dataSocket) { - self._closeSocket(self.dataSocket); - } - }); - - function handleUpload(dataSocket) { - var isPaused = false; - dataSocket.on('data', function(buff) { - var result = storeStream.write(buff); - // Handle back-pressure - if (result === false) { - dataSocket.pause(); - isPaused = true; - storeStream.once('drain', function() { - dataSocket.resume(); - isPaused = false; - }); - } - }); - dataSocket.once('error', function() { - notErr = false; - storeStream.end(); - }); - dataSocket.once('finish', function() { - if (isPaused) { - storeStream.once('drain', function() { - storeStream.end(); - }); - } else { - storeStream.end(); - } - }); - } -}; - -FtpConnection.prototype._STOR_usingWriteFile = function(filename, flag) { - var self = this; - - var erroredOut = false; - var slurpBuf = new Buffer(1024); - var totalBytes = 0; - var startTime = new Date(); - - self.emit('file:stor', 'open', { - user: self.username, - file: filename, - time: startTime, - }); - - self.respond('150 Ok to send data', function() { - self._whenDataReady(handleUpload); - }); - - 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 && - totalBytes + buf.length > self.server.options.uploadMaxSlurpSize) { - // Give up trying to slurp it -- it's too big. - - // If the 'fs' module we've been given doesn't implement 'createWriteStream', then - // we give up and send the client an error. - if (!self.fs.createWriteStream) { - if (self.dataSocket) { - self._closeSocket(self.dataSocket, true); - } - self.respond('552 Requested file action aborted; file too big'); - return; - } - - // Otherwise, we call _STOR_usingWriteStream, and tell it to prepend the stuff - // that we've buffered so far to the file. - self._logIf(LOG_WARN, 'uploadMaxSlurpSize exceeded; falling back to createWriteStream'); - self._STOR_usingCreateWriteStream(filename, [slurpBuf.slice(0, totalBytes), buf]); - self.dataSocket.removeListener('data', dataHandler); - self.dataSocket.removeListener('error', errorHandler); - self.dataSocket.removeListener('close', closeHandler); - } else { - if (totalBytes + buf.length > slurpBuf.length) { - var newLength = slurpBuf.length * 2; - if (newLength < totalBytes + buf.length) { - newLength = totalBytes + buf.length; - } - - var newSlurpBuf = new Buffer(newLength); - slurpBuf.copy(newSlurpBuf, 0, 0, totalBytes); - slurpBuf = newSlurpBuf; - } - buf.copy(slurpBuf, totalBytes, 0, buf.length); - totalBytes += buf.length; - } - } - - function closeHandler() { - if (erroredOut) { - return; - } - - 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.emit('file:stor', 'close', { - user: self.username, - file: filename, - filesize: totalBytes, - sTime: startTime, - eTime: new Date(), - duration: new Date() - startTime, - errorState: err ? true : false, - }); - if (err) { - erroredOut = true; - self._logIf(LOG_ERROR, 'Error writing file. ' + err); - if (self.dataSocket) { - self._closeSocket(self.dataSocket, true); - } - self.respond('426 Connection closed; transfer aborted'); - return; - } - - self.respond('226 Closing data connection'); - if (self.dataSocket) { - self._closeSocket(self.dataSocket); - } - }); - } - - function errorHandler() { - erroredOut = true; - } -}; - -FtpConnection.prototype._command_APPE = function(commandArg) { - var filename = withCwd(this.cwd, commandArg); - - if (this.server.options.useWriteFile) { - this._STOR_usingWriteFile(filename, 'a'); - } else { - this._STOR_usingCreateWriteStream(filename, null, 'a'); - } -}; - -/** - * Specify a username for login - * @param {string} username - * @return {FtpConnection} this - */ -FtpConnection.prototype._command_USER = function(username) { - var self = this; - - if (self.server.options.tlsOnly && !self.secure) { - self.respond( - '530 This server does not permit login over ' + - 'a non-secure connection; ' + - 'connect using FTP-SSL with explicit AUTH TLS'); - } else { - self.emit('command:user', username, - function success() { - self.respond('331 User name okay, need password.'); - }, - function failure() { - self.respond('530 Not logged in.'); - } - ); - } - return this; -}; - -/** - * Specify a password for login - * @param {string} password - * @return {FtpConnection} this - */ -FtpConnection.prototype._command_PASS = function(password) { - var self = this; - - if (self.previousCommand !== 'USER') { - self.respond('503 Bad sequence of commands.'); - } else { - self.emit('command:pass', password, - function success(username, userFsModule) { - function panic(error, method) { - self._logIf(LOG_ERROR, method + ' signaled error ' + util.inspect(error)); - self.respond('421 Service not available, closing control connection.', function() { - self._closeSocket(self.socket, true); - }); - } - function setCwd(cwd) { - function setRoot(root) { - self.root = root; - self.respond('230 User logged in, proceed.'); - } - - self.cwd = cwd; - if (self.server.getRoot.length <= 1) { - setRoot(self.server.getRoot(self)); - } else { - self.server.getRoot(self, function(err, root) { - if (err) { - panic(err, 'getRoot'); - } else { - setRoot(root); - } - }); - } - } - self.username = username; - self.fs = userFsModule || fsModule; - if (self.server.getInitialCwd.length <= 1) { - setCwd(withCwd(self.server.getInitialCwd(self))); - } else { - self.server.getInitialCwd(self, function(err, cwd) { - if (err) { - panic(err, 'getInitialCwd'); - } else { - setCwd(withCwd(cwd)); - } - }); - } - }, - function failure() { - self.respond('530 Not logged in.'); - self.username = null; - } - ); - } - return this; -}; - -FtpConnection.prototype._closeSocket = function(socket, shouldDestroy) { - // TODO: Should we always use destroy() to avoid keeping sockets open longer - // than necessary (and possibly exceeding OS max open sockets)? - if (shouldDestroy || this.server.options.destroySockets) { - // Don't call destroy() more than once. - if (!socket.destroyed) { - socket.destroy(); - } - } else { - // Don't call `end()` more than once. - if (socket.writable) { - socket.end(); - } - } -}; - -exports.FtpServer = FtpServer; diff --git a/package.json b/package.json index 131b276..fffd0af 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,16 @@ "name": "ftpd", "version": "0.2.14", "description": "Node FTP Server", - "main": "./lib/ftpd.js", + "main": "./ftpd.js", "engines": { "node": ">=0.10.0" }, "scripts": { - "test": "eslint --max-warnings 0 . && ./node_modules/.bin/istanbul test _mocha", - "lint": "eslint --max-warnings 0 ." + "lint": "eslint --max-warnings 0 .", + "build": "babel src --out-dir lib", + "test-lib": "mocha lib/__tests__ lib/**/__tests__", + "test": "npm run lint && npm run build && npm run test-lib", + "prepublish": "npm run build" }, "repository": { "type": "git", @@ -49,6 +52,10 @@ "name": "Indra Gunawan", "url": "https://github.com/coderbuzz" }, + { + "name": "Robert Daigle", + "url": "https://github.com/crunchytortoise" + }, { "name": "Eric Newton", "url": "https://github.com/eric-newton" @@ -116,12 +123,22 @@ }, "devDependencies": { "async": "~0.1.15", + "babel-cli": "^6.4.5", + "babel-eslint": "^4.1.6", + "babel-plugin-syntax-flow": "^6.3.13", + "babel-plugin-transform-class-properties": "^6.4.0", + "babel-plugin-transform-flow-strip-types": "^6.4.0", + "babel-polyfill": "^6.3.14", + "babel-preset-es2015": "^6.3.13", + "babel-preset-stage-2": "^6.3.13", + "babel-register": "^6.4.3", "collect-stream": "^1.1.1", "eslint": "^1.10.3", - "istanbul": "~0.2.4", + "eslint-plugin-babel": "^3.0.0", + "expect": "^1.13.4", + "ftp": "^0.3.10", "jsftp": "git://github.com/sergi/jsftp.git#master", "mocha": "^2.3.4", - "should": "~3.1.2", - "ftp": "^0.3.10" + "should": "~3.1.2" } } diff --git a/src/.eslintrc b/src/.eslintrc new file mode 100644 index 0000000..02ec504 --- /dev/null +++ b/src/.eslintrc @@ -0,0 +1,26 @@ +{ + "parser": "babel-eslint", + "plugins": [ + "babel" + ], + "rules": { + // ECMAScript 6/7 (2015 and above) + "arrow-parens": 1, // require parens in arrow function arguments + "arrow-spacing": 1, // require space before/after arrow function's arrow (fixable) + "constructor-super": 1, // verify calls of super() in constructors + "generator-star-spacing": 0, // enforce spacing around the * in generator functions (fixable) + "no-class-assign": 1, // disallow modifying variables of class declarations + "no-const-assign": 1, // disallow modifying variables that are declared using const + "no-dupe-class-members": 1, // disallow duplicate name in class members + "no-this-before-super": 1, // disallow use of this/super before calling super() in constructors. + "no-var": 0, // require let or const instead of var + "object-shorthand": 0, // require method and property shorthand syntax for object literals + "prefer-arrow-callback": 1, // suggest using arrow functions as callbacks + "prefer-const": 0, // suggest using const declaration for variables that are never modified after declared + "prefer-reflect": 0, // suggest using Reflect methods where applicable + "prefer-spread": 0, // suggest using the spread operator instead of .apply(). + "prefer-template": 0, // suggest using template literals instead of strings concatenation + "require-yield": 0, // disallow generator functions that do not have yield + "babel/no-await-in-loop": 1 // async inside a loop will run operations in serial, when often the desired behavior is to do do in parallel + } +} diff --git a/src/ActiveDataConnection.js b/src/ActiveDataConnection.js new file mode 100644 index 0000000..7f1a3c7 --- /dev/null +++ b/src/ActiveDataConnection.js @@ -0,0 +1,106 @@ +/* @//flow */ + +import net from 'net'; +import {EventEmitter} from 'events'; + +import starttls from './starttls'; + +export const CONNECTION_STATE = { + INITIALIZING: 0, // Connection is not yet ready (initial state). + INITIALIZING_TLS: 1, // Client is connected but we are negotiating TLS. + READY: 2, // Client is connected and socket is ready. + CLOSED: 3, // Connection is closed (error or normal connection end). +}; + +export const LISTENER_STATE = { + INITIALIZING: 0, // Initial state. + LISTENING: 1, // Listener is waiting for client to connect. + CLOSED: 2, // Listener has stopped listening (connections may still exist). +}; + +export class ActiveDataConnection extends EventEmitter { + constructor(port, remoteAddress, options) { + super(); + // It's important to store the listening port here so the control connection + // can send: 227 Entering Passive Mode (,) + this.port = port; + this.remoteAddress = remoteAddress; + this.state = CONNECTION_STATE.WAITING; + this._useTLS = options.useTLS; + this._socket = null; + // Auto-bind methods. + this._onError = this._onError.bind(this); + this._close = this._close.bind(this); + } + + getSocket() { + return this._socket; + } + + // This is not really a public method, except for use from the code that + // created this instance. + setSocket(socket) { + if (this._socket) { + throw new Error('DataConnection: method setSocket() called more than once.'); + } + if (!this._useTLS) { + this._socket = socket; + this.state = CONNECTION_STATE.READY; + socket.on('error', this._onError); + socket.on('close', this._close); + this.emit('ready', socket); + return; + } + this.state = CONNECTION_STATE.INITIALIZING_TLS; + this._upgradeConnection(socket, (error, cleartext) => { + this._socket = cleartext; + this.state = CONNECTION_STATE.READY; + cleartext.on('error', this._onError); + cleartext.on('close', this._close); + this.emit('ready', cleartext); + }); + } + + _upgradeConnection(rawSocket, callback) { + // this._log(LOG.INFO, 'Upgrading passive connection to TLS'); + let {tlsOptions} = this.options; + starttls.starttlsServer(rawSocket, tlsOptions, (error, cleartext) => { + if (error) { + // this._log(LOG.ERROR, 'Error upgrading passive connection to TLS:' + util.inspect(error)); + this._closeSocket(rawSocket, true); + callback(error); + return; + } + + if (cleartext.authorized || this.options.allowUnauthorizedTls) { + // this._log(LOG.INFO, 'Allowing unauthorized connection (allowUnauthorizedTls is on)'); + // this._log(LOG.INFO, 'Passive connection secured'); + callback(null, cleartext); + } else { + // this._log(LOG.INFO, 'Closing unauthorized connection (allowUnauthorizedTls is off)'); + this._closeSocket(rawSocket, true); + } + }); + } + + destroy() { + if (this._socket) { + this._socket.destroy(); // Will automatically emit `close`; + } else { + this._close(); + } + } + + _onError(error) { + this.emit('error', error); + process.nextTick(this._close); + } + + _close() { + if (this.state === CONNECTION_STATE.CLOSED) { + return; + } + this.state = CONNECTION_STATE.CLOSED; + this.emit('close'); + } +} diff --git a/src/Constants.js b/src/Constants.js new file mode 100644 index 0000000..8c4ba4c --- /dev/null +++ b/src/Constants.js @@ -0,0 +1,75 @@ +const Constants = { + CONCURRENT_STAT_CALLS: 5, + + // Alphabetized list of all commands that have a corresponding "__" prefixed + // method (basically, all commands we support). + COMMANDS_SUPPORTED: { + ALLO: true, + ACCT: true, + APPE: true, + AUTH: true, + CDUP: true, + CWD: true, + DELE: true, + EPRT: true, + EPSV: true, + FEAT: true, + LIST: true, + MDTM: true, + MKD: true, + NLST: true, + NOOP: true, + OPTS: true, + PASS: true, + PASV: true, + PBSZ: true, + PORT: true, + PROT: true, + PWD: true, + QUIT: true, + RETR: true, + RMD: true, + RNFR: true, + RNTO: true, + SIZE: true, + STAT: true, + STOR: true, + SYST: true, + TYPE: true, + USER: true, + }, + + // List of all commands which don't require authentication. + // All other commands sent by unauthorized users will be rejected by default. + COMMANDS_NO_AUTH: { + AUTH: true, + FEAT: true, + NOOP: true, + PASS: true, + PBSZ: true, + PROT: true, + QUIT: true, + TYPE: true, + SYST: true, + USER: true, + }, + + // List of all commands which can't be issued unless a PASV/PORT command has + // been received and the corresponding data socket is not in an error state. + COMMANDS_REQUIRE_DATA_SOCKET: { + LIST: true, + NLST: true, + RETR: true, + STOR: true, + }, + + LOG_LEVELS: { + ERROR: 0, + WARN: 1, + INFO: 2, + DEBUG: 3, + TRACE: 4, + }, +}; + +export default Constants; diff --git a/src/FtpConnection.js b/src/FtpConnection.js new file mode 100644 index 0000000..76697e7 --- /dev/null +++ b/src/FtpConnection.js @@ -0,0 +1,1351 @@ +import net from 'net'; +import util from 'util'; +import {EventEmitter} from 'events'; +import pathModule from 'path'; +import fsModule from 'fs'; +import StatMode from 'stat-mode'; +import dateformat from 'dateformat'; + +import * as glob from './glob'; +import starttls from './starttls'; +import Constants from './Constants'; + +import pathEscape from './helpers/pathEscape'; +import withCwd from './helpers/withCwd'; +import stripOptions from './helpers/stripOptions'; +import leftPad from './helpers/leftPad'; +import writeToStreamAsync from './helpers/writeToStreamAsync'; + +const ENCODED_ADDRESS = /^[0-9]{1,3}(,[0-9]{1,3}){5}$/; + +const { + // Use LOG for brevity. + LOG_LEVELS: LOG, + COMMANDS_SUPPORTED, + COMMANDS_NO_AUTH, + COMMANDS_REQUIRE_DATA_SOCKET, +} = Constants; + +const encodeAddress = (host, port) => { + var i1 = (port / 256) | 0; + var i2 = port % 256; + return host.split('.').join(',') + ',' + i1 + ',' + i2; +}; + +const decodeAddress = (encoded) => { + if (ENCODED_ADDRESS.test(encoded) === false) { + return null; + } + let octets = encoded.split(',').map((value) => parseInt(value, 10)); + let isOutOfRange = octets.some((number) => number > 255); + if (isOutOfRange) { + return null; + } + return { + host: octets.slice(0, 4).join('.'), + port: (octets[5] << 8) + octets[6], + }; +}; + +class FtpConnection extends EventEmitter { + constructor(options = {}) { + super(); + // TODO: Throw if any required option not present. + this.server = options.server; + this.socket = options.socket; + this.passiveListenerPool = options.passiveListenerPool; + this.allowedCommands = options.allowedCommands; + this.tlsOptions = options.tlsOptions; + + // dataPort and dataHost are for active data connections (server connecting to client) + // TODO: I don't think this should be 20. + this.dataPort = 20; + this.dataHost = null; + // The incoming (PASV) data connection. + this.dataConnection = null; + this._isEstablishingDataConnection = false; + this._hasReceivedPASV = false; + this._hasReceivedPORT = false; + + this.mode = 'ascii'; + this.filefrom = ''; + this.username = null; + this.fs = null; + this.cwd = null; + this.root = null; + this.hasQuit = false; + // State for handling TLS upgrades. + this.secure = false; + this.pbszReceived = false; + } + + // TODO: rename this to writeLine? + respond(message, callback) { + return this._writeText(this.socket, message + '\r\n', callback); + } + + _log(verbosity, message) { + var peerAddr = this.socket ? this.socket.remoteAddress : null; + return this.server._log( + verbosity, + peerAddr ? `<${peerAddr}> ${message}` : message + ); + } + + // We don't want to use setEncoding because it screws up TLS, but we + // also don't want to explicitly specify ASCII encoding for every call to 'write' + // with a string argument. + _writeText(socket, data, callback) { + if (!socket.writable) { + this._log(LOG.DEBUG, 'Attempted writing to a closed socket:\n>> ' + data.trim()); + return; + } + this._log(LOG.TRACE, '>> ' + data.trim()); + return socket.write(data, 'utf8', callback); + } + + _isAuthenticated() { + return !!this.username; + } + + _doesRequireDataConnection(command) { + return (COMMANDS_REQUIRE_DATA_SOCKET[command] === true); + } + + _hasReceivedDataConnectionRequest() { + return ( + this._isEstablishingDataConnection || + this.dataConnection != null + ); + } + + // TODO: rename this method. + _closeDataConnections() { + if (this.dataConnection) { + this.dataConnection.destroy(); + this.dataConnection = null; + } + } + + // TODO: this should call callback with first parameter `error`. + _whenDataReady(callback) { + // TODO: Better to check which mode we are (Active or Passive) and then act accordingly. + if (this._isEstablishingDataConnection) { + // TODO: reword. + this._log(LOG.DEBUG, 'Currently no data connection; expecting client to connect shortly...'); + this.on('dataConnectionEstablished', () => { + this._log(LOG.DEBUG, '...client has connected now'); + let socket = this.dataConnection.getSocket(); + callback(socket); + }); + return + } + if (this.dataConnection) { + this._log(LOG.DEBUG, 'A data connection exists'); + let socket = this.dataConnection.getSocket(); + process.nextTick(() => callback(socket)); + return; + } + // At this point we know to use Active Mode. Makes a connection + // to the client for data transfer. + this._initiateActiveDataConnection((socket) => { + callback(socket); + }); + } + + // This makes a connection to the client (Active mode). + // TODO: fix some stuff here. + _initiateActiveDataConnection(callback) { + var socket = net.connect(this.dataPort, this.dataHost || this.socket.remoteAddress); + socket.on('connect', () => { + callback(socket); + }); + const allOver = (err) => { + this._log( + err ? LOG.ERROR : LOG.DEBUG, + 'Active data connection ended' + (err ? 'due to error: ' + util.inspect(err) : '') + ); + }; + // TODO: allOver will get executed twice here. + socket.on('end', allOver); + socket.on('close', allOver); + socket.on('error', (err) => { + this._closeSocket(socket, true); + this._log(LOG.ERROR, 'Data connection error: ' + util.inspect(err)); + }); + } + + _onError(err) { + this._log(LOG.ERROR, 'Client connection error: ' + util.inspect(err)); + this._closeSocket(this.socket, true); + } + + _onEnd() { + this._log(LOG.DEBUG, 'Client connection ended'); + } + + _onClose(hadError) { + // I feel like some of this might be redundant since we probably are doing + //this elsewhere. But it is fine to call _closeSocket more than once. + if (this.socket) { + this._closeSocket(this.socket, hadError); + this.socket = null; + } + if (this.dataConnection) { + this.dataConnection.destroy(); + this.dataConnection = null; + } + // TODO: LOG.DEBUG? + this._log(LOG.INFO, 'Client connection closed'); + } + + _onData(data) { + if (this.hasQuit) { + return; + } + data = data.toString('utf-8').trim(); + this._log(LOG.TRACE, '<< ' + data); + // Don't include passwords in logs. + this._log( + LOG.INFO, + 'FTP command: ' + data.replace(/^PASS [\s\S]*$/i, 'PASS ***') + ); + var parts = data.split(' '); + var command = parts.shift().toUpperCase(); + var commandArg = parts.join(' ').trim(); + if ( + COMMANDS_SUPPORTED[command] !== true || + (this.allowedCommands != null && this.allowedCommands[command] !== true) + ) { + this.respond('502 Command not implemented.'); + return; + } + if (COMMANDS_NO_AUTH[command] === true) { + this._execCommand(command, commandArg); + return; + } + // If 'tlsOnly' option is set, all commands which require user authentication will only + // be permitted over a secure connection. See RFC4217 regarding error code. + if (!this.secure && this.server.options.tlsOnly) { + this.respond('522 Protection level not sufficient; send AUTH TLS'); + return; + } + if (!this._isAuthenticated()) { + this.respond('530 Not logged in.'); + return; + } + if ( + this._doesRequireDataConnection(command) && + !this._hasReceivedDataConnectionRequest() + ) { + this.respond('425 Data connection not configured; send PASV or PORT'); + return; + } + this._execCommand(command, commandArg); + } + + _execCommand(command, commandArg) { + var methodName = '__' + command; + // this.emit('pre-command:' + command, commandArg, command); + this[methodName](commandArg, command); + // this.emit('post-command:' + command, commandArg, command); + } + + _listFiles(fileInfos, isDetailed, command) { + const whenReady = (dataSocket) => { + const success = (err) => { + if (err) { + this.respond('550 Error listing files'); + } else { + this.respond(END_MSGS[command]); + } + if (command !== 'STAT') { + this._closeSocket(dataSocket); + } + }; + + if (fileInfos.length === 0) { + return success(); + } + + this._log(LOG.DEBUG, 'Sending file list'); + + for (var i = 0; i < fileInfos.length; ++i) { + var fileInfo = fileInfos[i]; + + var line = ''; + var file; + + if (!isDetailed) { + file = fileInfo; + line += file.name + '\r\n'; + } else { + file = fileInfo.file; + var s = file.stats; + var allModes = (new StatMode({mode: s.mode})).toString(); + var rwxModes = allModes.substr(1, 9); + line += (s.isDirectory() ? 'd' : '-') + rwxModes; + // ^-- Clients don't need to know about special files and pipes + line += ' 1 ' + + (fileInfo.uname || 'ftp') + ' ' + + (fileInfo.gname === null ? 'ftp' : fileInfo.gname) + ' '; + 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'; + } + this._writeText( + dataSocket, + line, + (i === fileInfos.length - 1 ? success : undefined) + ); + } + }; + + var m = '150 Here comes the directory listing'; + var BEGIN_MSGS = { + LIST: m, NLST: m, STAT: '213-Status follows', + }; + m = '226 Transfer OK'; + var END_MSGS = { + LIST: m, NLST: m, STAT: '213 End of status', + }; + + this.respond(BEGIN_MSGS[command], () => { + if (command === 'STAT') { + whenReady(this.socket); + } else { + this._whenDataReady(whenReady); + } + + }); + } + + _parseEPRT(commandArg) { + if ( + commandArg.length >= 3 && + commandArg.charAt(0) === '|' && + commandArg.charAt(2) === '|' && + commandArg.charAt(1) === '2' + ) { + // Only IPv4 is supported. + this.respond('522 Server cannot handle IPv6 EPRT commands, use (1)'); + return; + } + var m = commandArg.match(/^\|1\|([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})\|([0-9]{1,5})/); + if (!m) { + this.respond('501 Bad Argument to EPRT'); + return; + } + var r = parseInt(m[2], 10); + if (isNaN(r)) { + // The value should never be NaN because the relevant group in the regex matches 1-5 digits. + throw new Error('Impossible NaN in FtpConnection.prototype._PORT (2)'); + } + if (r > 65535 || r <= 0) { + this.respond('501 Bad argument to EPRT (invalid port number)'); + return; + } + return {host: m[1], port: r}; + } + + // TODO: this should work the same for active or passive. + _onDataConnection(socket) { + if (this._isEstablishingDataConnection) { + this._isEstablishingDataConnection = false; + } + this.emit('dataConnectionEstablished'); + + socket.on('error', (err) => { + this._log(LOG.ERROR, 'Data socket event: error: ' + err); + }); + + const allOver = (name) => { + let finished = false; + return (error) => { + // Prevent calling twice. + if (finished) { + return; + } + this._log( + error ? LOG.ERROR : LOG.DEBUG, + 'Data socket event: ' + name + (error ? ' due to error' : '') + ); + finished = true; + }; + }; + + // 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. + socket.on('close', allOver('close')); + socket.on('end', allOver('end')); + } + + _retrieveUsingCreateReadStream(commandArg, filename) { + var startTime = new Date(); + + this.emit('file:retr', 'open', { + user: this.username, + file: filename, + sTime: startTime, + }); + + const afterOk = (callback) => { + this.respond('150 Opening ' + this.mode.toUpperCase() + ' mode data connection', callback); + }; + + this.fs.open(filename, 'r', (err, fd) => { + if (err) { + this.emit('file:retr', 'error', { + user: this.username, + file: filename, + filesize: 0, + sTime: startTime, + eTime: new Date(), + duration: new Date() - startTime, + errorState: true, + error: err, + }); + if (err.code === 'ENOENT') { + this.respond('550 Not Found'); + } else { // Who knows what's going on here... + this.respond('550 Not Accessible'); + this._log(LOG.ERROR, "Error at read of '" + filename + "' other than ENOENT " + err); + } + } else { + afterOk(() => { + this._whenDataReady((dataSocket) => { + var readLength = 0; + var now = new Date(); + var rs = this.fs.createReadStream(null, {fd: fd}); + rs.pause(); + rs.once('error', (err) => { + this.emit('file:retr', 'close', { + user: this.username, + file: filename, + filesize: 0, + sTime: startTime, + eTime: now, + duration: now - startTime, + errorState: true, + error: err, + }); + }); + + rs.on('data', (buffer) => { + readLength += buffer.length; + }); + + rs.on('end', () => { + var now = new Date(); + this.emit('file:retr', 'close', { + user: this.username, + file: filename, + filesize: 0, + sTime: startTime, + eTime: now, + duration: now - startTime, + errorState: false, + }); + this.respond('226 Closing data connection, sent ' + readLength + ' bytes'); + }); + + rs.pipe(dataSocket); + rs.resume(); + }); + }); + } + }); + } + + _retrieveUsingReadFile(commandArg, filename) { + var startTime = new Date(); + + this.emit('file:retr', 'open', { + user: this.username, + file: filename, + sTime: startTime, + }); + + const afterOk = (callback) => { + this.respond('150 Opening ' + this.mode.toUpperCase() + ' mode data connection', callback); + }; + + this.fs.readFile(filename, (err, contents) => { + if (err) { + this.emit('file:retr', 'error', { + user: this.username, + file: filename, + filesize: 0, + sTime: startTime, + eTime: new Date(), + duration: new Date() - startTime, + errorState: true, + error: err, + }); + if (err.code === 'ENOENT') { + this.respond('550 Not Found'); + } else { // Who knows what's going on here... + this.respond('550 Not Accessible'); + this._log(LOG.ERROR, "Error at read of '" + filename + "' other than ENOENT " + err); + } + } else { + afterOk(() => { + this._whenDataReady((dataSocket) => { + contents = {filename: filename, data: contents}; + this.emit('file:retr:contents', contents); + contents = contents.data; + dataSocket.write(contents); + var contentLength = contents.length; + this.respond('226 Closing data connection, sent ' + contentLength + ' bytes'); + this.emit('file:retr', 'close', { + user: this.username, + file: filename, + filesize: contentLength, + sTime: startTime, + eTime: new Date(), + duration: new Date() - startTime, + errorState: false, + }); + this._closeSocket(dataSocket); + }); + }); + } + }); + } + + // `initialBuffers` is used when called from _storeUsingWriteFile. + _storeUsingCreateWriteStream(filename, flags, initialBuffers, dataSocket) { + var wOptions = {flags: flags || 'w', mode: 0o644}; + var storeStream = this.fs.createWriteStream(pathModule.join(this.root, filename), wOptions); + var wasError = false; + // Adding for event metadata for file upload (STOR) + var startTime = new Date(); + var uploadSize = 0; + + storeStream.on('open', () => { + this._log(LOG.DEBUG, 'File opened/created: ' + filename); + // Adding event emitter for upload start time + this.emit('file:stor', 'open', { + user: this.username, + file: filename, + time: startTime, + }); + this.respond('150 Ok to send data'); + this._log(LOG.DEBUG, 'Told client ok to send file data'); + }); + + storeStream.on('error', () => { + this.emit('file:stor', 'error', { + user: this.username, + file: filename, + filesize: uploadSize, + sTime: startTime, + eTime: new Date(), + duration: new Date() - startTime, + errorState: wasError, + }); + storeStream.end(); + wasError = true; + // TODO: Close data socket. + this.respond('426 Connection closed; transfer aborted'); + }); + + storeStream.on('finish', () => { + // Emit event for completed upload. + this.emit('file:stor', 'close', { + user: this.username, + file: filename, + filesize: uploadSize, + sTime: startTime, + eTime: new Date(), + duration: new Date() - startTime, + errorState: wasError, + }); + if (!wasError) { + this.respond('226 Closing data connection'); + } + // TODO: Close data socket. + }); + + const pipeFromSocket = () => { + var isBufferFull = false; + dataSocket.on('data', (data) => { + var result = storeStream.write(data); + // Handle back-pressure + if (result === false) { + isBufferFull = true; + dataSocket.pause(); + storeStream.once('drain', () => { + isBufferFull = false; + dataSocket.resume(); + }); + } + }); + dataSocket.once('error', () => { + wasError = true; + storeStream.end(); + }); + dataSocket.once('finish', () => { + if (isBufferFull) { + storeStream.once('drain', () => { + storeStream.end(); + }); + } else { + storeStream.end(); + } + }); + }; + + // If this is called from _storeUsingWriteFile (slurp size exceeded), + // then we have a dataSocket that is already mid-transfer. + if (initialBuffers) { + dataSocket.pause(); + writeToStreamAsync(initialBuffers, storeStream, () => { + pipeFromSocket(); + dataSocket.resume(); + }); + } else { + this._whenDataReady((socket) => { + dataSocket = socket; + pipeFromSocket(); + }); + } + + } + + _storeUsingWriteFile(filename, flags) { + var wasError = false; + var slurpBuf = new Buffer(1024); // TODO: Why is there a magic number here? + var totalBytes = 0; + var startTime = new Date(); + var dataSocket; + + this.emit('file:stor', 'open', { + user: this.username, + file: filename, + time: startTime, + }); + + const dataHandler = (buf) => { + if ( + this.server.options.uploadMaxSlurpSize != null && + totalBytes + buf.length > this.server.options.uploadMaxSlurpSize + ) { + // Give up trying to slurp it -- it's too big. + + // If the 'fs' module we've been given doesn't implement 'createWriteStream', then + // we give up and send the client an error. + if (!this.fs.createWriteStream) { + this.respond('552 Requested file action aborted; file too big'); + dataSocket.destroy(); + return; + } + + // Otherwise, we call _STOR_usingWriteStream, and tell it to prepend the stuff + // that we've buffered so far to the file. + this._log(LOG.WARN, 'uploadMaxSlurpSize exceeded; falling back to createWriteStream'); + // TODO: pause dataSocket first? + this._storeUsingCreateWriteStream( + filename, + flags, + [slurpBuf.slice(0, totalBytes), buf], + dataSocket + ); + dataSocket.removeListener('data', dataHandler); + dataSocket.removeListener('error', errorHandler); + dataSocket.removeListener('close', closeHandler); + } else { + if (totalBytes + buf.length > slurpBuf.length) { + var newLength = slurpBuf.length * 2; + if (newLength < totalBytes + buf.length) { + newLength = totalBytes + buf.length; + } + + var newSlurpBuf = new Buffer(newLength); + slurpBuf.copy(newSlurpBuf, 0, 0, totalBytes); + slurpBuf = newSlurpBuf; + } + buf.copy(slurpBuf, totalBytes, 0, buf.length); + totalBytes += buf.length; + } + }; + + const closeHandler = () => { + // TODO: Should this writeFile logic be done in the `end` event? That + // event will happen at the graceful end of a file transfer onlye, + // whereas the close event happens *always*. Including if there was an + // error (hence the `wasError` logic). + if (wasError) { + return; + } + // This is not a typo: `writeFile` uses `flag`, but `createWriteStream` + // uses `flags`. + var wOptions = {flag: flags || 'w', mode: 0o644}; + var contents = {filename: filename, data: slurpBuf.slice(0, totalBytes)}; + this.emit('file:stor:contents', contents); + this.fs.writeFile(pathModule.join(this.root, filename), contents.data, wOptions, (err) => { + this.emit('file:stor', 'close', { + user: this.username, + file: filename, + filesize: totalBytes, + sTime: startTime, + eTime: new Date(), + duration: new Date() - startTime, + errorState: err ? true : false, + }); + if (err) { + wasError = true; + this._log(LOG.ERROR, 'Error writing file.', err); + this.respond('426 Connection closed; transfer aborted'); + } else { + // Technically the connection is already closed at this point. + this.respond('226 Closing data connection'); + } + }); + }; + + const errorHandler = () => { + wasError = true; + }; + + this.respond('150 Ok to send data', () => { + this._whenDataReady((socket) => { + dataSocket = socket; + dataSocket.on('data', dataHandler); + dataSocket.once('close', closeHandler); + dataSocket.once('error', errorHandler); + }); + }); + } + + _closeSocket(socket, shouldDestroy) { + // TODO: Should we always use destroy() to avoid keeping sockets open longer + // than necessary (and possibly exceeding OS max open sockets)? + if (shouldDestroy || this.server.options.destroySockets) { + // Don't call destroy() more than once. + if (!socket.destroyed) { + socket.destroy(); + } + } else { + // Don't call `end()` more than once. + if (socket.writable) { + socket.end(); + } + } + } + + // Specify the user's account (superfluous) + __ACCT() { + this.respond('202 Command not implemented, superfluous at this site.'); + return this; + } + + // Allocate storage space (superfluous) + __ALLO() { + this.respond('202 Command not implemented, superfluous at this site.'); + return this; + } + + __AUTH(commandArg) { + let {tlsOptions} = this; + if (!tlsOptions || commandArg !== 'TLS') { + return this.respond('502 Command not implemented'); + } + + this.respond('234 Honored', () => { + this._log(LOG.INFO, 'Establishing secure connection...'); + starttls.starttlsServer(this.socket, tlsOptions, (err, cleartext) => { + const switchToSecure = () => { + this._log(LOG.INFO, 'Secure connection started'); + this.socket = cleartext; + this.socket.on('data', (data) => { + this._onData(data); + }); + this.secure = true; + }; + if (err) { + this._log(LOG.ERROR, 'Error upgrading connection to TLS: ' + util.inspect(err)); + this._closeSocket(this.socket, true); + } else if (!cleartext.authorized) { + this._log(LOG.INFO, 'Secure socket not authorized: ' + util.inspect(cleartext.authorizationError)); + if (this.server.options.allowUnauthorizedTls) { + this._log(LOG.INFO, 'Allowing unauthorized connection (allowUnauthorizedTls is on)'); + switchToSecure(); + } else { + this._log(LOG.INFO, 'Closing unauthorized connection (allowUnauthorizedTls is off)'); + this._closeSocket(this.socket, true); + } + } else { + switchToSecure(); + } + }); + }); + } + + // Change working directory to parent directory + __CDUP() { + var pathServer = pathModule.dirname(this.cwd); + var pathEscaped = pathEscape(pathServer); + this.cwd = pathServer; + this.respond('250 Directory changed to "' + pathEscaped + '"'); + return this; + } + + // Change working directory + __CWD(pathRequest) { + var pathServer = withCwd(this.cwd, pathRequest); + var pathFs = pathModule.join(this.root, pathServer); + var pathEscaped = pathEscape(pathServer); + this.fs.stat(pathFs, (err, stats) => { + if (err) { + this._log(LOG.ERROR, 'CWD ' + pathRequest + ': ' + err); + this.respond('550 Directory not found.'); + } else if (!stats.isDirectory()) { + this._log(LOG.WARN, 'Attempt to CWD to non-directory'); + this.respond('550 Not a directory'); + } else { + this.cwd = pathServer; + this.respond('250 CWD successful. "' + pathEscaped + '" is current directory'); + } + }); + return this; + } + + __DELE(commandArg) { + var filename = withCwd(this.cwd, commandArg); + this.fs.unlink(pathModule.join(this.root, filename), (err) => { + if (err) { + this._log(LOG.ERROR, 'Error deleting file: ' + filename + ', ' + err); + // write error to socket + this.respond('550 Permission denied'); + } else { + this.respond('250 File deleted'); + } + }); + } + + // Get the feature list implemented by the server. (RFC 2389) + __FEAT() { + let features = ['SIZE', 'UTF8', 'MDTM']; + if (this.tlsOptions) { + features.push('AUTH TLS', 'PBSZ', 'PROT'); + } + features = features.map((feature) => ' ' + feature + '\r\n'); + this.respond('211-Features\r\n' + features.join('') + '211 end'); + } + + __OPTS(commandArg) { + // http://tools.ietf.org/html/rfc2389#section-4 + if (commandArg.toUpperCase() === 'UTF8 ON') { + this.respond('200 OK'); + } else { + this.respond('451 Not supported'); + } + } + + // Print the file modification time + __MDTM(file) { + file = withCwd(this.cwd, file); + file = pathModule.join(this.root, file); + this.fs.stat(file, (err, stats) => { + if (err) { + this.respond('550 File unavailable'); + } else { + this.respond('213 ' + dateformat(stats.mtime, 'yyyymmddhhMMss')); + } + }); + return this; + } + + __LIST(commandArg, command) { + let isDetailed = (command === 'LIST' || command === 'STAT'); + /* + Normally the server responds with a mark using code 150. It then stops + accepting new connections, attempts to send the contents of the directory + over the data connection, and closes the data connection. + Finally it: + - accepts the LIST or NLST request with code 226 if the entire directory + was successfully transmitted + - rejects the LIST or NLST request with code 425 if no TCP connection was + established + - rejects the LIST or NLST request with code 426 if the TCP connection was + established but then broken by the client or by network failure + - rejects the LIST or NLST request with code 451 if the server had trouble + reading the directory from disk + + The server may reject the LIST or NLST request (with code 450 or 550) + without first responding with a mark. In this case the server does not + touch the data connection. + */ + + // LIST may be passed options (-a in particular). We just ignore any of these. + // (In the particular case of -a, we show hidden files anyway.) + let dirname = stripOptions(commandArg); + let dir = withCwd(this.cwd, dirname); + + // TODO: this is bad practice, use a class if options are required: + // new Glob({maxConcurrency: 5}).glob() + glob.setMaxStatsAtOnce(this.server.options.maxStatsAtOnce); + glob.glob( + pathModule.join(this.root, dir), + this.fs, + (err, files) => { + if (err) { + this._log(LOG.ERROR, 'Error sending file list, reading directory: ' + err); + this.respond('550 Not a directory'); + return; + } + + const handleFile = (ii) => { + if (i >= files.length) { + return i === files.length + j ? finished() : null; + } + this.server.getUsernameFromUid(files[ii].stats.uid, (e1, uname) => { + this.server.getGroupFromGid(files[ii].stats.gid, (e2, gname) => { + if (e1 || e2) { + this._log(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, + }); + } + handleFile(++i); + }); + }); + }; + + const finished = () => { + // Sort file names. + if (!this.server.options.dontSortFilenames) { + if (this.server.options.filenameSortMap !== false) { + var sm = ( + this.server.options.filenameSortMap || + ((x) => x.toUpperCase()) + ); + for (var i = 0; i < fileInfos.length; ++i) { + fileInfos[i]._s = sm(isDetailed ? fileInfos[i].file.name : fileInfos[i].name); + } + } + + var sf = (this.server.options.filenameSortFunc || + ((x, y) => x.localeCompare(y)) + ); + fileInfos = fileInfos.sort((x, y) => { + if (this.server.options.filenameSortMap !== false) { + return sf(x._s, y._s); + } else if (isDetailed) { + return sf(x.file.name, y.file.name); + } else { + return sf(x.name, y.name); + } + }); + } + + this._listFiles(fileInfos, isDetailed, command); + }; + + if (this.server.options.hideDotFiles) { + files = files.filter((file) => ( + (file.name && file.name[0] !== '.') ? true : false + )); + } + + this._log(LOG.INFO, 'Directory has ' + files.length + ' files'); + if (files.length === 0) { + return this._listFiles([], isDetailed, command); + } + + var fileInfos; // To contain list of files with info for each. + + if (!isDetailed) { + // We're not doing a detailed listing, so we don't need to get username + // and group name. + fileInfos = files; + return finished(); + } + + // Now we need to get username and group name for each file from user/group ids. + fileInfos = []; + + var CONC = this.server.options.maxStatsAtOnce; + var j = 0; + for (var i = 0; i < files.length && i < CONC; ++i) { + handleFile(i); + } + j = --i; + + }, + this.server.options.noWildcards + ); + } + + __NLST(commandArg, command) { + this.__LIST(commandArg, command); + } + + __STAT(commandArg, command) { + if (commandArg) { + this.__LIST(commandArg, command); + } else { + this.respond('211 FTP Server Status OK'); + } + } + + // Create a directory + __MKD(pathRequest) { + var pathServer = withCwd(this.cwd, pathRequest); + var pathEscaped = pathEscape(pathServer); + var pathFs = pathModule.join(this.root, pathServer); + this.fs.mkdir(pathFs, 0o755, (err) => { + if (err) { + this._log(LOG.ERROR, 'MKD ' + pathRequest + ': ' + err); + this.respond('550 "' + pathEscaped + '" directory NOT created'); + } else { + this.respond('257 "' + pathEscaped + '" directory created'); + } + }); + return this; + } + + // Perform a no-op (used to keep-alive connection) + __NOOP() { + this.respond('200 OK'); + return this; + } + + __PORT(commandArg) { + if (this._hasReceivedPASV || this._hasReceivedPORT) { + this.respond('503 Bad sequence of commands.'); + return; + } + this._hasReceivedPORT = true; + let {host, port} = decodeAddress(commandArg) || {}; + if (host == null || port == null) { + this.respond('501 Bad argument to PORT'); + return; + } + this.dataHost = host; + this.dataPort = port; + this._log(LOG.DEBUG, `self.dataHost, self.dataPort set to ${host}:${port}`); + this.respond('200 OK'); + } + + __EPRT(commandArg, command) { + this.__PORT(commandArg, command); + } + + _getDataConnection() { + const DATA_CONNECTION_STATE = {}; + class DataConnection { + constructor(ftpConnection) { + this.state = DATA_CONNECTION_STATE.NOT_READY; + this.isActive = false; + this.isPassive = false; + this._ftpConnection = ftpConnection; + } + setActivePort(port) { + this.isActive = true; + this._activePort = port; + } + } + return new DataConnection(); + } + + __PASV(commandArg, command) { + if (this._hasReceivedPASV || this._hasReceivedPORT) { + this.respond('503 Bad sequence of commands.'); + return; + } + this._hasReceivedPASV = true; + let isExtendedPASV = (command === 'EPSV'); + this._log(LOG.DEBUG, 'Setting up listener for passive connections'); + this._isEstablishingDataConnection = true; + // TODO: find a better way to get the address on which to bind listener. + let host = this.server.host; + this.passiveListenerPool.createDataConnection( + host, + {useTLS: this.secure}, + (error, dataConnection) => { + if (error) { + this._isEstablishingDataConnection = false; + this._log(LOG.WARN, 'Error with passive data listener: ' + util.inspect(error)); + this.respond('421 Server was unable to open passive connection listener'); + return; + } + this.dataConnection = dataConnection; + var port = dataConnection.port; + this._log(LOG.DEBUG, `Listening for passive data connection on port ${port}`); + // Now that we're successfully listening, tell the client. + if (isExtendedPASV) { + this.respond(`229 Entering Extended Passive Mode (|||${port}|)`); + } else { + this.respond(`227 Entering Passive Mode (${encodeAddress(host, port)})`); + } + dataConnection.on('error', (error) => { + this._log(LOG.WARN, 'Error with passive data connection: ' + util.inspect(error)); + // TODO: handle error + }); + dataConnection.on('ready', (socket) => { + // TODO: reword + this._log(LOG.INFO, 'Passive data event: connect'); + this._onDataConnection(socket); + }); + dataConnection.on('close', () => { + // TODO: reword. + this._log(LOG.DEBUG, 'Passive data listener closed'); + }); + } + ); + } + + __EPSV(commandArg, command) { + if (commandArg && commandArg !== '1') { + this.respond('202 Not supported'); + } else { + this.__PASV(commandArg, command); + } + } + + __PBSZ(commandArg) { + if (!this.tlsOptions) { + return this.respond('202 Not supported'); + } + + // Protection Buffer Size (RFC 2228) + if (!this.secure) { + this.respond('503 Secure connection not established'); + } else if (parseInt(commandArg, 10) !== 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... + this.pbszReceived = true; + this.respond('200 buffer too big, PBSZ=0'); + } else { + this.pbszReceived = true; + this.respond('200 OK'); + } + } + + __PROT(commandArg) { + if (!this.tlsOptions) { + return this.respond('202 Not supported'); + } + + if (!this.pbszReceived) { + this.respond('503 No PBSZ command received'); + } else if (commandArg === 'S' || commandArg === 'E' || commandArg === 'C') { + this.respond('536 Not supported'); + } else if (commandArg === 'P') { + this.respond('200 OK'); + } else { + // Don't even recognize this one... + this.respond('504 Not recognized'); + } + } + + // Print the current working directory. + __PWD(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; + } + + __QUIT() { + this.hasQuit = true; + this.respond('221 Goodbye', (err) => { + if (err) { + this._log(LOG.ERROR, "Error writing 'Goodbye' message following QUIT"); + } + this._closeSocket(this.socket, true); + this._closeDataConnections(); + }); + } + + __RETR(commandArg) { + var filename = pathModule.join(this.root, withCwd(this.cwd, commandArg)); + + if (this.server.options.useReadFile) { + this._retrieveUsingReadFile(commandArg, filename); + } else { + this._retrieveUsingCreateReadStream(commandArg, filename); + } + } + + // Remove a directory + __RMD(pathRequest) { + var pathServer = withCwd(this.cwd, pathRequest); + var pathFs = pathModule.join(this.root, pathServer); + this.fs.rmdir(pathFs, (err) => { + if (err) { + this._log(LOG.ERROR, 'RMD ' + pathRequest + ': ' + err); + this.respond('550 Delete operation failed'); + } else { + this.respond('250 "' + pathServer + '" directory removed'); + } + }); + return this; + } + + __RNFR(commandArg) { + this.filefrom = withCwd(this.cwd, commandArg); + this._log(LOG.DEBUG, 'Rename from ' + this.filefrom); + this.respond('350 Ready for destination name'); + } + + __RNTO(commandArg) { + var fileto = withCwd(this.cwd, commandArg); + this.fs.rename(pathModule.join(this.root, this.filefrom), pathModule.join(this.root, fileto), (err) => { + if (err) { + this._log(LOG.ERROR, 'Error renaming file from ' + this.filefrom + ' to ' + fileto); + this.respond('550 Rename failed' + (err.code === 'ENOENT' ? '; file does not exist' : '')); + } else { + this.respond('250 File renamed successfully'); + } + }); + } + + __SIZE(commandArg) { + var filename = withCwd(this.cwd, commandArg); + this.fs.stat(pathModule.join(this.root, filename), (err, s) => { + if (err) { + this._log(LOG.ERROR, "Error getting size of file '" + filename + "' "); + this.respond('450 Failed to get size of file'); + return; + } + this.respond('213 ' + s.size + ''); + }); + } + + __TYPE(commandArg) { + if (commandArg === 'I' || commandArg === 'A') { + this.respond('200 OK'); + } else { + this.respond('202 Not supported'); + } + } + + __SYST() { + this.respond('215 UNIX Type: I'); + } + + __STOR(commandArg) { + var filename = withCwd(this.cwd, commandArg); + + if (this.server.options.useWriteFile) { + this._storeUsingWriteFile(filename, 'w'); + } else { + this._storeUsingCreateWriteStream(filename, 'w'); + } + } + + __APPE(commandArg) { + var filename = withCwd(this.cwd, commandArg); + if (this.server.options.useWriteFile) { + this._storeUsingWriteFile(filename, 'a'); + } else { + this._storeUsingCreateWriteStream(filename, 'a'); + } + } + + // Specify a username for login + __USER(username) { + if (this.server.options.tlsOnly && !this.secure) { + this.respond( + '530 This server does not permit login over a non-secure connection; ' + + 'connect using FTP-SSL with explicit AUTH TLS' + ); + } else { + this.emit( + 'command:user', + username, + // success callback + () => { + this.respond('331 User name okay, need password.'); + this.isWaitingForPassword = true; + }, + // failure callback + () => { + this.respond('530 Not logged in.'); + } + ); + } + return this; + } + + // Specify a password for login + __PASS(password) { + if (!this.isWaitingForPassword) { + this.respond('503 Bad sequence of commands.'); + } else { + this.isWaitingForPassword = false; + this.emit( + 'command:pass', + password, + // success callback + (username, userFsModule) => { + const panic = (error, method) => { + this._log(LOG.ERROR, method + ' signaled error ' + util.inspect(error)); + this.respond('421 Service not available, closing control connection.', () => { + this._closeSocket(this.socket, true); + }); + }; + const setCwd = (cwd) => { + const setRoot = (root) => { + this.root = root; + this.respond('230 User logged in, proceed.'); + }; + + this.cwd = cwd; + if (this.server.getRoot.length <= 1) { + setRoot(this.server.getRoot(this)); + } else { + this.server.getRoot(this, (err, root) => { + if (err) { + panic(err, 'getRoot'); + } else { + setRoot(root); + } + }); + } + }; + this.username = username; + this.fs = userFsModule || fsModule; + if (this.server.getInitialCwd.length <= 1) { + setCwd(withCwd(this.server.getInitialCwd(this))); + } else { + this.server.getInitialCwd(this, (err, cwd) => { + if (err) { + panic(err, 'getInitialCwd'); + } else { + setCwd(withCwd(cwd)); + } + }); + } + }, + // failure callback + () => { + this.respond('530 Not logged in.'); + this.username = null; + } + ); + } + return this; + } +} + +export default FtpConnection; diff --git a/src/FtpServer.js b/src/FtpServer.js new file mode 100644 index 0000000..3b57a9d --- /dev/null +++ b/src/FtpServer.js @@ -0,0 +1,153 @@ +import net from 'net'; +import events from 'events'; +import FtpConnection from './FtpConnection'; +import Constants from './Constants'; +import PassiveListenerPool from './PassiveListenerPool'; + +var {EventEmitter} = events; + +// Use LOG for brevity. +var LOG = Constants.LOG_LEVELS; +var DEFAULT_OPTIONS = { + logLevel: 0, + maxStatsAtOnce: 5, + uploadMaxSlurpSize: null, + getGroupFromGid: (gid, c) => { + c(null, 'ftp'); + }, + getUsernameFromUid: (uid, c) => { + c(null, 'ftp'); + }, +}; + +class FtpServer extends EventEmitter { + constructor(host, options) { + super(); + this.host = host; + options = Object.assign({}, DEFAULT_OPTIONS, options); + if (!options.getInitialCwd) { + throw new Error("'getInitialCwd' option of FtpServer must be set"); + } + if (!options.getRoot) { + throw new Error("'getRoot' option of FtpServer must be set"); + } + this.options = options; + this.getInitialCwd = options.getInitialCwd; + this.getRoot = options.getRoot; + this.getUsernameFromUid = options.getUsernameFromUid; + this.getGroupFromGid = options.getGroupFromGid; + this.useWriteFile = options.useWriteFile; + this.useReadFile = options.useReadFile; + this.server = net.createServer(); + this.server.on('connection', (socket) => { + this._onConnection(socket); + }); + this.server.on('error', (err) => { + this.emit('error', err); + }); + this.server.on('close', () => { + this.emit('close'); + }); + } + + _onConnection(socket) { + // build an index for the allowable commands for this server + var allowedCommands = null; + if (this.options.allowedCommands) { + allowedCommands = {}; + this.options.allowedCommands.forEach((c) => { + allowedCommands[c.trim().toUpperCase()] = true; + }); + } + + var conn = new FtpConnection({ + server: this, + socket: socket, + passiveListenerPool: this.passiveListenerPool, + // subset of allowed commands for this server + allowedCommands: allowedCommands, + tlsOptions: this.options.tlsOptions, + }); + + this.emit('client:connected', conn); // pass client info so they can listen for client-specific events + + socket.setTimeout(0); + socket.setNoDelay(); + + this._log(LOG.INFO, 'Accepted a new client connection'); + conn.respond('220 FTP server (nodeftpd) ready'); + + socket.on('data', (buf) => { + conn._onData(buf); + }); + socket.on('end', () => { + conn._onEnd(); + }); + socket.on('error', (err) => { + conn._onError(err); + }); + // `close` will always be called once (directly after `end` or `error`) + socket.on('close', (hadError) => { + conn._onClose(hadError); + }); + } + + _log(verbosity, message) { + if (verbosity > this.options.logLevel) { + return; + } + if (verbosity === LOG.ERROR) { + message = 'ERROR: ' + message; + } else if (verbosity === LOG.WARN) { + message = 'WARNING: ' + message; + } + console.log(message); + var isError = (verbosity === LOG.ERROR); + if (isError && this.options.logLevel === LOG.TRACE) { + console.trace('Trace follows'); + } + } + + _getHost(arg1, arg2) { + let host; + let NUMERIC = /^[0-9]+$/; + let isObject = (arg1 === Object(arg1)); + let isNumeric = (typeof arg1 === 'number' || (typeof arg1 === 'string' || NUMERIC.test(arg1))); + if (isObject) { + host = arg1.host; + } else if (isNumeric && typeof arg2 === 'string') { + host = arg2; + } + if (host == null) { + host = '127.0.0.1'; + } + return host; + } + + listen(port, ...args) { + let NUMERIC = /^[0-9]+$/; + if (typeof port === 'string' && NUMERIC.test(port)) { + port = parseInt(port, 10); + } + if (typeof port !== 'number') { + throw new Error('Must specify port'); + } + let {host} = this; + let callback = (typeof args[args.length] === 'function') ? args.pop() : null; + this.server.listen(port, host, callback); + let {options} = this; + this.passiveListenerPool = new PassiveListenerPool({ + bindAddress: host, + portRange: [options.pasvPortRangeStart, options.pasvPortRangeEnd], + logger: (verbosity, message) => { + this._log(verbosity, '[PASV Listener Pool] >> ' + message); + }, + }); + } + + close() { + this.server.close(...arguments); + } +} + +export default FtpServer; diff --git a/src/PassiveListenerPool.js b/src/PassiveListenerPool.js new file mode 100644 index 0000000..8608aa2 --- /dev/null +++ b/src/PassiveListenerPool.js @@ -0,0 +1,321 @@ +/* @//flow */ + +import net from 'net'; +import {EventEmitter} from 'events'; +import Constants from './Constants'; + +import starttls from './starttls'; + +const LOG = Constants.LOG_LEVELS; + +const DEFAULT_OPTIONS = { + BIND_ADDRESS: '0.0.0.0', + MIN_PORT: 44001, + MAX_PORT: 44010, +}; + +// Maximum time we will wait for client to connect after PASV command. +const WAIT_TIMEOUT = 9000; + +export const CONNECTION_STATE = { + WAITING: 0, // Listener is waiting for client to connect (initial state). + INITIALIZING_TLS: 1, // Client is connected but we are negotiating TLS. + READY: 2, // Client is connected and socket is ready. + CLOSED: 3, // Connection is closed (error or normal connection end). +}; + +export const LISTENER_STATE = { + INITIALIZING: 0, // Initial state. + LISTENING: 1, // Listener is waiting for client to connect. + CLOSED: 2, // Listener has stopped listening (connections may still exist). +}; + +// For use constructing an error using `new Error()` +let listenError = (errorCode, address, port) => ({ + message: `listen ${errorCode} ${address}:${port}`, + props: {code: errorCode, address, port}, +}); + +export class PassiveDataConnection extends EventEmitter { + constructor(port, remoteAddress, options, logger) { + super(); + // It's important to store the listening port here so the control connection + // can send: 227 Entering Passive Mode (,) + this.port = port; + this.remoteAddress = remoteAddress; + this.state = CONNECTION_STATE.WAITING; + this._log = logger; + this._useTLS = options.useTLS; + this._socket = null; + this._timer = setTimeout(() => { + this._onError( + new Error(`Expected a connection within ${WAIT_TIMEOUT}ms`) + ); + }, WAIT_TIMEOUT); + // Auto-bind methods. + this._onError = this._onError.bind(this); + this._close = this._close.bind(this); + } + + getSocket() { + return this._socket; + } + + // This is not really a public method, except for use from the code that + // created this instance (Listener). + setSocket(socket) { + if (this._socket) { + throw new Error('PassiveDataConnection: method setSocket() called more than once.'); + } + clearTimeout(this._timer); + if (!this._useTLS) { + this._socket = socket; + this.state = CONNECTION_STATE.READY; + socket.on('error', this._onError); + socket.on('close', this._close); + this.emit('ready', socket); + return; + } + this.state = CONNECTION_STATE.INITIALIZING_TLS; + this._upgradeConnection(socket, (error, cleartext) => { + this._socket = cleartext; + this.state = CONNECTION_STATE.READY; + cleartext.on('error', this._onError); + cleartext.on('close', this._close); + this.emit('ready', cleartext); + }); + } + + _upgradeConnection(rawSocket, callback) { + this._log(LOG.INFO, 'Upgrading connection to TLS'); + let {tlsOptions} = this.options; + starttls.starttlsServer(rawSocket, tlsOptions, (error, cleartext) => { + if (error) { + this._log(LOG.ERROR, 'Error upgrading connection to TLS', error); + this._closeSocket(rawSocket, true); + callback(error); + return; + } + if (cleartext.authorized || this.options.allowUnauthorizedTls) { + this._log(LOG.INFO, 'Allowing unauthorized connection (allowUnauthorizedTls is on)'); + this._log(LOG.INFO, 'Connection secured'); + callback(null, cleartext); + } else { + this._log(LOG.INFO, 'Closing unauthorized connection (allowUnauthorizedTls is off)'); + this._closeSocket(rawSocket, true); + } + }); + } + + destroy() { + if (this._socket) { + this._socket.destroy(); // Will automatically emit `close`; + } else { + this._close(); + } + } + + _onError(error) { + this.emit('error', error); + process.nextTick(this._close); + } + + _close() { + if (this.state === CONNECTION_STATE.CLOSED) { + return; + } + this.state = CONNECTION_STATE.CLOSED; + this.emit('close'); + } +} + +export class Listener extends EventEmitter { + constructor(port, bindAddress, logger) { + super(); + this.port = port; + this.bindAddress = bindAddress; + this._log = logger; + this.state = LISTENER_STATE.CLOSED; + this._waitingConnections = new Map(); + this._allConnections = new Set(); + // Auto-bind methods. + this._onStartingError = this._onStartingError.bind(this); + this._onRunningError = this._onRunningError.bind(this); + this._onReady = this._onReady.bind(this); + this._onConnection = this._onConnection.bind(this); + } + + listenForClient(remoteAddress, options) { + let {bindAddress, port} = this; + let key = port + '|' + remoteAddress; + let connection = new PassiveDataConnection( + port, + remoteAddress, + options, + this._log + ); + if (this._waitingConnections.has(key)) { + // We cannot simultaneously have more than one waitingConnection for the + // same remote address or it would create ambiguity (we wouldn't know + // which instance to associate the incoming connection with). Treat this + // as an EADDRINUSE error to force the calling function to try another + // port. + process.nextTick(() => { + let {message, props} = listenError('EADDRINUSE', bindAddress, port); + connection.emit( + 'listenerError', + Object.assign(new Error(message), props) + ); + }); + return connection; + } + this._allConnections.add(connection); + this._waitingConnections.set(key, connection); + // `close` will be emitted when: + // * Client doesn't connect within wait time. + // * Client connects and transfer is completed successfully. + // * Client connects and some error occurs. + // but it will *not* be emitted when: + // * Listener fails to bind to bindAddress. + connection.on('close', () => { + this._allConnections.delete(connection); + this._waitingConnections.delete(key); + this._stopIfDone(); + }); + // If we're already listening, emit listenerReady on next tick. + // If we're not yet listening, start the listening server now. + // If we're in the process of starting the listening server (INITIALIZING) + // then do nothing since we will emit listenerReady when necessary. + if (this.state === LISTENER_STATE.LISTENING) { + process.nextTick(() => connection.emit('listenerReady')); + } else if (this.state === LISTENER_STATE.CLOSED) { + this._startServer(); + } + return connection; + } + + _onStartingError(error) { + for (let [key, connection] of this._waitingConnections.entries()) { + connection.emit('listenerError', error); + this._waitingConnections.delete(key); + } + this.state = LISTENER_STATE.CLOSED; + } + + _onRunningError(error) { + this._stopServer(); + this.state = LISTENER_STATE.CLOSED; + this.emit('error', error); + } + + _onReady(...args) { + this._server.removeListener('error', this._onStartingError); + this._server.on('error', this._onRunningError); + + this.state = LISTENER_STATE.LISTENING; + this.emit('listening', ...args); + for (let connection of this._waitingConnections.values()) { + connection.emit('listenerReady'); + } + } + + _onConnection(socket) { + let remoteAddress = socket.address().address; + if (remoteAddress.indexOf(':') !== -1) { + remoteAddress = remoteAddress.split(':').pop(); + } + let key = this.port + '|' + remoteAddress; + let connection = this._waitingConnections.get(key); + if (connection == null) { + socket.destroy(); + return; + } + this._waitingConnections.delete(key); + connection.setSocket(socket); + } + + _stopIfDone() { + if (this._waitingConnections.size !== 0) { + return; + } + this._stop(); + } + + // It's safe to call _stop() multiple times. + _stop() { + if (this._stopping) { + return; + } + if (this.state === LISTENER_STATE.INITIALIZING) { + this._stopping = true; + this.on('listening', () => { + this._stopping = false; + this._stop(); + }); + return; + } + if (this.state === LISTENER_STATE.LISTENING) { + this._stopServer(); + } + this.state = LISTENER_STATE.CLOSED; + } + + _startServer() { + this._server = net.createServer(); + this._server.on('error', this._onStartingError); + this._server.on('listening', this._onReady); + this._server.on('connection', this._onConnection); + this._server.listen(this.port, this.bindAddress); + this.state = LISTENER_STATE.INITIALIZING; + } + + _stopServer() { + this._server.removeListener('error', this._onStartingError); + this._server.removeListener('error', this._onRunningError); + this._server.removeListener('listening', this._onReady); + this._server.removeListener('connection', this._onConnection); + this._server.close(); + this._server = null; + } +} + +export default class PassiveListenerPool extends EventEmitter { + constructor(options = {}) { + super(); + this._bindAddress = options.bindAddress || DEFAULT_OPTIONS.BIND_ADDRESS; + let portRange = options.portRange || []; + this._minPort = portRange[0] || DEFAULT_OPTIONS.MIN_PORT; + this._maxPort = portRange[1] || DEFAULT_OPTIONS.MAX_PORT; + this._log = options.logger || () => {}; + this._listeners = new Map(); + } + + createDataConnection(remoteAddress, options, callback) { + let port = this._minPort; + let dataConnection; + let onError = (error) => { + if (error.code === 'EADDRINUSE' && port < this._maxPort) { + port += 1; + startListener(); + } else { + callback(error); + } + }; + let onSuccess = () => { + dataConnection.removeListener('listenerError', onError); + dataConnection.removeListener('listenerReady', onSuccess); + callback(null, dataConnection); + }; + let startListener = () => { + let listener = this._listeners.get(port); + if (listener == null) { + listener = new Listener(port, this._bindAddress, this._log); + this._listeners.set(port, listener); + } + dataConnection = listener.listenForClient(remoteAddress, options); + dataConnection.on('listenerError', onError); + dataConnection.on('listenerReady', onSuccess); + }; + startListener(); + } +} diff --git a/test/.eslintrc b/src/__tests__/.eslintrc similarity index 100% rename from test/.eslintrc rename to src/__tests__/.eslintrc diff --git a/src/__tests__/PassiveListenerPool-test.js b/src/__tests__/PassiveListenerPool-test.js new file mode 100644 index 0000000..5d3793b --- /dev/null +++ b/src/__tests__/PassiveListenerPool-test.js @@ -0,0 +1,104 @@ +/*eslint-env node, mocha */ +import net from 'net'; +import {EventEmitter} from 'events'; +import PassiveListenerPool from '../PassiveListenerPool'; +import toBase256 from '../helpers/toBase256'; +import expect from 'expect'; + +const BIND_ADDRESS = '127.0.0.1'; + +class MockControlConnection extends EventEmitter { + constructor({remoteAddress}) { + super(); + this.remoteAddress = remoteAddress; + this.messages = []; + this.writable = false; + process.nextTick(() => { + this.writable = true; + }); + } + + respond(message) { + this.messages.push(message); + } + + address() { + return {address: this.remoteAddress}; + } + + destroy() { + this.writable = false; + this.emit('close'); + } +} + +const getMessageOK = (address, port) => { + let encoded = address.split('.').join(',') + ',' + toBase256(port).join(','); + return `227 Entering Passive Mode (${encoded})`; +}; + +describe('PassiveListenerPool', () => { + let MIN_PORT = 2000; + let MAX_PORT = 2002; + let listenerPool = new PassiveListenerPool({ + bindAddress: BIND_ADDRESS, + portRange: [MIN_PORT, MAX_PORT], + }); + + it('should listen on a port after a connection is requested', (done) => { + let eventLog = []; + let log = (message) => { + eventLog.push(message); + }; + let finished = () => { + expect(eventLog).toEqual([ + 'listening on port 2000', + 'data connection received on listening port', + 'data connection closed', + ]); + done(); + }; + let controlConnection = new MockControlConnection({ + remoteAddress: '127.0.0.1', + }); + let remoteAddress = controlConnection.address().address; + let options = {secure: false}; + listenerPool.createDataConnection(remoteAddress, options, (error, dataConnection) => { + let {port} = dataConnection; + log(`listening on port ${port}`); + expect(port).toBe(MIN_PORT); + // TODO: test that listenerPool has stopped listening? + controlConnection.respond( + getMessageOK(BIND_ADDRESS, port) + ); + // TODO: This test is useless besides testing our mock control connection. + expect(controlConnection.messages).toEqual([ + getMessageOK(BIND_ADDRESS, port), + ]); + // TODO: Ensure that if the controlConnection closes, our associated data + // connection gets closed also. + // controlConnection.on('close', () => { + // dataConnection.destroy(); + // }); + dataConnection.on('error', (error) => { + throw error; + }); + dataConnection.on('ready', (socket) => { + log('data connection received on listening port'); + expect(socket.writable).toBe(true); + }); + dataConnection.on('close', () => { + log('data connection closed'); + finished(); + }); + let clientConnection = net.createConnection(port, BIND_ADDRESS); + clientConnection.on('connect', () => { + setTimeout(() => { + // This will cause a close event on dataConnection. + clientConnection.destroy(); + }, 10); + }); + }); + }); + +}); diff --git a/test/acct.js b/src/__tests__/acct.js similarity index 57% rename from test/acct.js rename to src/__tests__/acct.js index ad261cf..89c23a9 100644 --- a/test/acct.js +++ b/src/__tests__/acct.js @@ -1,25 +1,23 @@ var common = require('./lib/common'); -describe('ACCT command', function() { - 'use strict'; - +describe('ACCT command', () => { var client; var server; - beforeEach(function(done) { + beforeEach((done) => { server = common.server(); client = common.client(done); }); - it('should reply 202', function(done) { - client.execute('ACCT', function(error, reply) { + it('should reply 202', (done) => { + client.execute('ACCT', (error, reply) => { common.should.not.exist(error); reply.code.should.equal(202); done(); }); }); - afterEach(function() { + afterEach(() => { server.close(); }); }); diff --git a/test/allo.js b/src/__tests__/allo.js similarity index 57% rename from test/allo.js rename to src/__tests__/allo.js index 0014819..fbafab6 100644 --- a/test/allo.js +++ b/src/__tests__/allo.js @@ -1,25 +1,23 @@ var common = require('./lib/common'); -describe('ALLO command', function() { - 'use strict'; - +describe('ALLO command', () => { var client; var server; - beforeEach(function(done) { + beforeEach((done) => { server = common.server(); client = common.client(done); }); - it('should reply 202', function(done) { - client.execute('ALLO', function(error, reply) { + it('should reply 202', (done) => { + client.execute('ALLO', (error, reply) => { common.should.not.exist(error); reply.code.should.equal(202); done(); }); }); - afterEach(function() { + afterEach(() => { server.close(); }); }); diff --git a/src/__tests__/appe.js b/src/__tests__/appe.js new file mode 100644 index 0000000..7bed32d --- /dev/null +++ b/src/__tests__/appe.js @@ -0,0 +1,92 @@ +var common = require('./lib/common'); +var FtpClient = require('ftp'); +var path = require('path'); +var fs = require('fs'); + +describe('APPE command', () => { + var client = new FtpClient(); + var server; + var fileToUpload = __filename; + var savedFileName = path.basename(fileToUpload) + '.test.txt'; + var savedFilePublicPath = '/uploads/' + savedFileName; + var savedFilePrivatePath = path.join( + common.fixturesPath(), + common.defaultOptions().user, + 'uploads', + savedFileName + ); + + //run tests with various option combinations + let optionCombinations = [ + {useWriteFile: false}, + {useWriteFile: true}, + // This will cause writeFile to fall back to using a stream. + {useWriteFile: true, uploadMaxSlurpSize: 1}, + ]; + optionCombinations.forEach((options) => { + + describe('with ' + JSON.stringify(options), () => { + + beforeEach((done) => { + server = common.server(options); + client.once('ready', () => { + done(); + }); + let {host, port, user, pass} = common.defaultOptions(); + client.connect({host, port, user, password: pass}); + }); + + it('should append data to existing file', (done) => { + var removeFile = (callback) => { + fs.unlink(savedFilePrivatePath, (error) => { + common.should.not.exist(error); + callback(); + }); + }; + + var doTest = () => { + fs.stat(fileToUpload, (error, stat) => { + common.should.not.exist(error); + var fileSize = stat.size; + client.put(fileToUpload, savedFilePublicPath, (error) => { + common.should.not.exist(error); + fs.stat(savedFilePrivatePath, (error, stat) => { + common.should.not.exist(error); + stat.size.should.eql(fileSize); + client.append(fileToUpload, savedFilePublicPath, (error) => { + common.should.not.exist(error); + fs.stat(savedFilePrivatePath, (error, stat) => { + common.should.not.exist(error); + var newSize = stat.size; + newSize.should.eql(fileSize * 2); + removeFile(done); + }); + }); + }); + }); + }); + }; + + fs.stat(savedFilePrivatePath, (error) => { + if (error) { + if (error.code !== 'ENOENT') { + throw error; + } + // The file doesn't exist. This is expected. Proceed with test. + doTest(); + } else { + // The file exists. Delete it and then proceed with test. + removeFile(doTest); + } + }); + }); + + afterEach(() => { + server.close(); + }); + + }); + + }); + +}); diff --git a/test/cwd-cdup.js b/src/__tests__/cwd-cdup.js similarity index 61% rename from test/cwd-cdup.js rename to src/__tests__/cwd-cdup.js index 3a6d9c0..2a449d7 100644 --- a/test/cwd-cdup.js +++ b/src/__tests__/cwd-cdup.js @@ -1,8 +1,6 @@ var common = require('./lib/common'); -describe('CWD/CDUP commands', function() { - 'use strict'; - +describe('CWD/CDUP commands', () => { var client; var server; var pathExisting = 'usr/local'; @@ -19,17 +17,17 @@ describe('CWD/CDUP commands', function() { return text; } - beforeEach(function(done) { + beforeEach((done) => { server = common.server(); client = common.client(done); }); - describe('CWD command', function() { - it('should change to existing directory', function(done) { - client.raw('CWD', pathExisting, function(error, response) { + describe('CWD command', () => { + it('should change to existing directory', (done) => { + client.raw('CWD', pathExisting, (error, response) => { var pathCwd = pathExtract(response); response.code.should.equal(250); - client.raw('PWD', function(error, response) { + client.raw('PWD', (error, response) => { var pathPwd = pathExtract(response); response.code.should.equal(257); pathPwd.should.equal(pathCwd); @@ -38,35 +36,35 @@ describe('CWD/CDUP commands', function() { }); }); - it('should not change to non-existent directory', function(done) { - client.raw('CWD', pathExisting, function(error, response) { + it('should not change to non-existent directory', (done) => { + client.raw('CWD', pathExisting, (error, response) => { response.code.should.equal(250); server.suppressExpecteErrMsgs.push( /^CWD \S+: Error: ENOENT/ ); - client.raw('CWD', pathExisting, function(error) { + client.raw('CWD', pathExisting, (error) => { error.code.should.equal(550); done(); }); }); }); - it('should not change to regular file', function(done) { - client.raw('CWD', pathFile, function(error) { + it('should not change to regular file', (done) => { + client.raw('CWD', pathFile, (error) => { error.code.should.equal(550); done(); }); }); - it('should escape quotation marks', function(done) { - client.raw('MKD', pathWithQuotes, function(error, response) { + it('should escape quotation marks', (done) => { + client.raw('MKD', pathWithQuotes, (error, response) => { var pathEscaped = pathEscape(pathWithQuotes); var pathMkd = pathExtract(response); pathMkd.should.equal(pathEscaped); - client.raw('CWD', pathWithQuotes, function(error, response) { + client.raw('CWD', pathWithQuotes, (error, response) => { var pathCwd = pathExtract(response); pathCwd.should.equal(pathEscaped); - client.raw('PWD', function(error, response) { + client.raw('PWD', (error, response) => { var pathPwd = pathExtract(response); pathPwd.should.equal(pathEscaped); client.raw('RMD', pathWithQuotes); @@ -77,14 +75,14 @@ describe('CWD/CDUP commands', function() { }); }); - describe('CDUP command', function() { - it('should change to parent directory', function(done) { - client.raw('CWD', pathExisting, function(error, response) { + describe('CDUP command', () => { + it('should change to parent directory', (done) => { + client.raw('CWD', pathExisting, (error, response) => { response.code.should.equal(250); - client.raw('CDUP', function(error, response) { + client.raw('CDUP', (error, response) => { var pathCdup = pathExtract(response); response.code.should.equal(250); - client.raw('PWD', function(error, response) { + client.raw('PWD', (error, response) => { var pathPwd = pathExtract(response); response.code.should.equal(257); pathCdup.should.equal(pathPwd); @@ -95,7 +93,7 @@ describe('CWD/CDUP commands', function() { }); }); - afterEach(function() { + afterEach(() => { server.close(); }); }); diff --git a/test/glob.js b/src/__tests__/glob.js similarity index 90% rename from test/glob.js rename to src/__tests__/glob.js index 1af6d30..69cdd66 100644 --- a/test/glob.js +++ b/src/__tests__/glob.js @@ -1,10 +1,10 @@ -var glob = require('../lib/glob'); +var glob = require('../glob'); var assert = require('assert'); var matchPattern = glob.matchPattern; -describe('glob.matchPattern', function() { - it('should match ? and * glob characters', function() { +describe('glob.matchPattern', () => { + it('should match ? and * glob characters', () => { assert.equal(matchPattern('foo*', 'foooxx'), true); assert.equal(matchPattern('foo*', 'foo'), true); assert.equal(matchPattern('foo*', 'fo'), false); diff --git a/test/init.js b/src/__tests__/init.js similarity index 64% rename from test/init.js rename to src/__tests__/init.js index 2a20262..c13d7af 100644 --- a/test/init.js +++ b/src/__tests__/init.js @@ -1,9 +1,7 @@ var common = require('./lib/common'); var Client = require('jsftp'); -describe('initialization', function() { - 'use strict'; - +describe('initialization', () => { var client; var server; var options = { @@ -13,44 +11,44 @@ describe('initialization', function() { pass: 'esoj', }; - beforeEach(function(done) { + beforeEach((done) => { done(); }); - it('should getRoot synchronously', function(done) { + it('should getRoot synchronously', (done) => { server = common.server({ - getRoot: function() { + getRoot: () => { return '../fixture/'; }, }); client = common.client(done); }); - it('should getRoot asynchronously', function(done) { + it('should getRoot asynchronously', (done) => { server = common.server({ - getRoot: function(connection, callback) { + getRoot: (connection, callback) => { callback(null, '../fixture/'); }, }); client = common.client(done); }); - it('should bail if getRoot fails', function(done) { + it('should bail if getRoot fails', (done) => { server = common.server({ - getRoot: function(connection, callback) { + getRoot: (connection, callback) => { server.suppressExpecteErrMsgs.push( 'getRoot signaled error [Error: intentional failure]'); callback(new Error('intentional failure')); }, }); client = new Client(options); - client.auth(options.user, options.pass, function(error) { + client.auth(options.user, options.pass, (error) => { error.code.should.eql(421); done(); }); }); - it('should throw if getRoot is null', function(done) { + it('should throw if getRoot is null', (done) => { var fail = false; try { server = common.server({ @@ -64,40 +62,40 @@ describe('initialization', function() { done(); }); - it('should getInitialCwd synchronously', function(done) { + it('should getInitialCwd synchronously', (done) => { server = common.server({ - getInitialCwd: function() { + getInitialCwd: () => { return '/'; }, }); client = common.client(done); }); - it('should getInitialCwd asynchronously', function(done) { + it('should getInitialCwd asynchronously', (done) => { server = common.server({ - getInitialCwd: function(connection, callback) { + getInitialCwd: (connection, callback) => { callback(null, '/'); }, }); client = common.client(done); }); - it('should bail if getInitialCwd fails', function(done) { + it('should bail if getInitialCwd fails', (done) => { server = common.server({ - getInitialCwd: function(connection, callback) { + getInitialCwd: (connection, callback) => { server.suppressExpecteErrMsgs.push( 'getInitialCwd signaled error [Error: intentional failure]'); callback(new Error('intentional failure')); }, }); client = new Client(options); - client.auth(options.user, options.pass, function(error) { + client.auth(options.user, options.pass, (error) => { error.code.should.eql(421); done(); }); }); - it('should throw if getInitialCwd is null', function(done) { + it('should throw if getInitialCwd is null', (done) => { var fail = false; try { server = common.server({ @@ -111,7 +109,7 @@ describe('initialization', function() { done(); }); - afterEach(function() { + afterEach(() => { server.close(); }); }); diff --git a/test/lib/common.js b/src/__tests__/lib/common.js similarity index 70% rename from test/lib/common.js rename to src/__tests__/lib/common.js index e41847b..2e619bf 100644 --- a/test/lib/common.js +++ b/src/__tests__/lib/common.js @@ -1,30 +1,29 @@ -'use strict'; +import path from 'path'; +import util from 'util'; +import fs from 'fs'; +import Server from '../../FtpServer'; +import Constants from '../../Constants'; +import Client from 'jsftp'; +import should from 'should'; -var path = require('path'); -var util = require('util'); -var fs = require('fs'); -var ftpd = require('../../'); -var Client = require('jsftp'); -var should = require('should'); - -var Server = ftpd.FtpServer; -var LogLevels = ftpd.LOG_LEVELS; -var LogLevelNames = Object.keys(LogLevels).reduce(function(map, name) { - var value = LogLevels[name]; +var {LOG_LEVELS} = Constants; +// TODO: replace this stuff with github.com/rauschma/enumify +var LogLevelNames = Object.keys(LOG_LEVELS).reduce((map, name) => { + var value = LOG_LEVELS[name]; map[value] = name; return map; }, {}); -var fixturesPath = path.join(__dirname, '../../fixture'); +var fixturesPath = path.join(__dirname, '../../../fixture'); -function toString(value) { +const toString = (value) => { var isPrimitive = Object(value) !== value; if (isPrimitive) { return JSON.stringify(value); } else { return ('toString' in value) ? value.toString() : Object.prototype.toString(value); } -} +}; var options = { host: process.env.IP || '127.0.0.1', @@ -32,38 +31,38 @@ var options = { user: 'jose', pass: 'esoj', tlsOnly: false, - getInitialCwd: function() { + getInitialCwd() { return options.cwd; }, - getRoot: function(connection, callback) { + getRoot(connection, callback) { var username = connection.username; var root = path.join(fixturesPath, username); fs.realpath(root, callback); }, }; -var common = module.exports = { +const common = { should: should, - fixturesPath: function() { + fixturesPath() { return fixturesPath; }, - defaultOptions: function() { + defaultOptions() { return options; }, - server: function(customOptions) { + server(customOptions) { customOptions = customOptions || {}; - Object.keys(options).forEach(function(key) { + Object.keys(options).forEach((key) => { if (!customOptions.hasOwnProperty(key)) { customOptions[key] = options[key]; } }); var server = new Server(customOptions.host, customOptions); - server.on('client:connected', function(connection) { + server.on('client:connected', (connection) => { var username; - connection.on('command:user', function(user, success, failure) { + connection.on('command:user', (user, success, failure) => { if (user === customOptions.user) { username = user; success(); @@ -71,7 +70,7 @@ var common = module.exports = { failure(); } }); - connection.on('command:pass', function(pass, success, failure) { + connection.on('command:pass', (pass, success, failure) => { if (pass === customOptions.pass) { success(username); } else { @@ -79,12 +78,15 @@ var common = module.exports = { } }); }); - var origLogIf = server._logIf; + var _log = server._log; server.suppressExpecteErrMsgs = []; - server._logIf = function logIfNotExpected(verbosity, message, conn) { + server._log = (...args) => { + var verbosity = args[0]; + // Remove the <0.0.0.0> prefix. + var message = args[1].replace(/^<.+?> /, ''); var expecteErrMsgs = server.suppressExpecteErrMsgs; message = String(message).split(fixturesPath).join('fixture:/'); - if ((expecteErrMsgs.length > 0) && (verbosity < LogLevels.LOG_INFO)) { + if ((expecteErrMsgs.length > 0) && (verbosity < LOG_LEVELS.INFO)) { var expected = expecteErrMsgs.shift(); if (message === expected) { return; @@ -105,15 +107,15 @@ var common = module.exports = { ); } } - return origLogIf.call(this, verbosity, message, conn); + return _log.apply(server, args); }; server.listen(customOptions.port); return server; }, - client: function(done, customOptions) { + client(done, customOptions) { customOptions = customOptions || {}; - Object.keys(options).forEach(function(key) { + Object.keys(options).forEach((key) => { if (!customOptions.hasOwnProperty(key)) { customOptions[key] = options[key]; } @@ -125,7 +127,7 @@ var common = module.exports = { client.auth( customOptions.user, customOptions.pass, - function(error, response) { + (error, response) => { should.not.exist(error); should.exist(response); response.should.have.property('code', 230); @@ -134,9 +136,9 @@ var common = module.exports = { ); return client; }, - genFilterFuncFrom: function(filter) { + genFilterFuncFrom(filter) { if (!filter) { - return function() { + return () => { return true; }; } @@ -144,7 +146,7 @@ var common = module.exports = { return filter; } if ((typeof filter) === 'string') { - return function(item) { + return (item) => { return String(item).indexOf(filter) !== -1; }; } @@ -154,7 +156,7 @@ var common = module.exports = { } throw new Error('unsupported filter precursor: ' + util.inspect(filter)); }, - splitResponseLines: function(resp, filter) { + splitResponseLines(resp, filter) { var respType = typeof resp; respType.should.equal('string'); resp = String(resp); @@ -169,3 +171,5 @@ var common = module.exports = { return resp; }, }; + +module.exports = common; diff --git a/test/list.js b/src/__tests__/list.js similarity index 72% rename from test/list.js rename to src/__tests__/list.js index 31e0773..b8a8ecd 100644 --- a/test/list.js +++ b/src/__tests__/list.js @@ -1,12 +1,10 @@ var common = require('./lib/common'); -describe('LIST command', function() { - 'use strict'; - +describe('LIST command', () => { var client; var server; - beforeEach(function(done) { + beforeEach((done) => { server = common.server(); client = common.client(done); }); @@ -15,8 +13,8 @@ describe('LIST command', function() { return String(rgx).replace(/^\/|\/$/g, ''); } - it('should return "-" as first character for files', function(done) { - client.list('/', function(error, listing) { + it('should return "-" as first character for files', (done) => { + client.list('/', (error, listing) => { error.should.equal(false); listing = common.splitResponseLines(listing, / data\d*\.txt$/); listing.should.have.lengthOf(6); @@ -25,8 +23,8 @@ describe('LIST command', function() { }); }); - it('should return "d" as first character for directories', function(done) { - client.list('/', function(error, listing) { + it('should return "d" as first character for directories', (done) => { + client.list('/', (error, listing) => { error.should.equal(false); listing = common.splitResponseLines(listing, / usr$/); listing.should.have.lengthOf(1); @@ -35,14 +33,14 @@ describe('LIST command', function() { }); }); - it('should list files similar to ls -l', function(done) { - client.list('/usr', function(error, listing) { + it('should list files similar to ls -l', (done) => { + client.list('/usr', (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+/, + /($# ?�?�? inodes?: |)\d+/, /($# owner name: ___|)\S+/, /($# owner group: __|)\S+/, /($# size in bytes: |)\d+/, @@ -58,9 +56,9 @@ describe('LIST command', function() { }); }); - it('should list a single file', function(done) { + it('should list a single file', (done) => { var filename = 'data.txt'; - client.list('/' + filename, function(error, listing) { + client.list('/' + filename, (error, listing) => { error.should.equal(false); listing = common.splitResponseLines(listing, ' ' + filename); listing.should.have.lengthOf(1); @@ -69,8 +67,8 @@ describe('LIST command', function() { }); }); - it('should list a subdirectory', function(done) { - client.list('/usr', function(error, listing) { + it('should list a subdirectory', (done) => { + client.list('/usr', (error, listing) => { error.should.equal(false); listing = common.splitResponseLines(listing); listing.should.have.lengthOf(1); @@ -80,7 +78,7 @@ describe('LIST command', function() { }); }); - afterEach(function() { + afterEach(() => { server.close(); }); }); diff --git a/src/__tests__/mdtm.js b/src/__tests__/mdtm.js new file mode 100644 index 0000000..9a8236a --- /dev/null +++ b/src/__tests__/mdtm.js @@ -0,0 +1,30 @@ +var common = require('./lib/common'); + +describe('MDTM command', () => { + var client; + var server; + + beforeEach((done) => { + server = common.server(); + client = common.client(done); + }); + + it('should respond 213 for a valid file', (done) => { + client.raw('MDTM', '/data.txt', (error, response) => { + common.should.not.exist(error); + response.text.should.match(/^213 [0-9]{14}$/); + done(); + }); + }); + + it('should respond 550 for an invalid file', (done) => { + client.raw('MDTM', '/data-something.txt', (error) => { + error.code.should.equal(550); + done(); + }); + }); + + afterEach(() => { + server.close(); + }); +}); diff --git a/test/mkd-rmd.js b/src/__tests__/mkd-rmd.js similarity index 52% rename from test/mkd-rmd.js rename to src/__tests__/mkd-rmd.js index 0dee870..200c00e 100644 --- a/test/mkd-rmd.js +++ b/src/__tests__/mkd-rmd.js @@ -1,57 +1,55 @@ var common = require('./lib/common'); -describe('MKD/RMD commands', function() { - 'use strict'; - +describe('MKD/RMD commands', () => { var client; var server; var directory = '/testdir'; - beforeEach(function(done) { + beforeEach((done) => { server = common.server(); client = common.client(done); }); - describe('MKD command', function() { - it('should create a new directory', function(done) { - client.raw('MKD', directory, function(error, response) { + describe('MKD command', () => { + it('should create a new directory', (done) => { + client.raw('MKD', directory, (error, response) => { common.should.not.exist(error); response.text.should.startWith(257); done(); }); }); - it('should not create a duplicate directory', function(done) { + it('should not create a duplicate directory', (done) => { server.suppressExpecteErrMsgs.push( /^MKD \S+: Error: EEXIST/ ); - client.raw('MKD', directory, function(error) { + client.raw('MKD', directory, (error) => { error.code.should.equal(550); done(); }); }); }); - describe('RMD command', function() { - it('should delete an existing directory', function(done) { - client.raw('RMD', directory, function(error, response) { + describe('RMD command', () => { + it('should delete an existing directory', (done) => { + client.raw('RMD', directory, (error, response) => { common.should.not.exist(error); response.text.should.startWith(250); done(); }); }); - it('should not delete a non-existent directory', function(done) { + it('should not delete a non-existent directory', (done) => { server.suppressExpecteErrMsgs.push( /^RMD \S+: Error: ENOENT/); - client.raw('RMD', directory, function(error) { + client.raw('RMD', directory, (error) => { error.code.should.equal(550); done(); }); }); }); - afterEach(function() { + afterEach(() => { server.close(); }); }); diff --git a/test/noop.js b/src/__tests__/noop.js similarity index 56% rename from test/noop.js rename to src/__tests__/noop.js index bdc6a44..1d920ee 100644 --- a/test/noop.js +++ b/src/__tests__/noop.js @@ -1,25 +1,23 @@ var common = require('./lib/common'); -describe('NOOP command', function() { - 'use strict'; - +describe('NOOP command', () => { var client; var server; - beforeEach(function(done) { + beforeEach((done) => { server = common.server(); client = common.client(done); }); - it('should perform a NOOP', function(done) { - client.raw('NOOP', function(error, response) { + it('should perform a NOOP', (done) => { + client.raw('NOOP', (error, response) => { common.should.not.exist(error); response.code.should.equal(200); done(); }); }); - afterEach(function() { + afterEach(() => { server.close(); }); }); diff --git a/test/pass.js b/src/__tests__/pass.js similarity index 58% rename from test/pass.js rename to src/__tests__/pass.js index c900b7f..d3c58b0 100644 --- a/test/pass.js +++ b/src/__tests__/pass.js @@ -1,9 +1,7 @@ var common = require('./lib/common'); var Client = require('jsftp'); -describe('PASS command', function() { - 'use strict'; - +describe('PASS command', () => { var client; var server; var options = { @@ -13,19 +11,19 @@ describe('PASS command', function() { pass: 'esoj', }; - beforeEach(function(done) { + beforeEach((done) => { server = common.server(options); done(); }); - it('should reject invalid password', function(done) { + it('should reject invalid password', (done) => { var badPass = options.pass + '_invalid'; client = new Client(options); - client.auth(options.user, badPass, function(error) { + client.auth(options.user, badPass, (error) => { error.code.should.eql(530); - client.raw.user(options.user, function(error, reply) { + client.raw.user(options.user, (error, reply) => { reply.code.should.eql(331); - client.raw.pass(badPass, function(error) { + client.raw.pass(badPass, (error) => { error.code.should.eql(530); done(); }); @@ -33,15 +31,15 @@ describe('PASS command', function() { }); }); - it('should reject PASS without USER', function(done) { + it('should reject PASS without USER', (done) => { client = new Client(options); - client.raw.pass(options.pass, function(error) { + client.raw.pass(options.pass, (error) => { error.code.should.eql(503); done(); }); }); - afterEach(function() { + afterEach(() => { server.close(); }); }); diff --git a/test/pwd.js b/src/__tests__/pwd.js similarity index 60% rename from test/pwd.js rename to src/__tests__/pwd.js index 4508784..a811929 100644 --- a/test/pwd.js +++ b/src/__tests__/pwd.js @@ -1,9 +1,7 @@ var common = require('./lib/common'); var path = require('path'); -describe('PWD command', function() { - 'use strict'; - +describe('PWD command', () => { var client; var server; var directories = [ @@ -18,19 +16,19 @@ describe('PWD command', function() { undefined, ]; - directories.forEach(function(directory) { - describe('CWD = "' + directory + '"', function() { - beforeEach(function(done) { + directories.forEach((directory) => { + describe('CWD = "' + directory + '"', () => { + beforeEach((done) => { server = common.server({ - getInitialCwd: function() { + getInitialCwd: () => { return directory; }, }); client = common.client(done); }); - it('should be "' + directory + '"', function(done) { - client.raw.pwd(function(error, reply) { + it('should be "' + directory + '"', (done) => { + client.raw.pwd((error, reply) => { common.should.not.exist(error); reply.code.should.equal(257); reply.text.should.startWith('257 "' + directory + '"'); @@ -38,33 +36,33 @@ describe('PWD command', function() { }); }); - it('should reject parameters', function(done) { - client.raw.pwd(directory, function(error, reply) { + it('should reject parameters', (done) => { + client.raw.pwd(directory, (error, reply) => { error.code.should.equal(501); reply.code.should.equal(501); done(); }); }); - afterEach(function() { + afterEach(() => { server.close(); }); }); }); - falseyDirectories.forEach(function(directory) { - describe('CWD = "' + directory + '"', function() { - beforeEach(function(done) { + falseyDirectories.forEach((directory) => { + describe('CWD = "' + directory + '"', () => { + beforeEach((done) => { server = common.server({ - getInitialCwd: function() { + getInitialCwd: () => { return directory; }, }); client = common.client(done); }); - it('should be "/"', function(done) { - client.raw.pwd(function(error, reply) { + it('should be "/"', (done) => { + client.raw.pwd((error, reply) => { common.should.not.exist(error); reply.code.should.equal(257); reply.text.should.startWith('257 "/"'); @@ -72,7 +70,7 @@ describe('PWD command', function() { }); }); - afterEach(function() { + afterEach(() => { server.close(); }); }); diff --git a/src/__tests__/retr.js b/src/__tests__/retr.js new file mode 100644 index 0000000..bd218ff --- /dev/null +++ b/src/__tests__/retr.js @@ -0,0 +1,47 @@ +var common = require('./lib/common'); + +describe('RETR command', () => { + var client; + var server; + + //run tests both ways + [true, false].forEach((useReadFile) => { + + describe('with useReadFile = ' + useReadFile, () => { + + beforeEach((done) => { + server = common.server({useReadFile: useReadFile}); + client = common.client(done); + }); + + it('should contain "hola!"', (done) => { + var str = ''; + client.get('/data.txt', (error, socket) => { + common.should.not.exist(error); + socket.on('data', (data) => { + str += data.toString(); + }).on('close', (error) => { + error.should.not.equal(true); + str.should.eql('hola!'); + done(); + }).resume(); + }); + }); + + it('should fail when file not found', (done) => { + client.get('/bad.file', (error) => { + common.should.exist(error); + done(); + }); + }); + + afterEach(() => { + server.close(); + }); + + }); + + }); + + +}); diff --git a/src/__tests__/tricky-paths.js b/src/__tests__/tricky-paths.js new file mode 100644 index 0000000..4936f82 --- /dev/null +++ b/src/__tests__/tricky-paths.js @@ -0,0 +1,85 @@ +var common = require('./lib/common'); +var async = require('async'); +var collectStream = require('collect-stream'); + +describe('Tricky paths', () => { + var client; + var server; + + //run tests both ways + [true, false].forEach((useReadFile) => { + + describe('with useReadFile = ' + useReadFile, () => { + + beforeEach((done) => { + server = common.server({useReadFile: useReadFile}); + client = common.client(done); + }); + + it('should cope with unusual paths', (done) => { + var coolGlasses = '\uD83D\uDE0E'; + var trickyName = "b\\\\s\\l, \"\"q'u\"o\"te''; pi|p|e & ^up^"; + var dirPath = 'tricky_paths/' + trickyName; + var expectedData = 'good ' + coolGlasses + '\nfilesystem.\n'; + + function receiveAndCompare(socket, nxt) { + collectStream(socket, (error, receivedData) => { + common.should.not.exist(error); + String(receivedData).should.eql(expectedData); + nxt(); + }); + socket.resume(); + } + + async.waterfall( + [ + function strangePathRedundantEscape(nxt) { + var dirRfcQuoted = dirPath.replace(/"/g, '""'); + server.suppressExpecteErrMsgs.push( + /^CWD [\S\s]+: Error: ENOENT/ + ); + client.raw('CWD', dirRfcQuoted, (error) => { + common.should.exist(error); + error.code.should.equal(550); + nxt(); + }); + }, + function strangePathCwd(nxt) { + client.raw('CWD', dirPath, nxt); + }, + function checkResponse(response, nxt) { + response.code.should.equal(250); + if (response.code !== 250) { + return nxt(new Error('failed to CWD to unusual path')); + } + nxt(); + }, + function strangePathRetr(nxt) { + var filename = trickyName + '.txt'; + client.get(filename, nxt); + }, + receiveAndCompare, + function strangePathRetr(nxt) { + var filename = 'cool-glasses.' + coolGlasses + '.txt'; + client.get(filename, nxt); + }, + receiveAndCompare, + ], + // finished callback + (error) => { + common.should.not.exist(error); + done(); + } + ); + }); + + afterEach(() => { + server.close(); + }); + + }); + + }); + + +}); diff --git a/test/unsupported.js b/src/__tests__/unsupported.js similarity index 71% rename from test/unsupported.js rename to src/__tests__/unsupported.js index 6c741b8..180f9c3 100644 --- a/test/unsupported.js +++ b/src/__tests__/unsupported.js @@ -1,8 +1,6 @@ var common = require('./lib/common'); -describe('UNSUPPORTED commands', function() { - 'use strict'; - +describe('UNSUPPORTED commands', () => { var client; var server; var commands = [ @@ -23,14 +21,14 @@ describe('UNSUPPORTED commands', function() { 'CD', ]; - beforeEach(function(done) { + beforeEach((done) => { server = common.server(); client = common.client(done); }); - commands.forEach(function(command) { - it('should reply 502 to ' + command, function(done) { - var callback = function(error) { + commands.forEach((command) => { + it('should reply 502 to ' + command, (done) => { + var callback = (error) => { error.code.should.eql(502); done(); }; @@ -43,7 +41,7 @@ describe('UNSUPPORTED commands', function() { }); }); - afterEach(function() { + afterEach(() => { server.close(); }); }); diff --git a/test/user.js b/src/__tests__/user.js similarity index 59% rename from test/user.js rename to src/__tests__/user.js index e5447dc..5d4e928 100644 --- a/test/user.js +++ b/src/__tests__/user.js @@ -1,9 +1,7 @@ var common = require('./lib/common'); var Client = require('jsftp'); -describe('USER command', function() { - 'use strict'; - +describe('USER command', () => { var client; var server; var options = { @@ -13,38 +11,38 @@ describe('USER command', function() { pass: 'esoj', }; - beforeEach(function(done) { + beforeEach((done) => { done(); }); - it('should reject non-secure USER when tlsOnly', function(done) { + it('should reject non-secure USER when tlsOnly', (done) => { server = common.server({ tlsOnly: true, }); client = new Client(options); - client.auth(options.user, options.pass, function(error) { + client.auth(options.user, options.pass, (error) => { error.code.should.eql(530); - client.raw.user(options.user, function(error) { + client.raw.user(options.user, (error) => { error.code.should.eql(530); done(); }); }); }); - it('should reject invalid username', function(done) { + it('should reject invalid username', (done) => { var badUser = options.user + '_invalid'; server = common.server(); client = new Client(options); - client.auth(badUser, options.pass, function(error) { + client.auth(badUser, options.pass, (error) => { error.code.should.eql(530); - client.raw.user(badUser, function(error) { + client.raw.user(badUser, (error) => { error.code.should.eql(530); done(); }); }); }); - afterEach(function() { + afterEach(() => { server.close(); }); }); diff --git a/test/utf8.js b/src/__tests__/utf8.js similarity index 61% rename from test/utf8.js rename to src/__tests__/utf8.js index 4c70c8a..63be34c 100644 --- a/test/utf8.js +++ b/src/__tests__/utf8.js @@ -1,19 +1,17 @@ var common = require('./lib/common'); -describe('UTF8 support', function() { - 'use strict'; - +describe('UTF8 support', () => { var client; var server; - beforeEach(function(done) { + beforeEach((done) => { server = common.server(); client = common.client(done); }); - it('should support UTF8 in LIST command', function(done) { + it('should support UTF8 in LIST command', (done) => { var filename = 'привіт.txt'; - client.list('/' + filename, function(error, listing) { + client.list('/' + filename, (error, listing) => { error.should.equal(false); listing = common.splitResponseLines(listing, ' ' + filename); listing.should.have.lengthOf(1); @@ -22,14 +20,14 @@ describe('UTF8 support', function() { }); }); - it('should RETR file with UTF8 in filename', function(done) { + it('should RETR file with UTF8 in filename', (done) => { var filename = 'привіт.txt'; var str = ''; - client.get('/' + filename, function(error, socket) { + client.get('/' + filename, (error, socket) => { common.should.not.exist(error); - socket.on('data', function(data) { + socket.on('data', (data) => { str += data.toString(); - }).on('close', function(error) { + }).on('close', (error) => { error.should.not.equal(true); str.should.eql('1234\n'); done(); @@ -37,7 +35,7 @@ describe('UTF8 support', function() { }); }); - afterEach(function() { + afterEach(() => { server.close(); }); }); diff --git a/test/whitelisted.js b/src/__tests__/whitelisted.js similarity index 55% rename from test/whitelisted.js rename to src/__tests__/whitelisted.js index 533e272..a77ef2d 100644 --- a/test/whitelisted.js +++ b/src/__tests__/whitelisted.js @@ -1,8 +1,6 @@ var common = require('./lib/common'); -describe('Whitelisted commands', function() { - 'use strict'; - +describe('Whitelisted commands', () => { var client; var server; var options = { @@ -15,41 +13,41 @@ describe('Whitelisted commands', function() { ], }; - beforeEach(function(done) { + beforeEach((done) => { server = common.server(options); client = common.client(done); }); - it('LIST should be allowed', function(done) { - client.list('/', function(error) { + it('LIST should be allowed', (done) => { + client.list('/', (error) => { common.should(error).not.be.ok; done(); }); }); - it('NOOP should be allowed', function(done) { - client.raw('NOOP', function(error, response) { + it('NOOP should be allowed', (done) => { + client.raw('NOOP', (error, response) => { common.should.not.exist(error); response.code.should.equal(200); done(); }); }); - it('DELE should reply 502', function(done) { - client.execute('DELE', function(error) { + it('DELE should reply 502', (done) => { + client.execute('DELE', (error) => { error.code.should.eql(502); done(); }); }); - it('RETR should reply 502', function(done) { - client.get('/myfile', function(error) { + it('RETR should reply 502', (done) => { + client.get('/myfile', (error) => { error.code.should.eql(502); done(); }); }); - afterEach(function() { + afterEach(() => { server.close(); }); }); diff --git a/lib/glob.js b/src/glob.js similarity index 74% rename from lib/glob.js rename to src/glob.js index e6805aa..88da6f4 100644 --- a/lib/glob.js +++ b/src/glob.js @@ -1,28 +1,24 @@ -var PathModule = require('path'); +import pathModule from 'path'; +import Constants from './Constants'; -var CONC = 5; -function setMaxStatsAtOnce(n) { - CONC = n; -} +const {CONCURRENT_STAT_CALLS} = Constants; + +// TODO: this is bad practice, use a class if options are required: new Glob({maxConcurrency: 5}).glob() +let concurrentStatCalls = CONCURRENT_STAT_CALLS; +export const setMaxStatsAtOnce = (n) => { + concurrentStatCalls = n; +}; // Wildcard directory listing. There is no way that a client should // use wildcards in directory names unless they're identifying a // 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; - for (var i = 0; i < CONC; ++i) { - handleFile(); - } - - var erroredOut = false; +const statList = (fsm, list, callback) => { + const handleFile = () => { + const finished = () => { + callback(null, stats); + }; - function handleFile() { if (erroredOut) { return; } @@ -34,26 +30,34 @@ function statList(fsm, list, callback) { } var path = list.shift(); - fsm.stat(path, function(err, st) { + fsm.stat(path, (err, st) => { if (err) { erroredOut = true; callback(err); } else { stats.push({ - name: PathModule.basename(path), + name: pathModule.basename(path), stats: st, }); handleFile(); } }); + }; + + if (list.length === 0) { + return callback(null, []); } - function finished() { - callback(null, stats); + var stats = []; + var total = list.length; + for (var i = 0; i < concurrentStatCalls; ++i) { + handleFile(); } -} -function matchPattern(pattern, string) { + var erroredOut = false; +}; + +export const matchPattern = (pattern, string) => { var pi = 0; var si = 0; for (; si < string.length && pi < pattern.length; ++si) { @@ -81,19 +85,19 @@ function matchPattern(pattern, string) { } return (pi === pattern.length || (pi === pattern.length - 1 && pattern.charAt(pi) === '*')) && si === string.length; -} +}; -function glob(path, fsm, callback, noWildcards) { +export const glob = (path, fsm, callback, noWildcards) => { var w; for (w = 0; !noWildcards && w < path.length && path.charAt(w) !== '*' && path.charAt(w) !== '?'; ++w) { } if (w === path.length) { // There are no wildcards. - fsm.readdir(path, function(err, contents) { + fsm.readdir(path, (err, contents) => { if (err) { if (err.code === 'ENOTDIR') { - statList(fsm, [path], function(err, list) { + statList(fsm, [path], (err, list) => { if (err) { return callback(err); } @@ -110,10 +114,8 @@ function glob(path, fsm, callback, noWildcards) { } else { statList( fsm, - contents.map(function(p) { - return PathModule.join(path, p); - }), - function(err, list) { + contents.map((p) => pathModule.join(path, p)), + (err, list) => { if (err) { callback(err); } else { @@ -163,9 +165,22 @@ function glob(path, fsm, callback, noWildcards) { // 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) { + const readTheDir = (listingSingleDir) => { + fsm.readdir(base, (err, contents) => { + const doTheNormalThing = () => { + statList( + fsm, + matches.map((p) => pathModule.join(base, p)), + (err, list) => { + if (err) { + callback(err); + } else { + callback(null, list); + } + } + ); + }; + if (err) { if (err.code === 'ENOTDIR' || err.code === 'ENOENT') { callback(null, []); @@ -175,9 +190,7 @@ function glob(path, fsm, callback, noWildcards) { } else { var matches; if (!listingSingleDir) { - matches = contents.filter(function(n) { - return matchPattern(pattern, n); - }); + matches = contents.filter((n) => matchPattern(pattern, n)); } else { matches = contents; } @@ -187,8 +200,8 @@ function glob(path, fsm, callback, noWildcards) { // 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) { + var dir = pathModule.join(base, matches[0]); + fsm.stat(dir, (err, st) => { if (err) { return callback(err); } @@ -204,27 +217,10 @@ function glob(path, fsm, callback, noWildcards) { 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); - } - } - ); - } } }); - } - } -} + }; -exports.glob = glob; -exports.matchPattern = matchPattern; -exports.setMaxStatsAtOnce = setMaxStatsAtOnce; + readTheDir(false); + } +}; diff --git a/src/helpers/__tests__/leftPad-test.js b/src/helpers/__tests__/leftPad-test.js new file mode 100644 index 0000000..81dc157 --- /dev/null +++ b/src/helpers/__tests__/leftPad-test.js @@ -0,0 +1,13 @@ +/*eslint-env node, mocha */ +import leftPad from '../leftPad'; +import expect from 'expect'; + +describe('helpers/leftPad', () => { + it('should prepend space to a string', () => { + expect(leftPad('abc', 5)).toBe(' abc'); + expect(leftPad('abc', 3)).toBe('abc'); + expect(leftPad('abc', 2)).toBe('abc'); + expect(leftPad('', 3)).toBe(' '); + expect(leftPad(' a', 3)).toBe(' a'); + }); +}); diff --git a/src/helpers/__tests__/pathEscape-test.js b/src/helpers/__tests__/pathEscape-test.js new file mode 100644 index 0000000..fce4d42 --- /dev/null +++ b/src/helpers/__tests__/pathEscape-test.js @@ -0,0 +1,14 @@ +/*eslint-env node, mocha */ +import pathEscape from '../pathEscape'; +import expect from 'expect'; + +describe('helpers/pathEscape', () => { + it('should not change a string that does not need escaping', () => { + expect(pathEscape('')).toBe(''); + expect(pathEscape('abc')).toBe('abc'); + }); + it('should escape one ore more occurances', () => { + expect(pathEscape('a"b')).toBe('a""b'); + expect(pathEscape('a"b"c')).toBe('a""b""c'); + }); +}); diff --git a/src/helpers/__tests__/stripOptions-test.js b/src/helpers/__tests__/stripOptions-test.js new file mode 100644 index 0000000..ded3466 --- /dev/null +++ b/src/helpers/__tests__/stripOptions-test.js @@ -0,0 +1,15 @@ +/*eslint-env node, mocha */ +import stripOptions from '../stripOptions'; +import expect from 'expect'; + +describe('helpers/stripOptions', () => { + it('should not change a string unnecessarily', () => { + expect(stripOptions('')).toBe(''); + expect(stripOptions('/ab/c')).toBe('/ab/c'); + }); + it('should remove options', () => { + expect(stripOptions('-a /foo/bar')).toBe('/foo/bar'); + expect(stripOptions(' \t-a foo/bar')).toBe('foo/bar'); + expect(stripOptions('-d -ef A B C')).toBe('A B C'); + }); +}); diff --git a/src/helpers/__tests__/toBase256-test.js b/src/helpers/__tests__/toBase256-test.js new file mode 100644 index 0000000..e57d2c0 --- /dev/null +++ b/src/helpers/__tests__/toBase256-test.js @@ -0,0 +1,23 @@ +/*eslint-env node, mocha */ +import toBase256 from '../toBase256'; +import expect from 'expect'; + +describe('helpers/toBase256', () => { + it('should convert numbers to their base256 parts', () => { + expect(toBase256(1)).toEqual([1]); + expect(toBase256(256)).toEqual([1, 0]); + expect(toBase256(257)).toEqual([1, 1]); + }); + + it('should not allow negative numbers or overflow', () => { + expect(toBase256(-1)).toEqual([0]); + expect(toBase256(2147483648)).toEqual([0]); + expect(toBase256(Math.pow(2, 33))).toEqual([0]); + }); + + it('should accept second parameter `minLength`', () => { + expect(toBase256(-1, 2)).toEqual([0, 0]); + expect(toBase256(2147483647, 5)).toEqual([0, 127, 255, 255, 255]); + expect(toBase256(300, -1)).toEqual([1, 44]); + }); +}); diff --git a/src/helpers/__tests__/withCwd-test.js b/src/helpers/__tests__/withCwd-test.js new file mode 100644 index 0000000..e4c0c3a --- /dev/null +++ b/src/helpers/__tests__/withCwd-test.js @@ -0,0 +1,22 @@ +/*eslint-env node, mocha */ +import withCwd from '../withCwd'; +import expect from 'expect'; + +describe('helpers/withCwd', () => { + it('should accept no arguments', () => { + expect(withCwd()).toBe('/'); + }); + it('should accept one argument', () => { + expect(withCwd('/foo')).toBe('/foo'); + }); + it('should support relative paths', () => { + expect(withCwd('/a', '../b')).toBe('/b'); + expect(withCwd('/a/b/c', './d')).toBe('/a/b/c/d'); + expect(withCwd('/a/b/c', 'd')).toBe('/a/b/c/d'); + }); + it('should support absolute paths', () => { + expect(withCwd('/a', '/b')).toBe('/b'); + expect(withCwd('/a/b', '/c')).toBe('/c'); + expect(withCwd('/a/b/c', '/d')).toBe('/d'); + }); +}); diff --git a/src/helpers/__tests__/writeToStreamAsync-test.js b/src/helpers/__tests__/writeToStreamAsync-test.js new file mode 100644 index 0000000..a60fc96 --- /dev/null +++ b/src/helpers/__tests__/writeToStreamAsync-test.js @@ -0,0 +1,81 @@ +/* @//flow */ +/*eslint-env node, mocha */ +const {describe, it} = global; +import writeToStreamAsync from '../writeToStreamAsync'; +import expect from 'expect'; + +class MockWriteStream { + // _data: Array; + // _bytesWritten: number; + // _isBufferFull: number; + // _internalBufferSize: number; + + constructor(bufferSize = 10) { + this._data = []; + this._bytesWritten = 0; + this._bytesFlushed = 0; + this._isBufferFull = 0; + this._internalBufferSize = bufferSize; + } + + write(data, callback) { + this._data.push(data); + this._bytesWritten += data.length; + if (this._isBufferFull || data.length > this._internalBufferSize) { + this._isBufferFull += 1; + setTimeout(() => { + this._bytesFlushed += data.length; + this._isBufferFull -= 1; + callback && callback(); + }, 20); + return false; + } else { + this._bytesFlushed += data.length; + callback && process.nextTick(callback); + return true; + } + } + + getBytesWritten() { + return this._bytesWritten; + } + + getBytesFlushed() { + return this._bytesFlushed; + } + + isBufferFull() { + return this._isBufferFull !== 0; + } +} + +describe('helpers/writeToStreamAsync', () => { + it('should handle empty array', (done) => { + let writeStream = new MockWriteStream(); + writeToStreamAsync([], writeStream, () => { + expect(writeStream.getBytesWritten()).toBe(0); + done(); + }); + }); + + it('should write data to stream', (done) => { + let writeStream = new MockWriteStream(10); + let data = [ + new Buffer('abcdefghijkl', 'utf8'), + new Buffer('abcdefghijkl', 'utf8'), + new Buffer('abcdefghijkl', 'utf8'), + ]; + let result = writeStream.write(new Buffer('a', 'utf8')); + expect(result).toBe(true); + result = writeStream.write(data.shift()); + expect(result).toBe(false); + expect(writeStream.isBufferFull()).toBe(true); + expect(writeStream.getBytesWritten()).toBe(13); + writeToStreamAsync(data, writeStream, () => { + expect(writeStream.isBufferFull()).toBe(false); + expect(writeStream.getBytesWritten()).toBe(37); + expect(writeStream.getBytesFlushed()).toBe(37); + done(); + }); + }); +}); diff --git a/src/helpers/leftPad.js b/src/helpers/leftPad.js new file mode 100644 index 0000000..6fe8ff1 --- /dev/null +++ b/src/helpers/leftPad.js @@ -0,0 +1,10 @@ +const leftPad = (text, width) => { + var out = ''; + for (var j = text.length; j < width; j++) { + out += ' '; + } + out += text; + return out; +}; + +export default leftPad; diff --git a/src/helpers/pathEscape.js b/src/helpers/pathEscape.js new file mode 100644 index 0000000..0cbc7d7 --- /dev/null +++ b/src/helpers/pathEscape.js @@ -0,0 +1,8 @@ +const pathEscape = (text) => { + // Rules for quoting: RFC 959 -> Appendix II -> Directory Commands + // (http://www.w3.org/Protocols/rfc959/A2_DirectoryCommands.html) + // -> Reply Codes -> search for "embedded double-quotes" + return text.replace(/"/g, '""'); +}; + +export default pathEscape; diff --git a/src/helpers/stripOptions.js b/src/helpers/stripOptions.js new file mode 100644 index 0000000..abc685a --- /dev/null +++ b/src/helpers/stripOptions.js @@ -0,0 +1,23 @@ +// Currently used for stripping options from beginning of argument to LIST and NLST. +const stripOptions = (str) => { + var IN_SPACE = 0; + var IN_DASH = 1; + var state = IN_SPACE; + for (var i = 0; i < str.length; ++i) { + var c = str.charAt(i); + if (state === IN_SPACE) { + if (c === ' ' || c === '\t') { + + } else if (c === '-') { + state = IN_DASH; + } else { + return str.substr(i); + } + } else if (state === IN_DASH && (c === ' ' || c === '\t')) { + state = IN_SPACE; + } + } + return ''; +}; + +export default stripOptions; diff --git a/src/helpers/toBase256.js b/src/helpers/toBase256.js new file mode 100644 index 0000000..af0f202 --- /dev/null +++ b/src/helpers/toBase256.js @@ -0,0 +1,22 @@ +const toBase256 = (number, minLength = 1) => { + let digits = []; + // This will truncate the number if it's larger than 2^31 - 1. + number = number | 0; + if (number < 0) { + number = 0; + } + while (number !== 0) { + let modulus = (number % 256); + digits.unshift(modulus); + number = (number - modulus) / 256; + } + if (minLength < 1) { + minLength = 1; + } + while (digits.length < minLength) { + digits.unshift(0); + } + return digits; +}; + +export default toBase256; diff --git a/src/helpers/withCwd.js b/src/helpers/withCwd.js new file mode 100644 index 0000000..cc26e4d --- /dev/null +++ b/src/helpers/withCwd.js @@ -0,0 +1,13 @@ +import pathModule from 'path'; + +const SEP = pathModule.sep; + +const withCwd = (cwd, path) => { + let firstChar = (path || '').charAt(0); + if (firstChar === '/' || firstChar === SEP) { + cwd = SEP; + } + return pathModule.join(SEP, cwd || SEP, path || ''); +}; + +export default withCwd; diff --git a/src/helpers/writeToStreamAsync.js b/src/helpers/writeToStreamAsync.js new file mode 100644 index 0000000..6f48bb5 --- /dev/null +++ b/src/helpers/writeToStreamAsync.js @@ -0,0 +1,25 @@ +/* @flow */ + +type WriteStream = { + write: (data: Buffer) => boolean; + once: (name: string, handler: Function) => any; +}; + +// This will take an array of data and write it to a write stream, waiting +// for it to flush to the underlying socket. +export default function writeToStreamAsync( + buffers: Array, + writeStream: WriteStream, + callback: (error: ?Error) => any +) { + let index = 0; + const writeData = () => { + if (index >= buffers.length) { + callback(); + } else { + let data = buffers[index++]; + writeStream.write(data, writeData); + } + }; + process.nextTick(writeData); +} diff --git a/lib/starttls.js b/src/starttls.js similarity index 96% rename from lib/starttls.js rename to src/starttls.js index ede6b39..63c6f28 100644 --- a/lib/starttls.js +++ b/src/starttls.js @@ -36,7 +36,7 @@ function starttls(socket, options, callback, isServer) { var sslcontext; var opts = {}; - Object.keys(options).forEach(function(key) { + Object.keys(options).forEach((key) => { opts[key] = options[key]; }); if (!opts.ciphers) { @@ -53,7 +53,7 @@ function starttls(socket, options, callback, isServer) { var cleartext = pipe(pair, socket); var erroredOut = false; - pair.on('secure', function() { + pair.on('secure', () => { if (erroredOut) { pair.end(); return; @@ -70,7 +70,7 @@ function starttls(socket, options, callback, isServer) { callback(null, cleartext); }); - pair.once('error', function(err) { + pair.once('error', (err) => { if (!erroredOut) { erroredOut = true; callback(err); diff --git a/test/appe.js b/test/appe.js deleted file mode 100644 index 58d5e02..0000000 --- a/test/appe.js +++ /dev/null @@ -1,53 +0,0 @@ -var common = require('./lib/common'); -var FtpClient = require('ftp'); -var path = require('path'); -var fs = require('fs'); - -describe('APPE command', function() { - 'use strict'; - - var client = new FtpClient(); - var server; - - //run tests both ways - [true, false].forEach(function(useWriteFile) { - - describe('with useWriteFile = ' + useWriteFile, function() { - - beforeEach(function(done) { - server = common.server({useWriteFile:useWriteFile}); - client.once('ready', function() { - done(); - }); - client.connect({ - host: common.defaultOptions().host, - port: common.defaultOptions().port, - user: common.defaultOptions().user, - password: common.defaultOptions().pass, - }); - }); - - it('should append data to existing file', function(done) { - var basename = path.basename(__filename); - var fileSize = fs.statSync(__filename).size; - client.put(__filename, '/uploads/' + basename, function(err) { - common.should.not.exist(err); - client.append(__filename, '/uploads/' + basename, function(err) { - common.should.not.exist(err); - var newSize = fs.statSync(path.join(common.fixturesPath(), common.defaultOptions().user, 'uploads', basename)).size; - newSize.should.be.eql(fileSize * 2); - done(); - }); - }); - }); - - afterEach(function() { - server.close(); - }); - - }); - - }); - - -}); diff --git a/test/mdtm.js b/test/mdtm.js deleted file mode 100644 index 61c6fda..0000000 --- a/test/mdtm.js +++ /dev/null @@ -1,32 +0,0 @@ -var common = require('./lib/common'); - -describe('MDTM command', function() { - 'use strict'; - - var client; - var server; - - beforeEach(function(done) { - server = common.server(); - client = common.client(done); - }); - - it('should respond 213 for a valid file', function(done) { - client.raw('MDTM', '/data.txt', function(error, response) { - common.should.not.exist(error); - response.text.should.match(/^213 [0-9]{14}$/); - done(); - }); - }); - - it('should respond 550 for an invalid file', function(done) { - client.raw('MDTM', '/data-something.txt', function(error) { - error.code.should.equal(550); - done(); - }); - }); - - afterEach(function() { - server.close(); - }); -}); diff --git a/test/retr.js b/test/retr.js deleted file mode 100644 index 804614d..0000000 --- a/test/retr.js +++ /dev/null @@ -1,49 +0,0 @@ -var common = require('./lib/common'); - -describe('RETR command', function() { - 'use strict'; - - var client; - var server; - - //run tests both ways - [true, false].forEach(function(useReadFile) { - - describe('with useReadFile = ' + useReadFile, function() { - - beforeEach(function(done) { - server = common.server({useReadFile:useReadFile}); - client = common.client(done); - }); - - it('should contain "hola!"', function(done) { - var str = ''; - client.get('/data.txt', function(error, socket) { - common.should.not.exist(error); - socket.on('data', function(data) { - str += data.toString(); - }).on('close', function(error) { - error.should.not.equal(true); - str.should.eql('hola!'); - done(); - }).resume(); - }); - }); - - it('should fail when file not found', function(done) { - client.get('/bad.file', function(error) { - common.should.exist(error); - done(); - }); - }); - - afterEach(function() { - server.close(); - }); - - }); - - }); - - -}); diff --git a/test/tricky-paths.js b/test/tricky-paths.js deleted file mode 100644 index aa3d6c4..0000000 --- a/test/tricky-paths.js +++ /dev/null @@ -1,85 +0,0 @@ -/*jslint indent: 2, maxlen: 80, node: true, white: true, vars: true */ -/*globals describe, it, beforeEach, afterEach */ -'use strict'; - -var common = require('./lib/common'); -var async = require('async'); -var collectStream = require('collect-stream'); - -describe('Tricky paths', function() { - var client; - var server; - - //run tests both ways - [true, false].forEach(function(useReadFile) { - - describe('with useReadFile = ' + useReadFile, function() { - - beforeEach(function(done) { - server = common.server({useReadFile: useReadFile}); - client = common.client(done); - }); - - it('should cope with unusual paths', function(done) { - var coolGlasses = '\uD83D\uDE0E'; - var trickyName = "b\\\\s\\l, \"\"q'u\"o\"te''; pi|p|e & ^up^"; - var dirPath = 'tricky_paths/' + trickyName; - var expectedData = 'good ' + coolGlasses + '\nfilesystem.\n'; - - function receiveAndCompare(socket, nxt) { - collectStream(socket, function(error, receivedData) { - common.should.not.exist(error); - String(receivedData).should.eql(expectedData); - nxt(); - }); - socket.resume(); - } - - async.waterfall([ - function strangePathRedundantEscape(nxt) { - var dirRfcQuoted = dirPath.replace(/"/g, '""'); - server.suppressExpecteErrMsgs.push( - /^CWD [\S\s]+: Error: ENOENT/ - ); - client.raw('CWD', dirRfcQuoted, function(error) { - common.should.exist(error); - error.code.should.equal(550); - nxt(); - }); - }, - function strangePathCwd(nxt) { - client.raw('CWD', dirPath, nxt); - }, - function checkResponse(response, nxt) { - response.code.should.equal(250); - if (response.code !== 250) { - return nxt(new Error('failed to CWD to unusual path')); - } - nxt(); - }, - function strangePathRetr(nxt) { - var filename = trickyName + '.txt'; - client.get(filename, nxt); - }, - receiveAndCompare, - function strangePathRetr(nxt) { - var filename = 'cool-glasses.' + coolGlasses + '.txt'; - client.get(filename, nxt); - }, - receiveAndCompare, - ], function finished(error) { - common.should.not.exist(error); - done(); - }); - }); - - afterEach(function() { - server.close(); - }); - - }); - - }); - - -});