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
parent
bdea662daa
commit
ecb2b9f7b1
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue