Seregon/zftpd

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

C/11.0 KB/No license
src/ftp_commands.c
zftpd / src / ftp_commands.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_commands.c
27 * @brief FTP command handlers implementation
28 *
29 * @author SeregonWar
30 * @version 1.0.0
31 * @date 2026-02-13
32 *
33 * PROTOCOL: RFC 959 (File Transfer Protocol)
34 * EXTENSIONS: RFC 3659 (MLST, MLSD, SIZE, MDTM)
35 *
36 */
37 
38#include "ftp_commands.h"
39#include "ftp_buffer_pool.h"
40#include "ftp_crypto.h"
41#include "ftp_log.h"
42#include "ftp_path.h"
43#include "ftp_session.h"
44#include "pal_fileio.h"
45#include "pal_filesystem.h"
46#include "pal_network.h"
47#include <ctype.h>
48#include <dirent.h>
49#include <errno.h>
50#include <fcntl.h>
51#include <stdio.h>
52#include <stdlib.h>
53#include <string.h>
54#include <sys/stat.h>
55#if defined(__APPLE__) || \
56 (defined(__FreeBSD__) && !defined(PLATFORM_PS4) && !defined(PLATFORM_PS5))
57#include <sys/mount.h>
58#endif
59#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5)
60#include <sys/mount.h>
61extern int _fstatfs(int, struct statfs *);
62#endif
63#include <pthread.h>
64#include <time.h>
65#include <unistd.h>
66#if defined(__linux__)
67#include <fcntl.h> /* posix_fadvise(POSIX_FADV_DONTNEED) */
68#endif
69 
70/* Fallback: pal_fileio.h may be suppressed by a transitive include guard */
71#ifndef PAL_FILE_WRITE_CHUNK_MAX
72# if defined(PLATFORM_PS5) || defined(PS5)
73# define PAL_FILE_WRITE_CHUNK_MAX 131072U /* 128 KB */
74# elif defined(PLATFORM_PS4) || defined(PS4)
75# define PAL_FILE_WRITE_CHUNK_MAX 65536U /* 64 KB */
76# else
77# define PAL_FILE_WRITE_CHUNK_MAX 262144U /* 256 KB */
78# endif
79#endif
80 
81/*===========================================================================*
82 * PFS FILE-CREATION SERIALISER
83 *
84 * On PS4/PS5, pal_file_open(O_CREAT) on a PFS-encrypted partition acquires
85 * an inode-allocation lock inside the kernel. When two FTP sessions call it
86 * simultaneously the second one is forced to spin-wait on that same lock,
87 * turning a ~5 s open into a >20 s open — past FileZilla's command-response
88 * timeout — with 0 bytes transferred.
89 *
90 * Serialising O_CREAT opens at the application level lets each session
91 * proceed without journal contention. The total elapsed time for two
92 * parallel uploads is ~10 s instead of >20 s, comfortably within the
93 * FileZilla command-response timeout.
94 *
95 * The lock is only held during the open() call itself (typically 3–8 s on
96 * PFS); the actual data transfer runs fully in parallel.
97 *===========================================================================*/
98#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5)
99static pthread_mutex_t g_pfs_create_mtx = PTHREAD_MUTEX_INITIALIZER;
100 
101/* pthread_mutex_timedlock() is absent from the OrbisOS (PS4/PS5) SDK.
102 * This helper polls with trylock + nanosleep to emulate a timed lock.
103 * Returns 0 on success, ETIMEDOUT if the deadline passes. */
104static int pfs_mutex_lock_timeout(pthread_mutex_t *mtx, int timeout_s) {
105 struct timespec sleep_ts;
106 sleep_ts.tv_sec = 0;
107 sleep_ts.tv_nsec = 10000000; /* 10 ms */
108 int elapsed_ms = 0;
109 const int limit_ms = timeout_s * 1000;
110 while (elapsed_ms < limit_ms) {
111 if (pthread_mutex_trylock(mtx) == 0) {
112 return 0;
113 }
114 nanosleep(&sleep_ts, NULL);
115 elapsed_ms += 10;
116 }
117 return ETIMEDOUT;
118}
119#endif
120 
121/*===========================================================================*
122 * FORWARD DECLARATIONS
123 *===========================================================================*/
124 
125static ftp_error_t start_async_copy(ftp_session_t *session,
126 const char *src_ftp_path,
127 const char *dst_ftp_path, int is_move);
128 
129static ftp_error_t send_control_raw(ftp_session_t *session,
130 const char *payload) {
131 if ((session == NULL) || (payload == NULL)) {
132 return FTP_ERR_INVALID_PARAM;
133 }
134 
135 if (session->ctrl_fd < 0) {
136 return FTP_ERR_SOCKET_SEND;
137 }
138 
139 size_t len = strlen(payload);
140 if (len == 0U) {
141 return FTP_OK;
142 }
143 
144 ssize_t sent = pal_send_all(session->ctrl_fd, payload, len, 0);
145 if (sent != (ssize_t)len) {
146 return FTP_ERR_SOCKET_SEND;
147 }
148 
149 session->last_activity = time(NULL);
150 return FTP_OK;
151}
152 
153static const char *mlsx_type_from_mode(uint32_t mode) {
154 return (((mode & (uint32_t)S_IFMT) == (uint32_t)S_IFDIR)) ? "dir" : "file";
155}
156 
157static void mlsx_format_modify_time(int64_t mtime, char *buffer,
158 size_t size) {
159 if ((buffer == NULL) || (size == 0U)) {
160 return;
161 }
162 
163 time_t unix_time = (time_t)mtime;
164 struct tm tm_time;
165 memset(&tm_time, 0, sizeof(tm_time));
166 gmtime_r(&unix_time, &tm_time);
167 (void)strftime(buffer, size, "%Y%m%d%H%M%S", &tm_time);
168}
169 
170static ftp_error_t ftp_path_to_client_path(const ftp_session_t *session,
171 const char *resolved,
172 char *output, size_t size) {
173 if ((session == NULL) || (resolved == NULL) || (output == NULL)) {
174 return FTP_ERR_INVALID_PARAM;
175 }
176 
177 if (size < 2U) {
178 return FTP_ERR_PATH_TOO_LONG;
179 }
180 
181 if (strcmp(session->root_path, "/") == 0) {
182 size_t len = strlen(resolved);
183 if ((len + 1U) > size) {
184 return FTP_ERR_PATH_TOO_LONG;
185 }
186 memcpy(output, resolved, len + 1U);
187 return FTP_OK;
188 }
189 
190 size_t root_len = strlen(session->root_path);
191 if (strncmp(resolved, session->root_path, root_len) != 0) {
192 return FTP_ERR_PATH_INVALID;
193 }
194 
195 const char *suffix = resolved + root_len;
196 if (*suffix == '\0') {
197 output[0] = '/';
198 output[1] = '\0';
199 return FTP_OK;
200 }
201 
202 if (*suffix == '/') {
203 size_t len = strlen(suffix);
204 if ((len + 1U) > size) {
205 return FTP_ERR_PATH_TOO_LONG;
206 }
207 memcpy(output, suffix, len + 1U);
208 return FTP_OK;
209 }
210 
211 int n = snprintf(output, size, "/%s", suffix);
212 if ((n < 0) || ((size_t)n >= size)) {
213 return FTP_ERR_PATH_TOO_LONG;
214 }
215 
216 return FTP_OK;
217}
218 
219static ftp_error_t mlsx_format_line(const vfs_stat_t *st,
220 const char *display_name,
221 int leading_space, char *buffer,
222 size_t size) {
223 if ((st == NULL) || (display_name == NULL) || (buffer == NULL) ||
224 (size == 0U)) {
225 return FTP_ERR_INVALID_PARAM;
226 }
227 
228 char timebuf[20];
229 memset(timebuf, 0, sizeof(timebuf));
230 mlsx_format_modify_time(st->mtime, timebuf, sizeof(timebuf));
231 
232 const char *type_str = mlsx_type_from_mode(st->mode);
233 unsigned long long size_value =
234 (((st->mode & (uint32_t)S_IFMT) == (uint32_t)S_IFDIR))
235 ? 0ULL
236 : (unsigned long long)st->size;
237 unsigned mode_value = (unsigned)(st->mode & 07777U);
238 const char *prefix = (leading_space != 0) ? " " : "";
239 
240 int n = snprintf(buffer, size,
241 "%stype=%s;size=%llu;modify=%s;unix.mode=%04o; %s\r\n",
242 prefix, type_str, size_value, timebuf, mode_value,
243 display_name);
244 if ((n < 0) || ((size_t)n >= size)) {
245 return FTP_ERR_PATH_TOO_LONG;
246 }
247 
248 return FTP_OK;
249}
250 
251/*===========================================================================*
252 * AUTHENTICATION AND CONTROL
253 *===========================================================================*/
254 
255/**
256 * @brief USER command - Specify user name
257 */
258ftp_error_t cmd_USER(ftp_session_t *session, const char *args) {
259 if ((session == NULL) || (args == NULL)) {
260 return FTP_ERR_INVALID_PARAM;
261 }
262 
263 /*
264 * Open server — accept any username and log in immediately.
265 *
266 * Android apps (File Manager+, SuperFTP) expect 230 right
267 * after USER and never send PASS. GoldHEN/ftpsrv does the
268 * same: USER always returns 230.
269 *
270 * Clients that DO send PASS (FileZilla, WinSCP) will just
271 * receive a harmless 230 from cmd_PASS too.
272 */
273 (void)args;
274 session->user_ok = 1U;
275 session->authenticated = 1U;
276 session->auth_attempts = 0U;
277 atomic_store(&session->state, FTP_STATE_AUTHENTICATED);
278 return ftp_session_send_reply(session, FTP_REPLY_230_LOGGED_IN, NULL);
279}
280 
281/**
282 * @brief PASS command - Specify password
283 */
284ftp_error_t cmd_PASS(ftp_session_t *session, const char *args) {
285 (void)args;
286 
287 if (session == NULL) {
288 return FTP_ERR_INVALID_PARAM;
289 }
290 
291 /* Already logged in from USER — accept PASS as harmless no-op */
292 session->authenticated = 1U;
293 session->auth_attempts = 0U;
294 atomic_store(&session->state, FTP_STATE_AUTHENTICATED);
295 return ftp_session_send_reply(session, FTP_REPLY_230_LOGGED_IN, NULL);
296}
297 
298/**
299 * @brief QUIT command - Terminate session
300 */
301ftp_error_t cmd_QUIT(ftp_session_t *session, const char *args) {
302 (void)args; /* Unused */
303 
304 if (session == NULL) {
305 return FTP_ERR_INVALID_PARAM;
306 }
307 
308 return ftp_session_send_reply(session, FTP_REPLY_221_GOODBYE, NULL);
309}
310 
311/**
312 * @brief NOOP command - No operation
313 */
314ftp_error_t cmd_NOOP(ftp_session_t *session, const char *args) {
315 (void)args; /* Unused */
316 
317 if (session == NULL) {
318 return FTP_ERR_INVALID_PARAM;
319 }
320 
321 return ftp_session_send_reply(session, FTP_REPLY_200_OK, NULL);
322}
323 
324/*===========================================================================*
325 * NAVIGATION
326 *===========================================================================*/
327 
328/**
329 * @brief CWD command - Change working directory
330 */
331ftp_error_t cmd_CWD(ftp_session_t *session, const char *args) {
332 if ((session == NULL) || (args == NULL)) {
333 return FTP_ERR_INVALID_PARAM;
334 }
335 
336 /* Resolve path */
337 char resolved[FTP_PATH_MAX];
338 ftp_error_t err = ftp_path_resolve(session, args, resolved, sizeof(resolved));
339 
340 if (err != FTP_OK) {
341 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
342 "Invalid path.");
343 }
344 
345 /* Check if directory exists */
346 int is_dir = pal_path_is_directory(resolved);
347 
348 if (is_dir != 1) {
349 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
350 "Not a directory.");
351 }
352 
353 /* Update CWD */
354 size_t len = strlen(resolved);
355 if (len >= sizeof(session->cwd)) {
356 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
357 "Path too long.");
358 }
359 
360 memcpy(session->cwd, resolved, len + 1U);
361 
362 return ftp_session_send_reply(session, FTP_REPLY_250_FILE_ACTION_OK,
363 "Directory changed.");
364}
365 
366/**
367 * @brief CDUP command - Change to parent directory
368 */
369ftp_error_t cmd_CDUP(ftp_session_t *session, const char *args) {
370 (void)args; /* Unused */
371 
372 if (session == NULL) {
373 return FTP_ERR_INVALID_PARAM;
374 }
375 
376 /* Navigate to parent: "../" */
377 return cmd_CWD(session, "..");
378}
379 
380/**
381 * @brief PWD command - Print working directory
382 */
383ftp_error_t cmd_PWD(ftp_session_t *session, const char *args) {
384 (void)args; /* Unused */
385 
386 if (session == NULL) {
387 return FTP_ERR_INVALID_PARAM;
388 }
389 
390 /* Format: 257 "pathname" */
391 char reply[FTP_REPLY_BUFFER_SIZE];
392 int n = snprintf(reply, sizeof(reply), "\"%s\" is current directory.",
393 session->cwd);
394 
395 if ((n < 0) || ((size_t)n >= sizeof(reply))) {
396 return FTP_ERR_INVALID_PARAM;
397 }
398 
399 return ftp_session_send_reply(session, FTP_REPLY_257_PATH_CREATED, reply);
400}
401 
402/*===========================================================================*
403 * DIRECTORY LISTING
404 *===========================================================================*/
405 
406/**
407 * @brief Helper: Send directory listing via data connection
408 */
409static ftp_error_t send_directory_listing(ftp_session_t *session,
410 const char *path, int detailed) {
411 DIR *dir = opendir(path);
412 if (dir == NULL) {
413 return FTP_ERR_DIR_OPEN;
414 }
415 
416 char line_buffer[FTP_LIST_LINE_SIZE];
417 struct dirent *entry;
418 
419 int skip_stat = 0;
420 if (FTP_LIST_SAFE_MODE != 0) {
421 if ((strncmp(path, "/dev", 4) == 0) &&
422 ((path[4] == '\0') || (path[4] == '/'))) {
423 skip_stat = 1;
424 } else if ((strncmp(path, "/proc", 5) == 0) &&
425 ((path[5] == '\0') || (path[5] == '/'))) {
426 skip_stat = 1;
427 } else if ((strncmp(path, "/sys", 4) == 0) &&
428 ((path[4] == '\0') || (path[4] == '/'))) {
429 skip_stat = 1;
430 }
431#if defined(__APPLE__) || \
432 (defined(__FreeBSD__) && !defined(PLATFORM_PS4) && !defined(PLATFORM_PS5))
433 if (skip_stat == 0) {
434 struct statfs sfs;
435 if (statfs(path, &sfs) == 0) {
436 const char *t = sfs.f_fstypename;
437 if ((t != NULL) &&
438 ((strcmp(t, "devfs") == 0) || (strcmp(t, "procfs") == 0) ||
439 (strcmp(t, "fdescfs") == 0) || (strcmp(t, "sysfs") == 0) ||
440 (strcmp(t, "linsysfs") == 0))) {
441 skip_stat = 1;
442 }
443 }
444 }
445#endif
446 }
447 
448 while ((entry = readdir(dir)) != NULL) {
449 /* Skip . and .. */
450 if ((strcmp(entry->d_name, ".") == 0) ||
451 (strcmp(entry->d_name, "..") == 0)) {
452 continue;
453 }
454 
455 if (detailed != 0) {
456 /* Detailed listing (ls -l format) */
457 vfs_stat_t st;
458 int have_stat = 0;
459 if (skip_stat == 0) {
460 char fullpath[FTP_PATH_MAX];
461 int n =
462 snprintf(fullpath, sizeof(fullpath), "%s/%s", path, entry->d_name);
463 if ((n >= 0) && ((size_t)n < sizeof(fullpath))) {
464 if (vfs_stat(fullpath, &st) == FTP_OK) {
465 have_stat = 1;
466 }
467 }
468 }
469 if (have_stat == 0) {
470 memset(&st, 0, sizeof(st));
471 if (entry->d_type == DT_DIR) {
472 st.mode = (uint32_t)S_IFDIR;
473 } else {
474 st.mode = (uint32_t)S_IFREG;
475 }
476 }
477 
478 /* Format: -rw-r--r-- 1 user group size date filename */
479 char perms[11];
480 perms[0] = (((st.mode & S_IFMT) == S_IFDIR)) ? 'd' : '-';
481 perms[1] = ((st.mode & S_IRUSR) != 0U) ? 'r' : '-';
482 perms[2] = ((st.mode & S_IWUSR) != 0U) ? 'w' : '-';
483 perms[3] = ((st.mode & S_IXUSR) != 0U) ? 'x' : '-';
484 perms[4] = ((st.mode & S_IRGRP) != 0U) ? 'r' : '-';
485 perms[5] = ((st.mode & S_IWGRP) != 0U) ? 'w' : '-';
486 perms[6] = ((st.mode & S_IXGRP) != 0U) ? 'x' : '-';
487 perms[7] = ((st.mode & S_IROTH) != 0U) ? 'r' : '-';
488 perms[8] = ((st.mode & S_IWOTH) != 0U) ? 'w' : '-';
489 perms[9] = ((st.mode & S_IXOTH) != 0U) ? 'x' : '-';
490 perms[10] = '\0';
491 
492 /* Format time */
493 struct tm tm_time;
494 time_t mtime = (time_t)st.mtime;
495 gmtime_r(&mtime, &tm_time);
496 
497 char time_str[32];
498 strftime(time_str, sizeof(time_str), "%b %d %H:%M", &tm_time);
499 
500 int n = snprintf(line_buffer, sizeof(line_buffer),
501 "%s 1 ftp ftp %10lld %s %s\r\n", perms,
502 (long long)st.size, time_str, entry->d_name);
503 if ((n < 0) || ((size_t)n >= sizeof(line_buffer))) {
504 continue;
505 }
506 } else {
507 /* Simple listing (names only) */
508 int n =
509 snprintf(line_buffer, sizeof(line_buffer), "%s\r\n", entry->d_name);
510 
511 if ((n < 0) || ((size_t)n >= sizeof(line_buffer))) {
512 continue;
513 }
514 }
515 
516 /* Send line */
517 (void)ftp_session_send_data(session, line_buffer, strlen(line_buffer));
518 }
519 
520 closedir(dir);
521 
522 return FTP_OK;
523}
524 
525/**
526 * @brief LIST command - Detailed directory listing
527 */
528ftp_error_t cmd_LIST(ftp_session_t *session, const char *args) {
529 if (session == NULL) {
530 return FTP_ERR_INVALID_PARAM;
531 }
532 
533 /* Resolve path (use CWD if no args) */
534 const char *path_arg = (args != NULL) ? args : session->cwd;
535 
536 char resolved[FTP_PATH_MAX];
537 ftp_error_t err =
538 ftp_path_resolve(session, path_arg, resolved, sizeof(resolved));
539 
540 if (err != FTP_OK) {
541 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
542 "Invalid path.");
543 }
544 
545 /* Open data connection FIRST, then send 150.
546 * GoldHEN/ftpsrv does accept() before 150. This ensures the
547 * client receives 150 after data channel is ready, preventing
548 * 150+226 from merging in the same TCP segment.
549 */
550 ftp_log_line(FTP_LOG_INFO, "[DBG] LIST: opening data connection...");
551 err = ftp_session_open_data_connection(session);
552 if (err != FTP_OK) {
553 char dbg[64];
554 snprintf(dbg, sizeof(dbg), "[DBG] LIST: data conn FAILED err=%d", (int)err);
555 ftp_log_line(FTP_LOG_INFO, dbg);
556 return ftp_session_send_reply(session, FTP_REPLY_425_CANT_OPEN_DATA, NULL);
557 }
558 ftp_log_line(FTP_LOG_INFO, "[DBG] LIST: data conn OK, sending 150");
559 ftp_session_send_reply(session, FTP_REPLY_150_FILE_OK, NULL);
560 
561 /* Protocol pacing: ensure the 150 reply is delivered as a
562 * separate TCP segment before the data transfer + 226.
563 * On LAN, LIST completes in microseconds, so 150 and 226
564 * merge in the client's recv() buffer. File Manager+
565 * cannot handle multi-reply reads, causing Loading Error.
566 */
567 usleep(50000); /* 50 ms */
568 
569 /* Send listing */
570 err = send_directory_listing(session, resolved, 1);
571 
572 /* Close data connection */
573 ftp_session_close_data_connection(session);
574 
575 if (err != FTP_OK) {
576 return ftp_session_send_reply(session, FTP_REPLY_451_LOCAL_ERROR,
577 "Error reading directory.");
578 }
579 
580 return ftp_session_send_reply(session, FTP_REPLY_226_TRANSFER_COMPLETE, NULL);
581}
582 
583/**
584 * @brief NLST command - Name list
585 */
586ftp_error_t cmd_NLST(ftp_session_t *session, const char *args) {
587 if (session == NULL) {
588 return FTP_ERR_INVALID_PARAM;
589 }
590 
591 /* Similar to LIST but simpler format */
592 const char *path_arg = (args != NULL) ? args : session->cwd;
593 
594 char resolved[FTP_PATH_MAX];
595 ftp_error_t err =
596 ftp_path_resolve(session, path_arg, resolved, sizeof(resolved));
597 
598 if (err != FTP_OK) {
599 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
600 "Invalid path.");
601 }
602 
603 err = ftp_session_open_data_connection(session);
604 if (err != FTP_OK) {
605 return ftp_session_send_reply(session, FTP_REPLY_425_CANT_OPEN_DATA, NULL);
606 }
607 
608 ftp_session_send_reply(session, FTP_REPLY_150_FILE_OK, NULL);
609 
610 /* Protocol pacing: prevent 150 and 226 from merging */
611 usleep(50000); /* 50 ms */
612 
613 err = send_directory_listing(session, resolved, 0);
614 
615 ftp_session_close_data_connection(session);
616 
617 if (err != FTP_OK) {
618 return ftp_session_send_reply(session, FTP_REPLY_451_LOCAL_ERROR,
619 "Error reading directory.");
620 }
621 
622 return ftp_session_send_reply(session, FTP_REPLY_226_TRANSFER_COMPLETE, NULL);
623}
624 
625/**
626 * @brief MLSD command - Machine listing (RFC 3659)
627 *
628 * Unlike LIST which outputs human-readable "ls -l" lines,
629 * MLSD outputs machine-readable facts per entry:
630 *
631 * type=dir;size=0;modify=20250222120000; dirname
632 * type=file;size=1234;modify=20250222120000; filename
633 *
634 * Android apps (File Manager+, SuperFTP) use MLSD exclusively
635 * and cannot parse LIST format.
636 */
637ftp_error_t cmd_MLSD(ftp_session_t *session, const char *args) {
638 if (session == NULL) {
639 return FTP_ERR_INVALID_PARAM;
640 }
641 
642 /* Resolve path (use CWD if no args) */
643 const char *path_arg = (args != NULL) ? args : session->cwd;
644 
645 char resolved[FTP_PATH_MAX];
646 ftp_error_t err =
647 ftp_path_resolve(session, path_arg, resolved, sizeof(resolved));
648 
649 if (err != FTP_OK) {
650 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
651 "Invalid path.");
652 }
653 
654 /* Open data connection FIRST, then send 150 (same as LIST) */
655 err = ftp_session_open_data_connection(session);
656 if (err != FTP_OK) {
657 return ftp_session_send_reply(session, FTP_REPLY_425_CANT_OPEN_DATA, NULL);
658 }
659 
660 ftp_session_send_reply(session, FTP_REPLY_150_FILE_OK, NULL);
661 
662 /* Protocol pacing: prevent 150 and 226 from merging */
663 usleep(50000); /* 50 ms */
664 
665 /* Read directory and send machine-readable entries */
666 DIR *dir = opendir(resolved);
667 if (dir != NULL) {
668 struct dirent *entry;
669 char line_buffer[FTP_LIST_LINE_SIZE];
670 
671 while ((entry = readdir(dir)) != NULL) {
672 /* Skip . and .. */
673 if ((strcmp(entry->d_name, ".") == 0) ||
674 (strcmp(entry->d_name, "..") == 0)) {
675 continue;
676 }
677 
678 /* Stat the entry */
679 vfs_stat_t st;
680 int have_stat = 0;
681 char fullpath[FTP_PATH_MAX];
682 int n = snprintf(fullpath, sizeof(fullpath), "%s/%s", resolved,
683 entry->d_name);
684 if ((n >= 0) && ((size_t)n < sizeof(fullpath))) {
685 if (vfs_stat(fullpath, &st) == FTP_OK) {
686 have_stat = 1;
687 }
688 }
689 
690 if (have_stat == 0) {
691 memset(&st, 0, sizeof(st));
692 if (entry->d_type == DT_DIR) {
693 st.mode = (uint32_t)S_IFDIR;
694 } else {
695 st.mode = (uint32_t)S_IFREG;
696 }
697 }
698 
699 if (mlsx_format_line(&st, entry->d_name, 0, line_buffer,
700 sizeof(line_buffer)) == FTP_OK) {
701 size_t line_len = strlen(line_buffer);
702 (void)ftp_session_send_data(session, line_buffer, line_len);
703 }
704 }
705 
706 closedir(dir);
707 }
708 
709 /* Close data connection */
710 ftp_session_close_data_connection(session);
711 
712 return ftp_session_send_reply(session, FTP_REPLY_226_TRANSFER_COMPLETE, NULL);
713}
714 
715/**
716 * @brief MLST command - Machine list single file (RFC 3659)
717 */
718ftp_error_t cmd_MLST(ftp_session_t *session, const char *args) {
719 if (session == NULL) {
720 return FTP_ERR_INVALID_PARAM;
721 }
722 
723 const char *path_arg = ((args != NULL) && (args[0] != '\0')) ? args
724 : session->cwd;
725 
726 char resolved[FTP_PATH_MAX];
727 ftp_error_t err =
728 ftp_path_resolve(session, path_arg, resolved, sizeof(resolved));
729 if (err != FTP_OK) {
730 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
731 "Invalid path.");
732 }
733 
734 vfs_stat_t st;
735 err = vfs_stat(resolved, &st);
736 if (err != FTP_OK) {
737 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
738 "File not found.");
739 }
740 
741 char client_path[FTP_PATH_MAX];
742 err = ftp_path_to_client_path(session, resolved, client_path,
743 sizeof(client_path));
744 if (err != FTP_OK) {
745 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
746 "Invalid path.");
747 }
748 
749 char listing_line[FTP_LIST_LINE_SIZE];
750 err = mlsx_format_line(&st, client_path, 1, listing_line,
751 sizeof(listing_line));
752 if (err != FTP_OK) {
753 return ftp_session_send_reply(session, FTP_REPLY_451_LOCAL_ERROR,
754 "MLST formatting failed.");
755 }
756 
757 char reply[FTP_REPLY_BUFFER_SIZE];
758 int n = snprintf(reply, sizeof(reply), "250-Listing %s\r\n", client_path);
759 if ((n < 0) || ((size_t)n >= sizeof(reply))) {
760 return ftp_session_send_reply(session, FTP_REPLY_451_LOCAL_ERROR,
761 "MLST formatting failed.");
762 }
763 
764 err = send_control_raw(session, reply);
765 if (err != FTP_OK) {
766 return err;
767 }
768 
769 err = send_control_raw(session, listing_line);
770 if (err != FTP_OK) {
771 return err;
772 }
773 
774 return send_control_raw(session, "250 End\r\n");
775}
776 
777/*===========================================================================*
778 * FILE TRANSFER
779 *===========================================================================*/
780 
781/**
782 * @brief RETR command - Retrieve (download) file
783 */
784ftp_error_t cmd_RETR(ftp_session_t *session, const char *args) {
785 if ((session == NULL) || (args == NULL)) {
786 return FTP_ERR_INVALID_PARAM;
787 }
788 
789 /* Resolve path */
790 char resolved[FTP_PATH_MAX];
791 ftp_error_t err = ftp_path_resolve(session, args, resolved, sizeof(resolved));
792 
793 if (err != FTP_OK) {
794 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
795 "Invalid path.");
796 }
797 
798 vfs_node_t node;
799 err = vfs_open(&node, resolved);
800 if (err != FTP_OK) {
801 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
802 "Cannot open file.");
803 }
804 uint64_t file_size = vfs_get_size(&node);
805 
806 vfs_stat_t st;
807 int have_stat = 0;
808 if (vfs_stat(resolved, &st) == FTP_OK) {
809 have_stat = 1;
810 }
811 if (have_stat != 0) {
812 uint32_t fmt = (uint32_t)(st.mode & (uint32_t)S_IFMT);
813 if (fmt != (uint32_t)S_IFREG) {
814 vfs_close(&node);
815 session->restart_offset = 0;
816 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
817 "Not a regular file.");
818 }
819 }
820 
821 /* Handle REST (resume) offset */
822 off_t offset = session->restart_offset;
823 if ((offset < 0) || ((uint64_t)offset > file_size)) {
824 vfs_close(&node);
825 session->restart_offset = 0;
826 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
827 "Invalid offset.");
828 }
829 vfs_set_offset(&node, (uint64_t)offset);
830 
831 /* Open data connection */
832 ftp_session_send_reply(session, FTP_REPLY_150_FILE_OK, NULL);
833 
834 err = ftp_session_open_data_connection(session);
835 if (err != FTP_OK) {
836 vfs_close(&node);
837 return ftp_session_send_reply(session, FTP_REPLY_425_CANT_OPEN_DATA, NULL);
838 }
839 
840 size_t remaining = (size_t)(file_size - (uint64_t)offset);
841 uint64_t bytes_sent = 0U;
842 
843 /*
844 * sendfile eligibility: kernel-to-kernel transfer
845 *
846 * Disabled when encryption is active (XOR must happen in userspace)
847 * or when rate limiting is on (sendfile can't be throttled).
848 */
849 int use_sendfile = ((vfs_get_caps(&node) & VFS_CAP_SENDFILE) != 0U) &&
850 (FTP_TRANSFER_RATE_LIMIT_BPS == 0U);
851 
852 /* DIAGNOSTIC: log transfer configuration so bottlenecks are visible in klog */
853 {
854 char diag[256];
855#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5)
856 struct statfs sfs;
857 const char *fstype = "unknown";
858 if (_fstatfs(node.fd, &sfs) == 0) { fstype = sfs.f_fstypename; }
859 snprintf(diag, sizeof(diag),
860 "[RETR] file=%s size=%llu fs=%s sendfile=%d "
861 "chunk=%u cooldown=%u eagain_sleep=%u sndbuf=%u",
862 resolved, (unsigned long long)file_size, fstype, use_sendfile,
863 (unsigned)FTP_RETR_SENDFILE_CHUNK,
864 (unsigned)(4U << 20),
865 (unsigned)FTP_SENDFILE_EAGAIN_SLEEP_US,
866 (unsigned)FTP_TCP_DATA_SNDBUF);
867#else
868 snprintf(diag, sizeof(diag),
869 "[RETR] file=%s size=%llu sendfile=%d chunk=%u",
870 resolved, (unsigned long long)file_size, use_sendfile,
871 (unsigned)FTP_RETR_SENDFILE_CHUNK);
872#endif
873 ftp_log_line(FTP_LOG_INFO, diag);
874 }
875#if FTP_ENABLE_CRYPTO
876 if (session->crypto.active != 0U) {
877 use_sendfile = 0;
878 }
879#endif
880 
881 /*=========================================================================*
882 * Transfer loop: sendfile with read() cooldown retry
883 *
884 * PS4/PS5 exFAT driver stalls sendfile() after ~28 MB of continuous
885 * kernel-to-kernel transfer (page cache pressure). Instead of falling
886 * back to slow read() permanently, we alternate:
887 *
888 * sendfile burst (kernel speed, ~28 MB)
889 * -> stall detected
890 * -> read() cooldown (1 MB, releases page pressure)
891 * -> retry sendfile (if it works, another 28 MB burst)
892 * -> repeat until file complete
893 *
894 * If sendfile fails immediately on retry (0 bytes), the driver truly
895 * cannot recover and we finish the rest via read().
896 *
897 * ┌──────────┐ stall ┌──────────────┐ 1 MB done ┌──────────┐
898 * │ sendfile │──────────►│ read cooldown │────────────►│ sendfile │
899 * │ burst │ │ (1 MB) │ │ retry │
900 * └─────┬────┘ └──────────────┘ └─────┬────┘
901 * done │ 0 bytes │ ok
902 * ▼ ▼
903 * [226] ┌───────────────┐
904 * │ read() finish │
905 * └───────┬──────┘
906 * done │
907 * ▼
908 * [226]
909 *=========================================================================*/
910 
911/* Cooldown: bytes of read() between sendfile retries */
912#define SENDFILE_COOLDOWN_BYTES (4U << 20) /* 4 MB — allineato a PAL_FILE_COPY_BUFFER_SIZE PS5; meno rientri nel path lento */
913 
914 void *buf = NULL;
915 size_t buf_sz = 0U;
916 
917 while (remaining > 0U) {
918 
919 /*-- sendfile burst --*/
920 if (use_sendfile != 0) {
921 pal_socket_cork(session->data_fd);
922 int sf_sent_any = 0;
923 
924 while (remaining > 0U) {
925 ssize_t sent =
926 pal_sendfile(session->data_fd, node.fd, &offset,
927 (remaining > (size_t)FTP_RETR_SENDFILE_CHUNK)
928 ? (size_t)FTP_RETR_SENDFILE_CHUNK
929 : remaining);
930 
931 if (sent <= 0) {
932 if ((sent < 0) && (errno == EINTR)) {
933 continue;
934 }
935 
936 /*
937 * Fatal storage error — EIO / ESTALE / EBADF / EFAULT.
938 *
939 * pal_sendfile() now always returns -1 for these (never a
940 * positive sbytes), so errno is still set from the underlying
941 * sendfile(2) syscall.
942 *
943 * Do NOT enter the EAGAIN retry loop: every retry would call
944 * sendfile() again on the same bad/unmounted vnode, which on
945 * PS5/PS4 (FreeBSD sendfile) can trigger an unrecoverable
946 * kernel panic.
947 *
948 * Instead, disable sendfile for this session and fall through
949 * to the read()-based cooldown path, which handles I/O errors
950 * gracefully by returning an FTP 426 reply.
951 */
952 if ((sent < 0) && ((errno == EIO) || (errno == ESTALE) ||
953 (errno == EBADF) || (errno == EFAULT))) {
954 vfs_set_offset(&node, (uint64_t)offset);
955 use_sendfile = 0; /* switch to read() for remainder */
956 break;
957 }
958 
959 /*
960 * sent == 0: two possible causes need different responses.
961 *
962 * (A) TCP back-pressure — send buffer momentarily full.
963 * ACKs are in-flight; a short sleep lets them drain the
964 * buffer so sendfile can continue. This is identical to
965 * what the HTTP server does on EAGAIN (usleep 1 ms + retry).
966 *
967 * (B) Platform driver stall (PS5 exFAT / sendfile internal
968 * limit). Retries do not recover; fall to read() cooldown.
969 *
970 * Disambiguation strategy: retry up to FTP_SENDFILE_EAGAIN_RETRIES
971 * times, each time sleeping FTP_SENDFILE_EAGAIN_SLEEP_US µs. If
972 * any retry sends bytes → (A), continue burst. If all retries
973 * return 0 → (B), fall to cooldown.
974 *
975 * With FTP_SENDFILE_EAGAIN_RETRIES = 256 and 1 ms sleep, we wait
976 * up to 256 ms before declaring a stall — enough to cover any
977 * realistic internet RTT and allow TCP ACKs to return.
978 */
979 int recovered = 0;
980 for (int r = 0; r < FTP_SENDFILE_EAGAIN_RETRIES; r++) {
981 usleep(FTP_SENDFILE_EAGAIN_SLEEP_US);
982 ssize_t r_sent =
983 pal_sendfile(session->data_fd, node.fd, &offset,
984 (remaining > (size_t)FTP_RETR_SENDFILE_CHUNK)
985 ? (size_t)FTP_RETR_SENDFILE_CHUNK
986 : remaining);
987 if (r_sent > 0) {
988 /* TCP backpressure cleared — count as sent and continue */
989 sf_sent_any = 1;
990 remaining -= (size_t)r_sent;
991 bytes_sent += (uint64_t)r_sent;
992 session->last_activity = time(NULL);
993 atomic_fetch_add(&session->stats.bytes_sent, (uint64_t)r_sent);
994#if defined(POSIX_FADV_DONTNEED) && !defined(PLATFORM_PS4) && !defined(PS4)
995 if (node.fd >= 0) {
996 off_t evict_start = offset - (off_t)r_sent;
997 if (evict_start >= 0) {
998 (void)posix_fadvise(node.fd, evict_start,
999 (off_t)r_sent, POSIX_FADV_DONTNEED);
1000 }
1001 }
1002#endif
1003 recovered = 1;
1004 break;
1005 }
1006 if ((r_sent < 0) && (errno == EINTR)) {
1007 r--; /* don't count EINTR as a retry */
1008 }
1009 }
1010 
1011 if (recovered != 0) {
1012 continue; /* back to sendfile burst */
1013 }
1014 
1015 /* True driver stall (all retries failed) */
1016 vfs_set_offset(&node, (uint64_t)offset);
1017 if (sf_sent_any == 0) {
1018 use_sendfile = 0;
1019 }
1020 break;
1021 }
1022 
1023 sf_sent_any = 1;
1024 remaining -= (size_t)sent;
1025 bytes_sent += (uint64_t)sent;
1026 session->last_activity = time(NULL);
1027 atomic_fetch_add(&session->stats.bytes_sent, (uint64_t)sent);
1028 
1029 /*
1030 * Evict pages we have already sent from the kernel page cache.
1031 *
1032 * Without this hint, the kernel caches every page of the source
1033 * file as it is DMA'd to the socket buffer. For large transfers
1034 * (12–60 GB) the page cache fills all available RAM; the kernel
1035 * then spends increasing time on page reclaim, causing the
1036 * observed monotonic throughput drop from 260 Mbps toward 0.
1037 *
1038 * POSIX_FADV_DONTNEED marks the just-sent pages as eligible for
1039 * immediate eviction (they remain accessible but are freed under
1040 * memory pressure before any other pages), keeping cache pressure
1041 * flat regardless of file size.
1042 *
1043 * Safe on Linux AND FreeBSD/PS5: posix_fadvise(DONTNEED) on a
1044 * read-only fd simply marks pages as low-priority — it does not
1045 * write data or invalidate valid mappings. The previous guard
1046 * "#if defined(__linux__)" was an oversight; PS5 also supports
1047 * posix_fadvise(2) and benefits from the same eviction hint.
1048 * (The sendfile-internal mapping concern in pal_fileio.c is about
1049 * F_NOCACHE on the same fd being passed to sendfile(), which is
1050 * a different code path — it does not apply here.)
1051 */
1052#if defined(POSIX_FADV_DONTNEED) && !defined(PLATFORM_PS4) && !defined(PS4)
1053 if (node.fd >= 0) {
1054 off_t evict_start = offset - (off_t)sent;
1055 if (evict_start >= 0) {
1056 (void)posix_fadvise(node.fd, evict_start,
1057 (off_t)sent, POSIX_FADV_DONTNEED);
1058 }
1059 }
1060#endif
1061 }
1062 pal_socket_uncork(session->data_fd);
1063 
1064 if (remaining == 0U) {
1065 break; /* transfer complete */
1066 }
1067 /* use_sendfile == 0: permanent fallback, drop through */
1068 /* use_sendfile != 0: stalled mid-transfer, fall through to read() cooldown */
1069 }
1070 
1071 /*-- read() path: cooldown (limited) or finish (unlimited) --*/
1072 if (buf == NULL) {
1073 buf = ftp_buffer_acquire();
1074 buf_sz = ftp_buffer_size();
1075 }
1076 if (buf == NULL) {
1077 remaining = 1U; /* can't allocate buffer, force 426 */
1078 break;
1079 }
1080 
1081 /*
1082 * If sendfile is still eligible, run cooldown for SENDFILE_COOLDOWN_BYTES
1083 * then break back to the outer loop to retry sendfile.
1084 * If sendfile is permanently disabled, run until transfer complete.
1085 */
1086 int can_retry_sf = ((vfs_get_caps(&node) & VFS_CAP_SENDFILE) != 0U) &&
1087 (use_sendfile != 0) &&
1088 (bytes_sent > 0U) &&
1089 (remaining > SENDFILE_COOLDOWN_BYTES);
1090 
1091 size_t cooldown_left = can_retry_sf ? SENDFILE_COOLDOWN_BYTES : remaining;
1092 int read_error = 0;
1093 
1094 pal_socket_cork(session->data_fd);
1095 while ((remaining > 0U) && (cooldown_left > 0U)) {
1096 size_t want = (remaining < buf_sz) ? remaining : buf_sz;
1097 if (want > cooldown_left) {
1098 want = cooldown_left;
1099 }
1100 ssize_t n = vfs_read(&node, buf, want);
1101 if (n <= 0) {
1102 if ((n < 0) && (errno == EINTR)) {
1103 continue;
1104 }
1105 read_error = 1;
1106 break;
1107 }
1108 
1109 ssize_t sent = ftp_session_send_data(session, buf, (size_t)n);
1110 if (sent != n) {
1111 remaining = 1U;
1112 read_error = 1;
1113 break;
1114 }
1115 
1116 bytes_sent += (uint64_t)sent;
1117 remaining -= (size_t)n;
1118 cooldown_left -= (size_t)n;
1119 session->last_activity = time(NULL);
1120 
1121 /* Evict pages already sent; same rationale as the sendfile path. */
1122#if defined(POSIX_FADV_DONTNEED) && !defined(PLATFORM_PS4) && !defined(PS4)
1123 if (node.fd >= 0) {
1124 off_t sent_end = (off_t)(file_size - (uint64_t)remaining);
1125 off_t evict_start = sent_end - (off_t)sent;
1126 if (evict_start >= 0) {
1127 (void)posix_fadvise(node.fd, evict_start,
1128 (off_t)sent, POSIX_FADV_DONTNEED);
1129 }
1130 }
1131#endif
1132 }
1133 pal_socket_uncork(session->data_fd);
1134 
1135 if (read_error != 0) {
1136 break;
1137 }
1138 
1139 /* After cooldown, re-enable sendfile for retry */
1140 if ((can_retry_sf != 0) && (remaining > 0U)) {
1141 use_sendfile = 1;
1142 /* Sync offset for sendfile: vfs_read already advanced the
1143 internal file position, read it back for sendfile's &offset */
1144 offset = (off_t)(file_size - (uint64_t)remaining);
1145 }
1146 }
1147 
1148 ftp_buffer_release(buf);
1149#undef SENDFILE_COOLDOWN_BYTES
1150 
1151 /* Cleanup */
1152 vfs_close(&node);
1153 ftp_session_close_data_connection(session);
1154 session->restart_offset = 0;
1155 
1156 if (remaining == 0U) {
1157 atomic_fetch_add(&session->stats.files_sent, 1U);
1158 ftp_log_session_event(session, "RETR_OK", FTP_OK, bytes_sent);
1159 /* Log throughput so we can see MB/s in klog without an external tool */
1160 {
1161 struct timespec ts_end;
1162 clock_gettime(CLOCK_MONOTONIC, &ts_end);
1163 /* reuse session->last_activity as a rough start proxy — already set
1164 at transfer open. For a real elapsed we'd need a start timestamp.
1165 Instead log raw bytes; the surrounding timestamps in klog give elapsed. */
1166 char tput[128];
1167 snprintf(tput, sizeof(tput),
1168 "[RETR] complete: bytes=%llu (%.1f MB)",
1169 (unsigned long long)bytes_sent,
1170 (double)bytes_sent / (1024.0 * 1024.0));
1171 ftp_log_line(FTP_LOG_INFO, tput);
1172 }
1173 return ftp_session_send_reply(session, FTP_REPLY_226_TRANSFER_COMPLETE,
1174 NULL);
1175 }
1176 
1177 ftp_log_session_event(session, "RETR_FAIL", FTP_ERR_UNKNOWN, bytes_sent);
1178 return ftp_session_send_reply(session, FTP_REPLY_426_TRANSFER_ABORTED,
1179 "Transfer failed.");
1180}
1181 
1182/*===========================================================================*
1183 * DOUBLE-BUFFERED WRITER — overlaps recv() and write()
1184 *
1185 * ┌────────────┐ mutex+cond ┌────────────┐
1186 * │ FTP thread │ ──► swap ──► │ Writer thr │
1187 * │ recv() │ │ write() │
1188 * │ buf[0] │ │ buf[1] │
1189 * └────────────┘ └────────────┘
1190 *
1191 * The FTP thread fills the active buffer via recv(), then swaps
1192 * buffers with the writer thread which drains the filled buffer
1193 * to disk. This overlaps network I/O with PFS crypto writes.
1194 *===========================================================================*/
1195 
1196typedef struct {
1197 void *buf[2]; /* two buffers (from pool or malloc) */
1198 size_t len[2]; /* bytes stored in each buffer */
1199 int active; /* index currently being filled by recv */
1200 int fd; /* destination file descriptor */
1201 int error; /* writer error errno (0 = ok) */
1202 int done; /* set by recv thread on EOF/error */
1203 uint64_t written; /* total bytes flushed to disk */
1204 pthread_mutex_t mtx;
1205 pthread_cond_t cv_ready; /* writer waits: "data ready to write" */
1206 pthread_cond_t cv_free; /* recv waits: "buffer free to fill" */
1207} stor_pipe_t;
1208 
1209static void *stor_writer_thread(void *arg) {
1210 stor_pipe_t *p = (stor_pipe_t *)arg;
1211 
1212 pthread_mutex_lock(&p->mtx);
1213 for (;;) {
1214 /* Wait for data or done signal */
1215 while ((p->len[1 - p->active] == 0U) && (p->done == 0)) {
1216 pthread_cond_wait(&p->cv_ready, &p->mtx);
1217 }
1218 
1219 int drain = 1 - p->active;
1220 size_t nbytes = p->len[drain];
1221 
1222 if ((nbytes == 0U) && (p->done != 0)) {
1223 break; /* recv finished, nothing left to write */
1224 }
1225 
1226 /* Unlock while writing (slow PFS crypto path) */
1227 pthread_mutex_unlock(&p->mtx);
1228 
1229 ssize_t w = pal_file_write_all(p->fd, p->buf[drain], nbytes);
1230 int write_ok = (w == (ssize_t)nbytes) ? 1 : 0;
1231 
1232 pthread_mutex_lock(&p->mtx);
1233 if (write_ok != 0) {
1234 p->written += (uint64_t)nbytes;
1235 } else {
1236 p->error = errno;
1237 }
1238 p->len[drain] = 0U;
1239 
1240 /* Signal recv thread that buffer is free */
1241 pthread_cond_signal(&p->cv_free);
1242 
1243 if (write_ok == 0) {
1244 break; /* write error */
1245 }
1246 }
1247 pthread_mutex_unlock(&p->mtx);
1248 return NULL;
1249}
1250 
1251/**
1252 * @brief STOR command - Store (upload) file
1253 *
1254 * REST + STOR resume workflow
1255 * ~~~~~~~~~~~~~~~~~~~~~~~~~~~
1256 * Client: REST 52428800 <- set offset = 50 MB
1257 * Server: 350 Restart accepted
1258 * Client: STOR bigfile.pkg <- resumes here
1259 * Server: opens file WITHOUT O_TRUNC, lseek(offset)
1260 * receives remaining bytes and writes from offset
1261 *
1262 * If restart_offset == 0 the file is truncated as usual.
1263 */
1264ftp_error_t cmd_STOR(ftp_session_t *session, const char *args) {
1265 if ((session == NULL) || (args == NULL)) {
1266 return FTP_ERR_INVALID_PARAM;
1267 }
1268 
1269 /* Resolve path */
1270 char resolved[FTP_PATH_MAX];
1271 ftp_error_t err = ftp_path_resolve(session, args, resolved, sizeof(resolved));
1272 
1273 if (err != FTP_OK) {
1274 session->restart_offset = 0;
1275 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
1276 "Invalid path.");
1277 }
1278 
1279 /*
1280 * Atomic write strategy
1281 * ~~~~~~~~~~~~~~~~~~~~~
1282 * Fresh upload (offset == 0):
1283 * write to /path/.zftpd.tmp.FILENAME
1284 * rename() to final path on success
1285 * → external daemons (ShadowMount) never see a partial file
1286 *
1287 * Resume upload (offset > 0):
1288 * write directly to the original file (need to lseek)
1289 */
1290 char tmp_path[FTP_PATH_MAX];
1291 int was_fresh_upload = (session->restart_offset == 0) ? 1 : 0;
1292#if defined(PLATFORM_PS5) || defined(PLATFORM_PS4)
1293 /* PS4/PS5 /data/: no ShadowMount watching, skip atomic temp→rename overhead.
1294 * On PS4, PFS-encrypted writes through a temp file add ~40ms per 256 KB
1295 * chunk; the double-buffer producer stalls waiting for the writer, the TCP
1296 * recv buffer fills, and the FileZilla client times out after 20 s. */
1297 int use_atomic = 0;
1298#else
1299 int use_atomic = (session->restart_offset == 0) ? 1 : 0;
1300#endif
1301 
1302 if (use_atomic != 0) {
1303 /* Build temp name: /dir/.zftpd.tmp.basename */
1304 const char *slash = strrchr(resolved, '/');
1305 if (slash != NULL) {
1306 size_t dir_len = (size_t)(slash - resolved);
1307 snprintf(tmp_path, sizeof(tmp_path), "%.*s/.zftpd.tmp.%s", (int)dir_len,
1308 resolved, slash + 1);
1309 } else {
1310 snprintf(tmp_path, sizeof(tmp_path), ".zftpd.tmp.%s", resolved);
1311 }
1312 }
1313 
1314 const char *write_path = (use_atomic != 0) ? tmp_path : resolved;
1315 
1316 int open_flags = O_WRONLY | O_CREAT;
1317 if (session->restart_offset == 0) {
1318 open_flags |= O_TRUNC;
1319 }
1320 
1321 /*
1322 * Open the destination file BEFORE sending 150 or accepting the data
1323 * connection. This matches ftpsrv's proven ordering.
1324 *
1325 * On PS4/PS5, pal_file_open(O_CREAT|O_TRUNC) on a PFS-encrypted partition
1326 * (/data/pkg/) can block for several seconds while the filesystem allocates
1327 * and encrypts the inode. The critical insight is WHERE that latency is
1328 * hidden:
1329 *
1330 * Wrong order (150 → accept → open):
1331 * The client receives 150 and immediately connects / starts sending.
1332 * The server is still blocked in open(). The TCP receive buffer fills
1333 * in milliseconds (LAN speed >> PFS throughput), the window drops to 0,
1334 * the sender stalls, and FileZilla's 20-second DATA INACTIVITY timer
1335 * fires — even though 0 bytes were transferred.
1336 *
1337 * Correct order (open → 150 → accept):
1338 * The latency is absorbed while the client is waiting for the STOR
1339 * command response (FileZilla's COMMAND-RESPONSE timeout, 20 s).
1340 * When 150 finally arrives the server is already ready to call recv();
1341 * data starts flowing immediately and the inactivity timer never fires.
1342 */
1343#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5)
1344 int held_pfs_mtx = 0;
1345 if ((open_flags & O_CREAT) != 0) {
1346 if (pfs_mutex_lock_timeout(&g_pfs_create_mtx, 10) == 0) {
1347 held_pfs_mtx = 1;
1348 } else {
1349 session->restart_offset = 0;
1350 return ftp_session_send_reply(session, FTP_REPLY_451_LOCAL_ERROR,
1351 "Server busy, please retry.");
1352 }
1353 }
1354#endif
1355 int fd = pal_file_open(write_path, open_flags, FILE_PERM);
1356#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5)
1357 if (held_pfs_mtx != 0) {
1358 pthread_mutex_unlock(&g_pfs_create_mtx);
1359 }
1360#endif
1361 if (fd < 0) {
1362 if (use_atomic != 0) {
1363 (void)unlink(tmp_path);
1364 }
1365 session->restart_offset = 0;
1366 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
1367 "Cannot create file.");
1368 }
1369 
1370 /* Seek to restart offset for resume uploads */
1371 if (session->restart_offset > 0) {
1372 if (lseek(fd, session->restart_offset, SEEK_SET) < 0) {
1373 pal_file_close(fd);
1374 if (use_atomic != 0) {
1375 (void)unlink(tmp_path);
1376 } else if (was_fresh_upload != 0) {
1377 (void)unlink(write_path);
1378 }
1379 session->restart_offset = 0;
1380 return ftp_session_send_reply(session, FTP_REPLY_451_LOCAL_ERROR,
1381 "Seek failed.");
1382 }
1383 }
1384 
1385 ftp_session_send_reply(session, FTP_REPLY_150_FILE_OK, NULL);
1386 
1387 err = ftp_session_open_data_connection(session);
1388 if (err != FTP_OK) {
1389 pal_file_close(fd);
1390 if (use_atomic != 0) {
1391 (void)unlink(tmp_path);
1392 } else if (was_fresh_upload != 0) {
1393 (void)unlink(write_path);
1394 }
1395 session->restart_offset = 0;
1396 return ftp_session_send_reply(session, FTP_REPLY_425_CANT_OPEN_DATA, NULL);
1397 }
1398 
1399 /*=========================================================================*
1400 * Receive/write strategy: single-buffer on PS4/PS5, double-buffer elsewhere
1401 *
1402 * WHY DOUBLE-BUFFER FAILS ON PS4/PS5 (root-cause analysis)
1403 * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1404 * The double-buffer design requires that the TCP kernel receive buffer
1405 * (SO_RCVBUF) is large enough to absorb all incoming data during the
1406 * interval when both application buffers are full and recv() is not
1407 * being called (= the writer thread's PFS write latency per chunk).
1408 *
1409 * On OrbisOS (PS4/PS5), setsockopt(SO_RCVBUF) on an already-accepted
1410 * socket is silently capped to the system default (~256–512 KB on the
1411 * firmware versions tested), regardless of the requested value.
1412 * Setting it on the LISTENING socket before bind()/listen() should
1413 * propagate 4 MB via the 3-way handshake, but this is unreliable across
1414 * firmware versions — empirically the accepted socket often retains the
1415 * kernel default of ~256 KB.
1416 *
1417 * Result: with SO_RCVBUF ≈ 256 KB and two 256 KB app buffers:
1418 *
1419 * stall point = app_bufs + kernel_rcvbuf ≈ 512 KB + 256 KB ≈ 768 KB
1420 *
1421 * This matches the observed ~800 KB abort point precisely across all
1422 * tested firmware versions (1.0 MB → 800 KB → 818 KB — always sub-1 MB).
1423 *
1424 * After ~768 KB are received (in ~44 ms at 18 MB/s LAN), the TCP window
1425 * drops to zero. FileZilla sees no ACKs while the producer waits on
1426 * pthread_cond_wait. After 20 s of zero-window, FileZilla fires the
1427 * data-inactivity timeout and aborts the connection.
1428 *
1429 * WHY SINGLE-BUFFER WORKS (same path as cmd_APPE)
1430 * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1431 * In single-buffer mode, recv() is called immediately after every
1432 * write() returns. The TCP window reopens within one write cycle (~14 ms
1433 * at 18 MB/s). FileZilla's inactivity timer never accumulates.
1434 *
1435 * cmd_APPE uses single-buffer and consistently achieves 17+ MB/s on the
1436 * same PFS-encrypted /data/ partition, confirming that write latency is
1437 * not a bottleneck once the file is open — the constraint is entirely
1438 * the TCP zero-window imposed by the insufficient kernel recv buffer.
1439 *
1440 * PLATFORM DECISION
1441 * ~~~~~~~~~~~~~~~~~
1442 * PS4/PS5 : acquire only one buffer → fall through to single-buffer path.
1443 * SO_RCVBUF is unreliable; double-buffer causes zero-window
1444 * stalls that trigger FileZilla's 20 s data-inactivity timeout.
1445 * Other : acquire two buffers → double-buffer path.
1446 * SO_RCVBUF is fully controllable and the pipeline genuinely
1447 * improves throughput.
1448 *=========================================================================*/
1449 
1450#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5)
1451 /*
1452 * Force single-buffer on PS4/PS5: acquire only buf0 and leave buf1 = NULL.
1453 * The (buf0 == NULL || buf1 == NULL) branch below is the single-buffer path;
1454 * it handles NULL buf1 correctly (ftp_buffer_release(NULL) is a no-op).
1455 */
1456 void *buf0 = ftp_buffer_acquire();
1457 void *buf1 = NULL; /* intentionally NULL — forces single-buffer path */
1458#else
1459 void *buf0 = ftp_buffer_acquire();
1460 void *buf1 = ftp_buffer_acquire();
1461#endif
1462 size_t buf_sz = ftp_buffer_size();
1463 uint64_t total_received = 0U;
1464 int ok = 1;
1465 int fail_stage = 0; /* 1 = no buffer, 2 = recv error, 3 = write error */
1466 int saved_errno = 0;
1467 
1468 if ((buf0 == NULL) || (buf1 == NULL)) {
1469 /*
1470 * Fallback: if we can't get two buffers, use single-buffer mode.
1471 * This happens when the pool is exhausted under heavy load.
1472 */
1473 void *buffer = (buf0 != NULL) ? buf0 : buf1;
1474 ftp_buffer_release((buf0 != NULL) ? buf1 : buf0);
1475 
1476 while (1) {
1477 if (buffer == NULL) {
1478 fail_stage = 1;
1479 ok = 0;
1480 break;
1481 }
1482 ssize_t n = ftp_session_recv_data(session, buffer, buf_sz);
1483 if (n < 0) {
1484 if (errno == EINTR) {
1485 continue;
1486 }
1487 saved_errno = errno;
1488 fail_stage = 2;
1489 ok = 0;
1490 break;
1491 }
1492 if (n == 0) {
1493 break;
1494 }
1495 ssize_t written = pal_file_write_all(fd, buffer, (size_t)n);
1496 if (written != n) {
1497 saved_errno = errno;
1498 fail_stage = 3;
1499 ok = 0;
1500 break;
1501 }
1502 total_received += (uint64_t)n;
1503 session->last_activity = time(NULL);
1504 }
1505 
1506 ftp_buffer_release(buffer);
1507 } else {
1508 /*
1509 * Double-buffered path: spawn a writer thread.
1510 */
1511 stor_pipe_t pipe;
1512 pipe.buf[0] = buf0;
1513 pipe.buf[1] = buf1;
1514 pipe.len[0] = 0U;
1515 pipe.len[1] = 0U;
1516 pipe.active = 0;
1517 pipe.fd = fd;
1518 pipe.error = 0;
1519 pipe.done = 0;
1520 pipe.written = 0U;
1521 pthread_mutex_init(&pipe.mtx, NULL);
1522 pthread_cond_init(&pipe.cv_ready, NULL);
1523 pthread_cond_init(&pipe.cv_free, NULL);
1524 
1525 pthread_t writer;
1526 int thread_ok =
1527 (pthread_create(&writer, NULL, stor_writer_thread, &pipe) == 0) ? 1 : 0;
1528 if (thread_ok == 0) {
1529 /* Thread creation failed — fall back to single-buffer */
1530 while (1) {
1531 ssize_t n = ftp_session_recv_data(session, buf0, buf_sz);
1532 if (n < 0) {
1533 if (errno == EINTR) {
1534 continue;
1535 }
1536 saved_errno = errno;
1537 fail_stage = 2;
1538 ok = 0;
1539 break;
1540 }
1541 if (n == 0) {
1542 break;
1543 }
1544 ssize_t written = pal_file_write_all(fd, buf0, (size_t)n);
1545 if (written != n) {
1546 saved_errno = errno;
1547 fail_stage = 3;
1548 ok = 0;
1549 break;
1550 }
1551 total_received += (uint64_t)n;
1552 session->last_activity = time(NULL);
1553 }
1554 } else {
1555 /*
1556 * Producer loop: fill buf[active], then hand off to writer.
1557 */
1558 while (1) {
1559 pthread_mutex_lock(&pipe.mtx);
1560 
1561 /* Wait for our active buffer to be free */
1562 while (pipe.len[pipe.active] != 0U) {
1563 if (pipe.error != 0) {
1564 pthread_mutex_unlock(&pipe.mtx);
1565 goto recv_done;
1566 }
1567 pthread_cond_wait(&pipe.cv_free, &pipe.mtx);
1568 }
1569 
1570 /* Check if writer hit an error */
1571 if (pipe.error != 0) {
1572 pthread_mutex_unlock(&pipe.mtx);
1573 break;
1574 }
1575 
1576 int fill_idx = pipe.active;
1577 pthread_mutex_unlock(&pipe.mtx);
1578 
1579 /* Receive into the free buffer (slow, don't hold lock) */
1580 ssize_t n = ftp_session_recv_data(session, pipe.buf[fill_idx], buf_sz);
1581 if (n < 0) {
1582 if (errno == EINTR) {
1583 continue;
1584 }
1585 saved_errno = errno;
1586 fail_stage = 2;
1587 ok = 0;
1588 break;
1589 }
1590 if (n == 0) {
1591 break; /* EOF */
1592 }
1593 
1594 total_received += (uint64_t)n;
1595 session->last_activity = time(NULL);
1596 
1597 /* Hand the filled buffer to the writer */
1598 pthread_mutex_lock(&pipe.mtx);
1599 pipe.len[fill_idx] = (size_t)n;
1600 pipe.active = 1 - fill_idx; /* swap to other buffer */
1601 pthread_cond_signal(&pipe.cv_ready);
1602 pthread_mutex_unlock(&pipe.mtx);
1603 }
1604 
1605 recv_done:
1606 /* Signal writer that recv is done */
1607 pthread_mutex_lock(&pipe.mtx);
1608 pipe.done = 1;
1609 pthread_cond_signal(&pipe.cv_ready);
1610 pthread_mutex_unlock(&pipe.mtx);
1611 
1612 (void)pthread_join(writer, NULL);
1613 
1614 /* Collect writer result */
1615 if (pipe.error != 0) {
1616 saved_errno = pipe.error;
1617 fail_stage = 3;
1618 ok = 0;
1619 }
1620 total_received = pipe.written + (total_received - pipe.written);
1621 }
1622 
1623 pthread_mutex_destroy(&pipe.mtx);
1624 pthread_cond_destroy(&pipe.cv_ready);
1625 pthread_cond_destroy(&pipe.cv_free);
1626 ftp_buffer_release(buf0);
1627 ftp_buffer_release(buf1);
1628 }
1629 
1630 /*
1631 * Flush strategy — platform-specific
1632 *
1633 * OrbisOS: close() already flushes dirty pages to NVMe.
1634 * An explicit fsync() forces a full controller barrier
1635 * through PFS crypto — adds 5-15ms of pure overhead.
1636 *
1637 * Linux: fdatasync() flushes data only (skips metadata).
1638 *
1639 * Other: fsync() as safety default.
1640 */
1641#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5)
1642 /* OrbisOS: close() flushes; skip explicit sync */
1643 (void)0;
1644#elif defined(__linux__)
1645 if (ok != 0) {
1646 (void)fdatasync(fd);
1647 }
1648#else
1649 if (ok != 0) {
1650 (void)fsync(fd);
1651 }
1652#endif
1653 pal_file_close(fd);
1654 ftp_session_close_data_connection(session);
1655 session->restart_offset = 0;
1656 
1657 if (ok != 0) {
1658 /*
1659 * Atomic commit: rename temp → final
1660 *
1661 * rename() is atomic on POSIX: ShadowMount's stat() will
1662 * see either the old file or the new complete file, never
1663 * a half-written intermediate state.
1664 */
1665 if (use_atomic != 0) {
1666 if (rename(tmp_path, resolved) != 0) {
1667 (void)unlink(tmp_path);
1668 return ftp_session_send_reply(session, FTP_REPLY_451_LOCAL_ERROR,
1669 "Rename to final path failed.");
1670 }
1671 }
1672 
1673 atomic_fetch_add(&session->stats.files_received, 1U);
1674 ftp_log_session_event(session, "STOR_OK", FTP_OK, total_received);
1675 return ftp_session_send_reply(session, FTP_REPLY_226_TRANSFER_COMPLETE,
1676 NULL);
1677 }
1678 
1679 /* On failure, clean up partial file */
1680 if (use_atomic != 0) {
1681 (void)unlink(tmp_path);
1682 } else if (was_fresh_upload != 0) {
1683 /*
1684 * Non-atomic path (PS4/PS5): the file was opened with O_CREAT|O_TRUNC
1685 * directly on the destination. If the transfer failed, an empty or
1686 * partial file now sits on disk. Delete it so that a subsequent LIST
1687 * does not show a ghost file and cause the client to prompt for
1688 * overwrite (or silently skip the upload).
1689 *
1690 * Resume uploads (was_fresh_upload == 0) are intentionally left alone
1691 * so the client can attempt REST+STOR/APPE again.
1692 */
1693 (void)unlink(write_path);
1694 }
1695 
1696 ftp_log_session_event(session, "STOR_FAIL", FTP_ERR_UNKNOWN, total_received);
1697 
1698 char detail[128];
1699 if (fail_stage == 2) {
1700 snprintf(detail, sizeof(detail),
1701 "Transfer failed: network receive error (errno=%d).", saved_errno);
1702 } else if (fail_stage == 3) {
1703 snprintf(detail, sizeof(detail),
1704 "Transfer failed: disk write error (errno=%d).", saved_errno);
1705 } else {
1706 snprintf(detail, sizeof(detail), "Transfer failed.");
1707 }
1708 
1709 return ftp_session_send_reply(session, FTP_REPLY_426_TRANSFER_ABORTED,
1710 detail);
1711}
1712 
1713/**
1714 * @brief APPE command - Append to file
1715 *
1716 * REST + APPE resume workflow
1717 * ~~~~~~~~~~~~~~~~~~~~~~~~~~~
1718 * If restart_offset > 0, the file is opened for writing (not append)
1719 * and seeked to the offset. Otherwise it opens with O_APPEND.
1720 */
1721ftp_error_t cmd_APPE(ftp_session_t *session, const char *args) {
1722 if ((session == NULL) || (args == NULL)) {
1723 return FTP_ERR_INVALID_PARAM;
1724 }
1725 
1726 /* Resolve path */
1727 char resolved[FTP_PATH_MAX];
1728 ftp_error_t err = ftp_path_resolve(session, args, resolved, sizeof(resolved));
1729 
1730 if (err != FTP_OK) {
1731 session->restart_offset = 0;
1732 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
1733 "Invalid path.");
1734 }
1735 
1736 /*
1737 * Open flags depend on REST offset:
1738 *
1739 * offset == 0 -> O_WRONLY | O_CREAT | O_APPEND (true append)
1740 * offset > 0 -> O_WRONLY | O_CREAT (seek to offset)
1741 */
1742 int open_flags = O_WRONLY | O_CREAT;
1743 if (session->restart_offset == 0) {
1744 open_flags |= O_APPEND;
1745 }
1746 
1747 /* Open file BEFORE sending 150 — same rationale as cmd_STOR */
1748#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5)
1749 int held_pfs_mtx_appe = 0;
1750 if ((open_flags & O_CREAT) != 0) {
1751 if (pfs_mutex_lock_timeout(&g_pfs_create_mtx, 10) == 0) {
1752 held_pfs_mtx_appe = 1;
1753 } else {
1754 session->restart_offset = 0;
1755 return ftp_session_send_reply(session, FTP_REPLY_451_LOCAL_ERROR,
1756 "Server busy, please retry.");
1757 }
1758 }
1759#endif
1760 int fd = pal_file_open(resolved, open_flags, FILE_PERM);
1761#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5)
1762 if (held_pfs_mtx_appe != 0) {
1763 pthread_mutex_unlock(&g_pfs_create_mtx);
1764 }
1765#endif
1766 if (fd < 0) {
1767 session->restart_offset = 0;
1768 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
1769 "Cannot open file.");
1770 }
1771 
1772 /* Seek to restart offset when provided */
1773 if (session->restart_offset > 0) {
1774 if (lseek(fd, session->restart_offset, SEEK_SET) < 0) {
1775 pal_file_close(fd);
1776 session->restart_offset = 0;
1777 return ftp_session_send_reply(session, FTP_REPLY_451_LOCAL_ERROR,
1778 "Seek failed.");
1779 }
1780 }
1781 
1782 ftp_session_send_reply(session, FTP_REPLY_150_FILE_OK, NULL);
1783 
1784 err = ftp_session_open_data_connection(session);
1785 if (err != FTP_OK) {
1786 pal_file_close(fd);
1787 session->restart_offset = 0;
1788 return ftp_session_send_reply(session, FTP_REPLY_425_CANT_OPEN_DATA, NULL);
1789 }
1790 
1791 void *buffer = ftp_buffer_acquire();
1792 size_t buf_sz = ftp_buffer_size();
1793 uint64_t total_received = 0U;
1794 int ok = 1;
1795 int fail_stage = 0; /* 1 = no buffer, 2 = recv error, 3 = write error */
1796 int saved_errno = 0;
1797 
1798 while (1) {
1799 if (buffer == NULL) {
1800 fail_stage = 1;
1801 ok = 0;
1802 break;
1803 }
1804 ssize_t n = ftp_session_recv_data(session, buffer, buf_sz);
1805 
1806 if (n < 0) {
1807 if (errno == EINTR) {
1808 continue;
1809 }
1810 saved_errno = errno;
1811 fail_stage = 2;
1812 ok = 0;
1813 break;
1814 }
1815 if (n == 0) {
1816 break;
1817 }
1818 
1819 ssize_t written = pal_file_write_all(fd, buffer, (size_t)n);
1820 if (written != n) {
1821 saved_errno = errno;
1822 fail_stage = 3;
1823 ok = 0;
1824 break;
1825 }
1826 
1827 total_received += (uint64_t)n;
1828 session->last_activity = time(NULL);
1829 }
1830 
1831 /*
1832 * Flush strategy — see cmd_STOR comment for rationale.
1833 */
1834#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5)
1835 (void)0;
1836#elif defined(__linux__)
1837 if (ok != 0) {
1838 (void)fdatasync(fd);
1839 }
1840#else
1841 if (ok != 0) {
1842 (void)fsync(fd);
1843 }
1844#endif
1845 pal_file_close(fd);
1846 ftp_buffer_release(buffer);
1847 ftp_session_close_data_connection(session);
1848 session->restart_offset = 0;
1849 
1850 if (ok != 0) {
1851 ftp_log_session_event(session, "APPE_OK", FTP_OK, total_received);
1852 return ftp_session_send_reply(session, FTP_REPLY_226_TRANSFER_COMPLETE,
1853 NULL);
1854 }
1855 
1856 ftp_log_session_event(session, "APPE_FAIL", FTP_ERR_UNKNOWN, total_received);
1857 
1858 char detail[128];
1859 if (fail_stage == 2) {
1860 snprintf(detail, sizeof(detail),
1861 "Transfer failed: network receive error (errno=%d).", saved_errno);
1862 } else if (fail_stage == 3) {
1863 snprintf(detail, sizeof(detail),
1864 "Transfer failed: disk write error (errno=%d).", saved_errno);
1865 } else {
1866 snprintf(detail, sizeof(detail), "Transfer failed.");
1867 }
1868 
1869 return ftp_session_send_reply(session, FTP_REPLY_426_TRANSFER_ABORTED,
1870 detail);
1871}
1872 
1873/**
1874 * @brief REST command - Set restart offset
1875 */
1876ftp_error_t cmd_REST(ftp_session_t *session, const char *args) {
1877 if ((session == NULL) || (args == NULL)) {
1878 return FTP_ERR_INVALID_PARAM;
1879 }
1880 
1881 /* Parse offset */
1882 char *endptr;
1883 long long offset = strtoll(args, &endptr, 10);
1884 
1885 if ((*endptr != '\0') || (offset < 0)) {
1886 return ftp_session_send_reply(session, FTP_REPLY_501_SYNTAX_ARGS,
1887 "Invalid offset.");
1888 }
1889 
1890 session->restart_offset = (off_t)offset;
1891 
1892 return ftp_session_send_reply(session, FTP_REPLY_350_PENDING,
1893 "Restart position accepted.");
1894}
1895 
1896/*===========================================================================*
1897 * FILE MANAGEMENT
1898 *===========================================================================*/
1899 
1900/**
1901 * @brief DELE command - Delete file
1902 */
1903ftp_error_t cmd_DELE(ftp_session_t *session, const char *args) {
1904 if ((session == NULL) || (args == NULL)) {
1905 return FTP_ERR_INVALID_PARAM;
1906 }
1907 
1908 char resolved[FTP_PATH_MAX];
1909 ftp_error_t err = ftp_path_resolve(session, args, resolved, sizeof(resolved));
1910 
1911 if (err != FTP_OK) {
1912 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
1913 "Invalid path.");
1914 }
1915 
1916 err = pal_file_delete(resolved);
1917 
1918 if (err != FTP_OK) {
1919 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
1920 "Cannot delete file.");
1921 }
1922 
1923 return ftp_session_send_reply(session, FTP_REPLY_250_FILE_ACTION_OK,
1924 "File deleted.");
1925}
1926 
1927/**
1928 * @brief RMD command - Remove directory
1929 */
1930ftp_error_t cmd_RMD(ftp_session_t *session, const char *args) {
1931 if ((session == NULL) || (args == NULL)) {
1932 return FTP_ERR_INVALID_PARAM;
1933 }
1934 
1935 char resolved[FTP_PATH_MAX];
1936 ftp_error_t err = ftp_path_resolve(session, args, resolved, sizeof(resolved));
1937 
1938 if (err != FTP_OK) {
1939 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
1940 "Invalid path.");
1941 }
1942 
1943 err = pal_dir_remove(resolved);
1944 
1945 if (err != FTP_OK) {
1946 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
1947 "Cannot remove directory.");
1948 }
1949 
1950 return ftp_session_send_reply(session, FTP_REPLY_250_FILE_ACTION_OK,
1951 "Directory removed.");
1952}
1953 
1954/**
1955 * @brief MKD command - Make directory
1956 */
1957ftp_error_t cmd_MKD(ftp_session_t *session, const char *args) {
1958 if ((session == NULL) || (args == NULL)) {
1959 return FTP_ERR_INVALID_PARAM;
1960 }
1961 
1962 char resolved[FTP_PATH_MAX];
1963 ftp_error_t err = ftp_path_resolve(session, args, resolved, sizeof(resolved));
1964 
1965 if (err != FTP_OK) {
1966 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
1967 "Invalid path.");
1968 }
1969 
1970 err = pal_dir_create(resolved, DIR_PERM);
1971 
1972 if (err != FTP_OK) {
1973 if ((err == FTP_ERR_DIR_EXISTS) && (pal_path_is_directory(resolved) == 1)) {
1974 /*
1975 * +---------------------------------------------------------+
1976 * | CONCURRENCY HANDLING |
1977 * | Directory was just created by another active thread. |
1978 * | We treat this EEXIST as a success to prevent FileZilla |
1979 * | from aborting the entire directory tree upload. |
1980 * +---------------------------------------------------------+
1981 */
1982 } else {
1983 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
1984 "Cannot create directory.");
1985 }
1986 }
1987 
1988 char reply[FTP_REPLY_BUFFER_SIZE];
1989 int n = snprintf(reply, sizeof(reply), "\"%s\" created.", resolved);
1990 
1991 /* VULN-05 fix: check for truncation (same as cmd_PWD) */
1992 if ((n < 0) || ((size_t)n >= sizeof(reply))) {
1993 return ftp_session_send_reply(session, FTP_REPLY_257_PATH_CREATED,
1994 "Directory created.");
1995 }
1996 
1997 return ftp_session_send_reply(session, FTP_REPLY_257_PATH_CREATED, reply);
1998}
1999 
2000/**
2001 * @brief RNFR command - Rename from
2002 */
2003ftp_error_t cmd_RNFR(ftp_session_t *session, const char *args) {
2004 if ((session == NULL) || (args == NULL)) {
2005 return FTP_ERR_INVALID_PARAM;
2006 }
2007 
2008 char resolved[FTP_PATH_MAX];
2009 ftp_error_t err = ftp_path_resolve(session, args, resolved, sizeof(resolved));
2010 
2011 if (err != FTP_OK) {
2012 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
2013 "Invalid path.");
2014 }
2015 
2016 /* Check if file exists */
2017 if (pal_path_exists(resolved) != 1) {
2018 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
2019 "File not found.");
2020 }
2021 
2022 /* Store source path */
2023 size_t len = strlen(resolved);
2024 if (len >= sizeof(session->rename_from)) {
2025 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
2026 "Path too long.");
2027 }
2028 
2029 memcpy(session->rename_from, resolved, len + 1U);
2030 
2031 return ftp_session_send_reply(session, FTP_REPLY_350_PENDING,
2032 "Ready for RNTO.");
2033}
2034 
2035/**
2036 * @brief RNTO command - Rename to
2037 */
2038ftp_error_t cmd_RNTO(ftp_session_t *session, const char *args) {
2039 if ((session == NULL) || (args == NULL)) {
2040 return FTP_ERR_INVALID_PARAM;
2041 }
2042 
2043 /* Check if RNFR was called */
2044 if (session->rename_from[0] == '\0') {
2045 return ftp_session_send_reply(session, FTP_REPLY_503_BAD_SEQUENCE,
2046 "RNFR required first.");
2047 }
2048 
2049 char resolved[FTP_PATH_MAX];
2050 ftp_error_t err = ftp_path_resolve(session, args, resolved, sizeof(resolved));
2051 
2052 if (err != FTP_OK) {
2053 session->rename_from[0] = '\0';
2054 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
2055 "Invalid path.");
2056 }
2057 
2058 /* Perform rename */
2059 err = pal_file_rename(session->rename_from, resolved);
2060 
2061 if (err == FTP_ERR_CROSS_DEVICE) {
2062 ftp_error_t async_err =
2063 start_async_copy(session, session->rename_from, args, 1);
2064 /* Clear rename_from state */
2065 session->rename_from[0] = '\0';
2066 return async_err;
2067 }
2068 
2069 /* Clear rename_from */
2070 session->rename_from[0] = '\0';
2071 
2072 if (err != FTP_OK) {
2073 /*
2074 * Map ftp_error_t to a human-readable detail so the FTP client
2075 * (FileZilla, WinSCP, etc.) shows something actionable instead
2076 * of the opaque "Rename failed.".
2077 *
2078 * 550 Permission denied.
2079 * 550 Source not found.
2080 * 550 Path too long.
2081 * ...
2082 */
2083 const char *detail;
2084 switch (err) {
2085 case FTP_ERR_NOT_FOUND:
2086 detail = "Source not found.";
2087 break;
2088 case FTP_ERR_PERMISSION:
2089 detail = "Permission denied.";
2090 break;
2091 case FTP_ERR_PATH_TOO_LONG:
2092 detail = "Path too long.";
2093 break;
2094 case FTP_ERR_OUT_OF_MEMORY:
2095 detail = "Out of memory.";
2096 break;
2097 case FTP_ERR_DIR_OPEN:
2098 detail = "Cannot open directory.";
2099 break;
2100 case FTP_ERR_FILE_OPEN:
2101 detail = "Cannot open file.";
2102 break;
2103 case FTP_ERR_FILE_READ:
2104 detail = "Read error during copy.";
2105 break;
2106 default: {
2107 static _Thread_local char buf[64];
2108 snprintf(buf, sizeof(buf), "Rename failed (err=%d).", (int)err);
2109 detail = buf;
2110 break;
2111 }
2112 }
2113 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR, detail);
2114 }
2115 
2116 return ftp_session_send_reply(session, FTP_REPLY_250_FILE_ACTION_OK,
2117 "File renamed.");
2118}
2119 
2120/*===========================================================================*
2121 * DATA CONNECTION
2122 *===========================================================================*/
2123 
2124/**
2125 * @brief PORT command - Active mode data connection
2126 */
2127ftp_error_t cmd_PORT(ftp_session_t *session, const char *args) {
2128 if ((session == NULL) || (args == NULL)) {
2129 return FTP_ERR_INVALID_PARAM;
2130 }
2131 
2132 /* Parse h1,h2,h3,h4,p1,p2 */
2133 unsigned int h1, h2, h3, h4, p1, p2;
2134 
2135 if (sscanf(args, "%u,%u,%u,%u,%u,%u", &h1, &h2, &h3, &h4, &p1, &p2) != 6) {
2136 return ftp_session_send_reply(session, FTP_REPLY_501_SYNTAX_ARGS,
2137 "Invalid PORT format.");
2138 }
2139 
2140 /* Validate ranges */
2141 if ((h1 > 255U) || (h2 > 255U) || (h3 > 255U) || (h4 > 255U) || (p1 > 255U) ||
2142 (p2 > 255U)) {
2143 return ftp_session_send_reply(session, FTP_REPLY_501_SYNTAX_ARGS,
2144 "Invalid PORT values.");
2145 }
2146 
2147 /* Build IP address */
2148 char ip[INET_ADDRSTRLEN];
2149 snprintf(ip, sizeof(ip), "%u.%u.%u.%u", h1, h2, h3, h4);
2150 
2151 /* Build port */
2152 uint16_t port = (uint16_t)((p1 << 8) | p2);
2153 
2154 /* Debug: log PORT target */
2155 {
2156 char dbg[128];
2157 snprintf(dbg, sizeof(dbg), "[DBG] PORT target: %s:%u", ip, (unsigned)port);
2158 ftp_log_line(FTP_LOG_INFO, dbg);
2159 }
2160 /* Create sockaddr */
2161 ftp_error_t err = pal_make_sockaddr(ip, port, &session->data_addr);
2162 if (err != FTP_OK) {
2163 return ftp_session_send_reply(session, FTP_REPLY_501_SYNTAX_ARGS,
2164 "Invalid address.");
2165 }
2166 
2167 /*
2168 * VULN-04 fix: validate PORT IP against control connection
2169 *
2170 * RFC 2577 (FTP Security Considerations) recommends that
2171 * servers verify the PORT IP matches the client's control
2172 * connection IP to prevent SSRF (bounce attacks).
2173 *
2174 * Compile with -DFTP_PORT_ALLOW_FOREIGN_IP=1 to disable
2175 * this check for NAT environments (Android emulators).
2176 *
2177 * control IP: session->client_ip (e.g. "192.168.1.50")
2178 * PORT IP: ip (e.g. "192.168.1.1")
2179 * mismatch -> 501 rejected
2180 */
2181#ifndef FTP_PORT_ALLOW_FOREIGN_IP
2182#define FTP_PORT_ALLOW_FOREIGN_IP 0
2183#endif
2184 
2185#if !FTP_PORT_ALLOW_FOREIGN_IP
2186 if (strcmp(ip, session->client_ip) != 0) {
2187 char dbg[128];
2188 snprintf(dbg, sizeof(dbg),
2189 "[SEC] PORT IP mismatch: client=%s port=%s (rejected)",
2190 session->client_ip, ip);
2191 ftp_log_line(FTP_LOG_INFO, dbg);
2192 return ftp_session_send_reply(session, FTP_REPLY_501_SYNTAX_ARGS,
2193 "PORT address mismatch.");
2194 }
2195#endif
2196 
2197 /* Set mode to active */
2198 session->data_mode = FTP_DATA_MODE_ACTIVE;
2199 
2200 return ftp_session_send_reply(session, FTP_REPLY_200_OK,
2201 "PORT command successful.");
2202}
2203 
2204/**
2205 * @brief PASV command - Passive mode data connection
2206 */
2207ftp_error_t cmd_PASV(ftp_session_t *session, const char *args) {
2208 (void)args; /* Unused */
2209 
2210 if (session == NULL) {
2211 return FTP_ERR_INVALID_PARAM;
2212 }
2213 
2214 if (session->pasv_fd >= 0) {
2215 PAL_CLOSE(session->pasv_fd);
2216 session->pasv_fd = -1;
2217 }
2218 
2219 /* Create passive listener socket */
2220 int fd = PAL_SOCKET(AF_INET, SOCK_STREAM, 0);
2221 if (fd < 0) {
2222 return ftp_session_send_reply(session, FTP_REPLY_425_CANT_OPEN_DATA,
2223 "Cannot create socket.");
2224 }
2225 
2226 /* Enable address reuse */
2227 (void)pal_socket_set_reuseaddr(fd);
2228 
2229 /*
2230 * Set SO_RCVBUF on the LISTENING socket BEFORE bind/listen.
2231 *
2232 * On FreeBSD/PS4/PS5 the kernel copies the listening socket's receive
2233 * buffer size into each accepted connection during the 3-way handshake.
2234 * Setting SO_RCVBUF on the accepted socket after accept() is too late:
2235 * the kernel caps post-connect increases to kern.ipc.maxsockbuf (~1 MB
2236 * on OrbisOS), which is why STOR transfers stall after exactly 1 MB.
2237 * Setting it here propagates the full 4 MB to every accepted data socket.
2238 */
2239 {
2240 int rcvbuf = (int)FTP_TCP_RCVBUF;
2241 (void)PAL_SETSOCKOPT(fd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf));
2242 }
2243 
2244 /*
2245 * SO_SNDBUF is intentionally NOT set on the listening socket.
2246 *
2247 * DESIGN RATIONALE — auto-tuning vs. explicit SNDBUF:
2248 *
2249 * On both Linux (tcp_wmem) and FreeBSD (net.inet.tcp.sendbuf_auto),
2250 * calling setsockopt(SO_SNDBUF) explicitly on any socket — even
2251 * pre-bind — marks that socket as "manually sized" and DISABLES
2252 * the kernel's TCP send-buffer auto-tuning for it.
2253 *
2254 * The HTTP server never sets SO_SNDBUF and relies on auto-tuning;
2255 * it achieves full link speed at any internet RTT because the kernel
2256 * grows the buffer to exactly BDP = RTT × bandwidth.
2257 *
2258 * A previous version of this code set SO_SNDBUF = FTP_TCP_DATA_SNDBUF
2259 * (4 MB) here, hoping to bypass kern.ipc.maxsockbuf via the 3-way
2260 * handshake inheritance trick. On OrbisOS the kernel still capped the
2261 * effective buffer (≈ 512 KB–1 MB) and, critically, disabled
2262 * auto-tuning — leaving FTP stuck at ≈ 30 Mbps while HTTP with
2263 * auto-tuning reached 80 Mbps on the same link.
2264 *
2265 * SO_RCVBUF (STOR/uploads) cannot use auto-tuning because the kernel
2266 * does not auto-grow the receive buffer on FreeBSD; the explicit value
2267 * is required there to prevent zero-window stalls. The send buffer
2268 * (RETR/downloads) has no such constraint — leave it for auto-tuning.
2269 */
2270 
2271 /* Resolve the local IP from the control connection so the PASV
2272 * listener is bound to the specific interface — not INADDR_ANY.
2273 * On OrbisOS/FreeBSD (PS4/PS5), binding to INADDR_ANY causes
2274 * inbound SYNs to be silently dropped when the kernel routes the
2275 * incoming connection via a specific interface that doesn't match
2276 * the wildcard binding, even though the 227 reply advertises the
2277 * correct IP. Binding to the exact local address fixes this. */
2278 uint32_t ip = 0U;
2279 {
2280 struct sockaddr_in local;
2281 socklen_t local_len = (socklen_t)sizeof(local);
2282 memset(&local, 0, sizeof(local));
2283 if (PAL_GETSOCKNAME(session->ctrl_fd, (struct sockaddr *)&local,
2284 &local_len) == 0) {
2285 ip = PAL_NTOHL(local.sin_addr.s_addr);
2286 }
2287 }
2288 if (ip == 0U) {
2289 char ip_str[INET_ADDRSTRLEN];
2290 if (pal_network_get_primary_ip(ip_str, sizeof(ip_str)) == FTP_OK) {
2291 struct in_addr ia;
2292 if (PAL_INET_PTON(AF_INET, ip_str, &ia) == 1) {
2293 ip = PAL_NTOHL(ia.s_addr);
2294 }
2295 }
2296 }
2297 
2298 /* Bind to the resolved local IP, ephemeral port (port 0 = auto-assign) */
2299 struct sockaddr_in addr;
2300 memset(&addr, 0, sizeof(addr));
2301 addr.sin_family = AF_INET;
2302 addr.sin_addr.s_addr = (ip != 0U) ? PAL_HTONL(ip) : PAL_HTONL(INADDR_ANY);
2303 addr.sin_port = 0; /* Auto-assign port */
2304 
2305 if (PAL_BIND(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
2306 PAL_CLOSE(fd);
2307 return ftp_session_send_reply(session, FTP_REPLY_425_CANT_OPEN_DATA,
2308 "Bind failed.");
2309 }
2310 
2311 /* Listen */
2312 if (PAL_LISTEN(fd, 1) < 0) {
2313 PAL_CLOSE(fd);
2314 return ftp_session_send_reply(session, FTP_REPLY_425_CANT_OPEN_DATA,
2315 "Listen failed.");
2316 }
2317 
2318 /* Get assigned port */
2319 struct sockaddr_in pasv_addr;
2320 socklen_t addr_len = sizeof(pasv_addr);
2321 if (PAL_GETSOCKNAME(fd, (struct sockaddr *)&pasv_addr, &addr_len) < 0) {
2322 PAL_CLOSE(fd);
2323 return ftp_session_send_reply(session, FTP_REPLY_425_CANT_OPEN_DATA,
2324 "Cannot get socket name.");
2325 }
2326 
2327 session->pasv_fd = fd;
2328 session->data_mode = FTP_DATA_MODE_PASSIVE;
2329 
2330 /* Format reply: 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2) */
2331 if (ip == 0U) {
2332 ip = PAL_NTOHL(pasv_addr.sin_addr.s_addr);
2333 }
2334 uint16_t port = PAL_NTOHS(pasv_addr.sin_port);
2335 
2336 unsigned int h1 = (ip >> 24) & 0xFFU;
2337 unsigned int h2 = (ip >> 16) & 0xFFU;
2338 unsigned int h3 = (ip >> 8) & 0xFFU;
2339 unsigned int h4 = ip & 0xFFU;
2340 unsigned int p1 = (port >> 8) & 0xFFU;
2341 unsigned int p2 = port & 0xFFU;
2342 
2343 char reply[FTP_REPLY_BUFFER_SIZE];
2344 snprintf(reply, sizeof(reply), "Entering Passive Mode (%u,%u,%u,%u,%u,%u).",
2345 h1, h2, h3, h4, p1, p2);
2346 
2347 return ftp_session_send_reply(session, FTP_REPLY_227_PASV_MODE, reply);
2348}
2349 
2350/*---------------------------------------------------------------------------*
2351 * EPSV (RFC 2428 — Extended Passive Mode)
2352 *
2353 * Client: EPSV
2354 * Server: 229 Entering Extended Passive Mode (|||port|)
2355 *
2356 * WinSCP and many IPv6-aware clients try EPSV first.
2357 * Without it they fall back to PORT which often fails behind NAT.
2358 *---------------------------------------------------------------------------*/
2359 
2360ftp_error_t cmd_EPSV(ftp_session_t *session, const char *args) {
2361 (void)args;
2362 
2363 if (session == NULL) {
2364 return FTP_ERR_INVALID_PARAM;
2365 }
2366 
2367 /* Reuse PASV socket setup */
2368 if (session->pasv_fd >= 0) {
2369 PAL_CLOSE(session->pasv_fd);
2370 session->pasv_fd = -1;
2371 }
2372 
2373 int fd = PAL_SOCKET(AF_INET, SOCK_STREAM, 0);
2374 if (fd < 0) {
2375 return ftp_session_send_reply(session, FTP_REPLY_425_CANT_OPEN_DATA,
2376 "Cannot create socket.");
2377 }
2378 
2379 (void)pal_socket_set_reuseaddr(fd);
2380 
2381 /* Set SO_RCVBUF on the listener — same rationale as cmd_PASV */
2382 {
2383 int rcvbuf = (int)FTP_TCP_RCVBUF;
2384 (void)PAL_SETSOCKOPT(fd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf));
2385 }
2386 
2387 /* SO_SNDBUF intentionally NOT set — see cmd_PASV comment on auto-tuning. */
2388 
2389 /* Bind to the local IP of the control connection — not INADDR_ANY.
2390 * Same rationale as cmd_PASV: on OrbisOS/FreeBSD, INADDR_ANY causes
2391 * inbound SYNs to be silently dropped. */
2392 uint32_t epsv_ip = 0U;
2393 {
2394 struct sockaddr_in local;
2395 socklen_t local_len = (socklen_t)sizeof(local);
2396 memset(&local, 0, sizeof(local));
2397 if (PAL_GETSOCKNAME(session->ctrl_fd, (struct sockaddr *)&local,
2398 &local_len) == 0) {
2399 epsv_ip = PAL_NTOHL(local.sin_addr.s_addr);
2400 }
2401 }
2402 if (epsv_ip == 0U) {
2403 char ip_str[INET_ADDRSTRLEN];
2404 if (pal_network_get_primary_ip(ip_str, sizeof(ip_str)) == FTP_OK) {
2405 struct in_addr ia;
2406 if (PAL_INET_PTON(AF_INET, ip_str, &ia) == 1) {
2407 epsv_ip = PAL_NTOHL(ia.s_addr);
2408 }
2409 }
2410 }
2411 
2412 struct sockaddr_in addr;
2413 memset(&addr, 0, sizeof(addr));
2414 addr.sin_family = AF_INET;
2415 addr.sin_addr.s_addr = (epsv_ip != 0U) ? PAL_HTONL(epsv_ip) : PAL_HTONL(INADDR_ANY);
2416 addr.sin_port = 0;
2417 
2418 if (PAL_BIND(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
2419 PAL_CLOSE(fd);
2420 return ftp_session_send_reply(session, FTP_REPLY_425_CANT_OPEN_DATA,
2421 "Bind failed.");
2422 }
2423 
2424 if (PAL_LISTEN(fd, 1) < 0) {
2425 PAL_CLOSE(fd);
2426 return ftp_session_send_reply(session, FTP_REPLY_425_CANT_OPEN_DATA,
2427 "Listen failed.");
2428 }
2429 
2430 struct sockaddr_in pasv_addr;
2431 socklen_t addr_len = sizeof(pasv_addr);
2432 if (PAL_GETSOCKNAME(fd, (struct sockaddr *)&pasv_addr, &addr_len) < 0) {
2433 PAL_CLOSE(fd);
2434 return ftp_session_send_reply(session, FTP_REPLY_425_CANT_OPEN_DATA,
2435 "Cannot get socket name.");
2436 }
2437 
2438 session->pasv_fd = fd;
2439 session->data_mode = FTP_DATA_MODE_PASSIVE;
2440 
2441 /*
2442 * RFC 2428: 229 Entering Extended Passive Mode (|||port|)
2443 *
2444 * The triple-pipe delimiter is protocol-agnostic (works for IPv4 + IPv6).
2445 * The client already knows the server IP from the control connection.
2446 */
2447 uint16_t port = PAL_NTOHS(pasv_addr.sin_port);
2448 char reply[FTP_REPLY_BUFFER_SIZE];
2449 snprintf(reply, sizeof(reply), "Entering Extended Passive Mode (|||%u|).",
2450 (unsigned)port);
2451 
2452 return ftp_session_send_reply(session, FTP_REPLY_229_EPSV_MODE, reply);
2453}
2454 
2455/*---------------------------------------------------------------------------*
2456 * OPTS (RFC 2389 — Feature Negotiation)
2457 *
2458 * Client: OPTS UTF8 ON
2459 * Server: 200 UTF8 mode enabled.
2460 *
2461 * Almost every modern client sends "OPTS UTF8 ON" right after FEAT.
2462 * Without this command, they get 500 Unknown Command → may disconnect.
2463 *---------------------------------------------------------------------------*/
2464 
2465ftp_error_t cmd_OPTS(ftp_session_t *session, const char *args) {
2466 if (session == NULL) {
2467 return FTP_ERR_INVALID_PARAM;
2468 }
2469 
2470 if (args == NULL) {
2471 return ftp_session_send_reply(session, FTP_REPLY_501_SYNTAX_ARGS,
2472 "OPTS requires an argument.");
2473 }
2474 
2475 /* Case-insensitive check for "UTF8 ON" / "UTF8" / "utf8 on" */
2476 char upper[64];
2477 size_t len = strlen(args);
2478 if (len >= sizeof(upper)) {
2479 len = sizeof(upper) - 1U;
2480 }
2481 for (size_t i = 0U; i < len; i++) {
2482 upper[i] = (char)toupper((unsigned char)args[i]);
2483 }
2484 upper[len] = '\0';
2485 
2486 if ((strncmp(upper, "UTF8", 4) == 0) &&
2487 (len == 4U || strcmp(upper + 4, " ON") == 0)) {
2488 return ftp_session_send_reply(session, FTP_REPLY_200_OK,
2489 "UTF8 mode enabled.");
2490 }
2491 
2492 /*
2493 * OPTS MLST type*;size*;modify*;
2494 * Some clients send this to negotiate MLST facts.
2495 * Accept it silently.
2496 */
2497 if (strncmp(upper, "MLST", 4) == 0) {
2498 return ftp_session_send_reply(session, FTP_REPLY_200_OK, "MLST OPTS set.");
2499 }
2500 
2501 return ftp_session_send_reply(session, FTP_REPLY_501_SYNTAX_ARGS,
2502 "Option not recognized.");
2503}
2504 
2505/*---------------------------------------------------------------------------*
2506 * SITE (RFC 959 — Site-Specific Commands)
2507 *
2508 * Client: SITE CHMOD 755 somefile.txt
2509 * Server: 200 CHMOD ok. (no-op on consoles)
2510 *
2511 * WinSCP sends SITE CHMOD after every upload. Without this
2512 * command the client logs errors and some abort the transfer.
2513 *---------------------------------------------------------------------------*/
2514 
2515ftp_error_t cmd_SITE(ftp_session_t *session, const char *args) {
2516 if (session == NULL) {
2517 return FTP_ERR_INVALID_PARAM;
2518 }
2519 
2520 if (args == NULL || args[0] == '\0') {
2521 return ftp_session_send_reply(session, FTP_REPLY_501_SYNTAX_ARGS,
2522 "SITE requires a command.");
2523 }
2524 
2525 /* Accept CHMOD as a no-op (console filesystems don't use UNIX perms) */
2526 char upper[16];
2527 size_t len = strlen(args);
2528 if (len > 5U) {
2529 len = 5U;
2530 }
2531 for (size_t i = 0U; i < len; i++) {
2532 upper[i] = (char)toupper((unsigned char)args[i]);
2533 }
2534 upper[len] = '\0';
2535 
2536 if (strncmp(upper, "CHMOD", 5) == 0) {
2537 return ftp_session_send_reply(session, FTP_REPLY_200_OK,
2538 "CHMOD command successful.");
2539 }
2540 
2541 return ftp_session_send_reply(session, FTP_REPLY_502_NOT_IMPLEMENTED,
2542 "SITE command not supported.");
2543}
2544 
2545/*---------------------------------------------------------------------------*
2546 * CLNT (Client Identification)
2547 *
2548 * Client: CLNT SuperFTP/1.0
2549 * Server: 200 Noted.
2550 *
2551 * Android apps (File Manager+, SuperFTP) send CLNT to identify
2552 * themselves before USER/PASS. Without it they get 500 Unknown
2553 * Command and disconnect immediately.
2554 *---------------------------------------------------------------------------*/
2555 
2556ftp_error_t cmd_CLNT(ftp_session_t *session, const char *args) {
2557 (void)args;
2558 
2559 if (session == NULL) {
2560 return FTP_ERR_INVALID_PARAM;
2561 }
2562 
2563 return ftp_session_send_reply(session, FTP_REPLY_200_OK, "Noted.");
2564}
2565 
2566/*===========================================================================*
2567 * INFORMATION
2568 *===========================================================================*/
2569 
2570/**
2571 * @brief SIZE command - Return file size
2572 */
2573ftp_error_t cmd_SIZE(ftp_session_t *session, const char *args) {
2574 if ((session == NULL) || (args == NULL)) {
2575 return FTP_ERR_INVALID_PARAM;
2576 }
2577 
2578 char resolved[FTP_PATH_MAX];
2579 ftp_error_t err = ftp_path_resolve(session, args, resolved, sizeof(resolved));
2580 
2581 if (err != FTP_OK) {
2582 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
2583 "Invalid path.");
2584 }
2585 
2586 vfs_stat_t st;
2587 err = vfs_stat(resolved, &st);
2588 
2589 if (err != FTP_OK) {
2590 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
2591 "File not found.");
2592 }
2593 
2594 char reply[64];
2595 snprintf(reply, sizeof(reply), "%llu", (unsigned long long)st.size);
2596 
2597 return ftp_session_send_reply(session, FTP_REPLY_213_FILE_STATUS, reply);
2598}
2599 
2600/**
2601 * @brief MDTM command - Return modification time
2602 */
2603ftp_error_t cmd_MDTM(ftp_session_t *session, const char *args) {
2604 if ((session == NULL) || (args == NULL)) {
2605 return FTP_ERR_INVALID_PARAM;
2606 }
2607 
2608 char resolved[FTP_PATH_MAX];
2609 ftp_error_t err = ftp_path_resolve(session, args, resolved, sizeof(resolved));
2610 
2611 if (err != FTP_OK) {
2612 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
2613 "Invalid path.");
2614 }
2615 
2616 struct stat st;
2617 err = pal_file_stat(resolved, &st);
2618 
2619 if (err != FTP_OK) {
2620 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
2621 "File not found.");
2622 }
2623 
2624 /* Format: YYYYMMDDhhmmss */
2625 struct tm tm_time;
2626 gmtime_r(&st.st_mtime, &tm_time);
2627 
2628 char reply[32];
2629 snprintf(reply, sizeof(reply), "%04d%02d%02d%02d%02d%02d",
2630 tm_time.tm_year + 1900, tm_time.tm_mon + 1, tm_time.tm_mday,
2631 tm_time.tm_hour, tm_time.tm_min, tm_time.tm_sec);
2632 
2633 return ftp_session_send_reply(session, FTP_REPLY_213_FILE_STATUS, reply);
2634}
2635 
2636/**
2637 * @brief STAT command - Status
2638 */
2639ftp_error_t cmd_STAT(ftp_session_t *session, const char *args) {
2640 (void)args;
2641 
2642 if (session == NULL) {
2643 return FTP_ERR_INVALID_PARAM;
2644 }
2645 
2646 /* Simple status reply */
2647 return ftp_session_send_reply(session, FTP_REPLY_211_SYSTEM_STATUS,
2648 "Server status OK.");
2649}
2650 
2651/**
2652 * @brief SYST command - System type
2653 */
2654ftp_error_t cmd_SYST(ftp_session_t *session, const char *args) {
2655 (void)args;
2656 
2657 if (session == NULL) {
2658 return FTP_ERR_INVALID_PARAM;
2659 }
2660 
2661 return ftp_session_send_reply(session, FTP_REPLY_215_SYSTEM_TYPE, NULL);
2662}
2663 
2664/**
2665 * @brief FEAT command - Feature list
2666 */
2667ftp_error_t cmd_FEAT(ftp_session_t *session, const char *args) {
2668 (void)args;
2669 
2670 if (session == NULL) {
2671 return FTP_ERR_INVALID_PARAM;
2672 }
2673 
2674 /*
2675 * FEAT reply (RFC 2389)
2676 *
2677 * 211-Extensions supported:
2678 * SIZE
2679 * MDTM
2680 * REST STREAM
2681 * APPE
2682 * UTF8
2683 * 211 End
2684 */
2685 const char *features[] = {"Extensions supported:",
2686#if FTP_ENABLE_SIZE
2687 " SIZE",
2688#endif
2689#if FTP_ENABLE_MDTM
2690 " MDTM",
2691#endif
2692#if FTP_ENABLE_REST
2693 " REST STREAM",
2694#endif
2695 " APPE",
2696 " EPSV",
2697#if FTP_ENABLE_UTF8
2698 " UTF8",
2699#endif
2700#if FTP_ENABLE_MLST
2701 " MLSD",
2702 " MLST type*;size*;modify*;unix.mode*;",
2703#endif
2704#if FTP_ENABLE_CRYPTO
2705 " XCRYPT",
2706#endif
2707 " CPFR",
2708 " CPTO",
2709 " COPY",
2710 "End"};
2711 
2712 return ftp_session_send_multiline_reply(
2713 session, FTP_REPLY_211_SYSTEM_STATUS, features,
2714 sizeof(features) / sizeof(features[0]));
2715}
2716 
2717/**
2718 * @brief HELP command - Help information
2719 */
2720ftp_error_t cmd_HELP(ftp_session_t *session, const char *args) {
2721 (void)args;
2722 
2723 if (session == NULL) {
2724 return FTP_ERR_INVALID_PARAM;
2725 }
2726 
2727 const char *lines[] = {"Supported commands:",
2728 " USER PASS QUIT NOOP CWD CDUP PWD",
2729 " LIST NLST MLSD MLST",
2730 " RETR STOR APPE REST",
2731 " DELE RMD MKD RNFR RNTO",
2732 " PORT PASV SIZE MDTM STAT",
2733 " SYST FEAT HELP TYPE MODE STRU",
2734 "End"};
2735 
2736 return ftp_session_send_multiline_reply(session, FTP_REPLY_214_HELP, lines,
2737 sizeof(lines) / sizeof(lines[0]));
2738}
2739 
2740/*===========================================================================*
2741 * ASYNC BACKGROUND COPY
2742 *===========================================================================*/
2743 
2744typedef struct {
2745 ftp_session_t *session;
2746 char src_path[FTP_PATH_MAX];
2747 char dst_path[FTP_PATH_MAX];
2748 int is_move;
2749} ftp_copy_task_t;
2750 
2751static void *ftp_copy_thread_func(void *arg) {
2752 ftp_copy_task_t *task = (ftp_copy_task_t *)arg;
2753 ftp_session_t *session = task->session;
2754 
2755 ftp_log_line(FTP_LOG_INFO, "[COPY] Background task started");
2756 
2757 /* Use pal_file_copy_recursive_ex so the OS errno is captured and visible
2758 * in the failure log. Previously pal_file_copy_recursive was called with
2759 * no errno output, causing all error logs to show errno=0. */
2760 int copy_errno = 0;
2761 ftp_error_t err = pal_file_copy_recursive_ex(task->src_path, task->dst_path,
2762 !task->is_move, NULL, NULL,
2763 &copy_errno);
2764 
2765 pthread_mutex_lock(&session->copy_mutex);
2766 session->copy_in_progress = 0;
2767 pthread_mutex_unlock(&session->copy_mutex);
2768 
2769 if (err == FTP_OK) {
2770 ftp_log_line(FTP_LOG_INFO, "[COPY] Background task completed successfully");
2771 } else {
2772 char msg[256];
2773 snprintf(msg, sizeof(msg), "[COPY] Background task failed: err=%d errno=%d",
2774 (int)err, copy_errno);
2775 ftp_log_line(FTP_LOG_WARN, msg);
2776 }
2777 
2778 free(task);
2779 return NULL;
2780}
2781 
2782static ftp_error_t start_async_copy(ftp_session_t *session,
2783 const char *src_ftp_path,
2784 const char *dst_ftp_path, int is_move) {
2785 pthread_mutex_lock(&session->copy_mutex);
2786 if (session->copy_in_progress) {
2787 pthread_mutex_unlock(&session->copy_mutex);
2788 return ftp_session_send_reply(session, FTP_REPLY_450_FILE_UNAVAILABLE,
2789 "Operation already in progress.");
2790 }
2791 
2792 /* Validate paths */
2793 char src_resolved[FTP_PATH_MAX];
2794 char dst_resolved[FTP_PATH_MAX];
2795 if (ftp_path_resolve(session, src_ftp_path, src_resolved,
2796 sizeof(src_resolved)) != FTP_OK ||
2797 ftp_path_resolve(session, dst_ftp_path, dst_resolved,
2798 sizeof(dst_resolved)) != FTP_OK) {
2799 pthread_mutex_unlock(&session->copy_mutex);
2800 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
2801 "Invalid path.");
2802 }
2803 
2804 if (strcmp(src_resolved, dst_resolved) == 0) {
2805 pthread_mutex_unlock(&session->copy_mutex);
2806 return ftp_session_send_reply(session, FTP_REPLY_553_FILENAME_INVALID,
2807 "Source and destination are the same.");
2808 }
2809 
2810 ftp_copy_task_t *task = malloc(sizeof(ftp_copy_task_t));
2811 if (task == NULL) {
2812 pthread_mutex_unlock(&session->copy_mutex);
2813 return ftp_session_send_reply(session, FTP_REPLY_451_LOCAL_ERROR,
2814 "Memory allocation failed.");
2815 }
2816 
2817 task->session = session;
2818 strncpy(task->src_path, src_resolved, sizeof(task->src_path) - 1);
2819 task->src_path[sizeof(task->src_path) - 1] = '\0';
2820 strncpy(task->dst_path, dst_resolved, sizeof(task->dst_path) - 1);
2821 task->dst_path[sizeof(task->dst_path) - 1] = '\0';
2822 task->is_move = is_move;
2823 
2824 session->copy_in_progress = 1;
2825 
2826 if (session->copy_thread_valid) {
2827 pthread_join(session->copy_thread, NULL);
2828 }
2829 
2830 if (pthread_create(&session->copy_thread, NULL, ftp_copy_thread_func, task) !=
2831 0) {
2832 session->copy_in_progress = 0;
2833 session->copy_thread_valid = 0;
2834 pthread_mutex_unlock(&session->copy_mutex);
2835 free(task);
2836 return ftp_session_send_reply(session, FTP_REPLY_451_LOCAL_ERROR,
2837 "Failed to create background thread.");
2838 }
2839 
2840 session->copy_thread_valid = 1;
2841 pthread_mutex_unlock(&session->copy_mutex);
2842 
2843 return ftp_session_send_reply(session, FTP_REPLY_250_FILE_ACTION_OK,
2844 is_move ? "Move started in background."
2845 : "Copy started in background.");
2846}
2847 
2848ftp_error_t cmd_CPFR(ftp_session_t *session, const char *args) {
2849 if ((session == NULL) || (args == NULL)) {
2850 return FTP_ERR_INVALID_PARAM;
2851 }
2852 
2853 if (args[0] == '\0') {
2854 return ftp_session_send_reply(session, FTP_REPLY_501_SYNTAX_ARGS,
2855 "Syntax: CPFR <path>");
2856 }
2857 
2858 char resolved[FTP_PATH_MAX];
2859 ftp_error_t err = ftp_path_resolve(session, args, resolved, sizeof(resolved));
2860 
2861 if (err != FTP_OK) {
2862 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
2863 "Invalid source path.");
2864 }
2865 
2866 if (!pal_path_exists(resolved)) {
2867 return ftp_session_send_reply(session, FTP_REPLY_550_FILE_ERROR,
2868 "Source does not exist.");
2869 }
2870 
2871 strncpy(session->copy_from, args, sizeof(session->copy_from) - 1);
2872 session->copy_from[sizeof(session->copy_from) - 1] = '\0';
2873 
2874 return ftp_session_send_reply(session, FTP_REPLY_350_PENDING,
2875 "File exists, ready for destination name.");
2876}
2877 
2878ftp_error_t cmd_CPTO(ftp_session_t *session, const char *args) {
2879 if ((session == NULL) || (args == NULL)) {
2880 return FTP_ERR_INVALID_PARAM;
2881 }
2882 
2883 if (args[0] == '\0') {
2884 return ftp_session_send_reply(session, FTP_REPLY_501_SYNTAX_ARGS,
2885 "Syntax: CPTO <path>");
2886 }
2887 
2888 if (session->copy_from[0] == '\0') {
2889 return ftp_session_send_reply(session, FTP_REPLY_503_BAD_SEQUENCE,
2890 "Bad sequence of commands (use CPFR first).");
2891 }
2892 
2893 ftp_error_t result = start_async_copy(session, session->copy_from, args, 0);
2894 
2895 /* Clear the copy_from state regardless of success to prevent reuse */
2896 session->copy_from[0] = '\0';
2897 
2898 return result;
2899}
2900 
2901ftp_error_t cmd_COPY(ftp_session_t *session, const char *args) {
2902 if ((session == NULL) || (args == NULL)) {
2903 return FTP_ERR_INVALID_PARAM;
2904 }
2905 
2906 char src_arg[FTP_PATH_MAX];
2907 char dst_arg[FTP_PATH_MAX];
2908 
2909 const char *space = strchr(args, ' ');
2910 if (space == NULL) {
2911 return ftp_session_send_reply(session, FTP_REPLY_501_SYNTAX_ARGS,
2912 "Syntax: COPY <src> <dst>");
2913 }
2914 
2915 size_t src_len = (size_t)(space - args);
2916 if (src_len >= sizeof(src_arg)) {
2917 return ftp_session_send_reply(session, FTP_REPLY_501_SYNTAX_ARGS,
2918 "Paths too long.");
2919 }
2920 
2921 strncpy(src_arg, args, src_len);
2922 src_arg[src_len] = '\0';
2923 
2924 const char *dst_start = space + 1;
2925 while (*dst_start == ' ')
2926 dst_start++; /* skip extra spaces */
2927 
2928 if (*dst_start == '\0') {
2929 return ftp_session_send_reply(session, FTP_REPLY_501_SYNTAX_ARGS,
2930 "Syntax: COPY <src> <dst>");
2931 }
2932 
2933 strncpy(dst_arg, dst_start, sizeof(dst_arg) - 1);
2934 dst_arg[sizeof(dst_arg) - 1] = '\0';
2935 
2936 return start_async_copy(session, src_arg, dst_arg, 0);
2937}
2938 
2939/*===========================================================================*
2940 * TRANSFER PARAMETERS
2941 *===========================================================================*/
2942 
2943/**
2944 * @brief TYPE command - Set transfer type
2945 */
2946ftp_error_t cmd_TYPE(ftp_session_t *session, const char *args) {
2947 if ((session == NULL) || (args == NULL)) {
2948 return FTP_ERR_INVALID_PARAM;
2949 }
2950 
2951 if ((args[0] == 'A') || (args[0] == 'a')) {
2952 session->transfer_type = FTP_TYPE_ASCII;
2953 } else if ((args[0] == 'I') || (args[0] == 'i')) {
2954 session->transfer_type = FTP_TYPE_BINARY;
2955 } else {
2956 return ftp_session_send_reply(session, FTP_REPLY_504_NOT_IMPL_PARAM,
2957 "Type not supported.");
2958 }
2959 
2960 return ftp_session_send_reply(session, FTP_REPLY_200_OK, "Type set.");
2961}
2962 
2963/**
2964 * @brief MODE command - Set transfer mode
2965 */
2966ftp_error_t cmd_MODE(ftp_session_t *session, const char *args) {
2967 if ((session == NULL) || (args == NULL)) {
2968 return FTP_ERR_INVALID_PARAM;
2969 }
2970 
2971 if ((args[0] == 'S') || (args[0] == 's')) {
2972 session->transfer_mode = FTP_MODE_STREAM;
2973 return ftp_session_send_reply(session, FTP_REPLY_200_OK,
2974 "Mode set to Stream.");
2975 }
2976 
2977 return ftp_session_send_reply(session, FTP_REPLY_504_NOT_IMPL_PARAM,
2978 "Only Stream mode supported.");
2979}
2980 
2981/**
2982 * @brief STRU command - Set file structure
2983 */
2984ftp_error_t cmd_STRU(ftp_session_t *session, const char *args) {
2985 if ((session == NULL) || (args == NULL)) {
2986 return FTP_ERR_INVALID_PARAM;
2987 }
2988 
2989 if ((args[0] == 'F') || (args[0] == 'f')) {
2990 session->file_structure = FTP_STRU_FILE;
2991 return ftp_session_send_reply(session, FTP_REPLY_200_OK,
2992 "Structure set to File.");
2993 }
2994 
2995 return ftp_session_send_reply(session, FTP_REPLY_504_NOT_IMPL_PARAM,
2996 "Only File structure supported.");
2997}
2998 
2999/*===========================================================================*
3000 * ENCRYPTION (ChaCha20)
3001 *
3002 * AUTH XCRYPT handshake:
3003 *
3004 * Client Server
3005 * ────── ──────
3006 * AUTH XCRYPT ──────────────────►
3007 * ◄────────────────── 234 XCRYPT <24-hex-nonce>
3008 *
3009 * Both sides derive:
3010 * session_key = ChaCha20_KDF(PSK, nonce)
3011 *
3012 * All subsequent traffic is XORed with ChaCha20 keystream.
3013 *
3014 *===========================================================================*/
3015 
3016#if FTP_ENABLE_CRYPTO
3017 
3018/**
3019 * @brief Convert nibble (0-15) to hex character
3020 */
3021static char nibble_to_hex(uint8_t n) {
3022 return (n < 10U) ? (char)('0' + n) : (char)('a' + (n - 10U));
3023}
3024 
3025/**
3026 * @brief Generate cryptographic random nonce from /dev/urandom or fallback
3027 */
3028static int generate_nonce(uint8_t *buf, size_t len) {
3029 /*
3030 * /dev/urandom is available on Linux, macOS.
3031 * Falls back to time-based PRNG if unavailable.
3032 */
3033 int fd = pal_file_open("/dev/urandom", O_RDONLY, 0);
3034 if (fd >= 0) {
3035 ssize_t n = pal_file_read(fd, buf, len);
3036 pal_file_close(fd);
3037 if (n == (ssize_t)len) {
3038 return 0;
3039 }
3040 }
3041 
3042 /* Fallback: time-based seed (weaker but functional) */
3043 struct timespec ts;
3044 clock_gettime(CLOCK_MONOTONIC, &ts);
3045 uint64_t seed = ((uint64_t)ts.tv_sec * 1000000000ULL) + (uint64_t)ts.tv_nsec;
3046 for (size_t i = 0U; i < len; i++) {
3047 seed = (seed * 6364136223846793005ULL) + 1442695040888963407ULL;
3048 buf[i] = (uint8_t)(seed >> 33U);
3049 }
3050 return 0;
3051}
3052 
3053ftp_error_t cmd_AUTH(ftp_session_t *session, const char *args) {
3054 if ((session == NULL) || (args == NULL)) {
3055 return FTP_ERR_INVALID_PARAM;
3056 }
3057 
3058 /* Only XCRYPT mechanism is supported */
3059 if ((strcmp(args, "XCRYPT") != 0) && (strcmp(args, "xcrypt") != 0)) {
3060 return ftp_session_send_reply(session, FTP_REPLY_504_NOT_IMPL_PARAM,
3061 "Unsupported AUTH mechanism.");
3062 }
3063 
3064 /* Already encrypted? */
3065 if (session->crypto.active != 0U) {
3066 return ftp_session_send_reply(session, FTP_REPLY_503_BAD_SEQUENCE,
3067 "Already encrypted.");
3068 }
3069 
3070 /* Generate 12-byte random nonce */
3071 uint8_t nonce[12];
3072 (void)generate_nonce(nonce, sizeof(nonce));
3073 
3074 /* Derive session key from PSK + nonce */
3075 static const uint8_t psk[32] = FTP_CRYPTO_PSK;
3076 uint8_t session_key[32];
3077 ftp_crypto_derive_key(psk, nonce, session_key);
3078 
3079 /* Format nonce as hex string for reply */
3080 char hex_nonce[25]; /* 24 hex chars + NUL */
3081 for (size_t i = 0U; i < 12U; i++) {
3082 hex_nonce[i * 2U] = nibble_to_hex((nonce[i] >> 4U) & 0x0FU);
3083 hex_nonce[(i * 2U) + 1U] = nibble_to_hex(nonce[i] & 0x0FU);
3084 }
3085 hex_nonce[24] = '\0';
3086 
3087 /* Reply: 234 XCRYPT <nonce-hex> */
3088 char reply_msg[64];
3089 (void)snprintf(reply_msg, sizeof(reply_msg), "XCRYPT %s", hex_nonce);
3090 ftp_error_t err =
3091 ftp_session_send_reply(session, FTP_REPLY_234_AUTH_OK, reply_msg);
3092 
3093 if (err == FTP_OK) {
3094 /* Activate encryption on this session */
3095 ftp_crypto_init(&session->crypto, session_key, nonce);
3096 ftp_log_session_event(session, "CRYPTO_ON", FTP_OK, 0U);
3097 }
3098 
3099 /* Scrub key material from stack */
3100 volatile uint8_t *vk = (volatile uint8_t *)session_key;
3101 for (size_t i = 0U; i < sizeof(session_key); i++) {
3102 vk[i] = 0U;
3103 }
3104 
3105 return err;
3106}
3107 
3108#endif /* FTP_ENABLE_CRYPTO */
3109