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
22 changes: 22 additions & 0 deletions lib/src/sftp/sftp_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> 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);
Expand Down
21 changes: 21 additions & 0 deletions lib/src/sftp/sftp_packet_ext.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down
48 changes: 48 additions & 0 deletions test/src/sftp/sftp_client_protocol_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
}

Expand Down
Loading