diff --git a/lib/protocol/SFTP.js b/lib/protocol/SFTP.js index 9f33c021..54d1b07c 100644 --- a/lib/protocol/SFTP.js +++ b/lib/protocol/SFTP.js @@ -285,6 +285,14 @@ class SFTP extends EventEmitter { } destroy() { if (this.outgoing.state === 'open' || this.outgoing.state === 'eof') { + // When running as a server, send exit-status and EOF before closing + // the channel. This matches OpenSSH behavior where the sftp-server + // process exits with code 0, triggering exit-status + EOF + close. + // Without this, clients like SCP/Mutagen see exit code -1. + if (this.server && this.outgoing.state === 'open') { + this._protocol.exitStatus(this.outgoing.id, 0); + this._protocol.channelEOF(this.outgoing.id); + } this.outgoing.state = 'closing'; this._protocol.channelClose(this.outgoing.id); } diff --git a/test/test-sftp.js b/test/test-sftp.js index 595e5091..e2c646cd 100644 --- a/test/test-sftp.js +++ b/test/test-sftp.js @@ -755,6 +755,39 @@ setup('WriteStream', mustCall((client, server) => { })); } + +{ + const { client, server } = setup_( + 'SFTP server destroy() sends exit-status 0', + { + client: { username: 'foo', password: 'bar' }, + server: { hostKeys: [ fixture('ssh_host_rsa_key') ] }, + }, + ); + + server.on('connection', mustCall((conn) => { + conn.on('authentication', mustCall((ctx) => { + ctx.accept(); + })).on('ready', mustCall(() => { + conn.on('session', mustCall((accept, reject) => { + accept().on('sftp', mustCall((accept, reject) => { + const sftp = accept(); + sftp.destroy(); + })); + })); + })); + })); + + client.on('ready', mustCall(() => { + const timeout = setTimeout(mustNotCall(), 1000); + client.sftp(mustCall((err, sftp) => { + clearTimeout(timeout); + assert(err, 'Expected error'); + assert(err.code === 0, `Expected exit code 0, saw: ${err.code}`); + client.end(); + })); + })); +} { const { client, server } = setup_( 'SFTP client sets environment',