diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 23109e7d6d..db01870e2a 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -54,7 +54,7 @@ from .filter.permission import COMMAND_PERMISSION_TYPES, PermissionTypeFilter from .star import star_map, star_registry from .star_handler import EventType, star_handlers_registry -from .updater import PLUGIN_METADATA_FILENAMES, _PluginUpdater +from .updater import PLUGIN_METADATA_FILENAMES, _PluginUpdater, load_plugin_yaml try: from watchfiles import PythonFilter, awatch @@ -517,7 +517,7 @@ def _load_plugin_metadata(plugin_path: str, plugin_obj=None) -> StarMetadata | N if metadata_path: metadata_label = metadata_path.name with metadata_path.open(encoding="utf-8") as f: - metadata = yaml.safe_load(f) + metadata = load_plugin_yaml(f) if isinstance(metadata, dict): if "desc" not in metadata and "description" in metadata: @@ -652,7 +652,7 @@ def _get_plugin_dir_name_from_metadata(plugin_path: str) -> str: ) with metadata_path.open(encoding="utf-8") as f: - metadata = yaml.safe_load(f) + metadata = load_plugin_yaml(f) if not isinstance(metadata, dict): raise Exception(f"{metadata_path.name} 格式错误。") diff --git a/astrbot/core/star/updater.py b/astrbot/core/star/updater.py index 5a0ae4c517..e3f23b165a 100644 --- a/astrbot/core/star/updater.py +++ b/astrbot/core/star/updater.py @@ -31,6 +31,36 @@ __all__ = ["PLUGIN_METADATA_FILENAMES"] +class _StringScalarSafeLoader(yaml.SafeLoader): + """SafeLoader that keeps every scalar as its original source text. + + Prevents lossy YAML numeric coercion such as `version: 2.10` being + parsed as the float 2.1 and later stringified as "2.1". + """ + + +# Clear inherited implicit resolvers so plain scalars (e.g. 2.10, true, null) +# are not re-typed by YAML and instead keep their original string form. +_StringScalarSafeLoader.yaml_implicit_resolvers = {} + +_StringScalarSafeLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_SCALAR_TAG, + lambda loader, node: loader.construct_scalar(node), +) + + +def load_plugin_yaml(stream): + """Parse plugin YAML while preserving each scalar's original text. + + Args: + stream: YAML source as str, bytes, or a file object. + + Returns: + Parsed Python object; scalar values keep their source representation. + """ + return yaml.load(stream, Loader=_StringScalarSafeLoader) + + class _PluginUpdater(_RepoZipUpdater): """Install and update plugins from repository source archives.""" @@ -174,7 +204,7 @@ async def inspect_repository( except UnicodeDecodeError as exc: raise ValueError(f"{filename} 必须使用 UTF-8 编码。") from exc try: - metadata = yaml.safe_load(metadata_text) + metadata = load_plugin_yaml(metadata_text) except yaml.YAMLError as exc: raise ValueError(f"{filename} 格式错误。") from exc try: @@ -371,6 +401,15 @@ def validate_plugin_metadata(metadata: object, metadata_label: str) -> None: if "desc" not in normalized_metadata and "description" in normalized_metadata: normalized_metadata["desc"] = normalized_metadata["description"] + # YAML parses unquoted versions like `version: 2.4` as floats. Coerce + # scalar numbers to strings in the caller's dict as well, so callers + # reading the original metadata (e.g. StarMetadata loading) see a str. + for field in PLUGIN_METADATA_REQUIRED_FIELDS: + value = normalized_metadata.get(field) + if isinstance(value, (int, float)) and not isinstance(value, bool): + normalized_metadata[field] = str(value) + metadata[field] = str(value) + missing_fields = [ field for field in PLUGIN_METADATA_REQUIRED_FIELDS @@ -413,7 +452,7 @@ def inspect_plugin_directory(cls, plugin_path: str | Path) -> dict[str, object]: if metadata_path.stat().st_size > PLUGIN_METADATA_MAX_BYTES: raise ValueError(f"{filename} 超过 1MB。") try: - metadata = yaml.safe_load(metadata_path.read_text(encoding="utf-8")) + metadata = load_plugin_yaml(metadata_path.read_text(encoding="utf-8")) except UnicodeDecodeError as exc: raise ValueError(f"{filename} 必须使用 UTF-8 编码。") from exc except yaml.YAMLError as exc: @@ -445,7 +484,7 @@ def inspect_plugin_archive(cls, zip_path: str) -> dict[str, object]: try: metadata_text = z.read(metadata_entry).decode("utf-8") - metadata = yaml.safe_load(metadata_text) + metadata = load_plugin_yaml(metadata_text) except UnicodeDecodeError as exc: raise ValueError(f"{metadata_entry} 必须使用 UTF-8 编码。") from exc except yaml.YAMLError as exc: diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index 2bf27a2aac..cddbff4a44 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -16,6 +16,7 @@ from astrbot.core.star import star_manager as star_manager_module from astrbot.core.star.star_handler import EventType, StarHandlerMetadata from astrbot.core.star.star_manager import PluginDependencyInstallError, PluginManager +from astrbot.core.star.updater import _PluginUpdater from astrbot.core.utils.pip_installer import PipInstallError from astrbot.core.utils.requirements_utils import MissingRequirementsPlan @@ -218,6 +219,57 @@ def test_load_plugin_metadata_preserves_validation_error( PluginManager._load_plugin_metadata(str(plugin_path)) +def test_validate_plugin_metadata_coerces_numeric_version_to_string() -> None: + """YAML parses unquoted `version: 2.4` as a float; it must be coerced to str.""" + metadata = { + "name": TEST_PLUGIN_NAME, + "desc": "test plugin", + "version": 2.4, + "author": "AstrBot Team", + } + + _PluginUpdater.validate_plugin_metadata(metadata, "metadata.yaml") + + assert metadata["version"] == "2.4" + assert isinstance(metadata["version"], str) + + +def test_load_plugin_metadata_coerces_numeric_version_to_string(tmp_path: Path) -> None: + """A plugin whose metadata.yaml uses an unquoted numeric version must load.""" + plugin_path = tmp_path / "helloworld" + plugin_path.mkdir() + (plugin_path / "metadata.yaml").write_text( + "name: helloworld\n" + "desc: test plugin\n" + "version: 2.4\n" + "author: AstrBot Team\n", + encoding="utf-8", + ) + + loaded_metadata = PluginManager._load_plugin_metadata(str(plugin_path)) + + assert loaded_metadata is not None + assert loaded_metadata.version == "2.4" + + +def test_load_plugin_metadata_preserves_trailing_zero_version(tmp_path: Path) -> None: + """Unquoted `version: 2.10` must keep its original text, not become "2.1".""" + plugin_path = tmp_path / "helloworld" + plugin_path.mkdir() + (plugin_path / "metadata.yaml").write_text( + "name: helloworld\n" + "desc: test plugin\n" + "version: 2.10\n" + "author: AstrBot Team\n", + encoding="utf-8", + ) + + loaded_metadata = PluginManager._load_plugin_metadata(str(plugin_path)) + + assert loaded_metadata is not None + assert loaded_metadata.version == "2.10" + + def test_loaded_metadata_can_copy_i18n_into_existing_star_metadata(tmp_path: Path): plugin_path = tmp_path / "helloworld" _write_local_test_plugin(plugin_path, TEST_PLUGIN_REPO)