WIP 데이터 검토: 대소문자만 다른 이름으로 바꿀 때 대소문자를 가리는 폴더의 다른 파일을 덮던 것 (검토 대기)

관리자 지시 (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 <noreply@anthropic.com>
review_fix v2026.10.12_976
RyuJongJin 2026-10-18 04:39:24 +09:00
parent 25e3123aac
commit 50bd2bf259
2 changed files with 35 additions and 1 deletions

View File

@ -728,7 +728,7 @@ class FileOps {
final target = p.join(p.dirname(path), name); final target = p.join(p.dirname(path), name);
if (target == path) return path; if (target == path) return path;
if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound && if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound &&
target.toLowerCase() != path.toLowerCase()) { (target.toLowerCase() != path.toLowerCase() || _otherCaseExists(path, name))) {
throw FileSystemException(tr('같은 이름이 이미 있습니다'), target); throw FileSystemException(tr('같은 이름이 이미 있습니다'), target);
} }
final t = FileSystemEntity.typeSync(path); final t = FileSystemEntity.typeSync(path);
@ -737,6 +737,17 @@ class FileOps {
: (await File(path).rename(target)).path; : (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<String> makeFolder(String dir, String name) async { static Future<String> makeFolder(String dir, String name) async {
final n = name.trim(); final n = name.trim();
final bad = nameProblem(n, windowsRules: usesWindowsNames(dir)); final bad = nameProblem(n, windowsRules: usesWindowsNames(dir));

View File

@ -54,6 +54,29 @@ void main() {
throwsA(isA<FileSystemException>())); throwsA(isA<FileSystemException>()));
}); });
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<FileSystemException>()));
expect(lower.readAsStringSync(), 'lower');
expect(upper.readAsStringSync(), 'UPPER', reason: '다른 파일을 덮지 않는다');
});
test('이동 · 삭제 · 이름 바꾸기 · 새 폴더', () async { test('이동 · 삭제 · 이름 바꾸기 · 새 폴더', () async {
final f = file('a/x.mkv', 'video'); final f = file('a/x.mkv', 'video');
final b = Directory(p.join(tmp.path, 'b'))..createSync(); final b = Directory(p.join(tmp.path, 'b'))..createSync();