나누기 core C: sftp (612) 를 lib/core/sftp/ 모듈 2개로 (→ 정면 sftp)
- sftp_server (설정 · 실패 종류 · 오류 · 개인 키 읽기 · 호스트 키 지문 · 연결 꼴) - sftp_remote (SftpRemote 원격 저장소 · 연결 · 소켓 다리 · SftpRegistry) - sftp.dart 는 export 만 (부르는 쪽은 바꾸지 않음) - 약속 시험: test/sftp_modules_contract_test.dart (실제 paramiko 서버 · 시험 폴더) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>split_core
parent
8c3b9b7c55
commit
5a897344c6
|
|
@ -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,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)));
|
||||
}
|
||||
Loading…
Reference in New Issue