Skip to content

Commit cd996f7

Browse files
fix(manifests): reject non-string requires.speckit_version (#3980)
`requires.speckit_version` was presence-checked but never type-checked in both the extension and preset manifest validators, so an unquoted YAML `speckit_version: 1.0` (a float) passed validation and reached `SpecifierSet(required)` in `check_compatibility()`. That call is guarded by `except InvalidSpecifier` alone, which a non-string escapes two different ways: - a float/int/bool/None raises `TypeError: 'float' object is not iterable` from the `SpecifierSet` constructor; - a list or dict is an *iterable*, so `SpecifierSet` accepts it and the failure surfaces much later as `AttributeError: 'str' object has no attribute 'filter'` from inside `.contains()`. Neither is a `CompatibilityError`/`PresetCompatibilityError`, so both bypass the CLI's "Compatibility Error" handler in `_commands.py` and exit 1 with a raw traceback that names no field, leaving the author with no hint which manifest key is wrong. Type-check the field in both validators, requiring a non-empty string, and additionally guard `check_compatibility()` in both managers since each is public and reachable with a hand-built or mutated manifest. This mirrors the sibling `IntegrationDescriptor`, which already requires a non-empty string for the same key, and completes the type-checking pass started in #3943 for the neighbouring `extension`/`preset` fields. Adds 33 regression tests across both modules covering every escape path; 26 of them fail without this change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Assisted-by: Claude Code (model: Claude Opus 5, supervised)
1 parent 0fa86e8 commit cd996f7

4 files changed

Lines changed: 183 additions & 0 deletions

File tree

src/specify_cli/extensions/__init__.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,25 @@ def _validate(self):
341341
)
342342
if "speckit_version" not in requires:
343343
raise ValidationError("Missing requires.speckit_version")
344+
# Presence alone is not enough: check_compatibility() feeds this value to
345+
# ``SpecifierSet(required)``, guarded only by ``except InvalidSpecifier``,
346+
# which a non-string escapes two different ways. A float/int/bool/None
347+
# raises TypeError from the constructor, while a list or dict is an
348+
# *iterable*, so SpecifierSet accepts it and the failure surfaces much
349+
# later as ``AttributeError: 'str' object has no attribute 'filter'`` from
350+
# inside .contains(). Neither is a CompatibilityError, so both bypass the
351+
# CLI's "Compatibility Error" handler and exit 1 with a raw traceback
352+
# naming no field. An unquoted ``speckit_version: 1.0`` is an easy YAML
353+
# slip. Mirrors the sibling IntegrationDescriptor, which already requires
354+
# a non-empty string here.
355+
if (
356+
not isinstance(requires["speckit_version"], str)
357+
or not requires["speckit_version"].strip()
358+
):
359+
raise ValidationError(
360+
"Invalid requires.speckit_version: expected a non-empty string, "
361+
f"got {type(requires['speckit_version']).__name__}"
362+
)
344363

345364
# Validate provides section
346365
provides = self.data["provides"]
@@ -1851,6 +1870,17 @@ def check_compatibility(
18511870
required = manifest.requires_speckit_version
18521871

18531872
# Parse version specifier (e.g., ">=0.1.0,<2.0.0")
1873+
# Defense in depth: the manifest validator now rejects a non-string
1874+
# requires.speckit_version, but this method is public and also reachable
1875+
# with a hand-built manifest object. ``InvalidSpecifier`` alone does not
1876+
# cover a non-string -- scalars raise TypeError from the constructor, and
1877+
# a list/dict is iterable so it constructs here and only breaks inside
1878+
# .contains(). Reject up front so this always reports a CompatibilityError.
1879+
if not isinstance(required, str):
1880+
raise CompatibilityError(
1881+
"Invalid version specifier: expected a string, got "
1882+
f"{type(required).__name__} ({required!r})"
1883+
)
18541884
try:
18551885
SpecifierSet(required) # Just to validate
18561886
except InvalidSpecifier:

src/specify_cli/presets/__init__.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,25 @@ def _validate(self):
344344
requires = self.data["requires"]
345345
if "speckit_version" not in requires:
346346
raise PresetValidationError("Missing requires.speckit_version")
347+
# Presence alone is not enough: check_compatibility() feeds this value to
348+
# ``SpecifierSet(required)``, guarded only by ``except InvalidSpecifier``,
349+
# which a non-string escapes two different ways. A float/int/bool/None
350+
# raises TypeError from the constructor, while a list or dict is an
351+
# *iterable*, so SpecifierSet accepts it and the failure surfaces much
352+
# later as ``AttributeError: 'str' object has no attribute 'filter'`` from
353+
# inside .contains(). Neither is a PresetCompatibilityError, so both
354+
# bypass the CLI's "Compatibility Error" handler and exit 1 with a raw
355+
# traceback naming no field. An unquoted ``speckit_version: 1.0`` is an
356+
# easy YAML slip. Mirrors the sibling IntegrationDescriptor, which already
357+
# requires a non-empty string here.
358+
if (
359+
not isinstance(requires["speckit_version"], str)
360+
or not requires["speckit_version"].strip()
361+
):
362+
raise PresetValidationError(
363+
"Invalid requires.speckit_version: expected a non-empty string, "
364+
f"got {type(requires['speckit_version']).__name__}"
365+
)
347366

348367
# Validate provides section
349368
provides = self.data["provides"]
@@ -756,6 +775,18 @@ def check_compatibility(
756775
PresetCompatibilityError: If pack is incompatible
757776
"""
758777
required = manifest.requires_speckit_version
778+
# Defense in depth: the manifest validator now rejects a non-string
779+
# requires.speckit_version, but this method is public and also reachable
780+
# with a hand-built manifest object. ``InvalidSpecifier`` alone does not
781+
# cover a non-string -- scalars raise TypeError from the constructor, and
782+
# a list/dict is iterable so it constructs here and only breaks inside
783+
# .contains(). Reject up front so this always reports a
784+
# PresetCompatibilityError.
785+
if not isinstance(required, str):
786+
raise PresetCompatibilityError(
787+
"Invalid version specifier: expected a string, got "
788+
f"{type(required).__name__} ({required!r})"
789+
)
759790
try:
760791
SpecifierSet(required) # Just to validate
761792
except InvalidSpecifier:

tests/test_extensions.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,55 @@ def test_invalid_version(self, temp_dir, valid_manifest_data):
410410
with pytest.raises(ValidationError, match="Invalid version"):
411411
ExtensionManifest(manifest_path)
412412

413+
@pytest.mark.parametrize(
414+
"bad",
415+
[
416+
1.0, # unquoted YAML float -- the likeliest authoring slip
417+
5, # unquoted int
418+
True, # YAML `yes`/`true`
419+
None, # `speckit_version:` written but left empty
420+
[">=0.1.0"], # iterable: slips past SpecifierSet() entirely
421+
{"min": "0.1"}, # iterable: same
422+
],
423+
)
424+
def test_non_string_speckit_version(self, temp_dir, valid_manifest_data, bad):
425+
"""A non-string requires.speckit_version must be a ValidationError.
426+
427+
It was presence-checked only, so it reached ``SpecifierSet(required)`` in
428+
check_compatibility(), which is guarded by ``except InvalidSpecifier``
429+
alone. A non-string escapes that guard two ways: scalars raise TypeError
430+
from the constructor, and a list/dict is iterable so SpecifierSet accepts
431+
it and the failure surfaces later as ``AttributeError: 'str' object has no
432+
attribute 'filter'`` from inside .contains().
433+
"""
434+
import yaml
435+
436+
valid_manifest_data["requires"]["speckit_version"] = bad
437+
438+
manifest_path = temp_dir / "extension.yml"
439+
with open(manifest_path, 'w') as f:
440+
yaml.dump(valid_manifest_data, f)
441+
442+
with pytest.raises(
443+
ValidationError, match="Invalid requires.speckit_version"
444+
):
445+
ExtensionManifest(manifest_path)
446+
447+
def test_empty_speckit_version(self, temp_dir, valid_manifest_data):
448+
"""A blank requires.speckit_version must be rejected, not treated as any."""
449+
import yaml
450+
451+
valid_manifest_data["requires"]["speckit_version"] = " "
452+
453+
manifest_path = temp_dir / "extension.yml"
454+
with open(manifest_path, 'w') as f:
455+
yaml.dump(valid_manifest_data, f)
456+
457+
with pytest.raises(
458+
ValidationError, match="Invalid requires.speckit_version"
459+
):
460+
ExtensionManifest(manifest_path)
461+
413462
def test_valid_category(self, temp_dir, valid_manifest_data):
414463
"""Test manifest with various category values (free-form string)."""
415464
import yaml
@@ -1265,6 +1314,28 @@ def test_check_compatibility_invalid(self, extension_dir, project_dir):
12651314
with pytest.raises(CompatibilityError, match="Extension requires spec-kit"):
12661315
manager.check_compatibility(manifest, "0.0.1")
12671316

1317+
@pytest.mark.parametrize(
1318+
"bad",
1319+
[1.0, 5, True, None, [">=0.1.0"], {"min": "0.1"}],
1320+
)
1321+
def test_check_compatibility_non_string_specifier(self, project_dir, bad):
1322+
"""check_compatibility() must report a non-string as CompatibilityError.
1323+
1324+
Defense in depth for the validator check above: this method is public and
1325+
reachable with a hand-built manifest, and ``except InvalidSpecifier`` does
1326+
not cover a non-string. Without the guard, scalars raise a bare TypeError
1327+
and iterables construct fine only to break inside .contains() -- neither
1328+
is a CompatibilityError, so both bypass the CLI's "Compatibility Error"
1329+
handler and exit 1 with a raw traceback naming no field.
1330+
"""
1331+
from types import SimpleNamespace
1332+
1333+
manager = ExtensionManager(project_dir)
1334+
manifest = SimpleNamespace(requires_speckit_version=bad)
1335+
1336+
with pytest.raises(CompatibilityError, match="Invalid version specifier"):
1337+
manager.check_compatibility(manifest, "0.15.2")
1338+
12681339
def test_check_compatibility_allows_prerelease_builds(self, extension_dir, project_dir):
12691340
"""Prerelease spec-kit builds should satisfy compatible version ranges."""
12701341
manager = ExtensionManager(project_dir)

tests/test_presets.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,37 @@ def test_missing_speckit_version(self, temp_dir, valid_pack_data):
406406
with pytest.raises(PresetValidationError, match="Missing requires.speckit_version"):
407407
PresetManifest(manifest_path)
408408

409+
@pytest.mark.parametrize(
410+
"bad",
411+
[
412+
1.0, # unquoted YAML float -- the likeliest authoring slip
413+
5, # unquoted int
414+
True, # YAML `yes`/`true`
415+
None, # `speckit_version:` written but left empty
416+
[">=0.1.0"], # iterable: slips past SpecifierSet() entirely
417+
{"min": "0.1"}, # iterable: same
418+
" ", # blank string must not mean "any version"
419+
],
420+
)
421+
def test_non_string_speckit_version(self, temp_dir, valid_pack_data, bad):
422+
"""A non-string requires.speckit_version must be a PresetValidationError.
423+
424+
It was presence-checked only, so it reached ``SpecifierSet(required)`` in
425+
check_compatibility(), which is guarded by ``except InvalidSpecifier``
426+
alone. A non-string escapes that guard two ways: scalars raise TypeError
427+
from the constructor, and a list/dict is iterable so SpecifierSet accepts
428+
it and the failure surfaces later as ``AttributeError: 'str' object has no
429+
attribute 'filter'`` from inside .contains().
430+
"""
431+
valid_pack_data["requires"]["speckit_version"] = bad
432+
manifest_path = temp_dir / "preset.yml"
433+
with open(manifest_path, 'w') as f:
434+
yaml.dump(valid_pack_data, f)
435+
with pytest.raises(
436+
PresetValidationError, match="Invalid requires.speckit_version"
437+
):
438+
PresetManifest(manifest_path)
439+
409440
def test_no_templates_provided(self, temp_dir, valid_pack_data):
410441
"""Test pack with no templates."""
411442
valid_pack_data["provides"]["templates"] = []
@@ -964,6 +995,26 @@ def test_check_compatibility_invalid(self, pack_dir, temp_dir):
964995
with pytest.raises(PresetCompatibilityError, match="Invalid version specifier"):
965996
manager.check_compatibility(manifest, "0.1.5")
966997

998+
@pytest.mark.parametrize(
999+
"bad",
1000+
[1.0, 5, True, None, [">=0.1.0"], {"min": "0.1"}],
1001+
)
1002+
def test_check_compatibility_non_string_specifier(self, pack_dir, temp_dir, bad):
1003+
"""check_compatibility() must report a non-string as a compatibility error.
1004+
1005+
Defense in depth for the validator check: this method is public and the
1006+
specifier is read back out of mutable manifest data, and ``except
1007+
InvalidSpecifier`` does not cover a non-string. Without the guard, scalars
1008+
raise a bare TypeError and iterables construct fine only to break inside
1009+
.contains() -- neither is a PresetCompatibilityError, so both bypass the
1010+
CLI's "Compatibility Error" handler and exit 1 with a raw traceback.
1011+
"""
1012+
manager = PresetManager(temp_dir)
1013+
manifest = PresetManifest(pack_dir / "preset.yml")
1014+
manifest.data["requires"]["speckit_version"] = bad
1015+
with pytest.raises(PresetCompatibilityError, match="Invalid version specifier"):
1016+
manager.check_compatibility(manifest, "0.1.5")
1017+
9671018
def test_install_with_priority(self, project_dir, pack_dir):
9681019
"""Test installing a pack with custom priority."""
9691020
manager = PresetManager(project_dir)

0 commit comments

Comments
 (0)