diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 68a1d6b..e7918be 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -30,3 +30,5 @@ jobs: cmake -S . -B build export MAKEFLAGS=-j$(nproc) cmake --build build --verbose + - name: Test + run: ctest --test-dir build --output-on-failure diff --git a/CMakeLists.txt b/CMakeLists.txt index 2a8da0e..d95b747 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,6 +21,10 @@ add_executable(${PROJECT_NAME} main.cpp Sources/audioMenu.cpp Sources/config.cpp + Sources/directoryReader.cpp + Sources/fileBrowser.cpp + Sources/fileBrowserMenu.cpp + Sources/fileBrowserModel.cpp Sources/font.cpp Sources/ftpConnection.cpp Sources/ftpServer.cpp @@ -35,12 +39,15 @@ add_executable(${PROJECT_NAME} Sources/sntpClient.cpp Sources/subAppRouter.cpp Sources/subsystems.cpp + Sources/textLayout.cpp Sources/timing.cpp Sources/timeMenu.cpp Sources/videoMenu.cpp Sources/wipeCache.cpp Sources/xbeLauncher.cpp + Sources/xbeLaunchPath.cpp Sources/xbeScanner.cpp + Sources/xbeValidator.cpp 3rdparty/SDL_FontCache/SDL_FontCache.c ) @@ -58,5 +65,24 @@ else() target_compile_options(${PROJECT_NAME} PRIVATE -Wall -Wextra -pedantic) endif() +include(CTest) + +if(BUILD_TESTING) + add_executable(FileBrowserModelTests + Tests/fileBrowserModelTests.cpp + Sources/directoryReader.cpp + Sources/fileBrowserModel.cpp + Sources/textLayout.cpp + Sources/xbeLaunchPath.cpp + Sources/xbeScanner.cpp + Sources/xbeValidator.cpp) + target_include_directories(FileBrowserModelTests PRIVATE + ${CMAKE_SOURCE_DIR} + ${PROJECT_SOURCE_DIR}/Includes) + set_property(TARGET FileBrowserModelTests PROPERTY CXX_STANDARD 11) + set_property(TARGET FileBrowserModelTests PROPERTY CXX_STANDARD_REQUIRED ON) + add_test(NAME FileBrowserModelTests COMMAND FileBrowserModelTests) +endif() + #set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS_DEBUG} -fno-omit-frame-pointer -fsanitize=address") #set (CMAKE_LINKER_FLAGS "${CMAKE_LINKER_FLAGS_DEBUG} -fno-omit-frame-pointer -fsanitize=address") diff --git a/Includes/directoryReader.hpp b/Includes/directoryReader.hpp new file mode 100644 index 0000000..57ba6bf --- /dev/null +++ b/Includes/directoryReader.hpp @@ -0,0 +1,51 @@ +#ifndef NEVOLUTIONX_INCLUDES_DIRECTORYREADER_HPP_ +#define NEVOLUTIONX_INCLUDES_DIRECTORYREADER_HPP_ + +#include +#include +#include +#include +#include + +enum class DirectoryEntryKind +{ + DIRECTORY, + FILE, + OTHER +}; + +struct DirectoryEntry { + DirectoryEntry() = default; + DirectoryEntry(std::string entryName, + DirectoryEntryKind entryKind, + uint64_t entrySize = 0) : + name(std::move(entryName)), kind(entryKind), size(entrySize) {} + + std::string name; + DirectoryEntryKind kind{ DirectoryEntryKind::OTHER }; + uint64_t size{ 0 }; +}; + +struct DirectoryReadResult { + bool succeeded{ false }; + std::vector entries; + std::string error; +}; + +class DirectoryReader { +public: + virtual ~DirectoryReader() = default; + + virtual DirectoryReadResult read(const std::string& path) const = 0; + virtual char separator() const = 0; + virtual size_t maxPathLength() const = 0; +}; + +class PlatformDirectoryReader : public DirectoryReader { +public: + DirectoryReadResult read(const std::string& path) const override; + char separator() const override; + size_t maxPathLength() const override; +}; + +#endif // NEVOLUTIONX_INCLUDES_DIRECTORYREADER_HPP_ diff --git a/Includes/fileBrowser.hpp b/Includes/fileBrowser.hpp new file mode 100644 index 0000000..11e6d34 --- /dev/null +++ b/Includes/fileBrowser.hpp @@ -0,0 +1,41 @@ +#ifndef NEVOLUTIONX_INCLUDES_FILEBROWSER_HPP_ +#define NEVOLUTIONX_INCLUDES_FILEBROWSER_HPP_ + +#include +#include +#include +#include "fileBrowserModel.hpp" +#include "renderer.hpp" +#include "subApp.hpp" + +class FileBrowser : public SubApp { +public: + FileBrowser(Renderer& renderer, const std::vector& roots); + + void render(Font& font) override; + + void onUpPressed() override; + void onDownPressed() override; + void onLeftPressed() override; + void onRightPressed() override; + void onAPressed() override; + void onBPressed() override; + void onBackPressed() override; + void onXPressed() override; + + void onLeftStickDigitalUpPressed() override { onUpPressed(); } + void onLeftStickDigitalDownPressed() override { onDownPressed(); } + void onLeftStickDigitalLeftPressed() override { onLeftPressed(); } + void onLeftStickDigitalRightPressed() override { onRightPressed(); } + +private: + void navigateBack(); + std::string displayLabel(const DirectoryEntry& entry) const; + + Renderer& renderer; + std::shared_ptr reader; + FileBrowserModel model; + size_t visibleRows{ 1 }; +}; + +#endif // NEVOLUTIONX_INCLUDES_FILEBROWSER_HPP_ diff --git a/Includes/fileBrowserMenu.hpp b/Includes/fileBrowserMenu.hpp new file mode 100644 index 0000000..4e8ce96 --- /dev/null +++ b/Includes/fileBrowserMenu.hpp @@ -0,0 +1,20 @@ +#ifndef NEVOLUTIONX_INCLUDES_FILEBROWSERMENU_HPP_ +#define NEVOLUTIONX_INCLUDES_FILEBROWSERMENU_HPP_ + +#include +#include +#include "menu.hpp" + +class FileBrowserMenu : public MenuItem { +public: + FileBrowserMenu(MenuNode* parent, + const std::string& label, + std::vector roots); + + void execute(Menu* menu) override; + +private: + std::vector roots; +}; + +#endif // NEVOLUTIONX_INCLUDES_FILEBROWSERMENU_HPP_ diff --git a/Includes/fileBrowserModel.hpp b/Includes/fileBrowserModel.hpp new file mode 100644 index 0000000..86a2204 --- /dev/null +++ b/Includes/fileBrowserModel.hpp @@ -0,0 +1,76 @@ +#ifndef NEVOLUTIONX_INCLUDES_FILEBROWSERMODEL_HPP_ +#define NEVOLUTIONX_INCLUDES_FILEBROWSERMODEL_HPP_ + +#include +#include +#include +#include "directoryReader.hpp" + +class FileBrowserModel { +public: + enum class ActivateResult + { + NONE, + ENTERED_DIRECTORY, + FILE_SELECTED, + XBE_SELECTED, + FAILED + }; + + FileBrowserModel(std::vector configuredRoots, + std::shared_ptr reader); + + const std::vector& getEntries() const { return entries; } + size_t getSelected() const { return selected; } + const std::string& getStatus() const { return status; } + std::string getLocationLabel() const; + bool isAtVirtualRoot() const { return atVirtualRoot; } + + void moveSelection(int delta, bool allowWrap = true); + void pageSelection(int delta); + ActivateResult activateSelected(std::string* xbePath = nullptr); + bool navigateBack(); + bool refresh(); + void setStatus(const std::string& newStatus) { status = newStatus; } + + static bool isSafeChildName(const std::string& name); + static bool entryLess(const DirectoryEntry& lhs, const DirectoryEntry& rhs); + +private: + struct SelectionIdentity { + bool valid{ false }; + std::string name; + DirectoryEntryKind kind{ DirectoryEntryKind::OTHER }; + }; + + struct HistoryFrame { + bool virtualRoot{ true }; + size_t rootIndex{ 0 }; + std::vector components; + SelectionIdentity selection; + }; + + static std::string trim(const std::string& value); + static bool equalsIgnoreCase(const std::string& lhs, const std::string& rhs); + std::vector normalizeRoots(const std::vector& roots) const; + bool normalizeRoot(const std::string& input, std::string& output) const; + static bool hasXbeExtension(const std::string& name); + std::string buildCurrentPath() const; + bool buildChildPath(const std::string& name, std::string& path) const; + bool loadCurrentDirectory(bool preserveOldListing); + void showVirtualRoot(); + SelectionIdentity selectedIdentity() const; + void restoreSelection(const SelectionIdentity& identity); + + std::shared_ptr reader; + std::vector roots; + std::vector entries; + std::vector components; + std::vector history; + size_t rootIndex{ 0 }; + size_t selected{ 0 }; + bool atVirtualRoot{ true }; + std::string status; +}; + +#endif // NEVOLUTIONX_INCLUDES_FILEBROWSERMODEL_HPP_ diff --git a/Includes/textLayout.hpp b/Includes/textLayout.hpp new file mode 100644 index 0000000..7832fef --- /dev/null +++ b/Includes/textLayout.hpp @@ -0,0 +1,13 @@ +#ifndef NEVOLUTIONX_INCLUDES_TEXTLAYOUT_HPP_ +#define NEVOLUTIONX_INCLUDES_TEXTLAYOUT_HPP_ + +#include +#include + +std::string sanitizeUtf8ForDisplay(const std::string& text); + +std::string ellipsizeText(const std::string& text, + float maximumWidth, + const std::function& measure); + +#endif // NEVOLUTIONX_INCLUDES_TEXTLAYOUT_HPP_ diff --git a/Includes/xbeLaunchPath.hpp b/Includes/xbeLaunchPath.hpp new file mode 100644 index 0000000..99a0714 --- /dev/null +++ b/Includes/xbeLaunchPath.hpp @@ -0,0 +1,13 @@ +#ifndef NEVOLUTIONX_INCLUDES_XBELAUNCHPATH_HPP_ +#define NEVOLUTIONX_INCLUDES_XBELAUNCHPATH_HPP_ + +#include + +struct XBEPreparedLaunchPath { + bool valid{ false }; + std::string path; +}; + +XBEPreparedLaunchPath prepareXbeLaunchPath(const std::string& input); + +#endif // NEVOLUTIONX_INCLUDES_XBELAUNCHPATH_HPP_ diff --git a/Includes/xbeLauncher.hpp b/Includes/xbeLauncher.hpp index d32853d..c588ec8 100644 --- a/Includes/xbeLauncher.hpp +++ b/Includes/xbeLauncher.hpp @@ -12,8 +12,9 @@ class XBELauncher { // Returns to the dashboard. Should not return. static void exitToDashboard(); - // Launches the XBE at the given path. Should not return. - static void launch(std::string const& xbePath); + // Launches the XBE at the given path. Returns false if launch was refused or failed. + // A successful launch does not return. + static bool launch(std::string const& xbePath); private: // TODO(#113): Add support for a pre-launch image. diff --git a/Includes/xbeScanner.hpp b/Includes/xbeScanner.hpp index 127d40b..d7d66d7 100644 --- a/Includes/xbeScanner.hpp +++ b/Includes/xbeScanner.hpp @@ -10,6 +10,7 @@ #include #include #include +#include "xbeValidator.hpp" #ifdef NXDK #include @@ -41,6 +42,11 @@ class XBEScanner { // XBEInfo instances for any XBEs that were discovered. static void scanPath(std::string const& path, Callback&& callback); + // Produces display-safe scanner text, using the directory name when an XBE + // certificate has no title. + static std::string displayNameFor(const XBEValidationResult& validation, + const std::string& fallback); + private: class QueueItem { public: @@ -52,8 +58,6 @@ class XBEScanner { std::chrono::steady_clock::time_point scanStart; long long scanDuration{ 0 }; - const static int XBE_NAME_SIZE = 40; - std::string path; Callback callback; std::list results; @@ -65,9 +69,6 @@ class XBEScanner { HANDLE dirHandle{ INVALID_HANDLE_VALUE }; WIN32_FIND_DATAA findData{}; #endif - - char xbeName[XBE_NAME_SIZE + 1]{ 0 }; - std::vector xbeData; }; #ifdef SCANNER_THREADED diff --git a/Includes/xbeValidator.hpp b/Includes/xbeValidator.hpp new file mode 100644 index 0000000..0978c14 --- /dev/null +++ b/Includes/xbeValidator.hpp @@ -0,0 +1,38 @@ +#ifndef NEVOLUTIONX_INCLUDES_XBEVALIDATOR_HPP_ +#define NEVOLUTIONX_INCLUDES_XBEVALIDATOR_HPP_ + +#include +#include +#include + +enum class XBEValidationError +{ + NONE, + OPEN_FAILED, + READ_FAILED, + FILE_TOO_SMALL, + BAD_MAGIC, + BAD_IMAGE_BASE, + INVALID_HEADER_SIZE, + HEADER_TOO_LARGE, + TRUNCATED_HEADER, + INVALID_CERTIFICATE +}; + +struct XBEValidationResult { + bool valid{ false }; + XBEValidationError error{ XBEValidationError::NONE }; + std::string title; + + const char* message() const; +}; + +class XBEValidator { +public: + static XBEValidationResult validateFile(const std::string& path); + static XBEValidationResult validateBytes(const std::vector& data); + + static const size_t MAX_HEADER_SIZE = 4 * 1024 * 1024; +}; + +#endif // NEVOLUTIONX_INCLUDES_XBEVALIDATOR_HPP_ diff --git a/Makefile b/Makefile index 19443d9..8d9e87f 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,10 @@ SRCS += \ $(SRCDIR)/audioMenu.cpp \ $(SRCDIR)/config.cpp \ $(SRCDIR)/font.cpp \ + $(SRCDIR)/directoryReader.cpp \ + $(SRCDIR)/fileBrowser.cpp \ + $(SRCDIR)/fileBrowserMenu.cpp \ + $(SRCDIR)/fileBrowserModel.cpp \ $(SRCDIR)/ftpConnection.cpp \ $(SRCDIR)/ftpServer.cpp \ $(SRCDIR)/infoLog.cpp \ @@ -24,10 +28,13 @@ SRCS += \ $(SRCDIR)/subsystems.cpp \ $(SRCDIR)/timeMenu.cpp \ $(SRCDIR)/timing.cpp \ + $(SRCDIR)/textLayout.cpp \ $(SRCDIR)/videoMenu.cpp \ $(SRCDIR)/wipeCache.cpp \ $(SRCDIR)/xbeLauncher.cpp \ + $(SRCDIR)/xbeLaunchPath.cpp \ $(SRCDIR)/xbeScanner.cpp \ + $(SRCDIR)/xbeValidator.cpp \ $(CURDIR)/3rdparty/SDL_FontCache/SDL_FontCache.c NXDK_DIR ?= $(CURDIR)/../nxdk diff --git a/README.md b/README.md index 7f18947..4a328ac 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,29 @@ As the XboxDev community grew, the need for an open-source, nxdk based dashboard - [ ] TLS - [x] Application launcher - [x] DVD launcher +- [x] File browser with validated XBE launching - [ ] Themes +### File browser + +The file browser is opt-in. Add a `file_browser` entry to the `menu` array in +`config.json` and explicitly list the roots it may expose: + +```json +{ + "label": "File browser", + "type": "file_browser", + "roots": ["C:\\", "E:\\", "F:\\", "G:\\"] +} +``` + +The configured roots are a safety boundary; cache and dashboard-private drives +are not discovered automatically. The browser can enter directories and view +files. It can launch structurally validated `.xbe` files, but it cannot copy, +rename, or delete anything. Use D-pad up/down to select, left/right to page, A +to enter a directory or launch an XBE, X to refresh, and B or Back to move up or +close the browser. + ## Building with nxdk ### Build In order to build NevolutionX you'll need to install [nxdk](https://github.com/XboxDev/nxdk) first. @@ -41,6 +62,7 @@ There is no further configuration required. The FTP-server will start automatica ## Building with CMake (Linux target) TODO: Document what parts of the Linux target are not supported. TODO: Document installation for the Linux target, if applicable. + `cmake -S . -B build && cmake --build build --verbose` ## Credits @@ -51,4 +73,3 @@ This software is built on top of other awesome projects: ## License NevolutionX is published under the MIT License. See [LICENSE](LICENSE) for more information. MIT © 2019 Lucas Eriksson - diff --git a/Sources/directoryReader.cpp b/Sources/directoryReader.cpp new file mode 100644 index 0000000..b40fcb4 --- /dev/null +++ b/Sources/directoryReader.cpp @@ -0,0 +1,143 @@ +#include "directoryReader.hpp" +#include +#include + +#ifdef NXDK +#include +#else +#include +#include +#include +#endif + +namespace +{ + +bool isDotEntry(const char* name) { + return !std::strcmp(name, ".") || !std::strcmp(name, ".."); +} + +} // namespace + +DirectoryReadResult PlatformDirectoryReader::read(const std::string& path) const { + DirectoryReadResult result; + + if (path.empty() || path.size() >= maxPathLength()) { + result.error = "Path is empty or too long"; + return result; + } + +#ifdef NXDK + std::string searchMask(path); + if (searchMask.back() != '\\') { + searchMask.push_back('\\'); + } + searchMask.push_back('*'); + if (searchMask.size() >= maxPathLength()) { + result.error = "Directory search path is too long"; + return result; + } + + WIN32_FIND_DATAA data{}; + HANDLE handle = FindFirstFileA(searchMask.c_str(), &data); + if (handle == INVALID_HANDLE_VALUE) { + DWORD error = GetLastError(); + if (error == ERROR_FILE_NOT_FOUND) { + result.succeeded = true; + } else { + result.error = "Unable to read directory (error " + std::to_string(error) + ")"; + } + return result; + } + + do { + if (isDotEntry(data.cFileName)) { + continue; + } + + DirectoryEntryKind kind = DirectoryEntryKind::FILE; + if (data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + kind = DirectoryEntryKind::DIRECTORY; + } + uint64_t size = (static_cast(data.nFileSizeHigh) << 32) + | static_cast(data.nFileSizeLow); + result.entries.emplace_back(data.cFileName, kind, size); + } while (FindNextFileA(handle, &data)); + + DWORD finalError = GetLastError(); + FindClose(handle); + if (finalError != ERROR_NO_MORE_FILES) { + result.entries.clear(); + result.error = "Directory read failed (error " + std::to_string(finalError) + ")"; + return result; + } +#else + DIR* directory = opendir(path.c_str()); + if (!directory) { + result.error = std::strerror(errno); + return result; + } + + errno = 0; + while (dirent* item = readdir(directory)) { + if (isDotEntry(item->d_name)) { + continue; + } + + std::string childPath(path); + if (childPath.back() != '/') { + childPath.push_back('/'); + } + childPath.append(item->d_name); + if (childPath.size() >= maxPathLength()) { + result.entries.clear(); + result.error = "Directory entry path is too long"; + closedir(directory); + return result; + } + + struct stat metadata{}; + if (lstat(childPath.c_str(), &metadata) != 0) { + result.entries.clear(); + result.error = std::strerror(errno); + closedir(directory); + return result; + } + + DirectoryEntryKind kind = DirectoryEntryKind::OTHER; + if (S_ISDIR(metadata.st_mode)) { + kind = DirectoryEntryKind::DIRECTORY; + } else if (S_ISREG(metadata.st_mode)) { + kind = DirectoryEntryKind::FILE; + } + result.entries.emplace_back(item->d_name, kind, static_cast(metadata.st_size)); + } + + int readError = errno; + closedir(directory); + if (readError) { + result.entries.clear(); + result.error = std::strerror(readError); + return result; + } +#endif + + result.succeeded = true; + return result; +} + +char PlatformDirectoryReader::separator() const { +#ifdef NXDK + return '\\'; +#else + return '/'; +#endif +} + +size_t PlatformDirectoryReader::maxPathLength() const { +#ifdef NXDK + return MAX_PATH; +#else + return PATH_MAX; +#endif +} diff --git a/Sources/fileBrowser.cpp b/Sources/fileBrowser.cpp new file mode 100644 index 0000000..65162de --- /dev/null +++ b/Sources/fileBrowser.cpp @@ -0,0 +1,152 @@ +#include "fileBrowser.hpp" +#include +#include +#include "infoLog.hpp" +#include "subAppRouter.hpp" +#include "textLayout.hpp" +#include "xbeLauncher.hpp" +#include "xbeValidator.hpp" + +FileBrowser::FileBrowser(Renderer& browserRenderer, const std::vector& roots) : + renderer(browserRenderer), reader(std::make_shared()), + model(roots, reader) { + autoRepeatIntervals[SDL_CONTROLLER_BUTTON_DPAD_UP] = 250; + autoRepeatIntervals[SDL_CONTROLLER_BUTTON_DPAD_DOWN] = 250; +} + +void FileBrowser::render(Font& font) { + float width = static_cast(renderer.getWidth()); + float height = static_cast(renderer.getHeight()); + float marginX = width * 0.075f; + float marginY = height * 0.05f; + float lineHeight = std::max(font.getFontHeight(), 1.0f); + float contentWidth = width - (marginX * 2.0f); + + font.draw("File browser", std::make_pair(marginX, marginY)); + + std::string location = ellipsizeText( + sanitizeUtf8ForDisplay(model.getLocationLabel()), contentWidth, + [&font](const std::string& value) { return font.getTextWidth(value); }); + font.draw(location, std::make_pair(marginX, marginY + lineHeight)); + + float listTop = marginY + (lineHeight * 2.5f); + // The application-wide IP/FPS overlay begins at 85% height, so keep this + // SubApp's footer above it. + float footerBottom = height * 0.80f; + float statusY = footerBottom - (lineHeight * 2.0f); + float helpY = footerBottom - lineHeight; + float listHeight = std::max(statusY - listTop, lineHeight); + visibleRows = std::max(static_cast(listHeight / lineHeight), + static_cast(1)); + + const auto& entries = model.getEntries(); + size_t selected = model.getSelected(); + size_t first = 0; + if (selected >= visibleRows) { + first = selected - visibleRows + 1; + } + if (entries.size() > visibleRows) { + first = std::min(first, entries.size() - visibleRows); + } + + size_t last = std::min(entries.size(), first + visibleRows); + for (size_t index = first; index < last; ++index) { + float y = listTop + static_cast(index - first) * lineHeight; + std::string label = ellipsizeText( + sanitizeUtf8ForDisplay(displayLabel(entries[index])), contentWidth, + [&font](const std::string& value) { return font.getTextWidth(value); }); + auto dimensions = font.draw(label, std::make_pair(marginX, y)); + + if (index == selected) { + SDL_Rect outline; + outline.x = static_cast(marginX - 8.0f); + outline.y = static_cast(y); + outline.w = static_cast(std::min(dimensions.first + 16.0f, contentWidth + 16.0f)); + outline.h = static_cast(lineHeight); + renderer.setDrawColor(0xFF, 0xFF, 0xFF, 0xFF); + SDL_RenderDrawRect(renderer.getRenderer(), &outline); + } + } + + std::string status = model.getStatus(); + if (status.empty() && entries.empty()) { + status = ""; + } + status = ellipsizeText( + sanitizeUtf8ForDisplay(status), contentWidth, + [&font](const std::string& value) { return font.getTextWidth(value); }); + font.draw(status, std::make_pair(marginX, statusY)); + + std::string help = "A: enter/launch XBE X: refresh B/Back: up/close"; + help = ellipsizeText(help, contentWidth, [&font](const std::string& value) { + return font.getTextWidth(value); + }); + font.draw(help, std::make_pair(marginX, helpY)); +} + +void FileBrowser::onUpPressed() { + model.moveSelection(-1); +} + +void FileBrowser::onDownPressed() { + model.moveSelection(1); +} + +void FileBrowser::onLeftPressed() { + model.pageSelection(-static_cast(visibleRows)); +} + +void FileBrowser::onRightPressed() { + model.pageSelection(static_cast(visibleRows)); +} + +void FileBrowser::onAPressed() { + std::string xbePath; + FileBrowserModel::ActivateResult result = model.activateSelected(&xbePath); + if (result != FileBrowserModel::ActivateResult::XBE_SELECTED) { + return; + } + + XBEValidationResult validation = XBEValidator::validateFile(xbePath); + if (!validation.valid) { + InfoLog::outputLine(InfoLog::WARNING, "File browser refused an invalid XBE\n"); + model.setStatus(std::string("Invalid XBE: ") + validation.message()); + return; + } + + InfoLog::outputLine(InfoLog::INFO, "File browser launching a validated XBE\n"); + if (!XBELauncher::launch(xbePath)) { + InfoLog::outputLine(InfoLog::ERROR, "Validated XBE launch returned\n"); + model.setStatus("Unable to launch XBE"); + } +} + +void FileBrowser::onBPressed() { + navigateBack(); +} + +void FileBrowser::onBackPressed() { + navigateBack(); +} + +void FileBrowser::onXPressed() { + model.refresh(); +} + +void FileBrowser::navigateBack() { + if (!model.navigateBack()) { + SubAppRouter::getInstance()->pop(); + } +} + +std::string FileBrowser::displayLabel(const DirectoryEntry& entry) const { + switch (entry.kind) { + case DirectoryEntryKind::DIRECTORY: + return "[DIR] " + entry.name; + case DirectoryEntryKind::FILE: + return "[FILE] " + entry.name; + case DirectoryEntryKind::OTHER: + return "[OTHER] " + entry.name; + } + return entry.name; +} diff --git a/Sources/fileBrowserMenu.cpp b/Sources/fileBrowserMenu.cpp new file mode 100644 index 0000000..e06182a --- /dev/null +++ b/Sources/fileBrowserMenu.cpp @@ -0,0 +1,16 @@ +#include "fileBrowserMenu.hpp" +#include +#include +#include "fileBrowser.hpp" +#include "subAppRouter.hpp" + +FileBrowserMenu::FileBrowserMenu(MenuNode* parent, + const std::string& label, + std::vector browserRoots) : + MenuItem(parent, label), roots(std::move(browserRoots)) { +} + +void FileBrowserMenu::execute(Menu* menu) { + SubAppRouter::getInstance()->push( + std::make_shared(menu->getRenderer(), roots)); +} diff --git a/Sources/fileBrowserModel.cpp b/Sources/fileBrowserModel.cpp new file mode 100644 index 0000000..5882271 --- /dev/null +++ b/Sources/fileBrowserModel.cpp @@ -0,0 +1,502 @@ +#include "fileBrowserModel.hpp" +#include +#include + +namespace +{ + +unsigned char asciiFold(unsigned char value) { + if (value >= 'A' && value <= 'Z') { + return static_cast(value + ('a' - 'A')); + } + return value; +} + +int safeCaseCompare(const std::string& lhs, const std::string& rhs) { + size_t count = std::min(lhs.size(), rhs.size()); + for (size_t index = 0; index < count; ++index) { + unsigned char left = asciiFold(static_cast(lhs[index])); + unsigned char right = asciiFold(static_cast(rhs[index])); + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + } + if (lhs.size() < rhs.size()) { + return -1; + } + if (lhs.size() > rhs.size()) { + return 1; + } + return 0; +} + +int unsignedByteCompare(const std::string& lhs, const std::string& rhs) { + size_t count = std::min(lhs.size(), rhs.size()); + for (size_t index = 0; index < count; ++index) { + unsigned char left = static_cast(lhs[index]); + unsigned char right = static_cast(rhs[index]); + if (left != right) { + return left < right ? -1 : 1; + } + } + if (lhs.size() == rhs.size()) { + return 0; + } + return lhs.size() < rhs.size() ? -1 : 1; +} + +int naturalNameCompare(const std::string& lhs, const std::string& rhs) { + size_t leftIndex = 0; + size_t rightIndex = 0; + + while (leftIndex < lhs.size() && rightIndex < rhs.size()) { + unsigned char left = static_cast(lhs[leftIndex]); + unsigned char right = static_cast(rhs[rightIndex]); + bool leftDigit = left >= '0' && left <= '9'; + bool rightDigit = right >= '0' && right <= '9'; + + if (leftDigit && rightDigit) { + size_t leftRunEnd = leftIndex; + size_t rightRunEnd = rightIndex; + while (leftRunEnd < lhs.size() && lhs[leftRunEnd] >= '0' && lhs[leftRunEnd] <= '9') { + ++leftRunEnd; + } + while (rightRunEnd < rhs.size() && rhs[rightRunEnd] >= '0' + && rhs[rightRunEnd] <= '9') { + ++rightRunEnd; + } + + size_t leftSignificant = leftIndex; + size_t rightSignificant = rightIndex; + while (leftSignificant < leftRunEnd && lhs[leftSignificant] == '0') { + ++leftSignificant; + } + while (rightSignificant < rightRunEnd && rhs[rightSignificant] == '0') { + ++rightSignificant; + } + + size_t leftDigits = leftRunEnd - leftSignificant; + size_t rightDigits = rightRunEnd - rightSignificant; + if (leftDigits != rightDigits) { + return leftDigits < rightDigits ? -1 : 1; + } + for (size_t offset = 0; offset < leftDigits; ++offset) { + if (lhs[leftSignificant + offset] != rhs[rightSignificant + offset]) { + return lhs[leftSignificant + offset] < rhs[rightSignificant + offset] ? -1 : 1; + } + } + + size_t leftRunLength = leftRunEnd - leftIndex; + size_t rightRunLength = rightRunEnd - rightIndex; + if (leftRunLength != rightRunLength) { + return leftRunLength < rightRunLength ? -1 : 1; + } + leftIndex = leftRunEnd; + rightIndex = rightRunEnd; + continue; + } + + unsigned char foldedLeft = asciiFold(left); + unsigned char foldedRight = asciiFold(right); + if (foldedLeft != foldedRight) { + return foldedLeft < foldedRight ? -1 : 1; + } + ++leftIndex; + ++rightIndex; + } + + if (leftIndex != lhs.size() || rightIndex != rhs.size()) { + return leftIndex == lhs.size() ? -1 : 1; + } + return unsignedByteCompare(lhs, rhs); +} + +} // namespace + +FileBrowserModel::FileBrowserModel(std::vector configuredRoots, + std::shared_ptr directoryReader) : + reader(std::move(directoryReader)) { + if (!reader) { + status = "File browser has no directory reader"; + return; + } + + roots = normalizeRoots(configuredRoots); + if (roots.empty()) { + status = "File browser has no valid configured roots"; + return; + } + + if (roots.size() == 1) { + atVirtualRoot = false; + loadCurrentDirectory(false); + } else { + showVirtualRoot(); + } +} + +std::string FileBrowserModel::getLocationLabel() const { + if (atVirtualRoot) { + return "Configured roots"; + } + return buildCurrentPath(); +} + +void FileBrowserModel::moveSelection(int delta, bool allowWrap) { + if (entries.empty() || !delta) { + return; + } + + size_t count = entries.size(); + if (delta > 0) { + size_t offset = static_cast(delta); + selected = allowWrap ? (selected + offset) % count + : std::min(selected + offset, count - 1); + return; + } + + size_t offset = static_cast(-delta); + if (offset <= selected) { + selected -= offset; + } else if (!allowWrap) { + selected = 0; + } else { + offset = (offset - selected) % count; + selected = offset ? count - offset : 0; + } +} + +void FileBrowserModel::pageSelection(int delta) { + moveSelection(delta, false); +} + +FileBrowserModel::ActivateResult FileBrowserModel::activateSelected(std::string* xbePath) { + if (xbePath) { + xbePath->clear(); + } + if (entries.empty() || selected >= entries.size()) { + return ActivateResult::NONE; + } + + const DirectoryEntry entry = entries[selected]; + if (entry.kind != DirectoryEntryKind::DIRECTORY) { + if (entry.kind == DirectoryEntryKind::FILE && hasXbeExtension(entry.name)) { + std::string candidate; + if (!buildChildPath(entry.name, candidate)) { + status = "XBE path is invalid or too long"; + return ActivateResult::FAILED; + } + if (xbePath) { + *xbePath = std::move(candidate); + } + status.clear(); + return ActivateResult::XBE_SELECTED; + } + status = "Only XBE files can be launched"; + return ActivateResult::FILE_SELECTED; + } + + HistoryFrame frame; + frame.virtualRoot = atVirtualRoot; + frame.rootIndex = rootIndex; + frame.components = components; + frame.selection = selectedIdentity(); + + if (atVirtualRoot) { + rootIndex = selected; + components.clear(); + atVirtualRoot = false; + } else { + if (!isSafeChildName(entry.name)) { + status = "Unsafe directory name refused"; + return ActivateResult::FAILED; + } + components.push_back(entry.name); + } + + if (!loadCurrentDirectory(false)) { + std::string failureStatus = status; + atVirtualRoot = frame.virtualRoot; + rootIndex = frame.rootIndex; + components = frame.components; + if (atVirtualRoot) { + showVirtualRoot(); + } else { + loadCurrentDirectory(false); + } + restoreSelection(frame.selection); + status = failureStatus; + return ActivateResult::FAILED; + } + + history.push_back(frame); + return ActivateResult::ENTERED_DIRECTORY; +} + +bool FileBrowserModel::navigateBack() { + if (history.empty()) { + return false; + } + + HistoryFrame frame = history.back(); + history.pop_back(); + atVirtualRoot = frame.virtualRoot; + rootIndex = frame.rootIndex; + components = frame.components; + + if (atVirtualRoot) { + showVirtualRoot(); + } else { + loadCurrentDirectory(false); + } + restoreSelection(frame.selection); + return true; +} + +bool FileBrowserModel::refresh() { + if (atVirtualRoot) { + SelectionIdentity identity = selectedIdentity(); + showVirtualRoot(); + restoreSelection(identity); + return true; + } + return loadCurrentDirectory(true); +} + +bool FileBrowserModel::isSafeChildName(const std::string& name) { + if (name.empty() || name == "." || name == "..") { + return false; + } + if (name[0] == '/' || name[0] == '\\') { + return false; + } + return name.find('/') == std::string::npos && name.find('\\') == std::string::npos; +} + +bool FileBrowserModel::entryLess(const DirectoryEntry& lhs, const DirectoryEntry& rhs) { + bool leftDirectory = lhs.kind == DirectoryEntryKind::DIRECTORY; + bool rightDirectory = rhs.kind == DirectoryEntryKind::DIRECTORY; + if (leftDirectory != rightDirectory) { + return leftDirectory; + } + + int nameComparison = naturalNameCompare(lhs.name, rhs.name); + if (nameComparison) { + return nameComparison < 0; + } + return static_cast(lhs.kind) < static_cast(rhs.kind); +} + +std::string FileBrowserModel::trim(const std::string& value) { + size_t start = 0; + while (start < value.size() && std::isspace(static_cast(value[start]))) { + ++start; + } + size_t end = value.size(); + while (end > start && std::isspace(static_cast(value[end - 1]))) { + --end; + } + return value.substr(start, end - start); +} + +bool FileBrowserModel::equalsIgnoreCase(const std::string& lhs, const std::string& rhs) { + return safeCaseCompare(lhs, rhs) == 0; +} + +std::vector FileBrowserModel::normalizeRoots( + const std::vector& configuredRoots) const { + std::vector normalized; + for (const auto& configuredRoot: configuredRoots) { + std::string root; + if (!normalizeRoot(configuredRoot, root)) { + continue; + } + + bool duplicate = false; + for (const auto& existing: normalized) { + if (reader->separator() == '\\' ? equalsIgnoreCase(root, existing) + : root == existing) { + duplicate = true; + break; + } + } + if (!duplicate) { + normalized.push_back(root); + } + } + return normalized; +} + +bool FileBrowserModel::normalizeRoot(const std::string& input, std::string& output) const { + output = trim(input); + if (output.empty()) { + return false; + } + + const char separator = reader->separator(); + const char alternateSeparator = separator == '\\' ? '/' : '\\'; + std::replace(output.begin(), output.end(), alternateSeparator, separator); + + if (separator == '\\') { + if (output.size() < 3 || !std::isalpha(static_cast(output[0])) + || output[1] != ':' || output[2] != '\\') { + return false; + } + output[0] = static_cast(std::toupper(static_cast(output[0]))); + } else if (output[0] != '/') { + return false; + } + + std::string collapsed; + collapsed.reserve(output.size()); + for (char character: output) { + if (character == separator && !collapsed.empty() && collapsed.back() == separator) { + continue; + } + collapsed.push_back(character); + } + output.swap(collapsed); + + size_t componentStart = separator == '\\' ? 3 : 1; + while (componentStart < output.size()) { + size_t componentEnd = output.find(separator, componentStart); + std::string component = output.substr(componentStart, componentEnd - componentStart); + if (component.empty() || component == "." || component == "..") { + return false; + } + if (componentEnd == std::string::npos) { + break; + } + componentStart = componentEnd + 1; + } + + size_t minimumLength = separator == '\\' ? 3 : 1; + while (output.size() > minimumLength && output.back() == separator) { + output.pop_back(); + } + return output.size() < reader->maxPathLength(); +} + +bool FileBrowserModel::hasXbeExtension(const std::string& name) { + const std::string extension = ".xbe"; + if (name.size() < extension.size()) { + return false; + } + return equalsIgnoreCase(name.substr(name.size() - extension.size()), extension); +} + +std::string FileBrowserModel::buildCurrentPath() const { + if (roots.empty() || rootIndex >= roots.size()) { + return ""; + } + + std::string path = roots[rootIndex]; + for (const auto& component: components) { + if (!path.empty() && path.back() != reader->separator()) { + path.push_back(reader->separator()); + } + path.append(component); + } + return path; +} + +bool FileBrowserModel::buildChildPath(const std::string& name, std::string& path) const { + path.clear(); + if (atVirtualRoot || !isSafeChildName(name)) { + return false; + } + + path = buildCurrentPath(); + if (path.empty()) { + return false; + } + if (path.back() != reader->separator()) { + path.push_back(reader->separator()); + } + path.append(name); + if (path.size() >= reader->maxPathLength()) { + path.clear(); + return false; + } + return true; +} + +bool FileBrowserModel::loadCurrentDirectory(bool preserveOldListing) { + const std::string path = buildCurrentPath(); + // Reserve room for a separator, wildcard, and terminator in platform readers. + if (path.empty() || path.size() + 2 >= reader->maxPathLength()) { + status = "Path is empty or too long"; + if (!preserveOldListing) { + entries.clear(); + selected = 0; + } + return false; + } + + SelectionIdentity identity; + size_t previousSelected = 0; + if (preserveOldListing) { + identity = selectedIdentity(); + previousSelected = selected; + } + DirectoryReadResult result = reader->read(path); + if (!result.succeeded) { + status = "Unable to read " + path + ": " + result.error; + if (!preserveOldListing) { + entries.clear(); + selected = 0; + } + return false; + } + + std::vector safeEntries; + for (const auto& entry: result.entries) { + if (isSafeChildName(entry.name)) { + safeEntries.push_back(entry); + } + } + std::sort(safeEntries.begin(), safeEntries.end(), entryLess); + entries.swap(safeEntries); + selected = entries.empty() ? 0 : std::min(previousSelected, entries.size() - 1); + if (preserveOldListing) { + restoreSelection(identity); + } + status = entries.empty() ? "Directory is empty" : ""; + return true; +} + +void FileBrowserModel::showVirtualRoot() { + entries.clear(); + for (const auto& root: roots) { + entries.emplace_back(root, DirectoryEntryKind::DIRECTORY); + } + selected = 0; + status = entries.empty() ? "File browser has no valid configured roots" : ""; +} + +FileBrowserModel::SelectionIdentity FileBrowserModel::selectedIdentity() const { + SelectionIdentity identity; + if (selected < entries.size()) { + identity.valid = true; + identity.name = entries[selected].name; + identity.kind = entries[selected].kind; + } + return identity; +} + +void FileBrowserModel::restoreSelection(const SelectionIdentity& identity) { + if (!identity.valid) { + selected = entries.empty() ? 0 : std::min(selected, entries.size() - 1); + return; + } + for (size_t index = 0; index < entries.size(); ++index) { + if (entries[index].name == identity.name && entries[index].kind == identity.kind) { + selected = index; + return; + } + } + selected = entries.empty() ? 0 : std::min(selected, entries.size() - 1); +} diff --git a/Sources/menu.cpp b/Sources/menu.cpp index 6c420af..fb9fe28 100644 --- a/Sources/menu.cpp +++ b/Sources/menu.cpp @@ -1,6 +1,7 @@ #include "menu.hpp" #include #include "3rdparty/NaturalSort/natural_sort.hpp" +#include "fileBrowserMenu.hpp" #include "infoLog.hpp" #include "settingsMenu.hpp" #include "xbeLauncher.hpp" @@ -296,13 +297,11 @@ MenuLaunch::~MenuLaunch() { } void MenuLaunch::execute(Menu*) { - InfoLog::outputLine(InfoLog::DEBUG, "Launching xbe %s\n", this->path.c_str()); + InfoLog::outputLine(InfoLog::DEBUG, "Launching configured XBE\n"); #ifdef NXDK - std::string usePath = path; - if (path[0] == 'D') { - usePath.replace(0, 2, "\\Device\\CdRom0"); + if (!XBELauncher::launch(path)) { + InfoLog::outputLine(InfoLog::ERROR, "Configured XBE launch was refused or returned\n"); } - XBELauncher::launch(usePath); #endif } @@ -337,6 +336,18 @@ Menu::Menu(const Config& config, Renderer& renderer) : std::shared_ptr newNode = std::make_shared(e["label"], e["path"]); this->rootNode.addNode(newNode); + } else if (!static_cast(e["type"]).compare("file_browser")) { + std::vector roots; + if (e.contains("roots") && e["roots"].is_array()) { + for (const auto& root: e["roots"]) { + if (root.is_string()) { + roots.push_back(root); + } + } + } + std::shared_ptr newNode = std::make_shared( + currentMenu, e["label"], std::move(roots)); + this->rootNode.addNode(newNode); } else if (!static_cast(e["type"]).compare("reboot")) { std::shared_ptr newNode = std::make_shared( e["label"], [](Menu*) { XBELauncher::exitToDashboard(); }); diff --git a/Sources/textLayout.cpp b/Sources/textLayout.cpp new file mode 100644 index 0000000..1c6b5a7 --- /dev/null +++ b/Sources/textLayout.cpp @@ -0,0 +1,91 @@ +#include "textLayout.hpp" + +namespace +{ + +void removeLastUtf8Codepoint(std::string& text) { + if (text.empty()) { + return; + } + size_t start = text.size() - 1; + while (start > 0 && (static_cast(text[start]) & 0xC0) == 0x80) { + --start; + } + text.erase(start); +} + +bool isContinuation(unsigned char value) { + return (value & 0xC0) == 0x80; +} + +} // namespace + +std::string sanitizeUtf8ForDisplay(const std::string& text) { + std::string output; + output.reserve(text.size()); + + for (size_t index = 0; index < text.size();) { + unsigned char first = static_cast(text[index]); + if (first < 0x80) { + output.push_back(text[index++]); + continue; + } + + size_t sequenceLength = 0; + if (first >= 0xC2 && first <= 0xDF) { + sequenceLength = 2; + } else if (first >= 0xE0 && first <= 0xEF) { + sequenceLength = 3; + } else if (first >= 0xF0 && first <= 0xF4) { + sequenceLength = 4; + } + + bool valid = sequenceLength && index + sequenceLength <= text.size(); + for (size_t offset = 1; valid && offset < sequenceLength; ++offset) { + valid = isContinuation(static_cast(text[index + offset])); + } + + if (valid && sequenceLength == 3) { + unsigned char second = static_cast(text[index + 1]); + valid = (first != 0xE0 || second >= 0xA0) && (first != 0xED || second <= 0x9F); + } else if (valid && sequenceLength == 4) { + unsigned char second = static_cast(text[index + 1]); + valid = (first != 0xF0 || second >= 0x90) && (first != 0xF4 || second <= 0x8F); + } + + if (!valid) { + output.push_back('?'); + ++index; + continue; + } + + output.append(text, index, sequenceLength); + index += sequenceLength; + } + return output; +} + +std::string ellipsizeText(const std::string& text, + float maximumWidth, + const std::function& measure) { + if (maximumWidth <= 0.0f || !measure) { + return ""; + } + if (measure(text) <= maximumWidth) { + return text; + } + + std::string ellipsis = "..."; + while (!ellipsis.empty() && measure(ellipsis) > maximumWidth) { + ellipsis.pop_back(); + } + if (ellipsis.empty()) { + return ""; + } + + std::string prefix = text; + while (!prefix.empty() && measure(prefix + ellipsis) > maximumWidth) { + removeLastUtf8Codepoint(prefix); + } + return prefix + ellipsis; +} diff --git a/Sources/xbeLaunchPath.cpp b/Sources/xbeLaunchPath.cpp new file mode 100644 index 0000000..c73850f --- /dev/null +++ b/Sources/xbeLaunchPath.cpp @@ -0,0 +1,83 @@ +#include "xbeLaunchPath.hpp" +#include +#include + +namespace +{ + +const size_t XBOX_MAX_PATH = 260; + +bool isSupportedDrive(char value) { + switch (static_cast(std::toupper(static_cast(value)))) { + case 'C': + case 'D': + case 'E': + case 'F': + case 'G': + case 'X': + case 'Y': + case 'Z': + return true; + default: + return false; + } +} + +bool hasValidComponents(const std::string& path, size_t start, size_t minimumCount) { + if (start >= path.size() || path.back() == '\\') { + return false; + } + size_t count = 0; + while (start < path.size()) { + size_t end = path.find('\\', start); + std::string component = path.substr(start, end - start); + if (component.empty() || component == "." || component == "..") { + return false; + } + ++count; + if (end == std::string::npos) { + break; + } + start = end + 1; + } + return count >= minimumCount; +} + +} // namespace + +XBEPreparedLaunchPath prepareXbeLaunchPath(const std::string& input) { + XBEPreparedLaunchPath result; + // nxdk's path converter inspects an eight-byte device prefix before parsing + // DOS paths, so reject shorter strings before passing them across that API. + if (input.size() < 8 || input.size() >= XBOX_MAX_PATH + || input.find('\0') != std::string::npos) { + return result; + } + + std::string normalized = input; + std::replace(normalized.begin(), normalized.end(), '/', '\\'); + + bool devicePath = normalized.compare(0, 8, "\\Device\\") == 0; + bool drivePath = normalized.size() >= 4 && isSupportedDrive(normalized[0]) + && normalized[1] == ':' && normalized[2] == '\\'; + if (!devicePath && !drivePath) { + return result; + } + if (devicePath && !hasValidComponents(normalized, 8, 2)) { + return result; + } + if (drivePath && !hasValidComponents(normalized, 3, 1)) { + return result; + } + + if (drivePath && (normalized[0] == 'D' || normalized[0] == 'd')) { + normalized.replace(0, 2, "\\Device\\CdRom0"); + } + if (normalized.size() >= XBOX_MAX_PATH) { + return result; + } + + result.valid = true; + result.path = std::move(normalized); + return result; +} diff --git a/Sources/xbeLauncher.cpp b/Sources/xbeLauncher.cpp index f534575..50a0ce7 100644 --- a/Sources/xbeLauncher.cpp +++ b/Sources/xbeLauncher.cpp @@ -1,4 +1,5 @@ #include "xbeLauncher.hpp" +#include "xbeLaunchPath.hpp" #ifdef NXDK #include @@ -18,11 +19,15 @@ void XBELauncher::exitToDashboard() { exit(0); } -void XBELauncher::launch(std::string const& xbePath) { +bool XBELauncher::launch(std::string const& xbePath) { + XBEPreparedLaunchPath prepared = prepareXbeLaunchPath(xbePath); + if (!prepared.valid) { + return false; + } #ifdef NXDK - showLaunchImage(); - XLaunchXBE(const_cast(xbePath.c_str())); + XLaunchXBE(prepared.path.c_str()); #endif + return false; } void XBELauncher::showLaunchImage() { diff --git a/Sources/xbeScanner.cpp b/Sources/xbeScanner.cpp index 92daa59..3a8248f 100644 --- a/Sources/xbeScanner.cpp +++ b/Sources/xbeScanner.cpp @@ -6,11 +6,9 @@ #endif #include "infoLog.hpp" +#include "textLayout.hpp" #include "timing.hpp" -#define XBE_TYPE_MAGIC (0x48454258) -#define SECTORSIZE 0x1000 - XBEScanner* XBEScanner::singleton = nullptr; XBEScanner* XBEScanner::getInstance() { @@ -43,6 +41,14 @@ void XBEScanner::scanPath(const std::string& path, Callback&& callback) { #endif } +std::string XBEScanner::displayNameFor(const XBEValidationResult& validation, + const std::string& fallback) { + if (!validation.title.empty()) { + return validation.title; + } + return sanitizeUtf8ForDisplay(fallback); +} + #if SCANNER_THREADED void XBEScanner::addJob(std::string const& path, const Callback& callback) { std::lock_guard lock(queueMutex); @@ -78,7 +84,6 @@ void XBEScanner::threadMain(XBEScanner* scanner) { XBEScanner::QueueItem::QueueItem(std::string p, XBEScanner::Callback c) : path(std::move(p)), callback(std::move(c)) { - xbeData.resize(SECTORSIZE); } XBEScanner::QueueItem::~QueueItem() { @@ -131,46 +136,12 @@ bool XBEScanner::QueueItem::openDir() { void XBEScanner::QueueItem::processFile(const std::string& xbePath) { #ifdef NXDK - FILE* xbeFile = fopen(xbePath.c_str(), "rb"); - if (!xbeFile) { + XBEValidationResult validation = XBEValidator::validateFile(xbePath); + if (!validation.valid) { return; } - - size_t read_bytes = fread(xbeData.data(), 1, SECTORSIZE, xbeFile); - auto xbe = (PXBE_FILE_HEADER)xbeData.data(); - if (xbe->SizeOfHeaders > read_bytes) { - if (xbeData.size() < xbe->SizeOfHeaders) { - xbeData.resize(xbe->SizeOfHeaders); - } - read_bytes += fread(&xbeData[read_bytes], 1, xbe->SizeOfHeaders - read_bytes, xbeFile); - } - if (xbe->Magic != XBE_TYPE_MAGIC || xbe->ImageBase != XBE_DEFAULT_BASE - || xbe->ImageBase > (uint32_t)xbe->CertificateHeader - || (uint32_t)xbe->CertificateHeader + 4 >= (xbe->ImageBase + xbe->SizeOfHeaders) - || xbe->SizeOfHeaders > read_bytes) { - return; - } - auto xbeCert = - (PXBE_CERTIFICATE_HEADER)&xbeData[(uint32_t)xbe->CertificateHeader - xbe->ImageBase]; - - for (int offset = 0; offset < XBE_NAME_SIZE; ++offset) { - if (xbeCert->TitleName[offset] < 0x0100) { - xbeName[offset] = (char)xbeCert->TitleName[offset]; - } else if (xbeCert->TitleName[offset]) { - xbeName[offset] = '?'; - } else { - xbeName[offset] = 0; - break; - } - } - - // Some homebrew content may not have a name in the certification - // header, so fallback to using the path as the name. - if (!strlen(xbeName)) { - strncpy(xbeName, findData.cFileName, sizeof(xbeName) - 1); - } - fclose(xbeFile); - - results.emplace_back(xbeName, xbePath); + results.emplace_back(displayNameFor(validation, findData.cFileName), xbePath); +#else + (void)xbePath; #endif // #ifdef NXDK } diff --git a/Sources/xbeValidator.cpp b/Sources/xbeValidator.cpp new file mode 100644 index 0000000..94d28c0 --- /dev/null +++ b/Sources/xbeValidator.cpp @@ -0,0 +1,149 @@ +#include "xbeValidator.hpp" +#include + +namespace +{ + +const size_t MAGIC_OFFSET = 0x000; +const size_t IMAGE_BASE_OFFSET = 0x104; +const size_t HEADER_SIZE_OFFSET = 0x108; +const size_t CERTIFICATE_ADDRESS_OFFSET = 0x118; +const size_t REQUIRED_FILE_HEADER_SIZE = CERTIFICATE_ADDRESS_OFFSET + 4; +const size_t CERTIFICATE_TITLE_OFFSET = 12; +const size_t CERTIFICATE_TITLE_CHARACTERS = 40; +const size_t CERTIFICATE_TITLE_BYTES = CERTIFICATE_TITLE_CHARACTERS * 2; +const uint32_t XBE_MAGIC = 0x48454258; +const uint32_t XBE_IMAGE_BASE = 0x00010000; + +uint32_t readLittleEndian32(const std::vector& data, size_t offset) { + return static_cast(data[offset]) + | (static_cast(data[offset + 1]) << 8) + | (static_cast(data[offset + 2]) << 16) + | (static_cast(data[offset + 3]) << 24); +} + +uint16_t readLittleEndian16(const std::vector& data, size_t offset) { + return static_cast(data[offset]) + | static_cast(static_cast(data[offset + 1]) << 8); +} + +XBEValidationResult failure(XBEValidationError error) { + XBEValidationResult result; + result.error = error; + return result; +} + +} // namespace + +const char* XBEValidationResult::message() const { + switch (error) { + case XBEValidationError::NONE: + return "valid XBE"; + case XBEValidationError::OPEN_FAILED: + return "unable to open file"; + case XBEValidationError::READ_FAILED: + return "unable to read file"; + case XBEValidationError::FILE_TOO_SMALL: + return "file is too small"; + case XBEValidationError::BAD_MAGIC: + return "XBE signature is missing"; + case XBEValidationError::BAD_IMAGE_BASE: + return "XBE image base is invalid"; + case XBEValidationError::INVALID_HEADER_SIZE: + return "XBE header size is invalid"; + case XBEValidationError::HEADER_TOO_LARGE: + return "XBE header is too large"; + case XBEValidationError::TRUNCATED_HEADER: + return "XBE header is truncated"; + case XBEValidationError::INVALID_CERTIFICATE: + return "XBE certificate is invalid"; + } + return "unknown XBE error"; +} + +XBEValidationResult XBEValidator::validateFile(const std::string& path) { + FILE* file = fopen(path.c_str(), "rb"); + if (!file) { + return failure(XBEValidationError::OPEN_FAILED); + } + + std::vector data(REQUIRED_FILE_HEADER_SIZE); + size_t bytesRead = fread(data.data(), 1, data.size(), file); + if (bytesRead != data.size()) { + XBEValidationError error = ferror(file) ? XBEValidationError::READ_FAILED + : XBEValidationError::FILE_TOO_SMALL; + fclose(file); + return failure(error); + } + + uint32_t headerSize = readLittleEndian32(data, HEADER_SIZE_OFFSET); + if (headerSize < REQUIRED_FILE_HEADER_SIZE) { + fclose(file); + return failure(XBEValidationError::INVALID_HEADER_SIZE); + } + if (headerSize > MAX_HEADER_SIZE) { + fclose(file); + return failure(XBEValidationError::HEADER_TOO_LARGE); + } + + data.resize(headerSize); + size_t remaining = headerSize - bytesRead; + if (remaining && fread(data.data() + bytesRead, 1, remaining, file) != remaining) { + XBEValidationError error = ferror(file) ? XBEValidationError::READ_FAILED + : XBEValidationError::TRUNCATED_HEADER; + fclose(file); + return failure(error); + } + fclose(file); + return validateBytes(data); +} + +XBEValidationResult XBEValidator::validateBytes(const std::vector& data) { + if (data.size() < REQUIRED_FILE_HEADER_SIZE) { + return failure(XBEValidationError::FILE_TOO_SMALL); + } + if (readLittleEndian32(data, MAGIC_OFFSET) != XBE_MAGIC) { + return failure(XBEValidationError::BAD_MAGIC); + } + + uint32_t imageBase = readLittleEndian32(data, IMAGE_BASE_OFFSET); + if (imageBase != XBE_IMAGE_BASE) { + return failure(XBEValidationError::BAD_IMAGE_BASE); + } + + uint32_t headerSize = readLittleEndian32(data, HEADER_SIZE_OFFSET); + if (headerSize < REQUIRED_FILE_HEADER_SIZE) { + return failure(XBEValidationError::INVALID_HEADER_SIZE); + } + if (headerSize > MAX_HEADER_SIZE) { + return failure(XBEValidationError::HEADER_TOO_LARGE); + } + if (data.size() < headerSize) { + return failure(XBEValidationError::TRUNCATED_HEADER); + } + + uint32_t certificateAddress = readLittleEndian32(data, CERTIFICATE_ADDRESS_OFFSET); + if (certificateAddress < imageBase) { + return failure(XBEValidationError::INVALID_CERTIFICATE); + } + size_t certificateOffset = static_cast(certificateAddress - imageBase); + const size_t requiredCertificateBytes = CERTIFICATE_TITLE_OFFSET + + CERTIFICATE_TITLE_BYTES; + if (certificateOffset > headerSize + || requiredCertificateBytes > headerSize - certificateOffset) { + return failure(XBEValidationError::INVALID_CERTIFICATE); + } + + XBEValidationResult result; + result.valid = true; + for (size_t index = 0; index < CERTIFICATE_TITLE_CHARACTERS; ++index) { + uint16_t character = readLittleEndian16( + data, certificateOffset + CERTIFICATE_TITLE_OFFSET + (index * 2)); + if (!character) { + break; + } + result.title.push_back( + character >= 0x20 && character <= 0x7E ? static_cast(character) : '?'); + } + return result; +} diff --git a/Tests/fileBrowserModelTests.cpp b/Tests/fileBrowserModelTests.cpp new file mode 100644 index 0000000..fbc6ec0 --- /dev/null +++ b/Tests/fileBrowserModelTests.cpp @@ -0,0 +1,512 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "directoryReader.hpp" +#include "fileBrowserModel.hpp" +#include "textLayout.hpp" +#include "xbeLaunchPath.hpp" +#include "xbeScanner.hpp" +#include "xbeValidator.hpp" + +#define RUN_TEST(method) \ + do { \ + std::cout << "Running " #method "..." << std::endl; \ + method(); \ + } while (false) + +#define CHECK(expression) \ + do { \ + if (!(expression)) { \ + std::cerr << __FILE__ << ":" << __LINE__ << ": check failed: " #expression \ + << std::endl; \ + std::abort(); \ + } \ + } while (false) + +namespace +{ + +DirectoryReadResult success(std::vector entries = {}) { + DirectoryReadResult result; + result.succeeded = true; + result.entries = std::move(entries); + return result; +} + +DirectoryReadResult failure(const std::string& error) { + DirectoryReadResult result; + result.error = error; + return result; +} + +class FakeReader : public DirectoryReader { +public: + explicit FakeReader(char pathSeparator = '/', size_t pathLimit = 260) : + pathSeparator(pathSeparator), pathLimit(pathLimit) {} + + DirectoryReadResult read(const std::string& path) const override { + ++readCount; + auto result = results.find(path); + if (result == results.end()) { + return failure("not found"); + } + return result->second; + } + + char separator() const override { return pathSeparator; } + size_t maxPathLength() const override { return pathLimit; } + + void set(const std::string& path, DirectoryReadResult result) { + results[path] = std::move(result); + } + + mutable int readCount{ 0 }; + +private: + char pathSeparator; + size_t pathLimit; + std::map results; +}; + +void testRootValidationAndNormalization() { + auto reader = std::make_shared(); + FileBrowserModel model( + { " /first/ ", "/first", "", "relative", "/bad/../root", "/second//" }, reader); + + CHECK(model.isAtVirtualRoot()); + CHECK(model.getEntries().size() == 2); + CHECK(model.getEntries()[0].name == "/first"); + CHECK(model.getEntries()[1].name == "/second"); + + auto windowsReader = std::make_shared('\\'); + FileBrowserModel windowsModel( + { " c:/ ", "C:\\", "D:\\Apps\\", "not-a-drive", "E:\\..\\bad" }, windowsReader); + CHECK(windowsModel.getEntries().size() == 2); + CHECK(windowsModel.getEntries()[0].name == "C:\\"); + CHECK(windowsModel.getEntries()[1].name == "D:\\Apps"); +} + +void testNoValidRoots() { + auto reader = std::make_shared(); + FileBrowserModel model({ "", "relative" }, reader); + CHECK(model.getEntries().empty()); + CHECK(model.getStatus().find("no valid") != std::string::npos); + CHECK(!model.navigateBack()); +} + +void testSafeChildNames() { + CHECK(FileBrowserModel::isSafeChildName("normal name")); + CHECK(!FileBrowserModel::isSafeChildName("")); + CHECK(!FileBrowserModel::isSafeChildName(".")); + CHECK(!FileBrowserModel::isSafeChildName("..")); + CHECK(!FileBrowserModel::isSafeChildName("/absolute")); + CHECK(!FileBrowserModel::isSafeChildName("\\absolute")); + CHECK(!FileBrowserModel::isSafeChildName("one/two")); + CHECK(!FileBrowserModel::isSafeChildName("one\\two")); +} + +void testOrderingAndUnsafeEntryFiltering() { + auto reader = std::make_shared(); + std::string highName(1, static_cast(0xFF)); + reader->set("/root", success({ { "file10", DirectoryEntryKind::FILE }, + { "Dir10", DirectoryEntryKind::DIRECTORY }, + { "file2", DirectoryEntryKind::FILE }, + { "dir2", DirectoryEntryKind::DIRECTORY }, + { "Case", DirectoryEntryKind::FILE }, + { "case", DirectoryEntryKind::FILE }, + { highName, DirectoryEntryKind::FILE }, + { "..", DirectoryEntryKind::DIRECTORY }, + { "bad/name", DirectoryEntryKind::FILE } })); + FileBrowserModel model({ "/root" }, reader); + + const auto& entries = model.getEntries(); + CHECK(entries.size() == 7); + CHECK(entries[0].name == "dir2"); + CHECK(entries[1].name == "Dir10"); + CHECK(entries[2].name == "Case"); + CHECK(entries[3].name == "case"); + CHECK(entries[4].name == "file2"); + CHECK(entries[5].name == "file10"); + CHECK(entries[6].name == highName); +} + +void testOrderingIsTransitiveAcrossRawByteClasses() { + std::string highSuffix(1, static_cast(0xFF)); + std::vector entries{ + { "x2", DirectoryEntryKind::FILE }, + { "x10", DirectoryEntryKind::FILE }, + { "x1" + highSuffix, DirectoryEntryKind::FILE }, + { "X02", DirectoryEntryKind::FILE }, + { "x002", DirectoryEntryKind::FILE }, + }; + + for (size_t left = 0; left < entries.size(); ++left) { + CHECK(!FileBrowserModel::entryLess(entries[left], entries[left])); + for (size_t middle = 0; middle < entries.size(); ++middle) { + for (size_t right = 0; right < entries.size(); ++right) { + if (FileBrowserModel::entryLess(entries[left], entries[middle]) + && FileBrowserModel::entryLess(entries[middle], entries[right])) { + CHECK(FileBrowserModel::entryLess(entries[left], entries[right])); + } + } + } + } +} + +void testSingleRootNavigationAndSelectionRestore() { + auto reader = std::make_shared(); + reader->set("/root", success({ { "Folder2", DirectoryEntryKind::DIRECTORY }, + { "Folder10", DirectoryEntryKind::DIRECTORY }, + { "readme.txt", DirectoryEntryKind::FILE } })); + // Repeat the selected parent's name at a nonzero child index. Before the + // enter/refresh preservation split, entering Folder10 incorrectly selected + // this child instead of starting at the first entry. + reader->set("/root/Folder10", success({ { "Alpha", DirectoryEntryKind::DIRECTORY }, + { "Folder10", DirectoryEntryKind::DIRECTORY } })); + FileBrowserModel model({ "/root" }, reader); + + model.moveSelection(1); + CHECK(model.getEntries()[model.getSelected()].name == "Folder10"); + CHECK(model.activateSelected() == FileBrowserModel::ActivateResult::ENTERED_DIRECTORY); + CHECK(model.getLocationLabel() == "/root/Folder10"); + CHECK(model.getSelected() == 0); + CHECK(model.getEntries()[model.getSelected()].name == "Alpha"); + CHECK(model.navigateBack()); + CHECK(model.getLocationLabel() == "/root"); + CHECK(model.getEntries()[model.getSelected()].name == "Folder10"); + CHECK(!model.navigateBack()); +} + +void testVirtualRootNavigation() { + auto reader = std::make_shared(); + reader->set("/one", success()); + reader->set("/two", success({ { "first", DirectoryEntryKind::FILE }, + { "second", DirectoryEntryKind::FILE } })); + FileBrowserModel model({ "/one", "/two" }, reader); + + model.moveSelection(1); + CHECK(model.activateSelected() == FileBrowserModel::ActivateResult::ENTERED_DIRECTORY); + CHECK(model.getLocationLabel() == "/two"); + CHECK(model.getSelected() == 0); + CHECK(model.getEntries()[model.getSelected()].name == "first"); + CHECK(model.navigateBack()); + CHECK(model.isAtVirtualRoot()); + CHECK(model.getSelected() == 1); +} + +void testFileActivationAndXbeCandidate() { + auto reader = std::make_shared(); + reader->set("/root", success({ { "GAME.XBE", DirectoryEntryKind::FILE }, + { "not-an-xbe.txt", DirectoryEntryKind::FILE }, + { "other.xbe", DirectoryEntryKind::OTHER }, + { "trailing.xbe.bak", DirectoryEntryKind::FILE } })); + FileBrowserModel model({ "/root" }, reader); + + std::string candidate = "stale"; + CHECK(model.activateSelected(&candidate) + == FileBrowserModel::ActivateResult::XBE_SELECTED); + CHECK(candidate == "/root/GAME.XBE"); + + model.moveSelection(1); + CHECK(model.activateSelected(&candidate) + == FileBrowserModel::ActivateResult::FILE_SELECTED); + CHECK(candidate.empty()); + CHECK(model.getStatus().find("Only XBE") != std::string::npos); + + model.moveSelection(1); + CHECK(model.activateSelected(&candidate) + == FileBrowserModel::ActivateResult::FILE_SELECTED); + CHECK(candidate.empty()); + + model.moveSelection(1); + CHECK(model.activateSelected() == FileBrowserModel::ActivateResult::FILE_SELECTED); + CHECK(model.getLocationLabel() == "/root"); +} + +void testOverlongXbeCandidateIsRefused() { + auto reader = std::make_shared('/', 14); + reader->set("/root", success({ { "tool.xbe", DirectoryEntryKind::FILE } })); + FileBrowserModel model({ "/root" }, reader); + std::string candidate = "stale"; + CHECK(model.activateSelected(&candidate) == FileBrowserModel::ActivateResult::FAILED); + CHECK(candidate.empty()); + CHECK(model.getStatus().find("too long") != std::string::npos); +} + +void testRefreshRetentionAndRemoval() { + auto reader = std::make_shared(); + reader->set("/root", success({ { "a", DirectoryEntryKind::FILE }, + { "b", DirectoryEntryKind::FILE }, + { "c", DirectoryEntryKind::FILE } })); + FileBrowserModel model({ "/root" }, reader); + model.moveSelection(1); + CHECK(model.getEntries()[model.getSelected()].name == "b"); + + reader->set("/root", success({ { "b", DirectoryEntryKind::FILE }, + { "d", DirectoryEntryKind::FILE } })); + CHECK(model.refresh()); + CHECK(model.getEntries()[model.getSelected()].name == "b"); + + reader->set("/root", success({ { "b", DirectoryEntryKind::DIRECTORY }, + { "d", DirectoryEntryKind::FILE } })); + CHECK(model.refresh()); + CHECK(model.getEntries()[model.getSelected()].name == "b"); + CHECK(model.getEntries()[model.getSelected()].kind == DirectoryEntryKind::DIRECTORY); + + std::vector previous = model.getEntries(); + reader->set("/root", failure("media removed")); + CHECK(!model.refresh()); + CHECK(model.getEntries().size() == previous.size()); + CHECK(model.getEntries()[0].name == previous[0].name); + CHECK(model.getStatus().find("media removed") != std::string::npos); + + auto removalReader = std::make_shared(); + removalReader->set("/root", success({ { "a", DirectoryEntryKind::FILE }, + { "b", DirectoryEntryKind::FILE }, + { "c", DirectoryEntryKind::FILE } })); + FileBrowserModel removalModel({ "/root" }, removalReader); + removalModel.moveSelection(1); + removalReader->set("/root", success({ { "a", DirectoryEntryKind::FILE }, + { "c", DirectoryEntryKind::FILE } })); + CHECK(removalModel.refresh()); + CHECK(removalModel.getSelected() == 1); + CHECK(removalModel.getEntries()[removalModel.getSelected()].name == "c"); +} + +void testEmptyAndInitialReadError() { + auto reader = std::make_shared(); + reader->set("/empty", success()); + FileBrowserModel emptyModel({ "/empty" }, reader); + CHECK(emptyModel.getEntries().empty()); + CHECK(emptyModel.getStatus() == "Directory is empty"); + + FileBrowserModel failedModel({ "/missing" }, reader); + CHECK(failedModel.getEntries().empty()); + CHECK(failedModel.getStatus().find("not found") != std::string::npos); +} + +void testSelectionEdges() { + auto reader = std::make_shared(); + reader->set("/root", success({ { "a", DirectoryEntryKind::FILE }, + { "b", DirectoryEntryKind::FILE }, + { "c", DirectoryEntryKind::FILE } })); + FileBrowserModel model({ "/root" }, reader); + model.moveSelection(-1); + CHECK(model.getSelected() == 2); + model.moveSelection(1); + CHECK(model.getSelected() == 0); + model.pageSelection(20); + CHECK(model.getSelected() == 2); + model.pageSelection(-20); + CHECK(model.getSelected() == 0); +} + +void testOverlongChildPathIsRefused() { + auto reader = std::make_shared('/', 12); + reader->set("/root", success({ { "abcdef", DirectoryEntryKind::DIRECTORY } })); + FileBrowserModel model({ "/root" }, reader); + CHECK(model.activateSelected() == FileBrowserModel::ActivateResult::FAILED); + CHECK(model.getLocationLabel() == "/root"); + CHECK(model.getStatus().find("too long") != std::string::npos); +} + +float byteWidth(const std::string& value) { + return static_cast(value.size()); +} + +void testEllipsisBoundariesAndUtf8() { + CHECK(ellipsizeText("hello", 5.0f, byteWidth) == "hello"); + CHECK(ellipsizeText("hello", 4.0f, byteWidth) == "h..."); + CHECK(ellipsizeText("hello", 2.0f, byteWidth) == ".."); + CHECK(ellipsizeText("hello", 0.5f, byteWidth).empty()); + + std::string utf8 = "a\xE2\x82\xACz"; + CHECK(ellipsizeText(utf8, 4.0f, byteWidth) == "a..."); + + CHECK(sanitizeUtf8ForDisplay(utf8) == utf8); + CHECK(sanitizeUtf8ForDisplay(std::string("a\xFF" + "b", + 3)) + == "a?b"); + CHECK(sanitizeUtf8ForDisplay(std::string("x\xE2\x82", 3)) == "x??"); + CHECK(sanitizeUtf8ForDisplay(std::string("\xED\xA0\x80", 3)) == "???"); +} + +void writeLittleEndian32(std::vector& data, size_t offset, uint32_t value) { + data[offset] = static_cast(value); + data[offset + 1] = static_cast(value >> 8); + data[offset + 2] = static_cast(value >> 16); + data[offset + 3] = static_cast(value >> 24); +} + +void writeLittleEndian16(std::vector& data, size_t offset, uint16_t value) { + data[offset] = static_cast(value); + data[offset + 1] = static_cast(value >> 8); +} + +std::vector validXbeHeader(const std::string& title = "Test XBE") { + const uint32_t imageBase = 0x10000; + const size_t certificateOffset = 0x178; + std::vector data(0x400, 0); + writeLittleEndian32(data, 0x000, 0x48454258); + writeLittleEndian32(data, 0x104, imageBase); + writeLittleEndian32(data, 0x108, static_cast(data.size())); + writeLittleEndian32(data, 0x118, imageBase + static_cast(certificateOffset)); + for (size_t index = 0; index < title.size() && index < 40; ++index) { + writeLittleEndian16(data, certificateOffset + 12 + (index * 2), + static_cast(title[index])); + } + return data; +} + +void testXbeValidationAndTitleExtraction() { + std::vector data = validXbeHeader("Launch Me"); + XBEValidationResult result = XBEValidator::validateBytes(data); + CHECK(result.valid); + CHECK(result.title == "Launch Me"); + + writeLittleEndian16(data, 0x178 + 12, 0x20AC); + result = XBEValidator::validateBytes(data); + CHECK(result.valid); + CHECK(result.title == "?aunch Me"); + + writeLittleEndian16(data, 0x178 + 12, 0); + result = XBEValidator::validateBytes(data); + CHECK(result.valid); + CHECK(result.title.empty()); + CHECK(XBEScanner::displayNameFor(result, "Fallback") == "Fallback"); + CHECK(XBEScanner::displayNameFor(result, std::string("bad\xFF", 4)) == "bad?"); +} + +void testXbeValidationFailures() { + CHECK(XBEValidator::validateBytes(std::vector(10)).error + == XBEValidationError::FILE_TOO_SMALL); + + std::vector data = validXbeHeader(); + data[0] = 0; + CHECK(XBEValidator::validateBytes(data).error == XBEValidationError::BAD_MAGIC); + + data = validXbeHeader(); + writeLittleEndian32(data, 0x104, 0x20000); + CHECK(XBEValidator::validateBytes(data).error == XBEValidationError::BAD_IMAGE_BASE); + + data = validXbeHeader(); + writeLittleEndian32(data, 0x108, 0x100); + CHECK(XBEValidator::validateBytes(data).error == XBEValidationError::INVALID_HEADER_SIZE); + + data = validXbeHeader(); + writeLittleEndian32(data, 0x108, static_cast(XBEValidator::MAX_HEADER_SIZE + 1)); + CHECK(XBEValidator::validateBytes(data).error == XBEValidationError::HEADER_TOO_LARGE); + + data = validXbeHeader(); + writeLittleEndian32(data, 0x108, 0x800); + CHECK(XBEValidator::validateBytes(data).error == XBEValidationError::TRUNCATED_HEADER); + + data = validXbeHeader(); + writeLittleEndian32(data, 0x118, 0xFFFF0000); + CHECK(XBEValidator::validateBytes(data).error == XBEValidationError::INVALID_CERTIFICATE); + + data = validXbeHeader(); + writeLittleEndian32(data, 0x118, 0xFFFF); + CHECK(XBEValidator::validateBytes(data).error == XBEValidationError::INVALID_CERTIFICATE); + + CHECK(XBEValidator::validateFile("/definitely/missing/nevolutionx.xbe").error + == XBEValidationError::OPEN_FAILED); +} + +void testXbeLaunchPathPreparation() { + XBEPreparedLaunchPath result = prepareXbeLaunchPath("E:/Apps/tool.xbe"); + CHECK(result.valid); + CHECK(result.path == "E:\\Apps\\tool.xbe"); + + result = prepareXbeLaunchPath("d:/default.xbe"); + CHECK(result.valid); + CHECK(result.path == "\\Device\\CdRom0\\default.xbe"); + + result = prepareXbeLaunchPath("\\Device\\Harddisk0/Partition1/tool.xbe"); + CHECK(result.valid); + CHECK(result.path == "\\Device\\Harddisk0\\Partition1\\tool.xbe"); + + CHECK(!prepareXbeLaunchPath("").valid); + CHECK(!prepareXbeLaunchPath("D").valid); + CHECK(!prepareXbeLaunchPath("D:").valid); + CHECK(!prepareXbeLaunchPath("E:\\x").valid); + CHECK(!prepareXbeLaunchPath("D:relative.xbe").valid); + CHECK(!prepareXbeLaunchPath("H:\\tool.xbe").valid); + CHECK(!prepareXbeLaunchPath("relative.xbe").valid); + CHECK(!prepareXbeLaunchPath("E:\\").valid); + CHECK(!prepareXbeLaunchPath("E:\\Apps\\").valid); + CHECK(!prepareXbeLaunchPath("E:\\..\\tool.xbe").valid); + CHECK(!prepareXbeLaunchPath("E:\\Apps\\\\tool.xbe").valid); + CHECK(!prepareXbeLaunchPath("\\Device\\default.xbe").valid); + CHECK(!prepareXbeLaunchPath("\\Device\\\\default.xbe").valid); + CHECK(!prepareXbeLaunchPath("\\Device\\CdRom0\\..\\default.xbe").valid); + CHECK(!prepareXbeLaunchPath("\\Device\\CdRom0\\Folder\\").valid); + CHECK(!prepareXbeLaunchPath(std::string("E:\\ok.xbe\0ignored", 17)).valid); + CHECK(!prepareXbeLaunchPath("D:\\" + std::string(255, 'x')).valid); +} + +void testPosixReaderDoesNotTraverseSymlinks() { + char templatePath[] = "/tmp/nevolutionx-browser-XXXXXX"; + char* root = mkdtemp(templatePath); + CHECK(root); + + std::string rootPath(root); + CHECK(XBEValidator::validateFile(rootPath).error == XBEValidationError::READ_FAILED); + std::string directoryPath = rootPath + "/folder"; + std::string filePath = rootPath + "/file.txt"; + std::string symlinkPath = rootPath + "/link"; + CHECK(mkdir(directoryPath.c_str(), 0700) == 0); + FILE* file = std::fopen(filePath.c_str(), "wb"); + CHECK(file); + CHECK(std::fputs("test", file) >= 0); + CHECK(std::fclose(file) == 0); + CHECK(symlink(directoryPath.c_str(), symlinkPath.c_str()) == 0); + + PlatformDirectoryReader reader; + DirectoryReadResult result = reader.read(rootPath); + CHECK(result.succeeded); + std::map kinds; + for (const auto& entry: result.entries) { + kinds[entry.name] = entry.kind; + } + CHECK(kinds["folder"] == DirectoryEntryKind::DIRECTORY); + CHECK(kinds["file.txt"] == DirectoryEntryKind::FILE); + CHECK(kinds["link"] == DirectoryEntryKind::OTHER); + + CHECK(unlink(symlinkPath.c_str()) == 0); + CHECK(unlink(filePath.c_str()) == 0); + CHECK(rmdir(directoryPath.c_str()) == 0); + CHECK(rmdir(rootPath.c_str()) == 0); +} + +} // namespace + +int main() { + RUN_TEST(testRootValidationAndNormalization); + RUN_TEST(testNoValidRoots); + RUN_TEST(testSafeChildNames); + RUN_TEST(testOrderingAndUnsafeEntryFiltering); + RUN_TEST(testOrderingIsTransitiveAcrossRawByteClasses); + RUN_TEST(testSingleRootNavigationAndSelectionRestore); + RUN_TEST(testVirtualRootNavigation); + RUN_TEST(testFileActivationAndXbeCandidate); + RUN_TEST(testOverlongXbeCandidateIsRefused); + RUN_TEST(testRefreshRetentionAndRemoval); + RUN_TEST(testEmptyAndInitialReadError); + RUN_TEST(testSelectionEdges); + RUN_TEST(testOverlongChildPathIsRefused); + RUN_TEST(testEllipsisBoundariesAndUtf8); + RUN_TEST(testXbeValidationAndTitleExtraction); + RUN_TEST(testXbeValidationFailures); + RUN_TEST(testXbeLaunchPathPreparation); + RUN_TEST(testPosixReaderDoesNotTraverseSymlinks); + return 0; +}