From 197072b4821d29b2e9caee1a90c57c532c1eb669 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:25:46 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=EB=82=98=EB=88=84=EA=B8=B0=20core=20A:=20f?= =?UTF-8?q?ile=5Fops=20(1,081)=20=EB=A5=BC=20lib/core/files/=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=208=EA=B0=9C=EB=A1=9C=20(=E2=86=92=20=EC=A0=95?= =?UTF-8?q?=EB=A9=B4=20file=5Fops)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - file_listing (항목 · 정렬 · 폴더 읽기 · 찾기 · 크기 글) · windows_drives (드라이브 · 종류 · UNC 맨 위) - path_rules (같은 경로 · 안에 있음 · 바깥 것만 · 전송 전 확인 · 임시 이름) · name_ops (이름 규칙 · 이름 바꾸기 · 새 폴더) - transfer_guard (취소 · 원본을 읽을 수 없음 · 지우기 전 원본 확인 · 빈 폴더 정리) - transfer_control (취소 · 속도 제한 · 알림 · 같은 이름 처리 - FileOps 가 이것을 이음) - local_transfer (로컬끼리 복사 · 이동 · 맞추기 · 지우기) · any_transfer (원격이 섞인 복사 · 이동 · 맞추기) - file_ops.dart 는 FileOps (고르기만) 와 예전 이름 그대로 export - 부르는 쪽은 바꾸지 않음 - 약속 시험: test/file_ops_modules_contract_test.dart (실제 시험 폴더) Co-Authored-By: Claude Opus 5.5 --- lib/core/file_ops.dart | 1097 +--------------------- lib/core/files/any_transfer.dart | 273 ++++++ lib/core/files/file_listing.dart | 127 +++ lib/core/files/local_transfer.dart | 284 ++++++ lib/core/files/name_ops.dart | 79 ++ lib/core/files/path_rules.dart | 118 +++ lib/core/files/transfer_control.dart | 93 ++ lib/core/files/transfer_guard.dart | 129 +++ lib/core/files/windows_drives.dart | 50 + test/file_ops_modules_contract_test.dart | 138 +++ 10 files changed, 1343 insertions(+), 1045 deletions(-) create mode 100644 lib/core/files/any_transfer.dart create mode 100644 lib/core/files/file_listing.dart create mode 100644 lib/core/files/local_transfer.dart create mode 100644 lib/core/files/name_ops.dart create mode 100644 lib/core/files/path_rules.dart create mode 100644 lib/core/files/transfer_control.dart create mode 100644 lib/core/files/transfer_guard.dart create mode 100644 lib/core/files/windows_drives.dart create mode 100644 test/file_ops_modules_contract_test.dart diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart index b5ee4ab..dbada26 100644 --- a/lib/core/file_ops.dart +++ b/lib/core/file_ops.dart @@ -1,631 +1,69 @@ -import 'dart:async'; -import 'dart:ffi'; -import 'dart:io'; - -import 'package:ffi/ffi.dart'; -import 'package:path/path.dart' as p; - -import '../l10n/tr.dart'; -import 'playlist.dart' show naturalCompare; +import 'files/any_transfer.dart'; +import 'files/local_transfer.dart' as local; +import 'files/name_ops.dart'; +import 'files/transfer_control.dart'; +import 'files/transfer_guard.dart'; import 'rsync_filter.dart'; import 'vfs.dart'; -import 'webdav.dart' show DavException, DavLockedException; -/// 파일 탐색기 (X-plore 참고) 의 화면과 상관없는 부분: 폴더 읽기 · 정렬 · 복사 / 이동 / 삭제 · 찾기. +export 'files/file_listing.dart' show FileEntry, SortBy, sortEntries, listEntries, searchFiles, formatSize; +export 'files/windows_drives.dart' show windowsDrives, driveType, isNetworkDrive, uncRoot; +export 'files/path_rules.dart' + show + NameConflict, + uniqueTarget, + samePath, + shortUnder, + isSameOrInside, + outermostPaths, + transferProblem, + isPartialFile, + isAppTempName, + appTempRsyncExcludes, + appTempRobocopyExcludes; +export 'files/name_ops.dart' show nameProblem, usesWindowsNames; +export 'files/transfer_guard.dart' show FileOpCancelled, SourceUnreadableException, ensureSourceForDelete, removeEmptyDirs; +export 'files/transfer_control.dart' show CopyProgress; -/// 폴더 안의 항목 하나 -class FileEntry { - final String path; - final bool isDir; - final int size; - final DateTime modified; - const FileEntry(this.path, {required this.isDir, this.size = 0, required this.modified}); - - String get name => p.basename(path); - bool get hidden => name.startsWith('.'); - - /// 확장자 (소문자, 점 없이) - String get ext => isDir ? '' : p.extension(path).replaceFirst('.', '').toLowerCase(); -} - -enum SortBy { name, date, size, type } - -/// 정렬: 폴더 먼저, 이름은 숫자를 숫자로 비교 (file2 < file10) -/// 80: [dirSize] 가 있으면 크기순에서 폴더도 그 크기로 (모르는 폴더는 가장 작게) -List sortEntries(List list, SortBy by, {bool descending = false, int? Function(FileEntry dir)? dirSize}) { - int sizeOf(FileEntry e) => e.isDir ? (dirSize?.call(e) ?? -1) : e.size; - int cmp(FileEntry a, FileEntry b) { - if (a.isDir != b.isDir) return a.isDir ? -1 : 1; // 폴더는 거꾸로여도 늘 위 - final r = switch (by) { - SortBy.name => 0, - SortBy.date => a.modified.compareTo(b.modified), - SortBy.size => sizeOf(a).compareTo(sizeOf(b)), - SortBy.type => a.ext.compareTo(b.ext), - }; - final v = r != 0 ? r : naturalCompare(a.name, b.name); - return descending ? -v : v; - } - - return [...list]..sort(cmp); -} - -/// 폴더 읽기 (읽을 수 없는 항목은 건너뜀). [showHidden] 이 아니면 점으로 시작하는 항목을 뺀다. -Future> listEntries(String dir, {bool showHidden = false}) async { - if (isDav(dir)) { - // WebDAV: 네트워크 오류 · 인증 실패는 그대로 던진다 (화면이 "읽을 수 없음" 과 이유를 보여 줌) - return [ - for (final e in await vList(dir)) - if (showHidden || !vBasename(e.path).startsWith('.')) - FileEntry(e.path, isDir: e.isDir, size: e.size, modified: e.modified), - ]; - } - final out = []; - // E10: 폴더를 읽지 못하면 (권한 없음 · 빠진 USB 등) 빈 폴더로 보이지 않게 던진다 (화면이 "읽을 수 없습니다: 이유" 와 [다시 시도]). - // 일부를 읽은 뒤의 오류는 읽은 것까지 보여 준다 - Object? failed; - await for (final e in Directory(dir).list(followLinks: false).handleError((Object err) => failed ??= err)) { - try { - final st = await e.stat(); - final isDir = st.type == FileSystemEntityType.directory; - final entry = FileEntry(e.path, isDir: isDir, size: isDir ? 0 : st.size, modified: st.modified); - if (!showHidden && entry.hidden) continue; - out.add(entry); - } catch (_) {} - } - if (failed != null && out.isEmpty) throw failed!; - return out; -} - -/// 저장 장치 (맨 위 폴더): Windows 는 드라이브 (C:\ …), 그 밖은 주어진 목록. -/// 165: 드라이브 문자는 Windows 가 알려 주는 목록 (GetLogicalDrives) 으로 - 드라이브마다 열어 보지 않는다 -/// (끊긴 · 느린 네트워크 드라이브 (NAS) 를 열어 보느라 탐색기가 10초씩 멈췄다) -List<(String path, String label)> windowsDrives() { - int mask; - try { - mask = DynamicLibrary.open('kernel32.dll').lookupFunction('GetLogicalDrives')(); - } catch (_) { - mask = 0; - } - if (mask == 0) return [(r'C:\', 'C:')]; - return [ - for (final c in 'CDEFGHIJKLMNOPQRSTUVWXYZAB'.split('')) - if (mask & (1 << (c.codeUnitAt(0) - 65)) != 0) ('$c:\\', '$c:'), - ]; -} - -/// 165: 드라이브 종류 (GetDriveTypeW: 2 꺼내는 장치 (카드 리더 · USB) · 3 고정 · 4 네트워크 · 5 CD). 드라이브를 열어 보지 않는다. 모르면 0 -int driveType(String root) { - if (!Platform.isWindows) return 0; - final w = root.toNativeUtf16(); - try { - final f = DynamicLibrary.open('kernel32.dll') - .lookupFunction), int Function(Pointer)>('GetDriveTypeW'); - return f(w); - } catch (_) { - return 0; - } finally { - calloc.free(w); - } -} - -/// 네트워크 드라이브인지 -bool isNetworkDrive(String root) => driveType(root) == 4; - -/// 48: 받는 폴더에 같은 이름이 있을 때 -enum NameConflict { - /// "이름 (2).확장자" 로 (예전 동작) - rename, - - /// 덮어쓰기 (폴더는 합치고 안의 같은 이름 파일을 덮어씀) - overwrite, - - /// 건너뛰기 (그 항목은 복사 · 이동하지 않음) - skip, -} - -/// 같은 이름이 있으면 "이름 (2).확장자" 로 -String uniqueTarget(String dir, String name) { - var target = p.join(dir, name); - if (FileSystemEntity.typeSync(target) == FileSystemEntityType.notFound) return target; - final base = p.basenameWithoutExtension(name), ext = p.extension(name); - for (var i = 2;; i++) { - target = p.join(dir, '$base ($i)$ext'); - if (FileSystemEntity.typeSync(target) == FileSystemEntityType.notFound) return target; - } -} - -/// 같은 경로인지 (Windows 는 대소문자 무시) -bool samePath(String a, String b) { - final dav = davSame(a, b); - if (dav != null) return dav; - final x = p.normalize(p.absolute(a)), y = p.normalize(p.absolute(b)); - return Platform.isWindows ? x.toLowerCase() == y.toLowerCase() : x == y; -} - -/// 79 · 87: 찾기 · 분석 결과 줄의 폴더 - 시작한 폴더 이름부터의 상대 경로 ("Videos\Show\S1"). -/// 단계가 [keep] + 1 보다 많으면 가운데를 줄인다 ("Videos\…\S1\E01"). 시작한 폴더 밖이면 전체 경로. -/// (15 캡처: 폭 412 에서 절대 경로의 앞부분만 보여 구별에 필요한 아래 폴더가 잘림) -String shortUnder(String root, String dir, {int keep = 2}) { - final parts = []; - var x = dir; - for (var i = 0; i < 256 && !samePath(x, root); i++) { - final up = vDirname(x); - if (up == x) return vDisplay(dir); - parts.insert(0, vBasename(x)); - x = up; - } - if (!samePath(x, root)) return vDisplay(dir); - // 드라이브 · 서버 맨 위는 이름이 없어 그대로 (C: · ☁ 서버) - final top = vDirname(root) == root; - parts.insert(0, top ? (isDav(root) ? vDisplay(root) : root.replaceAll(RegExp(r'[\\/]+$'), '')) : vBasename(root)); - final sep = isDav(dir) ? '/' : p.separator; - if (parts.length > keep + 1) return [parts.first, '…', ...parts.sublist(parts.length - keep)].join(sep); - return parts.join(sep); -} - -/// [a] 가 [b] 와 같거나 그 안에 있는지 (폴더를 자기 안으로 복사 · 이동하지 않게) -bool isSameOrInside(String a, String b) { - final dav = davInside(a, b); - if (dav != null) return dav; - final x = p.normalize(p.absolute(a)), y = p.normalize(p.absolute(b)); - return samePath(a, b) || p.isWithin(y, x) || (Platform.isWindows && p.isWithin(y.toLowerCase(), x.toLowerCase())); -} - -/// 79-c: 고른 것 중 다른 고른 폴더 안에 있는 것 · 같은 것을 빼고 바깥 것만 (차례는 그대로). -/// 폴더와 그 안의 파일을 함께 고르면 안의 파일을 두 번 처리하거나 (이동 뒤 "없음" 오류) 확인 창 개수가 부풀었다 -List outermostPaths(Iterable paths) { - final all = paths.toList(); - final out = []; - for (var i = 0; i < all.length; i++) { - final x = all[i]; - if (out.any((o) => samePath(o, x))) continue; - if (all.any((o) => !samePath(o, x) && isSameOrInside(x, o))) continue; - out.add(x); - } - return out; -} - -/// 복사 · 이동을 시작하기 전 확인 (모든 방법 공통 - rsync · robocopy 는 스스로 막지 않는다). -/// 문제가 있으면 알릴 글, 없으면 null. -/// - 원본이 없음 (다른 곳에서 지워졌거나 옮겨짐) · 대상 폴더가 없음 -/// - 폴더를 자기 자신 (또는 그 안) 으로 복사 · 이동 -/// - 이미 그 폴더에 있는 것을 같은 폴더로 이동 -/// [contents]: 폴더 "안의 것" 을 옮긴다 (Rsync 화면 · 원본/ → 대상/) - 바로 위 폴더로 옮기는 것도 된다 (205) -String? transferProblem(List sources, String dest, {required bool move, bool contents = false}) { - // WebDAV 경로는 네트워크라 여기서 있는지 확인하지 않는다 (없으면 복사할 때 알림) - final missing = [for (final s in sources) if (vMissingSync(s)) s]; - if (missing.isNotEmpty) { - return trf('원본이 없습니다 (다른 곳에서 지워졌거나 옮겨졌습니다): {0}', [missing.map(vBasename).join(', ')]); - } - if (!isDav(dest) && !Directory(dest).existsSync()) return trf('대상 폴더가 없습니다: {0}', [dest]); - for (final s in sources) { - if ((isDav(s) || FileSystemEntity.isDirectorySync(s)) && isSameOrInside(dest, s)) { - return trf('폴더를 자기 자신 안으로 {0} 수 없습니다: {1}', [move ? tr('옮길') : tr('복사할'), vBasename(s)]); - } - // 폴더째 옮길 때만: 이미 그 폴더 안에 있으면 옮길 것이 없다. - // 205: 안의 것을 바로 위 폴더로 올리는 이동 (Down\Movie/ → Down) 은 막지 않는다 (예전에는 조용히 시작하지 않았다) - if (move && !contents && samePath(vDirname(s), dest)) return trf('이미 이 폴더에 있습니다: {0}', [vBasename(s)]); - } - return null; -} - -/// [root] 안의 빈 폴더를 안쪽부터 지운다 (find root/ -type d -empty -delete 와 같음). 지운 폴더 수. -/// [keepRoot] 면 [root] 자신은 비어도 남긴다. 파일 · 링크가 하나라도 있는 폴더는 그대로. -Future removeEmptyDirs(String root, {bool keepRoot = true}) async { - if (isDav(root)) return _removeEmptyDirsV(root, keepRoot: keepRoot); - var n = 0; - Future walk(Directory d) async { - var empty = true; - final items = await d.list(followLinks: false).handleError((_) => empty = false).toList(); - for (final e in items) { - if (e is Directory && await walk(e)) { - try { - await e.delete(); - n++; - } catch (_) { - empty = false; - } - } else { - empty = false; - } - } - return empty; - } - - final d = Directory(root); - if (!await d.exists()) return 0; - if (await walk(d) && !keepRoot) { - try { - await d.delete(); - n++; - } catch (_) {} - } - return n; -} - -/// 복사 · 이동 중 알림 (지금 파일, 지금까지 바이트, 전체 바이트) -typedef CopyProgress = void Function(String current, int done, int total); - -/// [removeEmptyDirs] 의 WebDAV 판 -Future _removeEmptyDirsV(String root, {bool keepRoot = true}) async { - var n = 0; - Future walk(String dir) async { - var empty = true; - for (final e in await vList(dir)) { - if (e.isDir && await walk(e.path)) { - try { - await vDelete(e.path); - n++; - } catch (_) { - empty = false; - } - } else { - empty = false; - } - } - return empty; - } - - if (!await vExists(root)) return 0; - if (await walk(root) && !keepRoot) { - try { - await vDelete(root); - n++; - } catch (_) {} - } - return n; -} - -class FileOpCancelled implements Exception { - const FileOpCancelled(); -} - -/// 동기화 · 미러: 원본을 읽지 못했거나 (권한 꺼짐 · SD 카드 빠짐 · 네트워크 끊김) 원본이 통째로 비었는데 대상에는 파일이 있다. -/// "원본에 없는 것을 대상에서 지우기" 가 원본을 빈 것으로 보고 대상을 모두 지우지 않도록, 아무것도 지우지 않고 멈춘다. -class SourceUnreadableException implements Exception { - final String path; - final Object? cause; - - /// 읽기는 됐지만 원본이 비어 있음 (대상에는 파일이 있음) - final bool empty; - const SourceUnreadableException(this.path, {this.cause, this.empty = false}); - - /// 128: 마스터 비밀번호를 기다리는 중 (읽지 못한 것이 아님) - bool get locked => cause is DavLockedException; - - /// 원인을 짧게 (예외 원문 대신 - 68) - String get detail { - final c = cause; - if (c == null) return ''; - if (c is FileSystemException) { - if (c.message == '없음' && c.osError == null) return tr('폴더가 없습니다'); - return c.osError?.message.trim().isNotEmpty == true ? c.osError!.message.trim() : c.message; - } - if (c is DavException) return c.message; - return '$c'.split('\n').first; - } - - @override - String toString() => empty - ? trf('원본 폴더가 비어 있어 멈췄습니다 (대상 파일은 지우지 않음). SD 카드 · 네트워크 · 권한을 확인하세요: {0}', [path]) - : trf('원본 폴더를 읽을 수 없어 멈췄습니다 (대상 파일은 지우지 않음): {0}{1}', [path, detail.isEmpty ? '' : ' · $detail']); -} - -/// Windows 네트워크 공유 경로의 맨 위 (\\서버\공유). 37 -String uncRoot(String path) { - final parts = path.replaceAll('/', r'\').split(r'\').where((s) => s.isNotEmpty).toList(); - return parts.length < 2 ? path : '\\\\${parts[0]}\\${parts[1]}'; -} - -/// 복사 · 동기화가 다 쓴 뒤 이름을 바꾸기 전의 임시 파일 (끊기면 남는다 - 71) -bool isPartialFile(String name) => name.endsWith('.jjpart') || name.endsWith('.jjsync'); - -/// 78 보충 (관리자 판단): 일괄 이름 바꾸기가 잠깐 쓰는 임시 이름 (~jjren_ · ~jjback_) - 실시간 동기화 · 맞추기 · rsync 에 섞이지 않게 -bool isAppTempName(String name) => name.startsWith('~jjren_') || name.startsWith('~jjback_'); - -/// rsync · robocopy 에 넣을 임시 이름 제외 (앱이 만드는 인수) -const appTempRsyncExcludes = ['--exclude=~jjren_*', '--exclude=~jjback_*']; -const appTempRobocopyExcludes = ['/XF', '~jjren_*', '~jjback_*']; - -/// 78 보충 (관리자 판단): 이름으로 쓸 수 없는 까닭 (괜찮으면 null). -/// 모든 곳: 빈 이름 · . · .. · \ / : * ? " < > | · 제어 문자. -/// [windowsRules] (Windows 의 로컬 경로): CON · PRN · AUX · NUL · COM1~9 · LPT1~9 (확장자가 붙어도), 끝이 점 · 빈칸, 255자 넘음 -String? nameProblem(String name, {required bool windowsRules}) { - if (name.isEmpty || name == '.' || name == '..' || name.contains(RegExp(r'[\\/:*?"<>|\x00-\x1f]'))) return tr('쓸 수 없는 이름'); - if (!windowsRules) return null; - final base = name.split('.').first.trim().toUpperCase(); - if (RegExp(r'^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$').hasMatch(base)) return trf('Windows 가 쓰는 이름 ({0})', [base]); - if (name.endsWith('.') || name.endsWith(' ')) return tr('이름 끝에 점 · 빈칸은 쓸 수 없음'); - if (name.length > 255) return tr('이름이 너무 김 (255자 넘음)'); - return null; -} - -/// 이 경로에 Windows 이름 규칙을 쓰는지 (Windows 의 로컬 · 네트워크 공유. WebDAV 는 서버 규칙이라 빼고) -bool usesWindowsNames(String path) => Platform.isWindows && !isDav(path); - -/// "지우기 포함" 으로 맞추기 전에: 원본을 읽을 수 있는지, 원본이 통째로 비었는데 대상에 파일이 있지 않은지. -/// 문제가 있으면 [SourceUnreadableException] (아무것도 지우지 않게). -/// [allowEmpty]: 사용자가 지울 목록을 보고 "원본이 비어도 그래도 맞추기" 를 고른 경우 (70) - 읽지 못하는 것은 그래도 막는다 -Future ensureSourceForDelete(String src, String dst, {bool allowEmpty = false}) async { - List<({String path, bool isDir, int size, DateTime modified})> items; - try { - if (!await vExists(src)) throw const FileSystemException('없음'); - items = await vList(src, strict: true); - } catch (e) { - throw SourceUnreadableException(src, cause: e); - } - if (items.isNotEmpty || allowEmpty) return; - var dstItems = const <({String path, bool isDir, int size, DateTime modified})>[]; - try { - dstItems = await vList(dst); - } catch (_) {} - if (dstItems.isNotEmpty) throw SourceUnreadableException(src, empty: true); -} - -/// 복사 · 이동 · 삭제. [cancel] 을 부르면 다음 조각에서 멈춘다. -class FileOps { - bool _cancel = false; - void cancel() => _cancel = true; - - /// 속도 제한 (KB/s, 0 = 제한 없음) - final int bandwidthKBps; - - /// 파일 하나를 다 복사할 때마다 (원본 경로) - final void Function(String source)? onFileDone; - - /// 조각을 보낼 때마다 (바이트) - 전송 속도 계산용 - final void Function(int bytes)? onBytes; - - /// 48: 같은 이름이 있을 때 (맨 위 항목 기준 - 덮어쓰기면 폴더 안의 같은 이름 파일도 덮어씀) - final NameConflict conflict; - - /// 건너뛴 항목 (원본 경로) - final skipped = []; - - /// M1 (관리자 P0, 10/19): 원격 맞추기에서 원본의 파일과 같은 이름의 **폴더** 가 받는 쪽에 있어 건너뛴 것. - /// --delete 가 없으면 그 폴더를 지우지 않는다 (rsync 도 --delete · --force 없이는 비어 있지 않은 폴더를 지우지 않음) - final dirConflicts = []; +// 약속 (나누기 file_ops): 파일 탐색기 (X-plore 참고) 의 화면과 상관없는 부분 - 부르는 곳은 이 파일 하나만 가져온다. +// 일은 lib/core/files/ 의 모듈이 한다: file_listing (폴더 읽기 · 정렬 · 찾기) · windows_drives · path_rules · name_ops · +// transfer_guard · transfer_control · local_transfer (로컬끼리) · any_transfer (원격이 섞임). +// [FileOps]: 입력 = 설정 (속도 제한 · 알림 · 같은 이름 처리), 원본들 · 받는 폴더. 출력 = 만든 경로 · 복사한 수. +// 오류 = 취소 [FileOpCancelled] · 원본을 읽지 못함 [SourceUnreadableException] · 파일 오류 (FileSystemException · DavException) 그대로. +/// 복사 · 이동 · 삭제. [cancel] 을 부르면 다음 조각에서 멈춘다. 한쪽이라도 원격 (WebDAV 등) 이면 어느 저장소든 판으로 +class FileOps extends TransferControl { FileOps({ - this.bandwidthKBps = 0, - this.onFileDone, - this.onBytes, - this.conflict = NameConflict.rename, - this.onSettled, - this.skipNewerInMerge = false, + super.bandwidthKBps, + super.onFileDone, + super.onBytes, + super.conflict, + super.onSettled, + super.skipNewerInMerge, }); - /// 229: 폴더를 [덮어쓰기] (합치기) 할 때 안쪽에서 받는 쪽이 더 새 파일은 건너뛴다 (환경 설정 "받는 쪽이 더 새것이면 건너뛰기"). - /// 맨 위 파일 하나를 사용자가 [덮어쓰기] 로 고른 것은 따른다 (창에 "(있는 것이 더 새것)" 이 보이므로) - final bool skipNewerInMerge; - - /// 229: 위 규칙으로 건너뛴 원본 파일 (이동이어도 원본에 남는다) - final keptNewer = []; - - /// 214: [mirror] 가 원본 파일 하나를 대상에 맞췄을 때 (지금 복사했거나, 엄격히 같다고 확인함) - 그 원본의 크기 · 바뀐 때. - /// 이동은 이것으로 알려 온 파일만 원본에서 지운다 (건너뛴 것 · 도중에 생긴 것은 남긴다) - final void Function(String source, int size, DateTime modified)? onSettled; - - /// [conflict] 에 따라 [destDir] 안의 대상 경로 (null = 건너뜀). - /// 같은 자리 (같은 폴더로 복사) 이거나 파일 ↔ 폴더처럼 종류가 다르면 덮어쓰지 않고 새 이름으로. - Future _targetFor(String src, String destDir) async { - final name = vBasename(src); - final plain = vJoin(destDir, name); - if (conflict == NameConflict.rename || !await vExists(plain)) return vUniqueTarget(destDir, name); - if (samePath(src, plain)) return vUniqueTarget(destDir, name); - if (conflict == NameConflict.skip) { - skipped.add(src); - return null; - } - if (await vIsDir(src) != await vIsDir(plain)) return vUniqueTarget(destDir, name); - return plain; - } - - /// 한쪽이라도 WebDAV 면 아래의 "V" (어느 저장소든) 판으로 + /// 한쪽이라도 WebDAV 면 어느 저장소든 판으로 static bool _anyDav(Iterable paths) => paths.any(isDav); - final _clock = Stopwatch(); - int _sent = 0; - - /// 속도 제한: 보낸 양이 허용량보다 앞서면 그만큼 쉰다 - Future _throttle(int bytes) async { - if (bandwidthKBps <= 0) return; - if (!_clock.isRunning) _clock.start(); - _sent += bytes; - final wantMs = _sent * 1000 ~/ (bandwidthKBps * 1024); - final ahead = wantMs - _clock.elapsedMilliseconds; - if (ahead > 5) await Future.delayed(Duration(milliseconds: ahead)); - } - - void _check() { - if (_cancel) throw const FileOpCancelled(); - } - /// 전체 크기 (진행률용) - static Future totalSize(List paths) async { - if (_anyDav(paths)) return _totalSizeV(paths); - var total = 0; - for (final s in paths) { - final t = FileSystemEntity.typeSync(s, followLinks: false); - if (t == FileSystemEntityType.directory) { - await for (final e in Directory(s).list(recursive: true, followLinks: false).handleError((_) {})) { - if (e is File) { - try { - total += await e.length(); - } catch (_) {} - } - } - } else if (t == FileSystemEntityType.file) { - total += await File(s).length(); - } - } - return total; - } + static Future totalSize(List paths) => _anyDav(paths) ? anyTotalSize(paths) : local.localTotalSize(paths); /// [sources] 를 [destDir] 로 복사 (같은 이름이 있으면 "이름 (2)"). 만든 경로들을 돌려준다. - Future> copy(List sources, String destDir, {CopyProgress? onProgress}) async { - if (_anyDav([...sources, destDir])) return _copyV(sources, destDir, onProgress: onProgress); - final total = await totalSize(sources); - var done = 0; - final made = []; - // 43: 임시 이름 (.jjpart) 에 다 쓴 뒤 이름을 바꾼다 - 복사 도중 앱이 끝나도 반쯤 쓴 파일이 완성된 이름으로 남지 않게 - Future copyFile(File f, String finalTarget) async { - _check(); - final target = '$finalTarget.jjpart'; - final out = File(target).openWrite(); - try { - try { - await for (final chunk in f.openRead()) { - _check(); - out.add(chunk); - done += chunk.length; - onProgress?.call(f.path, done, total); - onBytes?.call(chunk.length); - await _throttle(chunk.length); - } - } finally { - await out.close(); - } - _check(); - } catch (_) { - // 취소 · 실패: 만들다 만 파일을 남기지 않는다 - try { - await File(target).delete(); - } catch (_) {} - rethrow; - } - // 덮어쓰기: 새 파일을 다 쓴 뒤에야 옛 파일을 지운다 (도중에 실패하면 옛 파일이 남게) - if (File(finalTarget).existsSync()) await File(finalTarget).delete(); - await File(target).rename(finalTarget); - try { - await File(finalTarget).setLastModified(await f.lastModified()); - } catch (_) {} - onFileDone?.call(f.path); - } - - Future copyAny(String src, String target, {bool inner = false}) async { - if (FileSystemEntity.isDirectorySync(src)) { - await Directory(target).create(recursive: true); - await for (final e in Directory(src).list(followLinks: false)) { - await copyAny(e.path, p.join(target, p.basename(e.path)), inner: true); - } - } else { - // 229: 합치는 폴더 안에서 받는 쪽이 더 새것이면 예전 것으로 덮지 않는다 - if (inner && skipNewerInMerge && await isNewerThere(src, target)) { - keptNewer.add(src); - return; - } - await copyFile(File(src), target); - } - } - - for (final s in sources) { - if (FileSystemEntity.isDirectorySync(s) && isSameOrInside(destDir, s)) { - throw FileSystemException(tr('폴더를 자기 안으로 복사할 수 없습니다'), s); - } - final target = await _targetFor(s, destDir); - if (target == null) continue; - await copyAny(s, target); - made.add(target); - } - return made; - } - - /// 229: [dir] 안에서 [keep] 의 파일만 남기고 지운다 (비게 된 폴더도). [dir] 자체는 남는 것이 있으면 둔다 - Future _deleteExcept(String dir, Set keep) async { - Future walk(Directory d) async { - var empty = true; - await for (final e in d.list(followLinks: false)) { - if (e is Directory) { - if (await walk(e)) { - await e.delete(); - } else { - empty = false; - } - } else if (keep.contains(p.normalize(e.path))) { - empty = false; - } else { - await e.delete(); - } - } - return empty; - } - - if (await walk(Directory(dir))) await Directory(dir).delete(); - } + Future> copy(List sources, String destDir, {CopyProgress? onProgress}) => _anyDav([...sources, destDir]) + ? anyCopy(this, sources, destDir, onProgress: onProgress) + : local.localCopy(this, sources, destDir, onProgress: onProgress); /// 229: [target] 이 있고 [src] 보다 2초 넘게 새것인지 - static Future isNewerThere(String src, String target) async { - final t = File(target); - if (!await t.exists()) return false; - return (await t.lastModified()).isAfter((await File(src).lastModified()).add(const Duration(seconds: 2))); - } + static Future isNewerThere(String src, String target) => local.isNewerThere(src, target); /// 229: 같은 이름 창 - 폴더를 합치면 안쪽에서 받는 쪽이 더 새 파일 수 ([limit] 개까지 셈, 로컬만) - static Future countNewerInside(String src, String dst, {int limit = 5000}) async { - var n = 0, seen = 0; - Future walk(String s, String d) async { - await for (final e in Directory(s).list(followLinks: false).handleError((_) {})) { - if (seen++ > limit) return; - final t = p.join(d, p.basename(e.path)); - if (e is Directory) { - if (FileSystemEntity.isDirectorySync(t)) await walk(e.path, t); - } else if (await isNewerThere(e.path, t)) { - n++; - } - } - } - - if (FileSystemEntity.isDirectorySync(src) && FileSystemEntity.isDirectorySync(dst)) await walk(src, dst); - return n; - } + static Future countNewerInside(String src, String dst, {int limit = 5000}) => + local.countNewerInside(src, dst, limit: limit); /// 이동: 같은 드라이브면 이름 바꾸기, 아니면 복사한 뒤 원본 삭제 - Future> move(List sources, String destDir, {CopyProgress? onProgress}) async { - if (_anyDav([...sources, destDir])) return _moveV(sources, destDir, onProgress: onProgress); - final made = []; - for (final s in sources) { - _check(); - if (isSameOrInside(destDir, s)) throw FileSystemException(tr('폴더를 자기 안으로 옮길 수 없습니다'), s); - if (samePath(p.dirname(s), destDir)) { - made.add(s); // 이미 그 폴더에 있음 - continue; - } - final target = await _targetFor(s, destDir); - if (target == null) continue; - final exists = FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound; - if (exists && FileSystemEntity.isDirectorySync(s)) { - // 덮어쓰기로 폴더를 합친다: 복사 (안의 같은 이름은 덮어씀) 뒤 원본 삭제 - final keptBefore = keptNewer.length; - made.addAll(await copy([s], destDir, onProgress: onProgress)); - _check(); - final kept = keptNewer.sublist(keptBefore); - if (kept.isEmpty) { - await delete([s]); - } else { - // 229: 건너뛴 (받는 쪽이 더 새) 원본은 남기고 옮긴 것만 지운다 - await _deleteExcept(s, kept.map(p.normalize).toSet()); - } - continue; - } - try { - final t = FileSystemEntity.typeSync(s); - // 덮어쓰기 (파일): 이름 바꾸기가 옛 파일을 바로 바꿔 놓는다 (다른 드라이브면 아래 복사가 덮어씀) - if (t == FileSystemEntityType.directory) { - await Directory(s).rename(target); - } else { - await File(s).rename(target); - } - made.add(target); - } on FileSystemException { - // 다른 드라이브 · 저장 장치: 복사 후 삭제 - final copied = await copy([s], destDir, onProgress: onProgress); - await delete([s]); - made.addAll(copied); - } - } - return made; - } + Future> move(List sources, String destDir, {CopyProgress? onProgress}) => _anyDav([...sources, destDir]) + ? anyMove(this, sources, destDir, onProgress: onProgress) + : local.localMove(this, sources, destDir, onProgress: onProgress); /// 동기화 (실시간 동기화 · lsyncd 처럼): [srcDir] 의 내용을 [dstDir] 에 맞춘다. /// 크기나 바뀐 시각이 다른 파일만 복사, [delete] 면 원본에 없는 것을 대상에서 지운다. 복사한 파일 수를 돌려준다. @@ -637,445 +75,14 @@ class FileOps { {bool delete = false, bool update = false, bool allowEmptySource = false, bool strict = false, RsyncFilter filter = RsyncFilter.none}) async { // 지우기 포함이면 원본을 확실히 읽을 수 있을 때만 (읽지 못하면 원본이 빈 것으로 보여 대상이 모두 지워졌다) if (delete) await ensureSourceForDelete(srcDir, dstDir, allowEmpty: allowEmptySource); - if (_anyDav([srcDir, dstDir])) return _mirrorV(srcDir, dstDir, delete: delete, update: update, strict: strict, filter: filter); - var n = 0; - Future walk(String src, String dst) async { - _check(); - await Directory(dst).create(recursive: true); - final names = {}; - // 원본 목록을 다 읽은 뒤에 맞춘다: 읽다 실패하면 (그 폴더에서) 아무것도 지우지 않고 멈춘다 - List items; - try { - items = await Directory(src).list(followLinks: false).toList(); - } catch (e) { - throw SourceUnreadableException(src, cause: e); - } - for (final e in items) { - final name = p.basename(e.path); - if (isAppTempName(name)) continue; // 78 보충: 이름 바꾸는 중의 임시 이름은 맞추지 않는다 - names.add(name); - final target = p.join(dst, name); - if (e is Directory) { - await walk(e.path, target); - } else if (e is File) { - final st = await e.stat(); - final t = File(target); - final exists = await t.exists(); - final same = exists && - (await t.length()) == st.size && - ((await t.lastModified()).difference(st.modified).inSeconds.abs() <= 2); - if (same) { - onSettled?.call(e.path, st.size, st.modified); - continue; - } - if (update && exists && (await t.lastModified()).isAfter(st.modified.add(const Duration(seconds: 2)))) continue; - final tmp = '$target.jjsync'; - final out = File(tmp).openWrite(); - try { - await for (final chunk in e.openRead()) { - _check(); - out.add(chunk); - onBytes?.call(chunk.length); - await _throttle(chunk.length); - } - } finally { - await out.close(); - } - await File(tmp).rename(target); - try { - await File(target).setLastModified(st.modified); - } catch (_) {} - n++; - onFileDone?.call(e.path); - onSettled?.call(e.path, st.size, st.modified); - } - } - if (delete) { - await for (final e in Directory(dst).list(followLinks: false).handleError((_) {})) { - if (!names.contains(p.basename(e.path)) && !e.path.endsWith('.jjsync') && !isAppTempName(p.basename(e.path))) { - await e.delete(recursive: true); - } - } - } - } - - await walk(srcDir, dstDir); - return n; + if (_anyDav([srcDir, dstDir])) return anyMirror(this, srcDir, dstDir, delete: delete, update: update, strict: strict, filter: filter); + return local.localMirror(this, srcDir, dstDir, delete: delete, update: update); } - Future delete(List paths) async { - for (final s in paths) { - _check(); - if (isDav(s)) { - await vDelete(s); - continue; - } - final t = FileSystemEntity.typeSync(s, followLinks: false); - if (t == FileSystemEntityType.directory) { - await Directory(s).delete(recursive: true); - } else if (t != FileSystemEntityType.notFound) { - await File(s).delete(); - } - } - } + Future delete(List paths) => local.deletePaths(this, paths); /// 이름 바꾸기 (같은 폴더 안). 새 경로를 돌려준다. - static Future rename(String path, String newName) async { - final name = newName.trim(); - final bad = nameProblem(name, windowsRules: usesWindowsNames(path)); - if (bad != null) throw FileSystemException(bad, name); - if (isDav(path)) { - final target = vJoin(vDirname(path), name); - if (target == vNorm(path)) return target; - if (await vExists(target)) throw FileSystemException(tr('같은 이름이 이미 있습니다'), target); - await vRename(path, target); - return target; - } - final target = p.join(p.dirname(path), name); - if (target == path) return path; - if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound && - (target.toLowerCase() != path.toLowerCase() || _otherCaseExists(path, name))) { - throw FileSystemException(tr('같은 이름이 이미 있습니다'), target); - } - final t = FileSystemEntity.typeSync(path); - return t == FileSystemEntityType.directory - ? (await Directory(path).rename(target)).path - : (await File(path).rename(target)).path; - } + static Future rename(String path, String newName) => renameInPlace(path, newName); - /// 데이터 검토 (10/18): 대소문자만 바꾸는 이름 (a.jpg → A.jpg) 인데 대소문자를 가리는 저장소 (Android 의 앱 폴더 · ext4 · 일부 NAS) 에 - /// 그 이름의 다른 파일이 따로 있으면 true - rename 이 그 파일을 말없이 덮지 않게 (가리지 않는 곳이면 목록에 정확히 그 이름이 없다) - static bool _otherCaseExists(String path, String name) { - if (p.basename(path) == name) return false; - try { - return Directory(p.dirname(path)).listSync(followLinks: false).any((e) => p.basename(e.path) == name); - } catch (_) { - return true; // 모르면 막는다 (덮는 것보다 낫다) - } - } - - static Future makeFolder(String dir, String name) async { - final n = name.trim(); - final bad = nameProblem(n, windowsRules: usesWindowsNames(dir)); - if (bad != null) throw FileSystemException(bad, n); - if (isDav(dir)) { - final target = vJoin(dir, n); - if (await vExists(target)) throw FileSystemException(tr('같은 이름이 이미 있습니다'), target); - await vMkdirs(target); - return target; - } - final target = p.join(dir, n); - if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound) { - throw FileSystemException(tr('같은 이름이 이미 있습니다'), target); - } - return (await Directory(target).create()).path; - } - - // ───────── 어느 저장소든 (로컬 · WebDAV) ───────── - - static Future _totalSizeV(List paths) async { - var total = 0; - Future walk(String path) async { - final st = await vStat(path); - if (st == null) return; - if (!st.isDir) { - total += st.size; - return; - } - for (final e in await vList(path)) { - if (e.isDir) { - await walk(e.path); - } else { - total += e.size; - } - } - } - - for (final s in paths) { - await walk(s); - } - return total; - } - - /// 파일 하나: 읽어서 쓴다 (속도 제한 · 취소 · 진행) - Future _copyFileV(String src, String target, int size, DateTime modified, void Function(int n)? onChunk) async { - _check(); - final input = await vOpenRead(src); - final relay = StreamController>(); - final pump = () async { - try { - await for (final chunk in input) { - if (_cancel) { - relay.addError(const FileOpCancelled()); - break; - } - relay.add(chunk); - onBytes?.call(chunk.length); - onChunk?.call(chunk.length); - await _throttle(chunk.length); - } - } catch (e, st) { - relay.addError(e, st); - } finally { - await relay.close(); - } - }(); - try { - await vWrite(target, relay.stream, length: size, modified: modified); - } finally { - await pump; - } - if (_cancel) { - try { - await vDelete(target); - } catch (_) {} - throw const FileOpCancelled(); - } - onFileDone?.call(src); - } - - Future> _copyV(List sources, String destDir, {CopyProgress? onProgress}) async { - final total = await _totalSizeV(sources); - var done = 0; - final made = []; - Future copyAny(String src, String target, {bool inner = false}) async { - _check(); - final st = await vStat(src); - if (st == null) throw FileSystemException(tr('원본이 없습니다'), src); - if (st.isDir) { - await vMkdirs(target); - for (final e in await vList(src)) { - await copyAny(e.path, vJoin(target, vBasename(e.path)), inner: true); - } - } else { - // 229: 합치는 폴더 안에서 받는 쪽이 더 새것이면 예전 것으로 덮지 않는다 (WebDAV 도 로컬과 같게) - if (inner && skipNewerInMerge) { - final t = await vStat(target); - if (t != null && !t.isDir && t.modified.isAfter(st.modified.add(const Duration(seconds: 2)))) { - keptNewer.add(src); - return; - } - } - await _copyFileV(src, target, st.size, st.modified, (n) { - done += n; - onProgress?.call(src, done, total); - }); - } - } - - for (final s in sources) { - if (isSameOrInside(destDir, s) && await vIsDir(s)) throw FileSystemException(tr('폴더를 자기 안으로 복사할 수 없습니다'), s); - final target = await _targetFor(s, destDir); - if (target == null) continue; - await copyAny(s, target); - made.add(target); - } - return made; - } - - Future> _moveV(List sources, String destDir, {CopyProgress? onProgress}) async { - final made = []; - for (final s in sources) { - _check(); - if (isSameOrInside(destDir, s)) throw FileSystemException(tr('폴더를 자기 안으로 옮길 수 없습니다'), s); - if (samePath(vDirname(s), destDir)) { - made.add(s); - continue; - } - final target = await _targetFor(s, destDir); - if (target == null) continue; - final exists = await vExists(target); - // 같은 WebDAV 서버 안이면 서버가 옮긴다 (빠름) - 덮어쓸 폴더는 합쳐야 하니 복사로 - if (isDav(s) && isDav(destDir) && DavPath.parse(s).server == DavPath.parse(destDir).server && !(exists && await vIsDir(s))) { - if (exists) await vDelete(target); - await vRename(s, target); - made.add(target); - continue; - } - final keptBefore = keptNewer.length; - made.addAll(await _copyV([s], destDir, onProgress: onProgress)); - final kept = keptNewer.sublist(keptBefore); - if (kept.isEmpty) { - await vDelete(s); - } else { - // 229: 건너뛴 (받는 쪽이 더 새) 원본은 남기고 옮긴 것만 지운다 - await _deleteExceptV(s, kept.toSet()); - } - } - return made; - } - - /// 229: [dir] (로컬 · WebDAV) 안에서 [keep] 의 파일만 남기고 지운다 (비게 된 폴더도) - Future _deleteExceptV(String dir, Set keep) async { - var empty = true; - for (final e in await vList(dir)) { - if (e.isDir) { - if (!await _deleteExceptV(e.path, keep)) empty = false; - } else if (keep.contains(e.path)) { - empty = false; - } else { - await vDelete(e.path); - } - } - if (empty) await vDelete(dir); - return empty; - } - - Future _mirrorV(String srcDir, String dstDir, - {bool delete = false, bool update = false, bool strict = false, RsyncFilter filter = RsyncFilter.none}) async { - var n = 0; - // 관리자 지시 (10/19): 거르기는 rsync 와 같게 (미리 보기 sync_preview 와 같은 규칙) - 거른 것은 보내지 않고 받는 쪽의 거른 것은 지우지 않는다 - // (--delete-excluded 면 지운다). 받는 쪽에만 있는 폴더 안에 거른 것이 있으면 폴더는 남기고 거르지 않은 것만 지운다 - Future hasExcluded(String dir, String rel) async { - List<({String path, bool isDir, int size, DateTime modified})> items; - try { - items = await vList(dir); - } catch (_) { - return false; - } - for (final x in items) { - final r = '$rel/${vBasename(x.path)}'; - if (filter.protects(r, isDir: x.isDir)) return true; - if (x.isDir && await hasExcluded(x.path, r)) return true; - } - return false; - } - - Future deleteInside(String dir, String rel) async { - for (final x in await vList(dir)) { - _check(); - final name = vBasename(x.path); - if (name.endsWith('.jjsync') || isAppTempName(name)) continue; - final r = '$rel/$name'; - if (filter.protects(r, isDir: x.isDir)) continue; - if (x.isDir && await hasExcluded(x.path, r)) { - await deleteInside(x.path, r); - } else { - await vDelete(x.path); - } - } - } - - Future walk(String src, String dst, String rel) async { - _check(); - await vMkdirs(dst); - final there = {for (final e in await vList(dst)) vBasename(e.path): e}; - final names = {}; - List<({String path, bool isDir, int size, DateTime modified})> items; - try { - items = await vList(src, strict: true); - } catch (e) { - throw SourceUnreadableException(src, cause: e); - } - for (final e in items) { - final name = vBasename(e.path); - if (isAppTempName(name)) continue; // 78 보충 - final r = rel.isEmpty ? name : '$rel/$name'; - // 거른 것은 보내지 않는다 (거른 폴더 안으로도 내려가지 않음) - 받는 쪽의 같은 이름은 아래 지우기에서 (지키거나 --delete-excluded) - if (filter.excluded(r, isDir: e.isDir)) continue; - names.add(name); - final target = vJoin(dst, name); - // 214: 로컬 원본의 링크 · junction 은 따라가지 않는다 (로컬끼리 맞출 때처럼 - 링크가 가리키는 바깥 폴더를 건드리지 않게) - if (!isDav(e.path) && FileSystemEntity.isLinkSync(e.path)) continue; - if (e.isDir) { - await walk(e.path, target, r); - continue; - } - final t = there[name]; - if (t != null && !t.isDir && t.size == e.size) { - final close = t.modified.difference(e.modified).inSeconds.abs() <= 2; - // 크기가 같고 대상이 원본보다 새것이거나 (올린 파일은 서버 시각) 거의 같으면 그대로. - // 214: 이동 ([strict]) 은 거의 같은 시각일 때만 같다고 본다 (아니면 다시 보내고 지운다 - 내용이 다른데 지우지 않게) - if (close || (!strict && !t.modified.isBefore(e.modified.subtract(const Duration(seconds: 2))))) { - onSettled?.call(e.path, e.size, e.modified); - continue; - } - } - if (update && t != null && t.modified.isAfter(e.modified.add(const Duration(seconds: 2)))) continue; - // M1 (관리자 P0, 10/19): 예전에는 --delete 가 없어도 같은 이름의 대상 폴더를 통째로 지우고 파일로 바꿨다 (미리 보기에는 "바뀜" 만). - // 이제 --delete 일 때만 (미리 보기에 "폴더를 지우고 파일로"), 아니면 건너뛰고 알린다 - if (t != null && t.isDir) { - if (!delete) { - dirConflicts.add(target); - continue; - } - await vDelete(target); - } - await _copyFileV(e.path, target, e.size, e.modified, null); - n++; - onSettled?.call(e.path, e.size, e.modified); - } - if (delete) { - for (final t in there.entries) { - final name = t.key; - if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue; - _check(); - final r = rel.isEmpty ? name : '$rel/$name'; - // 받는 쪽 규칙에 걸린 것만 지킨다 (관리자 지시 10/20: 보내는 쪽에서만 거른 H · -,s · --delete-excluded 의 그냥 규칙은 지움) - if (filter.protects(r, isDir: t.value.isDir)) continue; - if (t.value.isDir && filter.protectsAny && await hasExcluded(t.value.path, r)) { - await deleteInside(t.value.path, r); - continue; - } - await vDelete(t.value.path); - } - } - } - - await walk(srcDir, dstDir, ''); - return n; - } -} - -/// [root] 아래에서 이름에 [query] 가 들어간 항목 찾기 (대소문자 무시, * ? 사용 가능). 찾는 대로 내보낸다. -/// [limit] 개에서 멈춘다. 읽지 못한 폴더는 [onSkipped] 로 알린다 (24: 조용히 빠뜨리지 않게). -/// 79: [accept] 가 있으면 그 조건 (크기 · 바뀐 날 · 종류) 에도 맞는 것만. 이때는 [query] 를 비워도 된다. -Stream searchFiles(String root, String query, - {bool showHidden = false, - int limit = 2000, - void Function(String dir, Object error)? onSkipped, - bool Function(FileEntry e)? accept}) async* { - final q = query.trim().toLowerCase(); - // 79: 이름 없이 조건만 ("1GB 넘는 30일 지난 영상") 으로도 찾는다 - if (q.isEmpty && accept == null) return; - final wild = q.contains('*') || q.contains('?'); - final re = wild - ? RegExp('^${RegExp.escape(q).replaceAll(r'\*', '.*').replaceAll(r'\?', '.')}\$') - : null; - var n = 0; - final pending = [root]; - while (pending.isNotEmpty) { - final dir = pending.removeAt(0); - List items; - try { - // strict: 읽지 못하면 빈 폴더로 치지 않고 알린다 (24) - items = [ - for (final e in await vList(dir, strict: true)) - if (showHidden || !vBasename(e.path).startsWith('.')) - FileEntry(e.path, isDir: e.isDir, size: e.size, modified: e.modified), - ]; - } catch (e) { - onSkipped?.call(dir, e); - continue; - } - for (final e in sortEntries(items, SortBy.name)) { - final name = e.name.toLowerCase(); - if ((re != null ? re.hasMatch(name) : name.contains(q)) && (accept == null || accept(e))) { - yield e; - if (++n >= limit) return; - } - if (e.isDir) pending.add(e.path); - } - } -} - -/// "1.2GB" · "350MB" · "12KB" -String formatSize(int bytes) { - // 203: 크기를 모름 (잠긴 시스템 파일 · 읽지 못함 → -1) 은 "-1B" 가 아니라 "—" - if (bytes < 0) return '—'; - const k = 1024; - if (bytes >= k * k * k) return '${(bytes / (k * k * k)).toStringAsFixed(1)}GB'; - if (bytes >= k * k) return '${(bytes / (k * k)).toStringAsFixed(bytes < 10 * k * k ? 1 : 0)}MB'; - if (bytes >= k) return '${(bytes / k).round()}KB'; - return '${bytes}B'; + static Future makeFolder(String dir, String name) => createFolder(dir, name); } diff --git a/lib/core/files/any_transfer.dart b/lib/core/files/any_transfer.dart new file mode 100644 index 0000000..aadf43d --- /dev/null +++ b/lib/core/files/any_transfer.dart @@ -0,0 +1,273 @@ +import 'dart:async'; +import 'dart:io'; + +import '../../l10n/tr.dart'; +import '../rsync_filter.dart'; +import '../vfs.dart'; +import 'path_rules.dart'; +import 'transfer_control.dart'; +import 'transfer_guard.dart'; + +// 약속 (나누기 file_ops): 어느 저장소든 (로컬 · WebDAV · SFTP · FTP 등 vfs 가 아는 곳) 의 복사 · 이동 · 맞추기 · 크기. +// 한쪽이라도 원격이면 [FileOps] 가 이쪽을 부른다. 같은 서버 안의 이동은 서버가 옮긴다. +// 입력: 설정 · 상태 [TransferControl], 원본 경로들 · 받는 폴더 (맞추기는 [RsyncFilter] 거르기도). 출력: 만든 경로들 · 보낸 파일 수. +// 오류: 취소 → [FileOpCancelled] (만들던 파일은 지움) · 원본 없음 · 자기 안으로 → [FileSystemException] · +// 맞추기에서 원본을 읽지 못함 → [SourceUnreadableException]. 원격 오류 (DavException 등) 는 그대로. + +/// 전체 크기 (진행률용) +Future anyTotalSize(List paths) async { + var total = 0; + Future walk(String path) async { + final st = await vStat(path); + if (st == null) return; + if (!st.isDir) { + total += st.size; + return; + } + for (final e in await vList(path)) { + if (e.isDir) { + await walk(e.path); + } else { + total += e.size; + } + } + } + + for (final s in paths) { + await walk(s); + } + return total; +} + +/// 파일 하나: 읽어서 쓴다 (속도 제한 · 취소 · 진행) +Future _copyFile(TransferControl c, String src, String target, int size, DateTime modified, void Function(int n)? onChunk) async { + c.check(); + final input = await vOpenRead(src); + final relay = StreamController>(); + final pump = () async { + try { + await for (final chunk in input) { + if (c.cancelled) { + relay.addError(const FileOpCancelled()); + break; + } + relay.add(chunk); + c.onBytes?.call(chunk.length); + onChunk?.call(chunk.length); + await c.throttle(chunk.length); + } + } catch (e, st) { + relay.addError(e, st); + } finally { + await relay.close(); + } + }(); + try { + await vWrite(target, relay.stream, length: size, modified: modified); + } finally { + await pump; + } + if (c.cancelled) { + try { + await vDelete(target); + } catch (_) {} + throw const FileOpCancelled(); + } + c.onFileDone?.call(src); +} + +/// [sources] 를 [destDir] 로 복사. 만든 경로들을 돌려준다 +Future> anyCopy(TransferControl c, List sources, String destDir, {CopyProgress? onProgress}) async { + final total = await anyTotalSize(sources); + var done = 0; + final made = []; + Future copyAny(String src, String target, {bool inner = false}) async { + c.check(); + final st = await vStat(src); + if (st == null) throw FileSystemException(tr('원본이 없습니다'), src); + if (st.isDir) { + await vMkdirs(target); + for (final e in await vList(src)) { + await copyAny(e.path, vJoin(target, vBasename(e.path)), inner: true); + } + } else { + // 229: 합치는 폴더 안에서 받는 쪽이 더 새것이면 예전 것으로 덮지 않는다 (WebDAV 도 로컬과 같게) + if (inner && c.skipNewerInMerge) { + final t = await vStat(target); + if (t != null && !t.isDir && t.modified.isAfter(st.modified.add(const Duration(seconds: 2)))) { + c.keptNewer.add(src); + return; + } + } + await _copyFile(c, src, target, st.size, st.modified, (n) { + done += n; + onProgress?.call(src, done, total); + }); + } + } + + for (final s in sources) { + if (isSameOrInside(destDir, s) && await vIsDir(s)) throw FileSystemException(tr('폴더를 자기 안으로 복사할 수 없습니다'), s); + final target = await c.targetFor(s, destDir); + if (target == null) continue; + await copyAny(s, target); + made.add(target); + } + return made; +} + +/// 이동: 같은 WebDAV 서버 안이면 서버가 옮기고, 아니면 복사한 뒤 원본을 지운다 +Future> anyMove(TransferControl c, List sources, String destDir, {CopyProgress? onProgress}) async { + final made = []; + for (final s in sources) { + c.check(); + if (isSameOrInside(destDir, s)) throw FileSystemException(tr('폴더를 자기 안으로 옮길 수 없습니다'), s); + if (samePath(vDirname(s), destDir)) { + made.add(s); + continue; + } + final target = await c.targetFor(s, destDir); + if (target == null) continue; + final exists = await vExists(target); + // 같은 WebDAV 서버 안이면 서버가 옮긴다 (빠름) - 덮어쓸 폴더는 합쳐야 하니 복사로 + if (isDav(s) && isDav(destDir) && DavPath.parse(s).server == DavPath.parse(destDir).server && !(exists && await vIsDir(s))) { + if (exists) await vDelete(target); + await vRename(s, target); + made.add(target); + continue; + } + final keptBefore = c.keptNewer.length; + made.addAll(await anyCopy(c, [s], destDir, onProgress: onProgress)); + final kept = c.keptNewer.sublist(keptBefore); + if (kept.isEmpty) { + await vDelete(s); + } else { + // 229: 건너뛴 (받는 쪽이 더 새) 원본은 남기고 옮긴 것만 지운다 + await _deleteExcept(s, kept.toSet()); + } + } + return made; +} + +/// 229: [dir] (로컬 · WebDAV) 안에서 [keep] 의 파일만 남기고 지운다 (비게 된 폴더도) +Future _deleteExcept(String dir, Set keep) async { + var empty = true; + for (final e in await vList(dir)) { + if (e.isDir) { + if (!await _deleteExcept(e.path, keep)) empty = false; + } else if (keep.contains(e.path)) { + empty = false; + } else { + await vDelete(e.path); + } + } + if (empty) await vDelete(dir); + return empty; +} + +/// 맞추기: [srcDir] 의 내용을 [dstDir] 에. [strict] (214, 이동): 같음은 크기 + 바뀐 때 (±2초) 로만. +/// [filter]: rsync 옵션의 거르기 (미리 보기 sync_preview 와 같은 규칙). 지우기 전 원본 확인은 부르는 쪽이 한다 +Future anyMirror(TransferControl c, String srcDir, String dstDir, + {bool delete = false, bool update = false, bool strict = false, RsyncFilter filter = RsyncFilter.none}) async { + var n = 0; + // 관리자 지시 (10/19): 거르기는 rsync 와 같게 (미리 보기 sync_preview 와 같은 규칙) - 거른 것은 보내지 않고 받는 쪽의 거른 것은 지우지 않는다 + // (--delete-excluded 면 지운다). 받는 쪽에만 있는 폴더 안에 거른 것이 있으면 폴더는 남기고 거르지 않은 것만 지운다 + Future hasExcluded(String dir, String rel) async { + List<({String path, bool isDir, int size, DateTime modified})> items; + try { + items = await vList(dir); + } catch (_) { + return false; + } + for (final x in items) { + final r = '$rel/${vBasename(x.path)}'; + if (filter.protects(r, isDir: x.isDir)) return true; + if (x.isDir && await hasExcluded(x.path, r)) return true; + } + return false; + } + + Future deleteInside(String dir, String rel) async { + for (final x in await vList(dir)) { + c.check(); + final name = vBasename(x.path); + if (name.endsWith('.jjsync') || isAppTempName(name)) continue; + final r = '$rel/$name'; + if (filter.protects(r, isDir: x.isDir)) continue; + if (x.isDir && await hasExcluded(x.path, r)) { + await deleteInside(x.path, r); + } else { + await vDelete(x.path); + } + } + } + + Future walk(String src, String dst, String rel) async { + c.check(); + await vMkdirs(dst); + final there = {for (final e in await vList(dst)) vBasename(e.path): e}; + final names = {}; + List<({String path, bool isDir, int size, DateTime modified})> items; + try { + items = await vList(src, strict: true); + } catch (e) { + throw SourceUnreadableException(src, cause: e); + } + for (final e in items) { + final name = vBasename(e.path); + if (isAppTempName(name)) continue; // 78 보충 + final r = rel.isEmpty ? name : '$rel/$name'; + // 거른 것은 보내지 않는다 (거른 폴더 안으로도 내려가지 않음) - 받는 쪽의 같은 이름은 아래 지우기에서 (지키거나 --delete-excluded) + if (filter.excluded(r, isDir: e.isDir)) continue; + names.add(name); + final target = vJoin(dst, name); + // 214: 로컬 원본의 링크 · junction 은 따라가지 않는다 (로컬끼리 맞출 때처럼 - 링크가 가리키는 바깥 폴더를 건드리지 않게) + if (!isDav(e.path) && FileSystemEntity.isLinkSync(e.path)) continue; + if (e.isDir) { + await walk(e.path, target, r); + continue; + } + final t = there[name]; + if (t != null && !t.isDir && t.size == e.size) { + final close = t.modified.difference(e.modified).inSeconds.abs() <= 2; + // 크기가 같고 대상이 원본보다 새것이거나 (올린 파일은 서버 시각) 거의 같으면 그대로. + // 214: 이동 ([strict]) 은 거의 같은 시각일 때만 같다고 본다 (아니면 다시 보내고 지운다 - 내용이 다른데 지우지 않게) + if (close || (!strict && !t.modified.isBefore(e.modified.subtract(const Duration(seconds: 2))))) { + c.onSettled?.call(e.path, e.size, e.modified); + continue; + } + } + if (update && t != null && t.modified.isAfter(e.modified.add(const Duration(seconds: 2)))) continue; + // M1 (관리자 P0, 10/19): 예전에는 --delete 가 없어도 같은 이름의 대상 폴더를 통째로 지우고 파일로 바꿨다 (미리 보기에는 "바뀜" 만). + // 이제 --delete 일 때만 (미리 보기에 "폴더를 지우고 파일로"), 아니면 건너뛰고 알린다 + if (t != null && t.isDir) { + if (!delete) { + c.dirConflicts.add(target); + continue; + } + await vDelete(target); + } + await _copyFile(c, e.path, target, e.size, e.modified, null); + n++; + c.onSettled?.call(e.path, e.size, e.modified); + } + if (delete) { + for (final t in there.entries) { + final name = t.key; + if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue; + c.check(); + final r = rel.isEmpty ? name : '$rel/$name'; + // 받는 쪽 규칙에 걸린 것만 지킨다 (관리자 지시 10/20: 보내는 쪽에서만 거른 H · -,s · --delete-excluded 의 그냥 규칙은 지움) + if (filter.protects(r, isDir: t.value.isDir)) continue; + if (t.value.isDir && filter.protectsAny && await hasExcluded(t.value.path, r)) { + await deleteInside(t.value.path, r); + continue; + } + await vDelete(t.value.path); + } + } + } + + await walk(srcDir, dstDir, ''); + return n; +} diff --git a/lib/core/files/file_listing.dart b/lib/core/files/file_listing.dart new file mode 100644 index 0000000..263c359 --- /dev/null +++ b/lib/core/files/file_listing.dart @@ -0,0 +1,127 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../playlist.dart' show naturalCompare; +import '../vfs.dart'; + +// 약속 (나누기 file_ops): 폴더 항목 · 정렬 · 폴더 읽기 · 이름 찾기 · 크기 글. +// 입력: 경로 (로컬 · WebDAV 등 vfs 가 아는 경로). 출력: [FileEntry] 목록 · 흐름 · 글. +// 오류: [listEntries] 는 하나도 읽지 못하면 그 오류를 던지고 (빈 폴더로 보이지 않게), 일부 읽은 뒤의 오류는 읽은 것까지 돌려준다. +// [searchFiles] 는 읽지 못한 폴더를 [onSkipped] 로 알리고 건너뛴다. [sortEntries] · [formatSize] 는 오류 없음. + +/// 폴더 안의 항목 하나 +class FileEntry { + final String path; + final bool isDir; + final int size; + final DateTime modified; + const FileEntry(this.path, {required this.isDir, this.size = 0, required this.modified}); + + String get name => p.basename(path); + bool get hidden => name.startsWith('.'); + + /// 확장자 (소문자, 점 없이) + String get ext => isDir ? '' : p.extension(path).replaceFirst('.', '').toLowerCase(); +} + +enum SortBy { name, date, size, type } + +/// 정렬: 폴더 먼저, 이름은 숫자를 숫자로 비교 (file2 < file10) +/// 80: [dirSize] 가 있으면 크기순에서 폴더도 그 크기로 (모르는 폴더는 가장 작게) +List sortEntries(List list, SortBy by, {bool descending = false, int? Function(FileEntry dir)? dirSize}) { + int sizeOf(FileEntry e) => e.isDir ? (dirSize?.call(e) ?? -1) : e.size; + int cmp(FileEntry a, FileEntry b) { + if (a.isDir != b.isDir) return a.isDir ? -1 : 1; // 폴더는 거꾸로여도 늘 위 + final r = switch (by) { + SortBy.name => 0, + SortBy.date => a.modified.compareTo(b.modified), + SortBy.size => sizeOf(a).compareTo(sizeOf(b)), + SortBy.type => a.ext.compareTo(b.ext), + }; + final v = r != 0 ? r : naturalCompare(a.name, b.name); + return descending ? -v : v; + } + + return [...list]..sort(cmp); +} + +/// 폴더 읽기 (읽을 수 없는 항목은 건너뜀). [showHidden] 이 아니면 점으로 시작하는 항목을 뺀다. +Future> listEntries(String dir, {bool showHidden = false}) async { + if (isDav(dir)) { + // WebDAV: 네트워크 오류 · 인증 실패는 그대로 던진다 (화면이 "읽을 수 없음" 과 이유를 보여 줌) + return [ + for (final e in await vList(dir)) + if (showHidden || !vBasename(e.path).startsWith('.')) + FileEntry(e.path, isDir: e.isDir, size: e.size, modified: e.modified), + ]; + } + final out = []; + // E10: 폴더를 읽지 못하면 (권한 없음 · 빠진 USB 등) 빈 폴더로 보이지 않게 던진다 (화면이 "읽을 수 없습니다: 이유" 와 [다시 시도]). + // 일부를 읽은 뒤의 오류는 읽은 것까지 보여 준다 + Object? failed; + await for (final e in Directory(dir).list(followLinks: false).handleError((Object err) => failed ??= err)) { + try { + final st = await e.stat(); + final isDir = st.type == FileSystemEntityType.directory; + final entry = FileEntry(e.path, isDir: isDir, size: isDir ? 0 : st.size, modified: st.modified); + if (!showHidden && entry.hidden) continue; + out.add(entry); + } catch (_) {} + } + if (failed != null && out.isEmpty) throw failed!; + return out; +} + +/// [root] 아래에서 이름에 [query] 가 들어간 항목 찾기 (대소문자 무시, * ? 사용 가능). 찾는 대로 내보낸다. +/// [limit] 개에서 멈춘다. 읽지 못한 폴더는 [onSkipped] 로 알린다 (24: 조용히 빠뜨리지 않게). +/// 79: [accept] 가 있으면 그 조건 (크기 · 바뀐 날 · 종류) 에도 맞는 것만. 이때는 [query] 를 비워도 된다. +Stream searchFiles(String root, String query, + {bool showHidden = false, + int limit = 2000, + void Function(String dir, Object error)? onSkipped, + bool Function(FileEntry e)? accept}) async* { + final q = query.trim().toLowerCase(); + // 79: 이름 없이 조건만 ("1GB 넘는 30일 지난 영상") 으로도 찾는다 + if (q.isEmpty && accept == null) return; + final wild = q.contains('*') || q.contains('?'); + final re = wild + ? RegExp('^${RegExp.escape(q).replaceAll(r'\*', '.*').replaceAll(r'\?', '.')}\$') + : null; + var n = 0; + final pending = [root]; + while (pending.isNotEmpty) { + final dir = pending.removeAt(0); + List items; + try { + // strict: 읽지 못하면 빈 폴더로 치지 않고 알린다 (24) + items = [ + for (final e in await vList(dir, strict: true)) + if (showHidden || !vBasename(e.path).startsWith('.')) + FileEntry(e.path, isDir: e.isDir, size: e.size, modified: e.modified), + ]; + } catch (e) { + onSkipped?.call(dir, e); + continue; + } + for (final e in sortEntries(items, SortBy.name)) { + final name = e.name.toLowerCase(); + if ((re != null ? re.hasMatch(name) : name.contains(q)) && (accept == null || accept(e))) { + yield e; + if (++n >= limit) return; + } + if (e.isDir) pending.add(e.path); + } + } +} + +/// "1.2GB" · "350MB" · "12KB" +String formatSize(int bytes) { + // 203: 크기를 모름 (잠긴 시스템 파일 · 읽지 못함 → -1) 은 "-1B" 가 아니라 "—" + if (bytes < 0) return '—'; + const k = 1024; + if (bytes >= k * k * k) return '${(bytes / (k * k * k)).toStringAsFixed(1)}GB'; + if (bytes >= k * k) return '${(bytes / (k * k)).toStringAsFixed(bytes < 10 * k * k ? 1 : 0)}MB'; + if (bytes >= k) return '${(bytes / k).round()}KB'; + return '${bytes}B'; +} diff --git a/lib/core/files/local_transfer.dart b/lib/core/files/local_transfer.dart new file mode 100644 index 0000000..f9cacfe --- /dev/null +++ b/lib/core/files/local_transfer.dart @@ -0,0 +1,284 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../../l10n/tr.dart'; +import '../vfs.dart'; +import 'path_rules.dart'; +import 'transfer_control.dart'; +import 'transfer_guard.dart'; + +// 약속 (나누기 file_ops): 로컬 (같은 기기의 폴더 · 네트워크 공유) 끼리의 복사 · 이동 · 맞추기 · 지우기 · 크기. +// 입력: 설정 · 상태 [TransferControl], 원본 경로들 · 받는 폴더. 출력: 만든 경로들 · 복사한 파일 수. +// 오류: 취소 → [FileOpCancelled] (만들다 만 파일은 지움) · 폴더를 자기 안으로 → [FileSystemException] · +// 맞추기에서 원본 폴더를 읽지 못함 → [SourceUnreadableException] (그 폴더에서는 아무것도 지우지 않음). 그 밖의 파일 오류는 그대로. +// WebDAV 등이 섞이면 any_transfer 를 쓴다 (고르는 것은 [FileOps]). + +/// 전체 크기 (진행률용) +Future localTotalSize(List paths) async { + var total = 0; + for (final s in paths) { + final t = FileSystemEntity.typeSync(s, followLinks: false); + if (t == FileSystemEntityType.directory) { + await for (final e in Directory(s).list(recursive: true, followLinks: false).handleError((_) {})) { + if (e is File) { + try { + total += await e.length(); + } catch (_) {} + } + } + } else if (t == FileSystemEntityType.file) { + total += await File(s).length(); + } + } + return total; +} + +/// [sources] 를 [destDir] 로 복사 (같은 이름이 있으면 [TransferControl.conflict] 대로). 만든 경로들을 돌려준다. +Future> localCopy(TransferControl c, List sources, String destDir, {CopyProgress? onProgress}) async { + final total = await localTotalSize(sources); + var done = 0; + final made = []; + // 43: 임시 이름 (.jjpart) 에 다 쓴 뒤 이름을 바꾼다 - 복사 도중 앱이 끝나도 반쯤 쓴 파일이 완성된 이름으로 남지 않게 + Future copyFile(File f, String finalTarget) async { + c.check(); + final target = '$finalTarget.jjpart'; + final out = File(target).openWrite(); + try { + try { + await for (final chunk in f.openRead()) { + c.check(); + out.add(chunk); + done += chunk.length; + onProgress?.call(f.path, done, total); + c.onBytes?.call(chunk.length); + await c.throttle(chunk.length); + } + } finally { + await out.close(); + } + c.check(); + } catch (_) { + // 취소 · 실패: 만들다 만 파일을 남기지 않는다 + try { + await File(target).delete(); + } catch (_) {} + rethrow; + } + // 덮어쓰기: 새 파일을 다 쓴 뒤에야 옛 파일을 지운다 (도중에 실패하면 옛 파일이 남게) + if (File(finalTarget).existsSync()) await File(finalTarget).delete(); + await File(target).rename(finalTarget); + try { + await File(finalTarget).setLastModified(await f.lastModified()); + } catch (_) {} + c.onFileDone?.call(f.path); + } + + Future copyAny(String src, String target, {bool inner = false}) async { + if (FileSystemEntity.isDirectorySync(src)) { + await Directory(target).create(recursive: true); + await for (final e in Directory(src).list(followLinks: false)) { + await copyAny(e.path, p.join(target, p.basename(e.path)), inner: true); + } + } else { + // 229: 합치는 폴더 안에서 받는 쪽이 더 새것이면 예전 것으로 덮지 않는다 + if (inner && c.skipNewerInMerge && await isNewerThere(src, target)) { + c.keptNewer.add(src); + return; + } + await copyFile(File(src), target); + } + } + + for (final s in sources) { + if (FileSystemEntity.isDirectorySync(s) && isSameOrInside(destDir, s)) { + throw FileSystemException(tr('폴더를 자기 안으로 복사할 수 없습니다'), s); + } + final target = await c.targetFor(s, destDir); + if (target == null) continue; + await copyAny(s, target); + made.add(target); + } + return made; +} + +/// 229: [dir] 안에서 [keep] 의 파일만 남기고 지운다 (비게 된 폴더도). [dir] 자체는 남는 것이 있으면 둔다 +Future _deleteExcept(String dir, Set keep) async { + Future walk(Directory d) async { + var empty = true; + await for (final e in d.list(followLinks: false)) { + if (e is Directory) { + if (await walk(e)) { + await e.delete(); + } else { + empty = false; + } + } else if (keep.contains(p.normalize(e.path))) { + empty = false; + } else { + await e.delete(); + } + } + return empty; + } + + if (await walk(Directory(dir))) await Directory(dir).delete(); +} + +/// 229: [target] 이 있고 [src] 보다 2초 넘게 새것인지 +Future isNewerThere(String src, String target) async { + final t = File(target); + if (!await t.exists()) return false; + return (await t.lastModified()).isAfter((await File(src).lastModified()).add(const Duration(seconds: 2))); +} + +/// 229: 같은 이름 창 - 폴더를 합치면 안쪽에서 받는 쪽이 더 새 파일 수 ([limit] 개까지 셈, 로컬만) +Future countNewerInside(String src, String dst, {int limit = 5000}) async { + var n = 0, seen = 0; + Future walk(String s, String d) async { + await for (final e in Directory(s).list(followLinks: false).handleError((_) {})) { + if (seen++ > limit) return; + final t = p.join(d, p.basename(e.path)); + if (e is Directory) { + if (FileSystemEntity.isDirectorySync(t)) await walk(e.path, t); + } else if (await isNewerThere(e.path, t)) { + n++; + } + } + } + + if (FileSystemEntity.isDirectorySync(src) && FileSystemEntity.isDirectorySync(dst)) await walk(src, dst); + return n; +} + +/// 이동: 같은 드라이브면 이름 바꾸기, 아니면 복사한 뒤 원본 삭제 +Future> localMove(TransferControl c, List sources, String destDir, {CopyProgress? onProgress}) async { + final made = []; + for (final s in sources) { + c.check(); + if (isSameOrInside(destDir, s)) throw FileSystemException(tr('폴더를 자기 안으로 옮길 수 없습니다'), s); + if (samePath(p.dirname(s), destDir)) { + made.add(s); // 이미 그 폴더에 있음 + continue; + } + final target = await c.targetFor(s, destDir); + if (target == null) continue; + final exists = FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound; + if (exists && FileSystemEntity.isDirectorySync(s)) { + // 덮어쓰기로 폴더를 합친다: 복사 (안의 같은 이름은 덮어씀) 뒤 원본 삭제 + final keptBefore = c.keptNewer.length; + made.addAll(await localCopy(c, [s], destDir, onProgress: onProgress)); + c.check(); + final kept = c.keptNewer.sublist(keptBefore); + if (kept.isEmpty) { + await deletePaths(c, [s]); + } else { + // 229: 건너뛴 (받는 쪽이 더 새) 원본은 남기고 옮긴 것만 지운다 + await _deleteExcept(s, kept.map(p.normalize).toSet()); + } + continue; + } + try { + final t = FileSystemEntity.typeSync(s); + // 덮어쓰기 (파일): 이름 바꾸기가 옛 파일을 바로 바꿔 놓는다 (다른 드라이브면 아래 복사가 덮어씀) + if (t == FileSystemEntityType.directory) { + await Directory(s).rename(target); + } else { + await File(s).rename(target); + } + made.add(target); + } on FileSystemException { + // 다른 드라이브 · 저장 장치: 복사 후 삭제 + final copied = await localCopy(c, [s], destDir, onProgress: onProgress); + await deletePaths(c, [s]); + made.addAll(copied); + } + } + return made; +} + +/// 동기화 (실시간 동기화 · lsyncd 처럼): [srcDir] 의 내용을 [dstDir] 에 맞춘다 (로컬끼리). +/// 크기나 바뀐 시각이 다른 파일만 복사, [delete] 면 원본에 없는 것을 대상에서 지운다. 복사한 파일 수를 돌려준다. +/// [update] (rsync -u): 대상이 더 새 파일이면 건너뛴다. 지우기 전 원본 확인 (ensureSourceForDelete) 은 부르는 쪽이 한다. +Future localMirror(TransferControl c, String srcDir, String dstDir, {bool delete = false, bool update = false}) async { + var n = 0; + Future walk(String src, String dst) async { + c.check(); + await Directory(dst).create(recursive: true); + final names = {}; + // 원본 목록을 다 읽은 뒤에 맞춘다: 읽다 실패하면 (그 폴더에서) 아무것도 지우지 않고 멈춘다 + List items; + try { + items = await Directory(src).list(followLinks: false).toList(); + } catch (e) { + throw SourceUnreadableException(src, cause: e); + } + for (final e in items) { + final name = p.basename(e.path); + if (isAppTempName(name)) continue; // 78 보충: 이름 바꾸는 중의 임시 이름은 맞추지 않는다 + names.add(name); + final target = p.join(dst, name); + if (e is Directory) { + await walk(e.path, target); + } else if (e is File) { + final st = await e.stat(); + final t = File(target); + final exists = await t.exists(); + final same = exists && + (await t.length()) == st.size && + ((await t.lastModified()).difference(st.modified).inSeconds.abs() <= 2); + if (same) { + c.onSettled?.call(e.path, st.size, st.modified); + continue; + } + if (update && exists && (await t.lastModified()).isAfter(st.modified.add(const Duration(seconds: 2)))) continue; + final tmp = '$target.jjsync'; + final out = File(tmp).openWrite(); + try { + await for (final chunk in e.openRead()) { + c.check(); + out.add(chunk); + c.onBytes?.call(chunk.length); + await c.throttle(chunk.length); + } + } finally { + await out.close(); + } + await File(tmp).rename(target); + try { + await File(target).setLastModified(st.modified); + } catch (_) {} + n++; + c.onFileDone?.call(e.path); + c.onSettled?.call(e.path, st.size, st.modified); + } + } + if (delete) { + await for (final e in Directory(dst).list(followLinks: false).handleError((_) {})) { + if (!names.contains(p.basename(e.path)) && !e.path.endsWith('.jjsync') && !isAppTempName(p.basename(e.path))) { + await e.delete(recursive: true); + } + } + } + } + + await walk(srcDir, dstDir); + return n; +} + +/// 지우기 (로컬 · WebDAV). 없는 것은 그냥 지나간다. 항목마다 취소를 본다 +Future deletePaths(TransferControl c, List paths) async { + for (final s in paths) { + c.check(); + if (isDav(s)) { + await vDelete(s); + continue; + } + final t = FileSystemEntity.typeSync(s, followLinks: false); + if (t == FileSystemEntityType.directory) { + await Directory(s).delete(recursive: true); + } else if (t != FileSystemEntityType.notFound) { + await File(s).delete(); + } + } +} diff --git a/lib/core/files/name_ops.dart b/lib/core/files/name_ops.dart new file mode 100644 index 0000000..ed55c29 --- /dev/null +++ b/lib/core/files/name_ops.dart @@ -0,0 +1,79 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../../l10n/tr.dart'; +import '../vfs.dart'; + +// 약속 (나누기 file_ops): 이름 규칙과 이름 일 - 쓸 수 없는 이름 · 이름 바꾸기 (같은 폴더 안) · 새 폴더. +// 입력: 경로 · 새 이름 (앞뒤 빈칸은 뺌). 출력: 문제 글 (괜찮으면 null) · 새 경로. +// 오류: 쓸 수 없는 이름 · 같은 이름이 이미 있음 → [FileSystemException] (그 글은 화면에 그대로 보인다). 저장소 오류는 그대로. + +/// 78 보충 (관리자 판단): 이름으로 쓸 수 없는 까닭 (괜찮으면 null). +/// 모든 곳: 빈 이름 · . · .. · \ / : * ? " < > | · 제어 문자. +/// [windowsRules] (Windows 의 로컬 경로): CON · PRN · AUX · NUL · COM1~9 · LPT1~9 (확장자가 붙어도), 끝이 점 · 빈칸, 255자 넘음 +String? nameProblem(String name, {required bool windowsRules}) { + if (name.isEmpty || name == '.' || name == '..' || name.contains(RegExp(r'[\\/:*?"<>|\x00-\x1f]'))) return tr('쓸 수 없는 이름'); + if (!windowsRules) return null; + final base = name.split('.').first.trim().toUpperCase(); + if (RegExp(r'^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$').hasMatch(base)) return trf('Windows 가 쓰는 이름 ({0})', [base]); + if (name.endsWith('.') || name.endsWith(' ')) return tr('이름 끝에 점 · 빈칸은 쓸 수 없음'); + if (name.length > 255) return tr('이름이 너무 김 (255자 넘음)'); + return null; +} + +/// 이 경로에 Windows 이름 규칙을 쓰는지 (Windows 의 로컬 · 네트워크 공유. WebDAV 는 서버 규칙이라 빼고) +bool usesWindowsNames(String path) => Platform.isWindows && !isDav(path); + +/// 이름 바꾸기 (같은 폴더 안). 새 경로를 돌려준다. +Future renameInPlace(String path, String newName) async { + final name = newName.trim(); + final bad = nameProblem(name, windowsRules: usesWindowsNames(path)); + if (bad != null) throw FileSystemException(bad, name); + if (isDav(path)) { + final target = vJoin(vDirname(path), name); + if (target == vNorm(path)) return target; + if (await vExists(target)) throw FileSystemException(tr('같은 이름이 이미 있습니다'), target); + await vRename(path, target); + return target; + } + final target = p.join(p.dirname(path), name); + if (target == path) return path; + if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound && + (target.toLowerCase() != path.toLowerCase() || _otherCaseExists(path, name))) { + throw FileSystemException(tr('같은 이름이 이미 있습니다'), target); + } + final t = FileSystemEntity.typeSync(path); + return t == FileSystemEntityType.directory + ? (await Directory(path).rename(target)).path + : (await File(path).rename(target)).path; +} + +/// 데이터 검토 (10/18): 대소문자만 바꾸는 이름 (a.jpg → A.jpg) 인데 대소문자를 가리는 저장소 (Android 의 앱 폴더 · ext4 · 일부 NAS) 에 +/// 그 이름의 다른 파일이 따로 있으면 true - rename 이 그 파일을 말없이 덮지 않게 (가리지 않는 곳이면 목록에 정확히 그 이름이 없다) +bool _otherCaseExists(String path, String name) { + if (p.basename(path) == name) return false; + try { + return Directory(p.dirname(path)).listSync(followLinks: false).any((e) => p.basename(e.path) == name); + } catch (_) { + return true; // 모르면 막는다 (덮는 것보다 낫다) + } +} + +/// [dir] 안에 새 폴더 [name]. 만든 경로를 돌려준다. +Future createFolder(String dir, String name) async { + final n = name.trim(); + final bad = nameProblem(n, windowsRules: usesWindowsNames(dir)); + if (bad != null) throw FileSystemException(bad, n); + if (isDav(dir)) { + final target = vJoin(dir, n); + if (await vExists(target)) throw FileSystemException(tr('같은 이름이 이미 있습니다'), target); + await vMkdirs(target); + return target; + } + final target = p.join(dir, n); + if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound) { + throw FileSystemException(tr('같은 이름이 이미 있습니다'), target); + } + return (await Directory(target).create()).path; +} diff --git a/lib/core/files/path_rules.dart b/lib/core/files/path_rules.dart new file mode 100644 index 0000000..ad6077f --- /dev/null +++ b/lib/core/files/path_rules.dart @@ -0,0 +1,118 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../../l10n/tr.dart'; +import '../vfs.dart'; + +// 약속 (나누기 file_ops): 경로 규칙 - 같은 경로 · 안에 있음 · 바깥 것만 · 짧은 표시 · 겹치지 않는 이름 · 전송 전 확인 · 앱 임시 이름. +// 입력: 경로 글 (로컬 · WebDAV). 출력: 참/거짓 · 경로 · 알릴 글 (문제 없으면 null). +// 오류: 던지지 않는다. [uniqueTarget] · [transferProblem] 만 로컬 파일 시스템을 본다 (없으면 없는 대로). + +/// 48: 받는 폴더에 같은 이름이 있을 때 +enum NameConflict { + /// "이름 (2).확장자" 로 (예전 동작) + rename, + + /// 덮어쓰기 (폴더는 합치고 안의 같은 이름 파일을 덮어씀) + overwrite, + + /// 건너뛰기 (그 항목은 복사 · 이동하지 않음) + skip, +} + +/// 같은 이름이 있으면 "이름 (2).확장자" 로 +String uniqueTarget(String dir, String name) { + var target = p.join(dir, name); + if (FileSystemEntity.typeSync(target) == FileSystemEntityType.notFound) return target; + final base = p.basenameWithoutExtension(name), ext = p.extension(name); + for (var i = 2;; i++) { + target = p.join(dir, '$base ($i)$ext'); + if (FileSystemEntity.typeSync(target) == FileSystemEntityType.notFound) return target; + } +} + +/// 같은 경로인지 (Windows 는 대소문자 무시) +bool samePath(String a, String b) { + final dav = davSame(a, b); + if (dav != null) return dav; + final x = p.normalize(p.absolute(a)), y = p.normalize(p.absolute(b)); + return Platform.isWindows ? x.toLowerCase() == y.toLowerCase() : x == y; +} + +/// 79 · 87: 찾기 · 분석 결과 줄의 폴더 - 시작한 폴더 이름부터의 상대 경로 ("Videos\Show\S1"). +/// 단계가 [keep] + 1 보다 많으면 가운데를 줄인다 ("Videos\…\S1\E01"). 시작한 폴더 밖이면 전체 경로. +/// (15 캡처: 폭 412 에서 절대 경로의 앞부분만 보여 구별에 필요한 아래 폴더가 잘림) +String shortUnder(String root, String dir, {int keep = 2}) { + final parts = []; + var x = dir; + for (var i = 0; i < 256 && !samePath(x, root); i++) { + final up = vDirname(x); + if (up == x) return vDisplay(dir); + parts.insert(0, vBasename(x)); + x = up; + } + if (!samePath(x, root)) return vDisplay(dir); + // 드라이브 · 서버 맨 위는 이름이 없어 그대로 (C: · ☁ 서버) + final top = vDirname(root) == root; + parts.insert(0, top ? (isDav(root) ? vDisplay(root) : root.replaceAll(RegExp(r'[\\/]+$'), '')) : vBasename(root)); + final sep = isDav(dir) ? '/' : p.separator; + if (parts.length > keep + 1) return [parts.first, '…', ...parts.sublist(parts.length - keep)].join(sep); + return parts.join(sep); +} + +/// [a] 가 [b] 와 같거나 그 안에 있는지 (폴더를 자기 안으로 복사 · 이동하지 않게) +bool isSameOrInside(String a, String b) { + final dav = davInside(a, b); + if (dav != null) return dav; + final x = p.normalize(p.absolute(a)), y = p.normalize(p.absolute(b)); + return samePath(a, b) || p.isWithin(y, x) || (Platform.isWindows && p.isWithin(y.toLowerCase(), x.toLowerCase())); +} + +/// 79-c: 고른 것 중 다른 고른 폴더 안에 있는 것 · 같은 것을 빼고 바깥 것만 (차례는 그대로). +/// 폴더와 그 안의 파일을 함께 고르면 안의 파일을 두 번 처리하거나 (이동 뒤 "없음" 오류) 확인 창 개수가 부풀었다 +List outermostPaths(Iterable paths) { + final all = paths.toList(); + final out = []; + for (var i = 0; i < all.length; i++) { + final x = all[i]; + if (out.any((o) => samePath(o, x))) continue; + if (all.any((o) => !samePath(o, x) && isSameOrInside(x, o))) continue; + out.add(x); + } + return out; +} + +/// 복사 · 이동을 시작하기 전 확인 (모든 방법 공통 - rsync · robocopy 는 스스로 막지 않는다). +/// 문제가 있으면 알릴 글, 없으면 null. +/// - 원본이 없음 (다른 곳에서 지워졌거나 옮겨짐) · 대상 폴더가 없음 +/// - 폴더를 자기 자신 (또는 그 안) 으로 복사 · 이동 +/// - 이미 그 폴더에 있는 것을 같은 폴더로 이동 +/// [contents]: 폴더 "안의 것" 을 옮긴다 (Rsync 화면 · 원본/ → 대상/) - 바로 위 폴더로 옮기는 것도 된다 (205) +String? transferProblem(List sources, String dest, {required bool move, bool contents = false}) { + // WebDAV 경로는 네트워크라 여기서 있는지 확인하지 않는다 (없으면 복사할 때 알림) + final missing = [for (final s in sources) if (vMissingSync(s)) s]; + if (missing.isNotEmpty) { + return trf('원본이 없습니다 (다른 곳에서 지워졌거나 옮겨졌습니다): {0}', [missing.map(vBasename).join(', ')]); + } + if (!isDav(dest) && !Directory(dest).existsSync()) return trf('대상 폴더가 없습니다: {0}', [dest]); + for (final s in sources) { + if ((isDav(s) || FileSystemEntity.isDirectorySync(s)) && isSameOrInside(dest, s)) { + return trf('폴더를 자기 자신 안으로 {0} 수 없습니다: {1}', [move ? tr('옮길') : tr('복사할'), vBasename(s)]); + } + // 폴더째 옮길 때만: 이미 그 폴더 안에 있으면 옮길 것이 없다. + // 205: 안의 것을 바로 위 폴더로 올리는 이동 (Down\Movie/ → Down) 은 막지 않는다 (예전에는 조용히 시작하지 않았다) + if (move && !contents && samePath(vDirname(s), dest)) return trf('이미 이 폴더에 있습니다: {0}', [vBasename(s)]); + } + return null; +} + +/// 복사 · 동기화가 다 쓴 뒤 이름을 바꾸기 전의 임시 파일 (끊기면 남는다 - 71) +bool isPartialFile(String name) => name.endsWith('.jjpart') || name.endsWith('.jjsync'); + +/// 78 보충 (관리자 판단): 일괄 이름 바꾸기가 잠깐 쓰는 임시 이름 (~jjren_ · ~jjback_) - 실시간 동기화 · 맞추기 · rsync 에 섞이지 않게 +bool isAppTempName(String name) => name.startsWith('~jjren_') || name.startsWith('~jjback_'); + +/// rsync · robocopy 에 넣을 임시 이름 제외 (앱이 만드는 인수) +const appTempRsyncExcludes = ['--exclude=~jjren_*', '--exclude=~jjback_*']; +const appTempRobocopyExcludes = ['/XF', '~jjren_*', '~jjback_*']; diff --git a/lib/core/files/transfer_control.dart b/lib/core/files/transfer_control.dart new file mode 100644 index 0000000..b112461 --- /dev/null +++ b/lib/core/files/transfer_control.dart @@ -0,0 +1,93 @@ +import '../vfs.dart'; +import 'path_rules.dart'; +import 'transfer_guard.dart' show FileOpCancelled; + +// 약속 (나누기 file_ops): 전송 한 번의 설정과 상태 - 취소 · 속도 제한 · 알림 · 같은 이름 처리 · 건너뛴 것. +// 로컬 전송 (local_transfer) 과 어느 저장소든 전송 (any_transfer) 이 함께 쓴다. [FileOps] 가 이것을 잇는다. +// 입력: 생성자의 설정. 출력: [targetFor] 의 받는 경로 (null = 건너뜀) · 쌓인 목록 ([skipped] · [dirConflicts] · [keptNewer]). +// 오류: [check] 는 취소됐으면 [FileOpCancelled]. 저장소 오류는 그대로. + +/// 복사 · 이동 중 알림 (지금 파일, 지금까지 바이트, 전체 바이트) +typedef CopyProgress = void Function(String current, int done, int total); + +class TransferControl { + TransferControl({ + this.bandwidthKBps = 0, + this.onFileDone, + this.onBytes, + this.conflict = NameConflict.rename, + this.onSettled, + this.skipNewerInMerge = false, + }); + + bool _cancel = false; + + /// 다음 조각에서 멈춘다 + void cancel() => _cancel = true; + + /// 멈추라고 했는지 + bool get cancelled => _cancel; + + /// 속도 제한 (KB/s, 0 = 제한 없음) + final int bandwidthKBps; + + /// 파일 하나를 다 복사할 때마다 (원본 경로) + final void Function(String source)? onFileDone; + + /// 조각을 보낼 때마다 (바이트) - 전송 속도 계산용 + final void Function(int bytes)? onBytes; + + /// 48: 같은 이름이 있을 때 (맨 위 항목 기준 - 덮어쓰기면 폴더 안의 같은 이름 파일도 덮어씀) + final NameConflict conflict; + + /// 건너뛴 항목 (원본 경로) + final skipped = []; + + /// M1 (관리자 P0, 10/19): 원격 맞추기에서 원본의 파일과 같은 이름의 **폴더** 가 받는 쪽에 있어 건너뛴 것. + /// --delete 가 없으면 그 폴더를 지우지 않는다 (rsync 도 --delete · --force 없이는 비어 있지 않은 폴더를 지우지 않음) + final dirConflicts = []; + + /// 229: 폴더를 [덮어쓰기] (합치기) 할 때 안쪽에서 받는 쪽이 더 새 파일은 건너뛴다 (환경 설정 "받는 쪽이 더 새것이면 건너뛰기"). + /// 맨 위 파일 하나를 사용자가 [덮어쓰기] 로 고른 것은 따른다 (창에 "(있는 것이 더 새것)" 이 보이므로) + final bool skipNewerInMerge; + + /// 229: 위 규칙으로 건너뛴 원본 파일 (이동이어도 원본에 남는다) + final keptNewer = []; + + /// 214: mirror 가 원본 파일 하나를 대상에 맞췄을 때 (지금 복사했거나, 엄격히 같다고 확인함) - 그 원본의 크기 · 바뀐 때. + /// 이동은 이것으로 알려 온 파일만 원본에서 지운다 (건너뛴 것 · 도중에 생긴 것은 남긴다) + final void Function(String source, int size, DateTime modified)? onSettled; + + /// [conflict] 에 따라 [destDir] 안의 대상 경로 (null = 건너뜀). + /// 같은 자리 (같은 폴더로 복사) 이거나 파일 ↔ 폴더처럼 종류가 다르면 덮어쓰지 않고 새 이름으로. + Future targetFor(String src, String destDir) async { + final name = vBasename(src); + final plain = vJoin(destDir, name); + if (conflict == NameConflict.rename || !await vExists(plain)) return vUniqueTarget(destDir, name); + if (samePath(src, plain)) return vUniqueTarget(destDir, name); + if (conflict == NameConflict.skip) { + skipped.add(src); + return null; + } + if (await vIsDir(src) != await vIsDir(plain)) return vUniqueTarget(destDir, name); + return plain; + } + + final _clock = Stopwatch(); + int _sent = 0; + + /// 속도 제한: 보낸 양이 허용량보다 앞서면 그만큼 쉰다 + Future throttle(int bytes) async { + if (bandwidthKBps <= 0) return; + if (!_clock.isRunning) _clock.start(); + _sent += bytes; + final wantMs = _sent * 1000 ~/ (bandwidthKBps * 1024); + final ahead = wantMs - _clock.elapsedMilliseconds; + if (ahead > 5) await Future.delayed(Duration(milliseconds: ahead)); + } + + /// 취소됐으면 [FileOpCancelled] + void check() { + if (_cancel) throw const FileOpCancelled(); + } +} diff --git a/lib/core/files/transfer_guard.dart b/lib/core/files/transfer_guard.dart new file mode 100644 index 0000000..680c849 --- /dev/null +++ b/lib/core/files/transfer_guard.dart @@ -0,0 +1,129 @@ +import 'dart:io'; + +import '../../l10n/tr.dart'; +import '../vfs.dart'; +import '../webdav.dart' show DavException, DavLockedException; + +// 약속 (나누기 file_ops): 전송의 안전장치 - 취소 표시 · "원본을 읽을 수 없음" · 지우기 전 원본 확인 · 빈 폴더 정리. +// 입력: 원본 · 대상 폴더 경로 (로컬 · WebDAV). 출력: 없음 / 지운 폴더 수. +// 오류: [ensureSourceForDelete] 는 원본을 읽지 못하거나 원본이 비었는데 대상에 파일이 있으면 [SourceUnreadableException] +// (그때는 아무것도 지우지 않게). [removeEmptyDirs] 는 지우지 못한 폴더를 남기고 던지지 않는다. + +/// 사용자가 복사 · 이동 · 맞추기를 멈췄다 +class FileOpCancelled implements Exception { + const FileOpCancelled(); +} + +/// 동기화 · 미러: 원본을 읽지 못했거나 (권한 꺼짐 · SD 카드 빠짐 · 네트워크 끊김) 원본이 통째로 비었는데 대상에는 파일이 있다. +/// "원본에 없는 것을 대상에서 지우기" 가 원본을 빈 것으로 보고 대상을 모두 지우지 않도록, 아무것도 지우지 않고 멈춘다. +class SourceUnreadableException implements Exception { + final String path; + final Object? cause; + + /// 읽기는 됐지만 원본이 비어 있음 (대상에는 파일이 있음) + final bool empty; + const SourceUnreadableException(this.path, {this.cause, this.empty = false}); + + /// 128: 마스터 비밀번호를 기다리는 중 (읽지 못한 것이 아님) + bool get locked => cause is DavLockedException; + + /// 원인을 짧게 (예외 원문 대신 - 68) + String get detail { + final c = cause; + if (c == null) return ''; + if (c is FileSystemException) { + if (c.message == '없음' && c.osError == null) return tr('폴더가 없습니다'); + return c.osError?.message.trim().isNotEmpty == true ? c.osError!.message.trim() : c.message; + } + if (c is DavException) return c.message; + return '$c'.split('\n').first; + } + + @override + String toString() => empty + ? trf('원본 폴더가 비어 있어 멈췄습니다 (대상 파일은 지우지 않음). SD 카드 · 네트워크 · 권한을 확인하세요: {0}', [path]) + : trf('원본 폴더를 읽을 수 없어 멈췄습니다 (대상 파일은 지우지 않음): {0}{1}', [path, detail.isEmpty ? '' : ' · $detail']); +} + +/// "지우기 포함" 으로 맞추기 전에: 원본을 읽을 수 있는지, 원본이 통째로 비었는데 대상에 파일이 있지 않은지. +/// 문제가 있으면 [SourceUnreadableException] (아무것도 지우지 않게). +/// [allowEmpty]: 사용자가 지울 목록을 보고 "원본이 비어도 그래도 맞추기" 를 고른 경우 (70) - 읽지 못하는 것은 그래도 막는다 +Future ensureSourceForDelete(String src, String dst, {bool allowEmpty = false}) async { + List<({String path, bool isDir, int size, DateTime modified})> items; + try { + if (!await vExists(src)) throw const FileSystemException('없음'); + items = await vList(src, strict: true); + } catch (e) { + throw SourceUnreadableException(src, cause: e); + } + if (items.isNotEmpty || allowEmpty) return; + var dstItems = const <({String path, bool isDir, int size, DateTime modified})>[]; + try { + dstItems = await vList(dst); + } catch (_) {} + if (dstItems.isNotEmpty) throw SourceUnreadableException(src, empty: true); +} + +/// [root] 안의 빈 폴더를 안쪽부터 지운다 (find root/ -type d -empty -delete 와 같음). 지운 폴더 수. +/// [keepRoot] 면 [root] 자신은 비어도 남긴다. 파일 · 링크가 하나라도 있는 폴더는 그대로. +Future removeEmptyDirs(String root, {bool keepRoot = true}) async { + if (isDav(root)) return _removeEmptyDirsV(root, keepRoot: keepRoot); + var n = 0; + Future walk(Directory d) async { + var empty = true; + final items = await d.list(followLinks: false).handleError((_) => empty = false).toList(); + for (final e in items) { + if (e is Directory && await walk(e)) { + try { + await e.delete(); + n++; + } catch (_) { + empty = false; + } + } else { + empty = false; + } + } + return empty; + } + + final d = Directory(root); + if (!await d.exists()) return 0; + if (await walk(d) && !keepRoot) { + try { + await d.delete(); + n++; + } catch (_) {} + } + return n; +} + +/// [removeEmptyDirs] 의 WebDAV 판 +Future _removeEmptyDirsV(String root, {bool keepRoot = true}) async { + var n = 0; + Future walk(String dir) async { + var empty = true; + for (final e in await vList(dir)) { + if (e.isDir && await walk(e.path)) { + try { + await vDelete(e.path); + n++; + } catch (_) { + empty = false; + } + } else { + empty = false; + } + } + return empty; + } + + if (!await vExists(root)) return 0; + if (await walk(root) && !keepRoot) { + try { + await vDelete(root); + n++; + } catch (_) {} + } + return n; +} diff --git a/lib/core/files/windows_drives.dart b/lib/core/files/windows_drives.dart new file mode 100644 index 0000000..3d0d83a --- /dev/null +++ b/lib/core/files/windows_drives.dart @@ -0,0 +1,50 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:ffi/ffi.dart'; + +// 약속 (나누기 file_ops): Windows 저장 장치 - 드라이브 목록 · 드라이브 종류 · 네트워크 공유의 맨 위. +// 입력: 드라이브 맨 위 ("C:\") · 경로. 출력: 목록 · 종류 번호 · 글. +// 오류: 던지지 않는다. Windows API 를 부르지 못하면 [windowsDrives] 는 C: 하나, [driveType] 은 0 (모름). +// 드라이브를 열어 보지 않는다 (끊긴 네트워크 드라이브에서 멈추지 않게). + +/// 저장 장치 (맨 위 폴더): Windows 는 드라이브 (C:\ …), 그 밖은 주어진 목록. +/// 165: 드라이브 문자는 Windows 가 알려 주는 목록 (GetLogicalDrives) 으로 - 드라이브마다 열어 보지 않는다 +/// (끊긴 · 느린 네트워크 드라이브 (NAS) 를 열어 보느라 탐색기가 10초씩 멈췄다) +List<(String path, String label)> windowsDrives() { + int mask; + try { + mask = DynamicLibrary.open('kernel32.dll').lookupFunction('GetLogicalDrives')(); + } catch (_) { + mask = 0; + } + if (mask == 0) return [(r'C:\', 'C:')]; + return [ + for (final c in 'CDEFGHIJKLMNOPQRSTUVWXYZAB'.split('')) + if (mask & (1 << (c.codeUnitAt(0) - 65)) != 0) ('$c:\\', '$c:'), + ]; +} + +/// 165: 드라이브 종류 (GetDriveTypeW: 2 꺼내는 장치 (카드 리더 · USB) · 3 고정 · 4 네트워크 · 5 CD). 드라이브를 열어 보지 않는다. 모르면 0 +int driveType(String root) { + if (!Platform.isWindows) return 0; + final w = root.toNativeUtf16(); + try { + final f = DynamicLibrary.open('kernel32.dll') + .lookupFunction), int Function(Pointer)>('GetDriveTypeW'); + return f(w); + } catch (_) { + return 0; + } finally { + calloc.free(w); + } +} + +/// 네트워크 드라이브인지 +bool isNetworkDrive(String root) => driveType(root) == 4; + +/// Windows 네트워크 공유 경로의 맨 위 (\\서버\공유). 37 +String uncRoot(String path) { + final parts = path.replaceAll('/', r'\').split(r'\').where((s) => s.isNotEmpty).toList(); + return parts.length < 2 ? path : '\\\\${parts[0]}\\${parts[1]}'; +} diff --git a/test/file_ops_modules_contract_test.dart b/test/file_ops_modules_contract_test.dart new file mode 100644 index 0000000..ca9b76d --- /dev/null +++ b/test/file_ops_modules_contract_test.dart @@ -0,0 +1,138 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/files/any_transfer.dart'; +import 'package:jj_mkvmaker/core/files/file_listing.dart'; +import 'package:jj_mkvmaker/core/files/local_transfer.dart'; +import 'package:jj_mkvmaker/core/files/name_ops.dart'; +import 'package:jj_mkvmaker/core/files/path_rules.dart'; +import 'package:jj_mkvmaker/core/files/transfer_control.dart'; +import 'package:jj_mkvmaker/core/files/transfer_guard.dart'; +import 'package:jj_mkvmaker/core/files/windows_drives.dart'; +import 'package:path/path.dart' as p; + +/// 나누기 file_ops 약속 시험: lib/core/files/ 모듈마다 맨 위 약속 (입력 · 출력 · 오류) - 실제 시험 폴더 · 실제 파일. +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_files_')); + tearDown(() => tmp.deleteSync(recursive: true)); + + String mk(String rel, [String text = 'x']) { + final f = File(p.join(tmp.path, rel))..createSync(recursive: true); + f.writeAsStringSync(text); + return f.path; + } + + test('file_listing: 읽기 · 숨김 · 정렬 (폴더 먼저 · 숫자) · 찾기 · 읽지 못함은 던짐 · 크기 글', () async { + mk('d/file10.txt'); + mk('d/file2.txt'); + mk('d/.hidden'); + Directory(p.join(tmp.path, 'd', 'sub')).createSync(); + final d = p.join(tmp.path, 'd'); + final list = await listEntries(d); + expect(list.map((e) => e.name).toSet(), {'file10.txt', 'file2.txt', 'sub'}); + expect((await listEntries(d, showHidden: true)).length, 4); + expect(sortEntries(list, SortBy.name).map((e) => e.name), ['sub', 'file2.txt', 'file10.txt']); + expect(await searchFiles(tmp.path, 'FILE*').map((e) => e.name).toList(), ['file2.txt', 'file10.txt']); + await expectLater(listEntries(p.join(tmp.path, 'none')), throwsA(isA())); + expect([formatSize(-1), formatSize(12), formatSize(2048), formatSize(3 * 1024 * 1024 * 1024)], ['—', '12B', '2KB', '3.0GB']); + }); + + test('windows_drives: 목록은 비지 않음 · 종류는 열지 않고 숫자 · 공유 맨 위', () { + final drives = windowsDrives(); + expect(drives, isNotEmpty); + if (Platform.isWindows) expect(driveType(drives.first.$1), greaterThan(0)); + expect(uncRoot(r'\\nas\share\a\b'), r'\\nas\share'); + expect(uncRoot('x'), 'x'); + }); + + test('path_rules: 겹치지 않는 이름 · 같은 경로 · 안에 있음 · 바깥 것만 · 전송 전 확인', () { + mk('a.txt'); + expect(p.basename(uniqueTarget(tmp.path, 'a.txt')), 'a (2).txt'); + final a = p.join(tmp.path, 'A'), inner = p.join(a, 'b'); + Directory(inner).createSync(recursive: true); + expect(isSameOrInside(inner, a), isTrue); + expect(isSameOrInside(a, inner), isFalse); + expect(outermostPaths([inner, a, a]), [a]); + if (Platform.isWindows) expect(samePath(a, a.toLowerCase()), isTrue); + expect(transferProblem([a], inner, move: false), isNotNull, reason: '자기 안으로'); + expect(transferProblem([p.join(tmp.path, 'none')], tmp.path, move: false), isNotNull, reason: '원본 없음'); + expect(transferProblem([p.join(tmp.path, 'a.txt')], a, move: true), isNull); + expect(isAppTempName('~jjren_1') && isPartialFile('x.jjpart'), isTrue); + }); + + test('name_ops: 쓸 수 없는 이름 · 이름 바꾸기 · 새 폴더 · 같은 이름은 던짐', () async { + expect(nameProblem('a/b', windowsRules: false), isNotNull); + expect(nameProblem('CON.txt', windowsRules: true), isNotNull); + expect(nameProblem('ok.txt', windowsRules: true), isNull); + final f = mk('x.txt'); + mk('y.txt'); + expect(p.basename(await renameInPlace(f, ' z.txt ')), 'z.txt'); + await expectLater(renameInPlace(p.join(tmp.path, 'z.txt'), 'y.txt'), throwsA(isA())); + final dir = await createFolder(tmp.path, '새 폴더'); + expect(Directory(dir).existsSync(), isTrue); + await expectLater(createFolder(tmp.path, '새 폴더'), throwsA(isA())); + }); + + test('transfer_guard: 원본 없음 · 빈 원본은 던지고 지우지 않음 · 빈 폴더 정리', () async { + final src = p.join(tmp.path, 'src'), dst = p.join(tmp.path, 'dst'); + mk('dst/keep.txt'); + await expectLater(ensureSourceForDelete(src, dst), throwsA(isA())); + Directory(src).createSync(); + await expectLater(ensureSourceForDelete(src, dst), throwsA(isA().having((e) => e.empty, 'empty', isTrue))); + await ensureSourceForDelete(src, dst, allowEmpty: true); + Directory(p.join(src, 'e1', 'e2')).createSync(recursive: true); + expect(await removeEmptyDirs(src), 2); + expect(Directory(src).existsSync(), isTrue); + }); + + test('transfer_control: 같은 이름 건너뛰기는 null 과 기록 · 취소 뒤 check 는 던짐', () async { + final f = mk('s/a.txt'); + mk('d/a.txt'); + final c = TransferControl(conflict: NameConflict.skip); + expect(await c.targetFor(f, p.join(tmp.path, 'd')), isNull); + expect(c.skipped, [f]); + c.check(); + c.cancel(); + expect(c.cancelled, isTrue); + expect(c.check, throwsA(isA())); + }); + + test('local_transfer: 복사 (진행 · 바뀐 때) · 이동 · 맞추기 (지우기) · 취소는 반쯤 쓴 파일을 남기지 않음', () async { + final a = mk('s/dir/a.txt', 'hello'); + final dst = p.join(tmp.path, 'd'); + Directory(dst).createSync(); + var last = 0; + final made = await localCopy(TransferControl(), [p.join(tmp.path, 's', 'dir')], dst, onProgress: (_, done, _) => last = done); + expect(File(p.join(made.single, 'a.txt')).readAsStringSync(), 'hello'); + expect(last, 5); + expect(await localTotalSize([p.join(tmp.path, 's')]), 5); + final moved = await localMove(TransferControl(), [a], dst); + expect(File(a).existsSync(), isFalse); + expect(File(moved.single).readAsStringSync(), 'hello'); + final m = p.join(tmp.path, 'm'); + mk('m/old.txt'); + expect(await localMirror(TransferControl(), p.join(tmp.path, 'd'), m, delete: true), 2); + expect(File(p.join(m, 'old.txt')).existsSync(), isFalse); + final c = TransferControl()..cancel(); + await expectLater(localCopy(c, [moved.single], p.join(tmp.path, 's')), throwsA(isA())); + expect(Directory(p.join(tmp.path, 's')).listSync(recursive: true).where((e) => e.path.endsWith('.jjpart')), isEmpty); + }); + + test('any_transfer: 로컬 경로로도 복사 · 이동 · 맞추기 · 크기, 원본 없음은 던짐', () async { + mk('s/x/b.txt', 'abc'); + final dst = p.join(tmp.path, 'd'); + Directory(dst).createSync(); + expect(await anyTotalSize([p.join(tmp.path, 's')]), 3); + final made = await anyCopy(TransferControl(), [p.join(tmp.path, 's', 'x')], dst); + expect(File(p.join(made.single, 'b.txt')).readAsStringSync(), 'abc'); + final m = p.join(tmp.path, 'm'); + expect(await anyMirror(TransferControl(), p.join(tmp.path, 's'), m), 1); + expect(await anyMirror(TransferControl(), p.join(tmp.path, 's'), m), 0, reason: '같으면 다시 보내지 않음'); + final m2 = Directory(p.join(tmp.path, 'm2'))..createSync(); + final moved = await anyMove(TransferControl(), [p.join(tmp.path, 's', 'x')], m2.path); + expect(Directory(p.join(tmp.path, 's', 'x')).existsSync(), isFalse); + expect(File(p.join(moved.single, 'b.txt')).readAsStringSync(), 'abc'); + await expectLater(anyCopy(TransferControl(), [p.join(tmp.path, 'none')], dst), throwsA(isA())); + }); +} From 8c3b9b7c55a3d320de2569aba76469c9f236ed77 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:48:24 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=EB=82=98=EB=88=84=EA=B8=B0=20core=20B:=20f?= =?UTF-8?q?tp=20(691)=20=EB=A5=BC=20lib/core/ftp/=20=EB=AA=A8=EB=93=88=203?= =?UTF-8?q?=EA=B0=9C=EB=A1=9C=20(=E2=86=92=20=EC=A0=95=EB=A9=B4=20ftp)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ftp_server (설정 · 실패 종류 · 오류 · 인증서 지문 · 이름 풀기 꼴) - ftp_session (명령 채널 하나: 연결 · TLS · 로그인 · 명령 · 자료 채널 - 예전 _Session 을 FtpSession 으로) - ftp_remote (FtpRemote 원격 저장소 · FtpRegistry) - ftp.dart 는 export 만 (debugFtpLog 포함, 부르는 쪽은 바꾸지 않음) - 약속 시험: test/ftp_modules_contract_test.dart (실제 pyftpdlib 서버 · 시험 폴더) Co-Authored-By: Claude Opus 5.5 --- lib/core/ftp.dart | 696 +--------------------------- lib/core/ftp/ftp_remote.dart | 352 ++++++++++++++ lib/core/ftp/ftp_server.dart | 109 +++++ lib/core/ftp/ftp_session.dart | 255 ++++++++++ test/ftp_modules_contract_test.dart | 98 ++++ 5 files changed, 819 insertions(+), 691 deletions(-) create mode 100644 lib/core/ftp/ftp_remote.dart create mode 100644 lib/core/ftp/ftp_server.dart create mode 100644 lib/core/ftp/ftp_session.dart create mode 100644 test/ftp_modules_contract_test.dart diff --git a/lib/core/ftp.dart b/lib/core/ftp.dart index 10f4c4c..9a4e983 100644 --- a/lib/core/ftp.dart +++ b/lib/core/ftp.dart @@ -1,691 +1,5 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:crypto/crypto.dart' show sha256; - -import '../l10n/tr.dart'; -import 'dns_resolver.dart'; -import 'remote_fs.dart'; -import 'secret_gate.dart'; -import 'webdav.dart' show DavException, DavItem, DavLockedException; -import 'remote_idle.dart'; - -/// FTP · FTPS 서버 설정 (관리자 지시 10/17, l). 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음). -/// [security]: 'explicit' (FTPS - AUTH TLS, 처음 값) · 'implicit' (FTPS 990) · 'none' (평문 FTP - 연결 · 설정에서 "암호화 안 됨" 경고). -/// 자체 서명 인증서는 처음 연결 때 사용자가 믿은 지문 ([certFingerprint], SHA-256) 만 받는다 (TOFU - SFTP 호스트 키와 같은 판단). -class FtpServer { - const FtpServer({ - required this.id, - required this.name, - required this.host, - this.port = 21, - this.user = '', - this.root = '', - this.password = '', - this.security = 'explicit', - this.certFingerprint = '', - }); - - final String id; - final String name; - final String host; - final int port; - final String user; - - /// 서버의 시작 폴더 (비우면 로그인한 곳) - final String root; - - /// 안전 저장소에서 채움 (설정 파일에는 없음) - final String password; - final String security; - - /// 믿은 자체 서명 인증서의 지문 "SHA256:xx:xx…" (비밀 아님) - final String certFingerprint; - - bool get tls => security != 'none'; - String get label => name.trim().isNotEmpty ? name.trim() : (host.trim().isEmpty ? 'FTP' : host.trim()); - - FtpServer copyWith({String? name, String? host, int? port, String? user, String? root, String? password, String? security, String? certFingerprint}) => - FtpServer( - id: id, - name: name ?? this.name, - host: host ?? this.host, - port: port ?? this.port, - user: user ?? this.user, - root: root ?? this.root, - password: password ?? this.password, - security: security ?? this.security, - certFingerprint: certFingerprint ?? this.certFingerprint, - ); - - /// 비밀 값은 넣지 않는다 (안전 저장소). 저장해 두었는지만 - Map toJson() => { - 'id': id, - 'name': name, - 'host': host, - 'port': port, - 'user': user, - 'root': root, - 'hasPassword': password.isNotEmpty, - 'security': security, - 'certFingerprint': certFingerprint, - }; - - factory FtpServer.fromJson(Map j) => FtpServer( - id: '${j['id'] ?? DateTime.now().microsecondsSinceEpoch}', - name: j['name'] as String? ?? '', - host: j['host'] as String? ?? '', - port: switch (j['port']) { final int p when p > 0 && p < 65536 => p, _ => j['security'] == 'implicit' ? 990 : 21 }, - user: j['user'] as String? ?? '', - root: j['root'] as String? ?? '', - // 모르는 값 · 없는 값은 FTPS (지침 3 - 덜 위험한 쪽, 관리자 지시 "처음 값은 FTPS 우선") - security: const ['explicit', 'implicit', 'none'].contains(j['security']) ? j['security'] as String : 'explicit', - certFingerprint: j['certFingerprint'] as String? ?? '', - ); -} - -/// FTP 실패의 종류 (안내 글 · 할 일 단추를 고르는 데) -enum FtpFailure { unreachable, auth, noSecret, certUnknown, certChanged, noTls, permission, notFound, other } - -class FtpException extends DavException { - final FtpFailure kind; - const FtpException(super.message, this.kind); -} - -/// 처음 보는 (또는 바뀐) 자체 서명 인증서: 화면이 지문을 보이고 믿을지 묻는다. 믿으면 [FtpServer.certFingerprint] 에 저장 -class FtpCertException extends FtpException { - final String fingerprint; - final String subject; - - /// 바뀐 경우 예전에 믿은 지문 - final String? previous; - const FtpCertException(super.message, super.kind, {required this.fingerprint, required this.subject, this.previous}); - bool get changed => previous != null; -} - -/// 인증서 지문 "SHA256:AB:CD:…" -String certSha256(X509Certificate c) => - 'SHA256:${sha256.convert(c.der).bytes.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(':')}'; - -/// 이름 → 연결할 주소 (DNS 강제면 정한 DNS - main 에서 바꾼다). 처음 값: 기기 DNS (주소를 그대로 넘김) -typedef FtpResolve = Future Function(String host); - -/// FTP 명령 채널 하나 (로그인까지). 응답은 줄 단위 ("123-…" 여러 줄 → "123 …" 끝) -class _Session { - final FtpServer server; - final Duration timeout; - final InternetAddress addr; - RawSocket _raw; - StreamSubscription? _sub; - final _buf = []; - final _lines = []; - Completer? _wake; - bool _closed = false; - bool secure = false; - bool mlsd = false; - bool _protP = false; - - /// 로그인 뒤 서버의 지금 폴더 (시작 폴더가 비었을 때 맨 위) - String home = '/'; - - _Session._(this.server, this.timeout, this.addr, this._raw); - - static Future<_Session> open(FtpServer s, Duration timeout, FtpResolve resolve) async { - final addr = await resolve(s.host.trim()) ?? (await InternetAddress.lookup(s.host.trim())).first; - final raw = await RawSocket.connect(addr, s.port, timeout: timeout); - final x = _Session._(s, timeout, addr, raw); - if (s.security == 'implicit') { - x._raw = await x._secure(raw, null); - x.secure = true; - } - x._listen(); - return x; - } - - void _listen() { - _sub = _raw.listen((ev) { - if (ev == RawSocketEvent.read) { - final d = _raw.read(); - if (d != null) _feed(d); - } else if (ev == RawSocketEvent.readClosed || ev == RawSocketEvent.closed) { - _closed = true; - _poke(); - } - }, onError: (_) { - _closed = true; - _poke(); - }); - } - - void _feed(Uint8List d) { - _buf.addAll(d); - while (true) { - final i = _buf.indexOf(10); - if (i < 0) break; - var line = utf8.decode(_buf.sublist(0, i), allowMalformed: true); - if (line.endsWith('\r')) line = line.substring(0, line.length - 1); - _buf.removeRange(0, i + 1); - debugLog?.call('< $line'); - _lines.add(line); - } - _poke(); - } - - void _poke() { - final w = _wake; - _wake = null; - w?.complete(); - } - - Future _line() async { - final end = DateTime.now().add(timeout); - while (_lines.isEmpty) { - if (_closed) throw FtpException(trf('서버 ({0}) 와의 연결이 끊겼습니다', [server.host]), FtpFailure.unreachable); - final left = end.difference(DateTime.now()); - if (left <= Duration.zero) throw TimeoutException(tr('답이 없습니다')); - _wake = Completer(); - await _wake!.future.timeout(left, onTimeout: () {}); - } - return _lines.removeAt(0); - } - - /// 응답 하나 (여러 줄이면 합침): (코드, 글) - Future<(int, String)> reply() async { - final first = await _line(); - final code = int.tryParse(first.length >= 3 ? first.substring(0, 3) : '') ?? 0; - final text = StringBuffer(first.length > 4 ? first.substring(4) : ''); - if (first.length > 3 && first[3] == '-') { - while (true) { - final l = await _line(); - if (l.startsWith('$code ')) { - text.write('\n${l.substring(4)}'); - break; - } - text.write('\n${l.startsWith(' ') ? l.substring(1) : l}'); - } - } - return (code, text.toString()); - } - - /// 시험: 명령 채널 주고받기 (비밀번호 줄은 가림) - static void Function(String line)? debugLog; - - void send(String cmd) { - // 보안 검토 8: 경로 · 이름에 줄바꿈이 있으면 명령이 하나 더 끼어든다 - 보내지 않는다 - if (cmd.contains('\r') || cmd.contains('\n') || cmd.contains('\u0000')) { - throw FtpException(tr('이름에 줄바꿈이 들어 있어 FTP 로 보낼 수 없습니다'), FtpFailure.other); - } - debugLog?.call('> ${cmd.startsWith('PASS ') ? 'PASS ***' : cmd}'); - _raw.write(utf8.encode('$cmd\r\n')); - } - - Future<(int, String)> cmd(String c) async { - send(c); - return reply(); - } - - /// [c] 를 보내고 [ok] 코드 (2xx 등) 가 아니면 [FtpException] - Future must(String c, {bool Function(int code)? ok, String? path}) async { - final (code, text) = await cmd(c); - if (ok != null ? ok(code) : code >= 200 && code < 300) return text; - throw failure(code, text, path); - } - - FtpException failure(int code, String text, String? path) { - if (code == 530) return FtpException(tr('아이디나 비밀번호가 맞지 않습니다'), FtpFailure.auth); - if (code == 550 || code == 450) { - final t = text.toLowerCase(); - if (t.contains('permission') || t.contains('denied') || t.contains('access')) { - return FtpException(trf('이 폴더 · 파일을 다룰 권한이 없습니다: {0}', [path ?? '']), FtpFailure.permission); - } - return FtpException(trf('없는 경로입니다: {0}', [path ?? '']), FtpFailure.notFound); - } - return FtpException('$code $text${path == null ? '' : ' ($path)'}', FtpFailure.other); - } - - /// TLS 로 바꾼다 (명시: AUTH TLS 뒤 · 암시: 연결하자마자). 자체 서명이면 믿은 지문만 - Future _secure(RawSocket raw, StreamSubscription? sub) async { - X509Certificate? bad; - try { - return await RawSecureSocket.secure(raw, subscription: sub, host: server.host.trim(), onBadCertificate: (c) { - bad = c; - return server.certFingerprint.isNotEmpty && certSha256(c) == server.certFingerprint; - }).timeout(timeout); - } on HandshakeException { - final c = bad; - if (c == null) rethrow; - final fp = certSha256(c); - throw FtpCertException( - server.certFingerprint.isEmpty - ? tr('처음 보는 서버 인증서입니다 (자체 서명). 지문을 확인한 뒤 믿을지 정하세요') - : tr('서버 인증서가 바뀌었습니다. 다른 서버로 연결되었을 수 있습니다'), - server.certFingerprint.isEmpty ? FtpFailure.certUnknown : FtpFailure.certChanged, - fingerprint: fp, - subject: c.subject, - previous: server.certFingerprint.isEmpty ? null : server.certFingerprint); - } - } - - /// 인사 · TLS · 로그인 · UTF-8 · 이진 · 시작 폴더 - Future login() async { - final (hello, text) = await reply(); - if (hello != 220) throw failure(hello, text, null); - if (server.security == 'explicit') { - final (code, t) = await cmd('AUTH TLS'); - if (code != 234) { - // FTPS 를 못 하는 서버: 몰래 평문으로 내려가지 않는다 (사용자가 "평문 FTP" 를 골라야) - throw FtpException(trf('서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 "평문 FTP" 를 고르세요', [code, t]), - FtpFailure.noTls); - } - // 보안 검토 2: 234 뒤에 평문으로 끼워 넣은 줄이 있으면 (중간자가 TLS 뒤의 답을 꾸미려는 것) 끊는다 - if (_lines.isNotEmpty || _buf.isNotEmpty) { - throw FtpException(tr('서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다'), FtpFailure.noTls); - } - final sub = _sub; - _sub = null; - _raw = await _secure(_raw, sub); - secure = true; - // RawSecureSocket.secure 가 넘겨받은 구독을 이어 쓰지 않으므로 다시 듣는다 - _listen(); - } - if (secure) { - await must('PBSZ 0'); - await must('PROT P'); - _protP = true; - } - if (server.user.isNotEmpty || server.password.isNotEmpty) { - final (code, t) = await cmd('USER ${server.user.isEmpty ? 'anonymous' : server.user}'); - if (code == 331 || code == 332) { - await must('PASS ${server.password}', ok: (c) => c == 230 || c == 202); - } else if (code != 230) { - throw failure(code, t, null); - } - } else { - await must('USER anonymous', ok: (c) => c == 230 || c == 331); - } - final feat = await cmd('FEAT'); - // MLST 를 알리면 MLSD 도 한다 (RFC 3659 - pyftpdlib 등은 FEAT 에 MLST 만 적는다) - mlsd = feat.$1 == 211 && RegExp(r'^\s*MLS[TD]', multiLine: true).hasMatch(feat.$2.toUpperCase()); - if (feat.$2.toUpperCase().contains('UTF8')) await cmd('OPTS UTF8 ON'); - await must('TYPE I'); - final pwd = await cmd('PWD'); - final m = RegExp(r'"((?:[^"]|"")*)"').firstMatch(pwd.$2); - if (pwd.$1 == 257 && m != null) home = m[1]!.replaceAll('""', '"'); - } - - /// 자료 채널 (EPSV, 안 되면 PASV). 주소는 명령 채널의 서버 주소로 (서버가 알려 준 다른 주소로는 가지 않는다 - FTP 바운스 막기) - Future data() async { - int? port; - final (c1, t1) = await cmd('EPSV'); - if (c1 == 229) { - final m = RegExp(r'\(\|\|\|(\d+)\|\)').firstMatch(t1); - if (m != null) port = int.parse(m[1]!); - } - if (port == null) { - final t = await must('PASV', ok: (c) => c == 227); - final m = RegExp(r'(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)').firstMatch(t); - if (m == null) throw FtpException(trf('서버의 자료 연결 응답을 읽지 못했습니다: {0}', [t]), FtpFailure.other); - port = int.parse(m[5]!) * 256 + int.parse(m[6]!); - } - return Socket.connect(addr, port, timeout: timeout); - } - - /// 자료 연결을 TLS 로 (PROT P). 명령 (RETR · STOR · MLSD) 을 보내고 150/125 를 받은 뒤에 한다 - 서버는 명령을 받고서야 TLS 를 시작한다 (pyftpdlib 등) - Future secureData(Socket sock) async { - if (!_protP) return sock; - final x = await SecureSocket.secure(sock, - host: server.host.trim(), - onBadCertificate: (c) => server.certFingerprint.isNotEmpty && certSha256(c) == server.certFingerprint).timeout(timeout); - return x; - } - - void close() { - if (_closed) return; - _closed = true; - try { - send('QUIT'); - } catch (_) {} - _sub?.cancel(); - _raw.close(); - } -} - -/// FTP · FTPS 클라이언트 (dart:io 로 직접 - 관리자 지시 l, 패키지 없이). 목록 · 정보 · 만들기 · 지우기 · 옮기기는 서버마다 명령 채널 하나를 -/// 열어 두고 차례로 쓰고, 받기 · 올리기는 따로 연결해 (동영상 건너뛰기처럼 여럿이 동시에 읽어도 서로 막지 않게) 끝나면 닫는다. -class FtpRemote implements RemoteClient { - final FtpServer server; - final Duration timeout; - FtpRemote(this.server, {this.timeout = const Duration(seconds: 15)}); - - Future<_Session>? _main; - Future _lock = Future.value(); - - Future<_Session> _connect() async { - final s = server; - if (s.host.trim().isEmpty) throw FtpException(tr('FTP 서버 주소가 없습니다'), FtpFailure.other); - if (s.password.isNotEmpty && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage)); - if (s.user.isNotEmpty && s.password.isEmpty) { - throw FtpException(tr('저장된 비밀번호가 없습니다. 서버 설정에서 비밀번호를 넣어 주세요'), FtpFailure.noSecret); - } - _Session? x; - try { - x = await _Session.open(s, timeout, FtpRegistry.resolve); - await x.login(); - return x; - } catch (e) { - x?.close(); - throw _explain(e); - } - } - - Object _explain(Object e) { - if (e is FtpException || e is DavLockedException || e is RemoteIdleTimeout) return e; - if (e is TimeoutException) return FtpException(trf('서버 ({0}) 가 답하지 않습니다', [server.host]), FtpFailure.unreachable); - if (e is SocketException) return FtpException(trf('서버 ({0}) 에 연결할 수 없습니다: {1}', [server.host, e.message]), FtpFailure.unreachable); - if (e is HandshakeException) return FtpException(trf('암호화 (TLS) 연결을 맺지 못했습니다: {0}', [e.message]), FtpFailure.other); - return FtpException('$e', FtpFailure.other); - } - - /// 명령 채널을 차례로 쓴다 (한 번에 하나). 끊겼으면 한 번 다시 연결 - Future _run(Future Function(_Session s) f) { - final prev = _lock; - final done = Completer(); - _lock = done.future; - return prev.then((_) async { - try { - for (var attempt = 0;; attempt++) { - final _Session s; - try { - s = await (_main ??= _connect()); - } catch (_) { - _main = null; - rethrow; - } - try { - return await f(s); - } on FtpException catch (e) { - if (e.kind == FtpFailure.unreachable && attempt == 0) { - s.close(); - _main = null; - continue; - } - rethrow; - } - } - } catch (e) { - if (e is! FtpException && e is! DavLockedException) { - _main = null; - throw _explain(e); - } - rethrow; - } finally { - done.complete(); - } - }); - } - - /// 서버 기준 경로 ("/" 로 시작) → 서버의 절대 경로 - String _abs(_Session s, String rel) { - var base = server.root.trim().isEmpty ? s.home : server.root.trim(); - if (!base.startsWith('/')) base = '/$base'; - if (base.length > 1 && base.endsWith('/')) base = base.substring(0, base.length - 1); - final r = rel == '/' || rel.isEmpty ? '' : rel; - if (base == '/') return r.isEmpty ? '/' : r; - return '$base$r'; - } - - static String _child(String rel, String name) => rel == '/' ? '/$name' : '$rel/$name'; - static String _parent(String rel) => rel.lastIndexOf('/') <= 0 ? '/' : rel.substring(0, rel.lastIndexOf('/')); - - /// MLSD · MLST 의 한 줄 "type=dir;size=1;modify=20261017120000; 이름" - static (String, bool, int, DateTime)? parseMlsx(String line) { - final sp = line.indexOf(' '); - if (sp < 0) return null; - final facts = {}; - for (final f in line.substring(0, sp).split(';')) { - final eq = f.indexOf('='); - if (eq > 0) facts[f.substring(0, eq).toLowerCase()] = f.substring(eq + 1); - } - final name = line.substring(sp + 1); - final type = (facts['type'] ?? '').toLowerCase(); - if (type == 'cdir' || type == 'pdir' || name == '.' || name == '..') return null; - final m = facts['modify'] ?? ''; - var when = DateTime.fromMillisecondsSinceEpoch(0); - if (m.length >= 14) { - when = DateTime.utc(int.parse(m.substring(0, 4)), int.parse(m.substring(4, 6)), int.parse(m.substring(6, 8)), - int.parse(m.substring(8, 10)), int.parse(m.substring(10, 12)), int.parse(m.substring(12, 14))); - } - final isDir = type == 'dir'; - return (name, isDir, isDir ? 0 : int.tryParse(facts['size'] ?? '') ?? 0, when); - } - - static const _months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; - - /// LIST 의 유닉스 꼴 한 줄 "drwxr-xr-x 1 u g 0 Oct 17 12:00 이름" (MLSD 가 없는 서버) - static (String, bool, int, DateTime)? parseList(String line) { - final m = RegExp(r'^([dl-])[rwxsStT-]{9}\S*\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\w{3})\s+(\d{1,2})\s+(\d{1,2}:\d{2}|\d{4})\s(.+)$').firstMatch(line); - if (m == null) return null; - var name = m[6]!; - if (m[1] == 'l' && name.contains(' -> ')) name = name.substring(0, name.indexOf(' -> ')); - if (name == '.' || name == '..') return null; - final mon = _months.indexOf(m[3]!.toLowerCase()) + 1; - final now = DateTime.now(); - final t = m[5]!; - final when = t.contains(':') - ? DateTime(now.year, mon, int.parse(m[4]!), int.parse(t.split(':')[0]), int.parse(t.split(':')[1])) - : DateTime(int.parse(t), mon, int.parse(m[4]!)); - final isDir = m[1] == 'd'; - return (name, isDir, isDir ? 0 : int.parse(m[2]!), when); - } - - /// 자료 채널로 목록 (MLSD 또는 LIST) 을 읽는다 - Future> _listRaw(_Session s, String abs, String rel) async { - final plain = await s.data(); - // LIST 에 -a 같은 옵션을 붙이면 경로로 읽는 서버가 있다 - s.send('${s.mlsd ? 'MLSD' : 'LIST'} $abs'); - final (code, text) = await s.reply(); - if (code != 150 && code != 125) { - plain.destroy(); - throw s.failure(code, text, rel); - } - final sock = await s.secureData(plain); - final bytes = await sock.fold>([], (a, b) => a..addAll(b)); - final (end, et) = await s.reply(); - if (end != 226 && end != 250) throw s.failure(end, et, rel); - final out = <(String, bool, int, DateTime)>[]; - for (final l in const LineSplitter().convert(utf8.decode(bytes, allowMalformed: true))) { - final x = s.mlsd ? parseMlsx(l) : parseList(l); - if (x != null) out.add(x); - } - return out; - } - - @override - Future> list(String rel) => _run((s) async => [ - for (final (n, dir, size, at) in await _listRaw(s, _abs(s, rel), rel)) DavItem(_child(rel, n), isDir: dir, size: size, modified: at), - ]); - - @override - Future stat(String rel) async { - if (rel == '/' || rel.isEmpty) return DavItem('/', isDir: true, modified: DateTime.fromMillisecondsSinceEpoch(0)); - // 위 폴더의 목록에서 찾는다 (MLST · SIZE 를 모든 서버가 같게 답하지 않아서 - 느리지만 확실하게) - try { - for (final x in await list(_parent(rel))) { - if (x.rel == rel) return x; - } - } on FtpException catch (e) { - if (e.kind == FtpFailure.notFound) return null; - rethrow; - } - return null; - } - - @override - Future mkdirs(String rel) async { - if (rel == '/' || rel.isEmpty) return; - if (await stat(rel) != null) return; - await mkdirs(_parent(rel)); - await _run((s) => s.must('MKD ${_abs(s, rel)}', path: rel)); - } - - @override - Future delete(String rel) async { - final st = await stat(rel); - if (st == null) return; - if (st.isDir) { - for (final x in await list(rel)) { - await delete(x.rel); - } - await _run((s) => s.must('RMD ${_abs(s, rel)}', path: rel)); - } else { - await _run((s) => s.must('DELE ${_abs(s, rel)}', path: rel)); - } - } - - @override - Future move(String from, String to, {bool overwrite = false}) async { - if (await stat(to) != null) { - if (!overwrite) throw FtpException(trf('같은 이름이 이미 있습니다: {0}', [to]), FtpFailure.other); - await delete(to); - } - await _run((s) async { - await s.must('RNFR ${_abs(s, from)}', ok: (c) => c == 350, path: from); - await s.must('RNTO ${_abs(s, to)}', path: to); - }); - } - - @override - Future>> openRead(String rel) => openReadFrom(rel); - - /// [offset] 부터 [length] 바이트 (null 이면 끝까지) - 따로 연결해 REST 로 (동영상 건너뛰기 · 이어 받기) - Future>> openReadFrom(String rel, {int offset = 0, int? length}) async { - final s = await _connect(); - Socket? sock; - try { - sock = await s.data(); - if (offset > 0) await s.must('REST $offset', ok: (c) => c == 350, path: rel); - s.send('RETR ${_abs(s, rel)}'); - final (code, text) = await s.reply(); - if (code != 150 && code != 125) throw s.failure(code, text, rel); - sock = await s.secureData(sock); - } catch (e) { - sock?.destroy(); - s.close(); - throw _explain(e); - } - final data = sock; - Stream> read() async* { - var left = length; - try { - // 관리자 판단 (10/20): 조각 사이에 정한 시간 동안 아무것도 오지 않으면 실패 (서버가 멈춰도 영원히 기다리지 않게) - await for (var chunk in idleRead(data, onTimeout: data.destroy)) { - if (left != null) { - if (chunk.length >= left) { - yield chunk.sublist(0, left); - left = 0; - break; - } - left -= chunk.length; - } - yield chunk; - } - if (left == null) { - final (end, et) = await s.reply(); - if (end != 226 && end != 250) throw s.failure(end, et, rel); - } - } finally { - data.destroy(); - s.close(); // 다 읽기 전에 멈추면 이 연결째 닫는다 (ABOR 대신 - 서버마다 답이 달라서) - } - } - - return read(); - } - - @override - Future write(String rel, Stream> data, {int? length}) async { - final s = await _connect(); - try { - final plain = await s.data(); - s.send('STOR ${_abs(s, rel)}'); - final (code, text) = await s.reply(); - if (code != 150 && code != 125) { - plain.destroy(); - throw s.failure(code, text, rel); - } - final sock = await s.secureData(plain); - // 관리자 판단 (10/20): 서버가 받지 않아 정한 시간 동안 한 조각도 더 보내지 못하면 실패 (연결을 끊음) - await idleWrite(data, (x) async { - await sock.addStream(x); - await sock.flush(); - }, onTimeout: sock.destroy); - // 닫기 (TLS 는 close_notify) 를 보내고, 서버가 답하지 않아도 오래 기다리지 않게 연결째 끊는다 - // 다 보낸 뒤 닫는다 (TLS 는 close_notify). close() 가 끝나도 암호화된 마지막 자료가 아직 나가지 않았을 수 있어 - // 연결이 정말 끝날 때 (done) 까지 기다리고, 서버가 답하지 않을 때만 끊는다 (곧바로 destroy 하면 빈 파일이 되었다) - await sock.close().timeout(const Duration(seconds: 5), onTimeout: () => sock); - await sock.done.timeout(const Duration(seconds: 5), onTimeout: () => sock.destroy()).catchError((_) {}); - final (end, et) = await s.reply(); - if (end != 226 && end != 250) throw s.failure(end, et, rel); - } catch (e) { - throw _explain(e); - } finally { - s.close(); - } - } - - /// FTP 는 남은 공간을 알려 주는 표준 명령이 없다 - @override - Future<(int?, int?)> quota() async => (null, null); - - @override - void close() { - final m = _main; - _main = null; - m?.then((s) => s.close(), onError: (_) {}); - } -} - -/// 설정의 FTP 서버 목록과 클라이언트 (서버마다 하나) -class FtpRegistry { - static final _clients = {}; - static final _servers = {}; - - /// 이름 풀기 (main 에서 DNS 강제를 따르게 바꾼다). 처음 값: 기기 DNS (null = InternetAddress.lookup) - static FtpResolve resolve = (host) async => InternetAddress.tryParse(host); - - /// 정한 DNS 로 이름을 풀기 (집 안 이름 · 주소는 기기 DNS) - SFTP 와 같게 - static FtpResolve resolveWithDns(DnsResolver resolver) => (host) async { - final ip = InternetAddress.tryParse(host); - if (ip != null) return ip; - if (!resolver.config().forced || isLocalName(host)) return null; - final addrs = await resolver.lookup(host); - return addrs.where((a) => a.type == InternetAddressType.IPv4).firstOrNull ?? addrs.firstOrNull; - }; - - static void configure(List servers) { - final keep = {for (final s in servers) s.id: s}; - for (final id in _clients.keys.toList()) { - final old = _servers[id], now = keep[id]; - if (now == null || old == null || old.password != now.password || jsonEncode(old.toJson()) != jsonEncode(now.toJson())) { - _clients.remove(id)?.close(); - } - } - _servers - ..clear() - ..addAll(keep); - } - - static List get servers => _servers.values.toList(); - static FtpServer? server(String id) => _servers[id]; - - static FtpRemote client(String id) { - final s = _servers[id]; - if (s == null) throw FtpException(trf('FTP 서버 설정이 없습니다 ({0})', [id]), FtpFailure.other); - return _clients.putIfAbsent(id, () => FtpRemote(s)); - } -} - -/// 시험: 명령 채널 기록 (비밀번호 가림) -void debugFtpLog(void Function(String line)? f) => _Session.debugLog = f; +// 약속 (나누기 ftp): FTP · FTPS (dart:io 로 직접 - 관리자 지시 l, 패키지 없이) - 부르는 곳은 이 파일 하나만 가져온다. +// 일은 lib/core/ftp/ 의 모듈이 한다: ftp_server (설정 · 오류 · 지문) · ftp_session (명령 채널 하나) · ftp_remote (원격 저장소 · 목록). +export 'ftp/ftp_server.dart'; +export 'ftp/ftp_session.dart' show debugFtpLog; +export 'ftp/ftp_remote.dart'; diff --git a/lib/core/ftp/ftp_remote.dart b/lib/core/ftp/ftp_remote.dart new file mode 100644 index 0000000..e14591d --- /dev/null +++ b/lib/core/ftp/ftp_remote.dart @@ -0,0 +1,352 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import '../../l10n/tr.dart'; +import '../dns_resolver.dart'; +import '../remote_fs.dart'; +import '../secret_gate.dart'; +import '../webdav.dart' show DavItem, DavLockedException; +import '../remote_idle.dart'; +import 'ftp_server.dart'; +import 'ftp_session.dart'; + +// 약속 (나누기 ftp): FTP · FTPS 원격 저장소 ([RemoteClient]) 와 서버마다 하나인 클라이언트 목록 ([FtpRegistry]). +// 입력: 서버 기준 경로 ("/" 로 시작). 출력: 목록 · 정보 ([DavItem]) · 읽기 흐름 · 쓰기 · 남은 공간 (FTP 는 모름). +// 오류: [FtpException] (연결 못함 · 로그인 · 권한 · 없는 경로 · 같은 이름) · [FtpCertException] · 잠금 [DavLockedException] · +// 조각 사이 시간 넘김 [RemoteIdleTimeout]. 명령 채널이 끊기면 한 번 다시 연결한다. + +/// FTP · FTPS 클라이언트 (dart:io 로 직접 - 관리자 지시 l, 패키지 없이). 목록 · 정보 · 만들기 · 지우기 · 옮기기는 서버마다 명령 채널 하나를 +/// 열어 두고 차례로 쓰고, 받기 · 올리기는 따로 연결해 (동영상 건너뛰기처럼 여럿이 동시에 읽어도 서로 막지 않게) 끝나면 닫는다. +class FtpRemote implements RemoteClient { + final FtpServer server; + final Duration timeout; + FtpRemote(this.server, {this.timeout = const Duration(seconds: 15)}); + + Future? _main; + Future _lock = Future.value(); + + Future _connect() async { + final s = server; + if (s.host.trim().isEmpty) throw FtpException(tr('FTP 서버 주소가 없습니다'), FtpFailure.other); + if (s.password.isNotEmpty && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage)); + if (s.user.isNotEmpty && s.password.isEmpty) { + throw FtpException(tr('저장된 비밀번호가 없습니다. 서버 설정에서 비밀번호를 넣어 주세요'), FtpFailure.noSecret); + } + FtpSession? x; + try { + x = await FtpSession.open(s, timeout, FtpRegistry.resolve); + await x.login(); + return x; + } catch (e) { + x?.close(); + throw _explain(e); + } + } + + Object _explain(Object e) { + if (e is FtpException || e is DavLockedException || e is RemoteIdleTimeout) return e; + if (e is TimeoutException) return FtpException(trf('서버 ({0}) 가 답하지 않습니다', [server.host]), FtpFailure.unreachable); + if (e is SocketException) return FtpException(trf('서버 ({0}) 에 연결할 수 없습니다: {1}', [server.host, e.message]), FtpFailure.unreachable); + if (e is HandshakeException) return FtpException(trf('암호화 (TLS) 연결을 맺지 못했습니다: {0}', [e.message]), FtpFailure.other); + return FtpException('$e', FtpFailure.other); + } + + /// 명령 채널을 차례로 쓴다 (한 번에 하나). 끊겼으면 한 번 다시 연결 + Future _run(Future Function(FtpSession s) f) { + final prev = _lock; + final done = Completer(); + _lock = done.future; + return prev.then((_) async { + try { + for (var attempt = 0;; attempt++) { + final FtpSession s; + try { + s = await (_main ??= _connect()); + } catch (_) { + _main = null; + rethrow; + } + try { + return await f(s); + } on FtpException catch (e) { + if (e.kind == FtpFailure.unreachable && attempt == 0) { + s.close(); + _main = null; + continue; + } + rethrow; + } + } + } catch (e) { + if (e is! FtpException && e is! DavLockedException) { + _main = null; + throw _explain(e); + } + rethrow; + } finally { + done.complete(); + } + }); + } + + /// 서버 기준 경로 ("/" 로 시작) → 서버의 절대 경로 + String _abs(FtpSession s, String rel) { + var base = server.root.trim().isEmpty ? s.home : server.root.trim(); + if (!base.startsWith('/')) base = '/$base'; + if (base.length > 1 && base.endsWith('/')) base = base.substring(0, base.length - 1); + final r = rel == '/' || rel.isEmpty ? '' : rel; + if (base == '/') return r.isEmpty ? '/' : r; + return '$base$r'; + } + + static String _child(String rel, String name) => rel == '/' ? '/$name' : '$rel/$name'; + static String _parent(String rel) => rel.lastIndexOf('/') <= 0 ? '/' : rel.substring(0, rel.lastIndexOf('/')); + + /// MLSD · MLST 의 한 줄 "type=dir;size=1;modify=20261017120000; 이름" + static (String, bool, int, DateTime)? parseMlsx(String line) { + final sp = line.indexOf(' '); + if (sp < 0) return null; + final facts = {}; + for (final f in line.substring(0, sp).split(';')) { + final eq = f.indexOf('='); + if (eq > 0) facts[f.substring(0, eq).toLowerCase()] = f.substring(eq + 1); + } + final name = line.substring(sp + 1); + final type = (facts['type'] ?? '').toLowerCase(); + if (type == 'cdir' || type == 'pdir' || name == '.' || name == '..') return null; + final m = facts['modify'] ?? ''; + var when = DateTime.fromMillisecondsSinceEpoch(0); + if (m.length >= 14) { + when = DateTime.utc(int.parse(m.substring(0, 4)), int.parse(m.substring(4, 6)), int.parse(m.substring(6, 8)), + int.parse(m.substring(8, 10)), int.parse(m.substring(10, 12)), int.parse(m.substring(12, 14))); + } + final isDir = type == 'dir'; + return (name, isDir, isDir ? 0 : int.tryParse(facts['size'] ?? '') ?? 0, when); + } + + static const _months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; + + /// LIST 의 유닉스 꼴 한 줄 "drwxr-xr-x 1 u g 0 Oct 17 12:00 이름" (MLSD 가 없는 서버) + static (String, bool, int, DateTime)? parseList(String line) { + final m = RegExp(r'^([dl-])[rwxsStT-]{9}\S*\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\w{3})\s+(\d{1,2})\s+(\d{1,2}:\d{2}|\d{4})\s(.+)$').firstMatch(line); + if (m == null) return null; + var name = m[6]!; + if (m[1] == 'l' && name.contains(' -> ')) name = name.substring(0, name.indexOf(' -> ')); + if (name == '.' || name == '..') return null; + final mon = _months.indexOf(m[3]!.toLowerCase()) + 1; + final now = DateTime.now(); + final t = m[5]!; + final when = t.contains(':') + ? DateTime(now.year, mon, int.parse(m[4]!), int.parse(t.split(':')[0]), int.parse(t.split(':')[1])) + : DateTime(int.parse(t), mon, int.parse(m[4]!)); + final isDir = m[1] == 'd'; + return (name, isDir, isDir ? 0 : int.parse(m[2]!), when); + } + + /// 자료 채널로 목록 (MLSD 또는 LIST) 을 읽는다 + Future> _listRaw(FtpSession s, String abs, String rel) async { + final plain = await s.data(); + // LIST 에 -a 같은 옵션을 붙이면 경로로 읽는 서버가 있다 + s.send('${s.mlsd ? 'MLSD' : 'LIST'} $abs'); + final (code, text) = await s.reply(); + if (code != 150 && code != 125) { + plain.destroy(); + throw s.failure(code, text, rel); + } + final sock = await s.secureData(plain); + final bytes = await sock.fold>([], (a, b) => a..addAll(b)); + final (end, et) = await s.reply(); + if (end != 226 && end != 250) throw s.failure(end, et, rel); + final out = <(String, bool, int, DateTime)>[]; + for (final l in const LineSplitter().convert(utf8.decode(bytes, allowMalformed: true))) { + final x = s.mlsd ? parseMlsx(l) : parseList(l); + if (x != null) out.add(x); + } + return out; + } + + @override + Future> list(String rel) => _run((s) async => [ + for (final (n, dir, size, at) in await _listRaw(s, _abs(s, rel), rel)) DavItem(_child(rel, n), isDir: dir, size: size, modified: at), + ]); + + @override + Future stat(String rel) async { + if (rel == '/' || rel.isEmpty) return DavItem('/', isDir: true, modified: DateTime.fromMillisecondsSinceEpoch(0)); + // 위 폴더의 목록에서 찾는다 (MLST · SIZE 를 모든 서버가 같게 답하지 않아서 - 느리지만 확실하게) + try { + for (final x in await list(_parent(rel))) { + if (x.rel == rel) return x; + } + } on FtpException catch (e) { + if (e.kind == FtpFailure.notFound) return null; + rethrow; + } + return null; + } + + @override + Future mkdirs(String rel) async { + if (rel == '/' || rel.isEmpty) return; + if (await stat(rel) != null) return; + await mkdirs(_parent(rel)); + await _run((s) => s.must('MKD ${_abs(s, rel)}', path: rel)); + } + + @override + Future delete(String rel) async { + final st = await stat(rel); + if (st == null) return; + if (st.isDir) { + for (final x in await list(rel)) { + await delete(x.rel); + } + await _run((s) => s.must('RMD ${_abs(s, rel)}', path: rel)); + } else { + await _run((s) => s.must('DELE ${_abs(s, rel)}', path: rel)); + } + } + + @override + Future move(String from, String to, {bool overwrite = false}) async { + if (await stat(to) != null) { + if (!overwrite) throw FtpException(trf('같은 이름이 이미 있습니다: {0}', [to]), FtpFailure.other); + await delete(to); + } + await _run((s) async { + await s.must('RNFR ${_abs(s, from)}', ok: (c) => c == 350, path: from); + await s.must('RNTO ${_abs(s, to)}', path: to); + }); + } + + @override + Future>> openRead(String rel) => openReadFrom(rel); + + /// [offset] 부터 [length] 바이트 (null 이면 끝까지) - 따로 연결해 REST 로 (동영상 건너뛰기 · 이어 받기) + Future>> openReadFrom(String rel, {int offset = 0, int? length}) async { + final s = await _connect(); + Socket? sock; + try { + sock = await s.data(); + if (offset > 0) await s.must('REST $offset', ok: (c) => c == 350, path: rel); + s.send('RETR ${_abs(s, rel)}'); + final (code, text) = await s.reply(); + if (code != 150 && code != 125) throw s.failure(code, text, rel); + sock = await s.secureData(sock); + } catch (e) { + sock?.destroy(); + s.close(); + throw _explain(e); + } + final data = sock; + Stream> read() async* { + var left = length; + try { + // 관리자 판단 (10/20): 조각 사이에 정한 시간 동안 아무것도 오지 않으면 실패 (서버가 멈춰도 영원히 기다리지 않게) + await for (var chunk in idleRead(data, onTimeout: data.destroy)) { + if (left != null) { + if (chunk.length >= left) { + yield chunk.sublist(0, left); + left = 0; + break; + } + left -= chunk.length; + } + yield chunk; + } + if (left == null) { + final (end, et) = await s.reply(); + if (end != 226 && end != 250) throw s.failure(end, et, rel); + } + } finally { + data.destroy(); + s.close(); // 다 읽기 전에 멈추면 이 연결째 닫는다 (ABOR 대신 - 서버마다 답이 달라서) + } + } + + return read(); + } + + @override + Future write(String rel, Stream> data, {int? length}) async { + final s = await _connect(); + try { + final plain = await s.data(); + s.send('STOR ${_abs(s, rel)}'); + final (code, text) = await s.reply(); + if (code != 150 && code != 125) { + plain.destroy(); + throw s.failure(code, text, rel); + } + final sock = await s.secureData(plain); + // 관리자 판단 (10/20): 서버가 받지 않아 정한 시간 동안 한 조각도 더 보내지 못하면 실패 (연결을 끊음) + await idleWrite(data, (x) async { + await sock.addStream(x); + await sock.flush(); + }, onTimeout: sock.destroy); + // 닫기 (TLS 는 close_notify) 를 보내고, 서버가 답하지 않아도 오래 기다리지 않게 연결째 끊는다 + // 다 보낸 뒤 닫는다 (TLS 는 close_notify). close() 가 끝나도 암호화된 마지막 자료가 아직 나가지 않았을 수 있어 + // 연결이 정말 끝날 때 (done) 까지 기다리고, 서버가 답하지 않을 때만 끊는다 (곧바로 destroy 하면 빈 파일이 되었다) + await sock.close().timeout(const Duration(seconds: 5), onTimeout: () => sock); + await sock.done.timeout(const Duration(seconds: 5), onTimeout: () => sock.destroy()).catchError((_) {}); + final (end, et) = await s.reply(); + if (end != 226 && end != 250) throw s.failure(end, et, rel); + } catch (e) { + throw _explain(e); + } finally { + s.close(); + } + } + + /// FTP 는 남은 공간을 알려 주는 표준 명령이 없다 + @override + Future<(int?, int?)> quota() async => (null, null); + + @override + void close() { + final m = _main; + _main = null; + m?.then((s) => s.close(), onError: (_) {}); + } +} + +/// 설정의 FTP 서버 목록과 클라이언트 (서버마다 하나) +class FtpRegistry { + static final _clients = {}; + static final _servers = {}; + + /// 이름 풀기 (main 에서 DNS 강제를 따르게 바꾼다). 처음 값: 기기 DNS (null = InternetAddress.lookup) + static FtpResolve resolve = (host) async => InternetAddress.tryParse(host); + + /// 정한 DNS 로 이름을 풀기 (집 안 이름 · 주소는 기기 DNS) - SFTP 와 같게 + static FtpResolve resolveWithDns(DnsResolver resolver) => (host) async { + final ip = InternetAddress.tryParse(host); + if (ip != null) return ip; + if (!resolver.config().forced || isLocalName(host)) return null; + final addrs = await resolver.lookup(host); + return addrs.where((a) => a.type == InternetAddressType.IPv4).firstOrNull ?? addrs.firstOrNull; + }; + + static void configure(List servers) { + final keep = {for (final s in servers) s.id: s}; + for (final id in _clients.keys.toList()) { + final old = _servers[id], now = keep[id]; + if (now == null || old == null || old.password != now.password || jsonEncode(old.toJson()) != jsonEncode(now.toJson())) { + _clients.remove(id)?.close(); + } + } + _servers + ..clear() + ..addAll(keep); + } + + static List get servers => _servers.values.toList(); + static FtpServer? server(String id) => _servers[id]; + + static FtpRemote client(String id) { + final s = _servers[id]; + if (s == null) throw FtpException(trf('FTP 서버 설정이 없습니다 ({0})', [id]), FtpFailure.other); + return _clients.putIfAbsent(id, () => FtpRemote(s)); + } +} diff --git a/lib/core/ftp/ftp_server.dart b/lib/core/ftp/ftp_server.dart new file mode 100644 index 0000000..bcf9f6a --- /dev/null +++ b/lib/core/ftp/ftp_server.dart @@ -0,0 +1,109 @@ +import 'dart:io'; + +import 'package:crypto/crypto.dart' show sha256; + +import '../webdav.dart' show DavException; + +// 약속 (나누기 ftp): FTP · FTPS 서버 설정 (저장 꼴) · 실패 종류 · 오류 · 인증서 지문. +// 입력: 설정 JSON (비밀 값 없음) · 인증서. 출력: [FtpServer] · JSON · "SHA256:AB:…" 지문. +// 오류: 던지지 않는다 ([FtpServer.fromJson] 은 모르는 값을 처음 값 (FTPS · 21/990) 으로). [FtpException] · [FtpCertException] 은 다른 ftp 모듈이 던진다. + +/// FTP · FTPS 서버 설정 (관리자 지시 10/17, l). 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음). +/// [security]: 'explicit' (FTPS - AUTH TLS, 처음 값) · 'implicit' (FTPS 990) · 'none' (평문 FTP - 연결 · 설정에서 "암호화 안 됨" 경고). +/// 자체 서명 인증서는 처음 연결 때 사용자가 믿은 지문 ([certFingerprint], SHA-256) 만 받는다 (TOFU - SFTP 호스트 키와 같은 판단). +class FtpServer { + const FtpServer({ + required this.id, + required this.name, + required this.host, + this.port = 21, + this.user = '', + this.root = '', + this.password = '', + this.security = 'explicit', + this.certFingerprint = '', + }); + + final String id; + final String name; + final String host; + final int port; + final String user; + + /// 서버의 시작 폴더 (비우면 로그인한 곳) + final String root; + + /// 안전 저장소에서 채움 (설정 파일에는 없음) + final String password; + final String security; + + /// 믿은 자체 서명 인증서의 지문 "SHA256:xx:xx…" (비밀 아님) + final String certFingerprint; + + bool get tls => security != 'none'; + String get label => name.trim().isNotEmpty ? name.trim() : (host.trim().isEmpty ? 'FTP' : host.trim()); + + FtpServer copyWith({String? name, String? host, int? port, String? user, String? root, String? password, String? security, String? certFingerprint}) => + FtpServer( + id: id, + name: name ?? this.name, + host: host ?? this.host, + port: port ?? this.port, + user: user ?? this.user, + root: root ?? this.root, + password: password ?? this.password, + security: security ?? this.security, + certFingerprint: certFingerprint ?? this.certFingerprint, + ); + + /// 비밀 값은 넣지 않는다 (안전 저장소). 저장해 두었는지만 + Map toJson() => { + 'id': id, + 'name': name, + 'host': host, + 'port': port, + 'user': user, + 'root': root, + 'hasPassword': password.isNotEmpty, + 'security': security, + 'certFingerprint': certFingerprint, + }; + + factory FtpServer.fromJson(Map j) => FtpServer( + id: '${j['id'] ?? DateTime.now().microsecondsSinceEpoch}', + name: j['name'] as String? ?? '', + host: j['host'] as String? ?? '', + port: switch (j['port']) { final int p when p > 0 && p < 65536 => p, _ => j['security'] == 'implicit' ? 990 : 21 }, + user: j['user'] as String? ?? '', + root: j['root'] as String? ?? '', + // 모르는 값 · 없는 값은 FTPS (지침 3 - 덜 위험한 쪽, 관리자 지시 "처음 값은 FTPS 우선") + security: const ['explicit', 'implicit', 'none'].contains(j['security']) ? j['security'] as String : 'explicit', + certFingerprint: j['certFingerprint'] as String? ?? '', + ); +} + +/// FTP 실패의 종류 (안내 글 · 할 일 단추를 고르는 데) +enum FtpFailure { unreachable, auth, noSecret, certUnknown, certChanged, noTls, permission, notFound, other } + +class FtpException extends DavException { + final FtpFailure kind; + const FtpException(super.message, this.kind); +} + +/// 처음 보는 (또는 바뀐) 자체 서명 인증서: 화면이 지문을 보이고 믿을지 묻는다. 믿으면 [FtpServer.certFingerprint] 에 저장 +class FtpCertException extends FtpException { + final String fingerprint; + final String subject; + + /// 바뀐 경우 예전에 믿은 지문 + final String? previous; + const FtpCertException(super.message, super.kind, {required this.fingerprint, required this.subject, this.previous}); + bool get changed => previous != null; +} + +/// 인증서 지문 "SHA256:AB:CD:…" +String certSha256(X509Certificate c) => + 'SHA256:${sha256.convert(c.der).bytes.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(':')}'; + +/// 이름 → 연결할 주소 (DNS 강제면 정한 DNS - main 에서 바꾼다). 처음 값: 기기 DNS (주소를 그대로 넘김) +typedef FtpResolve = Future Function(String host); diff --git a/lib/core/ftp/ftp_session.dart b/lib/core/ftp/ftp_session.dart new file mode 100644 index 0000000..bea9ec2 --- /dev/null +++ b/lib/core/ftp/ftp_session.dart @@ -0,0 +1,255 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import '../../l10n/tr.dart'; +import 'ftp_server.dart'; + +// 약속 (나누기 ftp): FTP 명령 채널 하나 - 연결 · TLS (명시 · 암시, 믿은 지문만) · 로그인 · 명령 주고받기 · 자료 채널 (EPSV / PASV). +// 입력: [FtpServer] · 시간 제한 · 이름 풀기 ([FtpResolve]). 출력: 응답 (코드, 글) · 자료 소켓. +// 오류: 끊김 · 로그인 실패 · 권한 · 없는 경로 → [FtpException] (종류 [FtpFailure]) · 처음 보는 / 바뀐 인증서 → [FtpCertException] · +// 답 없음 → [TimeoutException]. 줄바꿈이 든 명령은 보내지 않고 [FtpException]. + +/// FTP 명령 채널 하나 (로그인까지). 응답은 줄 단위 ("123-…" 여러 줄 → "123 …" 끝) +class FtpSession { + final FtpServer server; + final Duration timeout; + final InternetAddress addr; + RawSocket _raw; + StreamSubscription? _sub; + final _buf = []; + final _lines = []; + Completer? _wake; + bool _closed = false; + bool secure = false; + bool mlsd = false; + bool _protP = false; + + /// 로그인 뒤 서버의 지금 폴더 (시작 폴더가 비었을 때 맨 위) + String home = '/'; + + FtpSession._(this.server, this.timeout, this.addr, this._raw); + + static Future open(FtpServer s, Duration timeout, FtpResolve resolve) async { + final addr = await resolve(s.host.trim()) ?? (await InternetAddress.lookup(s.host.trim())).first; + final raw = await RawSocket.connect(addr, s.port, timeout: timeout); + final x = FtpSession._(s, timeout, addr, raw); + if (s.security == 'implicit') { + x._raw = await x._secure(raw, null); + x.secure = true; + } + x._listen(); + return x; + } + + void _listen() { + _sub = _raw.listen((ev) { + if (ev == RawSocketEvent.read) { + final d = _raw.read(); + if (d != null) _feed(d); + } else if (ev == RawSocketEvent.readClosed || ev == RawSocketEvent.closed) { + _closed = true; + _poke(); + } + }, onError: (_) { + _closed = true; + _poke(); + }); + } + + void _feed(Uint8List d) { + _buf.addAll(d); + while (true) { + final i = _buf.indexOf(10); + if (i < 0) break; + var line = utf8.decode(_buf.sublist(0, i), allowMalformed: true); + if (line.endsWith('\r')) line = line.substring(0, line.length - 1); + _buf.removeRange(0, i + 1); + debugLog?.call('< $line'); + _lines.add(line); + } + _poke(); + } + + void _poke() { + final w = _wake; + _wake = null; + w?.complete(); + } + + Future _line() async { + final end = DateTime.now().add(timeout); + while (_lines.isEmpty) { + if (_closed) throw FtpException(trf('서버 ({0}) 와의 연결이 끊겼습니다', [server.host]), FtpFailure.unreachable); + final left = end.difference(DateTime.now()); + if (left <= Duration.zero) throw TimeoutException(tr('답이 없습니다')); + _wake = Completer(); + await _wake!.future.timeout(left, onTimeout: () {}); + } + return _lines.removeAt(0); + } + + /// 응답 하나 (여러 줄이면 합침): (코드, 글) + Future<(int, String)> reply() async { + final first = await _line(); + final code = int.tryParse(first.length >= 3 ? first.substring(0, 3) : '') ?? 0; + final text = StringBuffer(first.length > 4 ? first.substring(4) : ''); + if (first.length > 3 && first[3] == '-') { + while (true) { + final l = await _line(); + if (l.startsWith('$code ')) { + text.write('\n${l.substring(4)}'); + break; + } + text.write('\n${l.startsWith(' ') ? l.substring(1) : l}'); + } + } + return (code, text.toString()); + } + + /// 시험: 명령 채널 주고받기 (비밀번호 줄은 가림) + static void Function(String line)? debugLog; + + void send(String cmd) { + // 보안 검토 8: 경로 · 이름에 줄바꿈이 있으면 명령이 하나 더 끼어든다 - 보내지 않는다 + if (cmd.contains('\r') || cmd.contains('\n') || cmd.contains('\u0000')) { + throw FtpException(tr('이름에 줄바꿈이 들어 있어 FTP 로 보낼 수 없습니다'), FtpFailure.other); + } + debugLog?.call('> ${cmd.startsWith('PASS ') ? 'PASS ***' : cmd}'); + _raw.write(utf8.encode('$cmd\r\n')); + } + + Future<(int, String)> cmd(String c) async { + send(c); + return reply(); + } + + /// [c] 를 보내고 [ok] 코드 (2xx 등) 가 아니면 [FtpException] + Future must(String c, {bool Function(int code)? ok, String? path}) async { + final (code, text) = await cmd(c); + if (ok != null ? ok(code) : code >= 200 && code < 300) return text; + throw failure(code, text, path); + } + + FtpException failure(int code, String text, String? path) { + if (code == 530) return FtpException(tr('아이디나 비밀번호가 맞지 않습니다'), FtpFailure.auth); + if (code == 550 || code == 450) { + final t = text.toLowerCase(); + if (t.contains('permission') || t.contains('denied') || t.contains('access')) { + return FtpException(trf('이 폴더 · 파일을 다룰 권한이 없습니다: {0}', [path ?? '']), FtpFailure.permission); + } + return FtpException(trf('없는 경로입니다: {0}', [path ?? '']), FtpFailure.notFound); + } + return FtpException('$code $text${path == null ? '' : ' ($path)'}', FtpFailure.other); + } + + /// TLS 로 바꾼다 (명시: AUTH TLS 뒤 · 암시: 연결하자마자). 자체 서명이면 믿은 지문만 + Future _secure(RawSocket raw, StreamSubscription? sub) async { + X509Certificate? bad; + try { + return await RawSecureSocket.secure(raw, subscription: sub, host: server.host.trim(), onBadCertificate: (c) { + bad = c; + return server.certFingerprint.isNotEmpty && certSha256(c) == server.certFingerprint; + }).timeout(timeout); + } on HandshakeException { + final c = bad; + if (c == null) rethrow; + final fp = certSha256(c); + throw FtpCertException( + server.certFingerprint.isEmpty + ? tr('처음 보는 서버 인증서입니다 (자체 서명). 지문을 확인한 뒤 믿을지 정하세요') + : tr('서버 인증서가 바뀌었습니다. 다른 서버로 연결되었을 수 있습니다'), + server.certFingerprint.isEmpty ? FtpFailure.certUnknown : FtpFailure.certChanged, + fingerprint: fp, + subject: c.subject, + previous: server.certFingerprint.isEmpty ? null : server.certFingerprint); + } + } + + /// 인사 · TLS · 로그인 · UTF-8 · 이진 · 시작 폴더 + Future login() async { + final (hello, text) = await reply(); + if (hello != 220) throw failure(hello, text, null); + if (server.security == 'explicit') { + final (code, t) = await cmd('AUTH TLS'); + if (code != 234) { + // FTPS 를 못 하는 서버: 몰래 평문으로 내려가지 않는다 (사용자가 "평문 FTP" 를 골라야) + throw FtpException(trf('서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 "평문 FTP" 를 고르세요', [code, t]), + FtpFailure.noTls); + } + // 보안 검토 2: 234 뒤에 평문으로 끼워 넣은 줄이 있으면 (중간자가 TLS 뒤의 답을 꾸미려는 것) 끊는다 + if (_lines.isNotEmpty || _buf.isNotEmpty) { + throw FtpException(tr('서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다'), FtpFailure.noTls); + } + final sub = _sub; + _sub = null; + _raw = await _secure(_raw, sub); + secure = true; + // RawSecureSocket.secure 가 넘겨받은 구독을 이어 쓰지 않으므로 다시 듣는다 + _listen(); + } + if (secure) { + await must('PBSZ 0'); + await must('PROT P'); + _protP = true; + } + if (server.user.isNotEmpty || server.password.isNotEmpty) { + final (code, t) = await cmd('USER ${server.user.isEmpty ? 'anonymous' : server.user}'); + if (code == 331 || code == 332) { + await must('PASS ${server.password}', ok: (c) => c == 230 || c == 202); + } else if (code != 230) { + throw failure(code, t, null); + } + } else { + await must('USER anonymous', ok: (c) => c == 230 || c == 331); + } + final feat = await cmd('FEAT'); + // MLST 를 알리면 MLSD 도 한다 (RFC 3659 - pyftpdlib 등은 FEAT 에 MLST 만 적는다) + mlsd = feat.$1 == 211 && RegExp(r'^\s*MLS[TD]', multiLine: true).hasMatch(feat.$2.toUpperCase()); + if (feat.$2.toUpperCase().contains('UTF8')) await cmd('OPTS UTF8 ON'); + await must('TYPE I'); + final pwd = await cmd('PWD'); + final m = RegExp(r'"((?:[^"]|"")*)"').firstMatch(pwd.$2); + if (pwd.$1 == 257 && m != null) home = m[1]!.replaceAll('""', '"'); + } + + /// 자료 채널 (EPSV, 안 되면 PASV). 주소는 명령 채널의 서버 주소로 (서버가 알려 준 다른 주소로는 가지 않는다 - FTP 바운스 막기) + Future data() async { + int? port; + final (c1, t1) = await cmd('EPSV'); + if (c1 == 229) { + final m = RegExp(r'\(\|\|\|(\d+)\|\)').firstMatch(t1); + if (m != null) port = int.parse(m[1]!); + } + if (port == null) { + final t = await must('PASV', ok: (c) => c == 227); + final m = RegExp(r'(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)').firstMatch(t); + if (m == null) throw FtpException(trf('서버의 자료 연결 응답을 읽지 못했습니다: {0}', [t]), FtpFailure.other); + port = int.parse(m[5]!) * 256 + int.parse(m[6]!); + } + return Socket.connect(addr, port, timeout: timeout); + } + + /// 자료 연결을 TLS 로 (PROT P). 명령 (RETR · STOR · MLSD) 을 보내고 150/125 를 받은 뒤에 한다 - 서버는 명령을 받고서야 TLS 를 시작한다 (pyftpdlib 등) + Future secureData(Socket sock) async { + if (!_protP) return sock; + final x = await SecureSocket.secure(sock, + host: server.host.trim(), + onBadCertificate: (c) => server.certFingerprint.isNotEmpty && certSha256(c) == server.certFingerprint).timeout(timeout); + return x; + } + + void close() { + if (_closed) return; + _closed = true; + try { + send('QUIT'); + } catch (_) {} + _sub?.cancel(); + _raw.close(); + } +} + +/// 시험: 명령 채널 기록 (비밀번호 가림) +void debugFtpLog(void Function(String line)? f) => FtpSession.debugLog = f; diff --git a/test/ftp_modules_contract_test.dart b/test/ftp_modules_contract_test.dart new file mode 100644 index 0000000..4819f54 --- /dev/null +++ b/test/ftp_modules_contract_test.dart @@ -0,0 +1,98 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/ftp/ftp_remote.dart'; +import 'package:jj_mkvmaker/core/ftp/ftp_server.dart'; +import 'package:jj_mkvmaker/core/ftp/ftp_session.dart'; +import 'package:path/path.dart' as p; + +import 'support/sftp_server.dart' show sftpTestPython; + +/// 나누기 ftp 약속 시험: lib/core/ftp/ 모듈마다 맨 위 약속 - 실제 서버 (tool/ftp_test_server.py, pyftpdlib) · 실제 시험 폴더. +/// 시험용 파이썬이 없으면 서버 시험은 건너뛴다 (ftp_test 와 같음) +const _pw = 'ftp-Pw-7Rk2xQ9t'; + +void main() { + final hasPy = File(sftpTestPython).existsSync(); + late Directory tmp; + Process? proc; + late int port; + late String fp; + + Future start(String mode) async { + final pr = await Process.start(sftpTestPython, ['tool/ftp_test_server.py', '--root', tmp.path, '--mode', mode, '--password', _pw]); + proc = pr; + final lines = pr.stdout.transform(utf8.decoder).transform(const LineSplitter()).asBroadcastStream(); + pr.stderr.drain(); + port = int.parse((await lines.firstWhere((l) => l.startsWith('PORT ')).timeout(const Duration(seconds: 20))).split(' ')[1]); + final f = (await lines.firstWhere((l) => l.startsWith('FINGERPRINT')).timeout(const Duration(seconds: 5))).split(' '); + fp = f.length > 1 ? f[1] : ''; + } + + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_ftpm_')); + tearDown(() async { + final pr = proc; + proc = null; + if (pr != null) { + pr.kill(); + await pr.exitCode.timeout(const Duration(seconds: 5), onTimeout: () => -1); + } + for (var i = 0; tmp.existsSync(); i++) { + try { + tmp.deleteSync(recursive: true); + } on FileSystemException { + if (i > 30) rethrow; + await Future.delayed(const Duration(milliseconds: 100)); + } + } + }); + + FtpServer srv(String security, {String cert = '', String password = _pw}) => + FtpServer(id: 'm1', name: '', host: '127.0.0.1', port: port, user: 'jj', password: password, security: security, certFingerprint: cert); + Future resolve(String h) async => InternetAddress.tryParse(h); + + test('ftp_server: JSON 에 비밀 값 없음 · 모르는 값은 FTPS · 이름', () { + const s = FtpServer(id: 'a', name: ' ', host: 'nas', password: 'secret', security: 'none'); + expect(jsonEncode(s.toJson()), isNot(contains('secret'))); + expect(FtpServer.fromJson(s.toJson()).security, 'none'); + expect(FtpServer.fromJson({'id': 'x', 'security': '?'}).security, 'explicit'); + expect((s.label, s.tls, s.copyWith(security: 'implicit').tls), ('nas', false, true)); + }); + + test('ftp_session: 처음 보는 인증서는 지문과 함께 막음 → 믿으면 로그인 · 명령 · 줄바꿈 명령은 보내지 않음', () async { + await start('explicit'); + final first = await FtpSession.open(srv('explicit'), const Duration(seconds: 10), resolve); + final e = await first.login().then((_) => null, onError: (Object x) => x); + first.close(); + expect(e, isA().having((x) => x.fingerprint, 'fingerprint', fp).having((x) => x.kind, 'kind', FtpFailure.certUnknown)); + final s = await FtpSession.open(srv('explicit', cert: fp), const Duration(seconds: 10), resolve); + addTearDown(s.close); + await s.login(); + expect(s.secure, isTrue); + expect(s.home, '/'); + expect((await s.cmd('NOOP')).$1, 200); + expect(() => s.send('DELE a\r\nDELE b'), throwsA(isA())); + }, skip: hasPy ? false : '시험용 파이썬 없음'); + + test('ftp_remote: 쓰기 · 목록 · 정보 · 부분 읽기 · 옮기기 · 지우기 (평문) · 틀린 비밀번호는 auth', () async { + await start('plain'); + final c = FtpRemote(srv('none')); + addTearDown(c.close); + await c.mkdirs('/가/나'); + await c.write('/가/나/a.txt', Stream.value(utf8.encode('0123456789'))); + expect(File(p.join(tmp.path, '가', '나', 'a.txt')).readAsStringSync(), '0123456789'); + expect((await c.list('/가/나')).map((x) => (x.rel, x.size)), [('/가/나/a.txt', 10)]); + expect(await c.stat('/없음'), isNull); + final part = await (await c.openReadFrom('/가/나/a.txt', offset: 4, length: 3)).expand((x) => x).toList(); + expect(utf8.decode(part), '456'); + await c.move('/가/나/a.txt', '/가/b.txt'); + expect(File(p.join(tmp.path, '가', 'b.txt')).existsSync(), isTrue); + await c.delete('/가'); + expect(Directory(p.join(tmp.path, '가')).existsSync(), isFalse); + final bad = FtpRemote(srv('none', password: 'wrong-pw')); + addTearDown(bad.close); + await expectLater(bad.list('/'), throwsA(isA().having((x) => x.kind, 'kind', FtpFailure.auth))); + }, skip: hasPy ? false : '시험용 파이썬 없음'); +} From 5a897344c61d5381f12dbcd8ee7540f9d81b1394 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:48:27 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=EB=82=98=EB=88=84=EA=B8=B0=20core=20C:=20s?= =?UTF-8?q?ftp=20(612)=20=EB=A5=BC=20lib/core/sftp/=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=202=EA=B0=9C=EB=A1=9C=20(=E2=86=92=20=EC=A0=95=EB=A9=B4=20sftp?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sftp_server (설정 · 실패 종류 · 오류 · 개인 키 읽기 · 호스트 키 지문 · 연결 꼴) - sftp_remote (SftpRemote 원격 저장소 · 연결 · 소켓 다리 · SftpRegistry) - sftp.dart 는 export 만 (부르는 쪽은 바꾸지 않음) - 약속 시험: test/sftp_modules_contract_test.dart (실제 paramiko 서버 · 시험 폴더) Co-Authored-By: Claude Opus 5.5 --- lib/core/sftp.dart | 616 +-------------------------- lib/core/sftp/sftp_remote.dart | 463 ++++++++++++++++++++ lib/core/sftp/sftp_server.dart | 167 ++++++++ test/sftp_modules_contract_test.dart | 59 +++ 4 files changed, 693 insertions(+), 612 deletions(-) create mode 100644 lib/core/sftp/sftp_remote.dart create mode 100644 lib/core/sftp/sftp_server.dart create mode 100644 test/sftp_modules_contract_test.dart diff --git a/lib/core/sftp.dart b/lib/core/sftp.dart index d6b0ab7..13017cc 100644 --- a/lib/core/sftp.dart +++ b/lib/core/sftp.dart @@ -1,612 +1,4 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:dartssh2/dartssh2.dart'; - -import '../l10n/tr.dart'; -import 'dns_resolver.dart'; -import 'remote_fs.dart'; -import 'secret_gate.dart'; -import 'webdav.dart' show DavException, DavItem, DavLockedException; -import 'remote_idle.dart'; - -/// 88: SFTP 서버 설정 (환경 설정 > 파일 탐색기 > 네트워크 서버). 비밀번호 · 개인 키 · 키 암호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음). -/// 호스트 키 (지문) 는 비밀이 아니라 설정에 둔다 - 처음 연결 때 사용자가 믿은 것 (TOFU). -class SftpServer { - final String id; - final String name; - final String host; - final int port; - final String user; - - /// 시작 폴더 (서버 기준 절대 경로). 비우면 로그인한 사용자의 홈 폴더. 탭의 "맨 위" 가 이 폴더다 - final String root; - final String password; - - /// 개인 키 (OpenSSH · PEM 글). 파일을 고르면 내용을 여기로 옮기고 원래 파일 경로는 저장하지 않는다 - final String privateKey; - - /// 개인 키의 암호 (사용자 결정 88-2: 안전 저장소에 저장) - final String keyPassphrase; - - /// 오래된 ssh-rsa (SHA-1) 서명 · SHA-1 키 교환 허용 (사용자 결정 88-3: 처음 끔, 서버마다 켬 - 예전 NAS 용) - final bool legacyRsa; - - /// 믿은 호스트 키: 종류 ("ssh-ed25519" 등) 와 지문 ("SHA256:…"). 비어 있으면 아직 믿은 키가 없음 - final String hostKeyType; - final String hostKeyFingerprint; - - const SftpServer({ - required this.id, - required this.name, - required this.host, - this.port = 22, - this.user = '', - this.root = '', - this.password = '', - this.privateKey = '', - this.keyPassphrase = '', - this.legacyRsa = false, - this.hostKeyType = '', - this.hostKeyFingerprint = '', - }); - - /// 탐색기 위쪽에 보일 이름 - String get label => name.trim().isNotEmpty ? name.trim() : host; - - SftpServer copyWith({ - String? name, - String? host, - int? port, - String? user, - String? root, - String? password, - String? privateKey, - String? keyPassphrase, - bool? legacyRsa, - String? hostKeyType, - String? hostKeyFingerprint, - }) => - SftpServer( - id: id, - name: name ?? this.name, - host: host ?? this.host, - port: port ?? this.port, - user: user ?? this.user, - root: root ?? this.root, - password: password ?? this.password, - privateKey: privateKey ?? this.privateKey, - keyPassphrase: keyPassphrase ?? this.keyPassphrase, - legacyRsa: legacyRsa ?? this.legacyRsa, - hostKeyType: hostKeyType ?? this.hostKeyType, - hostKeyFingerprint: hostKeyFingerprint ?? this.hostKeyFingerprint, - ); - - /// 비밀 값은 넣지 않는다 (안전 저장소). 저장해 두었는지만 - Map toJson() => { - 'id': id, - 'name': name, - 'host': host, - 'port': port, - 'user': user, - 'root': root, - 'hasPassword': password.isNotEmpty, - 'hasKey': privateKey.isNotEmpty, - 'legacyRsa': legacyRsa, - 'hostKeyType': hostKeyType, - 'hostKeyFingerprint': hostKeyFingerprint, - }; - - factory SftpServer.fromJson(Map j) => SftpServer( - id: '${j['id'] ?? DateTime.now().microsecondsSinceEpoch}', - name: j['name'] as String? ?? '', - host: j['host'] as String? ?? '', - port: switch (j['port']) { final int p when p > 0 && p < 65536 => p, _ => 22 }, - user: j['user'] as String? ?? '', - root: j['root'] as String? ?? '', - legacyRsa: j['legacyRsa'] == true, - hostKeyType: j['hostKeyType'] as String? ?? '', - hostKeyFingerprint: j['hostKeyFingerprint'] as String? ?? '', - ); -} - -/// SFTP 실패의 종류 (안내 글 · 할 일 단추를 고르는 데) -/// [keyFormat] · [keyPassphrase] · [keyRejected]: SSH 키 로그인의 실패를 따로 (관리자 지시 10/21 - 연결 확인에서 키 형식 · 암호 틀림 · 서버 거부를 나눠 알림) -enum SftpFailure { unreachable, auth, noSecret, hostKeyUnknown, hostKeyChanged, permission, notFound, keyFormat, keyPassphrase, keyRejected, other } - -/// 개인 키 글이 쓸 수 있는 꼴인지 본다 (OpenSSH · PEM RSA · PEM EC - dartssh2 가 읽는 것). 암호가 걸려 있으면 true. -/// 쓸 수 없으면 [SftpException] ([SftpFailure.keyFormat]) - PuTTY (.ppk) · PKCS#8 (BEGIN PRIVATE KEY) · 공개 키는 바꾸는 법을 알린다 -bool checkPrivateKey(String text) { - if (text.contains('PuTTY-User-Key-File')) throw SftpException(tr('PuTTY 키 (.ppk) 는 쓸 수 없습니다. PuTTYgen 의 [Conversions] > [Export OpenSSH key] 로 바꾼 파일을 고르세요'), SftpFailure.keyFormat); - try { - return SSHKeyPair.isEncryptedPem(text); - } catch (_) { - throw SftpException(tr('개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)'), SftpFailure.keyFormat); - } -} - -/// 개인 키 글 + 키 암호 → 로그인에 쓸 키. 키 형식 · 암호 없음 · 암호 틀림을 따로 알린다 -List parsePrivateKey(String text, String passphrase) { - final encrypted = checkPrivateKey(text); - if (encrypted && passphrase.isEmpty) throw SftpException(tr('개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요'), SftpFailure.keyPassphrase); - try { - return SSHKeyPair.fromPem(text, encrypted ? passphrase : null); - } catch (_) { - // 암호가 걸린 키를 풀지 못함 = 암호 틀림 (PEM 은 틀린 암호로 풀면 다른 오류가 나기도 한다) - if (encrypted) throw SftpException(tr('개인 키의 암호가 맞지 않습니다'), SftpFailure.keyPassphrase); - throw SftpException(tr('개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)'), SftpFailure.keyFormat); - } -} - -/// SFTP 오류 - WebDAV 오류와 같은 곳에서 보이도록 [DavException] 을 잇는다 -class SftpException extends DavException { - final SftpFailure kind; - const SftpException(super.message, this.kind); -} - -/// 처음 보는 호스트 키 (또는 바뀐 키): 화면이 지문을 보이고 믿을지 묻는다. 믿으면 [SftpServer.hostKeyFingerprint] 에 저장 -class SftpHostKeyException extends SftpException { - final String type; - final String fingerprint; - - /// 바뀐 경우 예전에 믿은 지문 (처음이면 null) - final String? previous; - const SftpHostKeyException(super.message, super.kind, {required this.type, required this.fingerprint, this.previous}); - bool get changed => previous != null; -} - -/// OpenSSH 와 같은 꼴의 지문 "SHA256:" + base64 (끝 '=' 없음). dartssh2 는 이 글을 바이트로 넘긴다 - -/// 판이 바뀌어 SHA-256 값 그대로 오면 직접 만든다 -String sshFingerprint(Uint8List fp) { - final text = utf8.decode(fp, allowMalformed: true); - return text.startsWith('SHA256:') ? text : 'SHA256:${base64.encode(fp).replaceAll('=', '')}'; -} - -/// 연결할 서버 주소. DNS 강제면 정한 DNS 로 (집 안 이름 · 주소는 기기 DNS) - 앱의 다른 연결과 같게 -typedef SftpConnect = Future Function(String host, int port, Duration timeout); - -/// 88: SFTP 클라이언트 (dartssh2). 서버마다 SSH 연결 하나를 열어 두고 다시 쓴다 (끊기면 다음에 다시 연다). -class SftpRemote implements RemoteClient { - final SftpServer server; - - /// 연결 방법 (DNS 강제 · 시험에서 바꿈). 처음 값: [connectWithDns] 를 쓰지 않으면 기기 DNS - final SftpConnect connect; - final Duration timeout; - SftpRemote(this.server, {SftpConnect? connect, this.timeout = const Duration(seconds: 15)}) - : connect = connect ?? SftpRegistry.connect; - - Future<_Conn>? _conn; - - /// 열린 연결 (없거나 끊겼으면 새로) - Future<_Conn> _open() { - final c = _conn; - if (c != null) return c; - final f = _connect(); - _conn = f; - f.then((x) => x.ssh.done.whenComplete(() { - if (identical(_conn, f)) _conn = null; - }), onError: (_) { - if (identical(_conn, f)) _conn = null; - }); - return f; - } - - Future<_Conn> _connect() async { - final s = server; - if (s.host.trim().isEmpty) throw SftpException(tr('SFTP 서버 주소가 없습니다'), SftpFailure.other); - final hasSecret = s.password.isNotEmpty || s.privateKey.isNotEmpty; - if (hasSecret && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage)); - // 키도 비밀번호도 없으면 (저장하지 않음) 시도하지 않고 정확히 알린다 (40 과 같게) - if (!hasSecret) throw SftpException(tr('저장된 비밀번호가 없습니다'), SftpFailure.noSecret); - Socket sock; - try { - sock = await connect(s.host.trim(), s.port, timeout); - } catch (e) { - throw SftpException(trf('서버 ({0}) 에 연결할 수 없습니다. 서버가 켜져 있는지, 집 밖이면 Tailscale 이 켜져 있는지 확인하세요', [s.host]), - SftpFailure.unreachable); - } - // 호스트 키: 처음 (믿은 키 없음) · 바뀜 → 연결을 끊고 화면이 묻게 한다 - (String, String)? seen; - List? keys; - if (s.privateKey.isNotEmpty) { - try { - keys = parsePrivateKey(s.privateKey, s.keyPassphrase); - } catch (_) { - sock.destroy(); - rethrow; - } - } - final ssh = SSHClient( - _Socket(sock), - username: s.user, - algorithms: s.legacyRsa ? legacyAlgorithms : const SSHAlgorithms(), - identities: keys, - onVerifyHostKey: (type, fp) { - final f = sshFingerprint(fp); - seen = (type, f); - return s.hostKeyFingerprint.isNotEmpty && s.hostKeyFingerprint == f; - }, - onPasswordRequest: s.password.isEmpty ? null : () => s.password, - // 일부 NAS 는 비밀번호를 keyboard-interactive 로 묻는다 - onUserInfoRequest: s.password.isEmpty ? null : (req) => [for (final _ in req.prompts) s.password], - ); - try { - await ssh.authenticated.timeout(timeout * 2); - final sftp = await ssh.sftp(); - final root = s.root.trim().isEmpty ? await sftp.absolute('.') : _clean(s.root.trim()); - return _Conn(ssh, sftp, root); - } catch (e) { - ssh.close(); - sock.destroy(); - throw _explain(e, seen); - } - } - - /// dartssh2 의 오류 → 사람 말 (무슨 일인지 · 할 일) - SftpException _explain(Object e, (String, String)? seen) { - final s = server; - if (e is SftpException) return e; - // 호스트 키를 믿지 않아 끊었으면 그 뒤의 오류 종류 (인증 중단 등) 와 상관없이 키 문제다 - if (seen != null && s.hostKeyFingerprint != seen.$2) { - final (type, fp) = seen; - if (s.hostKeyFingerprint.isEmpty) { - return SftpHostKeyException(trf('처음 연결하는 서버입니다. 서버의 키 지문이 맞는지 확인하세요: {0} ({1})', [fp, type]), - SftpFailure.hostKeyUnknown, - type: type, fingerprint: fp); - } - return SftpHostKeyException( - trf('서버의 키가 바뀌었습니다 - 중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 예전: {0} · 지금: {1}', [s.hostKeyFingerprint, fp]), - SftpFailure.hostKeyChanged, - type: type, - fingerprint: fp, - previous: s.hostKeyFingerprint); - } - if (e is TimeoutException) return SftpException(trf('서버 ({0}) 가 답하지 않습니다', [s.host]), SftpFailure.unreachable); - // 호스트 키까지 가지 못함 = 키 교환 · 암호 방식을 맞추지 못함 (오래된 서버면 ssh-rsa 허용을 안내) - if (seen == null || e is SSHHandshakeError) { - return SftpException( - s.legacyRsa - ? trf('서버와 암호 방식을 맞추지 못했습니다: {0}', [e is SSHMessageError ? e.message : '$e']) - : tr('서버와 암호 방식을 맞추지 못했습니다. 오래된 서버면 서버 설정의 "오래된 ssh-rsa 허용" 을 켜 보세요'), - SftpFailure.other); - } - if (e is SSHAuthError) { - // 관리자 지시 (10/21): 키만으로 들어가다 거부되면 서버가 그 키를 모르는 것 - 비밀번호와 따로 알린다 - if (s.privateKey.isNotEmpty && s.password.isEmpty) return SftpException(tr('서버가 이 키를 받지 않습니다. 서버의 ~/.ssh/authorized_keys 에 짝이 되는 공개 키 (.pub) 가 있는지, 아이디가 맞는지 보세요'), SftpFailure.keyRejected); - if (s.privateKey.isNotEmpty) return SftpException(tr('아이디 · 비밀번호 · 키가 모두 서버에서 거부되었습니다'), SftpFailure.auth); - return SftpException(tr('아이디나 비밀번호 (또는 키) 가 맞지 않습니다'), SftpFailure.auth); - } - return SftpException('$e', SftpFailure.other); - } - - /// 서버 기준 경로 → 서버의 절대 경로 - String _remote(_Conn c, String rel) { - final r = rel == '/' || rel.isEmpty ? '' : rel; - return c.root == '/' ? (r.isEmpty ? '/' : r) : '${c.root}$r'; - } - - static String _clean(String path) { - var x = path.replaceAll('\\', '/').replaceAll(RegExp(r'/+'), '/'); - if (!x.startsWith('/')) x = '/$x'; - if (x.length > 1 && x.endsWith('/')) x = x.substring(0, x.length - 1); - return x; - } - - /// 관리자 판단 (10/20): SFTP 명령 하나 (목록 · 열기 · 닫기 · 지우기 등) 를 정한 시간 ([RemoteIdle]) 까지만 기다린다 - - /// 서버가 멈추면 이 연결을 닫고 (다음에 다시 연다) "서버가 답하지 않습니다" - Future _cmd(Future f) => idleCall(f, onTimeout: close); - - /// 파일 다루다 난 오류 (SFTP 상태) → 사람 말. 끊긴 연결은 다음에 다시 연다 - Never _fail(Object e, String rel) { - if (e is RemoteIdleTimeout) throw e; - if (e is SftpStatusError) { - switch (e.code) { - case SftpStatusCode.noSuchFile: - throw SftpException(trf('없는 경로입니다: {0}', [rel]), SftpFailure.notFound); - case SftpStatusCode.permissionDenied: - throw SftpException(trf('이 폴더 · 파일을 다룰 권한이 없습니다: {0}', [rel]), SftpFailure.permission); - } - throw SftpException('${e.message} ($rel)', SftpFailure.other); - } - if (e is SftpException) throw e; - if (e is SSHError || e is SocketException || e is StateError) { - _conn = null; - throw SftpException(trf('서버 ({0}) 와의 연결이 끊겼습니다', [server.host]), SftpFailure.unreachable); - } - throw e; - } - - DavItem _item(String rel, SftpFileAttrs a) => DavItem(rel, - isDir: a.isDirectory, - size: a.isDirectory ? 0 : (a.size ?? 0), - modified: DateTime.fromMillisecondsSinceEpoch((a.modifyTime ?? 0) * 1000)); - - static String _child(String rel, String name) => rel == '/' ? '/$name' : '$rel/$name'; - static String _parent(String rel) => rel.lastIndexOf('/') <= 0 ? '/' : rel.substring(0, rel.lastIndexOf('/')); - - @override - Future> list(String rel) async { - final c = await _open(); - try { - final out = []; - for (final n in await _cmd(c.sftp.listdir(_remote(c, rel)))) { - if (n.filename == '.' || n.filename == '..') continue; - var a = n.attr; - // 링크는 가리키는 것으로 (폴더 링크를 폴더로) - 따라갈 수 없으면 크기 0 파일로 - if (a.type == SftpFileType.symbolicLink) { - try { - a = await _cmd(c.sftp.stat(_remote(c, _child(rel, n.filename)))); - } catch (_) {} - } - out.add(_item(_child(rel, n.filename), a)); - } - return out; - } catch (e) { - _fail(e, rel); - } - } - - @override - Future stat(String rel) async { - final c = await _open(); - try { - return _item(rel, await _cmd(c.sftp.stat(_remote(c, rel)))); - } on SftpStatusError catch (e) { - if (e.code == SftpStatusCode.noSuchFile) return null; - _fail(e, rel); - } catch (e) { - _fail(e, rel); - } - } - - @override - Future mkdirs(String rel) async { - if (rel == '/' || rel.isEmpty) return; - final st = await stat(rel); - if (st != null) { - if (st.isDir) return; - throw SftpException(trf('같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}', [rel]), SftpFailure.other); - } - await mkdirs(_parent(rel)); - final c = await _open(); - try { - await _cmd(c.sftp.mkdir(_remote(c, rel))); - } catch (e) { - // 그 사이에 다른 곳이 만들었으면 그대로 - if ((await stat(rel))?.isDir == true) return; - _fail(e, rel); - } - } - - @override - Future delete(String rel) async { - final st = await stat(rel); - if (st == null) return; - final c = await _open(); - try { - if (st.isDir) { - for (final x in await list(rel)) { - await delete(x.rel); - } - await _cmd(c.sftp.rmdir(_remote(c, rel))); - } else { - await _cmd(c.sftp.remove(_remote(c, rel))); - } - } catch (e) { - _fail(e, rel); - } - } - - @override - Future move(String from, String to, {bool overwrite = false}) async { - final c = await _open(); - // SFTP (v3) 의 이름 바꾸기는 대상이 있으면 실패한다 - 덮어쓰기면 대상을 먼저 지운다 (파일만. 폴더를 통째로 덮지 않음) - if (overwrite) { - final t = await stat(to); - if (t != null && !t.isDir) { - try { - await _cmd(c.sftp.remove(_remote(c, to))); - } catch (e) { - _fail(e, to); - } - } - } - try { - await _cmd(c.sftp.rename(_remote(c, from), _remote(c, to))); - } catch (e) { - _fail(e, from); - } - } - - @override - Future>> openRead(String rel) => openReadFrom(rel); - - /// [offset] 부터 [length] 바이트 (null 이면 끝까지) 만 읽는다 - 동영상 스트리밍 다리의 Range (sftp_next) - Future>> openReadFrom(String rel, {int offset = 0, int? length}) async { - final c = await _open(); - final SftpFile f; - try { - f = await _cmd(c.sftp.open(_remote(c, rel))); - } catch (e) { - _fail(e, rel); - } - late StreamController> out; - StreamSubscription? sub; - out = StreamController>( - onListen: () { - // 관리자 판단 (10/20): 조각 사이에 정한 시간 동안 아무것도 오지 않으면 실패 - 그 연결은 닫고 다음에 다시 연다 - sub = idleRead(f.read(offset: offset, length: length), onTimeout: close).listen(out.add, onError: (Object e) { - try { - _fail(e, rel); - } catch (x) { - out.addError(x); - } - }, onDone: () async { - await _cmd(f.close()).catchError((_) {}); - await out.close(); - }); - }, - onPause: () => sub?.pause(), - onResume: () => sub?.resume(), - onCancel: () async { - await sub?.cancel(); - await _cmd(f.close()).catchError((_) {}); - }, - ); - return out.stream; - } - - @override - Future write(String rel, Stream> data, {int? length}) async { - final c = await _open(); - final SftpFile f; - try { - f = await _cmd(c.sftp.open(_remote(c, rel), - mode: SftpFileOpenMode.create | SftpFileOpenMode.truncate | SftpFileOpenMode.write)); - } catch (e) { - _fail(e, rel); - } - try { - // 관리자 판단 (10/20): 서버가 받지 않아 정한 시간 동안 한 조각도 더 보내지 못하면 실패 (그 연결을 닫음) - await idleWrite(data, (x) => f.write(x.map((y) => y is Uint8List ? y : Uint8List.fromList(y))).done, onTimeout: close); - } catch (e) { - _fail(e, rel); - } finally { - await _cmd(f.close()).catchError((_) {}); - } - } - - @override - Future<(int?, int?)> quota() async { - final c = await _open(); - try { - final v = await _cmd(c.sftp.statvfs(c.root)); - final unit = v.fundamentalBlockSize > 0 ? v.fundamentalBlockSize : v.blockSize; - return (v.freeBlocksForNonRoot * unit, (v.totalBlocks - v.freeBlocks) * unit); - } catch (_) { - // statvfs 를 지원하지 않는 서버 (OpenSSH 확장) - 모름 - return (null, null); - } - } - - @override - void close() { - final c = _conn; - _conn = null; - c?.then((x) => x.ssh.close(), onError: (_) {}); - } - - /// 오래된 서버용 (88-3): 기본 알고리즘 + ssh-rsa (SHA-1) 서명 · SHA-1 키 교환 - static const legacyAlgorithms = SSHAlgorithms( - kex: [ - SSHKexType.x25519Rfc, - SSHKexType.x25519, - SSHKexType.nistp521, - SSHKexType.nistp384, - SSHKexType.nistp256, - SSHKexType.dhGexSha256, - SSHKexType.dh14Sha256, - SSHKexType.dh14Sha1, - SSHKexType.dhGexSha1, - ], - hostkey: [ - SSHHostkeyType.ed25519, - SSHHostkeyType.rsaSha512, - SSHHostkeyType.rsaSha256, - SSHHostkeyType.ecdsa521, - SSHHostkeyType.ecdsa384, - SSHHostkeyType.ecdsa256, - SSHHostkeyType.rsaSha1, - ], - ); -} - -class _Conn { - final SSHClient ssh; - final SftpClient sftp; - - /// 탭의 맨 위 (서버의 절대 경로) - final String root; - _Conn(this.ssh, this.sftp, this.root); -} - -/// dart:io Socket → dartssh2 (연결은 우리가 열어 DNS 강제를 따르게) -class _Socket implements SSHSocket { - final Socket _s; - _Socket(this._s) { - // 서버가 먼저 끊은 뒤의 쓰기 실패 (호스트 키를 믿지 않아 끊음 등) 가 처리되지 않은 오류로 앱에 올라오지 않게. - // dartssh2 는 같은 Future 를 기다려 끊김을 그대로 안다 - unawaited(_s.done.then((_) {}, onError: (Object _) {})); - } - /// 소켓 오류 (상대가 끊음 · 쓰기 실패) 는 끊김으로 (dartssh2 는 읽는 쪽의 오류를 받지 않는다 - 처리되지 않은 오류가 되었다) - @override - late final Stream stream = - _s.transform(StreamTransformer.fromHandlers(handleError: (e, st, out) => out.close())); - @override - StreamSink> get sink => _s; - // 닫기 · 비우기의 쓰기 실패도 같은 끊김이다 (dartssh2 가 기다리지 않는 Future - 처리되지 않은 오류가 되지 않게) - @override - Future close() => _s.close().then((_) {}, onError: (Object _) {}); - @override - Future get done => _s.done; - @override - void destroy() => _s.destroy(); - @override - Future flush() => _s.flush().then((_) {}, onError: (Object _) {}); -} - -/// 설정의 SFTP 서버 목록과 클라이언트 (서버마다 하나) -class SftpRegistry { - static final _clients = {}; - static final _servers = {}; - - /// 연결 방법 (main 에서 DNS 강제를 따르게 바꾼다). 처음 값: 기기 DNS - static SftpConnect connect = (host, port, timeout) => Socket.connect(host, port, timeout: timeout); - - /// 정한 DNS 로 이름을 풀어 연결 (집 안 이름 · 주소는 기기 DNS). [resolver] 가 DNS 강제가 아니면 기기 DNS - static SftpConnect connectWithDns(DnsResolver resolver) => (host, port, timeout) async { - if (InternetAddress.tryParse(host) != null || !resolver.config().forced || isLocalName(host)) { - return Socket.connect(host, port, timeout: timeout); - } - final addrs = await resolver.lookup(host); - Object? last; - for (final a in [...addrs.where((a) => a.type == InternetAddressType.IPv4), ...addrs.where((a) => a.type != InternetAddressType.IPv4)]) { - try { - return await Socket.connect(a, port, timeout: timeout); - } catch (e) { - last = e; - } - } - throw last ?? SocketException(trf('{0} 에 연결할 수 없습니다', [host])); - }; - - static void configure(List servers) { - final keep = {for (final s in servers) s.id: s}; - for (final id in _clients.keys.toList()) { - final old = _servers[id], now = keep[id]; - // 비밀 값은 toJson 에 없으므로 따로 비교 (바꾸면 바로 새 값으로 접속) - if (now == null || - old == null || - old.password != now.password || - old.privateKey != now.privateKey || - old.keyPassphrase != now.keyPassphrase || - jsonEncode(old.toJson()) != jsonEncode(now.toJson())) { - _clients.remove(id)?.close(); - } - } - _servers - ..clear() - ..addAll(keep); - } - - static List get servers => _servers.values.toList(); - static SftpServer? server(String id) => _servers[id]; - - static SftpRemote client(String id) { - final s = _servers[id]; - if (s == null) throw SftpException(trf('SFTP 서버 설정이 없습니다 ({0})', [id]), SftpFailure.other); - return _clients.putIfAbsent(id, () => SftpRemote(s)); - } -} +// 약속 (나누기 sftp): SFTP (dartssh2) - 부르는 곳은 이 파일 하나만 가져온다. +// 일은 lib/core/sftp/ 의 모듈이 한다: sftp_server (설정 · 오류 · 키 · 지문) · sftp_remote (원격 저장소 · 클라이언트 목록). +export 'sftp/sftp_server.dart'; +export 'sftp/sftp_remote.dart'; diff --git a/lib/core/sftp/sftp_remote.dart b/lib/core/sftp/sftp_remote.dart new file mode 100644 index 0000000..7987858 --- /dev/null +++ b/lib/core/sftp/sftp_remote.dart @@ -0,0 +1,463 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; + +import '../../l10n/tr.dart'; +import '../dns_resolver.dart'; +import '../remote_fs.dart'; +import '../secret_gate.dart'; +import '../webdav.dart' show DavItem, DavLockedException; +import '../remote_idle.dart'; +import 'sftp_server.dart'; + +// 약속 (나누기 sftp): SFTP 원격 저장소 ([RemoteClient], dartssh2) 와 서버마다 하나인 클라이언트 목록 ([SftpRegistry]). +// 입력: 서버 기준 경로 ("/" 로 시작). 출력: 목록 · 정보 ([DavItem]) · 부분 읽기 흐름 · 쓰기 · 남은 공간 (모르면 null). +// 오류: [SftpException] (연결 못함 · 로그인 · 키 · 권한 · 없는 경로 · 끊김) · 처음 보는 / 바뀐 호스트 키 [SftpHostKeyException] · +// 잠금 [DavLockedException] · 명령 · 조각 사이 시간 넘김 [RemoteIdleTimeout]. 끊긴 연결은 다음에 다시 연다. + +/// 88: SFTP 클라이언트 (dartssh2). 서버마다 SSH 연결 하나를 열어 두고 다시 쓴다 (끊기면 다음에 다시 연다). +class SftpRemote implements RemoteClient { + final SftpServer server; + + /// 연결 방법 (DNS 강제 · 시험에서 바꿈). 처음 값: [connectWithDns] 를 쓰지 않으면 기기 DNS + final SftpConnect connect; + final Duration timeout; + SftpRemote(this.server, {SftpConnect? connect, this.timeout = const Duration(seconds: 15)}) + : connect = connect ?? SftpRegistry.connect; + + Future<_Conn>? _conn; + + /// 열린 연결 (없거나 끊겼으면 새로) + Future<_Conn> _open() { + final c = _conn; + if (c != null) return c; + final f = _connect(); + _conn = f; + f.then((x) => x.ssh.done.whenComplete(() { + if (identical(_conn, f)) _conn = null; + }), onError: (_) { + if (identical(_conn, f)) _conn = null; + }); + return f; + } + + Future<_Conn> _connect() async { + final s = server; + if (s.host.trim().isEmpty) throw SftpException(tr('SFTP 서버 주소가 없습니다'), SftpFailure.other); + final hasSecret = s.password.isNotEmpty || s.privateKey.isNotEmpty; + if (hasSecret && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage)); + // 키도 비밀번호도 없으면 (저장하지 않음) 시도하지 않고 정확히 알린다 (40 과 같게) + if (!hasSecret) throw SftpException(tr('저장된 비밀번호가 없습니다'), SftpFailure.noSecret); + Socket sock; + try { + sock = await connect(s.host.trim(), s.port, timeout); + } catch (e) { + throw SftpException(trf('서버 ({0}) 에 연결할 수 없습니다. 서버가 켜져 있는지, 집 밖이면 Tailscale 이 켜져 있는지 확인하세요', [s.host]), + SftpFailure.unreachable); + } + // 호스트 키: 처음 (믿은 키 없음) · 바뀜 → 연결을 끊고 화면이 묻게 한다 + (String, String)? seen; + List? keys; + if (s.privateKey.isNotEmpty) { + try { + keys = parsePrivateKey(s.privateKey, s.keyPassphrase); + } catch (_) { + sock.destroy(); + rethrow; + } + } + final ssh = SSHClient( + _Socket(sock), + username: s.user, + algorithms: s.legacyRsa ? legacyAlgorithms : const SSHAlgorithms(), + identities: keys, + onVerifyHostKey: (type, fp) { + final f = sshFingerprint(fp); + seen = (type, f); + return s.hostKeyFingerprint.isNotEmpty && s.hostKeyFingerprint == f; + }, + onPasswordRequest: s.password.isEmpty ? null : () => s.password, + // 일부 NAS 는 비밀번호를 keyboard-interactive 로 묻는다 + onUserInfoRequest: s.password.isEmpty ? null : (req) => [for (final _ in req.prompts) s.password], + ); + try { + await ssh.authenticated.timeout(timeout * 2); + final sftp = await ssh.sftp(); + final root = s.root.trim().isEmpty ? await sftp.absolute('.') : _clean(s.root.trim()); + return _Conn(ssh, sftp, root); + } catch (e) { + ssh.close(); + sock.destroy(); + throw _explain(e, seen); + } + } + + /// dartssh2 의 오류 → 사람 말 (무슨 일인지 · 할 일) + SftpException _explain(Object e, (String, String)? seen) { + final s = server; + if (e is SftpException) return e; + // 호스트 키를 믿지 않아 끊었으면 그 뒤의 오류 종류 (인증 중단 등) 와 상관없이 키 문제다 + if (seen != null && s.hostKeyFingerprint != seen.$2) { + final (type, fp) = seen; + if (s.hostKeyFingerprint.isEmpty) { + return SftpHostKeyException(trf('처음 연결하는 서버입니다. 서버의 키 지문이 맞는지 확인하세요: {0} ({1})', [fp, type]), + SftpFailure.hostKeyUnknown, + type: type, fingerprint: fp); + } + return SftpHostKeyException( + trf('서버의 키가 바뀌었습니다 - 중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 예전: {0} · 지금: {1}', [s.hostKeyFingerprint, fp]), + SftpFailure.hostKeyChanged, + type: type, + fingerprint: fp, + previous: s.hostKeyFingerprint); + } + if (e is TimeoutException) return SftpException(trf('서버 ({0}) 가 답하지 않습니다', [s.host]), SftpFailure.unreachable); + // 호스트 키까지 가지 못함 = 키 교환 · 암호 방식을 맞추지 못함 (오래된 서버면 ssh-rsa 허용을 안내) + if (seen == null || e is SSHHandshakeError) { + return SftpException( + s.legacyRsa + ? trf('서버와 암호 방식을 맞추지 못했습니다: {0}', [e is SSHMessageError ? e.message : '$e']) + : tr('서버와 암호 방식을 맞추지 못했습니다. 오래된 서버면 서버 설정의 "오래된 ssh-rsa 허용" 을 켜 보세요'), + SftpFailure.other); + } + if (e is SSHAuthError) { + // 관리자 지시 (10/21): 키만으로 들어가다 거부되면 서버가 그 키를 모르는 것 - 비밀번호와 따로 알린다 + if (s.privateKey.isNotEmpty && s.password.isEmpty) return SftpException(tr('서버가 이 키를 받지 않습니다. 서버의 ~/.ssh/authorized_keys 에 짝이 되는 공개 키 (.pub) 가 있는지, 아이디가 맞는지 보세요'), SftpFailure.keyRejected); + if (s.privateKey.isNotEmpty) return SftpException(tr('아이디 · 비밀번호 · 키가 모두 서버에서 거부되었습니다'), SftpFailure.auth); + return SftpException(tr('아이디나 비밀번호 (또는 키) 가 맞지 않습니다'), SftpFailure.auth); + } + return SftpException('$e', SftpFailure.other); + } + + /// 서버 기준 경로 → 서버의 절대 경로 + String _remote(_Conn c, String rel) { + final r = rel == '/' || rel.isEmpty ? '' : rel; + return c.root == '/' ? (r.isEmpty ? '/' : r) : '${c.root}$r'; + } + + static String _clean(String path) { + var x = path.replaceAll('\\', '/').replaceAll(RegExp(r'/+'), '/'); + if (!x.startsWith('/')) x = '/$x'; + if (x.length > 1 && x.endsWith('/')) x = x.substring(0, x.length - 1); + return x; + } + + /// 관리자 판단 (10/20): SFTP 명령 하나 (목록 · 열기 · 닫기 · 지우기 등) 를 정한 시간 ([RemoteIdle]) 까지만 기다린다 - + /// 서버가 멈추면 이 연결을 닫고 (다음에 다시 연다) "서버가 답하지 않습니다" + Future _cmd(Future f) => idleCall(f, onTimeout: close); + + /// 파일 다루다 난 오류 (SFTP 상태) → 사람 말. 끊긴 연결은 다음에 다시 연다 + Never _fail(Object e, String rel) { + if (e is RemoteIdleTimeout) throw e; + if (e is SftpStatusError) { + switch (e.code) { + case SftpStatusCode.noSuchFile: + throw SftpException(trf('없는 경로입니다: {0}', [rel]), SftpFailure.notFound); + case SftpStatusCode.permissionDenied: + throw SftpException(trf('이 폴더 · 파일을 다룰 권한이 없습니다: {0}', [rel]), SftpFailure.permission); + } + throw SftpException('${e.message} ($rel)', SftpFailure.other); + } + if (e is SftpException) throw e; + if (e is SSHError || e is SocketException || e is StateError) { + _conn = null; + throw SftpException(trf('서버 ({0}) 와의 연결이 끊겼습니다', [server.host]), SftpFailure.unreachable); + } + throw e; + } + + DavItem _item(String rel, SftpFileAttrs a) => DavItem(rel, + isDir: a.isDirectory, + size: a.isDirectory ? 0 : (a.size ?? 0), + modified: DateTime.fromMillisecondsSinceEpoch((a.modifyTime ?? 0) * 1000)); + + static String _child(String rel, String name) => rel == '/' ? '/$name' : '$rel/$name'; + static String _parent(String rel) => rel.lastIndexOf('/') <= 0 ? '/' : rel.substring(0, rel.lastIndexOf('/')); + + @override + Future> list(String rel) async { + final c = await _open(); + try { + final out = []; + for (final n in await _cmd(c.sftp.listdir(_remote(c, rel)))) { + if (n.filename == '.' || n.filename == '..') continue; + var a = n.attr; + // 링크는 가리키는 것으로 (폴더 링크를 폴더로) - 따라갈 수 없으면 크기 0 파일로 + if (a.type == SftpFileType.symbolicLink) { + try { + a = await _cmd(c.sftp.stat(_remote(c, _child(rel, n.filename)))); + } catch (_) {} + } + out.add(_item(_child(rel, n.filename), a)); + } + return out; + } catch (e) { + _fail(e, rel); + } + } + + @override + Future stat(String rel) async { + final c = await _open(); + try { + return _item(rel, await _cmd(c.sftp.stat(_remote(c, rel)))); + } on SftpStatusError catch (e) { + if (e.code == SftpStatusCode.noSuchFile) return null; + _fail(e, rel); + } catch (e) { + _fail(e, rel); + } + } + + @override + Future mkdirs(String rel) async { + if (rel == '/' || rel.isEmpty) return; + final st = await stat(rel); + if (st != null) { + if (st.isDir) return; + throw SftpException(trf('같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}', [rel]), SftpFailure.other); + } + await mkdirs(_parent(rel)); + final c = await _open(); + try { + await _cmd(c.sftp.mkdir(_remote(c, rel))); + } catch (e) { + // 그 사이에 다른 곳이 만들었으면 그대로 + if ((await stat(rel))?.isDir == true) return; + _fail(e, rel); + } + } + + @override + Future delete(String rel) async { + final st = await stat(rel); + if (st == null) return; + final c = await _open(); + try { + if (st.isDir) { + for (final x in await list(rel)) { + await delete(x.rel); + } + await _cmd(c.sftp.rmdir(_remote(c, rel))); + } else { + await _cmd(c.sftp.remove(_remote(c, rel))); + } + } catch (e) { + _fail(e, rel); + } + } + + @override + Future move(String from, String to, {bool overwrite = false}) async { + final c = await _open(); + // SFTP (v3) 의 이름 바꾸기는 대상이 있으면 실패한다 - 덮어쓰기면 대상을 먼저 지운다 (파일만. 폴더를 통째로 덮지 않음) + if (overwrite) { + final t = await stat(to); + if (t != null && !t.isDir) { + try { + await _cmd(c.sftp.remove(_remote(c, to))); + } catch (e) { + _fail(e, to); + } + } + } + try { + await _cmd(c.sftp.rename(_remote(c, from), _remote(c, to))); + } catch (e) { + _fail(e, from); + } + } + + @override + Future>> openRead(String rel) => openReadFrom(rel); + + /// [offset] 부터 [length] 바이트 (null 이면 끝까지) 만 읽는다 - 동영상 스트리밍 다리의 Range (sftp_next) + Future>> openReadFrom(String rel, {int offset = 0, int? length}) async { + final c = await _open(); + final SftpFile f; + try { + f = await _cmd(c.sftp.open(_remote(c, rel))); + } catch (e) { + _fail(e, rel); + } + late StreamController> out; + StreamSubscription? sub; + out = StreamController>( + onListen: () { + // 관리자 판단 (10/20): 조각 사이에 정한 시간 동안 아무것도 오지 않으면 실패 - 그 연결은 닫고 다음에 다시 연다 + sub = idleRead(f.read(offset: offset, length: length), onTimeout: close).listen(out.add, onError: (Object e) { + try { + _fail(e, rel); + } catch (x) { + out.addError(x); + } + }, onDone: () async { + await _cmd(f.close()).catchError((_) {}); + await out.close(); + }); + }, + onPause: () => sub?.pause(), + onResume: () => sub?.resume(), + onCancel: () async { + await sub?.cancel(); + await _cmd(f.close()).catchError((_) {}); + }, + ); + return out.stream; + } + + @override + Future write(String rel, Stream> data, {int? length}) async { + final c = await _open(); + final SftpFile f; + try { + f = await _cmd(c.sftp.open(_remote(c, rel), + mode: SftpFileOpenMode.create | SftpFileOpenMode.truncate | SftpFileOpenMode.write)); + } catch (e) { + _fail(e, rel); + } + try { + // 관리자 판단 (10/20): 서버가 받지 않아 정한 시간 동안 한 조각도 더 보내지 못하면 실패 (그 연결을 닫음) + await idleWrite(data, (x) => f.write(x.map((y) => y is Uint8List ? y : Uint8List.fromList(y))).done, onTimeout: close); + } catch (e) { + _fail(e, rel); + } finally { + await _cmd(f.close()).catchError((_) {}); + } + } + + @override + Future<(int?, int?)> quota() async { + final c = await _open(); + try { + final v = await _cmd(c.sftp.statvfs(c.root)); + final unit = v.fundamentalBlockSize > 0 ? v.fundamentalBlockSize : v.blockSize; + return (v.freeBlocksForNonRoot * unit, (v.totalBlocks - v.freeBlocks) * unit); + } catch (_) { + // statvfs 를 지원하지 않는 서버 (OpenSSH 확장) - 모름 + return (null, null); + } + } + + @override + void close() { + final c = _conn; + _conn = null; + c?.then((x) => x.ssh.close(), onError: (_) {}); + } + + /// 오래된 서버용 (88-3): 기본 알고리즘 + ssh-rsa (SHA-1) 서명 · SHA-1 키 교환 + static const legacyAlgorithms = SSHAlgorithms( + kex: [ + SSHKexType.x25519Rfc, + SSHKexType.x25519, + SSHKexType.nistp521, + SSHKexType.nistp384, + SSHKexType.nistp256, + SSHKexType.dhGexSha256, + SSHKexType.dh14Sha256, + SSHKexType.dh14Sha1, + SSHKexType.dhGexSha1, + ], + hostkey: [ + SSHHostkeyType.ed25519, + SSHHostkeyType.rsaSha512, + SSHHostkeyType.rsaSha256, + SSHHostkeyType.ecdsa521, + SSHHostkeyType.ecdsa384, + SSHHostkeyType.ecdsa256, + SSHHostkeyType.rsaSha1, + ], + ); +} + +class _Conn { + final SSHClient ssh; + final SftpClient sftp; + + /// 탭의 맨 위 (서버의 절대 경로) + final String root; + _Conn(this.ssh, this.sftp, this.root); +} + +/// dart:io Socket → dartssh2 (연결은 우리가 열어 DNS 강제를 따르게) +class _Socket implements SSHSocket { + final Socket _s; + _Socket(this._s) { + // 서버가 먼저 끊은 뒤의 쓰기 실패 (호스트 키를 믿지 않아 끊음 등) 가 처리되지 않은 오류로 앱에 올라오지 않게. + // dartssh2 는 같은 Future 를 기다려 끊김을 그대로 안다 + unawaited(_s.done.then((_) {}, onError: (Object _) {})); + } + /// 소켓 오류 (상대가 끊음 · 쓰기 실패) 는 끊김으로 (dartssh2 는 읽는 쪽의 오류를 받지 않는다 - 처리되지 않은 오류가 되었다) + @override + late final Stream stream = + _s.transform(StreamTransformer.fromHandlers(handleError: (e, st, out) => out.close())); + @override + StreamSink> get sink => _s; + // 닫기 · 비우기의 쓰기 실패도 같은 끊김이다 (dartssh2 가 기다리지 않는 Future - 처리되지 않은 오류가 되지 않게) + @override + Future close() => _s.close().then((_) {}, onError: (Object _) {}); + @override + Future get done => _s.done; + @override + void destroy() => _s.destroy(); + @override + Future flush() => _s.flush().then((_) {}, onError: (Object _) {}); +} + +/// 설정의 SFTP 서버 목록과 클라이언트 (서버마다 하나) +class SftpRegistry { + static final _clients = {}; + static final _servers = {}; + + /// 연결 방법 (main 에서 DNS 강제를 따르게 바꾼다). 처음 값: 기기 DNS + static SftpConnect connect = (host, port, timeout) => Socket.connect(host, port, timeout: timeout); + + /// 정한 DNS 로 이름을 풀어 연결 (집 안 이름 · 주소는 기기 DNS). [resolver] 가 DNS 강제가 아니면 기기 DNS + static SftpConnect connectWithDns(DnsResolver resolver) => (host, port, timeout) async { + if (InternetAddress.tryParse(host) != null || !resolver.config().forced || isLocalName(host)) { + return Socket.connect(host, port, timeout: timeout); + } + final addrs = await resolver.lookup(host); + Object? last; + for (final a in [...addrs.where((a) => a.type == InternetAddressType.IPv4), ...addrs.where((a) => a.type != InternetAddressType.IPv4)]) { + try { + return await Socket.connect(a, port, timeout: timeout); + } catch (e) { + last = e; + } + } + throw last ?? SocketException(trf('{0} 에 연결할 수 없습니다', [host])); + }; + + static void configure(List servers) { + final keep = {for (final s in servers) s.id: s}; + for (final id in _clients.keys.toList()) { + final old = _servers[id], now = keep[id]; + // 비밀 값은 toJson 에 없으므로 따로 비교 (바꾸면 바로 새 값으로 접속) + if (now == null || + old == null || + old.password != now.password || + old.privateKey != now.privateKey || + old.keyPassphrase != now.keyPassphrase || + jsonEncode(old.toJson()) != jsonEncode(now.toJson())) { + _clients.remove(id)?.close(); + } + } + _servers + ..clear() + ..addAll(keep); + } + + static List get servers => _servers.values.toList(); + static SftpServer? server(String id) => _servers[id]; + + static SftpRemote client(String id) { + final s = _servers[id]; + if (s == null) throw SftpException(trf('SFTP 서버 설정이 없습니다 ({0})', [id]), SftpFailure.other); + return _clients.putIfAbsent(id, () => SftpRemote(s)); + } +} diff --git a/lib/core/sftp/sftp_server.dart b/lib/core/sftp/sftp_server.dart new file mode 100644 index 0000000..1f139eb --- /dev/null +++ b/lib/core/sftp/sftp_server.dart @@ -0,0 +1,167 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; + +import '../../l10n/tr.dart'; +import '../webdav.dart' show DavException; + +// 약속 (나누기 sftp): SFTP 서버 설정 (저장 꼴) · 실패 종류 · 오류 · 개인 키 읽기 · 호스트 키 지문. +// 입력: 설정 JSON (비밀 값 없음) · 개인 키 글 + 키 암호 · dartssh2 가 넘긴 지문 바이트. 출력: [SftpServer] · 키 · "SHA256:…" 지문. +// 오류: 키 형식 · 암호 없음 · 암호 틀림 → [SftpException] ([SftpFailure.keyFormat] · [SftpFailure.keyPassphrase]). 그 밖은 던지지 않는다. + +/// 88: SFTP 서버 설정 (환경 설정 > 파일 탐색기 > 네트워크 서버). 비밀번호 · 개인 키 · 키 암호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음). +/// 호스트 키 (지문) 는 비밀이 아니라 설정에 둔다 - 처음 연결 때 사용자가 믿은 것 (TOFU). +class SftpServer { + final String id; + final String name; + final String host; + final int port; + final String user; + + /// 시작 폴더 (서버 기준 절대 경로). 비우면 로그인한 사용자의 홈 폴더. 탭의 "맨 위" 가 이 폴더다 + final String root; + final String password; + + /// 개인 키 (OpenSSH · PEM 글). 파일을 고르면 내용을 여기로 옮기고 원래 파일 경로는 저장하지 않는다 + final String privateKey; + + /// 개인 키의 암호 (사용자 결정 88-2: 안전 저장소에 저장) + final String keyPassphrase; + + /// 오래된 ssh-rsa (SHA-1) 서명 · SHA-1 키 교환 허용 (사용자 결정 88-3: 처음 끔, 서버마다 켬 - 예전 NAS 용) + final bool legacyRsa; + + /// 믿은 호스트 키: 종류 ("ssh-ed25519" 등) 와 지문 ("SHA256:…"). 비어 있으면 아직 믿은 키가 없음 + final String hostKeyType; + final String hostKeyFingerprint; + + const SftpServer({ + required this.id, + required this.name, + required this.host, + this.port = 22, + this.user = '', + this.root = '', + this.password = '', + this.privateKey = '', + this.keyPassphrase = '', + this.legacyRsa = false, + this.hostKeyType = '', + this.hostKeyFingerprint = '', + }); + + /// 탐색기 위쪽에 보일 이름 + String get label => name.trim().isNotEmpty ? name.trim() : host; + + SftpServer copyWith({ + String? name, + String? host, + int? port, + String? user, + String? root, + String? password, + String? privateKey, + String? keyPassphrase, + bool? legacyRsa, + String? hostKeyType, + String? hostKeyFingerprint, + }) => + SftpServer( + id: id, + name: name ?? this.name, + host: host ?? this.host, + port: port ?? this.port, + user: user ?? this.user, + root: root ?? this.root, + password: password ?? this.password, + privateKey: privateKey ?? this.privateKey, + keyPassphrase: keyPassphrase ?? this.keyPassphrase, + legacyRsa: legacyRsa ?? this.legacyRsa, + hostKeyType: hostKeyType ?? this.hostKeyType, + hostKeyFingerprint: hostKeyFingerprint ?? this.hostKeyFingerprint, + ); + + /// 비밀 값은 넣지 않는다 (안전 저장소). 저장해 두었는지만 + Map toJson() => { + 'id': id, + 'name': name, + 'host': host, + 'port': port, + 'user': user, + 'root': root, + 'hasPassword': password.isNotEmpty, + 'hasKey': privateKey.isNotEmpty, + 'legacyRsa': legacyRsa, + 'hostKeyType': hostKeyType, + 'hostKeyFingerprint': hostKeyFingerprint, + }; + + factory SftpServer.fromJson(Map j) => SftpServer( + id: '${j['id'] ?? DateTime.now().microsecondsSinceEpoch}', + name: j['name'] as String? ?? '', + host: j['host'] as String? ?? '', + port: switch (j['port']) { final int p when p > 0 && p < 65536 => p, _ => 22 }, + user: j['user'] as String? ?? '', + root: j['root'] as String? ?? '', + legacyRsa: j['legacyRsa'] == true, + hostKeyType: j['hostKeyType'] as String? ?? '', + hostKeyFingerprint: j['hostKeyFingerprint'] as String? ?? '', + ); +} + +/// SFTP 실패의 종류 (안내 글 · 할 일 단추를 고르는 데) +/// [keyFormat] · [keyPassphrase] · [keyRejected]: SSH 키 로그인의 실패를 따로 (관리자 지시 10/21 - 연결 확인에서 키 형식 · 암호 틀림 · 서버 거부를 나눠 알림) +enum SftpFailure { unreachable, auth, noSecret, hostKeyUnknown, hostKeyChanged, permission, notFound, keyFormat, keyPassphrase, keyRejected, other } + +/// 개인 키 글이 쓸 수 있는 꼴인지 본다 (OpenSSH · PEM RSA · PEM EC - dartssh2 가 읽는 것). 암호가 걸려 있으면 true. +/// 쓸 수 없으면 [SftpException] ([SftpFailure.keyFormat]) - PuTTY (.ppk) · PKCS#8 (BEGIN PRIVATE KEY) · 공개 키는 바꾸는 법을 알린다 +bool checkPrivateKey(String text) { + if (text.contains('PuTTY-User-Key-File')) throw SftpException(tr('PuTTY 키 (.ppk) 는 쓸 수 없습니다. PuTTYgen 의 [Conversions] > [Export OpenSSH key] 로 바꾼 파일을 고르세요'), SftpFailure.keyFormat); + try { + return SSHKeyPair.isEncryptedPem(text); + } catch (_) { + throw SftpException(tr('개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)'), SftpFailure.keyFormat); + } +} + +/// 개인 키 글 + 키 암호 → 로그인에 쓸 키. 키 형식 · 암호 없음 · 암호 틀림을 따로 알린다 +List parsePrivateKey(String text, String passphrase) { + final encrypted = checkPrivateKey(text); + if (encrypted && passphrase.isEmpty) throw SftpException(tr('개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요'), SftpFailure.keyPassphrase); + try { + return SSHKeyPair.fromPem(text, encrypted ? passphrase : null); + } catch (_) { + // 암호가 걸린 키를 풀지 못함 = 암호 틀림 (PEM 은 틀린 암호로 풀면 다른 오류가 나기도 한다) + if (encrypted) throw SftpException(tr('개인 키의 암호가 맞지 않습니다'), SftpFailure.keyPassphrase); + throw SftpException(tr('개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)'), SftpFailure.keyFormat); + } +} + +/// SFTP 오류 - WebDAV 오류와 같은 곳에서 보이도록 [DavException] 을 잇는다 +class SftpException extends DavException { + final SftpFailure kind; + const SftpException(super.message, this.kind); +} + +/// 처음 보는 호스트 키 (또는 바뀐 키): 화면이 지문을 보이고 믿을지 묻는다. 믿으면 [SftpServer.hostKeyFingerprint] 에 저장 +class SftpHostKeyException extends SftpException { + final String type; + final String fingerprint; + + /// 바뀐 경우 예전에 믿은 지문 (처음이면 null) + final String? previous; + const SftpHostKeyException(super.message, super.kind, {required this.type, required this.fingerprint, this.previous}); + bool get changed => previous != null; +} + +/// OpenSSH 와 같은 꼴의 지문 "SHA256:" + base64 (끝 '=' 없음). dartssh2 는 이 글을 바이트로 넘긴다 - +/// 판이 바뀌어 SHA-256 값 그대로 오면 직접 만든다 +String sshFingerprint(Uint8List fp) { + final text = utf8.decode(fp, allowMalformed: true); + return text.startsWith('SHA256:') ? text : 'SHA256:${base64.encode(fp).replaceAll('=', '')}'; +} + +/// 연결할 서버 주소. DNS 강제면 정한 DNS 로 (집 안 이름 · 주소는 기기 DNS) - 앱의 다른 연결과 같게 +typedef SftpConnect = Future Function(String host, int port, Duration timeout); diff --git a/test/sftp_modules_contract_test.dart b/test/sftp_modules_contract_test.dart new file mode 100644 index 0000000..99b7e01 --- /dev/null +++ b/test/sftp_modules_contract_test.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/sftp/sftp_remote.dart'; +import 'package:jj_mkvmaker/core/sftp/sftp_server.dart'; +import 'package:path/path.dart' as p; + +import 'support/sftp_server.dart'; + +/// 나누기 sftp 약속 시험: lib/core/sftp/ 모듈마다 맨 위 약속 - 실제 서버 (tool/sftp_test_server.py, paramiko) · 실제 시험 폴더. +/// 시험용 파이썬이 없으면 서버 시험은 건너뛴다 (sftp_test 와 같음) +void main() { + test('sftp_server: JSON 에 비밀 값 없음 · 지문 꼴 · 키 형식 · 암호 오류 종류', () { + const s = SftpServer(id: 'a', name: '', host: 'nas', password: 'secret', privateKey: 'k'); + final j = s.toJson(); + expect(jsonEncode(j), isNot(contains('secret'))); + expect((j['hasPassword'], j['hasKey'], s.label), (true, true, 'nas')); + expect(SftpServer.fromJson({'id': 'x', 'port': 99999}).port, 22); + expect(sshFingerprint(Uint8List.fromList(utf8.encode('SHA256:abc'))), 'SHA256:abc'); + expect(sshFingerprint(Uint8List.fromList([1, 2, 3])), 'SHA256:AQID'); + expect(() => checkPrivateKey('PuTTY-User-Key-File-3: ssh-ed25519'), + throwsA(isA().having((e) => e.kind, 'kind', SftpFailure.keyFormat))); + expect(() => parsePrivateKey('not a key', ''), throwsA(isA().having((e) => e.kind, 'kind', SftpFailure.keyFormat))); + }); + + test('sftp_remote: 처음 보는 호스트 키는 지문과 함께 막음 → 믿으면 쓰기 · 목록 · 부분 읽기 · 옮기기 · 지우기 · 틀린 비밀번호는 auth', () async { + final tmp = Directory.systemTemp.createTempSync('jj_sftpm_'); + final root = Directory(p.join(tmp.path, 'root'))..createSync(); + final srv = await SftpTestServer.start(root: root.path, hostKeyFile: p.join(tmp.path, 'host.key')); + addTearDown(() async { + await srv.stop(); + tmp.deleteSync(recursive: true); + }); + SftpServer conf({String fp = '', String pw = 'pw'}) => + SftpServer(id: 'm1', name: '', host: '127.0.0.1', port: srv.port, user: 'jj', password: pw, hostKeyFingerprint: fp, root: root.path); + final first = SftpRemote(conf()); + final e = await first.list('/').then((_) => null, onError: (Object x) => x); + first.close(); + expect(e, isA().having((x) => x.fingerprint, 'fp', srv.fingerprint).having((x) => x.changed, 'changed', isFalse)); + final c = SftpRemote(conf(fp: srv.fingerprint)); + addTearDown(c.close); + await c.mkdirs('/가/나'); + await c.write('/가/나/a.txt', Stream.value(utf8.encode('0123456789'))); + expect(File(p.join(root.path, '가', '나', 'a.txt')).readAsStringSync(), '0123456789'); + expect((await c.list('/가/나')).map((x) => (x.rel, x.size)), [('/가/나/a.txt', 10)]); + expect(await c.stat('/없음'), isNull); + final part = await (await c.openReadFrom('/가/나/a.txt', offset: 4, length: 3)).expand((x) => x).toList(); + expect(utf8.decode(part), '456'); + await c.move('/가/나/a.txt', '/가/b.txt'); + expect(File(p.join(root.path, '가', 'b.txt')).existsSync(), isTrue); + await c.delete('/가'); + expect(Directory(p.join(root.path, '가')).existsSync(), isFalse); + final bad = SftpRemote(conf(fp: srv.fingerprint, pw: 'wrong')); + addTearDown(bad.close); + await expectLater(bad.list('/'), throwsA(isA().having((x) => x.kind, 'kind', SftpFailure.auth))); + }, skip: sftpTestServerAvailable ? false : '시험용 파이썬 없음', timeout: const Timeout(Duration(minutes: 3))); +} From 343ef533cd75c318a33af03887b72db1a8c20955 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:56:14 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=EB=82=98=EB=88=84=EA=B8=B0=20core=20D:=20s?= =?UTF-8?q?ync=5Ftools=20(656)=20=EB=A5=BC=20lib/core/sync/=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=203=EA=B0=9C=EB=A1=9C=20(=E2=86=92=20=EC=A0=95?= =?UTF-8?q?=EB=A9=B4=20sync=5Ftools)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rsync_options (옵션 글 고치기 · 나누기 · 문제 찾기 · 고칠 방법 · 목록 파일 값) - copy_commands (복사 방법 · rsync 인수 · MSYS 경로 · robocopy 묶음 · 성공 코드 · MSYS2 rsync 패키지) - copy_output (rsync · robocopy 출력 읽기) - sync_tools.dart 는 export 만 (부르는 쪽은 바꾸지 않음). 임시 이름 제외는 files/path_rules 를 부름 - 약속 시험: test/sync_modules_contract_test.dart Co-Authored-By: Claude Opus 5.5 --- lib/core/sync/copy_commands.dart | 238 ++++++++++ lib/core/sync/copy_output.dart | 102 +++++ lib/core/sync/rsync_options.dart | 328 +++++++++++++ lib/core/sync_tools.dart | 662 +-------------------------- test/sync_modules_contract_test.dart | 45 ++ 5 files changed, 719 insertions(+), 656 deletions(-) create mode 100644 lib/core/sync/copy_commands.dart create mode 100644 lib/core/sync/copy_output.dart create mode 100644 lib/core/sync/rsync_options.dart create mode 100644 test/sync_modules_contract_test.dart diff --git a/lib/core/sync/copy_commands.dart b/lib/core/sync/copy_commands.dart new file mode 100644 index 0000000..dd8590f --- /dev/null +++ b/lib/core/sync/copy_commands.dart @@ -0,0 +1,238 @@ +import 'package:path/path.dart' as p; + +import '../files/path_rules.dart' show appTempRsyncExcludes; +import 'rsync_options.dart'; + +// 약속 (나누기 sync_tools): 복사 방법 (앱 · rsync · robocopy) 과 그 명령 인수 - rsync 인수 · Windows 경로를 MSYS2 꼴로 · +// robocopy 실행 묶음 · 성공 종료 코드 · Windows 용 rsync 패키지 (MSYS2, SHA256 고정). +// 입력: 옵션 글 · 원본들 · 대상 · 속도 제한 · 이동인지. 출력: 인수 목록 (실행은 부르는 쪽). 오류: 던지지 않는다. + +/// 파일 탐색기의 복사 · 이동 방법 (환경 설정 > 파일 탐색기 > 복사 · 동기화) +/// +/// - builtin: 앱이 직접 복사 ("현재 방식"). 모든 기기. +/// - rsync: rsync (Windows 는 MSYS2 의 rsync 를 처음 쓸 때 내려받음, Android 는 직접 지정한 실행 파일) +/// - robocopy: Windows 에 들어 있는 robocopy (Windows 만) +enum CopyMethod { + builtin('현재 방식'), + rsync('rsync'), + robocopy('robocopy'); + + final String label; + const CopyMethod(this.label); + + static CopyMethod of(String? name) => values.firstWhere((m) => m.name == name, orElse: () => builtin); +} + +const defaultRsyncOptions = '-avPog'; +const defaultRobocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2'; + +/// 관리자 판단 (10/20, 28 확인): rsync (Windows MSYS2) 는 목록 · 기록 · 배치 파일의 C:\… · C:/… · \\서버\… 를 열지 못한다 +/// ("failed to open exclude file", exit 11) - 그 값들을 경로처럼 [toCygwinPath] 로 바꿔 넘긴다 (막지 않음). +/// 대상: --exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · "merge 파일" · --log-file · --write-batch · --read-batch · --only-write-batch +List rsyncFileOptionArgs(List words, {required bool windows}) { + if (!windows) return words; + const opts = { + '--exclude-from', '--include-from', '--files-from', '--log-file', '--write-batch', '--read-batch', '--only-write-batch', + }; + // 28 · 15 확인 (10/20, MSYS 3.5.1): 이 파일들은 UNC 를 \\서버\… 로만 연다 (//서버/… 는 "failed to open", exit 11) - UNC 는 \\ 로, + // 드라이브는 /cygdrive/… 로 + String conv(String v) => RegExp(r'^[A-Za-z]:[\\/]').hasMatch(v) + ? toCygwinPath(v) + : RegExp(r'^(?:\\\\|//)(?!cygdrive/)').hasMatch(v) + ? v.replaceAll('/', '\\') + : v; + String rule(String r) { + final m = RegExp(r'^(\s*(?:\.|merge)(?:,[^\s]*)?\s+)(.+)$').firstMatch(r); + return m == null ? r : '${m[1]}${conv(m[2]!)}'; + } + + final out = []; + for (var i = 0; i < words.length; i++) { + final w = words[i]; + final next = i + 1 < words.length ? words[i + 1] : null; + final eq = w.indexOf('='); + if (w.startsWith('--') && eq > 0 && opts.contains(w.substring(0, eq))) { + out.add('${w.substring(0, eq + 1)}${conv(w.substring(eq + 1))}'); + } else if (opts.contains(w) && next != null) { + out + ..add(w) + ..add(conv(next)); + i++; + } else if (w.startsWith('--filter=')) { + out.add('--filter=${rule(w.substring(9))}'); + } else if (w == '--filter' && next != null) { + out + ..add(w) + ..add(rule(next)); + i++; + } else if (!w.startsWith('--') && w.length > 1 && w.startsWith('-')) { + // 짧은 옵션 묶음의 f: 붙은 값 (-f". 목록") 또는 다음 낱말 (-avf ". 목록") + final k = w.indexOf(RegExp('[BefMT@]'), 1); + if (k >= 0 && w[k] == 'f' && k < w.length - 1) { + out.add('${w.substring(0, k + 1)}${rule(w.substring(k + 1))}'); + } else if (k >= 0 && w[k] == 'f' && next != null) { + out + ..add(w) + ..add(rule(next)); + i++; + } else { + out.add(w); + // 다른 값 받는 글자 (-e ssh 등) 의 값은 그대로 건너뜀 + if (k == w.length - 1 && next != null) { + out.add(next); + i++; + } + } + } else { + out.add(w); + } + } + return out; +} + +/// Windows 경로 → MSYS2 rsync 가 읽는 경로 (C:\a\b → /cygdrive/c/a/b, \\server\share\x → //server/share/x). +/// MSYS2 rsync 는 "C:" 를 원격 컴퓨터 이름으로 읽으므로 꼭 바꿔야 한다. +/// 받는 쪽 (대상) 경로를 rsync 에 넘길 꼴로. 28 · 15 확인 (10/20, MSYS 3.5.1): 받는 쪽의 UNC 는 \\서버\… 만 된다 +/// (//서버/… 는 change_dir 실패, exit 3) - 원본은 거꾸로 //서버/… 만 되어 [toCygwinPath] 그대로 +String rsyncDestPath(String path, {bool windows = true}) { + if (windows && RegExp(r'^(?:\\\\|//)(?!cygdrive/)').hasMatch(path)) return path.replaceAll('/', '\\'); + return toCygwinPath(path, windows: windows); +} + +String toCygwinPath(String path, {bool windows = true}) { + if (!windows) return path; + final s = path.replaceAll('\\', '/'); + final m = RegExp(r'^([A-Za-z]):(/.*)?$').firstMatch(s); + if (m != null) return '/cygdrive/${m[1]!.toLowerCase()}${m[2] ?? '/'}'; + return s; // //server/share 는 그대로 읽는다 +} + +/// rsync 옵션에 -u (--update: 받는 쪽이 더 새 파일은 건너뜀) 를 더한다. 이미 있으면 그대로 (-avu 처럼 묶인 것도). +/// 양쪽 (⇄) 으로 함께 맞출 때 서로의 새 파일을 옛 파일로 덮어쓰지 않게. +String withUpdateOption(String options) { + final has = splitOptions(options).any((o) => o == '--update' || (o.startsWith('-') && !o.startsWith('--') && o.contains('u'))); + return has ? options : '${options.trim()} -u'.trim(); +} + +/// rsync 인수: [options] + (대역폭 · 이동) + 원본들 + 대상 폴더/ +/// 원본 폴더는 끝에 / 를 붙이지 않는다 (폴더째 대상 안으로 - 앱의 복사와 같은 결과). +List rsyncArgs({ + required String options, + required List sources, + required String dest, + int bandwidthKBps = 0, + bool move = false, + bool windows = true, + bool contents = false, +}) { + // 178: 원본을 지우는 옵션은 [move] (이동 · [원본 파일 지우기]) 로만 - 옵션 글에 있어도 빼고, 이동일 때만 넣는다 + final opts = rsyncFileOptionArgs([for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o], windows: windows); + return [ + ...opts, + if (bandwidthKBps > 0 && !opts.any((o) => o.startsWith('--bwlimit'))) '--bwlimit=$bandwidthKBps', + // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 출력 (Android 빌드는 iconv 없음 · 진행 표시에 쓰는 이름) + if (!windows && !opts.contains('-8') && !opts.contains('--8-bit-output')) '-8', + if (move) '--remove-source-files', + ...appTempRsyncExcludes, // 78 보충: 일괄 이름 바꾸기 중의 임시 이름 (~jjren_ · ~jjback_) 은 보내지 않는다 + // [contents]: 폴더 "안의 것" 을 대상에 (rsync 의 끝 / - 동기화 · lsync 와 같은 모양) + for (final s in sources) '${toCygwinPath(_noTrailingSlash(s), windows: windows)}${contents ? '/' : ''}', + '${rsyncDestPath(_noTrailingSlash(dest), windows: windows)}/', + ]; +} + +String _noTrailingSlash(String s) => s.length > 3 && (s.endsWith('/') || s.endsWith('\\')) ? s.substring(0, s.length - 1) : s; + +/// 원본을 지우는 rsync · robocopy 옵션인지 (--remove-source-files · --remove-sent-files · /MOV · /MOVE) - 107 · 178 +bool isSourceRemovingOption(String o) => + o == '--remove-source-files' || o == '--remove-sent-files' || o.toUpperCase() == '/MOV' || o.toUpperCase() == '/MOVE'; + +/// robocopy 한 번 실행할 인수 (robocopy 는 "폴더 → 폴더" 라 원본마다 따로 만든다). +/// - 폴더: robocopy <폴더> <대상>\<폴더 이름> [옵션] +/// - 파일: robocopy <파일이 있는 폴더> <대상> <파일 이름들…> [옵션] (같은 폴더의 파일은 한 번에) +/// 대역폭: robocopy 에는 속도 제한이 없어 /IPG (조각 사이 쉬는 시간) 로 비슷하게 맞춘다. +List> robocopyRuns({ + required String options, + required List folders, + required List files, + required String dest, + int bandwidthKBps = 0, + bool move = false, + bool contents = false, + bool skipNewer = false, +}) { + // 178: 원본을 지우는 옵션 (/MOV · /MOVE) 은 [move] 로만 + final opts = [for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o]; + final extra = robocopyExtraOptions(opts, move: move, skipNewer: skipNewer, bandwidthKBps: bandwidthKBps); + final runs = >[ + for (final f in folders) [f, contents ? dest : p.join(dest, p.basename(f)), ...opts, ...extra], + ]; + final byDir = >{}; + for (final f in files) { + byDir.putIfAbsent(p.dirname(f), () => []).add(p.basename(f)); + } + // 파일만 옮길 때 /E · /MOVE 는 하위 폴더까지 건드리므로 뺀다 + final fileOpts = opts.where((o) => !const ['/E', '/S', '/MIR'].contains(o.toUpperCase())).toList(); + final fileExtra = extra.map((o) => o == '/MOVE' ? '/MOV' : o).toList(); + byDir.forEach((dir, names) => runs.add([dir, dest, ...names, ...fileOpts, ...fileExtra])); + return runs; +} + +/// 앱이 옵션 칸 ([opts], 원본 지우기 옵션은 뺀 것) 뒤에 붙이는 robocopy 옵션 - 실행 (robocopyRuns) 과 화면의 명령 줄이 같게 +List robocopyExtraOptions(List opts, {bool move = false, bool skipNewer = false, int bandwidthKBps = 0}) => [ + if (bandwidthKBps > 0 && !opts.any((o) => o.toUpperCase().startsWith('/IPG'))) '/IPG:${robocopyIpg(bandwidthKBps)}', + if (move) ...robocopyMoveOptions(opts), + // 데이터: 링크 · junction 은 따라가지 않는다 (복사 · 이동 모두 처음 값 - rsync -a 가 링크를 링크로 두는 것처럼). + // 따라가면 원본 바깥 폴더의 파일을 옮기고 지우거나 (/MOVE), 고리 junction (AppData 의 "Application Data" 등) 을 끝없이 돈다. + // 옵션 칸에 사용자가 링크 옵션을 넣었으면 그대로 + if (!robocopyLinkOption(opts)) '/XJ', + // 229: 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" - robocopy 는 기본으로 예전 원본 (Older) 도 덮어쓴다 + if (skipNewer && !opts.any((o) => o.toUpperCase() == '/XO')) '/XO', + ]; + +/// 옵션 칸에 링크 · junction 을 다루는 robocopy 옵션이 있는지 (/XJ · /XJD · /XJF · /SJ · /SL) +bool robocopyLinkOption(List opts) => opts.any((o) => const ['/XJ', '/XJD', '/XJF', '/SJ', '/SL'].contains(o.toUpperCase())); + +/// robocopy 가 원본의 링크 · junction 을 건너뛰는지 (미리 보기) - 사용자가 /SJ · /SL 로 링크째 복사를 고르지 않았으면 +bool robocopySkipsLinks(List opts) => !opts.any((o) => const ['/SJ', '/SL'].contains(o.toUpperCase())); + +/// 이동에 붙이는 robocopy 옵션: /MOVE + 같은 파일도 포함. +/// 217: 대상에 같은 파일이 있으면 robocopy 는 건너뛰고 원본에 남긴다 (종료 코드 0 - "옮겼습니다" 인데 남음, Windows 에서 확인). +/// 같음 (/IS) 뿐 아니라 속성만 다름 (Tweaked, /IT) · 만든 시각만 다름 (Modified, /IM - 다른 프로그램으로 복사한 같은 파일) 도 넣어 원본에서 지운다. +/// 그래도 남는 것은 이동 뒤 다시 세어 알린다 (216). 링크 · junction 은 [robocopyExtraOptions] 의 /XJ 로 남긴다 +/// (robocopy 는 기본으로 junction 을 따라가 /MOVE 가 링크가 가리키는 원본 바깥 폴더의 파일까지 옮기고 지웠다 - Windows 에서 확인) +List robocopyMoveOptions(List opts) => [ + '/MOVE', + for (final o in const ['/IS', '/IT', '/IM']) + if (!opts.any((x) => x.toUpperCase() == o)) o, + ]; + +/// robocopy /IPG 밀리초: robocopy 는 64KB 조각마다 쉰다 → 64 / KB/s 초 +int robocopyIpg(int kbps) => kbps <= 0 ? 0 : (64 * 1000 / kbps).round().clamp(1, 60000); + +/// rsync · robocopy 의 성공 종료 코드 +bool rsyncOk(int code) => code == 0 || code == 24; // 24: 복사 도중 원본 파일이 사라짐 +bool robocopyOk(int code) => code >= 0 && code < 8; + +/// Windows 용 rsync: MSYS2 공식 저장소의 패키지 (처음 쓸 때 내려받음, 파일 SHA256 을 고정해 검증) +const msys2Repo = 'https://repo.msys2.org/msys/x86_64/'; +const msys2RsyncPackages = <(String, String)>[ + ('rsync-3.5.1-2-x86_64.pkg.tar.zst', 'cba52e41d16f9873324fcf09853619964da87bc1ce116781a5449c968d4ffecc'), + ('msys2-runtime-3.6.10-6-x86_64.pkg.tar.zst', 'b2db5bae3826f15cacb536f4c7c6c7e31d3fabec00aac8f127473aba7e994d22'), + ('gcc-libs-15.3.0-1-x86_64.pkg.tar.zst', '0d99a122c453c05ae21ba3dcea910f2e0d93d38ae067c677a112a315b0f3cec5'), + ('libiconv-1.19-1-x86_64.pkg.tar.zst', '0fa55ea2a6ccf97cf8c58b24b2615815e15e16e6e4e888091c263c2c83c5313d'), + ('libidn2-2.3.8-1-x86_64.pkg.tar.zst', '55e0de96b893e9f67f14a4283d411fc2a45d7b59333bc16e2a9b16e5bfc9282f'), + ('libintl-0.22.5-1-x86_64.pkg.tar.zst', '336d66b9d95cf9c1804958f8e260762a3e83bf158ed5981f783bc772a31073cf'), + ('liblz4-1.10.0-1-x86_64.pkg.tar.zst', '1cbbe51ee91bf32691aabb15bdb7c8548e8b5ef8195122ef31f10edcca6e952f'), + ('libopenssl-3.6.5-1-x86_64.pkg.tar.zst', '05f6cc946f0dde3aecc3632007fa1d874e40720226117b750327bc2a01846325'), + ('libunistring-1.4.2-1-x86_64.pkg.tar.zst', 'bcd04706d95b5e7127d39834565bceed6b400192f5fb51599745aba28d5da018'), + ('libxxhash-0.8.4-1-x86_64.pkg.tar.zst', 'ba784ad117626401cd5d3ec0cf70399f9de72a63755f96c28fa2032303f9c330'), + ('libzstd-1.5.7-1-x86_64.pkg.tar.zst', '5e370b2e725a0a7e5ff58d84653c9a0cd5a10b9cfe930f2d949a28ad2a11c1f2'), + ('popt-1.19-1-x86_64.pkg.tar.zst', 'b402bd1d03815c5e28b7ab415d8e20f487eff2460ae0271a99674eeb0d77194f'), +]; + +/// 패키지에서 꺼낼 파일 (rsync.exe 와 필요한 DLL 만) +const msys2RsyncFiles = { + 'rsync.exe', 'msys-2.0.dll', 'msys-gcc_s-seh-1.dll', 'msys-iconv-2.dll', 'msys-idn2-0.dll', 'msys-intl-8.dll', + 'msys-lz4-1.dll', 'msys-crypto-3.dll', 'msys-ssl-3.dll', 'msys-unistring-5.dll', 'msys-xxhash-0.dll', + 'msys-zstd-1.dll', 'msys-popt-0.dll', 'msys-charset-1.dll', +}; diff --git a/lib/core/sync/copy_output.dart b/lib/core/sync/copy_output.dart new file mode 100644 index 0000000..cd25d45 --- /dev/null +++ b/lib/core/sync/copy_output.dart @@ -0,0 +1,102 @@ +// 약속 (나누기 sync_tools): rsync · robocopy 출력 읽기 - 조각을 받을 때마다 "다 보낸 파일" 줄과 진행률 · 속도 · 확인한 수. +// 입력: 출력 조각 (줄 중간에서 끊겨도 됨 - 남은 것은 다음 조각과 이어 읽음). 출력: 다 보낸 파일 (원본 기준 상대 경로) 목록. +// 오류: 던지지 않는다 (모르는 줄은 건너뜀). + +/// rsync -v 출력에서 "다 보낸 파일" 줄을 골라낸다 (-P 의 \r 진행 줄 · 머리글 · 요약 · 폴더 줄은 뺌). +/// 결과: 원본 기준 상대 경로 (예: "sub/inner.txt") +class RsyncOutput { + final _buf = StringBuffer(); + + /// 지금 파일의 진행률 (-P 의 " 1,234 45% 1.2MB/s" 줄) + double? currentPercent; + + /// 지금 전송 속도 (바이트/초, 같은 줄의 "1.2MB/s") + double? currentSpeed; + + /// 폴더 전체에서 확인한 항목 (진행 줄의 "to-chk=남은/전체" · "ir-chk=…"). 모르면 null. + int? checked, checkTotal; + + static final _chkRx = RegExp(r'(?:to|ir)-chk=(\d+)/(\d+)'); + static final _speedRx = RegExp(r'(\d+(?:\.\d+)?)([kKMGT]?)B/s'); + + List feed(String chunk) { + _buf.write(chunk); + final text = _buf.toString(); + final cut = text.lastIndexOf(RegExp(r'[\r\n]')); + if (cut < 0) return const []; + _buf + ..clear() + ..write(text.substring(cut + 1)); + final done = []; + for (final raw in text.substring(0, cut).split(RegExp(r'[\r\n]'))) { + final line = raw.trimRight(); + if (line.isEmpty) continue; + if (line.startsWith(' ')) { + final m = RegExp(r'\s(\d{1,3})%\s').firstMatch(line); + if (m != null) currentPercent = int.parse(m[1]!) / 100; + final ck = _chkRx.firstMatch(line); + if (ck != null) { + checkTotal = int.parse(ck[2]!); + checked = checkTotal! - int.parse(ck[1]!); + } + final sp = _speedRx.firstMatch(line); + if (sp != null) { + const mul = {'': 1, 'k': 1024, 'K': 1024, 'M': 1024 * 1024, 'G': 1024 * 1024 * 1024, 'T': 1024 * 1024 * 1024 * 1024}; + currentSpeed = double.parse(sp[1]!) * mul[sp[2]!]!; + } + continue; + } + if (line.endsWith('/')) continue; // 폴더 + if (line.startsWith('sending ') || + line.startsWith('receiving ') || + line.startsWith('sent ') || + line.startsWith('total size') || + line.startsWith('created directory') || + line.startsWith('deleting ') || + line.startsWith('rsync:') || + line.startsWith('rsync error') || + line.contains(' -> ')) { + continue; + } + currentPercent = null; + done.add(line); + } + return done; + } +} + +/// robocopy 출력에서 파일 줄 ("New File", "Newer", "Older", "Changed" … 뒤의 크기 · 이름) 을 센다 +class RobocopyOutput { + final _buf = StringBuffer(); + double? currentPercent; + + /// 파일 줄: 탭 · 종류 · 탭 탭 · 크기 · 탭 · 경로. 61: 종류 글은 Windows 언어마다 다르므로 + /// ("New File" · "새 파일" · "新しいファイル" · "新文件") 글이 아니라 탭 모양으로 알아본다. + /// 종류가 * 로 시작하면 (*EXTRA File 등) 대상에만 있는 것이라 세지 않는다. + static final _file = RegExp(r'^\t+\s*([^\t\s\d][^\t]*?)\s*\t+\s*[\d.]+\s*[kmgt]?\s*\t(.+)$', caseSensitive: false); + + List feed(String chunk) { + _buf.write(chunk); + final text = _buf.toString(); + final cut = text.lastIndexOf(RegExp(r'[\r\n]')); + if (cut < 0) return const []; + _buf + ..clear() + ..write(text.substring(cut + 1)); + final done = []; + for (final raw in text.substring(0, cut).split(RegExp(r'[\r\n]'))) { + final line = raw.trim(); + final pct = RegExp(r'^(\d{1,3}(?:\.\d)?)%$').firstMatch(line); + if (pct != null) { + currentPercent = double.parse(pct[1]!) / 100; + continue; + } + final m = _file.firstMatch(raw); + if (m != null && !m[1]!.startsWith('*')) { + currentPercent = null; + done.add(m[2]!.trim()); + } + } + return done; + } +} diff --git a/lib/core/sync/rsync_options.dart b/lib/core/sync/rsync_options.dart new file mode 100644 index 0000000..90979b3 --- /dev/null +++ b/lib/core/sync/rsync_options.dart @@ -0,0 +1,328 @@ +import 'dart:io' show Platform; + +import '../../l10n/tr.dart'; + +// 약속 (나누기 sync_tools): rsync 옵션 칸의 글 - 붙여 넣은 이상한 글자 고치기 · 인수로 나누기 · rsync 가 다르게 읽을 것 찾기 · 목록 파일 값. +// 입력: 옵션 글 (사용자가 적거나 붙여 넣은 것) · 낱말 목록 · Windows 인지 (null 이면 이 기기). +// 출력: 낱말 목록 · 문제 목록 ([OptionProblem]) · 고칠 방법 글. 오류: 던지지 않는다 (알아볼 수 없는 것은 문제 목록으로 돌려준다). + +/// 관리자 지시 (10/20): 화면에서 줄바꿈 없이 묶은 옵션 글 (sync_preview_view 의 unbreakableOption) 의 줄바꿈 없는 빈칸 (U+00A0 등) · +/// 보이지 않는 글자 (U+2060 등) 가 복사 · 붙여 넣기로 옵션에 들어오면 rsync 가 다른 인수로 읽는다 ("-", 보이지 않는 글자, "-filter") → 보통 빈칸으로 / 지움. +/// 다른 곳 (웹 페이지 · 문서) 에서 붙여 넣은 것도 같다 +/// 28 확인 (10/20): 소프트 하이픈 (U+00AD) 은 지우고 (rsync 가 "unknown option" 으로 실패), 전각 빈칸 (U+3000) 도 보통 빈칸으로 +String cleanOptionText(String s) => s + .replaceAll(RegExp('[\u00A0\u2007\u202F\u3000]'), ' ') + .replaceAll(RegExp('[\u00AD\u200B\u200C\u200D\u2060\uFEFF]'), ''); + +/// 낱말 맨 앞에서 - 로 읽는 대시 (‐ ‑ ‒ – — ― − ﹘ ﹣ -) - 워드 · 웹이 -- 를 바꿔 놓은 것 +const _dashLike = '\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uFE58\uFE63\uFF0D'; + +/// 굽은 따옴표 → 곧은 따옴표 (같은 무리끼리 열고 닫는다) +const _curlyQuote = {'\u2018': "'", '\u2019': "'", '\u201A': "'", '\u201C': '"', '\u201D': '"', '\u201E': '"'}; + +/// 옵션 글자를 인수로 나눈다 (큰따옴표 · 작은따옴표로 묶은 것은 하나로). 먼저 [cleanOptionText]. +/// 관리자 지시 (10/20, 28 · 15 확인): 웹 · 문서에서 붙여 넣은 글을 곧은 글자로 쓴 것과 같게 읽는다 - 예전에는 +/// --exclude=‘*.log’ 가 아무것도 거르지 못해 받는 쪽 keep.log 가 지워졌고, –delete 는 원본 경로로 넘어가 --delete 가 몰래 빠졌다. +/// - 굽은 따옴표 (‘ ’ “ ”) 는 낱말 맨 앞 · = 뒤 · 짧은 옵션 (-f) 뒤에서 열고, 같은 무리의 굽은 따옴표에서 닫는다 +/// (곧은 따옴표처럼 뒤에 글이 이어져도 - “a b”.txt → a b.txt). 작은따옴표 무리는 글자 사이 (it’s) 이면 닫지 않는다. +/// 이름 안의 굽은 따옴표 (it’s.txt) 는 그대로 둔다. +/// - 닫히지 않은 따옴표 (15 확인 F1): 예전에는 뒤의 옵션을 모두 삼켜 지키려던 --exclude 가 사라졌다 → 그 따옴표를 글자로 보고 +/// 나머지는 그대로 나누며, [unreadableOptions] 가 알려 실행을 막는다. +/// - 낱말 맨 앞의 대시 (– — ‑ - 등, 따옴표 밖): 둘이면 --, 하나면 rsync 의 긴 옵션 이름 (–delete) 일 때만 -- 그 밖 (–avz) 은 -. +/// 옵션의 값 자리 (--exclude –x.txt 의 –x.txt) · 낱말 가운데의 대시는 그대로 (파일 이름). --filter · -f 값의 규칙 머리 (“– *.tmp”) 는 - 로. +List splitOptions(String s) => _parseOptions(s).words; + +/// rsync 3.x 의 긴 옵션 이름 (앱에 든 rsync 3.5.1 의 --help) - 대시 하나로 붙여 넣은 –delete 를 --delete 로 읽을지 정한다 +const _rsyncLong = { + 'acls', 'address', 'append', 'append-verify', 'archive', 'atimes', 'backup', 'backup-dir', 'block-size', 'blocking-io', + 'bwlimit', 'cc', 'checksum', 'checksum-choice', 'checksum-seed', 'chmod', 'chown', 'compare-dest', 'compress', + 'compress-choice', 'compress-level', 'compress-threads', 'confine-root', 'contimeout', 'copy-as', 'copy-dest', + 'copy-devices', 'copy-dirlinks', 'copy-links', 'copy-unsafe-links', 'crtimes', 'cvs-exclude', 'daemon', 'debug', 'del', + 'delay-updates', 'delete', 'delete-after', 'delete-before', 'delete-delay', 'delete-during', 'delete-excluded', + 'delete-missing-args', 'devices', 'dirs', 'dry-run', 'early-input', 'exclude', 'exclude-from', 'executability', 'existing', + 'fake-super', 'files-from', 'filter', 'force', 'from0', 'fsync', 'fuzzy', 'group', 'groupmap', 'hard-links', 'help', + 'human-readable', 'i-r', 'iconv', 'ignore-errors', 'ignore-existing', 'ignore-missing-args', 'ignore-non-existing', + 'ignore-times', 'inc-recursive', 'include', 'include-from', 'info', 'inplace', 'insecure-links', 'ipv4', 'ipv6', + 'itemize-changes', 'keep-dirlinks', 'link-dest', 'links', 'list-only', 'log-file', 'log-file-format', 'max-alloc', + 'max-delete', 'max-size', 'min-size', 'mkpath', 'modify-window', 'munge-links', 'no-i-r', 'no-implied-dirs', + 'no-inc-recursive', 'no-motd', 'no-whole-file', 'numeric-ids', 'old-args', 'old-d', 'old-dirs', 'omit-dir-times', + 'omit-link-times', 'one-file-system', 'only-write-batch', 'open-noatime', 'out-format', 'outbuf', 'owner', 'partial', + 'partial-dir', 'password-file', 'perms', 'port', 'preallocate', 'progress', 'protocol', 'prune-empty-dirs', 'quiet', + 'read-batch', 'recursive', 'relative', 'remote-option', 'remove-source-files', 'rsh', 'rsync-path', 'safe-links', + 'secluded-args', 'size-only', 'skip-compress', 'sockopts', 'sparse', 'specials', 'stats', 'stderr', 'stop-after', 'stop-at', + 'suffix', 'super', 'temp-dir', 'timeout', 'times', 'trust-sender', 'update', 'usermap', 'verbose', 'version', + 'whole-file', 'write-batch', 'write-devices', 'xattrs', 'zc', 'zl', 'zt', +}; + +/// 나눈 낱말: 맨 앞이 따옴표 안이었는지 (그러면 맨 앞 대시를 옵션으로 고치지 않는다) +typedef _Word = ({String text, bool quoted}); + +/// [splitOptions] · [unreadableOptions] 의 몸통: 낱말들과 닫히지 않은 따옴표 (뒤 글 일부) +({List words, List unclosed}) _parseOptions(String s) { + final chars = cleanOptionText(s).split(''); + final literal = {}; // 닫히지 않아 글자로 보는 따옴표 자리 + final unclosed = []; + while (true) { + final (raw, open) = _splitRaw(chars, literal); + if (open == null) return (words: _normalizeWords(raw), unclosed: unclosed); + literal.add(open); + final rest = chars.sublist(open).join().trim(); + unclosed.add(rest.length > 30 ? '${rest.substring(0, 30)}…' : rest); + } +} + +/// 따옴표 · 빈칸으로 나눈다. 끝까지 닫히지 않은 따옴표가 있으면 그 자리 (두 번째 값) +(List<_Word>, int?) _splitRaw(List chars, Set literal) { + final out = <_Word>[]; + final cur = StringBuffer(); + String? quote; // 닫는 곧은 따옴표 (" 또는 ') + var curly = false; // 굽은 따옴표로 열었는지 + var openAt = -1; + var has = false; + var quoted = false; + bool letter(int i) => i >= 0 && i < chars.length && RegExp(r'[\p{L}\p{N}]', unicode: true).hasMatch(chars[i]); + for (var i = 0; i < chars.length; i++) { + final ch = chars[i]; + if (quote != null) { + // 작은따옴표 무리의 ’ 가 글자 사이면 (it’s) 닫지 않는다 + final closes = ch == quote && !curly || + curly && _curlyQuote[ch] == quote && !(quote == "'" && letter(i - 1) && letter(i + 1)); + if (closes) { + quote = null; + curly = false; + } else { + cur.write(ch); + } + } else if (literal.contains(i)) { + cur.write(ch); + } else if (ch == '"' || ch == "'") { + if (cur.isEmpty && !has) quoted = true; + quote = ch; + openAt = i; + has = true; + } else if (_curlyQuote.containsKey(ch) && (cur.isEmpty || '$cur'.endsWith('=') || RegExp(r'^-[A-Za-z0-9]+$').hasMatch('$cur'))) { + if (cur.isEmpty && !has) quoted = true; + quote = _curlyQuote[ch]; + curly = true; + openAt = i; + has = true; + } else if (ch.trim().isEmpty) { + if (cur.isNotEmpty || has) out.add((text: cur.toString(), quoted: quoted)); + cur.clear(); + has = false; + quoted = false; + } else { + cur.write(ch); + } + } + if (quote != null) return (out, openAt); + if (cur.isNotEmpty || has) out.add((text: cur.toString(), quoted: quoted)); + return (out, null); +} + +bool _dashAt(String t, int i) => i < t.length && (t[i] == '-' || _dashLike.contains(t[i])); + +/// 거르기 규칙 머리 ("– *.tmp" · "+ *.txt") 를 rsync 가 읽는 - · + 로 +String _fixRuleHead(String v) { + if (v.isEmpty) return v; + if (_dashLike.contains(v[0])) return '-${v.substring(1)}'; + if (v[0] == '\uFF0B') return '+${v.substring(1)}'; + return v; +} + +/// 낱말 맨 앞 대시 고치기 · 거르기 값의 규칙 머리 고치기 (값 자리는 그대로) +List _normalizeWords(List<_Word> raw) { + final out = []; + var value = ''; // 다음 낱말이 값: 'filter' (규칙 머리를 고침) · 'other' (그대로) + for (final w in raw) { + var t = w.text; + if (value.isNotEmpty) { + out.add(value == 'filter' ? _fixRuleHead(t) : t); + value = ''; + continue; + } + // 28 확인 (10/20): 곧은 하이픈과 섞인 것 (-–delete) 도 맨 앞 둘 중 하나라도 대시면 같은 규칙으로 + if (!w.quoted && t.isNotEmpty && (_dashLike.contains(t[0]) || t.length > 1 && t[0] == '-' && _dashLike.contains(t[1]))) { + final n = _dashAt(t, 1) ? 2 : 1; + final rest = t.substring(n); + final name = rest.split('=').first; + final long = n == 2 || _rsyncLong.contains(name) || (name.startsWith('no-') && _rsyncLong.contains(name.substring(3))); + t = '${long ? '--' : '-'}$rest'; + } + if (t.startsWith('--filter=')) { + t = '--filter=${_fixRuleHead(t.substring(9))}'; + } else if (t.length > 1 && t.startsWith('-') && !t.startsWith('--')) { + final k = t.indexOf(RegExp('[BefMT@]'), 1); + if (k > 0 && t[k] == 'f' && k < t.length - 1) t = '${t.substring(0, k + 1)}${_fixRuleHead(t.substring(k + 1))}'; + } + out.add(t); + if (t.startsWith('--')) { + if (!t.contains('=') && _rsyncLongWithValue.contains(t)) value = t == '--filter' ? 'filter' : 'other'; + } else if (t.length > 1 && t.startsWith('-')) { + final k = t.indexOf(RegExp('[BefMT@]'), 1); + if (k == t.length - 1) value = t[k] == 'f' ? 'filter' : 'other'; + } + } + return out; +} + +/// 값을 다음 낱말로 받는 rsync 긴 옵션 (--exclude *.log) - [unreadableOptions] 가 그 값을 옵션으로 보지 않게 +const _rsyncLongWithValue = { + '--exclude', '--include', '--filter', '--exclude-from', '--include-from', '--files-from', '--backup-dir', '--suffix', + '--log-file', '--log-file-format', '--out-format', '--chmod', '--chown', '--usermap', '--groupmap', '--bwlimit', + '--timeout', '--contimeout', '--rsh', '--rsync-path', '--compare-dest', '--copy-dest', '--link-dest', '--partial-dir', + '--temp-dir', '--max-size', '--min-size', '--max-delete', '--modify-window', '--password-file', '--iconv', + '--block-size', '--checksum-choice', '--compress-choice', '--compress-level', '--skip-compress', '--info', '--debug', + '--sockopts', '--port', '--address', '--outbuf', '--remote-option', '--max-alloc', '--stop-after', '--stop-at', + '--write-batch', '--only-write-batch', '--read-batch', '--protocol', '--checksum-seed', '--copy-as', '--early-input', + // 28 확인 (10/20): rsync --help 의 짧은 별칭 (aka --cc · --zc · --zl · --zt) 과 값 받는 옵션 - 빠져 있어 값이 "경로로 읽힙니다" 로 막혔다 + '--cc', '--zc', '--zl', '--zt', '--compress-threads', '--confine-root', +}; + +/// 관리자 지시 (10/20, 28 · 15 확인): [splitOptions] 로 고쳐 읽은 뒤에도 rsync 가 다르게 읽을 것 - 있으면 확인 창 · 실시간 동기화에 +/// 빨갛게 알리고 실행을 막는다: 닫히지 않은 따옴표, 옵션 자리의 - 로 시작하지 않는 낱말 (rsync 는 원본 경로로 읽어 옵션이 몰래 빠짐), +/// 하이픈 셋 이상, 끝에 값 없이 남은 값 받는 옵션. 옵션의 값 자리 (--exclude 한글.txt 의 한글.txt, -f 뒤) 는 세지 않는다 +List unreadableOptions(String options, {bool? windows}) => [for (final x in optionProblems(options, windows: windows)) x.text]; + +/// 옵션 칸의 문제 종류 - 안내의 끝 문구 (고칠 방법) 를 고른다 (관리자 지시 10/20, 28 지적) +enum OptionProblemKind { unclosed, path, noValue, odd, doubleQuote, relativePath, stdin } + +/// [unreadableOptions] 의 한 줄과 그 종류 +class OptionProblem { + final OptionProblemKind kind; + final String text; + + /// [text] 안의 옵션 낱말 (화면이 줄바꿈 없이 묶는다) + final String token; + const OptionProblem(this.kind, this.text, [this.token = '']); + + @override + String toString() => text; +} + +/// 문제 종류에 맞는 고칠 방법 (여러 종류면 · 로 잇는다) +String optionProblemHint(Iterable problems, {bool? windows}) { + windows ??= Platform.isWindows; + final kinds = {for (final x in problems) x.kind}; + return [ + if (kinds.contains(OptionProblemKind.unclosed)) tr('따옴표를 닫아 주세요'), + if (kinds.contains(OptionProblemKind.path)) tr('옵션은 - 나 -- 로 시작합니다'), + if (kinds.contains(OptionProblemKind.noValue)) tr('값을 적어 주세요 (예: --exclude=*.tmp)'), + if (kinds.contains(OptionProblemKind.odd)) tr('곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요'), + if (kinds.contains(OptionProblemKind.doubleQuote)) tr('큰따옴표 (") 를 빼 주세요'), + // 관리자 지시 (10/20): Windows 는 드라이브로 시작하는 전체 경로만 ("/" 는 rsync 가 자기 설치 폴더 기준으로 읽음), Android 는 / + if (kinds.contains(OptionProblemKind.relativePath)) windows ? tr('C:\\ 처럼 드라이브로 시작하는 전체 경로로') : tr('/ 로 시작하는 전체 경로로'), + if (kinds.contains(OptionProblemKind.stdin)) tr('목록 파일의 전체 경로를 적어 주세요'), + ].join(' · '); +} + +/// "옵션을 알아볼 수 없습니다: … - 고칠 방법" (작업 기록 · 실시간 동기화 카드 · 설정 칸) +String optionProblemMessage(List problems) => + trf('옵션을 알아볼 수 없습니다: {0} - {1}', [problems.join(' · '), optionProblemHint(problems)]); + +/// [unreadableOptions] 의 몸통 (종류와 함께) +List optionProblems(String options, {bool? windows}) { + windows ??= Platform.isWindows; + final parsed = _parseOptions(options); + // 15 확인 F1: 닫히지 않은 따옴표 - 뒤의 옵션이 다르게 읽힐 수 있다 + final bad = [for (final u in parsed.unclosed) OptionProblem(OptionProblemKind.unclosed, trf('따옴표가 닫히지 않았습니다: {0}', [u]), u)]; + var value = false; + var last = ''; + for (final t in parsed.words) { + // P0 (10/20): Windows 의 rsync (MSYS2) 에는 큰따옴표가 든 인수를 그대로 넘길 수 없다 (prepareRsyncLaunch) - 값 자리도. + // 닫히지 않은 따옴표로 이미 막혔으면 (그 따옴표가 낱말에 남음) 한 번만 알린다 + if (windows && parsed.unclosed.isEmpty && t.contains('"')) { + bad.add(OptionProblem(OptionProblemKind.doubleQuote, trf('큰따옴표 (") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}', [t]), t)); + value = false; + continue; + } + if (value) { + value = false; + continue; + } + if (t.startsWith('---')) { + // 관리자 판단 (10/20, 28 확인): 하이픈이 셋 이상 (---delete) 은 rsync 가 알아보지 못한다 - 끝 문구는 "옵션은 - 나 -- 로 시작합니다" + bad.add(OptionProblem(OptionProblemKind.path, t, t)); + } else if (t.startsWith('--')) { + value = !t.contains('=') && _rsyncLongWithValue.contains(t); + last = t; + } else if (t.length > 1 && t.startsWith('-')) { + // 짧은 옵션 묶음: 값을 받는 글자 (B · e · f · M · T · @) 가 끝이면 다음 낱말이 값 (rsync_filter 와 같은 규칙) + final k = t.indexOf(RegExp('[BefMT@]'), 1); + value = k == t.length - 1; + last = t; + } else { + // 관리자 판단 (10/20, 28 확인): 옵션 칸에는 경로가 들어갈 자리가 없다 (원본 · 대상은 앱이 넣음) - 값 자리가 아닌데 + // - 로 시작하지 않는 낱말 (-a delete 의 delete, 혼자 쓴 -) 은 rsync 가 원본 경로로 읽어 옵션이 몰래 빠진다 + // ASCII 밖 글자로 시작하면 (« … 등) 붙여 넣은 이상한 글자 - 안내는 곧은 글자로 다시 적기 + bad.add(OptionProblem(t.codeUnitAt(0) > 0x7F ? OptionProblemKind.odd : OptionProblemKind.path, trf('경로로 읽힙니다: {0}', [t]), t)); + } + } + // 값이 필요한 옵션이 끝에 값 없이 (--exclude) - rsync 가 원본 경로를 그 값으로 먹는다 + if (value) bad.add(OptionProblem(OptionProblemKind.noValue, trf('{0} 의 값이 없습니다', [last]), last)); + // 관리자 판단 (10/20): 목록 파일의 상대 경로 - rsync 는 자기 작업 폴더 (Windows 는 응답 파일 폴더) 기준으로, 미리 보기는 앱의 + // 작업 폴더 기준으로 읽어 서로 다른 파일을 볼 수 있다. 전체 경로만 받는다 + for (final f in listFileValues(parsed.words)) { + // 관리자 지시 (10/20, 28 지적): "-" (표준 입력) 는 따로 - 앱은 rsync 에 표준 입력을 주지 않는다 + if (f == '-') { + bad.add(OptionProblem(OptionProblemKind.stdin, tr('표준 입력 (-) 은 쓸 수 없습니다'), f)); + } else if (!_absolutePath(f, windows: windows)) { + bad.add(OptionProblem(OptionProblemKind.relativePath, trf('목록 파일은 전체 경로로 적어 주세요: {0}', [f]), f)); + } + } + // 관리자 지시 (10/20, 28 확인): 기록 · 배치 파일도 - 상대 경로면 응답 파일 임시 폴더에 생겼다가 함께 지워져 찾을 수 없다. + // --backup-dir · --partial-dir · --temp-dir · --compare-dest 류는 받는 쪽 기준이라 그대로 + for (final f in _optionValues(parsed.words, const {'--log-file', '--write-batch', '--read-batch', '--only-write-batch'})) { + if (!_absolutePath(f, windows: windows)) bad.add(OptionProblem(OptionProblemKind.relativePath, trf('기록 · 배치 파일은 전체 경로로 적어 주세요: {0}', [f]), f)); + } + return bad; +} + +/// 긴 옵션 [names] 의 값 (--이름=값 · --이름 값) +List _optionValues(List words, Set names) => [ + for (var i = 0; i < words.length; i++) + if (words[i].contains('=') && names.contains(words[i].substring(0, words[i].indexOf('=')))) + words[i].substring(words[i].indexOf('=') + 1) + else if (names.contains(words[i]) && i + 1 < words.length) + words[i + 1], + ]; + +/// 파일을 받는 옵션의 값 (--exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · "merge 파일"). +/// ": 이름" · dir-merge 는 폴더마다 읽는 파일 이름이라 (원래 상대) 빼고 +List listFileValues(List words) { + const fileOpts = {'--exclude-from', '--include-from', '--files-from'}; + final out = []; + String? mergeFile(String rule) => RegExp(r'^(?:\.|merge)(?:,[^\s]*)?\s+(.+)$').firstMatch(rule.trim())?.group(1); + for (var i = 0; i < words.length; i++) { + final w = words[i]; + final next = i + 1 < words.length ? words[i + 1] : null; + final eq = w.indexOf('='); + if (w.startsWith('--') && eq > 0 && fileOpts.contains(w.substring(0, eq))) { + out.add(w.substring(eq + 1)); + } else if (fileOpts.contains(w) && next != null) { + out.add(next); + i++; + } else if (w.startsWith('--filter=')) { + final f = mergeFile(w.substring(9)); + if (f != null) out.add(f); + } else if (w == '--filter' && next != null) { + final f = mergeFile(next); + if (f != null) out.add(f); + i++; + } else if (!w.startsWith('--') && w.length > 1 && w.startsWith('-')) { + // 짧은 옵션 묶음의 f: 붙은 값 (-f". 목록") 또는 다음 낱말 (-avf ". 목록") + final k = w.indexOf(RegExp('[BefMT@]'), 1); + if (k < 0 || w[k] != 'f') continue; + final rule = k == w.length - 1 ? next : w.substring(k + 1); + if (k == w.length - 1) i++; + final f = rule == null ? null : mergeFile(rule); + if (f != null) out.add(f); + } + } + return out; +} + +/// 전체 경로. Windows: 드라이브 (C:\ · C:/) · UNC (\\서버 · //서버) · /cygdrive/… - 그 밖의 / 는 MSYS 가 자기 설치 폴더 기준으로 읽어 뺀다. +/// Android: / 로 시작 +bool _absolutePath(String f, {required bool windows}) => + windows ? RegExp(r'^(?:[A-Za-z]:[\\/]|\\\\|//|/cygdrive/)').hasMatch(f) : f.startsWith('/'); diff --git a/lib/core/sync_tools.dart b/lib/core/sync_tools.dart index ee291d0..31dee77 100644 --- a/lib/core/sync_tools.dart +++ b/lib/core/sync_tools.dart @@ -1,656 +1,6 @@ -import 'dart:io' show Platform; - -import 'package:path/path.dart' as p; - -import 'file_ops.dart' show appTempRsyncExcludes; -import '../l10n/tr.dart'; - -/// 파일 탐색기의 복사 · 이동 방법 (환경 설정 > 파일 탐색기 > 복사 · 동기화) -/// -/// - builtin: 앱이 직접 복사 ("현재 방식"). 모든 기기. -/// - rsync: rsync (Windows 는 MSYS2 의 rsync 를 처음 쓸 때 내려받음, Android 는 직접 지정한 실행 파일) -/// - robocopy: Windows 에 들어 있는 robocopy (Windows 만) -enum CopyMethod { - builtin('현재 방식'), - rsync('rsync'), - robocopy('robocopy'); - - final String label; - const CopyMethod(this.label); - - static CopyMethod of(String? name) => values.firstWhere((m) => m.name == name, orElse: () => builtin); -} - -const defaultRsyncOptions = '-avPog'; -const defaultRobocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2'; - -/// 관리자 지시 (10/20): 화면에서 줄바꿈 없이 묶은 옵션 글 (sync_preview_view 의 unbreakableOption) 의 줄바꿈 없는 빈칸 (U+00A0 등) · -/// 보이지 않는 글자 (U+2060 등) 가 복사 · 붙여 넣기로 옵션에 들어오면 rsync 가 다른 인수로 읽는다 ("-", 보이지 않는 글자, "-filter") → 보통 빈칸으로 / 지움. -/// 다른 곳 (웹 페이지 · 문서) 에서 붙여 넣은 것도 같다 -/// 28 확인 (10/20): 소프트 하이픈 (U+00AD) 은 지우고 (rsync 가 "unknown option" 으로 실패), 전각 빈칸 (U+3000) 도 보통 빈칸으로 -String cleanOptionText(String s) => s - .replaceAll(RegExp('[\u00A0\u2007\u202F\u3000]'), ' ') - .replaceAll(RegExp('[\u00AD\u200B\u200C\u200D\u2060\uFEFF]'), ''); - -/// 낱말 맨 앞에서 - 로 읽는 대시 (‐ ‑ ‒ – — ― − ﹘ ﹣ -) - 워드 · 웹이 -- 를 바꿔 놓은 것 -const _dashLike = '\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uFE58\uFE63\uFF0D'; - -/// 굽은 따옴표 → 곧은 따옴표 (같은 무리끼리 열고 닫는다) -const _curlyQuote = {'\u2018': "'", '\u2019': "'", '\u201A': "'", '\u201C': '"', '\u201D': '"', '\u201E': '"'}; - -/// 옵션 글자를 인수로 나눈다 (큰따옴표 · 작은따옴표로 묶은 것은 하나로). 먼저 [cleanOptionText]. -/// 관리자 지시 (10/20, 28 · 15 확인): 웹 · 문서에서 붙여 넣은 글을 곧은 글자로 쓴 것과 같게 읽는다 - 예전에는 -/// --exclude=‘*.log’ 가 아무것도 거르지 못해 받는 쪽 keep.log 가 지워졌고, –delete 는 원본 경로로 넘어가 --delete 가 몰래 빠졌다. -/// - 굽은 따옴표 (‘ ’ “ ”) 는 낱말 맨 앞 · = 뒤 · 짧은 옵션 (-f) 뒤에서 열고, 같은 무리의 굽은 따옴표에서 닫는다 -/// (곧은 따옴표처럼 뒤에 글이 이어져도 - “a b”.txt → a b.txt). 작은따옴표 무리는 글자 사이 (it’s) 이면 닫지 않는다. -/// 이름 안의 굽은 따옴표 (it’s.txt) 는 그대로 둔다. -/// - 닫히지 않은 따옴표 (15 확인 F1): 예전에는 뒤의 옵션을 모두 삼켜 지키려던 --exclude 가 사라졌다 → 그 따옴표를 글자로 보고 -/// 나머지는 그대로 나누며, [unreadableOptions] 가 알려 실행을 막는다. -/// - 낱말 맨 앞의 대시 (– — ‑ - 등, 따옴표 밖): 둘이면 --, 하나면 rsync 의 긴 옵션 이름 (–delete) 일 때만 -- 그 밖 (–avz) 은 -. -/// 옵션의 값 자리 (--exclude –x.txt 의 –x.txt) · 낱말 가운데의 대시는 그대로 (파일 이름). --filter · -f 값의 규칙 머리 (“– *.tmp”) 는 - 로. -List splitOptions(String s) => _parseOptions(s).words; - -/// rsync 3.x 의 긴 옵션 이름 (앱에 든 rsync 3.5.1 의 --help) - 대시 하나로 붙여 넣은 –delete 를 --delete 로 읽을지 정한다 -const _rsyncLong = { - 'acls', 'address', 'append', 'append-verify', 'archive', 'atimes', 'backup', 'backup-dir', 'block-size', 'blocking-io', - 'bwlimit', 'cc', 'checksum', 'checksum-choice', 'checksum-seed', 'chmod', 'chown', 'compare-dest', 'compress', - 'compress-choice', 'compress-level', 'compress-threads', 'confine-root', 'contimeout', 'copy-as', 'copy-dest', - 'copy-devices', 'copy-dirlinks', 'copy-links', 'copy-unsafe-links', 'crtimes', 'cvs-exclude', 'daemon', 'debug', 'del', - 'delay-updates', 'delete', 'delete-after', 'delete-before', 'delete-delay', 'delete-during', 'delete-excluded', - 'delete-missing-args', 'devices', 'dirs', 'dry-run', 'early-input', 'exclude', 'exclude-from', 'executability', 'existing', - 'fake-super', 'files-from', 'filter', 'force', 'from0', 'fsync', 'fuzzy', 'group', 'groupmap', 'hard-links', 'help', - 'human-readable', 'i-r', 'iconv', 'ignore-errors', 'ignore-existing', 'ignore-missing-args', 'ignore-non-existing', - 'ignore-times', 'inc-recursive', 'include', 'include-from', 'info', 'inplace', 'insecure-links', 'ipv4', 'ipv6', - 'itemize-changes', 'keep-dirlinks', 'link-dest', 'links', 'list-only', 'log-file', 'log-file-format', 'max-alloc', - 'max-delete', 'max-size', 'min-size', 'mkpath', 'modify-window', 'munge-links', 'no-i-r', 'no-implied-dirs', - 'no-inc-recursive', 'no-motd', 'no-whole-file', 'numeric-ids', 'old-args', 'old-d', 'old-dirs', 'omit-dir-times', - 'omit-link-times', 'one-file-system', 'only-write-batch', 'open-noatime', 'out-format', 'outbuf', 'owner', 'partial', - 'partial-dir', 'password-file', 'perms', 'port', 'preallocate', 'progress', 'protocol', 'prune-empty-dirs', 'quiet', - 'read-batch', 'recursive', 'relative', 'remote-option', 'remove-source-files', 'rsh', 'rsync-path', 'safe-links', - 'secluded-args', 'size-only', 'skip-compress', 'sockopts', 'sparse', 'specials', 'stats', 'stderr', 'stop-after', 'stop-at', - 'suffix', 'super', 'temp-dir', 'timeout', 'times', 'trust-sender', 'update', 'usermap', 'verbose', 'version', - 'whole-file', 'write-batch', 'write-devices', 'xattrs', 'zc', 'zl', 'zt', -}; - -/// 나눈 낱말: 맨 앞이 따옴표 안이었는지 (그러면 맨 앞 대시를 옵션으로 고치지 않는다) -typedef _Word = ({String text, bool quoted}); - -/// [splitOptions] · [unreadableOptions] 의 몸통: 낱말들과 닫히지 않은 따옴표 (뒤 글 일부) -({List words, List unclosed}) _parseOptions(String s) { - final chars = cleanOptionText(s).split(''); - final literal = {}; // 닫히지 않아 글자로 보는 따옴표 자리 - final unclosed = []; - while (true) { - final (raw, open) = _splitRaw(chars, literal); - if (open == null) return (words: _normalizeWords(raw), unclosed: unclosed); - literal.add(open); - final rest = chars.sublist(open).join().trim(); - unclosed.add(rest.length > 30 ? '${rest.substring(0, 30)}…' : rest); - } -} - -/// 따옴표 · 빈칸으로 나눈다. 끝까지 닫히지 않은 따옴표가 있으면 그 자리 (두 번째 값) -(List<_Word>, int?) _splitRaw(List chars, Set literal) { - final out = <_Word>[]; - final cur = StringBuffer(); - String? quote; // 닫는 곧은 따옴표 (" 또는 ') - var curly = false; // 굽은 따옴표로 열었는지 - var openAt = -1; - var has = false; - var quoted = false; - bool letter(int i) => i >= 0 && i < chars.length && RegExp(r'[\p{L}\p{N}]', unicode: true).hasMatch(chars[i]); - for (var i = 0; i < chars.length; i++) { - final ch = chars[i]; - if (quote != null) { - // 작은따옴표 무리의 ’ 가 글자 사이면 (it’s) 닫지 않는다 - final closes = ch == quote && !curly || - curly && _curlyQuote[ch] == quote && !(quote == "'" && letter(i - 1) && letter(i + 1)); - if (closes) { - quote = null; - curly = false; - } else { - cur.write(ch); - } - } else if (literal.contains(i)) { - cur.write(ch); - } else if (ch == '"' || ch == "'") { - if (cur.isEmpty && !has) quoted = true; - quote = ch; - openAt = i; - has = true; - } else if (_curlyQuote.containsKey(ch) && (cur.isEmpty || '$cur'.endsWith('=') || RegExp(r'^-[A-Za-z0-9]+$').hasMatch('$cur'))) { - if (cur.isEmpty && !has) quoted = true; - quote = _curlyQuote[ch]; - curly = true; - openAt = i; - has = true; - } else if (ch.trim().isEmpty) { - if (cur.isNotEmpty || has) out.add((text: cur.toString(), quoted: quoted)); - cur.clear(); - has = false; - quoted = false; - } else { - cur.write(ch); - } - } - if (quote != null) return (out, openAt); - if (cur.isNotEmpty || has) out.add((text: cur.toString(), quoted: quoted)); - return (out, null); -} - -bool _dashAt(String t, int i) => i < t.length && (t[i] == '-' || _dashLike.contains(t[i])); - -/// 거르기 규칙 머리 ("– *.tmp" · "+ *.txt") 를 rsync 가 읽는 - · + 로 -String _fixRuleHead(String v) { - if (v.isEmpty) return v; - if (_dashLike.contains(v[0])) return '-${v.substring(1)}'; - if (v[0] == '\uFF0B') return '+${v.substring(1)}'; - return v; -} - -/// 낱말 맨 앞 대시 고치기 · 거르기 값의 규칙 머리 고치기 (값 자리는 그대로) -List _normalizeWords(List<_Word> raw) { - final out = []; - var value = ''; // 다음 낱말이 값: 'filter' (규칙 머리를 고침) · 'other' (그대로) - for (final w in raw) { - var t = w.text; - if (value.isNotEmpty) { - out.add(value == 'filter' ? _fixRuleHead(t) : t); - value = ''; - continue; - } - // 28 확인 (10/20): 곧은 하이픈과 섞인 것 (-–delete) 도 맨 앞 둘 중 하나라도 대시면 같은 규칙으로 - if (!w.quoted && t.isNotEmpty && (_dashLike.contains(t[0]) || t.length > 1 && t[0] == '-' && _dashLike.contains(t[1]))) { - final n = _dashAt(t, 1) ? 2 : 1; - final rest = t.substring(n); - final name = rest.split('=').first; - final long = n == 2 || _rsyncLong.contains(name) || (name.startsWith('no-') && _rsyncLong.contains(name.substring(3))); - t = '${long ? '--' : '-'}$rest'; - } - if (t.startsWith('--filter=')) { - t = '--filter=${_fixRuleHead(t.substring(9))}'; - } else if (t.length > 1 && t.startsWith('-') && !t.startsWith('--')) { - final k = t.indexOf(RegExp('[BefMT@]'), 1); - if (k > 0 && t[k] == 'f' && k < t.length - 1) t = '${t.substring(0, k + 1)}${_fixRuleHead(t.substring(k + 1))}'; - } - out.add(t); - if (t.startsWith('--')) { - if (!t.contains('=') && _rsyncLongWithValue.contains(t)) value = t == '--filter' ? 'filter' : 'other'; - } else if (t.length > 1 && t.startsWith('-')) { - final k = t.indexOf(RegExp('[BefMT@]'), 1); - if (k == t.length - 1) value = t[k] == 'f' ? 'filter' : 'other'; - } - } - return out; -} - -/// 값을 다음 낱말로 받는 rsync 긴 옵션 (--exclude *.log) - [unreadableOptions] 가 그 값을 옵션으로 보지 않게 -const _rsyncLongWithValue = { - '--exclude', '--include', '--filter', '--exclude-from', '--include-from', '--files-from', '--backup-dir', '--suffix', - '--log-file', '--log-file-format', '--out-format', '--chmod', '--chown', '--usermap', '--groupmap', '--bwlimit', - '--timeout', '--contimeout', '--rsh', '--rsync-path', '--compare-dest', '--copy-dest', '--link-dest', '--partial-dir', - '--temp-dir', '--max-size', '--min-size', '--max-delete', '--modify-window', '--password-file', '--iconv', - '--block-size', '--checksum-choice', '--compress-choice', '--compress-level', '--skip-compress', '--info', '--debug', - '--sockopts', '--port', '--address', '--outbuf', '--remote-option', '--max-alloc', '--stop-after', '--stop-at', - '--write-batch', '--only-write-batch', '--read-batch', '--protocol', '--checksum-seed', '--copy-as', '--early-input', - // 28 확인 (10/20): rsync --help 의 짧은 별칭 (aka --cc · --zc · --zl · --zt) 과 값 받는 옵션 - 빠져 있어 값이 "경로로 읽힙니다" 로 막혔다 - '--cc', '--zc', '--zl', '--zt', '--compress-threads', '--confine-root', -}; - -/// 관리자 지시 (10/20, 28 · 15 확인): [splitOptions] 로 고쳐 읽은 뒤에도 rsync 가 다르게 읽을 것 - 있으면 확인 창 · 실시간 동기화에 -/// 빨갛게 알리고 실행을 막는다: 닫히지 않은 따옴표, 옵션 자리의 - 로 시작하지 않는 낱말 (rsync 는 원본 경로로 읽어 옵션이 몰래 빠짐), -/// 하이픈 셋 이상, 끝에 값 없이 남은 값 받는 옵션. 옵션의 값 자리 (--exclude 한글.txt 의 한글.txt, -f 뒤) 는 세지 않는다 -List unreadableOptions(String options, {bool? windows}) => [for (final x in optionProblems(options, windows: windows)) x.text]; - -/// 옵션 칸의 문제 종류 - 안내의 끝 문구 (고칠 방법) 를 고른다 (관리자 지시 10/20, 28 지적) -enum OptionProblemKind { unclosed, path, noValue, odd, doubleQuote, relativePath, stdin } - -/// [unreadableOptions] 의 한 줄과 그 종류 -class OptionProblem { - final OptionProblemKind kind; - final String text; - - /// [text] 안의 옵션 낱말 (화면이 줄바꿈 없이 묶는다) - final String token; - const OptionProblem(this.kind, this.text, [this.token = '']); - - @override - String toString() => text; -} - -/// 문제 종류에 맞는 고칠 방법 (여러 종류면 · 로 잇는다) -String optionProblemHint(Iterable problems, {bool? windows}) { - windows ??= Platform.isWindows; - final kinds = {for (final x in problems) x.kind}; - return [ - if (kinds.contains(OptionProblemKind.unclosed)) tr('따옴표를 닫아 주세요'), - if (kinds.contains(OptionProblemKind.path)) tr('옵션은 - 나 -- 로 시작합니다'), - if (kinds.contains(OptionProblemKind.noValue)) tr('값을 적어 주세요 (예: --exclude=*.tmp)'), - if (kinds.contains(OptionProblemKind.odd)) tr('곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요'), - if (kinds.contains(OptionProblemKind.doubleQuote)) tr('큰따옴표 (") 를 빼 주세요'), - // 관리자 지시 (10/20): Windows 는 드라이브로 시작하는 전체 경로만 ("/" 는 rsync 가 자기 설치 폴더 기준으로 읽음), Android 는 / - if (kinds.contains(OptionProblemKind.relativePath)) windows ? tr('C:\\ 처럼 드라이브로 시작하는 전체 경로로') : tr('/ 로 시작하는 전체 경로로'), - if (kinds.contains(OptionProblemKind.stdin)) tr('목록 파일의 전체 경로를 적어 주세요'), - ].join(' · '); -} - -/// "옵션을 알아볼 수 없습니다: … - 고칠 방법" (작업 기록 · 실시간 동기화 카드 · 설정 칸) -String optionProblemMessage(List problems) => - trf('옵션을 알아볼 수 없습니다: {0} - {1}', [problems.join(' · '), optionProblemHint(problems)]); - -/// [unreadableOptions] 의 몸통 (종류와 함께) -List optionProblems(String options, {bool? windows}) { - windows ??= Platform.isWindows; - final parsed = _parseOptions(options); - // 15 확인 F1: 닫히지 않은 따옴표 - 뒤의 옵션이 다르게 읽힐 수 있다 - final bad = [for (final u in parsed.unclosed) OptionProblem(OptionProblemKind.unclosed, trf('따옴표가 닫히지 않았습니다: {0}', [u]), u)]; - var value = false; - var last = ''; - for (final t in parsed.words) { - // P0 (10/20): Windows 의 rsync (MSYS2) 에는 큰따옴표가 든 인수를 그대로 넘길 수 없다 (prepareRsyncLaunch) - 값 자리도. - // 닫히지 않은 따옴표로 이미 막혔으면 (그 따옴표가 낱말에 남음) 한 번만 알린다 - if (windows && parsed.unclosed.isEmpty && t.contains('"')) { - bad.add(OptionProblem(OptionProblemKind.doubleQuote, trf('큰따옴표 (") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}', [t]), t)); - value = false; - continue; - } - if (value) { - value = false; - continue; - } - if (t.startsWith('---')) { - // 관리자 판단 (10/20, 28 확인): 하이픈이 셋 이상 (---delete) 은 rsync 가 알아보지 못한다 - 끝 문구는 "옵션은 - 나 -- 로 시작합니다" - bad.add(OptionProblem(OptionProblemKind.path, t, t)); - } else if (t.startsWith('--')) { - value = !t.contains('=') && _rsyncLongWithValue.contains(t); - last = t; - } else if (t.length > 1 && t.startsWith('-')) { - // 짧은 옵션 묶음: 값을 받는 글자 (B · e · f · M · T · @) 가 끝이면 다음 낱말이 값 (rsync_filter 와 같은 규칙) - final k = t.indexOf(RegExp('[BefMT@]'), 1); - value = k == t.length - 1; - last = t; - } else { - // 관리자 판단 (10/20, 28 확인): 옵션 칸에는 경로가 들어갈 자리가 없다 (원본 · 대상은 앱이 넣음) - 값 자리가 아닌데 - // - 로 시작하지 않는 낱말 (-a delete 의 delete, 혼자 쓴 -) 은 rsync 가 원본 경로로 읽어 옵션이 몰래 빠진다 - // ASCII 밖 글자로 시작하면 (« … 등) 붙여 넣은 이상한 글자 - 안내는 곧은 글자로 다시 적기 - bad.add(OptionProblem(t.codeUnitAt(0) > 0x7F ? OptionProblemKind.odd : OptionProblemKind.path, trf('경로로 읽힙니다: {0}', [t]), t)); - } - } - // 값이 필요한 옵션이 끝에 값 없이 (--exclude) - rsync 가 원본 경로를 그 값으로 먹는다 - if (value) bad.add(OptionProblem(OptionProblemKind.noValue, trf('{0} 의 값이 없습니다', [last]), last)); - // 관리자 판단 (10/20): 목록 파일의 상대 경로 - rsync 는 자기 작업 폴더 (Windows 는 응답 파일 폴더) 기준으로, 미리 보기는 앱의 - // 작업 폴더 기준으로 읽어 서로 다른 파일을 볼 수 있다. 전체 경로만 받는다 - for (final f in listFileValues(parsed.words)) { - // 관리자 지시 (10/20, 28 지적): "-" (표준 입력) 는 따로 - 앱은 rsync 에 표준 입력을 주지 않는다 - if (f == '-') { - bad.add(OptionProblem(OptionProblemKind.stdin, tr('표준 입력 (-) 은 쓸 수 없습니다'), f)); - } else if (!_absolutePath(f, windows: windows)) { - bad.add(OptionProblem(OptionProblemKind.relativePath, trf('목록 파일은 전체 경로로 적어 주세요: {0}', [f]), f)); - } - } - // 관리자 지시 (10/20, 28 확인): 기록 · 배치 파일도 - 상대 경로면 응답 파일 임시 폴더에 생겼다가 함께 지워져 찾을 수 없다. - // --backup-dir · --partial-dir · --temp-dir · --compare-dest 류는 받는 쪽 기준이라 그대로 - for (final f in _optionValues(parsed.words, const {'--log-file', '--write-batch', '--read-batch', '--only-write-batch'})) { - if (!_absolutePath(f, windows: windows)) bad.add(OptionProblem(OptionProblemKind.relativePath, trf('기록 · 배치 파일은 전체 경로로 적어 주세요: {0}', [f]), f)); - } - return bad; -} - -/// 긴 옵션 [names] 의 값 (--이름=값 · --이름 값) -List _optionValues(List words, Set names) => [ - for (var i = 0; i < words.length; i++) - if (words[i].contains('=') && names.contains(words[i].substring(0, words[i].indexOf('=')))) - words[i].substring(words[i].indexOf('=') + 1) - else if (names.contains(words[i]) && i + 1 < words.length) - words[i + 1], - ]; - -/// 파일을 받는 옵션의 값 (--exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · "merge 파일"). -/// ": 이름" · dir-merge 는 폴더마다 읽는 파일 이름이라 (원래 상대) 빼고 -List listFileValues(List words) { - const fileOpts = {'--exclude-from', '--include-from', '--files-from'}; - final out = []; - String? mergeFile(String rule) => RegExp(r'^(?:\.|merge)(?:,[^\s]*)?\s+(.+)$').firstMatch(rule.trim())?.group(1); - for (var i = 0; i < words.length; i++) { - final w = words[i]; - final next = i + 1 < words.length ? words[i + 1] : null; - final eq = w.indexOf('='); - if (w.startsWith('--') && eq > 0 && fileOpts.contains(w.substring(0, eq))) { - out.add(w.substring(eq + 1)); - } else if (fileOpts.contains(w) && next != null) { - out.add(next); - i++; - } else if (w.startsWith('--filter=')) { - final f = mergeFile(w.substring(9)); - if (f != null) out.add(f); - } else if (w == '--filter' && next != null) { - final f = mergeFile(next); - if (f != null) out.add(f); - i++; - } else if (!w.startsWith('--') && w.length > 1 && w.startsWith('-')) { - // 짧은 옵션 묶음의 f: 붙은 값 (-f". 목록") 또는 다음 낱말 (-avf ". 목록") - final k = w.indexOf(RegExp('[BefMT@]'), 1); - if (k < 0 || w[k] != 'f') continue; - final rule = k == w.length - 1 ? next : w.substring(k + 1); - if (k == w.length - 1) i++; - final f = rule == null ? null : mergeFile(rule); - if (f != null) out.add(f); - } - } - return out; -} - -/// 전체 경로. Windows: 드라이브 (C:\ · C:/) · UNC (\\서버 · //서버) · /cygdrive/… - 그 밖의 / 는 MSYS 가 자기 설치 폴더 기준으로 읽어 뺀다. -/// Android: / 로 시작 -bool _absolutePath(String f, {required bool windows}) => - windows ? RegExp(r'^(?:[A-Za-z]:[\\/]|\\\\|//|/cygdrive/)').hasMatch(f) : f.startsWith('/'); - -/// 관리자 판단 (10/20, 28 확인): rsync (Windows MSYS2) 는 목록 · 기록 · 배치 파일의 C:\… · C:/… · \\서버\… 를 열지 못한다 -/// ("failed to open exclude file", exit 11) - 그 값들을 경로처럼 [toCygwinPath] 로 바꿔 넘긴다 (막지 않음). -/// 대상: --exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · "merge 파일" · --log-file · --write-batch · --read-batch · --only-write-batch -List rsyncFileOptionArgs(List words, {required bool windows}) { - if (!windows) return words; - const opts = { - '--exclude-from', '--include-from', '--files-from', '--log-file', '--write-batch', '--read-batch', '--only-write-batch', - }; - // 28 · 15 확인 (10/20, MSYS 3.5.1): 이 파일들은 UNC 를 \\서버\… 로만 연다 (//서버/… 는 "failed to open", exit 11) - UNC 는 \\ 로, - // 드라이브는 /cygdrive/… 로 - String conv(String v) => RegExp(r'^[A-Za-z]:[\\/]').hasMatch(v) - ? toCygwinPath(v) - : RegExp(r'^(?:\\\\|//)(?!cygdrive/)').hasMatch(v) - ? v.replaceAll('/', '\\') - : v; - String rule(String r) { - final m = RegExp(r'^(\s*(?:\.|merge)(?:,[^\s]*)?\s+)(.+)$').firstMatch(r); - return m == null ? r : '${m[1]}${conv(m[2]!)}'; - } - - final out = []; - for (var i = 0; i < words.length; i++) { - final w = words[i]; - final next = i + 1 < words.length ? words[i + 1] : null; - final eq = w.indexOf('='); - if (w.startsWith('--') && eq > 0 && opts.contains(w.substring(0, eq))) { - out.add('${w.substring(0, eq + 1)}${conv(w.substring(eq + 1))}'); - } else if (opts.contains(w) && next != null) { - out - ..add(w) - ..add(conv(next)); - i++; - } else if (w.startsWith('--filter=')) { - out.add('--filter=${rule(w.substring(9))}'); - } else if (w == '--filter' && next != null) { - out - ..add(w) - ..add(rule(next)); - i++; - } else if (!w.startsWith('--') && w.length > 1 && w.startsWith('-')) { - // 짧은 옵션 묶음의 f: 붙은 값 (-f". 목록") 또는 다음 낱말 (-avf ". 목록") - final k = w.indexOf(RegExp('[BefMT@]'), 1); - if (k >= 0 && w[k] == 'f' && k < w.length - 1) { - out.add('${w.substring(0, k + 1)}${rule(w.substring(k + 1))}'); - } else if (k >= 0 && w[k] == 'f' && next != null) { - out - ..add(w) - ..add(rule(next)); - i++; - } else { - out.add(w); - // 다른 값 받는 글자 (-e ssh 등) 의 값은 그대로 건너뜀 - if (k == w.length - 1 && next != null) { - out.add(next); - i++; - } - } - } else { - out.add(w); - } - } - return out; -} - -/// Windows 경로 → MSYS2 rsync 가 읽는 경로 (C:\a\b → /cygdrive/c/a/b, \\server\share\x → //server/share/x). -/// MSYS2 rsync 는 "C:" 를 원격 컴퓨터 이름으로 읽으므로 꼭 바꿔야 한다. -/// 받는 쪽 (대상) 경로를 rsync 에 넘길 꼴로. 28 · 15 확인 (10/20, MSYS 3.5.1): 받는 쪽의 UNC 는 \\서버\… 만 된다 -/// (//서버/… 는 change_dir 실패, exit 3) - 원본은 거꾸로 //서버/… 만 되어 [toCygwinPath] 그대로 -String rsyncDestPath(String path, {bool windows = true}) { - if (windows && RegExp(r'^(?:\\\\|//)(?!cygdrive/)').hasMatch(path)) return path.replaceAll('/', '\\'); - return toCygwinPath(path, windows: windows); -} - -String toCygwinPath(String path, {bool windows = true}) { - if (!windows) return path; - final s = path.replaceAll('\\', '/'); - final m = RegExp(r'^([A-Za-z]):(/.*)?$').firstMatch(s); - if (m != null) return '/cygdrive/${m[1]!.toLowerCase()}${m[2] ?? '/'}'; - return s; // //server/share 는 그대로 읽는다 -} - -/// rsync 옵션에 -u (--update: 받는 쪽이 더 새 파일은 건너뜀) 를 더한다. 이미 있으면 그대로 (-avu 처럼 묶인 것도). -/// 양쪽 (⇄) 으로 함께 맞출 때 서로의 새 파일을 옛 파일로 덮어쓰지 않게. -String withUpdateOption(String options) { - final has = splitOptions(options).any((o) => o == '--update' || (o.startsWith('-') && !o.startsWith('--') && o.contains('u'))); - return has ? options : '${options.trim()} -u'.trim(); -} - -/// rsync 인수: [options] + (대역폭 · 이동) + 원본들 + 대상 폴더/ -/// 원본 폴더는 끝에 / 를 붙이지 않는다 (폴더째 대상 안으로 - 앱의 복사와 같은 결과). -List rsyncArgs({ - required String options, - required List sources, - required String dest, - int bandwidthKBps = 0, - bool move = false, - bool windows = true, - bool contents = false, -}) { - // 178: 원본을 지우는 옵션은 [move] (이동 · [원본 파일 지우기]) 로만 - 옵션 글에 있어도 빼고, 이동일 때만 넣는다 - final opts = rsyncFileOptionArgs([for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o], windows: windows); - return [ - ...opts, - if (bandwidthKBps > 0 && !opts.any((o) => o.startsWith('--bwlimit'))) '--bwlimit=$bandwidthKBps', - // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 출력 (Android 빌드는 iconv 없음 · 진행 표시에 쓰는 이름) - if (!windows && !opts.contains('-8') && !opts.contains('--8-bit-output')) '-8', - if (move) '--remove-source-files', - ...appTempRsyncExcludes, // 78 보충: 일괄 이름 바꾸기 중의 임시 이름 (~jjren_ · ~jjback_) 은 보내지 않는다 - // [contents]: 폴더 "안의 것" 을 대상에 (rsync 의 끝 / - 동기화 · lsync 와 같은 모양) - for (final s in sources) '${toCygwinPath(_noTrailingSlash(s), windows: windows)}${contents ? '/' : ''}', - '${rsyncDestPath(_noTrailingSlash(dest), windows: windows)}/', - ]; -} - -String _noTrailingSlash(String s) => s.length > 3 && (s.endsWith('/') || s.endsWith('\\')) ? s.substring(0, s.length - 1) : s; - -/// 원본을 지우는 rsync · robocopy 옵션인지 (--remove-source-files · --remove-sent-files · /MOV · /MOVE) - 107 · 178 -bool isSourceRemovingOption(String o) => - o == '--remove-source-files' || o == '--remove-sent-files' || o.toUpperCase() == '/MOV' || o.toUpperCase() == '/MOVE'; - -/// robocopy 한 번 실행할 인수 (robocopy 는 "폴더 → 폴더" 라 원본마다 따로 만든다). -/// - 폴더: robocopy <폴더> <대상>\<폴더 이름> [옵션] -/// - 파일: robocopy <파일이 있는 폴더> <대상> <파일 이름들…> [옵션] (같은 폴더의 파일은 한 번에) -/// 대역폭: robocopy 에는 속도 제한이 없어 /IPG (조각 사이 쉬는 시간) 로 비슷하게 맞춘다. -List> robocopyRuns({ - required String options, - required List folders, - required List files, - required String dest, - int bandwidthKBps = 0, - bool move = false, - bool contents = false, - bool skipNewer = false, -}) { - // 178: 원본을 지우는 옵션 (/MOV · /MOVE) 은 [move] 로만 - final opts = [for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o]; - final extra = robocopyExtraOptions(opts, move: move, skipNewer: skipNewer, bandwidthKBps: bandwidthKBps); - final runs = >[ - for (final f in folders) [f, contents ? dest : p.join(dest, p.basename(f)), ...opts, ...extra], - ]; - final byDir = >{}; - for (final f in files) { - byDir.putIfAbsent(p.dirname(f), () => []).add(p.basename(f)); - } - // 파일만 옮길 때 /E · /MOVE 는 하위 폴더까지 건드리므로 뺀다 - final fileOpts = opts.where((o) => !const ['/E', '/S', '/MIR'].contains(o.toUpperCase())).toList(); - final fileExtra = extra.map((o) => o == '/MOVE' ? '/MOV' : o).toList(); - byDir.forEach((dir, names) => runs.add([dir, dest, ...names, ...fileOpts, ...fileExtra])); - return runs; -} - -/// 앱이 옵션 칸 ([opts], 원본 지우기 옵션은 뺀 것) 뒤에 붙이는 robocopy 옵션 - 실행 (robocopyRuns) 과 화면의 명령 줄이 같게 -List robocopyExtraOptions(List opts, {bool move = false, bool skipNewer = false, int bandwidthKBps = 0}) => [ - if (bandwidthKBps > 0 && !opts.any((o) => o.toUpperCase().startsWith('/IPG'))) '/IPG:${robocopyIpg(bandwidthKBps)}', - if (move) ...robocopyMoveOptions(opts), - // 데이터: 링크 · junction 은 따라가지 않는다 (복사 · 이동 모두 처음 값 - rsync -a 가 링크를 링크로 두는 것처럼). - // 따라가면 원본 바깥 폴더의 파일을 옮기고 지우거나 (/MOVE), 고리 junction (AppData 의 "Application Data" 등) 을 끝없이 돈다. - // 옵션 칸에 사용자가 링크 옵션을 넣었으면 그대로 - if (!robocopyLinkOption(opts)) '/XJ', - // 229: 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" - robocopy 는 기본으로 예전 원본 (Older) 도 덮어쓴다 - if (skipNewer && !opts.any((o) => o.toUpperCase() == '/XO')) '/XO', - ]; - -/// 옵션 칸에 링크 · junction 을 다루는 robocopy 옵션이 있는지 (/XJ · /XJD · /XJF · /SJ · /SL) -bool robocopyLinkOption(List opts) => opts.any((o) => const ['/XJ', '/XJD', '/XJF', '/SJ', '/SL'].contains(o.toUpperCase())); - -/// robocopy 가 원본의 링크 · junction 을 건너뛰는지 (미리 보기) - 사용자가 /SJ · /SL 로 링크째 복사를 고르지 않았으면 -bool robocopySkipsLinks(List opts) => !opts.any((o) => const ['/SJ', '/SL'].contains(o.toUpperCase())); - -/// 이동에 붙이는 robocopy 옵션: /MOVE + 같은 파일도 포함. -/// 217: 대상에 같은 파일이 있으면 robocopy 는 건너뛰고 원본에 남긴다 (종료 코드 0 - "옮겼습니다" 인데 남음, Windows 에서 확인). -/// 같음 (/IS) 뿐 아니라 속성만 다름 (Tweaked, /IT) · 만든 시각만 다름 (Modified, /IM - 다른 프로그램으로 복사한 같은 파일) 도 넣어 원본에서 지운다. -/// 그래도 남는 것은 이동 뒤 다시 세어 알린다 (216). 링크 · junction 은 [robocopyExtraOptions] 의 /XJ 로 남긴다 -/// (robocopy 는 기본으로 junction 을 따라가 /MOVE 가 링크가 가리키는 원본 바깥 폴더의 파일까지 옮기고 지웠다 - Windows 에서 확인) -List robocopyMoveOptions(List opts) => [ - '/MOVE', - for (final o in const ['/IS', '/IT', '/IM']) - if (!opts.any((x) => x.toUpperCase() == o)) o, - ]; - -/// robocopy /IPG 밀리초: robocopy 는 64KB 조각마다 쉰다 → 64 / KB/s 초 -int robocopyIpg(int kbps) => kbps <= 0 ? 0 : (64 * 1000 / kbps).round().clamp(1, 60000); - -/// rsync · robocopy 의 성공 종료 코드 -bool rsyncOk(int code) => code == 0 || code == 24; // 24: 복사 도중 원본 파일이 사라짐 -bool robocopyOk(int code) => code >= 0 && code < 8; - -/// rsync -v 출력에서 "다 보낸 파일" 줄을 골라낸다 (-P 의 \r 진행 줄 · 머리글 · 요약 · 폴더 줄은 뺌). -/// 결과: 원본 기준 상대 경로 (예: "sub/inner.txt") -class RsyncOutput { - final _buf = StringBuffer(); - - /// 지금 파일의 진행률 (-P 의 " 1,234 45% 1.2MB/s" 줄) - double? currentPercent; - - /// 지금 전송 속도 (바이트/초, 같은 줄의 "1.2MB/s") - double? currentSpeed; - - /// 폴더 전체에서 확인한 항목 (진행 줄의 "to-chk=남은/전체" · "ir-chk=…"). 모르면 null. - int? checked, checkTotal; - - static final _chkRx = RegExp(r'(?:to|ir)-chk=(\d+)/(\d+)'); - static final _speedRx = RegExp(r'(\d+(?:\.\d+)?)([kKMGT]?)B/s'); - - List feed(String chunk) { - _buf.write(chunk); - final text = _buf.toString(); - final cut = text.lastIndexOf(RegExp(r'[\r\n]')); - if (cut < 0) return const []; - _buf - ..clear() - ..write(text.substring(cut + 1)); - final done = []; - for (final raw in text.substring(0, cut).split(RegExp(r'[\r\n]'))) { - final line = raw.trimRight(); - if (line.isEmpty) continue; - if (line.startsWith(' ')) { - final m = RegExp(r'\s(\d{1,3})%\s').firstMatch(line); - if (m != null) currentPercent = int.parse(m[1]!) / 100; - final ck = _chkRx.firstMatch(line); - if (ck != null) { - checkTotal = int.parse(ck[2]!); - checked = checkTotal! - int.parse(ck[1]!); - } - final sp = _speedRx.firstMatch(line); - if (sp != null) { - const mul = {'': 1, 'k': 1024, 'K': 1024, 'M': 1024 * 1024, 'G': 1024 * 1024 * 1024, 'T': 1024 * 1024 * 1024 * 1024}; - currentSpeed = double.parse(sp[1]!) * mul[sp[2]!]!; - } - continue; - } - if (line.endsWith('/')) continue; // 폴더 - if (line.startsWith('sending ') || - line.startsWith('receiving ') || - line.startsWith('sent ') || - line.startsWith('total size') || - line.startsWith('created directory') || - line.startsWith('deleting ') || - line.startsWith('rsync:') || - line.startsWith('rsync error') || - line.contains(' -> ')) { - continue; - } - currentPercent = null; - done.add(line); - } - return done; - } -} - -/// robocopy 출력에서 파일 줄 ("New File", "Newer", "Older", "Changed" … 뒤의 크기 · 이름) 을 센다 -class RobocopyOutput { - final _buf = StringBuffer(); - double? currentPercent; - - /// 파일 줄: 탭 · 종류 · 탭 탭 · 크기 · 탭 · 경로. 61: 종류 글은 Windows 언어마다 다르므로 - /// ("New File" · "새 파일" · "新しいファイル" · "新文件") 글이 아니라 탭 모양으로 알아본다. - /// 종류가 * 로 시작하면 (*EXTRA File 등) 대상에만 있는 것이라 세지 않는다. - static final _file = RegExp(r'^\t+\s*([^\t\s\d][^\t]*?)\s*\t+\s*[\d.]+\s*[kmgt]?\s*\t(.+)$', caseSensitive: false); - - List feed(String chunk) { - _buf.write(chunk); - final text = _buf.toString(); - final cut = text.lastIndexOf(RegExp(r'[\r\n]')); - if (cut < 0) return const []; - _buf - ..clear() - ..write(text.substring(cut + 1)); - final done = []; - for (final raw in text.substring(0, cut).split(RegExp(r'[\r\n]'))) { - final line = raw.trim(); - final pct = RegExp(r'^(\d{1,3}(?:\.\d)?)%$').firstMatch(line); - if (pct != null) { - currentPercent = double.parse(pct[1]!) / 100; - continue; - } - final m = _file.firstMatch(raw); - if (m != null && !m[1]!.startsWith('*')) { - currentPercent = null; - done.add(m[2]!.trim()); - } - } - return done; - } -} - -/// Windows 용 rsync: MSYS2 공식 저장소의 패키지 (처음 쓸 때 내려받음, 파일 SHA256 을 고정해 검증) -const msys2Repo = 'https://repo.msys2.org/msys/x86_64/'; -const msys2RsyncPackages = <(String, String)>[ - ('rsync-3.5.1-2-x86_64.pkg.tar.zst', 'cba52e41d16f9873324fcf09853619964da87bc1ce116781a5449c968d4ffecc'), - ('msys2-runtime-3.6.10-6-x86_64.pkg.tar.zst', 'b2db5bae3826f15cacb536f4c7c6c7e31d3fabec00aac8f127473aba7e994d22'), - ('gcc-libs-15.3.0-1-x86_64.pkg.tar.zst', '0d99a122c453c05ae21ba3dcea910f2e0d93d38ae067c677a112a315b0f3cec5'), - ('libiconv-1.19-1-x86_64.pkg.tar.zst', '0fa55ea2a6ccf97cf8c58b24b2615815e15e16e6e4e888091c263c2c83c5313d'), - ('libidn2-2.3.8-1-x86_64.pkg.tar.zst', '55e0de96b893e9f67f14a4283d411fc2a45d7b59333bc16e2a9b16e5bfc9282f'), - ('libintl-0.22.5-1-x86_64.pkg.tar.zst', '336d66b9d95cf9c1804958f8e260762a3e83bf158ed5981f783bc772a31073cf'), - ('liblz4-1.10.0-1-x86_64.pkg.tar.zst', '1cbbe51ee91bf32691aabb15bdb7c8548e8b5ef8195122ef31f10edcca6e952f'), - ('libopenssl-3.6.5-1-x86_64.pkg.tar.zst', '05f6cc946f0dde3aecc3632007fa1d874e40720226117b750327bc2a01846325'), - ('libunistring-1.4.2-1-x86_64.pkg.tar.zst', 'bcd04706d95b5e7127d39834565bceed6b400192f5fb51599745aba28d5da018'), - ('libxxhash-0.8.4-1-x86_64.pkg.tar.zst', 'ba784ad117626401cd5d3ec0cf70399f9de72a63755f96c28fa2032303f9c330'), - ('libzstd-1.5.7-1-x86_64.pkg.tar.zst', '5e370b2e725a0a7e5ff58d84653c9a0cd5a10b9cfe930f2d949a28ad2a11c1f2'), - ('popt-1.19-1-x86_64.pkg.tar.zst', 'b402bd1d03815c5e28b7ab415d8e20f487eff2460ae0271a99674eeb0d77194f'), -]; - -/// 패키지에서 꺼낼 파일 (rsync.exe 와 필요한 DLL 만) -const msys2RsyncFiles = { - 'rsync.exe', 'msys-2.0.dll', 'msys-gcc_s-seh-1.dll', 'msys-iconv-2.dll', 'msys-idn2-0.dll', 'msys-intl-8.dll', - 'msys-lz4-1.dll', 'msys-crypto-3.dll', 'msys-ssl-3.dll', 'msys-unistring-5.dll', 'msys-xxhash-0.dll', - 'msys-zstd-1.dll', 'msys-popt-0.dll', 'msys-charset-1.dll', -}; +// 약속 (나누기 sync_tools): 복사 · 동기화 도구 (rsync · robocopy) - 부르는 곳은 이 파일 하나만 가져온다. +// 일은 lib/core/sync/ 의 모듈이 한다: rsync_options (옵션 글 읽기 · 문제 찾기) · copy_commands (복사 방법 · 명령 인수 · 패키지) · +// copy_output (출력 읽기). +export 'sync/rsync_options.dart'; +export 'sync/copy_commands.dart'; +export 'sync/copy_output.dart'; diff --git a/test/sync_modules_contract_test.dart b/test/sync_modules_contract_test.dart new file mode 100644 index 0000000..ce0f409 --- /dev/null +++ b/test/sync_modules_contract_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/sync/copy_commands.dart'; +import 'package:jj_mkvmaker/core/sync/copy_output.dart'; +import 'package:jj_mkvmaker/core/sync/rsync_options.dart'; + +/// 나누기 sync_tools 약속 시험: lib/core/sync/ 모듈마다 맨 위 약속 (입력 · 출력 · 오류 없음). +/// 실제 rsync · robocopy 실행은 rsync_msys_args_test · move_settled_test 등이 본다 +void main() { + test('rsync_options: 따옴표 · 굽은 따옴표 · 대시 고쳐 나누기 · 문제는 던지지 않고 목록으로', () { + expect(splitOptions('-av --exclude="a b.txt"'), ['-av', '--exclude=a b.txt']); + expect(splitOptions('–delete --exclude=‘*.log’'), ['--delete', '--exclude=*.log']); + expect(optionProblems('-a "x', windows: false).first.kind, OptionProblemKind.unclosed); + expect(optionProblems('-a delete', windows: false).map((e) => e.kind), [OptionProblemKind.path]); + expect(optionProblems('--exclude-from=list.txt', windows: true).map((e) => e.kind), [OptionProblemKind.relativePath]); + expect(optionProblems('-avP --exclude *.tmp', windows: false), isEmpty); + expect(listFileValues(['--files-from', '/a/list', '-f', '. /b/rules']), ['/a/list', '/b/rules']); + expect(optionProblemHint([const OptionProblem(OptionProblemKind.noValue, 'x')], windows: false), isNotEmpty); + }); + + test('copy_commands: rsync 인수 (원본 지우기는 이동일 때만 · 경로는 MSYS 꼴) · robocopy 묶음 · 성공 코드', () { + final a = rsyncArgs(options: '-av --remove-source-files', sources: [r'C:\s\dir\'], dest: r'D:\t', bandwidthKBps: 100); + expect(a, ['-av', '--bwlimit=100', '--exclude=~jjren_*', '--exclude=~jjback_*', '/cygdrive/c/s/dir', '/cygdrive/d/t/']); + expect(rsyncArgs(options: '-a', sources: ['/s'], dest: '/t', move: true, windows: false), contains('--remove-source-files')); + expect(rsyncDestPath('//nas/share/x'), r'\\nas\share\x'); + expect(withUpdateOption('-avu'), '-avu'); + expect(withUpdateOption('-av'), '-av -u'); + final runs = robocopyRuns(options: '/E', folders: [r'C:\s\f'], files: [r'C:\s\a.txt'], dest: r'D:\t', move: true); + expect(runs.first.take(2), [r'C:\s\f', r'D:\t\f']); + expect(runs.first, containsAll(['/MOVE', '/XJ'])); + expect(runs.last, [r'C:\s', r'D:\t', 'a.txt', '/MOV', '/IS', '/IT', '/IM', '/XJ']); + expect([rsyncOk(24), rsyncOk(23), robocopyOk(7), robocopyOk(8)], [true, false, true, false]); + expect(CopyMethod.of('없음'), CopyMethod.builtin); + }); + + test('copy_output: 줄 중간에서 끊긴 조각을 이어 읽음 · 진행률 · 속도 · 모르는 줄은 건너뜀', () { + final r = RsyncOutput(); + expect(r.feed('sending incremental file list\nsub/'), isEmpty); + expect(r.feed('\nsub/inner.txt\n 1,024 45% 1.50MB/s 0:00:01 (xfr#1, to-chk=3/10)\r'), ['sub/inner.txt']); + expect((r.currentPercent, r.checked, r.checkTotal), (0.45, 7, 10)); + expect(r.currentSpeed, 1.5 * 1024 * 1024); + final b = RobocopyOutput(); + expect(b.feed('\t New File \t\t 1234\tC:\\s\\a.txt\n\t *EXTRA File \t\t 1\tx\n 50%\n'), ['C:\\s\\a.txt']); + expect(b.currentPercent, 0.5); + }); +}