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) <noreply@anthropic.com>
This commit is contained in:
Georges-Antoine Assi
2026-05-12 22:26:43 -04:00
parent d87bf08074
commit 8210bab806
5 changed files with 101 additions and 14 deletions

View File

@@ -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]

View File

@@ -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!")

View File

@@ -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"

View File

@@ -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

View File

@@ -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"]