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
34 changes: 34 additions & 0 deletions .github/workflows/dart.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,37 @@ jobs:

- name: Run unit tests compiled to JavaScript (chrome)
run: dart test -p chrome --exclude-tags=integration

# Everything above proves the pieces compile and behave under dart2js.
# None of it puts a packet on a wire, which is the gap that let every
# AEAD cipher and the whole of SFTP sit broken on the web until 4.0.0.
# The steps below run the browser against the same OpenSSH server the VM
# interop tests use, through a WebSocket bridge, because a browser has no
# TCP socket of its own. That is also what the README tells web users to
# write, so the test doubles as a worked example of it.
- name: Start a local OpenSSH server
if: github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main')
run: tool/start_test_sshd.sh

- name: Start the WebSocket bridge
if: github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main')
run: |
dart run tool/ws_bridge.dart &
for _ in $(seq 1 30); do
if (exec 3<>/dev/tcp/127.0.0.1/8022) 2>/dev/null; then
exec 3<&- 3>&-
echo "bridge is up"
exit 0
fi
sleep 1
done
echo "bridge did not come up in time" >&2
exit 1

- name: Run interop tests in a browser against the real server
if: github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main')
run: dart test -p chrome --tags=integration

- name: Stop the local OpenSSH server
if: always()
run: docker rm -f dartssh2-test-sshd || true
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
## [4.1.0] - Unreleased
- Added `SSHClient.pipelineChannelRequests`, off by default, which sends all of a session's channel requests before reading any reply instead of waiting for each one in turn. `execute` and `shell` send `env`, agent forwarding, `pty-req` and `x11-req` ahead of `exec` or `shell`, and each of them cost a round trip: against a server 40 ms away, `execute` with a pty measured 246 ms before and 206 ms after. RFC 4254 §5.4 permits sending further messages without waiting and §4 requires the peer to answer a channel's requests in the order it received them, which is what `ssh(1)` relies on when it does the same thing. Setting it also adopts OpenSSH's reporting, because it has to: the command is on the wire before a refusal can come back, so a refused `pty-req`, `env`, agent forwarding or `x11-req` is reported through `printDebug` and the command runs, the way `ssh(1)` prints `PTY allocation request failed on channel 0` and carries on, rather than throwing an error that means the command has already run. Only a refused `exec` or `shell` still throws `SSHChannelRequestError`, because nothing has run when that one fails. Leaving it unset changes nothing, down to the order the requests go out and the message of every error [#243].
- Added a `dartssh2-nopty` account to the interop server, which `PermitTTY no` applies to, so both sides of a refused `pty-req` are exercised against a real OpenSSH: by default the command does not run, and with pipelining it does [#243].
- Fixed the package reading as web-incompatible on pub.dev. `SftpFile.downloadToRandomAccess` takes a `dart:io` `RandomAccessFile`, and naming that type from the SFTP library was enough for pub.dev to drop `platform:web` from the whole package, which also keeps it out of any search filtered to web. Everything else already compiled and ran there, and 4.0.0 made the ciphers and SFTP work. The method moves to an `SftpFileDownload` extension in its own library, exported conditionally the way `SSHSocket` and dynamic forwarding already are, so nothing changes for a caller on the VM: same import, same call. On the web the extension is simply absent, as is the `RandomAccessFile` it would need. Confirmed with pana, the tool pub.dev scores with: `platform:web` is present after and absent before [#248].
- Added browser interop tests, so "web is supported" is something CI checks rather than something the pieces individually suggest. Everything that ran under `-p chrome` until now proved the AEAD arithmetic, the SFTP encoding and the HTTP parsing compile and behave, and none of it put a packet on a wire, which is the gap that let every AEAD cipher and the whole of SFTP sit broken on the web until 4.0.0. The new tests run the browser against the same OpenSSH server the VM interop tests use, through `tool/ws_bridge.dart`, and cover a handshake, a command, an `aes256-gcm` session and an SFTP round trip. The `SSHSocket` they connect through is the WebSocket one the README tells web users to write, so it doubles as a worked example [#248].

## [4.0.1] - 2026-09-03
- Fixed a channel stalling permanently when data arrived before the application subscribed to it. `StreamController.isPaused` is true until something listens, which suppressed every `SSH_MSG_CHANNEL_WINDOW_ADJUST`, and Dart delivers the first subscription as `onListen` rather than `onResume` — the only hook wired — so a peer that legally filled the advertised window in that gap was left at zero credit with no further data able to arrive and trigger a grant. Reproduced at the client's own sizes with 64 packets of 32 KiB into a 2 MiB window, before and after the first listener and through `.map()`, in every case zero adjustments. The remote forwarding example in the README reaches it: it awaits `Socket.connect()` for each connection before subscribing, and the forwarded channel is created with no listener attached [#244].
Expand Down Expand Up @@ -439,6 +441,7 @@
[#237]: https://github.com/vicajilau/dartssh2/pull/237
[#239]: https://github.com/vicajilau/dartssh2/pull/239
[#243]: https://github.com/vicajilau/dartssh2/issues/243
[#248]: https://github.com/vicajilau/dartssh2/pull/248
[#244]: https://github.com/vicajilau/dartssh2/pull/244

[@linhanyu]: https://github.com/linhanyu
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,15 @@ under dart2js, so a browser connection died at the first encrypted packet even
with a correct transport. That is fixed, and a `dart test -p chrome` job now
guards it.

One caveat remains. `chacha20-poly1305@openssh.com` cannot be used on the web,
Two caveats remain. `chacha20-poly1305@openssh.com` cannot be used on the web,
because PointyCastle's Poly1305 requires full-width 64-bit integers. It sits
third in the default cipher list, so AES-GCM or AES-CTR is normally negotiated
and nothing needs doing. Only pinning ChaCha20-Poly1305 explicitly will fail.

And `SftpFile.downloadToRandomAccess` is not available, since it takes a
`dart:io` `RandomAccessFile` and there is no local file to hand it. Use
`SftpFile.downloadTo`, which takes a sink, or `SftpFile.read`.

### Customize client SSH identification

If your jump host or SSH gateway restricts client versions, you can customize the
Expand Down
5 changes: 5 additions & 0 deletions lib/dartssh2.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export 'src/sftp/sftp_file_attrs.dart';
export 'src/sftp/sftp_name.dart';
export 'src/sftp/sftp_status_code.dart';
export 'src/sftp/sftp_stream_io.dart';
// Adds SftpFile.downloadToRandomAccess where dart:io exists. Kept behind a
// conditional export so that naming RandomAccessFile does not make the whole
// package read as web-incompatible on pub.dev.
export 'src/sftp/sftp_file_io_stub.dart'
if (dart.library.io) 'src/sftp/sftp_file_io.dart';

export 'src/http/http_client.dart';
export 'src/http/http_exception.dart';
Expand Down
2 changes: 1 addition & 1 deletion lib/src/sftp/sftp_client.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';

import 'package:convert/convert.dart';
import 'package:meta/meta.dart';
import 'package:dartssh2/src/sftp/sftp_errors.dart';
import 'package:dartssh2/src/sftp/sftp_file_attrs.dart';
import 'package:dartssh2/src/sftp/sftp_file_open_mode.dart';
Expand Down
209 changes: 9 additions & 200 deletions lib/src/sftp/sftp_file.dart
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ class SftpFile {

void issueRead(int startOffset, int requestLength) {
pendingReadCount++;
_readChunk(requestLength, startOffset).then(
readChunk(requestLength, startOffset).then(
(chunk) {
pendingReadCount--;

Expand Down Expand Up @@ -301,197 +301,6 @@ class SftpFile {
return bytesRead;
}

/// Downloads this file into a random-access local file.
///
/// Unlike [read] and [downloadTo], this method does not require SFTP read
/// replies to be yielded in offset order. Replies are written to
/// [destination] at the same offset, allowing pipelined reads to make
/// progress even when later offsets complete before earlier ones.
///
/// Returns the total number of bytes written.
Future<int> downloadToRandomAccess(
RandomAccessFile destination, {
int? length,
int offset = 0,
void Function(int bytesRead)? onProgress,
int chunkSize = _kDownloadChunkSize,
int maxPendingRequests = _kDownloadMaxPendingRequests,
}) async {
_mustNotBeClosed();
if (chunkSize <= 0) {
throw ArgumentError.value(chunkSize, 'chunkSize', 'must be positive');
}
if (maxPendingRequests <= 0) {
throw ArgumentError.value(
maxPendingRequests,
'maxPendingRequests',
'must be positive',
);
}

// Whether `length` reflects a real, trustworthy byte count. It starts
// true (an explicit `length` from the caller is always trusted) and is
// only flipped below when we have to fall back to the stat()-size-0
// sentinel. It gates the truncation check at the end of this method -
// see the comment there for why that check can't just compare against
// the (possibly sentinel) `length` value directly.
var lengthIsKnown = true;

if (length == null) {
final fileSize = (await stat()).size;
if (fileSize == null) {
throw SftpError('Can not get file size');
}
length = fileSize - offset;

// Some filesystems report a size of 0 for files that actually contain
// data (e.g. Linux /proc entries, character/device files). SFTP
// signals end-of-file via the SSH_FX_EOF status code, not via the
// reported size, so don't trust a stat()-derived size of 0 as
// "nothing to download" - see the matching comment in [read] for the
// full reasoning. Fall back to downloading until the server tells us
// we've hit EOF instead. This only applies when we computed `length`
// ourselves; a caller who explicitly passes `length: 0` still gets an
// empty, zero-byte download below.
if (length == 0) {
length = _kUnboundedReadLength;
lengthIsKnown = false;
// See [read]: with the real end unknown, the reservation logic
// below can't tell when to stop fanning out speculative reads, so
// force strictly sequential requests instead of fanning out up to
// [maxPendingRequests] of them into a file that may only be a few
// bytes long. Because writes here go to their own offset (not
// gated on in-order arrival like [read]), concurrency wouldn't be
// *incorrect* - just wasteful for the common case this exists for.
maxPendingRequests = 1;
}
}

if (length == 0) return 0;
if (length < 0) {
throw SftpError('Length must be positive: $length');
}

final endOffset = offset + length;
final completionQueue = <_ReadCompletion>[];
var reservedOffset = offset;
var bytesWritten = 0;
var pendingReadCount = 0;
var activeReadLimit = 1;
var effectiveChunkSize = chunkSize;
Object? pendingError;
StackTrace? pendingStackTrace;
Completer<void>? completionSignal;

void notifyReadComplete() {
final signal = completionSignal;
if (signal != null && !signal.isCompleted) {
signal.complete();
}
}

Future<void> waitForReadComplete() {
if (completionQueue.isNotEmpty || pendingError != null) {
return Future.value();
}
final signal = completionSignal = Completer<void>();
return signal.future.whenComplete(() {
if (identical(completionSignal, signal)) {
completionSignal = null;
}
});
}

void issueRead(int startOffset, int requestLength) {
pendingReadCount++;
_readChunk(requestLength, startOffset).then(
(chunk) {
pendingReadCount--;
if (chunk != null && chunk.isNotEmpty) {
activeReadLimit = min(maxPendingRequests, activeReadLimit + 1);
}
completionQueue.add(_ReadCompletion(startOffset, chunk));
if (chunk != null &&
chunk.isNotEmpty &&
chunk.length < requestLength &&
startOffset + chunk.length < endOffset) {
effectiveChunkSize = max(1, min(effectiveChunkSize, chunk.length));
issueRead(
startOffset + chunk.length,
min(
requestLength - chunk.length,
endOffset - startOffset - chunk.length,
),
);
}
notifyReadComplete();
},
onError: (Object error, StackTrace stackTrace) {
pendingReadCount--;
pendingError = error;
pendingStackTrace = stackTrace;
notifyReadComplete();
},
);
}

void scheduleReads() {
while (reservedOffset < endOffset && pendingReadCount < activeReadLimit) {
final startOffset = reservedOffset;
final requestLength =
min(effectiveChunkSize, endOffset - reservedOffset);
issueRead(startOffset, requestLength);
reservedOffset += requestLength;
}
}

scheduleReads();

while (bytesWritten < length) {
if (pendingError != null) {
Error.throwWithStackTrace(pendingError!, pendingStackTrace!);
}

if (completionQueue.isEmpty) {
if (pendingReadCount == 0) break;
await waitForReadComplete();
continue;
}

final completion = completionQueue.removeAt(0);
final startOffset = completion.startOffset;
final chunk = completion.chunk;
if (chunk == null) break;
if (chunk.isEmpty) {
throw SftpError('Unexpected empty data chunk before EOF');
}

final remaining = length - (startOffset - offset);
final outputChunk = chunk.length <= remaining
? chunk
: Uint8List.sublistView(chunk, 0, remaining);
await destination.setPosition(startOffset);
await destination.writeFrom(outputChunk);

bytesWritten += outputChunk.length;
onProgress?.call(bytesWritten);
scheduleReads();
}

// Only enforce the truncation check when `length` is a real target byte
// count. When it's the unbounded sentinel (stat() reported size 0 but
// EOF is what actually ended the loop above), `bytesWritten` will almost
// never equal the sentinel and this would misfire on every such
// download, including genuinely-complete ones and genuinely-empty ones.
if (lengthIsKnown && bytesWritten != length) {
throw SftpError(
'Incomplete download: received $bytesWritten of $length bytes',
);
}

return bytesWritten;
}

/// Reads at most [length] bytes from the file starting at [offset]. If
/// [length] is null, reads until end of the file.
/// Use [read] if you want to stream large file in chunks.
Expand Down Expand Up @@ -628,7 +437,14 @@ class SftpFile {
SftpStatusError.check(reply);
}

Future<Uint8List?> _readChunk(int length, [int offset = 0]) async {
/// Reads one chunk from the remote file.
///
/// The single seam [SftpFileDownload.downloadToRandomAccess] needs. It lives
/// in its own library so that naming `dart:io`'s [RandomAccessFile] does not
/// pull `dart:io` into this one, which is what kept pub.dev from listing the
/// package as available on the web.
@internal
Future<Uint8List?> readChunk(int length, [int offset = 0]) async {
_mustNotBeClosed();
final reply = await _client._sendRead(_handle, offset, length);
if (reply is SftpDataPacket) return reply.data;
Expand Down Expand Up @@ -661,13 +477,6 @@ class SftpHandsake {
}

/// Tracks a pending SFTP read completion for [SftpFile.downloadToRandomAccess].
class _ReadCompletion {
_ReadCompletion(this.startOffset, this.chunk);

final int startOffset;
final Uint8List? chunk;
}

class _WriteCompletion {
_WriteCompletion(this.id);

Expand Down
Loading
Loading