Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/specify_cli/presets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions tests/test_presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 =====

Expand Down