From 3ef7365c66730b5210de0c22d30e5b1381d96dd7 Mon Sep 17 00:00:00 2001 From: Noor-ul-ain001 Date: Tue, 18 Aug 2026 21:59:09 +0500 Subject: [PATCH] fix(presets): reject duplicate provides.templates name+type entries PresetResolver._manifest_declared_template returns the FIRST 'provides.templates' entry matching a given (name, type) pair: for tmpl in manifest.templates: if tmpl.get("name") == template_name and tmpl.get("type") == template_type: ... return tmpl, ... So a preset.yml declaring two templates with the same (name, type) -- e.g. two "command"/"specify" entries pointing at different files -- had its second entry silently unreachable, while PresetManifest.templates still counted and exposed both. PresetManifest._validate never checked for this. Reject the duplicate at manifest-validation time instead, matching the sibling fix already applied to ExtensionManifest's provides.templates/ provides.scripts (commit 11e3176, PR #4016): "The resolver returns the first entry matching a declared name, so a later duplicate ... was silently unreachable while still counted". Presets use a (name, type) composite key rather than extensions' bare name, since the same name can legitimately recur across different template types (e.g. a "specify" template and a "specify" command); the fix only rejects a duplicate within the exact same (name, type) pair. Co-Authored-By: Claude Sonnet 5 --- src/specify_cli/presets/__init__.py | 15 +++++++++++++ tests/test_presets.py | 35 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) 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 =====