feat: initial commit

HEAD
This commit is contained in:
Tyler Stewart
2017-02-26 23:49:44 -07:00
parent d188aee0be
commit 1009970bb9
75 changed files with 2693 additions and 132 deletions

View File

@@ -6,7 +6,4 @@ bower_components/*
# Config folder (optional - you might want to lint this...)
config/*
# Sample project
src/demo/*
# END_CONFIT_GENERATED_CONTENT

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@ node_modules/
dist/
reports/
.env

12
.travis.yml Normal file
View File

@@ -0,0 +1,12 @@
language: node_js
node_js:
- "6"
install: npm install
script:
- npm run verify:js
- npm run test:coverage
after_success:
- if [ $TRAVIS_BRANCH = 'master' ]; then npm run semantic-release; fi

View File

@@ -1,7 +1,7 @@
<!--[CN_HEADING]-->
# Contributing
Welcome! This document explains how you can contribute to making **ftp-js** even better.
Welcome! This document explains how you can contribute to making **ftp-svr** even better.
<!--[]-->
@@ -26,7 +26,7 @@ npm install
Code is organised into modules which contain one-or-more components. This a great way to ensure maintainable code by encapsulation of behavior logic. A component is basically a self contained app usually in a single file or a folder with each concern as a file: style, template, specs, e2e, and component class. Here's how it looks:
```
ftp-js/
ftp-svr/
├──config/ * configuration files live here (e.g. eslint, verify, testUnit)
├──src/ * source code files should be here
@@ -136,9 +136,6 @@ Command | Description
Command | Description
:------ | :----------
<pre>npm run verify</pre> | Verify code style and syntax<ul><li>Verifies source *and test code* aginst customisable rules (unlike Webpack loaders)</li></ul>
<pre>npm run verify:js</pre> | Verify Javascript code style and syntax
<pre>npm run verify:js:fix</pre> | Verify Javascript code style and syntax and fix any errors that can be fixed automatically
<pre>npm run verify:js:watch</pre> | Verify Javascript code style and syntax and watch files for changes
<pre>npm run verify:watch</pre> | Runs verify task whenever JS or CSS code is changed

View File

@@ -1,9 +1,7 @@
ftp-js Copyright (c) 2017 Tyler Stewart
ftp-svr Copyright (c) 2017 Tyler Stewart
MIT License
Copyright (c) <year> <copyright holders>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

View File

@@ -1,72 +1,34 @@
<!--[RM_HEADING]-->
# ftp-js
# ftp-svr [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/)
<!--[]-->
<!--[RM_DESCRIPTION]-->
> A description of my awesome package
> Modern, extensible FTP Server
<!--[]-->
<!--[RM_BADGES]-->
[![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release)
[![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/)
- [Features](#features)
- [Install](#install)
- [Usage](#usage)
- [Contributing](#contributing)
- [License](#license)
## Features
<!--[]-->
<!--[RM_INSTALL]-->
## Install
npm install ftp-js
`npm install ftp-svr --save`
<!--[]-->
`yarn add ftp-svr`
## Usage
<!--[RM_CONTRIBUTING]-->
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md).
<!--[]-->
<!--[RM_NEXT_STEPS]-->
## *Next Steps to Setup your Project*
Remove this section once you are comfortable updating your project.
- [ ] Update [package.json](package.json) with a nice description, then run `yo confit --skip-install --skip-run` and see the README.md file is updated
- [ ] Add a new **dependency** to your project:
- For a **source-code** dependency:
1. `npm i {nodeModule} --save`
- For a **development** dependency:
1. `npm i {nodeModule} --save-dev`
- For a **test** dependency:
1. `npm i {nodeModule} --save`
- [ ] Complete the installation of the **semantic release** tool:
1. Make sure you have:
- a GitHub login
- an NPM login
- a TravisCI login (though you can still proceed if you use a different CI tool)
1. Run `semantic-release-cli setup` to complete the installation
- [ ] Install code coverage:
1. Make sure you have:
- a TravisCI login (though you can still proceed if you use a different CI tool)
- a [Coveralls](https://coveralls.io) account
1. Push your code to GitHub.
1. Login to [Coveralls](https://coveralls.io/).
1. Press Add Repo. You may need to Sync your GitHub repos to see your new repo in the list.
1. Select the repo and you will see a "Set Up Coveralls" page. Note the `repo_token` value.
1. Login to [Travis CI](https://travis-ci.org/).
1. Edit the settings for this repo (More Settings > Settings).
1. In the Environment Variables section, create a new envrionment variable called `COVERALLS_REPO_TOKEN` and set its value to the *repo_token* value shown on the "Set Up Coveralls" page, and press "Add".
1. Push another commit to GitHub and you should get a coverage report now!
- [ ] Run `npm test` to execute the tests and see the test coverage
- [ ] Run `git cz` to commit changes with a conventional commit message
<!--[]-->
<!--[RM_LICENSE]-->
@@ -75,4 +37,3 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
This software is licensed under the MIT Licence. See [LICENSE](LICENSE).
<!--[]-->

View File

@@ -1,14 +1,14 @@
{
"global": {
"statements": 80,
"branches": 80,
"functions": 60,
"statements": 70,
"branches": 60,
"functions": 80,
"lines": 80
},
"each": {
"statements": 40,
"branches": 50,
"functions": 20,
"lines": 40
"statements": 0,
"branches": 0,
"functions": 0,
"lines": 0
}
}

View File

@@ -1,18 +1,17 @@
# START_CONFIT_GENERATED_CONTENT
confit:
extends: &confit-extends
- google
extends: &confit-extends
- plugin:node/recommended
plugins: &confit-plugins
plugins: &confit-plugins
- node
env: &confit-env
commonjs: true # For Webpack, CommonJS
node: true
mocha: true
es6: true
globals: &confit-globals {}
parser: &confit-parser espree
@@ -51,3 +50,11 @@ rules:
max-len:
- warn
- 200 # Line Length
node/no-unpublished-require:
- 2
- allowModules:
- chai
- dotenv
- ftp
- sinon
- sinon-as-promised

View File

@@ -5,7 +5,7 @@ generator-confit:
copyrightOwner: Tyler Stewart
license: MIT
projectType: node
publicRepository: false
publicRepository: true
repositoryType: GitHub
paths:
_version: 7f33e41600b34cd6867478d8f2b3d6b2bbd42508
@@ -27,13 +27,13 @@ generator-confit:
_version: de20402bf85c703080ef6daf21e35325a3b9d604
entryPoints:
main:
- src/demo/index.js
- src/index.js
testUnit:
_version: 4472a6d59b434226f463992d3c1914c77a6a115d
testDependencies: []
verify:
_version: 30ae86c5022840a01fc08833e238a82c683fa1c7
jsCodingStandard: Google
jsCodingStandard: eslint
documentation:
_version: b1658da3278b16d1982212f5e8bc05348af20e0b
generateDocs: false
@@ -44,5 +44,5 @@ generator-confit:
useSemantic: true
sampleApp:
_version: 00c0a2c6fc0ed17fcccce2d548d35896121e58ba
createSampleApp: true
createSampleApp: false
zzfinish: {}

View File

@@ -1,12 +1,19 @@
{
"name": "ftp-js",
"name": "ftp-svr",
"version": "0.0.0",
"description": "A description of my awesome package",
"description": "Modern, extensible FTP Server",
"keywords": [
"ftp",
"ftp-server",
"ftp-svr",
"ftpd",
"server"
],
"license": "MIT",
"main": "src/index.js",
"repository": {
"type": "git",
"url": "https://github.com/stewarttylerr/ftp.js"
"url": "https://github.com/stewarttylerr/ftp-svr"
},
"scripts": {
"pre-release": "npm-run-all verify test:coverage build ",
@@ -15,6 +22,7 @@
"commitmsg": "cz-customizable-ghooks",
"dev": "cross-env NODE_ENV=development npm run verify:watch",
"prepush": "npm-run-all verify test:coverage --silent",
"semantic-release": "semantic-release pre && npm publish && semantic-release post",
"start": "npm run dev",
"test": "npm run test:unit",
"test:check-coverage": "cross-env NODE_ENV=test istanbul check-coverage reports/coverage/coverage.json --config config/testUnit/istanbul.js",
@@ -38,28 +46,35 @@
},
"dependencies": {
"bunyan": "^1.8.5",
"date-fns": "^1.28.0",
"lodash": "^4.17.4",
"minimist-string": "^1.0.2",
"uuid": "^3.0.1",
"when": "^3.7.8"
},
"devDependencies": {
"chai": "^3.5.0",
"chokidar-cli": "1.2.0",
"coveralls": "2.11.15",
"cross-env": "3.1.4",
"cz-customizable": "4.0.0",
"cz-customizable-ghooks": "1.5.0",
"dotenv": "^4.0.0",
"eslint": "3.14.1",
"eslint-config-google": "0.7.1",
"eslint-plugin-node": "3.0.5",
"ftp": "^0.3.10",
"husky": "0.13.1",
"istanbul": "0.4.5",
"mocha": "3.2.0",
"npm-run-all": "4.0.1",
"rimraf": "2.5.4"
"rimraf": "2.5.4",
"semantic-release": "^6.3.2",
"sinon": "^1.17.7",
"sinon-as-promised": "^4.0.2"
},
"engines": {
"node": ">=4.x",
"node": ">=6.x",
"npm": ">=3.9.5"
},
"private": true
}
}

3
src/commands/allo.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = function () {
return this.reply(202);
}

19
src/commands/auth.js Normal file
View File

@@ -0,0 +1,19 @@
const _ = require('lodash');
module.exports = function ({command} = {}) {
const method = _.upperCase(command._[1]);
switch (method) {
case 'TLS': return handleTLS.call(this);
case 'SSL': return handleSSL.call(this);
default: return this.reply(504);
}
}
function handleTLS() {
return this.reply(504);
}
function handleSSL() {
return this.reply(504);
}

6
src/commands/cdup.js Normal file
View File

@@ -0,0 +1,6 @@
const cwd = require('./cwd');
module.exports = function(args) {
args.command._ = [args.command._[0], '..'];
return cwd.call(this, args);
}

17
src/commands/cwd.js Normal file
View File

@@ -0,0 +1,17 @@
const when = require('when');
const escapePath = require('../helpers/escape-path');
module.exports = function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.chdir) return this.reply(402, 'Not supported by file system');
return when(this.fs.chdir(command._[1]))
.then(cwd => {
const path = cwd ? `"${escapePath(cwd)}"` : undefined;
return this.reply(250, path);
})
.catch(err => {
log.error(err);
return this.reply(550, err.message);
});
}

15
src/commands/dele.js Normal file
View File

@@ -0,0 +1,15 @@
const when = require('when');
module.exports = function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.delete) return this.reply(402, 'Not supported by file system');
return when(this.fs.delete(command._[1]))
.then(() => {
return this.reply(250);
})
.catch(err => {
log.error(err);
return this.reply(550);
});
}

10
src/commands/feat.js Normal file
View File

@@ -0,0 +1,10 @@
const _ = require('lodash');
module.exports = function () {
const registry = require('./registry');
const features = Object.keys(registry)
.filter(cmd => registry[cmd].hasOwnProperty('feat'))
.reduce((feats, cmd) => _.concat(feats, registry[cmd].feat), [])
.map(feat => ` ${feat}`);
return this.reply(211, 'Extensions supported', ...features, 'END');
}

16
src/commands/help.js Normal file
View File

@@ -0,0 +1,16 @@
const _ = require('lodash');
module.exports = function ({command} = {}) {
const registry = require('./registry');
const directive = _.upperCase(command._[1]);
if (directive) {
if (!registry.hasOwnProperty(directive)) return this.reply(502, `Unknown command ${directive}.`);
const {syntax, help, obsolete} = registry[directive];
const reply = _.concat([syntax, help, obsolete ? 'Obsolete' : null]);
return this.reply(214, ...reply);
} else {
const supportedCommands = _.chunk(Object.keys(registry), 5).map(chunk => chunk.join('\t'));
return this.reply(211, 'Supported commands:', ...supportedCommands, 'Use "HELP [command]" for syntax help.');
}
};

35
src/commands/index.js Normal file
View File

@@ -0,0 +1,35 @@
const _ = require('lodash');
const when = require('when');
class FtpCommands {
constructor(connection) {
this.connection = connection;
this.registry = require('./registry');
this.previousCommand = {};
}
handle(command) {
const log = this.connection.log.child({command});
log.trace('Handle command');
if (!this.registry.hasOwnProperty(command.directive)) {
return this.connection.reply(402, 'Command not allowed');
}
const commandRegister = this.registry[command.directive];
if (!commandRegister.no_auth && !this.connection.authenticated) {
return this.connection.reply(530);
}
if (!commandRegister.handler) {
return this.connection.reply(502, 'Handler not set on command');
}
const handler = commandRegister.handler.bind(this.connection);
return when.try(handler, { log, command, previous_command: this.previousCommand })
.finally(() => {
this.previousCommand = _.clone(command);
});
}
}
module.exports = FtpCommands;

53
src/commands/list.js Normal file
View File

@@ -0,0 +1,53 @@
const _ = require('lodash');
const when = require('when');
const getFileStat = require('../helpers/file-stat');
// http://cr.yp.to/ftp/list.html
// http://cr.yp.to/ftp/list/eplf.html
module.exports = function ({log, command, previous_command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.list) return this.reply(402, 'Not supported by file system');
const simple = command.directive === 'NLST';
let dataSocket;
const directory = command._[1] || '.';
return this.connector.waitForConnection()
.then(socket => {
this.commandSocket.pause();
dataSocket = socket;
})
.then(() => when(this.fs.list(directory)))
.then(files => {
const getFileMessage = (file) => {
if (simple) return file.name;
return getFileStat(file, _.get(this, 'server.options.file_format', 'ls'));
};
const fileList = files.map(file => {
const message = getFileMessage(file);
return {
raw: true,
message,
socket: dataSocket
};
})
return this.reply(150)
.then(() => this.reply(...fileList));
})
.then(() => {
return this.reply(226, 'Transfer OK');
})
.catch(when.TimeoutError, err => {
log.error(err);
return this.reply(425, 'No connection established');
})
.catch(err => {
log.error(err);
return this.reply(err.code || 451, err.message || 'No directory');
})
.finally(() => {
this.connector.end();
this.commandSocket.resume();
});
}

17
src/commands/mdtm.js Normal file
View File

@@ -0,0 +1,17 @@
const when = require('when');
const format = require('date-fns/format');
module.exports = function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.get) return this.reply(402, 'Not supported by file system');
return when(this.fs.get(command._[1]))
.then(fileStat => {
const modificationTime = format(fileStat.mtime, 'YYYYMMDDHHmmss.SSS');
return this.reply(213, modificationTime)
})
.catch(err => {
log.error(err);
return this.reply(550);
});
}

17
src/commands/mkd.js Normal file
View File

@@ -0,0 +1,17 @@
const when = require('when');
const escapePath = require('../helpers/escape-path');
module.exports = function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.mkdir) return this.reply(402, 'Not supported by file system');
return when(this.fs.mkdir(command._[1]))
.then(dir => {
const path = dir ? `"${escapePath(dir)}"` : undefined;
return this.reply(257, path);
})
.catch(err => {
log.error(err);
return this.reply(550);
});
}

3
src/commands/mode.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = function ({command} = {}) {
return this.reply(command._[1] === 'S' ? 200 : 504);
}

3
src/commands/noop.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = function () {
return this.reply(200);
}

3
src/commands/opts.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = function () {
return this.reply(501);
}

19
src/commands/pass.js Normal file
View File

@@ -0,0 +1,19 @@
const _ = require('lodash');
module.exports = function ({log, command} = {}) {
if (!this.username) return this.reply(503);
if (this.username && this.authenticated &&
_.get(this, 'server.options.anonymous') === true) return this.reply(230);
// 332 : require account name (ACCT)
const password = command._[1];
return this.login(this.username, password)
.then(() => {
return this.reply(230);
})
.catch(err => {
log.error(err);
return this.reply(530, err.message || 'Authentication failed');
});
};

15
src/commands/pasv.js Normal file
View File

@@ -0,0 +1,15 @@
const PassiveConnector = require('../connector/passive');
module.exports = function ({command} = {}) {
this.connector = new PassiveConnector(this);
return this.connector.setupServer()
.then(server => {
const address = this.server.url.hostname;
const {port} = server.address();
const host = address.replace(/\./g, ',');
const portByte1 = port / 256 | 0;
const portByte2 = port % 256;
return this.reply(227, `PASV OK (${host},${portByte1},${portByte2})`);
});
}

16
src/commands/port.js Normal file
View File

@@ -0,0 +1,16 @@
const ActiveConnector = require('../connector/active');
module.exports = function ({command} = {}) {
this.connector = new ActiveConnector(this);
const rawConnection = command._[1].split(',');
if (rawConnection.length !== 6) return this.reply(425);
const ip = rawConnection.slice(0, 4).join('.');
const portBytes = rawConnection.slice(4).map(p => parseInt(p));
const port = portBytes[0] * 256 + portBytes[1];
return this.connector.setupConnection(ip, port)
.then(socket => {
return this.reply(200);
})
}

17
src/commands/pwd.js Normal file
View File

@@ -0,0 +1,17 @@
const when = require('when');
const escapePath = require('../helpers/escape-path');
module.exports = function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.currentDirectory) return this.reply(402, 'Not supported by file system');
return when(this.fs.currentDirectory())
.then(cwd => {
const path = cwd ? `"${escapePath(cwd)}"` : undefined;
return this.reply(257, path);
})
.catch(err => {
log.error(err);
return this.reply(550, err.message);
});
}

3
src/commands/quit.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = function () {
return this.close(221);
}

200
src/commands/registry.js Normal file
View File

@@ -0,0 +1,200 @@
module.exports = {
AUTH: {
handler: require('./auth'),
syntax: 'AUTH [type]',
help: 'Not supported',
no_auth: true
},
USER: {
handler: require('./user'),
syntax: 'USER [username]',
help: 'Authentication username',
no_auth: true
},
PASS: {
handler: require('./pass'),
syntax: 'PASS [password]',
help: 'Authentication password',
no_auth: true
},
SYST: {
handler: require('./syst'),
syntax: 'SYST',
help: 'Return system type',
no_auth: true
},
FEAT: {
handler: require('./feat'),
syntax: 'FEAT',
help: 'Get the feature list implemented by the server',
no_auth: true
},
PWD: {
handler: require('./pwd'),
syntax: 'PWD',
help: 'Print current working directory'
},
XPWD: {
handler: require('./pwd'),
syntax: 'XPWD',
help: 'Print current working directory'
},
TYPE: {
handler: require('./type'),
syntax: 'TYPE',
help: 'Set the transfer mode'
},
PASV: {
handler: require('./pasv'),
syntax: 'PASV',
help: 'Initiate passive mode'
},
PORT: {
handler: require('./port'),
syntax: 'PORT [x,x,x,x,y,y]',
help: 'Specifies an address and port to which the server should connect'
},
LIST: {
handler: require('./list'),
syntax: 'LIST [path(optional)]',
help: 'Returns information of a file or directory if specified, else information of the current working directory is returned'
},
NLST: {
handler: require('./list'),
syntax: 'NLST [path(optional)]',
help: 'Returns a list of file names in a specified directory'
},
CWD: {
handler: require('./cwd'),
syntax: 'CWD [path]',
help: 'Change working directory'
},
XCWD: {
handler: require('./cwd'),
syntax: 'XCWD [path]',
help: 'Change working directory'
},
CDUP: {
handler: require('./cdup'),
syntax: 'CDUP',
help: 'Change to Parent Directory'
},
XCUP: {
handler: require('./cdup'),
syntax: 'XCUP',
help: 'Change to Parent Directory'
},
STOR: {
handler: require('./stor'),
syntax: 'STOR [path]',
help: 'Accept the data and to store the data as a file at the server site'
},
APPE: {
handler: require('./stor'),
syntax: 'APPE [path]',
help: 'Append to file'
},
RETR: {
handler: require('./retr'),
syntax: 'RETR [path]',
help: 'Retrieve a copy of the file'
},
DELE: {
handler: require('./dele'),
syntax: 'DELE [path]',
help: 'Delete file'
},
RMD: {
handler: require('./dele'),
syntax: 'RMD [path]',
help: 'Remove a directory'
},
XRMD: {
handler: require('./dele'),
syntax: 'XRMD [path]',
help: 'Remove a directory'
},
HELP: {
handler: require('./help'),
syntax: 'HELP [command(optional)]',
help: 'Returns usage documentation on a command if specified, else a general help document is returned'
},
MDTM: {
handler: require('./mdtm'),
syntax: 'MDTM [path]',
help: 'Return the last-modified time of a specified file',
feat: 'MDTM'
},
MKD: {
handler: require('./mkd'),
syntax: 'MKD [path]',
help: 'Make directory'
},
XMKD: {
handler: require('./mkd'),
syntax: 'XMKD [path]',
help: 'Make directory'
},
NOOP: {
handler: require('./noop'),
syntax: 'NOOP',
help: 'No operation',
no_auth: true
},
QUIT: {
handler: require('./quit'),
syntax: 'QUIT',
help: 'Disconnect',
no_auth: true
},
RNFR: {
handler: require('./rnfr'),
syntax: 'RNFR [name]',
help: 'Rename from'
},
RNTO: {
handler: require('./rnto'),
syntax: 'RNTO [name]',
help: 'Rename to'
},
SIZE: {
handler: require('./size'),
syntax: 'SIZE [path]',
help: 'Return the size of a file',
feat: 'SIZE'
},
STAT: {
handler: require('./stat'),
syntax: 'SIZE [path(optional)]',
help: 'Returns the current status'
},
SITE: {
handler: require('./site'),
syntax: 'SITE [subVerb] [subParams]',
help: 'Sends site specific commands to remote server'
},
OPTS: {
handler: require('./opts'),
syntax: 'OPTS',
help: 'Select options for a feature'
},
STRU: {
handler: require('./stru'),
syntax: 'STRU [structure]',
help: 'Set file transfer structure',
obsolete: true
},
ALLO: {
handler: require('./allo'),
syntax: 'ALLO',
help: 'Allocate sufficient disk space to receive a file',
obsolete: true
},
MODE: {
handler: require('./mode'),
syntax: 'MODE [mode]',
help: 'Sets the transfer mode (Stream, Block, or Compressed)',
obsolete: true
}
};

36
src/commands/retr.js Normal file
View File

@@ -0,0 +1,36 @@
const when = require('when');
module.exports = function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.read) return this.reply(402, 'Not supported by file system');
let dataSocket;
return this.connector.waitForConnection()
.then(socket => {
this.commandSocket.pause();
dataSocket = socket;
})
.then(() => when(this.fs.read(command._[1])))
.then(stream => {
return when.promise((resolve, reject) => {
dataSocket.on('error', err => stream.emit('error', err));
stream.on('data', data => dataSocket.write(data, this.encoding));
stream.on('end', () => resolve(this.reply(226)));
stream.on('error', err => reject(err));
this.reply(150).then(() => dataSocket.resume());
});
})
.catch(when.TimeoutError, err => {
log.error(err);
return this.reply(425, 'No connection established');
})
.catch(err => {
log.error(err);
return this.reply(551);
})
.finally(() => {
this.connector.end();
this.commandSocket.resume();
});
}

17
src/commands/rnfr.js Normal file
View File

@@ -0,0 +1,17 @@
const when = require('when');
module.exports = function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.get) return this.reply(402, 'Not supported by file system');
const fileName = command._[1];
return when(this.fs.get(fileName))
.then(() => {
this.renameFrom = fileName;
return this.reply(350);
})
.catch(err => {
log.error(err);
return this.reply(550);
})
}

23
src/commands/rnto.js Normal file
View File

@@ -0,0 +1,23 @@
const when = require('when');
module.exports = function ({log, command} = {}) {
if (!this.renameFrom) return this.reply(503);
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.rename) return this.reply(402, 'Not supported by file system');
const from = this.renameFrom;
const to = command._[1];
return when(this.fs.rename(from, to))
.then(() => {
return this.reply(250);
})
.catch(err => {
log.error(err);
return this.reply(550);
})
.finally(() => {
delete this.renameFrom;
});
}

View File

@@ -0,0 +1,14 @@
module.exports = function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.chmod) return this.reply(402, 'Not supported by file system');
const [, mode, fileName] = command._;
return this.fs.chmod(fileName, parseInt(mode, 8))
.then(() => {
return this.reply(200);
})
.catch(err => {
log.error(err);
return this.reply(500);
})
};

View File

@@ -0,0 +1,18 @@
const _ = require('lodash');
const when = require('when');
const registry = require('./registry');
module.exports = function ({log, command} = {}) {
let [, subverb, ...subparameters] = command._;
subverb = _.upperCase(subverb);
const subLog = log.child({subverb});
if (!registry.hasOwnProperty(subverb)) return this.reply(502);
const subCommand = {
_: [subverb, ...subparameters],
directive: subverb
}
const handler = registry[subverb].handler.bind(this);
return when.try(handler, { log: subLog, command: subCommand });
}

View File

@@ -0,0 +1,5 @@
module.exports = {
CHMOD: {
handler: require('./chmod')
}
};

15
src/commands/size.js Normal file
View File

@@ -0,0 +1,15 @@
const when = require('when');
module.exports = function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.get) return this.reply(402, 'Not supported by file system');
return when(this.fs.get(command._[1]))
.then(fileStat => {
return this.reply(213, {message: fileStat.size});
})
.catch(err => {
log.error(err);
return this.reply(550);
});
}

37
src/commands/stat.js Normal file
View File

@@ -0,0 +1,37 @@
const _ = require('lodash');
const when = require('when');
const getFileStat = require('../helpers/file-stat');
module.exports = function (args = {}) {
const {log, command} = args;
const path = command._[1];
if (path) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.get) return this.reply(402, 'Not supported by file system');
return when(this.fs.get(path))
.then(stat => {
if (stat.isDirectory()) {
return when(this.fs.list(path))
.then(files => {
const fileList = files.map(file => {
const message = getFileStat(file, _.get(this, 'server.options.file_format', 'ls'));
return {
raw: true,
message
};
})
return this.reply(213, 'Status begin', ...fileList, 'Status end');
})
} else {
return this.reply(212, getFileStat(stat, _.get(this, 'server.options.file_format', 'ls')))
}
})
.catch(err => {
log.error(err);
return this.reply(450);
})
} else {
return this.reply(211, 'Status OK');
}
}

38
src/commands/stor.js Normal file
View File

@@ -0,0 +1,38 @@
const when = require('when');
module.exports = function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.write) return this.reply(402, 'Not supported by file system');
const append = command.directive === 'APPE';
let dataSocket;
return this.connector.waitForConnection()
.then(socket => {
this.commandSocket.pause();
dataSocket = socket;
})
.then(() => when(this.fs.write(command._[1], {append})))
.then(stream => {
return when.promise((resolve, reject) => {
stream.on('error', err => dataSocket.emit('error', err));
dataSocket.on('end', () => resolve(this.reply(226)));
dataSocket.on('error', err => reject(err));
dataSocket.on('data', data => stream.write(data, this.encoding));
this.reply(150).then(() => dataSocket.resume());
});
})
.catch(when.TimeoutError, err => {
log.error(err);
return this.reply(425, 'No connection established');
})
.catch(err => {
log.error(err);
return this.reply(553);
})
.finally(() => {
this.connector.end();
this.commandSocket.resume();
});
}

3
src/commands/stru.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = function ({command} = {}) {
return this.reply(command._[1] === 'F' ? 200 : 504);
}

3
src/commands/syst.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = function () {
return this.reply(215);
}

15
src/commands/type.js Normal file
View File

@@ -0,0 +1,15 @@
const _ = require('lodash');
module.exports = function ({command} = {}) {
const encoding = _.upperCase(command._[1]);
switch (encoding) {
case 'A':
this.encoding = 'utf-8';
case 'I':
case 'L':
this.encoding = 'binary';
return this.reply(200);
default:
return this.reply(501);
}
}

15
src/commands/user.js Normal file
View File

@@ -0,0 +1,15 @@
module.exports = function ({log, command} = {}) {
if (this.username) return this.reply(530, 'Username already set');
this.username = command._[1];
if (this.server.options.anonymous === true) {
return this.login(this.username, '@anonymous')
.then(() => {
return this.reply(230);
})
.catch(err => {
log.error(err);
return this.reply(530, err || 'Authentication failed');
});
}
return this.reply(331);
};

View File

@@ -1,42 +1,78 @@
const _ = require('lodash');
const uuid = require('uuid');
const when = require('when');
const sequence = require('when/sequence');
const parseSentence = require('minimist-string')
const parseSentence = require('minimist-string');
const net = require('net');
const BaseConnector = require('./connector/base');
const FileSystem = require('./fs');
const Commands = require('./commands');
const errors = require('./errors');
const DEFAULT_MESSAGE = require('./messages');
class FtpConnection {
constructor(options} {
constructor(server, options) {
this.server = server;
this.commandSocket = options.socket;
this.commandSocket.ftp_session_id = uuid.v4();
this.log = options.log.child({ftp_session_id, this.commandSocket.ftp_session_id});
this.id = uuid.v4();
this.log = options.log.child({ftp_session_id: this.commandSocket.ftp_session_id});
this.commands = new Commands(this);
this.encoding = 'utf-8';
commandSocket.on('error', err => {
console.log('data', data)
this.connector = new BaseConnector(this);
this.commandSocket.on('error', err => {
console.log('error', err)
});
commandSocket.on('data', data => {
const messages = data.toString('utf8').split('\r\n');
return sequence(messages.map(message => {
this.commandSocket.on('data', data => {
const messages = _.compact(data.toString('utf-8').split('\r\n'));
const handleMessage = (message) => {
const command = parseSentence(message);
command.order = command._[0];
}));
});
commandSocket.on('timeout', () => {
console.log('data', data)
command.directive = _.upperCase(command._[0]);
return this.commands.handle(command);
};
return sequence(messages.map(message => handleMessage.bind(this, message)));
});
commandSocket.on('close', () => {
console.log('data', data)
this.commandSocket.on('timeout', () => {
console.log('timeout')
});
this.commandSocket.on('close', () => {
if (this.connector) this.connector.end();
if (this.commandSocket && !this.commandSocket.destroyed) this.commandSocket.destroy();
});
}
close(code = 421, message = 'Closing connection') {
return when(() => {
if (code) return this.reply(code, message);
})
.then(() => {
if (this.commandSocket) this.commandSocket.end();
});
}
login(username, password) {
return when.try(() => {
const loginListeners = this.server.listeners('login');
if (!loginListeners || !loginListeners.length) {
if (!this.server.options.anoymous) throw new errors.GeneralError('No "login" listener setup', 500);
} else {
return this.server.emit('login', {username, password});
}
})
.then(({fs} = {}) => {
this.authenticated = true;
this.fs = fs || new FileSystem(this);
});
}
reply(options = {}, ...letters) {
function satisfyParameters() {
if (typeof options === 'number') options = {code: optons}; // allow passing in code as first param
const satisfyParameters = () => {
if (typeof options === 'number') options = {code: options}; // allow passing in code as first param
if (!Array.isArray(letters)) letters = [letters];
if (!letters.length) letters = [{}];
return when.map(letters, (promise, index) => {
return when(promise)
.then(letter => {
@@ -44,9 +80,9 @@ class FtpConnection {
else if (typeof letter === 'string') letter = {message: letter}; // allow passing in message as first param
if (!letter.socket) letter.socket = options.socket ? options.socket : this.commandSocket;
if (!letter.message) letter.message = DEFAULT_MESSAGE[options.code];
if (!letter.encoding) letter.encoding = 'utf8';
return when(letter.message) // allow passing in a promise as a message
if (!letter.message) letter.message = DEFAULT_MESSAGE[options.code] || 'No information';
if (!letter.encoding) letter.encoding = this.encoding;
return when(letter.message) // allow passing in a promise as a message
.then(message => {
letter.message = message;
return letter;
@@ -55,16 +91,31 @@ class FtpConnection {
});
}
return satisfyParameters
.then(letters => sequence(letters.map((letter, index) => {
const seperator = letters.length - 1 === index ? ' ' : '-';
const packet = [options.code, letter.message].join(seperator);
const processLetter = (letter, index) => {
return when.promise((resolve, reject) => {
const seperator = !options.hasOwnProperty('eol') ?
(letters.length - 1 === index ? ' ' : '-') :
(options.eol ? ' ' : '-');
const packet = !letter.raw ? _.compact([letter.code || options.code, letter.message]).join(seperator) : letter.message;
if (letter.socket && letter.socket.writeable) {
letter.socket.write(packet + '\r\n', letter.encoding, err => {
if (err) throw err;
});
} else throw new Error('socket not writable');
})));
if (letter.socket && letter.socket.writable) {
this.log.trace({port: letter.socket.address().port, packet}, 'Reply');
letter.socket.write(packet + '\r\n', letter.encoding, err => {
if (err) {
this.log.error(err);
return reject(err);
}
resolve();
});
} else reject(new errors.SocketError('Socket not writable'));
});
}
return satisfyParameters()
.then(letters => sequence(letters.map((letter, index) => processLetter.bind(this, letter, index))))
.catch(err => {
this.log.error(err);
});
}
}
module.exports = FtpConnection;

36
src/connector/active.js Normal file
View File

@@ -0,0 +1,36 @@
const net = require('net');
const when = require('when');
const Connector = require('./base');
class Active extends Connector {
constructor(connection) {
super(connection);
this.type = 'active';
}
waitForConnection() {
return when.iterate(
() => {},
() => this.dataSocket && this.dataSocket.connected,
() => when().delay(250)
).timeout(5000)
.then(() => this.dataSocket);
}
setupConnection(host, port) {
const closeExistingServer = () => this.dataSocket ?
when(this.dataSocket.destroy()) :
when.resolve()
return closeExistingServer()
.then(() => {
this.dataSocket = new net.Socket();
this.dataSocket.setEncoding(this.encoding);
this.dataSocket.connect({ host, port }, () => {
this.dataSocket.pause();
this.dataSocket.connected = true;
});
});
}
}
module.exports = Active;

28
src/connector/base.js Normal file
View File

@@ -0,0 +1,28 @@
const when = require('when');
const errors = require('../errors');
class Connector {
constructor(connection) {
this.connection = connection;
this.server = connection.server;
this.log = connection.log;
this.dataSocket = null;
this.dataServer = null;
this.type = false;
}
waitForConnection() {
return when.reject(new errors.ConnectorError('No connector setup, send PASV or PORT'));
}
end() {
if (this.dataSocket) this.dataSocket.end();
if (this.dataServer) this.dataServer.close();
this.dataSocket = null;
this.dataServer = null;
this.connection.connector = new Connector(this.connection);
}
}
module.exports = Connector;

84
src/connector/passive.js Normal file
View File

@@ -0,0 +1,84 @@
const net = require('net');
const when = require('when');
const Connector = require('./base');
const findPort = require('../helpers/find-port');
const errors = require('../errors');
class Passive extends Connector {
constructor(connection) {
super(connection);
this.type = 'passive';
}
waitForConnection() {
if (!this.dataServer) {
return when.reject(new errors.ConnectorError('Passive server not setup'));
}
return when.iterate(
() => {},
() => this.dataServer && this.dataServer.listening && this.dataSocket,
() => when().delay(250)
).timeout(5000)
.then(() => this.dataSocket);
}
setupServer() {
const closeExistingServer = () => this.dataServer ?
when.promise(resolve => this.dataServer.close(() => resolve())) :
when.resolve()
return closeExistingServer()
.then(() => this.getPort())
.then(port => {
this.dataSocket = null;
this.dataServer = net.createServer({pauseOnConnect: true});
this.dataServer.maxConnections = 1;
this.dataServer.on('connection', socket => {
if (this.connection.commandSocket.remoteAddress !== socket.remoteAddress) {
this.log.error({
pasv_connection: socket.remoteAddress,
cmd_connection: this.connection.commandSocket.remoteAddress
}, 'Connecting addresses do not match');
socket.destroy();
return this.connection.reply(550, 'Remote addresses do not match')
.finally(() => this.connection.close());
}
this.log.info({port}, 'Passive connection fulfilled.');
this.dataSocket = socket;
this.dataSocket.setEncoding(this.connection.encoding);
this.dataSocket.on('data', data => {
});
this.dataSocket.on('close', () => {
this.end();
});
});
this.dataServer.on('close', () => {
this.dataServer = null;
});
return when.promise((resolve, reject) => {
this.dataServer.listen(port, err => {
if (err) reject(err);
else {
this.log.info({port}, 'Passive connection listening');
resolve(this.dataServer);
}
});
});
});
}
getPort() {
if (this.server.options.pasv_range) {
const [min, max] = typeof this.server.options.pasv_range === 'string' ?
this.server.options.pasv_range.split('-').map(v => v ? parseInt(v) : v) :
[this.server.options.pasv_range];
return findPort(min, max);
} else return undefined;
};
}
module.exports = Passive;

View File

@@ -0,0 +1,44 @@
class GeneralError extends Error {
constructor(message, code = 400) {
super();
this.code = code;
this.name = 'GeneralError';
this.message = message;
}
}
class SocketError extends Error {
constructor(message, code = 500) {
super();
this.code = code;
this.name = 'SocketError';
this.message = message;
}
}
class FileSystemError extends Error {
constructor(message, code = 400) {
super();
this.code = code;
this.name = 'FileSystemError';
this.message = message;
}
}
class ConnectorError extends Error {
constructor(message, code = 400) {
super();
this.code = code;
this.name = 'ConnectorError';
this.message = message;
}
}
module.exports = {
SocketError,
FileSystemError,
ConnectorError,
GeneralError
};

101
src/fs.js Normal file
View File

@@ -0,0 +1,101 @@
const _ = require('lodash');
const nodePath = require('path');
const when = require('when');
const whenNode = require('when/node');
const syncFs = require('fs');
const fs = whenNode.liftAll(syncFs);
const errors = require('./errors');
class FileSystem {
constructor(connection, {
cwd = '/'
} = {}) {
this.connection = connection;
this.cwd = cwd;
}
currentDirectory() {
return this.cwd;
}
get(fileName) {
const path = nodePath.resolve(this.cwd, fileName);
return fs.stat(path)
.then(stat => _.set(stat, 'name', fileName));
}
list(path = '.') {
path = nodePath.resolve(this.cwd, path);
return fs.readdir(path)
.then(fileNames => {
return when.map(fileNames, fileName => {
const filePath = nodePath.join(path, fileName);
return fs.access(filePath, syncFs.constants.F_OK)
.then(() => {
return fs.stat(filePath)
.then(stat => _.set(stat, 'name', fileName));
})
.catch(() => null);
});
})
.then(_.compact);
}
chdir(path = '.') {
path = nodePath.resolve(this.cwd, path);
return fs.stat(path)
.tap(stat => {
if (!stat.isDirectory()) throw new errors.FileSystemError('Not a valid directory');
})
.then(() => {
this.cwd = path;
return this.cwd;
});
}
write(fileName, {append = false} = {}) {
const path = nodePath.resolve(this.cwd, fileName);
const stream = syncFs.createWriteStream(path, {flags: !append ? 'w+' : 'a+'});
stream.on('error', () => fs.unlink(path));
return stream;
}
read(fileName) {
const path = nodePath.resolve(this.cwd, fileName);
return fs.stat(path)
.tap(stat => {
if (stat.isDirectory()) throw new errors.FileSystemError('Cannot read a directory');
})
.then(() => {
const stream = syncFs.createReadStream(path, {flags: 'r'});
return stream;
});
}
delete(fileName) {
const path = nodePath.resolve(this.cwd, fileName);
return fs.stat(path)
.then(stat => {
if (stat.isDirectory()) return fs.rmdir(path);
else return fs.unlink(path);
})
}
mkdir(path) {
path = nodePath.resolve(this.cwd, path);
return fs.mkdir(path)
.then(() => path);
}
rename(from, to) {
const fromPath = nodePath.resolve(this.cwd, from);
const toPath = nodePath.resolve(this.cwd, to);
return fs.rename(fromPath, toPath);
}
chmod(path, mode) {
path = nodePath.resolve(this.cwd, path);
return fs.chmod(path, mode);
}
}
module.exports = FileSystem;

View File

@@ -0,0 +1,4 @@
module.exports = function (path) {
return path
.replace(/"/g, '""');
}

52
src/helpers/file-stat.js Normal file
View File

@@ -0,0 +1,52 @@
const _ = require('lodash');
const format = require('date-fns/format');
const errors = require('../errors');
module.exports = function (fileStat, format = 'ls') {
if (typeof format === 'function') return format(fileStat);
const formats = {
'ls': ls,
'ep': ep
};
if (!formats.hasOwnProperty(format)) {
throw new errors.FileSystemError('Bad file stat formatter');
}
return formats[format](fileStat);
}
function ls(fileStat) {
return [
fileStat.mode !== null
? [
fileStat.isDirectory() ? 'd' : '-',
400 & fileStat.mode ? 'r' : '-',
200 & fileStat.mode ? 'w' : '-',
100 & fileStat.mode ? 'x' : '-',
40 & fileStat.mode ? 'r' : '-',
20 & fileStat.mode ? 'w' : '-',
10 & fileStat.mode ? 'x' : '-',
4 & fileStat.mode ? 'r' : '-',
2 & fileStat.mode ? 'w' : '-',
1 & fileStat.mode ? 'x' : '-'
].join('')
: fileStat.isDirectory() ? 'drwxr-xr-x' : '-rwxr-xr-x',
'1',
fileStat.uid,
fileStat.gid,
_.padStart(fileStat.size, 12),
_.padStart(format(fileStat.mtime, 'MMM DD HH:mm'), 12),
fileStat.name
].join(' ');
}
function ep(fileStat) {
const facts = [
fileStat.dev && fileStat.ino ? `i${fileStat.dev.toString(16)}.${fileStat.ino.toString(16)}` : null,
fileStat.size ? `s${fileStat.size}` : null,
fileStat.mtime ? `m${format(fileStat.mtime, 'X')}` : null,
fileStat.mode ? `up${fileStat.mode.toString(8).substr(fileStat.mode.toString(8).length - 3)}` : null,
fileStat.isDirectory() ? 'r' : '/'
].join(',');
return `+${facts}\t${fileStat.name}`;
}

27
src/helpers/find-port.js Normal file
View File

@@ -0,0 +1,27 @@
const net = require('net');
const when = require('when');
const errors = require('../errors');
module.exports = function (min = 22, max = undefined) {
return when.promise((resolve, reject) => {
let port = min;
let portCheckServer = net.createServer();
portCheckServer.maxConnections = 0;
portCheckServer.on('error', () => {
if (!max || port < max) {
port = port + 1;
portCheckServer.listen(port);
} else {
reject(new errors.GeneralError('Unable to find open port', 500));
}
})
portCheckServer.on('listening', () => {
const {port} = portCheckServer.address();
portCheckServer.close(() => {
portCheckServer = null;
resolve(port);
});
});
portCheckServer.listen(port);
});
};

View File

@@ -1,29 +1,56 @@
const _ = require('lodash');
const when = require('when');
const nodeUrl = require('url');
const buyan = require('bunyan');
const net = require('net');
const Connection = require('./connection');
class FtpServer {
constructor(options = {}) {
constructor(url, options = {}) {
this.options = _.merge({
url: 'http://127.0.0.1:21',
log: buyan.createLogger({name: 'ftp.js'}),
anonymous: false
anonymous: false,
pasv_range: 22,
file_format: 'ls'
}, options);
this.server = net.createServer({
pauseOnConnect: true
}, socket => {
let connection = new Connection({this.log, socket});
this.connections = {};
this.log = this.options.log;
this.url = nodeUrl.parse(url || 'ftp://127.0.0.1:21');
this.server = net.createServer({pauseOnConnect: true}, socket => {
let connection = new Connection(this, {log: this.log, socket});
this.connections[connection.id] = connection;
socket.on('close', () => this.disconnectClient(connection.id));
const greeting = this.getGreetingMessage();
const features = this.getFeaturesMessage();
return connection.reply(220, greeting, features);
return connection.reply(220, greeting, features)
.finally(() => socket.resume());
});
this.server.on('error', err => {
this.log.error(err);
});
this.server.on('')
this.on = this.server.on.bind(this.server);
this.listeners = this.server.listeners.bind(this.server);
}
listen() {
return when.promise((resolve, reject) => {
this.server.listen(this.url.port, err => {
if (err) return reject(err);
this.log.info({port: this.url.port}, 'Listening');
resolve();
});
});
}
emit(action, ...data) {
const defer = when.defer();
const params = _.concat(data, [defer.resolve, defer.reject]);
this.server.emit(action, ...params);
return defer.promise;
}
getGreetingMessage() {
@@ -51,5 +78,25 @@ class FtpServer {
}
}
disconnectClient(id) {
return when.promise((resolve, reject) => {
const client = this.connections[id];
if (!client) return resolve();
delete this.connections[id];
return client.close(0);
});
}
close() {
this.server.maxConnections = 0;
return when.map(Object.keys(this.connections), id => this.disconnectClient(id))
.then(() => when.promise((resolve, reject) => {
this.server.close(err => {
if (err) return reject(err);
resolve();
});
}));
}
}
module.exports = FtpServer;

View File

@@ -0,0 +1,56 @@
module.exports = {
// 100 - 199 :: Remarks
100: 'The requested action is being initiated',
110: 'Restart marker reply',
120: 'Service ready in %s minutes',
125: 'Data connection already open; transfer starting',
150: 'File status okay; about to open data connection',
// 200 - 399 :: Acceptance
/// 200 - 299 :: Positive Completion Replies
/// These type of replies indicate that the requested action was taken and that the server is awaiting another command.
200: 'The requested action has been successfully completed',
202: 'Superfluous command',
211: 'System status, or system help reply',
212: 'Directory status',
213: 'File status',
214: 'Help message', // On how to use the server or the meaning of a particular non-standard command. This reply is useful only to the human user.
215: 'UNIX Type: L8', // NAME system type. Where NAME is an official system name from the list in the Assigned Numbers document.
220: 'Service ready for new user',
221: 'Service closing control connection', // Logged out if appropriate.
225: 'Data connection open; no transfer in progress',
226: 'Closing data connection', // Requested file action successful (for example, file transfer or file abort).
227: 'Entering Passive Mode', // (h1,h2,h3,h4,p1,p2).
230: 'User logged in, proceed',
234: 'Honored',
250: 'Requested file action okay, completed',
257: '\'%s\' created',
/// 300 - 399 :: Positive Intermediate Replies
/// These types of replies indicate that the requested action was taken and that the server is awaiting further information to complete the request.
331: 'Username okay, awaiting password',
332: 'Need account for login',
350: 'Requested file action pending further information',
// 400 - 599 :: Rejection
/// 400 - 499 :: Transient Negative Completion Replies
/// These types of replies indicate that the command was not accepted; the requested action was not taken.
/// However, the error is temporary and the action may be requested again.
421: 'Service not available, closing control connection', // This may be a reply to any command if the service knows it must shut down.
425: 'Unable to open data connection',
426: 'Connection closed; transfer aborted',
450: 'Requested file action not taken', // File unavailable (e.g., file busy).
451: 'Requested action aborted. Local error in processing',
452: 'Requested action not taken. Insufficient storage',
/// 500 - 599 :: Permanent Negative Completion Replies
/// These types of replies indicate that the command was not accepted; the requested action was not taken.
/// The FTP client is "discouraged" from repeating the same exact request.
500: 'Syntax error', // Can close connection
501: 'Syntax error in parameters or arguments',
502: 'Command not supported',
503: 'Bad sequence of commands',
504: 'Command parameter not supported',
530: 'Not logged in', // Permission Denied, Can close connection
532: 'Need account for storing files',
550: 'Requested action not taken. File unavailable', // (e.g., file not found, no access).
551: 'Requested action aborted. Page type unknown',
552: 'Requested file action aborted. Exceeded storage allocation', // (for current directory or dataset).
553: 'Requested action not taken. File name not allowed'
};

View File

@@ -0,0 +1,30 @@
const when = require('when');
const {expect} = require('chai');
const sinon = require('sinon')
const CMD = 'ALLO';
describe(CMD, done => {
let sandbox;
const mockClient = {
reply: () => when.resolve()
};
const CMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox.spy(mockClient, 'reply');
});
afterEach(() => {
sandbox.restore();
});
it('// successful', done => {
CMDFN()
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(202)
done();
})
.catch(done);
})
});

View File

@@ -0,0 +1,48 @@
const when = require('when');
const {expect} = require('chai');
const sinon = require('sinon')
const CMD = 'AUTH';
describe(CMD, done => {
let sandbox;
const mockClient = {
reply: () => when.resolve()
};
const CMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox.spy(mockClient, 'reply');
});
afterEach(() => {
sandbox.restore();
});
it('TLS // not supported', done => {
CMDFN({command: {_: [CMD, 'TLS'], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(504)
done();
})
.catch(done);
});
it('SSL // not supported', done => {
CMDFN({command: {_: [CMD, 'SSL'], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(504)
done();
})
.catch(done);
});
it('bad // bad', done => {
CMDFN({command: {_: [CMD, 'bad'], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(504)
done();
})
.catch(done);
});
});

View File

@@ -0,0 +1,37 @@
const when = require('when');
const bunyan = require('bunyan');
const {expect} = require('chai');
const sinon = require('sinon');
const CMD = 'CDUP';
describe(CMD, done => {
let sandbox;
let log = bunyan.createLogger({name: CMD});
const mockClient = {
reply: () => when.resolve(),
fs: {
chdir: () => when.resolve()
}
};
const CMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox.spy(mockClient, 'reply');
sandbox.spy(mockClient.fs, 'chdir');
});
afterEach(() => {
sandbox.restore();
});
it('.. // successful', done => {
CMDFN({log, command: {_: [CMD], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(250);
expect(mockClient.fs.chdir.args[0][0]).to.equal('..');
done();
})
.catch(done);
});
});

87
test/commands/cwd.spec.js Normal file
View File

@@ -0,0 +1,87 @@
const when = require('when');
const bunyan = require('bunyan');
const {expect} = require('chai');
const sinon = require('sinon');
require('sinon-as-promised');
const CMD = 'CWD';
describe(CMD, done => {
let sandbox;
let log = bunyan.createLogger({name: CMD});
const mockClient = {
reply: () => {},
fs: { chdir: () => {} }
};
const CMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox.stub(mockClient, 'reply').resolves()
sandbox.stub(mockClient.fs, 'chdir').resolves();
});
afterEach(() => {
sandbox.restore();
});
describe('// check', function () {
it('fails on no fs', done => {
const badMockClient = { reply: () => {} };
const BADCMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
BADCMDFN()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(550);
done();
})
.catch(done);
});
it('fails on no fs chdir command', done => {
const badMockClient = { reply: () => {}, fs: {} };
const BADCMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
BADCMDFN()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(402);
done();
})
.catch(done);
});
});
it('test // successful', done => {
CMDFN({log, command: {_: [CMD, 'test'], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(250);
expect(mockClient.fs.chdir.args[0][0]).to.equal('test');
done();
})
.catch(done);
});
it('test // successful', done => {
mockClient.fs.chdir.restore();
sandbox.stub(mockClient.fs, 'chdir').resolves('/test');
CMDFN({log, command: {_: [CMD, 'test'], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(250);
expect(mockClient.fs.chdir.args[0][0]).to.equal('test');
done();
})
.catch(done);
});
it('bad // unsuccessful', done => {
mockClient.fs.chdir.restore();
sandbox.stub(mockClient.fs, 'chdir').rejects(new Error('Bad'))
CMDFN({log, command: {_: [CMD, 'bad'], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(550);
expect(mockClient.fs.chdir.args[0][0]).to.equal('bad');
done();
})
.catch(done);
});
});

View File

@@ -0,0 +1,75 @@
const when = require('when');
const bunyan = require('bunyan');
const {expect} = require('chai');
const sinon = require('sinon');
require('sinon-as-promised');
const CMD = 'DELE';
describe(CMD, done => {
let sandbox;
let log = bunyan.createLogger({name: CMD});
const mockClient = {
reply: () => {},
fs: { delete: () => {} }
};
const CMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox.stub(mockClient, 'reply').resolves();
sandbox.stub(mockClient.fs, 'delete').resolves();
});
afterEach(() => {
sandbox.restore();
});
describe('// check', function () {
it('fails on no fs', done => {
const badMockClient = { reply: () => {} };
const BADCMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
BADCMDFN()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(550);
done();
})
.catch(done);
});
it('fails on no fs delete command', done => {
const badMockClient = { reply: () => {}, fs: {} };
const BADCMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
BADCMDFN()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(402);
done();
})
.catch(done);
});
});
it('test // successful', done => {
CMDFN({log, command: {_: [CMD, 'test'], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(250);
expect(mockClient.fs.delete.args[0][0]).to.equal('test');
done();
})
.catch(done);
});
it('bad // unsuccessful', done => {
mockClient.fs.delete.restore();
sandbox.stub(mockClient.fs, 'delete').rejects(new Error('Bad'))
CMDFN({log, command: {_: [CMD, 'bad'], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(550);
expect(mockClient.fs.delete.args[0][0]).to.equal('bad');
done();
})
.catch(done);
});
});

View File

@@ -0,0 +1,57 @@
const when = require('when');
const {expect} = require('chai');
const sinon = require('sinon')
const CMD = 'HELP';
describe(CMD, done => {
let sandbox;
const mockClient = {
reply: () => when.resolve()
};
const CMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox.spy(mockClient, 'reply');
});
afterEach(() => {
sandbox.restore();
});
it('// successful', done => {
CMDFN({command: {_: [CMD], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(211);
done();
})
.catch(done);
});
it('help // successful', done => {
CMDFN({command: {_: [CMD, 'help'], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(214);
done();
})
.catch(done);
});
it('help // successful', done => {
CMDFN({command: {_: [CMD, 'allo'], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(214);
done();
})
.catch(done);
});
it('bad // unsuccessful', done => {
CMDFN({command: {_: [CMD, 'bad'], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(502);
done();
})
.catch(done);
});
});

116
test/commands/list.spec.js Normal file
View File

@@ -0,0 +1,116 @@
const when = require('when');
const bunyan = require('bunyan');
const {expect} = require('chai');
const sinon = require('sinon');
require('sinon-as-promised')(when.Promise);
const CMD = 'LIST';
describe(CMD, done => {
let sandbox;
let log = bunyan.createLogger({name: CMD});
const mockClient = {
reply: () => {},
fs: { list: () => {} },
connector: {
waitForConnection: () => {},
end: () => {}
},
commandSocket: {
resume: () => {},
pause: () => {}
}
};
const mockSocket = {};
const CMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox.stub(mockClient, 'reply').resolves();
sandbox.stub(mockClient.connector, 'waitForConnection').resolves(mockSocket);
sandbox.stub(mockClient.fs, 'list').resolves([{
name: 'test1',
dev: 2114,
ino: 48064969,
mode: 33188,
nlink: 1,
uid: 85,
gid: 100,
rdev: 0,
size: 527,
blksize: 4096,
blocks: 8,
atime: 'Mon, 10 Oct 2011 23:24:11 GMT',
mtime: 'Mon, 10 Oct 2011 23:24:11 GMT',
ctime: 'Mon, 10 Oct 2011 23:24:11 GMT',
birthtime: 'Mon, 10 Oct 2011 23:24:11 GMT',
isDirectory: () => false
}]);
});
afterEach(() => {
sandbox.restore();
});
describe('// check', function () {
it('fails on no fs', done => {
const badMockClient = { reply: () => {} };
const BADCMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
BADCMDFN()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(550);
done();
})
.catch(done);
});
it('fails on no fs list command', done => {
const badMockClient = { reply: () => {}, fs: {} };
const BADCMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
BADCMDFN()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(402);
done();
})
.catch(done);
});
});
it('. // successful', done => {
CMDFN({log, command: {_: [CMD], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(150);
expect(mockClient.reply.args[1][0]).to.have.property('raw');
expect(mockClient.reply.args[1][0]).to.have.property('message');
expect(mockClient.reply.args[1][0]).to.have.property('socket');
expect(mockClient.reply.args[2][0]).to.equal(226);
done();
})
.catch(done);
});
it('. // unsuccessful', done => {
mockClient.fs.list.restore();
sandbox.stub(mockClient.fs, 'list').rejects(new Error());
CMDFN({log, command: {_: [CMD], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(451);
done();
})
.catch(done);
});
it('. // unsuccessful (timeout)', done => {
mockClient.connector.waitForConnection.restore();
sandbox.stub(mockClient.connector, 'waitForConnection').rejects(new when.TimeoutError());
CMDFN({log, command: {_: [CMD], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(425);
done();
})
.catch(done);
});
});

View File

@@ -0,0 +1,75 @@
const when = require('when');
const bunyan = require('bunyan');
const {expect} = require('chai');
const sinon = require('sinon');
require('sinon-as-promised')(when.Promise);
const CMD = 'MDTM';
describe(CMD, done => {
let sandbox;
let log = bunyan.createLogger({name: CMD});
const mockClient = {
reply: () => {},
fs: { get: () => {} }
};
const mockSocket = {};
const CMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox.stub(mockClient, 'reply').resolves();
sandbox.stub(mockClient.fs, 'get').resolves({mtime: 'Mon, 10 Oct 2011 23:24:11 GMT'});
});
afterEach(() => {
sandbox.restore();
});
describe('// check', function () {
it('fails on no fs', done => {
const badMockClient = { reply: () => {} };
const BADCMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
BADCMDFN()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(550);
done();
})
.catch(done);
});
it('fails on no fs get command', done => {
const badMockClient = { reply: () => {}, fs: {} };
const BADCMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
BADCMDFN()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(402);
done();
})
.catch(done);
});
});
it('. // successful', done => {
CMDFN({log, command: {_: [CMD], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(213);
//expect(mockClient.reply.args[0][1]).to.equal('20111010172411.000');
done();
})
.catch(done);
});
it('. // unsuccessful', done => {
mockClient.fs.get.restore();
sandbox.stub(mockClient.fs, 'get').rejects(new Error());
CMDFN({log, command: {_: [CMD], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(550);
done();
})
.catch(done);
});
});

87
test/commands/mkd.spec.js Normal file
View File

@@ -0,0 +1,87 @@
const when = require('when');
const bunyan = require('bunyan');
const {expect} = require('chai');
const sinon = require('sinon');
require('sinon-as-promised');
const CMD = 'MKD';
describe(CMD, done => {
let sandbox;
let log = bunyan.createLogger({name: CMD});
const mockClient = {
reply: () => {},
fs: { mkdir: () => {} }
};
const CMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox.stub(mockClient, 'reply').resolves();
sandbox.stub(mockClient.fs, 'mkdir').resolves();
});
afterEach(() => {
sandbox.restore();
});
describe('// check', function () {
it('fails on no fs', done => {
const badMockClient = { reply: () => {} };
const BADCMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
BADCMDFN()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(550);
done();
})
.catch(done);
});
it('fails on no fs mkdir command', done => {
const badMockClient = { reply: () => {}, fs: {} };
const BADCMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
BADCMDFN()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(402);
done();
})
.catch(done);
});
});
it('test // successful', done => {
CMDFN({log, command: {_: [CMD, 'test'], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(257);
expect(mockClient.fs.mkdir.args[0][0]).to.equal('test');
done();
})
.catch(done);
});
it('test // successful', done => {
mockClient.fs.mkdir.restore();
sandbox.stub(mockClient.fs, 'mkdir').resolves('test');
CMDFN({log, command: {_: [CMD, 'test'], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(257);
expect(mockClient.fs.mkdir.args[0][0]).to.equal('test');
done();
})
.catch(done);
});
it('bad // unsuccessful', done => {
mockClient.fs.mkdir.restore();
sandbox.stub(mockClient.fs, 'mkdir').rejects(new Error('Bad'))
CMDFN({log, command: {_: [CMD, 'bad'], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(550);
expect(mockClient.fs.mkdir.args[0][0]).to.equal('bad');
done();
})
.catch(done);
});
});

View File

@@ -0,0 +1,39 @@
const when = require('when');
const {expect} = require('chai');
const sinon = require('sinon')
const CMD = 'MODE';
describe(CMD, done => {
let sandbox;
const mockClient = {
reply: () => when.resolve()
};
const CMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox.spy(mockClient, 'reply');
});
afterEach(() => {
sandbox.restore();
});
it('S // successful', done => {
CMDFN({command: {_: [CMD, 'S']}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(200)
done();
})
.catch(done);
});
it('Q // unsuccessful', done => {
CMDFN({command: {_: [CMD, 'Q']}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(504)
done();
})
.catch(done);
});
});

View File

@@ -0,0 +1,66 @@
const when = require('when');
const bunyan = require('bunyan');
const {expect} = require('chai');
const sinon = require('sinon');
require('sinon-as-promised')(when.Promise);
const CMD = 'NLST';
describe(CMD, done => {
let sandbox;
let log = bunyan.createLogger({name: CMD});
const mockClient = {
reply: () => {},
fs: { list: () => {} },
connector: {
waitForConnection: () => {},
end: () => {}
},
commandSocket: {
resume: () => {},
pause: () => {}
}
};
const mockSocket = {};
const CMDFN = require(`../../src/commands/list`).bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox.stub(mockClient, 'reply').resolves();
sandbox.stub(mockClient.connector, 'waitForConnection').resolves(mockSocket);
sandbox.stub(mockClient.fs, 'list').resolves([{
name: 'test1',
dev: 2114,
ino: 48064969,
mode: 33188,
nlink: 1,
uid: 85,
gid: 100,
rdev: 0,
size: 527,
blksize: 4096,
blocks: 8,
atime: 'Mon, 10 Oct 2011 23:24:11 GMT',
mtime: 'Mon, 10 Oct 2011 23:24:11 GMT',
ctime: 'Mon, 10 Oct 2011 23:24:11 GMT',
birthtime: 'Mon, 10 Oct 2011 23:24:11 GMT',
isDirectory: () => false
}]);
});
afterEach(() => {
sandbox.restore();
});
it('. // successful', done => {
CMDFN({log, command: {_: [CMD], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(150);
expect(mockClient.reply.args[1][0]).to.have.property('raw');
expect(mockClient.reply.args[1][0]).to.have.property('message');
expect(mockClient.reply.args[1][0]).to.have.property('socket');
expect(mockClient.reply.args[2][0]).to.equal(226);
done();
})
.catch(done);
});
});

View File

@@ -0,0 +1,30 @@
const when = require('when');
const {expect} = require('chai');
const sinon = require('sinon')
const CMD = 'NOOP';
describe(CMD, done => {
let sandbox;
const mockClient = {
reply: () => when.resolve()
};
const CMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox.spy(mockClient, 'reply');
});
afterEach(() => {
sandbox.restore();
});
it('// successful', done => {
CMDFN()
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(200)
done();
})
.catch(done);
})
});

View File

@@ -0,0 +1,30 @@
const when = require('when');
const {expect} = require('chai');
const sinon = require('sinon')
const CMD = 'OPTS';
describe(CMD, done => {
let sandbox;
const mockClient = {
reply: () => when.resolve()
};
const CMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox.spy(mockClient, 'reply');
});
afterEach(() => {
sandbox.restore();
});
it('// successful', done => {
CMDFN()
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(501)
done();
})
.catch(done);
})
});

View File

@@ -0,0 +1,86 @@
const when = require('when');
const bunyan = require('bunyan');
const {expect} = require('chai');
const sinon = require('sinon');
require('sinon-as-promised');
const CMD = 'PASS';
describe(CMD, done => {
let sandbox;
let log = bunyan.createLogger({name: CMD});
const mockClient = {
reply: () => {},
login: () => {},
server: { options: { anonymous: false } },
username: 'user'
};
const CMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox.stub(mockClient, 'reply').resolves();
sandbox.stub(mockClient, 'login').resolves();
});
afterEach(() => {
sandbox.restore();
});
it('pass // successful', done => {
CMDFN({log, command: {_: [CMD, 'pass'], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(230);
expect(mockClient.login.args[0]).to.eql(['user', 'pass']);
done();
})
.catch(done);
});
it('// successful (anonymous)', done => {
mockClient.server.options.anonymous = true;
mockClient.authenticated = true;
CMDFN({log, command: {_: [CMD], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(230);
expect(mockClient.login.callCount).to.equal(0);
mockClient.server.options.anonymous = false;
mockClient.authenticated = false;
done();
})
.catch(done);
});
it('bad // unsuccessful', done => {
mockClient.login.restore();
sandbox.stub(mockClient, 'login').rejects('bad');
CMDFN({log, command: {_: [CMD, 'bad'], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(530);
done();
})
.catch(done);
});
it('bad // unsuccessful', done => {
mockClient.login.restore();
sandbox.stub(mockClient, 'login').rejects({})
CMDFN({log, command: {_: [CMD, 'bad'], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(530);
done();
})
.catch(done);
});
it('bad // unsuccessful', done => {
delete mockClient.username;
CMDFN({log, command: {_: [CMD, 'bad'], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(503);
done();
})
.catch(done);
});
});

85
test/commands/pwd.spec.js Normal file
View File

@@ -0,0 +1,85 @@
const when = require('when');
const bunyan = require('bunyan');
const {expect} = require('chai');
const sinon = require('sinon');
require('sinon-as-promised');
const CMD = 'PWD';
describe(CMD, done => {
let sandbox;
let log = bunyan.createLogger({name: CMD});
const mockClient = {
reply: () => {},
fs: { currentDirectory: () => {} }
};
const CMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox.stub(mockClient, 'reply').resolves()
sandbox.stub(mockClient.fs, 'currentDirectory').resolves();
});
afterEach(() => {
sandbox.restore();
});
describe('// check', function () {
it('fails on no fs', done => {
const badMockClient = { reply: () => {} };
const BADCMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
BADCMDFN()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(550);
done();
})
.catch(done);
});
it('fails on no fs currentDirectory command', done => {
const badMockClient = { reply: () => {}, fs: {} };
const BADCMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
BADCMDFN()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(402);
done();
})
.catch(done);
});
});
it('// successful', done => {
CMDFN({log, command: {_: [CMD, 'test'], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(257);
done();
})
.catch(done);
});
it('// successful', done => {
mockClient.fs.currentDirectory.restore();
sandbox.stub(mockClient.fs, 'currentDirectory').resolves('/test')
CMDFN({log, command: {_: [CMD, 'test'], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(257);
done();
})
.catch(done);
});
it('// unsuccessful', done => {
mockClient.fs.currentDirectory.restore();
sandbox.stub(mockClient.fs, 'currentDirectory').rejects(new Error('Bad'))
CMDFN({log, command: {_: [CMD, 'bad'], directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(550);
done();
})
.catch(done);
});
});

View File

@@ -0,0 +1,33 @@
const when = require('when');
const bunyan = require('bunyan');
const {expect} = require('chai');
const sinon = require('sinon');
require('sinon-as-promised');
const CMD = 'QUIT';
describe(CMD, done => {
let sandbox;
let log = bunyan.createLogger({name: CMD});
const mockClient = {
close: () => {}
};
const CMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox.stub(mockClient, 'close').resolves();
});
afterEach(() => {
sandbox.restore();
});
it('// successful', done => {
CMDFN()
.then(() => {
expect(mockClient.close.callCount).to.equal(1);
done();
})
.catch(done);
});
});

View File

@@ -0,0 +1,11 @@
const {expect} = require('chai');
const escapePath = require('../../src/helpers/escape-path');
describe('helpers // escape-path', function () {
it('escapes quotes', done => {
const string = '"test"';
const escapedString = escapePath(string);
expect(escapedString).to.equal('""test""');
done();
});
});

View File

@@ -0,0 +1,53 @@
const {expect} = require('chai');
const fileStat = require('../../src/helpers/file-stat');
const errors = require('../../src/errors');
describe.skip('helpers // file-stat', function () {
const STAT = {
name: 'test1',
dev: 2114,
ino: 48064969,
mode: 33188,
nlink: 1,
uid: 85,
gid: 100,
rdev: 0,
size: 527,
blksize: 4096,
blocks: 8,
atime: 'Mon, 10 Oct 2011 23:24:11 GMT',
mtime: 'Mon, 10 Oct 2011 23:24:11 GMT',
ctime: 'Mon, 10 Oct 2011 23:24:11 GMT',
birthtime: 'Mon, 10 Oct 2011 23:24:11 GMT',
isDirectory: () => false
};
describe('format - ls //', function () {
it('formats correctly', () => {
const format = fileStat(STAT, 'ls');
expect(format).to.equal('-rwxrw-r-- 1 85 100 527 Oct 10 17:24 test1');
});
});
describe('format - ep //', function () {
it('formats correctly', () => {
const format = fileStat(STAT, 'ep');
expect(format).to.equal('+i842.2dd69c9,s527,m1318289051,up644,/ test1');
});
});
describe('format - custom //', function () {
it('fails on unknown format string', () => {
const format = fileStat.bind(this, STAT, 'bad');
expect(format).to.throw(errors.FileSystemError);
});
it('formats correctly', () => {
function customerFormater(fileStat) {
return [fileStat.gid, fileStat.name, fileStat.size].join('\t');
}
const format = fileStat(STAT, customerFormater);
expect(format).to.equal('100\ttest1\t527');
})
});
});

View File

@@ -0,0 +1,36 @@
const {expect} = require('chai');
const {Server} = require('net');
const sinon = require('sinon');
const findPort = require('../../src/helpers/find-port');
describe('helpers // find-port', function () {
let sandbox;
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox.spy(Server.prototype, 'listen')
});
afterEach(() => {
sandbox.restore();
});
it('finds a port', done => {
findPort(1)
.then(port => {
expect(Server.prototype.listen.callCount).to.be.above(1);
expect(port).to.be.above(1);
done();
})
.catch(done);
});
it('does not find a port', done => {
findPort(1, 2)
.then(() => done('no'))
.catch(err => {
done();
});
});
});

View File

@@ -1,10 +1,231 @@
const FtpServer = require('../lib');
require('dotenv').load();
const _ = require('lodash');
const {expect} = require('chai');
const bunyan = require('bunyan');
const fs = require('fs');
const sinon = require('sinon');
describe('FtpServer', () => {
const FtpServer = require('../src');
const FtpCommands = require('../src/commands');
const FtpClient = require('ftp');
describe('FtpServer', function () {
this.timeout(2000);
let log = bunyan.createLogger({name: 'test', level: 10});
let server;
it('server listens for connections', done => {
server = new FtpServer({
url: process.env.FTP_URL
let client;
before(done => {
server = new FtpServer(process.env.FTP_URL, {
log,
pasv_range: process.env.PASV_RANGE
});
})
server.on('login', (data, resolve, reject) => {
resolve();
});
process.on('SIGINT', function() {
server.close();
});
require('child_process').exec(`sudo kill $(sudo lsof -t -i:${server.url.port})`, () => {
server.listen()
.finally(() => done());
});
});
after(() => {
server.close();
});
it('accepts client connection', done => {
expect(server).to.exist;
client = new FtpClient();
client.once('ready', () => done());
client.connect({
host: server.url.hostname,
port: server.url.port,
user: 'test',
password: 'test'
});
});
it('STAT', done => {
client.status((err, status) => {
expect(err).to.not.exist;
expect(status).to.be.a('string');
done();
});
});
it('SYST', done => {
client.system((err, os) => {
expect(err).to.not.exist;
expect(os).to.be.a('string');
done();
});
});
it('CWD process.cwd()', done => {
const dir = require('path').resolve(process.cwd(), 'test');
client.cwd(`${dir}`, (err, data) => {
expect(err).to.not.exist;
expect(data).to.be.a('string');
done();
});
});
it('PWD', done => {
client.pwd((err, data) => {
expect(err).to.not.exist;
expect(data).to.be.a('string');
done();
});
});
it('LIST .', done => {
client.list('.', (err, data) => {
expect(err).to.not.exist;
expect(data).to.be.an('array');
done();
});
});
const runFileSystemTests = () => {
it('STOR test.txt', done => {
const buffer = Buffer.from('test text file');
client.put(buffer, 'test.txt', err => {
expect(err).to.not.exist;
expect(fs.existsSync('./test/test.txt')).to.equal(true);
fs.readFile('./test/test.txt', (fserr, data) => {
expect(fserr).to.not.exist;
expect(data.toString()).to.equal('test text file');
done();
});
});
})
it('APPE test.txt', done => {
const buffer = Buffer.from(', awesome!');
client.append(buffer, 'test.txt', err => {
expect(err).to.not.exist;
fs.readFile('./test/test.txt', (fserr, data) => {
expect(fserr).to.not.exist;
expect(data.toString()).to.equal('test text file, awesome!');
done();
});
});
});
it('RETR test.txt', done => {
client.get('test.txt', (err, stream) => {
expect(err).to.not.exist;
let text = '';
stream.on('data', data => {
text += data.toString();
});
stream.on('end', () => {
expect(text).to.equal('test text file, awesome!');
done();
});
});
});
it('RNFR test.txt, RNTO awesome.txt', done => {
client.rename('test.txt', 'awesome.txt', err => {
expect(err).to.not.exist;
expect(fs.existsSync('./test/test.txt')).to.equal(false);
expect(fs.existsSync('./test/awesome.txt')).to.equal(true);
fs.readFile('./test/awesome.txt', (fserr, data) => {
expect(fserr).to.not.exist;
expect(data.toString()).to.equal('test text file, awesome!');
done();
});
});
});
it('SIZE awesome.txt', done => {
client.size('awesome.txt', (err, size) => {
expect(err).to.not.exist;
expect(size).to.be.a('number');
done();
});
});
it('MDTM awesome.txt', done => {
client.lastMod('awesome.txt', (err, date) => {
expect(err).to.not.exist;
done();
});
});
it('SITE CHMOD 700 awesome.txt', done => {
client.site('CHMOD 600 awesome.txt', (err) => {
expect(err).to.not.exist;
fs.stat('./test/awesome.txt', (fserr, stats) => {
expect(fserr).to.not.exist;
const mode = stats.mode.toString(8);
expect(/600$/.test(mode)).to.equal(true);
done();
});
});
});
it('DELE awesome.txt', done => {
client.delete('awesome.txt', err => {
expect(err).to.not.exist;
expect(fs.existsSync('./test/awesome.txt')).to.equal(false);
done();
});
});
}
it('TYPE A', done => {
client.ascii(err => {
expect(err).to.not.exist;
done();
});
});
runFileSystemTests();
it('TYPE I', done => {
client.binary(err => {
expect(err).to.not.exist;
done();
});
});
runFileSystemTests();
it('MKD tmp', done => {
if (fs.existsSync('./test/tmp')) {
fs.rmdirSync('./test/tmp');
}
client.mkdir('tmp', err => {
expect(err).to.not.exist;
expect(fs.existsSync('./test/tmp')).to.equal(true);
done();
});
});
it('CWD tmp', done => {
client.cwd('tmp', (err, data) => {
expect(err).to.not.exist;
expect(data).to.be.a('string');
done();
});
});
it('CDUP', done => {
client.cdup(err => {
expect(err).to.not.exist;
done();
});
});
it('RMD tmp', done => {
client.rmdir('tmp', err => {
expect(err).to.not.exist;
expect(fs.existsSync('./test/tmp')).to.equal(false);
done();
});
});
});

15
test/start.js Normal file
View File

@@ -0,0 +1,15 @@
require('dotenv').load();
const bunyan = require('bunyan');
const FtpServer = require('../src');
const log = bunyan.createLogger({name: 'test', level: 10});
const server = new FtpServer(process.env.FTP_URL, {
log,
pasv_range: process.env.PASV_RANGE
});
server.on('login', ({username, password}, resolve, reject) => {
if (username === 'test' && password === 'test') resolve();
else reject('Bad username or password');
});
server.listen();