From ecb2b9f7b127560681e50cf36564eeca4a553e85 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:27:13 +0900 Subject: [PATCH] =?UTF-8?q?modular=5Fsync=20#6=20RemoteFs=20(F11):=20?= =?UTF-8?q?=EC=9B=90=EA=B2=A9=204=EC=A2=85=EC=9D=98=20mkdirs=20=EB=A5=BC?= =?UTF-8?q?=20=ED=95=9C=20=EA=B7=9C=EC=B9=99=EC=9C=BC=EB=A1=9C=20(remoteMk?= =?UTF-8?q?dirs)=20-=20=EA=B0=99=EC=9D=80=20=EC=9D=B4=EB=A6=84=EC=9D=98=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=9D=B4=EB=A9=B4=20=EB=AA=A8=EB=91=90=20Rem?= =?UTF-8?q?oteNameIsFile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- lib/core/ftp/ftp_remote.dart | 10 +--- lib/core/remote_fs.dart | 28 +++++++++- lib/core/sftp/sftp_remote.dart | 12 ++--- lib/core/smb.dart | 12 +---- lib/core/webdav.dart | 9 +--- test/remote_mkdirs_contract_test.dart | 75 +++++++++++++++++++++++++++ 6 files changed, 109 insertions(+), 37 deletions(-) create mode 100644 test/remote_mkdirs_contract_test.dart diff --git a/lib/core/ftp/ftp_remote.dart b/lib/core/ftp/ftp_remote.dart index e14591d..928fb72 100644 --- a/lib/core/ftp/ftp_remote.dart +++ b/lib/core/ftp/ftp_remote.dart @@ -101,7 +101,6 @@ class FtpRemote implements RemoteClient { } 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; 이름" 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)); // 위 폴더의 목록에서 찾는다 (MLST · SIZE 를 모든 서버가 같게 답하지 않아서 - 느리지만 확실하게) try { - for (final x in await list(_parent(rel))) { + for (final x in await list(remoteParent(rel))) { if (x.rel == rel) return x; } } on FtpException catch (e) { @@ -187,12 +186,7 @@ class FtpRemote implements RemoteClient { } @override - Future mkdirs(String rel) async { - 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)); - } + Future mkdirs(String rel) => remoteMkdirs(this, rel, (r) => _run((s) => s.must('MKD ${_abs(s, r)}', path: r))); @override Future delete(String rel) async { diff --git a/lib/core/remote_fs.dart b/lib/core/remote_fs.dart index baeccb4..1c508a5 100644 --- a/lib/core/remote_fs.dart +++ b/lib/core/remote_fs.dart @@ -1,4 +1,5 @@ -import 'webdav.dart' show DavItem; +import '../l10n/tr.dart'; +import 'webdav.dart' show DavItem, DavException; /// 원격 저장소 (WebDAV · SFTP) 의 공통 동작. vfs 가 경로의 서버 종류 (dav:// · sftp://) 에 맞는 것을 부른다. /// 경로 [rel] 은 서버 기준 ("/" 로 시작, 맨 위는 "/"). @@ -7,6 +8,8 @@ abstract class RemoteClient { /// 없으면 null Future stat(String rel); + + /// 폴더를 만든다 (위 폴더까지). 서버마다 [remoteMkdirs] 를 부른다 - 이미 폴더면 그대로, 같은 이름의 파일이 있으면 [RemoteNameIsFile] Future mkdirs(String rel); /// 지우기 (폴더면 안의 것까지) @@ -21,3 +24,26 @@ abstract class RemoteClient { Future<(int?, int?)> quota(); 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 remoteMkdirs(RemoteClient c, String rel, Future 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); +} diff --git a/lib/core/sftp/sftp_remote.dart b/lib/core/sftp/sftp_remote.dart index 7987858..65955c9 100644 --- a/lib/core/sftp/sftp_remote.dart +++ b/lib/core/sftp/sftp_remote.dart @@ -175,7 +175,6 @@ class SftpRemote implements RemoteClient { modified: DateTime.fromMillisecondsSinceEpoch((a.modifyTime ?? 0) * 1000)); 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 Future> list(String rel) async { @@ -213,14 +212,9 @@ class SftpRemote implements RemoteClient { } @override - Future mkdirs(String rel) async { - if (rel == '/' || rel.isEmpty) return; - final st = await stat(rel); - if (st != null) { - if (st.isDir) return; - throw SftpException(trf('같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}', [rel]), SftpFailure.other); - } - await mkdirs(_parent(rel)); + Future mkdirs(String rel) => remoteMkdirs(this, rel, _mkdirOne); + + Future _mkdirOne(String rel) async { final c = await _open(); try { await _cmd(c.sftp.mkdir(_remote(c, rel))); diff --git a/lib/core/smb.dart b/lib/core/smb.dart index 405b26c..43c1d76 100644 --- a/lib/core/smb.dart +++ b/lib/core/smb.dart @@ -178,7 +178,6 @@ class SmbRemote implements RemoteClient { } 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 m) => DavItem(rel, isDir: m['dir'] == true, @@ -214,16 +213,7 @@ class SmbRemote implements RemoteClient { } @override - Future mkdirs(String rel) async { - 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('mkdir', {'path': _path(rel)}, rel); - } + Future mkdirs(String rel) => remoteMkdirs(this, rel, (r) => _call('mkdir', {'path': _path(r)}, r)); @override Future delete(String rel) => _call('delete', {'path': _path(rel)}, rel); diff --git a/lib/core/webdav.dart b/lib/core/webdav.dart index 1abafae..59c0582 100644 --- a/lib/core/webdav.dart +++ b/lib/core/webdav.dart @@ -268,14 +268,7 @@ class DavClient implements RemoteClient { /// 위 폴더까지 차례로 만든다 @override - Future mkdirs(String rel) async { - 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); - } - } + Future mkdirs(String rel) => remoteMkdirs(this, rel.startsWith('/') ? rel : '/$rel', mkdir); @override Future delete(String rel) async { diff --git a/test/remote_mkdirs_contract_test.dart b/test/remote_mkdirs_contract_test.dart new file mode 100644 index 0000000..d11e421 --- /dev/null +++ b/test/remote_mkdirs_contract_test.dart @@ -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.delayed(const Duration(milliseconds: 100)); + } + } + }); + + Future 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())); + await expectLater(c.mkdirs('/file/sub'), throwsA(isA()), 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(); + 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); + }); +}