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? _sub; final _buf = []; final _lines = []; Completer? _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 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 _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(); 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 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 _secure(RawSocket raw, StreamSubscription? 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 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 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 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;