fix(manifests): reject non-string metadata instead of crashing on it (#3943)

* fix(manifests): reject non-string metadata instead of crashing on it

`ExtensionManifest` and `PresetManifest` checked only key PRESENCE for
`id`/`name`/`version`/`description`, then fed the values straight to
`re.match()` and `packaging.Version()`. Both raise a bare `TypeError` on a
non-string, which is neither `ValidationError` nor `PresetValidationError`,
so it escaped every caller that already handles a malformed manifest.

YAML makes this an easy authoring slip rather than a contrived one: an
unquoted `version: 1.0` parses as a float and `id: 2` as an int.

The user-visible symptom is the one the in-tree comment above the section
guards was written to prevent (#3898 for presets, and its extension twin):
`list_installed()` degrades a bad manifest to "⚠️ Corrupted extension" but
catches only the domain error, so a single bad manifest made
`specify extension list` / `specify preset list` exit 1 with a raw
traceback and *no output at all* — hiding every healthy extension/preset
too, not just the broken one.

Also unguarded on the same path:
- extension `provides.commands[].name` → `TypeError` from the command-name
  pattern match. The sibling `file` field was already safe, since
  `relative_extension_path_violation()` rejects a non-string.
- preset `provides.templates[].name`/`.file` → `TypeError` from `re.match`
  and `os.path.normpath` respectively.

The third manifest twin, `IntegrationDescriptor`, is already hardened: it
type-checks the same four fields and catches `TypeError` alongside
`InvalidVersion`. This brings the other two in line with it.

Tests: 68 added across both suites, covering each field against float, int,
None, list, dict, and bool, plus an end-to-end guard per manifest type
asserting a healthy entry still lists while the bad one degrades. All 68
fail with the source change reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Noor ul ain
2026-08-03 13:53:08 -05:00
committed by GitHub
co-authored by Claude Opus 5 Copilot Autofix powered by AI
parent 7ddb8194d9
commit 2d8904a21e
4 changed files with 247 additions and 0 deletions
+26
View File
@@ -285,9 +285,25 @@ class ExtensionManifest:
raise ValidationError(
f"Invalid extension: expected a mapping, got {type(ext).__name__}"
)
# Check presence AND type: the format/version checks below feed these
# values straight to ``re.match`` and ``packaging.Version``, both of
# which raise a bare TypeError on a non-string. YAML makes that an easy
# authoring slip -- unquoted ``version: 1.0`` parses as a float and
# ``id: 2`` as an int -- and TypeError is not a ValidationError, so it
# escapes every caller that already handles a malformed manifest (see
# list_installed()'s "Corrupted extension" fallback, which catches
# ValidationError only, making one bad extension exit ``specify
# extension list`` with a raw traceback and hide the healthy ones).
# Mirrors the sibling IntegrationDescriptor, which already type-checks
# the same four fields.
for field in ["id", "name", "version", "description"]:
if field not in ext:
raise ValidationError(f"Missing extension.{field}")
if not isinstance(ext[field], str):
raise ValidationError(
f"Invalid extension.{field}: expected a string, "
f"got {type(ext[field]).__name__}"
)
# Validate extension ID format
if not re.match(r"^[a-z0-9-]+$", ext["id"]):
@@ -391,6 +407,16 @@ class ExtensionManifest:
)
if "name" not in cmd or "file" not in cmd:
raise ValidationError("Command missing 'name' or 'file'")
# The pattern match below would raise a bare TypeError on a
# non-string name (``name: 2``), escaping the ValidationError
# contract. The 'file' field needs no check here:
# relative_extension_path_violation() below already rejects a
# non-string value.
if not isinstance(cmd["name"], str):
raise ValidationError(
f"Invalid command name: expected a string, "
f"got {type(cmd['name']).__name__}"
)
# Validate the 'file' field at manifest-load time using the single
# shared policy in relative_extension_path_violation(), so manifest
+29
View File
@@ -307,9 +307,25 @@ class PresetManifest:
# Validate preset metadata
pack = self.data["preset"]
# Check presence AND type: the format/version checks below feed these
# values straight to ``re.match`` and ``packaging.Version``, both of
# which raise a bare TypeError on a non-string. YAML makes that an easy
# authoring slip -- unquoted ``version: 1.0`` parses as a float and
# ``id: 2`` as an int -- and TypeError is not a PresetValidationError,
# so it escapes every caller that already handles a malformed manifest
# (see list_installed()'s "Corrupted preset" fallback, which catches
# PresetValidationError only, making one bad preset exit ``specify
# preset list`` with a raw traceback and hide the healthy ones).
# Mirrors the sibling IntegrationDescriptor, which already type-checks
# the same four fields.
for field in ["id", "name", "version", "description"]:
if field not in pack:
raise PresetValidationError(f"Missing preset.{field}")
if not isinstance(pack[field], str):
raise PresetValidationError(
f"Invalid preset.{field}: expected a string, "
f"got {type(pack[field]).__name__}"
)
# Validate pack ID format
if not re.match(r'^[a-z0-9-]+$', pack["id"]):
@@ -367,6 +383,19 @@ class PresetManifest:
"Template missing 'type', 'name', or 'file'"
)
# 'name' feeds re.match and 'file' feeds os.path.normpath below;
# both raise a bare TypeError on a non-string, which is not a
# PresetValidationError and so escapes the callers that handle a
# malformed manifest. The sibling extension manifest already
# rejects a non-string command 'file' via
# relative_extension_path_violation().
for field in ("type", "name", "file"):
if not isinstance(tmpl[field], str):
raise PresetValidationError(
f"Invalid template {field}: expected a string, "
f"got {type(tmpl[field]).__name__}"
)
if tmpl["type"] not in VALID_PRESET_TEMPLATE_TYPES:
raise PresetValidationError(
f"Invalid template type '{tmpl['type']}': "
+96
View File
@@ -671,6 +671,102 @@ class TestExtensionManifest:
with pytest.raises(ValidationError, match=f"Invalid {section}"):
ExtensionManifest(manifest_path)
@pytest.mark.parametrize("field", ["id", "name", "version", "description"])
@pytest.mark.parametrize("bad", [1.0, 5, None, ["a"], {"a": 1}, True])
def test_extension_metadata_field_not_string_rejected(
self, temp_dir, valid_manifest_data, field, bad
):
"""A non-string extension.<field> must raise ValidationError, not a raw
TypeError.
The loop over these four fields only checked key PRESENCE, then fed the
values to ``re.match`` (id) and ``packaging.Version`` (version), both of
which raise a bare TypeError on a non-string. YAML makes that an easy
authoring slip: unquoted ``version: 1.0`` parses as a float and ``id: 2``
as an int. TypeError is not a ValidationError, so it escaped
list_installed()'s "Corrupted extension" fallback and made
`specify extension list` exit 1 with a raw traceback, hiding every
healthy extension too. The sibling IntegrationDescriptor already
type-checks the same four fields.
"""
import yaml
valid_manifest_data["extension"][field] = bad
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match=f"Invalid extension.{field}"):
ExtensionManifest(manifest_path)
@pytest.mark.parametrize("bad", [1.0, 5, None, ["a"], {"a": 1}, True])
def test_command_name_not_string_rejected(
self, temp_dir, valid_manifest_data, bad
):
"""A non-string command name must raise ValidationError, not a raw
TypeError from the name-pattern match.
The sibling ``file`` field was already covered, since
relative_extension_path_violation() rejects a non-string value; ``name``
went straight into EXTENSION_COMMAND_NAME_PATTERN.match().
"""
import yaml
valid_manifest_data["provides"]["commands"][0]["name"] = bad
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="Invalid command name"):
ExtensionManifest(manifest_path)
def test_one_bad_manifest_does_not_hide_healthy_extensions(self, temp_dir):
"""End-to-end guard for the symptom: an unquoted ``version: 1.0`` in one
installed extension must degrade to "Corrupted extension" and still let
list_installed() report the healthy ones, instead of raising TypeError
out of the whole call.
"""
ext_root = temp_dir / ".specify" / "extensions"
for ext_id, version in (("good-ext", '"1.0.0"'), ("bad-ext", "1.0")):
ext_path = ext_root / ext_id
ext_path.mkdir(parents=True, exist_ok=True)
(ext_path / "extension.yml").write_text(
f"""schema_version: "1.0"
extension:
id: {ext_id}
name: {ext_id}
version: {version}
description: desc
requires:
speckit_version: ">=0.1.0"
provides:
commands:
- name: speckit.{ext_id}.hello
file: commands/hello.md
""",
encoding="utf-8",
)
(ext_root / ".registry").write_text(
json.dumps(
{
"schema_version": "1.0",
"extensions": {
"good-ext": {"version": "1.0.0", "enabled": True},
"bad-ext": {"version": "1.0", "enabled": True},
},
}
),
encoding="utf-8",
)
listed = {row["id"]: row for row in ExtensionManager(temp_dir).list_installed()}
assert set(listed) == {"good-ext", "bad-ext"}
assert "Corrupted" not in listed["good-ext"]["description"]
assert "Corrupted" in listed["bad-ext"]["description"]
def test_empty_provides_mapping_is_still_accepted_with_hooks(
self, temp_dir, valid_manifest_data
):
+96
View File
@@ -217,6 +217,102 @@ class TestPresetManifest:
):
PresetManifest(manifest_path)
@pytest.mark.parametrize("field", ["id", "name", "version", "description"])
@pytest.mark.parametrize("bad", [1.0, 5, None, ["a"], {"a": 1}, True])
def test_preset_metadata_field_not_string_raises_validation_error(
self, temp_dir, valid_pack_data, field, bad
):
"""A non-string preset.<field> raises PresetValidationError, not a raw
TypeError.
The loop over these four fields only checked key PRESENCE, then fed the
values to ``re.match`` (id) and ``packaging.Version`` (version), both of
which raise a bare TypeError on a non-string. YAML makes that an easy
authoring slip: unquoted ``version: 1.0`` parses as a float and ``id: 2``
as an int. TypeError is not a PresetValidationError, so it escaped
list_installed()'s "Corrupted preset" fallback and made
`specify preset list` exit 1 with a raw traceback, hiding every healthy
preset too. The sibling IntegrationDescriptor already type-checks the
same four fields.
"""
valid_pack_data["preset"][field] = bad
manifest_path = temp_dir / "preset.yml"
manifest_path.write_text(yaml.safe_dump(valid_pack_data), encoding="utf-8")
with pytest.raises(
PresetValidationError,
match=rf"Invalid preset\.{field}: expected a string",
):
PresetManifest(manifest_path)
@pytest.mark.parametrize("field", ["name", "file"])
@pytest.mark.parametrize("bad", [1.0, 5, None, ["a"], {"a": 1}, True])
def test_template_entry_field_not_string_raises_validation_error(
self, temp_dir, valid_pack_data, field, bad
):
"""A non-string template ``name``/``file`` raises PresetValidationError.
``name`` reaches ``re.match`` and ``file`` reaches ``os.path.normpath``;
both raise a bare TypeError on a non-string. The sibling extension
manifest already rejects a non-string command ``file`` via
relative_extension_path_violation().
"""
valid_pack_data["provides"]["templates"][0][field] = bad
manifest_path = temp_dir / "preset.yml"
manifest_path.write_text(yaml.safe_dump(valid_pack_data), encoding="utf-8")
with pytest.raises(
PresetValidationError,
match=rf"Invalid template {field}: expected a string",
):
PresetManifest(manifest_path)
def test_one_bad_manifest_does_not_hide_healthy_presets(self, temp_dir):
"""End-to-end guard for the symptom: an unquoted ``version: 1.0`` in one
installed preset must degrade to "Corrupted preset" and still let
list_installed() report the healthy ones, instead of raising TypeError
out of the whole call.
"""
preset_root = temp_dir / ".specify" / "presets"
for pack_id, version in (("good-pack", '"1.0.0"'), ("bad-pack", "1.0")):
pack_path = preset_root / pack_id
pack_path.mkdir(parents=True, exist_ok=True)
(pack_path / "preset.yml").write_text(
f"""schema_version: "1.0"
preset:
id: {pack_id}
name: {pack_id}
version: {version}
description: desc
requires:
speckit_version: ">=0.1.0"
provides:
templates:
- type: template
name: spec
file: templates/spec.md
""",
encoding="utf-8",
)
(preset_root / ".registry").write_text(
json.dumps(
{
"schema_version": "1.0",
"presets": {
"good-pack": {"version": "1.0.0", "enabled": True},
"bad-pack": {"version": "1.0", "enabled": True},
},
}
),
encoding="utf-8",
)
listed = {row["id"]: row for row in PresetManager(temp_dir).list_installed()}
assert set(listed) == {"good-pack", "bad-pack"}
assert "Corrupted" not in listed["good-pack"]["description"]
assert "Corrupted" in listed["bad-pack"]["description"]
@pytest.mark.parametrize(
"bad",
[