Skip to content

Commit cf0abe2

Browse files
jawwad-aliclaude
andauthored
fix(bundler): reject a top-level non-mapping bundle-catalogs.yml in _merge_config (#3659)
* fix(bundler): reject a top-level non-mapping bundle-catalogs.yml in _merge_config _merge_config silently ignored a top-level non-mapping document (a YAML list or scalar) — `data.get("catalogs") if isinstance(data, dict) else None` made it fall through to the built-in default stack — while the sibling reader of the SAME file (commands_impl/catalog_config._read) raises "expected a mapping at the top level". #3623 already made the inner non-list `catalogs` value agree between the two readers; this closes the remaining top-level-shape gap so both readers reject the same malformed documents. An empty file (load_yaml coerces to {}), absent `catalogs`, and `catalogs: []` all remain no-ops. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bundler): reject FALSY non-mapping catalog configs (parse raw, not load_yaml) Address review (Copilot on #3659): the top-level guard used the shared load_yaml, whose `yaml.safe_load(...) or {}` coerces a FALSY top-level document ([], false, 0, '') to {} BEFORE the isinstance check — so those malformed configs silently fell back to the built-in defaults instead of raising. Only truthy non-mappings ([a,b], 42) were caught. Parse the raw document in both readers of bundle-catalogs.yml (models/catalog._merge_config AND commands_impl/catalog_config._read): an empty document (None) stays a no-op, but every non-mapping top level — falsy or truthy — now raises "expected a mapping at the top level". This keeps the two readers genuinely consistent. Tests cover the falsy cases for both. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bundler): correct load_yaml so only empty documents become {} (not falsy non-mappings) Address review (Copilot re-review of #3659): the previous fix duplicated YAML parsing + exception wrapping inline in two readers, bypassing the centralized yamlio helper. Instead, correct the root cause in load_yaml. load_yaml did `yaml.safe_load(...) or {}`, which coerced ANY falsy result (None empty-doc, but also [], false, 0, '') to {} — contradicting its own docstring ("{} for an empty document") and hiding malformed non-mapping configs from callers' shape guards. Change to `{} if data is None else data` so only an empty document becomes {}; a non-mapping top level is returned as-parsed. Revert the inline raw-parse in models/catalog._merge_config and commands_impl/catalog_config._read back to the centralized load_yaml; their existing `isinstance(data, dict)` guards now correctly reject falsy non-mappings too. All three load_yaml callers (these two + manifest.from_dict) already guard the top-level shape, so none regresses. Falsy-case tests for both readers retained. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bundler): distinguish an empty YAML document from an explicit null in load_yaml Address review (Copilot on #3659): yaml.safe_load returns None for BOTH an empty document AND an explicit null scalar (`null`/`~`), so mapping None to {} still let a top-level null bundle-catalogs.yml fall back to defaults instead of being rejected by the mapping guard. Use yaml.compose (which yields a node only for a non-empty document) to tell the two apart: a truly empty document becomes {}, while an explicit null is returned as None so the callers' isinstance(dict) guard rejects it like any other non-mapping. Drop the now-incorrect `if data is None: return []` short-circuit in catalog_config._read so an explicit null reaches that guard. Tests cover null/~ for both readers plus empty/comment-only no-op. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9b3546f commit cf0abe2

5 files changed

Lines changed: 90 additions & 11 deletions

File tree

src/specify_cli/bundler/commands_impl/catalog_config.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,12 @@ def _read(project_root: Path) -> list[dict]:
4040
path = ensure_within(project_root, _config_path(project_root))
4141
if not path.exists():
4242
return []
43+
# ``load_yaml`` returns ``{}`` only for an empty document and the raw parse
44+
# otherwise, so a non-mapping top level — a falsy ``[]``/``false``/``0``/``''``
45+
# or an explicit null (``load_yaml`` -> ``None``) — is caught by the isinstance
46+
# guard below and raised like a truthy one, staying consistent with the other
47+
# reader of this file (models/catalog._merge_config).
4348
data = load_yaml(path)
44-
if data is None:
45-
return []
4649
if not isinstance(data, dict):
4750
raise BundlerError(
4851
f"Malformed catalog config at {path}: expected a mapping at the top "

src/specify_cli/bundler/lib/yamlio.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,35 @@ def ensure_within(root: Path, candidate: Path) -> Path:
3939

4040

4141
def load_yaml(path: Path) -> Any:
42-
"""Parse a YAML file, returning ``{}`` for an empty document."""
42+
"""Parse a YAML file, returning ``{}`` only for an *empty* document.
43+
44+
A non-empty document is returned exactly as parsed — including a
45+
non-mapping such as ``[]``, ``false``, ``0``, ``''``, or an explicit null
46+
(``null``/``~``) — so callers can validate the top-level shape (e.g. reject
47+
a non-mapping config) instead of having it silently coerced to an empty
48+
mapping.
49+
50+
``yaml.safe_load`` returns ``None`` for *both* an empty document and an
51+
explicit null scalar, so ``yaml.compose`` (which yields no node only for a
52+
truly empty document) is used to tell them apart: an empty document becomes
53+
``{}`` while an explicit ``null``/``~`` is returned as ``None`` for the
54+
caller to reject.
55+
"""
4356
path = Path(path)
4457
if not path.exists():
4558
raise BundlerError(f"File not found: {path}")
4659
try:
47-
with path.open("r", encoding="utf-8") as handle:
48-
return yaml.safe_load(handle) or {}
49-
except yaml.YAMLError as exc:
50-
raise BundlerError(f"Invalid YAML in {path}: {exc}") from exc
60+
text = path.read_text(encoding="utf-8")
5161
except OSError as exc:
5262
raise BundlerError(f"Could not read {path}: {exc}") from exc
63+
try:
64+
has_node = yaml.compose(text) is not None
65+
data = yaml.safe_load(text)
66+
except yaml.YAMLError as exc:
67+
raise BundlerError(f"Invalid YAML in {path}: {exc}") from exc
68+
if data is None and not has_node:
69+
return {}
70+
return data
5371

5472

5573
def dump_yaml(path: Path, data: Any, *, within: Path | None = None) -> Path:

src/specify_cli/bundler/models/catalog.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,8 +256,18 @@ def load_source_stack(project_root: Path, user_config_dir: Path | None = None) -
256256
def _merge_config(by_id: dict[str, CatalogSource], config_path: Path, scope: Scope) -> None:
257257
if not config_path.exists():
258258
return
259+
# ``load_yaml`` returns ``{}`` only for an empty document and the raw parse
260+
# otherwise, so a non-mapping top level (a YAML list or scalar, including
261+
# the falsy ``[]``/``false``/``0``/``''``) is caught here and raised —
262+
# matching the sibling reader commands_impl/catalog_config._read. #3623
263+
# aligned the inner non-list ``catalogs`` value between the two readers.
259264
data = load_yaml(config_path)
260-
catalogs = data.get("catalogs") if isinstance(data, dict) else None
265+
if not isinstance(data, dict):
266+
raise BundlerError(
267+
f"Malformed catalog config at {config_path}: expected a mapping at "
268+
f"the top level, got {type(data).__name__}."
269+
)
270+
catalogs = data.get("catalogs")
261271
if catalogs is None:
262272
return
263273
if not isinstance(catalogs, list):

tests/contract/test_catalog_schema.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,44 @@ def test_falsy_non_list_catalogs_still_raises(tmp_path: Path, value: str):
6868
load_source_stack(tmp_path)
6969

7070

71-
@pytest.mark.parametrize("body", ["catalogs:\n", "catalogs: []\n"])
71+
@pytest.mark.parametrize(
72+
"body",
73+
[
74+
"- a\n- b\n", # truthy list
75+
"42\n", # truthy scalar
76+
"[]\n", # falsy list
77+
"false\n", # falsy bool
78+
"0\n", # falsy int
79+
"''\n", # falsy empty string
80+
"null\n", # explicit null scalar (safe_load -> None, but a real node)
81+
"~\n", # explicit null scalar (alt spelling)
82+
],
83+
)
84+
def test_toplevel_non_mapping_raises(tmp_path: Path, body: str):
85+
"""A top-level non-mapping bundle-catalogs.yml (list/scalar/null) must raise,
86+
matching the sibling reader (catalog_config._read) — not silently fall back
87+
to the built-in default stack. This includes FALSY non-mappings ([], false,
88+
0, '') and an explicit null (null/~); the shared load_yaml would coerce those
89+
to {} and hide them, so it distinguishes them from a truly empty document."""
90+
make_project(tmp_path)
91+
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(body, encoding="utf-8")
92+
with pytest.raises(BundlerError, match="expected a mapping at the top level"):
93+
load_source_stack(tmp_path)
94+
95+
96+
@pytest.mark.parametrize(
97+
"body",
98+
[
99+
"catalogs:\n", # present key, null value
100+
"catalogs: []\n", # present key, empty list
101+
"", # truly empty document
102+
"# only a comment\n", # comment-only == empty document
103+
],
104+
)
72105
def test_absent_or_empty_catalogs_is_noop(tmp_path: Path, body: str):
73-
"""An absent (``None``) or empty-list ``catalogs:`` is valid: it contributes
74-
no project sources and falls back to the built-in default stack."""
106+
"""An empty document, comment-only file, or absent/empty-list ``catalogs:``
107+
is valid: it contributes no project sources and falls back to the built-in
108+
default stack (must not be confused with an explicit top-level null)."""
75109
make_project(tmp_path)
76110
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(body, encoding="utf-8")
77111
# Does not raise; still yields the built-in defaults.

tests/unit/test_bundler_catalog_config.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,20 @@ def test_read_rejects_non_mapping_top_level(tmp_path: Path):
154154
cc._read(project)
155155

156156

157+
@pytest.mark.parametrize("body", ["[]\n", "false\n", "0\n", "''\n", "null\n", "~\n"])
158+
def test_read_rejects_falsy_non_mapping_top_level(tmp_path: Path, body: str):
159+
# A FALSY non-mapping top level ([], false, 0, '') OR an explicit null
160+
# (null/~) must raise like a truthy one. safe_load coerces these to
161+
# None/{}, so load_yaml distinguishes them from a truly empty document —
162+
# staying consistent with models/catalog._merge_config.
163+
project = tmp_path / "proj"
164+
(project / ".specify").mkdir(parents=True)
165+
cc._config_path(project).write_text(body, encoding="utf-8")
166+
167+
with pytest.raises(BundlerError, match="expected a mapping at the top level"):
168+
cc._read(project)
169+
170+
157171
def test_read_rejects_unknown_schema_version(tmp_path: Path):
158172
project = tmp_path / "proj"
159173
(project / ".specify").mkdir(parents=True)

0 commit comments

Comments
 (0)