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
25 changes: 20 additions & 5 deletions src/specify_cli/workflows/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -784,12 +784,17 @@ def remove_catalog(self, index: int) -> str:
raise WorkflowValidationError("No catalog config file found.")

try:
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc:
raise WorkflowValidationError(
f"Catalog config file is unreadable or malformed: {exc}"
) from exc
if not isinstance(data, dict):
# Do NOT coerce with ``or {}`` here: that also turns a FALSY non-mapping
# (top-level ``[]``, ``false``, ``0``, ``''``) into ``{}`` and silently
# swallows it, matching _load_catalog_config's guard above.
if data is None:
data = {}
elif not isinstance(data, dict):
raise WorkflowValidationError(
"Catalog config file is corrupted (expected a mapping)."
)
Expand Down Expand Up @@ -1394,11 +1399,16 @@ def add_catalog(self, url: str, name: str | None = None) -> None:
data: dict[str, Any] = {"catalogs": []}
if config_path.exists():
try:
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc:
raise StepValidationError(
f"Catalog config file is unreadable or malformed: {exc}"
) from exc
# Do NOT coerce with ``or {}`` here: that also turns a FALSY
# non-mapping (top-level ``[]``, ``false``, ``0``, ``''``) into
# ``{}`` and silently swallows it.
if raw is None:
raw = {"catalogs": []}
if not isinstance(raw, dict):
raise StepValidationError(
"Catalog config file is corrupted (expected a mapping)."
Expand Down Expand Up @@ -1463,12 +1473,17 @@ def remove_catalog(self, index: int) -> str:
raise StepValidationError("No step catalog config file found.")

try:
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc:
raise StepValidationError(
f"Catalog config file is unreadable or malformed: {exc}"
) from exc
if not isinstance(data, dict):
# Do NOT coerce with ``or {}`` here: that also turns a FALSY non-mapping
# (top-level ``[]``, ``false``, ``0``, ``''``) into ``{}`` and silently
# swallows it.
if data is None:
data = {}
elif not isinstance(data, dict):
raise StepValidationError(
"Catalog config file is corrupted (expected a mapping)."
)
Expand Down
43 changes: 43 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -8453,6 +8453,20 @@ def test_remove_catalog_malformed_yaml_raises(self, project_dir):
with pytest.raises(WorkflowValidationError, match="unreadable or malformed"):
catalog.remove_catalog(0)

@pytest.mark.parametrize("body", ["[]\n", "false\n", "0\n", "''\n"])
def test_remove_catalog_rejects_falsy_non_mapping_config(self, project_dir, body):
"""A FALSY non-mapping top-level config ([], false, 0, '') must raise
'corrupted (expected a mapping)', not be silently coerced to {} by
``or {}`` and then fail as a misleading 'out of range' error."""
from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError

config_path = project_dir / ".specify" / "workflow-catalogs.yml"
config_path.write_text(body, encoding="utf-8")

catalog = WorkflowCatalog(project_dir)
with pytest.raises(WorkflowValidationError, match="expected a mapping"):
catalog.remove_catalog(0)

def test_add_catalog_wraps_write_oserror(self, project_dir, monkeypatch):
"""An OSError on write must be wrapped as WorkflowValidationError."""
from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError
Expand Down Expand Up @@ -9099,6 +9113,21 @@ def test_add_catalog_duplicate_rejected(self, project_dir):
with pytest.raises(StepValidationError, match="already configured"):
catalog.add_catalog("https://example.com/steps.json")

@pytest.mark.parametrize("body", ["[]\n", "false\n", "0\n", "''\n"])
def test_add_catalog_rejects_falsy_non_mapping_config(self, project_dir, body):
"""A FALSY non-mapping top-level config ([], false, 0, '') must raise
'corrupted (expected a mapping)', not be silently coerced to {} by
``or {}`` — matching the empty-document case above, which correctly
treats only a real absence of a document (None) as empty."""
from specify_cli.workflows.catalog import StepCatalog, StepValidationError

config_path = project_dir / ".specify" / "step-catalogs.yml"
config_path.write_text(body, encoding="utf-8")

catalog = StepCatalog(project_dir)
with pytest.raises(StepValidationError, match="expected a mapping"):
catalog.add_catalog("https://example.com/steps.json")

def test_remove_catalog(self, project_dir):
from specify_cli.workflows.catalog import StepCatalog

Expand All @@ -9122,6 +9151,20 @@ def test_remove_catalog_invalid_index(self, project_dir):
with pytest.raises(StepValidationError, match="out of range"):
catalog.remove_catalog(5)

@pytest.mark.parametrize("body", ["[]\n", "false\n", "0\n", "''\n"])
def test_remove_catalog_rejects_falsy_non_mapping_config(self, project_dir, body):
"""A FALSY non-mapping top-level config ([], false, 0, '') must raise
'corrupted (expected a mapping)', not be silently coerced to {} by
``or {}`` and then fail as a misleading 'out of range' error."""
from specify_cli.workflows.catalog import StepCatalog, StepValidationError

config_path = project_dir / ".specify" / "step-catalogs.yml"
config_path.write_text(body, encoding="utf-8")

catalog = StepCatalog(project_dir)
with pytest.raises(StepValidationError, match="expected a mapping"):
catalog.remove_catalog(0)

def test_remove_catalog_no_config(self, project_dir):
from specify_cli.workflows.catalog import StepCatalog, StepValidationError

Expand Down