import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'package:cryptography/cryptography.dart'; import 'package:dartssh2/dartssh2.dart'; import '../core/failure.dart'; import '../models/server_profile.dart'; String safeName(String name) { if (name.isEmpty || name == '.' || name == '..' || name.length > 255 || RegExp(r'[/\\\x00-\x1f\x7f]').hasMatch(name)) { throw const Failure('unsafePath'); } return name; } String safePath(String path) { if (!path.startsWith('/') || RegExp(r'[\\\x00-\x1f\x7f]').hasMatch(path) || path.split('/').any((s) => s == '.' || s == '..')) { throw const Failure('unsafePath'); } return path == '/' ? '/' : path.replaceAll(RegExp(r'/+$'), ''); } String childPath(String parent, String name) => '${safePath(parent) == '/' ? '' : safePath(parent)}/${safeName(name)}'; String parentPath(String path) { safePath(path); final index = path.lastIndexOf('/'); return index <= 0 ? '/' : path.substring(0, index); } class TransferToken { bool cancelled = false; void Function()? abort; void cancel() { cancelled = true; abort?.call(); } void check() { if (cancelled) throw const Failure('cancelled'); } } class SftpService { final SftpClient client; SftpService(this.client); Future home() async => safePath(await client.absolute('.')); Future guard(String path, {bool missingLeaf = false}) async { safePath(path); var current = ''; final parts = path.split('/').where((p) => p.isNotEmpty).toList(); for (var i = 0; i < parts.length; i++) { current += '/${safeName(parts[i])}'; try { final attrs = await client.stat(current, followLink: false); if (attrs.isSymbolicLink || (i < parts.length - 1 && !attrs.isDirectory)) { throw const Failure('unsafePath'); } } on SftpStatusError catch (e) { if (missingLeaf && i == parts.length - 1 && e.code == 2) return; rethrow; } } } Future> list(String path) async { await guard(path); final entries = await client.listdir(path); final filtered = entries .where((e) => e.filename != '.' && e.filename != '..') .toList(); for (final entry in filtered) { safeName(entry.filename); } filtered.sort( (a, b) => a.attr.isDirectory == b.attr.isDirectory ? a.filename.toLowerCase().compareTo(b.filename.toLowerCase()) : a.attr.isDirectory ? -1 : 1, ); return filtered; } Future mkdir(String path) async { await guard(path, missingLeaf: true); await client.mkdir(path); } Future rename(String oldPath, String newPath) async { await guard(oldPath); await guard(newPath, missingLeaf: true); await _absent(newPath); await client.rename(oldPath, newPath); } Future remove(String path) async { if (path == '/') throw const Failure('unsafePath'); await guard(path); final attrs = await client.stat(path, followLink: false); if (attrs.isDirectory) { await client.rmdir(path); } else { await client.remove(path); } } Future _absent(String path) async { try { await client.stat(path, followLink: false); } on SftpStatusError catch (e) { if (e.code == 2) return; rethrow; } throw const Failure('fileExists'); } Future upload( Stream stream, int size, String remote, TransferToken token, void Function(int, int) progress, ) async { await guard(remote, missingLeaf: true); await _absent(remote); token.check(); final temp = childPath(parentPath(remote), '.sm-${newId()}.part'); SftpFile? file; var created = false; try { file = await client.open( temp, mode: SftpFileOpenMode.write | SftpFileOpenMode.create | SftpFileOpenMode.exclusive, ); created = true; final writer = file.write( stream, onProgress: (n) { progress(n, size); }, ); token.abort = () { unawaited(writer.abort().catchError((Object _) {})); }; if (token.cancelled) token.abort!(); await writer.done; token.check(); await file.close(); file = null; await guard(remote, missingLeaf: true); await _absent(remote); await client.rename(temp, remote); } catch (_) { try { await file?.close(); if (created) await client.remove(temp); } catch (_) { /* Session may already be closed. */ } rethrow; } finally { token.abort = null; } } Future download( String remote, File local, TransferToken token, void Function(int, int) progress, ) async { await guard(remote); token.check(); final attrs = await client.stat(remote, followLink: false); if (!attrs.isFile) throw const Failure('unsafePath'); final file = await client.open(remote); final sink = local.openWrite(); token.abort = () { unawaited(file.close().catchError((Object _) {})); }; try { await sink.addStream( file .read( onProgress: (n) { progress(n, attrs.size ?? 0); }, ) .map((c) { token.check(); return c; }), ); token.check(); await sink.flush(); await sink.close(); } catch (_) { try { await sink.close(); } catch (_) { /* Preserve the original transfer failure. */ } if (await local.exists()) await local.delete(); rethrow; } finally { token.abort = null; try { await sink.close(); } catch (_) { /* Already handled above. */ } try { await file.close(); } catch (_) { /* Cancellation closes the handle. */ } } } /// Reads a whole regular file of at most [maxBytes]. Future<(Uint8List, SftpFileAttrs)> readFile( String path, { required int maxBytes, TransferToken? token, void Function(int, int)? progress, }) async { await guard(path); final attrs = await client.stat(path, followLink: false); if (!attrs.isFile) throw const Failure('unsafePath'); if ((attrs.size ?? 0) > maxBytes) throw const Failure('fileTooLarge'); final file = await client.open(path); token?.abort = () { unawaited(file.close().catchError((Object _) {})); }; try { final bytes = BytesBuilder(copy: false); await for (final chunk in file.read( onProgress: (n) => progress?.call(n, attrs.size ?? 0), )) { token?.check(); bytes.add(chunk); if (bytes.length > maxBytes) throw const Failure('fileTooLarge'); } return (bytes.takeBytes(), attrs); } finally { if (token != null) token.abort = null; try { await file.close(); } catch (_) { /* Closed by cancellation. */ } } } /// The file's attributes without following a symbolic link; null when it /// does not exist. Future statOrNull(String path) async { await guard(path, missingLeaf: true); try { return await client.stat(path, followLink: false); } on SftpStatusError catch (e) { if (e.code == 2) return null; rethrow; } } static Future> sha256Of(List bytes) async => (await Sha256().hash(bytes)).bytes; static bool _same(List a, List b) { if (a.length != b.length) return false; for (var i = 0; i < a.length; i++) { if (a[i] != b[i]) return false; } return true; } /// Files up to this size are compared by content before overwriting, so a /// change within the same second with the same size is still noticed. static const contentCheckLimit = 4 * 1024 * 1024; /// Throws 'remoteChanged' when the file is no longer what was loaded: /// different size, modification time or (for small files) content. Future checkUnchanged( String path, SftpFileAttrs expected, List? expectedHash, ) async { final current = await statOrNull(path); if (current == null) throw const Failure('remoteDeleted'); if (!current.isFile) throw const Failure('unsafePath'); if (current.size != expected.size || current.modifyTime != expected.modifyTime) { throw const Failure('remoteChanged'); } if (expectedHash != null && (current.size ?? 0) <= contentCheckLimit) { final (bytes, _) = await readFile(path, maxBytes: contentCheckLimit); if (!_same(await sha256Of(bytes), expectedHash)) { throw const Failure('remoteChanged'); } } return current; } /// Saves [bytes] to the existing file [path] without risking it: /// /// 1. unless [force], verifies the file was not changed since [expected]; /// 2. writes a temporary file next to it and gives it the original /// permission bits (and owner/group where the server allows it); /// 3. replaces the original by renaming (atomic with OpenSSH's /// posix-rename; otherwise via a backup that is restored on failure). /// /// A failed save leaves the original untouched. When a temporary file /// cannot be created there ('atomicNotPossible') or the owner would /// change ('ownerWouldChange'), nothing is written; the caller may ask /// the user and retry with [inPlace], which overwrites the file directly /// (keeps owner and links, but is not atomic). Future saveFile( String path, Uint8List bytes, { SftpFileAttrs? expected, List? expectedHash, bool force = false, bool inPlace = false, }) async { await guard(path, missingLeaf: true); var current = await statOrNull(path); if (current == null) { if (expected != null && !force) throw const Failure('remoteDeleted'); return _createNew(path, bytes); } if (!current.isFile) throw const Failure('unsafePath'); if (expected != null && !force) { current = await checkUnchanged(path, expected, expectedHash); } // Replacing via rename needs only folder permission; the user's write // permission on the file itself is checked first, without changing it. try { final probe = await client.open(path, mode: SftpFileOpenMode.write); await probe.close(); } on SftpStatusError catch (e) { if (e.code == 3) throw const Failure('permissionDenied'); rethrow; } if (inPlace) { final file = await client.open( path, mode: SftpFileOpenMode.write | SftpFileOpenMode.truncate, ); try { await file.writeBytes(bytes); } finally { await file.close(); } return client.stat(path, followLink: false); } final name = path.substring(path.lastIndexOf('/') + 1); final stem = name.length > 200 ? name.substring(0, 200) : name; final temp = childPath(parentPath(path), '.$stem.sm-${newId()}.tmp'); SftpFile? file; var created = false; try { try { file = await client.open( temp, mode: SftpFileOpenMode.write | SftpFileOpenMode.create | SftpFileOpenMode.exclusive, ); } on SftpStatusError catch (e) { if (e.code == 3) throw const Failure('atomicNotPossible'); rethrow; } created = true; await file.writeBytes(bytes); await file.close(); file = null; final mode = current.mode?.value; if (mode != null) { await client.setStat( temp, SftpFileAttrs(mode: SftpFileMode.value(mode & 0xfff)), ); } final made = await client.stat(temp, followLink: false); if (current.userID != null && (made.userID != current.userID || made.groupID != current.groupID)) { try { await client.setStat( temp, SftpFileAttrs(userID: current.userID, groupID: current.groupID), ); } catch (_) { throw const Failure('ownerWouldChange'); } } // The file may have changed while the copy was written. if (expected != null && !force) { await checkUnchanged(path, current, null); } try { await client.rename(temp, path); } on SftpStatusError { // No posix-rename: move the original aside, then put the new file // in place; restore the original if that fails. final backup = childPath(parentPath(path), '.$stem.sm-${newId()}.bak'); await client.rename(path, backup); try { await client.rename(temp, path); } catch (_) { await client.rename(backup, path); rethrow; } try { await client.remove(backup); } catch (_) { /* The new file is in place; a stray backup is harmless. */ } } created = false; return await client.stat(path, followLink: false); } finally { if (file != null) { try { await file.close(); } catch (_) { /* Session may be gone. */ } } if (created) { try { await client.remove(temp); } catch (_) { /* Session may be gone; the original is untouched. */ } } } } Future _createNew(String path, Uint8List bytes) async { final file = await client.open( path, mode: SftpFileOpenMode.write | SftpFileOpenMode.create | SftpFileOpenMode.exclusive, ); try { await file.writeBytes(bytes); } catch (_) { try { await file.close(); await client.remove(path); } catch (_) { /* Best effort. */ } rethrow; } await file.close(); return client.stat(path, followLink: false); } }