From a4209961e241178b3bb04fb17d97223aed2d5bad Mon Sep 17 00:00:00 2001 From: ITotalJustice <47043333+ITotalJustice@users.noreply.github.com> Date: Thu, 4 Sep 2025 22:29:35 +0100 Subject: [PATCH] devoptab: add ftp mount with random read and streaming write support. read still needs some work. opening a file should open a thread where data is read/written async. this avoids the huge number of roundtrips per r/w. eg, a read currently has to REST, open the data socket, send the file data, send ABOR to close and then close the data socket. using the thread approach would just send the file over the already open data socket. writes will also benefit from the above, just instead of REST it would be APPE. seeks would need to do the above ABORT, close and re-open. however most reads in sphaira are not random access. so this isn't an issue. --- sphaira/CMakeLists.txt | 1 + sphaira/include/utils/devoptab.hpp | 4 + sphaira/source/app.cpp | 10 + sphaira/source/location.cpp | 25 +- sphaira/source/utils/devoptab_ftp.cpp | 999 +++++++++++++++++++++++++ sphaira/source/utils/devoptab_http.cpp | 14 +- 6 files changed, 1040 insertions(+), 13 deletions(-) create mode 100644 sphaira/source/utils/devoptab_ftp.cpp diff --git a/sphaira/CMakeLists.txt b/sphaira/CMakeLists.txt index 4427400..53d4443 100644 --- a/sphaira/CMakeLists.txt +++ b/sphaira/CMakeLists.txt @@ -108,6 +108,7 @@ add_executable(sphaira source/utils/devoptab_http.cpp source/utils/devoptab_nfs.cpp source/utils/devoptab_smb2.cpp + source/utils/devoptab_ftp.cpp source/usb/base.cpp source/usb/usbds.cpp diff --git a/sphaira/include/utils/devoptab.hpp b/sphaira/include/utils/devoptab.hpp index c53afc2..ce93641 100644 --- a/sphaira/include/utils/devoptab.hpp +++ b/sphaira/include/utils/devoptab.hpp @@ -37,6 +37,10 @@ Result MountHttpAll(); Result GetHttpMounts(location::StdioEntries& out); void UnmountHttpAll(); +Result MountFtpAll(); +Result GetFtpMounts(location::StdioEntries& out); +void UnmountFtpAll(); + Result MountNfsAll(); Result GetNfsMounts(location::StdioEntries& out); void UnmountNfsAll(); diff --git a/sphaira/source/app.cpp b/sphaira/source/app.cpp index 522bf24..41536eb 100644 --- a/sphaira/source/app.cpp +++ b/sphaira/source/app.cpp @@ -1597,6 +1597,11 @@ App::App(const char* argv0) { devoptab::MountHttpAll(); } + { + SCOPED_TIMESTAMP("ftp init"); + devoptab::MountFtpAll(); + } + { SCOPED_TIMESTAMP("nfs init"); devoptab::MountNfsAll(); @@ -2205,6 +2210,11 @@ App::~App() { devoptab::UnmountHttpAll(); } + { + SCOPED_TIMESTAMP("ftp exit"); + devoptab::UnmountFtpAll(); + } + { SCOPED_TIMESTAMP("nfs exit"); devoptab::UnmountNfsAll(); diff --git a/sphaira/source/location.cpp b/sphaira/source/location.cpp index dbc2f74..ccbadbc 100644 --- a/sphaira/source/location.cpp +++ b/sphaira/source/location.cpp @@ -80,11 +80,22 @@ auto Load() -> Entries { auto GetStdio(bool write) -> StdioEntries { StdioEntries out{}; + const auto add_from_entries = [](const StdioEntries& entries, StdioEntries& out, bool write) { + for (const auto& e : entries) { + if (write && e.write_protect) { + log_write("[STDIO] skipping read only mount: %s\n", e.name.c_str()); + continue; + } + + out.emplace_back(e); + } + }; + { StdioEntries entries; if (R_SUCCEEDED(devoptab::GetNfsMounts(entries))) { log_write("[NFS] got nfs mounts: %zu\n", entries.size()); - out.insert(out.end(), entries.begin(), entries.end()); + add_from_entries(entries, out, write); } } @@ -92,7 +103,7 @@ auto GetStdio(bool write) -> StdioEntries { StdioEntries entries; if (R_SUCCEEDED(devoptab::GetSmb2Mounts(entries))) { log_write("[SMB2] got smb2 mounts: %zu\n", entries.size()); - out.insert(out.end(), entries.begin(), entries.end()); + add_from_entries(entries, out, write); } } @@ -100,7 +111,15 @@ auto GetStdio(bool write) -> StdioEntries { StdioEntries entries; if (R_SUCCEEDED(devoptab::GetHttpMounts(entries))) { log_write("[HTTP] got http mounts: %zu\n", entries.size()); - out.insert(out.end(), entries.begin(), entries.end()); + add_from_entries(entries, out, write); + } + } + + { + StdioEntries entries; + if (R_SUCCEEDED(devoptab::GetFtpMounts(entries))) { + log_write("[FTP] got ftp mounts: %zu\n", entries.size()); + add_from_entries(entries, out, write); } } diff --git a/sphaira/source/utils/devoptab_ftp.cpp b/sphaira/source/utils/devoptab_ftp.cpp new file mode 100644 index 0000000..9b48272 --- /dev/null +++ b/sphaira/source/utils/devoptab_ftp.cpp @@ -0,0 +1,999 @@ +#include "utils/devoptab_common.hpp" +#include "utils/profile.hpp" + +#include "location.hpp" +#include "log.hpp" +#include "defines.hpp" +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sphaira::devoptab { +namespace { + +constexpr long DEFAULT_FTP_PORT = 21; +constexpr long DEFAULT_FTP_TIMEOUT = 3000; // 3 seconds. + +#define CURL_EASY_SETOPT_LOG(handle, opt, v) \ + if (auto r = curl_easy_setopt(handle, opt, v); r != CURLE_OK) { \ + log_write("curl_easy_setopt(%s, %s) msg: %s\n", #opt, #v, curl_easy_strerror(r)); \ + } \ + +#define CURL_EASY_GETINFO_LOG(handle, opt, v) \ + if (auto r = curl_easy_getinfo(handle, opt, v); r != CURLE_OK) { \ + log_write("curl_easy_getinfo(%s, %s) msg: %s\n", #opt, #v, curl_easy_strerror(r)); \ + } \ + +struct FtpMountConfig { + std::string name{}; + std::string url{}; + std::string user{}; + std::string pass{}; + std::optional port{}; + long timeout{DEFAULT_FTP_TIMEOUT}; + bool read_only{}; +}; +using FtpMountConfigs = std::vector; + +struct Device { + CURL* curl{}; + FtpMountConfig config{}; + Mutex mutex{}; + bool mounted{}; +}; + +struct DirEntry { + std::string name{}; + bool is_dir{}; +}; +using DirEntries = std::vector; + +struct FileEntry { + std::string path{}; + struct stat st{}; +}; + +struct File { + Device* client; + FileEntry* entry; + size_t off; + bool write_mode; +}; + +struct Dir { + Device* client; + DirEntries* entries; + size_t index; +}; + +size_t write_memory_callback(char *ptr, size_t size, size_t nmemb, void *userdata) { + auto data = static_cast*>(userdata); + + // increase by chunk size. + const auto realsize = size * nmemb; + if (data->capacity() < data->size() + realsize) { + const auto rsize = std::max(realsize, data->size() + 1024 * 1024); + data->reserve(rsize); + } + + // store the data. + const auto offset = data->size(); + data->resize(offset + realsize); + std::memcpy(data->data() + offset, ptr, realsize); + + return realsize; +} + +size_t write_data_callback(char *ptr, size_t size, size_t nmemb, void *userdata) { + auto data = static_cast*>(userdata); + const auto rsize = std::min(size * nmemb, data->size()); + + std::memcpy(data->data(), ptr, rsize); + *data = data->subspan(rsize); + return rsize; +} + +size_t read_data_callback(char *ptr, size_t size, size_t nmemb, void *userdata) { + auto data = static_cast*>(userdata); + const auto rsize = std::min(size * nmemb, data->size()); + + std::memcpy(ptr, data->data(), rsize); + *data = data->subspan(rsize); + return rsize; +} + +std::string url_encode(const std::string& str) { + auto escaped = curl_escape(str.c_str(), str.length()); + if (!escaped) { + return str; + } + + std::string result(escaped); + curl_free(escaped); + return result; +} + +std::string build_url(const std::string& base, const std::string& path, bool is_dir) { + std::string url = base; + if (!url.ends_with('/')) { + url += '/'; + } + + url += url_encode(path); + if (is_dir && !url.ends_with('/')) { + url += '/'; // append trailing slash for folder. + } + + return url; +} + +void ftp_set_common_options(Device& client, const std::string& url) { + curl_easy_reset(client.curl); + CURL_EASY_SETOPT_LOG(client.curl, CURLOPT_URL, url.c_str()); + CURL_EASY_SETOPT_LOG(client.curl, CURLOPT_TIMEOUT_MS, client.config.timeout); + CURL_EASY_SETOPT_LOG(client.curl, CURLOPT_CONNECTTIMEOUT_MS, client.config.timeout); + CURL_EASY_SETOPT_LOG(client.curl, CURLOPT_NOPROGRESS, 0L); + CURL_EASY_SETOPT_LOG(client.curl, CURLOPT_FTP_CREATE_MISSING_DIRS, CURLFTP_CREATE_DIR_NONE); + CURL_EASY_SETOPT_LOG(client.curl, CURLOPT_FTP_FILEMETHOD, CURLFTPMETHOD_NOCWD); + + if (client.config.port) { + CURL_EASY_SETOPT_LOG(client.curl, CURLOPT_PORT, client.config.port.value()); + } + + if (!client.config.user.empty()) { + CURL_EASY_SETOPT_LOG(client.curl, CURLOPT_USERNAME, client.config.user.c_str()); + } + if (!client.config.pass.empty()) { + CURL_EASY_SETOPT_LOG(client.curl, CURLOPT_PASSWORD, client.config.pass.c_str()); + } +} + +bool ftp_parse_mlst_line(std::string_view line, struct stat* st, std::string* file_out, bool type_only) { + // trim leading white space. + while (line.size() > 0 && std::isspace(line[0])) { + line = line.substr(1); + } + + auto file_name_pos = line.rfind(';'); + if (file_name_pos == std::string_view::npos || file_name_pos + 1 >= line.size()) { + return false; + } + + // trim white space. + while (file_name_pos + 1 < line.size() && std::isspace(line[file_name_pos + 1])) { + file_name_pos++; + } + auto file_name = line.substr(file_name_pos + 1); + + auto facts = line.substr(0, file_name_pos); + if (file_name.empty()) { + return false; + } + + bool found_type = false; + while (!facts.empty()) { + const auto sep = facts.find(';'); + if (sep == std::string_view::npos) { + break; + } + + const auto fact = facts.substr(0, sep); + facts = facts.substr(sep + 1); + + const auto eq = fact.find('='); + if (eq == std::string_view::npos || eq + 1 >= fact.size()) { + continue; + } + + const auto key = fact.substr(0, eq); + const auto val = fact.substr(eq + 1); + + if (key == "type") { + if (val == "file") { + st->st_mode = S_IFREG | S_IRUSR | S_IRGRP | S_IROTH; + } else if (val == "dir") { + st->st_mode = S_IFDIR | S_IRUSR | S_IRGRP | S_IROTH; + } else { + log_write("[FTP] Unknown type fact value: %.*s\n", (int)val.size(), val.data()); + return false; + } + + found_type = true; + } else if (!type_only) { + if (key == "size") { + st->st_size = std::stoull(std::string(val)); + } else if (key == "modify") { + if (val.size() >= 14) { + struct tm tm{}; + tm.tm_year = std::stoi(std::string(val.substr(0, 4))) - 1900; + tm.tm_mon = std::stoi(std::string(val.substr(4, 2))) - 1; + tm.tm_mday = std::stoi(std::string(val.substr(6, 2))); + tm.tm_hour = std::stoi(std::string(val.substr(8, 2))); + tm.tm_min = std::stoi(std::string(val.substr(10, 2))); + tm.tm_sec = std::stoi(std::string(val.substr(12, 2))); + st->st_mtime = std::mktime(&tm); + st->st_atime = st->st_mtime; + st->st_ctime = st->st_mtime; + } + } + } + } + + if (!found_type) { + log_write("[FTP] MLST line missing type fact\n"); + return false; + } + + st->st_nlink = 1; + if (file_out) { + *file_out = std::string(file_name.data(), file_name.size()); + } + + return true; +} + +/* +C> MLst file1 +S> 250- Listing file1 +S> Type=file;Modify=19990929003355.237; file1 +S> 250 End +*/ +bool ftp_parse_mlist(std::string_view chunk, struct stat* st) { + // sometimes the header data includes the full login exchange + // so we need to find the actual start of the MLST response. + const auto start_pos = chunk.find("250-"); + const auto end_pos = chunk.rfind("\n250"); + + if (start_pos == std::string_view::npos || end_pos == std::string_view::npos) { + log_write("[FTP] MLST response missing start or end\n"); + return false; + } + + const auto end_line = chunk.find('\n', start_pos + 1); + if (end_line == std::string_view::npos || end_line > end_pos) { + log_write("[FTP] MLST response missing end line\n"); + return false; + } + + chunk = chunk.substr(end_line + 1, end_pos - (end_line + 1)); + return ftp_parse_mlst_line(chunk, st, nullptr, false); +} + +/* +C> MLSD tmp +S> 150 BINARY connection open for MLSD tmp +D> Type=cdir;Modify=19981107085215;Perm=el; tmp +D> Type=cdir;Modify=19981107085215;Perm=el; /tmp +D> Type=pdir;Modify=19990112030508;Perm=el; .. +D> Type=file;Size=25730;Modify=19940728095854;Perm=; capmux.tar.z +D> Type=file;Size=1024990;Modify=19980130010322;Perm=r; cap60.pl198.tar.gz +S> 226 MLSD completed +*/ +void ftp_parse_mlsd(std::string_view chunk, DirEntries& out) { + if (chunk.ends_with("\r\n")) { + chunk = chunk.substr(0, chunk.size() - 2); + } else if (chunk.ends_with('\n')) { + chunk = chunk.substr(0, chunk.size() - 1); + } + + for (const auto line : std::views::split(chunk, '\n')) { + std::string_view line_str(line.data(), line.size()); + if (line_str.empty() || line_str == "\r") { + continue; + } + + DirEntry entry{}; + struct stat st{}; + if (!ftp_parse_mlst_line(line_str, &st, &entry.name, true)) { + log_write("[FTP] Failed to parse MLSD line: %.*s\n", (int)line.size(), line.data()); + continue; + } + + entry.is_dir = S_ISDIR(st.st_mode); + out.emplace_back(entry); + } +} + +std::pair ftp_quote(Device& client, std::span commands, bool is_dir, std::vector* response_data = nullptr) { + const auto url = build_url(client.config.url, "/", is_dir); + + curl_slist* cmdlist{}; + ON_SCOPE_EXIT(curl_slist_free_all(cmdlist)); + + for (const auto& cmd : commands) { + cmdlist = curl_slist_append(cmdlist, cmd.c_str()); + } + + ftp_set_common_options(client, url); + CURL_EASY_SETOPT_LOG(client.curl, CURLOPT_QUOTE, cmdlist); + CURL_EASY_SETOPT_LOG(client.curl, CURLOPT_NOBODY, 1L); + + if (response_data) { + response_data->clear(); + CURL_EASY_SETOPT_LOG(client.curl, CURLOPT_HEADERFUNCTION, write_memory_callback); + CURL_EASY_SETOPT_LOG(client.curl, CURLOPT_HEADERDATA, (void *)response_data); + } + + const auto res = curl_easy_perform(client.curl); + if (res != CURLE_OK) { + log_write("[FTP] curl_easy_perform() failed: %s\n", curl_easy_strerror(res)); + return {false, 0}; + } + + long response_code = 0; + CURL_EASY_GETINFO_LOG(client.curl, CURLINFO_RESPONSE_CODE, &response_code); + return {true, response_code}; +} + +bool ftp_dirlist(Device& client, const std::string& path, DirEntries& out) { + const auto url = build_url(client.config.url, path, true); + std::vector chunk; + + ftp_set_common_options(client, url); + CURL_EASY_SETOPT_LOG(client.curl, CURLOPT_WRITEFUNCTION, write_memory_callback); + CURL_EASY_SETOPT_LOG(client.curl, CURLOPT_WRITEDATA, (void *)&chunk); + CURL_EASY_SETOPT_LOG(client.curl, CURLOPT_CUSTOMREQUEST, "MLSD"); + + const auto res = curl_easy_perform(client.curl); + if (res != CURLE_OK) { + log_write("[FTP] curl_easy_perform() failed: %s\n", curl_easy_strerror(res)); + return false; + } + + ftp_parse_mlsd({chunk.data(), chunk.size()}, out); + return true; +} + +bool ftp_stat(Device& client, const std::string& path, struct stat* st, bool is_dir) { + std::memset(st, 0, sizeof(*st)); + + std::vector chunk; + const auto [success, response_code] = ftp_quote(client, {"MLST " + path}, is_dir, &chunk); + if (!success) { + return false; + } + + if (!success || response_code >= 400) { + log_write("[FTP] MLST command failed with response code: %ld\n", response_code); + return false; + } + + if (!ftp_parse_mlist({chunk.data(), chunk.size()}, st)) { + log_write("[FTP] Failed to parse MLST response for path: %s\n", path.c_str()); + return false; + } + + return true; +} + +bool ftp_read_file_chunk(Device& client, const std::string& path, size_t start, std::span buffer) { + const auto url = build_url(client.config.url, path, false); + + char range[64]{}; + std::snprintf(range, sizeof(range), "%zu-%zu", start, start + buffer.size() - 1); + log_write("[FTP] Requesting range: %s\n", range); + + ftp_set_common_options(client, url); + CURL_EASY_SETOPT_LOG(client.curl, CURLOPT_RANGE, range); + CURL_EASY_SETOPT_LOG(client.curl, CURLOPT_WRITEFUNCTION, write_data_callback); + CURL_EASY_SETOPT_LOG(client.curl, CURLOPT_WRITEDATA, (void *)&buffer); + + const auto res = curl_easy_perform(client.curl); + if (res != CURLE_OK) { + log_write("[FTP] curl_easy_perform() failed: %s\n", curl_easy_strerror(res)); + return false; + } + + return true; +} + +bool ftp_write_file_chunk(Device& client, const std::string& path, size_t start, std::span buffer) { + // manually set offset as curl seems to not do anything if CURLOPT_RESUME_FROM_LARGE is used for ftp. + // NOTE: RFC 3659 specifies that setting the offset to anything other than the end for STOR + // is undefined behavior, so random access writes are disabled for now. + #if 0 + if (start || !buffer.empty()) { + const auto [success, response_code] = ftp_quote(client, {"REST " + std::to_string(start)}, false); + if (!success || response_code != 350) { + log_write("[FTP] REST command failed with response code: %ld\n", response_code); + return false; + } + } + #endif + + const auto url = build_url(client.config.url, path, false); + + log_write("[FTP] Writing %zu bytes at offset %zu to %s\n", buffer.size(), start, path.c_str()); + ftp_set_common_options(client, url); + CURL_EASY_SETOPT_LOG(client.curl, CURLOPT_UPLOAD, 1L); + CURL_EASY_SETOPT_LOG(client.curl, CURLOPT_INFILESIZE_LARGE, (curl_off_t)buffer.size()); + CURL_EASY_SETOPT_LOG(client.curl, CURLOPT_READFUNCTION, read_data_callback); + CURL_EASY_SETOPT_LOG(client.curl, CURLOPT_READDATA, (void *)&buffer); + + // set resume from if needed. + if (start || !buffer.empty()) { + CURL_EASY_SETOPT_LOG(client.curl, CURLOPT_APPEND, 1L); + } + + const auto res = curl_easy_perform(client.curl); + if (res != CURLE_OK) { + log_write("[FTP] curl_easy_perform() failed: %s\n", curl_easy_strerror(res)); + return false; + } + + return true; +} + +bool ftp_remove_file_folder(Device& client, const std::string& path, bool is_dir) { + const auto cmd = (is_dir ? "RMD " : "DELE ") + path; + const auto [success, response_code] = ftp_quote(client, {cmd}, is_dir); + if (!success || response_code >= 400) { + log_write("[FTP] MLST command failed with response code: %ld\n", response_code); + return false; + } + + return true; +} + +bool ftp_unlink(Device& client, const std::string& path) { + return ftp_remove_file_folder(client, path, false); +} + +bool ftp_rename(Device& client, const std::string& old_path, const std::string& new_path, bool is_dir) { + const auto url = build_url(client.config.url, "/", is_dir); + + std::vector commands; + commands.emplace_back("RNFR " + old_path); + commands.emplace_back("RNTO " + new_path); + + const auto [success, response_code] = ftp_quote(client, commands, is_dir); + if (!success || response_code >= 400) { + log_write("[FTP] MLST command failed with response code: %ld\n", response_code); + return false; + } + + return true; +} + +bool ftp_mkdir(Device& client, const std::string& path) { + std::vector chunk; + const auto [success, response_code] = ftp_quote(client, {"MKD " + path}, true); + if (!success) { + return false; + } + + // todo: handle result if directory already exists. + if (response_code >= 400) { + log_write("[FTP] MLST command failed with response code: %ld\n", response_code); + return false; + } + + return true; +} + +bool ftp_rmdir(Device& client, const std::string& path) { + return ftp_remove_file_folder(client, path, true); +} + +bool mount_ftp(Device& client) { + if (client.mounted) { + return true; + } + + if (!client.curl) { + client.curl = curl_easy_init(); + if (!client.curl) { + log_write("[FTP] curl_easy_init() failed\n"); + return false; + } + } + + // issue FEAT command to see if we support MLST/MLSD. + std::vector chunk; + const auto [success, response_code] = ftp_quote(client, {"FEAT"}, true, &chunk); + if (!success || response_code != 211) { + log_write("[FTP] FEAT command failed with response code: %ld\n", response_code); + return false; + } + + std::string_view view(chunk.data(), chunk.size()); + + // check for MLST/MLSD support. + // NOTE: RFC 3659 states that servers must support MLSD if they support MLST. + if (view.find("MLST") == std::string_view::npos) { + log_write("[FTP] Server does not support MLST/MLSD commands\n"); + return false; + } + + // if we support UTF8, enable it. + if (view.find("UTF8") != std::string_view::npos) { + // it doesn't matter if this fails tbh. + // also, i am not sure if this persists between logins or not... + ftp_quote(client, {"OPTS UTF8 ON"}, true); + } + + client.mounted = true; + return true; +} + +int set_errno(struct _reent *r, int err) { + r->_errno = err; + return -1; +} + +int devoptab_open(struct _reent *r, void *fileStruct, const char *_path, int flags, int mode) { + auto device = (Device*)r->deviceData; + auto file = static_cast(fileStruct); + std::memset(file, 0, sizeof(*file)); + SCOPED_MUTEX(&device->mutex); + + if (device->config.read_only && (flags & (O_WRONLY | O_RDWR | O_CREAT | O_TRUNC | O_APPEND))) { + return set_errno(r, EROFS); + } + + char path[PATH_MAX]{}; + if (!common::fix_path(_path, path)) { + return set_errno(r, ENOENT); + } + + if (!mount_ftp(*device)) { + return set_errno(r, EIO); + } + + // create an empty file. + if (flags & (O_CREAT | O_TRUNC)) { + std::span empty{}; + if (!ftp_write_file_chunk(*device, path, 0, empty)) { + log_write("[FTP] Failed to create file: %s\n", path); + return set_errno(r, EIO); + } + } + + // ensure the file exists and get its size. + struct stat st{}; + if (!ftp_stat(*device, path, &st, false)) { + log_write("[FTP] File not found: %s\n", path); + return set_errno(r, ENOENT); + } + + if (st.st_mode & S_IFDIR) { + log_write("[FTP] Path is a directory, not a file: %s\n", path); + return set_errno(r, EISDIR); + } + + if (flags & O_APPEND) { + file->off = st.st_size; + } else { + file->off = 0; + } + + log_write("[FTP] Opened file: %s (size=%zu)\n", path, (size_t)st.st_size); + file->client = device; + file->entry = new FileEntry{path, st}; + file->write_mode = (flags & (O_WRONLY | O_RDWR)); + return r->_errno = 0; +} + +int devoptab_close(struct _reent *r, void *fd) { + auto file = static_cast(fd); + SCOPED_MUTEX(&file->client->mutex); + + delete file->entry; + std::memset(file, 0, sizeof(*file)); + return r->_errno = 0; +} + +ssize_t devoptab_read(struct _reent *r, void *fd, char *ptr, size_t len) { + auto file = static_cast(fd); + SCOPED_MUTEX(&file->client->mutex); + len = std::min(len, file->entry->st.st_size - file->off); + + if (file->write_mode) { + log_write("[FTP] Attempt to read from a write-only file\n"); + return set_errno(r, EBADF); + } + + if (!len) { + return 0; + } + + if (!ftp_read_file_chunk(*file->client, file->entry->path, file->off, {ptr, len})) { + log_write("[FTP] Failed to read file chunk: %s\n", file->entry->path.c_str()); + return set_errno(r, EIO); + } + + file->off += len; + return len; +} + +ssize_t devoptab_write(struct _reent *r, void *fd, const char *ptr, size_t len) { + auto file = static_cast(fd); + SCOPED_MUTEX(&file->client->mutex); + + if (!ftp_write_file_chunk(*file->client, file->entry->path, file->off, {ptr, len})) { + log_write("[FTP] Failed to write file chunk: %s\n", file->entry->path.c_str()); + return set_errno(r, EIO); + } + + file->off += len; + file->entry->st.st_size = std::max(file->entry->st.st_size, file->off); + return len; +} + +off_t devoptab_seek(struct _reent *r, void *fd, off_t pos, int dir) { + auto file = static_cast(fd); + SCOPED_MUTEX(&file->client->mutex); + + if (dir == SEEK_CUR) { + pos += file->off; + } else if (dir == SEEK_END) { + pos = file->entry->st.st_size; + } + + // for now, random access writes are disabled. + if (file->write_mode && pos != file->off) { + set_errno(r, ESPIPE); + return file->off; + } + + r->_errno = 0; + return file->off = std::clamp(pos, 0, file->entry->st.st_size); +} + +int devoptab_fstat(struct _reent *r, void *fd, struct stat *st) { + auto file = static_cast(fd); + SCOPED_MUTEX(&file->client->mutex); + + std::memcpy(st, &file->entry->st, sizeof(*st)); + return r->_errno = 0; +} + +int devoptab_unlink(struct _reent *r, const char *_path) { + auto device = static_cast(r->deviceData); + SCOPED_MUTEX(&device->mutex); + + char path[PATH_MAX]{}; + if (!common::fix_path(_path, path)) { + return set_errno(r, ENOENT); + } + + if (!mount_ftp(*device)) { + return set_errno(r, EIO); + } + + if (!ftp_unlink(*device, path)) { + return set_errno(r, EIO); + } + + return r->_errno = 0; +} + +int devoptab_rename(struct _reent *r, const char *_oldName, const char *_newName) { + auto device = static_cast(r->deviceData); + SCOPED_MUTEX(&device->mutex); + + char oldName[PATH_MAX]{}; + if (!common::fix_path(_oldName, oldName)) { + return set_errno(r, ENOENT); + } + + char newName[PATH_MAX]{}; + if (!common::fix_path(_newName, newName)) { + return set_errno(r, ENOENT); + } + + if (!mount_ftp(*device)) { + return set_errno(r, EIO); + } + + if (!ftp_rename(*device, oldName, newName, false) && !ftp_rename(*device, oldName, newName, true)) { + return set_errno(r, EIO); + } + + return r->_errno = 0; +} + +int devoptab_mkdir(struct _reent *r, const char *_path, int mode) { + auto device = static_cast(r->deviceData); + SCOPED_MUTEX(&device->mutex); + + char path[PATH_MAX]{}; + if (!common::fix_path(_path, path)) { + return set_errno(r, ENOENT); + } + + if (!mount_ftp(*device)) { + return set_errno(r, EIO); + } + + if (!ftp_mkdir(*device, path)) { + return set_errno(r, EIO); + } + + return r->_errno = 0; +} + +int devoptab_rmdir(struct _reent *r, const char *_path) { + auto device = static_cast(r->deviceData); + SCOPED_MUTEX(&device->mutex); + + char path[PATH_MAX]{}; + if (!common::fix_path(_path, path)) { + return set_errno(r, ENOENT); + } + + if (!mount_ftp(*device)) { + return set_errno(r, EIO); + } + + if (!ftp_rmdir(*device, path)) { + return set_errno(r, EIO); + } + + return r->_errno = 0; +} + +DIR_ITER* devoptab_diropen(struct _reent *r, DIR_ITER *dirState, const char *_path) { + auto device = (Device*)r->deviceData; + auto dir = static_cast(dirState->dirStruct); + std::memset(dir, 0, sizeof(*dir)); + SCOPED_MUTEX(&device->mutex); + + char path[PATH_MAX]; + if (!common::fix_path(_path, path)) { + set_errno(r, ENOENT); + return NULL; + } + + if (!mount_ftp(*device)) { + set_errno(r, EIO); + return NULL; + } + + auto entries = new DirEntries(); + if (!ftp_dirlist(*device, path, *entries)) { + delete entries; + set_errno(r, ENOENT); + return NULL; + } + + dir->client = device; + dir->entries = entries; + r->_errno = 0; + return dirState; +} + +int devoptab_dirreset(struct _reent *r, DIR_ITER *dirState) { + auto dir = static_cast(dirState->dirStruct); + SCOPED_MUTEX(&dir->client->mutex); + + dir->index = 0; + return r->_errno = 0; +} + +int devoptab_dirnext(struct _reent *r, DIR_ITER *dirState, char *filename, struct stat *filestat) { + auto dir = static_cast(dirState->dirStruct); + std::memset(filestat, 0, sizeof(*filestat)); + SCOPED_MUTEX(&dir->client->mutex); + + if (dir->index >= dir->entries->size()) { + return set_errno(r, ENOENT); + } + + auto& entry = (*dir->entries)[dir->index]; + if (entry.is_dir) { + filestat->st_mode = S_IFDIR | S_IRUSR | S_IRGRP | S_IROTH; + } else { + filestat->st_mode = S_IFREG | S_IRUSR | S_IRGRP | S_IROTH; + } + + filestat->st_nlink = 1; + std::strcpy(filename, entry.name.c_str()); + + dir->index++; + return r->_errno = 0; +} + +int devoptab_dirclose(struct _reent *r, DIR_ITER *dirState) { + auto dir = static_cast(dirState->dirStruct); + SCOPED_MUTEX(&dir->client->mutex); + + delete dir->entries; + std::memset(dir, 0, sizeof(*dir)); + return r->_errno = 0; +} + +int devoptab_lstat(struct _reent *r, const char *_path, struct stat *st) { + auto device = (Device*)r->deviceData; + SCOPED_MUTEX(&device->mutex); + char path[PATH_MAX]; + + if (!common::fix_path(_path, path)) { + return set_errno(r, ENOENT); + } + + if (!mount_ftp(*device)) { + return set_errno(r, EIO); + } + + if (!ftp_stat(*device, path, st, false) && !ftp_stat(*device, path, st, true)) { + return set_errno(r, ENOENT); + } + + return r->_errno = 0; +} + +int devoptab_ftruncate(struct _reent *r, void *fd, off_t len) { + auto file = static_cast(fd); + SCOPED_MUTEX(&file->client->mutex); + + if (!file->write_mode) { + log_write("[FTP] Attempt to truncate a read-only file\n"); + return set_errno(r, EBADF); + } + + file->entry->st.st_size = len; + return r->_errno = 0; +} + +constexpr devoptab_t DEVOPTAB = { + .structSize = sizeof(File), + .open_r = devoptab_open, + .close_r = devoptab_close, + .write_r = devoptab_write, + .read_r = devoptab_read, + .seek_r = devoptab_seek, + .fstat_r = devoptab_fstat, + .stat_r = devoptab_lstat, + .unlink_r = devoptab_unlink, + .rename_r = devoptab_rename, + .mkdir_r = devoptab_mkdir, + .dirStateSize = sizeof(Dir), + .diropen_r = devoptab_diropen, + .dirreset_r = devoptab_dirreset, + .dirnext_r = devoptab_dirnext, + .dirclose_r = devoptab_dirclose, + .ftruncate_r = devoptab_ftruncate, + .rmdir_r = devoptab_rmdir, + .lstat_r = devoptab_lstat, +}; + +struct Entry { + Device device{}; + devoptab_t devoptab{}; + fs::FsPath mount{}; + char name[32]{}; + s32 ref_count{}; + + ~Entry() { + if (device.curl) { + curl_easy_cleanup(device.curl); + } + + RemoveDevice(mount); + } +}; + +Mutex g_mutex; +std::array, common::MAX_ENTRIES> g_entries; + +} // namespace + +Result MountFtpAll() { + SCOPED_MUTEX(&g_mutex); + + static const auto cb = [](const mTCHAR *Section, const mTCHAR *Key, const mTCHAR *Value, void *UserData) -> int { + auto e = static_cast(UserData); + if (!Section || !Key || !Value) return 1; + + if (!Section || !Key || !Value) { + return 1; + } + + // add new entry if use section changed. + if (e->empty() || std::strcmp(Section, e->back().name.c_str())) { + e->emplace_back(Section); + } + + if (!std::strcmp(Key, "url")) { + e->back().url = Value; + } else if (!std::strcmp(Key, "user")) { + e->back().user = Value; + } else if (!std::strcmp(Key, "pass")) { + e->back().pass = Value; + } else if (!std::strcmp(Key, "port")) { + e->back().port = ini_parse_getl(Value, DEFAULT_FTP_PORT); + } else if (!std::strcmp(Key, "timeout")) { + e->back().timeout = ini_parse_getl(Value, DEFAULT_FTP_TIMEOUT); + } else if (!std::strcmp(Key, "read_only")) { + e->back().read_only = ini_parse_getbool(Value, false); + } else { + log_write("[FTP] INI: Unknown key %s=%s\n", Key, Value); + } + + return 1; + }; + + FtpMountConfigs configs; + ini_browse(cb, &configs, "/config/sphaira/ftp.ini"); + log_write("[FTP] Found %zu mount configs\n", configs.size()); + + for (const auto& config : configs) { + // check if we already have the http mounted. + bool already_mounted = false; + for (const auto& entry : g_entries) { + if (entry && entry->mount == config.name) { + already_mounted = true; + break; + } + } + + if (already_mounted) { + log_write("[FTP] Already mounted %s, skipping\n", config.name.c_str()); + continue; + } + + // otherwise, find next free entry. + auto itr = std::ranges::find_if(g_entries, [](auto& e){ + return !e; + }); + + if (itr == g_entries.end()) { + log_write("[FTP] No free entries to mount %s\n", config.name.c_str()); + break; + } + + auto entry = std::make_unique(); + entry->devoptab = DEVOPTAB; + entry->devoptab.name = entry->name; + entry->devoptab.deviceData = &entry->device; + entry->device.config = config; + std::snprintf(entry->name, sizeof(entry->name), "[FTP] %s", config.name.c_str()); + std::snprintf(entry->mount, sizeof(entry->mount), "[FTP] %s:/", config.name.c_str()); + common::update_devoptab_for_read_only(&entry->devoptab, config.read_only); + + R_UNLESS(AddDevice(&entry->devoptab) >= 0, 0x1); + log_write("[FTP] DEVICE SUCCESS %s %s\n", entry->device.config.url.c_str(), entry->name); + + entry->ref_count++; + *itr = std::move(entry); + log_write("[FTP] Mounted %s at /%s\n", config.url.c_str(), config.name.c_str()); + } + + R_SUCCEED(); +} + +void UnmountFtpAll() { + SCOPED_MUTEX(&g_mutex); + + for (auto& entry : g_entries) { + if (entry) { + entry.reset(); + } + } +} + +Result GetFtpMounts(location::StdioEntries& out) { + SCOPED_MUTEX(&g_mutex); + out.clear(); + + for (const auto& entry : g_entries) { + if (entry) { + out.emplace_back(entry->mount, entry->name, entry->device.config.read_only); + } + } + + R_SUCCEED(); +} + +} // namespace sphaira::devoptab diff --git a/sphaira/source/utils/devoptab_http.cpp b/sphaira/source/utils/devoptab_http.cpp index 05c58cf..cf62a4e 100644 --- a/sphaira/source/utils/devoptab_http.cpp +++ b/sphaira/source/utils/devoptab_http.cpp @@ -92,17 +92,11 @@ size_t dirlist_callback(char *ptr, size_t size, size_t nmemb, void *userdata) { size_t write_data_callback(char *ptr, size_t size, size_t nmemb, void *userdata) { auto data = static_cast*>(userdata); + const auto rsize = std::min(size * nmemb, data->size()); - const auto realsize = size * nmemb; - if (data->size() < realsize) { - log_write("[HTTP] buffer is too small: %zu < %zu\n", data->size(), realsize); - return 0; // buffer is too small - } - - std::memcpy(data->data(), ptr, realsize); - *data = data->subspan(realsize); // advance the span - - return realsize; + std::memcpy(data->data(), ptr, rsize); + *data = data->subspan(rsize); + return rsize; } std::string url_encode(const std::string& str) {