import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/sftp.dart'; import 'package:jj_mkvmaker/core/vfs.dart'; import 'package:jj_mkvmaker/services/secret_store.dart'; import 'package:path/path.dart' as p; import 'support/sftp_server.dart'; import 'package:jj_mkvmaker/core/file_ops.dart'; import 'package:jj_mkvmaker/core/sync_preview.dart'; import 'package:jj_mkvmaker/core/rsync_filter.dart'; import 'support/rsync_scenes.dart'; /// 88: SFTP (dartssh2). 실제 서버 시험은 tool/sftp_test_server.py (paramiko) 를 띄운다 - /// 파이썬 (JJ_SFTP_PY, 없으면 ..\tools\sftp_test_py 의 venv) 이 없으면 건너뛴다. void main() { final hasServer = sftpTestServerAvailable; final keygen = Platform.isWindows ? r'C:\Windows\System32\OpenSSH\ssh-keygen.exe' : '/usr/bin/ssh-keygen'; group('설정 · 경로 (서버 없이)', () { test('설정 파일에는 비밀 값을 넣지 않고 안전 저장소에 · 다시 읽으면 채움', () async { final tmp = Directory.systemTemp.createTempSync('jj_sftp88_set_'); addTearDown(() => tmp.deleteSync(recursive: true)); final secrets = MemorySecretStore(); final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); final s = AppSettings() ..remote.sftpServers = [ const SftpServer( id: 's1', name: 'NAS', host: 'nas.local', port: 2222, user: 'jj', password: 'pw-secret', privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----', keyPassphrase: 'kp-secret', legacyRsa: true, hostKeyType: 'ssh-ed25519', hostKeyFingerprint: 'SHA256:abc'), ]; await store.save(s); final text = File(p.join(tmp.path, 'settings.json')).readAsStringSync(); expect(text, isNot(contains('pw-secret'))); expect(text, isNot(contains('kp-secret'))); expect(text, isNot(contains('OPENSSH PRIVATE KEY'))); expect(secrets.values['sftp.pw.s1'], 'pw-secret'); expect(secrets.values['sftp.kp.s1'], 'kp-secret'); expect(secrets.values['sftp.key.s1'], contains('OPENSSH PRIVATE KEY')); final back = await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load(); final x = back.remote.sftpServers.single; expect((x.host, x.port, x.user, x.legacyRsa, x.hostKeyFingerprint), ('nas.local', 2222, 'jj', true, 'SHA256:abc')); expect((x.password, x.keyPassphrase), ('pw-secret', 'kp-secret')); expect(x.privateKey, contains('OPENSSH PRIVATE KEY')); // .bak · 모르는 항목 보존용 JSON 에서도 빠진다 final stripped = AppSettings.stripSecrets({ 'sftpServers': [ {'id': 'x', 'password': 'a', 'privateKey': 'b', 'keyPassphrase': 'c', 'host': 'h'} ] }); expect(jsonEncode(stripped), isNot(anyOf(contains('"a"'), contains('"b"'), contains('"c"')))); expect(SettingsStore.redactSecrets('{"privateKey": "k", "keyPassphrase": "p"}'), '{"privateKey": "", "keyPassphrase": ""}'); }); test('sftp:// 경로: 정리 · 위 · 아래 · 화면 이름 (🔑 서버 이름)', () { SftpRegistry.configure([const SftpServer(id: 's1', name: 'Home NAS', host: 'nas')]); addTearDown(() => SftpRegistry.configure(const [])); final d = DavPath.parse(r'sftp://s1/a\b/'); expect(d.full, 'sftp://s1/a/b'); expect(d.isSftp, isTrue); expect(d.parent.full, 'sftp://s1/a'); expect(d.parent.parent.full, 'sftp://s1/'); expect(d.child('c').full, 'sftp://s1/a/b/c'); expect(vJoin('sftp://s1/', 'x'), 'sftp://s1/x'); expect(vDirname('sftp://s1/a/b'), 'sftp://s1/a'); expect(isDav('sftp://s1/a'), isTrue, reason: '원격 (복사 · 동기화 · 받아서 열기는 같은 길)'); expect((isWebDav('sftp://s1/a'), isSftp('sftp://s1/a'), isWebDav('dav://d/a')), (false, true, true)); expect(vDisplay('sftp://s1/a/b'), '🔑 Home NAS/a/b'); expect(vPlayable('sftp://s1/a.mkv'), 'sftp://s1/a.mkv', reason: 'SFTP 는 넘길 주소가 없다 - 받아서 연다'); expect(davSame('sftp://s1/a', 'dav://s1/a'), isFalse); }); }); group('실제 SFTP 서버 (paramiko)', () { late Directory tmp; late Directory root; SftpTestServer? server; late int port; late String fingerprint; Future start({String? password = 'pw', String? pubkey, String keytype = 'ed25519', bool legacyOnly = false}) async { final s = await SftpTestServer.start( root: root.path, hostKeyFile: p.join(tmp.path, 'hostkey_$keytype'), password: password, pubkey: pubkey, keytype: keytype, legacyOnly: legacyOnly); server = s; port = s.port; fingerprint = s.fingerprint; } setUp(() { tmp = Directory.systemTemp.createTempSync('jj_sftp88_'); root = Directory(p.join(tmp.path, 'root'))..createSync(); }); tearDown(() async { SftpRegistry.configure(const []); final s = server; server = null; await s?.stop(); for (var i = 0;; i++) { try { if (tmp.existsSync()) tmp.deleteSync(recursive: true); break; } on FileSystemException { if (i > 30) rethrow; await Future.delayed(const Duration(milliseconds: 100)); } } }); SftpServer srv({String password = 'pw', String fp = '', String key = '', String kp = '', bool legacy = false, String rootDir = ''}) => SftpServer( id: 's1', name: 'test', host: '127.0.0.1', port: port, user: 'jj', password: password, privateKey: key, keyPassphrase: kp, legacyRsa: legacy, hostKeyFingerprint: fp, root: rootDir); Future err(Future Function() f) async { try { await f(); return null; } catch (e) { return e; } } test('처음 연결: 지문을 보이고 막음 (TOFU) → 믿으면 연결 · 바뀐 키는 막음', () async { await start(); final e = await err(() => SftpRemote(srv()).list('/')); expect(e, isA()); final hk = e! as SftpHostKeyException; expect(hk.kind, SftpFailure.hostKeyUnknown); expect(hk.fingerprint, fingerprint, reason: 'OpenSSH 와 같은 SHA256 지문'); expect(hk.type, 'ssh-ed25519'); expect(hk.changed, isFalse); // 믿음 → 연결 final ok = SftpRemote(srv(fp: fingerprint)); addTearDown(ok.close); expect(await ok.list('/'), isEmpty); // 바뀐 키 → 막고 예전 · 지금 지문을 알림 final e2 = await err(() => SftpRemote(srv(fp: 'SHA256:예전키')).list('/')); expect(e2, isA()); expect((e2! as SftpHostKeyException).kind, SftpFailure.hostKeyChanged); expect((e2 as SftpHostKeyException).previous, 'SHA256:예전키'); expect('$e2', contains('바뀌었습니다')); }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); test('틀린 비밀번호 · 저장된 비밀번호 없음 · 닿지 않음은 이유를 정확히', () async { await start(); final bad = await err(() => SftpRemote(srv(password: 'nope', fp: fingerprint)).list('/')); expect((bad! as SftpException).kind, SftpFailure.auth); final none = await err(() => SftpRemote(srv(password: '', fp: fingerprint)).list('/')); expect((none! as SftpException).kind, SftpFailure.noSecret); expect('$none', '저장된 비밀번호가 없습니다'); // 닫힌 포트 final free = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); final closedPort = free.port; await free.close(); final off = await err(() => SftpRemote(SftpServer(id: 'x', name: '', host: '127.0.0.1', port: closedPort, user: 'jj', password: 'pw'), timeout: const Duration(seconds: 3)) .list('/')); expect((off! as SftpException).kind, SftpFailure.unreachable); }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); test('관리자 지시 (10/19): SFTP 맞추기도 거르기를 따름 (보내지 않기 · 받는 쪽 지키기 · 거른 것이 든 폴더)', () async { await start(); SftpRegistry.configure([srv(fp: fingerprint)]); final src = Directory(p.join(tmp.path, 'local', 'fsrc'))..createSync(recursive: true); File(p.join(src.path, 'a.txt')).writeAsStringSync('a'); File(p.join(src.path, 'skip.tmp')).writeAsStringSync('s'); Directory(p.join(root.path, 'fdst', 'gone')).createSync(recursive: true); File(p.join(root.path, 'fdst', 'only.tmp')).writeAsStringSync('o'); File(p.join(root.path, 'fdst', 'old.txt')).writeAsStringSync('old'); File(p.join(root.path, 'fdst', 'gone', 'g.tmp')).writeAsStringSync('g'); File(p.join(root.path, 'fdst', 'gone', 'g.txt')).writeAsStringSync('g'); const dst = 'sftp://s1/fdst'; final filter = RsyncFilter.parse('-av --exclude=*.tmp'); final pv = await previewSync(src.path, dst, toRight: true, delete: true, filter: filter); expect({for (final x in pv) if (x.action == PreviewAction.delete) x.rel}, {'old.txt', 'gone/g.txt'}); await FileOps().mirror(src.path, dst, delete: true, filter: filter); String real(String rel) => p.joinAll([root.path, 'fdst', ...rel.split('/')]); expect(File(real('a.txt')).existsSync(), isTrue); expect(File(real('skip.tmp')).existsSync(), isFalse); expect(File(real('only.tmp')).existsSync(), isTrue); expect(File(real('old.txt')).existsSync(), isFalse); expect(File(real('gone/g.tmp')).existsSync(), isTrue); expect(File(real('gone/g.txt')).existsSync(), isFalse); }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); test('관리자 지시 (10/20): SFTP 맞추기 결과 = 실제 rsync 결과 (28 의 장면 · 옵션 28개)', () async { final rsync = sceneRsync; if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)'); await start(); SftpRegistry.configure([srv(fp: fingerprint)]); final src = p.join(tmp.path, 'local', 'scene_src'), want = p.join(tmp.path, 'local', 'scene_rsync'), got = p.join(root.path, 'scene'); final bad = []; for (final opts in sceneOptions) { writeScene(src, want); final w = await realRsyncTree(rsync, src, want, opts); writeScene(src, got); await FileOps().mirror(src, 'sftp://s1/scene', delete: optionsDelete(opts), filter: RsyncFilter.parse(opts)); final d = treeDiff(w, treeOf(got)); if (d.isNotEmpty) bad.add('$opts\n ${d.join('\n ')}'); } expect(bad, isEmpty, reason: bad.join('\n')); }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음', timeout: const Timeout(Duration(minutes: 5))); test('M1 (관리자 P0): SFTP 로 맞출 때 받는 쪽 같은 이름 폴더 - 지우기 없으면 그대로 · 지우기면 지우고 파일로 (미리 보기 같음)', () async { await start(); SftpRegistry.configure([srv(fp: fingerprint)]); final local = Directory(p.join(tmp.path, 'local', 'src'))..createSync(recursive: true); File(p.join(local.path, 'x')).writeAsStringSync('file'); Directory(p.join(root.path, 'dst', 'x', 'sub')).createSync(recursive: true); File(p.join(root.path, 'dst', 'x', 'keep.txt')).writeAsStringSync('k'); const dst = 'sftp://s1/dst'; var pv = await previewSync(local.path, dst, toRight: true); expect(pv.where((x) => x.action == PreviewAction.skipDir).map((x) => x.rel), ['x']); final ops = FileOps(); await ops.mirror(local.path, dst); expect(File(p.join(root.path, 'dst', 'x', 'keep.txt')).existsSync(), isTrue); expect(ops.dirConflicts, hasLength(1)); pv = await previewSync(local.path, dst, toRight: true, delete: true); final del = pv.singleWhere((x) => x.action == PreviewAction.delete); expect((del.replacedByFile, del.inner), (true, 2)); await FileOps().mirror(local.path, dst, delete: true); expect(File(p.join(root.path, 'dst', 'x')).readAsStringSync(), 'file'); }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); test('vfs 로 폴더 · 쓰기 (덮어쓰기) · 목록 · 읽기 · 옮기기 · 지우기 (안쪽까지) · 시작 폴더', () async { await start(); SftpRegistry.configure([srv(fp: fingerprint)]); const base = 'sftp://s1/'; await vMkdirs('${base}a/b'); expect(Directory(p.join(root.path, 'a', 'b')).existsSync(), isTrue); Stream> bytes(String s) => Stream.value(utf8.encode(s)); await vWrite('${base}a/b/f.txt', bytes('hello')); await vWrite('${base}a/b/f.txt', bytes('hello again')); expect(File(p.join(root.path, 'a', 'b', 'f.txt')).readAsStringSync(), 'hello again', reason: '덮어쓰기 (.jjpart 뒤 이름 바꾸기)'); expect(Directory(p.join(root.path, 'a', 'b')).listSync().map((e) => p.basename(e.path)), ['f.txt'], reason: '.jjpart 가 남지 않음'); final list = await vList('${base}a/b'); expect(list.single.path, 'sftp://s1/a/b/f.txt'); expect((list.single.isDir, list.single.size), (false, 11)); final st = await vStat('${base}a'); expect(st!.isDir, isTrue); expect(await vStat('$base없음'), isNull); expect(utf8.decode(await (await vOpenRead('${base}a/b/f.txt')).expand((x) => x).toList()), 'hello again'); await vRename('${base}a/b/f.txt', '${base}a/g.txt'); expect(File(p.join(root.path, 'a', 'g.txt')).existsSync(), isTrue); // 받아서 열기 (임시 폴더) final local = await vLocalCopy('${base}a/g.txt', tmp.path); expect(File(local).readAsStringSync(), 'hello again'); expect(local, contains('jj_sftp')); await vDelete('${base}a'); expect(Directory(p.join(root.path, 'a')).existsSync(), isFalse); // 시작 폴더: 그 안이 탭의 맨 위 Directory(p.join(root.path, 'home', 'jj')).createSync(recursive: true); File(p.join(root.path, 'home', 'jj', 'mine.txt')).writeAsStringSync('m'); SftpRegistry.configure([srv(fp: fingerprint, rootDir: '/home/jj')]); expect((await vList(base)).map((e) => e.path), ['sftp://s1/mine.txt']); final (free, _) = await DavPath.parse(base).client.quota(); expect(free == null || free > 0, isTrue, reason: 'statvfs 를 모르면 null'); }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); test('개인 키 (암호 있음) 로 로그인 · 틀린 키 암호는 그렇다고', () async { final key = p.join(Directory.systemTemp.path, 'jj_sftp88_key_${DateTime.now().microsecondsSinceEpoch}'); final r = await Process.run(keygen, ['-q', '-t', 'ed25519', '-N', 'kp', '-f', key]); if (r.exitCode != 0) return markTestSkipped('ssh-keygen 없음'); addTearDown(() { for (final f in [key, '$key.pub']) { if (File(f).existsSync()) File(f).deleteSync(); } }); await start(password: null, pubkey: '$key.pub'); final pem = File(key).readAsStringSync(); final c = SftpRemote(srv(password: '', key: pem, kp: 'kp', fp: fingerprint)); addTearDown(c.close); expect(await c.list('/'), isEmpty); final bad = await err(() => SftpRemote(srv(password: '', key: pem, kp: 'nope', fp: fingerprint)).list('/')); expect((bad! as SftpException).kind, SftpFailure.keyPassphrase); expect('$bad', contains('개인 키의 암호')); }, skip: hasServer && File(keygen).existsSync() ? false : 'SFTP 시험 서버 · ssh-keygen 없음'); test('관리자 지시 (10/21): SSH 키 파일 로그인 - ed25519 · rsa · ecdsa × OpenSSH · PEM (암호 있음 · 없음) 이 실제 서버에 붙고, 서버 거부 · 암호 없음은 따로', () async { final dir = Directory(p.join(tmp.path, 'keys'))..createSync(); Future gen(String name, List args) async { final f = p.join(dir.path, name); final r = await Process.run(keygen, ['-q', ...args, '-f', f, '-C', 'jj-test']); return r.exitCode == 0 ? f : null; } final cases = [ ('ed25519 OpenSSH 암호', ['-t', 'ed25519', '-N', 'kp'], 'kp'), ('ed25519 OpenSSH 암호 없음', ['-t', 'ed25519', '-N', ''], ''), ('rsa OpenSSH 암호', ['-t', 'rsa', '-b', '2048', '-N', 'kp'], 'kp'), ('rsa PEM 암호 없음', ['-t', 'rsa', '-b', '2048', '-m', 'PEM', '-N', ''], ''), ('ecdsa OpenSSH 암호', ['-t', 'ecdsa', '-b', '256', '-N', 'kp'], 'kp'), ('ecdsa PEM 암호 없음', ['-t', 'ecdsa', '-b', '256', '-m', 'PEM', '-N', ''], ''), ]; final failed = []; for (final (i, (name, args, kp)) in cases.indexed) { final key = await gen('k$i', args); if (key == null) return markTestSkipped('ssh-keygen 이 $name 을 만들지 못함'); await server?.stop(); await start(password: null, pubkey: '$key.pub'); final pem = File(key).readAsStringSync(); expect(checkPrivateKey(pem), kp.isNotEmpty, reason: '$name: 암호가 걸렸는지'); final c = SftpRemote(srv(password: '', key: pem, kp: kp, fp: fingerprint)); try { await c.list('/'); } catch (e) { failed.add('$name: $e'); } finally { c.close(); } } expect(failed, isEmpty, reason: failed.join('\n')); // 서버가 모르는 키 (짝 공개 키가 서버에 없음) → 키 거부 (비밀번호 틀림과 따로) final other = await gen('other', ['-t', 'ed25519', '-N', '']); final rejected = await err(() => SftpRemote(srv(password: '', key: File(other!).readAsStringSync(), fp: fingerprint)).list('/')); expect((rejected! as SftpException).kind, SftpFailure.keyRejected, reason: '$rejected'); expect('$rejected', contains('authorized_keys')); // 암호가 걸린 키인데 키 암호를 넣지 않음 → 그렇다고 (서버에 묻기 전에) final locked = File(p.join(dir.path, 'k0')).readAsStringSync(); final noPass = await err(() => SftpRemote(srv(password: '', key: locked, fp: fingerprint)).list('/')); expect((noPass! as SftpException).kind, SftpFailure.keyPassphrase); expect('$noPass', contains('키 암호를 넣으세요')); }, skip: hasServer && File(keygen).existsSync() ? false : 'SFTP 시험 서버 · ssh-keygen 없음', timeout: const Timeout(Duration(minutes: 3))); test('관리자 지시 (10/21): 쓸 수 없는 키 꼴은 고를 때 바로 - PuTTY (.ppk) · PKCS#8 · 공개 키 · 다른 파일', () async { final dir = Directory(p.join(tmp.path, 'keys'))..createSync(); final f = p.join(dir.path, 'p8'); final r = await Process.run(keygen, ['-q', '-t', 'rsa', '-b', '2048', '-m', 'PKCS8', '-N', '', '-f', f]); final samples = { 'PuTTY': 'PuTTY-User-Key-File-3: ssh-ed25519\nEncryption: none\nComment: x\nPublic-Lines: 1\nAAAA\n', if (r.exitCode == 0) 'PKCS#8': File(f).readAsStringSync(), if (r.exitCode == 0) '공개 키': File('$f.pub').readAsStringSync(), '다른 파일': 'hello', }; for (final MapEntry(key: name, value: text) in samples.entries) { Object? e; try { checkPrivateKey(text); } catch (x) { e = x; } expect(e, isA().having((x) => x.kind, 'kind', SftpFailure.keyFormat), reason: name); if (name == 'PuTTY') expect('$e', contains('Export OpenSSH key')); } }, skip: File(keygen).existsSync() ? false : 'ssh-keygen 없음'); test('오래된 서버 (ssh-rsa 만): 처음엔 연결 안 됨 + 켜는 법 안내 → 서버마다 켜면 연결 (88-3)', () async { await start(keytype: 'rsa', legacyOnly: true); final e = await err(() => SftpRemote(srv(fp: fingerprint)).list('/')); expect(e, isA()); expect('$e', contains('ssh-rsa')); final c = SftpRemote(srv(fp: fingerprint, legacy: true)); addTearDown(c.close); expect(await c.list('/'), isEmpty); }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); }); }