From d205d0bbc8308098ff397b35076efbd0d5899066 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:09:02 +0200 Subject: [PATCH] fix: keep display names out of pack file paths --- scripts/generate_pack.py | 26 +++++++++++++----- tests/test_pack_file_names.py | 52 +++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 tests/test_pack_file_names.py diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index d0a36842..cd3598ab 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -203,6 +203,18 @@ def _system_tag(system_filter: list[str] | None) -> str: return "_" + "_".join(display_parts) +_UNSAFE_IN_NAME = re.compile(r'[/\\:*?"<>|]') + + +def _name_part(text: str, space: str = "") -> str: + """A display name made safe to stand in a file name. + + 'FinalBurn Neo (CPS-1/CPS-2)' named a directory that did not exist; a + Windows user cannot write ':' or '?' either. + """ + return _UNSAFE_IN_NAME.sub("-", text.replace(" ", space)) + + def _platform_pack_stem( group_platforms: list[str], representative: str, platforms_dir: str ) -> str: @@ -213,14 +225,14 @@ def _platform_pack_stem( """ rep_cfg = load_platform_config(representative, platforms_dir) version = rep_cfg.get("version", rep_cfg.get("dat_version", "")) - version_tag = f"_{version.replace(' ', '')}" if version else "" + version_tag = f"_{_name_part(str(version))}" if version else "" if len(group_platforms) <= 1: - return rep_cfg.get("platform", representative).replace(" ", "_") + version_tag + return _name_part(rep_cfg.get("platform", representative), "_") + version_tag names = [ load_platform_config(p, platforms_dir).get("platform", p) for p in group_platforms ] - return "_".join(n.replace(" ", "") for n in names) + version_tag + return "_".join(_name_part(n) for n in names) + version_tag @@ -1231,7 +1243,7 @@ def generate_emulator_pack( return None # ZIP naming - display_names = [p.get("emulator", n).replace(" ", "") for n, p in selected] + display_names = [_name_part(p.get("emulator", n)) for n, p in selected] validation_index = _build_validation_index(dict(selected)) narrow_tags = "".join( tag @@ -1524,7 +1536,7 @@ def generate_system_pack( # Use system-based ZIP name sys_display = "_".join( - "_".join(w.title() for w in sid.split("-")) for sid in system_ids + _name_part("_".join(w.title() for w in sid.split("-"))) for sid in system_ids ) result = generate_emulator_pack( matching, @@ -1697,7 +1709,7 @@ def generate_split_packs( source, regions, target_name, one_per_slot, required_only ) ) - safe_group = group_name.replace(" ", "_") + safe_group = _name_part(group_name, "_") new_name = f"{platform_display.replace(' ', '_')}{ver_tag}{narrow_tags}_{safe_group}_BIOS_Pack.zip" new_path = os.path.join(split_dir, new_name) if new_path != zip_path: @@ -1777,7 +1789,7 @@ def generate_md5_pack( ) ) zip_name = ( - f"{context_name.replace(' ', '_')}_Custom{custom_tags}_BIOS_Pack.zip" + f"{_name_part(context_name, '_')}_Custom{custom_tags}_BIOS_Pack.zip" ) zip_path = os.path.join(output_dir, zip_name) os.makedirs(output_dir, exist_ok=True) diff --git a/tests/test_pack_file_names.py b/tests/test_pack_file_names.py new file mode 100644 index 00000000..1fa54343 --- /dev/null +++ b/tests/test_pack_file_names.py @@ -0,0 +1,52 @@ +"""A display name never becomes a path inside the output directory. + +fbneo_cps12 is called 'FinalBurn Neo (CPS-1/CPS-2)': the slash made the +emulator and system packs write into a directory that does not exist, while +verify reported the same scope OK. +""" + +from __future__ import annotations + +import hashlib +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +from generate_pack import _name_part, generate_emulator_pack # noqa: E402 + + +class PackFileNames(unittest.TestCase): + def test_separators_and_reserved_characters_are_replaced(self): + self.assertEqual(_name_part("FinalBurn Neo (CPS-1/CPS-2)"), "FinalBurnNeo(CPS-1-CPS-2)") + self.assertEqual(_name_part('a\\b:c*d?"eg|h', "_"), "a-b-c-d--e-f-g-h") + + def test_an_emulator_named_with_a_slash_writes_its_pack(self): + with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp: + emulators = Path(tmp) / "emulators" + out = Path(tmp) / "out" + emulators.mkdir() + out.mkdir() + rom = Path(tmp) / "demo.bin" + rom.write_bytes(b"demo") + sha1 = hashlib.sha1(b"demo").hexdigest() + (emulators / "demo.yml").write_text( + 'emulator: "Demo (A/B)"\ntype: libretro\nsystems: [demo]\n' + f'files:\n - name: demo.bin\n sha1: "{sha1}"\n' + ) + db = { + "files": {sha1: {"path": str(rom), "name": "demo.bin", "size": 4, + "md5": hashlib.md5(b"demo").hexdigest()}}, + "indexes": {"by_name": {"demo.bin": [sha1]}, "by_md5": {}}, + } + result = generate_emulator_pack( + ["demo"], str(emulators), db, "bios", str(out), zip_contents={}, + ) + self.assertTrue(result and Path(result).parent == out, result) + + +if __name__ == "__main__": + unittest.main()