Merge branch 'split_core' into modular
commit
525b3ed0dc
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,273 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import '../../l10n/tr.dart';
|
||||
import '../rsync_filter.dart';
|
||||
import '../vfs.dart';
|
||||
import 'path_rules.dart';
|
||||
import 'transfer_control.dart';
|
||||
import 'transfer_guard.dart';
|
||||
|
||||
// 약속 (나누기 file_ops): 어느 저장소든 (로컬 · WebDAV · SFTP · FTP 등 vfs 가 아는 곳) 의 복사 · 이동 · 맞추기 · 크기.
|
||||
// 한쪽이라도 원격이면 [FileOps] 가 이쪽을 부른다. 같은 서버 안의 이동은 서버가 옮긴다.
|
||||
// 입력: 설정 · 상태 [TransferControl], 원본 경로들 · 받는 폴더 (맞추기는 [RsyncFilter] 거르기도). 출력: 만든 경로들 · 보낸 파일 수.
|
||||
// 오류: 취소 → [FileOpCancelled] (만들던 파일은 지움) · 원본 없음 · 자기 안으로 → [FileSystemException] ·
|
||||
// 맞추기에서 원본을 읽지 못함 → [SourceUnreadableException]. 원격 오류 (DavException 등) 는 그대로.
|
||||
|
||||
/// 전체 크기 (진행률용)
|
||||
Future<int> anyTotalSize(List<String> paths) async {
|
||||
var total = 0;
|
||||
Future<void> walk(String path) async {
|
||||
final st = await vStat(path);
|
||||
if (st == null) return;
|
||||
if (!st.isDir) {
|
||||
total += st.size;
|
||||
return;
|
||||
}
|
||||
for (final e in await vList(path)) {
|
||||
if (e.isDir) {
|
||||
await walk(e.path);
|
||||
} else {
|
||||
total += e.size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (final s in paths) {
|
||||
await walk(s);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// 파일 하나: 읽어서 쓴다 (속도 제한 · 취소 · 진행)
|
||||
Future<void> _copyFile(TransferControl c, String src, String target, int size, DateTime modified, void Function(int n)? onChunk) async {
|
||||
c.check();
|
||||
final input = await vOpenRead(src);
|
||||
final relay = StreamController<List<int>>();
|
||||
final pump = () async {
|
||||
try {
|
||||
await for (final chunk in input) {
|
||||
if (c.cancelled) {
|
||||
relay.addError(const FileOpCancelled());
|
||||
break;
|
||||
}
|
||||
relay.add(chunk);
|
||||
c.onBytes?.call(chunk.length);
|
||||
onChunk?.call(chunk.length);
|
||||
await c.throttle(chunk.length);
|
||||
}
|
||||
} catch (e, st) {
|
||||
relay.addError(e, st);
|
||||
} finally {
|
||||
await relay.close();
|
||||
}
|
||||
}();
|
||||
try {
|
||||
await vWrite(target, relay.stream, length: size, modified: modified);
|
||||
} finally {
|
||||
await pump;
|
||||
}
|
||||
if (c.cancelled) {
|
||||
try {
|
||||
await vDelete(target);
|
||||
} catch (_) {}
|
||||
throw const FileOpCancelled();
|
||||
}
|
||||
c.onFileDone?.call(src);
|
||||
}
|
||||
|
||||
/// [sources] 를 [destDir] 로 복사. 만든 경로들을 돌려준다
|
||||
Future<List<String>> anyCopy(TransferControl c, List<String> sources, String destDir, {CopyProgress? onProgress}) async {
|
||||
final total = await anyTotalSize(sources);
|
||||
var done = 0;
|
||||
final made = <String>[];
|
||||
Future<void> copyAny(String src, String target, {bool inner = false}) async {
|
||||
c.check();
|
||||
final st = await vStat(src);
|
||||
if (st == null) throw FileSystemException(tr('원본이 없습니다'), src);
|
||||
if (st.isDir) {
|
||||
await vMkdirs(target);
|
||||
for (final e in await vList(src)) {
|
||||
await copyAny(e.path, vJoin(target, vBasename(e.path)), inner: true);
|
||||
}
|
||||
} else {
|
||||
// 229: 합치는 폴더 안에서 받는 쪽이 더 새것이면 예전 것으로 덮지 않는다 (WebDAV 도 로컬과 같게)
|
||||
if (inner && c.skipNewerInMerge) {
|
||||
final t = await vStat(target);
|
||||
if (t != null && !t.isDir && t.modified.isAfter(st.modified.add(const Duration(seconds: 2)))) {
|
||||
c.keptNewer.add(src);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await _copyFile(c, src, target, st.size, st.modified, (n) {
|
||||
done += n;
|
||||
onProgress?.call(src, done, total);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (final s in sources) {
|
||||
if (isSameOrInside(destDir, s) && await vIsDir(s)) throw FileSystemException(tr('폴더를 자기 안으로 복사할 수 없습니다'), s);
|
||||
final target = await c.targetFor(s, destDir);
|
||||
if (target == null) continue;
|
||||
await copyAny(s, target);
|
||||
made.add(target);
|
||||
}
|
||||
return made;
|
||||
}
|
||||
|
||||
/// 이동: 같은 WebDAV 서버 안이면 서버가 옮기고, 아니면 복사한 뒤 원본을 지운다
|
||||
Future<List<String>> anyMove(TransferControl c, List<String> sources, String destDir, {CopyProgress? onProgress}) async {
|
||||
final made = <String>[];
|
||||
for (final s in sources) {
|
||||
c.check();
|
||||
if (isSameOrInside(destDir, s)) throw FileSystemException(tr('폴더를 자기 안으로 옮길 수 없습니다'), s);
|
||||
if (samePath(vDirname(s), destDir)) {
|
||||
made.add(s);
|
||||
continue;
|
||||
}
|
||||
final target = await c.targetFor(s, destDir);
|
||||
if (target == null) continue;
|
||||
final exists = await vExists(target);
|
||||
// 같은 WebDAV 서버 안이면 서버가 옮긴다 (빠름) - 덮어쓸 폴더는 합쳐야 하니 복사로
|
||||
if (isDav(s) && isDav(destDir) && DavPath.parse(s).server == DavPath.parse(destDir).server && !(exists && await vIsDir(s))) {
|
||||
if (exists) await vDelete(target);
|
||||
await vRename(s, target);
|
||||
made.add(target);
|
||||
continue;
|
||||
}
|
||||
final keptBefore = c.keptNewer.length;
|
||||
made.addAll(await anyCopy(c, [s], destDir, onProgress: onProgress));
|
||||
final kept = c.keptNewer.sublist(keptBefore);
|
||||
if (kept.isEmpty) {
|
||||
await vDelete(s);
|
||||
} else {
|
||||
// 229: 건너뛴 (받는 쪽이 더 새) 원본은 남기고 옮긴 것만 지운다
|
||||
await _deleteExcept(s, kept.toSet());
|
||||
}
|
||||
}
|
||||
return made;
|
||||
}
|
||||
|
||||
/// 229: [dir] (로컬 · WebDAV) 안에서 [keep] 의 파일만 남기고 지운다 (비게 된 폴더도)
|
||||
Future<bool> _deleteExcept(String dir, Set<String> keep) async {
|
||||
var empty = true;
|
||||
for (final e in await vList(dir)) {
|
||||
if (e.isDir) {
|
||||
if (!await _deleteExcept(e.path, keep)) empty = false;
|
||||
} else if (keep.contains(e.path)) {
|
||||
empty = false;
|
||||
} else {
|
||||
await vDelete(e.path);
|
||||
}
|
||||
}
|
||||
if (empty) await vDelete(dir);
|
||||
return empty;
|
||||
}
|
||||
|
||||
/// 맞추기: [srcDir] 의 내용을 [dstDir] 에. [strict] (214, 이동): 같음은 크기 + 바뀐 때 (±2초) 로만.
|
||||
/// [filter]: rsync 옵션의 거르기 (미리 보기 sync_preview 와 같은 규칙). 지우기 전 원본 확인은 부르는 쪽이 한다
|
||||
Future<int> anyMirror(TransferControl c, String srcDir, String dstDir,
|
||||
{bool delete = false, bool update = false, bool strict = false, RsyncFilter filter = RsyncFilter.none}) async {
|
||||
var n = 0;
|
||||
// 관리자 지시 (10/19): 거르기는 rsync 와 같게 (미리 보기 sync_preview 와 같은 규칙) - 거른 것은 보내지 않고 받는 쪽의 거른 것은 지우지 않는다
|
||||
// (--delete-excluded 면 지운다). 받는 쪽에만 있는 폴더 안에 거른 것이 있으면 폴더는 남기고 거르지 않은 것만 지운다
|
||||
Future<bool> hasExcluded(String dir, String rel) async {
|
||||
List<({String path, bool isDir, int size, DateTime modified})> items;
|
||||
try {
|
||||
items = await vList(dir);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
for (final x in items) {
|
||||
final r = '$rel/${vBasename(x.path)}';
|
||||
if (filter.protects(r, isDir: x.isDir)) return true;
|
||||
if (x.isDir && await hasExcluded(x.path, r)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> deleteInside(String dir, String rel) async {
|
||||
for (final x in await vList(dir)) {
|
||||
c.check();
|
||||
final name = vBasename(x.path);
|
||||
if (name.endsWith('.jjsync') || isAppTempName(name)) continue;
|
||||
final r = '$rel/$name';
|
||||
if (filter.protects(r, isDir: x.isDir)) continue;
|
||||
if (x.isDir && await hasExcluded(x.path, r)) {
|
||||
await deleteInside(x.path, r);
|
||||
} else {
|
||||
await vDelete(x.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> walk(String src, String dst, String rel) async {
|
||||
c.check();
|
||||
await vMkdirs(dst);
|
||||
final there = {for (final e in await vList(dst)) vBasename(e.path): e};
|
||||
final names = <String>{};
|
||||
List<({String path, bool isDir, int size, DateTime modified})> items;
|
||||
try {
|
||||
items = await vList(src, strict: true);
|
||||
} catch (e) {
|
||||
throw SourceUnreadableException(src, cause: e);
|
||||
}
|
||||
for (final e in items) {
|
||||
final name = vBasename(e.path);
|
||||
if (isAppTempName(name)) continue; // 78 보충
|
||||
final r = rel.isEmpty ? name : '$rel/$name';
|
||||
// 거른 것은 보내지 않는다 (거른 폴더 안으로도 내려가지 않음) - 받는 쪽의 같은 이름은 아래 지우기에서 (지키거나 --delete-excluded)
|
||||
if (filter.excluded(r, isDir: e.isDir)) continue;
|
||||
names.add(name);
|
||||
final target = vJoin(dst, name);
|
||||
// 214: 로컬 원본의 링크 · junction 은 따라가지 않는다 (로컬끼리 맞출 때처럼 - 링크가 가리키는 바깥 폴더를 건드리지 않게)
|
||||
if (!isDav(e.path) && FileSystemEntity.isLinkSync(e.path)) continue;
|
||||
if (e.isDir) {
|
||||
await walk(e.path, target, r);
|
||||
continue;
|
||||
}
|
||||
final t = there[name];
|
||||
if (t != null && !t.isDir && t.size == e.size) {
|
||||
final close = t.modified.difference(e.modified).inSeconds.abs() <= 2;
|
||||
// 크기가 같고 대상이 원본보다 새것이거나 (올린 파일은 서버 시각) 거의 같으면 그대로.
|
||||
// 214: 이동 ([strict]) 은 거의 같은 시각일 때만 같다고 본다 (아니면 다시 보내고 지운다 - 내용이 다른데 지우지 않게)
|
||||
if (close || (!strict && !t.modified.isBefore(e.modified.subtract(const Duration(seconds: 2))))) {
|
||||
c.onSettled?.call(e.path, e.size, e.modified);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (update && t != null && t.modified.isAfter(e.modified.add(const Duration(seconds: 2)))) continue;
|
||||
// M1 (관리자 P0, 10/19): 예전에는 --delete 가 없어도 같은 이름의 대상 폴더를 통째로 지우고 파일로 바꿨다 (미리 보기에는 "바뀜" 만).
|
||||
// 이제 --delete 일 때만 (미리 보기에 "폴더를 지우고 파일로"), 아니면 건너뛰고 알린다
|
||||
if (t != null && t.isDir) {
|
||||
if (!delete) {
|
||||
c.dirConflicts.add(target);
|
||||
continue;
|
||||
}
|
||||
await vDelete(target);
|
||||
}
|
||||
await _copyFile(c, e.path, target, e.size, e.modified, null);
|
||||
n++;
|
||||
c.onSettled?.call(e.path, e.size, e.modified);
|
||||
}
|
||||
if (delete) {
|
||||
for (final t in there.entries) {
|
||||
final name = t.key;
|
||||
if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue;
|
||||
c.check();
|
||||
final r = rel.isEmpty ? name : '$rel/$name';
|
||||
// 받는 쪽 규칙에 걸린 것만 지킨다 (관리자 지시 10/20: 보내는 쪽에서만 거른 H · -,s · --delete-excluded 의 그냥 규칙은 지움)
|
||||
if (filter.protects(r, isDir: t.value.isDir)) continue;
|
||||
if (t.value.isDir && filter.protectsAny && await hasExcluded(t.value.path, r)) {
|
||||
await deleteInside(t.value.path, r);
|
||||
continue;
|
||||
}
|
||||
await vDelete(t.value.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(srcDir, dstDir, '');
|
||||
return n;
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../playlist.dart' show naturalCompare;
|
||||
import '../vfs.dart';
|
||||
|
||||
// 약속 (나누기 file_ops): 폴더 항목 · 정렬 · 폴더 읽기 · 이름 찾기 · 크기 글.
|
||||
// 입력: 경로 (로컬 · WebDAV 등 vfs 가 아는 경로). 출력: [FileEntry] 목록 · 흐름 · 글.
|
||||
// 오류: [listEntries] 는 하나도 읽지 못하면 그 오류를 던지고 (빈 폴더로 보이지 않게), 일부 읽은 뒤의 오류는 읽은 것까지 돌려준다.
|
||||
// [searchFiles] 는 읽지 못한 폴더를 [onSkipped] 로 알리고 건너뛴다. [sortEntries] · [formatSize] 는 오류 없음.
|
||||
|
||||
/// 폴더 안의 항목 하나
|
||||
class FileEntry {
|
||||
final String path;
|
||||
final bool isDir;
|
||||
final int size;
|
||||
final DateTime modified;
|
||||
const FileEntry(this.path, {required this.isDir, this.size = 0, required this.modified});
|
||||
|
||||
String get name => p.basename(path);
|
||||
bool get hidden => name.startsWith('.');
|
||||
|
||||
/// 확장자 (소문자, 점 없이)
|
||||
String get ext => isDir ? '' : p.extension(path).replaceFirst('.', '').toLowerCase();
|
||||
}
|
||||
|
||||
enum SortBy { name, date, size, type }
|
||||
|
||||
/// 정렬: 폴더 먼저, 이름은 숫자를 숫자로 비교 (file2 < file10)
|
||||
/// 80: [dirSize] 가 있으면 크기순에서 폴더도 그 크기로 (모르는 폴더는 가장 작게)
|
||||
List<FileEntry> sortEntries(List<FileEntry> list, SortBy by, {bool descending = false, int? Function(FileEntry dir)? dirSize}) {
|
||||
int sizeOf(FileEntry e) => e.isDir ? (dirSize?.call(e) ?? -1) : e.size;
|
||||
int cmp(FileEntry a, FileEntry b) {
|
||||
if (a.isDir != b.isDir) return a.isDir ? -1 : 1; // 폴더는 거꾸로여도 늘 위
|
||||
final r = switch (by) {
|
||||
SortBy.name => 0,
|
||||
SortBy.date => a.modified.compareTo(b.modified),
|
||||
SortBy.size => sizeOf(a).compareTo(sizeOf(b)),
|
||||
SortBy.type => a.ext.compareTo(b.ext),
|
||||
};
|
||||
final v = r != 0 ? r : naturalCompare(a.name, b.name);
|
||||
return descending ? -v : v;
|
||||
}
|
||||
|
||||
return [...list]..sort(cmp);
|
||||
}
|
||||
|
||||
/// 폴더 읽기 (읽을 수 없는 항목은 건너뜀). [showHidden] 이 아니면 점으로 시작하는 항목을 뺀다.
|
||||
Future<List<FileEntry>> listEntries(String dir, {bool showHidden = false}) async {
|
||||
if (isDav(dir)) {
|
||||
// WebDAV: 네트워크 오류 · 인증 실패는 그대로 던진다 (화면이 "읽을 수 없음" 과 이유를 보여 줌)
|
||||
return [
|
||||
for (final e in await vList(dir))
|
||||
if (showHidden || !vBasename(e.path).startsWith('.'))
|
||||
FileEntry(e.path, isDir: e.isDir, size: e.size, modified: e.modified),
|
||||
];
|
||||
}
|
||||
final out = <FileEntry>[];
|
||||
// E10: 폴더를 읽지 못하면 (권한 없음 · 빠진 USB 등) 빈 폴더로 보이지 않게 던진다 (화면이 "읽을 수 없습니다: 이유" 와 [다시 시도]).
|
||||
// 일부를 읽은 뒤의 오류는 읽은 것까지 보여 준다
|
||||
Object? failed;
|
||||
await for (final e in Directory(dir).list(followLinks: false).handleError((Object err) => failed ??= err)) {
|
||||
try {
|
||||
final st = await e.stat();
|
||||
final isDir = st.type == FileSystemEntityType.directory;
|
||||
final entry = FileEntry(e.path, isDir: isDir, size: isDir ? 0 : st.size, modified: st.modified);
|
||||
if (!showHidden && entry.hidden) continue;
|
||||
out.add(entry);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (failed != null && out.isEmpty) throw failed!;
|
||||
return out;
|
||||
}
|
||||
|
||||
/// [root] 아래에서 이름에 [query] 가 들어간 항목 찾기 (대소문자 무시, * ? 사용 가능). 찾는 대로 내보낸다.
|
||||
/// [limit] 개에서 멈춘다. 읽지 못한 폴더는 [onSkipped] 로 알린다 (24: 조용히 빠뜨리지 않게).
|
||||
/// 79: [accept] 가 있으면 그 조건 (크기 · 바뀐 날 · 종류) 에도 맞는 것만. 이때는 [query] 를 비워도 된다.
|
||||
Stream<FileEntry> searchFiles(String root, String query,
|
||||
{bool showHidden = false,
|
||||
int limit = 2000,
|
||||
void Function(String dir, Object error)? onSkipped,
|
||||
bool Function(FileEntry e)? accept}) async* {
|
||||
final q = query.trim().toLowerCase();
|
||||
// 79: 이름 없이 조건만 ("1GB 넘는 30일 지난 영상") 으로도 찾는다
|
||||
if (q.isEmpty && accept == null) return;
|
||||
final wild = q.contains('*') || q.contains('?');
|
||||
final re = wild
|
||||
? RegExp('^${RegExp.escape(q).replaceAll(r'\*', '.*').replaceAll(r'\?', '.')}\$')
|
||||
: null;
|
||||
var n = 0;
|
||||
final pending = <String>[root];
|
||||
while (pending.isNotEmpty) {
|
||||
final dir = pending.removeAt(0);
|
||||
List<FileEntry> items;
|
||||
try {
|
||||
// strict: 읽지 못하면 빈 폴더로 치지 않고 알린다 (24)
|
||||
items = [
|
||||
for (final e in await vList(dir, strict: true))
|
||||
if (showHidden || !vBasename(e.path).startsWith('.'))
|
||||
FileEntry(e.path, isDir: e.isDir, size: e.size, modified: e.modified),
|
||||
];
|
||||
} catch (e) {
|
||||
onSkipped?.call(dir, e);
|
||||
continue;
|
||||
}
|
||||
for (final e in sortEntries(items, SortBy.name)) {
|
||||
final name = e.name.toLowerCase();
|
||||
if ((re != null ? re.hasMatch(name) : name.contains(q)) && (accept == null || accept(e))) {
|
||||
yield e;
|
||||
if (++n >= limit) return;
|
||||
}
|
||||
if (e.isDir) pending.add(e.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "1.2GB" · "350MB" · "12KB"
|
||||
String formatSize(int bytes) {
|
||||
// 203: 크기를 모름 (잠긴 시스템 파일 · 읽지 못함 → -1) 은 "-1B" 가 아니라 "—"
|
||||
if (bytes < 0) return '—';
|
||||
const k = 1024;
|
||||
if (bytes >= k * k * k) return '${(bytes / (k * k * k)).toStringAsFixed(1)}GB';
|
||||
if (bytes >= k * k) return '${(bytes / (k * k)).toStringAsFixed(bytes < 10 * k * k ? 1 : 0)}MB';
|
||||
if (bytes >= k) return '${(bytes / k).round()}KB';
|
||||
return '${bytes}B';
|
||||
}
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../l10n/tr.dart';
|
||||
import '../vfs.dart';
|
||||
import 'path_rules.dart';
|
||||
import 'transfer_control.dart';
|
||||
import 'transfer_guard.dart';
|
||||
|
||||
// 약속 (나누기 file_ops): 로컬 (같은 기기의 폴더 · 네트워크 공유) 끼리의 복사 · 이동 · 맞추기 · 지우기 · 크기.
|
||||
// 입력: 설정 · 상태 [TransferControl], 원본 경로들 · 받는 폴더. 출력: 만든 경로들 · 복사한 파일 수.
|
||||
// 오류: 취소 → [FileOpCancelled] (만들다 만 파일은 지움) · 폴더를 자기 안으로 → [FileSystemException] ·
|
||||
// 맞추기에서 원본 폴더를 읽지 못함 → [SourceUnreadableException] (그 폴더에서는 아무것도 지우지 않음). 그 밖의 파일 오류는 그대로.
|
||||
// WebDAV 등이 섞이면 any_transfer 를 쓴다 (고르는 것은 [FileOps]).
|
||||
|
||||
/// 전체 크기 (진행률용)
|
||||
Future<int> localTotalSize(List<String> paths) async {
|
||||
var total = 0;
|
||||
for (final s in paths) {
|
||||
final t = FileSystemEntity.typeSync(s, followLinks: false);
|
||||
if (t == FileSystemEntityType.directory) {
|
||||
await for (final e in Directory(s).list(recursive: true, followLinks: false).handleError((_) {})) {
|
||||
if (e is File) {
|
||||
try {
|
||||
total += await e.length();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
} else if (t == FileSystemEntityType.file) {
|
||||
total += await File(s).length();
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// [sources] 를 [destDir] 로 복사 (같은 이름이 있으면 [TransferControl.conflict] 대로). 만든 경로들을 돌려준다.
|
||||
Future<List<String>> localCopy(TransferControl c, List<String> sources, String destDir, {CopyProgress? onProgress}) async {
|
||||
final total = await localTotalSize(sources);
|
||||
var done = 0;
|
||||
final made = <String>[];
|
||||
// 43: 임시 이름 (.jjpart) 에 다 쓴 뒤 이름을 바꾼다 - 복사 도중 앱이 끝나도 반쯤 쓴 파일이 완성된 이름으로 남지 않게
|
||||
Future<void> copyFile(File f, String finalTarget) async {
|
||||
c.check();
|
||||
final target = '$finalTarget.jjpart';
|
||||
final out = File(target).openWrite();
|
||||
try {
|
||||
try {
|
||||
await for (final chunk in f.openRead()) {
|
||||
c.check();
|
||||
out.add(chunk);
|
||||
done += chunk.length;
|
||||
onProgress?.call(f.path, done, total);
|
||||
c.onBytes?.call(chunk.length);
|
||||
await c.throttle(chunk.length);
|
||||
}
|
||||
} finally {
|
||||
await out.close();
|
||||
}
|
||||
c.check();
|
||||
} catch (_) {
|
||||
// 취소 · 실패: 만들다 만 파일을 남기지 않는다
|
||||
try {
|
||||
await File(target).delete();
|
||||
} catch (_) {}
|
||||
rethrow;
|
||||
}
|
||||
// 덮어쓰기: 새 파일을 다 쓴 뒤에야 옛 파일을 지운다 (도중에 실패하면 옛 파일이 남게)
|
||||
if (File(finalTarget).existsSync()) await File(finalTarget).delete();
|
||||
await File(target).rename(finalTarget);
|
||||
try {
|
||||
await File(finalTarget).setLastModified(await f.lastModified());
|
||||
} catch (_) {}
|
||||
c.onFileDone?.call(f.path);
|
||||
}
|
||||
|
||||
Future<void> copyAny(String src, String target, {bool inner = false}) async {
|
||||
if (FileSystemEntity.isDirectorySync(src)) {
|
||||
await Directory(target).create(recursive: true);
|
||||
await for (final e in Directory(src).list(followLinks: false)) {
|
||||
await copyAny(e.path, p.join(target, p.basename(e.path)), inner: true);
|
||||
}
|
||||
} else {
|
||||
// 229: 합치는 폴더 안에서 받는 쪽이 더 새것이면 예전 것으로 덮지 않는다
|
||||
if (inner && c.skipNewerInMerge && await isNewerThere(src, target)) {
|
||||
c.keptNewer.add(src);
|
||||
return;
|
||||
}
|
||||
await copyFile(File(src), target);
|
||||
}
|
||||
}
|
||||
|
||||
for (final s in sources) {
|
||||
if (FileSystemEntity.isDirectorySync(s) && isSameOrInside(destDir, s)) {
|
||||
throw FileSystemException(tr('폴더를 자기 안으로 복사할 수 없습니다'), s);
|
||||
}
|
||||
final target = await c.targetFor(s, destDir);
|
||||
if (target == null) continue;
|
||||
await copyAny(s, target);
|
||||
made.add(target);
|
||||
}
|
||||
return made;
|
||||
}
|
||||
|
||||
/// 229: [dir] 안에서 [keep] 의 파일만 남기고 지운다 (비게 된 폴더도). [dir] 자체는 남는 것이 있으면 둔다
|
||||
Future<void> _deleteExcept(String dir, Set<String> keep) async {
|
||||
Future<bool> walk(Directory d) async {
|
||||
var empty = true;
|
||||
await for (final e in d.list(followLinks: false)) {
|
||||
if (e is Directory) {
|
||||
if (await walk(e)) {
|
||||
await e.delete();
|
||||
} else {
|
||||
empty = false;
|
||||
}
|
||||
} else if (keep.contains(p.normalize(e.path))) {
|
||||
empty = false;
|
||||
} else {
|
||||
await e.delete();
|
||||
}
|
||||
}
|
||||
return empty;
|
||||
}
|
||||
|
||||
if (await walk(Directory(dir))) await Directory(dir).delete();
|
||||
}
|
||||
|
||||
/// 229: [target] 이 있고 [src] 보다 2초 넘게 새것인지
|
||||
Future<bool> isNewerThere(String src, String target) async {
|
||||
final t = File(target);
|
||||
if (!await t.exists()) return false;
|
||||
return (await t.lastModified()).isAfter((await File(src).lastModified()).add(const Duration(seconds: 2)));
|
||||
}
|
||||
|
||||
/// 229: 같은 이름 창 - 폴더를 합치면 안쪽에서 받는 쪽이 더 새 파일 수 ([limit] 개까지 셈, 로컬만)
|
||||
Future<int> countNewerInside(String src, String dst, {int limit = 5000}) async {
|
||||
var n = 0, seen = 0;
|
||||
Future<void> walk(String s, String d) async {
|
||||
await for (final e in Directory(s).list(followLinks: false).handleError((_) {})) {
|
||||
if (seen++ > limit) return;
|
||||
final t = p.join(d, p.basename(e.path));
|
||||
if (e is Directory) {
|
||||
if (FileSystemEntity.isDirectorySync(t)) await walk(e.path, t);
|
||||
} else if (await isNewerThere(e.path, t)) {
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (FileSystemEntity.isDirectorySync(src) && FileSystemEntity.isDirectorySync(dst)) await walk(src, dst);
|
||||
return n;
|
||||
}
|
||||
|
||||
/// 이동: 같은 드라이브면 이름 바꾸기, 아니면 복사한 뒤 원본 삭제
|
||||
Future<List<String>> localMove(TransferControl c, List<String> sources, String destDir, {CopyProgress? onProgress}) async {
|
||||
final made = <String>[];
|
||||
for (final s in sources) {
|
||||
c.check();
|
||||
if (isSameOrInside(destDir, s)) throw FileSystemException(tr('폴더를 자기 안으로 옮길 수 없습니다'), s);
|
||||
if (samePath(p.dirname(s), destDir)) {
|
||||
made.add(s); // 이미 그 폴더에 있음
|
||||
continue;
|
||||
}
|
||||
final target = await c.targetFor(s, destDir);
|
||||
if (target == null) continue;
|
||||
final exists = FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound;
|
||||
if (exists && FileSystemEntity.isDirectorySync(s)) {
|
||||
// 덮어쓰기로 폴더를 합친다: 복사 (안의 같은 이름은 덮어씀) 뒤 원본 삭제
|
||||
final keptBefore = c.keptNewer.length;
|
||||
made.addAll(await localCopy(c, [s], destDir, onProgress: onProgress));
|
||||
c.check();
|
||||
final kept = c.keptNewer.sublist(keptBefore);
|
||||
if (kept.isEmpty) {
|
||||
await deletePaths(c, [s]);
|
||||
} else {
|
||||
// 229: 건너뛴 (받는 쪽이 더 새) 원본은 남기고 옮긴 것만 지운다
|
||||
await _deleteExcept(s, kept.map(p.normalize).toSet());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
final t = FileSystemEntity.typeSync(s);
|
||||
// 덮어쓰기 (파일): 이름 바꾸기가 옛 파일을 바로 바꿔 놓는다 (다른 드라이브면 아래 복사가 덮어씀)
|
||||
if (t == FileSystemEntityType.directory) {
|
||||
await Directory(s).rename(target);
|
||||
} else {
|
||||
await File(s).rename(target);
|
||||
}
|
||||
made.add(target);
|
||||
} on FileSystemException {
|
||||
// 다른 드라이브 · 저장 장치: 복사 후 삭제
|
||||
final copied = await localCopy(c, [s], destDir, onProgress: onProgress);
|
||||
await deletePaths(c, [s]);
|
||||
made.addAll(copied);
|
||||
}
|
||||
}
|
||||
return made;
|
||||
}
|
||||
|
||||
/// 동기화 (실시간 동기화 · lsyncd 처럼): [srcDir] 의 내용을 [dstDir] 에 맞춘다 (로컬끼리).
|
||||
/// 크기나 바뀐 시각이 다른 파일만 복사, [delete] 면 원본에 없는 것을 대상에서 지운다. 복사한 파일 수를 돌려준다.
|
||||
/// [update] (rsync -u): 대상이 더 새 파일이면 건너뛴다. 지우기 전 원본 확인 (ensureSourceForDelete) 은 부르는 쪽이 한다.
|
||||
Future<int> localMirror(TransferControl c, String srcDir, String dstDir, {bool delete = false, bool update = false}) async {
|
||||
var n = 0;
|
||||
Future<void> walk(String src, String dst) async {
|
||||
c.check();
|
||||
await Directory(dst).create(recursive: true);
|
||||
final names = <String>{};
|
||||
// 원본 목록을 다 읽은 뒤에 맞춘다: 읽다 실패하면 (그 폴더에서) 아무것도 지우지 않고 멈춘다
|
||||
List<FileSystemEntity> items;
|
||||
try {
|
||||
items = await Directory(src).list(followLinks: false).toList();
|
||||
} catch (e) {
|
||||
throw SourceUnreadableException(src, cause: e);
|
||||
}
|
||||
for (final e in items) {
|
||||
final name = p.basename(e.path);
|
||||
if (isAppTempName(name)) continue; // 78 보충: 이름 바꾸는 중의 임시 이름은 맞추지 않는다
|
||||
names.add(name);
|
||||
final target = p.join(dst, name);
|
||||
if (e is Directory) {
|
||||
await walk(e.path, target);
|
||||
} else if (e is File) {
|
||||
final st = await e.stat();
|
||||
final t = File(target);
|
||||
final exists = await t.exists();
|
||||
final same = exists &&
|
||||
(await t.length()) == st.size &&
|
||||
((await t.lastModified()).difference(st.modified).inSeconds.abs() <= 2);
|
||||
if (same) {
|
||||
c.onSettled?.call(e.path, st.size, st.modified);
|
||||
continue;
|
||||
}
|
||||
if (update && exists && (await t.lastModified()).isAfter(st.modified.add(const Duration(seconds: 2)))) continue;
|
||||
final tmp = '$target.jjsync';
|
||||
final out = File(tmp).openWrite();
|
||||
try {
|
||||
await for (final chunk in e.openRead()) {
|
||||
c.check();
|
||||
out.add(chunk);
|
||||
c.onBytes?.call(chunk.length);
|
||||
await c.throttle(chunk.length);
|
||||
}
|
||||
} finally {
|
||||
await out.close();
|
||||
}
|
||||
await File(tmp).rename(target);
|
||||
try {
|
||||
await File(target).setLastModified(st.modified);
|
||||
} catch (_) {}
|
||||
n++;
|
||||
c.onFileDone?.call(e.path);
|
||||
c.onSettled?.call(e.path, st.size, st.modified);
|
||||
}
|
||||
}
|
||||
if (delete) {
|
||||
await for (final e in Directory(dst).list(followLinks: false).handleError((_) {})) {
|
||||
if (!names.contains(p.basename(e.path)) && !e.path.endsWith('.jjsync') && !isAppTempName(p.basename(e.path))) {
|
||||
await e.delete(recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(srcDir, dstDir);
|
||||
return n;
|
||||
}
|
||||
|
||||
/// 지우기 (로컬 · WebDAV). 없는 것은 그냥 지나간다. 항목마다 취소를 본다
|
||||
Future<void> deletePaths(TransferControl c, List<String> paths) async {
|
||||
for (final s in paths) {
|
||||
c.check();
|
||||
if (isDav(s)) {
|
||||
await vDelete(s);
|
||||
continue;
|
||||
}
|
||||
final t = FileSystemEntity.typeSync(s, followLinks: false);
|
||||
if (t == FileSystemEntityType.directory) {
|
||||
await Directory(s).delete(recursive: true);
|
||||
} else if (t != FileSystemEntityType.notFound) {
|
||||
await File(s).delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../l10n/tr.dart';
|
||||
import '../vfs.dart';
|
||||
|
||||
// 약속 (나누기 file_ops): 이름 규칙과 이름 일 - 쓸 수 없는 이름 · 이름 바꾸기 (같은 폴더 안) · 새 폴더.
|
||||
// 입력: 경로 · 새 이름 (앞뒤 빈칸은 뺌). 출력: 문제 글 (괜찮으면 null) · 새 경로.
|
||||
// 오류: 쓸 수 없는 이름 · 같은 이름이 이미 있음 → [FileSystemException] (그 글은 화면에 그대로 보인다). 저장소 오류는 그대로.
|
||||
|
||||
/// 78 보충 (관리자 판단): 이름으로 쓸 수 없는 까닭 (괜찮으면 null).
|
||||
/// 모든 곳: 빈 이름 · . · .. · \ / : * ? " < > | · 제어 문자.
|
||||
/// [windowsRules] (Windows 의 로컬 경로): CON · PRN · AUX · NUL · COM1~9 · LPT1~9 (확장자가 붙어도), 끝이 점 · 빈칸, 255자 넘음
|
||||
String? nameProblem(String name, {required bool windowsRules}) {
|
||||
if (name.isEmpty || name == '.' || name == '..' || name.contains(RegExp(r'[\\/:*?"<>|\x00-\x1f]'))) return tr('쓸 수 없는 이름');
|
||||
if (!windowsRules) return null;
|
||||
final base = name.split('.').first.trim().toUpperCase();
|
||||
if (RegExp(r'^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$').hasMatch(base)) return trf('Windows 가 쓰는 이름 ({0})', [base]);
|
||||
if (name.endsWith('.') || name.endsWith(' ')) return tr('이름 끝에 점 · 빈칸은 쓸 수 없음');
|
||||
if (name.length > 255) return tr('이름이 너무 김 (255자 넘음)');
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 이 경로에 Windows 이름 규칙을 쓰는지 (Windows 의 로컬 · 네트워크 공유. WebDAV 는 서버 규칙이라 빼고)
|
||||
bool usesWindowsNames(String path) => Platform.isWindows && !isDav(path);
|
||||
|
||||
/// 이름 바꾸기 (같은 폴더 안). 새 경로를 돌려준다.
|
||||
Future<String> renameInPlace(String path, String newName) async {
|
||||
final name = newName.trim();
|
||||
final bad = nameProblem(name, windowsRules: usesWindowsNames(path));
|
||||
if (bad != null) throw FileSystemException(bad, name);
|
||||
if (isDav(path)) {
|
||||
final target = vJoin(vDirname(path), name);
|
||||
if (target == vNorm(path)) return target;
|
||||
if (await vExists(target)) throw FileSystemException(tr('같은 이름이 이미 있습니다'), target);
|
||||
await vRename(path, target);
|
||||
return target;
|
||||
}
|
||||
final target = p.join(p.dirname(path), name);
|
||||
if (target == path) return path;
|
||||
if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound &&
|
||||
(target.toLowerCase() != path.toLowerCase() || _otherCaseExists(path, name))) {
|
||||
throw FileSystemException(tr('같은 이름이 이미 있습니다'), target);
|
||||
}
|
||||
final t = FileSystemEntity.typeSync(path);
|
||||
return t == FileSystemEntityType.directory
|
||||
? (await Directory(path).rename(target)).path
|
||||
: (await File(path).rename(target)).path;
|
||||
}
|
||||
|
||||
/// 데이터 검토 (10/18): 대소문자만 바꾸는 이름 (a.jpg → A.jpg) 인데 대소문자를 가리는 저장소 (Android 의 앱 폴더 · ext4 · 일부 NAS) 에
|
||||
/// 그 이름의 다른 파일이 따로 있으면 true - rename 이 그 파일을 말없이 덮지 않게 (가리지 않는 곳이면 목록에 정확히 그 이름이 없다)
|
||||
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; // 모르면 막는다 (덮는 것보다 낫다)
|
||||
}
|
||||
}
|
||||
|
||||
/// [dir] 안에 새 폴더 [name]. 만든 경로를 돌려준다.
|
||||
Future<String> createFolder(String dir, String name) async {
|
||||
final n = name.trim();
|
||||
final bad = nameProblem(n, windowsRules: usesWindowsNames(dir));
|
||||
if (bad != null) throw FileSystemException(bad, n);
|
||||
if (isDav(dir)) {
|
||||
final target = vJoin(dir, n);
|
||||
if (await vExists(target)) throw FileSystemException(tr('같은 이름이 이미 있습니다'), target);
|
||||
await vMkdirs(target);
|
||||
return target;
|
||||
}
|
||||
final target = p.join(dir, n);
|
||||
if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound) {
|
||||
throw FileSystemException(tr('같은 이름이 이미 있습니다'), target);
|
||||
}
|
||||
return (await Directory(target).create()).path;
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../l10n/tr.dart';
|
||||
import '../vfs.dart';
|
||||
|
||||
// 약속 (나누기 file_ops): 경로 규칙 - 같은 경로 · 안에 있음 · 바깥 것만 · 짧은 표시 · 겹치지 않는 이름 · 전송 전 확인 · 앱 임시 이름.
|
||||
// 입력: 경로 글 (로컬 · WebDAV). 출력: 참/거짓 · 경로 · 알릴 글 (문제 없으면 null).
|
||||
// 오류: 던지지 않는다. [uniqueTarget] · [transferProblem] 만 로컬 파일 시스템을 본다 (없으면 없는 대로).
|
||||
|
||||
/// 48: 받는 폴더에 같은 이름이 있을 때
|
||||
enum NameConflict {
|
||||
/// "이름 (2).확장자" 로 (예전 동작)
|
||||
rename,
|
||||
|
||||
/// 덮어쓰기 (폴더는 합치고 안의 같은 이름 파일을 덮어씀)
|
||||
overwrite,
|
||||
|
||||
/// 건너뛰기 (그 항목은 복사 · 이동하지 않음)
|
||||
skip,
|
||||
}
|
||||
|
||||
/// 같은 이름이 있으면 "이름 (2).확장자" 로
|
||||
String uniqueTarget(String dir, String name) {
|
||||
var target = p.join(dir, name);
|
||||
if (FileSystemEntity.typeSync(target) == FileSystemEntityType.notFound) return target;
|
||||
final base = p.basenameWithoutExtension(name), ext = p.extension(name);
|
||||
for (var i = 2;; i++) {
|
||||
target = p.join(dir, '$base ($i)$ext');
|
||||
if (FileSystemEntity.typeSync(target) == FileSystemEntityType.notFound) return target;
|
||||
}
|
||||
}
|
||||
|
||||
/// 같은 경로인지 (Windows 는 대소문자 무시)
|
||||
bool samePath(String a, String b) {
|
||||
final dav = davSame(a, b);
|
||||
if (dav != null) return dav;
|
||||
final x = p.normalize(p.absolute(a)), y = p.normalize(p.absolute(b));
|
||||
return Platform.isWindows ? x.toLowerCase() == y.toLowerCase() : x == y;
|
||||
}
|
||||
|
||||
/// 79 · 87: 찾기 · 분석 결과 줄의 폴더 - 시작한 폴더 이름부터의 상대 경로 ("Videos\Show\S1").
|
||||
/// 단계가 [keep] + 1 보다 많으면 가운데를 줄인다 ("Videos\…\S1\E01"). 시작한 폴더 밖이면 전체 경로.
|
||||
/// (15 캡처: 폭 412 에서 절대 경로의 앞부분만 보여 구별에 필요한 아래 폴더가 잘림)
|
||||
String shortUnder(String root, String dir, {int keep = 2}) {
|
||||
final parts = <String>[];
|
||||
var x = dir;
|
||||
for (var i = 0; i < 256 && !samePath(x, root); i++) {
|
||||
final up = vDirname(x);
|
||||
if (up == x) return vDisplay(dir);
|
||||
parts.insert(0, vBasename(x));
|
||||
x = up;
|
||||
}
|
||||
if (!samePath(x, root)) return vDisplay(dir);
|
||||
// 드라이브 · 서버 맨 위는 이름이 없어 그대로 (C: · ☁ 서버)
|
||||
final top = vDirname(root) == root;
|
||||
parts.insert(0, top ? (isDav(root) ? vDisplay(root) : root.replaceAll(RegExp(r'[\\/]+$'), '')) : vBasename(root));
|
||||
final sep = isDav(dir) ? '/' : p.separator;
|
||||
if (parts.length > keep + 1) return [parts.first, '…', ...parts.sublist(parts.length - keep)].join(sep);
|
||||
return parts.join(sep);
|
||||
}
|
||||
|
||||
/// [a] 가 [b] 와 같거나 그 안에 있는지 (폴더를 자기 안으로 복사 · 이동하지 않게)
|
||||
bool isSameOrInside(String a, String b) {
|
||||
final dav = davInside(a, b);
|
||||
if (dav != null) return dav;
|
||||
final x = p.normalize(p.absolute(a)), y = p.normalize(p.absolute(b));
|
||||
return samePath(a, b) || p.isWithin(y, x) || (Platform.isWindows && p.isWithin(y.toLowerCase(), x.toLowerCase()));
|
||||
}
|
||||
|
||||
/// 79-c: 고른 것 중 다른 고른 폴더 안에 있는 것 · 같은 것을 빼고 바깥 것만 (차례는 그대로).
|
||||
/// 폴더와 그 안의 파일을 함께 고르면 안의 파일을 두 번 처리하거나 (이동 뒤 "없음" 오류) 확인 창 개수가 부풀었다
|
||||
List<String> outermostPaths(Iterable<String> paths) {
|
||||
final all = paths.toList();
|
||||
final out = <String>[];
|
||||
for (var i = 0; i < all.length; i++) {
|
||||
final x = all[i];
|
||||
if (out.any((o) => samePath(o, x))) continue;
|
||||
if (all.any((o) => !samePath(o, x) && isSameOrInside(x, o))) continue;
|
||||
out.add(x);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// 복사 · 이동을 시작하기 전 확인 (모든 방법 공통 - rsync · robocopy 는 스스로 막지 않는다).
|
||||
/// 문제가 있으면 알릴 글, 없으면 null.
|
||||
/// - 원본이 없음 (다른 곳에서 지워졌거나 옮겨짐) · 대상 폴더가 없음
|
||||
/// - 폴더를 자기 자신 (또는 그 안) 으로 복사 · 이동
|
||||
/// - 이미 그 폴더에 있는 것을 같은 폴더로 이동
|
||||
/// [contents]: 폴더 "안의 것" 을 옮긴다 (Rsync 화면 · 원본/ → 대상/) - 바로 위 폴더로 옮기는 것도 된다 (205)
|
||||
String? transferProblem(List<String> sources, String dest, {required bool move, bool contents = false}) {
|
||||
// WebDAV 경로는 네트워크라 여기서 있는지 확인하지 않는다 (없으면 복사할 때 알림)
|
||||
final missing = [for (final s in sources) if (vMissingSync(s)) s];
|
||||
if (missing.isNotEmpty) {
|
||||
return trf('원본이 없습니다 (다른 곳에서 지워졌거나 옮겨졌습니다): {0}', [missing.map(vBasename).join(', ')]);
|
||||
}
|
||||
if (!isDav(dest) && !Directory(dest).existsSync()) return trf('대상 폴더가 없습니다: {0}', [dest]);
|
||||
for (final s in sources) {
|
||||
if ((isDav(s) || FileSystemEntity.isDirectorySync(s)) && isSameOrInside(dest, s)) {
|
||||
return trf('폴더를 자기 자신 안으로 {0} 수 없습니다: {1}', [move ? tr('옮길') : tr('복사할'), vBasename(s)]);
|
||||
}
|
||||
// 폴더째 옮길 때만: 이미 그 폴더 안에 있으면 옮길 것이 없다.
|
||||
// 205: 안의 것을 바로 위 폴더로 올리는 이동 (Down\Movie/ → Down) 은 막지 않는다 (예전에는 조용히 시작하지 않았다)
|
||||
if (move && !contents && samePath(vDirname(s), dest)) return trf('이미 이 폴더에 있습니다: {0}', [vBasename(s)]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 복사 · 동기화가 다 쓴 뒤 이름을 바꾸기 전의 임시 파일 (끊기면 남는다 - 71)
|
||||
bool isPartialFile(String name) => name.endsWith('.jjpart') || name.endsWith('.jjsync');
|
||||
|
||||
/// 78 보충 (관리자 판단): 일괄 이름 바꾸기가 잠깐 쓰는 임시 이름 (~jjren_ · ~jjback_) - 실시간 동기화 · 맞추기 · rsync 에 섞이지 않게
|
||||
bool isAppTempName(String name) => name.startsWith('~jjren_') || name.startsWith('~jjback_');
|
||||
|
||||
/// rsync · robocopy 에 넣을 임시 이름 제외 (앱이 만드는 인수)
|
||||
const appTempRsyncExcludes = ['--exclude=~jjren_*', '--exclude=~jjback_*'];
|
||||
const appTempRobocopyExcludes = ['/XF', '~jjren_*', '~jjback_*'];
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import '../vfs.dart';
|
||||
import 'path_rules.dart';
|
||||
import 'transfer_guard.dart' show FileOpCancelled;
|
||||
|
||||
// 약속 (나누기 file_ops): 전송 한 번의 설정과 상태 - 취소 · 속도 제한 · 알림 · 같은 이름 처리 · 건너뛴 것.
|
||||
// 로컬 전송 (local_transfer) 과 어느 저장소든 전송 (any_transfer) 이 함께 쓴다. [FileOps] 가 이것을 잇는다.
|
||||
// 입력: 생성자의 설정. 출력: [targetFor] 의 받는 경로 (null = 건너뜀) · 쌓인 목록 ([skipped] · [dirConflicts] · [keptNewer]).
|
||||
// 오류: [check] 는 취소됐으면 [FileOpCancelled]. 저장소 오류는 그대로.
|
||||
|
||||
/// 복사 · 이동 중 알림 (지금 파일, 지금까지 바이트, 전체 바이트)
|
||||
typedef CopyProgress = void Function(String current, int done, int total);
|
||||
|
||||
class TransferControl {
|
||||
TransferControl({
|
||||
this.bandwidthKBps = 0,
|
||||
this.onFileDone,
|
||||
this.onBytes,
|
||||
this.conflict = NameConflict.rename,
|
||||
this.onSettled,
|
||||
this.skipNewerInMerge = false,
|
||||
});
|
||||
|
||||
bool _cancel = false;
|
||||
|
||||
/// 다음 조각에서 멈춘다
|
||||
void cancel() => _cancel = true;
|
||||
|
||||
/// 멈추라고 했는지
|
||||
bool get cancelled => _cancel;
|
||||
|
||||
/// 속도 제한 (KB/s, 0 = 제한 없음)
|
||||
final int bandwidthKBps;
|
||||
|
||||
/// 파일 하나를 다 복사할 때마다 (원본 경로)
|
||||
final void Function(String source)? onFileDone;
|
||||
|
||||
/// 조각을 보낼 때마다 (바이트) - 전송 속도 계산용
|
||||
final void Function(int bytes)? onBytes;
|
||||
|
||||
/// 48: 같은 이름이 있을 때 (맨 위 항목 기준 - 덮어쓰기면 폴더 안의 같은 이름 파일도 덮어씀)
|
||||
final NameConflict conflict;
|
||||
|
||||
/// 건너뛴 항목 (원본 경로)
|
||||
final skipped = <String>[];
|
||||
|
||||
/// M1 (관리자 P0, 10/19): 원격 맞추기에서 원본의 파일과 같은 이름의 **폴더** 가 받는 쪽에 있어 건너뛴 것.
|
||||
/// --delete 가 없으면 그 폴더를 지우지 않는다 (rsync 도 --delete · --force 없이는 비어 있지 않은 폴더를 지우지 않음)
|
||||
final dirConflicts = <String>[];
|
||||
|
||||
/// 229: 폴더를 [덮어쓰기] (합치기) 할 때 안쪽에서 받는 쪽이 더 새 파일은 건너뛴다 (환경 설정 "받는 쪽이 더 새것이면 건너뛰기").
|
||||
/// 맨 위 파일 하나를 사용자가 [덮어쓰기] 로 고른 것은 따른다 (창에 "(있는 것이 더 새것)" 이 보이므로)
|
||||
final bool skipNewerInMerge;
|
||||
|
||||
/// 229: 위 규칙으로 건너뛴 원본 파일 (이동이어도 원본에 남는다)
|
||||
final keptNewer = <String>[];
|
||||
|
||||
/// 214: mirror 가 원본 파일 하나를 대상에 맞췄을 때 (지금 복사했거나, 엄격히 같다고 확인함) - 그 원본의 크기 · 바뀐 때.
|
||||
/// 이동은 이것으로 알려 온 파일만 원본에서 지운다 (건너뛴 것 · 도중에 생긴 것은 남긴다)
|
||||
final void Function(String source, int size, DateTime modified)? onSettled;
|
||||
|
||||
/// [conflict] 에 따라 [destDir] 안의 대상 경로 (null = 건너뜀).
|
||||
/// 같은 자리 (같은 폴더로 복사) 이거나 파일 ↔ 폴더처럼 종류가 다르면 덮어쓰지 않고 새 이름으로.
|
||||
Future<String?> targetFor(String src, String destDir) async {
|
||||
final name = vBasename(src);
|
||||
final plain = vJoin(destDir, name);
|
||||
if (conflict == NameConflict.rename || !await vExists(plain)) return vUniqueTarget(destDir, name);
|
||||
if (samePath(src, plain)) return vUniqueTarget(destDir, name);
|
||||
if (conflict == NameConflict.skip) {
|
||||
skipped.add(src);
|
||||
return null;
|
||||
}
|
||||
if (await vIsDir(src) != await vIsDir(plain)) return vUniqueTarget(destDir, name);
|
||||
return plain;
|
||||
}
|
||||
|
||||
final _clock = Stopwatch();
|
||||
int _sent = 0;
|
||||
|
||||
/// 속도 제한: 보낸 양이 허용량보다 앞서면 그만큼 쉰다
|
||||
Future<void> throttle(int bytes) async {
|
||||
if (bandwidthKBps <= 0) return;
|
||||
if (!_clock.isRunning) _clock.start();
|
||||
_sent += bytes;
|
||||
final wantMs = _sent * 1000 ~/ (bandwidthKBps * 1024);
|
||||
final ahead = wantMs - _clock.elapsedMilliseconds;
|
||||
if (ahead > 5) await Future<void>.delayed(Duration(milliseconds: ahead));
|
||||
}
|
||||
|
||||
/// 취소됐으면 [FileOpCancelled]
|
||||
void check() {
|
||||
if (_cancel) throw const FileOpCancelled();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import 'dart:io';
|
||||
|
||||
import '../../l10n/tr.dart';
|
||||
import '../vfs.dart';
|
||||
import '../webdav.dart' show DavException, DavLockedException;
|
||||
|
||||
// 약속 (나누기 file_ops): 전송의 안전장치 - 취소 표시 · "원본을 읽을 수 없음" · 지우기 전 원본 확인 · 빈 폴더 정리.
|
||||
// 입력: 원본 · 대상 폴더 경로 (로컬 · WebDAV). 출력: 없음 / 지운 폴더 수.
|
||||
// 오류: [ensureSourceForDelete] 는 원본을 읽지 못하거나 원본이 비었는데 대상에 파일이 있으면 [SourceUnreadableException]
|
||||
// (그때는 아무것도 지우지 않게). [removeEmptyDirs] 는 지우지 못한 폴더를 남기고 던지지 않는다.
|
||||
|
||||
/// 사용자가 복사 · 이동 · 맞추기를 멈췄다
|
||||
class FileOpCancelled implements Exception {
|
||||
const FileOpCancelled();
|
||||
}
|
||||
|
||||
/// 동기화 · 미러: 원본을 읽지 못했거나 (권한 꺼짐 · SD 카드 빠짐 · 네트워크 끊김) 원본이 통째로 비었는데 대상에는 파일이 있다.
|
||||
/// "원본에 없는 것을 대상에서 지우기" 가 원본을 빈 것으로 보고 대상을 모두 지우지 않도록, 아무것도 지우지 않고 멈춘다.
|
||||
class SourceUnreadableException implements Exception {
|
||||
final String path;
|
||||
final Object? cause;
|
||||
|
||||
/// 읽기는 됐지만 원본이 비어 있음 (대상에는 파일이 있음)
|
||||
final bool empty;
|
||||
const SourceUnreadableException(this.path, {this.cause, this.empty = false});
|
||||
|
||||
/// 128: 마스터 비밀번호를 기다리는 중 (읽지 못한 것이 아님)
|
||||
bool get locked => cause is DavLockedException;
|
||||
|
||||
/// 원인을 짧게 (예외 원문 대신 - 68)
|
||||
String get detail {
|
||||
final c = cause;
|
||||
if (c == null) return '';
|
||||
if (c is FileSystemException) {
|
||||
if (c.message == '없음' && c.osError == null) return tr('폴더가 없습니다');
|
||||
return c.osError?.message.trim().isNotEmpty == true ? c.osError!.message.trim() : c.message;
|
||||
}
|
||||
if (c is DavException) return c.message;
|
||||
return '$c'.split('\n').first;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => empty
|
||||
? trf('원본 폴더가 비어 있어 멈췄습니다 (대상 파일은 지우지 않음). SD 카드 · 네트워크 · 권한을 확인하세요: {0}', [path])
|
||||
: trf('원본 폴더를 읽을 수 없어 멈췄습니다 (대상 파일은 지우지 않음): {0}{1}', [path, detail.isEmpty ? '' : ' · $detail']);
|
||||
}
|
||||
|
||||
/// "지우기 포함" 으로 맞추기 전에: 원본을 읽을 수 있는지, 원본이 통째로 비었는데 대상에 파일이 있지 않은지.
|
||||
/// 문제가 있으면 [SourceUnreadableException] (아무것도 지우지 않게).
|
||||
/// [allowEmpty]: 사용자가 지울 목록을 보고 "원본이 비어도 그래도 맞추기" 를 고른 경우 (70) - 읽지 못하는 것은 그래도 막는다
|
||||
Future<void> ensureSourceForDelete(String src, String dst, {bool allowEmpty = false}) async {
|
||||
List<({String path, bool isDir, int size, DateTime modified})> items;
|
||||
try {
|
||||
if (!await vExists(src)) throw const FileSystemException('없음');
|
||||
items = await vList(src, strict: true);
|
||||
} catch (e) {
|
||||
throw SourceUnreadableException(src, cause: e);
|
||||
}
|
||||
if (items.isNotEmpty || allowEmpty) return;
|
||||
var dstItems = const <({String path, bool isDir, int size, DateTime modified})>[];
|
||||
try {
|
||||
dstItems = await vList(dst);
|
||||
} catch (_) {}
|
||||
if (dstItems.isNotEmpty) throw SourceUnreadableException(src, empty: true);
|
||||
}
|
||||
|
||||
/// [root] 안의 빈 폴더를 안쪽부터 지운다 (find root/ -type d -empty -delete 와 같음). 지운 폴더 수.
|
||||
/// [keepRoot] 면 [root] 자신은 비어도 남긴다. 파일 · 링크가 하나라도 있는 폴더는 그대로.
|
||||
Future<int> removeEmptyDirs(String root, {bool keepRoot = true}) async {
|
||||
if (isDav(root)) return _removeEmptyDirsV(root, keepRoot: keepRoot);
|
||||
var n = 0;
|
||||
Future<bool> walk(Directory d) async {
|
||||
var empty = true;
|
||||
final items = await d.list(followLinks: false).handleError((_) => empty = false).toList();
|
||||
for (final e in items) {
|
||||
if (e is Directory && await walk(e)) {
|
||||
try {
|
||||
await e.delete();
|
||||
n++;
|
||||
} catch (_) {
|
||||
empty = false;
|
||||
}
|
||||
} else {
|
||||
empty = false;
|
||||
}
|
||||
}
|
||||
return empty;
|
||||
}
|
||||
|
||||
final d = Directory(root);
|
||||
if (!await d.exists()) return 0;
|
||||
if (await walk(d) && !keepRoot) {
|
||||
try {
|
||||
await d.delete();
|
||||
n++;
|
||||
} catch (_) {}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/// [removeEmptyDirs] 의 WebDAV 판
|
||||
Future<int> _removeEmptyDirsV(String root, {bool keepRoot = true}) async {
|
||||
var n = 0;
|
||||
Future<bool> walk(String dir) async {
|
||||
var empty = true;
|
||||
for (final e in await vList(dir)) {
|
||||
if (e.isDir && await walk(e.path)) {
|
||||
try {
|
||||
await vDelete(e.path);
|
||||
n++;
|
||||
} catch (_) {
|
||||
empty = false;
|
||||
}
|
||||
} else {
|
||||
empty = false;
|
||||
}
|
||||
}
|
||||
return empty;
|
||||
}
|
||||
|
||||
if (!await vExists(root)) return 0;
|
||||
if (await walk(root) && !keepRoot) {
|
||||
try {
|
||||
await vDelete(root);
|
||||
n++;
|
||||
} catch (_) {}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
|
||||
// 약속 (나누기 file_ops): Windows 저장 장치 - 드라이브 목록 · 드라이브 종류 · 네트워크 공유의 맨 위.
|
||||
// 입력: 드라이브 맨 위 ("C:\") · 경로. 출력: 목록 · 종류 번호 · 글.
|
||||
// 오류: 던지지 않는다. Windows API 를 부르지 못하면 [windowsDrives] 는 C: 하나, [driveType] 은 0 (모름).
|
||||
// 드라이브를 열어 보지 않는다 (끊긴 네트워크 드라이브에서 멈추지 않게).
|
||||
|
||||
/// 저장 장치 (맨 위 폴더): Windows 는 드라이브 (C:\ …), 그 밖은 주어진 목록.
|
||||
/// 165: 드라이브 문자는 Windows 가 알려 주는 목록 (GetLogicalDrives) 으로 - 드라이브마다 열어 보지 않는다
|
||||
/// (끊긴 · 느린 네트워크 드라이브 (NAS) 를 열어 보느라 탐색기가 10초씩 멈췄다)
|
||||
List<(String path, String label)> windowsDrives() {
|
||||
int mask;
|
||||
try {
|
||||
mask = DynamicLibrary.open('kernel32.dll').lookupFunction<Uint32 Function(), int Function()>('GetLogicalDrives')();
|
||||
} catch (_) {
|
||||
mask = 0;
|
||||
}
|
||||
if (mask == 0) return [(r'C:\', 'C:')];
|
||||
return [
|
||||
for (final c in 'CDEFGHIJKLMNOPQRSTUVWXYZAB'.split(''))
|
||||
if (mask & (1 << (c.codeUnitAt(0) - 65)) != 0) ('$c:\\', '$c:'),
|
||||
];
|
||||
}
|
||||
|
||||
/// 165: 드라이브 종류 (GetDriveTypeW: 2 꺼내는 장치 (카드 리더 · USB) · 3 고정 · 4 네트워크 · 5 CD). 드라이브를 열어 보지 않는다. 모르면 0
|
||||
int driveType(String root) {
|
||||
if (!Platform.isWindows) return 0;
|
||||
final w = root.toNativeUtf16();
|
||||
try {
|
||||
final f = DynamicLibrary.open('kernel32.dll')
|
||||
.lookupFunction<Uint32 Function(Pointer<Utf16>), int Function(Pointer<Utf16>)>('GetDriveTypeW');
|
||||
return f(w);
|
||||
} catch (_) {
|
||||
return 0;
|
||||
} finally {
|
||||
calloc.free(w);
|
||||
}
|
||||
}
|
||||
|
||||
/// 네트워크 드라이브인지
|
||||
bool isNetworkDrive(String root) => driveType(root) == 4;
|
||||
|
||||
/// Windows 네트워크 공유 경로의 맨 위 (\\서버\공유). 37
|
||||
String uncRoot(String path) {
|
||||
final parts = path.replaceAll('/', r'\').split(r'\').where((s) => s.isNotEmpty).toList();
|
||||
return parts.length < 2 ? path : '\\\\${parts[0]}\\${parts[1]}';
|
||||
}
|
||||
|
|
@ -1,691 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart' show sha256;
|
||||
|
||||
import '../l10n/tr.dart';
|
||||
import 'dns_resolver.dart';
|
||||
import 'remote_fs.dart';
|
||||
import 'secret_gate.dart';
|
||||
import 'webdav.dart' show DavException, DavItem, DavLockedException;
|
||||
import 'remote_idle.dart';
|
||||
|
||||
/// FTP · FTPS 서버 설정 (관리자 지시 10/17, l). 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음).
|
||||
/// [security]: 'explicit' (FTPS - AUTH TLS, 처음 값) · 'implicit' (FTPS 990) · 'none' (평문 FTP - 연결 · 설정에서 "암호화 안 됨" 경고).
|
||||
/// 자체 서명 인증서는 처음 연결 때 사용자가 믿은 지문 ([certFingerprint], SHA-256) 만 받는다 (TOFU - SFTP 호스트 키와 같은 판단).
|
||||
class FtpServer {
|
||||
const FtpServer({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.host,
|
||||
this.port = 21,
|
||||
this.user = '',
|
||||
this.root = '',
|
||||
this.password = '',
|
||||
this.security = 'explicit',
|
||||
this.certFingerprint = '',
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String host;
|
||||
final int port;
|
||||
final String user;
|
||||
|
||||
/// 서버의 시작 폴더 (비우면 로그인한 곳)
|
||||
final String root;
|
||||
|
||||
/// 안전 저장소에서 채움 (설정 파일에는 없음)
|
||||
final String password;
|
||||
final String security;
|
||||
|
||||
/// 믿은 자체 서명 인증서의 지문 "SHA256:xx:xx…" (비밀 아님)
|
||||
final String certFingerprint;
|
||||
|
||||
bool get tls => security != 'none';
|
||||
String get label => name.trim().isNotEmpty ? name.trim() : (host.trim().isEmpty ? 'FTP' : host.trim());
|
||||
|
||||
FtpServer copyWith({String? name, String? host, int? port, String? user, String? root, String? password, String? security, String? certFingerprint}) =>
|
||||
FtpServer(
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
host: host ?? this.host,
|
||||
port: port ?? this.port,
|
||||
user: user ?? this.user,
|
||||
root: root ?? this.root,
|
||||
password: password ?? this.password,
|
||||
security: security ?? this.security,
|
||||
certFingerprint: certFingerprint ?? this.certFingerprint,
|
||||
);
|
||||
|
||||
/// 비밀 값은 넣지 않는다 (안전 저장소). 저장해 두었는지만
|
||||
Map<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'host': host,
|
||||
'port': port,
|
||||
'user': user,
|
||||
'root': root,
|
||||
'hasPassword': password.isNotEmpty,
|
||||
'security': security,
|
||||
'certFingerprint': certFingerprint,
|
||||
};
|
||||
|
||||
factory FtpServer.fromJson(Map<Object?, Object?> j) => FtpServer(
|
||||
id: '${j['id'] ?? DateTime.now().microsecondsSinceEpoch}',
|
||||
name: j['name'] as String? ?? '',
|
||||
host: j['host'] as String? ?? '',
|
||||
port: switch (j['port']) { final int p when p > 0 && p < 65536 => p, _ => j['security'] == 'implicit' ? 990 : 21 },
|
||||
user: j['user'] as String? ?? '',
|
||||
root: j['root'] as String? ?? '',
|
||||
// 모르는 값 · 없는 값은 FTPS (지침 3 - 덜 위험한 쪽, 관리자 지시 "처음 값은 FTPS 우선")
|
||||
security: const ['explicit', 'implicit', 'none'].contains(j['security']) ? j['security'] as String : 'explicit',
|
||||
certFingerprint: j['certFingerprint'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// FTP 실패의 종류 (안내 글 · 할 일 단추를 고르는 데)
|
||||
enum FtpFailure { unreachable, auth, noSecret, certUnknown, certChanged, noTls, permission, notFound, other }
|
||||
|
||||
class FtpException extends DavException {
|
||||
final FtpFailure kind;
|
||||
const FtpException(super.message, this.kind);
|
||||
}
|
||||
|
||||
/// 처음 보는 (또는 바뀐) 자체 서명 인증서: 화면이 지문을 보이고 믿을지 묻는다. 믿으면 [FtpServer.certFingerprint] 에 저장
|
||||
class FtpCertException extends FtpException {
|
||||
final String fingerprint;
|
||||
final String subject;
|
||||
|
||||
/// 바뀐 경우 예전에 믿은 지문
|
||||
final String? previous;
|
||||
const FtpCertException(super.message, super.kind, {required this.fingerprint, required this.subject, this.previous});
|
||||
bool get changed => previous != null;
|
||||
}
|
||||
|
||||
/// 인증서 지문 "SHA256:AB:CD:…"
|
||||
String certSha256(X509Certificate c) =>
|
||||
'SHA256:${sha256.convert(c.der).bytes.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(':')}';
|
||||
|
||||
/// 이름 → 연결할 주소 (DNS 강제면 정한 DNS - main 에서 바꾼다). 처음 값: 기기 DNS (주소를 그대로 넘김)
|
||||
typedef FtpResolve = Future<InternetAddress?> Function(String host);
|
||||
|
||||
/// FTP 명령 채널 하나 (로그인까지). 응답은 줄 단위 ("123-…" 여러 줄 → "123 …" 끝)
|
||||
class _Session {
|
||||
final FtpServer server;
|
||||
final Duration timeout;
|
||||
final InternetAddress addr;
|
||||
RawSocket _raw;
|
||||
StreamSubscription<RawSocketEvent>? _sub;
|
||||
final _buf = <int>[];
|
||||
final _lines = <String>[];
|
||||
Completer<void>? _wake;
|
||||
bool _closed = false;
|
||||
bool secure = false;
|
||||
bool mlsd = false;
|
||||
bool _protP = false;
|
||||
|
||||
/// 로그인 뒤 서버의 지금 폴더 (시작 폴더가 비었을 때 맨 위)
|
||||
String home = '/';
|
||||
|
||||
_Session._(this.server, this.timeout, this.addr, this._raw);
|
||||
|
||||
static Future<_Session> open(FtpServer s, Duration timeout, FtpResolve resolve) async {
|
||||
final addr = await resolve(s.host.trim()) ?? (await InternetAddress.lookup(s.host.trim())).first;
|
||||
final raw = await RawSocket.connect(addr, s.port, timeout: timeout);
|
||||
final x = _Session._(s, timeout, addr, raw);
|
||||
if (s.security == 'implicit') {
|
||||
x._raw = await x._secure(raw, null);
|
||||
x.secure = true;
|
||||
}
|
||||
x._listen();
|
||||
return x;
|
||||
}
|
||||
|
||||
void _listen() {
|
||||
_sub = _raw.listen((ev) {
|
||||
if (ev == RawSocketEvent.read) {
|
||||
final d = _raw.read();
|
||||
if (d != null) _feed(d);
|
||||
} else if (ev == RawSocketEvent.readClosed || ev == RawSocketEvent.closed) {
|
||||
_closed = true;
|
||||
_poke();
|
||||
}
|
||||
}, onError: (_) {
|
||||
_closed = true;
|
||||
_poke();
|
||||
});
|
||||
}
|
||||
|
||||
void _feed(Uint8List d) {
|
||||
_buf.addAll(d);
|
||||
while (true) {
|
||||
final i = _buf.indexOf(10);
|
||||
if (i < 0) break;
|
||||
var line = utf8.decode(_buf.sublist(0, i), allowMalformed: true);
|
||||
if (line.endsWith('\r')) line = line.substring(0, line.length - 1);
|
||||
_buf.removeRange(0, i + 1);
|
||||
debugLog?.call('< $line');
|
||||
_lines.add(line);
|
||||
}
|
||||
_poke();
|
||||
}
|
||||
|
||||
void _poke() {
|
||||
final w = _wake;
|
||||
_wake = null;
|
||||
w?.complete();
|
||||
}
|
||||
|
||||
Future<String> _line() async {
|
||||
final end = DateTime.now().add(timeout);
|
||||
while (_lines.isEmpty) {
|
||||
if (_closed) throw FtpException(trf('서버 ({0}) 와의 연결이 끊겼습니다', [server.host]), FtpFailure.unreachable);
|
||||
final left = end.difference(DateTime.now());
|
||||
if (left <= Duration.zero) throw TimeoutException(tr('답이 없습니다'));
|
||||
_wake = Completer<void>();
|
||||
await _wake!.future.timeout(left, onTimeout: () {});
|
||||
}
|
||||
return _lines.removeAt(0);
|
||||
}
|
||||
|
||||
/// 응답 하나 (여러 줄이면 합침): (코드, 글)
|
||||
Future<(int, String)> reply() async {
|
||||
final first = await _line();
|
||||
final code = int.tryParse(first.length >= 3 ? first.substring(0, 3) : '') ?? 0;
|
||||
final text = StringBuffer(first.length > 4 ? first.substring(4) : '');
|
||||
if (first.length > 3 && first[3] == '-') {
|
||||
while (true) {
|
||||
final l = await _line();
|
||||
if (l.startsWith('$code ')) {
|
||||
text.write('\n${l.substring(4)}');
|
||||
break;
|
||||
}
|
||||
text.write('\n${l.startsWith(' ') ? l.substring(1) : l}');
|
||||
}
|
||||
}
|
||||
return (code, text.toString());
|
||||
}
|
||||
|
||||
/// 시험: 명령 채널 주고받기 (비밀번호 줄은 가림)
|
||||
static void Function(String line)? debugLog;
|
||||
|
||||
void send(String cmd) {
|
||||
// 보안 검토 8: 경로 · 이름에 줄바꿈이 있으면 명령이 하나 더 끼어든다 - 보내지 않는다
|
||||
if (cmd.contains('\r') || cmd.contains('\n') || cmd.contains('\u0000')) {
|
||||
throw FtpException(tr('이름에 줄바꿈이 들어 있어 FTP 로 보낼 수 없습니다'), FtpFailure.other);
|
||||
}
|
||||
debugLog?.call('> ${cmd.startsWith('PASS ') ? 'PASS ***' : cmd}');
|
||||
_raw.write(utf8.encode('$cmd\r\n'));
|
||||
}
|
||||
|
||||
Future<(int, String)> cmd(String c) async {
|
||||
send(c);
|
||||
return reply();
|
||||
}
|
||||
|
||||
/// [c] 를 보내고 [ok] 코드 (2xx 등) 가 아니면 [FtpException]
|
||||
Future<String> must(String c, {bool Function(int code)? ok, String? path}) async {
|
||||
final (code, text) = await cmd(c);
|
||||
if (ok != null ? ok(code) : code >= 200 && code < 300) return text;
|
||||
throw failure(code, text, path);
|
||||
}
|
||||
|
||||
FtpException failure(int code, String text, String? path) {
|
||||
if (code == 530) return FtpException(tr('아이디나 비밀번호가 맞지 않습니다'), FtpFailure.auth);
|
||||
if (code == 550 || code == 450) {
|
||||
final t = text.toLowerCase();
|
||||
if (t.contains('permission') || t.contains('denied') || t.contains('access')) {
|
||||
return FtpException(trf('이 폴더 · 파일을 다룰 권한이 없습니다: {0}', [path ?? '']), FtpFailure.permission);
|
||||
}
|
||||
return FtpException(trf('없는 경로입니다: {0}', [path ?? '']), FtpFailure.notFound);
|
||||
}
|
||||
return FtpException('$code $text${path == null ? '' : ' ($path)'}', FtpFailure.other);
|
||||
}
|
||||
|
||||
/// TLS 로 바꾼다 (명시: AUTH TLS 뒤 · 암시: 연결하자마자). 자체 서명이면 믿은 지문만
|
||||
Future<RawSecureSocket> _secure(RawSocket raw, StreamSubscription<RawSocketEvent>? sub) async {
|
||||
X509Certificate? bad;
|
||||
try {
|
||||
return await RawSecureSocket.secure(raw, subscription: sub, host: server.host.trim(), onBadCertificate: (c) {
|
||||
bad = c;
|
||||
return server.certFingerprint.isNotEmpty && certSha256(c) == server.certFingerprint;
|
||||
}).timeout(timeout);
|
||||
} on HandshakeException {
|
||||
final c = bad;
|
||||
if (c == null) rethrow;
|
||||
final fp = certSha256(c);
|
||||
throw FtpCertException(
|
||||
server.certFingerprint.isEmpty
|
||||
? tr('처음 보는 서버 인증서입니다 (자체 서명). 지문을 확인한 뒤 믿을지 정하세요')
|
||||
: tr('서버 인증서가 바뀌었습니다. 다른 서버로 연결되었을 수 있습니다'),
|
||||
server.certFingerprint.isEmpty ? FtpFailure.certUnknown : FtpFailure.certChanged,
|
||||
fingerprint: fp,
|
||||
subject: c.subject,
|
||||
previous: server.certFingerprint.isEmpty ? null : server.certFingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
/// 인사 · TLS · 로그인 · UTF-8 · 이진 · 시작 폴더
|
||||
Future<void> login() async {
|
||||
final (hello, text) = await reply();
|
||||
if (hello != 220) throw failure(hello, text, null);
|
||||
if (server.security == 'explicit') {
|
||||
final (code, t) = await cmd('AUTH TLS');
|
||||
if (code != 234) {
|
||||
// FTPS 를 못 하는 서버: 몰래 평문으로 내려가지 않는다 (사용자가 "평문 FTP" 를 골라야)
|
||||
throw FtpException(trf('서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 "평문 FTP" 를 고르세요', [code, t]),
|
||||
FtpFailure.noTls);
|
||||
}
|
||||
// 보안 검토 2: 234 뒤에 평문으로 끼워 넣은 줄이 있으면 (중간자가 TLS 뒤의 답을 꾸미려는 것) 끊는다
|
||||
if (_lines.isNotEmpty || _buf.isNotEmpty) {
|
||||
throw FtpException(tr('서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다'), FtpFailure.noTls);
|
||||
}
|
||||
final sub = _sub;
|
||||
_sub = null;
|
||||
_raw = await _secure(_raw, sub);
|
||||
secure = true;
|
||||
// RawSecureSocket.secure 가 넘겨받은 구독을 이어 쓰지 않으므로 다시 듣는다
|
||||
_listen();
|
||||
}
|
||||
if (secure) {
|
||||
await must('PBSZ 0');
|
||||
await must('PROT P');
|
||||
_protP = true;
|
||||
}
|
||||
if (server.user.isNotEmpty || server.password.isNotEmpty) {
|
||||
final (code, t) = await cmd('USER ${server.user.isEmpty ? 'anonymous' : server.user}');
|
||||
if (code == 331 || code == 332) {
|
||||
await must('PASS ${server.password}', ok: (c) => c == 230 || c == 202);
|
||||
} else if (code != 230) {
|
||||
throw failure(code, t, null);
|
||||
}
|
||||
} else {
|
||||
await must('USER anonymous', ok: (c) => c == 230 || c == 331);
|
||||
}
|
||||
final feat = await cmd('FEAT');
|
||||
// MLST 를 알리면 MLSD 도 한다 (RFC 3659 - pyftpdlib 등은 FEAT 에 MLST 만 적는다)
|
||||
mlsd = feat.$1 == 211 && RegExp(r'^\s*MLS[TD]', multiLine: true).hasMatch(feat.$2.toUpperCase());
|
||||
if (feat.$2.toUpperCase().contains('UTF8')) await cmd('OPTS UTF8 ON');
|
||||
await must('TYPE I');
|
||||
final pwd = await cmd('PWD');
|
||||
final m = RegExp(r'"((?:[^"]|"")*)"').firstMatch(pwd.$2);
|
||||
if (pwd.$1 == 257 && m != null) home = m[1]!.replaceAll('""', '"');
|
||||
}
|
||||
|
||||
/// 자료 채널 (EPSV, 안 되면 PASV). 주소는 명령 채널의 서버 주소로 (서버가 알려 준 다른 주소로는 가지 않는다 - FTP 바운스 막기)
|
||||
Future<Socket> data() async {
|
||||
int? port;
|
||||
final (c1, t1) = await cmd('EPSV');
|
||||
if (c1 == 229) {
|
||||
final m = RegExp(r'\(\|\|\|(\d+)\|\)').firstMatch(t1);
|
||||
if (m != null) port = int.parse(m[1]!);
|
||||
}
|
||||
if (port == null) {
|
||||
final t = await must('PASV', ok: (c) => c == 227);
|
||||
final m = RegExp(r'(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)').firstMatch(t);
|
||||
if (m == null) throw FtpException(trf('서버의 자료 연결 응답을 읽지 못했습니다: {0}', [t]), FtpFailure.other);
|
||||
port = int.parse(m[5]!) * 256 + int.parse(m[6]!);
|
||||
}
|
||||
return Socket.connect(addr, port, timeout: timeout);
|
||||
}
|
||||
|
||||
/// 자료 연결을 TLS 로 (PROT P). 명령 (RETR · STOR · MLSD) 을 보내고 150/125 를 받은 뒤에 한다 - 서버는 명령을 받고서야 TLS 를 시작한다 (pyftpdlib 등)
|
||||
Future<Socket> secureData(Socket sock) async {
|
||||
if (!_protP) return sock;
|
||||
final x = await SecureSocket.secure(sock,
|
||||
host: server.host.trim(),
|
||||
onBadCertificate: (c) => server.certFingerprint.isNotEmpty && certSha256(c) == server.certFingerprint).timeout(timeout);
|
||||
return x;
|
||||
}
|
||||
|
||||
void close() {
|
||||
if (_closed) return;
|
||||
_closed = true;
|
||||
try {
|
||||
send('QUIT');
|
||||
} catch (_) {}
|
||||
_sub?.cancel();
|
||||
_raw.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// FTP · FTPS 클라이언트 (dart:io 로 직접 - 관리자 지시 l, 패키지 없이). 목록 · 정보 · 만들기 · 지우기 · 옮기기는 서버마다 명령 채널 하나를
|
||||
/// 열어 두고 차례로 쓰고, 받기 · 올리기는 따로 연결해 (동영상 건너뛰기처럼 여럿이 동시에 읽어도 서로 막지 않게) 끝나면 닫는다.
|
||||
class FtpRemote implements RemoteClient {
|
||||
final FtpServer server;
|
||||
final Duration timeout;
|
||||
FtpRemote(this.server, {this.timeout = const Duration(seconds: 15)});
|
||||
|
||||
Future<_Session>? _main;
|
||||
Future<void> _lock = Future.value();
|
||||
|
||||
Future<_Session> _connect() async {
|
||||
final s = server;
|
||||
if (s.host.trim().isEmpty) throw FtpException(tr('FTP 서버 주소가 없습니다'), FtpFailure.other);
|
||||
if (s.password.isNotEmpty && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage));
|
||||
if (s.user.isNotEmpty && s.password.isEmpty) {
|
||||
throw FtpException(tr('저장된 비밀번호가 없습니다. 서버 설정에서 비밀번호를 넣어 주세요'), FtpFailure.noSecret);
|
||||
}
|
||||
_Session? x;
|
||||
try {
|
||||
x = await _Session.open(s, timeout, FtpRegistry.resolve);
|
||||
await x.login();
|
||||
return x;
|
||||
} catch (e) {
|
||||
x?.close();
|
||||
throw _explain(e);
|
||||
}
|
||||
}
|
||||
|
||||
Object _explain(Object e) {
|
||||
if (e is FtpException || e is DavLockedException || e is RemoteIdleTimeout) return e;
|
||||
if (e is TimeoutException) return FtpException(trf('서버 ({0}) 가 답하지 않습니다', [server.host]), FtpFailure.unreachable);
|
||||
if (e is SocketException) return FtpException(trf('서버 ({0}) 에 연결할 수 없습니다: {1}', [server.host, e.message]), FtpFailure.unreachable);
|
||||
if (e is HandshakeException) return FtpException(trf('암호화 (TLS) 연결을 맺지 못했습니다: {0}', [e.message]), FtpFailure.other);
|
||||
return FtpException('$e', FtpFailure.other);
|
||||
}
|
||||
|
||||
/// 명령 채널을 차례로 쓴다 (한 번에 하나). 끊겼으면 한 번 다시 연결
|
||||
Future<T> _run<T>(Future<T> Function(_Session s) f) {
|
||||
final prev = _lock;
|
||||
final done = Completer<void>();
|
||||
_lock = done.future;
|
||||
return prev.then((_) async {
|
||||
try {
|
||||
for (var attempt = 0;; attempt++) {
|
||||
final _Session s;
|
||||
try {
|
||||
s = await (_main ??= _connect());
|
||||
} catch (_) {
|
||||
_main = null;
|
||||
rethrow;
|
||||
}
|
||||
try {
|
||||
return await f(s);
|
||||
} on FtpException catch (e) {
|
||||
if (e.kind == FtpFailure.unreachable && attempt == 0) {
|
||||
s.close();
|
||||
_main = null;
|
||||
continue;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is! FtpException && e is! DavLockedException) {
|
||||
_main = null;
|
||||
throw _explain(e);
|
||||
}
|
||||
rethrow;
|
||||
} finally {
|
||||
done.complete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 서버 기준 경로 ("/" 로 시작) → 서버의 절대 경로
|
||||
String _abs(_Session s, String rel) {
|
||||
var base = server.root.trim().isEmpty ? s.home : server.root.trim();
|
||||
if (!base.startsWith('/')) base = '/$base';
|
||||
if (base.length > 1 && base.endsWith('/')) base = base.substring(0, base.length - 1);
|
||||
final r = rel == '/' || rel.isEmpty ? '' : rel;
|
||||
if (base == '/') return r.isEmpty ? '/' : r;
|
||||
return '$base$r';
|
||||
}
|
||||
|
||||
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) {
|
||||
final sp = line.indexOf(' ');
|
||||
if (sp < 0) return null;
|
||||
final facts = <String, String>{};
|
||||
for (final f in line.substring(0, sp).split(';')) {
|
||||
final eq = f.indexOf('=');
|
||||
if (eq > 0) facts[f.substring(0, eq).toLowerCase()] = f.substring(eq + 1);
|
||||
}
|
||||
final name = line.substring(sp + 1);
|
||||
final type = (facts['type'] ?? '').toLowerCase();
|
||||
if (type == 'cdir' || type == 'pdir' || name == '.' || name == '..') return null;
|
||||
final m = facts['modify'] ?? '';
|
||||
var when = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
if (m.length >= 14) {
|
||||
when = DateTime.utc(int.parse(m.substring(0, 4)), int.parse(m.substring(4, 6)), int.parse(m.substring(6, 8)),
|
||||
int.parse(m.substring(8, 10)), int.parse(m.substring(10, 12)), int.parse(m.substring(12, 14)));
|
||||
}
|
||||
final isDir = type == 'dir';
|
||||
return (name, isDir, isDir ? 0 : int.tryParse(facts['size'] ?? '') ?? 0, when);
|
||||
}
|
||||
|
||||
static const _months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
||||
|
||||
/// LIST 의 유닉스 꼴 한 줄 "drwxr-xr-x 1 u g 0 Oct 17 12:00 이름" (MLSD 가 없는 서버)
|
||||
static (String, bool, int, DateTime)? parseList(String line) {
|
||||
final m = RegExp(r'^([dl-])[rwxsStT-]{9}\S*\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\w{3})\s+(\d{1,2})\s+(\d{1,2}:\d{2}|\d{4})\s(.+)$').firstMatch(line);
|
||||
if (m == null) return null;
|
||||
var name = m[6]!;
|
||||
if (m[1] == 'l' && name.contains(' -> ')) name = name.substring(0, name.indexOf(' -> '));
|
||||
if (name == '.' || name == '..') return null;
|
||||
final mon = _months.indexOf(m[3]!.toLowerCase()) + 1;
|
||||
final now = DateTime.now();
|
||||
final t = m[5]!;
|
||||
final when = t.contains(':')
|
||||
? DateTime(now.year, mon, int.parse(m[4]!), int.parse(t.split(':')[0]), int.parse(t.split(':')[1]))
|
||||
: DateTime(int.parse(t), mon, int.parse(m[4]!));
|
||||
final isDir = m[1] == 'd';
|
||||
return (name, isDir, isDir ? 0 : int.parse(m[2]!), when);
|
||||
}
|
||||
|
||||
/// 자료 채널로 목록 (MLSD 또는 LIST) 을 읽는다
|
||||
Future<List<(String, bool, int, DateTime)>> _listRaw(_Session s, String abs, String rel) async {
|
||||
final plain = await s.data();
|
||||
// LIST 에 -a 같은 옵션을 붙이면 경로로 읽는 서버가 있다
|
||||
s.send('${s.mlsd ? 'MLSD' : 'LIST'} $abs');
|
||||
final (code, text) = await s.reply();
|
||||
if (code != 150 && code != 125) {
|
||||
plain.destroy();
|
||||
throw s.failure(code, text, rel);
|
||||
}
|
||||
final sock = await s.secureData(plain);
|
||||
final bytes = await sock.fold<List<int>>(<int>[], (a, b) => a..addAll(b));
|
||||
final (end, et) = await s.reply();
|
||||
if (end != 226 && end != 250) throw s.failure(end, et, rel);
|
||||
final out = <(String, bool, int, DateTime)>[];
|
||||
for (final l in const LineSplitter().convert(utf8.decode(bytes, allowMalformed: true))) {
|
||||
final x = s.mlsd ? parseMlsx(l) : parseList(l);
|
||||
if (x != null) out.add(x);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<DavItem>> list(String rel) => _run((s) async => [
|
||||
for (final (n, dir, size, at) in await _listRaw(s, _abs(s, rel), rel)) DavItem(_child(rel, n), isDir: dir, size: size, modified: at),
|
||||
]);
|
||||
|
||||
@override
|
||||
Future<DavItem?> stat(String rel) async {
|
||||
if (rel == '/' || rel.isEmpty) return DavItem('/', isDir: true, modified: DateTime.fromMillisecondsSinceEpoch(0));
|
||||
// 위 폴더의 목록에서 찾는다 (MLST · SIZE 를 모든 서버가 같게 답하지 않아서 - 느리지만 확실하게)
|
||||
try {
|
||||
for (final x in await list(_parent(rel))) {
|
||||
if (x.rel == rel) return x;
|
||||
}
|
||||
} on FtpException catch (e) {
|
||||
if (e.kind == FtpFailure.notFound) return null;
|
||||
rethrow;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String rel) async {
|
||||
final st = await stat(rel);
|
||||
if (st == null) return;
|
||||
if (st.isDir) {
|
||||
for (final x in await list(rel)) {
|
||||
await delete(x.rel);
|
||||
}
|
||||
await _run((s) => s.must('RMD ${_abs(s, rel)}', path: rel));
|
||||
} else {
|
||||
await _run((s) => s.must('DELE ${_abs(s, rel)}', path: rel));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> move(String from, String to, {bool overwrite = false}) async {
|
||||
if (await stat(to) != null) {
|
||||
if (!overwrite) throw FtpException(trf('같은 이름이 이미 있습니다: {0}', [to]), FtpFailure.other);
|
||||
await delete(to);
|
||||
}
|
||||
await _run((s) async {
|
||||
await s.must('RNFR ${_abs(s, from)}', ok: (c) => c == 350, path: from);
|
||||
await s.must('RNTO ${_abs(s, to)}', path: to);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Stream<List<int>>> openRead(String rel) => openReadFrom(rel);
|
||||
|
||||
/// [offset] 부터 [length] 바이트 (null 이면 끝까지) - 따로 연결해 REST 로 (동영상 건너뛰기 · 이어 받기)
|
||||
Future<Stream<List<int>>> openReadFrom(String rel, {int offset = 0, int? length}) async {
|
||||
final s = await _connect();
|
||||
Socket? sock;
|
||||
try {
|
||||
sock = await s.data();
|
||||
if (offset > 0) await s.must('REST $offset', ok: (c) => c == 350, path: rel);
|
||||
s.send('RETR ${_abs(s, rel)}');
|
||||
final (code, text) = await s.reply();
|
||||
if (code != 150 && code != 125) throw s.failure(code, text, rel);
|
||||
sock = await s.secureData(sock);
|
||||
} catch (e) {
|
||||
sock?.destroy();
|
||||
s.close();
|
||||
throw _explain(e);
|
||||
}
|
||||
final data = sock;
|
||||
Stream<List<int>> read() async* {
|
||||
var left = length;
|
||||
try {
|
||||
// 관리자 판단 (10/20): 조각 사이에 정한 시간 동안 아무것도 오지 않으면 실패 (서버가 멈춰도 영원히 기다리지 않게)
|
||||
await for (var chunk in idleRead(data, onTimeout: data.destroy)) {
|
||||
if (left != null) {
|
||||
if (chunk.length >= left) {
|
||||
yield chunk.sublist(0, left);
|
||||
left = 0;
|
||||
break;
|
||||
}
|
||||
left -= chunk.length;
|
||||
}
|
||||
yield chunk;
|
||||
}
|
||||
if (left == null) {
|
||||
final (end, et) = await s.reply();
|
||||
if (end != 226 && end != 250) throw s.failure(end, et, rel);
|
||||
}
|
||||
} finally {
|
||||
data.destroy();
|
||||
s.close(); // 다 읽기 전에 멈추면 이 연결째 닫는다 (ABOR 대신 - 서버마다 답이 달라서)
|
||||
}
|
||||
}
|
||||
|
||||
return read();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(String rel, Stream<List<int>> data, {int? length}) async {
|
||||
final s = await _connect();
|
||||
try {
|
||||
final plain = await s.data();
|
||||
s.send('STOR ${_abs(s, rel)}');
|
||||
final (code, text) = await s.reply();
|
||||
if (code != 150 && code != 125) {
|
||||
plain.destroy();
|
||||
throw s.failure(code, text, rel);
|
||||
}
|
||||
final sock = await s.secureData(plain);
|
||||
// 관리자 판단 (10/20): 서버가 받지 않아 정한 시간 동안 한 조각도 더 보내지 못하면 실패 (연결을 끊음)
|
||||
await idleWrite(data, (x) async {
|
||||
await sock.addStream(x);
|
||||
await sock.flush();
|
||||
}, onTimeout: sock.destroy);
|
||||
// 닫기 (TLS 는 close_notify) 를 보내고, 서버가 답하지 않아도 오래 기다리지 않게 연결째 끊는다
|
||||
// 다 보낸 뒤 닫는다 (TLS 는 close_notify). close() 가 끝나도 암호화된 마지막 자료가 아직 나가지 않았을 수 있어
|
||||
// 연결이 정말 끝날 때 (done) 까지 기다리고, 서버가 답하지 않을 때만 끊는다 (곧바로 destroy 하면 빈 파일이 되었다)
|
||||
await sock.close().timeout(const Duration(seconds: 5), onTimeout: () => sock);
|
||||
await sock.done.timeout(const Duration(seconds: 5), onTimeout: () => sock.destroy()).catchError((_) {});
|
||||
final (end, et) = await s.reply();
|
||||
if (end != 226 && end != 250) throw s.failure(end, et, rel);
|
||||
} catch (e) {
|
||||
throw _explain(e);
|
||||
} finally {
|
||||
s.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// FTP 는 남은 공간을 알려 주는 표준 명령이 없다
|
||||
@override
|
||||
Future<(int?, int?)> quota() async => (null, null);
|
||||
|
||||
@override
|
||||
void close() {
|
||||
final m = _main;
|
||||
_main = null;
|
||||
m?.then((s) => s.close(), onError: (_) {});
|
||||
}
|
||||
}
|
||||
|
||||
/// 설정의 FTP 서버 목록과 클라이언트 (서버마다 하나)
|
||||
class FtpRegistry {
|
||||
static final _clients = <String, FtpRemote>{};
|
||||
static final _servers = <String, FtpServer>{};
|
||||
|
||||
/// 이름 풀기 (main 에서 DNS 강제를 따르게 바꾼다). 처음 값: 기기 DNS (null = InternetAddress.lookup)
|
||||
static FtpResolve resolve = (host) async => InternetAddress.tryParse(host);
|
||||
|
||||
/// 정한 DNS 로 이름을 풀기 (집 안 이름 · 주소는 기기 DNS) - SFTP 와 같게
|
||||
static FtpResolve resolveWithDns(DnsResolver resolver) => (host) async {
|
||||
final ip = InternetAddress.tryParse(host);
|
||||
if (ip != null) return ip;
|
||||
if (!resolver.config().forced || isLocalName(host)) return null;
|
||||
final addrs = await resolver.lookup(host);
|
||||
return addrs.where((a) => a.type == InternetAddressType.IPv4).firstOrNull ?? addrs.firstOrNull;
|
||||
};
|
||||
|
||||
static void configure(List<FtpServer> servers) {
|
||||
final keep = {for (final s in servers) s.id: s};
|
||||
for (final id in _clients.keys.toList()) {
|
||||
final old = _servers[id], now = keep[id];
|
||||
if (now == null || old == null || old.password != now.password || jsonEncode(old.toJson()) != jsonEncode(now.toJson())) {
|
||||
_clients.remove(id)?.close();
|
||||
}
|
||||
}
|
||||
_servers
|
||||
..clear()
|
||||
..addAll(keep);
|
||||
}
|
||||
|
||||
static List<FtpServer> get servers => _servers.values.toList();
|
||||
static FtpServer? server(String id) => _servers[id];
|
||||
|
||||
static FtpRemote client(String id) {
|
||||
final s = _servers[id];
|
||||
if (s == null) throw FtpException(trf('FTP 서버 설정이 없습니다 ({0})', [id]), FtpFailure.other);
|
||||
return _clients.putIfAbsent(id, () => FtpRemote(s));
|
||||
}
|
||||
}
|
||||
|
||||
/// 시험: 명령 채널 기록 (비밀번호 가림)
|
||||
void debugFtpLog(void Function(String line)? f) => _Session.debugLog = f;
|
||||
// 약속 (나누기 ftp): FTP · FTPS (dart:io 로 직접 - 관리자 지시 l, 패키지 없이) - 부르는 곳은 이 파일 하나만 가져온다.
|
||||
// 일은 lib/core/ftp/ 의 모듈이 한다: ftp_server (설정 · 오류 · 지문) · ftp_session (명령 채널 하나) · ftp_remote (원격 저장소 · 목록).
|
||||
export 'ftp/ftp_server.dart';
|
||||
export 'ftp/ftp_session.dart' show debugFtpLog;
|
||||
export 'ftp/ftp_remote.dart';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,352 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../../l10n/tr.dart';
|
||||
import '../dns_resolver.dart';
|
||||
import '../remote_fs.dart';
|
||||
import '../secret_gate.dart';
|
||||
import '../webdav.dart' show DavItem, DavLockedException;
|
||||
import '../remote_idle.dart';
|
||||
import 'ftp_server.dart';
|
||||
import 'ftp_session.dart';
|
||||
|
||||
// 약속 (나누기 ftp): FTP · FTPS 원격 저장소 ([RemoteClient]) 와 서버마다 하나인 클라이언트 목록 ([FtpRegistry]).
|
||||
// 입력: 서버 기준 경로 ("/" 로 시작). 출력: 목록 · 정보 ([DavItem]) · 읽기 흐름 · 쓰기 · 남은 공간 (FTP 는 모름).
|
||||
// 오류: [FtpException] (연결 못함 · 로그인 · 권한 · 없는 경로 · 같은 이름) · [FtpCertException] · 잠금 [DavLockedException] ·
|
||||
// 조각 사이 시간 넘김 [RemoteIdleTimeout]. 명령 채널이 끊기면 한 번 다시 연결한다.
|
||||
|
||||
/// FTP · FTPS 클라이언트 (dart:io 로 직접 - 관리자 지시 l, 패키지 없이). 목록 · 정보 · 만들기 · 지우기 · 옮기기는 서버마다 명령 채널 하나를
|
||||
/// 열어 두고 차례로 쓰고, 받기 · 올리기는 따로 연결해 (동영상 건너뛰기처럼 여럿이 동시에 읽어도 서로 막지 않게) 끝나면 닫는다.
|
||||
class FtpRemote implements RemoteClient {
|
||||
final FtpServer server;
|
||||
final Duration timeout;
|
||||
FtpRemote(this.server, {this.timeout = const Duration(seconds: 15)});
|
||||
|
||||
Future<FtpSession>? _main;
|
||||
Future<void> _lock = Future.value();
|
||||
|
||||
Future<FtpSession> _connect() async {
|
||||
final s = server;
|
||||
if (s.host.trim().isEmpty) throw FtpException(tr('FTP 서버 주소가 없습니다'), FtpFailure.other);
|
||||
if (s.password.isNotEmpty && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage));
|
||||
if (s.user.isNotEmpty && s.password.isEmpty) {
|
||||
throw FtpException(tr('저장된 비밀번호가 없습니다. 서버 설정에서 비밀번호를 넣어 주세요'), FtpFailure.noSecret);
|
||||
}
|
||||
FtpSession? x;
|
||||
try {
|
||||
x = await FtpSession.open(s, timeout, FtpRegistry.resolve);
|
||||
await x.login();
|
||||
return x;
|
||||
} catch (e) {
|
||||
x?.close();
|
||||
throw _explain(e);
|
||||
}
|
||||
}
|
||||
|
||||
Object _explain(Object e) {
|
||||
if (e is FtpException || e is DavLockedException || e is RemoteIdleTimeout) return e;
|
||||
if (e is TimeoutException) return FtpException(trf('서버 ({0}) 가 답하지 않습니다', [server.host]), FtpFailure.unreachable);
|
||||
if (e is SocketException) return FtpException(trf('서버 ({0}) 에 연결할 수 없습니다: {1}', [server.host, e.message]), FtpFailure.unreachable);
|
||||
if (e is HandshakeException) return FtpException(trf('암호화 (TLS) 연결을 맺지 못했습니다: {0}', [e.message]), FtpFailure.other);
|
||||
return FtpException('$e', FtpFailure.other);
|
||||
}
|
||||
|
||||
/// 명령 채널을 차례로 쓴다 (한 번에 하나). 끊겼으면 한 번 다시 연결
|
||||
Future<T> _run<T>(Future<T> Function(FtpSession s) f) {
|
||||
final prev = _lock;
|
||||
final done = Completer<void>();
|
||||
_lock = done.future;
|
||||
return prev.then((_) async {
|
||||
try {
|
||||
for (var attempt = 0;; attempt++) {
|
||||
final FtpSession s;
|
||||
try {
|
||||
s = await (_main ??= _connect());
|
||||
} catch (_) {
|
||||
_main = null;
|
||||
rethrow;
|
||||
}
|
||||
try {
|
||||
return await f(s);
|
||||
} on FtpException catch (e) {
|
||||
if (e.kind == FtpFailure.unreachable && attempt == 0) {
|
||||
s.close();
|
||||
_main = null;
|
||||
continue;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is! FtpException && e is! DavLockedException) {
|
||||
_main = null;
|
||||
throw _explain(e);
|
||||
}
|
||||
rethrow;
|
||||
} finally {
|
||||
done.complete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 서버 기준 경로 ("/" 로 시작) → 서버의 절대 경로
|
||||
String _abs(FtpSession s, String rel) {
|
||||
var base = server.root.trim().isEmpty ? s.home : server.root.trim();
|
||||
if (!base.startsWith('/')) base = '/$base';
|
||||
if (base.length > 1 && base.endsWith('/')) base = base.substring(0, base.length - 1);
|
||||
final r = rel == '/' || rel.isEmpty ? '' : rel;
|
||||
if (base == '/') return r.isEmpty ? '/' : r;
|
||||
return '$base$r';
|
||||
}
|
||||
|
||||
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) {
|
||||
final sp = line.indexOf(' ');
|
||||
if (sp < 0) return null;
|
||||
final facts = <String, String>{};
|
||||
for (final f in line.substring(0, sp).split(';')) {
|
||||
final eq = f.indexOf('=');
|
||||
if (eq > 0) facts[f.substring(0, eq).toLowerCase()] = f.substring(eq + 1);
|
||||
}
|
||||
final name = line.substring(sp + 1);
|
||||
final type = (facts['type'] ?? '').toLowerCase();
|
||||
if (type == 'cdir' || type == 'pdir' || name == '.' || name == '..') return null;
|
||||
final m = facts['modify'] ?? '';
|
||||
var when = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
if (m.length >= 14) {
|
||||
when = DateTime.utc(int.parse(m.substring(0, 4)), int.parse(m.substring(4, 6)), int.parse(m.substring(6, 8)),
|
||||
int.parse(m.substring(8, 10)), int.parse(m.substring(10, 12)), int.parse(m.substring(12, 14)));
|
||||
}
|
||||
final isDir = type == 'dir';
|
||||
return (name, isDir, isDir ? 0 : int.tryParse(facts['size'] ?? '') ?? 0, when);
|
||||
}
|
||||
|
||||
static const _months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
||||
|
||||
/// LIST 의 유닉스 꼴 한 줄 "drwxr-xr-x 1 u g 0 Oct 17 12:00 이름" (MLSD 가 없는 서버)
|
||||
static (String, bool, int, DateTime)? parseList(String line) {
|
||||
final m = RegExp(r'^([dl-])[rwxsStT-]{9}\S*\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\w{3})\s+(\d{1,2})\s+(\d{1,2}:\d{2}|\d{4})\s(.+)$').firstMatch(line);
|
||||
if (m == null) return null;
|
||||
var name = m[6]!;
|
||||
if (m[1] == 'l' && name.contains(' -> ')) name = name.substring(0, name.indexOf(' -> '));
|
||||
if (name == '.' || name == '..') return null;
|
||||
final mon = _months.indexOf(m[3]!.toLowerCase()) + 1;
|
||||
final now = DateTime.now();
|
||||
final t = m[5]!;
|
||||
final when = t.contains(':')
|
||||
? DateTime(now.year, mon, int.parse(m[4]!), int.parse(t.split(':')[0]), int.parse(t.split(':')[1]))
|
||||
: DateTime(int.parse(t), mon, int.parse(m[4]!));
|
||||
final isDir = m[1] == 'd';
|
||||
return (name, isDir, isDir ? 0 : int.parse(m[2]!), when);
|
||||
}
|
||||
|
||||
/// 자료 채널로 목록 (MLSD 또는 LIST) 을 읽는다
|
||||
Future<List<(String, bool, int, DateTime)>> _listRaw(FtpSession s, String abs, String rel) async {
|
||||
final plain = await s.data();
|
||||
// LIST 에 -a 같은 옵션을 붙이면 경로로 읽는 서버가 있다
|
||||
s.send('${s.mlsd ? 'MLSD' : 'LIST'} $abs');
|
||||
final (code, text) = await s.reply();
|
||||
if (code != 150 && code != 125) {
|
||||
plain.destroy();
|
||||
throw s.failure(code, text, rel);
|
||||
}
|
||||
final sock = await s.secureData(plain);
|
||||
final bytes = await sock.fold<List<int>>(<int>[], (a, b) => a..addAll(b));
|
||||
final (end, et) = await s.reply();
|
||||
if (end != 226 && end != 250) throw s.failure(end, et, rel);
|
||||
final out = <(String, bool, int, DateTime)>[];
|
||||
for (final l in const LineSplitter().convert(utf8.decode(bytes, allowMalformed: true))) {
|
||||
final x = s.mlsd ? parseMlsx(l) : parseList(l);
|
||||
if (x != null) out.add(x);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<DavItem>> list(String rel) => _run((s) async => [
|
||||
for (final (n, dir, size, at) in await _listRaw(s, _abs(s, rel), rel)) DavItem(_child(rel, n), isDir: dir, size: size, modified: at),
|
||||
]);
|
||||
|
||||
@override
|
||||
Future<DavItem?> stat(String rel) async {
|
||||
if (rel == '/' || rel.isEmpty) return DavItem('/', isDir: true, modified: DateTime.fromMillisecondsSinceEpoch(0));
|
||||
// 위 폴더의 목록에서 찾는다 (MLST · SIZE 를 모든 서버가 같게 답하지 않아서 - 느리지만 확실하게)
|
||||
try {
|
||||
for (final x in await list(_parent(rel))) {
|
||||
if (x.rel == rel) return x;
|
||||
}
|
||||
} on FtpException catch (e) {
|
||||
if (e.kind == FtpFailure.notFound) return null;
|
||||
rethrow;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String rel) async {
|
||||
final st = await stat(rel);
|
||||
if (st == null) return;
|
||||
if (st.isDir) {
|
||||
for (final x in await list(rel)) {
|
||||
await delete(x.rel);
|
||||
}
|
||||
await _run((s) => s.must('RMD ${_abs(s, rel)}', path: rel));
|
||||
} else {
|
||||
await _run((s) => s.must('DELE ${_abs(s, rel)}', path: rel));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> move(String from, String to, {bool overwrite = false}) async {
|
||||
if (await stat(to) != null) {
|
||||
if (!overwrite) throw FtpException(trf('같은 이름이 이미 있습니다: {0}', [to]), FtpFailure.other);
|
||||
await delete(to);
|
||||
}
|
||||
await _run((s) async {
|
||||
await s.must('RNFR ${_abs(s, from)}', ok: (c) => c == 350, path: from);
|
||||
await s.must('RNTO ${_abs(s, to)}', path: to);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Stream<List<int>>> openRead(String rel) => openReadFrom(rel);
|
||||
|
||||
/// [offset] 부터 [length] 바이트 (null 이면 끝까지) - 따로 연결해 REST 로 (동영상 건너뛰기 · 이어 받기)
|
||||
Future<Stream<List<int>>> openReadFrom(String rel, {int offset = 0, int? length}) async {
|
||||
final s = await _connect();
|
||||
Socket? sock;
|
||||
try {
|
||||
sock = await s.data();
|
||||
if (offset > 0) await s.must('REST $offset', ok: (c) => c == 350, path: rel);
|
||||
s.send('RETR ${_abs(s, rel)}');
|
||||
final (code, text) = await s.reply();
|
||||
if (code != 150 && code != 125) throw s.failure(code, text, rel);
|
||||
sock = await s.secureData(sock);
|
||||
} catch (e) {
|
||||
sock?.destroy();
|
||||
s.close();
|
||||
throw _explain(e);
|
||||
}
|
||||
final data = sock;
|
||||
Stream<List<int>> read() async* {
|
||||
var left = length;
|
||||
try {
|
||||
// 관리자 판단 (10/20): 조각 사이에 정한 시간 동안 아무것도 오지 않으면 실패 (서버가 멈춰도 영원히 기다리지 않게)
|
||||
await for (var chunk in idleRead(data, onTimeout: data.destroy)) {
|
||||
if (left != null) {
|
||||
if (chunk.length >= left) {
|
||||
yield chunk.sublist(0, left);
|
||||
left = 0;
|
||||
break;
|
||||
}
|
||||
left -= chunk.length;
|
||||
}
|
||||
yield chunk;
|
||||
}
|
||||
if (left == null) {
|
||||
final (end, et) = await s.reply();
|
||||
if (end != 226 && end != 250) throw s.failure(end, et, rel);
|
||||
}
|
||||
} finally {
|
||||
data.destroy();
|
||||
s.close(); // 다 읽기 전에 멈추면 이 연결째 닫는다 (ABOR 대신 - 서버마다 답이 달라서)
|
||||
}
|
||||
}
|
||||
|
||||
return read();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(String rel, Stream<List<int>> data, {int? length}) async {
|
||||
final s = await _connect();
|
||||
try {
|
||||
final plain = await s.data();
|
||||
s.send('STOR ${_abs(s, rel)}');
|
||||
final (code, text) = await s.reply();
|
||||
if (code != 150 && code != 125) {
|
||||
plain.destroy();
|
||||
throw s.failure(code, text, rel);
|
||||
}
|
||||
final sock = await s.secureData(plain);
|
||||
// 관리자 판단 (10/20): 서버가 받지 않아 정한 시간 동안 한 조각도 더 보내지 못하면 실패 (연결을 끊음)
|
||||
await idleWrite(data, (x) async {
|
||||
await sock.addStream(x);
|
||||
await sock.flush();
|
||||
}, onTimeout: sock.destroy);
|
||||
// 닫기 (TLS 는 close_notify) 를 보내고, 서버가 답하지 않아도 오래 기다리지 않게 연결째 끊는다
|
||||
// 다 보낸 뒤 닫는다 (TLS 는 close_notify). close() 가 끝나도 암호화된 마지막 자료가 아직 나가지 않았을 수 있어
|
||||
// 연결이 정말 끝날 때 (done) 까지 기다리고, 서버가 답하지 않을 때만 끊는다 (곧바로 destroy 하면 빈 파일이 되었다)
|
||||
await sock.close().timeout(const Duration(seconds: 5), onTimeout: () => sock);
|
||||
await sock.done.timeout(const Duration(seconds: 5), onTimeout: () => sock.destroy()).catchError((_) {});
|
||||
final (end, et) = await s.reply();
|
||||
if (end != 226 && end != 250) throw s.failure(end, et, rel);
|
||||
} catch (e) {
|
||||
throw _explain(e);
|
||||
} finally {
|
||||
s.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// FTP 는 남은 공간을 알려 주는 표준 명령이 없다
|
||||
@override
|
||||
Future<(int?, int?)> quota() async => (null, null);
|
||||
|
||||
@override
|
||||
void close() {
|
||||
final m = _main;
|
||||
_main = null;
|
||||
m?.then((s) => s.close(), onError: (_) {});
|
||||
}
|
||||
}
|
||||
|
||||
/// 설정의 FTP 서버 목록과 클라이언트 (서버마다 하나)
|
||||
class FtpRegistry {
|
||||
static final _clients = <String, FtpRemote>{};
|
||||
static final _servers = <String, FtpServer>{};
|
||||
|
||||
/// 이름 풀기 (main 에서 DNS 강제를 따르게 바꾼다). 처음 값: 기기 DNS (null = InternetAddress.lookup)
|
||||
static FtpResolve resolve = (host) async => InternetAddress.tryParse(host);
|
||||
|
||||
/// 정한 DNS 로 이름을 풀기 (집 안 이름 · 주소는 기기 DNS) - SFTP 와 같게
|
||||
static FtpResolve resolveWithDns(DnsResolver resolver) => (host) async {
|
||||
final ip = InternetAddress.tryParse(host);
|
||||
if (ip != null) return ip;
|
||||
if (!resolver.config().forced || isLocalName(host)) return null;
|
||||
final addrs = await resolver.lookup(host);
|
||||
return addrs.where((a) => a.type == InternetAddressType.IPv4).firstOrNull ?? addrs.firstOrNull;
|
||||
};
|
||||
|
||||
static void configure(List<FtpServer> servers) {
|
||||
final keep = {for (final s in servers) s.id: s};
|
||||
for (final id in _clients.keys.toList()) {
|
||||
final old = _servers[id], now = keep[id];
|
||||
if (now == null || old == null || old.password != now.password || jsonEncode(old.toJson()) != jsonEncode(now.toJson())) {
|
||||
_clients.remove(id)?.close();
|
||||
}
|
||||
}
|
||||
_servers
|
||||
..clear()
|
||||
..addAll(keep);
|
||||
}
|
||||
|
||||
static List<FtpServer> get servers => _servers.values.toList();
|
||||
static FtpServer? server(String id) => _servers[id];
|
||||
|
||||
static FtpRemote client(String id) {
|
||||
final s = _servers[id];
|
||||
if (s == null) throw FtpException(trf('FTP 서버 설정이 없습니다 ({0})', [id]), FtpFailure.other);
|
||||
return _clients.putIfAbsent(id, () => FtpRemote(s));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart' show sha256;
|
||||
|
||||
import '../webdav.dart' show DavException;
|
||||
|
||||
// 약속 (나누기 ftp): FTP · FTPS 서버 설정 (저장 꼴) · 실패 종류 · 오류 · 인증서 지문.
|
||||
// 입력: 설정 JSON (비밀 값 없음) · 인증서. 출력: [FtpServer] · JSON · "SHA256:AB:…" 지문.
|
||||
// 오류: 던지지 않는다 ([FtpServer.fromJson] 은 모르는 값을 처음 값 (FTPS · 21/990) 으로). [FtpException] · [FtpCertException] 은 다른 ftp 모듈이 던진다.
|
||||
|
||||
/// FTP · FTPS 서버 설정 (관리자 지시 10/17, l). 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음).
|
||||
/// [security]: 'explicit' (FTPS - AUTH TLS, 처음 값) · 'implicit' (FTPS 990) · 'none' (평문 FTP - 연결 · 설정에서 "암호화 안 됨" 경고).
|
||||
/// 자체 서명 인증서는 처음 연결 때 사용자가 믿은 지문 ([certFingerprint], SHA-256) 만 받는다 (TOFU - SFTP 호스트 키와 같은 판단).
|
||||
class FtpServer {
|
||||
const FtpServer({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.host,
|
||||
this.port = 21,
|
||||
this.user = '',
|
||||
this.root = '',
|
||||
this.password = '',
|
||||
this.security = 'explicit',
|
||||
this.certFingerprint = '',
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String host;
|
||||
final int port;
|
||||
final String user;
|
||||
|
||||
/// 서버의 시작 폴더 (비우면 로그인한 곳)
|
||||
final String root;
|
||||
|
||||
/// 안전 저장소에서 채움 (설정 파일에는 없음)
|
||||
final String password;
|
||||
final String security;
|
||||
|
||||
/// 믿은 자체 서명 인증서의 지문 "SHA256:xx:xx…" (비밀 아님)
|
||||
final String certFingerprint;
|
||||
|
||||
bool get tls => security != 'none';
|
||||
String get label => name.trim().isNotEmpty ? name.trim() : (host.trim().isEmpty ? 'FTP' : host.trim());
|
||||
|
||||
FtpServer copyWith({String? name, String? host, int? port, String? user, String? root, String? password, String? security, String? certFingerprint}) =>
|
||||
FtpServer(
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
host: host ?? this.host,
|
||||
port: port ?? this.port,
|
||||
user: user ?? this.user,
|
||||
root: root ?? this.root,
|
||||
password: password ?? this.password,
|
||||
security: security ?? this.security,
|
||||
certFingerprint: certFingerprint ?? this.certFingerprint,
|
||||
);
|
||||
|
||||
/// 비밀 값은 넣지 않는다 (안전 저장소). 저장해 두었는지만
|
||||
Map<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'host': host,
|
||||
'port': port,
|
||||
'user': user,
|
||||
'root': root,
|
||||
'hasPassword': password.isNotEmpty,
|
||||
'security': security,
|
||||
'certFingerprint': certFingerprint,
|
||||
};
|
||||
|
||||
factory FtpServer.fromJson(Map<Object?, Object?> j) => FtpServer(
|
||||
id: '${j['id'] ?? DateTime.now().microsecondsSinceEpoch}',
|
||||
name: j['name'] as String? ?? '',
|
||||
host: j['host'] as String? ?? '',
|
||||
port: switch (j['port']) { final int p when p > 0 && p < 65536 => p, _ => j['security'] == 'implicit' ? 990 : 21 },
|
||||
user: j['user'] as String? ?? '',
|
||||
root: j['root'] as String? ?? '',
|
||||
// 모르는 값 · 없는 값은 FTPS (지침 3 - 덜 위험한 쪽, 관리자 지시 "처음 값은 FTPS 우선")
|
||||
security: const ['explicit', 'implicit', 'none'].contains(j['security']) ? j['security'] as String : 'explicit',
|
||||
certFingerprint: j['certFingerprint'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// FTP 실패의 종류 (안내 글 · 할 일 단추를 고르는 데)
|
||||
enum FtpFailure { unreachable, auth, noSecret, certUnknown, certChanged, noTls, permission, notFound, other }
|
||||
|
||||
class FtpException extends DavException {
|
||||
final FtpFailure kind;
|
||||
const FtpException(super.message, this.kind);
|
||||
}
|
||||
|
||||
/// 처음 보는 (또는 바뀐) 자체 서명 인증서: 화면이 지문을 보이고 믿을지 묻는다. 믿으면 [FtpServer.certFingerprint] 에 저장
|
||||
class FtpCertException extends FtpException {
|
||||
final String fingerprint;
|
||||
final String subject;
|
||||
|
||||
/// 바뀐 경우 예전에 믿은 지문
|
||||
final String? previous;
|
||||
const FtpCertException(super.message, super.kind, {required this.fingerprint, required this.subject, this.previous});
|
||||
bool get changed => previous != null;
|
||||
}
|
||||
|
||||
/// 인증서 지문 "SHA256:AB:CD:…"
|
||||
String certSha256(X509Certificate c) =>
|
||||
'SHA256:${sha256.convert(c.der).bytes.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(':')}';
|
||||
|
||||
/// 이름 → 연결할 주소 (DNS 강제면 정한 DNS - main 에서 바꾼다). 처음 값: 기기 DNS (주소를 그대로 넘김)
|
||||
typedef FtpResolve = Future<InternetAddress?> Function(String host);
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../../l10n/tr.dart';
|
||||
import 'ftp_server.dart';
|
||||
|
||||
// 약속 (나누기 ftp): FTP 명령 채널 하나 - 연결 · TLS (명시 · 암시, 믿은 지문만) · 로그인 · 명령 주고받기 · 자료 채널 (EPSV / PASV).
|
||||
// 입력: [FtpServer] · 시간 제한 · 이름 풀기 ([FtpResolve]). 출력: 응답 (코드, 글) · 자료 소켓.
|
||||
// 오류: 끊김 · 로그인 실패 · 권한 · 없는 경로 → [FtpException] (종류 [FtpFailure]) · 처음 보는 / 바뀐 인증서 → [FtpCertException] ·
|
||||
// 답 없음 → [TimeoutException]. 줄바꿈이 든 명령은 보내지 않고 [FtpException].
|
||||
|
||||
/// FTP 명령 채널 하나 (로그인까지). 응답은 줄 단위 ("123-…" 여러 줄 → "123 …" 끝)
|
||||
class FtpSession {
|
||||
final FtpServer server;
|
||||
final Duration timeout;
|
||||
final InternetAddress addr;
|
||||
RawSocket _raw;
|
||||
StreamSubscription<RawSocketEvent>? _sub;
|
||||
final _buf = <int>[];
|
||||
final _lines = <String>[];
|
||||
Completer<void>? _wake;
|
||||
bool _closed = false;
|
||||
bool secure = false;
|
||||
bool mlsd = false;
|
||||
bool _protP = false;
|
||||
|
||||
/// 로그인 뒤 서버의 지금 폴더 (시작 폴더가 비었을 때 맨 위)
|
||||
String home = '/';
|
||||
|
||||
FtpSession._(this.server, this.timeout, this.addr, this._raw);
|
||||
|
||||
static Future<FtpSession> open(FtpServer s, Duration timeout, FtpResolve resolve) async {
|
||||
final addr = await resolve(s.host.trim()) ?? (await InternetAddress.lookup(s.host.trim())).first;
|
||||
final raw = await RawSocket.connect(addr, s.port, timeout: timeout);
|
||||
final x = FtpSession._(s, timeout, addr, raw);
|
||||
if (s.security == 'implicit') {
|
||||
x._raw = await x._secure(raw, null);
|
||||
x.secure = true;
|
||||
}
|
||||
x._listen();
|
||||
return x;
|
||||
}
|
||||
|
||||
void _listen() {
|
||||
_sub = _raw.listen((ev) {
|
||||
if (ev == RawSocketEvent.read) {
|
||||
final d = _raw.read();
|
||||
if (d != null) _feed(d);
|
||||
} else if (ev == RawSocketEvent.readClosed || ev == RawSocketEvent.closed) {
|
||||
_closed = true;
|
||||
_poke();
|
||||
}
|
||||
}, onError: (_) {
|
||||
_closed = true;
|
||||
_poke();
|
||||
});
|
||||
}
|
||||
|
||||
void _feed(Uint8List d) {
|
||||
_buf.addAll(d);
|
||||
while (true) {
|
||||
final i = _buf.indexOf(10);
|
||||
if (i < 0) break;
|
||||
var line = utf8.decode(_buf.sublist(0, i), allowMalformed: true);
|
||||
if (line.endsWith('\r')) line = line.substring(0, line.length - 1);
|
||||
_buf.removeRange(0, i + 1);
|
||||
debugLog?.call('< $line');
|
||||
_lines.add(line);
|
||||
}
|
||||
_poke();
|
||||
}
|
||||
|
||||
void _poke() {
|
||||
final w = _wake;
|
||||
_wake = null;
|
||||
w?.complete();
|
||||
}
|
||||
|
||||
Future<String> _line() async {
|
||||
final end = DateTime.now().add(timeout);
|
||||
while (_lines.isEmpty) {
|
||||
if (_closed) throw FtpException(trf('서버 ({0}) 와의 연결이 끊겼습니다', [server.host]), FtpFailure.unreachable);
|
||||
final left = end.difference(DateTime.now());
|
||||
if (left <= Duration.zero) throw TimeoutException(tr('답이 없습니다'));
|
||||
_wake = Completer<void>();
|
||||
await _wake!.future.timeout(left, onTimeout: () {});
|
||||
}
|
||||
return _lines.removeAt(0);
|
||||
}
|
||||
|
||||
/// 응답 하나 (여러 줄이면 합침): (코드, 글)
|
||||
Future<(int, String)> reply() async {
|
||||
final first = await _line();
|
||||
final code = int.tryParse(first.length >= 3 ? first.substring(0, 3) : '') ?? 0;
|
||||
final text = StringBuffer(first.length > 4 ? first.substring(4) : '');
|
||||
if (first.length > 3 && first[3] == '-') {
|
||||
while (true) {
|
||||
final l = await _line();
|
||||
if (l.startsWith('$code ')) {
|
||||
text.write('\n${l.substring(4)}');
|
||||
break;
|
||||
}
|
||||
text.write('\n${l.startsWith(' ') ? l.substring(1) : l}');
|
||||
}
|
||||
}
|
||||
return (code, text.toString());
|
||||
}
|
||||
|
||||
/// 시험: 명령 채널 주고받기 (비밀번호 줄은 가림)
|
||||
static void Function(String line)? debugLog;
|
||||
|
||||
void send(String cmd) {
|
||||
// 보안 검토 8: 경로 · 이름에 줄바꿈이 있으면 명령이 하나 더 끼어든다 - 보내지 않는다
|
||||
if (cmd.contains('\r') || cmd.contains('\n') || cmd.contains('\u0000')) {
|
||||
throw FtpException(tr('이름에 줄바꿈이 들어 있어 FTP 로 보낼 수 없습니다'), FtpFailure.other);
|
||||
}
|
||||
debugLog?.call('> ${cmd.startsWith('PASS ') ? 'PASS ***' : cmd}');
|
||||
_raw.write(utf8.encode('$cmd\r\n'));
|
||||
}
|
||||
|
||||
Future<(int, String)> cmd(String c) async {
|
||||
send(c);
|
||||
return reply();
|
||||
}
|
||||
|
||||
/// [c] 를 보내고 [ok] 코드 (2xx 등) 가 아니면 [FtpException]
|
||||
Future<String> must(String c, {bool Function(int code)? ok, String? path}) async {
|
||||
final (code, text) = await cmd(c);
|
||||
if (ok != null ? ok(code) : code >= 200 && code < 300) return text;
|
||||
throw failure(code, text, path);
|
||||
}
|
||||
|
||||
FtpException failure(int code, String text, String? path) {
|
||||
if (code == 530) return FtpException(tr('아이디나 비밀번호가 맞지 않습니다'), FtpFailure.auth);
|
||||
if (code == 550 || code == 450) {
|
||||
final t = text.toLowerCase();
|
||||
if (t.contains('permission') || t.contains('denied') || t.contains('access')) {
|
||||
return FtpException(trf('이 폴더 · 파일을 다룰 권한이 없습니다: {0}', [path ?? '']), FtpFailure.permission);
|
||||
}
|
||||
return FtpException(trf('없는 경로입니다: {0}', [path ?? '']), FtpFailure.notFound);
|
||||
}
|
||||
return FtpException('$code $text${path == null ? '' : ' ($path)'}', FtpFailure.other);
|
||||
}
|
||||
|
||||
/// TLS 로 바꾼다 (명시: AUTH TLS 뒤 · 암시: 연결하자마자). 자체 서명이면 믿은 지문만
|
||||
Future<RawSecureSocket> _secure(RawSocket raw, StreamSubscription<RawSocketEvent>? sub) async {
|
||||
X509Certificate? bad;
|
||||
try {
|
||||
return await RawSecureSocket.secure(raw, subscription: sub, host: server.host.trim(), onBadCertificate: (c) {
|
||||
bad = c;
|
||||
return server.certFingerprint.isNotEmpty && certSha256(c) == server.certFingerprint;
|
||||
}).timeout(timeout);
|
||||
} on HandshakeException {
|
||||
final c = bad;
|
||||
if (c == null) rethrow;
|
||||
final fp = certSha256(c);
|
||||
throw FtpCertException(
|
||||
server.certFingerprint.isEmpty
|
||||
? tr('처음 보는 서버 인증서입니다 (자체 서명). 지문을 확인한 뒤 믿을지 정하세요')
|
||||
: tr('서버 인증서가 바뀌었습니다. 다른 서버로 연결되었을 수 있습니다'),
|
||||
server.certFingerprint.isEmpty ? FtpFailure.certUnknown : FtpFailure.certChanged,
|
||||
fingerprint: fp,
|
||||
subject: c.subject,
|
||||
previous: server.certFingerprint.isEmpty ? null : server.certFingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
/// 인사 · TLS · 로그인 · UTF-8 · 이진 · 시작 폴더
|
||||
Future<void> login() async {
|
||||
final (hello, text) = await reply();
|
||||
if (hello != 220) throw failure(hello, text, null);
|
||||
if (server.security == 'explicit') {
|
||||
final (code, t) = await cmd('AUTH TLS');
|
||||
if (code != 234) {
|
||||
// FTPS 를 못 하는 서버: 몰래 평문으로 내려가지 않는다 (사용자가 "평문 FTP" 를 골라야)
|
||||
throw FtpException(trf('서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 "평문 FTP" 를 고르세요', [code, t]),
|
||||
FtpFailure.noTls);
|
||||
}
|
||||
// 보안 검토 2: 234 뒤에 평문으로 끼워 넣은 줄이 있으면 (중간자가 TLS 뒤의 답을 꾸미려는 것) 끊는다
|
||||
if (_lines.isNotEmpty || _buf.isNotEmpty) {
|
||||
throw FtpException(tr('서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다'), FtpFailure.noTls);
|
||||
}
|
||||
final sub = _sub;
|
||||
_sub = null;
|
||||
_raw = await _secure(_raw, sub);
|
||||
secure = true;
|
||||
// RawSecureSocket.secure 가 넘겨받은 구독을 이어 쓰지 않으므로 다시 듣는다
|
||||
_listen();
|
||||
}
|
||||
if (secure) {
|
||||
await must('PBSZ 0');
|
||||
await must('PROT P');
|
||||
_protP = true;
|
||||
}
|
||||
if (server.user.isNotEmpty || server.password.isNotEmpty) {
|
||||
final (code, t) = await cmd('USER ${server.user.isEmpty ? 'anonymous' : server.user}');
|
||||
if (code == 331 || code == 332) {
|
||||
await must('PASS ${server.password}', ok: (c) => c == 230 || c == 202);
|
||||
} else if (code != 230) {
|
||||
throw failure(code, t, null);
|
||||
}
|
||||
} else {
|
||||
await must('USER anonymous', ok: (c) => c == 230 || c == 331);
|
||||
}
|
||||
final feat = await cmd('FEAT');
|
||||
// MLST 를 알리면 MLSD 도 한다 (RFC 3659 - pyftpdlib 등은 FEAT 에 MLST 만 적는다)
|
||||
mlsd = feat.$1 == 211 && RegExp(r'^\s*MLS[TD]', multiLine: true).hasMatch(feat.$2.toUpperCase());
|
||||
if (feat.$2.toUpperCase().contains('UTF8')) await cmd('OPTS UTF8 ON');
|
||||
await must('TYPE I');
|
||||
final pwd = await cmd('PWD');
|
||||
final m = RegExp(r'"((?:[^"]|"")*)"').firstMatch(pwd.$2);
|
||||
if (pwd.$1 == 257 && m != null) home = m[1]!.replaceAll('""', '"');
|
||||
}
|
||||
|
||||
/// 자료 채널 (EPSV, 안 되면 PASV). 주소는 명령 채널의 서버 주소로 (서버가 알려 준 다른 주소로는 가지 않는다 - FTP 바운스 막기)
|
||||
Future<Socket> data() async {
|
||||
int? port;
|
||||
final (c1, t1) = await cmd('EPSV');
|
||||
if (c1 == 229) {
|
||||
final m = RegExp(r'\(\|\|\|(\d+)\|\)').firstMatch(t1);
|
||||
if (m != null) port = int.parse(m[1]!);
|
||||
}
|
||||
if (port == null) {
|
||||
final t = await must('PASV', ok: (c) => c == 227);
|
||||
final m = RegExp(r'(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)').firstMatch(t);
|
||||
if (m == null) throw FtpException(trf('서버의 자료 연결 응답을 읽지 못했습니다: {0}', [t]), FtpFailure.other);
|
||||
port = int.parse(m[5]!) * 256 + int.parse(m[6]!);
|
||||
}
|
||||
return Socket.connect(addr, port, timeout: timeout);
|
||||
}
|
||||
|
||||
/// 자료 연결을 TLS 로 (PROT P). 명령 (RETR · STOR · MLSD) 을 보내고 150/125 를 받은 뒤에 한다 - 서버는 명령을 받고서야 TLS 를 시작한다 (pyftpdlib 등)
|
||||
Future<Socket> secureData(Socket sock) async {
|
||||
if (!_protP) return sock;
|
||||
final x = await SecureSocket.secure(sock,
|
||||
host: server.host.trim(),
|
||||
onBadCertificate: (c) => server.certFingerprint.isNotEmpty && certSha256(c) == server.certFingerprint).timeout(timeout);
|
||||
return x;
|
||||
}
|
||||
|
||||
void close() {
|
||||
if (_closed) return;
|
||||
_closed = true;
|
||||
try {
|
||||
send('QUIT');
|
||||
} catch (_) {}
|
||||
_sub?.cancel();
|
||||
_raw.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// 시험: 명령 채널 기록 (비밀번호 가림)
|
||||
void debugFtpLog(void Function(String line)? f) => FtpSession.debugLog = f;
|
||||
|
|
@ -1,612 +1,4 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dartssh2/dartssh2.dart';
|
||||
|
||||
import '../l10n/tr.dart';
|
||||
import 'dns_resolver.dart';
|
||||
import 'remote_fs.dart';
|
||||
import 'secret_gate.dart';
|
||||
import 'webdav.dart' show DavException, DavItem, DavLockedException;
|
||||
import 'remote_idle.dart';
|
||||
|
||||
/// 88: SFTP 서버 설정 (환경 설정 > 파일 탐색기 > 네트워크 서버). 비밀번호 · 개인 키 · 키 암호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음).
|
||||
/// 호스트 키 (지문) 는 비밀이 아니라 설정에 둔다 - 처음 연결 때 사용자가 믿은 것 (TOFU).
|
||||
class SftpServer {
|
||||
final String id;
|
||||
final String name;
|
||||
final String host;
|
||||
final int port;
|
||||
final String user;
|
||||
|
||||
/// 시작 폴더 (서버 기준 절대 경로). 비우면 로그인한 사용자의 홈 폴더. 탭의 "맨 위" 가 이 폴더다
|
||||
final String root;
|
||||
final String password;
|
||||
|
||||
/// 개인 키 (OpenSSH · PEM 글). 파일을 고르면 내용을 여기로 옮기고 원래 파일 경로는 저장하지 않는다
|
||||
final String privateKey;
|
||||
|
||||
/// 개인 키의 암호 (사용자 결정 88-2: 안전 저장소에 저장)
|
||||
final String keyPassphrase;
|
||||
|
||||
/// 오래된 ssh-rsa (SHA-1) 서명 · SHA-1 키 교환 허용 (사용자 결정 88-3: 처음 끔, 서버마다 켬 - 예전 NAS 용)
|
||||
final bool legacyRsa;
|
||||
|
||||
/// 믿은 호스트 키: 종류 ("ssh-ed25519" 등) 와 지문 ("SHA256:…"). 비어 있으면 아직 믿은 키가 없음
|
||||
final String hostKeyType;
|
||||
final String hostKeyFingerprint;
|
||||
|
||||
const SftpServer({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.host,
|
||||
this.port = 22,
|
||||
this.user = '',
|
||||
this.root = '',
|
||||
this.password = '',
|
||||
this.privateKey = '',
|
||||
this.keyPassphrase = '',
|
||||
this.legacyRsa = false,
|
||||
this.hostKeyType = '',
|
||||
this.hostKeyFingerprint = '',
|
||||
});
|
||||
|
||||
/// 탐색기 위쪽에 보일 이름
|
||||
String get label => name.trim().isNotEmpty ? name.trim() : host;
|
||||
|
||||
SftpServer copyWith({
|
||||
String? name,
|
||||
String? host,
|
||||
int? port,
|
||||
String? user,
|
||||
String? root,
|
||||
String? password,
|
||||
String? privateKey,
|
||||
String? keyPassphrase,
|
||||
bool? legacyRsa,
|
||||
String? hostKeyType,
|
||||
String? hostKeyFingerprint,
|
||||
}) =>
|
||||
SftpServer(
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
host: host ?? this.host,
|
||||
port: port ?? this.port,
|
||||
user: user ?? this.user,
|
||||
root: root ?? this.root,
|
||||
password: password ?? this.password,
|
||||
privateKey: privateKey ?? this.privateKey,
|
||||
keyPassphrase: keyPassphrase ?? this.keyPassphrase,
|
||||
legacyRsa: legacyRsa ?? this.legacyRsa,
|
||||
hostKeyType: hostKeyType ?? this.hostKeyType,
|
||||
hostKeyFingerprint: hostKeyFingerprint ?? this.hostKeyFingerprint,
|
||||
);
|
||||
|
||||
/// 비밀 값은 넣지 않는다 (안전 저장소). 저장해 두었는지만
|
||||
Map<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'host': host,
|
||||
'port': port,
|
||||
'user': user,
|
||||
'root': root,
|
||||
'hasPassword': password.isNotEmpty,
|
||||
'hasKey': privateKey.isNotEmpty,
|
||||
'legacyRsa': legacyRsa,
|
||||
'hostKeyType': hostKeyType,
|
||||
'hostKeyFingerprint': hostKeyFingerprint,
|
||||
};
|
||||
|
||||
factory SftpServer.fromJson(Map<Object?, Object?> j) => SftpServer(
|
||||
id: '${j['id'] ?? DateTime.now().microsecondsSinceEpoch}',
|
||||
name: j['name'] as String? ?? '',
|
||||
host: j['host'] as String? ?? '',
|
||||
port: switch (j['port']) { final int p when p > 0 && p < 65536 => p, _ => 22 },
|
||||
user: j['user'] as String? ?? '',
|
||||
root: j['root'] as String? ?? '',
|
||||
legacyRsa: j['legacyRsa'] == true,
|
||||
hostKeyType: j['hostKeyType'] as String? ?? '',
|
||||
hostKeyFingerprint: j['hostKeyFingerprint'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// SFTP 실패의 종류 (안내 글 · 할 일 단추를 고르는 데)
|
||||
/// [keyFormat] · [keyPassphrase] · [keyRejected]: SSH 키 로그인의 실패를 따로 (관리자 지시 10/21 - 연결 확인에서 키 형식 · 암호 틀림 · 서버 거부를 나눠 알림)
|
||||
enum SftpFailure { unreachable, auth, noSecret, hostKeyUnknown, hostKeyChanged, permission, notFound, keyFormat, keyPassphrase, keyRejected, other }
|
||||
|
||||
/// 개인 키 글이 쓸 수 있는 꼴인지 본다 (OpenSSH · PEM RSA · PEM EC - dartssh2 가 읽는 것). 암호가 걸려 있으면 true.
|
||||
/// 쓸 수 없으면 [SftpException] ([SftpFailure.keyFormat]) - PuTTY (.ppk) · PKCS#8 (BEGIN PRIVATE KEY) · 공개 키는 바꾸는 법을 알린다
|
||||
bool checkPrivateKey(String text) {
|
||||
if (text.contains('PuTTY-User-Key-File')) throw SftpException(tr('PuTTY 키 (.ppk) 는 쓸 수 없습니다. PuTTYgen 의 [Conversions] > [Export OpenSSH key] 로 바꾼 파일을 고르세요'), SftpFailure.keyFormat);
|
||||
try {
|
||||
return SSHKeyPair.isEncryptedPem(text);
|
||||
} catch (_) {
|
||||
throw SftpException(tr('개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)'), SftpFailure.keyFormat);
|
||||
}
|
||||
}
|
||||
|
||||
/// 개인 키 글 + 키 암호 → 로그인에 쓸 키. 키 형식 · 암호 없음 · 암호 틀림을 따로 알린다
|
||||
List<SSHKeyPair> parsePrivateKey(String text, String passphrase) {
|
||||
final encrypted = checkPrivateKey(text);
|
||||
if (encrypted && passphrase.isEmpty) throw SftpException(tr('개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요'), SftpFailure.keyPassphrase);
|
||||
try {
|
||||
return SSHKeyPair.fromPem(text, encrypted ? passphrase : null);
|
||||
} catch (_) {
|
||||
// 암호가 걸린 키를 풀지 못함 = 암호 틀림 (PEM 은 틀린 암호로 풀면 다른 오류가 나기도 한다)
|
||||
if (encrypted) throw SftpException(tr('개인 키의 암호가 맞지 않습니다'), SftpFailure.keyPassphrase);
|
||||
throw SftpException(tr('개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)'), SftpFailure.keyFormat);
|
||||
}
|
||||
}
|
||||
|
||||
/// SFTP 오류 - WebDAV 오류와 같은 곳에서 보이도록 [DavException] 을 잇는다
|
||||
class SftpException extends DavException {
|
||||
final SftpFailure kind;
|
||||
const SftpException(super.message, this.kind);
|
||||
}
|
||||
|
||||
/// 처음 보는 호스트 키 (또는 바뀐 키): 화면이 지문을 보이고 믿을지 묻는다. 믿으면 [SftpServer.hostKeyFingerprint] 에 저장
|
||||
class SftpHostKeyException extends SftpException {
|
||||
final String type;
|
||||
final String fingerprint;
|
||||
|
||||
/// 바뀐 경우 예전에 믿은 지문 (처음이면 null)
|
||||
final String? previous;
|
||||
const SftpHostKeyException(super.message, super.kind, {required this.type, required this.fingerprint, this.previous});
|
||||
bool get changed => previous != null;
|
||||
}
|
||||
|
||||
/// OpenSSH 와 같은 꼴의 지문 "SHA256:" + base64 (끝 '=' 없음). dartssh2 는 이 글을 바이트로 넘긴다 -
|
||||
/// 판이 바뀌어 SHA-256 값 그대로 오면 직접 만든다
|
||||
String sshFingerprint(Uint8List fp) {
|
||||
final text = utf8.decode(fp, allowMalformed: true);
|
||||
return text.startsWith('SHA256:') ? text : 'SHA256:${base64.encode(fp).replaceAll('=', '')}';
|
||||
}
|
||||
|
||||
/// 연결할 서버 주소. DNS 강제면 정한 DNS 로 (집 안 이름 · 주소는 기기 DNS) - 앱의 다른 연결과 같게
|
||||
typedef SftpConnect = Future<Socket> Function(String host, int port, Duration timeout);
|
||||
|
||||
/// 88: SFTP 클라이언트 (dartssh2). 서버마다 SSH 연결 하나를 열어 두고 다시 쓴다 (끊기면 다음에 다시 연다).
|
||||
class SftpRemote implements RemoteClient {
|
||||
final SftpServer server;
|
||||
|
||||
/// 연결 방법 (DNS 강제 · 시험에서 바꿈). 처음 값: [connectWithDns] 를 쓰지 않으면 기기 DNS
|
||||
final SftpConnect connect;
|
||||
final Duration timeout;
|
||||
SftpRemote(this.server, {SftpConnect? connect, this.timeout = const Duration(seconds: 15)})
|
||||
: connect = connect ?? SftpRegistry.connect;
|
||||
|
||||
Future<_Conn>? _conn;
|
||||
|
||||
/// 열린 연결 (없거나 끊겼으면 새로)
|
||||
Future<_Conn> _open() {
|
||||
final c = _conn;
|
||||
if (c != null) return c;
|
||||
final f = _connect();
|
||||
_conn = f;
|
||||
f.then((x) => x.ssh.done.whenComplete(() {
|
||||
if (identical(_conn, f)) _conn = null;
|
||||
}), onError: (_) {
|
||||
if (identical(_conn, f)) _conn = null;
|
||||
});
|
||||
return f;
|
||||
}
|
||||
|
||||
Future<_Conn> _connect() async {
|
||||
final s = server;
|
||||
if (s.host.trim().isEmpty) throw SftpException(tr('SFTP 서버 주소가 없습니다'), SftpFailure.other);
|
||||
final hasSecret = s.password.isNotEmpty || s.privateKey.isNotEmpty;
|
||||
if (hasSecret && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage));
|
||||
// 키도 비밀번호도 없으면 (저장하지 않음) 시도하지 않고 정확히 알린다 (40 과 같게)
|
||||
if (!hasSecret) throw SftpException(tr('저장된 비밀번호가 없습니다'), SftpFailure.noSecret);
|
||||
Socket sock;
|
||||
try {
|
||||
sock = await connect(s.host.trim(), s.port, timeout);
|
||||
} catch (e) {
|
||||
throw SftpException(trf('서버 ({0}) 에 연결할 수 없습니다. 서버가 켜져 있는지, 집 밖이면 Tailscale 이 켜져 있는지 확인하세요', [s.host]),
|
||||
SftpFailure.unreachable);
|
||||
}
|
||||
// 호스트 키: 처음 (믿은 키 없음) · 바뀜 → 연결을 끊고 화면이 묻게 한다
|
||||
(String, String)? seen;
|
||||
List<SSHKeyPair>? keys;
|
||||
if (s.privateKey.isNotEmpty) {
|
||||
try {
|
||||
keys = parsePrivateKey(s.privateKey, s.keyPassphrase);
|
||||
} catch (_) {
|
||||
sock.destroy();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
final ssh = SSHClient(
|
||||
_Socket(sock),
|
||||
username: s.user,
|
||||
algorithms: s.legacyRsa ? legacyAlgorithms : const SSHAlgorithms(),
|
||||
identities: keys,
|
||||
onVerifyHostKey: (type, fp) {
|
||||
final f = sshFingerprint(fp);
|
||||
seen = (type, f);
|
||||
return s.hostKeyFingerprint.isNotEmpty && s.hostKeyFingerprint == f;
|
||||
},
|
||||
onPasswordRequest: s.password.isEmpty ? null : () => s.password,
|
||||
// 일부 NAS 는 비밀번호를 keyboard-interactive 로 묻는다
|
||||
onUserInfoRequest: s.password.isEmpty ? null : (req) => [for (final _ in req.prompts) s.password],
|
||||
);
|
||||
try {
|
||||
await ssh.authenticated.timeout(timeout * 2);
|
||||
final sftp = await ssh.sftp();
|
||||
final root = s.root.trim().isEmpty ? await sftp.absolute('.') : _clean(s.root.trim());
|
||||
return _Conn(ssh, sftp, root);
|
||||
} catch (e) {
|
||||
ssh.close();
|
||||
sock.destroy();
|
||||
throw _explain(e, seen);
|
||||
}
|
||||
}
|
||||
|
||||
/// dartssh2 의 오류 → 사람 말 (무슨 일인지 · 할 일)
|
||||
SftpException _explain(Object e, (String, String)? seen) {
|
||||
final s = server;
|
||||
if (e is SftpException) return e;
|
||||
// 호스트 키를 믿지 않아 끊었으면 그 뒤의 오류 종류 (인증 중단 등) 와 상관없이 키 문제다
|
||||
if (seen != null && s.hostKeyFingerprint != seen.$2) {
|
||||
final (type, fp) = seen;
|
||||
if (s.hostKeyFingerprint.isEmpty) {
|
||||
return SftpHostKeyException(trf('처음 연결하는 서버입니다. 서버의 키 지문이 맞는지 확인하세요: {0} ({1})', [fp, type]),
|
||||
SftpFailure.hostKeyUnknown,
|
||||
type: type, fingerprint: fp);
|
||||
}
|
||||
return SftpHostKeyException(
|
||||
trf('서버의 키가 바뀌었습니다 - 중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 예전: {0} · 지금: {1}', [s.hostKeyFingerprint, fp]),
|
||||
SftpFailure.hostKeyChanged,
|
||||
type: type,
|
||||
fingerprint: fp,
|
||||
previous: s.hostKeyFingerprint);
|
||||
}
|
||||
if (e is TimeoutException) return SftpException(trf('서버 ({0}) 가 답하지 않습니다', [s.host]), SftpFailure.unreachable);
|
||||
// 호스트 키까지 가지 못함 = 키 교환 · 암호 방식을 맞추지 못함 (오래된 서버면 ssh-rsa 허용을 안내)
|
||||
if (seen == null || e is SSHHandshakeError) {
|
||||
return SftpException(
|
||||
s.legacyRsa
|
||||
? trf('서버와 암호 방식을 맞추지 못했습니다: {0}', [e is SSHMessageError ? e.message : '$e'])
|
||||
: tr('서버와 암호 방식을 맞추지 못했습니다. 오래된 서버면 서버 설정의 "오래된 ssh-rsa 허용" 을 켜 보세요'),
|
||||
SftpFailure.other);
|
||||
}
|
||||
if (e is SSHAuthError) {
|
||||
// 관리자 지시 (10/21): 키만으로 들어가다 거부되면 서버가 그 키를 모르는 것 - 비밀번호와 따로 알린다
|
||||
if (s.privateKey.isNotEmpty && s.password.isEmpty) return SftpException(tr('서버가 이 키를 받지 않습니다. 서버의 ~/.ssh/authorized_keys 에 짝이 되는 공개 키 (.pub) 가 있는지, 아이디가 맞는지 보세요'), SftpFailure.keyRejected);
|
||||
if (s.privateKey.isNotEmpty) return SftpException(tr('아이디 · 비밀번호 · 키가 모두 서버에서 거부되었습니다'), SftpFailure.auth);
|
||||
return SftpException(tr('아이디나 비밀번호 (또는 키) 가 맞지 않습니다'), SftpFailure.auth);
|
||||
}
|
||||
return SftpException('$e', SftpFailure.other);
|
||||
}
|
||||
|
||||
/// 서버 기준 경로 → 서버의 절대 경로
|
||||
String _remote(_Conn c, String rel) {
|
||||
final r = rel == '/' || rel.isEmpty ? '' : rel;
|
||||
return c.root == '/' ? (r.isEmpty ? '/' : r) : '${c.root}$r';
|
||||
}
|
||||
|
||||
static String _clean(String path) {
|
||||
var x = path.replaceAll('\\', '/').replaceAll(RegExp(r'/+'), '/');
|
||||
if (!x.startsWith('/')) x = '/$x';
|
||||
if (x.length > 1 && x.endsWith('/')) x = x.substring(0, x.length - 1);
|
||||
return x;
|
||||
}
|
||||
|
||||
/// 관리자 판단 (10/20): SFTP 명령 하나 (목록 · 열기 · 닫기 · 지우기 등) 를 정한 시간 ([RemoteIdle]) 까지만 기다린다 -
|
||||
/// 서버가 멈추면 이 연결을 닫고 (다음에 다시 연다) "서버가 답하지 않습니다"
|
||||
Future<T> _cmd<T>(Future<T> f) => idleCall(f, onTimeout: close);
|
||||
|
||||
/// 파일 다루다 난 오류 (SFTP 상태) → 사람 말. 끊긴 연결은 다음에 다시 연다
|
||||
Never _fail(Object e, String rel) {
|
||||
if (e is RemoteIdleTimeout) throw e;
|
||||
if (e is SftpStatusError) {
|
||||
switch (e.code) {
|
||||
case SftpStatusCode.noSuchFile:
|
||||
throw SftpException(trf('없는 경로입니다: {0}', [rel]), SftpFailure.notFound);
|
||||
case SftpStatusCode.permissionDenied:
|
||||
throw SftpException(trf('이 폴더 · 파일을 다룰 권한이 없습니다: {0}', [rel]), SftpFailure.permission);
|
||||
}
|
||||
throw SftpException('${e.message} ($rel)', SftpFailure.other);
|
||||
}
|
||||
if (e is SftpException) throw e;
|
||||
if (e is SSHError || e is SocketException || e is StateError) {
|
||||
_conn = null;
|
||||
throw SftpException(trf('서버 ({0}) 와의 연결이 끊겼습니다', [server.host]), SftpFailure.unreachable);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
DavItem _item(String rel, SftpFileAttrs a) => DavItem(rel,
|
||||
isDir: a.isDirectory,
|
||||
size: a.isDirectory ? 0 : (a.size ?? 0),
|
||||
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<DavItem>> list(String rel) async {
|
||||
final c = await _open();
|
||||
try {
|
||||
final out = <DavItem>[];
|
||||
for (final n in await _cmd(c.sftp.listdir(_remote(c, rel)))) {
|
||||
if (n.filename == '.' || n.filename == '..') continue;
|
||||
var a = n.attr;
|
||||
// 링크는 가리키는 것으로 (폴더 링크를 폴더로) - 따라갈 수 없으면 크기 0 파일로
|
||||
if (a.type == SftpFileType.symbolicLink) {
|
||||
try {
|
||||
a = await _cmd(c.sftp.stat(_remote(c, _child(rel, n.filename))));
|
||||
} catch (_) {}
|
||||
}
|
||||
out.add(_item(_child(rel, n.filename), a));
|
||||
}
|
||||
return out;
|
||||
} catch (e) {
|
||||
_fail(e, rel);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DavItem?> stat(String rel) async {
|
||||
final c = await _open();
|
||||
try {
|
||||
return _item(rel, await _cmd(c.sftp.stat(_remote(c, rel))));
|
||||
} on SftpStatusError catch (e) {
|
||||
if (e.code == SftpStatusCode.noSuchFile) return null;
|
||||
_fail(e, rel);
|
||||
} catch (e) {
|
||||
_fail(e, rel);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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));
|
||||
final c = await _open();
|
||||
try {
|
||||
await _cmd(c.sftp.mkdir(_remote(c, rel)));
|
||||
} catch (e) {
|
||||
// 그 사이에 다른 곳이 만들었으면 그대로
|
||||
if ((await stat(rel))?.isDir == true) return;
|
||||
_fail(e, rel);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String rel) async {
|
||||
final st = await stat(rel);
|
||||
if (st == null) return;
|
||||
final c = await _open();
|
||||
try {
|
||||
if (st.isDir) {
|
||||
for (final x in await list(rel)) {
|
||||
await delete(x.rel);
|
||||
}
|
||||
await _cmd(c.sftp.rmdir(_remote(c, rel)));
|
||||
} else {
|
||||
await _cmd(c.sftp.remove(_remote(c, rel)));
|
||||
}
|
||||
} catch (e) {
|
||||
_fail(e, rel);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> move(String from, String to, {bool overwrite = false}) async {
|
||||
final c = await _open();
|
||||
// SFTP (v3) 의 이름 바꾸기는 대상이 있으면 실패한다 - 덮어쓰기면 대상을 먼저 지운다 (파일만. 폴더를 통째로 덮지 않음)
|
||||
if (overwrite) {
|
||||
final t = await stat(to);
|
||||
if (t != null && !t.isDir) {
|
||||
try {
|
||||
await _cmd(c.sftp.remove(_remote(c, to)));
|
||||
} catch (e) {
|
||||
_fail(e, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await _cmd(c.sftp.rename(_remote(c, from), _remote(c, to)));
|
||||
} catch (e) {
|
||||
_fail(e, from);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Stream<List<int>>> openRead(String rel) => openReadFrom(rel);
|
||||
|
||||
/// [offset] 부터 [length] 바이트 (null 이면 끝까지) 만 읽는다 - 동영상 스트리밍 다리의 Range (sftp_next)
|
||||
Future<Stream<List<int>>> openReadFrom(String rel, {int offset = 0, int? length}) async {
|
||||
final c = await _open();
|
||||
final SftpFile f;
|
||||
try {
|
||||
f = await _cmd(c.sftp.open(_remote(c, rel)));
|
||||
} catch (e) {
|
||||
_fail(e, rel);
|
||||
}
|
||||
late StreamController<List<int>> out;
|
||||
StreamSubscription<Uint8List>? sub;
|
||||
out = StreamController<List<int>>(
|
||||
onListen: () {
|
||||
// 관리자 판단 (10/20): 조각 사이에 정한 시간 동안 아무것도 오지 않으면 실패 - 그 연결은 닫고 다음에 다시 연다
|
||||
sub = idleRead(f.read(offset: offset, length: length), onTimeout: close).listen(out.add, onError: (Object e) {
|
||||
try {
|
||||
_fail(e, rel);
|
||||
} catch (x) {
|
||||
out.addError(x);
|
||||
}
|
||||
}, onDone: () async {
|
||||
await _cmd(f.close()).catchError((_) {});
|
||||
await out.close();
|
||||
});
|
||||
},
|
||||
onPause: () => sub?.pause(),
|
||||
onResume: () => sub?.resume(),
|
||||
onCancel: () async {
|
||||
await sub?.cancel();
|
||||
await _cmd(f.close()).catchError((_) {});
|
||||
},
|
||||
);
|
||||
return out.stream;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(String rel, Stream<List<int>> data, {int? length}) async {
|
||||
final c = await _open();
|
||||
final SftpFile f;
|
||||
try {
|
||||
f = await _cmd(c.sftp.open(_remote(c, rel),
|
||||
mode: SftpFileOpenMode.create | SftpFileOpenMode.truncate | SftpFileOpenMode.write));
|
||||
} catch (e) {
|
||||
_fail(e, rel);
|
||||
}
|
||||
try {
|
||||
// 관리자 판단 (10/20): 서버가 받지 않아 정한 시간 동안 한 조각도 더 보내지 못하면 실패 (그 연결을 닫음)
|
||||
await idleWrite(data, (x) => f.write(x.map((y) => y is Uint8List ? y : Uint8List.fromList(y))).done, onTimeout: close);
|
||||
} catch (e) {
|
||||
_fail(e, rel);
|
||||
} finally {
|
||||
await _cmd(f.close()).catchError((_) {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<(int?, int?)> quota() async {
|
||||
final c = await _open();
|
||||
try {
|
||||
final v = await _cmd(c.sftp.statvfs(c.root));
|
||||
final unit = v.fundamentalBlockSize > 0 ? v.fundamentalBlockSize : v.blockSize;
|
||||
return (v.freeBlocksForNonRoot * unit, (v.totalBlocks - v.freeBlocks) * unit);
|
||||
} catch (_) {
|
||||
// statvfs 를 지원하지 않는 서버 (OpenSSH 확장) - 모름
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {
|
||||
final c = _conn;
|
||||
_conn = null;
|
||||
c?.then((x) => x.ssh.close(), onError: (_) {});
|
||||
}
|
||||
|
||||
/// 오래된 서버용 (88-3): 기본 알고리즘 + ssh-rsa (SHA-1) 서명 · SHA-1 키 교환
|
||||
static const legacyAlgorithms = SSHAlgorithms(
|
||||
kex: [
|
||||
SSHKexType.x25519Rfc,
|
||||
SSHKexType.x25519,
|
||||
SSHKexType.nistp521,
|
||||
SSHKexType.nistp384,
|
||||
SSHKexType.nistp256,
|
||||
SSHKexType.dhGexSha256,
|
||||
SSHKexType.dh14Sha256,
|
||||
SSHKexType.dh14Sha1,
|
||||
SSHKexType.dhGexSha1,
|
||||
],
|
||||
hostkey: [
|
||||
SSHHostkeyType.ed25519,
|
||||
SSHHostkeyType.rsaSha512,
|
||||
SSHHostkeyType.rsaSha256,
|
||||
SSHHostkeyType.ecdsa521,
|
||||
SSHHostkeyType.ecdsa384,
|
||||
SSHHostkeyType.ecdsa256,
|
||||
SSHHostkeyType.rsaSha1,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _Conn {
|
||||
final SSHClient ssh;
|
||||
final SftpClient sftp;
|
||||
|
||||
/// 탭의 맨 위 (서버의 절대 경로)
|
||||
final String root;
|
||||
_Conn(this.ssh, this.sftp, this.root);
|
||||
}
|
||||
|
||||
/// dart:io Socket → dartssh2 (연결은 우리가 열어 DNS 강제를 따르게)
|
||||
class _Socket implements SSHSocket {
|
||||
final Socket _s;
|
||||
_Socket(this._s) {
|
||||
// 서버가 먼저 끊은 뒤의 쓰기 실패 (호스트 키를 믿지 않아 끊음 등) 가 처리되지 않은 오류로 앱에 올라오지 않게.
|
||||
// dartssh2 는 같은 Future 를 기다려 끊김을 그대로 안다
|
||||
unawaited(_s.done.then<void>((_) {}, onError: (Object _) {}));
|
||||
}
|
||||
/// 소켓 오류 (상대가 끊음 · 쓰기 실패) 는 끊김으로 (dartssh2 는 읽는 쪽의 오류를 받지 않는다 - 처리되지 않은 오류가 되었다)
|
||||
@override
|
||||
late final Stream<Uint8List> stream =
|
||||
_s.transform(StreamTransformer<Uint8List, Uint8List>.fromHandlers(handleError: (e, st, out) => out.close()));
|
||||
@override
|
||||
StreamSink<List<int>> get sink => _s;
|
||||
// 닫기 · 비우기의 쓰기 실패도 같은 끊김이다 (dartssh2 가 기다리지 않는 Future - 처리되지 않은 오류가 되지 않게)
|
||||
@override
|
||||
Future<void> close() => _s.close().then<void>((_) {}, onError: (Object _) {});
|
||||
@override
|
||||
Future<void> get done => _s.done;
|
||||
@override
|
||||
void destroy() => _s.destroy();
|
||||
@override
|
||||
Future<void> flush() => _s.flush().then<void>((_) {}, onError: (Object _) {});
|
||||
}
|
||||
|
||||
/// 설정의 SFTP 서버 목록과 클라이언트 (서버마다 하나)
|
||||
class SftpRegistry {
|
||||
static final _clients = <String, SftpRemote>{};
|
||||
static final _servers = <String, SftpServer>{};
|
||||
|
||||
/// 연결 방법 (main 에서 DNS 강제를 따르게 바꾼다). 처음 값: 기기 DNS
|
||||
static SftpConnect connect = (host, port, timeout) => Socket.connect(host, port, timeout: timeout);
|
||||
|
||||
/// 정한 DNS 로 이름을 풀어 연결 (집 안 이름 · 주소는 기기 DNS). [resolver] 가 DNS 강제가 아니면 기기 DNS
|
||||
static SftpConnect connectWithDns(DnsResolver resolver) => (host, port, timeout) async {
|
||||
if (InternetAddress.tryParse(host) != null || !resolver.config().forced || isLocalName(host)) {
|
||||
return Socket.connect(host, port, timeout: timeout);
|
||||
}
|
||||
final addrs = await resolver.lookup(host);
|
||||
Object? last;
|
||||
for (final a in [...addrs.where((a) => a.type == InternetAddressType.IPv4), ...addrs.where((a) => a.type != InternetAddressType.IPv4)]) {
|
||||
try {
|
||||
return await Socket.connect(a, port, timeout: timeout);
|
||||
} catch (e) {
|
||||
last = e;
|
||||
}
|
||||
}
|
||||
throw last ?? SocketException(trf('{0} 에 연결할 수 없습니다', [host]));
|
||||
};
|
||||
|
||||
static void configure(List<SftpServer> servers) {
|
||||
final keep = {for (final s in servers) s.id: s};
|
||||
for (final id in _clients.keys.toList()) {
|
||||
final old = _servers[id], now = keep[id];
|
||||
// 비밀 값은 toJson 에 없으므로 따로 비교 (바꾸면 바로 새 값으로 접속)
|
||||
if (now == null ||
|
||||
old == null ||
|
||||
old.password != now.password ||
|
||||
old.privateKey != now.privateKey ||
|
||||
old.keyPassphrase != now.keyPassphrase ||
|
||||
jsonEncode(old.toJson()) != jsonEncode(now.toJson())) {
|
||||
_clients.remove(id)?.close();
|
||||
}
|
||||
}
|
||||
_servers
|
||||
..clear()
|
||||
..addAll(keep);
|
||||
}
|
||||
|
||||
static List<SftpServer> get servers => _servers.values.toList();
|
||||
static SftpServer? server(String id) => _servers[id];
|
||||
|
||||
static SftpRemote client(String id) {
|
||||
final s = _servers[id];
|
||||
if (s == null) throw SftpException(trf('SFTP 서버 설정이 없습니다 ({0})', [id]), SftpFailure.other);
|
||||
return _clients.putIfAbsent(id, () => SftpRemote(s));
|
||||
}
|
||||
}
|
||||
// 약속 (나누기 sftp): SFTP (dartssh2) - 부르는 곳은 이 파일 하나만 가져온다.
|
||||
// 일은 lib/core/sftp/ 의 모듈이 한다: sftp_server (설정 · 오류 · 키 · 지문) · sftp_remote (원격 저장소 · 클라이언트 목록).
|
||||
export 'sftp/sftp_server.dart';
|
||||
export 'sftp/sftp_remote.dart';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,463 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dartssh2/dartssh2.dart';
|
||||
|
||||
import '../../l10n/tr.dart';
|
||||
import '../dns_resolver.dart';
|
||||
import '../remote_fs.dart';
|
||||
import '../secret_gate.dart';
|
||||
import '../webdav.dart' show DavItem, DavLockedException;
|
||||
import '../remote_idle.dart';
|
||||
import 'sftp_server.dart';
|
||||
|
||||
// 약속 (나누기 sftp): SFTP 원격 저장소 ([RemoteClient], dartssh2) 와 서버마다 하나인 클라이언트 목록 ([SftpRegistry]).
|
||||
// 입력: 서버 기준 경로 ("/" 로 시작). 출력: 목록 · 정보 ([DavItem]) · 부분 읽기 흐름 · 쓰기 · 남은 공간 (모르면 null).
|
||||
// 오류: [SftpException] (연결 못함 · 로그인 · 키 · 권한 · 없는 경로 · 끊김) · 처음 보는 / 바뀐 호스트 키 [SftpHostKeyException] ·
|
||||
// 잠금 [DavLockedException] · 명령 · 조각 사이 시간 넘김 [RemoteIdleTimeout]. 끊긴 연결은 다음에 다시 연다.
|
||||
|
||||
/// 88: SFTP 클라이언트 (dartssh2). 서버마다 SSH 연결 하나를 열어 두고 다시 쓴다 (끊기면 다음에 다시 연다).
|
||||
class SftpRemote implements RemoteClient {
|
||||
final SftpServer server;
|
||||
|
||||
/// 연결 방법 (DNS 강제 · 시험에서 바꿈). 처음 값: [connectWithDns] 를 쓰지 않으면 기기 DNS
|
||||
final SftpConnect connect;
|
||||
final Duration timeout;
|
||||
SftpRemote(this.server, {SftpConnect? connect, this.timeout = const Duration(seconds: 15)})
|
||||
: connect = connect ?? SftpRegistry.connect;
|
||||
|
||||
Future<_Conn>? _conn;
|
||||
|
||||
/// 열린 연결 (없거나 끊겼으면 새로)
|
||||
Future<_Conn> _open() {
|
||||
final c = _conn;
|
||||
if (c != null) return c;
|
||||
final f = _connect();
|
||||
_conn = f;
|
||||
f.then((x) => x.ssh.done.whenComplete(() {
|
||||
if (identical(_conn, f)) _conn = null;
|
||||
}), onError: (_) {
|
||||
if (identical(_conn, f)) _conn = null;
|
||||
});
|
||||
return f;
|
||||
}
|
||||
|
||||
Future<_Conn> _connect() async {
|
||||
final s = server;
|
||||
if (s.host.trim().isEmpty) throw SftpException(tr('SFTP 서버 주소가 없습니다'), SftpFailure.other);
|
||||
final hasSecret = s.password.isNotEmpty || s.privateKey.isNotEmpty;
|
||||
if (hasSecret && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage));
|
||||
// 키도 비밀번호도 없으면 (저장하지 않음) 시도하지 않고 정확히 알린다 (40 과 같게)
|
||||
if (!hasSecret) throw SftpException(tr('저장된 비밀번호가 없습니다'), SftpFailure.noSecret);
|
||||
Socket sock;
|
||||
try {
|
||||
sock = await connect(s.host.trim(), s.port, timeout);
|
||||
} catch (e) {
|
||||
throw SftpException(trf('서버 ({0}) 에 연결할 수 없습니다. 서버가 켜져 있는지, 집 밖이면 Tailscale 이 켜져 있는지 확인하세요', [s.host]),
|
||||
SftpFailure.unreachable);
|
||||
}
|
||||
// 호스트 키: 처음 (믿은 키 없음) · 바뀜 → 연결을 끊고 화면이 묻게 한다
|
||||
(String, String)? seen;
|
||||
List<SSHKeyPair>? keys;
|
||||
if (s.privateKey.isNotEmpty) {
|
||||
try {
|
||||
keys = parsePrivateKey(s.privateKey, s.keyPassphrase);
|
||||
} catch (_) {
|
||||
sock.destroy();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
final ssh = SSHClient(
|
||||
_Socket(sock),
|
||||
username: s.user,
|
||||
algorithms: s.legacyRsa ? legacyAlgorithms : const SSHAlgorithms(),
|
||||
identities: keys,
|
||||
onVerifyHostKey: (type, fp) {
|
||||
final f = sshFingerprint(fp);
|
||||
seen = (type, f);
|
||||
return s.hostKeyFingerprint.isNotEmpty && s.hostKeyFingerprint == f;
|
||||
},
|
||||
onPasswordRequest: s.password.isEmpty ? null : () => s.password,
|
||||
// 일부 NAS 는 비밀번호를 keyboard-interactive 로 묻는다
|
||||
onUserInfoRequest: s.password.isEmpty ? null : (req) => [for (final _ in req.prompts) s.password],
|
||||
);
|
||||
try {
|
||||
await ssh.authenticated.timeout(timeout * 2);
|
||||
final sftp = await ssh.sftp();
|
||||
final root = s.root.trim().isEmpty ? await sftp.absolute('.') : _clean(s.root.trim());
|
||||
return _Conn(ssh, sftp, root);
|
||||
} catch (e) {
|
||||
ssh.close();
|
||||
sock.destroy();
|
||||
throw _explain(e, seen);
|
||||
}
|
||||
}
|
||||
|
||||
/// dartssh2 의 오류 → 사람 말 (무슨 일인지 · 할 일)
|
||||
SftpException _explain(Object e, (String, String)? seen) {
|
||||
final s = server;
|
||||
if (e is SftpException) return e;
|
||||
// 호스트 키를 믿지 않아 끊었으면 그 뒤의 오류 종류 (인증 중단 등) 와 상관없이 키 문제다
|
||||
if (seen != null && s.hostKeyFingerprint != seen.$2) {
|
||||
final (type, fp) = seen;
|
||||
if (s.hostKeyFingerprint.isEmpty) {
|
||||
return SftpHostKeyException(trf('처음 연결하는 서버입니다. 서버의 키 지문이 맞는지 확인하세요: {0} ({1})', [fp, type]),
|
||||
SftpFailure.hostKeyUnknown,
|
||||
type: type, fingerprint: fp);
|
||||
}
|
||||
return SftpHostKeyException(
|
||||
trf('서버의 키가 바뀌었습니다 - 중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 예전: {0} · 지금: {1}', [s.hostKeyFingerprint, fp]),
|
||||
SftpFailure.hostKeyChanged,
|
||||
type: type,
|
||||
fingerprint: fp,
|
||||
previous: s.hostKeyFingerprint);
|
||||
}
|
||||
if (e is TimeoutException) return SftpException(trf('서버 ({0}) 가 답하지 않습니다', [s.host]), SftpFailure.unreachable);
|
||||
// 호스트 키까지 가지 못함 = 키 교환 · 암호 방식을 맞추지 못함 (오래된 서버면 ssh-rsa 허용을 안내)
|
||||
if (seen == null || e is SSHHandshakeError) {
|
||||
return SftpException(
|
||||
s.legacyRsa
|
||||
? trf('서버와 암호 방식을 맞추지 못했습니다: {0}', [e is SSHMessageError ? e.message : '$e'])
|
||||
: tr('서버와 암호 방식을 맞추지 못했습니다. 오래된 서버면 서버 설정의 "오래된 ssh-rsa 허용" 을 켜 보세요'),
|
||||
SftpFailure.other);
|
||||
}
|
||||
if (e is SSHAuthError) {
|
||||
// 관리자 지시 (10/21): 키만으로 들어가다 거부되면 서버가 그 키를 모르는 것 - 비밀번호와 따로 알린다
|
||||
if (s.privateKey.isNotEmpty && s.password.isEmpty) return SftpException(tr('서버가 이 키를 받지 않습니다. 서버의 ~/.ssh/authorized_keys 에 짝이 되는 공개 키 (.pub) 가 있는지, 아이디가 맞는지 보세요'), SftpFailure.keyRejected);
|
||||
if (s.privateKey.isNotEmpty) return SftpException(tr('아이디 · 비밀번호 · 키가 모두 서버에서 거부되었습니다'), SftpFailure.auth);
|
||||
return SftpException(tr('아이디나 비밀번호 (또는 키) 가 맞지 않습니다'), SftpFailure.auth);
|
||||
}
|
||||
return SftpException('$e', SftpFailure.other);
|
||||
}
|
||||
|
||||
/// 서버 기준 경로 → 서버의 절대 경로
|
||||
String _remote(_Conn c, String rel) {
|
||||
final r = rel == '/' || rel.isEmpty ? '' : rel;
|
||||
return c.root == '/' ? (r.isEmpty ? '/' : r) : '${c.root}$r';
|
||||
}
|
||||
|
||||
static String _clean(String path) {
|
||||
var x = path.replaceAll('\\', '/').replaceAll(RegExp(r'/+'), '/');
|
||||
if (!x.startsWith('/')) x = '/$x';
|
||||
if (x.length > 1 && x.endsWith('/')) x = x.substring(0, x.length - 1);
|
||||
return x;
|
||||
}
|
||||
|
||||
/// 관리자 판단 (10/20): SFTP 명령 하나 (목록 · 열기 · 닫기 · 지우기 등) 를 정한 시간 ([RemoteIdle]) 까지만 기다린다 -
|
||||
/// 서버가 멈추면 이 연결을 닫고 (다음에 다시 연다) "서버가 답하지 않습니다"
|
||||
Future<T> _cmd<T>(Future<T> f) => idleCall(f, onTimeout: close);
|
||||
|
||||
/// 파일 다루다 난 오류 (SFTP 상태) → 사람 말. 끊긴 연결은 다음에 다시 연다
|
||||
Never _fail(Object e, String rel) {
|
||||
if (e is RemoteIdleTimeout) throw e;
|
||||
if (e is SftpStatusError) {
|
||||
switch (e.code) {
|
||||
case SftpStatusCode.noSuchFile:
|
||||
throw SftpException(trf('없는 경로입니다: {0}', [rel]), SftpFailure.notFound);
|
||||
case SftpStatusCode.permissionDenied:
|
||||
throw SftpException(trf('이 폴더 · 파일을 다룰 권한이 없습니다: {0}', [rel]), SftpFailure.permission);
|
||||
}
|
||||
throw SftpException('${e.message} ($rel)', SftpFailure.other);
|
||||
}
|
||||
if (e is SftpException) throw e;
|
||||
if (e is SSHError || e is SocketException || e is StateError) {
|
||||
_conn = null;
|
||||
throw SftpException(trf('서버 ({0}) 와의 연결이 끊겼습니다', [server.host]), SftpFailure.unreachable);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
DavItem _item(String rel, SftpFileAttrs a) => DavItem(rel,
|
||||
isDir: a.isDirectory,
|
||||
size: a.isDirectory ? 0 : (a.size ?? 0),
|
||||
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<DavItem>> list(String rel) async {
|
||||
final c = await _open();
|
||||
try {
|
||||
final out = <DavItem>[];
|
||||
for (final n in await _cmd(c.sftp.listdir(_remote(c, rel)))) {
|
||||
if (n.filename == '.' || n.filename == '..') continue;
|
||||
var a = n.attr;
|
||||
// 링크는 가리키는 것으로 (폴더 링크를 폴더로) - 따라갈 수 없으면 크기 0 파일로
|
||||
if (a.type == SftpFileType.symbolicLink) {
|
||||
try {
|
||||
a = await _cmd(c.sftp.stat(_remote(c, _child(rel, n.filename))));
|
||||
} catch (_) {}
|
||||
}
|
||||
out.add(_item(_child(rel, n.filename), a));
|
||||
}
|
||||
return out;
|
||||
} catch (e) {
|
||||
_fail(e, rel);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DavItem?> stat(String rel) async {
|
||||
final c = await _open();
|
||||
try {
|
||||
return _item(rel, await _cmd(c.sftp.stat(_remote(c, rel))));
|
||||
} on SftpStatusError catch (e) {
|
||||
if (e.code == SftpStatusCode.noSuchFile) return null;
|
||||
_fail(e, rel);
|
||||
} catch (e) {
|
||||
_fail(e, rel);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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));
|
||||
final c = await _open();
|
||||
try {
|
||||
await _cmd(c.sftp.mkdir(_remote(c, rel)));
|
||||
} catch (e) {
|
||||
// 그 사이에 다른 곳이 만들었으면 그대로
|
||||
if ((await stat(rel))?.isDir == true) return;
|
||||
_fail(e, rel);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String rel) async {
|
||||
final st = await stat(rel);
|
||||
if (st == null) return;
|
||||
final c = await _open();
|
||||
try {
|
||||
if (st.isDir) {
|
||||
for (final x in await list(rel)) {
|
||||
await delete(x.rel);
|
||||
}
|
||||
await _cmd(c.sftp.rmdir(_remote(c, rel)));
|
||||
} else {
|
||||
await _cmd(c.sftp.remove(_remote(c, rel)));
|
||||
}
|
||||
} catch (e) {
|
||||
_fail(e, rel);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> move(String from, String to, {bool overwrite = false}) async {
|
||||
final c = await _open();
|
||||
// SFTP (v3) 의 이름 바꾸기는 대상이 있으면 실패한다 - 덮어쓰기면 대상을 먼저 지운다 (파일만. 폴더를 통째로 덮지 않음)
|
||||
if (overwrite) {
|
||||
final t = await stat(to);
|
||||
if (t != null && !t.isDir) {
|
||||
try {
|
||||
await _cmd(c.sftp.remove(_remote(c, to)));
|
||||
} catch (e) {
|
||||
_fail(e, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await _cmd(c.sftp.rename(_remote(c, from), _remote(c, to)));
|
||||
} catch (e) {
|
||||
_fail(e, from);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Stream<List<int>>> openRead(String rel) => openReadFrom(rel);
|
||||
|
||||
/// [offset] 부터 [length] 바이트 (null 이면 끝까지) 만 읽는다 - 동영상 스트리밍 다리의 Range (sftp_next)
|
||||
Future<Stream<List<int>>> openReadFrom(String rel, {int offset = 0, int? length}) async {
|
||||
final c = await _open();
|
||||
final SftpFile f;
|
||||
try {
|
||||
f = await _cmd(c.sftp.open(_remote(c, rel)));
|
||||
} catch (e) {
|
||||
_fail(e, rel);
|
||||
}
|
||||
late StreamController<List<int>> out;
|
||||
StreamSubscription<Uint8List>? sub;
|
||||
out = StreamController<List<int>>(
|
||||
onListen: () {
|
||||
// 관리자 판단 (10/20): 조각 사이에 정한 시간 동안 아무것도 오지 않으면 실패 - 그 연결은 닫고 다음에 다시 연다
|
||||
sub = idleRead(f.read(offset: offset, length: length), onTimeout: close).listen(out.add, onError: (Object e) {
|
||||
try {
|
||||
_fail(e, rel);
|
||||
} catch (x) {
|
||||
out.addError(x);
|
||||
}
|
||||
}, onDone: () async {
|
||||
await _cmd(f.close()).catchError((_) {});
|
||||
await out.close();
|
||||
});
|
||||
},
|
||||
onPause: () => sub?.pause(),
|
||||
onResume: () => sub?.resume(),
|
||||
onCancel: () async {
|
||||
await sub?.cancel();
|
||||
await _cmd(f.close()).catchError((_) {});
|
||||
},
|
||||
);
|
||||
return out.stream;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(String rel, Stream<List<int>> data, {int? length}) async {
|
||||
final c = await _open();
|
||||
final SftpFile f;
|
||||
try {
|
||||
f = await _cmd(c.sftp.open(_remote(c, rel),
|
||||
mode: SftpFileOpenMode.create | SftpFileOpenMode.truncate | SftpFileOpenMode.write));
|
||||
} catch (e) {
|
||||
_fail(e, rel);
|
||||
}
|
||||
try {
|
||||
// 관리자 판단 (10/20): 서버가 받지 않아 정한 시간 동안 한 조각도 더 보내지 못하면 실패 (그 연결을 닫음)
|
||||
await idleWrite(data, (x) => f.write(x.map((y) => y is Uint8List ? y : Uint8List.fromList(y))).done, onTimeout: close);
|
||||
} catch (e) {
|
||||
_fail(e, rel);
|
||||
} finally {
|
||||
await _cmd(f.close()).catchError((_) {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<(int?, int?)> quota() async {
|
||||
final c = await _open();
|
||||
try {
|
||||
final v = await _cmd(c.sftp.statvfs(c.root));
|
||||
final unit = v.fundamentalBlockSize > 0 ? v.fundamentalBlockSize : v.blockSize;
|
||||
return (v.freeBlocksForNonRoot * unit, (v.totalBlocks - v.freeBlocks) * unit);
|
||||
} catch (_) {
|
||||
// statvfs 를 지원하지 않는 서버 (OpenSSH 확장) - 모름
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {
|
||||
final c = _conn;
|
||||
_conn = null;
|
||||
c?.then((x) => x.ssh.close(), onError: (_) {});
|
||||
}
|
||||
|
||||
/// 오래된 서버용 (88-3): 기본 알고리즘 + ssh-rsa (SHA-1) 서명 · SHA-1 키 교환
|
||||
static const legacyAlgorithms = SSHAlgorithms(
|
||||
kex: [
|
||||
SSHKexType.x25519Rfc,
|
||||
SSHKexType.x25519,
|
||||
SSHKexType.nistp521,
|
||||
SSHKexType.nistp384,
|
||||
SSHKexType.nistp256,
|
||||
SSHKexType.dhGexSha256,
|
||||
SSHKexType.dh14Sha256,
|
||||
SSHKexType.dh14Sha1,
|
||||
SSHKexType.dhGexSha1,
|
||||
],
|
||||
hostkey: [
|
||||
SSHHostkeyType.ed25519,
|
||||
SSHHostkeyType.rsaSha512,
|
||||
SSHHostkeyType.rsaSha256,
|
||||
SSHHostkeyType.ecdsa521,
|
||||
SSHHostkeyType.ecdsa384,
|
||||
SSHHostkeyType.ecdsa256,
|
||||
SSHHostkeyType.rsaSha1,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _Conn {
|
||||
final SSHClient ssh;
|
||||
final SftpClient sftp;
|
||||
|
||||
/// 탭의 맨 위 (서버의 절대 경로)
|
||||
final String root;
|
||||
_Conn(this.ssh, this.sftp, this.root);
|
||||
}
|
||||
|
||||
/// dart:io Socket → dartssh2 (연결은 우리가 열어 DNS 강제를 따르게)
|
||||
class _Socket implements SSHSocket {
|
||||
final Socket _s;
|
||||
_Socket(this._s) {
|
||||
// 서버가 먼저 끊은 뒤의 쓰기 실패 (호스트 키를 믿지 않아 끊음 등) 가 처리되지 않은 오류로 앱에 올라오지 않게.
|
||||
// dartssh2 는 같은 Future 를 기다려 끊김을 그대로 안다
|
||||
unawaited(_s.done.then<void>((_) {}, onError: (Object _) {}));
|
||||
}
|
||||
/// 소켓 오류 (상대가 끊음 · 쓰기 실패) 는 끊김으로 (dartssh2 는 읽는 쪽의 오류를 받지 않는다 - 처리되지 않은 오류가 되었다)
|
||||
@override
|
||||
late final Stream<Uint8List> stream =
|
||||
_s.transform(StreamTransformer<Uint8List, Uint8List>.fromHandlers(handleError: (e, st, out) => out.close()));
|
||||
@override
|
||||
StreamSink<List<int>> get sink => _s;
|
||||
// 닫기 · 비우기의 쓰기 실패도 같은 끊김이다 (dartssh2 가 기다리지 않는 Future - 처리되지 않은 오류가 되지 않게)
|
||||
@override
|
||||
Future<void> close() => _s.close().then<void>((_) {}, onError: (Object _) {});
|
||||
@override
|
||||
Future<void> get done => _s.done;
|
||||
@override
|
||||
void destroy() => _s.destroy();
|
||||
@override
|
||||
Future<void> flush() => _s.flush().then<void>((_) {}, onError: (Object _) {});
|
||||
}
|
||||
|
||||
/// 설정의 SFTP 서버 목록과 클라이언트 (서버마다 하나)
|
||||
class SftpRegistry {
|
||||
static final _clients = <String, SftpRemote>{};
|
||||
static final _servers = <String, SftpServer>{};
|
||||
|
||||
/// 연결 방법 (main 에서 DNS 강제를 따르게 바꾼다). 처음 값: 기기 DNS
|
||||
static SftpConnect connect = (host, port, timeout) => Socket.connect(host, port, timeout: timeout);
|
||||
|
||||
/// 정한 DNS 로 이름을 풀어 연결 (집 안 이름 · 주소는 기기 DNS). [resolver] 가 DNS 강제가 아니면 기기 DNS
|
||||
static SftpConnect connectWithDns(DnsResolver resolver) => (host, port, timeout) async {
|
||||
if (InternetAddress.tryParse(host) != null || !resolver.config().forced || isLocalName(host)) {
|
||||
return Socket.connect(host, port, timeout: timeout);
|
||||
}
|
||||
final addrs = await resolver.lookup(host);
|
||||
Object? last;
|
||||
for (final a in [...addrs.where((a) => a.type == InternetAddressType.IPv4), ...addrs.where((a) => a.type != InternetAddressType.IPv4)]) {
|
||||
try {
|
||||
return await Socket.connect(a, port, timeout: timeout);
|
||||
} catch (e) {
|
||||
last = e;
|
||||
}
|
||||
}
|
||||
throw last ?? SocketException(trf('{0} 에 연결할 수 없습니다', [host]));
|
||||
};
|
||||
|
||||
static void configure(List<SftpServer> servers) {
|
||||
final keep = {for (final s in servers) s.id: s};
|
||||
for (final id in _clients.keys.toList()) {
|
||||
final old = _servers[id], now = keep[id];
|
||||
// 비밀 값은 toJson 에 없으므로 따로 비교 (바꾸면 바로 새 값으로 접속)
|
||||
if (now == null ||
|
||||
old == null ||
|
||||
old.password != now.password ||
|
||||
old.privateKey != now.privateKey ||
|
||||
old.keyPassphrase != now.keyPassphrase ||
|
||||
jsonEncode(old.toJson()) != jsonEncode(now.toJson())) {
|
||||
_clients.remove(id)?.close();
|
||||
}
|
||||
}
|
||||
_servers
|
||||
..clear()
|
||||
..addAll(keep);
|
||||
}
|
||||
|
||||
static List<SftpServer> get servers => _servers.values.toList();
|
||||
static SftpServer? server(String id) => _servers[id];
|
||||
|
||||
static SftpRemote client(String id) {
|
||||
final s = _servers[id];
|
||||
if (s == null) throw SftpException(trf('SFTP 서버 설정이 없습니다 ({0})', [id]), SftpFailure.other);
|
||||
return _clients.putIfAbsent(id, () => SftpRemote(s));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dartssh2/dartssh2.dart';
|
||||
|
||||
import '../../l10n/tr.dart';
|
||||
import '../webdav.dart' show DavException;
|
||||
|
||||
// 약속 (나누기 sftp): SFTP 서버 설정 (저장 꼴) · 실패 종류 · 오류 · 개인 키 읽기 · 호스트 키 지문.
|
||||
// 입력: 설정 JSON (비밀 값 없음) · 개인 키 글 + 키 암호 · dartssh2 가 넘긴 지문 바이트. 출력: [SftpServer] · 키 · "SHA256:…" 지문.
|
||||
// 오류: 키 형식 · 암호 없음 · 암호 틀림 → [SftpException] ([SftpFailure.keyFormat] · [SftpFailure.keyPassphrase]). 그 밖은 던지지 않는다.
|
||||
|
||||
/// 88: SFTP 서버 설정 (환경 설정 > 파일 탐색기 > 네트워크 서버). 비밀번호 · 개인 키 · 키 암호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음).
|
||||
/// 호스트 키 (지문) 는 비밀이 아니라 설정에 둔다 - 처음 연결 때 사용자가 믿은 것 (TOFU).
|
||||
class SftpServer {
|
||||
final String id;
|
||||
final String name;
|
||||
final String host;
|
||||
final int port;
|
||||
final String user;
|
||||
|
||||
/// 시작 폴더 (서버 기준 절대 경로). 비우면 로그인한 사용자의 홈 폴더. 탭의 "맨 위" 가 이 폴더다
|
||||
final String root;
|
||||
final String password;
|
||||
|
||||
/// 개인 키 (OpenSSH · PEM 글). 파일을 고르면 내용을 여기로 옮기고 원래 파일 경로는 저장하지 않는다
|
||||
final String privateKey;
|
||||
|
||||
/// 개인 키의 암호 (사용자 결정 88-2: 안전 저장소에 저장)
|
||||
final String keyPassphrase;
|
||||
|
||||
/// 오래된 ssh-rsa (SHA-1) 서명 · SHA-1 키 교환 허용 (사용자 결정 88-3: 처음 끔, 서버마다 켬 - 예전 NAS 용)
|
||||
final bool legacyRsa;
|
||||
|
||||
/// 믿은 호스트 키: 종류 ("ssh-ed25519" 등) 와 지문 ("SHA256:…"). 비어 있으면 아직 믿은 키가 없음
|
||||
final String hostKeyType;
|
||||
final String hostKeyFingerprint;
|
||||
|
||||
const SftpServer({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.host,
|
||||
this.port = 22,
|
||||
this.user = '',
|
||||
this.root = '',
|
||||
this.password = '',
|
||||
this.privateKey = '',
|
||||
this.keyPassphrase = '',
|
||||
this.legacyRsa = false,
|
||||
this.hostKeyType = '',
|
||||
this.hostKeyFingerprint = '',
|
||||
});
|
||||
|
||||
/// 탐색기 위쪽에 보일 이름
|
||||
String get label => name.trim().isNotEmpty ? name.trim() : host;
|
||||
|
||||
SftpServer copyWith({
|
||||
String? name,
|
||||
String? host,
|
||||
int? port,
|
||||
String? user,
|
||||
String? root,
|
||||
String? password,
|
||||
String? privateKey,
|
||||
String? keyPassphrase,
|
||||
bool? legacyRsa,
|
||||
String? hostKeyType,
|
||||
String? hostKeyFingerprint,
|
||||
}) =>
|
||||
SftpServer(
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
host: host ?? this.host,
|
||||
port: port ?? this.port,
|
||||
user: user ?? this.user,
|
||||
root: root ?? this.root,
|
||||
password: password ?? this.password,
|
||||
privateKey: privateKey ?? this.privateKey,
|
||||
keyPassphrase: keyPassphrase ?? this.keyPassphrase,
|
||||
legacyRsa: legacyRsa ?? this.legacyRsa,
|
||||
hostKeyType: hostKeyType ?? this.hostKeyType,
|
||||
hostKeyFingerprint: hostKeyFingerprint ?? this.hostKeyFingerprint,
|
||||
);
|
||||
|
||||
/// 비밀 값은 넣지 않는다 (안전 저장소). 저장해 두었는지만
|
||||
Map<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'host': host,
|
||||
'port': port,
|
||||
'user': user,
|
||||
'root': root,
|
||||
'hasPassword': password.isNotEmpty,
|
||||
'hasKey': privateKey.isNotEmpty,
|
||||
'legacyRsa': legacyRsa,
|
||||
'hostKeyType': hostKeyType,
|
||||
'hostKeyFingerprint': hostKeyFingerprint,
|
||||
};
|
||||
|
||||
factory SftpServer.fromJson(Map<Object?, Object?> j) => SftpServer(
|
||||
id: '${j['id'] ?? DateTime.now().microsecondsSinceEpoch}',
|
||||
name: j['name'] as String? ?? '',
|
||||
host: j['host'] as String? ?? '',
|
||||
port: switch (j['port']) { final int p when p > 0 && p < 65536 => p, _ => 22 },
|
||||
user: j['user'] as String? ?? '',
|
||||
root: j['root'] as String? ?? '',
|
||||
legacyRsa: j['legacyRsa'] == true,
|
||||
hostKeyType: j['hostKeyType'] as String? ?? '',
|
||||
hostKeyFingerprint: j['hostKeyFingerprint'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// SFTP 실패의 종류 (안내 글 · 할 일 단추를 고르는 데)
|
||||
/// [keyFormat] · [keyPassphrase] · [keyRejected]: SSH 키 로그인의 실패를 따로 (관리자 지시 10/21 - 연결 확인에서 키 형식 · 암호 틀림 · 서버 거부를 나눠 알림)
|
||||
enum SftpFailure { unreachable, auth, noSecret, hostKeyUnknown, hostKeyChanged, permission, notFound, keyFormat, keyPassphrase, keyRejected, other }
|
||||
|
||||
/// 개인 키 글이 쓸 수 있는 꼴인지 본다 (OpenSSH · PEM RSA · PEM EC - dartssh2 가 읽는 것). 암호가 걸려 있으면 true.
|
||||
/// 쓸 수 없으면 [SftpException] ([SftpFailure.keyFormat]) - PuTTY (.ppk) · PKCS#8 (BEGIN PRIVATE KEY) · 공개 키는 바꾸는 법을 알린다
|
||||
bool checkPrivateKey(String text) {
|
||||
if (text.contains('PuTTY-User-Key-File')) throw SftpException(tr('PuTTY 키 (.ppk) 는 쓸 수 없습니다. PuTTYgen 의 [Conversions] > [Export OpenSSH key] 로 바꾼 파일을 고르세요'), SftpFailure.keyFormat);
|
||||
try {
|
||||
return SSHKeyPair.isEncryptedPem(text);
|
||||
} catch (_) {
|
||||
throw SftpException(tr('개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)'), SftpFailure.keyFormat);
|
||||
}
|
||||
}
|
||||
|
||||
/// 개인 키 글 + 키 암호 → 로그인에 쓸 키. 키 형식 · 암호 없음 · 암호 틀림을 따로 알린다
|
||||
List<SSHKeyPair> parsePrivateKey(String text, String passphrase) {
|
||||
final encrypted = checkPrivateKey(text);
|
||||
if (encrypted && passphrase.isEmpty) throw SftpException(tr('개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요'), SftpFailure.keyPassphrase);
|
||||
try {
|
||||
return SSHKeyPair.fromPem(text, encrypted ? passphrase : null);
|
||||
} catch (_) {
|
||||
// 암호가 걸린 키를 풀지 못함 = 암호 틀림 (PEM 은 틀린 암호로 풀면 다른 오류가 나기도 한다)
|
||||
if (encrypted) throw SftpException(tr('개인 키의 암호가 맞지 않습니다'), SftpFailure.keyPassphrase);
|
||||
throw SftpException(tr('개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)'), SftpFailure.keyFormat);
|
||||
}
|
||||
}
|
||||
|
||||
/// SFTP 오류 - WebDAV 오류와 같은 곳에서 보이도록 [DavException] 을 잇는다
|
||||
class SftpException extends DavException {
|
||||
final SftpFailure kind;
|
||||
const SftpException(super.message, this.kind);
|
||||
}
|
||||
|
||||
/// 처음 보는 호스트 키 (또는 바뀐 키): 화면이 지문을 보이고 믿을지 묻는다. 믿으면 [SftpServer.hostKeyFingerprint] 에 저장
|
||||
class SftpHostKeyException extends SftpException {
|
||||
final String type;
|
||||
final String fingerprint;
|
||||
|
||||
/// 바뀐 경우 예전에 믿은 지문 (처음이면 null)
|
||||
final String? previous;
|
||||
const SftpHostKeyException(super.message, super.kind, {required this.type, required this.fingerprint, this.previous});
|
||||
bool get changed => previous != null;
|
||||
}
|
||||
|
||||
/// OpenSSH 와 같은 꼴의 지문 "SHA256:" + base64 (끝 '=' 없음). dartssh2 는 이 글을 바이트로 넘긴다 -
|
||||
/// 판이 바뀌어 SHA-256 값 그대로 오면 직접 만든다
|
||||
String sshFingerprint(Uint8List fp) {
|
||||
final text = utf8.decode(fp, allowMalformed: true);
|
||||
return text.startsWith('SHA256:') ? text : 'SHA256:${base64.encode(fp).replaceAll('=', '')}';
|
||||
}
|
||||
|
||||
/// 연결할 서버 주소. DNS 강제면 정한 DNS 로 (집 안 이름 · 주소는 기기 DNS) - 앱의 다른 연결과 같게
|
||||
typedef SftpConnect = Future<Socket> Function(String host, int port, Duration timeout);
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../files/path_rules.dart' show appTempRsyncExcludes;
|
||||
import 'rsync_options.dart';
|
||||
|
||||
// 약속 (나누기 sync_tools): 복사 방법 (앱 · rsync · robocopy) 과 그 명령 인수 - rsync 인수 · Windows 경로를 MSYS2 꼴로 ·
|
||||
// robocopy 실행 묶음 · 성공 종료 코드 · Windows 용 rsync 패키지 (MSYS2, SHA256 고정).
|
||||
// 입력: 옵션 글 · 원본들 · 대상 · 속도 제한 · 이동인지. 출력: 인수 목록 (실행은 부르는 쪽). 오류: 던지지 않는다.
|
||||
|
||||
/// 파일 탐색기의 복사 · 이동 방법 (환경 설정 > 파일 탐색기 > 복사 · 동기화)
|
||||
///
|
||||
/// - builtin: 앱이 직접 복사 ("현재 방식"). 모든 기기.
|
||||
/// - rsync: rsync (Windows 는 MSYS2 의 rsync 를 처음 쓸 때 내려받음, Android 는 직접 지정한 실행 파일)
|
||||
/// - robocopy: Windows 에 들어 있는 robocopy (Windows 만)
|
||||
enum CopyMethod {
|
||||
builtin('현재 방식'),
|
||||
rsync('rsync'),
|
||||
robocopy('robocopy');
|
||||
|
||||
final String label;
|
||||
const CopyMethod(this.label);
|
||||
|
||||
static CopyMethod of(String? name) => values.firstWhere((m) => m.name == name, orElse: () => builtin);
|
||||
}
|
||||
|
||||
const defaultRsyncOptions = '-avPog';
|
||||
const defaultRobocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2';
|
||||
|
||||
/// 관리자 판단 (10/20, 28 확인): rsync (Windows MSYS2) 는 목록 · 기록 · 배치 파일의 C:\… · C:/… · \\서버\… 를 열지 못한다
|
||||
/// ("failed to open exclude file", exit 11) - 그 값들을 경로처럼 [toCygwinPath] 로 바꿔 넘긴다 (막지 않음).
|
||||
/// 대상: --exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · "merge 파일" · --log-file · --write-batch · --read-batch · --only-write-batch
|
||||
List<String> rsyncFileOptionArgs(List<String> words, {required bool windows}) {
|
||||
if (!windows) return words;
|
||||
const opts = {
|
||||
'--exclude-from', '--include-from', '--files-from', '--log-file', '--write-batch', '--read-batch', '--only-write-batch',
|
||||
};
|
||||
// 28 · 15 확인 (10/20, MSYS 3.5.1): 이 파일들은 UNC 를 \\서버\… 로만 연다 (//서버/… 는 "failed to open", exit 11) - UNC 는 \\ 로,
|
||||
// 드라이브는 /cygdrive/… 로
|
||||
String conv(String v) => RegExp(r'^[A-Za-z]:[\\/]').hasMatch(v)
|
||||
? toCygwinPath(v)
|
||||
: RegExp(r'^(?:\\\\|//)(?!cygdrive/)').hasMatch(v)
|
||||
? v.replaceAll('/', '\\')
|
||||
: v;
|
||||
String rule(String r) {
|
||||
final m = RegExp(r'^(\s*(?:\.|merge)(?:,[^\s]*)?\s+)(.+)$').firstMatch(r);
|
||||
return m == null ? r : '${m[1]}${conv(m[2]!)}';
|
||||
}
|
||||
|
||||
final out = <String>[];
|
||||
for (var i = 0; i < words.length; i++) {
|
||||
final w = words[i];
|
||||
final next = i + 1 < words.length ? words[i + 1] : null;
|
||||
final eq = w.indexOf('=');
|
||||
if (w.startsWith('--') && eq > 0 && opts.contains(w.substring(0, eq))) {
|
||||
out.add('${w.substring(0, eq + 1)}${conv(w.substring(eq + 1))}');
|
||||
} else if (opts.contains(w) && next != null) {
|
||||
out
|
||||
..add(w)
|
||||
..add(conv(next));
|
||||
i++;
|
||||
} else if (w.startsWith('--filter=')) {
|
||||
out.add('--filter=${rule(w.substring(9))}');
|
||||
} else if (w == '--filter' && next != null) {
|
||||
out
|
||||
..add(w)
|
||||
..add(rule(next));
|
||||
i++;
|
||||
} else if (!w.startsWith('--') && w.length > 1 && w.startsWith('-')) {
|
||||
// 짧은 옵션 묶음의 f: 붙은 값 (-f". 목록") 또는 다음 낱말 (-avf ". 목록")
|
||||
final k = w.indexOf(RegExp('[BefMT@]'), 1);
|
||||
if (k >= 0 && w[k] == 'f' && k < w.length - 1) {
|
||||
out.add('${w.substring(0, k + 1)}${rule(w.substring(k + 1))}');
|
||||
} else if (k >= 0 && w[k] == 'f' && next != null) {
|
||||
out
|
||||
..add(w)
|
||||
..add(rule(next));
|
||||
i++;
|
||||
} else {
|
||||
out.add(w);
|
||||
// 다른 값 받는 글자 (-e ssh 등) 의 값은 그대로 건너뜀
|
||||
if (k == w.length - 1 && next != null) {
|
||||
out.add(next);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out.add(w);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Windows 경로 → MSYS2 rsync 가 읽는 경로 (C:\a\b → /cygdrive/c/a/b, \\server\share\x → //server/share/x).
|
||||
/// MSYS2 rsync 는 "C:" 를 원격 컴퓨터 이름으로 읽으므로 꼭 바꿔야 한다.
|
||||
/// 받는 쪽 (대상) 경로를 rsync 에 넘길 꼴로. 28 · 15 확인 (10/20, MSYS 3.5.1): 받는 쪽의 UNC 는 \\서버\… 만 된다
|
||||
/// (//서버/… 는 change_dir 실패, exit 3) - 원본은 거꾸로 //서버/… 만 되어 [toCygwinPath] 그대로
|
||||
String rsyncDestPath(String path, {bool windows = true}) {
|
||||
if (windows && RegExp(r'^(?:\\\\|//)(?!cygdrive/)').hasMatch(path)) return path.replaceAll('/', '\\');
|
||||
return toCygwinPath(path, windows: windows);
|
||||
}
|
||||
|
||||
String toCygwinPath(String path, {bool windows = true}) {
|
||||
if (!windows) return path;
|
||||
final s = path.replaceAll('\\', '/');
|
||||
final m = RegExp(r'^([A-Za-z]):(/.*)?$').firstMatch(s);
|
||||
if (m != null) return '/cygdrive/${m[1]!.toLowerCase()}${m[2] ?? '/'}';
|
||||
return s; // //server/share 는 그대로 읽는다
|
||||
}
|
||||
|
||||
/// rsync 옵션에 -u (--update: 받는 쪽이 더 새 파일은 건너뜀) 를 더한다. 이미 있으면 그대로 (-avu 처럼 묶인 것도).
|
||||
/// 양쪽 (⇄) 으로 함께 맞출 때 서로의 새 파일을 옛 파일로 덮어쓰지 않게.
|
||||
String withUpdateOption(String options) {
|
||||
final has = splitOptions(options).any((o) => o == '--update' || (o.startsWith('-') && !o.startsWith('--') && o.contains('u')));
|
||||
return has ? options : '${options.trim()} -u'.trim();
|
||||
}
|
||||
|
||||
/// rsync 인수: [options] + (대역폭 · 이동) + 원본들 + 대상 폴더/
|
||||
/// 원본 폴더는 끝에 / 를 붙이지 않는다 (폴더째 대상 안으로 - 앱의 복사와 같은 결과).
|
||||
List<String> rsyncArgs({
|
||||
required String options,
|
||||
required List<String> sources,
|
||||
required String dest,
|
||||
int bandwidthKBps = 0,
|
||||
bool move = false,
|
||||
bool windows = true,
|
||||
bool contents = false,
|
||||
}) {
|
||||
// 178: 원본을 지우는 옵션은 [move] (이동 · [원본 파일 지우기]) 로만 - 옵션 글에 있어도 빼고, 이동일 때만 넣는다
|
||||
final opts = rsyncFileOptionArgs([for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o], windows: windows);
|
||||
return [
|
||||
...opts,
|
||||
if (bandwidthKBps > 0 && !opts.any((o) => o.startsWith('--bwlimit'))) '--bwlimit=$bandwidthKBps',
|
||||
// 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 출력 (Android 빌드는 iconv 없음 · 진행 표시에 쓰는 이름)
|
||||
if (!windows && !opts.contains('-8') && !opts.contains('--8-bit-output')) '-8',
|
||||
if (move) '--remove-source-files',
|
||||
...appTempRsyncExcludes, // 78 보충: 일괄 이름 바꾸기 중의 임시 이름 (~jjren_ · ~jjback_) 은 보내지 않는다
|
||||
// [contents]: 폴더 "안의 것" 을 대상에 (rsync 의 끝 / - 동기화 · lsync 와 같은 모양)
|
||||
for (final s in sources) '${toCygwinPath(_noTrailingSlash(s), windows: windows)}${contents ? '/' : ''}',
|
||||
'${rsyncDestPath(_noTrailingSlash(dest), windows: windows)}/',
|
||||
];
|
||||
}
|
||||
|
||||
String _noTrailingSlash(String s) => s.length > 3 && (s.endsWith('/') || s.endsWith('\\')) ? s.substring(0, s.length - 1) : s;
|
||||
|
||||
/// 원본을 지우는 rsync · robocopy 옵션인지 (--remove-source-files · --remove-sent-files · /MOV · /MOVE) - 107 · 178
|
||||
bool isSourceRemovingOption(String o) =>
|
||||
o == '--remove-source-files' || o == '--remove-sent-files' || o.toUpperCase() == '/MOV' || o.toUpperCase() == '/MOVE';
|
||||
|
||||
/// robocopy 한 번 실행할 인수 (robocopy 는 "폴더 → 폴더" 라 원본마다 따로 만든다).
|
||||
/// - 폴더: robocopy <폴더> <대상>\<폴더 이름> [옵션]
|
||||
/// - 파일: robocopy <파일이 있는 폴더> <대상> <파일 이름들…> [옵션] (같은 폴더의 파일은 한 번에)
|
||||
/// 대역폭: robocopy 에는 속도 제한이 없어 /IPG (조각 사이 쉬는 시간) 로 비슷하게 맞춘다.
|
||||
List<List<String>> robocopyRuns({
|
||||
required String options,
|
||||
required List<String> folders,
|
||||
required List<String> files,
|
||||
required String dest,
|
||||
int bandwidthKBps = 0,
|
||||
bool move = false,
|
||||
bool contents = false,
|
||||
bool skipNewer = false,
|
||||
}) {
|
||||
// 178: 원본을 지우는 옵션 (/MOV · /MOVE) 은 [move] 로만
|
||||
final opts = [for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o];
|
||||
final extra = robocopyExtraOptions(opts, move: move, skipNewer: skipNewer, bandwidthKBps: bandwidthKBps);
|
||||
final runs = <List<String>>[
|
||||
for (final f in folders) [f, contents ? dest : p.join(dest, p.basename(f)), ...opts, ...extra],
|
||||
];
|
||||
final byDir = <String, List<String>>{};
|
||||
for (final f in files) {
|
||||
byDir.putIfAbsent(p.dirname(f), () => []).add(p.basename(f));
|
||||
}
|
||||
// 파일만 옮길 때 /E · /MOVE 는 하위 폴더까지 건드리므로 뺀다
|
||||
final fileOpts = opts.where((o) => !const ['/E', '/S', '/MIR'].contains(o.toUpperCase())).toList();
|
||||
final fileExtra = extra.map((o) => o == '/MOVE' ? '/MOV' : o).toList();
|
||||
byDir.forEach((dir, names) => runs.add([dir, dest, ...names, ...fileOpts, ...fileExtra]));
|
||||
return runs;
|
||||
}
|
||||
|
||||
/// 앱이 옵션 칸 ([opts], 원본 지우기 옵션은 뺀 것) 뒤에 붙이는 robocopy 옵션 - 실행 (robocopyRuns) 과 화면의 명령 줄이 같게
|
||||
List<String> robocopyExtraOptions(List<String> opts, {bool move = false, bool skipNewer = false, int bandwidthKBps = 0}) => [
|
||||
if (bandwidthKBps > 0 && !opts.any((o) => o.toUpperCase().startsWith('/IPG'))) '/IPG:${robocopyIpg(bandwidthKBps)}',
|
||||
if (move) ...robocopyMoveOptions(opts),
|
||||
// 데이터: 링크 · junction 은 따라가지 않는다 (복사 · 이동 모두 처음 값 - rsync -a 가 링크를 링크로 두는 것처럼).
|
||||
// 따라가면 원본 바깥 폴더의 파일을 옮기고 지우거나 (/MOVE), 고리 junction (AppData 의 "Application Data" 등) 을 끝없이 돈다.
|
||||
// 옵션 칸에 사용자가 링크 옵션을 넣었으면 그대로
|
||||
if (!robocopyLinkOption(opts)) '/XJ',
|
||||
// 229: 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" - robocopy 는 기본으로 예전 원본 (Older) 도 덮어쓴다
|
||||
if (skipNewer && !opts.any((o) => o.toUpperCase() == '/XO')) '/XO',
|
||||
];
|
||||
|
||||
/// 옵션 칸에 링크 · junction 을 다루는 robocopy 옵션이 있는지 (/XJ · /XJD · /XJF · /SJ · /SL)
|
||||
bool robocopyLinkOption(List<String> opts) => opts.any((o) => const ['/XJ', '/XJD', '/XJF', '/SJ', '/SL'].contains(o.toUpperCase()));
|
||||
|
||||
/// robocopy 가 원본의 링크 · junction 을 건너뛰는지 (미리 보기) - 사용자가 /SJ · /SL 로 링크째 복사를 고르지 않았으면
|
||||
bool robocopySkipsLinks(List<String> opts) => !opts.any((o) => const ['/SJ', '/SL'].contains(o.toUpperCase()));
|
||||
|
||||
/// 이동에 붙이는 robocopy 옵션: /MOVE + 같은 파일도 포함.
|
||||
/// 217: 대상에 같은 파일이 있으면 robocopy 는 건너뛰고 원본에 남긴다 (종료 코드 0 - "옮겼습니다" 인데 남음, Windows 에서 확인).
|
||||
/// 같음 (/IS) 뿐 아니라 속성만 다름 (Tweaked, /IT) · 만든 시각만 다름 (Modified, /IM - 다른 프로그램으로 복사한 같은 파일) 도 넣어 원본에서 지운다.
|
||||
/// 그래도 남는 것은 이동 뒤 다시 세어 알린다 (216). 링크 · junction 은 [robocopyExtraOptions] 의 /XJ 로 남긴다
|
||||
/// (robocopy 는 기본으로 junction 을 따라가 /MOVE 가 링크가 가리키는 원본 바깥 폴더의 파일까지 옮기고 지웠다 - Windows 에서 확인)
|
||||
List<String> robocopyMoveOptions(List<String> opts) => [
|
||||
'/MOVE',
|
||||
for (final o in const ['/IS', '/IT', '/IM'])
|
||||
if (!opts.any((x) => x.toUpperCase() == o)) o,
|
||||
];
|
||||
|
||||
/// robocopy /IPG 밀리초: robocopy 는 64KB 조각마다 쉰다 → 64 / KB/s 초
|
||||
int robocopyIpg(int kbps) => kbps <= 0 ? 0 : (64 * 1000 / kbps).round().clamp(1, 60000);
|
||||
|
||||
/// rsync · robocopy 의 성공 종료 코드
|
||||
bool rsyncOk(int code) => code == 0 || code == 24; // 24: 복사 도중 원본 파일이 사라짐
|
||||
bool robocopyOk(int code) => code >= 0 && code < 8;
|
||||
|
||||
/// Windows 용 rsync: MSYS2 공식 저장소의 패키지 (처음 쓸 때 내려받음, 파일 SHA256 을 고정해 검증)
|
||||
const msys2Repo = 'https://repo.msys2.org/msys/x86_64/';
|
||||
const msys2RsyncPackages = <(String, String)>[
|
||||
('rsync-3.5.1-2-x86_64.pkg.tar.zst', 'cba52e41d16f9873324fcf09853619964da87bc1ce116781a5449c968d4ffecc'),
|
||||
('msys2-runtime-3.6.10-6-x86_64.pkg.tar.zst', 'b2db5bae3826f15cacb536f4c7c6c7e31d3fabec00aac8f127473aba7e994d22'),
|
||||
('gcc-libs-15.3.0-1-x86_64.pkg.tar.zst', '0d99a122c453c05ae21ba3dcea910f2e0d93d38ae067c677a112a315b0f3cec5'),
|
||||
('libiconv-1.19-1-x86_64.pkg.tar.zst', '0fa55ea2a6ccf97cf8c58b24b2615815e15e16e6e4e888091c263c2c83c5313d'),
|
||||
('libidn2-2.3.8-1-x86_64.pkg.tar.zst', '55e0de96b893e9f67f14a4283d411fc2a45d7b59333bc16e2a9b16e5bfc9282f'),
|
||||
('libintl-0.22.5-1-x86_64.pkg.tar.zst', '336d66b9d95cf9c1804958f8e260762a3e83bf158ed5981f783bc772a31073cf'),
|
||||
('liblz4-1.10.0-1-x86_64.pkg.tar.zst', '1cbbe51ee91bf32691aabb15bdb7c8548e8b5ef8195122ef31f10edcca6e952f'),
|
||||
('libopenssl-3.6.5-1-x86_64.pkg.tar.zst', '05f6cc946f0dde3aecc3632007fa1d874e40720226117b750327bc2a01846325'),
|
||||
('libunistring-1.4.2-1-x86_64.pkg.tar.zst', 'bcd04706d95b5e7127d39834565bceed6b400192f5fb51599745aba28d5da018'),
|
||||
('libxxhash-0.8.4-1-x86_64.pkg.tar.zst', 'ba784ad117626401cd5d3ec0cf70399f9de72a63755f96c28fa2032303f9c330'),
|
||||
('libzstd-1.5.7-1-x86_64.pkg.tar.zst', '5e370b2e725a0a7e5ff58d84653c9a0cd5a10b9cfe930f2d949a28ad2a11c1f2'),
|
||||
('popt-1.19-1-x86_64.pkg.tar.zst', 'b402bd1d03815c5e28b7ab415d8e20f487eff2460ae0271a99674eeb0d77194f'),
|
||||
];
|
||||
|
||||
/// 패키지에서 꺼낼 파일 (rsync.exe 와 필요한 DLL 만)
|
||||
const msys2RsyncFiles = {
|
||||
'rsync.exe', 'msys-2.0.dll', 'msys-gcc_s-seh-1.dll', 'msys-iconv-2.dll', 'msys-idn2-0.dll', 'msys-intl-8.dll',
|
||||
'msys-lz4-1.dll', 'msys-crypto-3.dll', 'msys-ssl-3.dll', 'msys-unistring-5.dll', 'msys-xxhash-0.dll',
|
||||
'msys-zstd-1.dll', 'msys-popt-0.dll', 'msys-charset-1.dll',
|
||||
};
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
// 약속 (나누기 sync_tools): rsync · robocopy 출력 읽기 - 조각을 받을 때마다 "다 보낸 파일" 줄과 진행률 · 속도 · 확인한 수.
|
||||
// 입력: 출력 조각 (줄 중간에서 끊겨도 됨 - 남은 것은 다음 조각과 이어 읽음). 출력: 다 보낸 파일 (원본 기준 상대 경로) 목록.
|
||||
// 오류: 던지지 않는다 (모르는 줄은 건너뜀).
|
||||
|
||||
/// rsync -v 출력에서 "다 보낸 파일" 줄을 골라낸다 (-P 의 \r 진행 줄 · 머리글 · 요약 · 폴더 줄은 뺌).
|
||||
/// 결과: 원본 기준 상대 경로 (예: "sub/inner.txt")
|
||||
class RsyncOutput {
|
||||
final _buf = StringBuffer();
|
||||
|
||||
/// 지금 파일의 진행률 (-P 의 " 1,234 45% 1.2MB/s" 줄)
|
||||
double? currentPercent;
|
||||
|
||||
/// 지금 전송 속도 (바이트/초, 같은 줄의 "1.2MB/s")
|
||||
double? currentSpeed;
|
||||
|
||||
/// 폴더 전체에서 확인한 항목 (진행 줄의 "to-chk=남은/전체" · "ir-chk=…"). 모르면 null.
|
||||
int? checked, checkTotal;
|
||||
|
||||
static final _chkRx = RegExp(r'(?:to|ir)-chk=(\d+)/(\d+)');
|
||||
static final _speedRx = RegExp(r'(\d+(?:\.\d+)?)([kKMGT]?)B/s');
|
||||
|
||||
List<String> feed(String chunk) {
|
||||
_buf.write(chunk);
|
||||
final text = _buf.toString();
|
||||
final cut = text.lastIndexOf(RegExp(r'[\r\n]'));
|
||||
if (cut < 0) return const [];
|
||||
_buf
|
||||
..clear()
|
||||
..write(text.substring(cut + 1));
|
||||
final done = <String>[];
|
||||
for (final raw in text.substring(0, cut).split(RegExp(r'[\r\n]'))) {
|
||||
final line = raw.trimRight();
|
||||
if (line.isEmpty) continue;
|
||||
if (line.startsWith(' ')) {
|
||||
final m = RegExp(r'\s(\d{1,3})%\s').firstMatch(line);
|
||||
if (m != null) currentPercent = int.parse(m[1]!) / 100;
|
||||
final ck = _chkRx.firstMatch(line);
|
||||
if (ck != null) {
|
||||
checkTotal = int.parse(ck[2]!);
|
||||
checked = checkTotal! - int.parse(ck[1]!);
|
||||
}
|
||||
final sp = _speedRx.firstMatch(line);
|
||||
if (sp != null) {
|
||||
const mul = {'': 1, 'k': 1024, 'K': 1024, 'M': 1024 * 1024, 'G': 1024 * 1024 * 1024, 'T': 1024 * 1024 * 1024 * 1024};
|
||||
currentSpeed = double.parse(sp[1]!) * mul[sp[2]!]!;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (line.endsWith('/')) continue; // 폴더
|
||||
if (line.startsWith('sending ') ||
|
||||
line.startsWith('receiving ') ||
|
||||
line.startsWith('sent ') ||
|
||||
line.startsWith('total size') ||
|
||||
line.startsWith('created directory') ||
|
||||
line.startsWith('deleting ') ||
|
||||
line.startsWith('rsync:') ||
|
||||
line.startsWith('rsync error') ||
|
||||
line.contains(' -> ')) {
|
||||
continue;
|
||||
}
|
||||
currentPercent = null;
|
||||
done.add(line);
|
||||
}
|
||||
return done;
|
||||
}
|
||||
}
|
||||
|
||||
/// robocopy 출력에서 파일 줄 ("New File", "Newer", "Older", "Changed" … 뒤의 크기 · 이름) 을 센다
|
||||
class RobocopyOutput {
|
||||
final _buf = StringBuffer();
|
||||
double? currentPercent;
|
||||
|
||||
/// 파일 줄: 탭 · 종류 · 탭 탭 · 크기 · 탭 · 경로. 61: 종류 글은 Windows 언어마다 다르므로
|
||||
/// ("New File" · "새 파일" · "新しいファイル" · "新文件") 글이 아니라 탭 모양으로 알아본다.
|
||||
/// 종류가 * 로 시작하면 (*EXTRA File 등) 대상에만 있는 것이라 세지 않는다.
|
||||
static final _file = RegExp(r'^\t+\s*([^\t\s\d][^\t]*?)\s*\t+\s*[\d.]+\s*[kmgt]?\s*\t(.+)$', caseSensitive: false);
|
||||
|
||||
List<String> feed(String chunk) {
|
||||
_buf.write(chunk);
|
||||
final text = _buf.toString();
|
||||
final cut = text.lastIndexOf(RegExp(r'[\r\n]'));
|
||||
if (cut < 0) return const [];
|
||||
_buf
|
||||
..clear()
|
||||
..write(text.substring(cut + 1));
|
||||
final done = <String>[];
|
||||
for (final raw in text.substring(0, cut).split(RegExp(r'[\r\n]'))) {
|
||||
final line = raw.trim();
|
||||
final pct = RegExp(r'^(\d{1,3}(?:\.\d)?)%$').firstMatch(line);
|
||||
if (pct != null) {
|
||||
currentPercent = double.parse(pct[1]!) / 100;
|
||||
continue;
|
||||
}
|
||||
final m = _file.firstMatch(raw);
|
||||
if (m != null && !m[1]!.startsWith('*')) {
|
||||
currentPercent = null;
|
||||
done.add(m[2]!.trim());
|
||||
}
|
||||
}
|
||||
return done;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
import 'dart:io' show Platform;
|
||||
|
||||
import '../../l10n/tr.dart';
|
||||
|
||||
// 약속 (나누기 sync_tools): rsync 옵션 칸의 글 - 붙여 넣은 이상한 글자 고치기 · 인수로 나누기 · rsync 가 다르게 읽을 것 찾기 · 목록 파일 값.
|
||||
// 입력: 옵션 글 (사용자가 적거나 붙여 넣은 것) · 낱말 목록 · Windows 인지 (null 이면 이 기기).
|
||||
// 출력: 낱말 목록 · 문제 목록 ([OptionProblem]) · 고칠 방법 글. 오류: 던지지 않는다 (알아볼 수 없는 것은 문제 목록으로 돌려준다).
|
||||
|
||||
/// 관리자 지시 (10/20): 화면에서 줄바꿈 없이 묶은 옵션 글 (sync_preview_view 의 unbreakableOption) 의 줄바꿈 없는 빈칸 (U+00A0 등) ·
|
||||
/// 보이지 않는 글자 (U+2060 등) 가 복사 · 붙여 넣기로 옵션에 들어오면 rsync 가 다른 인수로 읽는다 ("-", 보이지 않는 글자, "-filter") → 보통 빈칸으로 / 지움.
|
||||
/// 다른 곳 (웹 페이지 · 문서) 에서 붙여 넣은 것도 같다
|
||||
/// 28 확인 (10/20): 소프트 하이픈 (U+00AD) 은 지우고 (rsync 가 "unknown option" 으로 실패), 전각 빈칸 (U+3000) 도 보통 빈칸으로
|
||||
String cleanOptionText(String s) => s
|
||||
.replaceAll(RegExp('[\u00A0\u2007\u202F\u3000]'), ' ')
|
||||
.replaceAll(RegExp('[\u00AD\u200B\u200C\u200D\u2060\uFEFF]'), '');
|
||||
|
||||
/// 낱말 맨 앞에서 - 로 읽는 대시 (‐ ‑ ‒ – — ― − ﹘ ﹣ -) - 워드 · 웹이 -- 를 바꿔 놓은 것
|
||||
const _dashLike = '\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uFE58\uFE63\uFF0D';
|
||||
|
||||
/// 굽은 따옴표 → 곧은 따옴표 (같은 무리끼리 열고 닫는다)
|
||||
const _curlyQuote = {'\u2018': "'", '\u2019': "'", '\u201A': "'", '\u201C': '"', '\u201D': '"', '\u201E': '"'};
|
||||
|
||||
/// 옵션 글자를 인수로 나눈다 (큰따옴표 · 작은따옴표로 묶은 것은 하나로). 먼저 [cleanOptionText].
|
||||
/// 관리자 지시 (10/20, 28 · 15 확인): 웹 · 문서에서 붙여 넣은 글을 곧은 글자로 쓴 것과 같게 읽는다 - 예전에는
|
||||
/// --exclude=‘*.log’ 가 아무것도 거르지 못해 받는 쪽 keep.log 가 지워졌고, –delete 는 원본 경로로 넘어가 --delete 가 몰래 빠졌다.
|
||||
/// - 굽은 따옴표 (‘ ’ “ ”) 는 낱말 맨 앞 · = 뒤 · 짧은 옵션 (-f) 뒤에서 열고, 같은 무리의 굽은 따옴표에서 닫는다
|
||||
/// (곧은 따옴표처럼 뒤에 글이 이어져도 - “a b”.txt → a b.txt). 작은따옴표 무리는 글자 사이 (it’s) 이면 닫지 않는다.
|
||||
/// 이름 안의 굽은 따옴표 (it’s.txt) 는 그대로 둔다.
|
||||
/// - 닫히지 않은 따옴표 (15 확인 F1): 예전에는 뒤의 옵션을 모두 삼켜 지키려던 --exclude 가 사라졌다 → 그 따옴표를 글자로 보고
|
||||
/// 나머지는 그대로 나누며, [unreadableOptions] 가 알려 실행을 막는다.
|
||||
/// - 낱말 맨 앞의 대시 (– — ‑ - 등, 따옴표 밖): 둘이면 --, 하나면 rsync 의 긴 옵션 이름 (–delete) 일 때만 -- 그 밖 (–avz) 은 -.
|
||||
/// 옵션의 값 자리 (--exclude –x.txt 의 –x.txt) · 낱말 가운데의 대시는 그대로 (파일 이름). --filter · -f 값의 규칙 머리 (“– *.tmp”) 는 - 로.
|
||||
List<String> splitOptions(String s) => _parseOptions(s).words;
|
||||
|
||||
/// rsync 3.x 의 긴 옵션 이름 (앱에 든 rsync 3.5.1 의 --help) - 대시 하나로 붙여 넣은 –delete 를 --delete 로 읽을지 정한다
|
||||
const _rsyncLong = {
|
||||
'acls', 'address', 'append', 'append-verify', 'archive', 'atimes', 'backup', 'backup-dir', 'block-size', 'blocking-io',
|
||||
'bwlimit', 'cc', 'checksum', 'checksum-choice', 'checksum-seed', 'chmod', 'chown', 'compare-dest', 'compress',
|
||||
'compress-choice', 'compress-level', 'compress-threads', 'confine-root', 'contimeout', 'copy-as', 'copy-dest',
|
||||
'copy-devices', 'copy-dirlinks', 'copy-links', 'copy-unsafe-links', 'crtimes', 'cvs-exclude', 'daemon', 'debug', 'del',
|
||||
'delay-updates', 'delete', 'delete-after', 'delete-before', 'delete-delay', 'delete-during', 'delete-excluded',
|
||||
'delete-missing-args', 'devices', 'dirs', 'dry-run', 'early-input', 'exclude', 'exclude-from', 'executability', 'existing',
|
||||
'fake-super', 'files-from', 'filter', 'force', 'from0', 'fsync', 'fuzzy', 'group', 'groupmap', 'hard-links', 'help',
|
||||
'human-readable', 'i-r', 'iconv', 'ignore-errors', 'ignore-existing', 'ignore-missing-args', 'ignore-non-existing',
|
||||
'ignore-times', 'inc-recursive', 'include', 'include-from', 'info', 'inplace', 'insecure-links', 'ipv4', 'ipv6',
|
||||
'itemize-changes', 'keep-dirlinks', 'link-dest', 'links', 'list-only', 'log-file', 'log-file-format', 'max-alloc',
|
||||
'max-delete', 'max-size', 'min-size', 'mkpath', 'modify-window', 'munge-links', 'no-i-r', 'no-implied-dirs',
|
||||
'no-inc-recursive', 'no-motd', 'no-whole-file', 'numeric-ids', 'old-args', 'old-d', 'old-dirs', 'omit-dir-times',
|
||||
'omit-link-times', 'one-file-system', 'only-write-batch', 'open-noatime', 'out-format', 'outbuf', 'owner', 'partial',
|
||||
'partial-dir', 'password-file', 'perms', 'port', 'preallocate', 'progress', 'protocol', 'prune-empty-dirs', 'quiet',
|
||||
'read-batch', 'recursive', 'relative', 'remote-option', 'remove-source-files', 'rsh', 'rsync-path', 'safe-links',
|
||||
'secluded-args', 'size-only', 'skip-compress', 'sockopts', 'sparse', 'specials', 'stats', 'stderr', 'stop-after', 'stop-at',
|
||||
'suffix', 'super', 'temp-dir', 'timeout', 'times', 'trust-sender', 'update', 'usermap', 'verbose', 'version',
|
||||
'whole-file', 'write-batch', 'write-devices', 'xattrs', 'zc', 'zl', 'zt',
|
||||
};
|
||||
|
||||
/// 나눈 낱말: 맨 앞이 따옴표 안이었는지 (그러면 맨 앞 대시를 옵션으로 고치지 않는다)
|
||||
typedef _Word = ({String text, bool quoted});
|
||||
|
||||
/// [splitOptions] · [unreadableOptions] 의 몸통: 낱말들과 닫히지 않은 따옴표 (뒤 글 일부)
|
||||
({List<String> words, List<String> unclosed}) _parseOptions(String s) {
|
||||
final chars = cleanOptionText(s).split('');
|
||||
final literal = <int>{}; // 닫히지 않아 글자로 보는 따옴표 자리
|
||||
final unclosed = <String>[];
|
||||
while (true) {
|
||||
final (raw, open) = _splitRaw(chars, literal);
|
||||
if (open == null) return (words: _normalizeWords(raw), unclosed: unclosed);
|
||||
literal.add(open);
|
||||
final rest = chars.sublist(open).join().trim();
|
||||
unclosed.add(rest.length > 30 ? '${rest.substring(0, 30)}…' : rest);
|
||||
}
|
||||
}
|
||||
|
||||
/// 따옴표 · 빈칸으로 나눈다. 끝까지 닫히지 않은 따옴표가 있으면 그 자리 (두 번째 값)
|
||||
(List<_Word>, int?) _splitRaw(List<String> chars, Set<int> literal) {
|
||||
final out = <_Word>[];
|
||||
final cur = StringBuffer();
|
||||
String? quote; // 닫는 곧은 따옴표 (" 또는 ')
|
||||
var curly = false; // 굽은 따옴표로 열었는지
|
||||
var openAt = -1;
|
||||
var has = false;
|
||||
var quoted = false;
|
||||
bool letter(int i) => i >= 0 && i < chars.length && RegExp(r'[\p{L}\p{N}]', unicode: true).hasMatch(chars[i]);
|
||||
for (var i = 0; i < chars.length; i++) {
|
||||
final ch = chars[i];
|
||||
if (quote != null) {
|
||||
// 작은따옴표 무리의 ’ 가 글자 사이면 (it’s) 닫지 않는다
|
||||
final closes = ch == quote && !curly ||
|
||||
curly && _curlyQuote[ch] == quote && !(quote == "'" && letter(i - 1) && letter(i + 1));
|
||||
if (closes) {
|
||||
quote = null;
|
||||
curly = false;
|
||||
} else {
|
||||
cur.write(ch);
|
||||
}
|
||||
} else if (literal.contains(i)) {
|
||||
cur.write(ch);
|
||||
} else if (ch == '"' || ch == "'") {
|
||||
if (cur.isEmpty && !has) quoted = true;
|
||||
quote = ch;
|
||||
openAt = i;
|
||||
has = true;
|
||||
} else if (_curlyQuote.containsKey(ch) && (cur.isEmpty || '$cur'.endsWith('=') || RegExp(r'^-[A-Za-z0-9]+$').hasMatch('$cur'))) {
|
||||
if (cur.isEmpty && !has) quoted = true;
|
||||
quote = _curlyQuote[ch];
|
||||
curly = true;
|
||||
openAt = i;
|
||||
has = true;
|
||||
} else if (ch.trim().isEmpty) {
|
||||
if (cur.isNotEmpty || has) out.add((text: cur.toString(), quoted: quoted));
|
||||
cur.clear();
|
||||
has = false;
|
||||
quoted = false;
|
||||
} else {
|
||||
cur.write(ch);
|
||||
}
|
||||
}
|
||||
if (quote != null) return (out, openAt);
|
||||
if (cur.isNotEmpty || has) out.add((text: cur.toString(), quoted: quoted));
|
||||
return (out, null);
|
||||
}
|
||||
|
||||
bool _dashAt(String t, int i) => i < t.length && (t[i] == '-' || _dashLike.contains(t[i]));
|
||||
|
||||
/// 거르기 규칙 머리 ("– *.tmp" · "+ *.txt") 를 rsync 가 읽는 - · + 로
|
||||
String _fixRuleHead(String v) {
|
||||
if (v.isEmpty) return v;
|
||||
if (_dashLike.contains(v[0])) return '-${v.substring(1)}';
|
||||
if (v[0] == '\uFF0B') return '+${v.substring(1)}';
|
||||
return v;
|
||||
}
|
||||
|
||||
/// 낱말 맨 앞 대시 고치기 · 거르기 값의 규칙 머리 고치기 (값 자리는 그대로)
|
||||
List<String> _normalizeWords(List<_Word> raw) {
|
||||
final out = <String>[];
|
||||
var value = ''; // 다음 낱말이 값: 'filter' (규칙 머리를 고침) · 'other' (그대로)
|
||||
for (final w in raw) {
|
||||
var t = w.text;
|
||||
if (value.isNotEmpty) {
|
||||
out.add(value == 'filter' ? _fixRuleHead(t) : t);
|
||||
value = '';
|
||||
continue;
|
||||
}
|
||||
// 28 확인 (10/20): 곧은 하이픈과 섞인 것 (-–delete) 도 맨 앞 둘 중 하나라도 대시면 같은 규칙으로
|
||||
if (!w.quoted && t.isNotEmpty && (_dashLike.contains(t[0]) || t.length > 1 && t[0] == '-' && _dashLike.contains(t[1]))) {
|
||||
final n = _dashAt(t, 1) ? 2 : 1;
|
||||
final rest = t.substring(n);
|
||||
final name = rest.split('=').first;
|
||||
final long = n == 2 || _rsyncLong.contains(name) || (name.startsWith('no-') && _rsyncLong.contains(name.substring(3)));
|
||||
t = '${long ? '--' : '-'}$rest';
|
||||
}
|
||||
if (t.startsWith('--filter=')) {
|
||||
t = '--filter=${_fixRuleHead(t.substring(9))}';
|
||||
} else if (t.length > 1 && t.startsWith('-') && !t.startsWith('--')) {
|
||||
final k = t.indexOf(RegExp('[BefMT@]'), 1);
|
||||
if (k > 0 && t[k] == 'f' && k < t.length - 1) t = '${t.substring(0, k + 1)}${_fixRuleHead(t.substring(k + 1))}';
|
||||
}
|
||||
out.add(t);
|
||||
if (t.startsWith('--')) {
|
||||
if (!t.contains('=') && _rsyncLongWithValue.contains(t)) value = t == '--filter' ? 'filter' : 'other';
|
||||
} else if (t.length > 1 && t.startsWith('-')) {
|
||||
final k = t.indexOf(RegExp('[BefMT@]'), 1);
|
||||
if (k == t.length - 1) value = t[k] == 'f' ? 'filter' : 'other';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// 값을 다음 낱말로 받는 rsync 긴 옵션 (--exclude *.log) - [unreadableOptions] 가 그 값을 옵션으로 보지 않게
|
||||
const _rsyncLongWithValue = {
|
||||
'--exclude', '--include', '--filter', '--exclude-from', '--include-from', '--files-from', '--backup-dir', '--suffix',
|
||||
'--log-file', '--log-file-format', '--out-format', '--chmod', '--chown', '--usermap', '--groupmap', '--bwlimit',
|
||||
'--timeout', '--contimeout', '--rsh', '--rsync-path', '--compare-dest', '--copy-dest', '--link-dest', '--partial-dir',
|
||||
'--temp-dir', '--max-size', '--min-size', '--max-delete', '--modify-window', '--password-file', '--iconv',
|
||||
'--block-size', '--checksum-choice', '--compress-choice', '--compress-level', '--skip-compress', '--info', '--debug',
|
||||
'--sockopts', '--port', '--address', '--outbuf', '--remote-option', '--max-alloc', '--stop-after', '--stop-at',
|
||||
'--write-batch', '--only-write-batch', '--read-batch', '--protocol', '--checksum-seed', '--copy-as', '--early-input',
|
||||
// 28 확인 (10/20): rsync --help 의 짧은 별칭 (aka --cc · --zc · --zl · --zt) 과 값 받는 옵션 - 빠져 있어 값이 "경로로 읽힙니다" 로 막혔다
|
||||
'--cc', '--zc', '--zl', '--zt', '--compress-threads', '--confine-root',
|
||||
};
|
||||
|
||||
/// 관리자 지시 (10/20, 28 · 15 확인): [splitOptions] 로 고쳐 읽은 뒤에도 rsync 가 다르게 읽을 것 - 있으면 확인 창 · 실시간 동기화에
|
||||
/// 빨갛게 알리고 실행을 막는다: 닫히지 않은 따옴표, 옵션 자리의 - 로 시작하지 않는 낱말 (rsync 는 원본 경로로 읽어 옵션이 몰래 빠짐),
|
||||
/// 하이픈 셋 이상, 끝에 값 없이 남은 값 받는 옵션. 옵션의 값 자리 (--exclude 한글.txt 의 한글.txt, -f 뒤) 는 세지 않는다
|
||||
List<String> unreadableOptions(String options, {bool? windows}) => [for (final x in optionProblems(options, windows: windows)) x.text];
|
||||
|
||||
/// 옵션 칸의 문제 종류 - 안내의 끝 문구 (고칠 방법) 를 고른다 (관리자 지시 10/20, 28 지적)
|
||||
enum OptionProblemKind { unclosed, path, noValue, odd, doubleQuote, relativePath, stdin }
|
||||
|
||||
/// [unreadableOptions] 의 한 줄과 그 종류
|
||||
class OptionProblem {
|
||||
final OptionProblemKind kind;
|
||||
final String text;
|
||||
|
||||
/// [text] 안의 옵션 낱말 (화면이 줄바꿈 없이 묶는다)
|
||||
final String token;
|
||||
const OptionProblem(this.kind, this.text, [this.token = '']);
|
||||
|
||||
@override
|
||||
String toString() => text;
|
||||
}
|
||||
|
||||
/// 문제 종류에 맞는 고칠 방법 (여러 종류면 · 로 잇는다)
|
||||
String optionProblemHint(Iterable<OptionProblem> problems, {bool? windows}) {
|
||||
windows ??= Platform.isWindows;
|
||||
final kinds = {for (final x in problems) x.kind};
|
||||
return [
|
||||
if (kinds.contains(OptionProblemKind.unclosed)) tr('따옴표를 닫아 주세요'),
|
||||
if (kinds.contains(OptionProblemKind.path)) tr('옵션은 - 나 -- 로 시작합니다'),
|
||||
if (kinds.contains(OptionProblemKind.noValue)) tr('값을 적어 주세요 (예: --exclude=*.tmp)'),
|
||||
if (kinds.contains(OptionProblemKind.odd)) tr('곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요'),
|
||||
if (kinds.contains(OptionProblemKind.doubleQuote)) tr('큰따옴표 (") 를 빼 주세요'),
|
||||
// 관리자 지시 (10/20): Windows 는 드라이브로 시작하는 전체 경로만 ("/" 는 rsync 가 자기 설치 폴더 기준으로 읽음), Android 는 /
|
||||
if (kinds.contains(OptionProblemKind.relativePath)) windows ? tr('C:\\ 처럼 드라이브로 시작하는 전체 경로로') : tr('/ 로 시작하는 전체 경로로'),
|
||||
if (kinds.contains(OptionProblemKind.stdin)) tr('목록 파일의 전체 경로를 적어 주세요'),
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
/// "옵션을 알아볼 수 없습니다: … - 고칠 방법" (작업 기록 · 실시간 동기화 카드 · 설정 칸)
|
||||
String optionProblemMessage(List<OptionProblem> problems) =>
|
||||
trf('옵션을 알아볼 수 없습니다: {0} - {1}', [problems.join(' · '), optionProblemHint(problems)]);
|
||||
|
||||
/// [unreadableOptions] 의 몸통 (종류와 함께)
|
||||
List<OptionProblem> optionProblems(String options, {bool? windows}) {
|
||||
windows ??= Platform.isWindows;
|
||||
final parsed = _parseOptions(options);
|
||||
// 15 확인 F1: 닫히지 않은 따옴표 - 뒤의 옵션이 다르게 읽힐 수 있다
|
||||
final bad = [for (final u in parsed.unclosed) OptionProblem(OptionProblemKind.unclosed, trf('따옴표가 닫히지 않았습니다: {0}', [u]), u)];
|
||||
var value = false;
|
||||
var last = '';
|
||||
for (final t in parsed.words) {
|
||||
// P0 (10/20): Windows 의 rsync (MSYS2) 에는 큰따옴표가 든 인수를 그대로 넘길 수 없다 (prepareRsyncLaunch) - 값 자리도.
|
||||
// 닫히지 않은 따옴표로 이미 막혔으면 (그 따옴표가 낱말에 남음) 한 번만 알린다
|
||||
if (windows && parsed.unclosed.isEmpty && t.contains('"')) {
|
||||
bad.add(OptionProblem(OptionProblemKind.doubleQuote, trf('큰따옴표 (") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}', [t]), t));
|
||||
value = false;
|
||||
continue;
|
||||
}
|
||||
if (value) {
|
||||
value = false;
|
||||
continue;
|
||||
}
|
||||
if (t.startsWith('---')) {
|
||||
// 관리자 판단 (10/20, 28 확인): 하이픈이 셋 이상 (---delete) 은 rsync 가 알아보지 못한다 - 끝 문구는 "옵션은 - 나 -- 로 시작합니다"
|
||||
bad.add(OptionProblem(OptionProblemKind.path, t, t));
|
||||
} else if (t.startsWith('--')) {
|
||||
value = !t.contains('=') && _rsyncLongWithValue.contains(t);
|
||||
last = t;
|
||||
} else if (t.length > 1 && t.startsWith('-')) {
|
||||
// 짧은 옵션 묶음: 값을 받는 글자 (B · e · f · M · T · @) 가 끝이면 다음 낱말이 값 (rsync_filter 와 같은 규칙)
|
||||
final k = t.indexOf(RegExp('[BefMT@]'), 1);
|
||||
value = k == t.length - 1;
|
||||
last = t;
|
||||
} else {
|
||||
// 관리자 판단 (10/20, 28 확인): 옵션 칸에는 경로가 들어갈 자리가 없다 (원본 · 대상은 앱이 넣음) - 값 자리가 아닌데
|
||||
// - 로 시작하지 않는 낱말 (-a delete 의 delete, 혼자 쓴 -) 은 rsync 가 원본 경로로 읽어 옵션이 몰래 빠진다
|
||||
// ASCII 밖 글자로 시작하면 (« … 등) 붙여 넣은 이상한 글자 - 안내는 곧은 글자로 다시 적기
|
||||
bad.add(OptionProblem(t.codeUnitAt(0) > 0x7F ? OptionProblemKind.odd : OptionProblemKind.path, trf('경로로 읽힙니다: {0}', [t]), t));
|
||||
}
|
||||
}
|
||||
// 값이 필요한 옵션이 끝에 값 없이 (--exclude) - rsync 가 원본 경로를 그 값으로 먹는다
|
||||
if (value) bad.add(OptionProblem(OptionProblemKind.noValue, trf('{0} 의 값이 없습니다', [last]), last));
|
||||
// 관리자 판단 (10/20): 목록 파일의 상대 경로 - rsync 는 자기 작업 폴더 (Windows 는 응답 파일 폴더) 기준으로, 미리 보기는 앱의
|
||||
// 작업 폴더 기준으로 읽어 서로 다른 파일을 볼 수 있다. 전체 경로만 받는다
|
||||
for (final f in listFileValues(parsed.words)) {
|
||||
// 관리자 지시 (10/20, 28 지적): "-" (표준 입력) 는 따로 - 앱은 rsync 에 표준 입력을 주지 않는다
|
||||
if (f == '-') {
|
||||
bad.add(OptionProblem(OptionProblemKind.stdin, tr('표준 입력 (-) 은 쓸 수 없습니다'), f));
|
||||
} else if (!_absolutePath(f, windows: windows)) {
|
||||
bad.add(OptionProblem(OptionProblemKind.relativePath, trf('목록 파일은 전체 경로로 적어 주세요: {0}', [f]), f));
|
||||
}
|
||||
}
|
||||
// 관리자 지시 (10/20, 28 확인): 기록 · 배치 파일도 - 상대 경로면 응답 파일 임시 폴더에 생겼다가 함께 지워져 찾을 수 없다.
|
||||
// --backup-dir · --partial-dir · --temp-dir · --compare-dest 류는 받는 쪽 기준이라 그대로
|
||||
for (final f in _optionValues(parsed.words, const {'--log-file', '--write-batch', '--read-batch', '--only-write-batch'})) {
|
||||
if (!_absolutePath(f, windows: windows)) bad.add(OptionProblem(OptionProblemKind.relativePath, trf('기록 · 배치 파일은 전체 경로로 적어 주세요: {0}', [f]), f));
|
||||
}
|
||||
return bad;
|
||||
}
|
||||
|
||||
/// 긴 옵션 [names] 의 값 (--이름=값 · --이름 값)
|
||||
List<String> _optionValues(List<String> words, Set<String> names) => [
|
||||
for (var i = 0; i < words.length; i++)
|
||||
if (words[i].contains('=') && names.contains(words[i].substring(0, words[i].indexOf('='))))
|
||||
words[i].substring(words[i].indexOf('=') + 1)
|
||||
else if (names.contains(words[i]) && i + 1 < words.length)
|
||||
words[i + 1],
|
||||
];
|
||||
|
||||
/// 파일을 받는 옵션의 값 (--exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · "merge 파일").
|
||||
/// ": 이름" · dir-merge 는 폴더마다 읽는 파일 이름이라 (원래 상대) 빼고
|
||||
List<String> listFileValues(List<String> words) {
|
||||
const fileOpts = {'--exclude-from', '--include-from', '--files-from'};
|
||||
final out = <String>[];
|
||||
String? mergeFile(String rule) => RegExp(r'^(?:\.|merge)(?:,[^\s]*)?\s+(.+)$').firstMatch(rule.trim())?.group(1);
|
||||
for (var i = 0; i < words.length; i++) {
|
||||
final w = words[i];
|
||||
final next = i + 1 < words.length ? words[i + 1] : null;
|
||||
final eq = w.indexOf('=');
|
||||
if (w.startsWith('--') && eq > 0 && fileOpts.contains(w.substring(0, eq))) {
|
||||
out.add(w.substring(eq + 1));
|
||||
} else if (fileOpts.contains(w) && next != null) {
|
||||
out.add(next);
|
||||
i++;
|
||||
} else if (w.startsWith('--filter=')) {
|
||||
final f = mergeFile(w.substring(9));
|
||||
if (f != null) out.add(f);
|
||||
} else if (w == '--filter' && next != null) {
|
||||
final f = mergeFile(next);
|
||||
if (f != null) out.add(f);
|
||||
i++;
|
||||
} else if (!w.startsWith('--') && w.length > 1 && w.startsWith('-')) {
|
||||
// 짧은 옵션 묶음의 f: 붙은 값 (-f". 목록") 또는 다음 낱말 (-avf ". 목록")
|
||||
final k = w.indexOf(RegExp('[BefMT@]'), 1);
|
||||
if (k < 0 || w[k] != 'f') continue;
|
||||
final rule = k == w.length - 1 ? next : w.substring(k + 1);
|
||||
if (k == w.length - 1) i++;
|
||||
final f = rule == null ? null : mergeFile(rule);
|
||||
if (f != null) out.add(f);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// 전체 경로. Windows: 드라이브 (C:\ · C:/) · UNC (\\서버 · //서버) · /cygdrive/… - 그 밖의 / 는 MSYS 가 자기 설치 폴더 기준으로 읽어 뺀다.
|
||||
/// Android: / 로 시작
|
||||
bool _absolutePath(String f, {required bool windows}) =>
|
||||
windows ? RegExp(r'^(?:[A-Za-z]:[\\/]|\\\\|//|/cygdrive/)').hasMatch(f) : f.startsWith('/');
|
||||
|
|
@ -1,656 +1,6 @@
|
|||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'file_ops.dart' show appTempRsyncExcludes;
|
||||
import '../l10n/tr.dart';
|
||||
|
||||
/// 파일 탐색기의 복사 · 이동 방법 (환경 설정 > 파일 탐색기 > 복사 · 동기화)
|
||||
///
|
||||
/// - builtin: 앱이 직접 복사 ("현재 방식"). 모든 기기.
|
||||
/// - rsync: rsync (Windows 는 MSYS2 의 rsync 를 처음 쓸 때 내려받음, Android 는 직접 지정한 실행 파일)
|
||||
/// - robocopy: Windows 에 들어 있는 robocopy (Windows 만)
|
||||
enum CopyMethod {
|
||||
builtin('현재 방식'),
|
||||
rsync('rsync'),
|
||||
robocopy('robocopy');
|
||||
|
||||
final String label;
|
||||
const CopyMethod(this.label);
|
||||
|
||||
static CopyMethod of(String? name) => values.firstWhere((m) => m.name == name, orElse: () => builtin);
|
||||
}
|
||||
|
||||
const defaultRsyncOptions = '-avPog';
|
||||
const defaultRobocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2';
|
||||
|
||||
/// 관리자 지시 (10/20): 화면에서 줄바꿈 없이 묶은 옵션 글 (sync_preview_view 의 unbreakableOption) 의 줄바꿈 없는 빈칸 (U+00A0 등) ·
|
||||
/// 보이지 않는 글자 (U+2060 등) 가 복사 · 붙여 넣기로 옵션에 들어오면 rsync 가 다른 인수로 읽는다 ("-", 보이지 않는 글자, "-filter") → 보통 빈칸으로 / 지움.
|
||||
/// 다른 곳 (웹 페이지 · 문서) 에서 붙여 넣은 것도 같다
|
||||
/// 28 확인 (10/20): 소프트 하이픈 (U+00AD) 은 지우고 (rsync 가 "unknown option" 으로 실패), 전각 빈칸 (U+3000) 도 보통 빈칸으로
|
||||
String cleanOptionText(String s) => s
|
||||
.replaceAll(RegExp('[\u00A0\u2007\u202F\u3000]'), ' ')
|
||||
.replaceAll(RegExp('[\u00AD\u200B\u200C\u200D\u2060\uFEFF]'), '');
|
||||
|
||||
/// 낱말 맨 앞에서 - 로 읽는 대시 (‐ ‑ ‒ – — ― − ﹘ ﹣ -) - 워드 · 웹이 -- 를 바꿔 놓은 것
|
||||
const _dashLike = '\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uFE58\uFE63\uFF0D';
|
||||
|
||||
/// 굽은 따옴표 → 곧은 따옴표 (같은 무리끼리 열고 닫는다)
|
||||
const _curlyQuote = {'\u2018': "'", '\u2019': "'", '\u201A': "'", '\u201C': '"', '\u201D': '"', '\u201E': '"'};
|
||||
|
||||
/// 옵션 글자를 인수로 나눈다 (큰따옴표 · 작은따옴표로 묶은 것은 하나로). 먼저 [cleanOptionText].
|
||||
/// 관리자 지시 (10/20, 28 · 15 확인): 웹 · 문서에서 붙여 넣은 글을 곧은 글자로 쓴 것과 같게 읽는다 - 예전에는
|
||||
/// --exclude=‘*.log’ 가 아무것도 거르지 못해 받는 쪽 keep.log 가 지워졌고, –delete 는 원본 경로로 넘어가 --delete 가 몰래 빠졌다.
|
||||
/// - 굽은 따옴표 (‘ ’ “ ”) 는 낱말 맨 앞 · = 뒤 · 짧은 옵션 (-f) 뒤에서 열고, 같은 무리의 굽은 따옴표에서 닫는다
|
||||
/// (곧은 따옴표처럼 뒤에 글이 이어져도 - “a b”.txt → a b.txt). 작은따옴표 무리는 글자 사이 (it’s) 이면 닫지 않는다.
|
||||
/// 이름 안의 굽은 따옴표 (it’s.txt) 는 그대로 둔다.
|
||||
/// - 닫히지 않은 따옴표 (15 확인 F1): 예전에는 뒤의 옵션을 모두 삼켜 지키려던 --exclude 가 사라졌다 → 그 따옴표를 글자로 보고
|
||||
/// 나머지는 그대로 나누며, [unreadableOptions] 가 알려 실행을 막는다.
|
||||
/// - 낱말 맨 앞의 대시 (– — ‑ - 등, 따옴표 밖): 둘이면 --, 하나면 rsync 의 긴 옵션 이름 (–delete) 일 때만 -- 그 밖 (–avz) 은 -.
|
||||
/// 옵션의 값 자리 (--exclude –x.txt 의 –x.txt) · 낱말 가운데의 대시는 그대로 (파일 이름). --filter · -f 값의 규칙 머리 (“– *.tmp”) 는 - 로.
|
||||
List<String> splitOptions(String s) => _parseOptions(s).words;
|
||||
|
||||
/// rsync 3.x 의 긴 옵션 이름 (앱에 든 rsync 3.5.1 의 --help) - 대시 하나로 붙여 넣은 –delete 를 --delete 로 읽을지 정한다
|
||||
const _rsyncLong = {
|
||||
'acls', 'address', 'append', 'append-verify', 'archive', 'atimes', 'backup', 'backup-dir', 'block-size', 'blocking-io',
|
||||
'bwlimit', 'cc', 'checksum', 'checksum-choice', 'checksum-seed', 'chmod', 'chown', 'compare-dest', 'compress',
|
||||
'compress-choice', 'compress-level', 'compress-threads', 'confine-root', 'contimeout', 'copy-as', 'copy-dest',
|
||||
'copy-devices', 'copy-dirlinks', 'copy-links', 'copy-unsafe-links', 'crtimes', 'cvs-exclude', 'daemon', 'debug', 'del',
|
||||
'delay-updates', 'delete', 'delete-after', 'delete-before', 'delete-delay', 'delete-during', 'delete-excluded',
|
||||
'delete-missing-args', 'devices', 'dirs', 'dry-run', 'early-input', 'exclude', 'exclude-from', 'executability', 'existing',
|
||||
'fake-super', 'files-from', 'filter', 'force', 'from0', 'fsync', 'fuzzy', 'group', 'groupmap', 'hard-links', 'help',
|
||||
'human-readable', 'i-r', 'iconv', 'ignore-errors', 'ignore-existing', 'ignore-missing-args', 'ignore-non-existing',
|
||||
'ignore-times', 'inc-recursive', 'include', 'include-from', 'info', 'inplace', 'insecure-links', 'ipv4', 'ipv6',
|
||||
'itemize-changes', 'keep-dirlinks', 'link-dest', 'links', 'list-only', 'log-file', 'log-file-format', 'max-alloc',
|
||||
'max-delete', 'max-size', 'min-size', 'mkpath', 'modify-window', 'munge-links', 'no-i-r', 'no-implied-dirs',
|
||||
'no-inc-recursive', 'no-motd', 'no-whole-file', 'numeric-ids', 'old-args', 'old-d', 'old-dirs', 'omit-dir-times',
|
||||
'omit-link-times', 'one-file-system', 'only-write-batch', 'open-noatime', 'out-format', 'outbuf', 'owner', 'partial',
|
||||
'partial-dir', 'password-file', 'perms', 'port', 'preallocate', 'progress', 'protocol', 'prune-empty-dirs', 'quiet',
|
||||
'read-batch', 'recursive', 'relative', 'remote-option', 'remove-source-files', 'rsh', 'rsync-path', 'safe-links',
|
||||
'secluded-args', 'size-only', 'skip-compress', 'sockopts', 'sparse', 'specials', 'stats', 'stderr', 'stop-after', 'stop-at',
|
||||
'suffix', 'super', 'temp-dir', 'timeout', 'times', 'trust-sender', 'update', 'usermap', 'verbose', 'version',
|
||||
'whole-file', 'write-batch', 'write-devices', 'xattrs', 'zc', 'zl', 'zt',
|
||||
};
|
||||
|
||||
/// 나눈 낱말: 맨 앞이 따옴표 안이었는지 (그러면 맨 앞 대시를 옵션으로 고치지 않는다)
|
||||
typedef _Word = ({String text, bool quoted});
|
||||
|
||||
/// [splitOptions] · [unreadableOptions] 의 몸통: 낱말들과 닫히지 않은 따옴표 (뒤 글 일부)
|
||||
({List<String> words, List<String> unclosed}) _parseOptions(String s) {
|
||||
final chars = cleanOptionText(s).split('');
|
||||
final literal = <int>{}; // 닫히지 않아 글자로 보는 따옴표 자리
|
||||
final unclosed = <String>[];
|
||||
while (true) {
|
||||
final (raw, open) = _splitRaw(chars, literal);
|
||||
if (open == null) return (words: _normalizeWords(raw), unclosed: unclosed);
|
||||
literal.add(open);
|
||||
final rest = chars.sublist(open).join().trim();
|
||||
unclosed.add(rest.length > 30 ? '${rest.substring(0, 30)}…' : rest);
|
||||
}
|
||||
}
|
||||
|
||||
/// 따옴표 · 빈칸으로 나눈다. 끝까지 닫히지 않은 따옴표가 있으면 그 자리 (두 번째 값)
|
||||
(List<_Word>, int?) _splitRaw(List<String> chars, Set<int> literal) {
|
||||
final out = <_Word>[];
|
||||
final cur = StringBuffer();
|
||||
String? quote; // 닫는 곧은 따옴표 (" 또는 ')
|
||||
var curly = false; // 굽은 따옴표로 열었는지
|
||||
var openAt = -1;
|
||||
var has = false;
|
||||
var quoted = false;
|
||||
bool letter(int i) => i >= 0 && i < chars.length && RegExp(r'[\p{L}\p{N}]', unicode: true).hasMatch(chars[i]);
|
||||
for (var i = 0; i < chars.length; i++) {
|
||||
final ch = chars[i];
|
||||
if (quote != null) {
|
||||
// 작은따옴표 무리의 ’ 가 글자 사이면 (it’s) 닫지 않는다
|
||||
final closes = ch == quote && !curly ||
|
||||
curly && _curlyQuote[ch] == quote && !(quote == "'" && letter(i - 1) && letter(i + 1));
|
||||
if (closes) {
|
||||
quote = null;
|
||||
curly = false;
|
||||
} else {
|
||||
cur.write(ch);
|
||||
}
|
||||
} else if (literal.contains(i)) {
|
||||
cur.write(ch);
|
||||
} else if (ch == '"' || ch == "'") {
|
||||
if (cur.isEmpty && !has) quoted = true;
|
||||
quote = ch;
|
||||
openAt = i;
|
||||
has = true;
|
||||
} else if (_curlyQuote.containsKey(ch) && (cur.isEmpty || '$cur'.endsWith('=') || RegExp(r'^-[A-Za-z0-9]+$').hasMatch('$cur'))) {
|
||||
if (cur.isEmpty && !has) quoted = true;
|
||||
quote = _curlyQuote[ch];
|
||||
curly = true;
|
||||
openAt = i;
|
||||
has = true;
|
||||
} else if (ch.trim().isEmpty) {
|
||||
if (cur.isNotEmpty || has) out.add((text: cur.toString(), quoted: quoted));
|
||||
cur.clear();
|
||||
has = false;
|
||||
quoted = false;
|
||||
} else {
|
||||
cur.write(ch);
|
||||
}
|
||||
}
|
||||
if (quote != null) return (out, openAt);
|
||||
if (cur.isNotEmpty || has) out.add((text: cur.toString(), quoted: quoted));
|
||||
return (out, null);
|
||||
}
|
||||
|
||||
bool _dashAt(String t, int i) => i < t.length && (t[i] == '-' || _dashLike.contains(t[i]));
|
||||
|
||||
/// 거르기 규칙 머리 ("– *.tmp" · "+ *.txt") 를 rsync 가 읽는 - · + 로
|
||||
String _fixRuleHead(String v) {
|
||||
if (v.isEmpty) return v;
|
||||
if (_dashLike.contains(v[0])) return '-${v.substring(1)}';
|
||||
if (v[0] == '\uFF0B') return '+${v.substring(1)}';
|
||||
return v;
|
||||
}
|
||||
|
||||
/// 낱말 맨 앞 대시 고치기 · 거르기 값의 규칙 머리 고치기 (값 자리는 그대로)
|
||||
List<String> _normalizeWords(List<_Word> raw) {
|
||||
final out = <String>[];
|
||||
var value = ''; // 다음 낱말이 값: 'filter' (규칙 머리를 고침) · 'other' (그대로)
|
||||
for (final w in raw) {
|
||||
var t = w.text;
|
||||
if (value.isNotEmpty) {
|
||||
out.add(value == 'filter' ? _fixRuleHead(t) : t);
|
||||
value = '';
|
||||
continue;
|
||||
}
|
||||
// 28 확인 (10/20): 곧은 하이픈과 섞인 것 (-–delete) 도 맨 앞 둘 중 하나라도 대시면 같은 규칙으로
|
||||
if (!w.quoted && t.isNotEmpty && (_dashLike.contains(t[0]) || t.length > 1 && t[0] == '-' && _dashLike.contains(t[1]))) {
|
||||
final n = _dashAt(t, 1) ? 2 : 1;
|
||||
final rest = t.substring(n);
|
||||
final name = rest.split('=').first;
|
||||
final long = n == 2 || _rsyncLong.contains(name) || (name.startsWith('no-') && _rsyncLong.contains(name.substring(3)));
|
||||
t = '${long ? '--' : '-'}$rest';
|
||||
}
|
||||
if (t.startsWith('--filter=')) {
|
||||
t = '--filter=${_fixRuleHead(t.substring(9))}';
|
||||
} else if (t.length > 1 && t.startsWith('-') && !t.startsWith('--')) {
|
||||
final k = t.indexOf(RegExp('[BefMT@]'), 1);
|
||||
if (k > 0 && t[k] == 'f' && k < t.length - 1) t = '${t.substring(0, k + 1)}${_fixRuleHead(t.substring(k + 1))}';
|
||||
}
|
||||
out.add(t);
|
||||
if (t.startsWith('--')) {
|
||||
if (!t.contains('=') && _rsyncLongWithValue.contains(t)) value = t == '--filter' ? 'filter' : 'other';
|
||||
} else if (t.length > 1 && t.startsWith('-')) {
|
||||
final k = t.indexOf(RegExp('[BefMT@]'), 1);
|
||||
if (k == t.length - 1) value = t[k] == 'f' ? 'filter' : 'other';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// 값을 다음 낱말로 받는 rsync 긴 옵션 (--exclude *.log) - [unreadableOptions] 가 그 값을 옵션으로 보지 않게
|
||||
const _rsyncLongWithValue = {
|
||||
'--exclude', '--include', '--filter', '--exclude-from', '--include-from', '--files-from', '--backup-dir', '--suffix',
|
||||
'--log-file', '--log-file-format', '--out-format', '--chmod', '--chown', '--usermap', '--groupmap', '--bwlimit',
|
||||
'--timeout', '--contimeout', '--rsh', '--rsync-path', '--compare-dest', '--copy-dest', '--link-dest', '--partial-dir',
|
||||
'--temp-dir', '--max-size', '--min-size', '--max-delete', '--modify-window', '--password-file', '--iconv',
|
||||
'--block-size', '--checksum-choice', '--compress-choice', '--compress-level', '--skip-compress', '--info', '--debug',
|
||||
'--sockopts', '--port', '--address', '--outbuf', '--remote-option', '--max-alloc', '--stop-after', '--stop-at',
|
||||
'--write-batch', '--only-write-batch', '--read-batch', '--protocol', '--checksum-seed', '--copy-as', '--early-input',
|
||||
// 28 확인 (10/20): rsync --help 의 짧은 별칭 (aka --cc · --zc · --zl · --zt) 과 값 받는 옵션 - 빠져 있어 값이 "경로로 읽힙니다" 로 막혔다
|
||||
'--cc', '--zc', '--zl', '--zt', '--compress-threads', '--confine-root',
|
||||
};
|
||||
|
||||
/// 관리자 지시 (10/20, 28 · 15 확인): [splitOptions] 로 고쳐 읽은 뒤에도 rsync 가 다르게 읽을 것 - 있으면 확인 창 · 실시간 동기화에
|
||||
/// 빨갛게 알리고 실행을 막는다: 닫히지 않은 따옴표, 옵션 자리의 - 로 시작하지 않는 낱말 (rsync 는 원본 경로로 읽어 옵션이 몰래 빠짐),
|
||||
/// 하이픈 셋 이상, 끝에 값 없이 남은 값 받는 옵션. 옵션의 값 자리 (--exclude 한글.txt 의 한글.txt, -f 뒤) 는 세지 않는다
|
||||
List<String> unreadableOptions(String options, {bool? windows}) => [for (final x in optionProblems(options, windows: windows)) x.text];
|
||||
|
||||
/// 옵션 칸의 문제 종류 - 안내의 끝 문구 (고칠 방법) 를 고른다 (관리자 지시 10/20, 28 지적)
|
||||
enum OptionProblemKind { unclosed, path, noValue, odd, doubleQuote, relativePath, stdin }
|
||||
|
||||
/// [unreadableOptions] 의 한 줄과 그 종류
|
||||
class OptionProblem {
|
||||
final OptionProblemKind kind;
|
||||
final String text;
|
||||
|
||||
/// [text] 안의 옵션 낱말 (화면이 줄바꿈 없이 묶는다)
|
||||
final String token;
|
||||
const OptionProblem(this.kind, this.text, [this.token = '']);
|
||||
|
||||
@override
|
||||
String toString() => text;
|
||||
}
|
||||
|
||||
/// 문제 종류에 맞는 고칠 방법 (여러 종류면 · 로 잇는다)
|
||||
String optionProblemHint(Iterable<OptionProblem> problems, {bool? windows}) {
|
||||
windows ??= Platform.isWindows;
|
||||
final kinds = {for (final x in problems) x.kind};
|
||||
return [
|
||||
if (kinds.contains(OptionProblemKind.unclosed)) tr('따옴표를 닫아 주세요'),
|
||||
if (kinds.contains(OptionProblemKind.path)) tr('옵션은 - 나 -- 로 시작합니다'),
|
||||
if (kinds.contains(OptionProblemKind.noValue)) tr('값을 적어 주세요 (예: --exclude=*.tmp)'),
|
||||
if (kinds.contains(OptionProblemKind.odd)) tr('곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요'),
|
||||
if (kinds.contains(OptionProblemKind.doubleQuote)) tr('큰따옴표 (") 를 빼 주세요'),
|
||||
// 관리자 지시 (10/20): Windows 는 드라이브로 시작하는 전체 경로만 ("/" 는 rsync 가 자기 설치 폴더 기준으로 읽음), Android 는 /
|
||||
if (kinds.contains(OptionProblemKind.relativePath)) windows ? tr('C:\\ 처럼 드라이브로 시작하는 전체 경로로') : tr('/ 로 시작하는 전체 경로로'),
|
||||
if (kinds.contains(OptionProblemKind.stdin)) tr('목록 파일의 전체 경로를 적어 주세요'),
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
/// "옵션을 알아볼 수 없습니다: … - 고칠 방법" (작업 기록 · 실시간 동기화 카드 · 설정 칸)
|
||||
String optionProblemMessage(List<OptionProblem> problems) =>
|
||||
trf('옵션을 알아볼 수 없습니다: {0} - {1}', [problems.join(' · '), optionProblemHint(problems)]);
|
||||
|
||||
/// [unreadableOptions] 의 몸통 (종류와 함께)
|
||||
List<OptionProblem> optionProblems(String options, {bool? windows}) {
|
||||
windows ??= Platform.isWindows;
|
||||
final parsed = _parseOptions(options);
|
||||
// 15 확인 F1: 닫히지 않은 따옴표 - 뒤의 옵션이 다르게 읽힐 수 있다
|
||||
final bad = [for (final u in parsed.unclosed) OptionProblem(OptionProblemKind.unclosed, trf('따옴표가 닫히지 않았습니다: {0}', [u]), u)];
|
||||
var value = false;
|
||||
var last = '';
|
||||
for (final t in parsed.words) {
|
||||
// P0 (10/20): Windows 의 rsync (MSYS2) 에는 큰따옴표가 든 인수를 그대로 넘길 수 없다 (prepareRsyncLaunch) - 값 자리도.
|
||||
// 닫히지 않은 따옴표로 이미 막혔으면 (그 따옴표가 낱말에 남음) 한 번만 알린다
|
||||
if (windows && parsed.unclosed.isEmpty && t.contains('"')) {
|
||||
bad.add(OptionProblem(OptionProblemKind.doubleQuote, trf('큰따옴표 (") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}', [t]), t));
|
||||
value = false;
|
||||
continue;
|
||||
}
|
||||
if (value) {
|
||||
value = false;
|
||||
continue;
|
||||
}
|
||||
if (t.startsWith('---')) {
|
||||
// 관리자 판단 (10/20, 28 확인): 하이픈이 셋 이상 (---delete) 은 rsync 가 알아보지 못한다 - 끝 문구는 "옵션은 - 나 -- 로 시작합니다"
|
||||
bad.add(OptionProblem(OptionProblemKind.path, t, t));
|
||||
} else if (t.startsWith('--')) {
|
||||
value = !t.contains('=') && _rsyncLongWithValue.contains(t);
|
||||
last = t;
|
||||
} else if (t.length > 1 && t.startsWith('-')) {
|
||||
// 짧은 옵션 묶음: 값을 받는 글자 (B · e · f · M · T · @) 가 끝이면 다음 낱말이 값 (rsync_filter 와 같은 규칙)
|
||||
final k = t.indexOf(RegExp('[BefMT@]'), 1);
|
||||
value = k == t.length - 1;
|
||||
last = t;
|
||||
} else {
|
||||
// 관리자 판단 (10/20, 28 확인): 옵션 칸에는 경로가 들어갈 자리가 없다 (원본 · 대상은 앱이 넣음) - 값 자리가 아닌데
|
||||
// - 로 시작하지 않는 낱말 (-a delete 의 delete, 혼자 쓴 -) 은 rsync 가 원본 경로로 읽어 옵션이 몰래 빠진다
|
||||
// ASCII 밖 글자로 시작하면 (« … 등) 붙여 넣은 이상한 글자 - 안내는 곧은 글자로 다시 적기
|
||||
bad.add(OptionProblem(t.codeUnitAt(0) > 0x7F ? OptionProblemKind.odd : OptionProblemKind.path, trf('경로로 읽힙니다: {0}', [t]), t));
|
||||
}
|
||||
}
|
||||
// 값이 필요한 옵션이 끝에 값 없이 (--exclude) - rsync 가 원본 경로를 그 값으로 먹는다
|
||||
if (value) bad.add(OptionProblem(OptionProblemKind.noValue, trf('{0} 의 값이 없습니다', [last]), last));
|
||||
// 관리자 판단 (10/20): 목록 파일의 상대 경로 - rsync 는 자기 작업 폴더 (Windows 는 응답 파일 폴더) 기준으로, 미리 보기는 앱의
|
||||
// 작업 폴더 기준으로 읽어 서로 다른 파일을 볼 수 있다. 전체 경로만 받는다
|
||||
for (final f in listFileValues(parsed.words)) {
|
||||
// 관리자 지시 (10/20, 28 지적): "-" (표준 입력) 는 따로 - 앱은 rsync 에 표준 입력을 주지 않는다
|
||||
if (f == '-') {
|
||||
bad.add(OptionProblem(OptionProblemKind.stdin, tr('표준 입력 (-) 은 쓸 수 없습니다'), f));
|
||||
} else if (!_absolutePath(f, windows: windows)) {
|
||||
bad.add(OptionProblem(OptionProblemKind.relativePath, trf('목록 파일은 전체 경로로 적어 주세요: {0}', [f]), f));
|
||||
}
|
||||
}
|
||||
// 관리자 지시 (10/20, 28 확인): 기록 · 배치 파일도 - 상대 경로면 응답 파일 임시 폴더에 생겼다가 함께 지워져 찾을 수 없다.
|
||||
// --backup-dir · --partial-dir · --temp-dir · --compare-dest 류는 받는 쪽 기준이라 그대로
|
||||
for (final f in _optionValues(parsed.words, const {'--log-file', '--write-batch', '--read-batch', '--only-write-batch'})) {
|
||||
if (!_absolutePath(f, windows: windows)) bad.add(OptionProblem(OptionProblemKind.relativePath, trf('기록 · 배치 파일은 전체 경로로 적어 주세요: {0}', [f]), f));
|
||||
}
|
||||
return bad;
|
||||
}
|
||||
|
||||
/// 긴 옵션 [names] 의 값 (--이름=값 · --이름 값)
|
||||
List<String> _optionValues(List<String> words, Set<String> names) => [
|
||||
for (var i = 0; i < words.length; i++)
|
||||
if (words[i].contains('=') && names.contains(words[i].substring(0, words[i].indexOf('='))))
|
||||
words[i].substring(words[i].indexOf('=') + 1)
|
||||
else if (names.contains(words[i]) && i + 1 < words.length)
|
||||
words[i + 1],
|
||||
];
|
||||
|
||||
/// 파일을 받는 옵션의 값 (--exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · "merge 파일").
|
||||
/// ": 이름" · dir-merge 는 폴더마다 읽는 파일 이름이라 (원래 상대) 빼고
|
||||
List<String> listFileValues(List<String> words) {
|
||||
const fileOpts = {'--exclude-from', '--include-from', '--files-from'};
|
||||
final out = <String>[];
|
||||
String? mergeFile(String rule) => RegExp(r'^(?:\.|merge)(?:,[^\s]*)?\s+(.+)$').firstMatch(rule.trim())?.group(1);
|
||||
for (var i = 0; i < words.length; i++) {
|
||||
final w = words[i];
|
||||
final next = i + 1 < words.length ? words[i + 1] : null;
|
||||
final eq = w.indexOf('=');
|
||||
if (w.startsWith('--') && eq > 0 && fileOpts.contains(w.substring(0, eq))) {
|
||||
out.add(w.substring(eq + 1));
|
||||
} else if (fileOpts.contains(w) && next != null) {
|
||||
out.add(next);
|
||||
i++;
|
||||
} else if (w.startsWith('--filter=')) {
|
||||
final f = mergeFile(w.substring(9));
|
||||
if (f != null) out.add(f);
|
||||
} else if (w == '--filter' && next != null) {
|
||||
final f = mergeFile(next);
|
||||
if (f != null) out.add(f);
|
||||
i++;
|
||||
} else if (!w.startsWith('--') && w.length > 1 && w.startsWith('-')) {
|
||||
// 짧은 옵션 묶음의 f: 붙은 값 (-f". 목록") 또는 다음 낱말 (-avf ". 목록")
|
||||
final k = w.indexOf(RegExp('[BefMT@]'), 1);
|
||||
if (k < 0 || w[k] != 'f') continue;
|
||||
final rule = k == w.length - 1 ? next : w.substring(k + 1);
|
||||
if (k == w.length - 1) i++;
|
||||
final f = rule == null ? null : mergeFile(rule);
|
||||
if (f != null) out.add(f);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// 전체 경로. Windows: 드라이브 (C:\ · C:/) · UNC (\\서버 · //서버) · /cygdrive/… - 그 밖의 / 는 MSYS 가 자기 설치 폴더 기준으로 읽어 뺀다.
|
||||
/// Android: / 로 시작
|
||||
bool _absolutePath(String f, {required bool windows}) =>
|
||||
windows ? RegExp(r'^(?:[A-Za-z]:[\\/]|\\\\|//|/cygdrive/)').hasMatch(f) : f.startsWith('/');
|
||||
|
||||
/// 관리자 판단 (10/20, 28 확인): rsync (Windows MSYS2) 는 목록 · 기록 · 배치 파일의 C:\… · C:/… · \\서버\… 를 열지 못한다
|
||||
/// ("failed to open exclude file", exit 11) - 그 값들을 경로처럼 [toCygwinPath] 로 바꿔 넘긴다 (막지 않음).
|
||||
/// 대상: --exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · "merge 파일" · --log-file · --write-batch · --read-batch · --only-write-batch
|
||||
List<String> rsyncFileOptionArgs(List<String> words, {required bool windows}) {
|
||||
if (!windows) return words;
|
||||
const opts = {
|
||||
'--exclude-from', '--include-from', '--files-from', '--log-file', '--write-batch', '--read-batch', '--only-write-batch',
|
||||
};
|
||||
// 28 · 15 확인 (10/20, MSYS 3.5.1): 이 파일들은 UNC 를 \\서버\… 로만 연다 (//서버/… 는 "failed to open", exit 11) - UNC 는 \\ 로,
|
||||
// 드라이브는 /cygdrive/… 로
|
||||
String conv(String v) => RegExp(r'^[A-Za-z]:[\\/]').hasMatch(v)
|
||||
? toCygwinPath(v)
|
||||
: RegExp(r'^(?:\\\\|//)(?!cygdrive/)').hasMatch(v)
|
||||
? v.replaceAll('/', '\\')
|
||||
: v;
|
||||
String rule(String r) {
|
||||
final m = RegExp(r'^(\s*(?:\.|merge)(?:,[^\s]*)?\s+)(.+)$').firstMatch(r);
|
||||
return m == null ? r : '${m[1]}${conv(m[2]!)}';
|
||||
}
|
||||
|
||||
final out = <String>[];
|
||||
for (var i = 0; i < words.length; i++) {
|
||||
final w = words[i];
|
||||
final next = i + 1 < words.length ? words[i + 1] : null;
|
||||
final eq = w.indexOf('=');
|
||||
if (w.startsWith('--') && eq > 0 && opts.contains(w.substring(0, eq))) {
|
||||
out.add('${w.substring(0, eq + 1)}${conv(w.substring(eq + 1))}');
|
||||
} else if (opts.contains(w) && next != null) {
|
||||
out
|
||||
..add(w)
|
||||
..add(conv(next));
|
||||
i++;
|
||||
} else if (w.startsWith('--filter=')) {
|
||||
out.add('--filter=${rule(w.substring(9))}');
|
||||
} else if (w == '--filter' && next != null) {
|
||||
out
|
||||
..add(w)
|
||||
..add(rule(next));
|
||||
i++;
|
||||
} else if (!w.startsWith('--') && w.length > 1 && w.startsWith('-')) {
|
||||
// 짧은 옵션 묶음의 f: 붙은 값 (-f". 목록") 또는 다음 낱말 (-avf ". 목록")
|
||||
final k = w.indexOf(RegExp('[BefMT@]'), 1);
|
||||
if (k >= 0 && w[k] == 'f' && k < w.length - 1) {
|
||||
out.add('${w.substring(0, k + 1)}${rule(w.substring(k + 1))}');
|
||||
} else if (k >= 0 && w[k] == 'f' && next != null) {
|
||||
out
|
||||
..add(w)
|
||||
..add(rule(next));
|
||||
i++;
|
||||
} else {
|
||||
out.add(w);
|
||||
// 다른 값 받는 글자 (-e ssh 등) 의 값은 그대로 건너뜀
|
||||
if (k == w.length - 1 && next != null) {
|
||||
out.add(next);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out.add(w);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Windows 경로 → MSYS2 rsync 가 읽는 경로 (C:\a\b → /cygdrive/c/a/b, \\server\share\x → //server/share/x).
|
||||
/// MSYS2 rsync 는 "C:" 를 원격 컴퓨터 이름으로 읽으므로 꼭 바꿔야 한다.
|
||||
/// 받는 쪽 (대상) 경로를 rsync 에 넘길 꼴로. 28 · 15 확인 (10/20, MSYS 3.5.1): 받는 쪽의 UNC 는 \\서버\… 만 된다
|
||||
/// (//서버/… 는 change_dir 실패, exit 3) - 원본은 거꾸로 //서버/… 만 되어 [toCygwinPath] 그대로
|
||||
String rsyncDestPath(String path, {bool windows = true}) {
|
||||
if (windows && RegExp(r'^(?:\\\\|//)(?!cygdrive/)').hasMatch(path)) return path.replaceAll('/', '\\');
|
||||
return toCygwinPath(path, windows: windows);
|
||||
}
|
||||
|
||||
String toCygwinPath(String path, {bool windows = true}) {
|
||||
if (!windows) return path;
|
||||
final s = path.replaceAll('\\', '/');
|
||||
final m = RegExp(r'^([A-Za-z]):(/.*)?$').firstMatch(s);
|
||||
if (m != null) return '/cygdrive/${m[1]!.toLowerCase()}${m[2] ?? '/'}';
|
||||
return s; // //server/share 는 그대로 읽는다
|
||||
}
|
||||
|
||||
/// rsync 옵션에 -u (--update: 받는 쪽이 더 새 파일은 건너뜀) 를 더한다. 이미 있으면 그대로 (-avu 처럼 묶인 것도).
|
||||
/// 양쪽 (⇄) 으로 함께 맞출 때 서로의 새 파일을 옛 파일로 덮어쓰지 않게.
|
||||
String withUpdateOption(String options) {
|
||||
final has = splitOptions(options).any((o) => o == '--update' || (o.startsWith('-') && !o.startsWith('--') && o.contains('u')));
|
||||
return has ? options : '${options.trim()} -u'.trim();
|
||||
}
|
||||
|
||||
/// rsync 인수: [options] + (대역폭 · 이동) + 원본들 + 대상 폴더/
|
||||
/// 원본 폴더는 끝에 / 를 붙이지 않는다 (폴더째 대상 안으로 - 앱의 복사와 같은 결과).
|
||||
List<String> rsyncArgs({
|
||||
required String options,
|
||||
required List<String> sources,
|
||||
required String dest,
|
||||
int bandwidthKBps = 0,
|
||||
bool move = false,
|
||||
bool windows = true,
|
||||
bool contents = false,
|
||||
}) {
|
||||
// 178: 원본을 지우는 옵션은 [move] (이동 · [원본 파일 지우기]) 로만 - 옵션 글에 있어도 빼고, 이동일 때만 넣는다
|
||||
final opts = rsyncFileOptionArgs([for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o], windows: windows);
|
||||
return [
|
||||
...opts,
|
||||
if (bandwidthKBps > 0 && !opts.any((o) => o.startsWith('--bwlimit'))) '--bwlimit=$bandwidthKBps',
|
||||
// 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 출력 (Android 빌드는 iconv 없음 · 진행 표시에 쓰는 이름)
|
||||
if (!windows && !opts.contains('-8') && !opts.contains('--8-bit-output')) '-8',
|
||||
if (move) '--remove-source-files',
|
||||
...appTempRsyncExcludes, // 78 보충: 일괄 이름 바꾸기 중의 임시 이름 (~jjren_ · ~jjback_) 은 보내지 않는다
|
||||
// [contents]: 폴더 "안의 것" 을 대상에 (rsync 의 끝 / - 동기화 · lsync 와 같은 모양)
|
||||
for (final s in sources) '${toCygwinPath(_noTrailingSlash(s), windows: windows)}${contents ? '/' : ''}',
|
||||
'${rsyncDestPath(_noTrailingSlash(dest), windows: windows)}/',
|
||||
];
|
||||
}
|
||||
|
||||
String _noTrailingSlash(String s) => s.length > 3 && (s.endsWith('/') || s.endsWith('\\')) ? s.substring(0, s.length - 1) : s;
|
||||
|
||||
/// 원본을 지우는 rsync · robocopy 옵션인지 (--remove-source-files · --remove-sent-files · /MOV · /MOVE) - 107 · 178
|
||||
bool isSourceRemovingOption(String o) =>
|
||||
o == '--remove-source-files' || o == '--remove-sent-files' || o.toUpperCase() == '/MOV' || o.toUpperCase() == '/MOVE';
|
||||
|
||||
/// robocopy 한 번 실행할 인수 (robocopy 는 "폴더 → 폴더" 라 원본마다 따로 만든다).
|
||||
/// - 폴더: robocopy <폴더> <대상>\<폴더 이름> [옵션]
|
||||
/// - 파일: robocopy <파일이 있는 폴더> <대상> <파일 이름들…> [옵션] (같은 폴더의 파일은 한 번에)
|
||||
/// 대역폭: robocopy 에는 속도 제한이 없어 /IPG (조각 사이 쉬는 시간) 로 비슷하게 맞춘다.
|
||||
List<List<String>> robocopyRuns({
|
||||
required String options,
|
||||
required List<String> folders,
|
||||
required List<String> files,
|
||||
required String dest,
|
||||
int bandwidthKBps = 0,
|
||||
bool move = false,
|
||||
bool contents = false,
|
||||
bool skipNewer = false,
|
||||
}) {
|
||||
// 178: 원본을 지우는 옵션 (/MOV · /MOVE) 은 [move] 로만
|
||||
final opts = [for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o];
|
||||
final extra = robocopyExtraOptions(opts, move: move, skipNewer: skipNewer, bandwidthKBps: bandwidthKBps);
|
||||
final runs = <List<String>>[
|
||||
for (final f in folders) [f, contents ? dest : p.join(dest, p.basename(f)), ...opts, ...extra],
|
||||
];
|
||||
final byDir = <String, List<String>>{};
|
||||
for (final f in files) {
|
||||
byDir.putIfAbsent(p.dirname(f), () => []).add(p.basename(f));
|
||||
}
|
||||
// 파일만 옮길 때 /E · /MOVE 는 하위 폴더까지 건드리므로 뺀다
|
||||
final fileOpts = opts.where((o) => !const ['/E', '/S', '/MIR'].contains(o.toUpperCase())).toList();
|
||||
final fileExtra = extra.map((o) => o == '/MOVE' ? '/MOV' : o).toList();
|
||||
byDir.forEach((dir, names) => runs.add([dir, dest, ...names, ...fileOpts, ...fileExtra]));
|
||||
return runs;
|
||||
}
|
||||
|
||||
/// 앱이 옵션 칸 ([opts], 원본 지우기 옵션은 뺀 것) 뒤에 붙이는 robocopy 옵션 - 실행 (robocopyRuns) 과 화면의 명령 줄이 같게
|
||||
List<String> robocopyExtraOptions(List<String> opts, {bool move = false, bool skipNewer = false, int bandwidthKBps = 0}) => [
|
||||
if (bandwidthKBps > 0 && !opts.any((o) => o.toUpperCase().startsWith('/IPG'))) '/IPG:${robocopyIpg(bandwidthKBps)}',
|
||||
if (move) ...robocopyMoveOptions(opts),
|
||||
// 데이터: 링크 · junction 은 따라가지 않는다 (복사 · 이동 모두 처음 값 - rsync -a 가 링크를 링크로 두는 것처럼).
|
||||
// 따라가면 원본 바깥 폴더의 파일을 옮기고 지우거나 (/MOVE), 고리 junction (AppData 의 "Application Data" 등) 을 끝없이 돈다.
|
||||
// 옵션 칸에 사용자가 링크 옵션을 넣었으면 그대로
|
||||
if (!robocopyLinkOption(opts)) '/XJ',
|
||||
// 229: 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" - robocopy 는 기본으로 예전 원본 (Older) 도 덮어쓴다
|
||||
if (skipNewer && !opts.any((o) => o.toUpperCase() == '/XO')) '/XO',
|
||||
];
|
||||
|
||||
/// 옵션 칸에 링크 · junction 을 다루는 robocopy 옵션이 있는지 (/XJ · /XJD · /XJF · /SJ · /SL)
|
||||
bool robocopyLinkOption(List<String> opts) => opts.any((o) => const ['/XJ', '/XJD', '/XJF', '/SJ', '/SL'].contains(o.toUpperCase()));
|
||||
|
||||
/// robocopy 가 원본의 링크 · junction 을 건너뛰는지 (미리 보기) - 사용자가 /SJ · /SL 로 링크째 복사를 고르지 않았으면
|
||||
bool robocopySkipsLinks(List<String> opts) => !opts.any((o) => const ['/SJ', '/SL'].contains(o.toUpperCase()));
|
||||
|
||||
/// 이동에 붙이는 robocopy 옵션: /MOVE + 같은 파일도 포함.
|
||||
/// 217: 대상에 같은 파일이 있으면 robocopy 는 건너뛰고 원본에 남긴다 (종료 코드 0 - "옮겼습니다" 인데 남음, Windows 에서 확인).
|
||||
/// 같음 (/IS) 뿐 아니라 속성만 다름 (Tweaked, /IT) · 만든 시각만 다름 (Modified, /IM - 다른 프로그램으로 복사한 같은 파일) 도 넣어 원본에서 지운다.
|
||||
/// 그래도 남는 것은 이동 뒤 다시 세어 알린다 (216). 링크 · junction 은 [robocopyExtraOptions] 의 /XJ 로 남긴다
|
||||
/// (robocopy 는 기본으로 junction 을 따라가 /MOVE 가 링크가 가리키는 원본 바깥 폴더의 파일까지 옮기고 지웠다 - Windows 에서 확인)
|
||||
List<String> robocopyMoveOptions(List<String> opts) => [
|
||||
'/MOVE',
|
||||
for (final o in const ['/IS', '/IT', '/IM'])
|
||||
if (!opts.any((x) => x.toUpperCase() == o)) o,
|
||||
];
|
||||
|
||||
/// robocopy /IPG 밀리초: robocopy 는 64KB 조각마다 쉰다 → 64 / KB/s 초
|
||||
int robocopyIpg(int kbps) => kbps <= 0 ? 0 : (64 * 1000 / kbps).round().clamp(1, 60000);
|
||||
|
||||
/// rsync · robocopy 의 성공 종료 코드
|
||||
bool rsyncOk(int code) => code == 0 || code == 24; // 24: 복사 도중 원본 파일이 사라짐
|
||||
bool robocopyOk(int code) => code >= 0 && code < 8;
|
||||
|
||||
/// rsync -v 출력에서 "다 보낸 파일" 줄을 골라낸다 (-P 의 \r 진행 줄 · 머리글 · 요약 · 폴더 줄은 뺌).
|
||||
/// 결과: 원본 기준 상대 경로 (예: "sub/inner.txt")
|
||||
class RsyncOutput {
|
||||
final _buf = StringBuffer();
|
||||
|
||||
/// 지금 파일의 진행률 (-P 의 " 1,234 45% 1.2MB/s" 줄)
|
||||
double? currentPercent;
|
||||
|
||||
/// 지금 전송 속도 (바이트/초, 같은 줄의 "1.2MB/s")
|
||||
double? currentSpeed;
|
||||
|
||||
/// 폴더 전체에서 확인한 항목 (진행 줄의 "to-chk=남은/전체" · "ir-chk=…"). 모르면 null.
|
||||
int? checked, checkTotal;
|
||||
|
||||
static final _chkRx = RegExp(r'(?:to|ir)-chk=(\d+)/(\d+)');
|
||||
static final _speedRx = RegExp(r'(\d+(?:\.\d+)?)([kKMGT]?)B/s');
|
||||
|
||||
List<String> feed(String chunk) {
|
||||
_buf.write(chunk);
|
||||
final text = _buf.toString();
|
||||
final cut = text.lastIndexOf(RegExp(r'[\r\n]'));
|
||||
if (cut < 0) return const [];
|
||||
_buf
|
||||
..clear()
|
||||
..write(text.substring(cut + 1));
|
||||
final done = <String>[];
|
||||
for (final raw in text.substring(0, cut).split(RegExp(r'[\r\n]'))) {
|
||||
final line = raw.trimRight();
|
||||
if (line.isEmpty) continue;
|
||||
if (line.startsWith(' ')) {
|
||||
final m = RegExp(r'\s(\d{1,3})%\s').firstMatch(line);
|
||||
if (m != null) currentPercent = int.parse(m[1]!) / 100;
|
||||
final ck = _chkRx.firstMatch(line);
|
||||
if (ck != null) {
|
||||
checkTotal = int.parse(ck[2]!);
|
||||
checked = checkTotal! - int.parse(ck[1]!);
|
||||
}
|
||||
final sp = _speedRx.firstMatch(line);
|
||||
if (sp != null) {
|
||||
const mul = {'': 1, 'k': 1024, 'K': 1024, 'M': 1024 * 1024, 'G': 1024 * 1024 * 1024, 'T': 1024 * 1024 * 1024 * 1024};
|
||||
currentSpeed = double.parse(sp[1]!) * mul[sp[2]!]!;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (line.endsWith('/')) continue; // 폴더
|
||||
if (line.startsWith('sending ') ||
|
||||
line.startsWith('receiving ') ||
|
||||
line.startsWith('sent ') ||
|
||||
line.startsWith('total size') ||
|
||||
line.startsWith('created directory') ||
|
||||
line.startsWith('deleting ') ||
|
||||
line.startsWith('rsync:') ||
|
||||
line.startsWith('rsync error') ||
|
||||
line.contains(' -> ')) {
|
||||
continue;
|
||||
}
|
||||
currentPercent = null;
|
||||
done.add(line);
|
||||
}
|
||||
return done;
|
||||
}
|
||||
}
|
||||
|
||||
/// robocopy 출력에서 파일 줄 ("New File", "Newer", "Older", "Changed" … 뒤의 크기 · 이름) 을 센다
|
||||
class RobocopyOutput {
|
||||
final _buf = StringBuffer();
|
||||
double? currentPercent;
|
||||
|
||||
/// 파일 줄: 탭 · 종류 · 탭 탭 · 크기 · 탭 · 경로. 61: 종류 글은 Windows 언어마다 다르므로
|
||||
/// ("New File" · "새 파일" · "新しいファイル" · "新文件") 글이 아니라 탭 모양으로 알아본다.
|
||||
/// 종류가 * 로 시작하면 (*EXTRA File 등) 대상에만 있는 것이라 세지 않는다.
|
||||
static final _file = RegExp(r'^\t+\s*([^\t\s\d][^\t]*?)\s*\t+\s*[\d.]+\s*[kmgt]?\s*\t(.+)$', caseSensitive: false);
|
||||
|
||||
List<String> feed(String chunk) {
|
||||
_buf.write(chunk);
|
||||
final text = _buf.toString();
|
||||
final cut = text.lastIndexOf(RegExp(r'[\r\n]'));
|
||||
if (cut < 0) return const [];
|
||||
_buf
|
||||
..clear()
|
||||
..write(text.substring(cut + 1));
|
||||
final done = <String>[];
|
||||
for (final raw in text.substring(0, cut).split(RegExp(r'[\r\n]'))) {
|
||||
final line = raw.trim();
|
||||
final pct = RegExp(r'^(\d{1,3}(?:\.\d)?)%$').firstMatch(line);
|
||||
if (pct != null) {
|
||||
currentPercent = double.parse(pct[1]!) / 100;
|
||||
continue;
|
||||
}
|
||||
final m = _file.firstMatch(raw);
|
||||
if (m != null && !m[1]!.startsWith('*')) {
|
||||
currentPercent = null;
|
||||
done.add(m[2]!.trim());
|
||||
}
|
||||
}
|
||||
return done;
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows 용 rsync: MSYS2 공식 저장소의 패키지 (처음 쓸 때 내려받음, 파일 SHA256 을 고정해 검증)
|
||||
const msys2Repo = 'https://repo.msys2.org/msys/x86_64/';
|
||||
const msys2RsyncPackages = <(String, String)>[
|
||||
('rsync-3.5.1-2-x86_64.pkg.tar.zst', 'cba52e41d16f9873324fcf09853619964da87bc1ce116781a5449c968d4ffecc'),
|
||||
('msys2-runtime-3.6.10-6-x86_64.pkg.tar.zst', 'b2db5bae3826f15cacb536f4c7c6c7e31d3fabec00aac8f127473aba7e994d22'),
|
||||
('gcc-libs-15.3.0-1-x86_64.pkg.tar.zst', '0d99a122c453c05ae21ba3dcea910f2e0d93d38ae067c677a112a315b0f3cec5'),
|
||||
('libiconv-1.19-1-x86_64.pkg.tar.zst', '0fa55ea2a6ccf97cf8c58b24b2615815e15e16e6e4e888091c263c2c83c5313d'),
|
||||
('libidn2-2.3.8-1-x86_64.pkg.tar.zst', '55e0de96b893e9f67f14a4283d411fc2a45d7b59333bc16e2a9b16e5bfc9282f'),
|
||||
('libintl-0.22.5-1-x86_64.pkg.tar.zst', '336d66b9d95cf9c1804958f8e260762a3e83bf158ed5981f783bc772a31073cf'),
|
||||
('liblz4-1.10.0-1-x86_64.pkg.tar.zst', '1cbbe51ee91bf32691aabb15bdb7c8548e8b5ef8195122ef31f10edcca6e952f'),
|
||||
('libopenssl-3.6.5-1-x86_64.pkg.tar.zst', '05f6cc946f0dde3aecc3632007fa1d874e40720226117b750327bc2a01846325'),
|
||||
('libunistring-1.4.2-1-x86_64.pkg.tar.zst', 'bcd04706d95b5e7127d39834565bceed6b400192f5fb51599745aba28d5da018'),
|
||||
('libxxhash-0.8.4-1-x86_64.pkg.tar.zst', 'ba784ad117626401cd5d3ec0cf70399f9de72a63755f96c28fa2032303f9c330'),
|
||||
('libzstd-1.5.7-1-x86_64.pkg.tar.zst', '5e370b2e725a0a7e5ff58d84653c9a0cd5a10b9cfe930f2d949a28ad2a11c1f2'),
|
||||
('popt-1.19-1-x86_64.pkg.tar.zst', 'b402bd1d03815c5e28b7ab415d8e20f487eff2460ae0271a99674eeb0d77194f'),
|
||||
];
|
||||
|
||||
/// 패키지에서 꺼낼 파일 (rsync.exe 와 필요한 DLL 만)
|
||||
const msys2RsyncFiles = {
|
||||
'rsync.exe', 'msys-2.0.dll', 'msys-gcc_s-seh-1.dll', 'msys-iconv-2.dll', 'msys-idn2-0.dll', 'msys-intl-8.dll',
|
||||
'msys-lz4-1.dll', 'msys-crypto-3.dll', 'msys-ssl-3.dll', 'msys-unistring-5.dll', 'msys-xxhash-0.dll',
|
||||
'msys-zstd-1.dll', 'msys-popt-0.dll', 'msys-charset-1.dll',
|
||||
};
|
||||
// 약속 (나누기 sync_tools): 복사 · 동기화 도구 (rsync · robocopy) - 부르는 곳은 이 파일 하나만 가져온다.
|
||||
// 일은 lib/core/sync/ 의 모듈이 한다: rsync_options (옵션 글 읽기 · 문제 찾기) · copy_commands (복사 방법 · 명령 인수 · 패키지) ·
|
||||
// copy_output (출력 읽기).
|
||||
export 'sync/rsync_options.dart';
|
||||
export 'sync/copy_commands.dart';
|
||||
export 'sync/copy_output.dart';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/core/files/any_transfer.dart';
|
||||
import 'package:jj_mkvmaker/core/files/file_listing.dart';
|
||||
import 'package:jj_mkvmaker/core/files/local_transfer.dart';
|
||||
import 'package:jj_mkvmaker/core/files/name_ops.dart';
|
||||
import 'package:jj_mkvmaker/core/files/path_rules.dart';
|
||||
import 'package:jj_mkvmaker/core/files/transfer_control.dart';
|
||||
import 'package:jj_mkvmaker/core/files/transfer_guard.dart';
|
||||
import 'package:jj_mkvmaker/core/files/windows_drives.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 나누기 file_ops 약속 시험: lib/core/files/ 모듈마다 맨 위 약속 (입력 · 출력 · 오류) - 실제 시험 폴더 · 실제 파일.
|
||||
void main() {
|
||||
late Directory tmp;
|
||||
setUp(() => tmp = Directory.systemTemp.createTempSync('jj_files_'));
|
||||
tearDown(() => tmp.deleteSync(recursive: true));
|
||||
|
||||
String mk(String rel, [String text = 'x']) {
|
||||
final f = File(p.join(tmp.path, rel))..createSync(recursive: true);
|
||||
f.writeAsStringSync(text);
|
||||
return f.path;
|
||||
}
|
||||
|
||||
test('file_listing: 읽기 · 숨김 · 정렬 (폴더 먼저 · 숫자) · 찾기 · 읽지 못함은 던짐 · 크기 글', () async {
|
||||
mk('d/file10.txt');
|
||||
mk('d/file2.txt');
|
||||
mk('d/.hidden');
|
||||
Directory(p.join(tmp.path, 'd', 'sub')).createSync();
|
||||
final d = p.join(tmp.path, 'd');
|
||||
final list = await listEntries(d);
|
||||
expect(list.map((e) => e.name).toSet(), {'file10.txt', 'file2.txt', 'sub'});
|
||||
expect((await listEntries(d, showHidden: true)).length, 4);
|
||||
expect(sortEntries(list, SortBy.name).map((e) => e.name), ['sub', 'file2.txt', 'file10.txt']);
|
||||
expect(await searchFiles(tmp.path, 'FILE*').map((e) => e.name).toList(), ['file2.txt', 'file10.txt']);
|
||||
await expectLater(listEntries(p.join(tmp.path, 'none')), throwsA(isA<FileSystemException>()));
|
||||
expect([formatSize(-1), formatSize(12), formatSize(2048), formatSize(3 * 1024 * 1024 * 1024)], ['—', '12B', '2KB', '3.0GB']);
|
||||
});
|
||||
|
||||
test('windows_drives: 목록은 비지 않음 · 종류는 열지 않고 숫자 · 공유 맨 위', () {
|
||||
final drives = windowsDrives();
|
||||
expect(drives, isNotEmpty);
|
||||
if (Platform.isWindows) expect(driveType(drives.first.$1), greaterThan(0));
|
||||
expect(uncRoot(r'\\nas\share\a\b'), r'\\nas\share');
|
||||
expect(uncRoot('x'), 'x');
|
||||
});
|
||||
|
||||
test('path_rules: 겹치지 않는 이름 · 같은 경로 · 안에 있음 · 바깥 것만 · 전송 전 확인', () {
|
||||
mk('a.txt');
|
||||
expect(p.basename(uniqueTarget(tmp.path, 'a.txt')), 'a (2).txt');
|
||||
final a = p.join(tmp.path, 'A'), inner = p.join(a, 'b');
|
||||
Directory(inner).createSync(recursive: true);
|
||||
expect(isSameOrInside(inner, a), isTrue);
|
||||
expect(isSameOrInside(a, inner), isFalse);
|
||||
expect(outermostPaths([inner, a, a]), [a]);
|
||||
if (Platform.isWindows) expect(samePath(a, a.toLowerCase()), isTrue);
|
||||
expect(transferProblem([a], inner, move: false), isNotNull, reason: '자기 안으로');
|
||||
expect(transferProblem([p.join(tmp.path, 'none')], tmp.path, move: false), isNotNull, reason: '원본 없음');
|
||||
expect(transferProblem([p.join(tmp.path, 'a.txt')], a, move: true), isNull);
|
||||
expect(isAppTempName('~jjren_1') && isPartialFile('x.jjpart'), isTrue);
|
||||
});
|
||||
|
||||
test('name_ops: 쓸 수 없는 이름 · 이름 바꾸기 · 새 폴더 · 같은 이름은 던짐', () async {
|
||||
expect(nameProblem('a/b', windowsRules: false), isNotNull);
|
||||
expect(nameProblem('CON.txt', windowsRules: true), isNotNull);
|
||||
expect(nameProblem('ok.txt', windowsRules: true), isNull);
|
||||
final f = mk('x.txt');
|
||||
mk('y.txt');
|
||||
expect(p.basename(await renameInPlace(f, ' z.txt ')), 'z.txt');
|
||||
await expectLater(renameInPlace(p.join(tmp.path, 'z.txt'), 'y.txt'), throwsA(isA<FileSystemException>()));
|
||||
final dir = await createFolder(tmp.path, '새 폴더');
|
||||
expect(Directory(dir).existsSync(), isTrue);
|
||||
await expectLater(createFolder(tmp.path, '새 폴더'), throwsA(isA<FileSystemException>()));
|
||||
});
|
||||
|
||||
test('transfer_guard: 원본 없음 · 빈 원본은 던지고 지우지 않음 · 빈 폴더 정리', () async {
|
||||
final src = p.join(tmp.path, 'src'), dst = p.join(tmp.path, 'dst');
|
||||
mk('dst/keep.txt');
|
||||
await expectLater(ensureSourceForDelete(src, dst), throwsA(isA<SourceUnreadableException>()));
|
||||
Directory(src).createSync();
|
||||
await expectLater(ensureSourceForDelete(src, dst), throwsA(isA<SourceUnreadableException>().having((e) => e.empty, 'empty', isTrue)));
|
||||
await ensureSourceForDelete(src, dst, allowEmpty: true);
|
||||
Directory(p.join(src, 'e1', 'e2')).createSync(recursive: true);
|
||||
expect(await removeEmptyDirs(src), 2);
|
||||
expect(Directory(src).existsSync(), isTrue);
|
||||
});
|
||||
|
||||
test('transfer_control: 같은 이름 건너뛰기는 null 과 기록 · 취소 뒤 check 는 던짐', () async {
|
||||
final f = mk('s/a.txt');
|
||||
mk('d/a.txt');
|
||||
final c = TransferControl(conflict: NameConflict.skip);
|
||||
expect(await c.targetFor(f, p.join(tmp.path, 'd')), isNull);
|
||||
expect(c.skipped, [f]);
|
||||
c.check();
|
||||
c.cancel();
|
||||
expect(c.cancelled, isTrue);
|
||||
expect(c.check, throwsA(isA<FileOpCancelled>()));
|
||||
});
|
||||
|
||||
test('local_transfer: 복사 (진행 · 바뀐 때) · 이동 · 맞추기 (지우기) · 취소는 반쯤 쓴 파일을 남기지 않음', () async {
|
||||
final a = mk('s/dir/a.txt', 'hello');
|
||||
final dst = p.join(tmp.path, 'd');
|
||||
Directory(dst).createSync();
|
||||
var last = 0;
|
||||
final made = await localCopy(TransferControl(), [p.join(tmp.path, 's', 'dir')], dst, onProgress: (_, done, _) => last = done);
|
||||
expect(File(p.join(made.single, 'a.txt')).readAsStringSync(), 'hello');
|
||||
expect(last, 5);
|
||||
expect(await localTotalSize([p.join(tmp.path, 's')]), 5);
|
||||
final moved = await localMove(TransferControl(), [a], dst);
|
||||
expect(File(a).existsSync(), isFalse);
|
||||
expect(File(moved.single).readAsStringSync(), 'hello');
|
||||
final m = p.join(tmp.path, 'm');
|
||||
mk('m/old.txt');
|
||||
expect(await localMirror(TransferControl(), p.join(tmp.path, 'd'), m, delete: true), 2);
|
||||
expect(File(p.join(m, 'old.txt')).existsSync(), isFalse);
|
||||
final c = TransferControl()..cancel();
|
||||
await expectLater(localCopy(c, [moved.single], p.join(tmp.path, 's')), throwsA(isA<FileOpCancelled>()));
|
||||
expect(Directory(p.join(tmp.path, 's')).listSync(recursive: true).where((e) => e.path.endsWith('.jjpart')), isEmpty);
|
||||
});
|
||||
|
||||
test('any_transfer: 로컬 경로로도 복사 · 이동 · 맞추기 · 크기, 원본 없음은 던짐', () async {
|
||||
mk('s/x/b.txt', 'abc');
|
||||
final dst = p.join(tmp.path, 'd');
|
||||
Directory(dst).createSync();
|
||||
expect(await anyTotalSize([p.join(tmp.path, 's')]), 3);
|
||||
final made = await anyCopy(TransferControl(), [p.join(tmp.path, 's', 'x')], dst);
|
||||
expect(File(p.join(made.single, 'b.txt')).readAsStringSync(), 'abc');
|
||||
final m = p.join(tmp.path, 'm');
|
||||
expect(await anyMirror(TransferControl(), p.join(tmp.path, 's'), m), 1);
|
||||
expect(await anyMirror(TransferControl(), p.join(tmp.path, 's'), m), 0, reason: '같으면 다시 보내지 않음');
|
||||
final m2 = Directory(p.join(tmp.path, 'm2'))..createSync();
|
||||
final moved = await anyMove(TransferControl(), [p.join(tmp.path, 's', 'x')], m2.path);
|
||||
expect(Directory(p.join(tmp.path, 's', 'x')).existsSync(), isFalse);
|
||||
expect(File(p.join(moved.single, 'b.txt')).readAsStringSync(), 'abc');
|
||||
await expectLater(anyCopy(TransferControl(), [p.join(tmp.path, 'none')], dst), throwsA(isA<FileSystemException>()));
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/core/ftp/ftp_remote.dart';
|
||||
import 'package:jj_mkvmaker/core/ftp/ftp_server.dart';
|
||||
import 'package:jj_mkvmaker/core/ftp/ftp_session.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/sftp_server.dart' show sftpTestPython;
|
||||
|
||||
/// 나누기 ftp 약속 시험: lib/core/ftp/ 모듈마다 맨 위 약속 - 실제 서버 (tool/ftp_test_server.py, pyftpdlib) · 실제 시험 폴더.
|
||||
/// 시험용 파이썬이 없으면 서버 시험은 건너뛴다 (ftp_test 와 같음)
|
||||
const _pw = 'ftp-Pw-7Rk2xQ9t';
|
||||
|
||||
void main() {
|
||||
final hasPy = File(sftpTestPython).existsSync();
|
||||
late Directory tmp;
|
||||
Process? proc;
|
||||
late int port;
|
||||
late String fp;
|
||||
|
||||
Future<void> start(String mode) async {
|
||||
final pr = await Process.start(sftpTestPython, ['tool/ftp_test_server.py', '--root', tmp.path, '--mode', mode, '--password', _pw]);
|
||||
proc = pr;
|
||||
final lines = pr.stdout.transform(utf8.decoder).transform(const LineSplitter()).asBroadcastStream();
|
||||
pr.stderr.drain<void>();
|
||||
port = int.parse((await lines.firstWhere((l) => l.startsWith('PORT ')).timeout(const Duration(seconds: 20))).split(' ')[1]);
|
||||
final f = (await lines.firstWhere((l) => l.startsWith('FINGERPRINT')).timeout(const Duration(seconds: 5))).split(' ');
|
||||
fp = f.length > 1 ? f[1] : '';
|
||||
}
|
||||
|
||||
setUp(() => tmp = Directory.systemTemp.createTempSync('jj_ftpm_'));
|
||||
tearDown(() async {
|
||||
final pr = proc;
|
||||
proc = null;
|
||||
if (pr != null) {
|
||||
pr.kill();
|
||||
await pr.exitCode.timeout(const Duration(seconds: 5), onTimeout: () => -1);
|
||||
}
|
||||
for (var i = 0; tmp.existsSync(); i++) {
|
||||
try {
|
||||
tmp.deleteSync(recursive: true);
|
||||
} on FileSystemException {
|
||||
if (i > 30) rethrow;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
FtpServer srv(String security, {String cert = '', String password = _pw}) =>
|
||||
FtpServer(id: 'm1', name: '', host: '127.0.0.1', port: port, user: 'jj', password: password, security: security, certFingerprint: cert);
|
||||
Future<InternetAddress?> resolve(String h) async => InternetAddress.tryParse(h);
|
||||
|
||||
test('ftp_server: JSON 에 비밀 값 없음 · 모르는 값은 FTPS · 이름', () {
|
||||
const s = FtpServer(id: 'a', name: ' ', host: 'nas', password: 'secret', security: 'none');
|
||||
expect(jsonEncode(s.toJson()), isNot(contains('secret')));
|
||||
expect(FtpServer.fromJson(s.toJson()).security, 'none');
|
||||
expect(FtpServer.fromJson({'id': 'x', 'security': '?'}).security, 'explicit');
|
||||
expect((s.label, s.tls, s.copyWith(security: 'implicit').tls), ('nas', false, true));
|
||||
});
|
||||
|
||||
test('ftp_session: 처음 보는 인증서는 지문과 함께 막음 → 믿으면 로그인 · 명령 · 줄바꿈 명령은 보내지 않음', () async {
|
||||
await start('explicit');
|
||||
final first = await FtpSession.open(srv('explicit'), const Duration(seconds: 10), resolve);
|
||||
final e = await first.login().then<Object?>((_) => null, onError: (Object x) => x);
|
||||
first.close();
|
||||
expect(e, isA<FtpCertException>().having((x) => x.fingerprint, 'fingerprint', fp).having((x) => x.kind, 'kind', FtpFailure.certUnknown));
|
||||
final s = await FtpSession.open(srv('explicit', cert: fp), const Duration(seconds: 10), resolve);
|
||||
addTearDown(s.close);
|
||||
await s.login();
|
||||
expect(s.secure, isTrue);
|
||||
expect(s.home, '/');
|
||||
expect((await s.cmd('NOOP')).$1, 200);
|
||||
expect(() => s.send('DELE a\r\nDELE b'), throwsA(isA<FtpException>()));
|
||||
}, skip: hasPy ? false : '시험용 파이썬 없음');
|
||||
|
||||
test('ftp_remote: 쓰기 · 목록 · 정보 · 부분 읽기 · 옮기기 · 지우기 (평문) · 틀린 비밀번호는 auth', () async {
|
||||
await start('plain');
|
||||
final c = FtpRemote(srv('none'));
|
||||
addTearDown(c.close);
|
||||
await c.mkdirs('/가/나');
|
||||
await c.write('/가/나/a.txt', Stream.value(utf8.encode('0123456789')));
|
||||
expect(File(p.join(tmp.path, '가', '나', 'a.txt')).readAsStringSync(), '0123456789');
|
||||
expect((await c.list('/가/나')).map((x) => (x.rel, x.size)), [('/가/나/a.txt', 10)]);
|
||||
expect(await c.stat('/없음'), isNull);
|
||||
final part = await (await c.openReadFrom('/가/나/a.txt', offset: 4, length: 3)).expand((x) => x).toList();
|
||||
expect(utf8.decode(part), '456');
|
||||
await c.move('/가/나/a.txt', '/가/b.txt');
|
||||
expect(File(p.join(tmp.path, '가', 'b.txt')).existsSync(), isTrue);
|
||||
await c.delete('/가');
|
||||
expect(Directory(p.join(tmp.path, '가')).existsSync(), isFalse);
|
||||
final bad = FtpRemote(srv('none', password: 'wrong-pw'));
|
||||
addTearDown(bad.close);
|
||||
await expectLater(bad.list('/'), throwsA(isA<FtpException>().having((x) => x.kind, 'kind', FtpFailure.auth)));
|
||||
}, skip: hasPy ? false : '시험용 파이썬 없음');
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/core/sftp/sftp_remote.dart';
|
||||
import 'package:jj_mkvmaker/core/sftp/sftp_server.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/sftp_server.dart';
|
||||
|
||||
/// 나누기 sftp 약속 시험: lib/core/sftp/ 모듈마다 맨 위 약속 - 실제 서버 (tool/sftp_test_server.py, paramiko) · 실제 시험 폴더.
|
||||
/// 시험용 파이썬이 없으면 서버 시험은 건너뛴다 (sftp_test 와 같음)
|
||||
void main() {
|
||||
test('sftp_server: JSON 에 비밀 값 없음 · 지문 꼴 · 키 형식 · 암호 오류 종류', () {
|
||||
const s = SftpServer(id: 'a', name: '', host: 'nas', password: 'secret', privateKey: 'k');
|
||||
final j = s.toJson();
|
||||
expect(jsonEncode(j), isNot(contains('secret')));
|
||||
expect((j['hasPassword'], j['hasKey'], s.label), (true, true, 'nas'));
|
||||
expect(SftpServer.fromJson({'id': 'x', 'port': 99999}).port, 22);
|
||||
expect(sshFingerprint(Uint8List.fromList(utf8.encode('SHA256:abc'))), 'SHA256:abc');
|
||||
expect(sshFingerprint(Uint8List.fromList([1, 2, 3])), 'SHA256:AQID');
|
||||
expect(() => checkPrivateKey('PuTTY-User-Key-File-3: ssh-ed25519'),
|
||||
throwsA(isA<SftpException>().having((e) => e.kind, 'kind', SftpFailure.keyFormat)));
|
||||
expect(() => parsePrivateKey('not a key', ''), throwsA(isA<SftpException>().having((e) => e.kind, 'kind', SftpFailure.keyFormat)));
|
||||
});
|
||||
|
||||
test('sftp_remote: 처음 보는 호스트 키는 지문과 함께 막음 → 믿으면 쓰기 · 목록 · 부분 읽기 · 옮기기 · 지우기 · 틀린 비밀번호는 auth', () async {
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_sftpm_');
|
||||
final root = Directory(p.join(tmp.path, 'root'))..createSync();
|
||||
final srv = await SftpTestServer.start(root: root.path, hostKeyFile: p.join(tmp.path, 'host.key'));
|
||||
addTearDown(() async {
|
||||
await srv.stop();
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
SftpServer conf({String fp = '', String pw = 'pw'}) =>
|
||||
SftpServer(id: 'm1', name: '', host: '127.0.0.1', port: srv.port, user: 'jj', password: pw, hostKeyFingerprint: fp, root: root.path);
|
||||
final first = SftpRemote(conf());
|
||||
final e = await first.list('/').then<Object?>((_) => null, onError: (Object x) => x);
|
||||
first.close();
|
||||
expect(e, isA<SftpHostKeyException>().having((x) => x.fingerprint, 'fp', srv.fingerprint).having((x) => x.changed, 'changed', isFalse));
|
||||
final c = SftpRemote(conf(fp: srv.fingerprint));
|
||||
addTearDown(c.close);
|
||||
await c.mkdirs('/가/나');
|
||||
await c.write('/가/나/a.txt', Stream.value(utf8.encode('0123456789')));
|
||||
expect(File(p.join(root.path, '가', '나', 'a.txt')).readAsStringSync(), '0123456789');
|
||||
expect((await c.list('/가/나')).map((x) => (x.rel, x.size)), [('/가/나/a.txt', 10)]);
|
||||
expect(await c.stat('/없음'), isNull);
|
||||
final part = await (await c.openReadFrom('/가/나/a.txt', offset: 4, length: 3)).expand((x) => x).toList();
|
||||
expect(utf8.decode(part), '456');
|
||||
await c.move('/가/나/a.txt', '/가/b.txt');
|
||||
expect(File(p.join(root.path, '가', 'b.txt')).existsSync(), isTrue);
|
||||
await c.delete('/가');
|
||||
expect(Directory(p.join(root.path, '가')).existsSync(), isFalse);
|
||||
final bad = SftpRemote(conf(fp: srv.fingerprint, pw: 'wrong'));
|
||||
addTearDown(bad.close);
|
||||
await expectLater(bad.list('/'), throwsA(isA<SftpException>().having((x) => x.kind, 'kind', SftpFailure.auth)));
|
||||
}, skip: sftpTestServerAvailable ? false : '시험용 파이썬 없음', timeout: const Timeout(Duration(minutes: 3)));
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/core/sync/copy_commands.dart';
|
||||
import 'package:jj_mkvmaker/core/sync/copy_output.dart';
|
||||
import 'package:jj_mkvmaker/core/sync/rsync_options.dart';
|
||||
|
||||
/// 나누기 sync_tools 약속 시험: lib/core/sync/ 모듈마다 맨 위 약속 (입력 · 출력 · 오류 없음).
|
||||
/// 실제 rsync · robocopy 실행은 rsync_msys_args_test · move_settled_test 등이 본다
|
||||
void main() {
|
||||
test('rsync_options: 따옴표 · 굽은 따옴표 · 대시 고쳐 나누기 · 문제는 던지지 않고 목록으로', () {
|
||||
expect(splitOptions('-av --exclude="a b.txt"'), ['-av', '--exclude=a b.txt']);
|
||||
expect(splitOptions('–delete --exclude=‘*.log’'), ['--delete', '--exclude=*.log']);
|
||||
expect(optionProblems('-a "x', windows: false).first.kind, OptionProblemKind.unclosed);
|
||||
expect(optionProblems('-a delete', windows: false).map((e) => e.kind), [OptionProblemKind.path]);
|
||||
expect(optionProblems('--exclude-from=list.txt', windows: true).map((e) => e.kind), [OptionProblemKind.relativePath]);
|
||||
expect(optionProblems('-avP --exclude *.tmp', windows: false), isEmpty);
|
||||
expect(listFileValues(['--files-from', '/a/list', '-f', '. /b/rules']), ['/a/list', '/b/rules']);
|
||||
expect(optionProblemHint([const OptionProblem(OptionProblemKind.noValue, 'x')], windows: false), isNotEmpty);
|
||||
});
|
||||
|
||||
test('copy_commands: rsync 인수 (원본 지우기는 이동일 때만 · 경로는 MSYS 꼴) · robocopy 묶음 · 성공 코드', () {
|
||||
final a = rsyncArgs(options: '-av --remove-source-files', sources: [r'C:\s\dir\'], dest: r'D:\t', bandwidthKBps: 100);
|
||||
expect(a, ['-av', '--bwlimit=100', '--exclude=~jjren_*', '--exclude=~jjback_*', '/cygdrive/c/s/dir', '/cygdrive/d/t/']);
|
||||
expect(rsyncArgs(options: '-a', sources: ['/s'], dest: '/t', move: true, windows: false), contains('--remove-source-files'));
|
||||
expect(rsyncDestPath('//nas/share/x'), r'\\nas\share\x');
|
||||
expect(withUpdateOption('-avu'), '-avu');
|
||||
expect(withUpdateOption('-av'), '-av -u');
|
||||
final runs = robocopyRuns(options: '/E', folders: [r'C:\s\f'], files: [r'C:\s\a.txt'], dest: r'D:\t', move: true);
|
||||
expect(runs.first.take(2), [r'C:\s\f', r'D:\t\f']);
|
||||
expect(runs.first, containsAll(['/MOVE', '/XJ']));
|
||||
expect(runs.last, [r'C:\s', r'D:\t', 'a.txt', '/MOV', '/IS', '/IT', '/IM', '/XJ']);
|
||||
expect([rsyncOk(24), rsyncOk(23), robocopyOk(7), robocopyOk(8)], [true, false, true, false]);
|
||||
expect(CopyMethod.of('없음'), CopyMethod.builtin);
|
||||
});
|
||||
|
||||
test('copy_output: 줄 중간에서 끊긴 조각을 이어 읽음 · 진행률 · 속도 · 모르는 줄은 건너뜀', () {
|
||||
final r = RsyncOutput();
|
||||
expect(r.feed('sending incremental file list\nsub/'), isEmpty);
|
||||
expect(r.feed('\nsub/inner.txt\n 1,024 45% 1.50MB/s 0:00:01 (xfr#1, to-chk=3/10)\r'), ['sub/inner.txt']);
|
||||
expect((r.currentPercent, r.checked, r.checkTotal), (0.45, 7, 10));
|
||||
expect(r.currentSpeed, 1.5 * 1024 * 1024);
|
||||
final b = RobocopyOutput();
|
||||
expect(b.feed('\t New File \t\t 1234\tC:\\s\\a.txt\n\t *EXTRA File \t\t 1\tx\n 50%\n'), ['C:\\s\\a.txt']);
|
||||
expect(b.currentPercent, 0.5);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue