diff --git a/core/libs/prx/libSceSystemService/CMakeLists.txt b/core/libs/prx/libSceSystemService/CMakeLists.txt index 5885fd39d51e..a9428ec7a37d 100644 --- a/core/libs/prx/libSceSystemService/CMakeLists.txt +++ b/core/libs/prx/libSceSystemService/CMakeLists.txt @@ -3,9 +3,15 @@ if(APPLE) target_link_libraries(system_service_host_locale INTERFACE "-framework CoreFoundation") endif() -add_library(libSceSystemService SHARED EXCLUDE_FROM_ALL Export.cpp PendingExports.cpp LoadExec.cpp) -target_include_directories(libSceSystemService PRIVATE ${LIBS_INCLUDE_DIR}) -target_link_libraries(libSceSystemService PRIVATE libSceAgcDriver libSceUsbStorage system_service_host_locale) +add_library(libSceSystemService SHARED EXCLUDE_FROM_ALL Export.cpp PendingExports.cpp LoadExec.cpp HostUrlConfirmation.cpp) +if(WIN32) + target_sources(libSceSystemService PRIVATE HostUrlLauncherWindows.cpp) + target_link_libraries(libSceSystemService PRIVATE shell32) +else() + target_sources(libSceSystemService PRIVATE HostUrlLauncherPosix.cpp) +endif() +target_include_directories(libSceSystemService PRIVATE ${LIBS_INCLUDE_DIR} ${ANYPS5_SDL2_INCLUDE_DIR}) +target_link_libraries(libSceSystemService PRIVATE libSceAgcDriver libSceUsbStorage system_service_host_locale SDL2-static) configure_windows_unwind(libSceSystemService) if(BUILD_TESTING) @@ -97,6 +103,24 @@ if(BUILD_TESTING) "PATH=path_list_prepend:$") endif() endforeach() + add_test_executable(guest_system_service_open_url_tests ${LIBS_INCLUDE_DIR}/tests/GuestSystemServiceOpenUrl.cpp) + target_include_directories(guest_system_service_open_url_tests PRIVATE ${LIBS_INCLUDE_DIR}) + target_link_libraries(guest_system_service_open_url_tests PRIVATE libSceSystemService libc) + configure_windows_unwind(guest_system_service_open_url_tests) + add_test(NAME guest_system_service_open_url COMMAND guest_system_service_open_url_tests) + if(WIN32) + set_tests_properties(guest_system_service_open_url PROPERTIES ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:$") + endif() + add_test_executable(guest_system_service_open_url_limits_tests ${LIBS_INCLUDE_DIR}/tests/GuestSystemServiceOpenUrlLimits.cpp) + target_include_directories(guest_system_service_open_url_limits_tests PRIVATE ${LIBS_INCLUDE_DIR}) + target_link_libraries(guest_system_service_open_url_limits_tests PRIVATE libSceSystemService libc) + configure_windows_unwind(guest_system_service_open_url_limits_tests) + add_test(NAME guest_system_service_open_url_limits COMMAND guest_system_service_open_url_limits_tests) + if(WIN32) + set_tests_properties(guest_system_service_open_url_limits PROPERTIES ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:$") + endif() add_test_executable(guest_system_service_language_tests ${LIBS_INCLUDE_DIR}/tests/GuestSystemServiceLanguage.cpp) target_include_directories(guest_system_service_language_tests PRIVATE ${LIBS_INCLUDE_DIR}) target_link_libraries(guest_system_service_language_tests PRIVATE libSceSystemService libc) diff --git a/core/libs/prx/libSceSystemService/Export.cpp b/core/libs/prx/libSceSystemService/Export.cpp index dc56656c981f..897db4d93675 100644 --- a/core/libs/prx/libSceSystemService/Export.cpp +++ b/core/libs/prx/libSceSystemService/Export.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -32,11 +33,26 @@ #include "prx/libkernel/KernelErrors.hpp" #include "prx/libkernel/AppMetadata/include/AppMetadata.hpp" #include "prx/libSceSystemService/SystemService.hpp" +#include "prx/libSceSystemService/HostUrlBackends.hpp" #include "prx/libSceUserService/UserService.hpp" #include "prx/libSceAgcDriver/Execution/include/HostHdr.hpp" namespace { +HostBrowserLauncher g_defaultUrlLauncher; +HostMessageBoxConfirmation g_defaultUrlConfirmation; +std::atomic g_urlLauncher{&g_defaultUrlLauncher}; +std::atomic g_urlConfirmation{&g_defaultUrlConfirmation}; +SteadyUrlClock g_steadyUrlClock; +OpenUrlGate g_urlGate(g_steadyUrlClock); + +int OpenWebBrowserUrl(const char* uri) { + const OpenUrlMode mode = OpenUrlModeFromEnvironment("sceSystemServiceLaunchWebBrowser"); + if (mode == OpenUrlMode::Never) return SCE_KERNEL_ERROR_EOPNOTSUPP; + const OpenUrlGateOutcome outcome = g_urlGate.Run(mode, uri, *g_urlLauncher.load(), *g_urlConfirmation.load()); + return outcome == OpenUrlGateOutcome::Launched ? SYSTEM_SERVICE_OK : SCE_KERNEL_ERROR_EOPNOTSUPP; +} + struct MeasuredParameter { int id; int value; @@ -623,7 +639,13 @@ int APS5_VABI sceSystemServiceLaunchWebBrowser(const char* uri) { while (length < 6016 && uri[length] != 0) ++length; if (length > 6015) return static_cast(0x80a10007); if (std::strncmp(uri, "http:", 5) != 0 && std::strncmp(uri, "https:", 6) != 0 && std::strncmp(uri, "psno:", 5) != 0) return static_cast(0x80a10008); - return SCE_KERNEL_ERROR_EOPNOTSUPP; + return OpenWebBrowserUrl(uri); +} + +extern "C" void SystemServiceSetUrlHandlers_nid_no_patch(IUrlLauncher* launcher, IUrlConfirmation* confirmation, IUrlClock* clock) { + g_urlLauncher = launcher != nullptr ? launcher : &g_defaultUrlLauncher; + g_urlConfirmation = confirmation != nullptr ? confirmation : &g_defaultUrlConfirmation; + g_urlGate.Reset(clock != nullptr ? *clock : static_cast(g_steadyUrlClock)); } int APS5_VABI sceSystemServiceLaunchApp(const char* titleId, const char* const* arguments, const void* param) { diff --git a/core/libs/prx/libSceSystemService/HostUrl.hpp b/core/libs/prx/libSceSystemService/HostUrl.hpp new file mode 100644 index 000000000000..0e4dd82ebe8e --- /dev/null +++ b/core/libs/prx/libSceSystemService/HostUrl.hpp @@ -0,0 +1,117 @@ +#pragma once + +#include +#include + +constexpr std::size_t HOST_URL_MAX_LENGTH = 2048; +constexpr std::size_t HOST_URL_MAX_PORT = 65535; + +enum class HostUrlVerdict { + Ok, + Empty, + TooLong, + BadCharacter, + BadEscape, + BadScheme, + BadAuthority, + Credentials, + BadPort, +}; + +namespace host_url { + +constexpr bool IsAlphaNumeric(char value) { + return (value >= '0' && value <= '9') || (value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z'); +} + +constexpr bool IsHexDigit(char value) { + return (value >= '0' && value <= '9') || (value >= 'a' && value <= 'f') || (value >= 'A' && value <= 'F'); +} + +constexpr int HexValue(char value) { + if (value >= '0' && value <= '9') return value - '0'; + if (value >= 'a' && value <= 'f') return value - 'a' + 10; + return value - 'A' + 10; +} + +constexpr bool IsUrlCharacter(char value) { + if (IsAlphaNumeric(value)) return true; + for (const char allowed : std::string_view("-._~:/?#[]@!$&'()*+,;=%")) + if (value == allowed) return true; + return false; +} + +constexpr bool IsHostCharacter(char value) { + return IsAlphaNumeric(value) || value == '-' || value == '.' || value == '_'; +} + +constexpr HostUrlVerdict ValidateEscapes(std::string_view url) { + for (std::size_t index = 0; index < url.size(); ++index) { + if (url[index] != '%') continue; + if (index + 2 >= url.size()) return HostUrlVerdict::BadEscape; + if (!IsHexDigit(url[index + 1]) || !IsHexDigit(url[index + 2])) return HostUrlVerdict::BadEscape; + const int decoded = HexValue(url[index + 1]) * 16 + HexValue(url[index + 2]); + if (decoded < 0x20 || decoded == 0x7f) return HostUrlVerdict::BadEscape; + index += 2; + } + return HostUrlVerdict::Ok; +} + +constexpr HostUrlVerdict ValidatePort(std::string_view port) { + if (port.empty() || port.size() > 5) return HostUrlVerdict::BadPort; + std::size_t value = 0; + for (const char digit : port) { + if (digit < '0' || digit > '9') return HostUrlVerdict::BadPort; + value = value * 10 + static_cast(digit - '0'); + } + return value >= 1 && value <= HOST_URL_MAX_PORT ? HostUrlVerdict::Ok : HostUrlVerdict::BadPort; +} + +constexpr HostUrlVerdict ValidateAuthority(std::string_view authority) { + if (authority.empty()) return HostUrlVerdict::BadAuthority; + if (authority.find('@') != std::string_view::npos) return HostUrlVerdict::Credentials; + std::string_view host = authority; + std::string_view port; + bool hasPort = false; + if (authority.front() == '[') { + const std::size_t close = authority.find(']'); + if (close == std::string_view::npos || close == 1) return HostUrlVerdict::BadAuthority; + host = authority.substr(1, close - 1); + for (const char value : host) + if (!IsHexDigit(value) && value != ':' && value != '.') return HostUrlVerdict::BadAuthority; + const std::string_view rest = authority.substr(close + 1); + if (!rest.empty()) { + if (rest.front() != ':') return HostUrlVerdict::BadAuthority; + hasPort = true; + port = rest.substr(1); + } + } else { + const std::size_t colon = authority.find(':'); + if (colon != std::string_view::npos) { + host = authority.substr(0, colon); + hasPort = true; + port = authority.substr(colon + 1); + } + if (host.empty()) return HostUrlVerdict::BadAuthority; + for (const char value : host) + if (!IsHostCharacter(value)) return HostUrlVerdict::BadAuthority; + } + if (hasPort) return ValidatePort(port); + return HostUrlVerdict::Ok; +} + +} + +constexpr HostUrlVerdict ValidateHostUrl(std::string_view url) { + if (url.empty()) return HostUrlVerdict::Empty; + if (url.size() > HOST_URL_MAX_LENGTH) return HostUrlVerdict::TooLong; + for (const char value : url) + if (!host_url::IsUrlCharacter(value)) return HostUrlVerdict::BadCharacter; + if (const HostUrlVerdict escapes = host_url::ValidateEscapes(url); escapes != HostUrlVerdict::Ok) return escapes; + std::string_view rest = url; + if (rest.substr(0, 7) == "http://") rest.remove_prefix(7); + else if (rest.substr(0, 8) == "https://") rest.remove_prefix(8); + else return HostUrlVerdict::BadScheme; + const std::size_t end = rest.find_first_of("/?#"); + return host_url::ValidateAuthority(end == std::string_view::npos ? rest : rest.substr(0, end)); +} diff --git a/core/libs/prx/libSceSystemService/HostUrlBackends.hpp b/core/libs/prx/libSceSystemService/HostUrlBackends.hpp new file mode 100644 index 000000000000..83b5f4abcf03 --- /dev/null +++ b/core/libs/prx/libSceSystemService/HostUrlBackends.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#include "prx/libSceSystemService/HostUrlPolicy.hpp" + +constexpr int HOST_BROWSER_MAX_OPENERS = 2; + +class HostBrowserLauncher final : public IUrlLauncher { +public: + HostBrowserLauncher(); + explicit HostBrowserLauncher(std::string program); + bool Launch(const std::string& url) override; + +private: + std::string program; + std::shared_ptr> running = std::make_shared>(0); +}; + +class HostMessageBoxConfirmation final : public IUrlConfirmation { +public: + bool Confirm(const std::string& url) override; +}; + +std::string HostConfirmationText(const std::string& url); + +extern "C" void SystemServiceSetUrlHandlers_nid_no_patch(IUrlLauncher* launcher, IUrlConfirmation* confirmation, IUrlClock* clock); diff --git a/core/libs/prx/libSceSystemService/HostUrlConfirmation.cpp b/core/libs/prx/libSceSystemService/HostUrlConfirmation.cpp new file mode 100644 index 000000000000..c9018da9fab7 --- /dev/null +++ b/core/libs/prx/libSceSystemService/HostUrlConfirmation.cpp @@ -0,0 +1,37 @@ +#include "prx/libSceSystemService/HostUrlBackends.hpp" +#include + +namespace { + +constexpr std::size_t WRAP_WIDTH = 96; +constexpr int BUTTON_CANCEL = 0; +constexpr int BUTTON_OPEN = 1; + +} + +std::string HostConfirmationText(const std::string& url) { + std::string text = "A game asks to open this address in your default web browser:\n\n"; + for (std::size_t offset = 0; offset < url.size(); offset += WRAP_WIDTH) { + if (offset != 0) text += '\n'; + text.append(url, offset, WRAP_WIDTH); + } + text += "\n\nOpen it only if you trust it."; + return text; +} + +bool HostMessageBoxConfirmation::Confirm(const std::string& url) { + const std::string text = HostConfirmationText(url); + const SDL_MessageBoxButtonData buttons[] = { + {SDL_MESSAGEBOX_BUTTON_ESCAPEKEY_DEFAULT | SDL_MESSAGEBOX_BUTTON_RETURNKEY_DEFAULT, BUTTON_CANCEL, "Cancel"}, + {0, BUTTON_OPEN, "Open"}, + }; + SDL_MessageBoxData data{}; + data.flags = SDL_MESSAGEBOX_WARNING; + data.title = "AnyPS5: open a link?"; + data.message = text.c_str(); + data.numbuttons = 2; + data.buttons = buttons; + int pressed = BUTTON_CANCEL; + if (SDL_ShowMessageBox(&data, &pressed) != 0) return false; + return pressed == BUTTON_OPEN; +} diff --git a/core/libs/prx/libSceSystemService/HostUrlLauncherPosix.cpp b/core/libs/prx/libSceSystemService/HostUrlLauncherPosix.cpp new file mode 100644 index 000000000000..07de8436a565 --- /dev/null +++ b/core/libs/prx/libSceSystemService/HostUrlLauncherPosix.cpp @@ -0,0 +1,71 @@ +#include "prx/libSceSystemService/HostUrl.hpp" +#include "prx/libSceSystemService/HostUrlBackends.hpp" +#include +#include +#include +#include +#include +#include +#include +#ifdef __APPLE__ +#include +#else +extern char** environ; +#endif + +namespace { + +char** HostEnvironment() { +#ifdef __APPLE__ + return *_NSGetEnviron(); +#else + return environ; +#endif +} + +const char* DefaultProgram() { +#ifdef __APPLE__ + return "/usr/bin/open"; +#else + return "xdg-open"; +#endif +} + +} + +HostBrowserLauncher::HostBrowserLauncher() : program(DefaultProgram()) {} + +HostBrowserLauncher::HostBrowserLauncher(std::string program) : program(std::move(program)) {} + +bool HostBrowserLauncher::Launch(const std::string& url) { + if (ValidateHostUrl(url) != HostUrlVerdict::Ok || program.empty()) return false; + if (running->fetch_add(1) >= HOST_BROWSER_MAX_OPENERS) { + --*running; + return false; + } + std::string name = program; + std::string address = url; + char* arguments[] = {name.data(), address.data(), nullptr}; + posix_spawn_file_actions_t actions; + if (posix_spawn_file_actions_init(&actions) != 0) { + --*running; + return false; + } + bool prepared = posix_spawn_file_actions_addopen(&actions, 0, "/dev/null", O_RDONLY, 0) == 0 && + posix_spawn_file_actions_addopen(&actions, 1, "/dev/null", O_WRONLY, 0) == 0 && + posix_spawn_file_actions_addopen(&actions, 2, "/dev/null", O_WRONLY, 0) == 0; + pid_t child = 0; + const int error = prepared ? posix_spawnp(&child, name.c_str(), &actions, nullptr, arguments, HostEnvironment()) : -1; + posix_spawn_file_actions_destroy(&actions); + if (error != 0) { + --*running; + return false; + } + std::thread([child, count = running] { + int status = 0; + while (waitpid(child, &status, 0) < 0 && errno == EINTR) { + } + --*count; + }).detach(); + return true; +} diff --git a/core/libs/prx/libSceSystemService/HostUrlLauncherWindows.cpp b/core/libs/prx/libSceSystemService/HostUrlLauncherWindows.cpp new file mode 100644 index 000000000000..287eb2a207ab --- /dev/null +++ b/core/libs/prx/libSceSystemService/HostUrlLauncherWindows.cpp @@ -0,0 +1,20 @@ +#include "prx/libSceSystemService/HostUrl.hpp" +#include "prx/libSceSystemService/HostUrlBackends.hpp" +#include +#include +#include +#include + +HostBrowserLauncher::HostBrowserLauncher() = default; + +HostBrowserLauncher::HostBrowserLauncher(std::string program) : program(std::move(program)) {} + +bool HostBrowserLauncher::Launch(const std::string& url) { + if (ValidateHostUrl(url) != HostUrlVerdict::Ok) return false; + const int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, url.c_str(), static_cast(url.size()), nullptr, 0); + if (length <= 0) return false; + std::wstring address(static_cast(length), L'\0'); + if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, url.c_str(), static_cast(url.size()), address.data(), length) != length) return false; + const auto result = reinterpret_cast(ShellExecuteW(nullptr, L"open", address.c_str(), nullptr, nullptr, SW_SHOWNORMAL)); + return result > 32; +} diff --git a/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp b/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp new file mode 100644 index 000000000000..5f8b58f209f1 --- /dev/null +++ b/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp @@ -0,0 +1,126 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include "prx/libSceSystemService/HostUrl.hpp" + +enum class OpenUrlMode { + Never, + Ask, + Always, +}; + +inline OpenUrlMode ParseOpenUrlMode(const char* configured, std::string_view caller) { + if (configured == nullptr || configured[0] == '\0') return OpenUrlMode::Never; + const std::string_view text(configured); + if (text == "never") return OpenUrlMode::Never; + if (text == "ask") return OpenUrlMode::Ask; + if (text == "always") return OpenUrlMode::Always; + throw std::runtime_error(std::string(caller) + ": ANYPS5_OPEN_URLS must be never, ask or always, not \"" + std::string(text) + "\""); +} + +inline OpenUrlMode OpenUrlModeFromEnvironment(std::string_view caller) { + return ParseOpenUrlMode(std::getenv("ANYPS5_OPEN_URLS"), caller); +} + +class IUrlLauncher { +public: + virtual ~IUrlLauncher() = default; + virtual bool Launch(const std::string& url) = 0; +}; + +class IUrlConfirmation { +public: + virtual ~IUrlConfirmation() = default; + virtual bool Confirm(const std::string& url) = 0; +}; + +enum class OpenUrlOutcome { + Disabled, + Rejected, + Declined, + Failed, + Launched, +}; + +inline OpenUrlOutcome OpenUrlOnHost(OpenUrlMode mode, std::string_view url, IUrlLauncher& launcher, IUrlConfirmation& confirmation) { + if (mode == OpenUrlMode::Never) return OpenUrlOutcome::Disabled; + if (ValidateHostUrl(url) != HostUrlVerdict::Ok) return OpenUrlOutcome::Rejected; + const std::string address(url); + if (mode == OpenUrlMode::Ask && !confirmation.Confirm(address)) return OpenUrlOutcome::Declined; + return launcher.Launch(address) ? OpenUrlOutcome::Launched : OpenUrlOutcome::Failed; +} + +class IUrlClock { +public: + virtual ~IUrlClock() = default; + virtual std::chrono::steady_clock::time_point Now() = 0; +}; + +class SteadyUrlClock final : public IUrlClock { +public: + std::chrono::steady_clock::time_point Now() override { return std::chrono::steady_clock::now(); } +}; + +constexpr std::size_t OPEN_URL_MAX_ATTEMPTS = 3; +constexpr std::chrono::seconds OPEN_URL_WINDOW{60}; +constexpr std::chrono::seconds OPEN_URL_DECLINE_COOLDOWN{600}; + +enum class OpenUrlGateOutcome { + Disabled, + Rejected, + Busy, + Limited, + Suppressed, + Declined, + Failed, + Launched, +}; + +class OpenUrlGate { +public: + explicit OpenUrlGate(IUrlClock& clock) : clock(&clock) {} + + void Reset(IUrlClock& replacement) { + const std::lock_guard lock(state); + clock = &replacement; + attempts.clear(); + suppressed = false; + } + + OpenUrlGateOutcome Run(OpenUrlMode mode, std::string_view url, IUrlLauncher& launcher, IUrlConfirmation& confirmation) { + if (mode == OpenUrlMode::Never) return OpenUrlGateOutcome::Disabled; + if (ValidateHostUrl(url) != HostUrlVerdict::Ok) return OpenUrlGateOutcome::Rejected; + std::unique_lock lock(state, std::try_to_lock); + if (!lock.owns_lock()) return OpenUrlGateOutcome::Busy; + const auto now = clock->Now(); + if (suppressed && now < suppressedUntil) return OpenUrlGateOutcome::Suppressed; + while (!attempts.empty() && now - attempts.front() >= OPEN_URL_WINDOW) attempts.pop_front(); + if (attempts.size() >= OPEN_URL_MAX_ATTEMPTS) return OpenUrlGateOutcome::Limited; + attempts.push_back(now); + switch (OpenUrlOnHost(mode, url, launcher, confirmation)) { + case OpenUrlOutcome::Launched: return OpenUrlGateOutcome::Launched; + case OpenUrlOutcome::Declined: + suppressed = true; + suppressedUntil = now + OPEN_URL_DECLINE_COOLDOWN; + return OpenUrlGateOutcome::Declined; + case OpenUrlOutcome::Failed: return OpenUrlGateOutcome::Failed; + case OpenUrlOutcome::Rejected: return OpenUrlGateOutcome::Rejected; + case OpenUrlOutcome::Disabled: return OpenUrlGateOutcome::Disabled; + } + return OpenUrlGateOutcome::Failed; + } + +private: + std::mutex state; + IUrlClock* clock; + std::deque attempts; + bool suppressed = false; + std::chrono::steady_clock::time_point suppressedUntil; +}; diff --git a/core/libs/tests/GuestSystemServiceOpenUrl.cpp b/core/libs/tests/GuestSystemServiceOpenUrl.cpp new file mode 100644 index 000000000000..cac874f256b6 --- /dev/null +++ b/core/libs/tests/GuestSystemServiceOpenUrl.cpp @@ -0,0 +1,526 @@ +#include "GuestSystemServiceOpenUrlFakes.hpp" +#include "prx/libSceSystemService/include/SystemService.hpp" +#include "prx/libSceSystemService/HostUrl.hpp" +#include "prx/libSceSystemService/HostUrlPolicy.hpp" +#include "prx/libSceSystemService/HostUrlBackends.hpp" +#include +#include +#include +#include +#include +#include +#include +#ifdef _WIN32 +#include +#endif +#include +#include +#include + +namespace { + +void Require(bool value) { if (!value) std::abort(); } + +void SetEnvironment(const char* name, const char* value) { +#ifdef _WIN32 + Require(_putenv_s(name, value == nullptr ? "" : value) == 0); +#else + if (value == nullptr) Require(unsetenv(name) == 0); + else Require(setenv(name, value, 1) == 0); +#endif +} + +void CheckValidator() { + Require(ValidateHostUrl("http://example.com") == HostUrlVerdict::Ok); + Require(ValidateHostUrl("https://example.com/") == HostUrlVerdict::Ok); + Require(ValidateHostUrl("https://example.com:8443/a/b?c=d&e=f#g") == HostUrlVerdict::Ok); + Require(ValidateHostUrl("https://[2001:db8::1]:443/x") == HostUrlVerdict::Ok); + Require(ValidateHostUrl("https://[::1]/") == HostUrlVerdict::Ok); + Require(ValidateHostUrl("http://a_b.example-site.com?q=%41%20") == HostUrlVerdict::Ok); + Require(ValidateHostUrl("https://example.com#frag") == HostUrlVerdict::Ok); + Require(ValidateHostUrl("") == HostUrlVerdict::Empty); +} + +void CheckScheme() { + Require(ValidateHostUrl("http:") == HostUrlVerdict::BadScheme); + Require(ValidateHostUrl("https:") == HostUrlVerdict::BadScheme); + Require(ValidateHostUrl("http:example.com") == HostUrlVerdict::BadScheme); + Require(ValidateHostUrl("psno://example.com") == HostUrlVerdict::BadScheme); + Require(ValidateHostUrl("file:///etc/passwd") == HostUrlVerdict::BadScheme); + Require(ValidateHostUrl("javascript:alert(1)") == HostUrlVerdict::BadScheme); + Require(ValidateHostUrl("ftp://example.com") == HostUrlVerdict::BadScheme); + Require(ValidateHostUrl("HTTP://example.com") == HostUrlVerdict::BadScheme); + Require(ValidateHostUrl("//example.com") == HostUrlVerdict::BadScheme); + Require(ValidateHostUrl("example.com") == HostUrlVerdict::BadScheme); +} + +void CheckCharacters() { + Require(ValidateHostUrl("https://example.com/a b") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://example.com/\n") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://example.com/\t") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl(std::string("https://example.com/\0x", 22)) == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://example.com/\x7f") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://example.com/\x1b[0m") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://example.com/\xc3\xa9") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://\xd0\xb0pple.com/") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://example.com/\\x") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://example.com/\"x") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://example.com/") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://example.com/`x`") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://example.com/|x") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://example.com/{x}") == HostUrlVerdict::BadCharacter); +} + +void CheckEscapes() { + Require(ValidateHostUrl("https://example.com/%") == HostUrlVerdict::BadEscape); + Require(ValidateHostUrl("https://example.com/%4") == HostUrlVerdict::BadEscape); + Require(ValidateHostUrl("https://example.com/%zz") == HostUrlVerdict::BadEscape); + Require(ValidateHostUrl("https://example.com/%00") == HostUrlVerdict::BadEscape); + Require(ValidateHostUrl("https://example.com/%0a") == HostUrlVerdict::BadEscape); + Require(ValidateHostUrl("https://example.com/%0D") == HostUrlVerdict::BadEscape); + Require(ValidateHostUrl("https://example.com/%1F") == HostUrlVerdict::BadEscape); + Require(ValidateHostUrl("https://example.com/%7f") == HostUrlVerdict::BadEscape); + Require(ValidateHostUrl("https://example.com/%20") == HostUrlVerdict::Ok); + Require(ValidateHostUrl("https://example.com/%7E") == HostUrlVerdict::Ok); + Require(ValidateHostUrl("https://example.com/%c3%a9") == HostUrlVerdict::Ok); +} + +void CheckAuthority() { + Require(ValidateHostUrl("http://") == HostUrlVerdict::BadAuthority); + Require(ValidateHostUrl("https:///path") == HostUrlVerdict::BadAuthority); + Require(ValidateHostUrl("https://?q=1") == HostUrlVerdict::BadAuthority); + Require(ValidateHostUrl("https://#x") == HostUrlVerdict::BadAuthority); + Require(ValidateHostUrl("https://:443/") == HostUrlVerdict::BadAuthority); + Require(ValidateHostUrl("https://exa mple.com/") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://exam!ple.com/") == HostUrlVerdict::BadAuthority); + Require(ValidateHostUrl("https://exam%41ple.com/") == HostUrlVerdict::BadAuthority); + Require(ValidateHostUrl("https://[]/") == HostUrlVerdict::BadAuthority); + Require(ValidateHostUrl("https://[::g]/") == HostUrlVerdict::BadAuthority); + Require(ValidateHostUrl("https://[::1/") == HostUrlVerdict::BadAuthority); + Require(ValidateHostUrl("https://[::1]x/") == HostUrlVerdict::BadAuthority); +} + +void CheckCredentials() { + Require(ValidateHostUrl("https://user@example.com/") == HostUrlVerdict::Credentials); + Require(ValidateHostUrl("https://user:secret@example.com/") == HostUrlVerdict::Credentials); + Require(ValidateHostUrl("https://@example.com/") == HostUrlVerdict::Credentials); + Require(ValidateHostUrl("https://good.com@evil.com/") == HostUrlVerdict::Credentials); + Require(ValidateHostUrl("https://good.com:443@evil.com/") == HostUrlVerdict::Credentials); + Require(ValidateHostUrl("https://example.com/path@x") == HostUrlVerdict::Ok); + Require(ValidateHostUrl("https://example.com?u=a@b") == HostUrlVerdict::Ok); +} + +void CheckPort() { + Require(ValidateHostUrl("https://example.com:/") == HostUrlVerdict::BadPort); + Require(ValidateHostUrl("https://example.com:0/") == HostUrlVerdict::BadPort); + Require(ValidateHostUrl("https://example.com:65536/") == HostUrlVerdict::BadPort); + Require(ValidateHostUrl("https://example.com:123456/") == HostUrlVerdict::BadPort); + Require(ValidateHostUrl("https://example.com:80a/") == HostUrlVerdict::BadPort); + Require(ValidateHostUrl("https://example.com:65535/") == HostUrlVerdict::Ok); + Require(ValidateHostUrl("https://example.com:1") == HostUrlVerdict::Ok); + Require(ValidateHostUrl("https://[::1]:/") == HostUrlVerdict::BadPort); + Require(ValidateHostUrl("https://[::1]:99999/") == HostUrlVerdict::BadPort); +} + +void CheckLength() { + const std::string prefix = "https://example.com/"; + Require(ValidateHostUrl(prefix + std::string(HOST_URL_MAX_LENGTH - prefix.size(), 'a')) == HostUrlVerdict::Ok); + Require(ValidateHostUrl(prefix + std::string(HOST_URL_MAX_LENGTH - prefix.size() + 1, 'a')) == HostUrlVerdict::TooLong); + Require(ValidateHostUrl(std::string(100000, 'a')) == HostUrlVerdict::TooLong); +} + +bool ModeThrows(const char* value, std::string& message) { + try { + static_cast(ParseOpenUrlMode(value, "caller")); + } catch (const std::runtime_error& error) { + message = error.what(); + return true; + } + return false; +} + +void CheckMode() { + Require(ParseOpenUrlMode(nullptr, "caller") == OpenUrlMode::Never); + Require(ParseOpenUrlMode("", "caller") == OpenUrlMode::Never); + Require(ParseOpenUrlMode("never", "caller") == OpenUrlMode::Never); + Require(ParseOpenUrlMode("ask", "caller") == OpenUrlMode::Ask); + Require(ParseOpenUrlMode("always", "caller") == OpenUrlMode::Always); + for (const char* invalid : {"Ask", "ALWAYS", "1", "yes", "ask ", " ask", "never,ask", "0", "on"}) { + std::string message; + Require(ModeThrows(invalid, message)); + Require(message.find("ANYPS5_OPEN_URLS") != std::string::npos); + Require(message.find("caller") != std::string::npos); + Require(message.find(invalid) != std::string::npos); + } + SetEnvironment("ANYPS5_OPEN_URLS", nullptr); + Require(OpenUrlModeFromEnvironment("caller") == OpenUrlMode::Never); + SetEnvironment("ANYPS5_OPEN_URLS", ""); + Require(OpenUrlModeFromEnvironment("caller") == OpenUrlMode::Never); + SetEnvironment("ANYPS5_OPEN_URLS", "ask"); + Require(OpenUrlModeFromEnvironment("caller") == OpenUrlMode::Ask); + SetEnvironment("ANYPS5_OPEN_URLS", "always"); + Require(OpenUrlModeFromEnvironment("caller") == OpenUrlMode::Always); + SetEnvironment("ANYPS5_OPEN_URLS", "bogus"); + bool threw = false; + try { + static_cast(OpenUrlModeFromEnvironment("caller")); + } catch (const std::runtime_error&) { + threw = true; + } + Require(threw); + SetEnvironment("ANYPS5_OPEN_URLS", nullptr); +} + +class RecordingLauncher final : public IUrlLauncher { +public: + bool Launch(const std::string& url) override { + launched.push_back(url); + return result; + } + std::vector launched; + bool result = true; +}; + +class ScriptedConfirmation final : public IUrlConfirmation { +public: + bool Confirm(const std::string& url) override { + asked.push_back(url); + return answer; + } + std::vector asked; + bool answer = true; +}; + +void CheckPolicy() { + const std::string good = "https://example.com/a?b=c"; + RecordingLauncher launcher; + ScriptedConfirmation confirmation; + + Require(OpenUrlOnHost(OpenUrlMode::Never, good, launcher, confirmation) == OpenUrlOutcome::Disabled); + Require(launcher.launched.empty() && confirmation.asked.empty()); + + Require(OpenUrlOnHost(OpenUrlMode::Ask, good, launcher, confirmation) == OpenUrlOutcome::Launched); + Require(confirmation.asked == std::vector{good}); + Require(launcher.launched == std::vector{good}); + + confirmation.answer = false; + Require(OpenUrlOnHost(OpenUrlMode::Ask, good, launcher, confirmation) == OpenUrlOutcome::Declined); + Require(confirmation.asked.size() == 2 && launcher.launched.size() == 1); + confirmation.answer = true; + + launcher.result = false; + Require(OpenUrlOnHost(OpenUrlMode::Ask, good, launcher, confirmation) == OpenUrlOutcome::Failed); + Require(confirmation.asked.size() == 3 && launcher.launched.size() == 2); + launcher.result = true; + + confirmation.answer = false; + Require(OpenUrlOnHost(OpenUrlMode::Always, good, launcher, confirmation) == OpenUrlOutcome::Launched); + Require(confirmation.asked.size() == 3 && launcher.launched.size() == 3); + confirmation.answer = true; + + for (const std::string& bad : {std::string("psno://example.com"), std::string("https://u:p@example.com/"), std::string("https://example.com/\n"), + std::string("http://"), std::string("")}) { + for (const OpenUrlMode mode : {OpenUrlMode::Ask, OpenUrlMode::Always}) { + const std::size_t asked = confirmation.asked.size(); + const std::size_t launched = launcher.launched.size(); + Require(OpenUrlOnHost(mode, bad, launcher, confirmation) == OpenUrlOutcome::Rejected); + Require(confirmation.asked.size() == asked && launcher.launched.size() == launched); + } + const std::size_t launched = launcher.launched.size(); + Require(OpenUrlOnHost(OpenUrlMode::Never, bad, launcher, confirmation) == OpenUrlOutcome::Disabled); + Require(launcher.launched.size() == launched); + } +} + +#ifndef _WIN32 +std::filesystem::path MakeScratchDirectory() { + const auto directory = std::filesystem::temp_directory_path() / ("anyps5-open-url-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + Require(std::filesystem::create_directories(directory)); + return directory; +} + +std::filesystem::path WriteOpener(const std::filesystem::path& directory) { + const auto opener = directory / "opener.sh"; + { + std::ofstream script(opener); + script << "#!/bin/sh\nprintf '%s\\n' \"$#\" \"$0\" \"$1\" > \"" << directory.string() << "/record.tmp\"\nmv \"" << directory.string() << "/record.tmp\" \"" + << directory.string() << "/record\"\n"; + } + std::filesystem::permissions(opener, std::filesystem::perms::owner_all); + return opener; +} + +std::vector WaitForRecord(const std::filesystem::path& directory) { + for (int attempt = 0; attempt < 500; ++attempt) { + if (std::filesystem::exists(directory / "record")) { + std::ifstream record(directory / "record"); + std::vector lines; + for (std::string line; std::getline(record, line);) lines.push_back(line); + return lines; + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + std::abort(); +} + +void CheckPosixLauncher() { + const auto directory = MakeScratchDirectory(); + const auto opener = WriteOpener(directory); + const std::string url = "https://example.com/$(touch_pwned);id&x=1#a*b"; + { + HostBrowserLauncher launcher(opener.string()); + Require(launcher.Launch(url)); + } + const auto lines = WaitForRecord(directory); + Require(lines.size() == 3); + Require(lines[0] == "1"); + Require(lines[1] == opener.string()); + Require(lines[2] == url); + Require(!std::filesystem::exists(directory / "touch_pwned")); + Require(!std::filesystem::exists(std::filesystem::current_path() / "touch_pwned")); + + HostBrowserLauncher missing((directory / "absent").string()); + Require(!missing.Launch("https://example.com/")); + HostBrowserLauncher empty{std::string()}; + Require(!empty.Launch("https://example.com/")); + HostBrowserLauncher guarded(opener.string()); + std::filesystem::remove(directory / "record"); + Require(!guarded.Launch("-https://example.com/")); + Require(!guarded.Launch("")); + Require(!guarded.Launch("file:///etc/passwd")); + Require(!guarded.Launch("https://user@example.com/")); + Require(!guarded.Launch("https://example.com/ x")); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + Require(!std::filesystem::exists(directory / "record")); + std::filesystem::remove_all(directory); +} + +std::size_t CountStarted(const std::filesystem::path& directory) { + std::size_t count = 0; + for (const auto& entry : std::filesystem::directory_iterator(directory)) + if (entry.path().filename().string().rfind("started.", 0) == 0) ++count; + return count; +} + +void CheckPosixOutstandingBound() { + const auto directory = MakeScratchDirectory(); + const auto opener = directory / "blocking.sh"; + { + std::ofstream script(opener); + script << "#!/bin/sh\ntouch \"" << directory.string() << "/started.$$\"\nwhile [ ! -e \"" << directory.string() + << "/release\" ]; do sleep 0.05; done\n"; + } + std::filesystem::permissions(opener, std::filesystem::perms::owner_all); + HostBrowserLauncher launcher(opener.string()); + Require(HOST_BROWSER_MAX_OPENERS == 2); + Require(launcher.Launch("https://example.com/1")); + Require(launcher.Launch("https://example.com/2")); + for (int index = 0; index < 20; ++index) Require(!launcher.Launch("https://example.com/3")); + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + Require(CountStarted(directory) == 2); + + { std::ofstream release(directory / "release"); } + bool recovered = false; + for (int attempt = 0; attempt < 500 && !recovered; ++attempt) { + recovered = launcher.Launch("https://example.com/4"); + if (!recovered) std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + Require(recovered); + for (int attempt = 0; attempt < 500 && CountStarted(directory) < 3; ++attempt) std::this_thread::sleep_for(std::chrono::milliseconds(10)); + Require(CountStarted(directory) == 3); + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + std::filesystem::remove_all(directory); +} + +#endif + +void CheckConfirmationText() { + const std::string url = "https://example.com/" + std::string(500, 'a') + "?q=1"; + const std::string text = HostConfirmationText(url); + std::size_t lineLength = 0; + std::size_t longest = 0; + for (const char value : text) { + if (value == '\n') { + longest = std::max(longest, lineLength); + lineLength = 0; + } else { + ++lineLength; + } + } + Require(longest <= 96); + std::string stripped; + for (const char value : text) + if (value != '\n') stripped += value; + Require(stripped.find(url) != std::string::npos); + Require(HostConfirmationText("https://example.com/").find("https://example.com/") != std::string::npos); +} + +#ifdef __linux__ +void CheckConfirmationWithoutDisplay() { + SetEnvironment("DISPLAY", nullptr); + SetEnvironment("WAYLAND_DISPLAY", nullptr); + SetEnvironment("SDL_VIDEODRIVER", nullptr); + HostMessageBoxConfirmation confirmation; + Require(!confirmation.Confirm("https://example.com/")); +} +#endif + +void CheckLaunchWebBrowser() { + constexpr int unavailable = static_cast(0x8002002Du); + const std::string good = "https://example.com/store?id=1"; + RecordingLauncher launcher; + ScriptedConfirmation confirmation; + FakeClock clock; + SystemServiceSetUrlHandlers_nid_no_patch(&launcher, &confirmation, &clock); + const auto call = [&](const char* uri) { + const int result = sceSystemServiceLaunchWebBrowser(uri); + clock.Advance(std::chrono::seconds(700)); + return result; + }; + + SetEnvironment("ANYPS5_OPEN_URLS", nullptr); + Require(call(good.c_str()) == unavailable); + SetEnvironment("ANYPS5_OPEN_URLS", "never"); + Require(call(good.c_str()) == unavailable); + Require(launcher.launched.empty() && confirmation.asked.empty()); + + SetEnvironment("ANYPS5_OPEN_URLS", "ask"); + Require(call(good.c_str()) == 0); + Require(confirmation.asked == std::vector{good} && launcher.launched == std::vector{good}); + confirmation.answer = false; + Require(call(good.c_str()) == unavailable); + Require(confirmation.asked.size() == 2 && launcher.launched.size() == 1); + confirmation.answer = true; + launcher.result = false; + Require(call(good.c_str()) == unavailable); + launcher.result = true; + Require(launcher.launched.size() == 2); + + SetEnvironment("ANYPS5_OPEN_URLS", "always"); + confirmation.answer = false; + Require(call(good.c_str()) == 0); + Require(confirmation.asked.size() == 3 && launcher.launched.size() == 3); + confirmation.answer = true; + + const std::size_t asked = confirmation.asked.size(); + const std::size_t launched = launcher.launched.size(); + for (const char* refused : {"psno:", "http:", "https:", "http://", "https://user:pw@example.com/", "http://exa mple.com/", "https://example.com:0/", "https://example.com/%00"}) + Require(call(refused) == unavailable); + Require(call("") == static_cast(0x80a10008)); + Require(call("HTTP://example.com/") == static_cast(0x80a10008)); + Require(call("file:///etc/passwd") == static_cast(0x80a10008)); + Require(call(nullptr) == static_cast(0x80a10003)); + const std::string tooLong = "https://example.com/" + std::string(5990, 'a'); + Require(call(tooLong.c_str()) == unavailable); + const std::string overLimit = "https://example.com/" + std::string(6000, 'a') + std::string(100, 'a'); + Require(call(overLimit.c_str()) == static_cast(0x80a10007)); + Require(confirmation.asked.size() == asked && launcher.launched.size() == launched); + + SetEnvironment("ANYPS5_OPEN_URLS", "bogus"); + bool threw = false; + try { + static_cast(call(good.c_str())); + } catch (const std::runtime_error& error) { + threw = std::string(error.what()).find("ANYPS5_OPEN_URLS") != std::string::npos; + } + Require(threw); + Require(call("") == static_cast(0x80a10008)); + Require(launcher.launched.size() == launched); + + SetEnvironment("ANYPS5_OPEN_URLS", nullptr); + SystemServiceSetUrlHandlers_nid_no_patch(nullptr, nullptr, nullptr); + Require(call(good.c_str()) == unavailable); +} + +void CheckExportedLimits() { + constexpr int unavailable = static_cast(0x8002002Du); + CountingLauncher launcher; + CountingConfirmation confirmation; + FakeClock clock; + SystemServiceSetUrlHandlers_nid_no_patch(&launcher, &confirmation, &clock); + SetEnvironment("ANYPS5_OPEN_URLS", "ask"); + + confirmation.answer = false; + Require(sceSystemServiceLaunchWebBrowser("https://example.com/a") == unavailable); + for (int index = 0; index < 200; ++index) + Require(sceSystemServiceLaunchWebBrowser(index % 2 == 0 ? "https://example.com/a" : "https://example.org/b") == unavailable); + Require(confirmation.Count() == 1 && launcher.Count() == 0); + clock.Advance(OPEN_URL_DECLINE_COOLDOWN); + confirmation.answer = true; + Require(sceSystemServiceLaunchWebBrowser("https://example.com/a") == 0); + Require(confirmation.Count() == 2 && launcher.Count() == 1); + + SetEnvironment("ANYPS5_OPEN_URLS", "always"); + Require(sceSystemServiceLaunchWebBrowser("https://example.com/b") == 0); + Require(sceSystemServiceLaunchWebBrowser("https://example.com/c") == 0); + for (int index = 0; index < 200; ++index) Require(sceSystemServiceLaunchWebBrowser("https://example.com/d") == unavailable); + Require(launcher.Count() == OPEN_URL_MAX_ATTEMPTS); + clock.Advance(OPEN_URL_WINDOW); + Require(sceSystemServiceLaunchWebBrowser("https://example.com/e") == 0); + Require(launcher.Count() == OPEN_URL_MAX_ATTEMPTS + 1); + + SetEnvironment("ANYPS5_OPEN_URLS", "never"); + const std::size_t asked = confirmation.Count(); + const std::size_t launched = launcher.Count(); + for (int index = 0; index < 1000; ++index) Require(sceSystemServiceLaunchWebBrowser("https://example.com/f") == unavailable); + Require(confirmation.Count() == asked && launcher.Count() == launched); + SetEnvironment("ANYPS5_OPEN_URLS", nullptr); + SystemServiceSetUrlHandlers_nid_no_patch(nullptr, nullptr, nullptr); +} + +void CheckExportedConcurrentCallers() { + constexpr int unavailable = static_cast(0x8002002Du); + CountingLauncher launcher; + CountingConfirmation confirmation; + FakeClock clock; + SystemServiceSetUrlHandlers_nid_no_patch(&launcher, &confirmation, &clock); + SetEnvironment("ANYPS5_OPEN_URLS", "ask"); + confirmation.blocking = true; + + std::atomic first{-1}; + std::thread active([&] { first = sceSystemServiceLaunchWebBrowser("https://example.com/a"); }); + confirmation.WaitUntilAsked(1); + std::vector callers; + std::atomic unavailableCount{0}; + for (int index = 0; index < 16; ++index) + callers.emplace_back([&, index] { + const std::string url = "https://example.com/" + std::to_string(index % 3); + if (sceSystemServiceLaunchWebBrowser(url.c_str()) == unavailable) ++unavailableCount; + }); + for (std::thread& caller : callers) caller.join(); + Require(unavailableCount == 16); + Require(confirmation.Count() == 1 && launcher.Count() == 0); + confirmation.Release(); + active.join(); + Require(first == 0); + Require(confirmation.Count() == 1 && launcher.Count() == 1); + SetEnvironment("ANYPS5_OPEN_URLS", nullptr); + SystemServiceSetUrlHandlers_nid_no_patch(nullptr, nullptr, nullptr); +} + +} + +int main() { + CheckValidator(); + CheckScheme(); + CheckCharacters(); + CheckEscapes(); + CheckAuthority(); + CheckCredentials(); + CheckPort(); + CheckLength(); + CheckMode(); + CheckPolicy(); + CheckLaunchWebBrowser(); + CheckExportedLimits(); + CheckExportedConcurrentCallers(); + CheckConfirmationText(); +#ifdef __linux__ + CheckConfirmationWithoutDisplay(); +#endif +#ifndef _WIN32 + CheckPosixLauncher(); + CheckPosixOutstandingBound(); +#endif + return 0; +} diff --git a/core/libs/tests/GuestSystemServiceOpenUrlFakes.hpp b/core/libs/tests/GuestSystemServiceOpenUrlFakes.hpp new file mode 100644 index 000000000000..c58c29330235 --- /dev/null +++ b/core/libs/tests/GuestSystemServiceOpenUrlFakes.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include "prx/libSceSystemService/HostUrlPolicy.hpp" +#include +#include +#include +#include +#include +#include + +class FakeClock final : public IUrlClock { +public: + std::chrono::steady_clock::time_point Now() override { return now; } + void Advance(std::chrono::seconds amount) { now += amount; } + +private: + std::chrono::steady_clock::time_point now{std::chrono::hours(1)}; +}; + +class CountingLauncher final : public IUrlLauncher { +public: + bool Launch(const std::string& url) override { + const std::lock_guard lock(guard); + launched.push_back(url); + return result; + } + std::size_t Count() { + const std::lock_guard lock(guard); + return launched.size(); + } + std::mutex guard; + std::vector launched; + bool result = true; +}; + +class CountingConfirmation final : public IUrlConfirmation { +public: + bool Confirm(const std::string& url) override { + std::unique_lock lock(guard); + asked.push_back(url); + entered.notify_all(); + if (blocking) released.wait(lock, [this] { return !blocking; }); + return answer; + } + std::size_t Count() { + const std::lock_guard lock(guard); + return asked.size(); + } + void WaitUntilAsked(std::size_t count) { + std::unique_lock lock(guard); + entered.wait(lock, [&] { return asked.size() >= count; }); + } + void Release() { + const std::lock_guard lock(guard); + blocking = false; + released.notify_all(); + } + std::mutex guard; + std::condition_variable entered; + std::condition_variable released; + std::vector asked; + bool answer = true; + bool blocking = false; +}; diff --git a/core/libs/tests/GuestSystemServiceOpenUrlLimits.cpp b/core/libs/tests/GuestSystemServiceOpenUrlLimits.cpp new file mode 100644 index 000000000000..4b94f5fe156e --- /dev/null +++ b/core/libs/tests/GuestSystemServiceOpenUrlLimits.cpp @@ -0,0 +1,165 @@ +#include "GuestSystemServiceOpenUrlFakes.hpp" +#include "prx/libSceSystemService/HostUrlPolicy.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void Require(bool value) { if (!value) std::abort(); } + +void CheckBusy() { + FakeClock clock; + OpenUrlGate gate(clock); + CountingLauncher launcher; + CountingConfirmation confirmation; + confirmation.blocking = true; + OpenUrlGateOutcome first = OpenUrlGateOutcome::Failed; + std::thread active([&] { first = gate.Run(OpenUrlMode::Ask, "https://example.com/a", launcher, confirmation); }); + confirmation.WaitUntilAsked(1); + + Require(gate.Run(OpenUrlMode::Ask, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Busy); + Require(gate.Run(OpenUrlMode::Ask, "https://example.com/b", launcher, confirmation) == OpenUrlGateOutcome::Busy); + Require(gate.Run(OpenUrlMode::Always, "https://example.com/c", launcher, confirmation) == OpenUrlGateOutcome::Busy); + Require(confirmation.Count() == 1 && launcher.Count() == 0); + Require(gate.Run(OpenUrlMode::Never, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Disabled); + Require(gate.Run(OpenUrlMode::Ask, "psno://example.com", launcher, confirmation) == OpenUrlGateOutcome::Rejected); + + confirmation.Release(); + active.join(); + Require(first == OpenUrlGateOutcome::Launched); + Require(confirmation.Count() == 1 && launcher.Count() == 1); +} + +void CheckNever() { + FakeClock clock; + OpenUrlGate gate(clock); + CountingLauncher launcher; + CountingConfirmation confirmation; + for (int index = 0; index < 1000; ++index) + Require(gate.Run(OpenUrlMode::Never, "https://example.com/" + std::to_string(index), launcher, confirmation) == OpenUrlGateOutcome::Disabled); + Require(confirmation.Count() == 0 && launcher.Count() == 0); +} + +void CheckRateCap() { + FakeClock clock; + for (const OpenUrlMode mode : {OpenUrlMode::Ask, OpenUrlMode::Always}) { + OpenUrlGate gate(clock); + CountingLauncher launcher; + CountingConfirmation confirmation; + for (std::size_t index = 0; index < OPEN_URL_MAX_ATTEMPTS; ++index) { + Require(gate.Run(mode, "https://example.com/" + std::to_string(index), launcher, confirmation) == OpenUrlGateOutcome::Launched); + clock.Advance(std::chrono::seconds(1)); + } + for (int index = 0; index < 1000; ++index) + Require(gate.Run(mode, index % 2 == 0 ? "https://example.com/same" : "https://example.org/" + std::to_string(index), launcher, confirmation) == + OpenUrlGateOutcome::Limited); + Require(launcher.Count() == OPEN_URL_MAX_ATTEMPTS); + Require(confirmation.Count() == (mode == OpenUrlMode::Ask ? OPEN_URL_MAX_ATTEMPTS : 0)); + + clock.Advance(OPEN_URL_WINDOW - std::chrono::seconds(OPEN_URL_MAX_ATTEMPTS) - std::chrono::seconds(1)); + Require(gate.Run(mode, "https://example.com/late", launcher, confirmation) == OpenUrlGateOutcome::Limited); + clock.Advance(std::chrono::seconds(1)); + Require(gate.Run(mode, "https://example.com/recovered", launcher, confirmation) == OpenUrlGateOutcome::Launched); + Require(launcher.Count() == OPEN_URL_MAX_ATTEMPTS + 1); + Require(gate.Run(mode, "https://example.com/again", launcher, confirmation) == OpenUrlGateOutcome::Limited); + clock.Advance(std::chrono::seconds(1)); + Require(gate.Run(mode, "https://example.com/again", launcher, confirmation) == OpenUrlGateOutcome::Launched); + Require(gate.Run(mode, "https://example.com/again2", launcher, confirmation) == OpenUrlGateOutcome::Limited); + clock.Advance(OPEN_URL_WINDOW); + } +} + +void CheckRejectedAndFailedCount() { + FakeClock clock; + OpenUrlGate gate(clock); + CountingLauncher launcher; + CountingConfirmation confirmation; + for (int index = 0; index < 50; ++index) + Require(gate.Run(OpenUrlMode::Always, "https://u:p@example.com/", launcher, confirmation) == OpenUrlGateOutcome::Rejected); + Require(launcher.Count() == 0); + launcher.result = false; + for (std::size_t index = 0; index < OPEN_URL_MAX_ATTEMPTS; ++index) + Require(gate.Run(OpenUrlMode::Always, "https://example.com/", launcher, confirmation) == OpenUrlGateOutcome::Failed); + Require(gate.Run(OpenUrlMode::Always, "https://example.com/", launcher, confirmation) == OpenUrlGateOutcome::Limited); + Require(launcher.Count() == OPEN_URL_MAX_ATTEMPTS); +} + +void CheckDeclineCooldown() { + FakeClock clock; + OpenUrlGate gate(clock); + CountingLauncher launcher; + CountingConfirmation confirmation; + confirmation.answer = false; + Require(gate.Run(OpenUrlMode::Ask, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Declined); + for (int index = 0; index < 1000; ++index) { + Require(gate.Run(OpenUrlMode::Ask, index % 2 == 0 ? "https://example.com/a" : "https://example.net/" + std::to_string(index), launcher, confirmation) == + OpenUrlGateOutcome::Suppressed); + } + Require(gate.Run(OpenUrlMode::Always, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Suppressed); + Require(confirmation.Count() == 1 && launcher.Count() == 0); + + clock.Advance(OPEN_URL_DECLINE_COOLDOWN - std::chrono::seconds(1)); + Require(gate.Run(OpenUrlMode::Ask, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Suppressed); + Require(confirmation.Count() == 1); + clock.Advance(std::chrono::seconds(1)); + confirmation.answer = true; + Require(gate.Run(OpenUrlMode::Ask, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Launched); + Require(confirmation.Count() == 2 && launcher.Count() == 1); + Require(gate.Run(OpenUrlMode::Ask, "https://example.com/b", launcher, confirmation) == OpenUrlGateOutcome::Launched); +} + +void CheckRepeatedCancel() { + FakeClock clock; + OpenUrlGate gate(clock); + CountingLauncher launcher; + CountingConfirmation confirmation; + confirmation.answer = false; + for (int round = 0; round < 5; ++round) { + Require(gate.Run(OpenUrlMode::Ask, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Declined); + Require(gate.Run(OpenUrlMode::Ask, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Suppressed); + clock.Advance(OPEN_URL_DECLINE_COOLDOWN); + } + Require(confirmation.Count() == 5 && launcher.Count() == 0); +} + +void CheckSuppressedDoesNotUseAttempts() { + FakeClock clock; + OpenUrlGate gate(clock); + CountingLauncher launcher; + CountingConfirmation confirmation; + confirmation.answer = false; + Require(gate.Run(OpenUrlMode::Ask, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Declined); + clock.Advance(OPEN_URL_DECLINE_COOLDOWN - std::chrono::seconds(1)); + for (int index = 0; index < 10; ++index) Require(gate.Run(OpenUrlMode::Ask, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Suppressed); + clock.Advance(std::chrono::seconds(1)); + confirmation.answer = true; + for (std::size_t index = 0; index < OPEN_URL_MAX_ATTEMPTS; ++index) + Require(gate.Run(OpenUrlMode::Ask, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Launched); + Require(gate.Run(OpenUrlMode::Ask, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Limited); +} + +void CheckDocumentedValues() { + Require(OPEN_URL_MAX_ATTEMPTS == 3); + Require(OPEN_URL_WINDOW == std::chrono::seconds(60)); + Require(OPEN_URL_DECLINE_COOLDOWN == std::chrono::seconds(600)); +} + +} + +int main() { + CheckDocumentedValues(); + CheckBusy(); + CheckNever(); + CheckRateCap(); + CheckRejectedAndFailedCount(); + CheckDeclineCooldown(); + CheckRepeatedCancel(); + CheckSuppressedDoesNotUseAttempts(); + return 0; +} diff --git a/docs/dev/ENVIRONMENT.md b/docs/dev/ENVIRONMENT.md index 1e13b52662cb..932df533dc3b 100644 --- a/docs/dev/ENVIRONMENT.md +++ b/docs/dev/ENVIRONMENT.md @@ -605,6 +605,7 @@ The code reads environment names through `getenv`, `IsEnvironmentSet`, `kibSetti | Name | Kind | Source | Default | Description | |------|------|--------|---------|-------------| | `ANYPS5_LANGUAGE` | integer | `core/libs/prx/libSceSystemService/SystemService.hpp` | 1 | console language number from 0 to 30 returned by sceSystemServiceParamGetInt, defaulting to English US; `auto` (any letter case) maps the host's locale to a console language once per process and falls back to English US with one stderr line; any other value throws | +| `ANYPS5_OPEN_URLS` | string | `core/libs/prx/libSceSystemService/HostUrlPolicy.hpp` | | `never`, `ask` or `always`: whether `sceSystemServiceLaunchWebBrowser` opens a valid http or https address in the host's default browser; unset, empty and `never` keep the call failing, `ask` shows the address in a confirmation box first, `always` opens it without asking; any other value throws | ### libSceUsbStorage diff --git a/docs/dev/debt/libSceSystemService.md b/docs/dev/debt/libSceSystemService.md index 636f2afbdb80..7506ac3316df 100644 --- a/docs/dev/debt/libSceSystemService.md +++ b/docs/dev/debt/libSceSystemService.md @@ -44,7 +44,7 @@ ### Silent stubs - [libSceSystemService](../../../core/libs/prx/libSceSystemService/Export.cpp) - `sceSystemServicePowerTick`, `sceSystemServiceReportAbnormalTermination`, `sceSystemServiceDisableMusicPlayer`, `sceSystemServiceReenableMusicPlayer`, `sceSystemServiceDisableMediaPlay` and `sceSystemServiceReenableMediaPlay` return success and do nothing: the host has no auto power-off timer to reset, no system crash reporter to notify, no system music player to pause or resume and no system media playback to disable. PPSA01521 calls `sceSystemServiceDisableMediaPlay` during start-up, and its wrappers for both pass no arguments -- [sceSystemServiceLaunchWebBrowser](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - AnyPS5 launches no web browser, so the call fails and nothing is shown. RommPS PPSA76677 then tells the player to scan its QR code instead +- [sceSystemServiceLaunchWebBrowser](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - By default (`ANYPS5_OPEN_URLS` unset, empty or `never`) AnyPS5 launches no web browser, so the call fails and nothing is shown. RommPS PPSA76677 then tells the player to scan its QR code instead - [sceSystemServiceShowControllerSettings](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - returns success without showing the controller settings: AnyPS5 has no system UI to open. Cat Quest III (PPSA15065) imports it - [sceSystemServiceShowDisplaySafeAreaSettings](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - returns success without showing the display safe area settings dialog: AnyPS5 has no system UI to open. PPSA03332 imports it, and the no-argument signature is assumed: [Orbital](https://github.com/AlexAltea/orbital/blob/cc408367da6a6ebbf5ac1ac559fefeadc73b2831/tools/ida/db_types.json) has `sceSystemServiceShowControllerSettings` but not this call - [sceSystemServiceOpenChallengeActivity](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - returns success without opening the challenge activity: AnyPS5 has no system UI to open. Cat Quest III (PPSA15065) imports it @@ -65,7 +65,10 @@ - [sceLncUtilGetAppLaunchedUser, sceSystemServiceGetAppLaunchedUser, sceShellCoreUtilGetUserFocus](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - for the running title's app ID they write the initial user, 0x10000000 (`USER_SERVICE_INITIAL_USER_ID`): `sceUserServiceGetInitialUser` reports that user as the one who started the title, and `sceUserServiceGetForegroundUser` reports it as the foreground user. A null user pointer returns 0x80940005 (0x80e30003 from `sceShellCoreUtilGetUserFocus`) before the app ID is looked at, as firmware 13.42's `libSceSystemService.sprx` does ([emulator run](https://github.com/boykopovar/AnyPS5/pull/10801#issuecomment-6403075212)). Another app ID throws, because the console's answer for it is unknown. The user is written as a 32-bit value, as the LncUtil process and status payloads entry says - [sceLncUtilIsAppSuspended, sceSystemServiceIsAppSuspended](../../../core/libs/prx/libSceSystemService/Export.cpp), [sceLncUtilIsSpeculativeLaunch](../../../core/libs/prx/libSceSystemService/PendingExports.cpp) (libSceSystemService) - for the running title's app ID they write false. The title that calls them is running, and AnyPS5 has no shell to suspend it. It was started from the host, not by `sceLncUtilLaunchApp`, so no `LncLaunchParam` asked for a speculative launch. A null output returns 0x80940005 before the app ID is looked at, as firmware 13.42's `libSceSystemService.sprx` does ([emulator run](https://github.com/boykopovar/AnyPS5/pull/10801#issuecomment-6403075212)). Another app ID throws, because the console's answer for it is unknown. Whether the console already reports a title as suspended while it handles its own suspend event is not known - [sceSystemServiceLaunchApp](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - AnyPS5 runs one title and cannot start another, so every call throws without reading its arguments. The (title ID, null-terminated argv, launch parameters) signature follows [SharpProspero](https://github.com/SvenGDK/SharpProspero/blob/5dfdb4b0b166e83dc9ae275001afb695f12c7174/src/SharpProspero/Interop/SystemService/SystemService.cs#L141-L148), whose `AppLauncher` Prospero Explorer (PPSA99109) and Prospero Multi Tools (PPSA99110) import it through and which passes a 0x20-byte launch context or null; the console's argument checks and errors are unknown -- [sceSystemServiceLaunchWebBrowser](../../../core/libs/prx/libSceSystemService/Export.cpp): the native export takes one URI argument. Null input, the 6015-byte length limit and supported scheme checks are recovered. The host still returns kernel `EOPNOTSUPP` for valid input; a missing browser service's native error is unresolved. +- [sceSystemServiceLaunchWebBrowser](../../../core/libs/prx/libSceSystemService/Export.cpp): the native export takes one URI argument. Null input, the 6015-byte length limit and supported scheme checks are recovered. The host returns kernel `EOPNOTSUPP` for valid input unless `ANYPS5_OPEN_URLS` is `ask` or `always`; a missing browser service's native error is unresolved. +- [sceSystemServiceLaunchWebBrowser host launch](../../../core/libs/prx/libSceSystemService/HostUrlPolicy.hpp): with `ANYPS5_OPEN_URLS=ask` or `always` the call returns 0 once the host opener has started, and the native success value is not measured. Only `http://` and `https://` addresses of at most 2048 bytes without credentials, spaces, control characters, non-ASCII bytes or bad escapes open; `psno:` addresses and anything else the console's checks accept still return `EOPNOTSUPP`. These limits protect the host and are not console measurements. +- [sceSystemServiceLaunchWebBrowser host backends](../../../core/libs/prx/libSceSystemService/HostUrlConfirmation.cpp): the call does not observe what the opener does after it starts, so an opener that fails later still reports success. The Windows `ShellExecuteW` backend, the macOS `open` backend and the confirmation box on Windows and macOS are not run, only the Linux backend is; the macOS box is shown from the calling guest thread by dispatching to the main queue, which blocks if the main thread never services it. A Wayland-only session with no Xwayland has no box, so `ask` opens nothing there. +- [sceSystemServiceLaunchWebBrowser host limits](../../../core/libs/prx/libSceSystemService/HostUrlPolicy.hpp): the host refuses a request while another is active, allows three prompts or launches in 60 seconds, pauses ten minutes after a Cancel and runs at most two POSIX openers at once. These numbers protect the host from a title that retries in a loop; they are not console measurements, and the console's own behaviour for repeated calls is not measured. - [sceSystemServiceParamGetString](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - the system name (parameter 6) is the fixed `PS5`, not the console's name. As in a payload process on a PS5 (firmware 13.60), the name is written NUL-padded to the whole buffer, and a buffer under 65 bytes, a null buffer or any other parameter returns `SYSTEM_SERVICE_ERROR_PARAMETER` without writing. Parameters 0 to 40, 100 to 110, 1000 to 1010, -1 and `INT_MAX` were tried there; the others are assumed to fail the same way. PPSA26344 at `0xA87935` passes parameter 6 with a 65-byte buffer - [sceSystemServiceParamGetInt](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - console language numbers 0 to 29 follow the PS4 SDK enum in [OpenOrbis](https://github.com/OpenOrbis/OpenOrbis-PS4-Toolchain/blob/06fcf7fd108fa080035cdd3259ed22eb07e0508c/include/orbis/_types/sys_service.h#L54-L83) (21 Arabic). 30 (Ukrainian) is not in it and follows PPSA99505 ([Jelly5](https://github.com/02dnot/Jelly5/blob/552b866d89b2a58b27f7f7b0484dc743ce235526/app/src/app/i18n.cpp#L29-L60)) and PPSA99002 ([ProsperoLight](https://github.com/blackbearreloaded/ProsperoLight/blob/b708bbde3457bef1073a9ab6959957cb691e8e2f/include/i18n.hpp#L252-L309)), whose numbers agree with the enum. Not confirmed on a console, and neither title starts on `main` yet. Parameter 400 returns the same number as parameter 1: PPSA01325 asks for 400 first, asks for 1 when that fails, and uses either answer as an index into the same 31 languages; in the PS5 10.01 and 12.60 `libSceSystemService` modules parameter 400 reads the same registry value as parameter 1, without parameter 1's mapping of newer language numbers by SDK version, and returns 0x80a10003 when an imported check that is not identified returns 1; the 4.03 module has no parameter 400. Neither the mapping nor that error is modelled, and the parameter's name is not known. Without `ANYPS5_LANGUAGE` the language is English (United States); the date format and time format stay fixed - [sceSystemServiceParamGetInt](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - `ANYPS5_LANGUAGE=auto` takes the console language from the host locale with the table in [LanguageLocale.hpp](../../../core/libs/prx/libSceSystemService/LanguageLocale.hpp). The table is a host-side convenience: nothing was measured on a console about which language a person of a given host locale would choose, and what a title does with the number is unmeasured too. The language numbers and names are those of the entry above ([OpenOrbis](https://github.com/OpenOrbis/OpenOrbis-PS4-Toolchain/blob/06fcf7fd108fa080035cdd3259ed22eb07e0508c/include/orbis/_types/sys_service.h#L54-L83) for 0 to 29). diff --git a/docs/dev/debt/libSceWebBrowserDialog.md b/docs/dev/debt/libSceWebBrowserDialog.md index 4637724ad494..ea46b2bc88aa 100644 --- a/docs/dev/debt/libSceWebBrowserDialog.md +++ b/docs/dev/debt/libSceWebBrowserDialog.md @@ -3,7 +3,7 @@ ### Silent stubs - [sceWebBrowserDialogOpen, sceWebBrowserDialogOpenForPredeterminedContent](../../../core/libs/prx/libSceWebBrowserDialog/Export.cpp) (libSceWebBrowserDialog) - with `ANYPS5_PSN_DIALOGS=show`, a successful open shows AnyPS5's generic notice that the system feature is not available; no PlayStation Network link is claimed, since no source ties this dialog to it, and the console's own display is not measured. -- [libSceWebBrowserDialog](../../../core/libs/prx/libSceWebBrowserDialog/Export.cpp) - `sceWebBrowserDialogOpen` and `sceWebBrowserDialogOpenForPredeterminedContent` run the common-dialog state machine without displaying an interactive browser, and `sceWebBrowserDialogGetResult` reports that the user cancelled. Minecraft Bedrock (PPSA17221) imports `sceWebBrowserDialogOpenForPredeterminedContent` (#5744). The 424-byte allow-list parameter and its twenty optional address pointers are validated, but address filtering is not modelled without an interactive browser. Main open-parameter validation remains incomplete. +- [libSceWebBrowserDialog](../../../core/libs/prx/libSceWebBrowserDialog/Export.cpp) - `sceWebBrowserDialogOpen` and `sceWebBrowserDialogOpenForPredeterminedContent` run the common-dialog state machine without displaying an interactive browser, and `sceWebBrowserDialogGetResult` reports that the user cancelled. Minecraft Bedrock (PPSA17221) imports `sceWebBrowserDialogOpenForPredeterminedContent` (#5744). The 424-byte allow-list parameter and its twenty optional address pointers are validated, but address filtering is not modelled without an interactive browser. Main open-parameter validation remains incomplete. The layout of the open parameter and the offset of its URL field have no public source (the OpenOrbis headers declare no parameters and shadPS4 takes none), so no URL from it is opened on the host. - [sceWebBrowserDialogSetCookie, sceWebBrowserDialogResetCookie](../../../core/libs/prx/libSceWebBrowserDialog/Export.cpp) (libSceWebBrowserDialog) - there is no browser cookie store. Both calls check their parameters in the same order and with the same codes as the PS5 13.60 module, then return 0 without storing or clearing anything. On the console they then forward the request to the browser service. While an opened dialog has not been updated to finished, closed or terminated, they return `0x80B80006` (the console's open-dialog flag). ### Unknown function info diff --git a/docs/user/USAGE.md b/docs/user/USAGE.md index 99ec93356a8a..1697c31d0993 100644 --- a/docs/user/USAGE.md +++ b/docs/user/USAGE.md @@ -383,6 +383,14 @@ On Linux the request goes to [Feral GameMode](https://github.com/FeralInteractiv Games ask whether an add-on (downloadable content) is installed with `sceNpEntitlementAccessGetAddcontEntitlementInfo` and list the installed ones with `sceNpEntitlementAccessGetAddcontEntitlementInfoList`. An add-on counts as installed only when its entitlement label is listed in `anyps5-entitlements.ini` beside the output executable, or in the file `ANYPS5_ENTITLEMENTS` names: one label per line, up to 16 characters, with `#` or `;` starting a comment. List the add-ons your copy includes. Every other label is reported as not installed, and the first query for each prints one line, such as `[entitlements] the title asked for the add-on EXAMPLEADDON0000, which anyps5-entitlements.ini does not list, so it is reported as not installed`. The line names the file as `anyps5-entitlements.ini`, or by the path `ANYPS5_ENTITLEMENTS` gives when it is set. Bytes of a label outside printable ASCII, and the backslash, are written as `\xNN`. After 64 such labels, one more line says that further ones are reported without a line. Some games wait at start-up for an add-on they look for instead of going on without it: a title (PPSA26344, version 02.002.000) stays on its loading spinner until the add-on label it asks for is listed. +### Opening links in the host browser + +A game that asks for a web page with `sceSystemServiceLaunchWebBrowser` gets a failure and nothing opens, unless `ANYPS5_OPEN_URLS` is set. `ANYPS5_OPEN_URLS=ask` shows the whole address in a message box with Cancel as the default button and opens it in the default browser only when you press Open; with no display to show the box, nothing opens. `ANYPS5_OPEN_URLS=always` opens it without asking, which is not recommended because the game picks the address. Unlike `ask`, where you see the address first, `always` opens any `http` or `https` host the game names, including `localhost` and private-network addresses such as a router's web page. `never`, an empty value and an unset variable keep the failure. Any other value stops the game at its first browser request with an error that names the variable. + +Browser requests are limited for all addresses and all game threads together: a request that arrives while a box or a launch is still active is refused at once, at most three boxes or launches happen in any 60 seconds, and after you press Cancel no further box or launch happens for 10 minutes. Refused requests fail like the default. + +Only `http://` and `https://` addresses of at most 2048 bytes open. The host refuses addresses with a user name or password, a space, a control character, a character outside ASCII (use punycode and percent escapes), a malformed or control-character percent escape, an empty host or a port outside 1 to 65535, and `psno:` addresses. The browser is started without a shell: `xdg-open` on Linux, `open` on macOS and `ShellExecuteW` on Windows. + ### Trophies A game unlocks a trophy by posting a `_UnlockTrophy` event with an int32 `_trophy_id` property to `libSceNpUniversalDataSystem`, or through the statistics rules described below. The unlock is stored in `_trophy/_.txt` in the directory the game runs in (the one that holds `app0/`), one ` ` line per trophy; the time is an RTC tick, microseconds since 0001-01-01 UTC. Delete the file before starting the game to lock its trophies again. Each new unlock prints one line, such as `[trophy] PPSA01556 unlocked trophy 12 for user 268435456`; unlocking a trophy again prints nothing. `sceNpTrophy2GetTrophyInfo`, `sceNpTrophy2GetTrophyInfoArray` and `sceNpTrophy2GetGameInfo` report the stored unlocks and their times to the game. When the game's trophy data is loaded (see below), a posted unlock of a trophy that the data does not have, of the platinum, or of a progressive trophy is not stored and prints the reason, such as `[trophy] ignored the posted unlock of trophy 0 for user 268435456: it is the platinum, which unlocks with the last trophy that names it`; the platinum unlocks with the last trophy that names it, and a progressive trophy through its statistic. Without trophy data every posted id of 0 or more is stored, and the platinum is not unlocked when the other trophies are.