From 8210bab8061ef39e6c7bcbae2efc823ecea2acb3 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Tue, 12 May 2026 22:26:43 -0400 Subject: [PATCH] Tolerate forward-compat values and malformed YAML in config loader Sample configs shipped with newer releases can reference media types or options unknown to older versions; previously the loader called sys.exit(3) and refused to boot. Now drop unknown scan.media entries, fall back to defaults for unknown gamelist media thumbnail/image values, and recover from YAML parse errors with a clear critical log instead of a traceback. Type-mismatch validation still hard-fails since those are real misconfigurations. Co-Authored-By: Claude Opus 4.7 (1M context) --- .trunk/trunk.yaml | 2 + backend/config/config_manager.py | 52 ++++++++++++++----- .../fixtures/config/forward_compat_config.yml | 17 ++++++ .../fixtures/config/malformed_config.yml | 9 ++++ backend/tests/config/test_config_loader.py | 35 +++++++++++++ 5 files changed, 101 insertions(+), 14 deletions(-) create mode 100644 backend/tests/config/fixtures/config/forward_compat_config.yml create mode 100644 backend/tests/config/fixtures/config/malformed_config.yml diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 9d55ed808..93f54192d 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -51,6 +51,8 @@ lint: - frontend/src/__generated__/** - docker/Dockerfile - docker/nginx/js/** + # Intentionally malformed YAML, used as a test fixture + - backend/tests/config/fixtures/config/malformed_config.yml files: - name: vue extensions: [vue] diff --git a/backend/config/config_manager.py b/backend/config/config_manager.py index 4655f1774..88d26feaa 100644 --- a/backend/config/config_manager.py +++ b/backend/config/config_manager.py @@ -174,7 +174,7 @@ class ConfigManager: # Check if the config file is mounted with open(self.config_file, "r") as cf: self._config_file_mounted = True - self._raw_config = yaml.load(cf, Loader=SafeLoader) or {} + self._raw_config = self._safe_load_yaml(cf) # Also check if the config file is writable self._config_file_writable = os.access(self.config_file, os.W_OK) @@ -189,6 +189,19 @@ class ConfigManager: self._parse_config() self._validate_config() + def _safe_load_yaml(self, cf) -> dict: + """Load YAML, falling back to an empty config on syntax errors so the + app can still boot with defaults rather than crashing.""" + try: + return yaml.load(cf, Loader=SafeLoader) or {} + except yaml.YAMLError as exc: + log.critical( + f"Failed to parse {hl(self.config_file, BLUE)}: {exc}. " + "Falling back to default configuration, fix the YAML " + "syntax to apply your settings." + ) + return {} + def _create_missing_config_file(self) -> None: log.warning( f"Config file not found, creating an empty config at {hl(self.config_file, BLUE)}" @@ -633,12 +646,19 @@ class ConfigManager: log.critical("Invalid config.yml: scan.media must be a list") sys.exit(3) - for media in self.config.SCAN_MEDIA: - if media not in MetadataMediaType: - log.critical( - f"Invalid config.yml: scan.media.{media} is not a valid media type" - ) - sys.exit(3) + # Drop unknown media types rather than exiting — a newer release may + # ship sample configs referencing media types this version doesn't know. + unknown_media = [ + m for m in self.config.SCAN_MEDIA if m not in MetadataMediaType + ] + if unknown_media: + log.warning( + f"Ignoring unknown values in scan.media: {unknown_media}. " + "These may be from a newer RomM version — update to use them." + ) + self.config.SCAN_MEDIA = [ + m for m in self.config.SCAN_MEDIA if m in MetadataMediaType + ] valid_thumbnail_options = { MetadataMediaType.BOX2D, @@ -652,10 +672,12 @@ class ConfigManager: ) sys.exit(3) if self.config.GAMELIST_MEDIA_THUMBNAIL not in valid_thumbnail_options: - log.critical( - f"Invalid config.yml: scan.gamelist.media.thumbnail must be one of {valid_thumbnail_options}" + log.warning( + f"Unknown scan.gamelist.media.thumbnail value " + f"{self.config.GAMELIST_MEDIA_THUMBNAIL!r}; falling back to " + f"{MetadataMediaType.BOX2D.value!r}. Valid options: {sorted(o.value for o in valid_thumbnail_options)}." ) - sys.exit(3) + self.config.GAMELIST_MEDIA_THUMBNAIL = MetadataMediaType.BOX2D valid_image_options = { MetadataMediaType.TITLE_SCREEN, @@ -671,15 +693,17 @@ class ConfigManager: sys.exit(3) if self.config.GAMELIST_MEDIA_IMAGE not in valid_image_options: - log.critical( - f"Invalid config.yml: scan.gamelist.media.image must be one of {valid_image_options}" + log.warning( + f"Unknown scan.gamelist.media.image value " + f"{self.config.GAMELIST_MEDIA_IMAGE!r}; falling back to " + f"{MetadataMediaType.SCREENSHOT.value!r}. Valid options: {sorted(o.value for o in valid_image_options)}." ) - sys.exit(3) + self.config.GAMELIST_MEDIA_IMAGE = MetadataMediaType.SCREENSHOT def get_config(self) -> Config: try: with open(self.config_file, "r") as config_file: - self._raw_config = yaml.load(config_file, Loader=SafeLoader) or {} + self._raw_config = self._safe_load_yaml(config_file) except FileNotFoundError: log.debug("Config file not found!") diff --git a/backend/tests/config/fixtures/config/forward_compat_config.yml b/backend/tests/config/fixtures/config/forward_compat_config.yml new file mode 100644 index 000000000..c84f7a0e7 --- /dev/null +++ b/backend/tests/config/fixtures/config/forward_compat_config.yml @@ -0,0 +1,17 @@ +# Config file containing values that a future RomM release might ship but +# which this version doesn't yet know about. Used to verify the loader +# tolerates unknown enum/option values instead of refusing to boot. + +scan: + media: + - box2d + - some_future_media_type + - screenshot + gamelist: + media: + thumbnail: "some_future_thumbnail" + image: "some_future_image" + +future_top_level_section: + enabled: true + setting: "value" diff --git a/backend/tests/config/fixtures/config/malformed_config.yml b/backend/tests/config/fixtures/config/malformed_config.yml new file mode 100644 index 000000000..f66ab5452 --- /dev/null +++ b/backend/tests/config/fixtures/config/malformed_config.yml @@ -0,0 +1,9 @@ +# Intentionally malformed YAML — used to verify the loader falls back to +# defaults rather than crashing on a parse error. + +scan: + media: + - box2d + - screenshot # bad indentation, will fail to parse + gamelist: + export: true diff --git a/backend/tests/config/test_config_loader.py b/backend/tests/config/test_config_loader.py index 72b57f940..bd6a8f9ab 100644 --- a/backend/tests/config/test_config_loader.py +++ b/backend/tests/config/test_config_loader.py @@ -125,3 +125,38 @@ def test_missing_config_file_is_created(tmp_path): assert config_file.read_text() == "" assert loader.config.CONFIG_FILE_MOUNTED assert loader.config.CONFIG_FILE_WRITABLE + + +def test_forward_compat_unknown_values_are_tolerated(): + """A newer release may ship sample configs that reference media types + this version doesn't yet recognize. The loader should drop unknowns and + fall back to defaults rather than exiting.""" + loader = ConfigManager( + os.path.join( + Path(__file__).resolve().parent, + "fixtures", + "config/forward_compat_config.yml", + ) + ) + + # Unknown entries in scan.media are filtered out; known ones survive. + assert loader.config.SCAN_MEDIA == ["box2d", "screenshot"] + # Unknown thumbnail/image values fall back to their defaults. + assert loader.config.GAMELIST_MEDIA_THUMBNAIL == "box2d" + assert loader.config.GAMELIST_MEDIA_IMAGE == "screenshot" + + +def test_malformed_yaml_falls_back_to_defaults(): + """A YAML parse error should log critically and leave the app on + defaults, not crash.""" + loader = ConfigManager( + os.path.join( + Path(__file__).resolve().parent, + "fixtures", + "config/malformed_config.yml", + ) + ) + + assert loader.config.ROMS_FOLDER_NAME == "roms" + assert loader.config.FIRMWARE_FOLDER_NAME == "bios" + assert loader.config.SCAN_MEDIA == ["box2d", "screenshot", "manual"]