QFtpCompat 1.0.0
Qt6-compatible async FTP client library
Loading...
Searching...
No Matches
qftpcompat.cpp
1// SPDX-License-Identifier: MIT
2
3#include "qftpcompat.h"
4#include "private/qftpcompatcommand_p.h"
5
6#include <QBuffer>
7#include <QDebug>
8#include <QQueue>
9#include <QSslSocket>
10#include <QTimer>
11
12using Cmd = QFtpCompatInternal::Command;
13
14static constexpr quint16 DefaultFtpPort = 21;
15static constexpr int InactivityMs = 30000;
16static const QString AnonymousUser = QStringLiteral("anonymous");
17static const QString AnonymousPass = QStringLiteral("anonymous@");
18
19// =============================================================================
20// Construction / Destruction
21// =============================================================================
22
23QFtpCompat::QFtpCompat(QObject *parent)
24 : QObject(parent)
25 , m_control(new QSslSocket(this))
26 , m_data(new QSslSocket(this))
27{
28 qDebug() << Q_FUNC_INFO << "created";
29 // Control channel
30 connect(m_control, &QAbstractSocket::connected,
31 this, &QFtpCompat::onControlConnected);
32 connect(m_control, &QAbstractSocket::readyRead,
33 this, &QFtpCompat::onControlReadyRead);
34 connect(m_control, &QAbstractSocket::errorOccurred,
35 this, &QFtpCompat::onControlError);
36
37 // Control channel – SSL
38 connect(m_control, &QSslSocket::encrypted, this, [this]() {
39 qDebug() << Q_FUNC_INFO << "control channel encrypted";
40 if (m_ctrlState == ControlState::WaitingForControlHandshake) {
41 if (m_encMode == EncryptionMode::Implicit) {
42 // Implicit: TLS done, now expect the 220 banner
43 m_ctrlState = ControlState::WaitingForWelcome;
44 } else {
45 // Explicit: TLS done after AUTH TLS, send PBSZ 0
46 sendCommand("PBSZ 0");
47 m_ctrlState = ControlState::WaitingForPbszAck;
48 }
49 }
50 });
51 connect(m_control, &QSslSocket::sslErrors, this,
52 [this](const QList<QSslError> &errs) {
53 qDebug() << Q_FUNC_INFO << "control SSL errors:" << errs;
54 emit sslErrors(errs);
55 });
56
57 // Data channel
58 connect(m_data, &QAbstractSocket::connected, this, &QFtpCompat::onDataConnected);
59 connect(m_data, &QAbstractSocket::readyRead, this, &QFtpCompat::onDataReadyRead);
60 connect(m_data, &QAbstractSocket::disconnected, this, &QFtpCompat::onDataDisconnected);
61 connect(m_data, &QAbstractSocket::errorOccurred,this, &QFtpCompat::onDataError);
62
63 // Data channel – SSL
64 connect(m_data, &QSslSocket::encrypted, this, [this]() {
65 qDebug() << Q_FUNC_INFO << "data channel encrypted";
66 if (m_ctrlState == ControlState::WaitingForDataHandshake) {
67 m_ctrlState = ControlState::WaitingForTransferStartAck;
68 sendDataTransferCommand();
69 }
70 });
71 connect(m_data, &QSslSocket::sslErrors, this,
72 [this](const QList<QSslError> &errs) {
73 qDebug() << Q_FUNC_INFO << "data SSL errors:" << errs;
74 emit sslErrors(errs);
75 });
76}
77
78QFtpCompat::~QFtpCompat()
79{
80 qDebug() << Q_FUNC_INFO << "destroyed";
81 qDeleteAll(m_queue);
82 delete m_current;
83}
84
85// =============================================================================
86// Public API – connection
87// =============================================================================
88
89int QFtpCompat::open(const QUrl &url, EncryptionMode mode)
90{
91 qDebug() << Q_FUNC_INFO << url.toString() << "encryption:" << static_cast<int>(mode);
92 auto *cmd = new Cmd;
93 cmd->type = Cmd::Type::Open;
94 cmd->url = url;
95 cmd->encMode = mode;
96 return enqueue(cmd);
97}
98
99void QFtpCompat::setSslConfiguration(const QSslConfiguration &config)
100{
101 m_sslConfig = config;
102 m_control->setSslConfiguration(config);
103 m_data->setSslConfiguration(config);
104}
105
106QSslConfiguration QFtpCompat::sslConfiguration() const
107{
108 return m_sslConfig;
109}
110
112{
113 m_control->ignoreSslErrors();
114 m_data->ignoreSslErrors();
115}
116
118{
119 qDebug() << Q_FUNC_INFO;
120 clearPendingCommands();
121
122 auto *cmd = new Cmd;
123 cmd->type = Cmd::Type::Close;
124 enqueue(cmd);
125}
126
128{
129 if (!m_current) {
130 return;
131 }
132
133 qDebug() << Q_FUNC_INFO << "aborting command" << m_current->id;
134
135 m_abortPending = true;
136
137 if (isDataTransferCommand()) {
138 // RFC 959: send ABOR on the control channel; the server responds
139 // with 426 (transfer aborted) followed by 226 (ABOR successful).
140 // Abort the data socket to stop the transfer immediately.
141 sendCommand("ABOR");
142 m_data->abort();
143 m_ctrlState = ControlState::WaitingForAbortAck;
144 } else {
145 // No data channel active – mark the current command as aborted
146 // and move on.
147 setError(Error::Aborted,
148 QStringLiteral("Operation aborted by the user (command id %1)")
149 .arg(m_current->id));
150 finalizeCommand(true);
151 }
152}
153
154// =============================================================================
155// Public API – transfer commands
156// =============================================================================
157
158int QFtpCompat::get(const QUrl &url, QIODevice *dst, TransferType type)
159{
160 qDebug() << Q_FUNC_INFO << url.path();
161 auto *cmd = new Cmd;
162 cmd->type = Cmd::Type::Get;
163 cmd->url = url;
164 cmd->device = dst;
165 cmd->transferType = type;
166 return enqueue(cmd);
167}
168
169int QFtpCompat::get(const QUrl &url, QByteArray *dst, TransferType type)
170{
171 qDebug() << Q_FUNC_INFO << url.path() << "(→ QByteArray)";
172 // Wrap the QByteArray in a QBuffer so the rest of the code stays uniform.
173 auto *buf = new QBuffer;
174 buf->open(QIODevice::WriteOnly);
175
176 auto *cmd = new Cmd;
177 cmd->type = Cmd::Type::Get;
178 cmd->url = url;
179 cmd->device = buf;
180 cmd->byteArrayDst = dst;
181 cmd->ownsDevice = true; // we created the QBuffer; we must delete it
182 cmd->transferType = type;
183 return enqueue(cmd);
184}
185
186int QFtpCompat::put(const QUrl &url, const QByteArray &data, TransferType type)
187{
188 qDebug() << Q_FUNC_INFO << url.path() << "size:" << data.size();
189 auto *buf = new QBuffer;
190 buf->setData(data);
191 buf->open(QIODevice::ReadOnly);
192
193 auto *cmd = new Cmd;
194 cmd->type = Cmd::Type::Put;
195 cmd->url = url;
196 cmd->device = buf;
197 cmd->ownsDevice = true;
198 cmd->transferType = type;
199 return enqueue(cmd);
200}
201
202int QFtpCompat::put(const QUrl &url, QIODevice *src, TransferType type)
203{
204 qDebug() << Q_FUNC_INFO << url.path()
205 << "size:" << (src ? src->size() : -1);
206 auto *cmd = new Cmd;
207 cmd->type = Cmd::Type::Put;
208 cmd->url = url;
209 cmd->device = src;
210 cmd->transferType = type;
211 return enqueue(cmd);
212}
213
214// =============================================================================
215// Public API – directory / management commands
216// =============================================================================
217
218int QFtpCompat::list(const QUrl &url)
219{
220 qDebug() << Q_FUNC_INFO << url.path();
221 auto *cmd = new Cmd;
222 cmd->type = Cmd::Type::List;
223 cmd->url = url;
224 return enqueue(cmd);
225}
226
227int QFtpCompat::mkdir(const QUrl &url)
228{
229 qDebug() << Q_FUNC_INFO << url.path();
230 auto *cmd = new Cmd;
231 cmd->type = Cmd::Type::Mkdir;
232 cmd->url = url;
233 return enqueue(cmd);
234}
235
236int QFtpCompat::rmdir(const QUrl &url)
237{
238 qDebug() << Q_FUNC_INFO << url.path();
239 auto *cmd = new Cmd;
240 cmd->type = Cmd::Type::Rmdir;
241 cmd->url = url;
242 return enqueue(cmd);
243}
244
245int QFtpCompat::remove(const QUrl &url)
246{
247 qDebug() << Q_FUNC_INFO << url.path();
248 auto *cmd = new Cmd;
249 cmd->type = Cmd::Type::Remove;
250 cmd->url = url;
251 return enqueue(cmd);
252}
253
254int QFtpCompat::rename(const QUrl &from, const QUrl &to)
255{
256 qDebug() << Q_FUNC_INFO << from.path() << "->" << to.path();
257 auto *cmd = new Cmd;
258 cmd->type = Cmd::Type::Rename;
259 cmd->url = from;
260 cmd->renameDst = to.path();
261 return enqueue(cmd);
262}
263
264int QFtpCompat::cd(const QUrl &url)
265{
266 qDebug() << Q_FUNC_INFO << url.path();
267 auto *cmd = new Cmd;
268 cmd->type = Cmd::Type::Cd;
269 cmd->url = url;
270 return enqueue(cmd);
271}
272
274{
275 auto *cmd = new Cmd;
276 cmd->type = Cmd::Type::Pwd;
277 cmd->rawCmd = QStringLiteral("PWD");
278 return enqueue(cmd);
279}
280
281int QFtpCompat::rawCommand(const QString &command)
282{
283 qDebug() << Q_FUNC_INFO << command;
284 auto *cmd = new Cmd;
285 cmd->type = Cmd::Type::Raw;
286 cmd->rawCmd = command;
287 return enqueue(cmd);
288}
289
290// =============================================================================
291// Public API – state queries
292// =============================================================================
293
294QFtpCompat::State QFtpCompat::state() const noexcept { return m_state; }
295QFtpCompat::Error QFtpCompat::error() const noexcept { return m_error; }
296QString QFtpCompat::errorString() const noexcept { return m_errorString; }
297int QFtpCompat::currentId() const noexcept { return m_current ? m_current->id : -1; }
298bool QFtpCompat::hasPendingCommands() const noexcept { return !m_queue.isEmpty(); }
299
301{
302 qDebug() << Q_FUNC_INFO;
303 clearPendingCommands();
304
305 // Delete current command without emitting commandFinished
306 delete m_current;
307 m_current = nullptr;
308
309 // Set Unconnected BEFORE aborting sockets so that onControlError()
310 // sees the Unconnected state and returns early without re-finalizing.
311 setState(State::Unconnected);
312 m_ctrlState = ControlState::Idle;
313 m_abortPending = false;
314
315 // Hard-close both channels (no QUIT, immediate TCP RST)
316 m_data->abort();
317 m_control->abort();
318 m_encMode = EncryptionMode::None;
319}
320
321void QFtpCompat::clearPendingCommands()
322{
323 qDebug() << Q_FUNC_INFO << "clearing" << m_queue.size() << "pending commands";
324 qDeleteAll(m_queue);
325 m_queue.clear();
326}
327
328// =============================================================================
329// Command queue management
330// =============================================================================
331
332int QFtpCompat::enqueue(Cmd *cmd)
333{
334 cmd->id = m_nextId++;
335 qDebug() << Q_FUNC_INFO << "id:" << cmd->id
336 << "type:" << static_cast<int>(cmd->type)
337 << "queue depth:" << m_queue.size() + 1;
338 m_queue.enqueue(cmd);
339
340 // If nothing is currently executing, kick off the queue
341 if (!m_current) {
342 QMetaObject::invokeMethod(this, &QFtpCompat::startNextCommand,
343 Qt::QueuedConnection);
344 }
345 return cmd->id;
346}
347
348void QFtpCompat::startNextCommand()
349{
350 qDebug() << Q_FUNC_INFO << "queue:" << m_queue.size();
351 if (m_current || m_queue.isEmpty()) {
352 return;
353 }
354
355 m_current = m_queue.dequeue();
356
357 // Reset per-command transfer flags
358 m_dataChannelClosed = false;
359 m_ctrlTransferComplete = false;
360 m_abortPending = false;
361 m_dataReadBuf.clear();
362
363 emit commandStarted(m_current->id);
364 startCurrentCommand();
365}
366
367void QFtpCompat::startCurrentCommand()
368{
369 Q_ASSERT(m_current);
370
371 // All commands except Open require an active login.
372 // Fail fast instead of writing to a closed socket and hanging.
373 if (m_current->type != Cmd::Type::Open
374 && m_current->type != Cmd::Type::Close
375 && m_state != State::LoggedIn) {
376 qWarning() << Q_FUNC_INFO
377 << "not logged in – failing command"
378 << static_cast<int>(m_current->type);
379 setError(Error::NotLoggedIn,
380 QStringLiteral("command %1 requires login")
381 .arg(static_cast<int>(m_current->type)));
382 finalizeCommand(true);
383 return;
384 }
385
386 switch (m_current->type) {
387
388 case Cmd::Type::Open: {
389 if (m_state == State::LoggedIn) {
390 finalizeCommand(false);
391 return;
392 }
393 if (m_state == State::Unconnected) {
394 const QUrl &url = m_current->url;
395 m_encMode = m_current->encMode;
396 m_host = url.host();
397
398 // Default port: 990 for Implicit FTPS, 21 for everything else
399 constexpr quint16 ImplicitFtpsPort = 990;
400 m_port = (url.port() > 0)
401 ? static_cast<quint16>(url.port())
402 : (m_encMode == EncryptionMode::Implicit ? ImplicitFtpsPort
403 : DefaultFtpPort);
404
405 if (m_host.trimmed().isEmpty()) {
406 setError(Error::HostNotFound,
407 QStringLiteral("open(): URL contains no host name ('%1')")
408 .arg(url.toString()));
409 finalizeCommand(true);
410 return;
411 }
412
413 // Apply SSL configuration to both sockets
414 m_control->setSslConfiguration(m_sslConfig);
415 m_data->setSslConfiguration(m_sslConfig);
416
417 qDebug() << Q_FUNC_INFO
418 << "Connecting to"
419 << m_host << ":" << m_port
420 << "encryption:" << static_cast<int>(m_encMode);
421
422 setState(State::Connecting);
423
424 if (m_encMode == EncryptionMode::Implicit) {
425 // Implicit FTPS: TLS from the first byte
426 // Wait for encrypted() signal before expecting 220 banner
427 m_ctrlState = ControlState::WaitingForControlHandshake;
428 m_control->connectToHostEncrypted(m_host, m_port);
429 } else {
430 // Plain FTP or Explicit FTPS: plain TCP connect first
431 m_ctrlState = ControlState::WaitingForWelcome;
432 m_control->connectToHost(m_host, m_port);
433 }
434 return;
435 }
436 setError(Error::NetworkError,
437 QStringLiteral("open(): called in unexpected state %1")
438 .arg(static_cast<int>(m_state)));
439 finalizeCommand(true);
440 return;
441 }
442
443 case Cmd::Type::List:
444 case Cmd::Type::Get:
445 case Cmd::Type::Put:
446 if (m_state != State::LoggedIn) {
447 setError(Error::NotLoggedIn,
448 QStringLiteral("FTP %1: cannot execute – not logged in")
449 .arg(m_current->type == Cmd::Type::List ? QLatin1String("LIST")
450 : m_current->type == Cmd::Type::Get ? QLatin1String("RETR")
451 : QLatin1String("STOR")));
452 finalizeCommand(true);
453 return;
454 }
455 beginDataTransfer();
456 return;
457
458 case Cmd::Type::Remove:
459 sendCommand("DELE " + m_current->url.path().toUtf8());
460 m_ctrlState = ControlState::WaitingForSimpleAck;
461 return;
462
463 case Cmd::Type::Mkdir:
464 sendCommand("MKD " + m_current->url.path().toUtf8());
465 m_ctrlState = ControlState::WaitingForSimpleAck;
466 return;
467
468 case Cmd::Type::Rmdir:
469 sendCommand("RMD " + m_current->url.path().toUtf8());
470 m_ctrlState = ControlState::WaitingForSimpleAck;
471 return;
472
473 case Cmd::Type::Rename:
474 sendCommand("RNFR " + m_current->url.path().toUtf8());
475 m_ctrlState = ControlState::WaitingForRnfrAck;
476 return;
477
478 case Cmd::Type::Cd:
479 sendCommand("CWD " + m_current->url.path().toUtf8());
480 m_ctrlState = ControlState::WaitingForSimpleAck;
481 return;
482
483 case Cmd::Type::Pwd:
484 case Cmd::Type::Raw:
485 sendCommand(m_current->rawCmd.toUtf8());
486 m_ctrlState = ControlState::WaitingForSimpleAck;
487 return;
488
489 case Cmd::Type::Close:
490 setState(State::Closing);
491 sendCommand("QUIT");
492 m_ctrlState = ControlState::WaitingForQuitAck;
493 return;
494 }
495}
496
497void QFtpCompat::finalizeCommand(bool hadError)
498{
499 if (!m_current) {
500 return;
501 }
502
503 const int id = m_current->id;
504 qDebug() << Q_FUNC_INFO << "id:" << id << "error:" << hadError;
505
506 // Clean up owned device
507 if (m_current->ownsDevice && m_current->device) {
508 // For get-to-QByteArray: flush QBuffer → QByteArray
509 if (m_current->byteArrayDst) {
510 auto *buf = qobject_cast<QBuffer *>(m_current->device);
511 if (buf) {
512 *m_current->byteArrayDst = buf->data();
513 }
514 }
515 delete m_current->device;
516 m_current->device = nullptr;
517 }
518
519 delete m_current;
520 m_current = nullptr;
521
522 emit commandFinished(id, hadError);
523
524 if (m_queue.isEmpty()) {
525 emit done(hadError);
526 } else {
527 QMetaObject::invokeMethod(this, &QFtpCompat::startNextCommand,
528 Qt::QueuedConnection);
529 }
530}
531
532// =============================================================================
533// Control channel
534// =============================================================================
535
536void QFtpCompat::sendCommand(const QByteArray &cmd)
537{
538 if (cmd.startsWith("PASS")) {
539 qDebug() << Q_FUNC_INFO << "PASS ****";
540 } else {
541 qDebug() << Q_FUNC_INFO << cmd;
542 }
543 m_control->write(cmd + "\r\n");
544}
545
546void QFtpCompat::onControlConnected()
547{
548 qDebug() << Q_FUNC_INFO;
549 setState(State::Connected);
550 // Welcome banner (220) arrives via onControlReadyRead → processResponse
551}
552
553void QFtpCompat::onControlReadyRead()
554{
555 // canReadLine() returns true only when a complete line (ending with '\n')
556 // is available in Qt's internal socket buffer. Qt handles partial-line
557 // buffering internally, so no manual accumulation buffer is needed here.
558 while (m_control->canReadLine()) {
559 const QByteArray line = m_control->readLine().trimmed();
560
561 if (line.size() < 3) {
562 continue;
563 }
564
565 // Multi-line response continuation: "NNN-text" – skip until "NNN text"
566 if (line.size() >= 4 && line.at(3) == '-') {
567 qDebug() << Q_FUNC_INFO << "[multi-line]" << line;
568 continue;
569 }
570
571 bool ok{false};
572 const int code = line.left(3).toInt(&ok);
573 if (!ok) {
574 qDebug() << Q_FUNC_INFO << "Unparseable control line:" << line;
575 continue;
576 }
577
578 processResponse(code, line.size() > 4 ? line.mid(4) : QByteArray{});
579 }
580}
581
582void QFtpCompat::processResponse(int code, const QByteArray &message)
583{
584 qDebug() << Q_FUNC_INFO << code << message;
585
586 // Server-side errors in any state → abort current command.
587 // 421 = service unavailable (timeout/shutdown).
588 if (code >= 500) {
589 // Switch to Idle BEFORE aborting the data socket so that any
590 // synchronous onDataDisconnected / onDataError fired by abort()
591 // is filtered by the state guard and does not corrupt state.
592 m_ctrlState = ControlState::Idle;
593 if (m_data->state() != QAbstractSocket::UnconnectedState) {
594 m_data->abort();
595 }
596 const QString detail = QStringLiteral("server returned error %1: '%2'")
597 .arg(code)
598 .arg(QString::fromUtf8(message));
599 setError(code == 530 ? Error::LoginFailed
600 : code == 550 ? Error::FileNotFound
601 : code == 553 ? Error::PermissionDenied
603 detail);
604 finalizeCommand(true);
605 return;
606 }
607 if (code == 421) {
608 setError(Error::NetworkTimeout,
609 QStringLiteral("server closed connection (421): '%1'")
610 .arg(QString::fromUtf8(message)));
611 finalizeCommand(true);
612 m_control->abort();
613 setState(State::Unconnected);
614 return;
615 }
616
617 switch (m_ctrlState) {
618
619 // ------------------------------------------------------------------
620 // Open: waiting for server greeting
621 // ------------------------------------------------------------------
622 case ControlState::WaitingForWelcome:
623 if (code == 220) {
624 setState(State::Connected);
625 if (m_encMode == EncryptionMode::Explicit) {
626 // Explicit FTPS: upgrade the control channel to TLS first
627 sendCommand("AUTH TLS");
628 m_ctrlState = ControlState::WaitingForAuthTlsAck;
629 } else {
630 const QUrl &url = m_current->url;
631 const QString user = url.userName().isEmpty() ? AnonymousUser : url.userName();
632 sendCommand("USER " + user.toUtf8());
633 m_ctrlState = ControlState::WaitingForUserAck;
634 }
635 } else {
636 setError(Error::ServerError,
637 QStringLiteral("open(): expected 220 welcome banner, got %1: '%2'")
638 .arg(code).arg(QString::fromUtf8(message)));
639 finalizeCommand(true);
640 }
641 break;
642
643 case ControlState::WaitingForUserAck:
644 if (code == 331) {
645 // Password required (normal)
646 const QUrl &url = m_current->url;
647 const QString pass = url.password().isEmpty() ? AnonymousPass : url.password();
648 sendCommand("PASS " + pass.toUtf8());
649 m_ctrlState = ControlState::WaitingForPassAck;
650 } else if (code == 230) {
651 // Server accepted without password
652 setState(State::LoggedIn);
653 finalizeCommand(false);
654 } else {
655 setError(Error::LoginFailed,
656 QStringLiteral("open(): USER rejected (code %1): '%2' – check username")
657 .arg(code).arg(QString::fromUtf8(message)));
658 finalizeCommand(true);
659 }
660 break;
661
662 case ControlState::WaitingForPassAck:
663 if (code == 230) {
664 setState(State::LoggedIn);
665 finalizeCommand(false);
666 } else if (code == 332) {
667 // ACCT required (rare)
668 setError(Error::LoginFailed,
669 QStringLiteral("open(): server requires ACCOUNT (332), which is not supported"));
670 finalizeCommand(true);
671 } else {
672 setError(Error::LoginFailed,
673 QStringLiteral("open(): authentication failed (code %1): '%2' – check password")
674 .arg(code).arg(QString::fromUtf8(message)));
675 finalizeCommand(true);
676 }
677 break;
678
679 // ------------------------------------------------------------------
680 // Data-transfer preamble: TYPE response
681 // ------------------------------------------------------------------
682 case ControlState::WaitingForTypeAck:
683 // RFC 959: 200 = command OK; some embedded servers respond 250
684 if (code == 200 || code == 250) {
685 sendPasv();
686 } else {
687 setError(Error::ServerError,
688 QStringLiteral("TYPE command rejected (code %1): '%2'")
689 .arg(code).arg(QString::fromUtf8(message)));
690 finalizeCommand(true);
691 }
692 break;
693
694 // ------------------------------------------------------------------
695 // Data-transfer preamble: PASV response
696 // ------------------------------------------------------------------
697 case ControlState::WaitingForPasvAck: {
698 if (code != 227) {
699 setError(Error::ServerError,
700 QStringLiteral("PASV rejected (code %1): '%2'")
701 .arg(code).arg(QString::fromUtf8(message)));
702 finalizeCommand(true);
703 return;
704 }
705
706 QString dataHost;
707 quint16 dataPort{0};
708 if (!parsePasvAddress(message, dataHost, dataPort)) {
709 setError(Error::ParseError,
710 QStringLiteral("PASV: could not parse server address from '%1'")
711 .arg(QString::fromUtf8(message)));
712 finalizeCommand(true);
713 return;
714 }
715
716 // NAT fallback: some embedded FTP servers report 0.0.0.0
717 if (dataHost == QLatin1String("0.0.0.0") || dataHost.isEmpty()) {
718 dataHost = m_control->peerAddress().toString();
719 qDebug() << Q_FUNC_INFO
720 << "PASV returned invalid host, using control peer address:" << dataHost;
721 }
722
723 if (dataPort == 0) {
724 setError(Error::ParseError,
725 QStringLiteral("PASV: server returned port 0 in '%1'")
726 .arg(QString::fromUtf8(message)));
727 finalizeCommand(true);
728 return;
729 }
730
731 qDebug() << Q_FUNC_INFO << "Data channel:" << dataHost << ":" << dataPort;
732 m_ctrlState = ControlState::ConnectingData;
733 m_data->connectToHost(dataHost, dataPort);
734 break;
735 }
736
737 // ConnectingData: no control-channel response expected here.
738 // onDataConnected() drives the next step.
739 case ControlState::ConnectingData:
740 break;
741
742 // ------------------------------------------------------------------
743 // Transfer start acknowledgement (RETR / STOR / LIST)
744 // ------------------------------------------------------------------
745 case ControlState::WaitingForTransferStartAck:
746 if (code == 125 || code == 150) {
747 m_ctrlState = ControlState::Transferring;
748 if (m_current && m_current->type == Cmd::Type::Put) {
749 // Upload: read source into buffer and write it all to the data channel.
750 // Qt buffers the write; disconnectFromHost() is deferred until sent.
751 QByteArray payload;
752 if (m_current->device) {
753 if (!m_current->device->isReadable()) {
754 m_current->device->open(QIODevice::ReadOnly);
755 }
756 payload = m_current->device->readAll();
757 }
758 qDebug() << Q_FUNC_INFO << "UP uploading:" << payload.size() << "bytes";
759 emit dataTransferProgress(0, payload.size());
760 m_data->write(payload);
761 m_data->disconnectFromHost();
762 }
763 // For Get / List: data arrives via onDataReadyRead()
764 } else {
765 setError(Error::ServerError,
766 QStringLiteral("transfer start rejected (code %1): '%2' – "
767 "check remote path '%3'")
768 .arg(code)
769 .arg(QString::fromUtf8(message))
770 .arg(m_current ? m_current->url.path() : QString{}));
771 finalizeCommand(true);
772 }
773 break;
774
775 // ------------------------------------------------------------------
776 // Transfer completion (226 / 250)
777 // Can arrive before or after onDataDisconnected().
778 // ------------------------------------------------------------------
779 case ControlState::Transferring:
780 case ControlState::WaitingForTransferCompleteAck:
781 if (code == 226 || code == 250) {
782 m_ctrlTransferComplete = true;
783 if (m_dataChannelClosed) {
784 onTransferCompleted();
785 }
786 } else if (code >= 400) {
787 setError(Error::TransferFailed,
788 QStringLiteral("transfer failed mid-stream (code %1): '%2'")
789 .arg(code).arg(QString::fromUtf8(message)));
790 m_data->abort();
791 finalizeCommand(true);
792 }
793 break;
794
795 // ------------------------------------------------------------------
796 // ABOR acknowledgement
797 // RFC 959: server sends 426 (connection closed, transfer aborted)
798 // then 226 (abort successful). We accept either 2xx as "done".
799 // ------------------------------------------------------------------
800 case ControlState::WaitingForAbortAck:
801 if (code == 426) {
802 // Expected "transfer aborted" – wait for the following 226
803 break;
804 }
805 if (code == 226 || code == 225) {
806 setError(Error::Aborted,
807 QStringLiteral("operation aborted by user (ABOR acknowledged)"));
808 finalizeCommand(true);
809 }
810 break;
811
812 // ------------------------------------------------------------------
813 // Simple single-response commands (DELE, MKD, RMD, CWD, PWD, raw)
814 // ------------------------------------------------------------------
815 case ControlState::WaitingForSimpleAck:
816 if (code >= 200 && code < 300) {
817 if (m_current && (m_current->type == Cmd::Type::Pwd
818 || m_current->type == Cmd::Type::Raw)) {
819 emit rawCommandReply(code, QString::fromUtf8(message));
820 }
821 finalizeCommand(false);
822 } else {
823 setError(Error::ServerError,
824 QStringLiteral("command rejected (code %1): '%2' – path: '%3'")
825 .arg(code)
826 .arg(QString::fromUtf8(message))
827 .arg(m_current ? m_current->url.path() : QString{}));
828 finalizeCommand(true);
829 }
830 break;
831
832 // ------------------------------------------------------------------
833 // RNFR / RNTO
834 // ------------------------------------------------------------------
835 case ControlState::WaitingForRnfrAck:
836 if (code == 350) {
837 // "File exists, ready for destination name"
838 if (m_current) {
839 sendCommand("RNTO " + m_current->renameDst.toUtf8());
840 m_ctrlState = ControlState::WaitingForRntoAck;
841 }
842 } else {
843 setError(Error::ServerError,
844 QStringLiteral("RNFR rejected (code %1): '%2' – source path: '%3'")
845 .arg(code)
846 .arg(QString::fromUtf8(message))
847 .arg(m_current ? m_current->url.path() : QString{}));
848 finalizeCommand(true);
849 }
850 break;
851
852 case ControlState::WaitingForRntoAck:
853 if (code == 250) {
854 finalizeCommand(false);
855 } else {
856 setError(Error::ServerError,
857 QStringLiteral("RNTO rejected (code %1): '%2' – destination path: '%3'")
858 .arg(code)
859 .arg(QString::fromUtf8(message))
860 .arg(m_current ? m_current->renameDst : QString{}));
861 finalizeCommand(true);
862 }
863 break;
864
865 // ------------------------------------------------------------------
866 // QUIT
867 // ------------------------------------------------------------------
868 case ControlState::WaitingForQuitAck:
869 // 221 = service closing; accept any 2xx
870 if (code >= 200 && code < 300) {
871 m_control->disconnectFromHost();
872 setState(State::Unconnected);
873 finalizeCommand(false);
874 }
875 break;
876
877 // ------------------------------------------------------------------
878 // Explicit FTPS: AUTH TLS → PBSZ 0 → PROT P → USER/PASS
879 // ------------------------------------------------------------------
880 case ControlState::WaitingForAuthTlsAck:
881 if (code == 234) {
882 // Server accepted AUTH TLS – start TLS handshake on control socket
883 m_ctrlState = ControlState::WaitingForControlHandshake;
884 m_control->startClientEncryption();
885 } else {
886 setError(Error::AuthTlsRejected,
887 QStringLiteral("AUTH TLS rejected (code %1): '%2'")
888 .arg(code).arg(QString::fromUtf8(message)));
889 finalizeCommand(true);
890 }
891 break;
892
893 case ControlState::WaitingForPbszAck:
894 if (code == 200) {
895 sendCommand("PROT P"); // Protect data channel
896 m_ctrlState = ControlState::WaitingForProtAck;
897 } else {
899 QStringLiteral("PBSZ rejected (code %1): '%2'")
900 .arg(code).arg(QString::fromUtf8(message)));
901 finalizeCommand(true);
902 }
903 break;
904
905 case ControlState::WaitingForProtAck:
906 if (code == 200) {
907 // Data channel protection confirmed – proceed with login
908 const QUrl &url = m_current->url;
909 const QString user = url.userName().isEmpty() ? AnonymousUser : url.userName();
910 sendCommand("USER " + user.toUtf8());
911 m_ctrlState = ControlState::WaitingForUserAck;
912 } else {
914 QStringLiteral("PROT P rejected (code %1): '%2'")
915 .arg(code).arg(QString::fromUtf8(message)));
916 finalizeCommand(true);
917 }
918 break;
919
920 case ControlState::WaitingForControlHandshake:
921 case ControlState::WaitingForDataHandshake:
922 // Handled by QSslSocket::encrypted() signal – server responses
923 // during handshake are unexpected and ignored here.
924 qDebug() << Q_FUNC_INFO << "Response during TLS handshake (ignored):" << code;
925 break;
926
927 case ControlState::Idle:
928 qDebug() << Q_FUNC_INFO << "Unexpected response in Idle state:" << code << message;
929 break;
930
931 } // end switch
932}
933
934void QFtpCompat::onControlError(QAbstractSocket::SocketError socketError)
935{
936 qDebug() << Q_FUNC_INFO << socketError << m_control->errorString();
937
938 if (m_state == State::Unconnected || m_ctrlState == ControlState::WaitingForQuitAck) {
939 return; // normal close during QUIT
940 }
941
942 Error err;
943 QString detail;
944
945 switch (socketError) {
946 case QAbstractSocket::HostNotFoundError:
948 detail = QStringLiteral("host '%1' not found – check the URL and network connectivity")
949 .arg(m_host);
950 break;
951 case QAbstractSocket::ConnectionRefusedError:
953 detail = QStringLiteral("connection refused by '%1:%2' – "
954 "verify the FTP server is running and the port is correct")
955 .arg(m_host).arg(m_port);
956 break;
957 case QAbstractSocket::RemoteHostClosedError:
959 detail = QStringLiteral("server '%1' closed the control connection unexpectedly "
960 "(possible server-side timeout or crash)")
961 .arg(m_host);
962 break;
963 case QAbstractSocket::SocketTimeoutError:
965 detail = QStringLiteral("control connection to '%1' timed out").arg(m_host);
966 break;
967 default:
969 detail = QStringLiteral("control socket error [Qt code %1]: %2")
970 .arg(static_cast<int>(socketError))
971 .arg(m_control->errorString());
972 break;
973 }
974
975 setError(err, detail);
976 setState(State::Unconnected);
977
978 if (m_current) {
979 finalizeCommand(true);
980 }
981}
982
983// =============================================================================
984// Data channel
985// =============================================================================
986
987void QFtpCompat::onDataConnected()
988{
989 qDebug() << Q_FUNC_INFO;
990
991 if (!m_current) { return; }
992
993 if (m_encMode != EncryptionMode::None) {
994 // FTPS: encrypt the data channel before sending RETR/STOR/LIST
995 qDebug() << Q_FUNC_INFO << "starting data channel TLS handshake";
996 m_ctrlState = ControlState::WaitingForDataHandshake;
997 startDataChannelEncryption();
998 } else {
999 m_ctrlState = ControlState::WaitingForTransferStartAck;
1000 sendDataTransferCommand();
1001 }
1002}
1003
1004void QFtpCompat::startDataChannelEncryption()
1005{
1006 m_data->setSslConfiguration(m_sslConfig);
1007 m_data->startClientEncryption();
1008}
1009
1010void QFtpCompat::sendDataTransferCommand()
1011{
1012 if (!m_current) {
1013 return;
1014 }
1015
1016 switch (m_current->type) {
1017 case Cmd::Type::List:
1018 sendCommand("LIST" + (m_current->url.path().isEmpty()
1019 ? QByteArray{}
1020 : " " + m_current->url.path().toUtf8()));
1021 break;
1022 case Cmd::Type::Get:
1023 sendCommand("RETR " + m_current->url.path().toUtf8());
1024 break;
1025 case Cmd::Type::Put:
1026 sendCommand("STOR " + m_current->url.path().toUtf8());
1027 break;
1028 default:
1029 break;
1030 }
1031}
1032
1033void QFtpCompat::onDataReadyRead()
1034{
1035 const QByteArray chunk = m_data->readAll();
1036
1037 if (!m_current) {
1038 return;
1039 }
1040
1041 if (m_current->type == Cmd::Type::Get) {
1042 if (m_current->device) {
1043 m_current->device->write(chunk);
1044 }
1045 const qint64 received = m_current->device ? m_current->device->pos() : 0;
1046 qDebug() << Q_FUNC_INFO << "DOWN received:" << received << "bytes";
1047 emit dataTransferProgress(received, -1);
1048 } else if (m_current->type == Cmd::Type::List) {
1049 m_dataReadBuf += chunk;
1050 }
1051}
1052
1053void QFtpCompat::onDataDisconnected()
1054{
1055 qDebug() << Q_FUNC_INFO;
1056
1057 // Ignore stale disconnect events (e.g. from a 550-aborted data socket
1058 // firing while the next command is already in WaitingForPasvAck).
1059 if (m_ctrlState != ControlState::Transferring
1060 && m_ctrlState != ControlState::WaitingForTransferStartAck
1061 && m_ctrlState != ControlState::WaitingForTransferCompleteAck
1062 && m_ctrlState != ControlState::WaitingForAbortAck) {
1063 qDebug() << Q_FUNC_INFO << "stale – ignored in state"
1064 << static_cast<int>(m_ctrlState);
1065 return;
1066 }
1067
1068 // Drain any last bytes
1069 const QByteArray tail = m_data->readAll();
1070
1071 if (m_current) {
1072 if (m_current->type == Cmd::Type::Get && m_current->device) {
1073 if (!tail.isEmpty()) {
1074 m_current->device->write(tail);
1075 }
1076 } else if (m_current->type == Cmd::Type::List) {
1077 m_dataReadBuf += tail;
1078 }
1079 }
1080
1081 m_dataChannelClosed = true;
1082
1083 if (m_ctrlTransferComplete) {
1084 onTransferCompleted();
1085 } else {
1086 m_ctrlState = ControlState::WaitingForTransferCompleteAck;
1087 }
1088}
1089
1090void QFtpCompat::onDataError(QAbstractSocket::SocketError socketError)
1091{
1092 qDebug() << Q_FUNC_INFO << socketError;
1093
1094 // RemoteHostClosedError is normal end-of-transfer – let onDataDisconnected handle it.
1095 if (socketError == QAbstractSocket::RemoteHostClosedError) {
1096 return;
1097 }
1098
1099 // Ignore stale errors not belonging to an active data transfer.
1100 if (m_ctrlState != ControlState::Transferring
1101 && m_ctrlState != ControlState::WaitingForTransferStartAck
1102 && m_ctrlState != ControlState::WaitingForTransferCompleteAck
1103 && m_ctrlState != ControlState::ConnectingData
1104 && m_ctrlState != ControlState::WaitingForAbortAck) {
1105 qDebug() << Q_FUNC_INFO << "stale – ignored in state"
1106 << static_cast<int>(m_ctrlState);
1107 return;
1108 }
1109
1110 setError(Error::NetworkError,
1111 QStringLiteral("data channel error [Qt code %1]: %2 – "
1112 "transfer of '%3' interrupted")
1113 .arg(static_cast<int>(socketError))
1114 .arg(m_data->errorString())
1115 .arg(m_current ? m_current->url.path() : QString{}));
1116 finalizeCommand(true);
1117}
1118
1119// =============================================================================
1120// Transfer completion (called when BOTH 226 received AND data channel closed)
1121// =============================================================================
1122
1123void QFtpCompat::onTransferCompleted()
1124{
1125 qDebug() << Q_FUNC_INFO;
1126 if (!m_current) {
1127 return;
1128 }
1129 // Emit 100% progress on upload completion
1130 if (m_current->type == Cmd::Type::Put && m_uploadTotal > 0) {
1131 qDebug() << Q_FUNC_INFO << "↑ upload complete:" << m_uploadTotal << "bytes";
1132 emit dataTransferProgress(m_uploadTotal, m_uploadTotal);
1133 }
1134
1135 if (m_current->type == Cmd::Type::List) {
1136 // Parse the accumulated listing and emit one signal per entry
1137 const auto entries = QFtpCompatDirEntry::parseList(m_dataReadBuf);
1138 qDebug() << Q_FUNC_INFO << "LIST: parsed" << entries.size() << "entries";
1139 for (const QFtpCompatDirEntry &e : entries) {
1140 emit listInfo(e);
1141 }
1142 }
1143
1144 finalizeCommand(false);
1145}
1146
1147// =============================================================================
1148// Data-transfer preamble helpers
1149// =============================================================================
1150
1151void QFtpCompat::beginDataTransfer()
1152{
1153 qDebug() << Q_FUNC_INFO;
1154 sendTypeCommand();
1155}
1156
1157void QFtpCompat::sendTypeCommand()
1158{
1159 qDebug() << Q_FUNC_INFO;
1160 const bool isBinary = !m_current
1161 || m_current->transferType == TransferType::Binary;
1162
1163 if (isBinary) {
1164 sendCommand("TYPE I");
1165 } else {
1166 // Ascii mode: TYPE A is sent; CRLF transformation is NOT applied
1167 // to the data stream in this version (see TransferType::Ascii doc).
1168 // TODO: implement CRLF normalisation for proper Ascii-mode transfers.
1169 sendCommand("TYPE A");
1170 }
1171 m_ctrlState = ControlState::WaitingForTypeAck;
1172}
1173
1174void QFtpCompat::sendPasv()
1175{
1176 qDebug() << Q_FUNC_INFO;
1177 sendCommand("PASV");
1178 m_ctrlState = ControlState::WaitingForPasvAck;
1179}
1180
1181// =============================================================================
1182// PASV address parser
1183// Format: "Entering Passive Mode (h1,h2,h3,h4,p1,p2)"
1184// =============================================================================
1185
1186bool QFtpCompat::parsePasvAddress(const QByteArray &msg,
1187 QString &host, quint16 &port) const
1188{
1189 const int open = msg.indexOf('(');
1190 const int close = msg.indexOf(')');
1191
1192 if (open < 0 || close <= open) {
1193 return false;
1194 }
1195
1196 const QByteArray inner = msg.mid(open + 1, close - open - 1);
1197 const QList<QByteArray> parts = inner.split(',');
1198
1199 if (parts.size() != 6) {
1200 return false;
1201 }
1202
1203 bool ok{false};
1204 const int h1 = parts[0].trimmed().toInt(&ok); if (!ok) { return false; }
1205 const int h2 = parts[1].trimmed().toInt(&ok); if (!ok) { return false; }
1206 const int h3 = parts[2].trimmed().toInt(&ok); if (!ok) { return false; }
1207 const int h4 = parts[3].trimmed().toInt(&ok); if (!ok) { return false; }
1208 const int p1 = parts[4].trimmed().toInt(&ok); if (!ok) { return false; }
1209 const int p2 = parts[5].trimmed().toInt(&ok); if (!ok) { return false; }
1210
1211 host = QStringLiteral("%1.%2.%3.%4").arg(h1).arg(h2).arg(h3).arg(h4);
1212 port = static_cast<quint16>(p1 * 256 + p2);
1213 qDebug() << Q_FUNC_INFO << "data channel:" << host << ":" << port;
1214 return true;
1215}
1216
1217// =============================================================================
1218// State / error helpers
1219// =============================================================================
1220
1221void QFtpCompat::setState(State s)
1222{
1223 if (m_state == s) {
1224 return;
1225 }
1226 qDebug() << Q_FUNC_INFO
1227 << "state:" << static_cast<int>(m_state)
1228 << "->" << static_cast<int>(s);
1229 m_state = s;
1230 emit stateChanged(s);
1231}
1232
1233void QFtpCompat::setError(Error err, const QString &detail)
1234{
1235 m_error = err;
1236 m_errorString = detail;
1237 qDebug() << Q_FUNC_INFO << detail;
1238}
1239
1240bool QFtpCompat::isDataTransferCommand() const noexcept
1241{
1242 if (!m_current) {
1243 return false;
1244 }
1245 switch (m_current->type) {
1246 case Cmd::Type::List:
1247 case Cmd::Type::Get:
1248 case Cmd::Type::Put:
1249 return true;
1250 default:
1251 return false;
1252 }
1253}
static QVector< QFtpCompatDirEntry > parseList(const QByteArray &rawListing)
Parses a full LIST / MLSD response body into directory entries.
int currentId() const noexcept
ID of the command currently being executed, or -1 if idle.
int remove(const QUrl &url)
Deletes a file on the server (DELE).
void abort()
Aborts the current data transfer and clears the command queue.
State state() const noexcept
Current connection state.
void done(bool error)
Emitted when all queued commands have completed.
int pwd()
Requests the current working directory path (PWD).
int open(const QUrl &url, EncryptionMode mode=EncryptionMode::None)
Connects to the FTP server and logs in using the URL credentials.
int rmdir(const QUrl &url)
Removes an empty directory on the server (RMD).
int put(const QUrl &url, const QByteArray &data, TransferType type=TransferType::Binary)
Uploads data to the remote path in url.
int rawCommand(const QString &command)
Sends an arbitrary FTP command on the control channel.
void stateChanged(QFtpCompat::State newState)
Emitted when the connection state changes.
int cd(const QUrl &url)
Changes the current working directory (CWD).
void rawCommandReply(int code, QString detail)
Emitted for rawCommand() responses and pwd() results.
EncryptionMode
FTP encryption mode. \value None Plain FTP – default, fully backward-compatible. \value Explicit Expl...
Definition qftpcompat.h:149
void ignoreSslErrors()
int mkdir(const QUrl &url)
Creates a directory on the server (MKD).
void commandStarted(int id)
Emitted when a queued command begins executing.
int list(const QUrl &url)
Requests a directory listing (LIST).
Error
Error codes set on the most recent failed command.
Definition qftpcompat.h:93
@ TlsHandshakeFailed
TLS handshake failed on control or data channel.
Definition qftpcompat.h:111
@ FileNotFound
Server returned 550 for a file operation.
Definition qftpcompat.h:102
@ NetworkError
Generic socket error.
Definition qftpcompat.h:98
@ PermissionDenied
Server returned 553 / 550 permission error.
Definition qftpcompat.h:103
@ LoginFailed
USER/PASS rejected by the server.
Definition qftpcompat.h:101
@ NotLoggedIn
Command requires authentication.
Definition qftpcompat.h:100
@ Aborted
Operation cancelled by abort().
Definition qftpcompat.h:107
@ ServerError
Unexpected server response code.
Definition qftpcompat.h:104
@ ConnectionRefused
Server actively refused the TCP connection.
Definition qftpcompat.h:96
@ TransferFailed
Data channel closed before transfer completed.
Definition qftpcompat.h:108
@ ParseError
Failed to parse a server response (PASV, LIST).
Definition qftpcompat.h:105
@ AuthTlsRejected
Server rejected the AUTH TLS command.
Definition qftpcompat.h:112
@ NetworkTimeout
No activity within the inactivity timeout.
Definition qftpcompat.h:97
@ HostNotFound
DNS resolution failed.
Definition qftpcompat.h:95
void listInfo(QFtpCompatDirEntry entry)
Emitted once per entry during a list() command.
void close()
Sends QUIT and closes the connection gracefully.
int rename(const QUrl &from, const QUrl &to)
Renames or moves a file on the server (RNFR + RNTO).
void setSslConfiguration(const QSslConfiguration &config)
Removes all pending (not yet started) commands from the queue.
Error error() const noexcept
Error code set by the most recent failed command.
void commandFinished(int id, bool error)
Emitted when a command completes (successfully or with error).
int get(const QUrl &url, QIODevice *dst, TransferType type=TransferType::Binary)
Downloads a remote file into dst.
void resetConnection()
State
High-level connection state.
Definition qftpcompat.h:81
@ Unconnected
No TCP connection established.
Definition qftpcompat.h:82
@ Connected
TCP connected, waiting for server greeting (220).
Definition qftpcompat.h:84
@ Closing
QUIT sent, waiting for server acknowledgement.
Definition qftpcompat.h:86
@ Connecting
TCP connect in progress.
Definition qftpcompat.h:83
@ LoggedIn
Authenticated; ready to accept commands.
Definition qftpcompat.h:85
QSslConfiguration sslConfiguration() const
Returns the current SSL configuration.
bool hasPendingCommands() const noexcept
true if commands are waiting in the queue.
QString errorString() const noexcept
Verbose, human-readable description of the last error.
TransferType
FTP data representation type (RFC 959 §3.1.1).
Definition qftpcompat.h:137
@ Binary
TYPE I – raw binary (8-bit) transfer. Fully implemented.
Definition qftpcompat.h:138
void dataTransferProgress(qint64 bytesTransferred, qint64 bytesTotal)
Emitted periodically during data transfers.
Qt6-compatible async FTP client.