Seregon/zftpd

Zero-copy FTP/HTTP Daemon compatible with all POSIX systems

C/11.0 KB/No license
src/ftp_session.c
zftpd / src / ftp_session.c
1/*
2MIT License
3 
4Copyright (c) 2026 Seregon
5 
6Permission is hereby granted, free of charge, to any person obtaining a copy
7of this software and associated documentation files (the "Software"), to deal
8in the Software without restriction, including without limitation the rights
9to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10copies of the Software, and to permit persons to whom the Software is
11furnished to do so, subject to the following conditions:
12 
13The above copyright notice and this permission notice shall be included in all
14copies or substantial portions of the Software.
15 
16THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22SOFTWARE.
23*/
24 
25/**
26 * @file ftp_session.c
27 * @brief FTP session management implementation
28 *
29 * @author SeregonWar
30 * @version 1.0.0
31 * @date 2026-02-13
32 *
33 */
34 
35#include "ftp_session.h"
36#include "ftp_server.h" /* ftp_server_release_session() — called at thread exit */
37#include "ftp_crypto.h"
38#include "ftp_log.h"
39#include "ftp_path.h"
40#include "ftp_protocol.h"
41#include "pal_fileio.h"
42#include "pal_network.h"
43#include <errno.h>
44#include <stdio.h>
45#include <stdlib.h>
46#include <string.h>
47#include <sys/select.h>
48#include <sys/time.h>
49#include <time.h>
50#include <unistd.h>
51 
52/*===========================================================================*
53 * SESSION LIFECYCLE
54 *===========================================================================*/
55 
56/**
57 * @brief Initialize session structure
58 */
59ftp_error_t ftp_session_init(ftp_session_t *session, int ctrl_fd,
60 const struct sockaddr_in *client_addr,
61 uint32_t session_id, const char *root_path) {
62 /* Validate parameters */
63 if ((session == NULL) || (client_addr == NULL) || (root_path == NULL)) {
64 return FTP_ERR_INVALID_PARAM;
65 }
66 
67 if (ctrl_fd < 0) {
68 return FTP_ERR_INVALID_PARAM;
69 }
70 
71 /* Zero-initialize structure */
72 memset(session, 0, sizeof(*session));
73 
74 /* Control connection */
75 session->ctrl_fd = ctrl_fd;
76 memcpy(&session->ctrl_addr, client_addr, sizeof(session->ctrl_addr));
77 (void)pal_socket_set_timeouts(session->ctrl_fd, FTP_CTRL_IO_TIMEOUT_MS,
78 FTP_CTRL_IO_TIMEOUT_MS);
79 
80 /* Disable Nagle on control channel so 220 greeting + short replies
81 * are sent immediately instead of being coalesced. Many Android FTP
82 * clients timeout if the greeting is delayed even slightly.
83 */
84 {
85 int nodelay = 1;
86 (void)PAL_SETSOCKOPT(session->ctrl_fd, IPPROTO_TCP, TCP_NODELAY, &nodelay,
87 sizeof(nodelay));
88 }
89 
90 /* Data connection (initially closed) */
91 session->data_fd = -1;
92 session->pasv_fd = -1;
93 session->data_mode = FTP_DATA_MODE_NONE;
94 
95 /* Session state */
96 atomic_store(&session->state, FTP_STATE_CONNECTED);
97 
98 /* Transfer parameters (defaults) */
99 session->transfer_type = FTP_TYPE_BINARY;
100 session->transfer_mode = FTP_MODE_STREAM;
101 session->file_structure = FTP_STRU_FILE;
102 session->restart_offset = 0;
103 
104 size_t root_len = strlen(root_path);
105 if (root_len >= sizeof(session->root_path)) {
106 return FTP_ERR_PATH_TOO_LONG;
107 }
108 memcpy(session->root_path, root_path, root_len + 1U);
109 memcpy(session->cwd, root_path, root_len + 1U);
110 
111 if (pal_path_exists(session->root_path) == 1) {
112 char real_buf[FTP_PATH_MAX];
113 if (realpath(session->root_path, real_buf) != NULL) {
114 size_t n = strlen(real_buf);
115 if (n < sizeof(session->root_path)) {
116 memcpy(session->root_path, real_buf, n + 1U);
117 memcpy(session->cwd, real_buf, n + 1U);
118 }
119 }
120 }
121 
122 session->rename_from[0] = '\0';
123 session->copy_from[0] = '\0';
124 pthread_mutex_init(&session->copy_mutex, NULL);
125 session->copy_in_progress = 0;
126 session->copy_thread_valid = 0;
127 
128 /* Authentication */
129 session->auth_attempts = 0U;
130 session->authenticated = 0U;
131 session->user_ok = 0U;
132 
133 session->ctrl_rx_len = 0U;
134 session->ctrl_rx_off = 0U;
135 
136 /* Session identification */
137 session->session_id = session_id;
138 
139 /* Timing */
140 session->connect_time = time(NULL);
141 session->last_activity = session->connect_time;
142 
143 session->rl_tokens = 0U;
144 session->rl_last_ns = 0U;
145 
146 /* Crypto: start with encryption disabled (cleared by memset above) */
147#if FTP_ENABLE_CRYPTO
148 ftp_crypto_reset(&session->crypto);
149#endif
150 
151 /* Client IP address (text) */
152 ftp_error_t err = pal_sockaddr_to_ip(client_addr, session->client_ip,
153 sizeof(session->client_ip));
154 if (err != FTP_OK) {
155 /* Non-fatal: use placeholder */
156 strncpy(session->client_ip, "unknown", sizeof(session->client_ip) - 1U);
157 session->client_ip[sizeof(session->client_ip) - 1U] = '\0';
158 }
159 
160 session->client_port = pal_sockaddr_get_port(client_addr);
161 
162 /* Initialize statistics */
163 atomic_store(&session->stats.bytes_sent, 0U);
164 atomic_store(&session->stats.bytes_received, 0U);
165 atomic_store(&session->stats.files_sent, 0U);
166 atomic_store(&session->stats.files_received, 0U);
167 atomic_store(&session->stats.commands_processed, 0U);
168 atomic_store(&session->stats.errors, 0U);
169 
170 return FTP_OK;
171}
172 
173/**
174 * @brief Cleanup session resources
175 */
176void ftp_session_cleanup(ftp_session_t *session) {
177 if (session == NULL) {
178 return;
179 }
180 
181 /* Set state to terminating */
182 atomic_store(&session->state, FTP_STATE_TERMINATING);
183 
184 /* Wait for active copy to finish and destroy mutex */
185 if (session->copy_thread_valid) {
186 pthread_join(session->copy_thread, NULL);
187 }
188 pthread_mutex_destroy(&session->copy_mutex);
189 
190 /* Close data connection */
191 ftp_session_close_data_connection(session);
192 
193 /* Close control connection */
194 if (session->ctrl_fd >= 0) {
195 PAL_CLOSE(session->ctrl_fd);
196 session->ctrl_fd = -1;
197 }
198 
199 /* Scrub key material from session */
200#if FTP_ENABLE_CRYPTO
201 ftp_crypto_reset(&session->crypto);
202#endif
203}
204 
205/**
206 * @brief Session thread entry point
207 */
208void *ftp_session_thread(void *arg) {
209 ftp_session_t *session = (ftp_session_t *)arg;
210 
211 if (session == NULL) {
212 return NULL;
213 }
214 
215 /* Send greeting */
216 ftp_session_send_reply(session, FTP_REPLY_220_SERVICE_READY, NULL);
217 
218 ftp_log_session_event(session, "CONNECT", FTP_OK, 0U);
219 
220 /* Command processing loop */
221 char cmd_buffer[FTP_CMD_BUFFER_SIZE];
222 int should_quit = 0;
223 
224 while (!should_quit) {
225 time_t now = time(NULL);
226 if ((now != (time_t)-1) && (now > session->last_activity)) {
227 if ((uint64_t)(now - session->last_activity) >
228 (uint64_t)FTP_SESSION_TIMEOUT) {
229 (void)ftp_session_send_reply(session, FTP_REPLY_421_SERVICE_UNAVAIL,
230 "Idle timeout.");
231 ftp_log_session_event(session, "IDLE_TIMEOUT", FTP_ERR_TIMEOUT, 0U);
232 break;
233 }
234 }
235 
236 /* Read command line */
237 ssize_t n =
238 ftp_session_read_command(session, cmd_buffer, sizeof(cmd_buffer));
239 
240 if (n == 0) {
241 break;
242 }
243 if (n < 0) {
244 if (n == (ssize_t)FTP_ERR_TIMEOUT) {
245 continue;
246 }
247 if (n == (ssize_t)FTP_ERR_PROTOCOL) {
248 continue;
249 }
250 break;
251 }
252 
253 /* Update activity timestamp */
254 session->last_activity = time(NULL);
255 
256 /* Process command */
257 int result = ftp_session_process_command(session, cmd_buffer);
258 
259 if (result == 1) {
260 /* QUIT command: graceful exit */
261 should_quit = 1;
262 } else if (result < 0) {
263 /* Error: terminate session */
264 break;
265 }
266 
267 /* Increment command counter */
268 atomic_fetch_add(&session->stats.commands_processed, 1U);
269 }
270 
271 /* Cleanup and exit */
272 ftp_session_cleanup(session);
273 
274 ftp_log_session_event(session, "DISCONNECT", FTP_OK, 0U);
275 
276 /*
277 * Release this session slot back to the server pool.
278 *
279 * WHY THIS IS THE CRITICAL FIX:
280 * ftp_server_stop() blocks on (active_sessions > 0) waiting for all
281 * sessions to finish. Previously, active_sessions was incremented in
282 * server_accept_thread when a new session thread was spawned, but was
283 * NEVER decremented when the thread exited — free_session() existed but
284 * was only called from error paths before the thread was created.
285 *
286 * Without this call:
287 * - active_sessions grows monotonically and never returns to 0
288 * - ftp_server_stop() loops infinitely
289 * - PS5 shutdown waits, extends the timer 7 times, then sends SIGKILL
290 * - zftpd dies with 300+ open file descriptors
291 * - M2 filesystem cannot unmount cleanly → forced unmount → kernel panic
292 *
293 * With this call:
294 * - Each thread decrements the counter on exit
295 * - ftp_server_stop() unblocks once all threads have exited
296 * - All FDs are closed before the process exits
297 * - M2 unmounts cleanly → no kernel panic
298 *
299 * ORDERING: Must be called AFTER ftp_session_cleanup() so all file
300 * descriptors are already closed before the slot is marked as available.
301 * This prevents a race where a new session grabs the slot while the old
302 * session's FDs are still open.
303 *
304 * session->server_ctx is guaranteed non-NULL here: it is set in
305 * server_accept_thread before pthread_create(), and this function only
306 * runs inside the created thread.
307 */
308 ftp_server_release_session(session->server_ctx, session);
309 
310 return NULL;
311}
312 
313/*===========================================================================*
314 * REPLY SENDING
315 *===========================================================================*/
316 
317/**
318 * @brief Send FTP reply to client
319 */
320ftp_error_t ftp_session_send_reply(ftp_session_t *session,
321 ftp_reply_code_t code, const char *message) {
322 if (session == NULL) {
323 return FTP_ERR_INVALID_PARAM;
324 }
325 
326 if (session->ctrl_fd < 0) {
327 return FTP_ERR_SOCKET_SEND;
328 }
329 
330 /* Format reply */
331 char buffer[FTP_REPLY_BUFFER_SIZE];
332 ssize_t len = ftp_format_reply(code, message, buffer, sizeof(buffer));
333 
334 if (len < 0) {
335 return (ftp_error_t)len;
336 }
337 
338 /* Send reply */
339 ssize_t sent = pal_send_all(session->ctrl_fd, buffer, (size_t)len, 0);
340 if (sent != len) {
341 return FTP_ERR_SOCKET_SEND;
342 }
343 
344 return FTP_OK;
345}
346 
347/**
348 * @brief Send multi-line reply
349 */
350ftp_error_t ftp_session_send_multiline_reply(ftp_session_t *session,
351 ftp_reply_code_t code,
352 const char **lines, size_t count) {
353 if ((session == NULL) || (lines == NULL) || (count == 0U)) {
354 return FTP_ERR_INVALID_PARAM;
355 }
356 
357 if (session->ctrl_fd < 0) {
358 return FTP_ERR_SOCKET_SEND;
359 }
360 
361 char buffer[FTP_REPLY_BUFFER_SIZE];
362 
363 /* Send all lines except last with '-' */
364 for (size_t i = 0U; i < (count - 1U); i++) {
365 int n = snprintf(buffer, sizeof(buffer), "%d-%s\r\n", (int)code, lines[i]);
366 
367 if ((n < 0) || ((size_t)n >= sizeof(buffer))) {
368 return FTP_ERR_INVALID_PARAM;
369 }
370 
371 ssize_t sent = pal_send_all(session->ctrl_fd, buffer, (size_t)n, 0);
372 if (sent != n) {
373 return FTP_ERR_SOCKET_SEND;
374 }
375 }
376 
377 /* Send last line with ' ' */
378 int n = snprintf(buffer, sizeof(buffer), "%d %s\r\n", (int)code,
379 lines[count - 1U]);
380 
381 if ((n < 0) || ((size_t)n >= sizeof(buffer))) {
382 return FTP_ERR_INVALID_PARAM;
383 }
384 
385 ssize_t sent = pal_send_all(session->ctrl_fd, buffer, (size_t)n, 0);
386 if (sent != n) {
387 return FTP_ERR_SOCKET_SEND;
388 }
389 
390 return FTP_OK;
391}
392 
393/*===========================================================================*
394 * DATA CONNECTION MANAGEMENT
395 *===========================================================================*/
396 
397static int wait_fd_ready(int fd, int for_write, uint32_t timeout_ms) {
398 if (fd < 0) {
399 errno = EBADF;
400 return -1;
401 }
402 
403 fd_set rfds;
404 fd_set wfds;
405 
406 while (1) {
407 /* Re-initialize fd_set and tv on every iteration.
408 * POSIX select() modifies both: fd_sets are undefined after return,
409 * and on Linux tv is decremented in place. Failing to reinitialize
410 * them on EINTR retry causes select() to see an empty set and return
411 * 0 immediately, which is misinterpreted as a timeout — closing the
412 * passive listener before the client's TCP SYN arrives. */
413 struct timeval tv;
414 tv.tv_sec = (time_t)(timeout_ms / 1000U);
415 tv.tv_usec = (suseconds_t)((timeout_ms % 1000U) * 1000U);
416 
417 FD_ZERO(&rfds);
418 FD_ZERO(&wfds);
419 
420 if (for_write != 0) {
421 FD_SET(fd, &wfds);
422 } else {
423 FD_SET(fd, &rfds);
424 }
425 
426 int rc = select(fd + 1, (for_write != 0) ? NULL : &rfds,
427 (for_write != 0) ? &wfds : NULL, NULL, &tv);
428 if (rc == 0) {
429 errno = ETIMEDOUT;
430 return 0;
431 }
432 if (rc < 0) {
433 if (errno == EINTR) {
434 continue;
435 }
436 }
437 return rc;
438 }
439}
440 
441static uint64_t monotonic_ns(void) {
442#if defined(CLOCK_MONOTONIC)
443 struct timespec ts;
444 if (clock_gettime(CLOCK_MONOTONIC, &ts) == 0) {
445 return ((uint64_t)ts.tv_sec * 1000000000ULL) + (uint64_t)ts.tv_nsec;
446 }
447#endif
448 struct timeval tv;
449 if (gettimeofday(&tv, NULL) == 0) {
450 return ((uint64_t)tv.tv_sec * 1000000000ULL) +
451 ((uint64_t)tv.tv_usec * 1000ULL);
452 }
453 return 0U;
454}
455 
456static void rate_limit(ftp_session_t *session, size_t bytes) {
457 if ((session == NULL) || (bytes == 0U)) {
458 return;
459 }
460 
461 uint64_t rate = (uint64_t)FTP_TRANSFER_RATE_LIMIT_BPS;
462 if (rate == 0U) {
463 return;
464 }
465 
466 uint64_t burst = (uint64_t)FTP_TRANSFER_RATE_BURST_BYTES;
467 uint64_t cap = (burst != 0U) ? burst : rate;
468 
469 uint64_t now = monotonic_ns();
470 if (now == 0U) {
471 return;
472 }
473 
474 if (session->rl_last_ns == 0U) {
475 session->rl_last_ns = now;
476 session->rl_tokens = cap;
477 } else if (now > session->rl_last_ns) {
478 uint64_t elapsed = now - session->rl_last_ns;
479 uint64_t add = (elapsed * rate) / 1000000000ULL;
480 session->rl_last_ns = now;
481 if (add > 0U) {
482 uint64_t t = session->rl_tokens + add;
483 session->rl_tokens = (t > cap) ? cap : t;
484 }
485 }
486 
487 uint64_t need = (uint64_t)bytes;
488 while (session->rl_tokens < need) {
489 uint64_t missing = need - session->rl_tokens;
490 uint64_t wait_ns = (missing * 1000000000ULL + rate - 1U) / rate;
491 uint64_t wait_us = (wait_ns + 999ULL) / 1000ULL;
492 if (wait_us > 500000ULL) {
493 wait_us = 500000ULL;
494 }
495 usleep((useconds_t)wait_us);
496 
497 now = monotonic_ns();
498 if ((now != 0U) && (now > session->rl_last_ns)) {
499 uint64_t elapsed = now - session->rl_last_ns;
500 uint64_t add = (elapsed * rate) / 1000000000ULL;
501 session->rl_last_ns = now;
502 if (add > 0U) {
503 uint64_t t = session->rl_tokens + add;
504 session->rl_tokens = (t > cap) ? cap : t;
505 }
506 } else {
507 break;
508 }
509 }
510 
511 if (session->rl_tokens >= need) {
512 session->rl_tokens -= need;
513 } else {
514 session->rl_tokens = 0U;
515 }
516}
517 
518/**
519 * @brief Open data connection
520 */
521ftp_error_t ftp_session_open_data_connection(ftp_session_t *session) {
522 if (session == NULL) {
523 return FTP_ERR_INVALID_PARAM;
524 }
525 
526 if (session->data_mode == FTP_DATA_MODE_NONE) {
527 return FTP_ERR_INVALID_PARAM;
528 }
529 
530 if (session->data_mode == FTP_DATA_MODE_ACTIVE) {
531 /* Active mode: Connect to client.
532 * GoldHEN/ftpsrv uses a simple blocking connect().
533 * The non-blocking + EINPROGRESS + select() approach
534 * has portability issues on FreeBSD/PS4/PS5.
535 */
536 int fd = PAL_SOCKET(AF_INET, SOCK_STREAM, 0);
537 if (fd < 0) {
538 return FTP_ERR_SOCKET_CREATE;
539 }
540 
541 /* Short connect timeout (3s) so PORT fails quickly through NAT.
542 * File Manager+ opens both PASV and PORT sessions — if PORT
543 * hangs for 15s the app shows a network error even though
544 * the PASV session works. A fast failure lets the app
545 * fall back to the working PASV session.
546 */
547 struct timeval tv;
548 tv.tv_sec = 3;
549 tv.tv_usec = 0;
550 (void)PAL_SETSOCKOPT(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
551 
552 /* Set SO_RCVBUF before connect() — pre-connect avoids the
553 * post-connect kern.ipc.maxsockbuf cap on OrbisOS/FreeBSD.
554 * (Passive mode handles this on the listening socket in cmd_PASV.) */
555 {
556 int rcvbuf = (int)FTP_TCP_RCVBUF;
557 (void)PAL_SETSOCKOPT(fd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf));
558 }
559 
560 /* SO_SNDBUF intentionally NOT set — see cmd_PASV comment on auto-tuning. */
561 
562 {
563 char dbg[128];
564 snprintf(dbg, sizeof(dbg), "[DBG] ACTIVE connect to %s:%u ...",
565 inet_ntoa(session->data_addr.sin_addr),
566 (unsigned)PAL_NTOHS(session->data_addr.sin_port));
567 ftp_log_line(FTP_LOG_INFO, dbg);
568 }
569 
570 if (PAL_CONNECT(fd, (struct sockaddr *)&session->data_addr,
571 sizeof(session->data_addr)) < 0) {
572 char dbg[128];
573 snprintf(dbg, sizeof(dbg), "[DBG] ACTIVE connect FAILED errno=%d", errno);
574 ftp_log_line(FTP_LOG_INFO, dbg);
575 PAL_CLOSE(fd);
576 return FTP_ERR_SOCKET_SEND;
577 }
578 
579 ftp_log_line(FTP_LOG_INFO, "[DBG] ACTIVE connect OK");
580 session->data_fd = fd;
581 
582 } else if (session->data_mode == FTP_DATA_MODE_PASSIVE) {
583 /* Passive mode: Accept from client */
584 if (session->pasv_fd < 0) {
585 return FTP_ERR_INVALID_PARAM;
586 }
587 
588 struct sockaddr_in client_addr;
589 socklen_t addr_len = sizeof(client_addr);
590 
591 int ready = wait_fd_ready(session->pasv_fd, 0, FTP_DATA_CONNECT_TIMEOUT_MS);
592 if (ready <= 0) {
593 PAL_CLOSE(session->pasv_fd);
594 session->pasv_fd = -1;
595 return FTP_ERR_TIMEOUT;
596 }
597 
598 int fd = PAL_ACCEPT(session->pasv_fd, (struct sockaddr *)&client_addr,
599 &addr_len);
600 
601 if (fd < 0) {
602 return FTP_ERR_SOCKET_ACCEPT;
603 }
604 
605 session->data_fd = fd;
606 
607 /* Close passive listener (one connection only) */
608 PAL_CLOSE(session->pasv_fd);
609 session->pasv_fd = -1;
610 
611 /*
612 * SO_RCVBUF post-accept: conditional bump only.
613 *
614 * cmd_PASV sets SO_RCVBUF = FTP_TCP_RCVBUF on the LISTENING socket
615 * before bind/listen. On OrbisOS/FreeBSD, the kernel propagates this
616 * value into every accepted connection during the 3-way handshake,
617 * bypassing the normal kern.ipc.maxsockbuf cap (~1 MB on OrbisOS).
618 *
619 * PROBLEM with an unconditional setsockopt here:
620 * When the accepted socket has already INHERITED 4 MB (from the
621 * listening socket), calling setsockopt(SO_RCVBUF) on it re-applies
622 * the maxsockbuf cap, downgrading the buffer from 4 MB back to 1 MB.
623 * This is precisely why STOR stalls after exactly 1 MB.
624 *
625 * CORRECT strategy — read before write:
626 * 1. getsockopt: read the currently effective receive buffer size.
627 * 2. If it is already >= FTP_TCP_RCVBUF, the inheritance worked.
628 * Leave it alone to preserve the cap-bypassed value.
629 * 3. If it is < FTP_TCP_RCVBUF, the inheritance was unreliable
630 * (observed on some PS5 firmware builds). Apply the 2× trick:
631 * FreeBSD/OrbisOS halves the requested value when storing it, so
632 * requesting 2 × FTP_TCP_RCVBUF yields the desired FTP_TCP_RCVBUF.
633 *
634 * @pre fd is a freshly accepted, not-yet-connected socket.
635 * @post SO_RCVBUF >= FTP_TCP_RCVBUF, without ever downgrading an
636 * already-correct inherited value.
637 */
638 {
639 int current_rcvbuf = 0;
640 socklen_t optlen = (socklen_t)sizeof(current_rcvbuf);
641 const int target_rcvbuf = (int)FTP_TCP_RCVBUF;
642 
643 if (PAL_GETSOCKOPT(fd, SOL_SOCKET, SO_RCVBUF,
644 &current_rcvbuf, &optlen) != 0) {
645 current_rcvbuf = 0; /* treat read failure as "buffer unknown" */
646 }
647 
648 if (current_rcvbuf < target_rcvbuf) {
649 /*
650 * Inheritance was unreliable on this firmware build.
651 * Request 2× to compensate for OrbisOS/FreeBSD halving.
652 */
653 int rcvbuf = target_rcvbuf * 2;
654 (void)PAL_SETSOCKOPT(fd, SOL_SOCKET, SO_RCVBUF,
655 &rcvbuf, sizeof(rcvbuf));
656 }
657 /* else: inherited value is adequate — do NOT call setsockopt,
658 * which would downgrade the cap-bypassed buffer. */
659 }
660 
661 /*
662 * SO_SNDBUF is intentionally NOT set here.
663 * See cmd_PASV for the full rationale (auto-tuning vs. explicit value).
664 */
665 }
666 
667 /* Configure data socket for bulk transfer
668 * - Nagle ON (TCP_NODELAY=0) for packet coalescing
669 * - SO_RCVTIMEO / SO_SNDTIMEO = 120s (prevent infinite stall)
670 * - SO_LINGER = 10s (flush buffer before close)
671 */
672 (void)pal_socket_configure_data(session->data_fd);
673 
674 if (atomic_load(&session->state) != FTP_STATE_TERMINATING) {
675 atomic_store(&session->state, FTP_STATE_TRANSFERRING);
676 }
677 
678 return FTP_OK;
679}
680 
681/**
682 * @brief Close data connection
683 */
684void ftp_session_close_data_connection(ftp_session_t *session) {
685 if (session == NULL) {
686 return;
687 }
688 
689 if (session->data_fd >= 0) {
690 /* Simple close — matches GoldHEN/ftpsrv exactly.
691 *
692 * GoldHEN: close(env->data_fd);
693 *
694 * The previous shutdown(SHUT_WR) + drain + close sequence
695 * caused File Manager+ to receive RST on FreeBSD/PS4,
696 * triggering "Loading Error! Check your network connection."
697 */
698 PAL_CLOSE(session->data_fd);
699 session->data_fd = -1;
700 }
701 
702 if (session->pasv_fd >= 0) {
703 PAL_CLOSE(session->pasv_fd);
704 session->pasv_fd = -1;
705 }
706 
707 session->data_mode = FTP_DATA_MODE_NONE;
708 session->restart_offset = 0;
709 
710 if (atomic_load(&session->state) != FTP_STATE_TERMINATING) {
711 atomic_store(&session->state,
712 (session->authenticated != 0U) ? FTP_STATE_AUTHENTICATED
713 : FTP_STATE_CONNECTED);
714 }
715}
716 
717/**
718 * @brief Send data via data connection
719 */
720ssize_t ftp_session_send_data(ftp_session_t *session, const void *buffer,
721 size_t length) {
722 if ((session == NULL) || (buffer == NULL) || (length == 0U)) {
723 return FTP_ERR_INVALID_PARAM;
724 }
725 
726 if (session->data_fd < 0) {
727 return FTP_ERR_SOCKET_SEND;
728 }
729 
730 rate_limit(session, length);
731 
732 /*
733 * ChaCha20 encrypt before sending
734 *
735 * plaintext ──► copy+XOR(keystream) ──► ciphertext ──► TCP
736 *
737 * The caller's buffer is const, so we copy into a local scratch
738 * buffer and XOR there. 4 KiB chunks match typical socket sends.
739 */
740#if FTP_ENABLE_CRYPTO
741 if (session->crypto.active != 0U) {
742 const uint8_t *src = (const uint8_t *)buffer;
743 size_t todo = length;
744 ssize_t total = 0;
745 
746 while (todo > 0U) {
747 uint8_t scratch[4096];
748 size_t chunk = (todo < sizeof(scratch)) ? todo : sizeof(scratch);
749 memcpy(scratch, src, chunk);
750 ftp_crypto_xor(&session->crypto, scratch, chunk);
751 
752 ssize_t sent = pal_send_all(session->data_fd, scratch, chunk, 0);
753 if (sent <= 0) {
754 return (total > 0) ? total : sent;
755 }
756 total += sent;
757 src += chunk;
758 todo -= chunk;
759 }
760 
761 session->last_activity = time(NULL);
762 atomic_fetch_add(&session->stats.bytes_sent, (uint64_t)total);
763 return total;
764 }
765#endif
766 
767 ssize_t sent = pal_send_all(session->data_fd, buffer, length, 0);
768 
769 if (sent > 0) {
770 session->last_activity = time(NULL);
771 atomic_fetch_add(&session->stats.bytes_sent, (uint64_t)sent);
772 }
773 
774 return sent;
775}
776 
777/**
778 * @brief Receive data via data connection
779 */
780ssize_t ftp_session_recv_data(ftp_session_t *session, void *buffer,
781 size_t length) {
782 if ((session == NULL) || (buffer == NULL) || (length == 0U)) {
783 return FTP_ERR_INVALID_PARAM;
784 }
785 
786 if (session->data_fd < 0) {
787 return FTP_ERR_SOCKET_RECV;
788 }
789 
790 ssize_t received = PAL_RECV(session->data_fd, buffer, length, 0);
791 
792 if (received > 0) {
793 /*
794 * ChaCha20 decrypt after receiving
795 *
796 * TCP ──► ciphertext ──► XOR(keystream) ──► plaintext
797 */
798#if FTP_ENABLE_CRYPTO
799 if (session->crypto.active != 0U) {
800 ftp_crypto_xor(&session->crypto, buffer, (size_t)received);
801 }
802#endif
803 session->last_activity = time(NULL);
804 rate_limit(session, (size_t)received);
805 atomic_fetch_add(&session->stats.bytes_received, (uint64_t)received);
806 }
807 
808 return received;
809}
810 
811/*===========================================================================*
812 * COMMAND PROCESSING
813 *===========================================================================*/
814 
815/**
816 * @brief Read command line from control connection
817 */
818ssize_t ftp_session_read_command(ftp_session_t *session, char *buffer,
819 size_t size) {
820 if ((session == NULL) || (buffer == NULL)) {
821 return FTP_ERR_INVALID_PARAM;
822 }
823 
824 if (session->ctrl_fd < 0) {
825 return FTP_ERR_SOCKET_RECV;
826 }
827 
828 if (size < FTP_CMD_BUFFER_SIZE) {
829 return FTP_ERR_INVALID_PARAM;
830 }
831 
832 size_t out_len = 0U;
833 int too_long = 0;
834 
835 while (1) {
836 for (uint16_t i = session->ctrl_rx_off; i < session->ctrl_rx_len; i++) {
837 char c = session->ctrl_rxbuf[i];
838 
839 if (too_long == 0) {
840 if (out_len < (size - 1U)) {
841 buffer[out_len] = c;
842 out_len++;
843 } else {
844 too_long = 1;
845 }
846 }
847 
848 if (c == '\n') {
849 uint16_t line_end = (uint16_t)(i + 1U);
850 uint16_t consume = line_end;
851 
852 session->ctrl_rx_off = consume;
853 if (session->ctrl_rx_off >= session->ctrl_rx_len) {
854 session->ctrl_rx_off = 0U;
855 session->ctrl_rx_len = 0U;
856 }
857 
858 if (too_long != 0) {
859 (void)ftp_session_send_reply(session, FTP_REPLY_500_SYNTAX_ERROR,
860 "Command too long.");
861 return (ssize_t)FTP_ERR_PROTOCOL;
862 }
863 
864 if ((out_len >= 2U) && (buffer[out_len - 2U] == '\r') &&
865 (buffer[out_len - 1U] == '\n')) {
866 /* Standard \r\n line ending */
867 out_len -= 2U;
868 buffer[out_len] = '\0';
869 return (ssize_t)out_len;
870 }
871 
872 if ((out_len >= 1U) && (buffer[out_len - 1U] == '\n')) {
873 /* Bare \n (some Android/mobile clients send this) */
874 out_len -= 1U;
875 buffer[out_len] = '\0';
876 return (ssize_t)out_len;
877 }
878 
879 /* Line contains \n but no usable content — retry */
880 out_len = 0U;
881 }
882 }
883 
884 if (too_long != 0) {
885 out_len = 0U;
886 }
887 
888 if (session->ctrl_rx_off >= session->ctrl_rx_len) {
889 session->ctrl_rx_off = 0U;
890 session->ctrl_rx_len = 0U;
891 }
892 
893 ssize_t n = PAL_RECV(session->ctrl_fd, session->ctrl_rxbuf,
894 sizeof(session->ctrl_rxbuf), 0);
895 if (n == 0) {
896 return 0;
897 }
898 if (n < 0) {
899 if (errno == EINTR) {
900 continue;
901 }
902 if ((errno == EAGAIN) || (errno == EWOULDBLOCK)) {
903 return (ssize_t)FTP_ERR_TIMEOUT;
904 }
905 return (ssize_t)FTP_ERR_SOCKET_RECV;
906 }
907 
908 session->ctrl_rx_len = (uint16_t)n;
909 session->ctrl_rx_off = 0U;
910 }
911}
912 
913/**
914 * @brief Process FTP command
915 */
916int ftp_session_process_command(ftp_session_t *session, const char *line) {
917 if ((session == NULL) || (line == NULL)) {
918 return FTP_ERR_INVALID_PARAM;
919 }
920 
921 /* Parse command line */
922 char command[64];
923 char args[FTP_CMD_BUFFER_SIZE];
924 
925 ftp_error_t err = ftp_parse_command_line(line, command, args, sizeof(command),
926 sizeof(args));
927 
928 if (err != FTP_OK) {
929 ftp_session_send_reply(session, FTP_REPLY_500_SYNTAX_ERROR, NULL);
930 return 0;
931 }
932 
933 /* Find command handler */
934 const ftp_command_entry_t *cmd = ftp_find_command(command);
935 
936 if (cmd == NULL) {
937 ftp_session_send_reply(session, FTP_REPLY_500_SYNTAX_ERROR,
938 "Unknown command.");
939 return 0;
940 }
941 
942 if (session->authenticated == 0U) {
943 if ((strcmp(command, "USER") != 0) && (strcmp(command, "PASS") != 0) &&
944 (strcmp(command, "QUIT") != 0) && (strcmp(command, "NOOP") != 0) &&
945 (strcmp(command, "FEAT") != 0) && (strcmp(command, "SYST") != 0) &&
946 (strcmp(command, "AUTH") != 0) && (strcmp(command, "OPTS") != 0) &&
947 (strcmp(command, "CLNT") != 0)) {
948 ftp_session_send_reply(session, FTP_REPLY_530_NOT_LOGGED_IN,
949 "Please login with USER and PASS.");
950 return 0;
951 }
952 }
953 
954 /* Validate arguments */
955 const char *cmd_args = (args[0] != '\0') ? args : NULL;
956 err = ftp_validate_command_args(cmd, cmd_args);
957 
958 if (err != FTP_OK) {
959 ftp_session_send_reply(session, FTP_REPLY_501_SYNTAX_ARGS, NULL);
960 return 0;
961 }
962 
963 /* Execute command */
964 err = cmd->handler(session, cmd_args);
965 
966 if (FTP_LOG_COMMANDS != 0) {
967 ftp_log_session_cmd(session, command, err);
968 }
969 
970 if (err != FTP_OK) {
971 /* Command failed */
972 atomic_fetch_add(&session->stats.errors, 1U);
973 
974 /* Most command handlers send their own error replies */
975 /* This is a fallback for unhandled errors */
976 if (err == FTP_ERR_NOT_FOUND) {
977 ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR, NULL);
978 } else if (err == FTP_ERR_PERMISSION) {
979 ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
980 "Permission denied.");
981 }
982 }
983 
984 /* Check if QUIT command */
985 if (strcmp(command, "QUIT") == 0) {
986 return 1; /* Signal to close session */
987 }
988 
989 return 0; /* Continue processing */
990}
991