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) {