From c2ddd8005dff72f44f1733bd48eb53464cfbebfd Mon Sep 17 00:00:00 2001 From: Roman Tarasov Date: Tue, 20 Oct 2026 17:10:45 +0300 Subject: [PATCH] Fix cross-root image duplicate remount loops --- include/sm_image_index.h | 2 ++ src/sm_image_index.c | 42 ++++++++++++++++++++++++++++++++++------ src/sm_scan.c | 24 ++++++++++++++++------- 3 files changed, 55 insertions(+), 13 deletions(-) diff --git a/include/sm_image_index.h b/include/sm_image_index.h index c033532..cf2bea1 100644 --- a/include/sm_image_index.h +++ b/include/sm_image_index.h @@ -29,6 +29,8 @@ bool sm_image_index_visit_ready_titles( sm_image_index_title_visitor_t visitor, const void *visitor_ctx); // Return true when a title is mapped to an existing backing image. bool sm_image_index_has_source_for_title(const char *title_id); +// Validate a title against a complete index with an unchanged regular source. +bool sm_image_index_source_has_title(const char *path, const char *title_id); // Start a new fingerprint generation before mounting an image. void sm_image_index_begin_scan(const char *path, const struct stat *st); // Associate a discovered mounted game with its outer backing image. Returns diff --git a/src/sm_image_index.c b/src/sm_image_index.c index adea50f..66c3c0f 100644 --- a/src/sm_image_index.c +++ b/src/sm_image_index.c @@ -324,6 +324,36 @@ static void begin_scan_locked(const char *path, const struct stat *st) { g_image_index_dirty = true; } +// Caller holds g_image_index_mutex. A retained link alone cannot prove that a +// replaced image still contains the same title. +static bool indexed_source_has_title(const char *path, const char *title_id) { + int entry_index = find_entry(path); + if (entry_index < 0 || !g_image_index[entry_index].complete) + return false; + + for (int i = 0; i < MAX_IMAGE_TITLES; ++i) { + if (g_image_titles[i].valid && + g_image_titles[i].image_index == (uint16_t)entry_index && + strcmp(g_image_titles[i].title_id, title_id) == 0) { + struct stat st; + return stat(path, &st) == 0 && S_ISREG(st.st_mode) && + stamp_matches(&g_image_index[entry_index], &st); + } + } + return false; +} + +bool sm_image_index_source_has_title(const char *path, const char *title_id) { + if (!path || !title_id || path[0] == '\0' || title_id[0] == '\0') + return false; + + pthread_mutex_lock(&g_image_index_mutex); + load_index(); + bool found = indexed_source_has_title(path, title_id); + pthread_mutex_unlock(&g_image_index_mutex); + return found; +} + static bool cached_titles_ready(int entry_index, const struct AppDbTitleList *app_db_titles, bool app_db_titles_ready) { @@ -334,18 +364,18 @@ static bool cached_titles_ready(int entry_index, continue; } const char *title_id = g_image_titles[i].title_id; - if (!is_installed(title_id) || !has_appmeta_data(title_id)) + if ((app_db_titles_ready && + !app_db_title_list_contains(app_db_titles, title_id)) || + !is_installed(title_id) || !has_appmeta_data(title_id)) { return false; + } char runtime_path[MAX_PATH]; char linked_image[MAX_PATH]; if (!read_mount_link(title_id, runtime_path, sizeof(runtime_path)) || !is_under_image_mount_base(runtime_path) || !read_mount_image_link(title_id, linked_image, sizeof(linked_image)) || - strcmp(linked_image, image_path) != 0) { - return false; - } - if (app_db_titles_ready && - !app_db_title_list_contains(app_db_titles, title_id)) { + (strcmp(linked_image, image_path) != 0 && + !indexed_source_has_title(linked_image, title_id))) { return false; } } diff --git a/src/sm_scan.c b/src/sm_scan.c index d3f0207..a7b89bd 100644 --- a/src/sm_scan.c +++ b/src/sm_scan.c @@ -68,10 +68,11 @@ static void note_found_indexed_title(const char *title_id, const void *ctx) { char linked_image[MAX_PATH]; if (!image_path || !read_mount_link(title_id, runtime_path, sizeof(runtime_path)) || - !read_mount_image_link(title_id, linked_image, sizeof(linked_image)) || - strcmp(linked_image, image_path) != 0) { + !read_mount_image_link(title_id, linked_image, sizeof(linked_image))) { return; } + if (strcmp(linked_image, image_path) != 0) + notify_duplicate_title_once(title_id, image_path, linked_image); cache_game_entry(runtime_path, title_id, ""); } @@ -338,11 +339,20 @@ static existing_directory_result_t handle_existing_directory_candidate( } if (installed && appmeta_present && has_tracked_path && - strcmp(tracked_path, full_path) != 0 && - is_data_mounted(info->title_id)) { - (void)strlcpy(preferred_existing_path_out, tracked_path, MAX_PATH); - cache_game_entry(tracked_path, info->title_id, info->title_name); - return EXISTING_DIRECTORY_PREFER_CACHED; + strcmp(tracked_path, full_path) != 0) { + char linked_image[MAX_PATH]; + // Runtime layers are normally released between scans. Keep the selected + // image while its fingerprint still proves that it contains this title. + bool linked_source_valid = + is_under_image_mount_base(tracked_path) && + read_mount_image_link(info->title_id, linked_image, + sizeof(linked_image)) && + sm_image_index_source_has_title(linked_image, info->title_id); + if (linked_source_valid || is_data_mounted(info->title_id)) { + (void)strlcpy(preferred_existing_path_out, tracked_path, MAX_PATH); + cache_game_entry(tracked_path, info->title_id, info->title_name); + return EXISTING_DIRECTORY_PREFER_CACHED; + } } if (!in_app_db && is_title_install_pending(info->title_id))