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();