diff --git a/assets/l10n/en.json b/assets/l10n/en.json index c886a29..d60b5f4 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -2598,6 +2598,8 @@ "큰 파일": "Large files", "큰 파일이 없습니다": "No large files", "큰 폴더": "Large folders", + "큰따옴표 (\") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}": "Options containing a double quote (\") cannot be passed to rsync on Windows: {0}", + "큰따옴표 (\") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}": "Arguments containing a double quote (\") cannot be passed as-is to rsync on Windows, so it was not run: {0}", "키 있음": "Key saved", "타갈로그어": "Tagalog", "타밀어": "Tamil", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 7c674ed..b9a086d 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -2598,6 +2598,8 @@ "큰 파일": "大きいファイル", "큰 파일이 없습니다": "大きいファイルはありません", "큰 폴더": "大きいフォルダー", + "큰따옴표 (\") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}": "ダブルクォート (\") を含むオプションは Windows の rsync に渡せません: {0}", + "큰따옴표 (\") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}": "ダブルクォート (\") を含む引数は Windows の rsync にそのまま渡せないため、実行しませんでした: {0}", "키 있음": "キーあり", "타갈로그어": "タガログ語", "타밀어": "タミル語", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 663d1e7..7c6be10 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -2598,6 +2598,8 @@ "큰 파일": "大文件", "큰 파일이 없습니다": "没有大文件", "큰 폴더": "大文件夹", + "큰따옴표 (\") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}": "含有双引号 (\") 的选项无法传给 Windows 上的 rsync: {0}", + "큰따옴표 (\") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}": "含有双引号 (\") 的参数无法原样传给 Windows 上的 rsync,因此未执行: {0}", "키 있음": "已有密钥", "타갈로그어": "他加禄语", "타밀어": "泰米尔语", diff --git a/lib/app/live_sync.dart b/lib/app/live_sync.dart index f64ee3c..92a3003 100644 --- a/lib/app/live_sync.dart +++ b/lib/app/live_sync.dart @@ -17,6 +17,7 @@ import '../platform/windows/rsync_installer.dart'; import 'app_controller.dart'; import 'settings.dart'; import '../core/rsync_filter.dart'; +import '../core/rsync_launch.dart'; /// 앱에 들어 있는 rsync (없으면 null). 시작할 때 [findBundledRsync] 로 정한다. /// - Windows: 실행 파일 옆 rsync/rsync.exe (tool/fetch_rsync.ps1 로 준비해 빌드에 넣음) @@ -630,7 +631,15 @@ class LiveSync extends ChangeNotifier { if (exe == null) throw StateError(tr('rsync 가 없습니다 (환경 설정 > 파일 탐색기에서 내려받기 · 경로 지정)')); final args = rsyncArgsFor(x, s, windows: Platform.isWindows); await Directory(x.target).create(recursive: true); - final r = await Process.run(exe, args, stdoutEncoding: utf8, stderrEncoding: utf8); + // P0 (10/20): Windows 의 rsync 는 응답 파일 · MSYS=noglob 으로 (' · * 를 런타임이 다시 읽지 않게 - [prepareRsyncLaunch]) + final launch = await prepareRsyncLaunch(args); + final ProcessResult r; + try { + r = await Process.run(exe, launch.args, + workingDirectory: launch.workingDirectory, environment: launch.environment, stdoutEncoding: utf8, stderrEncoding: utf8); + } finally { + launch.dispose(); + } if (!rsyncOk(r.exitCode)) throw ProcessException(exe, const [], '${r.stderr}'.trim(), r.exitCode); return RsyncOutput().feed('${r.stdout}\n').length; } diff --git a/lib/app/transfer_job.dart b/lib/app/transfer_job.dart index 2d82eab..4d0846b 100644 --- a/lib/app/transfer_job.dart +++ b/lib/app/transfer_job.dart @@ -11,6 +11,7 @@ import '../core/sync_tools.dart'; import '../core/vfs.dart'; import '../l10n/tr.dart'; import '../core/rsync_filter.dart'; +import '../core/rsync_launch.dart'; /// 복사 · 이동 한 번 (파일 탐색기). 방법 (현재 방식 · rsync · robocopy) 과 상관없이 같은 진행 상태를 알린다: /// - 위쪽: 고른 항목 (폴더) 중 몇 번째인지 · 전체 진행률 @@ -401,8 +402,20 @@ class TransferJob extends ChangeNotifier { if (log.length > 20) log.removeAt(0); } - Future _start(String exe, List args, Encoding enc, void Function(String chunk) onOut) async { - _proc = await Process.start(exe, args, runInShell: false); + /// [rsync]: Windows 의 rsync 는 응답 파일 · MSYS=noglob 으로 띄운다 (P0 10/20 - ' · * 를 런타임이 다시 읽지 않게, [prepareRsyncLaunch]). + /// robocopy 는 Windows 프로그램이라 그대로 + Future _start(String exe, List args, Encoding enc, void Function(String chunk) onOut, {bool rsync = false}) async { + final launch = rsync ? await prepareRsyncLaunch(args) : null; + try { + return await _startRaw(exe, launch?.args ?? args, enc, onOut, workingDirectory: launch?.workingDirectory, environment: launch?.environment); + } finally { + launch?.dispose(); + } + } + + Future _startRaw(String exe, List args, Encoding enc, void Function(String chunk) onOut, + {String? workingDirectory, Map? environment}) async { + _proc = await Process.start(exe, args, runInShell: false, workingDirectory: workingDirectory, environment: environment); final a = _proc!.stdout.transform(enc.decoder).listen(onOut); final b = _proc!.stderr.transform(enc.decoder).listen((t) { for (final l in t.split(RegExp(r'[\r\n]'))) { @@ -459,6 +472,7 @@ class TransferJob extends ChangeNotifier { } _tick(); }, + rsync: true, ); if (cancelled) return; if (!rsyncOk(code)) throw ProcessException(exe, const [], rsyncFailureText(code, errLog, log), code); diff --git a/lib/core/rsync_launch.dart b/lib/core/rsync_launch.dart new file mode 100644 index 0000000..79d7852 --- /dev/null +++ b/lib/core/rsync_launch.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../l10n/tr.dart'; + +/// 관리자 지시 (10/20, 28 재현 - P0 데이터): Windows 의 rsync (MSYS2 · Cygwin 런타임) 는 Windows 프로그램이 넘긴 명령 줄을 +/// 스스로 다시 읽는다 - ' 를 따옴표로 짝지어 (--exclude=it's.txt --exclude=Bob's.txt 가 한 인수로 합쳐져 거르기가 사라지고 +/// 지킬 파일이 지워짐, ' 가 든 경로는 합쳐져 실패) * ? [ ] { } 를 작업 폴더의 파일로 펼친다 (--exclude *.log 가 x.log y.log 로). +/// 앱이 만든 인수를 응답 파일 (@파일) 에 큰따옴표로 감싸 넣고 MSYS=noglob 으로 띄우면 그대로 간다 (실제 rsync 3.5.1 로 확인 - +/// MSYS=noglob 만으로는 ' 를 막지 못하고, CYGWIN=noglob 은 소용없다). +/// 응답 파일의 경로에 빈칸 · ' 가 있으면 @ 가 먹히지 않아, 그 파일이 든 폴더를 작업 폴더로 하고 "@args.txt" 로 넘긴다. +/// 큰따옴표 (") 가 든 인수는 이 방법으로도 그대로 넘기지 못한다 - 실행하지 않고 이유를 알린다 (Windows 파일 이름에는 " 가 없다). +class RsyncLaunch { + final List args; + final String? workingDirectory; + final Map? environment; + final Directory? _dir; + RsyncLaunch._(this.args, this.workingDirectory, this.environment, this._dir); + + /// 다 쓴 응답 파일을 지운다 (rsync 가 끝난 뒤) + void dispose() { + try { + _dir?.deleteSync(recursive: true); + } catch (_) {} + } +} + +/// Windows rsync 에 그대로 넘길 수 없는 인수 (큰따옴표가 듦) +List msysUnsafeArgs(List args) => [for (final a in args) if (a.contains('"')) a]; + +/// [args] 를 rsync 에 그대로 넘기도록 준비한다 (Windows 만 응답 파일 · MSYS=noglob, 그 밖은 그대로). 끝나면 [RsyncLaunch.dispose] +Future prepareRsyncLaunch(List args, {bool? windows}) async { + if (!(windows ?? Platform.isWindows)) return RsyncLaunch._(args, null, null, null); + final bad = msysUnsafeArgs(args); + if (bad.isNotEmpty) { + throw StateError(trf('큰따옴표 (") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}', [bad.join(' · ')])); + } + final dir = await Directory.systemTemp.createTemp('jj_rsargs_'); + await File(p.join(dir.path, 'args.txt')).writeAsString(args.map((a) => '"$a"').join(' '), encoding: utf8); + final msys = Platform.environment['MSYS']; + return RsyncLaunch._(['@args.txt'], dir.path, {'MSYS': msys == null || msys.trim().isEmpty ? 'noglob' : '$msys noglob'}, dir); +} diff --git a/lib/core/sync_tools.dart b/lib/core/sync_tools.dart index 3751c43..3782ece 100644 --- a/lib/core/sync_tools.dart +++ b/lib/core/sync_tools.dart @@ -1,3 +1,5 @@ +import 'dart:io' show Platform; + import 'package:path/path.dart' as p; import 'file_ops.dart' show appTempRsyncExcludes; @@ -195,13 +197,21 @@ const _rsyncLongWithValue = { /// 관리자 지시 (10/20, 28 · 15 확인): [splitOptions] 로 고쳐 읽은 뒤에도 rsync 가 다르게 읽을 것 - 있으면 확인 창 · 실시간 동기화에 /// 빨갛게 알리고 실행을 막는다: 닫히지 않은 따옴표, 옵션 자리의 - 로 시작하지 않는 낱말 (rsync 는 원본 경로로 읽어 옵션이 몰래 빠짐), /// 하이픈 셋 이상, 끝에 값 없이 남은 값 받는 옵션. 옵션의 값 자리 (--exclude 한글.txt 의 한글.txt, -f 뒤) 는 세지 않는다 -List unreadableOptions(String options) { +List unreadableOptions(String options, {bool? windows}) { + windows ??= Platform.isWindows; final parsed = _parseOptions(options); // 15 확인 F1: 닫히지 않은 따옴표 - 뒤의 옵션이 다르게 읽힐 수 있다 final bad = [for (final u in parsed.unclosed) trf('따옴표가 닫히지 않았습니다: {0}', [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(trf('큰따옴표 (") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}', [t])); + value = false; + continue; + } if (value) { value = false; continue; diff --git a/test/rsync_msys_args_test.dart b/test/rsync_msys_args_test.dart new file mode 100644 index 0000000..f782fb8 --- /dev/null +++ b/test/rsync_msys_args_test.dart @@ -0,0 +1,131 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/live_sync.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/app/transfer_job.dart'; +import 'package:jj_mkvmaker/core/rsync_launch.dart'; +import 'package:jj_mkvmaker/core/sync_tools.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:path/path.dart' as p; + +import 'support/rsync_scenes.dart' show sceneRsync, treeOf; + +/// P0 (관리자 지시 10/20, 28 재현 - 데이터): Windows 의 rsync (MSYS2 런타임) 가 명령 줄을 다시 읽어 ' 를 짝짓고 (거르기가 사라져 +/// 지킬 파일이 지워짐 · ' 경로가 합쳐져 실패) * ? [ ] { } 를 작업 폴더의 파일로 펼치던 것 - 응답 파일 + MSYS=noglob 으로 그대로 넘긴다. +/// 앱이 rsync 를 띄우는 두 길 (복사 · Rsync 화면 · 모니터링 · 최종 정리 = TransferJob, 실시간 동기화 = LiveSync) 을 실제 rsync 로. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('도우미: Windows 는 응답 파일 · MSYS=noglob, 그 밖은 그대로 · 큰따옴표가 든 인수는 막음', () async { + final plain = await prepareRsyncLaunch(['-a', "it's"], windows: false); + expect(plain.args, ['-a', "it's"]); + expect(plain.environment, isNull); + final w = await prepareRsyncLaunch(['-a', "--exclude=it's.txt", 'a b'], windows: true); + addTearDown(w.dispose); + expect(w.args, ['@args.txt']); + expect(w.environment!['MSYS'], contains('noglob')); + expect(File(p.join(w.workingDirectory!, 'args.txt')).readAsStringSync(), '"-a" "--exclude=it\'s.txt" "a b"'); + await expectLater(prepareRsyncLaunch(['--exclude=a"b'], windows: true), throwsA(isA())); + // 옵션 칸에서도 미리 빨갛게 (Windows 만) + expect(unreadableOptions('-a --exclude=\'a"b\'', windows: true), hasLength(1)); + expect(unreadableOptions('-a --exclude=\'a"b\'', windows: false), isEmpty); + expect(unreadableOptions('-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"', windows: true), isEmpty); + }); + + group('실제 rsync', () { + final rsync = sceneRsync; + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_msysargs_')); + tearDown(() => tmp.deleteSync(recursive: true)); + + /// 원본 · 대상 폴더를 만들고 파일을 넣는다 (내용 = 쪽:이름) + (String, String) scene(String sn, String dn, List sf, List df) { + final s = Directory(p.join(tmp.path, sn))..createSync(recursive: true); + final d = Directory(p.join(tmp.path, dn))..createSync(recursive: true); + for (final f in sf) { + File(p.join(s.path, f)) + ..parent.createSync(recursive: true) + ..writeAsStringSync('s:$f'); + } + for (final f in df) { + File(p.join(d.path, f)) + ..parent.createSync(recursive: true) + ..writeAsStringSync('d:$f'); + } + return (s.path, d.path); + } + + // (이름, 옵션, 원본 폴더, 대상 폴더, 원본 파일, 대상 파일, 남아야 할 것 (대상), 없어야 할 것 (대상)) + final cases = <(String, String, String, String, List, List, List, List)>[ + ("28 재현: ' 두 개의 --exclude", '-a --delete --exclude="it\'s.txt" --exclude="Bob\'s.txt"', 's', 'd', ['a.txt'], + ["it's.txt", "Bob's.txt", 'old.txt'], ["it's.txt", "Bob's.txt", 'a.txt'], ['old.txt']), + ("' 하나의 --exclude", "-a --delete --exclude=\"it's.txt\"", 's', 'd', ['a.txt'], ["it's.txt", 'old.txt'], ["it's.txt", 'a.txt'], ['old.txt']), + ("' 가 든 원본 경로", '-a --delete', "s'1", 'd', ["a'b.txt"], ['old.txt'], ["a'b.txt"], ['old.txt']), + ("' 가 든 원본 · 대상 경로", '-a --delete', "s'1", "d'2", ['a.txt', "c'd/e'f.txt"], ['old.txt'], ['a.txt', "c'd/e'f.txt"], ['old.txt']), + ("' 경로 + ' --exclude", "-a --delete --exclude=\"it's.txt\"", "s'1", "d'2", ['a.txt'], ["it's.txt", 'old.txt'], ["it's.txt", 'a.txt'], + ['old.txt']), + ('따로 쓴 * (작업 폴더에 맞는 파일이 있어도)', '-a --delete --exclude *.txt', 's', 'd', ['a.txt', 'n.log'], ['keep.txt', 'old.log'], + ['keep.txt', 'n.log'], ['old.log', 'a.txt']), + ('따로 쓴 ? · [ ] · { }', '-a --delete --exclude b?.txt --exclude [x]* --exclude {q,r}.txt', 's', 'd', ['a.txt'], + ['b9.txt', 'xk.txt', '{q,r}.txt', 'q.txt', 'old.txt'], ['b9.txt', 'xk.txt', '{q,r}.txt', 'a.txt'], ['q.txt', 'old.txt']), + ('[ ] · { } · 빈칸 · 한글이 든 경로', '-a --delete', '원본 [1] {a}', "받는 쪽 [2] {b}'s", ['가 나.txt'], ['old.txt'], ['가 나.txt'], ['old.txt']), + ]; + + for (final (name, opts, sn, dn, sf, df, keep, gone) in cases) { + test('복사 작업 (TransferJob): $name', () async { + if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); + final (s, d) = scene(sn, dn, sf, df); + // 작업 폴더에 글로브에 맞을 파일을 둔다 (예전에는 따로 쓴 * 가 이것들로 펼쳐졌다) + final cwd = Directory.current; + final job = TransferJob(sources: [s], dest: d, move: false, method: CopyMethod.rsync, options: opts, rsyncExe: rsync, contents: true); + await job.run(); + expect(job.error, isNull, reason: '${job.error}'); + final t = treeOf(d); + for (final k in keep) { + expect(t.containsKey(k), isTrue, reason: '$name: $k 가 있어야 함 - $t'); + } + for (final g in gone) { + expect(t.containsKey(g), isFalse, reason: '$name: $g 는 없어야 함 - $t'); + } + expect(Directory.current.path, cwd.path); + }, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)'); + } + + test("실시간 동기화 (LiveSync rsync 쌍): ' 두 개의 --exclude · ' 가 든 경로", () async { + if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); + final (s, d) = scene("s'1", "d'2", ['a.txt'], ["it's.txt", "Bob's.txt", 'old.txt']); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings + ..rsyncSource = 'custom' + ..rsyncPath = rsync + ..rsyncOptions = '-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"' + ..liveSyncPairs = [LiveSyncPair(s, d, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"')]; + final live = LiveSync(c); + addTearDown(live.dispose); + final pair = c.settings.liveSyncPairs.single; + await live.syncNow(pair); + expect(live.status[LiveSync.keyOf(pair)]!.$2, isNot(contains('실패'))); + final t = treeOf(d); + expect(t.keys.toSet(), {"it's.txt", "Bob's.txt", 'a.txt'}, reason: '거르기를 지키고 old.txt 만 지움'); + }, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)'); + + test('응답 파일은 끝나면 지운다', () async { + if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); + final (s, d) = scene('s', 'd', ['a.txt'], []); + final job = TransferJob(sources: [s], dest: d, move: false, method: CopyMethod.rsync, options: '-a', rsyncExe: rsync, contents: true); + await job.run(); + expect(job.error, isNull); + // 이 작업의 응답 파일 (이 시험의 경로가 든 것) 이 남지 않았는지 - 함께 도는 다른 시험의 것은 세지 않는다 + final mine = [ + for (final e in Directory.systemTemp.listSync()) + if (p.basename(e.path).startsWith('jj_rsargs_') && File(p.join(e.path, 'args.txt')).existsSync()) + if (File(p.join(e.path, 'args.txt')).readAsStringSync().contains(toCygwinPath(s))) e.path, + ]; + expect(mine, isEmpty); + }, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)'); + }); +}