macOS: normalize SDMC directory filenames (#2080)

* macOS: normalize SDMC directory filenames

* Guard macOS filename normalization behind __APPLE__

* Guard macOS filename normalization test

* Apply clang-format

* Update license headers
This commit is contained in:
Rodrigo Iglesias 2026-04-30 09:50:32 +02:00 committed by PabloMK7
parent ec6a0dd1c8
commit 83eef0012e
4 changed files with 85 additions and 9 deletions

View File

@ -1,3 +1,7 @@
// Copyright Citra Emulator Project / Azahar Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
// Copyright 2013 Dolphin Emulator Project / 2014 Citra Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
@ -20,6 +24,10 @@
#include <windows.h>
#endif
#if defined(__APPLE__)
#include <CoreFoundation/CFString.h>
#endif
namespace Common {
/// Make a char lowercase
@ -164,6 +172,52 @@ std::u16string UTF8ToUTF16(std::string_view input) {
return boost::locale::conv::utf_to_utf<char16_t>(input.data(), input.data() + input.size());
}
#if defined(__APPLE__)
// macOS filesystems may expose decomposed Unicode names through directory listings.
// Normalize to NFC before passing names to guest APIs that expect stable text.
std::string NormalizeNFDToNFC(std::string_view input) {
const std::string fallback(input);
// Core Foundation string
CFStringRef source =
CFStringCreateWithBytes(kCFAllocatorDefault, reinterpret_cast<const UInt8*>(input.data()),
static_cast<CFIndex>(input.size()), kCFStringEncodingUTF8, false);
if (source == nullptr) {
return fallback;
}
// Mutable copy of the source string
CFMutableStringRef normalized = CFStringCreateMutableCopy(kCFAllocatorDefault, 0, source);
CFRelease(source);
if (normalized == nullptr) {
return fallback;
}
// Normalize the string to NFC form
CFStringNormalize(normalized, kCFStringNormalizationFormC);
const CFIndex max_size = CFStringGetMaximumSizeForEncoding(CFStringGetLength(normalized),
kCFStringEncodingUTF8) +
1; // +1 for null terminator
std::string output(static_cast<std::size_t>(max_size), '\0');
// Convert the normalized string back to UTF-8
const bool converted =
CFStringGetCString(normalized, &output[0], max_size, kCFStringEncodingUTF8);
CFRelease(normalized);
if (!converted) {
return fallback;
}
output.resize(std::strlen(output.c_str()));
return output;
}
#endif
#ifdef _WIN32
static std::wstring CPToUTF16(u32 code_page, const std::string& input) {
const auto size =

View File

@ -49,6 +49,10 @@ void BuildCompleteFilename(std::string& _CompleteFilename, const std::string& _P
[[nodiscard]] std::string UTF16ToUTF8(std::u16string_view input);
[[nodiscard]] std::u16string UTF8ToUTF16(std::string_view input);
// Returns UTF-8 normalized to NFC on platforms that need explicit Unicode normalization.
#if defined(__APPLE__)
[[nodiscard]] std::string NormalizeNFDToNFC(std::string_view input);
#endif
#ifdef _WIN32
[[nodiscard]] std::string UTF16ToUTF8(const std::wstring& input);

View File

@ -1,13 +1,15 @@
// Copyright 2014 Citra Emulator Project
// Copyright Citra Emulator Project / Azahar Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
#include <algorithm>
#include <iterator>
#include <memory>
#include "common/archives.h"
#include "common/common_types.h"
#include "common/file_util.h"
#include "common/logging/log.h"
#include "common/string_util.h"
#include "core/file_sys/disk_archive.h"
#include "core/file_sys/errors.h"
@ -62,22 +64,29 @@ u32 DiskDirectory::Read(const u32 count, Entry* entries) {
while (entries_read < count && children_iterator != directory.children.cend()) {
const FileUtil::FSTEntry& file = *children_iterator;
// Directory entries are exposed to the guest as UTF-16. Normalize host UTF-8 names first
// so host Unicode normalization differences do not leak into guest-visible SDMC paths.
#if defined(__APPLE__)
const std::string filename = Common::NormalizeNFDToNFC(file.virtualName);
#else
const std::string& filename = file.virtualName;
#endif
const std::u16string filename_utf16 = Common::UTF8ToUTF16(filename);
Entry& entry = entries[entries_read];
LOG_TRACE(Service_FS, "File {}: size={} dir={}", filename, file.size, file.isDirectory);
// TODO(Link Mauve): use a proper conversion to UTF-16.
for (std::size_t j = 0; j < FILENAME_LENGTH; ++j) {
entry.filename[j] = filename[j];
if (!filename[j])
break;
std::fill(std::begin(entry.filename), std::end(entry.filename), u'\0');
const std::size_t copy_length = std::min(filename_utf16.size(), FILENAME_LENGTH - 1);
for (std::size_t j = 0; j < copy_length; ++j) {
entry.filename[j] = filename_utf16[j];
}
FileUtil::SplitFilename83(filename, entry.short_name, entry.extension);
entry.is_directory = file.isDirectory;
entry.is_hidden = (filename[0] == '.');
entry.is_hidden = (!filename.empty() && filename[0] == '.');
entry.is_read_only = 0;
entry.file_size = file.size;
@ -92,5 +101,4 @@ u32 DiskDirectory::Read(const u32 count, Entry* entries) {
}
return entries_read;
}
} // namespace FileSys

View File

@ -1,4 +1,4 @@
// Copyright 2023 Citra Emulator Project
// Copyright Citra Emulator Project / Azahar Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
@ -24,3 +24,13 @@ TEST_CASE("SplitFilename83 Sanity", "[common]") {
REQUIRE(std::memcmp(short_name.data(), expected_short_name.data(), short_name.size()) == 0);
REQUIRE(std::memcmp(extension.data(), expected_extension.data(), extension.size()) == 0);
}
#if defined(__APPLE__)
TEST_CASE("NormalizeNFDToNFC Sanity", "[common]") {
const std::string decomposed = "i\xCC\x81";
const std::string composed = "\xC3\xAD";
REQUIRE(Common::NormalizeNFDToNFC(decomposed) == composed);
}
#endif