diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 3d37f6fb74..54dc5d2845 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -409,6 +409,7 @@ def _validate(self): raise PresetValidationError( "Preset must provide at least one template" ) + seen_name_types: set[tuple[str, str]] = set() for tmpl in templates: if not isinstance(tmpl, dict): raise PresetValidationError( @@ -438,6 +439,20 @@ def _validate(self): f"must be one of {sorted(VALID_PRESET_TEMPLATE_TYPES)}" ) + # PresetResolver._manifest_declared_template returns the first + # 'provides.templates' entry matching a given (name, type) pair, so + # a later duplicate would be silently unreachable while still being + # counted by PresetManifest.templates. Reject at validation time + # instead, mirroring the sibling fix for ExtensionManifest's + # provides.templates/scripts (#4016). + name_type = (tmpl["name"], tmpl["type"]) + if name_type in seen_name_types: + raise PresetValidationError( + f"Duplicate template name '{tmpl['name']}' of type " + f"'{tmpl['type']}' in 'provides.templates'" + ) + seen_name_types.add(name_type) + # Validate file path safety: must be relative, no parent traversal file_path = tmpl["file"] normalized = os.path.normpath(file_path) diff --git a/tests/test_presets.py b/tests/test_presets.py index 9775e0afa9..660a26d1b1 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -500,6 +500,41 @@ def test_multiple_templates(self, temp_dir, valid_pack_data): manifest = PresetManifest(manifest_path) assert len(manifest.templates) == 4 + def test_duplicate_template_name_and_type_raises_validation_error( + self, temp_dir, valid_pack_data + ): + """A later entry with the same (name, type) pair must be rejected. + + ``PresetResolver._manifest_declared_template`` returns the FIRST + 'provides.templates' entry matching a given (name, type) pair, so a + later duplicate would be silently unreachable while still being + counted by ``PresetManifest.templates`` -- mirroring the sibling bug + fixed for ``ExtensionManifest``'s provides.templates/scripts (#4016). + """ + valid_pack_data["provides"]["templates"] = [ + {"type": "command", "name": "specify", "file": "commands/specify-v1.md"}, + {"type": "command", "name": "specify", "file": "commands/specify-v2.md"}, + ] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Duplicate template name"): + PresetManifest(manifest_path) + + def test_same_name_different_type_templates_allowed( + self, temp_dir, valid_pack_data + ): + """The same name may recur across different template types.""" + valid_pack_data["provides"]["templates"] = [ + {"type": "template", "name": "specify", "file": "templates/specify.md"}, + {"type": "command", "name": "specify", "file": "commands/specify.md"}, + ] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + manifest = PresetManifest(manifest_path) + assert len(manifest.templates) == 2 + # ===== PresetRegistry Tests =====