Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions .github/workflows/dart.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
sdk: [stable]

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dart-lang/setup-dart@v1
with:
sdk: ${{ matrix.sdk }}
Expand All @@ -40,14 +40,21 @@ jobs:
- name: Activate coverage tool
run: dart pub global activate coverage

- name: Run tests
run: dart pub global run coverage:test_with_coverage
- name: Run unit tests with coverage
run: dart test --exclude-tags=integration --coverage=coverage

- name: Build LCOV report
run: dart pub global run coverage:format_coverage --lcov --in=coverage/test --out=coverage/lcov.info --packages=.dart_tool/package_config.json --report-on=lib

- name: Run integration tests
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
run: dart test --tags=integration

- name: Upload coverage
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
with:
fail_ci_if_error: true # optional (default = false)
files: ./coverage1.xml,./coverage2.xml # optional
files: ./coverage/lcov.info # optional
flags: unittests # optional
name: codecov-umbrella # optional
token: ${{ secrets.CODECOV_TOKEN }} # required
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ doc/api/

.history

.vscode
.vscode

coverage/
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
- 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].
Expand Down Expand Up @@ -185,6 +187,7 @@

[#141]: https://github.com/TerminalStudio/dartssh2/pull/141
[#140]: https://github.com/TerminalStudio/dartssh2/pull/140
[#145]: https://github.com/TerminalStudio/dartssh2/pull/145
[#139]: https://github.com/TerminalStudio/dartssh2/pull/139
[#132]: https://github.com/TerminalStudio/dartssh2/pull/132
[#133]: https://github.com/TerminalStudio/dartssh2/pull/133
Expand Down
19 changes: 19 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
coverage:
status:
project:
default:
target: auto
threshold: 3%
flags:
- unittests
patch:
default:
target: auto
threshold: 3%
flags:
- unittests

comment:
layout: "reach,diff,flags,files"
behavior: default
require_changes: false
3 changes: 3 additions & 0 deletions dart_test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
tags:
integration:
timeout: 2x
17 changes: 10 additions & 7 deletions lib/src/http/http_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -415,20 +415,22 @@ class SSHHttpClientResponse {
var contentRead = 0;

void processLine(String line, int bytesRead, LineDecoder decoder) {
final normalizedLine = line.trimRight();
if (inBody) {
body.write(line);
contentRead += bytesRead;
} else if (inHeader) {
if (line.trim().isEmpty) {
if (normalizedLine.trim().isEmpty) {
inBody = true;
if (contentLength > 0) {
decoder.expectedByteCount = contentLength;
}
return;
}
final separator = line.indexOf(':');
final name = line.substring(0, separator).toLowerCase().trim();
final value = line.substring(separator + 1).trim();
final separator = normalizedLine.indexOf(':');
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');
Expand All @@ -440,11 +442,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');
Expand Down
4 changes: 2 additions & 2 deletions lib/src/sftp/sftp_packet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ class SftpInitPacket implements SftpPacket {
reader.readUint8(); // packet type
final version = reader.readUint32();
final extensions = <String, String>{};
while (reader.isDone) {
while (!reader.isDone) {
final name = reader.readUtf8();
final value = reader.readUtf8();
extensions[name] = value;
Expand Down Expand Up @@ -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();
}

Expand Down
3 changes: 3 additions & 0 deletions test/src/channel/ssh_channel_test.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
@Tags(['integration'])
library ssh_channel_test;

import 'package:dartssh2/dartssh2.dart';
import 'package:test/test.dart';

Expand Down
177 changes: 177 additions & 0 deletions test/src/http/http_client_test.dart
Original file line number Diff line number Diff line change
@@ -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<UnsupportedError>()),
);
});

test('throws for unsupported response format', () async {
final socket = _FakeSocket([
'NOT_HTTP\r\n',
]);

await expectLater(
SSHHttpClientResponse.from(socket),
throwsA(isA<UnsupportedError>()),
);
});
});

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<SSHHttpException>()),
);
});

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<UnsupportedError>()),
);
expect(
() => response.headers.set('x', '1'),
throwsA(isA<UnsupportedError>()),
);
expect(
() => response.headers.removeAll('x'),
throwsA(isA<UnsupportedError>()),
);
expect(
() => response.headers.clear(),
throwsA(isA<UnsupportedError>()),
);
});
});
}

class _FakeSocket implements SSHSocket {
_FakeSocket(List<String> chunks)
: _chunks = chunks
.map((chunk) => Uint8List.fromList(chunk.codeUnits))
.toList(growable: false);

final List<Uint8List> _chunks;
final _sinkController = StreamController<List<int>>();
final _doneCompleter = Completer<void>();
bool closed = false;

@override
Stream<Uint8List> get stream => Stream<Uint8List>.fromIterable(_chunks);

@override
StreamSink<List<int>> get sink => _sinkController.sink;

@override
Future<void> get done => _doneCompleter.future;

@override
Future<void> 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());
}
}
Loading
Loading