From 50bd2bf2591794d8dd751106b7c4dba3dcd095e6 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sun, 18 Oct 2026 04:39:24 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B2=80?= =?UTF-8?q?=ED=86=A0:=20=EB=8C=80=EC=86=8C=EB=AC=B8=EC=9E=90=EB=A7=8C=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20=EC=9D=B4=EB=A6=84=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=94=EA=BF=80=20=EB=95=8C=20=EB=8C=80=EC=86=8C=EB=AC=B8?= =?UTF-8?q?=EC=9E=90=EB=A5=BC=20=EA=B0=80=EB=A6=AC=EB=8A=94=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=EC=9D=98=20=EB=8B=A4=EB=A5=B8=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EC=9D=84=20=EB=8D=AE=EB=8D=98=20=EA=B2=83=20(=EA=B2=80?= =?UTF-8?q?=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (10/18, cd 의 X-plore e~i · 칸 그림 데이터 안전 검토) 중 찾음: FileOps.rename 은 a.txt → A.txt 처럼 대소문자만 바뀌면 "같은 파일의 대소문자 바꾸기" 로 보고 같은 이름 확인을 건너뛰었다. 대소문자를 가리는 곳 (Android 앱 폴더 · ext4 · 일부 NAS 를 로컬로 붙인 것 · Windows 의 대소문자 구분 폴더) 에 A.txt 가 따로 있으면 File.rename 이 그 파일을 말없이 덮었다 (이름 변경 · 일괄 이름 바꾸기 · [D][T] 모두 이 길). → 대소문자만 다를 때는 폴더 목록에 정확히 그 이름이 따로 있는지 보고, 있으면 "같은 이름이 이미 있습니다" (목록을 못 읽으면 막음). 대소문자를 가리지 않는 보통 Windows 폴더에서 a.txt → A.txt 는 그대로 된다. 시험: file_ops_test (새 1: 보통 폴더는 바뀜 · fsutil 로 만든 대소문자 구분 폴더에서는 막고 두 파일 그대로 - 고치기 전에는 A.txt 를 덮어 실패) · batch_rename · batch_rename_date · app_trash · xplore_tools 34 통과, analyze 문제 없음. Co-Authored-By: Claude Opus 5.5 --- lib/core/file_ops.dart | 13 ++++++++++++- test/file_ops_test.dart | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart index 823a8a6..c319e2e 100644 --- a/lib/core/file_ops.dart +++ b/lib/core/file_ops.dart @@ -728,7 +728,7 @@ class FileOps { final target = p.join(p.dirname(path), name); if (target == path) return path; if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound && - target.toLowerCase() != path.toLowerCase()) { + (target.toLowerCase() != path.toLowerCase() || _otherCaseExists(path, name))) { throw FileSystemException(tr('같은 이름이 이미 있습니다'), target); } final t = FileSystemEntity.typeSync(path); @@ -737,6 +737,17 @@ class FileOps { : (await File(path).rename(target)).path; } + /// 데이터 검토 (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)); diff --git a/test/file_ops_test.dart b/test/file_ops_test.dart index 9212a1f..2f94f50 100644 --- a/test/file_ops_test.dart +++ b/test/file_ops_test.dart @@ -54,6 +54,29 @@ void main() { throwsA(isA())); }); + test('데이터 검토: 대소문자만 바꾸는 이름 - 대소문자를 가리는 폴더에 그 이름의 다른 파일이 있으면 덮지 않는다', () async { + // 보통 폴더 (Windows - 가리지 않음): a.txt → A.txt 는 바뀐다 + final plain = Directory(p.join(tmp.path, 'plain'))..createSync(); + final a = File(p.join(plain.path, 'a.txt'))..writeAsStringSync('lower'); + final up = await FileOps.rename(a.path, 'A.txt'); + expect(p.basename(up), 'A.txt'); + expect([for (final e in plain.listSync()) p.basename(e.path)], ['A.txt']); + expect(File(up).readAsStringSync(), 'lower'); + // 대소문자를 가리는 폴더 (Android 앱 폴더 · ext4 와 같음 - Windows 는 fsutil 로) + if (!Platform.isWindows) return; + final cs = Directory(p.join(tmp.path, 'cs'))..createSync(); + final r = await Process.run('fsutil.exe', ['file', 'setCaseSensitiveInfo', cs.path, 'enable']); + if (r.exitCode != 0) { + markTestSkipped('대소문자를 가리는 폴더를 만들 수 없음: ${r.stdout}'); + return; + } + final lower = File(p.join(cs.path, 'a.txt'))..writeAsStringSync('lower'); + final upper = File(p.join(cs.path, 'A.txt'))..writeAsStringSync('UPPER'); + await expectLater(FileOps.rename(lower.path, 'A.txt'), throwsA(isA())); + expect(lower.readAsStringSync(), 'lower'); + expect(upper.readAsStringSync(), 'UPPER', reason: '다른 파일을 덮지 않는다'); + }); + test('이동 · 삭제 · 이름 바꾸기 · 새 폴더', () async { final f = file('a/x.mkv', 'video'); final b = Directory(p.join(tmp.path, 'b'))..createSync();