From e26c80c12908c95301fc7088f778cd499a6fe1ef Mon Sep 17 00:00:00 2001 From: Antonino Di Guardo <64427768+digant73@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:06:02 +0200 Subject: [PATCH] Add ISO integrity check --- Utilities/rXml.cpp | 13 ++ Utilities/rXml.h | 1 + rpcs3/Crypto/utils.cpp | 19 +- rpcs3/Crypto/utils.h | 5 +- rpcs3/Emu/CMakeLists.txt | 1 + rpcs3/Emu/system_utils.cpp | 15 ++ rpcs3/Emu/system_utils.hpp | 4 + rpcs3/Loader/ISO.cpp | 223 +++++++++++++++-------- rpcs3/Loader/ISO.h | 15 +- rpcs3/Loader/iso_validation.cpp | 162 ++++++++++++++++ rpcs3/Loader/iso_validation.h | 42 +++++ rpcs3/emucore.vcxproj | 2 + rpcs3/emucore.vcxproj.filters | 6 + rpcs3/rpcs3.vcxproj | 17 ++ rpcs3/rpcs3.vcxproj.filters | 14 ++ rpcs3/rpcs3qt/CMakeLists.txt | 1 + rpcs3/rpcs3qt/game_list_actions.cpp | 101 ++++++++++ rpcs3/rpcs3qt/game_list_actions.h | 4 + rpcs3/rpcs3qt/game_list_context_menu.cpp | 38 ++++ rpcs3/rpcs3qt/game_list_frame.cpp | 1 + rpcs3/rpcs3qt/game_list_frame.h | 4 + rpcs3/rpcs3qt/iso_integrity.cpp | 113 ++++++++++++ rpcs3/rpcs3qt/iso_integrity.h | 26 +++ 23 files changed, 748 insertions(+), 79 deletions(-) create mode 100644 rpcs3/Loader/iso_validation.cpp create mode 100644 rpcs3/Loader/iso_validation.h create mode 100644 rpcs3/rpcs3qt/iso_integrity.cpp create mode 100644 rpcs3/rpcs3qt/iso_integrity.h diff --git a/Utilities/rXml.cpp b/Utilities/rXml.cpp index 14ac0659f6..cf44a70ead 100644 --- a/Utilities/rXml.cpp +++ b/Utilities/rXml.cpp @@ -10,6 +10,19 @@ rXmlNode::rXmlNode(const pugi::xml_node& node) handle = node; } +std::shared_ptr rXmlNode::GetChild(std::string_view name) +{ + if (handle) + { + if (const pugi::xml_node child = handle.child(name)) + { + return std::make_shared(child); + } + } + + return nullptr; +} + std::shared_ptr rXmlNode::GetChildren() { if (handle) diff --git a/Utilities/rXml.h b/Utilities/rXml.h index 8b70d06ee4..607525e462 100644 --- a/Utilities/rXml.h +++ b/Utilities/rXml.h @@ -20,6 +20,7 @@ struct rXmlNode { rXmlNode(); rXmlNode(const pugi::xml_node& node); + std::shared_ptr GetChild(std::string_view name); std::shared_ptr GetChildren(); std::shared_ptr GetNext(); std::string GetName(); diff --git a/rpcs3/Crypto/utils.cpp b/rpcs3/Crypto/utils.cpp index 71f687bf83..51ad284c62 100644 --- a/rpcs3/Crypto/utils.cpp +++ b/rpcs3/Crypto/utils.cpp @@ -23,7 +23,24 @@ // Auxiliary functions (endian swap, xor). -// Hex string conversion auxiliary functions. +// Bytes conversion auxiliary function. +void bytes_to_hex(std::string& hex_str, const unsigned char* data, unsigned int data_length) +{ + size_t str_length = data_length * 2; + + hex_str.resize(str_length); + + for (size_t i = 0; i < str_length; i += 2) + { + const auto [ptr, err] = std::to_chars(hex_str.data() + i, hex_str.data() + i + 2, *data++, 16); + if (err != std::errc()) + { + fmt::throw_exception("Failed to read bytes: %s", std::make_error_code(err).message()); + } + } +} + +// Hex string conversion auxiliary function. void hex_to_bytes(unsigned char* data, std::string_view hex_str, unsigned int str_length) { const auto strn_length = (str_length > 0) ? str_length : hex_str.size(); diff --git a/rpcs3/Crypto/utils.h b/rpcs3/Crypto/utils.h index 6d9bb8092b..6e8dab9734 100644 --- a/rpcs3/Crypto/utils.h +++ b/rpcs3/Crypto/utils.h @@ -15,7 +15,10 @@ char* extract_file_name(const char* file_path, char real_file_name[CRYPTO_MAX_PA std::string sha256_get_hash(const char* data, usz size, bool lower_case); -// Hex string conversion auxiliary functions. +// Bytes conversion auxiliary function. +void bytes_to_hex(std::string& hex_str, const unsigned char* data, unsigned int data_length); + +// Hex string conversion auxiliary function. void hex_to_bytes(unsigned char* data, std::string_view hex_str, unsigned int str_length); // Crypto functions (AES128-CBC, AES128-ECB, SHA1-HMAC and AES-CMAC). diff --git a/rpcs3/Emu/CMakeLists.txt b/rpcs3/Emu/CMakeLists.txt index 8591399ce8..be2e682693 100644 --- a/rpcs3/Emu/CMakeLists.txt +++ b/rpcs3/Emu/CMakeLists.txt @@ -127,6 +127,7 @@ target_sources(rpcs3_emu PRIVATE ../Loader/TAR.cpp ../Loader/ISO.cpp ../Loader/iso_cache.cpp + ../Loader/iso_validation.cpp ../Loader/TROPUSR.cpp ../Loader/TRP.cpp ) diff --git a/rpcs3/Emu/system_utils.cpp b/rpcs3/Emu/system_utils.cpp index 1d94b22987..b1235dabf6 100644 --- a/rpcs3/Emu/system_utils.cpp +++ b/rpcs3/Emu/system_utils.cpp @@ -241,6 +241,21 @@ namespace rpcs3::utils return cache_dir; } + std::string get_redump_db_path() + { + return fs::get_config_dir(true) + "redump.dat"; + } + + std::string get_redump_db_download_url() + { + return "https://api.rpcs3.net/redump/?api=v1"; + } + + std::string get_redump_key_dir() + { + return get_data_dir() + "redump/"; + } + std::string get_data_dir() { return fs::get_config_dir() + "data/"; diff --git a/rpcs3/Emu/system_utils.hpp b/rpcs3/Emu/system_utils.hpp index fd8717f4f9..1545cbf829 100644 --- a/rpcs3/Emu/system_utils.hpp +++ b/rpcs3/Emu/system_utils.hpp @@ -46,6 +46,10 @@ namespace rpcs3::utils std::string get_cache_dir(); std::string get_cache_dir(std::string_view module_path); + std::string get_redump_db_path(); + std::string get_redump_db_download_url(); + std::string get_redump_key_dir(); + std::string get_data_dir(); std::string get_icons_dir(); std::string get_savestates_dir(); diff --git a/rpcs3/Loader/ISO.cpp b/rpcs3/Loader/ISO.cpp index 0dd50501b6..e79373fcb4 100644 --- a/rpcs3/Loader/ISO.cpp +++ b/rpcs3/Loader/ISO.cpp @@ -2,6 +2,7 @@ #include "ISO.h" #include "Emu/VFS.h" +#include "Emu/system_utils.hpp" #include "Crypto/utils.h" #include @@ -13,8 +14,6 @@ LOG_CHANNEL(sys_log, "SYS"); LOG_CHANNEL(iso_log, "ISO"); -constexpr u64 ISO_SECTOR_SIZE = 2048; - struct iso_sector { u64 lba_address; @@ -28,25 +27,27 @@ struct iso_sector bool is_file_iso(const std::string& path) { - if (path.empty()) return false; - if (fs::is_dir(path)) return false; + if (path.empty() || fs::is_dir(path)) + { + return false; + } return is_file_iso(fs::file(path)); } bool is_file_iso(const fs::file& file) { - if (!file) return false; - if (file.size() < 32768 + 6) return false; + if (!file || file.size() < 32768 + 6) + { + return false; + } file.seek(32768); char magic[5]; file.read_at(32768 + 1, magic, 5); - return magic[0] == 'C' && magic[1] == 'D' - && magic[2] == '0' && magic[3] == '0' - && magic[4] == '1'; + return magic[0] == 'C' && magic[1] == 'D' && magic[2] == '0' && magic[3] == '0' && magic[4] == '1'; } // Convert 4 bytes in big-endian format to an unsigned integer @@ -79,7 +80,7 @@ static bool decrypt_data(aes_context& aes, u64 offset, unsigned char* buffer, u6 //if ((size % 16) != 0) //{ - // sys_log.error("decrypt_data(): Requested ciphertext blocks' size must be a multiple of 16 (%ull)", size); + // iso_log.error("decrypt_data: Requested ciphertext blocks' size must be a multiple of 16 (%ull)", size); // return; //} @@ -156,6 +157,83 @@ void iso_file_decryption::reset() m_region_info.clear(); } +iso_type_status iso_file_decryption::check_type(const std::string& path, std::string& key_path, aes_context* aes_ctx) +{ + if (!is_file_iso(path)) + { + return iso_type_status::NOT_ISO; + } + + // Remove file extension from file path + const usz ext_pos = path.rfind('.'); + const std::string name_path = ext_pos == umax ? path : path.substr(0, ext_pos); + + // Detect file name (with no parent folder and no file extension) + const usz name_pos = name_path.rfind('/'); + const std::string name = name_pos == umax ? name_path : name_path.substr(name_pos); + fs::file key_file; + + const std::array key_paths { + name_path + ".dkey", + name_path + ".key", + rpcs3::utils::get_redump_key_dir() + name + ".dkey", + rpcs3::utils::get_redump_key_dir() + name + ".key" + }; + + for (const std::string& path : key_paths) + { + key_file = fs::file(path); + + if (key_file) + { + key_path = path; + break; + } + } + + // If no ".dkey" and ".key" file exists + if (!key_file) + { + return iso_type_status::ERROR_OPENING_KEY; + } + + char key_str[32]; + std::array key {}; + + const u64 key_len = key_file.read(key_str, sizeof(key_str)); + + if (key_len == sizeof(key_str) || key_len == sizeof(key)) + { + // If the key read from the key file is 16 bytes long instead of 32, consider the file as + // binary (".key") and so not needing any further conversion from hex string to bytes + if (key_len == sizeof(key)) + { + memcpy(key.data(), key_str, sizeof(key)); + } + else + { + hex_to_bytes(key.data(), std::string_view(key_str, key_len), static_cast(key_len)); + } + + aes_context aes_dec; + + // If "aes_ctx" not requested + if (!aes_ctx) + { + aes_ctx = &aes_dec; + } + + // Create the decryption context. If the context is successfully created, fill in "aes_ctx" + // (if requested) and return REDUMP_ISO + if (aes_setkey_dec(aes_ctx, key.data(), 128) == 0) + { + return iso_type_status::REDUMP_ISO; + } + } + + return iso_type_status::ERROR_PROCESSING_KEY; +} + bool iso_file_decryption::init(const std::string& path) { reset(); @@ -220,57 +298,25 @@ bool iso_file_decryption::init(const std::string& path) // Check for Redump type // - const usz ext_pos = path.rfind('.'); std::string key_path; - // If no file extension is provided, set "key_path" appending ".dkey" to "path". - // Otherwise, replace the extension (e.g. ".iso") with ".dkey" - key_path = ext_pos == umax ? path + ".dkey" : path.substr(0, ext_pos) + ".dkey"; - - fs::file key_file(key_path); - - // If no ".dkey" file exists, try with ".key" - if (!key_file) - { - key_path = ext_pos == umax ? path + ".key" : path.substr(0, ext_pos) + ".key"; - key_file = fs::file(key_path); - } - - // Check if "key_path" exists and create the "m_aes_dec" context if so - if (key_file) - { - char key_str[32]; - unsigned char key[16]; - - const u64 key_len = key_file.read(key_str, sizeof(key_str)); - - if (key_len == sizeof(key_str) || key_len == sizeof(key)) - { - // If the key read from the key file is 16 bytes long instead of 32, consider the file as - // binary (".key") and so not needing any further conversion from hex string to bytes - if (key_len == sizeof(key)) - { - memcpy(key, key_str, sizeof(key)); - } - else - { - hex_to_bytes(key, std::string_view(key_str, key_len), static_cast(key_len)); - } - - if (aes_setkey_dec(&m_aes_dec, key, 128) == 0) - { - m_enc_type = iso_encryption_type::REDUMP; // SET ENCRYPTION TYPE: REDUMP - } - } - - if (m_enc_type == iso_encryption_type::NONE) // If encryption type was not set to REDUMP for any reason - { - iso_log.error("init: Failed to process key file: %s", key_path); - } - } - else + // Try to detect the Redump type. If so, the decryption context is set into "m_aes_dec" + switch (check_type(path, key_path, &m_aes_dec)) { + case iso_type_status::NOT_ISO: + iso_log.warning("init: Failed to recognize ISO file: %s", path); + break; + case iso_type_status::REDUMP_ISO: + m_enc_type = iso_encryption_type::REDUMP; // SET ENCRYPTION TYPE: REDUMP + break; + case iso_type_status::ERROR_OPENING_KEY: iso_log.warning("init: Failed to open, or missing, key file: %s", key_path); + break; + case iso_type_status::ERROR_PROCESSING_KEY: + iso_log.error("init: Failed to process key file: %s", key_path); + break; + default: + break; } // @@ -402,12 +448,12 @@ inline T retrieve_endian_int(const u8* buf) if constexpr (std::endian::little == std::endian::native) { - // first half = little-endian copy + // First half = little-endian copy std::memcpy(&out, buf, sizeof(T)); } else { - // second half = big-endian copy + // Second half = big-endian copy std::memcpy(&out, buf + sizeof(T), sizeof(T)); } @@ -603,6 +649,7 @@ static void iso_form_hierarchy(fs::file& file, iso_fs_node& node, bool use_ucs2_ u64 iso_fs_metadata::size() const { u64 total_size = 0; + for (const auto& extent : extents) { total_size += extent.size; @@ -660,7 +707,10 @@ iso_archive::iso_archive(const std::string& path) iso_fs_node* iso_archive::retrieve(const std::string& passed_path) { - if (passed_path.empty()) return nullptr; + if (passed_path.empty()) + { + return nullptr; + } const std::string path = std::filesystem::path(passed_path).string(); const std::string_view path_sv = path; @@ -669,11 +719,16 @@ iso_fs_node* iso_archive::retrieve(const std::string& passed_path) usz end = path_sv.find_first_of(fs::delim); std::stack search_stack; + search_stack.push(&m_root); do { - if (search_stack.empty()) return nullptr; + if (search_stack.empty()) + { + return nullptr; + } + const auto* top_entry = search_stack.top(); if (end == umax) @@ -681,7 +736,7 @@ iso_fs_node* iso_archive::retrieve(const std::string& passed_path) end = path.size(); } - const std::string_view path_component = path_sv.substr(start, end-start); + const std::string_view path_component = path_sv.substr(start, end - start); bool found = false; @@ -692,6 +747,7 @@ iso_fs_node* iso_archive::retrieve(const std::string& passed_path) else if (path_component == "..") { search_stack.pop(); + found = true; } else @@ -708,14 +764,20 @@ iso_fs_node* iso_archive::retrieve(const std::string& passed_path) } } - if (!found) return nullptr; + if (!found) + { + return nullptr; + } start = end + 1; end = path_sv.find_first_of(fs::delim, start); } while (start < path.size()); - if (search_stack.empty()) return nullptr; + if (search_stack.empty()) + { + return nullptr; + } return search_stack.top(); } @@ -728,7 +790,11 @@ bool iso_archive::exists(const std::string& path) bool iso_archive::is_file(const std::string& path) { const auto file_node = retrieve(path); - if (!file_node) return false; + + if (!file_node) + { + return false; + } return !file_node->metadata.is_directory; } @@ -979,8 +1045,10 @@ u64 iso_file::seek(s64 offset, fs::seek_mode whence) return -1; } - const u64 result = m_file.seek(file_offset(m_pos)); - if (result == umax) return umax; + if (m_file.seek(file_offset(new_pos)) == umax) + { + return umax; + } m_pos = new_pos; return m_pos; @@ -989,6 +1057,7 @@ u64 iso_file::seek(s64 offset, fs::seek_mode whence) u64 iso_file::size() { u64 extent_sizes = 0; + for (const auto& extent : m_meta.extents) { extent_sizes += extent.size; @@ -1018,18 +1087,23 @@ bool iso_dir::read(fs::dir_entry& entry) entry.size = selected.size(); m_pos++; - return true; } return false; } +void iso_dir::rewind() +{ + m_pos = 0; +} + bool iso_device::stat(const std::string& path, fs::stat_t& info) { const auto relative_path = std::filesystem::relative(std::filesystem::path(path), std::filesystem::path(fs_prefix)).string(); const auto node = m_archive.retrieve(relative_path); + if (!node) { fs::g_tls_error = fs::error::noent; @@ -1057,14 +1131,14 @@ bool iso_device::statfs(const std::string& path, fs::device_stat& info) const auto relative_path = std::filesystem::relative(std::filesystem::path(path), std::filesystem::path(fs_prefix)).string(); const auto node = m_archive.retrieve(relative_path); + if (!node) { fs::g_tls_error = fs::error::noent; return false; } - const auto& meta = node->metadata; - const u64 size = meta.size(); + const u64 size = node->metadata.size(); info = fs::device_stat { @@ -1074,7 +1148,7 @@ bool iso_device::statfs(const std::string& path, fs::device_stat& info) .avail_free = 0 }; - return false; + return true; } std::unique_ptr iso_device::open(const std::string& path, bs_t mode) @@ -1082,6 +1156,7 @@ std::unique_ptr iso_device::open(const std::string& path, bs_t iso_device::open_dir(const std::string& path) const auto relative_path = std::filesystem::relative(std::filesystem::path(path), std::filesystem::path(fs_prefix)).string(); const auto node = m_archive.retrieve(relative_path); + if (!node) { fs::g_tls_error = fs::error::noent; @@ -1118,11 +1194,6 @@ std::unique_ptr iso_device::open_dir(const std::string& path) return std::make_unique(*node); } -void iso_dir::rewind() -{ - m_pos = 0; -} - void load_iso(const std::string& path) { sys_log.notice("Loading ISO '%s'", path); diff --git a/rpcs3/Loader/ISO.h b/rpcs3/Loader/ISO.h index 8a88e876bf..52cc33065a 100644 --- a/rpcs3/Loader/ISO.h +++ b/rpcs3/Loader/ISO.h @@ -1,6 +1,6 @@ #pragma once -#include "Loader/PSF.h" +#include "PSF.h" #include "Utilities/File.h" #include "util/types.hpp" @@ -12,6 +12,8 @@ bool is_file_iso(const fs::file& path); void load_iso(const std::string& path); void unload_iso(); +constexpr u64 ISO_SECTOR_SIZE = 2048; + /* - Hijacked the "iso_archive::iso_archive" method to test if the ".iso" file is encrypted and sets a flag. The flag is set according to the first matching encryption type found following the order below: @@ -47,6 +49,15 @@ enum class iso_encryption_type REDUMP }; +// Enum returned by checking type +enum class iso_type_status +{ + NOT_ISO, + REDUMP_ISO, + ERROR_OPENING_KEY, + ERROR_PROCESSING_KEY +}; + // ISO file decryption class class iso_file_decryption { @@ -58,6 +69,8 @@ private: void reset(); public: + static iso_type_status check_type(const std::string& path, std::string& key_path, aes_context* aes_ctx = nullptr); + iso_encryption_type get_enc_type() const { return m_enc_type; } bool init(const std::string& path); diff --git a/rpcs3/Loader/iso_validation.cpp b/rpcs3/Loader/iso_validation.cpp new file mode 100644 index 0000000000..2309fc43ae --- /dev/null +++ b/rpcs3/Loader/iso_validation.cpp @@ -0,0 +1,162 @@ +#include "stdafx.h" + +#include "iso_validation.h" +#include "ISO.h" + +#include "Emu/system_utils.hpp" +#include "Utilities/File.h" +#include "Utilities/rXml.h" +#include "Crypto/md5.h" +#include "Crypto/utils.h" + +LOG_CHANNEL(iso_log, "ISO"); + +iso_integrity_status iso_file_validation::check_integrity(const std::string& path, const std::string& hash, std::string* game_name) +{ + // + // Check for Redump db + // + + const std::string db_path = rpcs3::utils::get_redump_db_path(); + fs::file db_file(db_path); + + // If no db file exists + if (!db_file) + { + // An empty hash is used to simply test the presence (without any logging) of the Redump db + if (!hash.empty()) + { + iso_log.error("check_integrity: Failed to open file: %s", db_path); + } + + return iso_integrity_status::ERROR_OPENING_DB; + } + + if (hash.empty()) + { + return iso_integrity_status::NO_MATCH; + } + + rXmlDocument db; + + if (!db.Read(db_file.to_string())) + { + iso_log.error("check_integrity: Failed to process file: %s", db_path); + return iso_integrity_status::ERROR_PARSING_DB; + } + + std::shared_ptr db_base = db.GetRoot(); + + if (!db_base) + { + iso_log.error("check_integrity: Failed to get 'root' node on file: %s", db_path); + return iso_integrity_status::ERROR_PARSING_DB; + } + + if (db_base = db_base->GetChild(std::string_view("datafile")); !db_base) + { + iso_log.error("check_integrity: Failed to get 'datafile' node on file: %s", db_path); + return iso_integrity_status::ERROR_PARSING_DB; + } + + // + // Check for a match on Redump db + // + + for (auto node = db_base->GetChildren(); node; node = node->GetNext()) + { + if (node->GetName() == "game") + { + if (const auto child = node->GetChild(std::string_view("rom"))) + { + // If a match is found, fill in "game_desc" (if requested) and return FOUND_MATCH + if (hash == child->GetAttribute(std::string_view("md5"))) + { + if (game_name) + { + *game_name = node->GetAttribute(std::string_view("name")); + } + + return iso_integrity_status::FOUND_MATCH; + } + } + } + } + + // No match found + return iso_integrity_status::NO_MATCH; +} + +bool iso_file_validation::init_hash(const std::string& path) +{ + fs::file iso_file(path); + + // If no ISO file exists + if (!iso_file) + { + iso_log.error("init_hash: Failed to open file: %s", path); + m_status = iso_hash_status::ABORTED; + return false; + } + + m_path = path; + m_size = iso_file.size(); + m_bytes_read = 0; + m_status = iso_hash_status::INITIALIZED; + return true; +} + +iso_hash_status iso_file_validation::calculate_hash(std::string& hash) +{ + if (m_status != iso_hash_status::INITIALIZED) + { + iso_log.error("calculate_hash: MD5 hash calculation already performed: %s", m_path); + m_status = iso_hash_status::ABORTED; + return m_status; + } + + fs::file iso_file(m_path); + + // If no ISO file exists + if (!iso_file) + { + iso_log.error("calculate_hash: Failed to open file: %s", m_path); + m_status = iso_hash_status::ABORTED; + return m_status; + } + + constexpr u64 block_size = ISO_SECTOR_SIZE * 2; + std::array buf; + u64 bytes_read; + mbedtls_md5_context md5_ctx; + unsigned char md5_hash[16]; + + mbedtls_md5_starts_ret(&md5_ctx); + + do + { + bytes_read = iso_file.read(buf.data(), block_size); + mbedtls_md5_update_ret(&md5_ctx, buf.data(), bytes_read); + + m_bytes_read += bytes_read; + } while (bytes_read == block_size && m_status != iso_hash_status::ABORTED); + + if (m_status == iso_hash_status::ABORTED) + { + iso_log.warning("calculate_hash: MD5 hash calculation aborted by user: %s", m_path); + return m_status; + } + + if (mbedtls_md5_finish_ret(&md5_ctx, md5_hash) != 0) + { + iso_log.error("calculate_hash: Failed to calculate MD5 hash on file: %s", m_path); + m_status = iso_hash_status::ABORTED; + return m_status; + } + + // Convert the MD5 hash to hex string + bytes_to_hex(hash, md5_hash, 16); + + m_status = iso_hash_status::COMPLETED; + return m_status; +} diff --git a/rpcs3/Loader/iso_validation.h b/rpcs3/Loader/iso_validation.h new file mode 100644 index 0000000000..f8a8d6948f --- /dev/null +++ b/rpcs3/Loader/iso_validation.h @@ -0,0 +1,42 @@ +#pragma once + +#include "util/types.hpp" + +// Enum returned by calculating hash +enum class iso_hash_status +{ + INITIALIZED, + COMPLETED, + ABORTED +}; + +// Enum returned by checking integrity +enum class iso_integrity_status +{ + NO_MATCH, + FOUND_MATCH, + ERROR_OPENING_DB, + ERROR_PARSING_DB +}; + +// ISO file validation class +class iso_file_validation +{ +private: + std::string m_path; + u64 m_size = 0; + u64 m_bytes_read = 0; + iso_hash_status m_status = iso_hash_status::INITIALIZED; + +public: + static iso_integrity_status check_integrity(const std::string& path, const std::string& hash, std::string* game_name = nullptr); + + const std::string& get_path() const { return m_path; } + u64 get_size() const { return m_size; } + u64 get_bytes_read() const { return m_bytes_read; } + iso_hash_status get_status() const { return m_status; } + void abort_hash() { m_status = iso_hash_status::ABORTED; } + + bool init_hash(const std::string& path); + iso_hash_status calculate_hash(std::string& hash); +}; diff --git a/rpcs3/emucore.vcxproj b/rpcs3/emucore.vcxproj index c89a066075..48b532e036 100644 --- a/rpcs3/emucore.vcxproj +++ b/rpcs3/emucore.vcxproj @@ -203,6 +203,7 @@ + NotUsing @@ -768,6 +769,7 @@ + diff --git a/rpcs3/emucore.vcxproj.filters b/rpcs3/emucore.vcxproj.filters index 6b9c82c959..87bf4fed67 100644 --- a/rpcs3/emucore.vcxproj.filters +++ b/rpcs3/emucore.vcxproj.filters @@ -1429,6 +1429,9 @@ Emu\GPU\RSX\Overlays + + Loader + @@ -2875,6 +2878,9 @@ Emu\GPU\RSX\Common + + Loader + diff --git a/rpcs3/rpcs3.vcxproj b/rpcs3/rpcs3.vcxproj index 2ab3e592bc..8bf5e5bc4f 100644 --- a/rpcs3/rpcs3.vcxproj +++ b/rpcs3/rpcs3.vcxproj @@ -341,6 +341,9 @@ true + + true + true @@ -650,6 +653,9 @@ true + + true + true @@ -922,6 +928,7 @@ + @@ -1915,6 +1922,16 @@ .\QTGeneratedFiles\$(ConfigurationName)\moc_%(Filename).cpp "$(QTDIR)\bin\moc.exe" "%(FullPath)" -o ".\QTGeneratedFiles\$(ConfigurationName)\moc_%(Filename).cpp" -D_WINDOWS -DUNICODE -DWIN32 -DWIN64 -DWITH_DISCORD_RPC -DQT_NO_DEBUG -DQT_WIDGETS_LIB -DQT_GUI_LIB -DQT_CORE_LIB -DNDEBUG -DQT_CONCURRENT_LIB -D%(PreprocessorDefinitions) "-I.\..\3rdparty\wolfssl\wolfssl" "-I.\..\3rdparty\curl\curl\include" "-I.\..\3rdparty\libusb\libusb\libusb" "-I$(VULKAN_SDK)\Include" "-I$(QTDIR)\include" "-I$(QTDIR)\include\QtWidgets" "-I$(QTDIR)\include\QtGui" "-I$(QTDIR)\include\QtCore" "-I.\release" "-I.\QTGeneratedFiles\$(ConfigurationName)" "-I.\QTGeneratedFiles" "-I$(QTDIR)\include\QtConcurrent" + + $(QTDIR)\bin\moc.exe;%(FullPath) + Moc%27ing iso_integrity.h... + .\QTGeneratedFiles\$(ConfigurationName)\moc_%(Filename).cpp + "$(QTDIR)\bin\moc.exe" "%(FullPath)" -o ".\QTGeneratedFiles\$(ConfigurationName)\moc_%(Filename).cpp" -D_WINDOWS -DUNICODE -DWIN32 -DWIN64 -DQT_WIDGETS_LIB -DQT_GUI_LIB -DQT_CORE_LIB -DQT_CONCURRENT_LIB -D%(PreprocessorDefinitions) "-I.\..\3rdparty\wolfssl\wolfssl" "-I.\..\3rdparty\curl\curl\include" "-I.\..\3rdparty\libusb\libusb\libusb" "-I$(VULKAN_SDK)\Include" "-I$(QTDIR)\include" "-I$(QTDIR)\include\QtWidgets" "-I$(QTDIR)\include\QtGui" "-I$(QTDIR)\include\QtCore" "-I.\debug" "-I.\QTGeneratedFiles\$(ConfigurationName)" "-I.\QTGeneratedFiles" "-I$(QTDIR)\include\QtConcurrent" + $(QTDIR)\bin\moc.exe;%(FullPath) + Moc%27ing iso_integrity.h... + .\QTGeneratedFiles\$(ConfigurationName)\moc_%(Filename).cpp + "$(QTDIR)\bin\moc.exe" "%(FullPath)" -o ".\QTGeneratedFiles\$(ConfigurationName)\moc_%(Filename).cpp" -D_WINDOWS -DUNICODE -DWIN32 -DWIN64 -DWITH_DISCORD_RPC -DQT_NO_DEBUG -DQT_WIDGETS_LIB -DQT_GUI_LIB -DQT_CORE_LIB -DNDEBUG -DQT_CONCURRENT_LIB -D%(PreprocessorDefinitions) "-I.\..\3rdparty\wolfssl\wolfssl" "-I.\..\3rdparty\curl\curl\include" "-I.\..\3rdparty\libusb\libusb\libusb" "-I$(VULKAN_SDK)\Include" "-I$(QTDIR)\include" "-I$(QTDIR)\include\QtWidgets" "-I$(QTDIR)\include\QtGui" "-I$(QTDIR)\include\QtCore" "-I.\release" "-I.\QTGeneratedFiles\$(ConfigurationName)" "-I.\QTGeneratedFiles" "-I$(QTDIR)\include\QtConcurrent" + $(QTDIR)\bin\moc.exe;%(FullPath) Moc%27ing %(Identity)... diff --git a/rpcs3/rpcs3.vcxproj.filters b/rpcs3/rpcs3.vcxproj.filters index 8c5b6b2905..184069c388 100644 --- a/rpcs3/rpcs3.vcxproj.filters +++ b/rpcs3/rpcs3.vcxproj.filters @@ -1302,6 +1302,15 @@ Gui\settings + + Generated Files\Debug + + + Generated Files\Release + + + Gui\game list + @@ -2067,4 +2076,9 @@ buildfiles\cmake + + + Gui\game list + + \ No newline at end of file diff --git a/rpcs3/rpcs3qt/CMakeLists.txt b/rpcs3/rpcs3qt/CMakeLists.txt index 0a63343e96..849e162a86 100644 --- a/rpcs3/rpcs3qt/CMakeLists.txt +++ b/rpcs3/rpcs3qt/CMakeLists.txt @@ -50,6 +50,7 @@ add_library(rpcs3_ui STATIC input_dialog.cpp instruction_editor_dialog.cpp ipc_settings_dialog.cpp + iso_integrity.cpp kamen_rider_dialog.cpp kernel_explorer.cpp localized.cpp diff --git a/rpcs3/rpcs3qt/game_list_actions.cpp b/rpcs3/rpcs3qt/game_list_actions.cpp index e1a78feb11..a199c20e66 100644 --- a/rpcs3/rpcs3qt/game_list_actions.cpp +++ b/rpcs3/rpcs3qt/game_list_actions.cpp @@ -16,6 +16,8 @@ #include "Input/pad_thread.h" +#include + #include #include #include @@ -365,6 +367,105 @@ void game_list_actions::ShowGameInfoDialog(const std::vector& games) QMessageBox::information(m_game_list_frame, tr("Game Info"), GetContentInfo(games).info); } +void game_list_actions::ShowGameIntegrityDialog(const game_info& game) +{ + if (m_game_integrity_future.isRunning()) // Still running the last request + return; + + // Initialize the validator (set also file size etc.) + m_iso_validator->init_hash(game->info.path); + + // Game integrity check can take a while (in particular on non ssd/m.2 disks) + // so run it on a concurrent thread avoiding to block the entire GUI + m_game_integrity_future = QtConcurrent::run([this]() + { + thread_base::set_name("Game Integrity"); + + QString text; + std::string hash, game_name; + bool info_dialog = false; + + if (m_iso_validator->calculate_hash(hash) != iso_hash_status::COMPLETED) + { + text = "Hash calculation failed!\n\nIntegrity check aborted"; + } + else + { + text = "Integrity check completed!\n\n"; + + switch (m_iso_validator->check_integrity(m_iso_validator->get_path(), hash, &game_name)) + { + case iso_integrity_status::NO_MATCH: + text += tr("Game check NOT PASSED\n\nNo match found on DB or game corrupted:\n - Hash: %0") + .arg(QString::fromStdString(hash)); + break; + case iso_integrity_status::FOUND_MATCH: + text += tr("Game check PASSED\n\nMatch found on DB:\n - Game: %0\n - Hash: %1") + .arg(QString::fromStdString(game_name)) + .arg(QString::fromStdString(hash)); + + info_dialog = true; + break; + default: + text += tr("Error parsing DB"); + break; + } + } + + Emu.CallFromMainThread([this, text, info_dialog]() + { + if (info_dialog) + { + sys_log.success("%s", text.toStdString()); + QMessageBox::information(m_game_list_frame, tr("Game Integrity"), text); + } + else + { + sys_log.error("%s", text.toStdString()); + QMessageBox::critical(m_game_list_frame, tr("Game Integrity"), text); + } + }, nullptr, false); + }); + + progress_dialog* pdlg = new progress_dialog(tr("ISO File Hash Calculation"), tr("Calculating hash"), tr("Cancel"), + 0, 100, false, m_game_list_frame); + + pdlg->setAutoClose(false); + pdlg->setAutoReset(false); + pdlg->open(); + + connect(pdlg, &progress_dialog::canceled, m_game_list_frame, [this]() + { + m_iso_validator->abort_hash(); + }); + + QTimer* update_timer = new QTimer(m_game_list_frame); + + connect(update_timer, &QTimer::timeout, m_game_list_frame, [this, pdlg, update_timer]() + { + if (m_iso_validator->get_status() == iso_hash_status::INITIALIZED) + { + // Set progress in range 0-100 + const int progress = m_iso_validator->get_size() ? + (static_cast(m_iso_validator->get_bytes_read()) / m_iso_validator->get_size()) * 100 : + 0; + + pdlg->setValue(progress); + } + else + { + update_timer->stop(); + update_timer->deleteLater(); + + // As last, close the progress bar (it will be already closed if the process was aborted) and delete the object + pdlg->close(); + pdlg->deleteLater(); + } + }); + + update_timer->start(500); +} + void game_list_actions::ShowDiskUsageDialog() { if (m_disk_usage_future.isRunning()) // Still running the last request diff --git a/rpcs3/rpcs3qt/game_list_actions.h b/rpcs3/rpcs3qt/game_list_actions.h index ccb97ff89a..0b9fa8f8ac 100644 --- a/rpcs3/rpcs3qt/game_list_actions.h +++ b/rpcs3/rpcs3qt/game_list_actions.h @@ -2,6 +2,7 @@ #include "gui_game_info.h" #include "shortcut_utils.h" +#include "Loader/iso_validation.h" #include #include @@ -54,6 +55,7 @@ public: void ShowRemoveGameDialog(const std::vector& games); void ShowGameInfoDialog(const std::vector& games); + void ShowGameIntegrityDialog(const game_info& game); void ShowDiskUsageDialog(); // NOTES: @@ -97,6 +99,8 @@ private: game_list_frame* m_game_list_frame = nullptr; std::shared_ptr m_gui_settings; QFuture m_disk_usage_future; + QFuture m_game_integrity_future; + std::shared_ptr m_iso_validator = std::make_shared(); // NOTE: // m_content_info is used by: diff --git a/rpcs3/rpcs3qt/game_list_context_menu.cpp b/rpcs3/rpcs3qt/game_list_context_menu.cpp index f8a26d3f80..849aa6c966 100644 --- a/rpcs3/rpcs3qt/game_list_context_menu.cpp +++ b/rpcs3/rpcs3qt/game_list_context_menu.cpp @@ -16,6 +16,8 @@ #include "Utilities/File.h" #include "Emu/system_utils.hpp" +#include "Loader/ISO.h" +#include "Loader/iso_validation.h" #include "QApplication" #include "QClipboard" @@ -600,6 +602,42 @@ void game_list_context_menu::show_single_selection_context_menu(const game_info& addSeparator(); + // Check integrity + if (QString::fromStdString(current_game.category) == cat::cat_disc_game) + { + std::string key_path; + const iso_type_status iso_type = iso_file_decryption::check_type(current_game.path, key_path); + + // If it's an ISO file (e.g. even a decrypted ISO), always provide the entry on the context menu but disable + // it if the ISO does not support integrity check (e.g. non Redump ISO) or no integrity DB is found. + // That is to highlight a Redump ISO from a non Redump ISO + if (iso_type != iso_type_status::NOT_ISO) + { + const iso_integrity_status iso_integrity = iso_file_validation::check_integrity(current_game.path, ""); + + QAction* check_integrity = addAction(tr("&Check ISO Integrity")); + + // If it's a Redump ISO and the integrity DB exists + if (iso_type == iso_type_status::REDUMP_ISO && iso_integrity != iso_integrity_status::ERROR_OPENING_DB) + { + connect(check_integrity, &QAction::triggered, this, [this, gameinfo]() + { + m_game_list_actions->ShowGameIntegrityDialog(gameinfo); + }); + } + else + { + check_integrity->setEnabled(false); + } + + QAction* download_integrity = addAction(tr("&Download Integrity Database")); + connect(download_integrity, &QAction::triggered, m_game_list_frame, [this] + { + ensure(m_game_list_frame->GetIsoIntegrity())->download(); + }); + } + } + QAction* check_compat = addAction(tr("&Check Game Compatibility")); QAction* download_compat = addAction(tr("&Download Compatibility Database")); QAction* download_config_db = addAction(tr("&Download Config Database")); diff --git a/rpcs3/rpcs3qt/game_list_frame.cpp b/rpcs3/rpcs3qt/game_list_frame.cpp index 050589240d..ca0affc585 100644 --- a/rpcs3/rpcs3qt/game_list_frame.cpp +++ b/rpcs3/rpcs3qt/game_list_frame.cpp @@ -74,6 +74,7 @@ game_list_frame::game_list_frame(std::shared_ptr gui_settings, std m_game_list->installEventFilter(this); m_game_list->verticalScrollBar()->installEventFilter(this); + m_iso_integrity = new iso_integrity(this); m_game_compat = new game_compatibility(m_gui_settings, this); m_config_db = new config_database(m_gui_settings, this); diff --git a/rpcs3/rpcs3qt/game_list_frame.h b/rpcs3/rpcs3qt/game_list_frame.h index b195cdf5c7..55569aa950 100644 --- a/rpcs3/rpcs3qt/game_list_frame.h +++ b/rpcs3/rpcs3qt/game_list_frame.h @@ -3,6 +3,8 @@ #include "game_list.h" #include "game_list_actions.h" #include "custom_dock_widget.h" +#include "iso_integrity.h" + #include "Utilities/lockless.h" #include "Utilities/mutex.h" #include "util/auto_typemap.hpp" @@ -54,6 +56,7 @@ public: void SetShowHidden(bool show); + iso_integrity* GetIsoIntegrity() const { return m_iso_integrity; } game_compatibility* GetGameCompatibility() const { return ensure(m_game_compat); } config_database* GetConfigDatabase() const { return ensure(m_config_db); } const std::vector& GetGameInfo() const { return m_game_data; } @@ -153,6 +156,7 @@ private: // Game List game_list_table* m_game_list = nullptr; + iso_integrity* m_iso_integrity = nullptr; game_compatibility* m_game_compat = nullptr; config_database* m_config_db = nullptr; progress_dialog* m_progress_dialog = nullptr; diff --git a/rpcs3/rpcs3qt/iso_integrity.cpp b/rpcs3/rpcs3qt/iso_integrity.cpp new file mode 100644 index 0000000000..ee0f433653 --- /dev/null +++ b/rpcs3/rpcs3qt/iso_integrity.cpp @@ -0,0 +1,113 @@ +#include "iso_integrity.h" +#include "gui_settings.h" + +#include "Emu/system_utils.hpp" + +#include +#include + +LOG_CHANNEL(iso_log, "ISO"); + +iso_integrity::iso_integrity(QWidget* parent) + : QObject(parent) +{ + m_filepath = QString::fromStdString(rpcs3::utils::get_redump_db_path()); + m_downloader = new downloader(parent); + + connect(m_downloader, &downloader::signal_download_finished, this, &iso_integrity::handle_download_finished); + connect(m_downloader, &downloader::signal_download_canceled, this, &iso_integrity::handle_download_canceled); + connect(m_downloader, &downloader::signal_download_error, this, &iso_integrity::handle_download_error); +} + +void iso_integrity::download() +{ + const std::string url = rpcs3::utils::get_redump_db_download_url(); + + iso_log.notice("Starting database download from: %s", url); + + m_downloader->start(url, true, true, true, tr("Downloading database")); +} + +void iso_integrity::handle_download_finished(const QByteArray& content) +{ + iso_log.notice("Database download finished"); + + // Write database to file + if (QByteArray data = read_json(content, true); !data.isEmpty()) + { + QFile file(m_filepath); + + if (file.exists()) + { + iso_log.notice("Database file found: %s", m_filepath); + } + + if (!file.open(QIODevice::WriteOnly)) + { + iso_log.error("Failed to write database to file: %s", m_filepath); + return; + } + + file.write(data); + file.close(); + + iso_log.success("Database written to file: %s", m_filepath); + } +} + +void iso_integrity::handle_download_canceled() +{ + iso_log.notice("Database download canceled"); +} + +void iso_integrity::handle_download_error(const QString& error) +{ + iso_log.error("", error.toStdString().c_str()); +} + +QByteArray iso_integrity::read_json(const QByteArray& data, bool after_download) +{ + QJsonParseError error{}; + const QJsonDocument json_document = QJsonDocument::fromJson(data, &error); + + if (!json_document.isObject()) + { + iso_log.error("ISO Integrity database error - Invalid JSON: '%s'", error.errorString()); + return {}; + } + + const QJsonObject json_data = json_document.object(); + const int return_code = json_data["return_code"].toInt(-255); + + if (return_code < 0) + { + if (after_download) + { + std::string error_message; + + switch (return_code) + { + case -1: error_message = "Server Error - Internal Error"; break; + case -2: error_message = "Server Error - Maintenance Mode"; break; + case -255: error_message = "Server Error - Return code not found"; break; + default: error_message = "Server Error - Unknown Error"; break; + } + + iso_log.error("%s: return code %d", error_message, return_code); + } + else + { + iso_log.error("ISO Integrity database error - Invalid: return code %d", return_code); + } + + return {}; + } + + if (!json_data["redump"].isString()) + { + iso_log.error("ISO Integrity database error - Unusable Redump string"); + return {}; + } + + return QByteArray().fromStdString(json_data["redump"].toString().toStdString()); +}; diff --git a/rpcs3/rpcs3qt/iso_integrity.h b/rpcs3/rpcs3qt/iso_integrity.h new file mode 100644 index 0000000000..a4225770de --- /dev/null +++ b/rpcs3/rpcs3qt/iso_integrity.h @@ -0,0 +1,26 @@ +#pragma once + +#include "downloader.h" + +class iso_integrity : public QObject +{ + Q_OBJECT + +public: + // Handles download for the ISO integrity database + iso_integrity(QWidget* parent); + + // Downloads and writes the database to file + void download(); + +private Q_SLOTS: + void handle_download_finished(const QByteArray& content); + void handle_download_canceled(); + void handle_download_error(const QString& error); + +private: + QByteArray read_json(const QByteArray& data, bool after_download); + + QString m_filepath; + downloader* m_downloader = nullptr; +};