Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
2b7ae79
chore: update CHANGELOG.md with release date and contributor links
vicajilau Mar 20, 2026
f5d4168
chore: update version to 2.16.0 and clarify stdio handling for CLI-on…
vicajilau Mar 22, 2026
a34416b
style: format code for better readability in shell.dart
vicajilau Mar 22, 2026
0a046af
fix(channel): make sendEnv awaitable to prevent race condition with p…
vicajilau Mar 22, 2026
0695950
feat(keys): support legacy EC PRIVATE KEY parsing
vicajilau Mar 22, 2026
8557522
test(keys): add coverage for legacy EC PRIVATE KEY parser paths
vicajilau Mar 22, 2026
07dfa35
test(keys): add tests for ECDSA nistp384 and nistp521 private key han…
vicajilau Mar 22, 2026
3de396d
Merge pull request #147 from TerminalStudio/fix/issue-121-shell-stdio…
vicajilau Mar 22, 2026
416694b
test: add integration test for SftpClient.listdir method
vicajilau Mar 22, 2026
2fd9949
feat(client): add runWithResult with exit metadata
vicajilau Mar 22, 2026
447a436
feat(example): implement runWithResult with exit metadata and shell flow
vicajilau Mar 22, 2026
885acf7
docs(README): add examples for runWithResult and end-to-end flow
vicajilau Mar 22, 2026
8eed8a5
Merge pull request #148 from TerminalStudio/fix/102-sendenv-race-cond…
vicajilau Mar 22, 2026
f9b9ea1
test(client): add unit coverage for runWithResult
vicajilau Mar 22, 2026
e649e64
Merge pull request #149 from TerminalStudio/feat/109-legacy-ec-privat…
vicajilau Mar 22, 2026
aa5d5e7
Merge pull request #150 from TerminalStudio/feat/99-run-with-result
vicajilau Mar 22, 2026
58a86e4
feat(sftp): add download APIs and integration coverage
vicajilau Mar 24, 2026
b86bc03
fix(ci): update integration test trigger to include pull requests
vicajilau Mar 24, 2026
b2c7a23
feat(sftp): add download progress reporting and streaming write funct…
vicajilau Mar 24, 2026
5a291ad
Merge pull request #151 from TerminalStudio/feat/sftp-download-api-124
vicajilau Mar 24, 2026
0627ee8
fix(sftp): tolerate malformed UTF-8 filenames (#95)
vicajilau Mar 24, 2026
efdd62b
fix(tests): handle null exit codes in SSH command results
vicajilau Mar 24, 2026
1323d28
Merge pull request #152 from TerminalStudio/feat/sftp-nonutf8-filenam…
vicajilau Mar 24, 2026
0817921
docs(README): update client.run() documentation for clarity on output…
vicajilau Mar 24, 2026
e17591f
docs(README): add web support section with guidance on SSHSocket usag…
vicajilau Mar 24, 2026
6ec65df
fix(ssh_socket): update error handling for web socket connections
vicajilau Mar 24, 2026
c369227
docs(CHANGELOG): update release date for version 2.16.0
vicajilau Mar 24, 2026
1e62efc
feat: Added new features and fixed several issues
GT-610 Mar 30, 2026
eccf26f
fix: Fixed an error in the X11 screen number type and added RSA signa…
GT-610 Mar 30, 2026
bd6f8d2
fix(ssh_client): Fixed an issue where completion callbacks for stdout…
GT-610 Mar 30, 2026
804379f
fix(ssh_key_pair): Properly handle UnsupportedError and validate the …
GT-610 Mar 30, 2026
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
22 changes: 19 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
## [2.15.0] - 2026-03-30
## [2.16.0] - 2026-03-24
- **BREAKING**: Changed `SSHChannelController.sendEnv()` from `void` to `Future<bool>` to properly await environment variable setup responses and avoid race conditions with PTY requests [#102]. Thanks [@itzhoujun] and [@vicajilau].
- Clarified shell stdio wiring for CLI-only usage and guarded `example/shell.dart` against missing local terminal handles (for example GUI-launched Windows `.exe`) [#121]. Thanks [@bradmartin333] and [@vicajilau].
- Added support for parsing legacy unencrypted `EC PRIVATE KEY` PEM format in `SSHKeyPair.fromPem` [#109]. Thanks [@jooy2] and [@vicajilau].
- Added `SSHClient.runWithResult()` to expose command output together with `exitCode` and `exitSignal` while keeping `run()` as a convenience API [#99]. Thanks [@falrom] and [@vicajilau].
- Added non-breaking high-level SFTP `download()` / `downloadTo()` APIs and read pipeline tuning knobs (`chunkSize`, `maxPendingRequests`) for improved large-file throughput while preserving stream compatibility [#124]. Thanks [@vicajilau].
- Made SFTP directory/file name parsing tolerant to malformed UTF-8 bytes to avoid `FormatException` on non-UTF-8 server filenames [#95]. Thanks [@vicajilau].

## [2.15.0] - 2026-03-20
- 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].
Expand Down Expand Up @@ -188,6 +196,12 @@
[#145]: https://github.com/TerminalStudio/dartssh2/pull/145
[#141]: https://github.com/TerminalStudio/dartssh2/pull/141
[#140]: https://github.com/TerminalStudio/dartssh2/pull/140
[#102]: https://github.com/TerminalStudio/dartssh2/issues/102
[#99]: https://github.com/TerminalStudio/dartssh2/issues/99
[#109]: https://github.com/TerminalStudio/dartssh2/issues/109
[#121]: https://github.com/TerminalStudio/dartssh2/issues/121
[#124]: https://github.com/TerminalStudio/dartssh2/issues/124
[#95]: https://github.com/TerminalStudio/dartssh2/issues/95
[#139]: https://github.com/TerminalStudio/dartssh2/pull/139
[#133]: https://github.com/TerminalStudio/dartssh2/pull/133
[#132]: https://github.com/TerminalStudio/dartssh2/pull/132
Expand Down Expand Up @@ -219,7 +233,9 @@
[@alexander-irion]: https://github.com/alexander-irion
[@Remulic]: https://github.com/Remulic
[@james-thorpe]: https://github.com/james-thorpe
[@itzhoujun]: https://github.com/itzhoujun
[@jooy2]: https://github.com/jooy2
[@falrom]: https://github.com/falrom
[@bradmartin333]: https://github.com/bradmartin333
[@Wackymax]: https://github.com/Wackymax
[@vicajilau]: https://github.com/vicajilau
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Removed CHANGELOG link definitions for contributors still referenced in text

The link definitions for [@shihuili1218] and [@isegal] were removed from the CHANGELOG footer, but both are still referenced in the 2.14.0 section (lines 20-21). This results in broken markdown links — the rendered text will show raw [@shihuili1218] and [@isegal] brackets instead of clickable hyperlinks.

Suggested change
[@vicajilau]: https://github.com/vicajilau
[@vicajilau]: https://github.com/vicajilau
[@shihuili1218]: https://github.com/shihuili1218
[@isegal]: https://github.com/isegal
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

[@shihuili1218]: https://github.com/shihuili1218
[@isegal]: https://github.com/isegal
139 changes: 135 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,18 @@ 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.

### Web support

Direct native TCP sockets are not available in browsers, so this will fail on
Flutter Web / Dart Web:

```dart
await SSHSocket.connect('host', 22);
```

For web apps, use a custom `SSHSocket` transport over a browser-supported
channel (for example, a WebSocket tunnel/proxy to your SSH endpoint).

### Customize client SSH identification

If your jump host or SSH gateway restricts client versions, you can customize the
Expand All @@ -142,15 +154,25 @@ void main() async {
```dart
void main() async {
final shell = await client.shell();
stdout.addStream(shell.stdout); // listening for stdout
stderr.addStream(shell.stderr); // listening for stderr
stdin.cast<Uint8List>().listen(shell.write); // writing to stdin

// Attach local terminal streams only when a terminal is available.
// GUI apps on Windows may not have stdin/stdout/stderr attached.
final hasTerminal = stdin.hasTerminal && stdout.hasTerminal && stderr.hasTerminal;
if (hasTerminal) {
stdout.addStream(shell.stdout); // listening for stdout
stderr.addStream(shell.stderr); // listening for stderr
stdin.cast<Uint8List>().listen(shell.write); // writing to stdin
}

await shell.done; // wait for shell to exit
client.close();
}
```

> Note: The stdin/stdout bridging above is for CLI apps. If your app is launched
> without a terminal (for example, double-clicking a Windows `.exe`), skip the
> local stdio wiring and use your own UI/input pipeline.

### Execute a command on remote host


Expand All @@ -169,7 +191,51 @@ void main() async {
}
```

> `client.run()` is a convenience method that wraps `client.execute()` for running non-interactive commands.
> `client.run()` is a convenience method that returns combined output bytes.
> Use `client.runWithResult()` when you need separate `stdout` / `stderr`
> streams and command exit metadata (`exitCode` / `exitSignal`).

To also access command exit metadata:

```dart
void main() async {
final result = await client.runWithResult('echo hello');
print('exitCode: ${result.exitCode}');
print('stdout: ${utf8.decode(result.stdout)}');
print('stderr: ${utf8.decode(result.stderr)}');
}
```

### End-to-end flow example

Use `example/run_flows.dart` to test the main execution flows in one run:

- `run()`
- `runWithResult()`
- `execute()`
- optional `shell()` via `--shell`

Run it with environment variables:

```sh
SSH_HOST=test.rebex.net SSH_PORT=22 SSH_USERNAME=demo SSH_PASSWORD=password dart run example/run_flows.dart
```

Run shell flow too:

```sh
SSH_HOST=test.rebex.net SSH_PORT=22 SSH_USERNAME=demo SSH_PASSWORD=password dart run example/run_flows.dart --shell
```

On Windows PowerShell:

```powershell
$env:SSH_HOST = 'test.rebex.net'
$env:SSH_PORT = '22'
$env:SSH_USERNAME = 'demo'
$env:SSH_PASSWORD = 'password'
dart run example/run_flows.dart --shell
```

### Start a process on remote host
```dart
Expand Down Expand Up @@ -324,6 +390,71 @@ void main() async {
}
```

### Download remote file (high-level API)
```dart
void main() async {
final sftp = await client.sftp();
final output = File('local_file.txt').openWrite();

final bytes = await sftp.download(
'/remote/file.txt',
output,
onProgress: (bytesRead) => print('downloaded: $bytesRead bytes'),
closeDestination: true,
);

print('download complete: $bytes bytes');
}
```

`download()` and `downloadTo()` are opt-in convenience APIs built on top of the
existing stream-based behavior, so existing code remains fully compatible.

When to use each API:

- Use `sftp.download(path, sink)` when you only have a remote path and want the
simplest one-liner flow. It opens and closes the remote file for you.
- Use `file.downloadTo(sink)` when you already have an open `SftpFile` (for
example you want partial downloads with `offset`/`length` or want to reuse the
same handle).

```dart
void main() async {
final sftp = await client.sftp();
final file = await sftp.open('/remote/file.txt');
final output = File('local_partial.bin').openWrite();

try {
// Download bytes [1024, 1024 + 4096) using an existing open handle.
await file.downloadTo(
output,
offset: 1024,
length: 4096,
closeDestination: true,
);
} finally {
await file.close();
}
}
```

For high-latency links or large files, you can tune pipelining:

```dart
void main() async {
final sftp = await client.sftp();
final output = File('local_file.txt').openWrite();

await sftp.download(
'/remote/file.txt',
output,
chunkSize: 64 * 1024,
maxPendingRequests: 128,
closeDestination: true,
);
}
```

### Write remote file
```dart
void main() async {
Expand Down
102 changes: 102 additions & 0 deletions example/run_flows.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';

import 'package:dartssh2/dartssh2.dart';

Future<Uint8List> _collectBytes(Stream<Uint8List> stream) async {
final builder = BytesBuilder(copy: false);
await for (final chunk in stream) {
builder.add(chunk);
}
return builder.takeBytes();
}

Future<void> main(List<String> args) async {
final host = Platform.environment['SSH_HOST'] ?? 'localhost';
final port = int.tryParse(Platform.environment['SSH_PORT'] ?? '') ?? 22;
final username = Platform.environment['SSH_USERNAME'] ?? 'root';

final runShellDemo = args.contains('--shell');

final client = SSHClient(
await SSHSocket.connect(host, port),
username: username,
onPasswordRequest: () {
final envPassword = Platform.environment['SSH_PASSWORD'];
if (envPassword != null) {
return envPassword;
}

if (!stdin.hasTerminal || !stdout.hasTerminal) {
throw StateError(
'No terminal attached. Set SSH_PASSWORD environment variable.',
);
}

stdout.write('Password for $username@$host:$port: ');
try {
stdin.echoMode = false;
final password = stdin.readLineSync();
if (password == null || password.isEmpty) {
throw StateError('Empty password');
}
return password;
} finally {
stdin.echoMode = true;
stdout.writeln();
}
},
);

try {
// 1) Convenience run() for simple command output.
final runOutput = await client.run('echo run-ok');
stdout.writeln('[run] output: ${utf8.decode(runOutput).trim()}');

// 2) runWithResult() with exit metadata.
final runResult = await client.runWithResult(
'sh -lc "echo runWithResult-out; echo runWithResult-err 1>&2; exit 7"',
);
stdout.writeln('[runWithResult] exitCode: ${runResult.exitCode}');
stdout.writeln('[runWithResult] stdout: ${utf8.decode(runResult.stdout)}');
stdout.writeln('[runWithResult] stderr: ${utf8.decode(runResult.stderr)}');

// 3) execute() for lower-level session control.
final session = await client.execute('echo execute-ok');
final executeStdoutFuture = _collectBytes(session.stdout);
final executeStderrFuture = _collectBytes(session.stderr);
await session.done;
final executeStdout = await executeStdoutFuture;
final executeStderr = await executeStderrFuture;
stdout.writeln('[execute] exitCode: ${session.exitCode}');
stdout.writeln('[execute] stdout: ${utf8.decode(executeStdout).trim()}');
if (executeStderr.isNotEmpty) {
stdout.writeln('[execute] stderr: ${utf8.decode(executeStderr).trim()}');
}

// 4) Optional shell flow for interactive/pty scenarios.
if (runShellDemo) {
final shell = await client.shell(pty: const SSHPtyConfig());
final shellStdoutFuture = _collectBytes(shell.stdout);
final shellStderrFuture = _collectBytes(shell.stderr);

shell.write(Uint8List.fromList('echo shell-ok; exit\n'.codeUnits));
await shell.stdin.close();
await shell.done;

final shellStdout = await shellStdoutFuture;
final shellStderr = await shellStderrFuture;
stdout.writeln('[shell] exitCode: ${shell.exitCode}');
stdout.writeln('[shell] stdout: ${utf8.decode(shellStdout).trim()}');
if (shellStderr.isNotEmpty) {
stdout.writeln('[shell] stderr: ${utf8.decode(shellStderr).trim()}');
}
} else {
stdout.writeln('Shell flow skipped. Use --shell to run it.');
}
} finally {
client.close();
await client.done;
}
}
8 changes: 8 additions & 0 deletions example/shell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import 'dart:typed_data';
import 'package:dartssh2/dartssh2.dart';

void main(List<String> args) async {
final hasTerminal =
stdin.hasTerminal && stdout.hasTerminal && stderr.hasTerminal;
if (!hasTerminal) {
stderr.writeln('No terminal attached. Exiting.');
exit(1);
}

final socket = await SSHSocket.connect('localhost', 22);

final client = SSHClient(
Expand All @@ -17,6 +24,7 @@ void main(List<String> args) async {
);

final shell = await client.shell();

stdout.addStream(shell.stdout);
stderr.addStream(shell.stderr);
stdin.cast<Uint8List>().listen(shell.write);
Expand Down
4 changes: 2 additions & 2 deletions lib/src/message/base.dart
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ class SSHMessageReader {
return value;
}

String readUtf8() {
return utf8.decode(readString());
String readUtf8({bool allowMalformed = false}) {
return utf8.decode(readString(), allowMalformed: allowMalformed);
}

List<String> readNameList() {
Expand Down
8 changes: 4 additions & 4 deletions lib/src/message/msg_channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -606,7 +606,7 @@ class SSH_Message_Channel_Request implements SSHMessage {
final bool? singleConnection;
final String? x11AuthenticationProtocol;
final String? x11AuthenticationCookie;
final String? x11ScreenNumber;
final int? x11ScreenNumber;

/// "env" request specific data
final String? variableName;
Expand Down Expand Up @@ -687,7 +687,7 @@ class SSH_Message_Channel_Request implements SSHMessage {
bool singleConnection = false,
required String x11AuthenticationProtocol,
required String x11AuthenticationCookie,
required String x11ScreenNumber,
required int x11ScreenNumber,
}) {
return SSH_Message_Channel_Request(
recipientChannel: recipientChannel,
Expand Down Expand Up @@ -849,7 +849,7 @@ class SSH_Message_Channel_Request implements SSHMessage {
final singleConnection = reader.readBool();
final x11AuthenticationProtocol = reader.readUtf8();
final x11AuthenticationCookie = reader.readUtf8();
final x11ScreenNumber = reader.readUtf8();
final x11ScreenNumber = reader.readUint32();
return SSH_Message_Channel_Request(
recipientChannel: recipientChannel,
requestType: requestType,
Expand Down Expand Up @@ -953,7 +953,7 @@ class SSH_Message_Channel_Request implements SSHMessage {
writer.writeBool(singleConnection!);
writer.writeUtf8(x11AuthenticationProtocol!);
writer.writeUtf8(x11AuthenticationCookie!);
writer.writeUtf8(x11ScreenNumber!);
writer.writeUint32(x11ScreenNumber!);
break;
case 'env':
writer.writeUtf8(variableName!);
Expand Down
Loading
Loading