diff --git a/.gitignore b/.gitignore index 336b955..831a3ab 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,6 @@ doc/api/ .history -.vscode \ No newline at end of file +.vscode + +coverage/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f984f0d..8d6bd2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,16 @@ -## [2.14.0] - 2026-03-19 -- Fixed SSH connections through bastion hosts where the target server sends its version string immediately upon connection (which is standard behavior per RFC 4253) [#141]. Thanks @shihuili1218. -- Adds a new forwardLocalUnix() function, which is an equivalent of ssh -L localPort:remoteSocketPath [#140]. Thanks @isegal. +## [2.15.0] - 2026-03-30 +- Updated `pointycastle` dependency to `^4.0.0` [#131]. Thanks [@vicajilau]. +- Added foundational X11 forwarding support with session x11-req API, incoming x11 channel handling, and protocol tests [#1]. Thanks [@vicajilau]. +- Exposed SSH ident configuration from `SSHClient` [#135]. Thanks [@Remulic] and [@vicajilau]. +- Propagated the underlying exception in `SSHAuthAbortError` through `reason` for better diagnostics [#133]. Thanks [@james-thorpe] and [@vicajilau]. +- Accepted `SSH-1.99-*` server banners as SSH-2 compatible during version exchange and added regression tests [#132]. Thanks [@james-thorpe] and [@vicajilau]. +- Added SSH agent forwarding support (`auth-agent-req@openssh.com`) with in-memory agent handling and RSA sign-request flag support [#139]. Thanks [@Wackymax] and [@vicajilau]. +- Normalized HTTP response line parsing in `SSHHttpClientResponse` to handle CRLF endings consistently and avoid trailing line-ending artifacts in parsed status/header fields [#145]. Thanks [@vicajilau]. +- Fixed SFTP packet encoding/decoding consistency: `SftpInitPacket.decode` now parses extension pairs correctly and `SftpExtendedReplyPacket.encode` now preserves raw payload bytes [#145]. Thanks [@vicajilau]. +## [2.14.0] - 2026-03-19 +- Fixed SSH connections through bastion hosts where the target server sends its version string immediately upon connection (which is standard behavior per RFC 4253) [#141]. Thanks [@shihuili1218]. +- Adds a new forwardLocalUnix() function, which is an equivalent of ssh -L localPort:remoteSocketPath [#140]. Thanks [@isegal]. ## [2.13.0] - 2025-06-22 - docs: Update NoPorts naming [#115]. [@XavierChanth]. @@ -176,8 +185,13 @@ - Initial release. +[#145]: https://github.com/TerminalStudio/dartssh2/pull/145 [#141]: https://github.com/TerminalStudio/dartssh2/pull/141 [#140]: https://github.com/TerminalStudio/dartssh2/pull/140 +[#139]: https://github.com/TerminalStudio/dartssh2/pull/139 +[#133]: https://github.com/TerminalStudio/dartssh2/pull/133 +[#132]: https://github.com/TerminalStudio/dartssh2/pull/132 +[#131]: https://github.com/TerminalStudio/dartssh2/pull/131 [#127]: https://github.com/TerminalStudio/dartssh2/pull/127 [#126]: https://github.com/TerminalStudio/dartssh2/pull/126 [#125]: https://github.com/TerminalStudio/dartssh2/pull/125 @@ -202,4 +216,10 @@ [@XavierChanth]: https://github.com/XavierChanth [@MarBazuz]: https://github.com/MarBazuz [@reinbeumer]: https://github.com/reinbeumer -[@alexander-irion]: https://github.com/alexander-irion \ No newline at end of file +[@alexander-irion]: https://github.com/alexander-irion +[@Remulic]: https://github.com/Remulic +[@james-thorpe]: https://github.com/james-thorpe +[@Wackymax]: https://github.com/Wackymax +[@vicajilau]: https://github.com/vicajilau +[@shihuili1218]: https://github.com/shihuili1218 +[@isegal]: https://github.com/isegal diff --git a/README.md b/README.md index 85bbaa3..4086fe3 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,24 @@ void main() async { > `SSHSocket` is an interface and it's possible to implement your own `SSHSocket` if you want to use a different underlying transport rather than standard TCP socket. For example WebSocket or Unix domain socket. +### Customize client SSH identification + +If your jump host or SSH gateway restricts client versions, you can customize the +software version part of the identification string (`SSH-2.0-`): + +```dart +void main() async { + final client = SSHClient( + await SSHSocket.connect('localhost', 22), + username: '', + onPasswordRequest: () => '', + ident: 'MyClient_1.0', + ); +} +``` + +`ident` defaults to `DartSSH_2.0`. + ### Spawn a shell on remote host ```dart diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..b12366a --- /dev/null +++ b/codecov.yml @@ -0,0 +1,19 @@ +coverage: + status: + project: + default: + target: auto + threshold: 5% + flags: + - unittests + patch: + default: + target: auto + threshold: 2% + flags: + - unittests + +comment: + layout: "reach,diff,flags,files" + behavior: default + require_changes: true diff --git a/dart_test.yaml b/dart_test.yaml new file mode 100644 index 0000000..49921b8 --- /dev/null +++ b/dart_test.yaml @@ -0,0 +1,3 @@ +tags: + integration: + timeout: 2x diff --git a/lib/dartssh2.dart b/lib/dartssh2.dart index f917e06..a564fed 100644 --- a/lib/dartssh2.dart +++ b/lib/dartssh2.dart @@ -1,4 +1,5 @@ export 'src/ssh_algorithm.dart' show SSHAlgorithms; +export 'src/ssh_agent.dart'; export 'src/ssh_client.dart'; export 'src/ssh_errors.dart'; export 'src/ssh_forward.dart'; diff --git a/lib/src/algorithm/ssh_mac_type.dart b/lib/src/algorithm/ssh_mac_type.dart index de37ab3..98b80f9 100644 --- a/lib/src/algorithm/ssh_mac_type.dart +++ b/lib/src/algorithm/ssh_mac_type.dart @@ -57,7 +57,6 @@ class SSHMacType extends SSHAlgorithm { macFactory: _hmacSha512Factory, isEtm: true, ); - // end added by Rein const SSHMacType._({ required this.name, diff --git a/lib/src/http/http_client.dart b/lib/src/http/http_client.dart index 941a99b..dc0c103 100644 --- a/lib/src/http/http_client.dart +++ b/lib/src/http/http_client.dart @@ -423,8 +423,7 @@ class SSHHttpClientResponse { var currentChunkSize = 0; void processLine(String line, int bytesRead, LineDecoder decoder) { - if (finished) return; - + final normalizedLine = line.trimRight(); if (inBody) { if (chunked) { if (expectingChunkSize) { @@ -482,7 +481,7 @@ class SSHHttpClientResponse { contentRead += bytesRead; } } else if (inHeader) { - if (line.trim().isEmpty) { + if (normalizedLine.trim().isEmpty) { inBody = true; // Decide body framing final te = headers[SSHHttpHeaders.transferEncodingHeader] @@ -499,15 +498,17 @@ class SSHHttpClientResponse { } return; } - final separator = line.indexOf(':'); - final name = line.substring(0, separator).toLowerCase().trim(); - final value = line.substring(separator + 1).trim(); - if (name == SSHHttpHeaders.transferEncodingHeader) { - // Allow only identity or chunked; others unsupported - final v = value.toLowerCase(); - if (!(v == 'identity' || v.contains('chunked'))) { - throw UnsupportedError('unsupported transfer encoding: $value'); - } + final separator = normalizedLine.indexOf(':'); + if (separator <= 0) { + throw FormatException( + 'Invalid header line: "$normalizedLine" - no colon separator found'); + } + final name = + normalizedLine.substring(0, separator).toLowerCase().trim(); + final value = normalizedLine.substring(separator + 1).trim(); + if (name == SSHHttpHeaders.transferEncodingHeader && + value.toLowerCase() != 'identity') { + throw UnsupportedError('only identity transfer encoding is accepted'); } if (name == SSHHttpHeaders.contentLengthHeader) { contentLength = int.parse(value); @@ -516,11 +517,12 @@ class SSHHttpClientResponse { headers[name] = []; } headers[name]!.add(value); - } else if (line.startsWith('HTTP/1.1') || line.startsWith('HTTP/1.0')) { + } else if (normalizedLine.startsWith('HTTP/1.1') || + normalizedLine.startsWith('HTTP/1.0')) { statusCode = int.parse( - line.substring('HTTP/1.x '.length, 'HTTP/1.x xxx'.length), + normalizedLine.substring('HTTP/1.x '.length, 'HTTP/1.x xxx'.length), ); - reasonPhrase = line.substring('HTTP/1.x xxx '.length); + reasonPhrase = normalizedLine.substring('HTTP/1.x xxx '.length); inHeader = true; } else { throw UnsupportedError('unsupported http response format'); diff --git a/lib/src/message/msg_channel.dart b/lib/src/message/msg_channel.dart index bcd2802..7b0d115 100644 --- a/lib/src/message/msg_channel.dart +++ b/lib/src/message/msg_channel.dart @@ -578,6 +578,7 @@ abstract class SSHChannelRequestType { static const shell = 'shell'; static const exec = 'exec'; static const subsystem = 'subsystem'; + static const authAgent = 'auth-agent-req@openssh.com'; static const windowChange = 'window-change'; static const xon = 'xon-xoff'; static const signal = 'signal'; diff --git a/lib/src/sftp/sftp_packet.dart b/lib/src/sftp/sftp_packet.dart index fa87897..7f9f00e 100644 --- a/lib/src/sftp/sftp_packet.dart +++ b/lib/src/sftp/sftp_packet.dart @@ -60,7 +60,7 @@ class SftpInitPacket implements SftpPacket { reader.readUint8(); // packet type final version = reader.readUint32(); final extensions = {}; - while (reader.isDone) { + while (!reader.isDone) { final name = reader.readUtf8(); final value = reader.readUtf8(); extensions[name] = value; @@ -1031,7 +1031,7 @@ class SftpExtendedReplyPacket implements SftpResponsePacket { final writer = SSHMessageWriter(); writer.writeUint8(packetType); writer.writeUint32(requestId); - writer.writeString(payload); + writer.writeBytes(payload); return writer.takeBytes(); } diff --git a/lib/src/ssh_agent.dart b/lib/src/ssh_agent.dart new file mode 100644 index 0000000..f5f360b --- /dev/null +++ b/lib/src/ssh_agent.dart @@ -0,0 +1,250 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dartssh2/src/hostkey/hostkey_rsa.dart'; +import 'package:dartssh2/src/ssh_hostkey.dart'; +import 'package:dartssh2/src/ssh_channel.dart'; +import 'package:dartssh2/src/ssh_key_pair.dart'; +import 'package:dartssh2/src/message/base.dart'; +import 'package:dartssh2/src/ssh_transport.dart'; +import 'package:pointycastle/api.dart' hide Signature; +import 'package:pointycastle/asymmetric/api.dart' as asymmetric; +import 'package:pointycastle/digests/sha1.dart'; +import 'package:pointycastle/digests/sha256.dart'; +import 'package:pointycastle/digests/sha512.dart'; +import 'package:pointycastle/signers/rsa_signer.dart'; + +abstract class SSHAgentHandler { + Future handleRequest(Uint8List request); +} + +class SSHKeyPairAgent implements SSHAgentHandler { + SSHKeyPairAgent(this._identities, {this.comment}); + + final List _identities; + final String? comment; + + @override + Future handleRequest(Uint8List request) async { + if (request.isEmpty) { + return _failure(); + } + final reader = SSHMessageReader(request); + final messageType = reader.readUint8(); + switch (messageType) { + case SSHAgentProtocol.requestIdentities: + return _handleRequestIdentities(); + case SSHAgentProtocol.signRequest: + return _handleSignRequest(reader); + default: + return _failure(); + } + } + + Uint8List _handleRequestIdentities() { + final writer = SSHMessageWriter(); + writer.writeUint8(SSHAgentProtocol.identitiesAnswer); + writer.writeUint32(_identities.length); + for (final identity in _identities) { + final publicKey = identity.toPublicKey().encode(); + writer.writeString(publicKey); + writer.writeUtf8(comment ?? ''); + } + return writer.takeBytes(); + } + + Uint8List _handleSignRequest(SSHMessageReader reader) { + final keyBlob = reader.readString(); + final data = reader.readString(); + final flags = reader.readUint32(); + + final identity = _findIdentity(keyBlob); + if (identity == null) { + return _failure(); + } + + final signature = _sign(identity, data, flags); + final writer = SSHMessageWriter(); + writer.writeUint8(SSHAgentProtocol.signResponse); + writer.writeString(signature.encode()); + return writer.takeBytes(); + } + + SSHSignature _sign(SSHKeyPair identity, Uint8List data, int flags) { + if (identity is OpenSSHRsaKeyPair || identity is RsaPrivateKey) { + final signatureType = _rsaSignatureTypeForFlags(flags); + return _signRsa(identity, data, signatureType); + } + return identity.sign(data); + } + + String _rsaSignatureTypeForFlags(int flags) { + if (flags & SSHAgentProtocol.rsaSha2_512 != 0) { + return SSHRsaSignatureType.sha512; + } + if (flags & SSHAgentProtocol.rsaSha2_256 != 0) { + return SSHRsaSignatureType.sha256; + } + return SSHRsaSignatureType.sha1; + } + + SSHRsaSignature _signRsa( + SSHKeyPair identity, + Uint8List data, + String signatureType, + ) { + final key = _rsaKeyFrom(identity); + if (key == null) { + final signature = identity.sign(data); + if (signature is SSHRsaSignature) { + return signature; + } + throw StateError( + 'RSA signing requested but identity produced non-RSA signature: ${signature.runtimeType}'); + } + + final signer = _rsaSignerFor(signatureType); + signer.init(true, PrivateKeyParameter(key)); + return SSHRsaSignature(signatureType, signer.generateSignature(data).bytes); + } + + asymmetric.RSAPrivateKey? _rsaKeyFrom(SSHKeyPair identity) { + if (identity is OpenSSHRsaKeyPair) { + return asymmetric.RSAPrivateKey( + identity.n, identity.d, identity.p, identity.q); + } + if (identity is RsaPrivateKey) { + return asymmetric.RSAPrivateKey( + identity.n, identity.d, identity.p, identity.q); + } + return null; + } + + RSASigner _rsaSignerFor(String signatureType) { + switch (signatureType) { + case SSHRsaSignatureType.sha1: + return RSASigner(SHA1Digest(), '06052b0e03021a'); + case SSHRsaSignatureType.sha256: + return RSASigner(SHA256Digest(), '0609608648016503040201'); + case SSHRsaSignatureType.sha512: + return RSASigner(SHA512Digest(), '0609608648016503040203'); + default: + return RSASigner(SHA256Digest(), '0609608648016503040201'); + } + } + + SSHKeyPair? _findIdentity(Uint8List keyBlob) { + for (final identity in _identities) { + final publicKey = identity.toPublicKey().encode(); + if (_bytesEqual(publicKey, keyBlob)) { + return identity; + } + } + return null; + } + + Uint8List _failure() { + final writer = SSHMessageWriter(); + writer.writeUint8(SSHAgentProtocol.failure); + return writer.takeBytes(); + } + + bool _bytesEqual(Uint8List a, Uint8List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} + +class SSHAgentChannel { + static const maxFrameSize = 256 * 1024; + + SSHAgentChannel(this._channel, this._handler, {this.printDebug}) { + _subscription = _channel.stream.listen( + _handleData, + onDone: _handleDone, + onError: (_, __) => _handleDone(), + ); + } + + final SSHChannel _channel; + final SSHAgentHandler _handler; + final SSHPrintHandler? printDebug; + + StreamSubscription? _subscription; + Uint8List _buffer = Uint8List(0); + bool _processing = false; + + void _handleDone() { + _subscription?.cancel(); + } + + void _handleData(SSHChannelData data) { + _buffer = _appendBytes(_buffer, data.bytes); + _drainRequests(); + } + + void _drainRequests() { + if (_processing) return; + _processing = true; + _processQueue().whenComplete(() => _processing = false); + } + + Future _processQueue() async { + while (_buffer.length >= 4) { + final length = ByteData.sublistView(_buffer, 0, 4).getUint32(0); + if (length == 0 || length > maxFrameSize) { + printDebug?.call( + 'SSH agent: invalid frame length $length, closing channel'); + _channel.destroy(); + _buffer = Uint8List(0); + return; + } + if (_buffer.length < 4 + length) return; + final payload = _buffer.sublist(4, 4 + length); + _buffer = _buffer.sublist(4 + length); + Uint8List response; + try { + response = await _handler.handleRequest(payload); + } catch (error) { + printDebug?.call('SSH agent handler error: $error'); + response = _failureResponse(); + } + _sendResponse(response); + } + } + + Uint8List _failureResponse() { + final writer = SSHMessageWriter(); + writer.writeUint8(SSHAgentProtocol.failure); + return writer.takeBytes(); + } + + void _sendResponse(Uint8List payload) { + final writer = SSHMessageWriter(); + writer.writeUint32(payload.length); + writer.writeBytes(payload); + _channel.addData(writer.takeBytes()); + } + + Uint8List _appendBytes(Uint8List a, Uint8List b) { + if (a.isEmpty) return b; + if (b.isEmpty) return a; + final combined = Uint8List(a.length + b.length); + combined.setAll(0, a); + combined.setAll(a.length, b); + return combined; + } +} + +abstract class SSHAgentProtocol { + static const int failure = 5; + static const int requestIdentities = 11; + static const int identitiesAnswer = 12; + static const int signRequest = 13; + static const int signResponse = 14; + static const int rsaSha2_256 = 2; + static const int rsaSha2_512 = 4; +} diff --git a/lib/src/ssh_channel.dart b/lib/src/ssh_channel.dart index eae0dca..3254cb4 100644 --- a/lib/src/ssh_channel.dart +++ b/lib/src/ssh_channel.dart @@ -70,6 +70,11 @@ class SSHChannelController { /// An [AsyncQueue] of pending request replies from the remote side. final _requestReplyQueue = AsyncQueue(); + /// Fails all pending request reply waiters. + void failPendingRequestReplies(Object error, [StackTrace? stackTrace]) { + _requestReplyQueue.failAll(error, stackTrace ?? StackTrace.current); + } + /// true if we have sent an EOF message to the remote side. var _hasSentEOF = false; @@ -122,6 +127,36 @@ class SSHChannelController { return await _requestReplyQueue.next; } + Future sendX11Req({ + bool singleConnection = false, + String authenticationProtocol = 'MIT-MAGIC-COOKIE-1', + required String authenticationCookie, + int screenNumber = 0, + }) async { + sendMessage( + SSH_Message_Channel_Request.x11( + recipientChannel: remoteId, + wantReply: true, + singleConnection: singleConnection, + x11AuthenticationProtocol: authenticationProtocol, + x11AuthenticationCookie: authenticationCookie, + x11ScreenNumber: screenNumber.toString(), + ), + ); + return await _requestReplyQueue.next; + } + + Future sendAgentForwardingRequest() async { + sendMessage( + SSH_Message_Channel_Request( + recipientChannel: remoteId, + requestType: SSHChannelRequestType.authAgent, + wantReply: true, + ), + ); + return await _requestReplyQueue.next; + } + Future sendSubsystem(String subsystem) async { sendMessage( SSH_Message_Channel_Request.subsystem( @@ -453,6 +488,20 @@ class SSHChannel { return await _controller.sendShell(); } + Future sendX11Req({ + bool singleConnection = false, + String authenticationProtocol = 'MIT-MAGIC-COOKIE-1', + required String authenticationCookie, + int screenNumber = 0, + }) async { + return await _controller.sendX11Req( + singleConnection: singleConnection, + authenticationProtocol: authenticationProtocol, + authenticationCookie: authenticationCookie, + screenNumber: screenNumber, + ); + } + void sendTerminalWindowChange({ required int width, required int height, diff --git a/lib/src/ssh_client.dart b/lib/src/ssh_client.dart index 6e01249..54e1dc0 100644 --- a/lib/src/ssh_client.dart +++ b/lib/src/ssh_client.dart @@ -2,24 +2,24 @@ import 'dart:async'; import 'dart:collection'; import 'dart:typed_data'; -import 'package:dartssh2/src/ssh_channel.dart'; -import 'package:dartssh2/src/utils/async_queue.dart'; -import 'package:meta/meta.dart'; - +import 'package:dartssh2/src/http/http_client.dart'; import 'package:dartssh2/src/sftp/sftp_client.dart'; +import 'package:dartssh2/src/ssh_agent.dart'; +import 'package:dartssh2/src/ssh_algorithm.dart'; +import 'package:dartssh2/src/ssh_channel.dart'; +import 'package:dartssh2/src/message/base.dart'; import 'package:dartssh2/src/ssh_channel_id.dart'; import 'package:dartssh2/src/ssh_errors.dart'; import 'package:dartssh2/src/ssh_forward.dart'; -import 'package:dartssh2/src/ssh_hostkey.dart'; // Added import -import 'package:dartssh2/src/message/base.dart'; -import 'package:dartssh2/src/ssh_transport.dart'; -import 'package:dartssh2/src/ssh_userauth.dart'; -import 'package:dartssh2/src/socket/ssh_socket.dart'; +import 'package:dartssh2/src/ssh_hostkey.dart'; import 'package:dartssh2/src/ssh_keepalive.dart'; import 'package:dartssh2/src/ssh_key_pair.dart'; -import 'package:dartssh2/src/ssh_algorithm.dart'; import 'package:dartssh2/src/ssh_session.dart'; -import 'package:dartssh2/src/http/http_client.dart'; +import 'package:dartssh2/src/ssh_transport.dart'; +import 'package:dartssh2/src/ssh_userauth.dart'; +import 'package:dartssh2/src/socket/ssh_socket.dart'; +import 'package:dartssh2/src/utils/async_queue.dart'; +import 'package:meta/meta.dart'; /// Type definition for the host keys handler. typedef SSHHostKeysHandler = void Function(List hostKeys); @@ -42,6 +42,8 @@ typedef SSHAuthenticatedHandler = void Function(); typedef SSHRemoteConnectionFilter = bool Function(String host, int port); +typedef SSHX11ForwardHandler = void Function(SSHX11Channel channel); + // /// Function called when the host has sent additional host keys after the initial // /// key exchange. // typedef SSHHostKeysHandler = void Function(List); @@ -75,6 +77,27 @@ class SSHPtyConfig { }); } +class SSHX11Config { + /// Whether only a single forwarded X11 connection should be accepted. + final bool singleConnection; + + /// X11 authentication protocol name. + final String authenticationProtocol; + + /// X11 authentication cookie value. + final String authenticationCookie; + + /// X11 screen number. + final int screenNumber; + + const SSHX11Config({ + required this.authenticationCookie, + this.singleConnection = false, + this.authenticationProtocol = 'MIT-MAGIC-COOKIE-1', + this.screenNumber = 0, + }); +} + class SSHClient { /// RFC 4252 recommended authentication timeout period static const Duration defaultAuthTimeout = Duration(minutes: 10); @@ -135,6 +158,12 @@ class SSHClient { /// Function called when authentication is complete. final SSHAuthenticatedHandler? onAuthenticated; + /// Function called when the server opens an incoming forwarded X11 channel. + final SSHX11ForwardHandler? onX11Forward; + + /// Optional handler for SSH agent forwarding requests. + final SSHAgentHandler? agentHandler; + /// The interval at which to send a keep-alive message through the [ping] /// method. Set this to null to disable automatic keep-alive messages. final Duration? keepAliveInterval; @@ -166,6 +195,10 @@ class SSHClient { /// Allow to disable hostkey verification, which can be slow in debug mode. final bool disableHostkeyVerification; + /// Identification string advertised during the SSH version exchange (the part + /// after `SSH-2.0-`). Defaults to `'DartSSH_2.0'` + String get ident => _ident; + /// A [Future] that completes when the transport is closed, or when an error /// occurs. After this [Future] completes, [isClosed] will be true and no more /// data can be sent or received. @@ -174,33 +207,38 @@ class SSHClient { /// true if the connection is closed normally or due to an error. bool get isClosed => _transport.isClosed; - SSHClient(this.socket, - {required this.username, - this.printDebug, - this.printTrace, - this.algorithms = const SSHAlgorithms(), - this.onVerifyHostKey, - this.identities, - this.hostbasedIdentities, - this.hostName, - this.userNameOnClientHost, - this.onPasswordRequest, - this.onChangePasswordRequest, - this.onUserInfoRequest, - this.onUserauthBanner, - this.onAuthenticated, - this.keepAliveInterval = const Duration(seconds: 10), - - /// Authentication timeout period. RFC 4252 recommends 10 minutes. - this.authTimeout = defaultAuthTimeout, - - /// Handshake timeout period. Defaults to 30s. - this.handshakeTimeout = defaultHandshakeTimeout, - - /// Maximum authentication attempts. RFC 4252 recommends 20 attempts. - this.maxAuthAttempts = defaultMaxAuthAttempts, - this.onHostKeys, - this.disableHostkeyVerification = false}) { + SSHClient( + this.socket, { + required this.username, + this.printDebug, + this.printTrace, + this.algorithms = const SSHAlgorithms(), + this.onVerifyHostKey, + this.identities, + this.hostbasedIdentities, + this.hostName, + this.userNameOnClientHost, + this.onPasswordRequest, + this.onChangePasswordRequest, + this.onUserInfoRequest, + this.onUserauthBanner, + this.onAuthenticated, + this.keepAliveInterval = const Duration(seconds: 10), + + /// Authentication timeout period. RFC 4252 recommends 10 minutes. + this.authTimeout = defaultAuthTimeout, + + /// Handshake timeout period. Defaults to 30s. + this.handshakeTimeout = defaultHandshakeTimeout, + + /// Maximum authentication attempts. RFC 4252 recommends 20 attempts. + this.maxAuthAttempts = defaultMaxAuthAttempts, + this.onHostKeys, + this.disableHostkeyVerification = false, + this.onX11Forward, + this.agentHandler, + String ident = 'DartSSH_2.0', + }) : _ident = _validateIdent(ident) { _transport = SSHTransport( socket, isServer: false, @@ -211,11 +249,14 @@ class SSHClient { onReady: _handleTransportReady, onPacket: _handlePacket, disableHostkeyVerification: disableHostkeyVerification, + version: _ident, ); _transport.done.then( - (_) => _handleTransportClosed(), - onError: (_) => _handleTransportClosed(), + (_) => _handleTransportClosed(null), + onError: (e) => _handleTransportClosed( + e is SSHError ? e : SSHSocketError(e), + ), ); _authenticated.future.catchError( @@ -238,6 +279,36 @@ class SSHClient { _handshakeTimeoutTimer = Timer(handshakeTimeout, _onHandshakeTimeout); } + static String _validateIdent(String ident) { + if (ident.isEmpty) { + throw ArgumentError.value( + ident, + 'ident', + 'must not be empty', + ); + } + + if (ident.startsWith('SSH-')) { + throw ArgumentError.value( + ident, + 'ident', + 'must not include SSH- prefix', + ); + } + + if (ident.contains('\r') || ident.contains('\n')) { + throw ArgumentError.value( + ident, + 'ident', + 'must not contain carriage return or newline characters', + ); + } + + return ident; + } + + final String _ident; + final _hostbasedKeyPairsLeft = Queue(); Timer? _authTimeoutTimer; Timer? _handshakeTimeoutTimer; @@ -377,6 +448,7 @@ class SSHClient { Future execute( String command, { SSHPtyConfig? pty, + SSHX11Config? x11, Map? environment, }) async { await _authenticated.future; @@ -389,6 +461,14 @@ class SSHClient { } } + if (agentHandler != null) { + final agentOk = await channelController.sendAgentForwardingRequest(); + if (!agentOk) { + channelController.close(); + throw SSHChannelRequestError('Failed to request agent forwarding'); + } + } + if (pty != null) { final ptyOk = await channelController.sendPtyReq( terminalType: pty.type, @@ -403,6 +483,19 @@ class SSHClient { } } + if (x11 != null) { + final x11Ok = await channelController.sendX11Req( + singleConnection: x11.singleConnection, + authenticationProtocol: x11.authenticationProtocol, + authenticationCookie: x11.authenticationCookie, + screenNumber: x11.screenNumber, + ); + if (!x11Ok) { + channelController.close(); + throw SSHChannelRequestError('Failed to request x11 forwarding'); + } + } + final success = await channelController.sendExec(command); if (!success) { channelController.close(); @@ -416,6 +509,7 @@ class SSHClient { /// used to read, write and control the pty on the remote side. Future shell({ SSHPtyConfig? pty = const SSHPtyConfig(), + SSHX11Config? x11, Map? environment, }) async { await _authenticated.future; @@ -428,6 +522,14 @@ class SSHClient { } } + if (agentHandler != null) { + final agentOk = await channelController.sendAgentForwardingRequest(); + if (!agentOk) { + channelController.close(); + throw SSHChannelRequestError('Failed to request agent forwarding'); + } + } + if (pty != null) { final ok = await channelController.sendPtyReq( terminalType: pty.type, @@ -442,6 +544,19 @@ class SSHClient { } } + if (x11 != null) { + final x11Ok = await channelController.sendX11Req( + singleConnection: x11.singleConnection, + authenticationProtocol: x11.authenticationProtocol, + authenticationCookie: x11.authenticationCookie, + screenNumber: x11.screenNumber, + ); + if (!x11Ok) { + channelController.close(); + throw SSHChannelRequestError('Failed to request x11 forwarding'); + } + } + if (!await channelController.sendShell()) { channelController.close(); throw SSHChannelRequestError('Failed to start shell'); @@ -553,10 +668,12 @@ class SSHClient { _requestAuthentication(); } - void _handleTransportClosed() { + void _handleTransportClosed(SSHError? error) { printDebug?.call('SSHClient._onTransportClosed'); _handshakeTimeoutTimer?.cancel(); _handshakeTimeoutTimer = null; + _authTimeoutTimer?.cancel(); + _authTimeoutTimer = null; if (!_authenticated.isCompleted) { final currentMethod = _currentAuthMethod != null ? "Current method: ${_currentAuthMethod!.name}" @@ -567,11 +684,34 @@ class SSHClient { _authenticated.completeError( SSHAuthAbortError( - 'Connection closed before authentication. $currentMethod. $attempts'), + 'Connection closed before authentication. $currentMethod. $attempts', + error), ); } _keepAlive?.stop(); + // Complete any pending channel-open waiters so callers (e.g. + // forwardLocalUnix) don't hang forever when the connection drops. + for (final entry in _channelOpenReplyWaiters.entries) { + if (!entry.value.isCompleted) { + entry.value.completeError(error ?? + SSHStateError('Connection closed while waiting for channel open')); + } + } + _channelOpenReplyWaiters.clear(); + + // Fail any pending global request replies (e.g. ping, forwardRemote). + _globalRequestReplyQueue.failAll(error ?? + SSHStateError( + 'Connection closed while waiting for global request reply')); + + // Fail pending request replies for each channel. + for (final controller in _channels.values) { + controller.failPendingRequestReplies(error ?? + SSHStateError( + 'Connection closed while waiting for channel request reply')); + } + try { _closeChannels(); } catch (e) { @@ -837,6 +977,10 @@ class SSHClient { switch (message.channelType) { case 'forwarded-tcpip': return _handleForwardedTcpipChannelOpen(message); + case 'x11': + return _handleX11ChannelOpen(message); + case 'auth-agent@openssh.com': + return _handleAgentChannelOpen(message); } printDebug?.call('unknown channelType: ${message.channelType}'); @@ -901,6 +1045,84 @@ class SSHClient { ); } + void _handleX11ChannelOpen(SSH_Message_Channel_Open message) { + printDebug?.call('SSHClient._handleX11ChannelOpen'); + + if (onX11Forward == null) { + final reply = SSH_Message_Channel_Open_Failure( + recipientChannel: message.senderChannel, + reasonCode: 1, // SSH_OPEN_ADMINISTRATIVELY_PROHIBITED + description: 'x11 forwarding not enabled', + ); + _sendMessage(reply); + return; + } + + final localChannelId = _channelIdAllocator.allocate(); + + final confirmation = SSH_Message_Channel_Confirmation( + recipientChannel: message.senderChannel, + senderChannel: localChannelId, + initialWindowSize: _initialWindowSize, + maximumPacketSize: _maximumPacketSize, + data: Uint8List(0), + ); + + _sendMessage(confirmation); + + final channelController = _acceptChannel( + localChannelId: localChannelId, + remoteChannelId: message.senderChannel, + remoteInitialWindowSize: message.initialWindowSize, + remoteMaximumPacketSize: message.maximumPacketSize, + ); + + onX11Forward!( + SSHX11Channel( + channelController.channel, + originatorIP: message.originatorIP ?? '', + originatorPort: message.originatorPort ?? 0, + ), + ); + } + + void _handleAgentChannelOpen(SSH_Message_Channel_Open message) { + final handler = agentHandler; + if (handler == null) { + final reply = SSH_Message_Channel_Open_Failure( + recipientChannel: message.senderChannel, + reasonCode: + SSH_Message_Channel_Open_Failure.codeAdministrativelyProhibited, + description: 'agent forwarding not enabled', + ); + _sendMessage(reply); + return; + } + + final localChannelId = _channelIdAllocator.allocate(); + final confirmation = SSH_Message_Channel_Confirmation( + recipientChannel: message.senderChannel, + senderChannel: localChannelId, + initialWindowSize: _initialWindowSize, + maximumPacketSize: _maximumPacketSize, + data: Uint8List(0), + ); + _sendMessage(confirmation); + + final channelController = _acceptChannel( + localChannelId: localChannelId, + remoteChannelId: message.senderChannel, + remoteInitialWindowSize: message.initialWindowSize, + remoteMaximumPacketSize: message.maximumPacketSize, + ); + + SSHAgentChannel( + channelController.channel, + handler, + printDebug: printDebug, + ); + } + /// Finds a remote forward that matches the given host and port. SSHRemoteForward? _findRemoteForward(String host, int port) { final result = _remoteForwards.where( @@ -912,6 +1134,22 @@ class SSHClient { void _handleChannelConfirmation(Uint8List payload) { final message = SSH_Message_Channel_Confirmation.decode(payload); printTrace?.call('<- $socket: $message'); + if (!_channelOpenReplyWaiters.containsKey(message.recipientChannel)) { + printDebug?.call( + '_handleChannelConfirmation: no pending open for local channel ${message.recipientChannel}, discarding'); + return; + } + if (_channels.containsKey(message.recipientChannel)) { + printDebug?.call( + '_handleChannelConfirmation: channel ${message.recipientChannel} already closed, discarding'); + return; + } + _acceptChannel( + localChannelId: message.recipientChannel, + remoteChannelId: message.senderChannel, + remoteInitialWindowSize: message.initialWindowSize, + remoteMaximumPacketSize: message.maximumPacketSize, + ); _dispatchChannelOpenReply(message.recipientChannel, message); } @@ -1304,17 +1542,12 @@ class SSHClient { throw SSHChannelOpenError(message.reasonCode, message.description); } - final reply = message as SSH_Message_Channel_Confirmation; - if (reply.recipientChannel != localChannelId) { - throw SSHStateError('Unexpected channel confirmation'); + final controller = _channels[localChannelId]; + if (controller == null) { + throw SSHStateError( + 'Channel $localChannelId was closed before channel-open completed'); } - - return _acceptChannel( - localChannelId: localChannelId, - remoteChannelId: reply.senderChannel, - remoteInitialWindowSize: reply.initialWindowSize, - remoteMaximumPacketSize: reply.maximumPacketSize, - ); + return controller; } SSHChannelController _acceptChannel({ diff --git a/lib/src/ssh_errors.dart b/lib/src/ssh_errors.dart index 68407de..982efb1 100644 --- a/lib/src/ssh_errors.dart +++ b/lib/src/ssh_errors.dart @@ -50,7 +50,17 @@ class SSHAuthAbortError with SSHMessageError implements SSHAuthError { @override final String message; - SSHAuthAbortError(this.message); + final SSHError? reason; + + SSHAuthAbortError(this.message, [this.reason]); + + @override + String toString() { + if (reason != null) { + return 'SSHAuthAbortError($message, reason: $reason)'; + } + return 'SSHAuthAbortError($message)'; + } } /// Errors that happen when the library receives an malformed packet. diff --git a/lib/src/ssh_forward.dart b/lib/src/ssh_forward.dart index 2494df3..f2b462b 100644 --- a/lib/src/ssh_forward.dart +++ b/lib/src/ssh_forward.dart @@ -39,3 +39,17 @@ class SSHForwardChannel implements SSHSocket { _channel.destroy(); } } + +class SSHX11Channel extends SSHForwardChannel { + /// Originator address reported by the SSH server for this X11 channel. + final String originatorIP; + + /// Originator port reported by the SSH server for this X11 channel. + final int originatorPort; + + SSHX11Channel( + super.channel, { + required this.originatorIP, + required this.originatorPort, + }); +} diff --git a/lib/src/utils/async_queue.dart b/lib/src/utils/async_queue.dart index cc23550..f2041e9 100644 --- a/lib/src/utils/async_queue.dart +++ b/lib/src/utils/async_queue.dart @@ -33,4 +33,14 @@ class AsyncQueue { _data.add(value); } } + + /// Fails all pending waiters with the given error. + void failAll(Object error, [StackTrace? stackTrace]) { + for (final completer in _completers) { + if (!completer.isCompleted) { + completer.completeError(error, stackTrace ?? StackTrace.current); + } + } + _completers.clear(); + } } diff --git a/pubspec.yaml b/pubspec.yaml index 3cc6926..0d4d0e8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: dartssh2 -version: 2.14.0 +version: 2.15.0 description: SSH and SFTP client written in pure Dart, aiming to be feature-rich as well as easy to use. homepage: https://github.com/TerminalStudio/dartssh2 diff --git a/test/src/channel/ssh_channel_test.dart b/test/src/channel/ssh_channel_test.dart index ac3a09f..9f6420a 100644 --- a/test/src/channel/ssh_channel_test.dart +++ b/test/src/channel/ssh_channel_test.dart @@ -1,3 +1,6 @@ +@Tags(['integration']) +library; + import 'package:dartssh2/dartssh2.dart'; import 'package:test/test.dart'; diff --git a/test/src/http/http_client_test.dart b/test/src/http/http_client_test.dart new file mode 100644 index 0000000..91c9ebf --- /dev/null +++ b/test/src/http/http_client_test.dart @@ -0,0 +1,177 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dartssh2/src/http/http_client.dart'; +import 'package:dartssh2/src/http/http_exception.dart'; +import 'package:dartssh2/src/socket/ssh_socket.dart'; +import 'package:test/test.dart'; + +void main() { + group('SSHHttpClientResponse.from', () { + test('parses status line, headers and body', () async { + final socket = _FakeSocket([ + 'HTTP/1.1 200 OK\r\n', + 'content-length: 5\r\n', + 'content-type: text/plain; charset=utf-8\r\n', + '\r\n', + 'hello', + ]); + + final response = await SSHHttpClientResponse.from(socket); + + expect(response.statusCode, 200); + expect(response.reasonPhrase, 'OK'); + expect(response.body, 'hello'); + expect(response.headers.contentLength, 5); + expect(response.headers.contentType?.mimeType, 'text/plain'); + expect(socket.closed, isTrue); + }); + + test('supports HTTP/1.0 responses', () async { + final socket = _FakeSocket([ + 'HTTP/1.0 404 Not Found\r\n', + 'content-length: 0\r\n', + '\r\n', + ]); + + final response = await SSHHttpClientResponse.from(socket); + + expect(response.statusCode, 404); + expect(response.reasonPhrase, 'Not Found'); + expect(response.body, isEmpty); + }); + + test('throws for non-identity transfer encoding', () async { + final socket = _FakeSocket([ + 'HTTP/1.1 200 OK\r\n', + 'transfer-encoding: chunked\r\n', + 'content-length: 0\r\n', + '\r\n', + ]); + + await expectLater( + SSHHttpClientResponse.from(socket), + throwsA(isA()), + ); + }); + + test('throws for unsupported response format', () async { + final socket = _FakeSocket([ + 'NOT_HTTP\r\n', + ]); + + await expectLater( + SSHHttpClientResponse.from(socket), + throwsA(isA()), + ); + }); + }); + + group('SSHHttpClientResponse headers', () { + test('throws when reading duplicated header via value()', () async { + final socket = _FakeSocket([ + 'HTTP/1.1 200 OK\r\n', + 'x-test: a\r\n', + 'x-test: b\r\n', + 'content-length: 0\r\n', + '\r\n', + ]); + + final response = await SSHHttpClientResponse.from(socket); + + expect( + () => response.headers.value('x-test'), + throwsA(isA()), + ); + }); + + test('parses date-like headers and exposes raw host header', () async { + final socket = _FakeSocket([ + 'HTTP/1.1 200 OK\r\n', + 'host: localhost:8080\r\n', + 'date: 2024-01-01T10:00:00.000Z\r\n', + 'expires: 2024-01-01T12:00:00.000Z\r\n', + 'if-modified-since: 2024-01-01T09:00:00.000Z\r\n', + 'content-length: 0\r\n', + '\r\n', + ]); + + final response = await SSHHttpClientResponse.from(socket); + + expect(response.headers.value('host'), 'localhost:8080'); + expect(response.headers.date, DateTime.parse('2024-01-01T10:00:00.000Z')); + expect( + response.headers.expires, DateTime.parse('2024-01-01T12:00:00.000Z')); + expect( + response.headers.ifModifiedSince, + DateTime.parse('2024-01-01T09:00:00.000Z'), + ); + }); + + test('response headers are immutable', () async { + final socket = _FakeSocket([ + 'HTTP/1.1 200 OK\r\n', + 'content-length: 0\r\n', + '\r\n', + ]); + + final response = await SSHHttpClientResponse.from(socket); + + expect( + () => response.headers.add('x', '1'), + throwsA(isA()), + ); + expect( + () => response.headers.set('x', '1'), + throwsA(isA()), + ); + expect( + () => response.headers.removeAll('x'), + throwsA(isA()), + ); + expect( + () => response.headers.clear(), + throwsA(isA()), + ); + }); + }); +} + +class _FakeSocket implements SSHSocket { + _FakeSocket(List chunks) + : _chunks = chunks + .map((chunk) => Uint8List.fromList(chunk.codeUnits)) + .toList(growable: false); + + final List _chunks; + final _sinkController = StreamController>(); + final _doneCompleter = Completer(); + bool closed = false; + + @override + Stream get stream => Stream.fromIterable(_chunks); + + @override + StreamSink> get sink => _sinkController.sink; + + @override + Future get done => _doneCompleter.future; + + @override + Future close() async { + closed = true; + if (!_doneCompleter.isCompleted) { + _doneCompleter.complete(); + } + await _sinkController.close(); + } + + @override + void destroy() { + closed = true; + if (!_doneCompleter.isCompleted) { + _doneCompleter.complete(); + } + unawaited(_sinkController.close()); + } +} diff --git a/test/src/message/msg_channel_test.dart b/test/src/message/msg_channel_test.dart new file mode 100644 index 0000000..043e65d --- /dev/null +++ b/test/src/message/msg_channel_test.dart @@ -0,0 +1,345 @@ +import 'dart:typed_data'; + +import 'package:dartssh2/src/message/base.dart'; +import 'package:test/test.dart'; + +void main() { + group('SSH_Message_Channel_Open', () { + test('x11 encode/decode roundtrip', () { + final message = SSH_Message_Channel_Open.x11( + senderChannel: 7, + initialWindowSize: 1024, + maximumPacketSize: 32768, + originatorIP: '127.0.0.1', + originatorPort: 6123, + ); + + final decoded = SSH_Message_Channel_Open.decode(message.encode()); + + expect(decoded.channelType, 'x11'); + expect(decoded.senderChannel, 7); + expect(decoded.initialWindowSize, 1024); + expect(decoded.maximumPacketSize, 32768); + expect(decoded.originatorIP, '127.0.0.1'); + expect(decoded.originatorPort, 6123); + }); + + test('direct-streamlocal encode/decode roundtrip', () { + final message = SSH_Message_Channel_Open.directStreamLocal( + senderChannel: 11, + initialWindowSize: 2048, + maximumPacketSize: 32768, + socketPath: '/var/run/docker.sock', + ); + + final decoded = SSH_Message_Channel_Open.decode(message.encode()); + + expect(decoded.channelType, 'direct-streamlocal@openssh.com'); + expect(decoded.senderChannel, 11); + expect(decoded.initialWindowSize, 2048); + expect(decoded.maximumPacketSize, 32768); + expect(decoded.socketPath, '/var/run/docker.sock'); + }); + + test('direct-tcpip encode/decode roundtrip', () { + final message = SSH_Message_Channel_Open.directTcpip( + senderChannel: 9, + initialWindowSize: 4096, + maximumPacketSize: 32768, + host: 'example.com', + port: 443, + originatorIP: '10.0.0.5', + originatorPort: 5111, + ); + + final decoded = SSH_Message_Channel_Open.decode(message.encode()); + + expect(decoded.channelType, 'direct-tcpip'); + expect(decoded.host, 'example.com'); + expect(decoded.port, 443); + expect(decoded.originatorIP, '10.0.0.5'); + expect(decoded.originatorPort, 5111); + }); + + test('forwarded-tcpip encode/decode roundtrip', () { + final message = SSH_Message_Channel_Open.forwardedTcpip( + senderChannel: 10, + initialWindowSize: 4096, + maximumPacketSize: 32768, + host: '127.0.0.1', + port: 8080, + originatorIP: '192.168.1.2', + originatorPort: 1234, + ); + + final decoded = SSH_Message_Channel_Open.decode(message.encode()); + + expect(decoded.channelType, 'forwarded-tcpip'); + expect(decoded.host, '127.0.0.1'); + expect(decoded.port, 8080); + expect(decoded.originatorIP, '192.168.1.2'); + expect(decoded.originatorPort, 1234); + }); + + test('session encode/decode roundtrip', () { + final message = SSH_Message_Channel_Open.session( + senderChannel: 2, + initialWindowSize: 2048, + maximumPacketSize: 16384, + ); + + final decoded = SSH_Message_Channel_Open.decode(message.encode()); + + expect(decoded.channelType, 'session'); + expect(decoded.senderChannel, 2); + expect(decoded.initialWindowSize, 2048); + expect(decoded.maximumPacketSize, 16384); + }); + }); + + group('SSH_Message_Channel_Generic', () { + test('confirmation encode/decode roundtrip', () { + final message = SSH_Message_Channel_Confirmation( + recipientChannel: 1, + senderChannel: 2, + initialWindowSize: 1024, + maximumPacketSize: 2048, + data: Uint8List.fromList([4, 5, 6]), + ); + + final decoded = SSH_Message_Channel_Confirmation.decode(message.encode()); + + expect(decoded.recipientChannel, 1); + expect(decoded.senderChannel, 2); + expect(decoded.initialWindowSize, 1024); + expect(decoded.maximumPacketSize, 2048); + expect(decoded.data, Uint8List.fromList([4, 5, 6])); + }); + + test('open failure encode/decode roundtrip', () { + final message = SSH_Message_Channel_Open_Failure( + recipientChannel: 3, + reasonCode: SSH_Message_Channel_Open_Failure.codeConnectFailed, + description: 'failed', + languageTag: 'en', + ); + + final decoded = SSH_Message_Channel_Open_Failure.decode(message.encode()); + + expect(decoded.recipientChannel, 3); + expect(decoded.reasonCode, + SSH_Message_Channel_Open_Failure.codeConnectFailed); + expect(decoded.description, 'failed'); + expect(decoded.languageTag, 'en'); + }); + + test('window adjust encode/decode roundtrip', () { + final message = SSH_Message_Channel_Window_Adjust( + recipientChannel: 7, + bytesToAdd: 8192, + ); + + final decoded = + SSH_Message_Channel_Window_Adjust.decode(message.encode()); + + expect(decoded.recipientChannel, 7); + expect(decoded.bytesToAdd, 8192); + }); + + test('data and extended data encode/decode roundtrip', () { + final data = SSH_Message_Channel_Data( + recipientChannel: 1, + data: Uint8List.fromList([1, 2, 3]), + ); + final dataDecoded = SSH_Message_Channel_Data.decode(data.encode()); + expect(dataDecoded.recipientChannel, 1); + expect(dataDecoded.data, Uint8List.fromList([1, 2, 3])); + + final ext = SSH_Message_Channel_Extended_Data( + recipientChannel: 1, + dataTypeCode: SSH_Message_Channel_Extended_Data.dataTypeStderr, + data: Uint8List.fromList([9, 8]), + ); + final extDecoded = SSH_Message_Channel_Extended_Data.decode(ext.encode()); + expect(extDecoded.recipientChannel, 1); + expect(extDecoded.dataTypeCode, + SSH_Message_Channel_Extended_Data.dataTypeStderr); + expect(extDecoded.data, Uint8List.fromList([9, 8])); + }); + + test('eof, close, success and failure encode/decode roundtrip', () { + final eof = SSH_Message_Channel_EOF(recipientChannel: 9); + final eofDecoded = SSH_Message_Channel_EOF.decode(eof.encode()); + expect(eofDecoded.recipientChannel, 9); + + final close = SSH_Message_Channel_Close(recipientChannel: 10); + final closeDecoded = SSH_Message_Channel_Close.decode(close.encode()); + expect(closeDecoded.recipientChannel, 10); + + final success = SSH_Message_Channel_Success(recipientChannel: 11); + final successDecoded = + SSH_Message_Channel_Success.decode(success.encode()); + expect(successDecoded.recipientChannel, 11); + + final failure = SSH_Message_Channel_Failure(recipientChannel: 12); + final failureDecoded = + SSH_Message_Channel_Failure.decode(failure.encode()); + expect(failureDecoded.recipientChannel, 12); + }); + }); + + group('SSH_Message_Channel_Request', () { + test('x11-req encode/decode roundtrip', () { + final message = SSH_Message_Channel_Request.x11( + recipientChannel: 5, + wantReply: true, + singleConnection: true, + x11AuthenticationProtocol: 'MIT-MAGIC-COOKIE-1', + x11AuthenticationCookie: 'deadbeef', + x11ScreenNumber: '0', + ); + + final decoded = SSH_Message_Channel_Request.decode(message.encode()); + + expect(decoded.requestType, SSHChannelRequestType.x11); + expect(decoded.recipientChannel, 5); + expect(decoded.wantReply, isTrue); + expect(decoded.singleConnection, isTrue); + expect(decoded.x11AuthenticationProtocol, 'MIT-MAGIC-COOKIE-1'); + expect(decoded.x11AuthenticationCookie, 'deadbeef'); + expect(decoded.x11ScreenNumber, '0'); + }); + + test('auth-agent request encode/decode roundtrip', () { + final message = SSH_Message_Channel_Request( + recipientChannel: 3, + requestType: SSHChannelRequestType.authAgent, + wantReply: true, + ); + + final decoded = SSH_Message_Channel_Request.decode(message.encode()); + + expect(decoded.requestType, SSHChannelRequestType.authAgent); + expect(decoded.recipientChannel, 3); + expect(decoded.wantReply, isTrue); + }); + + test('pty request encode/decode roundtrip', () { + final message = SSH_Message_Channel_Request.pty( + recipientChannel: 9, + wantReply: true, + termType: 'xterm-256color', + termWidth: 120, + termHeight: 40, + termPixelWidth: 0, + termPixelHeight: 0, + termModes: Uint8List.fromList([0]), + ); + + final decoded = SSH_Message_Channel_Request.decode(message.encode()); + + expect(decoded.requestType, SSHChannelRequestType.pty); + expect(decoded.termType, 'xterm-256color'); + expect(decoded.termWidth, 120); + expect(decoded.termHeight, 40); + }); + + test('env, exec and subsystem requests roundtrip', () { + final env = SSH_Message_Channel_Request.env( + recipientChannel: 1, + wantReply: true, + variableName: 'LANG', + variableValue: 'en_US.UTF-8', + ); + final envDecoded = SSH_Message_Channel_Request.decode(env.encode()); + expect(envDecoded.requestType, SSHChannelRequestType.env); + expect(envDecoded.variableName, 'LANG'); + expect(envDecoded.variableValue, 'en_US.UTF-8'); + + final exec = SSH_Message_Channel_Request.exec( + recipientChannel: 1, + wantReply: true, + command: 'ls -la', + ); + final execDecoded = SSH_Message_Channel_Request.decode(exec.encode()); + expect(execDecoded.requestType, SSHChannelRequestType.exec); + expect(execDecoded.command, 'ls -la'); + + final subsystem = SSH_Message_Channel_Request.subsystem( + recipientChannel: 1, + wantReply: true, + subsystemName: 'sftp', + ); + final subsystemDecoded = + SSH_Message_Channel_Request.decode(subsystem.encode()); + expect(subsystemDecoded.requestType, SSHChannelRequestType.subsystem); + expect(subsystemDecoded.subsystemName, 'sftp'); + }); + + test('window-change, signal and exit-status requests roundtrip', () { + final windowChange = SSH_Message_Channel_Request.windowChange( + recipientChannel: 2, + termWidth: 100, + termHeight: 30, + termPixelWidth: 800, + termPixelHeight: 600, + ); + final windowDecoded = + SSH_Message_Channel_Request.decode(windowChange.encode()); + expect(windowDecoded.requestType, SSHChannelRequestType.windowChange); + expect(windowDecoded.termWidth, 100); + expect(windowDecoded.termHeight, 30); + expect(windowDecoded.termPixelWidth, 800); + expect(windowDecoded.termPixelHeight, 600); + + final signal = SSH_Message_Channel_Request.signal( + recipientChannel: 2, + signalName: 'KILL', + ); + final signalDecoded = SSH_Message_Channel_Request.decode(signal.encode()); + expect(signalDecoded.requestType, SSHChannelRequestType.signal); + expect(signalDecoded.signalName, 'KILL'); + + final exitStatus = SSH_Message_Channel_Request.exitStatus( + recipientChannel: 2, + exitStatus: 127, + ); + final exitStatusDecoded = + SSH_Message_Channel_Request.decode(exitStatus.encode()); + expect(exitStatusDecoded.requestType, SSHChannelRequestType.exitStatus); + expect(exitStatusDecoded.exitStatus, 127); + }); + + test('exit-signal request encode/decode roundtrip', () { + final message = SSH_Message_Channel_Request.exitSignal( + recipientChannel: 6, + exitSignalName: 'TERM', + coreDumped: true, + errorMessage: 'terminated', + languageTag: 'en', + ); + + final decoded = SSH_Message_Channel_Request.decode(message.encode()); + + expect(decoded.requestType, SSHChannelRequestType.exitSignal); + expect(decoded.exitSignalName, 'TERM'); + expect(decoded.coreDumped, isTrue); + expect(decoded.errorMessage, 'terminated'); + expect(decoded.languageTag, 'en'); + }); + + test('unknown request type decodes as generic request', () { + final writer = SSHMessageWriter(); + writer.writeUint8(SSH_Message_Channel_Request.messageId); + writer.writeUint32(13); + writer.writeUtf8('custom-request'); + writer.writeBool(false); + + final decoded = SSH_Message_Channel_Request.decode(writer.takeBytes()); + + expect(decoded.recipientChannel, 13); + expect(decoded.requestType, 'custom-request'); + expect(decoded.wantReply, isFalse); + }); + }); +} diff --git a/test/src/sftp/sftp_client_protocol_test.dart b/test/src/sftp/sftp_client_protocol_test.dart new file mode 100644 index 0000000..fdf5b92 --- /dev/null +++ b/test/src/sftp/sftp_client_protocol_test.dart @@ -0,0 +1,158 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dartssh2/src/sftp/sftp_client.dart'; +import 'package:dartssh2/src/sftp/sftp_errors.dart'; +import 'package:dartssh2/src/sftp/sftp_file_attrs.dart'; +import 'package:dartssh2/src/sftp/sftp_packet.dart'; +import 'package:dartssh2/src/sftp/sftp_status_code.dart'; +import 'package:dartssh2/src/ssh_channel.dart'; +import 'package:dartssh2/src/message/base.dart'; +import 'package:test/test.dart'; + +void main() { + group('SftpClient protocol', () { + test('handshake completes with version packet', () async { + final harness = _SftpHarness(); + final initPayload = await harness.nextOutgoingPacket(); + final init = SftpInitPacket.decode(initPayload); + expect(init.version, 3); + + harness.sendResponsePacket( + SftpVersionPacket(3, {'fstatvfs@openssh.com': '2'}), + ); + + final handshake = await harness.client.handshake; + expect(handshake.version, 3); + expect(handshake.extensions['fstatvfs@openssh.com'], '2'); + + harness.dispose(); + }); + + test('stat uses lstat when followLink is false', () async { + final harness = _SftpHarness(); + await harness.nextOutgoingPacket(); + harness.sendResponsePacket(SftpVersionPacket(3)); + await harness.client.handshake; + + final statFuture = harness.client.stat('/tmp/file', followLink: false); + final packet = await harness.nextOutgoingPacket(); + final lstat = SftpLStatPacket.decode(packet); + + harness.sendResponsePacket( + SftpAttrsPacket( + lstat.requestId, + SftpFileAttrs(size: 55, mode: const SftpFileMode.value(1 << 15)), + ), + ); + + final attrs = await statFuture; + expect(attrs.size, 55); + expect(attrs.isFile, isTrue); + + harness.dispose(); + }); + + test('stat throws SftpStatusError on failure status', () async { + final harness = _SftpHarness(); + await harness.nextOutgoingPacket(); + harness.sendResponsePacket(SftpVersionPacket(3)); + await harness.client.handshake; + + final statFuture = harness.client.stat('/tmp/file'); + final packet = await harness.nextOutgoingPacket(); + final stat = SftpStatPacket.decode(packet); + + harness.sendResponsePacket( + SftpStatusPacket( + requestId: stat.requestId, + code: SftpStatusCode.permissionDenied, + message: 'denied', + ), + ); + + await expectLater( + statFuture, + throwsA( + isA() + .having((e) => e.code, 'code', SftpStatusCode.permissionDenied), + ), + ); + + harness.dispose(); + }); + + test('close aborts pending requests', () async { + final harness = _SftpHarness(); + await harness.nextOutgoingPacket(); + harness.sendResponsePacket(SftpVersionPacket(3)); + await harness.client.handshake; + + final openFuture = harness.client.open('/tmp/f'); + await harness.nextOutgoingPacket(); + + harness.client.close(); + + await expectLater(openFuture, throwsA(isA())); + harness.dispose(); + }); + }); +} + +class _SftpHarness { + _SftpHarness() { + _controller = SSHChannelController( + localId: 1, + localMaximumPacketSize: 1024 * 1024, + localInitialWindowSize: 1024 * 1024, + remoteId: 2, + remoteMaximumPacketSize: 1024 * 1024, + remoteInitialWindowSize: 1024 * 1024, + sendMessage: _handleOutboundMessage, + ); + client = SftpClient(_controller.channel); + } + + late final SSHChannelController _controller; + late final SftpClient client; + + final _outgoing = StreamController.broadcast(); + var _disposed = false; + + void _handleOutboundMessage(SSHMessage message) { + if (message is! SSH_Message_Channel_Data) return; + final reader = SSHMessageReader(message.data); + final length = reader.readUint32(); + final payload = reader.readBytes(length); + _outgoing.add(payload); + } + + Future nextOutgoingPacket() => _outgoing.stream.first; + + void sendResponsePacket(SftpPacket packet) { + final payload = packet.encode(); + final writer = SSHMessageWriter(); + writer.writeUint32(payload.length); + writer.writeBytes(payload); + + _controller.handleMessage( + SSH_Message_Channel_Data( + recipientChannel: _controller.localId, + data: writer.takeBytes(), + ), + ); + } + + void dispose() { + if (_disposed) return; + _disposed = true; + + try { + client.close(); + } catch (_) { + // SftpClient.close is not idempotent when already completed with error. + } + _controller.destroy(); + _outgoing.close(); + } +} diff --git a/test/src/sftp/sftp_client_test.dart b/test/src/sftp/sftp_client_test.dart index 5368622..ffa1e83 100644 --- a/test/src/sftp/sftp_client_test.dart +++ b/test/src/sftp/sftp_client_test.dart @@ -1,3 +1,6 @@ +@Tags(['integration']) +library; + import 'package:dartssh2/dartssh2.dart'; import 'package:test/test.dart'; diff --git a/test/src/sftp/sftp_errors_test.dart b/test/src/sftp/sftp_errors_test.dart new file mode 100644 index 0000000..d08a378 --- /dev/null +++ b/test/src/sftp/sftp_errors_test.dart @@ -0,0 +1,77 @@ +import 'package:dartssh2/src/sftp/sftp_errors.dart'; +import 'package:dartssh2/src/sftp/sftp_packet.dart'; +import 'package:dartssh2/src/sftp/sftp_status_code.dart'; +import 'package:test/test.dart'; + +void main() { + group('SftpError types', () { + test('toString includes message details', () { + expect(SftpError('oops').toString(), 'SftpError: oops'); + expect(SftpAbortError('abort').toString(), 'SftpAbortError: abort'); + expect( + SftpExtensionUnsupportedError('posix-rename@openssh.com').toString(), + contains('not supported'), + ); + expect( + SftpExtensionVersionMismatchError('copy-data', '2').toString(), + contains('version 2'), + ); + }); + + test('SftpStatusError.fromStatus maps code and message', () { + final status = SftpStatusPacket( + requestId: 1, + code: SftpStatusCode.permissionDenied, + message: 'denied', + ); + + final error = SftpStatusError.fromStatus(status); + + expect(error.code, SftpStatusCode.permissionDenied); + expect(error.message, 'denied'); + expect(error.toString(), + contains('code ${SftpStatusCode.permissionDenied}')); + }); + + test('SftpStatusError.check allows ok and eof', () { + expect( + () => SftpStatusError.check( + SftpStatusPacket( + requestId: 1, + code: SftpStatusCode.ok, + message: 'ok', + ), + ), + returnsNormally, + ); + + expect( + () => SftpStatusError.check( + SftpStatusPacket( + requestId: 2, + code: SftpStatusCode.eof, + message: 'eof', + ), + ), + returnsNormally, + ); + }); + + test('SftpStatusError.check throws for non-ok status', () { + expect( + () => SftpStatusError.check( + SftpStatusPacket( + requestId: 3, + code: SftpStatusCode.failure, + message: 'failed', + ), + ), + throwsA( + isA() + .having((e) => e.code, 'code', SftpStatusCode.failure) + .having((e) => e.message, 'message', 'failed'), + ), + ); + }); + }); +} diff --git a/test/src/sftp/sftp_file_attrs_test.dart b/test/src/sftp/sftp_file_attrs_test.dart new file mode 100644 index 0000000..69158fe --- /dev/null +++ b/test/src/sftp/sftp_file_attrs_test.dart @@ -0,0 +1,97 @@ +import 'package:dartssh2/src/sftp/sftp_file_attrs.dart'; +import 'package:dartssh2/src/message/base.dart'; +import 'package:test/test.dart'; + +void main() { + group('SftpFileMode', () { + test('factory sets expected permission flags', () { + final mode = SftpFileMode( + userRead: true, + userWrite: false, + userExecute: true, + groupRead: false, + groupWrite: true, + groupExecute: false, + otherRead: true, + otherWrite: false, + otherExecute: true, + ); + + expect(mode.userRead, isTrue); + expect(mode.userWrite, isFalse); + expect(mode.userExecute, isTrue); + expect(mode.groupRead, isFalse); + expect(mode.groupWrite, isTrue); + expect(mode.groupExecute, isFalse); + expect(mode.otherRead, isTrue); + expect(mode.otherWrite, isFalse); + expect(mode.otherExecute, isTrue); + }); + + test('type detection covers all known mode masks', () { + expect(const SftpFileMode.value(1 << 12).type, SftpFileType.pipe); + expect( + const SftpFileMode.value(1 << 13).type, + SftpFileType.characterDevice, + ); + expect(const SftpFileMode.value(1 << 14).type, SftpFileType.directory); + expect( + const SftpFileMode.value((1 << 14) + (1 << 13)).type, + SftpFileType.blockDevice, + ); + expect(const SftpFileMode.value(1 << 15).type, SftpFileType.regularFile); + expect( + const SftpFileMode.value((1 << 15) + (1 << 13)).type, + SftpFileType.symbolicLink, + ); + expect( + const SftpFileMode.value((1 << 15) + (1 << 14)).type, + SftpFileType.socket, + ); + expect( + const SftpFileMode.value((1 << 15) + (1 << 14) + (1 << 13)).type, + SftpFileType.whiteout, + ); + expect(const SftpFileMode.value(0).type, SftpFileType.unknown); + }); + }); + + group('SftpFileAttrs', () { + test('writeTo/readFrom roundtrip keeps all fields', () { + final attrs = SftpFileAttrs( + size: 987654321, + userID: 1001, + groupID: 1002, + mode: const SftpFileMode.value((1 << 14) + 0x1A4), + accessTime: 1700000000, + modifyTime: 1700000100, + extended: const {'keyA': 'valueA', 'keyB': 'valueB'}, + ); + + final writer = SSHMessageWriter(); + attrs.writeTo(writer); + + final reader = SSHMessageReader(writer.takeBytes()); + final decoded = SftpFileAttrs.readFrom(reader); + + expect(decoded.size, attrs.size); + expect(decoded.userID, attrs.userID); + expect(decoded.groupID, attrs.groupID); + expect(decoded.mode?.value, attrs.mode?.value); + expect(decoded.accessTime, attrs.accessTime); + expect(decoded.modifyTime, attrs.modifyTime); + expect(decoded.extended, attrs.extended); + }); + + test('type helper getters reflect mode type', () { + final attrs = SftpFileAttrs( + mode: const SftpFileMode.value((1 << 15) + (1 << 13)), + ); + + expect(attrs.isSymbolicLink, isTrue); + expect(attrs.isDirectory, isFalse); + expect(attrs.isFile, isFalse); + expect(attrs.type, SftpFileType.symbolicLink); + }); + }); +} diff --git a/test/src/sftp/sftp_name_test.dart b/test/src/sftp/sftp_name_test.dart new file mode 100644 index 0000000..1264867 --- /dev/null +++ b/test/src/sftp/sftp_name_test.dart @@ -0,0 +1,29 @@ +import 'package:dartssh2/src/sftp/sftp_file_attrs.dart'; +import 'package:dartssh2/src/sftp/sftp_name.dart'; +import 'package:dartssh2/src/message/base.dart'; +import 'package:test/test.dart'; + +void main() { + test('SftpName writeTo/readFrom roundtrip', () { + final original = SftpName( + filename: 'report.txt', + longname: '-rw-r--r-- 1 user group 10 report.txt', + attr: SftpFileAttrs( + size: 10, + mode: const SftpFileMode.value( + (1 << 15) + 0x1A4), // 0x1A4 = 0644 rw-r--r-- + ), + ); + + final writer = SSHMessageWriter(); + original.writeTo(writer); + + final reader = SSHMessageReader(writer.takeBytes()); + final decoded = SftpName.readFrom(reader); + + expect(decoded.filename, 'report.txt'); + expect(decoded.longname, '-rw-r--r-- 1 user group 10 report.txt'); + expect(decoded.attr.size, 10); + expect(decoded.attr.isFile, isTrue); + }); +} diff --git a/test/src/sftp/sftp_packet_ext_test.dart b/test/src/sftp/sftp_packet_ext_test.dart new file mode 100644 index 0000000..3a1b247 --- /dev/null +++ b/test/src/sftp/sftp_packet_ext_test.dart @@ -0,0 +1,51 @@ +import 'dart:typed_data'; + +import 'package:dartssh2/src/sftp/sftp_packet_ext.dart'; +import 'package:dartssh2/src/message/base.dart'; +import 'package:test/test.dart'; + +void main() { + group('SFTP extended payload', () { + test('statvfs request encodes name + path', () { + final request = SftpStatVfsRequest(path: '/tmp'); + final encoded = request.encode(); + final reader = SSHMessageReader(encoded); + + expect(reader.readUtf8(), 'statvfs@openssh.com'); + expect(reader.readUtf8(), '/tmp'); + expect(reader.isDone, isTrue); + }); + + test('fstatvfs request encodes name + handle', () { + final request = + SftpFstatVfsRequest(handle: Uint8List.fromList([1, 2, 3])); + final encoded = request.encode(); + final reader = SSHMessageReader(encoded); + + expect(reader.readUtf8(), 'fstatvfs@openssh.com'); + expect(reader.readString(), Uint8List.fromList([1, 2, 3])); + expect(reader.isDone, isTrue); + }); + + test('statvfs reply decodes all fields in order', () { + final writer = SSHMessageWriter(); + for (var i = 1; i <= 11; i++) { + writer.writeUint64(i); + } + + final reply = SftpStatVfsReply.decode(writer.takeBytes()); + + expect(reply.blockSize, 1); + expect(reply.fundamentalBlockSize, 2); + expect(reply.totalBlocks, 3); + expect(reply.freeBlocks, 4); + expect(reply.freeBlocksForNonRoot, 5); + expect(reply.totalInodes, 6); + expect(reply.freeInodes, 7); + expect(reply.freeInodesForNonRoot, 8); + expect(reply.fileSystemId, 9); + expect(reply.flag, 10); + expect(reply.maximumFilenameLength, 11); + }); + }); +} diff --git a/test/src/sftp/sftp_packet_test.dart b/test/src/sftp/sftp_packet_test.dart new file mode 100644 index 0000000..2fd67b7 --- /dev/null +++ b/test/src/sftp/sftp_packet_test.dart @@ -0,0 +1,122 @@ +import 'dart:typed_data'; + +import 'package:dartssh2/src/sftp/sftp_file_attrs.dart'; +import 'package:dartssh2/src/sftp/sftp_name.dart'; +import 'package:dartssh2/src/sftp/sftp_packet.dart'; +import 'package:test/test.dart'; + +void main() { + group('SFTP packet roundtrips', () { + final attrs = SftpFileAttrs( + size: 123, + userID: 1000, + groupID: 1000, + mode: const SftpFileMode.value(0x8000 | 0x1A4), + accessTime: 11, + modifyTime: 22, + extended: {'k': 'v'}, + ); + + test('init/version packets keep extensions', () { + final init = SftpInitPacket(3, {'posix-rename@openssh.com': '1'}); + final decodedInit = SftpInitPacket.decode(init.encode()); + expect(decodedInit.version, 3); + expect(decodedInit.extensions['posix-rename@openssh.com'], '1'); + + final version = SftpVersionPacket(3, {'fstatvfs@openssh.com': '2'}); + final decodedVersion = SftpVersionPacket.decode(version.encode()); + expect(decodedVersion.version, 3); + expect(decodedVersion.extensions['fstatvfs@openssh.com'], '2'); + }); + + test('request packets decode expected fields', () { + final open = SftpOpenPacket(1, '/tmp/a', 0x12, attrs); + final openDecoded = SftpOpenPacket.decode(open.encode()); + expect(openDecoded.requestId, 1); + expect(openDecoded.path, '/tmp/a'); + expect(openDecoded.flags, 0x12); + expect(openDecoded.attrs.size, 123); + + final read = SftpReadPacket( + requestId: 2, + handle: Uint8List.fromList([1, 2]), + offset: 42, + length: 9, + ); + final readDecoded = SftpReadPacket.decode(read.encode()); + expect(readDecoded.requestId, 2); + expect(readDecoded.handle, Uint8List.fromList([1, 2])); + expect(readDecoded.offset, 42); + expect(readDecoded.length, 9); + + final write = SftpWritePacket( + requestId: 3, + handle: Uint8List.fromList([3]), + offset: 7, + data: Uint8List.fromList([8, 9]), + ); + final writeDecoded = SftpWritePacket.decode(write.encode()); + expect(writeDecoded.requestId, 3); + expect(writeDecoded.handle, Uint8List.fromList([3])); + expect(writeDecoded.offset, 7); + expect(writeDecoded.data, Uint8List.fromList([8, 9])); + + final rename = SftpRenamePacket(4, '/a', '/b'); + final renameDecoded = SftpRenamePacket.decode(rename.encode()); + expect(renameDecoded.requestId, 4); + expect(renameDecoded.oldPath, '/a'); + expect(renameDecoded.newPath, '/b'); + }); + + test('response packets decode expected fields', () { + final status = SftpStatusPacket( + requestId: 7, + code: 5, + message: 'err', + language: 'en', + ); + final statusDecoded = SftpStatusPacket.decode(status.encode()); + expect(statusDecoded.requestId, 7); + expect(statusDecoded.code, 5); + expect(statusDecoded.message, 'err'); + expect(statusDecoded.language, 'en'); + + final data = SftpDataPacket(8, Uint8List.fromList([1, 2, 3])); + final dataDecoded = SftpDataPacket.decode(data.encode()); + expect(dataDecoded.requestId, 8); + expect(dataDecoded.data, Uint8List.fromList([1, 2, 3])); + + final name = SftpName( + filename: 'f.txt', + longname: '-rw-r--r-- f.txt', + attr: attrs, + ); + final names = SftpNamePacket(9, [name]); + final namesDecoded = SftpNamePacket.decode(names.encode()); + expect(namesDecoded.requestId, 9); + expect(namesDecoded.names.length, 1); + expect(namesDecoded.names.single.filename, 'f.txt'); + expect(namesDecoded.names.single.longname, '-rw-r--r-- f.txt'); + expect(namesDecoded.names.single.attr.size, 123); + + final attrsPacket = SftpAttrsPacket(10, attrs); + final attrsDecoded = SftpAttrsPacket.decode(attrsPacket.encode()); + expect(attrsDecoded.requestId, 10); + expect(attrsDecoded.attrs.size, 123); + expect(attrsDecoded.attrs.extended?['k'], 'v'); + }); + + test('extended packets preserve raw payload', () { + final payload = Uint8List.fromList([10, 20, 30, 40]); + final request = SftpExtendedPacket(11, payload); + final requestDecoded = SftpExtendedPacket.decode(request.encode()); + expect(requestDecoded.requestId, 11); + expect(requestDecoded.payload, payload); + + final reply = SftpExtendedReplyPacket(12, payload); + final replyDecoded = SftpExtendedReplyPacket.decode(reply.encode()); + expect(replyDecoded.requestId, 12); + expect(replyDecoded.payload, payload); + }); + }); +} diff --git a/test/src/sftp/sftp_stream_io_test.dart b/test/src/sftp/sftp_stream_io_test.dart index 5693fea..4e6f77c 100644 --- a/test/src/sftp/sftp_stream_io_test.dart +++ b/test/src/sftp/sftp_stream_io_test.dart @@ -1,3 +1,6 @@ +@Tags(['integration']) +library; + import 'package:dartssh2/dartssh2.dart'; import 'package:test/test.dart'; diff --git a/test/src/socket/ssh_socket_io_test.dart b/test/src/socket/ssh_socket_io_test.dart index 626f678..f08b6ae 100644 --- a/test/src/socket/ssh_socket_io_test.dart +++ b/test/src/socket/ssh_socket_io_test.dart @@ -1,3 +1,6 @@ +@Tags(['integration']) +library; + import 'package:dartssh2/dartssh2.dart'; import 'package:test/test.dart'; diff --git a/test/src/ssh_agent_test.dart b/test/src/ssh_agent_test.dart new file mode 100644 index 0000000..2a653f2 --- /dev/null +++ b/test/src/ssh_agent_test.dart @@ -0,0 +1,292 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:dartssh2/src/hostkey/hostkey_rsa.dart'; +import 'package:dartssh2/src/ssh_channel.dart'; +import 'package:dartssh2/src/message/base.dart'; +import 'package:test/test.dart'; + +import '../test_utils.dart'; + +class _RecordingAgentHandler implements SSHAgentHandler { + _RecordingAgentHandler(this.response); + + final Uint8List response; + final requests = []; + + @override + Future handleRequest(Uint8List request) async { + requests.add(request); + return response; + } +} + +void main() { + final rsaPrivate = fixture('ssh-rsa/id_rsa'); + + SSHKeyPair rsaIdentity() { + return SSHKeyPair.fromPem(rsaPrivate).single; + } + + Uint8List buildRequestIdentities() { + final writer = SSHMessageWriter(); + writer.writeUint8(SSHAgentProtocol.requestIdentities); + return writer.takeBytes(); + } + + Uint8List buildSignRequest(SSHKeyPair identity, Uint8List data, int flags) { + final writer = SSHMessageWriter(); + writer.writeUint8(SSHAgentProtocol.signRequest); + writer.writeString(identity.toPublicKey().encode()); + writer.writeString(data); + writer.writeUint32(flags); + return writer.takeBytes(); + } + + test('SSHKeyPairAgent returns identities', () async { + final identity = rsaIdentity(); + final agent = SSHKeyPairAgent([identity], comment: 'test-key'); + + final response = await agent.handleRequest(buildRequestIdentities()); + final reader = SSHMessageReader(response); + + expect(reader.readUint8(), SSHAgentProtocol.identitiesAnswer); + expect(reader.readUint32(), 1); + final keyBlob = reader.readString(); + final comment = reader.readUtf8(); + + expect(keyBlob, identity.toPublicKey().encode()); + expect(comment, 'test-key'); + }); + + test('SSHKeyPairAgent signs RSA with expected signature type', () async { + final identity = rsaIdentity(); + final agent = SSHKeyPairAgent([identity]); + final data = Uint8List.fromList('sign-me'.codeUnits); + + final cases = { + SSHAgentProtocol.rsaSha2_256: SSHRsaSignatureType.sha256, + SSHAgentProtocol.rsaSha2_512: SSHRsaSignatureType.sha512, + 0: SSHRsaSignatureType.sha1, + }; + + for (final entry in cases.entries) { + final response = await agent.handleRequest( + buildSignRequest(identity, data, entry.key), + ); + final reader = SSHMessageReader(response); + expect(reader.readUint8(), SSHAgentProtocol.signResponse); + + final signatureBlob = reader.readString(); + final signature = SSHRsaSignature.decode(signatureBlob); + expect(signature.type, entry.value); + } + }); + + test('SSHKeyPairAgent returns failure for empty and unknown requests', + () async { + final identity = rsaIdentity(); + final agent = SSHKeyPairAgent([identity]); + + final emptyResponse = await agent.handleRequest(Uint8List(0)); + expect( + SSHMessageReader(emptyResponse).readUint8(), SSHAgentProtocol.failure); + + final unknownWriter = SSHMessageWriter(); + unknownWriter.writeUint8(255); + final unknownResponse = + await agent.handleRequest(unknownWriter.takeBytes()); + expect( + SSHMessageReader(unknownResponse).readUint8(), + SSHAgentProtocol.failure, + ); + }); + + test('SSHKeyPairAgent returns failure when signing with unknown identity', + () async { + final agent = SSHKeyPairAgent([rsaIdentity()]); + final writer = SSHMessageWriter(); + writer.writeUint8(SSHAgentProtocol.signRequest); + writer.writeString(Uint8List.fromList([0, 1, 2, 3])); + writer.writeString(Uint8List.fromList('sign-me'.codeUnits)); + writer.writeUint32(SSHAgentProtocol.rsaSha2_256); + + final response = await agent.handleRequest(writer.takeBytes()); + + expect(SSHMessageReader(response).readUint8(), SSHAgentProtocol.failure); + }); + + test('SSHAgentChannel handles fragmented request frames', () async { + final sentMessages = []; + final dataMessage = Completer(); + final handler = _RecordingAgentHandler(Uint8List.fromList([42, 43, 44])); + + final controller = SSHChannelController( + localId: 1, + localMaximumPacketSize: 1024, + localInitialWindowSize: 1024, + remoteId: 2, + remoteMaximumPacketSize: 1024, + remoteInitialWindowSize: 1024, + sendMessage: (message) { + sentMessages.add(message); + if (message is SSH_Message_Channel_Data && !dataMessage.isCompleted) { + dataMessage.complete(message); + } + }, + ); + + SSHAgentChannel(controller.channel, handler); + + final requestPayload = Uint8List.fromList([ + SSHAgentProtocol.requestIdentities, + ]); + final requestWriter = SSHMessageWriter(); + requestWriter.writeUint32(requestPayload.length); + requestWriter.writeBytes(requestPayload); + final framedRequest = requestWriter.takeBytes(); + + controller.handleMessage( + SSH_Message_Channel_Data( + recipientChannel: controller.localId, + data: framedRequest.sublist(0, 2), + ), + ); + + controller.handleMessage( + SSH_Message_Channel_Data( + recipientChannel: controller.localId, + data: framedRequest.sublist(2), + ), + ); + + final responseMessage = await dataMessage.future; + expect(handler.requests.length, 1); + expect(handler.requests.single, requestPayload); + + final responseReader = SSHMessageReader(responseMessage.data); + expect(responseReader.readUint32(), 3); + expect(responseReader.readBytes(3), Uint8List.fromList([42, 43, 44])); + + expect( + sentMessages.whereType().length, + 1, + ); + + controller.destroy(); + }); + + test('SSHAgentChannel handles back-to-back frames in one chunk', () async { + final dataMessages = []; + final twoResponses = Completer(); + final handler = _RecordingAgentHandler(Uint8List.fromList([7, 8])); + + final controller = SSHChannelController( + localId: 1, + localMaximumPacketSize: 1024, + localInitialWindowSize: 1024, + remoteId: 2, + remoteMaximumPacketSize: 1024, + remoteInitialWindowSize: 1024, + sendMessage: (message) { + if (message is SSH_Message_Channel_Data) { + dataMessages.add(message); + if (dataMessages.length == 2 && !twoResponses.isCompleted) { + twoResponses.complete(); + } + } + }, + ); + + SSHAgentChannel(controller.channel, handler); + + Uint8List frame(Uint8List payload) { + final writer = SSHMessageWriter(); + writer.writeUint32(payload.length); + writer.writeBytes(payload); + return writer.takeBytes(); + } + + final requestA = Uint8List.fromList([SSHAgentProtocol.requestIdentities]); + final requestB = Uint8List.fromList([SSHAgentProtocol.signRequest]); + + final frameA = frame(requestA); + final frameB = frame(requestB); + final chunk = Uint8List(frameA.length + frameB.length) + ..setAll(0, frameA) + ..setAll(frameA.length, frameB); + + controller.handleMessage( + SSH_Message_Channel_Data( + recipientChannel: controller.localId, + data: chunk, + ), + ); + + await twoResponses.future; + + expect(handler.requests.length, 2); + expect(handler.requests[0], requestA); + expect(handler.requests[1], requestB); + + for (final response in dataMessages) { + final responseReader = SSHMessageReader(response.data); + expect(responseReader.readUint32(), 2); + expect(responseReader.readBytes(2), Uint8List.fromList([7, 8])); + } + + controller.destroy(); + }); + + test('SSHAgentChannel ignores incomplete frame when channel closes', + () async { + final dataMessages = []; + final handler = _RecordingAgentHandler(Uint8List.fromList([1])); + + final controller = SSHChannelController( + localId: 1, + localMaximumPacketSize: 1024, + localInitialWindowSize: 1024, + remoteId: 2, + remoteMaximumPacketSize: 1024, + remoteInitialWindowSize: 1024, + sendMessage: (message) { + if (message is SSH_Message_Channel_Data) { + dataMessages.add(message); + } + }, + ); + + SSHAgentChannel(controller.channel, handler); + + final truncatedFrame = Uint8List.fromList([ + 0, + 0, + 0, + 10, + SSHAgentProtocol.requestIdentities, + ]); + + controller.handleMessage( + SSH_Message_Channel_Data( + recipientChannel: controller.localId, + data: truncatedFrame, + ), + ); + + controller.handleMessage( + SSH_Message_Channel_EOF(recipientChannel: controller.localId), + ); + controller.handleMessage( + SSH_Message_Channel_Close(recipientChannel: controller.localId), + ); + + await Future.delayed(Duration.zero); + + expect(handler.requests, isEmpty); + expect(dataMessages, isEmpty); + + controller.destroy(); + }); +} diff --git a/test/src/ssh_auth_abort_error_test.dart b/test/src/ssh_auth_abort_error_test.dart new file mode 100644 index 0000000..e400574 --- /dev/null +++ b/test/src/ssh_auth_abort_error_test.dart @@ -0,0 +1,102 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:test/test.dart'; + +void main() { + group('SSHAuthAbortError', () { + test('supports constructor without reason for backward compatibility', () { + final error = SSHAuthAbortError('aborted'); + + expect(error.message, 'aborted'); + expect(error.reason, isNull); + }); + + test('stores provided reason', () { + final reason = SSHSocketError('boom'); + final error = SSHAuthAbortError('aborted', reason); + + expect(error.reason, same(reason)); + }); + }); + + group('SSHClient auth abort reason', () { + test('is null when transport closes without underlying error', () async { + final socket = _FakeSSHSocket(); + final client = SSHClient( + socket, + username: 'demo', + ); + + await socket.close(); + + await expectLater( + client.authenticated, + throwsA( + predicate((error) { + return error is SSHAuthAbortError && error.reason == null; + }), + ), + ); + + client.close(); + }); + }); +} + +class _FakeSSHSocket implements SSHSocket { + final _inputController = StreamController(); + final _doneCompleter = Completer(); + final _sink = _RecordingSink(); + + @override + Stream get stream => _inputController.stream; + + @override + StreamSink> get sink => _sink; + + @override + Future get done => _doneCompleter.future; + + @override + Future close() async { + if (!_doneCompleter.isCompleted) { + _doneCompleter.complete(); + } + await _inputController.close(); + } + + @override + void destroy() { + if (!_doneCompleter.isCompleted) { + _doneCompleter.complete(); + } + unawaited(_inputController.close()); + } +} + +class _RecordingSink implements StreamSink> { + @override + void add(List data) { + // Transport writes SSH version banner here; decode to validate data is valid bytes. + latin1.decode(data); + } + + @override + void addError(Object error, [StackTrace? stackTrace]) {} + + @override + Future addStream(Stream> stream) async { + await for (final data in stream) { + add(data); + } + } + + @override + Future close() async {} + + @override + Future get done async {} +} diff --git a/test/src/ssh_client_ident_test.dart b/test/src/ssh_client_ident_test.dart new file mode 100644 index 0000000..ea6c57b --- /dev/null +++ b/test/src/ssh_client_ident_test.dart @@ -0,0 +1,130 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:test/test.dart'; + +void main() { + group('SSHClient.ident', () { + test('uses default ident when not provided', () async { + final socket = _FakeSSHSocket(); + final client = SSHClient( + socket, + username: 'demo', + ); + + await Future.delayed(Duration.zero); + + expect(client.ident, 'DartSSH_2.0'); + expect(socket.writes, contains('SSH-2.0-DartSSH_2.0\r\n')); + + client.close(); + }); + + test('uses custom ident when provided', () async { + final socket = _FakeSSHSocket(); + final client = SSHClient( + socket, + username: 'demo', + ident: 'MyClient_1.0', + ); + + await Future.delayed(Duration.zero); + + expect(client.ident, 'MyClient_1.0'); + expect(socket.writes, contains('SSH-2.0-MyClient_1.0\r\n')); + + client.close(); + }); + + test('throws when ident is empty', () { + expect( + () => SSHClient( + _FakeSSHSocket(), + username: 'demo', + ident: '', + ), + throwsA(isA()), + ); + }); + + test('throws when ident contains newline characters', () { + expect( + () => SSHClient( + _FakeSSHSocket(), + username: 'demo', + ident: 'Bad\nIdent', + ), + throwsA(isA()), + ); + + expect( + () => SSHClient( + _FakeSSHSocket(), + username: 'demo', + ident: 'Bad\rIdent', + ), + throwsA(isA()), + ); + }); + }); +} + +class _FakeSSHSocket implements SSHSocket { + final _inputController = StreamController(); + final _doneCompleter = Completer(); + final writes = []; + + @override + Stream get stream => _inputController.stream; + + @override + StreamSink> get sink => _RecordingSink(writes); + + @override + Future get done => _doneCompleter.future; + + @override + Future close() async { + if (!_doneCompleter.isCompleted) { + _doneCompleter.complete(); + } + await _inputController.close(); + } + + @override + void destroy() { + if (!_doneCompleter.isCompleted) { + _doneCompleter.complete(); + } + unawaited(_inputController.close()); + } +} + +class _RecordingSink implements StreamSink> { + _RecordingSink(this._writes); + + final List _writes; + + @override + void add(List data) { + _writes.add(latin1.decode(data)); + } + + @override + void addError(Object error, [StackTrace? stackTrace]) {} + + @override + Future addStream(Stream> stream) async { + await for (final data in stream) { + add(data); + } + } + + @override + Future close() async {} + + @override + Future get done async {} +} diff --git a/test/src/ssh_client_test.dart b/test/src/ssh_client_test.dart index 4efb80a..084325b 100644 --- a/test/src/ssh_client_test.dart +++ b/test/src/ssh_client_test.dart @@ -1,3 +1,6 @@ +@Tags(['integration']) +library; + import 'package:dartssh2/dartssh2.dart'; import 'package:test/test.dart'; @@ -26,8 +29,8 @@ void main() { // client.close(); // }); - // These are non-standard MAC extensions (hmac-sha2-256-96, hmac-sha2-512-96) - // and require server support. Enable once server compatibility is verified. +// These are non-standard MAC extensions (hmac-sha2-256-96, hmac-sha2-512-96) +// and require server support. Enable once server compatibility is verified. // test('hmacSha256_96 mac works', () async { // var client = await getHoneypotClient( // algorithms: SSHAlgorithms(mac: [SSHMacType.hmacSha256_96]), @@ -155,6 +158,7 @@ void main() { fail('should have thrown'); } catch (e) { expect(e, isA()); + expect((e as SSHAuthAbortError).reason!, isA()); } client.close(); diff --git a/test/src/ssh_transport_version_test.dart b/test/src/ssh_transport_version_test.dart new file mode 100644 index 0000000..ce37450 --- /dev/null +++ b/test/src/ssh_transport_version_test.dart @@ -0,0 +1,116 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:test/test.dart'; + +void main() { + group('SSH transport version exchange', () { + test('accepts SSH-1.99 server banner', () async { + final socket = _FakeSSHSocket(); + final client = SSHClient( + socket, + username: 'demo', + ); + + socket.addIncoming('SSH-1.99-OpenSSH_3.6.1p2\r\n'); + await _pumpUntil(() => client.remoteVersion != null); + + expect(client.remoteVersion, 'SSH-1.99-OpenSSH_3.6.1p2'); + + client.close(); + }); + + test('rejects non SSH-2 compatible server banners', () async { + final socket = _FakeSSHSocket(); + final client = SSHClient( + socket, + username: 'demo', + ); + + socket.addIncoming('SSH-1.5-OpenSSH_1.2\r\n'); + + await expectLater( + client.authenticated, + throwsA( + predicate((error) { + return error is SSHAuthAbortError && + error.reason is SSHHandshakeError; + }), + ), + ); + + client.close(); + }); + }); +} + +Future _pumpUntil(bool Function() condition) async { + for (var i = 0; i < 50; i++) { + if (condition()) { + return; + } + await Future.delayed(const Duration(milliseconds: 10)); + } + fail('Timed out waiting for condition'); +} + +class _FakeSSHSocket implements SSHSocket { + final _inputController = StreamController(); + final _doneCompleter = Completer(); + final _sink = _RecordingSink(); + + @override + Stream get stream => _inputController.stream; + + @override + StreamSink> get sink => _sink; + + @override + Future get done => _doneCompleter.future; + + void addIncoming(String data) { + _inputController.add(Uint8List.fromList(latin1.encode(data))); + } + + @override + Future close() async { + if (!_doneCompleter.isCompleted) { + _doneCompleter.complete(); + } + await _inputController.close(); + } + + @override + void destroy() { + if (!_doneCompleter.isCompleted) { + _doneCompleter.complete(); + } + unawaited(_inputController.close()); + } +} + +class _RecordingSink implements StreamSink> { + @override + void add(List data) { + // SSHTransport writes protocol lines and packets to the sink. + latin1.decode(data); + } + + @override + void addError(Object error, [StackTrace? stackTrace]) {} + + @override + Future addStream(Stream> stream) async { + await for (final data in stream) { + add(data); + } + } + + @override + Future close() async {} + + @override + Future get done async {} +}