modular_sync #6 RemoteFs (F11): 원격 4종의 mkdirs 를 한 규칙으로 (remoteMkdirs) - 같은 이름의 파일이면 모두 RemoteNameIsFile

- remote_fs.dart: remoteMkdirs(client, rel, mkdirOne) - 없는 위 폴더까지 · 이미 폴더면 그대로 · 같은 이름의 파일이면 RemoteNameIsFile (파일은 그대로).
  서버마다 다른 것은 한 칸 만들기뿐 (FTP MKD · SFTP mkdir (그 사이 다른 곳이 만들었으면 그대로) · SMB 채널 · WebDAV MKCOL).
  위 폴더 계산 remoteParent 하나 (FTP · SFTP · SMB 의 _parent 복사 3벌 지움)
- 고치기 전 판 (bdea662) 에서 FTP 는 같은 이름의 파일 위 mkdirs 가 오류 없이 넘어감을 확인 (r6_before.log) - 뒤의 쓰기가 엉뚱한 오류로 끝났다.
  WebDAV 는 서버 오류 글, SFTP · SMB 는 서버마다 다른 예외였다
- 약속 시험: 실제 시험 서버 WebDAV (TestDavServer) · FTP (pyftpdlib) · SFTP (asyncssh). SMB 는 Android 에서만 (기기 확인)

사용자가 보게 될 동작: 원격으로 복사 · 맞출 때 받는 쪽에 같은 이름의 파일이 있으면 서버와 상관없이 "같은 이름의 파일이 있어 폴더를 만들 수 없습니다"

확인: analyze 0 · 단위 1489 ×2 · 통합 6/6 ×2 · base10 214장 0px · windows_ftp_shot · windows_sshkey_shot ×2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-24 13:27:13 +09:00
parent bdea662daa
commit ecb2b9f7b1
6 changed files with 109 additions and 37 deletions

View File

@ -101,7 +101,6 @@ class FtpRemote implements RemoteClient {
} }
static String _child(String rel, String name) => rel == '/' ? '/$name' : '$rel/$name'; static String _child(String rel, String name) => rel == '/' ? '/$name' : '$rel/$name';
static String _parent(String rel) => rel.lastIndexOf('/') <= 0 ? '/' : rel.substring(0, rel.lastIndexOf('/'));
/// MLSD · MLST 의 한 줄 "type=dir;size=1;modify=20261017120000; 이름" /// MLSD · MLST 의 한 줄 "type=dir;size=1;modify=20261017120000; 이름"
static (String, bool, int, DateTime)? parseMlsx(String line) { static (String, bool, int, DateTime)? parseMlsx(String line) {
@ -176,7 +175,7 @@ class FtpRemote implements RemoteClient {
if (rel == '/' || rel.isEmpty) return DavItem('/', isDir: true, modified: DateTime.fromMillisecondsSinceEpoch(0)); if (rel == '/' || rel.isEmpty) return DavItem('/', isDir: true, modified: DateTime.fromMillisecondsSinceEpoch(0));
// 위 폴더의 목록에서 찾는다 (MLST · SIZE 를 모든 서버가 같게 답하지 않아서 - 느리지만 확실하게) // 위 폴더의 목록에서 찾는다 (MLST · SIZE 를 모든 서버가 같게 답하지 않아서 - 느리지만 확실하게)
try { try {
for (final x in await list(_parent(rel))) { for (final x in await list(remoteParent(rel))) {
if (x.rel == rel) return x; if (x.rel == rel) return x;
} }
} on FtpException catch (e) { } on FtpException catch (e) {
@ -187,12 +186,7 @@ class FtpRemote implements RemoteClient {
} }
@override @override
Future<void> mkdirs(String rel) async { Future<void> mkdirs(String rel) => remoteMkdirs(this, rel, (r) => _run((s) => s.must('MKD ${_abs(s, r)}', path: r)));
if (rel == '/' || rel.isEmpty) return;
if (await stat(rel) != null) return;
await mkdirs(_parent(rel));
await _run((s) => s.must('MKD ${_abs(s, rel)}', path: rel));
}
@override @override
Future<void> delete(String rel) async { Future<void> delete(String rel) async {

View File

@ -1,4 +1,5 @@
import 'webdav.dart' show DavItem; import '../l10n/tr.dart';
import 'webdav.dart' show DavItem, DavException;
/// 원격 저장소 (WebDAV · SFTP) 의 공통 동작. vfs 가 경로의 서버 종류 (dav:// · sftp://) 에 맞는 것을 부른다. /// 원격 저장소 (WebDAV · SFTP) 의 공통 동작. vfs 가 경로의 서버 종류 (dav:// · sftp://) 에 맞는 것을 부른다.
/// 경로 [rel] 은 서버 기준 ("/" 로 시작, 맨 위는 "/"). /// 경로 [rel] 은 서버 기준 ("/" 로 시작, 맨 위는 "/").
@ -7,6 +8,8 @@ abstract class RemoteClient {
/// 없으면 null /// 없으면 null
Future<DavItem?> stat(String rel); Future<DavItem?> stat(String rel);
/// 폴더를 만든다 (위 폴더까지). 서버마다 [remoteMkdirs] 를 부른다 - 이미 폴더면 그대로, 같은 이름의 파일이 있으면 [RemoteNameIsFile]
Future<void> mkdirs(String rel); Future<void> mkdirs(String rel);
/// 지우기 (폴더면 안의 것까지) /// 지우기 (폴더면 안의 것까지)
@ -21,3 +24,26 @@ abstract class RemoteClient {
Future<(int?, int?)> quota(); Future<(int?, int?)> quota();
void close(); void close();
} }
/// modular_sync #6 RemoteFs: 같은 이름의 파일이 있어 폴더를 만들 수 없음 - 서버 종류와 상관없이 같은 오류 (F11: 예전에는 SFTP · SMB 만 알리고
/// FTP 는 조용히 넘어가 뒤의 쓰기가 엉뚱한 오류로, WebDAV 는 서버 오류로 끝났다)
class RemoteNameIsFile extends DavException {
RemoteNameIsFile(this.rel) : super(trf('같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}', [rel]));
final String rel;
}
/// 원격 경로의 위 폴더 ("/a/b" → "/a", 맨 위는 "/")
String remoteParent(String rel) => rel.lastIndexOf('/') <= 0 ? '/' : rel.substring(0, rel.lastIndexOf('/'));
/// 약속 (#6, 모든 원격 서버의 mkdirs): [rel] 까지 폴더를 만든다. 이미 폴더면 그대로 · 같은 이름의 파일이면 [RemoteNameIsFile] ·
/// 위 폴더부터 차례로. 서버마다 다른 것은 한 칸 만들기 [mkdirOne] 뿐 (그 사이 다른 곳이 만들었으면 그대로 두는 것은 [mkdirOne] 이)
Future<void> remoteMkdirs(RemoteClient c, String rel, Future<void> Function(String rel) mkdirOne) async {
if (rel == '/' || rel.isEmpty) return;
final st = await c.stat(rel);
if (st != null) {
if (st.isDir) return;
throw RemoteNameIsFile(rel);
}
await remoteMkdirs(c, remoteParent(rel), mkdirOne);
await mkdirOne(rel);
}

View File

@ -175,7 +175,6 @@ class SftpRemote implements RemoteClient {
modified: DateTime.fromMillisecondsSinceEpoch((a.modifyTime ?? 0) * 1000)); modified: DateTime.fromMillisecondsSinceEpoch((a.modifyTime ?? 0) * 1000));
static String _child(String rel, String name) => rel == '/' ? '/$name' : '$rel/$name'; static String _child(String rel, String name) => rel == '/' ? '/$name' : '$rel/$name';
static String _parent(String rel) => rel.lastIndexOf('/') <= 0 ? '/' : rel.substring(0, rel.lastIndexOf('/'));
@override @override
Future<List<DavItem>> list(String rel) async { Future<List<DavItem>> list(String rel) async {
@ -213,14 +212,9 @@ class SftpRemote implements RemoteClient {
} }
@override @override
Future<void> mkdirs(String rel) async { Future<void> mkdirs(String rel) => remoteMkdirs(this, rel, _mkdirOne);
if (rel == '/' || rel.isEmpty) return;
final st = await stat(rel); Future<void> _mkdirOne(String rel) async {
if (st != null) {
if (st.isDir) return;
throw SftpException(trf('같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}', [rel]), SftpFailure.other);
}
await mkdirs(_parent(rel));
final c = await _open(); final c = await _open();
try { try {
await _cmd(c.sftp.mkdir(_remote(c, rel))); await _cmd(c.sftp.mkdir(_remote(c, rel)));

View File

@ -178,7 +178,6 @@ class SmbRemote implements RemoteClient {
} }
static String _child(String rel, String name) => rel == '/' ? '/$name' : '$rel/$name'; static String _child(String rel, String name) => rel == '/' ? '/$name' : '$rel/$name';
static String _parent(String rel) => rel.lastIndexOf('/') <= 0 ? '/' : rel.substring(0, rel.lastIndexOf('/'));
DavItem _item(String rel, Map<Object?, Object?> m) => DavItem(rel, DavItem _item(String rel, Map<Object?, Object?> m) => DavItem(rel,
isDir: m['dir'] == true, isDir: m['dir'] == true,
@ -214,16 +213,7 @@ class SmbRemote implements RemoteClient {
} }
@override @override
Future<void> mkdirs(String rel) async { Future<void> mkdirs(String rel) => remoteMkdirs(this, rel, (r) => _call<void>('mkdir', {'path': _path(r)}, r));
if (rel == '/' || rel.isEmpty) return;
final st = await stat(rel);
if (st != null) {
if (st.isDir) return;
throw SmbException(trf('같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}', [rel]), SmbFailure.other);
}
await mkdirs(_parent(rel));
await _call<void>('mkdir', {'path': _path(rel)}, rel);
}
@override @override
Future<void> delete(String rel) => _call<void>('delete', {'path': _path(rel)}, rel); Future<void> delete(String rel) => _call<void>('delete', {'path': _path(rel)}, rel);

View File

@ -268,14 +268,7 @@ class DavClient implements RemoteClient {
/// 위 폴더까지 차례로 만든다 /// 위 폴더까지 차례로 만든다
@override @override
Future<void> mkdirs(String rel) async { Future<void> mkdirs(String rel) => remoteMkdirs(this, rel.startsWith('/') ? rel : '/$rel', mkdir);
var cur = '';
for (final part in rel.split('/').where((x) => x.isNotEmpty)) {
cur = '$cur/$part';
if ((await stat(cur))?.isDir == true) continue;
await mkdir(cur);
}
}
@override @override
Future<void> delete(String rel) async { Future<void> delete(String rel) async {

View File

@ -0,0 +1,75 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/core/ftp.dart';
import 'package:jj_mkvmaker/core/remote_fs.dart';
import 'package:jj_mkvmaker/core/sftp.dart';
import 'package:jj_mkvmaker/core/webdav.dart';
import 'package:path/path.dart' as p;
import 'support/dav_server.dart';
import 'support/sftp_server.dart';
/// modular_sync #6 RemoteFs 약속 (mkdirs, F11): 서버 종류와 상관없이 같게 - 없는 위 폴더까지 만들고 · 이미 폴더면 그대로 ·
/// 같은 이름의 파일이 있으면 RemoteNameIsFile (파일은 그대로). 예전에는 FTP 는 조용히 넘어가고 WebDAV 는 서버 오류였다.
/// 실제 시험 서버: WebDAV (TestDavServer) · FTP (pyftpdlib) · SFTP (asyncssh). SMB 는 Android 에서만 돈다 (기기 확인)
void main() {
late Directory tmp, root;
setUp(() {
tmp = Directory.systemTemp.createTempSync('jj_rmkdirs_');
root = Directory(p.join(tmp.path, 'root'))..createSync();
File(p.join(root.path, 'file')).writeAsStringSync('keep');
});
tearDown(() async {
for (var i = 0;; i++) {
try {
if (tmp.existsSync()) tmp.deleteSync(recursive: true);
break;
} on FileSystemException {
if (i > 30) rethrow;
await Future<void>.delayed(const Duration(milliseconds: 100));
}
}
});
Future<void> contract(RemoteClient c) async {
await c.mkdirs('/a/b/c');
expect(Directory(p.join(root.path, 'a', 'b', 'c')).existsSync(), isTrue, reason: '위 폴더까지');
await c.mkdirs('/a/b');
await expectLater(c.mkdirs('/file'), throwsA(isA<RemoteNameIsFile>()));
await expectLater(c.mkdirs('/file/sub'), throwsA(isA<RemoteNameIsFile>()), reason: '위 폴더 자리가 파일');
expect(File(p.join(root.path, 'file')).readAsStringSync(), 'keep', reason: '파일은 그대로');
}
test('WebDAV', () async {
final server = TestDavServer(root);
await server.start();
addTearDown(server.stop);
await contract(DavClient(DavServer(id: 'd', name: 'd', url: server.url, user: 'user', password: 'pass')));
});
test('FTP (pyftpdlib)', () async {
if (!sftpTestServerAvailable) return markTestSkipped('시험용 python 없음');
final pr = await Process.start(sftpTestPython, ['tool/ftp_test_server.py', '--root', root.path, '--mode', 'plain', '--password', 'pw-rm']);
addTearDown(() async {
pr.kill();
await pr.exitCode.timeout(const Duration(seconds: 5), onTimeout: () => -1);
});
pr.stderr.drain<void>();
final lines = pr.stdout.transform(utf8.decoder).transform(const LineSplitter()).asBroadcastStream();
final port = int.parse((await lines.firstWhere((l) => l.startsWith('PORT ')).timeout(const Duration(seconds: 20))).split(' ')[1]);
final c = FtpRemote(FtpServer(id: 'f', name: 'f', host: '127.0.0.1', port: port, user: 'jj', password: 'pw-rm', security: 'none'));
addTearDown(c.close);
await contract(c);
});
test('SFTP (asyncssh)', () async {
if (!sftpTestServerAvailable) return markTestSkipped('시험용 python 없음');
final server = await SftpTestServer.start(root: root.path, hostKeyFile: p.join(tmp.path, 'hostkey'));
addTearDown(server.stop);
final c = SftpRemote(SftpServer(id: 's', name: 's', host: '127.0.0.1', port: server.port, user: 'jj', password: 'pw', hostKeyFingerprint: server.fingerprint));
addTearDown(c.close);
await contract(c);
});
}