diff --git a/lib/src/sftp/sftp_client.dart b/lib/src/sftp/sftp_client.dart index c2cf42f..26173b4 100644 --- a/lib/src/sftp/sftp_client.dart +++ b/lib/src/sftp/sftp_client.dart @@ -162,7 +162,29 @@ class SftpClient { } /// Renames a file or directory from [oldPath] to [newPath]. + /// + /// If the server supports the `posix-rename@openssh.com` SFTP extension + /// (version "1"), this method uses it to perform an atomic rename with + /// POSIX semantics (replace destination if it exists). Otherwise, it falls + /// back to the standard SFTP `SSH_FXP_RENAME` request. Future rename(String oldPath, String newPath) async { + // Prefer OpenSSH's posix-rename extension when available. + final hs = await handshake; + final extVersion = hs.extensions['posix-rename@openssh.com']; + if (extVersion != null) { + try { + await _checkExtension('posix-rename@openssh.com', '1'); + final payload = + SftpPosixRenameRequest(oldPath: oldPath, newPath: newPath); + final reply = await _sendExtended(payload); + if (reply is! SftpStatusPacket) throw SftpError('Unexpected reply'); + SftpStatusError.check(reply); + return; + } on SftpExtensionError { + // Fall through to standard rename if extension unsupported/mismatched. + } + } + final reply = await _sendRename(oldPath, newPath); if (reply is! SftpStatusPacket) throw SftpError('Unexpected reply'); SftpStatusError.check(reply); diff --git a/lib/src/sftp/sftp_packet_ext.dart b/lib/src/sftp/sftp_packet_ext.dart index 9808d67..5b12de2 100644 --- a/lib/src/sftp/sftp_packet_ext.dart +++ b/lib/src/sftp/sftp_packet_ext.dart @@ -27,6 +27,27 @@ abstract class SftpExtendedRequest { /// [SftpExtendedReplyPacket] before being sent. abstract class SftpExtendedReply {} +/// Represents the `posix-rename@openssh.com` extension request. +/// +/// Performs an atomic rename with POSIX semantics (replace destination if it +/// exists), as defined by OpenSSH. +class SftpPosixRenameRequest extends SftpExtendedRequest { + SftpPosixRenameRequest({required this.oldPath, required this.newPath}); + + @override + final String name = 'posix-rename@openssh.com'; + + final String oldPath; + + final String newPath; + + @override + void writeTo(SSHMessageWriter writer) { + writer.writeUtf8(oldPath); + writer.writeUtf8(newPath); + } +} + /// This request correspond to the statvfs POSIX system interface. class SftpStatVfsRequest extends SftpExtendedRequest { SftpStatVfsRequest({required this.path}); diff --git a/test/src/sftp/sftp_client_protocol_test.dart b/test/src/sftp/sftp_client_protocol_test.dart index 01a3dfd..0165b3b 100644 --- a/test/src/sftp/sftp_client_protocol_test.dart +++ b/test/src/sftp/sftp_client_protocol_test.dart @@ -358,6 +358,54 @@ void main() { await closeFuture; harness.dispose(); }); + + test('rename uses posix-rename extension when advertised', () async { + final harness = _SftpHarness(); + await harness.nextOutgoingPacket(); + harness.sendResponsePacket( + SftpVersionPacket(3, {'posix-rename@openssh.com': '1'}), + ); + await harness.client.handshake; + + final renameFuture = harness.client.rename('/tmp/a', '/tmp/b'); + final packet = await harness.nextOutgoingPacket(); + final extended = SftpExtendedPacket.decode(packet); + + harness.sendResponsePacket( + SftpStatusPacket( + requestId: extended.requestId, + code: SftpStatusCode.ok, + message: 'ok', + ), + ); + + await renameFuture; + harness.dispose(); + }); + + test('rename falls back to SSH_FXP_RENAME without extension', () async { + final harness = _SftpHarness(); + await harness.nextOutgoingPacket(); + harness.sendResponsePacket(SftpVersionPacket(3)); + await harness.client.handshake; + + final renameFuture = harness.client.rename('/tmp/a', '/tmp/b'); + final packet = await harness.nextOutgoingPacket(); + final rename = SftpRenamePacket.decode(packet); + expect(rename.oldPath, '/tmp/a'); + expect(rename.newPath, '/tmp/b'); + + harness.sendResponsePacket( + SftpStatusPacket( + requestId: rename.requestId, + code: SftpStatusCode.ok, + message: 'ok', + ), + ); + + await renameFuture; + harness.dispose(); + }); }); }