WIP 옵션 칸 틈 셋 (관리자 판단 10/20, 28 확인): 값 자리가 아닌데 - 로 시작하지 않는 낱말 ("경로로 읽힙니다") · 하이픈 셋 이상 · 끝에 값 없는 옵션 ("--exclude 의 값이 없습니다") 도 알아볼 수 없음으로 실행을 막음 - 실제 rsync 는 원본 경로로 먹어 --delete 가 빠지거나 아무것도 보내지 않음 (시험으로 확인) (검토 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
review_55caef5 v2026.10.18_984
RyuJongJin 2026-10-20 16:06:04 +09:00
parent 55caef5c3f
commit f45ad3b25f
5 changed files with 58 additions and 9 deletions

View File

@ -526,6 +526,7 @@
"{0} 을(를) 설치했습니다.": "Installed {0}.",
"{0} 을(를) 지울까요?": "Delete {0}?",
"{0} 의 SHA256 이 다릅니다 (손상되었거나 다른 파일). 설치를 멈췄습니다.": "The SHA256 of {0} does not match (damaged or a different file). Installation stopped.",
"{0} 의 값이 없습니다": "{0} has no value",
"{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} is empty (insert a card, USB drive or disc and tap again).",
"{0} 이상": "{0} or larger",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg does not have the {0} encoder.",
@ -725,6 +726,7 @@
"경로 입력 · 네트워크 서버": "Enter path · Network servers",
"경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "The path is too long for the Recycle Bin (the Windows Recycle Bin does not accept paths over 260 characters).",
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "The path is too long (the Windows Recycle Bin doesn't accept paths over 260 characters) - you can delete it permanently",
"경로로 읽힙니다: {0}": "Read as a path: {0}",
"경로를 복사했습니다: {0}": "Copied path: {0}",
"계산 안 함": "Don't calculate",
"계속": "Always",

View File

@ -526,6 +526,7 @@
"{0} 을(를) 설치했습니다.": "{0} をインストールしました。",
"{0} 을(를) 지울까요?": "{0} を削除しますか?",
"{0} 의 SHA256 이 다릅니다 (손상되었거나 다른 파일). 설치를 멈췄습니다.": "{0} の SHA256 が一致しません (破損しているか別のファイル)。インストールを中止しました。",
"{0} 의 값이 없습니다": "{0} の値がありません",
"{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} は空です (カード・USB・ディスクを入れてからもう一度押してください)。",
"{0} 이상": "{0} 以上",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。",
@ -725,6 +726,7 @@
"경로 입력 · 네트워크 서버": "パス入力・ネットワークサーバー",
"경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "パスが長すぎてごみ箱に入れられません (Windows のごみ箱は 260 文字を超えるパスを受け付けません)。",
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "パスが長すぎます (Windows のごみ箱は 260 文字を超えるパスを受け付けません) - 完全に削除はできます",
"경로로 읽힙니다: {0}": "パスとして読まれます: {0}",
"경로를 복사했습니다: {0}": "パスをコピーしました: {0}",
"계산 안 함": "計算しない",
"계속": "常時",

View File

@ -526,6 +526,7 @@
"{0} 을(를) 설치했습니다.": "已安装 {0}。",
"{0} 을(를) 지울까요?": "要删除 {0} 吗?",
"{0} 의 SHA256 이 다릅니다 (손상되었거나 다른 파일). 설치를 멈췄습니다.": "{0} 的 SHA256 不一致(已损坏或是其他文件)。已停止安装。",
"{0} 의 값이 없습니다": "{0} 缺少值",
"{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} 为空 (插入存储卡、U 盘或光盘后再点按)。",
"{0} 이상": "{0} 及以上",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。",
@ -725,6 +726,7 @@
"경로 입력 · 네트워크 서버": "输入路径 · 网络服务器",
"경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "路径过长,无法放入回收站 (Windows 回收站不接受超过 260 个字符的路径)。",
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "路径过长 (Windows 回收站不接受超过 260 个字符的路径) - 可以永久删除",
"경로로 읽힙니다: {0}": "会被当作路径: {0}",
"경로를 복사했습니다: {0}": "已复制路径: {0}",
"계산 안 함": "不计算",
"계속": "持续",

View File

@ -192,29 +192,39 @@ const _rsyncLongWithValue = {
'--write-batch', '--only-write-batch', '--read-batch', '--protocol', '--checksum-seed', '--copy-as', '--early-input',
};
/// 관리자 지시 (10/20, 28 확인): [splitOptions] 로 고쳐 읽은 뒤에도 옵션 자리에 ASCII 밖 글자로 시작하는 낱말이 남으면
/// (rsync 는 그것을 원본 경로로 읽어 옵션이 몰래 빠진다) 그 낱말들. 있으면 확인 창 · 실시간 동기화에 빨갛게 알리고 실행을 막는다.
/// 옵션의 값 자리 (--exclude 한글.txt 의 한글.txt, -f 뒤) 는 세지 않는다
/// 관리자 지시 (10/20, 28 · 15 확인): [splitOptions] 로 고쳐 읽은 뒤에도 rsync 가 다르게 읽을 것 - 있으면 확인 창 · 실시간 동기화에
/// 빨갛게 알리고 실행을 막는다: 닫히지 않은 따옴표, 옵션 자리의 - 로 시작하지 않는 낱말 (rsync 는 원본 경로로 읽어 옵션이 몰래 빠짐),
/// 하이픈 셋 이상, 끝에 값 없이 남은 값 받는 옵션. 옵션의 값 자리 (--exclude 한글.txt 의 한글.txt, -f 뒤) 는 세지 않는다
List<String> unreadableOptions(String options) {
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) {
if (value) {
value = false;
continue;
}
if (t.startsWith('--')) {
if (t.startsWith('---')) {
// 관리자 판단 (10/20, 28 확인): 하이픈이 셋 이상 (---delete) 은 rsync 가 알아보지 못한다
bad.add(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;
} else if (t.isNotEmpty && t.codeUnitAt(0) > 0x7F) {
bad.add(t);
last = t;
} else {
// 관리자 판단 (10/20, 28 확인): 옵션 칸에는 경로가 들어갈 자리가 없다 (원본 · 대상은 앱이 넣음) - 값 자리가 아닌데
// - 로 시작하지 않는 낱말 (-a delete 의 delete, 혼자 쓴 -) 은 rsync 가 원본 경로로 읽어 옵션이 몰래 빠진다
bad.add(trf('경로로 읽힙니다: {0}', [t]));
}
}
// 값이 필요한 옵션이 끝에 값 없이 (--exclude) - rsync 가 원본 경로를 그 값으로 먹는다
if (value) bad.add(trf('{0} 의 값이 없습니다', [last]));
return bad;
}

View File

@ -90,12 +90,21 @@ void main() {
});
test('고쳐 읽어도 알아볼 수 없는 옵션 (ASCII 밖 글자로 시작) 은 알린다 - 옵션의 값 자리는 세지 않음', () {
expect(unreadableOptions('-a \u00ABdelete\u00BB'), ['\u00ABdelete\u00BB']);
expect(unreadableOptions('-a \u2026delete'), ['\u2026delete']);
expect(unreadableOptions('-a \u00ABdelete\u00BB'), ['경로로 읽힙니다: \u00ABdelete\u00BB']);
expect(unreadableOptions('-a \u2026delete'), ['경로로 읽힙니다: \u2026delete']);
expect(unreadableOptions('--exclude 한글.txt -a'), isEmpty, reason: '--exclude 의 값');
expect(unreadableOptions('-f 한글.txt'), isEmpty, reason: '-f 의 값');
expect(unreadableOptions('-a 한글'), ['한글'], reason: '옵션 자리 - rsync 는 원본 경로로 읽는다');
expect(unreadableOptions('-a 한글'), ['경로로 읽힙니다: 한글'], reason: '옵션 자리 - rsync 는 원본 경로로 읽는다');
expect(unreadableOptions('-avPog --delete'), isEmpty);
// 관리자 판단 (10/20, 28 확인): - 로 시작하지 않는 낱말 · 하이픈 셋 이상 · 끝에 값 없는 옵션
expect(unreadableOptions('-a delete'), ['경로로 읽힙니다: delete']);
expect(unreadableOptions('-a - --delete'), ['경로로 읽힙니다: -']);
expect(unreadableOptions('-a ---delete'), ['---delete']);
expect(unreadableOptions('-a --exclude'), ['--exclude 의 값이 없습니다']);
expect(unreadableOptions('-a -f'), ['-f 의 값이 없습니다']);
expect(unreadableOptions('-a --exclude *.log --delete'), isEmpty, reason: '값이 있음');
expect(unreadableOptions('-a --exclude=*.log'), isEmpty);
expect(unreadableOptions(''), isEmpty);
});
testWidgets('알아볼 수 없는 옵션 알림 (빨간 글, 옵션 글은 줄바꿈 없이)', (tester) async {
@ -103,6 +112,30 @@ void main() {
expect(find.textContaining('옵션을 알아볼 수 없습니다'), findsOneWidget);
});
// 28 확인 (10/20): 막기 전 실제 rsync 는 이런 낱말을 원본 경로로 먹는다 - 그래서 [unreadableOptions] 로 막는다
test('실제 rsync: -a delete 의 delete · 끝의 --exclude 는 원본 경로로 먹혀 의도와 다르게 돈다', () async {
final rsync = sceneRsync;
if (rsync == null) return markTestSkipped('앱에 든 rsync 없음 (Windows · third_party)');
final tmp = Directory.systemTemp.createTempSync('jj_optpath_');
addTearDown(() => tmp.deleteSync(recursive: true));
final s = p.join(tmp.path, 's'), d = p.join(tmp.path, 'd');
Future<ProcessResult> run(String opts) {
writeScene(s, d, srcFiles: const ['a.txt'], dstFiles: const ['old.txt']);
return Process.run(rsync, [...splitOptions(opts), '${toCygwinPath(s)}/', '${toCygwinPath(d)}/'], workingDirectory: tmp.path);
}
// "delete" 를 원본으로 찾다 실패 (exit 23) - --delete 를 뜻했지만 old.txt 는 그대로
final r1 = await run('-r delete');
expect(r1.exitCode, isNot(0));
expect(File(p.join(d, 'old.txt')).existsSync(), isTrue);
// --exclude 가 원본 폴더를 값으로 먹고 받는 쪽 하나만 남아 목록만 보이고 끝 (아무것도 보내지 않음)
final r2 = await run('-r --exclude');
expect(r2.exitCode, 0);
expect(File(p.join(d, 'a.txt')).existsSync(), isFalse, reason: '보내지 않음');
expect(unreadableOptions('-r delete'), isNotEmpty);
expect(unreadableOptions('-r --exclude'), isNotEmpty);
});
// 실제 rsync (Windows, 앱에 든 것): 붙여 넣은 글로 맞춘 결과 = 곧은 글자로 맞춘 결과 (28 의 장면: 받는 쪽 keep.log · old.txt)
group('실제 rsync 결과가 같다', () {
for (final (name, pasted, straight) in cases) {