diff --git a/mobile/lib/features/shell/sftp_session_manager.dart b/mobile/lib/features/shell/sftp_session_manager.dart index 0b42573..06af482 100644 --- a/mobile/lib/features/shell/sftp_session_manager.dart +++ b/mobile/lib/features/shell/sftp_session_manager.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:archive/archive.dart'; @@ -346,6 +347,56 @@ class SftpSessionManager extends ChangeNotifier { return _readRemoteFile(connection.sftp, remotePath); } + static const int defaultTextFileMaxBytes = 2 * 1024 * 1024; + + Future readTextFile( + String remotePath, { + int maxBytes = defaultTextFileMaxBytes, + }) async { + final state = activeSession; + if (state == null) { + throw StateError('No active SFTP session'); + } + final connection = _connections[state.server.id]; + if (connection == null) { + throw StateError('No active SFTP connection'); + } + final sftp = connection.sftp; + final stat = await sftp.stat(remotePath); + final size = stat.size ?? 0; + if (size > maxBytes) { + throw SftpFileTooLargeException( + path: remotePath, + size: size, + limit: maxBytes, + ); + } + final bytes = await _readRemoteFile(sftp, remotePath); + final probeLen = bytes.length < 8192 ? bytes.length : 8192; + for (var i = 0; i < probeLen; i++) { + if (bytes[i] == 0) { + throw SftpBinaryFileException(path: remotePath); + } + } + return bytes; + } + + Future writeTextFile(String remotePath, String content) async { + final state = activeSession; + if (state == null) { + throw StateError('No active SFTP session'); + } + final connection = _connections[state.server.id]; + if (connection == null) { + throw StateError('No active SFTP connection'); + } + await _writeRemoteFile( + connection.sftp, + remotePath, + Uint8List.fromList(utf8.encode(content)), + ); + } + Future zipEntries(List entries) async { final state = activeSession; if (state == null || entries.isEmpty) return Uint8List(0); @@ -787,3 +838,37 @@ extension on String { return substring(count); } } + +class SftpFileTooLargeException implements Exception { + SftpFileTooLargeException({ + required this.path, + required this.size, + required this.limit, + }); + + final String path; + final int size; + final int limit; + + @override + String toString() => 'SftpFileTooLargeException($path, size=$size, limit=$limit)'; +} + +class SftpBinaryFileException implements Exception { + SftpBinaryFileException({required this.path}); + + final String path; + + @override + String toString() => 'SftpBinaryFileException($path)'; +} + +class SftpTextFileData { + const SftpTextFileData({ + required this.bytes, + required this.malformedUtf8, + }); + + final Uint8List bytes; + final bool malformedUtf8; +} diff --git a/mobile/test/features/shell/sftp_session_manager_text_test.dart b/mobile/test/features/shell/sftp_session_manager_text_test.dart new file mode 100644 index 0000000..5d9125c --- /dev/null +++ b/mobile/test/features/shell/sftp_session_manager_text_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile/features/shell/sftp_session_manager.dart'; + +void main() { + group('Sftp text exceptions', () { + test('SftpFileTooLargeException carries size info', () { + final err = SftpFileTooLargeException( + path: '/tmp/big.log', + size: 5 * 1024 * 1024, + limit: 2 * 1024 * 1024, + ); + expect(err.path, '/tmp/big.log'); + expect(err.size, 5 * 1024 * 1024); + expect(err.limit, 2 * 1024 * 1024); + expect(err.toString(), contains('big.log')); + }); + + test('SftpBinaryFileException carries path', () { + final err = SftpBinaryFileException(path: '/usr/bin/ls'); + expect(err.path, '/usr/bin/ls'); + expect(err.toString(), contains('ls')); + }); + }); +}