diff --git a/Makefile b/Makefile index a9158e9..ec43479 100644 --- a/Makefile +++ b/Makefile @@ -208,7 +208,11 @@ LIBS := $(PLATFORM_LIBS) # Source files SOURCES := src/platform/pal_network.c -SOURCES += src/platform/pal_fileio.c +SOURCES += src/platform/fileio/basic.c +SOURCES += src/platform/fileio/sendfile.c +SOURCES += src/platform/fileio/copy.c +SOURCES += src/platform/fileio/tree.c +SOURCES += src/platform/fileio/directory.c SOURCES += src/platform/pal_alloc.c SOURCES += src/platform/pal_scratch.c SOURCES += src/platform/pal_notification.c @@ -815,6 +819,8 @@ TEST_BINS += $(BUILD_DIR)/tests/test_instance TEST_BINS += $(BUILD_DIR)/tests/test_chmod TEST_BINS += $(BUILD_DIR)/tests/test_copy_atomic TEST_BINS += $(BUILD_DIR)/tests/test_ftp_commands +TEST_BINS += $(BUILD_DIR)/tests/test_fileio_tree +TEST_BINS += $(BUILD_DIR)/tests/test_fileio_basic ifeq ($(ENABLE_ZHTTPD),1) TEST_BINS += $(BUILD_DIR)/tests/test_transfer TEST_BINS += $(BUILD_DIR)/tests/test_http_api_common diff --git a/include/ftp/ftp_types.h b/include/ftp/ftp_types.h index f8e375c..a8aaf1b 100644 --- a/include/ftp/ftp_types.h +++ b/include/ftp/ftp_types.h @@ -78,6 +78,7 @@ typedef enum { FTP_ERR_PROTOCOL = -22, /**< Protocol violation */ FTP_ERR_DIR_EXISTS = -23, /**< Directory already exists */ FTP_ERR_CROSS_DEVICE = -24, /**< Cross-device link (EXDEV) */ + FTP_ERR_CANCELLED = -25, /**< Operation cancelled by caller */ FTP_ERR_UNKNOWN = -99, /**< Unknown error */ } ftp_error_t; diff --git a/include/platform/pal_fileio.h b/include/platform/pal_fileio.h index 71ebca2..b5751ca 100644 --- a/include/platform/pal_fileio.h +++ b/include/platform/pal_fileio.h @@ -24,15 +24,7 @@ SOFTWARE. /** * @file pal_fileio.h - * @brief Platform-agnostic file I/O with zero-copy support - * - * @author SeregonWar - * @version 1.0.0 - * @date 2026-02-13 - * - * OPTIMIZATION: Zero-copy file transfer via sendfile() where supported - * FALLBACK: Buffered read/write for platforms without sendfile() - * + * @brief Platform-agnostic file I/O and zero-copy transfer primitives. */ #ifndef PAL_FILEIO_H @@ -61,6 +53,9 @@ SOFTWARE. #if defined(__linux__) #define HAS_SENDFILE 1 #include +#elif defined(__APPLE__) && defined(__MACH__) +#define HAS_SENDFILE 1 +#include #elif defined(__FreeBSD__) || defined(PLATFORM_PS4) || defined(PLATFORM_PS5) #define HAS_SENDFILE 1 #include @@ -68,45 +63,12 @@ SOFTWARE. #define HAS_SENDFILE 0 #endif -/*===========================================================================* - * ZERO-COPY FILE TRANSFER - *===========================================================================*/ - /** - * @brief Send file data via socket (zero-copy where supported) - * - * OPTIMIZATION: Uses sendfile() syscall when available for zero-copy - * kernel-to-socket transfer without userspace buffering. - * - * PERFORMANCE: - * - Zero-copy: ~950 MB/s on PS4, ~118 MB/s on PS5 (network-limited) - * - Buffered: ~300-400 MB/s (userspace copy overhead) - * - * @param sock_fd Socket file descriptor (destination) - * @param file_fd File descriptor (source) - * @param offset Starting offset in file (updated on return) - * @param count Number of bytes to send - * - * @return Number of bytes sent on success, negative error code on failure - * @retval >0 Number of bytes successfully sent - * @retval 0 End of file reached - * @retval -1 I/O error (check errno) - * - * @pre sock_fd >= 0 (valid socket) - * @pre file_fd >= 0 (valid file) - * @pre offset != NULL - * @pre *offset >= 0 - * @pre count > 0 - * - * @post *offset updated by number of bytes sent - * - * @note Thread-safety: Safe if file descriptors not shared - * @note WCET: Unbounded (depends on network/disk I/O) - * - * @warning Non-blocking sockets may return partial writes (EAGAIN) - * @warning Caller must handle partial transfers in loop + * Send up to count bytes starting at *offset and advance the offset by bytes sent. + * Callers must handle partial transfers and EAGAIN. */ ssize_t pal_sendfile(int sock_fd, int file_fd, off_t *offset, size_t count); +int pal_file_error_is_fatal(int error); /*===========================================================================* * FILE OPERATIONS @@ -287,17 +249,7 @@ ftp_error_t pal_file_rename(const char *old_path, const char *new_path); ftp_error_t pal_file_copy_recursive(const char *src, const char *dst, int keep_src); -/*===========================================================================* - * COPY PROGRESS CALLBACK - * - * Invoked after each read/write chunk during file copy. - * - * bytes_copied : cumulative bytes written so far (across all files) - * user_data : opaque pointer passed by the caller - * - * Return: 0 = continue - * -1 = cancel (copy will abort and return FTP_ERR_CANCELLED) - *===========================================================================*/ +/* Progress callbacks receive cumulative bytes; a negative return cancels the copy. */ typedef int (*pal_copy_progress_cb_t)(uint64_t bytes_copied, void *user_data); diff --git a/src/ftp/commands/transfer.c b/src/ftp/commands/transfer.c index 082a97c..341c1a4 100644 --- a/src/ftp/commands/transfer.c +++ b/src/ftp/commands/transfer.c @@ -49,17 +49,6 @@ SOFTWARE. extern int _fstatfs(int, struct statfs *); #endif -/* Fallback: pal_fileio.h may be suppressed by a transitive include guard */ -#ifndef PAL_FILE_WRITE_CHUNK_MAX -# if defined(PLATFORM_PS5) || defined(PS5) -# define PAL_FILE_WRITE_CHUNK_MAX 131072U /* 128 KB */ -# elif defined(PLATFORM_PS4) || defined(PS4) -# define PAL_FILE_WRITE_CHUNK_MAX 65536U /* 64 KB */ -# else -# define PAL_FILE_WRITE_CHUNK_MAX 262144U /* 256 KB */ -# endif -#endif - /* PFS serializes inode creation on PS4/PS5. Serialize only O_CREAT opens * in userspace to avoid multi-session journal contention and client timeouts. */ #if defined(PLATFORM_PS4) || defined(PLATFORM_PS5) @@ -367,8 +356,7 @@ ftp_error_t cmd_RETR(ftp_session_t *session, const char *args) { } /* Retrying a bad vnode can panic PS4/PS5, so storage faults are terminal. */ - if ((sent < 0) && ((errno == EIO) || (errno == ESTALE) || - (errno == EBADF) || (errno == EFAULT))) { + if ((sent < 0) && pal_file_error_is_fatal(errno)) { remaining = 1U; break; } @@ -403,8 +391,7 @@ ftp_error_t cmd_RETR(ftp_session_t *session, const char *args) { if ((r_sent < 0) && (errno == EINTR)) { r--; /* don't count EINTR as a retry */ } - if ((r_sent < 0) && ((errno == EIO) || (errno == ESTALE) || - (errno == EBADF) || (errno == EFAULT))) { + if ((r_sent < 0) && pal_file_error_is_fatal(errno)) { break; } } diff --git a/src/platform/fileio/basic.c b/src/platform/fileio/basic.c new file mode 100644 index 0000000..ce8e627 --- /dev/null +++ b/src/platform/fileio/basic.c @@ -0,0 +1,264 @@ +/* +MIT License + +Copyright (c) 2026 Seregon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +/** @file basic.c @brief Basic file descriptor and metadata operations. */ +#include "fileio_internal.h" +#include +#include +#include +#include +#include + +#if defined(PLATFORM_PS5) || defined(PS5) +#define PAL_FILE_WRITE_CHUNK_MAX (1024U * 1024U) +#elif defined(PLATFORM_PS4) || defined(PS4) +#define PAL_FILE_WRITE_CHUNK_MAX (64U * 1024U) +#else +#define PAL_FILE_WRITE_CHUNK_MAX (256U * 1024U) +#endif + +#if defined(PLATFORM_PS4) || defined(PS4) +#define PS4_SYS_FTRUNCATE 480 +#endif + +int pal_file_error_is_fatal(int error) { + return error == EIO || error == ESTALE || error == EBADF || error == EFAULT; +} + +ftp_error_t fileio_error_from_errno(int error, ftp_error_t fallback) { + switch (error) { + case ENOENT: + return FTP_ERR_NOT_FOUND; + case EACCES: + case EPERM: + return FTP_ERR_PERMISSION; + case ENAMETOOLONG: + return FTP_ERR_PATH_TOO_LONG; + case ENOMEM: + case EMFILE: + case ENFILE: + return FTP_ERR_OUT_OF_MEMORY; + default: + return fallback; + } +} + +int pal_file_open(const char *path, int flags, mode_t mode) { + if (path == NULL) { + errno = EINVAL; + return FTP_ERR_INVALID_PARAM; + } + + size_t len = strlen(path); + if (len >= FTP_PATH_MAX) { + errno = ENAMETOOLONG; + return FTP_ERR_PATH_TOO_LONG; + } + + int fd = open(path, flags, mode); + if (fd < 0) return fileio_error_from_errno(errno, FTP_ERR_FILE_OPEN); + +#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5) + /* PS5 keeps read pages pager-backed for sendfile; PS4 uses F_NOCACHE only on reads. */ +#if defined(PLATFORM_PS4) +#ifdef F_NOCACHE + if ((flags & O_WRONLY) == 0 && (flags & O_RDWR) == 0) { + (void)fcntl(fd, F_NOCACHE, 1); + } +#endif +#endif /* PLATFORM_PS4 only — explicitly excluded from PS5 */ + +#if defined(PLATFORM_PS5) && defined(POSIX_FADV_SEQUENTIAL) + if ((flags & O_WRONLY) == 0 && (flags & O_RDWR) == 0) + (void)posix_fadvise(fd, 0, 0, POSIX_FADV_SEQUENTIAL); +#endif +#endif + + return fd; +} + +/* FreeBSD/OrbisOS leaves an fd open after close(EINTR); Linux does not. */ +ftp_error_t pal_file_close(int fd) { + if (fd < 0) { + return FTP_ERR_INVALID_PARAM; + } + +#if defined(__FreeBSD__) || defined(PLATFORM_PS4) || defined(PLATFORM_PS5) + { + unsigned retries = 0U; + const unsigned MAX_CLOSE_RETRIES = 8U; + while (close(fd) < 0) { + if (errno != EINTR) { + return FTP_ERR_FILE_WRITE; + } + if (++retries >= MAX_CLOSE_RETRIES) { + return FTP_ERR_FILE_WRITE; + } + } + } +#else + if (close(fd) < 0) { + if (errno != EINTR) { + return FTP_ERR_FILE_WRITE; + } + } +#endif + + return FTP_OK; +} + +ftp_error_t pal_file_stat(const char *path, struct stat *st) { + if ((path == NULL) || (st == NULL)) { + return FTP_ERR_INVALID_PARAM; + } + + if (stat(path, st) < 0) return fileio_error_from_errno(errno, FTP_ERR_FILE_STAT); + + return FTP_OK; +} + +ftp_error_t pal_file_fstat(int fd, struct stat *st) { + if ((fd < 0) || (st == NULL)) { + return FTP_ERR_INVALID_PARAM; + } + + if (fstat(fd, st) < 0) { + return FTP_ERR_FILE_STAT; + } + + return FTP_OK; +} + +ssize_t pal_file_read(int fd, void *buffer, size_t count) { + if (fd < 0 || (buffer == NULL && count != 0U)) { + errno = EINVAL; + return -1; + } + if (count == 0U) return 0; + return read(fd, buffer, count); +} + +ssize_t pal_file_write(int fd, const void *buffer, size_t count) { + if (fd < 0 || (buffer == NULL && count != 0U)) { + errno = EINVAL; + return -1; + } + if (count == 0U) return 0; + return write(fd, buffer, count); +} + +ssize_t pal_file_write_all(int fd, const void *buffer, size_t count) { + if (fd < 0 || (buffer == NULL && count != 0U)) { + errno = EINVAL; + return -1; + } + if (count == 0U) return 0; + + const uint8_t *p = (const uint8_t *)buffer; + size_t total = 0U; + + while (total < count) { + size_t remaining = count - total; + size_t chunk = remaining; + if (chunk > (size_t)PAL_FILE_WRITE_CHUNK_MAX) { + chunk = (size_t)PAL_FILE_WRITE_CHUNK_MAX; + } + ssize_t n = write(fd, p + total, chunk); + if (n > 0) { + total += (size_t)n; + continue; + } + if (n == 0) { + /* Some PFS builds report a full filesystem as write()==0. */ + errno = ENOSPC; + return -1; + } + if (errno == EINTR) { + continue; + } + return -1; + } + + return (ssize_t)total; +} + +off_t pal_file_seek(int fd, off_t offset, int whence) { + if (fd < 0) { + errno = EINVAL; + return -1; + } + + return lseek(fd, offset, whence); +} + +ftp_error_t pal_file_truncate(int fd, off_t len) { + if ((fd < 0) || (len < 0)) { + return FTP_ERR_INVALID_PARAM; + } + +#ifdef PLATFORM_PS4 + if (syscall(PS4_SYS_FTRUNCATE, fd, len) < 0) { + return FTP_ERR_FILE_WRITE; + } +#else + if (ftruncate(fd, len) < 0) { + return FTP_ERR_FILE_WRITE; + } +#endif + + return FTP_OK; +} + +ftp_error_t pal_file_delete(const char *path) { + if (path == NULL) { + return FTP_ERR_INVALID_PARAM; + } + + if (unlink(path) < 0) { + if (errno == EISDIR) return FTP_ERR_INVALID_PARAM; + return fileio_error_from_errno(errno, FTP_ERR_FILE_WRITE); + } + + return FTP_OK; +} + +ftp_error_t pal_file_chmod(const char *path, mode_t mode) { + if (path == NULL) { + return FTP_ERR_INVALID_PARAM; + } + + if (chmod(path, mode) < 0) { + int error = errno; + if (error == EINVAL +#if defined(EOPNOTSUPP) + || error == EOPNOTSUPP +#elif defined(ENOTSUP) + || error == ENOTSUP +#endif + ) return FTP_ERR_PERMISSION; + return fileio_error_from_errno(error, FTP_ERR_FILE_WRITE); + } + + return FTP_OK; +} diff --git a/src/platform/fileio/copy.c b/src/platform/fileio/copy.c new file mode 100644 index 0000000..0e32980 --- /dev/null +++ b/src/platform/fileio/copy.c @@ -0,0 +1,565 @@ +/* +MIT License + +Copyright (c) 2026 Seregon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +/** @file copy.c @brief Atomic single-file copy pipeline. */ + +#include "pal_fileio.h" +#include "fileio_internal.h" +#include "ftp_log.h" +#include +#include +#include +#include +#include +#include +#include +#include + +/* File-to-file copies use large writes; network write chunk limits do not apply. */ +#ifndef PAL_FILE_COPY_BUFFER_SIZE +#if defined(PLATFORM_PS5) +#define PAL_FILE_COPY_BUFFER_SIZE \ + (4U * 1024U * \ + 1024U) /* 4 MB — NVMe ~215 MB/s, T_write=19ms covers T_read=12ms */ +#elif defined(PLATFORM_PS4) +#define PAL_FILE_COPY_BUFFER_SIZE \ + (1024U * 1024U) /* 1 MB — HDD ~85 MB/s, T_write=12ms covers T_read=3ms */ +#else +#define PAL_FILE_COPY_BUFFER_SIZE (4U * 1024U * 1024U) /* 4 MB */ +#endif +#endif + +/* Large copies overlap source reads and destination writes. */ +#include + +typedef struct { + uint8_t *buf[2]; /* two PAL_FILE_COPY_BUFFER_SIZE buffers */ + size_t len[2]; /* bytes filled in each buffer (0 = free) */ + int fill_idx; /* index reader is filling right now */ + int src_fd; /* source file descriptor */ + size_t buf_sz; /* PAL_FILE_COPY_BUFFER_SIZE */ + int done; /* reader set to 1 on EOF or error */ + int reader_err; /* errno from reader (0 = ok) */ + pthread_mutex_t mtx; + pthread_cond_t cv_ready; /* writer waits: "buffer filled and ready" */ + pthread_cond_t cv_free; /* reader waits: "buffer drained and free" */ +} copy_pipe_t; + +static void *copy_reader_thread(void *arg) { + copy_pipe_t *p = (copy_pipe_t *)arg; + + pthread_mutex_lock(&p->mtx); + for (;;) { + int fi = p->fill_idx; + + while ((p->len[fi] != 0U) && (p->done == 0)) { + pthread_cond_wait(&p->cv_free, &p->mtx); + } + if (p->done != 0) { + break; + } + + pthread_mutex_unlock(&p->mtx); + + ssize_t n; + do { + n = read(p->src_fd, p->buf[fi], p->buf_sz); + } while ((n < 0) && (errno == EINTR)); + + pthread_mutex_lock(&p->mtx); + + if (n < 0) { + p->reader_err = errno; + p->done = 1; + pthread_cond_signal(&p->cv_ready); + break; + } + if (n == 0) { + p->done = 1; + pthread_cond_signal(&p->cv_ready); + break; + } + + p->len[fi] = (size_t)n; + p->fill_idx = 1 - fi; + pthread_cond_signal(&p->cv_ready); + } + pthread_mutex_unlock(&p->mtx); + return NULL; +} + +#define PAL_COPY_PIPELINE_MIN_BYTES ((uint64_t)PAL_FILE_COPY_BUFFER_SIZE * 2U) + +static int copy_write_full(int fd, const uint8_t *data, size_t size, + int *saved_errno) { + size_t offset = 0U; + while (offset < size) { + ssize_t n = write(fd, data + offset, size - offset); + if (n > 0) { + offset += (size_t)n; + continue; + } + if (n < 0 && errno == EINTR) continue; + if (saved_errno != NULL) *saved_errno = (n == 0) ? ENOSPC : errno; + return -1; + } + return 0; +} + +static ftp_error_t copy_report_progress(uint64_t bytes, uint64_t *cumulative, + pal_copy_progress_cb_t cb, + void *user_data) { + uint64_t total = bytes; + if (cumulative != NULL) { + *cumulative += bytes; + total = *cumulative; + } + if (cb != NULL && cb(total, user_data) < 0) return FTP_ERR_CANCELLED; + return FTP_OK; +} + +static atomic_uint_fast32_t g_tmp_counter = ATOMIC_VAR_INIT(0U); + +static ftp_error_t build_temp_path(const char *dst_path, char *out, + size_t out_size) { + char parent[FTP_PATH_MAX]; + ftp_error_t err = fileio_parent_path(dst_path, parent, sizeof(parent)); + if (err != FTP_OK) return err; + + char name[64]; + uint_fast32_t counter = atomic_fetch_add(&g_tmp_counter, 1U); + int n = snprintf(name, sizeof(name), ".zftpd.%lu.%lu.tmp", + (unsigned long)getpid(), (unsigned long)counter); + if (n < 0 || (size_t)n >= sizeof(name)) return FTP_ERR_PATH_TOO_LONG; + return fileio_join_path(parent, name, out, out_size); +} + +static ftp_error_t create_temp_file(const char *dst_path, mode_t mode, + char *tmp_path, size_t tmp_size, + int *fd_out, int *out_errno) { + if (fd_out == NULL) return FTP_ERR_INVALID_PARAM; + for (unsigned attempt = 0U; attempt < 16U; attempt++) { + ftp_error_t err = build_temp_path(dst_path, tmp_path, tmp_size); + if (err != FTP_OK) return err; + int flags = O_WRONLY | O_CREAT | O_EXCL; +#ifndef PLATFORM_PS4 +#ifndef PLATFORM_PS5 +#ifdef O_CLOEXEC + flags |= O_CLOEXEC; +#endif +#endif +#endif +#ifdef O_NOFOLLOW + flags |= O_NOFOLLOW; +#endif + int fd = open(tmp_path, flags, mode); + if (fd >= 0) { *fd_out = fd; return FTP_OK; } + int error = errno; + if (error == EEXIST) continue; + if (out_errno != NULL) *out_errno = error; + return fileio_error_from_errno(error, FTP_ERR_FILE_OPEN); + } + if (out_errno != NULL) *out_errno = EEXIST; + return FTP_ERR_FILE_OPEN; +} + +/*===========================================================================* + * FILE OPERATIONS + *===========================================================================*/ + +ftp_error_t +pal_file_copy_atomic_ex(const char *src_path, const char *dst_path, + pal_copy_progress_cb_t cb, void *user_data, + uint64_t *cumulative, int *out_errno) { + if ((src_path == NULL) || (dst_path == NULL)) { + return FTP_ERR_INVALID_PARAM; + } + if (out_errno != NULL) *out_errno = 0; + + struct stat st; + if (stat(src_path, &st) < 0) + return fileio_error_from_errno(errno, FTP_ERR_FILE_STAT); + + if ((st.st_mode & S_IFMT) != S_IFREG) { + return FTP_ERR_INVALID_PARAM; + } + + int src_fd = -1; + int dst_fd = -1; + int tmp_created = 0; + uint8_t *copy_buf = NULL; + size_t copy_buf_size = 0U; + ftp_error_t out_err = FTP_ERR_FILE_WRITE; + + char tmp_path[FTP_PATH_MAX] = {0}; + + src_fd = open(src_path, O_RDONLY); + + /* Source pages are one-shot copy data; avoid polluting the page cache where supported. */ + if (src_fd >= 0) { +#ifdef F_NOCACHE + (void)fcntl(src_fd, F_NOCACHE, 1); +#endif +#ifdef F_RDAHEAD + (void)fcntl(src_fd, F_RDAHEAD, 1); +#endif +#if defined(POSIX_FADV_SEQUENTIAL) && !defined(PLATFORM_PS4) && !defined(PS4) + (void)posix_fadvise(src_fd, 0, 0, POSIX_FADV_SEQUENTIAL); +#endif + } + if (src_fd < 0) { + int e = errno; + { + char msg[256]; + snprintf(msg, sizeof(msg), + "[XDEV] open(src) failed: errno=%d path=%.*s", e, + PAL_LOG_PATH_CHARS, src_path); + ftp_log_line(FTP_LOG_WARN, msg); + } + if (out_errno != NULL) { + *out_errno = e; + } + out_err = fileio_error_from_errno(e, FTP_ERR_FILE_OPEN); + goto cleanup; + } + + mode_t mode = (mode_t)(st.st_mode & 0777); + + /* PFS may report a full filesystem as write()==0, so reject known ENOSPC early. */ + { + char dst_dir[FTP_PATH_MAX]; + if (fileio_parent_path(dst_path, dst_dir, sizeof(dst_dir)) == FTP_OK) { + struct statvfs vfs; + if (statvfs(dst_dir, &vfs) == 0) { + uint64_t free_bytes = + (uint64_t)vfs.f_bavail * (uint64_t)vfs.f_frsize; + uint64_t need_bytes = (uint64_t)st.st_size; + if (need_bytes > free_bytes) { + char msg[256]; + snprintf(msg, sizeof(msg), + "[XDEV] pre-flight ENOSPC: need=%llu free=%llu dst=%.*s", + (unsigned long long)need_bytes, + (unsigned long long)free_bytes, PAL_LOG_PATH_CHARS, + dst_path); + ftp_log_line(FTP_LOG_WARN, msg); + if (out_errno != NULL) { + *out_errno = ENOSPC; + } + return FTP_ERR_FILE_WRITE; + } + } + } + } + + out_err = create_temp_file(dst_path, mode, tmp_path, sizeof(tmp_path), + &dst_fd, out_errno); + if (out_err != FTP_OK) { + if (out_errno != NULL && *out_errno != 0) { + char msg[256]; + snprintf(msg, sizeof(msg), + "[XDEV] create temp failed: errno=%d dst=%.*s", *out_errno, + PAL_LOG_PATH_CHARS, dst_path); + ftp_log_line(FTP_LOG_WARN, msg); + } + goto cleanup; + } + tmp_created = 1; + + /* VM-backed buffers avoid fragmenting the daemon buddy allocator. */ + if (st.st_size == 0) goto copy_done; + + if ((uint64_t)st.st_size >= PAL_COPY_PIPELINE_MIN_BYTES) { + uint8_t *dbuf0 = (uint8_t *)mmap(NULL, PAL_FILE_COPY_BUFFER_SIZE, + PROT_READ | PROT_WRITE, + MAP_ANONYMOUS | MAP_PRIVATE, -1, 0); + if (dbuf0 == MAP_FAILED) { dbuf0 = NULL; } + uint8_t *dbuf1 = (uint8_t *)mmap(NULL, PAL_FILE_COPY_BUFFER_SIZE, + PROT_READ | PROT_WRITE, + MAP_ANONYMOUS | MAP_PRIVATE, -1, 0); + if (dbuf1 == MAP_FAILED) { dbuf1 = NULL; } + + if ((dbuf0 != NULL) && (dbuf1 != NULL)) { + copy_pipe_t pipe; + pipe.buf[0] = dbuf0; + pipe.buf[1] = dbuf1; + pipe.len[0] = 0U; + pipe.len[1] = 0U; + pipe.fill_idx = 0; + pipe.src_fd = src_fd; + pipe.buf_sz = (size_t)PAL_FILE_COPY_BUFFER_SIZE; + pipe.done = 0; + pipe.reader_err = 0; + pthread_mutex_init(&pipe.mtx, NULL); + pthread_cond_init(&pipe.cv_ready, NULL); + pthread_cond_init(&pipe.cv_free, NULL); + + pthread_t reader_tid; + int pt_ret = pthread_create(&reader_tid, NULL, copy_reader_thread, &pipe); + int thread_ok = (pt_ret == 0) ? 1 : 0; + + /* Log pthread_create result — on PS4 this can fail with EAGAIN (thread + * limit) or ENOMEM (stack allocation failed under memory pressure). */ + if (thread_ok == 0) { + char msg[256]; + snprintf(msg, sizeof(msg), + "[XDEV] pthread_create failed: errno=%d — " + "falling back to serial copy for %.*s", + pt_ret, PAL_LOG_PATH_CHARS, src_path); + ftp_log_line(FTP_LOG_WARN, msg); + } + + if (thread_ok != 0) { + ssize_t written = 0; /* last write result — checked after join */ + int write_errno = 0; /* saved errno from the last failed write(); + * hoisted outside the for loop so it remains + * accessible after break for the post-join log */ + pthread_mutex_lock(&pipe.mtx); + for (;;) { + while ((pipe.len[1 - pipe.fill_idx] == 0U) && (pipe.done == 0)) { + pthread_cond_wait(&pipe.cv_ready, &pipe.mtx); + } + + int drain_idx = 1 - pipe.fill_idx; + size_t nbytes = pipe.len[drain_idx]; + + if ((nbytes == 0U) && (pipe.done != 0)) { + break; + } + + pthread_mutex_unlock(&pipe.mtx); + + /* Keep file-to-file writes large to avoid PFS per-write overhead. */ + write_errno = 0; + written = copy_write_full(dst_fd, pipe.buf[drain_idx], nbytes, + &write_errno) == 0 + ? (ssize_t)nbytes + : -1; + + pthread_mutex_lock(&pipe.mtx); + + if (written < 0) { + /* + * Write error — signal reader to stop, then break. + * We log below after joining the reader thread. + */ + if (out_errno != NULL) { + *out_errno = write_errno; + } + pipe.done = 1; + pthread_cond_signal(&pipe.cv_free); + out_err = FTP_ERR_FILE_WRITE; + break; + } + + if (copy_report_progress((uint64_t)nbytes, cumulative, cb, + user_data) == FTP_ERR_CANCELLED) { + pipe.done = 1; + pthread_cond_signal(&pipe.cv_free); + out_err = FTP_ERR_CANCELLED; + break; + } + + pipe.len[drain_idx] = 0U; + pthread_cond_signal(&pipe.cv_free); + } + pthread_mutex_unlock(&pipe.mtx); + + (void)pthread_join(reader_tid, NULL); + + /* Preserve write errors ahead of reader errors and cancellation. */ + if (written < 0) { + char msg[256]; + snprintf(msg, sizeof(msg), "[COPY] write failed: errno=%d dst=%.*s", + write_errno, PAL_LOG_PATH_CHARS, dst_path); + ftp_log_line(FTP_LOG_WARN, msg); + if (out_errno != NULL) { + *out_errno = write_errno; + } + out_err = FTP_ERR_FILE_WRITE; + } else if (pipe.reader_err != 0) { + char msg[256]; + snprintf(msg, sizeof(msg), "[COPY] read failed: errno=%d src=%.*s", + pipe.reader_err, PAL_LOG_PATH_CHARS, src_path); + ftp_log_line(FTP_LOG_WARN, msg); + if (out_errno != NULL) { + *out_errno = pipe.reader_err; + } + out_err = FTP_ERR_FILE_READ; + } else if (out_err == FTP_ERR_CANCELLED) { + } else { + out_err = FTP_OK; + } + } else { + out_err = FTP_ERR_FILE_WRITE; + } + + pthread_mutex_destroy(&pipe.mtx); + pthread_cond_destroy(&pipe.cv_ready); + pthread_cond_destroy(&pipe.cv_free); + + (void)munmap(dbuf0, PAL_FILE_COPY_BUFFER_SIZE); + (void)munmap(dbuf1, PAL_FILE_COPY_BUFFER_SIZE); + + if (thread_ok != 0) { + if (out_err != FTP_OK) { + goto cleanup; + } + goto copy_done; + } + } else { + { + char msg[256]; + snprintf(msg, sizeof(msg), + "[XDEV] pipeline mmap failed (buf0=%s buf1=%s) — " + "falling back to serial copy for %.*s", + (dbuf0 != NULL) ? "ok" : "NULL", + (dbuf1 != NULL) ? "ok" : "NULL", + PAL_LOG_PATH_CHARS, src_path); + ftp_log_line(FTP_LOG_WARN, msg); + } + if (dbuf0 != NULL) { (void)munmap(dbuf0, PAL_FILE_COPY_BUFFER_SIZE); } + if (dbuf1 != NULL) { (void)munmap(dbuf1, PAL_FILE_COPY_BUFFER_SIZE); } + } + } + + copy_buf_size = ((uint64_t)st.st_size < (uint64_t)PAL_FILE_COPY_BUFFER_SIZE) + ? (size_t)st.st_size + : (size_t)PAL_FILE_COPY_BUFFER_SIZE; + copy_buf = (uint8_t *)mmap(NULL, copy_buf_size, PROT_READ | PROT_WRITE, + MAP_ANONYMOUS | MAP_PRIVATE, -1, 0); + if (copy_buf == MAP_FAILED) { + copy_buf = NULL; + } + if (copy_buf == NULL) { + { + char msg[256]; + snprintf(msg, sizeof(msg), + "[XDEV] serial mmap failed: bufsz=%u errno=%d src=%.*s", + (unsigned)copy_buf_size, + errno, + PAL_LOG_PATH_CHARS, src_path); + ftp_log_line(FTP_LOG_WARN, msg); + } + out_err = FTP_ERR_OUT_OF_MEMORY; + goto cleanup; + } + + { + uint64_t serial_written = 0U; + for (;;) { + ssize_t r = read(src_fd, copy_buf, copy_buf_size); + if (r > 0) { + /* The serial fallback uses the same large-write primitive as the pipeline. */ + int write_errno = 0; + if (copy_write_full(dst_fd, copy_buf, (size_t)r, &write_errno) != 0) { + char msg[256]; + snprintf(msg, sizeof(msg), + "[XDEV] write failed: errno=%d written_so_far=%llu " + "file_size=%llu dst=%.*s", + write_errno, (unsigned long long)serial_written, + (unsigned long long)st.st_size, PAL_LOG_PATH_CHARS, + dst_path); + ftp_log_line(FTP_LOG_WARN, msg); + if (out_errno != NULL) *out_errno = write_errno; + out_err = FTP_ERR_FILE_WRITE; + goto cleanup; + } + serial_written += (uint64_t)r; + + + if (copy_report_progress((uint64_t)r, cumulative, cb, user_data) == + FTP_ERR_CANCELLED) { + out_err = FTP_ERR_CANCELLED; + goto cleanup; + } + continue; + } + if (r == 0) { + break; + } + if (errno == EINTR) { + continue; + } + { + int e = errno; + char msg[256]; + snprintf(msg, sizeof(msg), + "[XDEV] read failed: errno=%d written_so_far=%llu src=%.*s", + e, (unsigned long long)serial_written, PAL_LOG_PATH_CHARS, + src_path); + ftp_log_line(FTP_LOG_WARN, msg); + if (out_errno != NULL) { + *out_errno = e; + } + } + out_err = FTP_ERR_FILE_READ; + goto cleanup; + } /* end serial for(;;) loop */ + } /* end serial_written scope */ + + out_err = FTP_OK; + +copy_done:; + + /* Evict only clean source pages; DONTNEED on writable PFS fds can force a costly sync. */ +#if defined(POSIX_FADV_DONTNEED) && !defined(PLATFORM_PS4) && !defined(PS4) + if (src_fd >= 0) { + (void)posix_fadvise(src_fd, 0, 0, POSIX_FADV_DONTNEED); + } +#endif + + if (rename(tmp_path, dst_path) < 0) { + { + int e = errno; + char msg[256]; + snprintf(msg, sizeof(msg), + "[XDEV] rename(tmp->dst) failed: errno=%d tmp=%.*s dst=%.*s", + e, PAL_LOG_PATH_PAIR_CHARS, tmp_path, PAL_LOG_PATH_PAIR_CHARS, + dst_path); + ftp_log_line(FTP_LOG_WARN, msg); + if (out_errno != NULL) { + *out_errno = e; + } + } + out_err = FTP_ERR_FILE_WRITE; + goto cleanup; + } + + out_err = FTP_OK; + +cleanup: + if (copy_buf != NULL) { + (void)munmap(copy_buf, copy_buf_size); + } + if (dst_fd >= 0) { + (void)close(dst_fd); + } + if (src_fd >= 0) { + (void)close(src_fd); + } + if (out_err != FTP_OK && tmp_created != 0) { + (void)unlink(tmp_path); + } + return out_err; +} diff --git a/src/platform/fileio/directory.c b/src/platform/fileio/directory.c new file mode 100644 index 0000000..bff7bc9 --- /dev/null +++ b/src/platform/fileio/directory.c @@ -0,0 +1,134 @@ +/* +MIT License + +Copyright (c) 2026 Seregon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +/** @file directory.c @brief Directory and path query operations. */ +#include "fileio_internal.h" +#include +#include +#include +#include + +/*===========================================================================* + * DIRECTORY OPERATIONS + *===========================================================================*/ + + +ftp_error_t fileio_parent_path(const char *path, char *out, size_t out_size) { + if (path == NULL || out == NULL || out_size == 0U) + return FTP_ERR_INVALID_PARAM; + + const char *slash = strrchr(path, '/'); + if (slash == NULL) { + if (out_size < 2U) return FTP_ERR_PATH_TOO_LONG; + memcpy(out, ".", 2U); + return FTP_OK; + } + + size_t len = (size_t)(slash - path); + if (len == 0U) len = 1U; + if (len >= out_size) return FTP_ERR_PATH_TOO_LONG; + memcpy(out, path, len); + out[len] = '\0'; + return FTP_OK; +} + +ftp_error_t fileio_join_path(const char *parent, const char *name, char *out, + size_t out_size) { + if (parent == NULL || name == NULL || out == NULL || out_size == 0U) + return FTP_ERR_INVALID_PARAM; + + size_t parent_len = strlen(parent); + size_t name_len = strlen(name); + size_t sep = parent_len > 0U && parent[parent_len - 1U] != '/' ? 1U : 0U; + if (parent_len > out_size - 1U || sep > out_size - 1U - parent_len || + name_len > out_size - 1U - parent_len - sep) + return FTP_ERR_PATH_TOO_LONG; + + memcpy(out, parent, parent_len); + size_t pos = parent_len; + if (sep != 0U) out[pos++] = '/'; + memcpy(out + pos, name, name_len); + out[pos + name_len] = '\0'; + return FTP_OK; +} + +static int path_mode(const char *path, mode_t *mode) { + if (path == NULL || mode == NULL) return FTP_ERR_INVALID_PARAM; + struct stat st; + if (stat(path, &st) == 0) { + *mode = st.st_mode; + return 1; + } + if (errno == ENOENT || errno == ENOTDIR) return 0; + return fileio_error_from_errno(errno, FTP_ERR_FILE_STAT); +} + +ftp_error_t pal_dir_create(const char *path, mode_t mode) { + if (path == NULL) { + return FTP_ERR_INVALID_PARAM; + } + + if (mkdir(path, mode) < 0) { + if (errno == EEXIST) return FTP_ERR_DIR_EXISTS; + return fileio_error_from_errno(errno, FTP_ERR_FILE_WRITE); + } + + return FTP_OK; +} + +/** + * @brief Remove directory + */ +ftp_error_t pal_dir_remove(const char *path) { + if (path == NULL) { + return FTP_ERR_INVALID_PARAM; + } + + if (rmdir(path) < 0) { + if (errno == ENOTEMPTY || errno == ENOTDIR) return FTP_ERR_INVALID_PARAM; + return fileio_error_from_errno(errno, FTP_ERR_FILE_WRITE); + } + + return FTP_OK; +} + +/** + * @brief Check if path exists + */ +int pal_path_exists(const char *path) { + mode_t mode = 0; + return path_mode(path, &mode); +} + +int pal_path_is_directory(const char *path) { + mode_t mode = 0; + int result = path_mode(path, &mode); + return result == 1 ? (S_ISDIR(mode) ? 1 : 0) : result; +} + +int pal_path_is_file(const char *path) { + mode_t mode = 0; + int result = path_mode(path, &mode); + return result == 1 ? (S_ISREG(mode) ? 1 : 0) : result; +} diff --git a/src/platform/fileio/fileio_internal.h b/src/platform/fileio/fileio_internal.h new file mode 100644 index 0000000..c40f60c --- /dev/null +++ b/src/platform/fileio/fileio_internal.h @@ -0,0 +1,20 @@ +#ifndef ZFTPD_PLATFORM_FILEIO_INTERNAL_H +#define ZFTPD_PLATFORM_FILEIO_INTERNAL_H + +#include "pal_fileio.h" +#include + +#define PAL_MOVE_MAX_DEPTH 64U +#define PAL_LOG_PATH_CHARS 120 +#define PAL_LOG_PATH_PAIR_CHARS 72 + +ftp_error_t fileio_error_from_errno(int error, ftp_error_t fallback); +ftp_error_t fileio_parent_path(const char *path, char *out, size_t out_size); +ftp_error_t fileio_join_path(const char *parent, const char *name, char *out, + size_t out_size); + +ftp_error_t pal_file_copy_atomic_ex(const char *src_path, const char *dst_path, + pal_copy_progress_cb_t cb, void *user_data, + uint64_t *cumulative, int *out_errno); + +#endif diff --git a/src/platform/fileio/sendfile.c b/src/platform/fileio/sendfile.c new file mode 100644 index 0000000..361d7d3 --- /dev/null +++ b/src/platform/fileio/sendfile.c @@ -0,0 +1,106 @@ +/* +MIT License + +Copyright (c) 2026 Seregon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +/** @file sendfile.c @brief Platform sendfile abstraction. */ +#include "pal_fileio.h" +#include "pal_network.h" +#include +#include + +#define FALLBACK_BUFFER_SIZE FTP_BUFFER_SIZE + +ssize_t pal_sendfile(int sock_fd, int file_fd, off_t *offset, size_t count) { + /* Validate parameters */ + if ((sock_fd < 0) || (file_fd < 0)) { + errno = EINVAL; + return -1; + } + + if ((offset == NULL) || (*offset < 0)) { + errno = EINVAL; + return -1; + } + + if (count == 0U) { + return 0; + } + + /* Chunk policy belongs to callers; the PAL forwards the requested count. */ + +#if defined(__linux__) + return sendfile(sock_fd, file_fd, offset, count); + +#elif defined(__APPLE__) && defined(__MACH__) + off_t sent = (off_t)count; + int ret = sendfile(file_fd, sock_fd, *offset, &sent, NULL, 0); + if (sent > 0) *offset += sent; + if (ret == 0 || sent > 0) return sent; + return -1; + +#elif defined(__FreeBSD__) || defined(PLATFORM_PS4) || defined(PLATFORM_PS5) + /* FreeBSD uses the inverse fd order and reports partial bytes via sbytes. */ + off_t sbytes = 0; + off_t start_offset = *offset; + + int ret = sendfile(file_fd, sock_fd, start_offset, count, NULL, &sbytes, 0); + + if (sbytes > 0) { + *offset += sbytes; + } + + if (ret == 0) { + return sbytes; + } else if ((ret == -1) && (errno == EAGAIN)) { + return sbytes; + } else if ((ret == -1) && (errno == EINTR)) { + /* sbytes already advanced the offset, so expose the partial transfer. */ + return sbytes; + } else if ((ret == -1) && pal_file_error_is_fatal(errno)) { + /* Retrying a fatal storage error on an invalid vnode can panic OrbisOS. */ + return -1; + + } else { + /* Do not hide unsupported sendfile paths behind an implicit copy fallback. */ + return (sbytes > 0) ? sbytes : -1; + } + +#else + /* Portable fallback for platforms without sendfile. */ + static _Thread_local char buffer[FALLBACK_BUFFER_SIZE]; + + ssize_t nread = pread( + file_fd, buffer, + (count < FALLBACK_BUFFER_SIZE) ? count : FALLBACK_BUFFER_SIZE, *offset); + if (nread <= 0) { + return nread; + } + + ssize_t nsent = pal_send_all(sock_fd, buffer, (size_t)nread, 0); + if (nsent < 0) { + return -1; + } + *offset += nsent; + return nsent; +#endif +} diff --git a/src/platform/fileio/tree.c b/src/platform/fileio/tree.c new file mode 100644 index 0000000..50dd315 --- /dev/null +++ b/src/platform/fileio/tree.c @@ -0,0 +1,391 @@ +/* +MIT License + +Copyright (c) 2026 Seregon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +/** @file tree.c @brief Recursive copy, move and tree removal operations. */ +#include "fileio_internal.h" +#include "ftp_log.h" +#include +#include +#include +#include +#include +#include +#include + +/* Recursive fallback used when a file operation crosses filesystems. */ + +static int is_dot_entry(const char *name) { + return name != NULL && name[0] == '.' && + (name[1] == '\0' || (name[1] == '.' && name[2] == '\0')); +} + +static int canonicalize_target(const char *path, char *out, size_t out_size) { + if (path == NULL || out == NULL || out_size == 0U) return -1; + if (realpath(path, out) != NULL) return 0; + if (errno != ENOENT) return -1; + + const char *slash = strrchr(path, '/'); + const char *name = slash != NULL ? slash + 1 : path; + if (name[0] == '\0') return -1; + + char parent[FTP_PATH_MAX]; + if (fileio_parent_path(path, parent, sizeof(parent)) != FTP_OK) return -1; + char parent_real[FTP_PATH_MAX]; + if (realpath(parent, parent_real) == NULL) return -1; + return fileio_join_path(parent_real, name, out, out_size) == FTP_OK ? 0 : -1; +} + +static int canonicalize_entry_no_follow(const char *path, char *out, + size_t out_size) { + if (path == NULL || out == NULL || out_size == 0U) return -1; + if (strcmp(path, "/") == 0) { + if (out_size < 2U) return -1; + memcpy(out, "/", 2U); + return 0; + } + + const char *slash = strrchr(path, '/'); + const char *name = slash != NULL ? slash + 1 : path; + if (name[0] == '\0') return -1; + + char parent[FTP_PATH_MAX]; + if (fileio_parent_path(path, parent, sizeof(parent)) != FTP_OK) return -1; + char parent_real[FTP_PATH_MAX]; + if (realpath(parent, parent_real) == NULL) return -1; + return fileio_join_path(parent_real, name, out, out_size) == FTP_OK ? 0 : -1; +} + +static int path_is_same_or_child(const char *path, const char *root) { + size_t root_len = strlen(root); + if (strcmp(path, root) == 0) return 1; + if (root_len == 1U && root[0] == '/') return path[0] == '/'; + return root_len > 0U && strncmp(path, root, root_len) == 0 && + path[root_len] == '/'; +} + +static ftp_error_t validate_copy_roots(const char *src, const char *dst) { + char src_real[FTP_PATH_MAX]; + char dst_real[FTP_PATH_MAX]; + struct stat st; + if (lstat(src, &st) < 0) return fileio_error_from_errno(errno, FTP_ERR_FILE_STAT); + + int src_ok = S_ISLNK(st.st_mode) + ? canonicalize_entry_no_follow(src, src_real, sizeof(src_real)) + : (realpath(src, src_real) != NULL ? 0 : -1); + if (src_ok != 0 || canonicalize_target(dst, dst_real, sizeof(dst_real)) != 0) + return FTP_ERR_PATH_INVALID; + if (path_is_same_or_child(dst_real, src_real)) return FTP_ERR_INVALID_PARAM; + return FTP_OK; +} + +static ftp_error_t pal_dir_remove_recursive(const char *path, unsigned depth) { + if (path == NULL) { + return FTP_ERR_INVALID_PARAM; + } + if (depth > PAL_MOVE_MAX_DEPTH) { + return FTP_ERR_PATH_TOO_LONG; + } + + DIR *dir = opendir(path); + if (dir == NULL) { + if (errno == ENOENT) { + return FTP_OK; + } + { + char msg[256]; + snprintf(msg, sizeof(msg), + "[XDEV] opendir(cleanup) failed: errno=%d path=%.*s", errno, + PAL_LOG_PATH_CHARS, path); + ftp_log_line(FTP_LOG_WARN, msg); + } + return FTP_ERR_DIR_OPEN; + } + + struct dirent *ent; + ftp_error_t err = FTP_OK; + + while ((ent = readdir(dir)) != NULL) { + if (is_dot_entry(ent->d_name)) continue; + + char child[FTP_PATH_MAX]; + err = fileio_join_path(path, ent->d_name, child, sizeof(child)); + if (err != FTP_OK) break; + + struct stat st; + if (lstat(child, &st) < 0) { + err = fileio_error_from_errno(errno, FTP_ERR_FILE_STAT); + break; + } + + if (S_ISDIR(st.st_mode)) { + err = pal_dir_remove_recursive(child, depth + 1U); + } else { + if (unlink(child) != 0) { + { + char msg[256]; + snprintf(msg, sizeof(msg), + "[XDEV] unlink(cleanup) failed: errno=%d path=%.*s", errno, + PAL_LOG_PATH_CHARS, child); + ftp_log_line(FTP_LOG_WARN, msg); + } + err = FTP_ERR_FILE_WRITE; + } + } + + if (err != FTP_OK) { + break; + } + } + + (void)closedir(dir); + + if (err == FTP_OK) { + if (rmdir(path) < 0) { + { + char msg[256]; + snprintf(msg, sizeof(msg), + "[XDEV] rmdir(cleanup) failed: errno=%d path=%.*s", errno, + PAL_LOG_PATH_CHARS, path); + ftp_log_line(FTP_LOG_WARN, msg); + } + err = FTP_ERR_FILE_WRITE; + } + } + + return err; +} + +static ftp_error_t pal_copy_cross_device_r_ex(const char *src, const char *dst, + unsigned depth, int keep_src, + pal_copy_progress_cb_t cb, + void *user_data, + uint64_t *cumulative, + int *out_errno) { + if ((src == NULL) || (dst == NULL)) { + return FTP_ERR_INVALID_PARAM; + } + if (depth > PAL_MOVE_MAX_DEPTH) { + { + char msg[256]; + snprintf(msg, sizeof(msg), "[XDEV] max depth %u exceeded: %.*s", + (unsigned)PAL_MOVE_MAX_DEPTH, PAL_LOG_PATH_CHARS, src); + ftp_log_line(FTP_LOG_WARN, msg); + } + return FTP_ERR_PATH_TOO_LONG; + } + + if (depth == 0U) { + ftp_error_t root_err = validate_copy_roots(src, dst); + if (root_err != FTP_OK) return root_err; + + char msg[256]; + snprintf(msg, sizeof(msg), "[XDEV] cross-device move: %.*s -> %.*s", + PAL_LOG_PATH_PAIR_CHARS, src, PAL_LOG_PATH_PAIR_CHARS, dst); + ftp_log_line(FTP_LOG_INFO, msg); + } + + struct stat src_st; + if (lstat(src, &src_st) < 0) { + int e = errno; + { + char msg[256]; + snprintf(msg, sizeof(msg), + "[XDEV] stat(src) failed: errno=%d path=%.*s", e, + PAL_LOG_PATH_CHARS, src); + ftp_log_line(FTP_LOG_WARN, msg); + } + return (e == ENOENT) ? FTP_ERR_NOT_FOUND : FTP_ERR_FILE_STAT; + } + + if (S_ISLNK(src_st.st_mode)) { + char target[FTP_PATH_MAX]; + ssize_t n = readlink(src, target, sizeof(target) - 1U); + if (n < 0) return fileio_error_from_errno(errno, FTP_ERR_FILE_READ); + target[n] = '\0'; + if (symlink(target, dst) < 0) + return fileio_error_from_errno(errno, FTP_ERR_FILE_WRITE); + if (keep_src == 0 && unlink(src) < 0) + return fileio_error_from_errno(errno, FTP_ERR_FILE_WRITE); + return FTP_OK; + } + + if (S_ISREG(src_st.st_mode)) { + int local_errno = 0; + int *errno_ptr = (out_errno != NULL) ? out_errno : &local_errno; + ftp_error_t err = + pal_file_copy_atomic_ex(src, dst, cb, user_data, cumulative, errno_ptr); + if (err != FTP_OK) { + { + int os_err = *errno_ptr; + char msg[256]; + snprintf(msg, sizeof(msg), + "[XDEV] file copy failed (err=%d, errno=%d): %.*s -> %.*s", + (int)err, os_err, PAL_LOG_PATH_PAIR_CHARS, src, + PAL_LOG_PATH_PAIR_CHARS, dst); + ftp_log_line(FTP_LOG_WARN, msg); + } + return err; + } + if (keep_src == 0) { + if (unlink(src) < 0) { + { + char msg[256]; + snprintf(msg, sizeof(msg), + "[XDEV] unlink(src) failed: errno=%d path=%.*s", errno, + PAL_LOG_PATH_CHARS, src); + ftp_log_line(FTP_LOG_WARN, msg); + } + return FTP_ERR_FILE_WRITE; + } + } + return FTP_OK; + } + + if (!S_ISDIR(src_st.st_mode)) { + return FTP_ERR_INVALID_PARAM; + } + + mode_t mode = (mode_t)(src_st.st_mode & 0777); + + /* Never roll back a destination directory that pre-dated this operation. */ + int dst_created_by_us = 0; + if (mkdir(dst, mode) < 0) { + if (errno != EEXIST) { + int e = errno; + { + char msg[256]; + snprintf(msg, sizeof(msg), + "[XDEV] mkdir(dst) failed: errno=%d path=%.*s", e, + PAL_LOG_PATH_CHARS, dst); + ftp_log_line(FTP_LOG_WARN, msg); + } + return FTP_ERR_FILE_WRITE; + } + } else { + dst_created_by_us = 1; + } + + DIR *dir = opendir(src); + if (dir == NULL) { + { + char msg[256]; + snprintf(msg, sizeof(msg), + "[XDEV] opendir(src) failed: errno=%d path=%.*s", errno, + PAL_LOG_PATH_CHARS, src); + ftp_log_line(FTP_LOG_WARN, msg); + } + if (dst_created_by_us != 0) { + (void)rmdir(dst); + } + return FTP_ERR_DIR_OPEN; + } + + struct dirent *ent; + ftp_error_t err = FTP_OK; + + while ((ent = readdir(dir)) != NULL) { + if (is_dot_entry(ent->d_name)) continue; + + char src_child[FTP_PATH_MAX]; + char dst_child[FTP_PATH_MAX]; + err = fileio_join_path(src, ent->d_name, src_child, sizeof(src_child)); + if (err == FTP_OK) + err = fileio_join_path(dst, ent->d_name, dst_child, sizeof(dst_child)); + if (err != FTP_OK) break; + + err = pal_copy_cross_device_r_ex(src_child, dst_child, depth + 1U, keep_src, + cb, user_data, cumulative, out_errno); + if (err != FTP_OK) { + break; + } + } + + (void)closedir(dir); + + if (err != FTP_OK) { + if (dst_created_by_us != 0) { + (void)pal_dir_remove_recursive(dst, 0U); + } + return err; + } + + if (keep_src == 0) { + if (rmdir(src) < 0) { + { + char msg[256]; + snprintf(msg, sizeof(msg), + "[XDEV] rmdir(src) failed: errno=%d path=%.*s", errno, + PAL_LOG_PATH_CHARS, src); + ftp_log_line(FTP_LOG_WARN, msg); + } + return FTP_ERR_FILE_WRITE; + } + } + + if (depth == 0U) { + ftp_log_line(FTP_LOG_INFO, "[XDEV] cross-device operation completed OK"); + } + + return FTP_OK; +} + +ftp_error_t pal_file_rename(const char *old_path, const char *new_path) { + if ((old_path == NULL) || (new_path == NULL)) { + return FTP_ERR_INVALID_PARAM; + } + + if (rename(old_path, new_path) < 0) { + if (errno == EXDEV) return FTP_ERR_CROSS_DEVICE; + return fileio_error_from_errno(errno, FTP_ERR_FILE_WRITE); + } + + return FTP_OK; +} + +ftp_error_t pal_file_copy_recursive(const char *src, const char *dst, + int keep_src) { + uint64_t cum = 0U; + return pal_copy_cross_device_r_ex(src, dst, 0U, keep_src, NULL, NULL, &cum, + NULL); +} + +ftp_error_t pal_file_copy_recursive_ex(const char *src, const char *dst, + int keep_src, pal_copy_progress_cb_t cb, + void *user_data, int *out_errno) { + uint64_t cum = 0U; + return pal_copy_cross_device_r_ex(src, dst, 0U, keep_src, cb, user_data, &cum, + out_errno); +} + + +ftp_error_t pal_dir_remove_recursive_pub(const char *path) { + if (path == NULL || path[0] == '\0') return FTP_ERR_INVALID_PARAM; + + char resolved[FTP_PATH_MAX]; + if (realpath(path, resolved) != NULL && strcmp(resolved, "/") == 0) + return FTP_ERR_PERMISSION; + if (strcmp(path, "/") == 0) return FTP_ERR_PERMISSION; + + return pal_dir_remove_recursive(path, 0U); +} diff --git a/src/platform/pal_fileio.c b/src/platform/pal_fileio.c deleted file mode 100644 index 7b22796..0000000 --- a/src/platform/pal_fileio.c +++ /dev/null @@ -1,1976 +0,0 @@ -/* -MIT License - -Copyright (c) 2026 Seregon - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ - -/** - * @file pal_fileio.c - * @brief Platform Abstraction Layer - File I/O Implementation - * - * @author SeregonWar - * @version 1.0.0 - * @date 2026-02-13 - * - */ - -#include "pal_fileio.h" -#include "ftp_log.h" -#include "pal_network.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -/* Fallback buffer size for non-sendfile platforms */ -#define FALLBACK_BUFFER_SIZE FTP_BUFFER_SIZE - -/* - * PAL_FILE_WRITE_CHUNK_MAX is defined in pal_fileio.h. - * This fallback guard protects against edge cases where a transitive include - * of pal_fileio.h fires before our own #include above (e.g. via ftp_types.h), - * causing the header guard to suppress the macro on the second pass. - */ -#ifndef PAL_FILE_WRITE_CHUNK_MAX -# if defined(PLATFORM_PS5) || defined(PS5) -/* - * 1 MB — matches FTP_BUFFER_SIZE and ftpsrv's IO_COPY_BUFSIZE. - * - * The original 128 KB limit existed to keep per-chunk PFS write latency - * under ~5 ms in the STOR double-buffer path, preventing the recv thread - * from stalling the TCP window. On PS5 the double-buffer path is - * explicitly disabled (buf1 = NULL forced in cmd_STOR), so this cap is - * never protective — it only creates 8× 128 KB writes per 1 MB recv: - * 8 × 5 ms = 40 ms/MB → 25 MB/s (observed bottleneck) - * - * With 1 MB writes matching the recv buffer size: - * 1 × ~9 ms = ~115 MB/s (matches ftpsrv on /data) - * - * Safety: FTP_TCP_RCVBUF = 4 MB absorbs all in-flight network data - * during the single write() call, so no TCP zero-window stall occurs. - */ -# define PAL_FILE_WRITE_CHUNK_MAX 1048576U /* 1 MB — PS5: double-buf disabled, no TCP stall risk */ -# elif defined(PLATFORM_PS4) || defined(PS4) -# define PAL_FILE_WRITE_CHUNK_MAX 65536U /* 64 KB — PS4: keep original; HDD write latency higher */ -# else -# define PAL_FILE_WRITE_CHUNK_MAX 262144U /* 256 KB */ -# endif -#endif - -/* Max recursion depth for cross-device directory move */ -#define PAL_MOVE_MAX_DEPTH 64U - -/* Keep diagnostic log lines within their fixed 256-byte buffers on GCC/fortify. */ -#define PAL_LOG_PATH_CHARS 120 -#define PAL_LOG_PATH_PAIR_CHARS 72 - -/* - * PAL_FILE_COPY_BUFFER_SIZE — per-buffer size for the copy pipeline. - * - * PS5 USB→NVMe throughput analysis (exFAT → PFS, 12 GB file): - * - * Observed serial throughput: 135 MB/s - * Target pipelined throughput: 215 MB/s - * - * USB exFAT sequential read: ~363 MB/s - * NVMe PFS sequential write: ~215 MB/s - * - * Serial model: 1/(1/363 + 1/215) = 135 MB/s ✓ matches observation - * Pipelined: min(363, 215) = 215 MB/s ✓ matches target - * - * PS4 USB→HDD/SSD throughput analysis: - * - * USB exFAT sequential read: ~320 MB/s - * HDD 5400 RPM PFS write: ~85 MB/s → serial ~67 MB/s, pipelined 85 - * MB/s (+27%) SSD aftermarket PFS write: ~175 MB/s → serial ~113 MB/s, - * pipelined 175 MB/s (+55%) - * - * 1 MB buffers are sufficient on PS4: T_read(1MB) = 3.3 ms is well under - * T_write_HDD(1MB) = 12.3 ms, so the reader always finishes before the - * writer and the pipeline never stalls. 4 MB buffers add memory pressure - * (PS4 daemon budget ~78 MB) with no throughput gain. - * - * The serial read+write loop leaves one device idle during every cycle. - * A double-buffer pipeline overlaps USB reads with NVMe/HDD writes fully, - * recovering the throughput gap on both platforms. - * - * IMPORTANT: the copy path writes the FULL buffer in a single write() - * call, bypassing pal_file_write_all()'s PAL_WRITE_CHUNK_MAX subdivision. - * That limit (128 KB on PS5, 64 KB on PS4) exists to prevent TCP stalls - * in cmd_STOR; it has no relevance for a file-to-file copy. - */ -#ifndef PAL_FILE_COPY_BUFFER_SIZE -#if defined(PLATFORM_PS5) -#define PAL_FILE_COPY_BUFFER_SIZE \ - (4U * 1024U * \ - 1024U) /* 4 MB — NVMe ~215 MB/s, T_write=19ms covers T_read=12ms */ -#elif defined(PLATFORM_PS4) -#define PAL_FILE_COPY_BUFFER_SIZE \ - (1024U * 1024U) /* 1 MB — HDD ~85 MB/s, T_write=12ms covers T_read=3ms */ -#else -#define PAL_FILE_COPY_BUFFER_SIZE (4U * 1024U * 1024U) /* 4 MB */ -#endif -#endif - -/*---------------------------------------------------------------------------* - * DOUBLE-BUFFER COPY PIPELINE (PS4 and PS5) - * - * Two buffers alternate between a reader thread (USB exFAT) and the - * calling thread (NVMe/HDD PFS writer), fully overlapping I/O: - * - * Cycle N: [read buf A from USB] [write buf B to storage] - * Cycle N+1: [read buf B from USB] [write buf A to storage] - * - * PS5: writer (NVMe, ~215 MB/s) is the bottleneck; reader (USB, ~363 MB/s) - * always finishes first. Net throughput: 215 MB/s. - * PS4: writer (HDD, ~85 MB/s) is the bottleneck; reader (USB, ~320 MB/s) - * always finishes first. Net throughput: ~85 MB/s (vs ~67 MB/s serial). - * - * Thread roles: - * Main thread — writer: drains the filled buffer to dst_fd, calls - * the progress callback, signals cv_free. - * Reader thread — reads from src_fd into the free buffer, signals - * cv_ready when a full chunk is available. - * - * State machine (protected by pipe_mtx): - * fill_idx — buffer currently being read into (0 or 1) - * drain_idx — buffer currently being written out (1-fill_idx) - * len[i] — bytes available in buffer i (0 = free, >0 = ready) - * done — reader has hit EOF or error - * reader_err — non-zero errno set by reader on error - *---------------------------------------------------------------------------*/ -#include - -typedef struct { - uint8_t *buf[2]; /* two PAL_FILE_COPY_BUFFER_SIZE buffers */ - size_t len[2]; /* bytes filled in each buffer (0 = free) */ - int fill_idx; /* index reader is filling right now */ - int src_fd; /* source file descriptor */ - size_t buf_sz; /* PAL_FILE_COPY_BUFFER_SIZE */ - int done; /* reader set to 1 on EOF or error */ - int reader_err; /* errno from reader (0 = ok) */ - pthread_mutex_t mtx; - pthread_cond_t cv_ready; /* writer waits: "buffer filled and ready" */ - pthread_cond_t cv_free; /* reader waits: "buffer drained and free" */ -} copy_pipe_t; - -static void *copy_reader_thread(void *arg) { - copy_pipe_t *p = (copy_pipe_t *)arg; - - pthread_mutex_lock(&p->mtx); - for (;;) { - int fi = p->fill_idx; - - /* Wait until the fill buffer is free (len[fi] == 0) */ - while ((p->len[fi] != 0U) && (p->done == 0)) { - pthread_cond_wait(&p->cv_free, &p->mtx); - } - if (p->done != 0) { - break; /* main thread requested stop (write error or cancel) */ - } - - pthread_mutex_unlock(&p->mtx); - - /* Read without holding the lock — this is the slow USB read */ - ssize_t n; - do { - n = read(p->src_fd, p->buf[fi], p->buf_sz); - } while ((n < 0) && (errno == EINTR)); - - pthread_mutex_lock(&p->mtx); - - if (n < 0) { - p->reader_err = errno; - p->done = 1; - pthread_cond_signal(&p->cv_ready); - break; - } - if (n == 0) { - /* EOF */ - p->done = 1; - pthread_cond_signal(&p->cv_ready); - break; - } - - p->len[fi] = (size_t)n; - p->fill_idx = 1 - fi; /* swap to the other buffer */ - pthread_cond_signal(&p->cv_ready); - } - pthread_mutex_unlock(&p->mtx); - return NULL; -} - -/*===========================================================================* - * ZERO-COPY FILE TRANSFER - *===========================================================================*/ - -/** - * @brief Send file data via socket (zero-copy) - * - * DESIGN RATIONALE: - * - sendfile() eliminates userspace copy (kernel-direct transfer) - * - Fallback to buffered I/O maintains portability - * - Performance difference: 2-3x throughput improvement - */ -ssize_t pal_sendfile(int sock_fd, int file_fd, off_t *offset, size_t count) { - /* Validate parameters */ - if ((sock_fd < 0) || (file_fd < 0)) { - errno = EINVAL; - return -1; - } - - if ((offset == NULL) || (*offset < 0)) { - errno = EINVAL; - return -1; - } - - if (count == 0U) { - return 0; - } - - /* - * NOTE: PAL_FILE_WRITE_CHUNK_MAX is intentionally NOT applied here. - * - * That cap (128 KB on PS5, 64 KB on PS4) exists to keep per-chunk write - * latency under ~5 ms so the FTP double-buffer producer never starves the - * kernel TCP recv-buffer long enough to trigger a client inactivity timeout. - * It is an FTP protocol concern, not a platform abstraction concern. - * - * Enforcing it here silently degraded HTTP /api/download throughput from - * ~300 MB/s to ~240 MB/s: the HTTP server passes 1 MB chunks but each call - * was internally cut to 128 KB, generating 8x more syscalls per MB. - * - * The FTP caller (ftp_commands.c cmd_RETR) now passes pre-capped chunks - * using PAL_FILE_WRITE_CHUNK_MAX directly, so the limit is preserved where - * it matters without penalising other callers. - */ - -#if defined(__linux__) - /* - * Linux sendfile(2) - * Signature: sendfile(out_fd, in_fd, offset, count) - */ - ssize_t result = sendfile(sock_fd, file_fd, offset, count); - return result; - -#elif defined(__FreeBSD__) || defined(PLATFORM_PS4) || defined(PLATFORM_PS5) - /* - * FreeBSD sendfile(2) - * Signature: sendfile(in_fd, out_fd, offset, count, hdtr, sbytes, flags) - * - * IMPORTANT: Different parameter order than Linux! - */ - off_t sbytes = 0; - off_t start_offset = *offset; - - int ret = sendfile(file_fd, sock_fd, start_offset, count, NULL, &sbytes, 0); - - /* - * Update offset by bytes actually sent. - * FreeBSD updates sbytes even on EAGAIN (partial send). - */ - if (sbytes > 0) { - *offset += sbytes; - } - - if (ret == 0) { - /* Success: all bytes sent */ - return sbytes; - } else if ((ret == -1) && (errno == EAGAIN)) { - /* Non-blocking socket: partial send — caller retries */ - return sbytes; - } else if ((ret == -1) && (errno == EINTR)) { - /* - * Interrupted by signal mid-transfer. - * - * IMPORTANT: *offset has already been advanced by sbytes above. - * Returning -1 here would cause the caller to retry from the - * old offset, re-sending bytes already transmitted — file - * corruption guaranteed. - * - * Return sbytes (>= 0) so the caller advances its own position - * and retries from the correct point. - */ - return sbytes; /* may be 0 if interrupted before any byte sent */ - } else if ((ret == -1) && ((errno == EIO) || (errno == ESTALE) || - (errno == EBADF) || (errno == EFAULT))) { - /* - * Fatal storage-level error during transfer. - * - * EIO : underlying device I/O error (USB read failure, NVMe fault) - * ESTALE : stale vnode — filesystem unmounted while transfer was in flight - * EBADF : fd invalidated — should never reach here, but guard anyway - * EFAULT : kernel memory fault — extremely rare; never retry - * - * CRITICAL: ALWAYS return -1 here, regardless of sbytes. - * - * Previous behaviour returned sbytes when sbytes > 0, signalling - * a "partial success" to the caller. This caused the caller's loop - * to decrement its remaining-byte counter and call pal_sendfile() - * again on the same (now-corrupted/unmounted) vnode. On PS5/PS4 - * that second call can trigger an unrecoverable kernel panic. - * - * Safety: *offset has already been advanced by sbytes above, so no - * data is double-sent. The caller sees -1, breaks out of its loop, - * and closes the connection cleanly — which is the correct behaviour. - * - * FTP callers (ftp_commands.c): they detect sent < 0, check errno, - * and skip the EAGAIN retry loop (see the EIO guard there), falling - * through to the read()-based cooldown path without any sendfile retry. - */ - return -1; - - } else { - /* - * EINVAL / ENOSYS / ENXIO / other unexpected sendfile() error. - * - * No fallback — sendfile is the only data path. If the kernel - * cannot DMA from this fd, the caller receives the error and - * must abort the transfer rather than silently degrading to - * a userspace copy. - */ - return (sbytes > 0) ? sbytes : -1; - } - -#else - /* - * Fallback: Buffered read/write - * Used on platforms without sendfile() support - */ - static _Thread_local char buffer[FALLBACK_BUFFER_SIZE]; - - /* Read from file at specified offset */ - ssize_t nread = pread( - file_fd, buffer, - (count < FALLBACK_BUFFER_SIZE) ? count : FALLBACK_BUFFER_SIZE, *offset); - if (nread <= 0) { - return nread; /* EOF or error */ - } - - /* Send via socket */ - ssize_t nsent = pal_send_all(sock_fd, buffer, (size_t)nread, 0); - if (nsent < 0) { - return -1; - } - *offset += nsent; - return nsent; -#endif -} - -/*===========================================================================* - * FILE OPERATIONS - *===========================================================================*/ - -static ftp_error_t -pal_file_copy_atomic_ex(const char *src_path, const char *dst_path, - pal_copy_progress_cb_t cb, void *user_data, - uint64_t *cumulative, int *out_errno) { - if ((src_path == NULL) || (dst_path == NULL)) { - return FTP_ERR_INVALID_PARAM; - } - - struct stat st; - if (stat(src_path, &st) < 0) { - switch (errno) { - case ENOENT: - return FTP_ERR_NOT_FOUND; - case EACCES: - case EPERM: - return FTP_ERR_PERMISSION; - default: - return FTP_ERR_FILE_STAT; - } - } - - if ((st.st_mode & S_IFMT) != S_IFREG) { - return FTP_ERR_INVALID_PARAM; - } - - int src_fd = -1; - int dst_fd = -1; - uint8_t *copy_buf = NULL; /* mmap-allocated; freed in cleanup */ - ftp_error_t out_err = FTP_ERR_FILE_WRITE; - - /* - * TEMP FILENAME STRATEGY — safe for exFAT 255-char name limit - * - * Old: ".zftpd-tmp--" (appends ~25 chars) - * New: "/.zftpd...tmp" (fixed short name) - * - * The old scheme overflows when the destination filename already - * approaches 255 characters (common with PS5 game directories). - * The new scheme puts a short, fixed-length temp file in the same - * directory so the final same-FS rename() is always valid. - */ - static atomic_uint_fast32_t g_tmp_counter = ATOMIC_VAR_INIT(0U); - uint_fast32_t counter = atomic_fetch_add(&g_tmp_counter, 1U); - - /* Find parent directory of dst_path */ - char tmp_path[FTP_PATH_MAX]; - const char *last_slash = strrchr(dst_path, '/'); - if (last_slash != NULL) { - size_t dir_len = (size_t)(last_slash - dst_path); - /* - * Precision must be the parent directory length, NOT remaining buffer - * capacity. Using sizeof(tmp_path)-N here previously produced paths like: - * /mnt/usb/foo.ffpfsc/.zftpd.PID.N.tmp - * (filename treated as a directory → ENOTDIR on open). That broke every - * dashboard cross-device / atomic copy (issue #7). - */ - int n; - if (dir_len == 0U) { - /* Destination is "/basename" — parent is root. */ - n = snprintf(tmp_path, sizeof(tmp_path), "/.zftpd.%lu.%lu.tmp", - (unsigned long)getpid(), (unsigned long)counter); - } else { - /* Reserve room for "/.zftpd...tmp" + NUL (~40 bytes). */ - const size_t suffix_reserve = 48U; - if (dir_len + suffix_reserve > sizeof(tmp_path)) { - return FTP_ERR_PATH_TOO_LONG; - } - n = snprintf(tmp_path, sizeof(tmp_path), "%.*s/.zftpd.%lu.%lu.tmp", - (int)dir_len, dst_path, (unsigned long)getpid(), - (unsigned long)counter); - } - if ((n < 0) || ((size_t)n >= sizeof(tmp_path))) { - return FTP_ERR_PATH_TOO_LONG; - } - } else { - int n = snprintf(tmp_path, sizeof(tmp_path), ".zftpd.%lu.%lu.tmp", - (unsigned long)getpid(), (unsigned long)counter); - if ((n < 0) || ((size_t)n >= sizeof(tmp_path))) { - return FTP_ERR_PATH_TOO_LONG; - } - } - - src_fd = open(src_path, O_RDONLY); - - /* - * SOURCE FD CACHE POLICY — F_NOCACHE + POSIX_FADV_SEQUENTIAL - * - * ┌────────────────────────────────────────────────────────────────────┐ - * │ WHY F_NOCACHE ON THE SOURCE fd? │ - * │ │ - * │ For a sequential file-to-file copy the source pages are read │ - * │ exactly once and never needed again. Without F_NOCACHE, every │ - * │ read() places the just-read pages in the kernel page cache. For │ - * │ a 12 GB game file this fills all available daemon RSS (~3-4 GB │ - * │ on PS5) within the first few seconds of the copy. Once RAM is │ - * │ full the kernel must evict older cache pages on every new read, │ - * │ adding eviction overhead that degrades throughput from 400 MB/s │ - * │ (clean cache) to ~250 MB/s (cache thrashing). │ - * │ │ - * │ F_NOCACHE (FreeBSD equivalent of O_DIRECT) bypasses the page │ - * │ cache for this fd: source blocks are read directly into the │ - * │ pipeline buffer without caching, keeping RAM pressure flat for │ - * │ the entire duration of the copy regardless of file size. │ - * │ │ - * │ SAFETY: │ - * │ • This fd is NEVER passed to sendfile() — pal_file_copy_atomic_ex │ - * │ uses read()+write() loops only. The F_NOCACHE + sendfile() │ - * │ KP concern described in pal_file_open() does NOT apply here. │ - * │ • F_NOCACHE on the SOURCE (read) fd is safe on PS5 PFS even for │ - * │ encrypted /data files: decryption happens inside the kernel │ - * │ at the block layer before the data reaches userspace. │ - * │ • F_NOCACHE is deliberately NOT set on the DESTINATION (write) fd │ - * │ (dst_fd, opened below). The write-side has PFS alignment │ - * │ constraints: F_NOCACHE forces 512-byte-aligned write() calls │ - * │ and OrbisOS PFS returns EINVAL for unaligned writes, breaking │ - * │ the copy. Write caching is beneficial anyway (write-back │ - * │ coalescing helps the NVMe controller build large sequential │ - * │ extents rather than many small random writes). │ - * │ │ - * │ RESULT: sustained 400 MB/s for /data → /mnt/ext1 (USB-C M.2) │ - * │ instead of 400 MB/s for small files / 250 MB/s for 12+ GB files. │ - * └────────────────────────────────────────────────────────────────────┘ - * - * POSIX_FADV_SEQUENTIAL is also set: hints the kernel read-ahead engine - * to prefetch large contiguous extents. On PS5 this doubles read-ahead - * window from the default (128 KB) to 2× the file system block size, - * keeping the pipeline reader thread from stalling on rotational-latency- - * equivalent NVMe seek delays between extents. - */ - if (src_fd >= 0) { -#ifdef F_NOCACHE - /* - * Bypass page cache for source reads. - * Failure is non-fatal: fall through to cached reads (slower but correct). - */ - (void)fcntl(src_fd, F_NOCACHE, 1); -#endif -#ifdef F_RDAHEAD - (void)fcntl(src_fd, F_RDAHEAD, 1); -#endif -#if defined(POSIX_FADV_SEQUENTIAL) && !defined(PLATFORM_PS4) && !defined(PS4) - (void)posix_fadvise(src_fd, 0, 0, POSIX_FADV_SEQUENTIAL); -#endif - } - if (src_fd < 0) { - int e = errno; - { - char msg[256]; - snprintf(msg, sizeof(msg), - "[XDEV] open(src) failed: errno=%d path=%.*s", e, - PAL_LOG_PATH_CHARS, src_path); - ftp_log_line(FTP_LOG_WARN, msg); - } - if (out_errno != NULL) { - *out_errno = e; - } - switch (e) { - case ENOENT: - out_err = FTP_ERR_NOT_FOUND; - break; - case EACCES: - case EPERM: - out_err = FTP_ERR_PERMISSION; - break; - default: - out_err = FTP_ERR_FILE_OPEN; - break; - } - goto cleanup; - } - - mode_t mode = (mode_t)(st.st_mode & 0777); - - /* - * PRE-FLIGHT SPACE CHECK (PS4/PS5 PFS silent-full workaround) - * - * PFS on /data does not always propagate ENOSPC through write() — it - * can silently return 0, leaving errno==0 and making the failure - * completely opaque. Check available space up front so we can abort - * immediately with a clear diagnostic rather than failing mid-copy - * with errno=0 deep inside the write pipeline. - * - * statvfs() on the DESTINATION directory (not src) gives us the free - * blocks on the target filesystem. We accept up to a ~1 % statvfs - * race (file is being written by someone else), so the check is a - * warning rather than a hard block — we still attempt the copy and - * rely on the write-loop fix to surface ENOSPC if it happens anyway. - */ - { - /* Extract parent directory of dst_path for statvfs */ - char dst_dir[FTP_PATH_MAX]; - const char *dst_slash = strrchr(dst_path, '/'); - if (dst_slash != NULL && dst_slash != dst_path) { - size_t dlen = (size_t)(dst_slash - dst_path); - if (dlen < sizeof(dst_dir)) { - memcpy(dst_dir, dst_path, dlen); - dst_dir[dlen] = '\0'; - } else { - dst_dir[0] = '\0'; /* fallback: skip check */ - } - } else { - dst_dir[0] = '/'; - dst_dir[1] = '\0'; - } - - if (dst_dir[0] != '\0') { - struct statvfs vfs; - if (statvfs(dst_dir, &vfs) == 0) { - uint64_t free_bytes = - (uint64_t)vfs.f_bavail * (uint64_t)vfs.f_frsize; - uint64_t need_bytes = (uint64_t)st.st_size; - if (need_bytes > free_bytes) { - char msg[256]; - snprintf(msg, sizeof(msg), - "[XDEV] pre-flight ENOSPC: need=%llu free=%llu dst=%.*s", - (unsigned long long)need_bytes, - (unsigned long long)free_bytes, PAL_LOG_PATH_CHARS, - dst_path); - ftp_log_line(FTP_LOG_WARN, msg); - if (out_errno != NULL) { - *out_errno = ENOSPC; - } - return FTP_ERR_FILE_WRITE; - } - } - } - } - -#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5) - dst_fd = open(tmp_path, O_WRONLY | O_CREAT | O_TRUNC, mode); -#else - dst_fd = open(tmp_path, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, mode); -#endif - if (dst_fd < 0) { - int e = errno; - { - char msg[256]; - snprintf(msg, sizeof(msg), - "[XDEV] open(tmp) failed: errno=%d path=%.*s", e, - PAL_LOG_PATH_CHARS, tmp_path); - ftp_log_line(FTP_LOG_WARN, msg); - } - if (out_errno != NULL) { - *out_errno = e; - } - switch (e) { - case EACCES: - case EPERM: - out_err = FTP_ERR_PERMISSION; - break; - default: - out_err = FTP_ERR_FILE_OPEN; - break; - } - goto cleanup; - } - - /* - * DESIGN RATIONALE — mmap vs. buddy allocator: - * - * The buddy-allocator arena (16 MB) is shared by HTTP buffers, FTP - * session state, and all other daemon allocations. Two page-aligned - * 4 MB buffers need 2 × 8 MB (buddy rounding) = 16 MB of contiguous - * free space — which is the ENTIRE arena. Any prior allocation (even - * a few KB for an HTTP request header) fragments the arena and makes - * the second pipeline buffer allocation fail, forcing a silent fallback - * to the serial path at ~135 MB/s instead of the pipelined 215 MB/s. - * - * mmap(MAP_ANONYMOUS|MAP_PRIVATE) gets fresh page-aligned pages - * directly from the kernel, bypassing the buddy arena entirely. - * Memory is released with munmap() immediately after the copy, - * keeping the daemon footprint minimal when idle. - */ - /*=========================================================================* - * COPY LOOP - * - * PS5: double-buffer pipeline — reader thread (USB) runs in parallel - * with the writer (NVMe), achieving min(USB_bw, NVMe_bw) = 215 MB/s. - * - * Other: simple serial read→write loop. - *=========================================================================*/ - - /*-----------------------------------------------------------------------* - * Double-buffer copy pipeline - * - * Allocate both buffers with mmap() — page-aligned, no arena pressure. - * If either mmap fails (extremely unlikely: only under severe virtual - * memory exhaustion), fall through to the serial path. - *-----------------------------------------------------------------------*/ - { - uint8_t *dbuf0 = (uint8_t *)mmap(NULL, PAL_FILE_COPY_BUFFER_SIZE, - PROT_READ | PROT_WRITE, - MAP_ANONYMOUS | MAP_PRIVATE, -1, 0); - if (dbuf0 == MAP_FAILED) { dbuf0 = NULL; } - uint8_t *dbuf1 = (uint8_t *)mmap(NULL, PAL_FILE_COPY_BUFFER_SIZE, - PROT_READ | PROT_WRITE, - MAP_ANONYMOUS | MAP_PRIVATE, -1, 0); - if (dbuf1 == MAP_FAILED) { dbuf1 = NULL; } - - /* Log pipeline buffer acquisition — mmap bypasses the buddy arena - * so there is no arena_inuse/peak diagnostic to report. On PS5 - * MAP_ANONYMOUS is backed by the system VM and never fragments. */ - { - char msg[256]; - snprintf(msg, sizeof(msg), - "[XDEV] pipeline mmap: buf0=%s buf1=%s bufsz=%u file=%.*s", - (dbuf0 != NULL) ? "ok" : "NULL", - (dbuf1 != NULL) ? "ok" : "NULL", - (unsigned)PAL_FILE_COPY_BUFFER_SIZE, - PAL_LOG_PATH_CHARS, src_path); - ftp_log_line((dbuf0 && dbuf1) ? FTP_LOG_INFO : FTP_LOG_WARN, msg); - } - - if ((dbuf0 != NULL) && (dbuf1 != NULL)) { - /* Initialise pipeline state */ - copy_pipe_t pipe; - pipe.buf[0] = dbuf0; - pipe.buf[1] = dbuf1; - pipe.len[0] = 0U; - pipe.len[1] = 0U; - pipe.fill_idx = 0; - pipe.src_fd = src_fd; - pipe.buf_sz = (size_t)PAL_FILE_COPY_BUFFER_SIZE; - pipe.done = 0; - pipe.reader_err = 0; - pthread_mutex_init(&pipe.mtx, NULL); - pthread_cond_init(&pipe.cv_ready, NULL); - pthread_cond_init(&pipe.cv_free, NULL); - - pthread_t reader_tid; - int pt_ret = pthread_create(&reader_tid, NULL, copy_reader_thread, &pipe); - int thread_ok = (pt_ret == 0) ? 1 : 0; - - /* Log pthread_create result — on PS4 this can fail with EAGAIN (thread - * limit) or ENOMEM (stack allocation failed under memory pressure). */ - if (thread_ok == 0) { - char msg[256]; - snprintf(msg, sizeof(msg), - "[XDEV] pthread_create failed: errno=%d — " - "falling back to serial copy for %.*s", - pt_ret, PAL_LOG_PATH_CHARS, src_path); - ftp_log_line(FTP_LOG_WARN, msg); - } - - if (thread_ok != 0) { - /* Writer loop: drain the buffer that the reader just filled */ - ssize_t written = 0; /* last write result — checked after join */ - int write_errno = 0; /* saved errno from the last failed write(); - * hoisted outside the for loop so it remains - * accessible after break for the post-join log */ - pthread_mutex_lock(&pipe.mtx); - for (;;) { - /* Wait for a filled buffer or EOF/error */ - while ((pipe.len[1 - pipe.fill_idx] == 0U) && (pipe.done == 0)) { - pthread_cond_wait(&pipe.cv_ready, &pipe.mtx); - } - - int drain_idx = 1 - pipe.fill_idx; - size_t nbytes = pipe.len[drain_idx]; - - if ((nbytes == 0U) && (pipe.done != 0)) { - break; /* EOF — pipeline drained */ - } - - pthread_mutex_unlock(&pipe.mtx); - - /* - * Write the full buffer in a single write() call. - * - * We intentionally bypass pal_file_write_all()'s - * PAL_WRITE_CHUNK_MAX=128 KB subdivision here. That limit - * exists to prevent TCP recv-buffer stalls in cmd_STOR; it - * has no relevance for a file-to-file copy. A single 4 MB - * write() is processed by the PFS driver as a sequential - * extent, avoiding per-chunk AES-XTS context setup overhead. - */ - written = 0; - write_errno = 0; /* reset each iteration; saved before mutex lock */ - { - const uint8_t *p_out = pipe.buf[drain_idx]; - size_t remaining = nbytes; - while (remaining > 0U) { - ssize_t w = write(dst_fd, p_out, remaining); - if (w > 0) { - p_out += (size_t)w; - remaining -= (size_t)w; - written += w; - continue; - } - if ((w < 0) && (errno == EINTR)) { - continue; - } - /* - * Save errno NOW — pthread_mutex_lock() below can - * overwrite it on success (POSIX does not guarantee - * errno is untouched on a successful call). - * - * IMPORTANT — w == 0 case (PS4/PS5 PFS quirk): - * POSIX does not define write() returning 0 for a - * positive count on a regular file. On Orbis/Prospero - * PFS, a full filesystem silently returns 0 instead of - * -1 + ENOSPC. Detect this and synthesise ENOSPC so - * the log always shows a meaningful errno value. - */ - write_errno = (w == 0) ? ENOSPC : errno; - written = -1; - break; - } - } - - pthread_mutex_lock(&pipe.mtx); - - if (written < 0) { - /* - * Write error — signal reader to stop, then break. - * We log below after joining the reader thread. - */ - if (out_errno != NULL) { - *out_errno = write_errno; - } - pipe.done = 1; - pthread_cond_signal(&pipe.cv_free); - out_err = FTP_ERR_FILE_WRITE; - break; - } - - /* Report progress */ - if ((cb != NULL) && (cumulative != NULL)) { - *cumulative += (uint64_t)written; - if (cb(*cumulative, user_data) < 0) { - pipe.done = 1; - pthread_cond_signal(&pipe.cv_free); - out_err = FTP_ERR_UNKNOWN; /* cancelled */ - break; - } - } - - /* Mark buffer as free and wake reader */ - pipe.len[drain_idx] = 0U; - pthread_cond_signal(&pipe.cv_free); - } - pthread_mutex_unlock(&pipe.mtx); - - /* Join reader; collect any read-side error */ - (void)pthread_join(reader_tid, NULL); - - /* - * Post-join result resolution. - * - * Priority: write error > read error > cancellation > success. - * - * IMPORTANT: do NOT use `out_err` to detect write failures here. - * `out_err` is initialised to FTP_ERR_FILE_WRITE (line ~376) and - * is only changed during the loop for cancellation (FTP_ERR_UNKNOWN) - * or left at its initial value on both success AND write failure. - * Using it as a success/failure discriminator therefore confuses the - * two cases and causes a successful pipeline copy to return - * FTP_ERR_FILE_WRITE. Use `written < 0` instead — it is set to -1 - * exclusively by the write-error break path. - */ - if (written < 0) { - /* write() failed — write_errno was captured before mutex lock */ - char msg[256]; - snprintf(msg, sizeof(msg), "[COPY] write failed: errno=%d dst=%.*s", - write_errno, PAL_LOG_PATH_CHARS, dst_path); - ftp_log_line(FTP_LOG_WARN, msg); - if (out_errno != NULL) { - *out_errno = write_errno; - } - out_err = FTP_ERR_FILE_WRITE; - } else if (pipe.reader_err != 0) { - char msg[256]; - snprintf(msg, sizeof(msg), "[COPY] read failed: errno=%d src=%.*s", - pipe.reader_err, PAL_LOG_PATH_CHARS, src_path); - ftp_log_line(FTP_LOG_WARN, msg); - if (out_errno != NULL) { - *out_errno = pipe.reader_err; - } - out_err = FTP_ERR_FILE_READ; - } else if (out_err == FTP_ERR_UNKNOWN) { - /* cancelled by progress callback — out_err already set */ - } else { - /* Pipeline completed successfully */ - out_err = FTP_OK; - } - } else { - /* pthread_create failed — fall through to serial path below */ - out_err = FTP_ERR_FILE_WRITE; /* will be overwritten by serial path */ - } - - pthread_mutex_destroy(&pipe.mtx); - pthread_cond_destroy(&pipe.cv_ready); - pthread_cond_destroy(&pipe.cv_free); - - (void)munmap(dbuf0, PAL_FILE_COPY_BUFFER_SIZE); - (void)munmap(dbuf1, PAL_FILE_COPY_BUFFER_SIZE); - - if (thread_ok != 0) { - /* Pipeline ran (success or failure) — skip serial fallback */ - if (out_err != FTP_OK) { - goto cleanup; - } - goto copy_done; - } - /* thread_ok == 0: fall through to serial path (already logged above) */ - } else { - /* One or both mmap failed — release whichever succeeded, fall through */ - { - char msg[256]; - snprintf(msg, sizeof(msg), - "[XDEV] pipeline mmap failed (buf0=%s buf1=%s) — " - "falling back to serial copy for %.*s", - (dbuf0 != NULL) ? "ok" : "NULL", - (dbuf1 != NULL) ? "ok" : "NULL", - PAL_LOG_PATH_CHARS, src_path); - ftp_log_line(FTP_LOG_WARN, msg); - } - if (dbuf0 != NULL) { (void)munmap(dbuf0, PAL_FILE_COPY_BUFFER_SIZE); } - if (dbuf1 != NULL) { (void)munmap(dbuf1, PAL_FILE_COPY_BUFFER_SIZE); } - } - } - /* --- Serial fallback (mmap failure or pthread_create failure) --- */ - - { - char msg[256]; - snprintf(msg, sizeof(msg), - "[XDEV] serial copy starting: file_size=%llu buf=%u src=%.*s", - (unsigned long long)st.st_size, - (unsigned)PAL_FILE_COPY_BUFFER_SIZE, - PAL_LOG_PATH_CHARS, src_path); - ftp_log_line(FTP_LOG_INFO, msg); - } - - copy_buf = (uint8_t *)mmap(NULL, PAL_FILE_COPY_BUFFER_SIZE, - PROT_READ | PROT_WRITE, - MAP_ANONYMOUS | MAP_PRIVATE, -1, 0); - if (copy_buf == MAP_FAILED) { - copy_buf = NULL; - } - if (copy_buf == NULL) { - { - char msg[256]; - snprintf(msg, sizeof(msg), - "[XDEV] serial mmap failed: bufsz=%u errno=%d src=%.*s", - (unsigned)PAL_FILE_COPY_BUFFER_SIZE, - errno, - PAL_LOG_PATH_CHARS, src_path); - ftp_log_line(FTP_LOG_WARN, msg); - } - out_err = FTP_ERR_OUT_OF_MEMORY; - goto cleanup; - } - - { - uint64_t serial_written = 0U; - uint64_t serial_last_log = 0U; - /* Log every 64 MB so we can see how far the copy got before failing */ - const uint64_t LOG_INTERVAL = 64U * 1024U * 1024U; - - for (;;) { - ssize_t r = read(src_fd, copy_buf, (size_t)PAL_FILE_COPY_BUFFER_SIZE); - if (r > 0) { - /* - * Write the full buffer in a single write() loop — do NOT call - * pal_file_write_all(), which subdivides writes into - * PAL_FILE_WRITE_CHUNK_MAX (128 KB on PS5, 64 KB on PS4). - * - * That limit exists exclusively to prevent TCP recv-buffer stalls - * in cmd_STOR. It has no relevance for a file-to-file copy: large - * single write() calls let the USB exFAT driver allocate contiguous - * extents and avoid per-chunk FAT chain updates, which is the same - * rationale used by the double-buffer pipeline above. - * - * On PS5 (NVMe PFS, ~215 MB/s write bandwidth), using 128 KB chunks - * instead of 4 MB chunks causes ~32x more write() syscalls per buffer, - * each with its own AES-XTS context setup in the kernel VFS layer. - * This is the primary cause of the observed 130 MB/s cap vs the - * 230 MB/s target. - * - * PS4/PS5 PFS quirk: a full filesystem silently returns write() == 0 - * instead of -1 + ENOSPC. Detect and synthesise ENOSPC so the log - * always shows a meaningful errno value. - */ - { - const uint8_t *p_wr = copy_buf; - size_t rem_wr = (size_t)r; - int write_ok = 1; - int write_errno = 0; - - while (rem_wr > 0U) { - ssize_t w = write(dst_fd, p_wr, rem_wr); - if (w > 0) { - p_wr += (size_t)w; - rem_wr -= (size_t)w; - continue; - } - if ((w < 0) && (errno == EINTR)) { - continue; - } - /* - * w == 0: PS4/PS5 PFS silent ENOSPC — synthesise the real errno - * so the caller and log see a meaningful error code. - */ - write_errno = (w == 0) ? ENOSPC : errno; - write_ok = 0; - break; - } - - if (write_ok == 0) { - { - char msg[256]; - snprintf(msg, sizeof(msg), - "[XDEV] write failed: errno=%d written_so_far=%llu " - "file_size=%llu dst=%.*s", - write_errno, - (unsigned long long)serial_written, - (unsigned long long)st.st_size, - PAL_LOG_PATH_CHARS, dst_path); - ftp_log_line(FTP_LOG_WARN, msg); - if (out_errno != NULL) { - *out_errno = write_errno; - } - } - out_err = FTP_ERR_FILE_WRITE; - goto cleanup; - } - } - serial_written += (uint64_t)r; - - /* Periodic progress log */ - if (serial_written - serial_last_log >= LOG_INTERVAL) { - serial_last_log = serial_written; - char msg[256]; - snprintf(msg, sizeof(msg), - "[XDEV] serial progress: %llu / %llu bytes (%.1f%%) dst=%.*s", - (unsigned long long)serial_written, - (unsigned long long)st.st_size, - (st.st_size > 0) - ? (100.0 * (double)serial_written / (double)st.st_size) - : 0.0, - PAL_LOG_PATH_CHARS, dst_path); - ftp_log_line(FTP_LOG_INFO, msg); - } - - /* Report progress to caller; check for cancellation */ - if ((cb != NULL) && (cumulative != NULL)) { - *cumulative += (uint64_t)r; - if (cb(*cumulative, user_data) < 0) { - out_err = FTP_ERR_UNKNOWN; /* cancelled */ - goto cleanup; - } - } - continue; - } - if (r == 0) { - break; - } - if (errno == EINTR) { - continue; - } - { - int e = errno; - char msg[256]; - snprintf(msg, sizeof(msg), - "[XDEV] read failed: errno=%d written_so_far=%llu src=%.*s", - e, (unsigned long long)serial_written, PAL_LOG_PATH_CHARS, - src_path); - ftp_log_line(FTP_LOG_WARN, msg); - if (out_errno != NULL) { - *out_errno = e; - } - } - out_err = FTP_ERR_FILE_READ; - goto cleanup; - } /* end serial for(;;) loop */ - } /* end serial_written scope */ - - out_err = FTP_OK; - -copy_done:; - - /* - * POST-COPY CACHE EVICTION — release source file pages from page cache. - * - * After the copy pipeline drains the source file, all its pages are still - * pinned in the page cache (unless F_NOCACHE was effective above). - * POSIX_FADV_DONTNEED marks them as immediately reclaimable without - * waiting for memory pressure. - * - * This matters most when: - * (a) F_NOCACHE is unavailable (non-PS4/PS5 platforms) - * (b) F_NOCACHE failed silently (fcntl returned -1) - * (c) The src filesystem does not honour F_NOCACHE (some exFAT builds) - * - * On PS5 this keeps the daemon RSS low between consecutive copy operations, - * so that the page cache arena is fresh for the NEXT file's read pipeline - * rather than full of the previous file's stale blocks. - * - * Safe on both Linux and FreeBSD/PS5: fadvise DONTNEED on a read-only fd - * simply marks pages as low-priority; it never writes or invalidates data. - */ -#if defined(POSIX_FADV_DONTNEED) && !defined(PLATFORM_PS4) && !defined(PS4) - /* - * POST-COPY CACHE EVICTION — source file only. - * - * After the copy pipeline drains the source file, all its pages are still - * pinned in the page cache (unless F_NOCACHE was effective). - * POSIX_FADV_DONTNEED marks them as immediately reclaimable. - * - * DELIBERATELY SKIP dst_fd: on FreeBSD/PS5, POSIX_FADV_DONTNEED on a - * writable fd triggers a synchronous flush of all dirty pages through - * the filesystem's write path (PFS crypto on PS5 adds 5-15 ms per extent) - * before evicting them. This adds seconds of post-copy latency for large - * files and is the root cause of the USB→internal / internal→internal - * throughput regression observed between v1.4.0 and v1.5.0. The kernel - * already performs async writeback when memory pressure demands it; - * an explicit sync is unnecessary and harmful. - */ - if (src_fd >= 0) { - (void)posix_fadvise(src_fd, 0, 0, POSIX_FADV_DONTNEED); - } -#endif - - /* Log completion before the atomic rename so a crash here is diagnosable */ - { - char msg[256]; - snprintf(msg, sizeof(msg), - "[XDEV] copy complete, renaming tmp -> dst: %.*s", - PAL_LOG_PATH_CHARS, dst_path); - ftp_log_line(FTP_LOG_INFO, msg); - } - - if (rename(tmp_path, dst_path) < 0) { - { - int e = errno; - char msg[256]; - snprintf(msg, sizeof(msg), - "[XDEV] rename(tmp->dst) failed: errno=%d tmp=%.*s dst=%.*s", - e, PAL_LOG_PATH_PAIR_CHARS, tmp_path, PAL_LOG_PATH_PAIR_CHARS, - dst_path); - ftp_log_line(FTP_LOG_WARN, msg); - if (out_errno != NULL) { - *out_errno = e; - } - } - out_err = FTP_ERR_FILE_WRITE; - goto cleanup; - } - - out_err = FTP_OK; - -cleanup: - if (copy_buf != NULL) { - (void)munmap(copy_buf, PAL_FILE_COPY_BUFFER_SIZE); - } - if (dst_fd >= 0) { - (void)close(dst_fd); - } - if (src_fd >= 0) { - (void)close(src_fd); - } - if (out_err != FTP_OK) { - (void)unlink(tmp_path); - } - return out_err; -} - -/** - * @brief Safe file open - */ -int pal_file_open(const char *path, int flags, mode_t mode) { - if (path == NULL) { - errno = EINVAL; - return FTP_ERR_INVALID_PARAM; - } - - /* Check path length */ - size_t len = strlen(path); - if (len >= FTP_PATH_MAX) { - errno = ENAMETOOLONG; - return FTP_ERR_PATH_TOO_LONG; - } - - /* Open file */ - int fd = open(path, flags, mode); - if (fd < 0) { - /* Map errno to FTP error code */ - switch (errno) { - case ENOENT: - return FTP_ERR_NOT_FOUND; - case EACCES: - case EPERM: - return FTP_ERR_PERMISSION; - case EMFILE: - case ENFILE: - return FTP_ERR_OUT_OF_MEMORY; - default: - return FTP_ERR_FILE_OPEN; - } - } - -#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5) - /* - * F_NOCACHE / POSIX_FADV_SEQUENTIAL — PLATFORM NOTES - * - * F_NOCACHE (FreeBSD O_DIRECT equivalent): - * Bypasses the kernel page cache for this fd. This is intentionally - * NOT set on PS5 because sendfile(2) relies on the page cache to pin - * source pages before DMA-ing them to the socket buffer. Setting - * F_NOCACHE on an fd that is later passed to sendfile() produces - * undefined behavior on FreeBSD; on PS5's modified kernel this - * manifests as a kernel panic when the file resides on exFAT/USB. - * - * PS4: F_NOCACHE is retained — PS4 does not use sendfile() for the - * data transfer path (VFS_CAP_SENDFILE is not set by vfs_open on PS4 - * for USB-backed files via the fstatfs check added above). - * - * POSIX_FADV_SEQUENTIAL: - * Safe on PS5 — does not affect sendfile() compatibility. - * Retained for read-ahead hinting on sequential transfers. - */ -#if defined(PLATFORM_PS4) -#ifdef F_NOCACHE - /* F_NOCACHE is only safe for read-only fds on PFS-encrypted partitions. - * For write fds, OrbisOS PFS enforces alignment constraints that cause - * write() to fail with EINVAL after ~512 KB on /data/pkg/ and similar - * encrypted mounts. GoldHEN/ftpsrv never set F_NOCACHE; skip it. */ - if ((flags & O_WRONLY) == 0 && (flags & O_RDWR) == 0) { - (void)fcntl(fd, F_NOCACHE, 1); - } -#endif -#endif /* PLATFORM_PS4 only — explicitly excluded from PS5 */ - -#if defined(PLATFORM_PS5) && defined(POSIX_FADV_SEQUENTIAL) - (void)posix_fadvise(fd, 0, 0, POSIX_FADV_SEQUENTIAL); -#endif -#endif - - return fd; -} - -/** - * @brief Safe file close - * - * PLATFORM NOTE — EINTR semantics differ between Linux and FreeBSD: - * - * Linux: close() interrupted by a signal still closes the fd. - * Retrying would attempt to close an fd already freed and - * potentially reused by another thread — silent data corruption. - * - * FreeBSD: close() interrupted by a signal does NOT close the fd. - * The fd remains open and MUST be retried, or it leaks. - * This is the behaviour on PS4 and PS5. - * - * Failure to handle this distinction on PS5 causes one leaked fd per - * interrupted close(), accumulating into BUDGET_FD_FILE exhaustion - * (visible in klog as "called fdescfree(), but remain BUDGET_FD_FILE"). - * - * @pre fd >= 0 - * @note Thread-safety: safe (fd is caller-owned) - */ -ftp_error_t pal_file_close(int fd) { - if (fd < 0) { - return FTP_ERR_INVALID_PARAM; - } - -#if defined(__FreeBSD__) || defined(PLATFORM_PS4) || defined(PLATFORM_PS5) - /* - * FreeBSD/PS4/PS5: EINTR means fd is STILL OPEN — must retry. - * Cap iterations to avoid looping forever on a persistent signal storm. - */ - { - unsigned retries = 0U; - const unsigned MAX_CLOSE_RETRIES = 8U; - while (close(fd) < 0) { - if (errno != EINTR) { - return FTP_ERR_FILE_WRITE; - } - if (++retries >= MAX_CLOSE_RETRIES) { - return FTP_ERR_FILE_WRITE; /* give up; fd leaks, not much we can do */ - } - } - } -#else - /* - * Linux / generic POSIX: close() with EINTR has already closed the fd. - * Do NOT retry — the fd number may be reused by this point. - */ - if (close(fd) < 0) { - if (errno != EINTR) { - return FTP_ERR_FILE_WRITE; - } - /* EINTR on Linux: fd is gone, treat as success */ - } -#endif - - return FTP_OK; -} - -/** - * @brief Get file status - */ -ftp_error_t pal_file_stat(const char *path, struct stat *st) { - if ((path == NULL) || (st == NULL)) { - return FTP_ERR_INVALID_PARAM; - } - - if (stat(path, st) < 0) { - switch (errno) { - case ENOENT: - return FTP_ERR_NOT_FOUND; - case EACCES: - return FTP_ERR_PERMISSION; - default: - return FTP_ERR_FILE_STAT; - } - } - - return FTP_OK; -} - -/** - * @brief Get file status from descriptor - */ -ftp_error_t pal_file_fstat(int fd, struct stat *st) { - if ((fd < 0) || (st == NULL)) { - return FTP_ERR_INVALID_PARAM; - } - - if (fstat(fd, st) < 0) { - return FTP_ERR_FILE_STAT; - } - - return FTP_OK; -} - -/** - * @brief Read file data - */ -ssize_t pal_file_read(int fd, void *buffer, size_t count) { - if ((fd < 0) || (buffer == NULL) || (count == 0U)) { - errno = EINVAL; - return -1; - } - - return read(fd, buffer, count); -} - -/** - * @brief Write file data - */ -ssize_t pal_file_write(int fd, const void *buffer, size_t count) { - if ((fd < 0) || (buffer == NULL) || (count == 0U)) { - errno = EINVAL; - return -1; - } - - return write(fd, buffer, count); -} - -ssize_t pal_file_write_all(int fd, const void *buffer, size_t count) { - if ((fd < 0) || (buffer == NULL) || (count == 0U)) { - errno = EINVAL; - return -1; - } - - const uint8_t *p = (const uint8_t *)buffer; - size_t total = 0U; - - while (total < count) { - size_t remaining = count - total; - size_t chunk = remaining; - if (chunk > (size_t)PAL_FILE_WRITE_CHUNK_MAX) { - chunk = (size_t)PAL_FILE_WRITE_CHUNK_MAX; - } - ssize_t n = write(fd, p + total, chunk); - if (n > 0) { - total += (size_t)n; - continue; - } - if (n == 0) { - /* POSIX does not define write() returning 0 for count > 0. - * On PS4/PS5 PFS a full filesystem silently returns 0 instead - * of -1 + ENOSPC. Map this to ENOSPC so callers see the real - * cause rather than the misleading EIO. */ - errno = ENOSPC; - return -1; - } - if (errno == EINTR) { - continue; - } - return -1; - } - - return (ssize_t)total; -} - -/** - * @brief Seek to file position - */ -off_t pal_file_seek(int fd, off_t offset, int whence) { - if (fd < 0) { - errno = EINVAL; - return -1; - } - - return lseek(fd, offset, whence); -} - -/** - * @brief Truncate file - */ -ftp_error_t pal_file_truncate(int fd, off_t len) { - if ((fd < 0) || (len < 0)) { - return FTP_ERR_INVALID_PARAM; - } - -#ifdef PLATFORM_PS4 - /* PS4: Use syscall directly */ - if (syscall(480, fd, len) < 0) { - return FTP_ERR_FILE_WRITE; - } -#else - /* POSIX: Standard ftruncate */ - if (ftruncate(fd, len) < 0) { - return FTP_ERR_FILE_WRITE; - } -#endif - - return FTP_OK; -} - -/** - * @brief Delete file - */ -ftp_error_t pal_file_delete(const char *path) { - if (path == NULL) { - return FTP_ERR_INVALID_PARAM; - } - - if (unlink(path) < 0) { - switch (errno) { - case ENOENT: - return FTP_ERR_NOT_FOUND; - case EACCES: - case EPERM: - return FTP_ERR_PERMISSION; - case EISDIR: - return FTP_ERR_INVALID_PARAM; /* Use rmdir for directories */ - default: - return FTP_ERR_FILE_WRITE; - } - } - - return FTP_OK; -} - -/** - * @brief Change permission bits (SITE CHMOD) - */ -ftp_error_t pal_file_chmod(const char *path, mode_t mode) { - if (path == NULL) { - return FTP_ERR_INVALID_PARAM; - } - - if (chmod(path, mode) < 0) { - switch (errno) { - case ENOENT: - return FTP_ERR_NOT_FOUND; - case EACCES: - case EPERM: - case EINVAL: -#if defined(EOPNOTSUPP) - case EOPNOTSUPP: -#elif defined(ENOTSUP) - case ENOTSUP: -#endif - return FTP_ERR_PERMISSION; - default: - return FTP_ERR_FILE_WRITE; - } - } - - return FTP_OK; -} - -/*===========================================================================* - * CROSS-DEVICE MOVE (EXDEV fallback) - * - * rename() fails with EXDEV when src and dst live on different - * filesystems (e.g. /data/homebrew → /mnt/ext1). For regular - * files we already had pal_file_copy_atomic(). The functions - * below extend the fallback to entire directory trees. - * - * pal_file_rename - * │ - * ├── rename() ─── OK? done - * │ - * └── EXDEV? - * │ - * ├── regular file → pal_file_copy_atomic + unlink - * │ - * └── directory → pal_move_cross_device_r - * ├── mkdir dst - * ├── for each entry: - * │ ├── file → copy_atomic + unlink - * │ └── dir → recurse - * └── rmdir src - *===========================================================================*/ - -/** - * @brief Remove a directory tree recursively (depth-first). - * - * Used to clean up the source tree after a successful cross-device - * copy, or to roll back a partial destination on failure. - */ -static ftp_error_t pal_dir_remove_recursive(const char *path, unsigned depth) { - if (path == NULL) { - return FTP_ERR_INVALID_PARAM; - } - if (depth > PAL_MOVE_MAX_DEPTH) { - return FTP_ERR_PATH_TOO_LONG; - } - - DIR *dir = opendir(path); - if (dir == NULL) { - if (errno == ENOENT) { - return FTP_OK; - } - { - char msg[256]; - snprintf(msg, sizeof(msg), - "[XDEV] opendir(cleanup) failed: errno=%d path=%.*s", errno, - PAL_LOG_PATH_CHARS, path); - ftp_log_line(FTP_LOG_WARN, msg); - } - return FTP_ERR_DIR_OPEN; - } - - struct dirent *ent; - ftp_error_t err = FTP_OK; - - while ((ent = readdir(dir)) != NULL) { - /* Skip "." and ".." */ - if ((ent->d_name[0] == '.') && - ((ent->d_name[1] == '\0') || - ((ent->d_name[1] == '.') && (ent->d_name[2] == '\0')))) { - continue; - } - - char child[FTP_PATH_MAX]; - int nmax = (int)(sizeof(child) - 2 - strlen(ent->d_name)); - if (nmax < 0) { err = FTP_ERR_PATH_TOO_LONG; break; } - int n = snprintf(child, sizeof(child), "%.*s/%s", nmax, path, ent->d_name); - if ((n < 0) || ((size_t)n >= sizeof(child))) { - err = FTP_ERR_PATH_TOO_LONG; - break; - } - - struct stat st; - if (stat(child, &st) < 0) { - err = FTP_ERR_FILE_STAT; - break; - } - - if (S_ISDIR(st.st_mode)) { - err = pal_dir_remove_recursive(child, depth + 1U); - } else { - if (unlink(child) != 0) { - { - char msg[256]; - snprintf(msg, sizeof(msg), - "[XDEV] unlink(cleanup) failed: errno=%d path=%.*s", errno, - PAL_LOG_PATH_CHARS, child); - ftp_log_line(FTP_LOG_WARN, msg); - } - err = FTP_ERR_FILE_WRITE; - } - } - - if (err != FTP_OK) { - break; - } - } - - (void)closedir(dir); - - if (err == FTP_OK) { - if (rmdir(path) < 0) { - { - char msg[256]; - snprintf(msg, sizeof(msg), - "[XDEV] rmdir(cleanup) failed: errno=%d path=%.*s", errno, - PAL_LOG_PATH_CHARS, path); - ftp_log_line(FTP_LOG_WARN, msg); - } - err = FTP_ERR_FILE_WRITE; - } - } - - return err; -} - -/** - * @brief Recursively move a directory tree across filesystems. - * - * Creates the destination directory, copies every file with - * pal_file_copy_atomic(), recurses into subdirectories, then - * removes each source entry after its copy succeeds. - * - * On failure the partial destination is cleaned up and the - * source is left intact so the user can retry. - */ -static ftp_error_t pal_copy_cross_device_r_ex(const char *src, const char *dst, - unsigned depth, int keep_src, - pal_copy_progress_cb_t cb, - void *user_data, - uint64_t *cumulative, - int *out_errno) { - if ((src == NULL) || (dst == NULL)) { - return FTP_ERR_INVALID_PARAM; - } - if (depth > PAL_MOVE_MAX_DEPTH) { - { - char msg[256]; - snprintf(msg, sizeof(msg), "[XDEV] max depth %u exceeded: %.*s", - (unsigned)PAL_MOVE_MAX_DEPTH, PAL_LOG_PATH_CHARS, src); - ftp_log_line(FTP_LOG_WARN, msg); - } - return FTP_ERR_PATH_TOO_LONG; - } - - if (depth == 0U) { - char msg[256]; - snprintf(msg, sizeof(msg), "[XDEV] cross-device move: %.*s -> %.*s", - PAL_LOG_PATH_PAIR_CHARS, src, PAL_LOG_PATH_PAIR_CHARS, dst); - ftp_log_line(FTP_LOG_INFO, msg); - } - - /* Stat source to get permissions */ - struct stat src_st; - if (stat(src, &src_st) < 0) { - int e = errno; - { - char msg[256]; - snprintf(msg, sizeof(msg), - "[XDEV] stat(src) failed: errno=%d path=%.*s", e, - PAL_LOG_PATH_CHARS, src); - ftp_log_line(FTP_LOG_WARN, msg); - } - return (e == ENOENT) ? FTP_ERR_NOT_FOUND : FTP_ERR_FILE_STAT; - } - - /*-------------------------------------------------------* - * Leaf: regular file -> atomic copy + delete source * - *-------------------------------------------------------*/ - if (S_ISREG(src_st.st_mode)) { - /* Use a local errno so the error log below always shows the real OS code - * even when the caller passes NULL for out_errno (e.g. the async copy - * thread). If the caller DID provide out_errno, we forward into it. */ - int local_errno = 0; - int *errno_ptr = (out_errno != NULL) ? out_errno : &local_errno; - ftp_error_t err = - pal_file_copy_atomic_ex(src, dst, cb, user_data, cumulative, errno_ptr); - if (err != FTP_OK) { - { - int os_err = *errno_ptr; /* always valid: either *out_errno or local_errno */ - char msg[256]; - snprintf(msg, sizeof(msg), - "[XDEV] file copy failed (err=%d, errno=%d): %.*s -> %.*s", - (int)err, os_err, PAL_LOG_PATH_PAIR_CHARS, src, - PAL_LOG_PATH_PAIR_CHARS, dst); - ftp_log_line(FTP_LOG_WARN, msg); - } - return err; - } - if (keep_src == 0) { - if (unlink(src) < 0) { - { - char msg[256]; - snprintf(msg, sizeof(msg), - "[XDEV] unlink(src) failed: errno=%d path=%.*s", errno, - PAL_LOG_PATH_CHARS, src); - ftp_log_line(FTP_LOG_WARN, msg); - } - /* Copy succeeded but source delete failed — not fatal, - caller gets an error but data is safe at dst. */ - return FTP_ERR_FILE_WRITE; - } - } - return FTP_OK; - } - - /*-------------------------------------------------------* - * Branch: directory → mkdir dst, recurse, rmdir src * - *-------------------------------------------------------*/ - if (!S_ISDIR(src_st.st_mode)) { - /* Symlinks, devices, etc. — skip silently */ - return FTP_OK; - } - - mode_t mode = (mode_t)(src_st.st_mode & 0777); - - /* - * DATA LOSS BUG — fixed here. - * - * Original code: mkdir() silently accepts EEXIST, then on failure - * calls pal_dir_remove_recursive(dst) unconditionally — deleting a - * directory that pre-existed and was not created by this move. - * - * Fix: track whether WE created dst. Roll back only our own work; - * never touch a directory that existed before this call. - */ - int dst_created_by_us = 0; - if (mkdir(dst, mode) < 0) { - if (errno != EEXIST) { - int e = errno; - { - char msg[256]; - snprintf(msg, sizeof(msg), - "[XDEV] mkdir(dst) failed: errno=%d path=%.*s", e, - PAL_LOG_PATH_CHARS, dst); - ftp_log_line(FTP_LOG_WARN, msg); - } - return FTP_ERR_FILE_WRITE; - } - /* dst already existed — do not delete it on rollback */ - } else { - dst_created_by_us = 1; - } - - DIR *dir = opendir(src); - if (dir == NULL) { - { - char msg[256]; - snprintf(msg, sizeof(msg), - "[XDEV] opendir(src) failed: errno=%d path=%.*s", errno, - PAL_LOG_PATH_CHARS, src); - ftp_log_line(FTP_LOG_WARN, msg); - } - if (dst_created_by_us != 0) { - (void)rmdir(dst); /* undo our mkdir before returning */ - } - return FTP_ERR_DIR_OPEN; - } - - struct dirent *ent; - ftp_error_t err = FTP_OK; - - while ((ent = readdir(dir)) != NULL) { - /* Skip "." and ".." */ - if ((ent->d_name[0] == '.') && - ((ent->d_name[1] == '\0') || - ((ent->d_name[1] == '.') && (ent->d_name[2] == '\0')))) { - continue; - } - - char src_child[FTP_PATH_MAX]; - char dst_child[FTP_PATH_MAX]; - - int ns = snprintf(src_child, sizeof(src_child), "%.*s/%s", - (int)(sizeof(src_child) - 2 - strlen(ent->d_name)), - src, ent->d_name); - int nd = snprintf(dst_child, sizeof(dst_child), "%.*s/%s", - (int)(sizeof(dst_child) - 2 - strlen(ent->d_name)), - dst, ent->d_name); - - if ((ns < 0) || ((size_t)ns >= sizeof(src_child)) || (nd < 0) || - ((size_t)nd >= sizeof(dst_child))) { - err = FTP_ERR_PATH_TOO_LONG; - break; - } - - err = pal_copy_cross_device_r_ex(src_child, dst_child, depth + 1U, keep_src, - cb, user_data, cumulative, out_errno); - if (err != FTP_OK) { - break; - } - } - - (void)closedir(dir); - - if (err != FTP_OK) { - /* - * Roll back only if we created dst. If it pre-existed, leave it - * alone — its original contents are not our responsibility. - * Source is untouched so the user can retry. - */ - if (dst_created_by_us != 0) { - (void)pal_dir_remove_recursive(dst, 0U); - } - return err; - } - - if (keep_src == 0) { - /* Source directory should now be empty — remove it */ - if (rmdir(src) < 0) { - { - char msg[256]; - snprintf(msg, sizeof(msg), - "[XDEV] rmdir(src) failed: errno=%d path=%.*s", errno, - PAL_LOG_PATH_CHARS, src); - ftp_log_line(FTP_LOG_WARN, msg); - } - return FTP_ERR_FILE_WRITE; - } - } - - if (depth == 0U) { - ftp_log_line(FTP_LOG_INFO, "[XDEV] cross-device operation completed OK"); - } - - return FTP_OK; -} - -/** - * @brief Rename/move file or directory - * - * Falls back to recursive copy + delete when rename() returns - * EXDEV (source and destination on different filesystems). - */ -ftp_error_t pal_file_rename(const char *old_path, const char *new_path) { - if ((old_path == NULL) || (new_path == NULL)) { - return FTP_ERR_INVALID_PARAM; - } - - if (rename(old_path, new_path) < 0) { - switch (errno) { - case ENOENT: - return FTP_ERR_NOT_FOUND; - case EACCES: - case EPERM: - return FTP_ERR_PERMISSION; - case EXDEV: - return FTP_ERR_CROSS_DEVICE; - default: - return FTP_ERR_FILE_WRITE; - } - } - - return FTP_OK; -} - -/** - * @brief Recursively copy file or directory - */ -ftp_error_t pal_file_copy_recursive(const char *src, const char *dst, - int keep_src) { - uint64_t cum = 0U; - return pal_copy_cross_device_r_ex(src, dst, 0U, keep_src, NULL, NULL, &cum, - NULL); -} - -ftp_error_t pal_file_copy_recursive_ex(const char *src, const char *dst, - int keep_src, pal_copy_progress_cb_t cb, - void *user_data, int *out_errno) { - uint64_t cum = 0U; - return pal_copy_cross_device_r_ex(src, dst, 0U, keep_src, cb, user_data, &cum, - out_errno); -} - -/*===========================================================================* - * DIRECTORY OPERATIONS - *===========================================================================*/ - -/** - * @brief Create directory - */ -ftp_error_t pal_dir_create(const char *path, mode_t mode) { - if (path == NULL) { - return FTP_ERR_INVALID_PARAM; - } - - if (mkdir(path, mode) < 0) { - switch (errno) { - case EEXIST: - return FTP_ERR_DIR_EXISTS; /* Already exists */ - case EACCES: - case EPERM: - return FTP_ERR_PERMISSION; - case ENOENT: - return FTP_ERR_NOT_FOUND; /* Parent doesn't exist */ - default: - return FTP_ERR_FILE_WRITE; - } - } - - return FTP_OK; -} - -/** - * @brief Remove directory - */ -ftp_error_t pal_dir_remove(const char *path) { - if (path == NULL) { - return FTP_ERR_INVALID_PARAM; - } - - if (rmdir(path) < 0) { - switch (errno) { - case ENOENT: - return FTP_ERR_NOT_FOUND; - case EACCES: - case EPERM: - return FTP_ERR_PERMISSION; - case ENOTEMPTY: - return FTP_ERR_INVALID_PARAM; /* Directory not empty */ - case ENOTDIR: - return FTP_ERR_INVALID_PARAM; /* Not a directory */ - default: - return FTP_ERR_FILE_WRITE; - } - } - - return FTP_OK; -} - -/** - * @brief Check if path exists - */ -int pal_path_exists(const char *path) { - if (path == NULL) { - return FTP_ERR_INVALID_PARAM; - } - - struct stat st; - if (stat(path, &st) == 0) { - return 1; /* Exists */ - } - - if (errno == ENOENT) { - return 0; /* Does not exist */ - } - - return FTP_ERR_FILE_STAT; /* Other error */ -} - -/** - * @brief Check if path is a directory - */ -int pal_path_is_directory(const char *path) { - if (path == NULL) { - return FTP_ERR_INVALID_PARAM; - } - - struct stat st; - if (stat(path, &st) < 0) { - return FTP_ERR_FILE_STAT; - } - - return S_ISDIR(st.st_mode) ? 1 : 0; -} - -/** - * @brief Check if path is a regular file - */ -int pal_path_is_file(const char *path) { - if (path == NULL) { - return FTP_ERR_INVALID_PARAM; - } - - struct stat st; - if (stat(path, &st) < 0) { - return FTP_ERR_FILE_STAT; - } - - return S_ISREG(st.st_mode) ? 1 : 0; -} - -/** - * @brief Remove a directory tree recursively (public wrapper) - * - * Wraps the static pal_dir_remove_recursive() for use by callers outside - * this translation unit (e.g. http_api.c api_delete with recursive=1). - * - * @param[in] path Root of the tree to remove (must not be NULL or "/") - * @return FTP_OK on success, FTP_ERR_* on failure - * - * @note Thread-safety: NOT thread-safe - * @note All files and subdirectories are removed depth-first. - * This is irreversible — call only after user confirmation. - * @warning Do NOT call on paths that are still in use by other processes. - */ -ftp_error_t pal_dir_remove_recursive_pub(const char *path) { - if (path == NULL) { - return FTP_ERR_INVALID_PARAM; - } - return pal_dir_remove_recursive(path, 0U); -} diff --git a/src/platform/pal_filesystem.c b/src/platform/pal_filesystem.c index 7f4e818..8843b1e 100644 --- a/src/platform/pal_filesystem.c +++ b/src/platform/pal_filesystem.c @@ -109,9 +109,6 @@ ftp_error_t vfs_open(vfs_node_t *node, const char *path) * available for platform-internal use (e.g. module loading) but must not * be on the data-transfer path. * - * @note VFS_CAP_SENDFILE is now always set on PS4/PS5 — pal_sendfile() - * handles unsupported filesystems with a transparent - * pread()+send_all() fallback when the kernel returns EINVAL. */ int fd = pal_file_open(path, O_RDONLY, 0); @@ -130,46 +127,8 @@ ftp_error_t vfs_open(vfs_node_t *node, const char *path) node->size = (uint64_t)st.st_size; node->offset = 0U; - /* - * SENDFILE SAFETY CHECK (PS4/PS5) - * - * DESIGN RATIONALE: - * FreeBSD's sendfile(2) uses the kernel VM pager to DMA file pages - * directly into the socket buffer (zero userspace copy). This requires - * the source vnode's pager to implement the vm_pager_ops interface. - * - * On PS5's modified FreeBSD kernel, the exFAT (exfatfs) and FAT32 - * (msdosfs) drivers used for USB storage do NOT implement this - * interface correctly. Calling sendfile() on a vnode backed by these - * filesystems dereferences a null/invalid pager function pointer, - * causing an immediate kernel panic (KP). - * - * nullfs mirrors the underlying vnode — if the origin is exFAT, the - * nullfs vnode inherits the same broken pager ops. - * - * Detection: fstatfs() on the open fd returns the filesystem type - * name without any additional syscall cost. For USB-backed filesystems - * we clear VFS_CAP_SENDFILE, forcing the buffered read/write path. - * - * @see pal_sendfile() in pal_fileio.c — callers must check this cap - * @see https://github.com/seregonwar/zftpd — KP report from USB download - */ -#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5) - /* - * SENDFILE — enabled on all PS4/PS5 filesystems. - * - * The previous blacklist (exfatfs, msdosfs, nullfs, pfsmnt, pfs) - * disabled VFS_CAP_SENDFILE for most actual filesystems - * on PS5, forcing the read()+send() path for FTP RETR. - * - * Zero-copy sendfile is the only transfer path. If the kernel - * cannot DMA from a particular filesystem, the transfer fails - * with 426 — there is no silent userspace fallback. - */ + /* Transfer backends operate on raw file bytes; capability is handled by pal_sendfile(). */ node->caps = VFS_CAP_SENDFILE; -#else - node->caps = VFS_CAP_SENDFILE; -#endif return FTP_OK; } diff --git a/tests/test_fileio_basic.c b/tests/test_fileio_basic.c new file mode 100644 index 0000000..af91a25 --- /dev/null +++ b/tests/test_fileio_basic.c @@ -0,0 +1,124 @@ +#include "pal_fileio.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define CHECK(x) do { \ + if (!(x)) { \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #x); \ + return -1; \ + } \ +} while (0) + +static int make_tcp_pair(int *server_fd, int *client_fd) { + int listener = socket(AF_INET, SOCK_STREAM, 0); + if (listener < 0) return -1; + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; + if (bind(listener, (struct sockaddr *)&addr, sizeof(addr)) != 0 || + listen(listener, 1) != 0) { + close(listener); + return -1; + } + socklen_t len = sizeof(addr); + if (getsockname(listener, (struct sockaddr *)&addr, &len) != 0) { + close(listener); + return -1; + } + + int client = socket(AF_INET, SOCK_STREAM, 0); + if (client < 0 || connect(client, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + if (client >= 0) close(client); + close(listener); + return -1; + } + int server = accept(listener, NULL, NULL); + close(listener); + if (server < 0) { close(client); return -1; } + *server_fd = server; + *client_fd = client; + return 0; +} +static int test_basic_io(const char *path) { + int fd = pal_file_open(path, O_RDWR | O_CREAT | O_TRUNC, 0600); + CHECK(fd >= 0); + CHECK(pal_file_write(fd, NULL, 0U) == 0); + CHECK(pal_file_write_all(fd, NULL, 0U) == 0); + + static const char payload[] = "abcdef"; + CHECK(pal_file_write_all(fd, payload, sizeof(payload) - 1U) == + (ssize_t)(sizeof(payload) - 1U)); + CHECK(pal_file_seek(fd, 0, SEEK_SET) == 0); + CHECK(pal_file_read(fd, NULL, 0U) == 0); + + char buf[8] = {0}; + CHECK(pal_file_read(fd, buf, 6U) == 6); + CHECK(memcmp(buf, payload, 6U) == 0); + CHECK(pal_file_truncate(fd, 3) == FTP_OK); + + struct stat st; + CHECK(pal_file_fstat(fd, &st) == FTP_OK); + CHECK(st.st_size == 3); + CHECK(pal_file_close(fd) == FTP_OK); + return 0; +} +static int test_sendfile_path(const char *path) { + int file_fd = pal_file_open(path, O_RDONLY, 0); + CHECK(file_fd >= 0); + int server_fd = -1, client_fd = -1; + CHECK(make_tcp_pair(&server_fd, &client_fd) == 0); + + off_t offset = 0; + CHECK(pal_sendfile(server_fd, file_fd, &offset, 0U) == 0); + size_t remaining = 3U; + while (remaining > 0U) { + ssize_t n = pal_sendfile(server_fd, file_fd, &offset, remaining); + CHECK(n > 0); + CHECK((size_t)n <= remaining); + remaining -= (size_t)n; + } + CHECK(offset == 3); + + char buf[4] = {0}; + size_t got = 0U; + while (got < 3U) { + ssize_t n = recv(client_fd, buf + got, 3U - got, 0); + CHECK(n > 0); + got += (size_t)n; + } + CHECK(memcmp(buf, "abc", 3U) == 0); + errno = 0; + CHECK(pal_sendfile(server_fd, file_fd, NULL, 1U) == -1); + CHECK(errno == EINVAL); + CHECK(pal_file_close(file_fd) == FTP_OK); + close(server_fd); + close(client_fd); + return 0; +} + +int main(void) { + char path[] = "/tmp/zftpd-fileio-basic-XXXXXX"; + int seed_fd = mkstemp(path); + CHECK(seed_fd >= 0); + close(seed_fd); + + CHECK(test_basic_io(path) == 0); + CHECK(test_sendfile_path(path) == 0); + CHECK(pal_file_stat(path, &(struct stat){0}) == FTP_OK); + CHECK(pal_file_delete(path) == FTP_OK); + CHECK(pal_file_stat(path, &(struct stat){0}) == FTP_ERR_NOT_FOUND); + CHECK(pal_path_exists(path) == 0); + CHECK(pal_path_is_file(path) == 0); + CHECK(pal_path_is_directory(path) == 0); + puts("test_fileio_basic: ok"); + return 0; +} diff --git a/tests/test_fileio_tree.c b/tests/test_fileio_tree.c new file mode 100644 index 0000000..401e157 --- /dev/null +++ b/tests/test_fileio_tree.c @@ -0,0 +1,224 @@ +#include "pal_fileio.h" +#include +#include +#include +#include +#include +#include +#include +#include + +#define CHECK(x) do { \ + if (!(x)) { \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #x); \ + return -1; \ + } \ +} while (0) + +static int write_text(const char *path, const char *text) { + FILE *fp = fopen(path, "wb"); + if (fp == NULL) return -1; + size_t len = strlen(text); + int ok = fwrite(text, 1U, len, fp) == len; + fclose(fp); + return ok ? 0 : -1; +} +static int file_equals(const char *path, const char *expected) { + char buf[128]; + FILE *fp = fopen(path, "rb"); + if (fp == NULL) return 0; + size_t n = fread(buf, 1U, sizeof(buf) - 1U, fp); + fclose(fp); + buf[n] = '\0'; + return strcmp(buf, expected) == 0; +} + +static int write_large_file(const char *path, size_t size) { + int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd < 0) return -1; + unsigned char buf[65536]; + memset(buf, 0xA5, sizeof(buf)); + size_t left = size; + while (left > 0U) { + size_t n = left < sizeof(buf) ? left : sizeof(buf); + if (write(fd, buf, n) != (ssize_t)n) { close(fd); return -1; } + left -= n; + } + return close(fd); +} +static int cancel_copy(uint64_t bytes, void *user_data) { + int *calls = (int *)user_data; + (void)bytes; + (*calls)++; + return -1; +} + +static int test_temp_collision(const char *root) { + char src[FTP_PATH_MAX], dst[FTP_PATH_MAX], stale[FTP_PATH_MAX]; + CHECK(snprintf(src, sizeof(src), "%s/collision-src", root) > 0); + CHECK(snprintf(dst, sizeof(dst), "%s/collision-dst", root) > 0); + CHECK(snprintf(stale, sizeof(stale), "%s/.zftpd.%lu.0.tmp", root, + (unsigned long)getpid()) > 0); + CHECK(write_text(src, "payload") == 0); + CHECK(write_text(stale, "sentinel") == 0); + + CHECK(pal_file_copy_recursive(src, dst, 1) == FTP_OK); + CHECK(file_equals(dst, "payload")); + CHECK(file_equals(stale, "sentinel")); + CHECK(unlink(src) == 0); + CHECK(unlink(dst) == 0); + CHECK(unlink(stale) == 0); + return 0; +} + +static int test_recursive_copy(const char *root) { + char src[FTP_PATH_MAX], nested[FTP_PATH_MAX], file[FTP_PATH_MAX]; + char dst[FTP_PATH_MAX], copied[FTP_PATH_MAX]; + CHECK(snprintf(src, sizeof(src), "%s/src", root) > 0); + CHECK(snprintf(nested, sizeof(nested), "%s/nested", src) > 0); + CHECK(snprintf(file, sizeof(file), "%s/value.txt", nested) > 0); + CHECK(snprintf(dst, sizeof(dst), "%s/dst", root) > 0); + CHECK(snprintf(copied, sizeof(copied), "%s/nested/value.txt", dst) > 0); + + CHECK(mkdir(src, 0700) == 0); + CHECK(mkdir(nested, 0700) == 0); + CHECK(write_text(file, "tree-copy") == 0); + CHECK(pal_file_copy_recursive(src, dst, 1) == FTP_OK); + CHECK(file_equals(file, "tree-copy")); + CHECK(file_equals(copied, "tree-copy")); + return 0; +} +static int test_invalid_copy_roots(const char *root) { + char src[FTP_PATH_MAX], child[FTP_PATH_MAX]; + CHECK(snprintf(src, sizeof(src), "%s/src", root) > 0); + CHECK(snprintf(child, sizeof(child), "%s/inside-copy", src) > 0); + + CHECK(pal_file_copy_recursive(src, src, 1) == FTP_ERR_INVALID_PARAM); + CHECK(pal_file_copy_recursive(src, child, 1) == FTP_ERR_INVALID_PARAM); + CHECK(pal_path_exists(child) == 0); + CHECK(pal_file_copy_recursive("/", child, 1) == FTP_ERR_INVALID_PARAM); + return 0; +} + +static int count_copy_temps(const char *dir_path) { + DIR *dir = opendir(dir_path); + if (dir == NULL) return -1; + int count = 0; + struct dirent *ent; + while ((ent = readdir(dir)) != NULL) { + if (strncmp(ent->d_name, ".zftpd.", 7U) == 0) count++; + } + closedir(dir); + return count; +} +static int test_cancelled_copy(const char *root) { + char src[FTP_PATH_MAX], dst[FTP_PATH_MAX]; + CHECK(snprintf(src, sizeof(src), "%s/cancel-src.bin", root) > 0); + CHECK(snprintf(dst, sizeof(dst), "%s/cancel-dst.bin", root) > 0); + CHECK(write_large_file(src, 6U * 1024U * 1024U) == 0); + + int calls = 0; + int os_errno = 0; + ftp_error_t err = pal_file_copy_recursive_ex(src, dst, 1, cancel_copy, + &calls, &os_errno); + CHECK(err == FTP_ERR_CANCELLED); + CHECK(calls == 1); + CHECK(pal_path_exists(src) == 1); + CHECK(pal_path_exists(dst) == 0); + CHECK(count_copy_temps(root) == 0); + CHECK(unlink(src) == 0); + return 0; +} + +static int test_special_file_rejected(const char *root) { + char fifo_path[FTP_PATH_MAX], dst[FTP_PATH_MAX]; + CHECK(snprintf(fifo_path, sizeof(fifo_path), "%s/input.fifo", root) > 0); + CHECK(snprintf(dst, sizeof(dst), "%s/fifo-copy", root) > 0); + CHECK(mkfifo(fifo_path, 0600) == 0); + CHECK(pal_file_copy_recursive(fifo_path, dst, 1) == FTP_ERR_INVALID_PARAM); + CHECK(pal_path_exists(dst) == 0); + CHECK(unlink(fifo_path) == 0); + return 0; +} +static int test_symlink_copy(const char *root) { + char src[FTP_PATH_MAX], dst[FTP_PATH_MAX], link_src[FTP_PATH_MAX]; + char link_dst[FTP_PATH_MAX], external[FTP_PATH_MAX], target[FTP_PATH_MAX]; + CHECK(snprintf(src, sizeof(src), "%s/link-src", root) > 0); + CHECK(snprintf(dst, sizeof(dst), "%s/link-dst", root) > 0); + CHECK(snprintf(link_src, sizeof(link_src), "%s/external-link", src) > 0); + CHECK(snprintf(link_dst, sizeof(link_dst), "%s/external-link", dst) > 0); + CHECK(snprintf(external, sizeof(external), "%s/external.txt", root) > 0); + CHECK(mkdir(src, 0700) == 0); + CHECK(write_text(external, "outside") == 0); + CHECK(symlink("../external.txt", link_src) == 0); + CHECK(pal_file_copy_recursive(src, dst, 1) == FTP_OK); + + struct stat st; + CHECK(lstat(link_dst, &st) == 0 && S_ISLNK(st.st_mode)); + ssize_t n = readlink(link_dst, target, sizeof(target) - 1U); + CHECK(n > 0); + target[n] = '\0'; + CHECK(strcmp(target, "../external.txt") == 0); + CHECK(file_equals(external, "outside")); + CHECK(pal_dir_remove_recursive_pub(src) == FTP_OK); + CHECK(pal_dir_remove_recursive_pub(dst) == FTP_OK); + CHECK(unlink(external) == 0); + return 0; +} + +static int test_delete_does_not_follow_symlink(const char *root) { + char tree[FTP_PATH_MAX], external[FTP_PATH_MAX], sentinel[FTP_PATH_MAX]; + char link_path[FTP_PATH_MAX]; + CHECK(snprintf(tree, sizeof(tree), "%s/delete-link-tree", root) > 0); + CHECK(snprintf(external, sizeof(external), "%s/external-dir", root) > 0); + CHECK(snprintf(sentinel, sizeof(sentinel), "%s/sentinel.txt", external) > 0); + CHECK(snprintf(link_path, sizeof(link_path), "%s/outside", tree) > 0); + CHECK(mkdir(tree, 0700) == 0); + CHECK(mkdir(external, 0700) == 0); + CHECK(write_text(sentinel, "keep-me") == 0); + CHECK(symlink(external, link_path) == 0); + + CHECK(pal_dir_remove_recursive_pub(tree) == FTP_OK); + CHECK(pal_path_exists(tree) == 0); + CHECK(file_equals(sentinel, "keep-me")); + CHECK(unlink(sentinel) == 0); + CHECK(rmdir(external) == 0); + return 0; +} + +static int test_recursive_delete(const char *root) { + char tree[FTP_PATH_MAX], nested[FTP_PATH_MAX], file[FTP_PATH_MAX]; + CHECK(snprintf(tree, sizeof(tree), "%s/delete-tree", root) > 0); + CHECK(snprintf(nested, sizeof(nested), "%s/nested", tree) > 0); + CHECK(snprintf(file, sizeof(file), "%s/value.txt", nested) > 0); + CHECK(mkdir(tree, 0700) == 0); + CHECK(mkdir(nested, 0700) == 0); + CHECK(write_text(file, "remove-me") == 0); + + CHECK(pal_dir_remove_recursive_pub("/") == FTP_ERR_PERMISSION); + CHECK(pal_dir_remove_recursive_pub("/./") == FTP_ERR_PERMISSION); + CHECK(pal_dir_remove_recursive_pub(tree) == FTP_OK); + CHECK(pal_path_exists(tree) == 0); + return 0; +} + +int main(void) { + char root[] = "/tmp/zftpd-fileio-tree-XXXXXX"; + CHECK(mkdtemp(root) != NULL); + CHECK(test_temp_collision(root) == 0); + CHECK(test_recursive_copy(root) == 0); + CHECK(test_invalid_copy_roots(root) == 0); + CHECK(test_cancelled_copy(root) == 0); + CHECK(test_special_file_rejected(root) == 0); + CHECK(test_symlink_copy(root) == 0); + CHECK(test_delete_does_not_follow_symlink(root) == 0); + CHECK(test_recursive_delete(root) == 0); + char src[FTP_PATH_MAX], dst[FTP_PATH_MAX]; + CHECK(snprintf(src, sizeof(src), "%s/src", root) > 0); + CHECK(snprintf(dst, sizeof(dst), "%s/dst", root) > 0); + CHECK(pal_dir_remove_recursive_pub(src) == FTP_OK); + CHECK(pal_dir_remove_recursive_pub(dst) == FTP_OK); + CHECK(rmdir(root) == 0); + puts("test_fileio_tree: ok"); + return 0; +}