Skip to content

Commit 690e61d

Browse files
mnriemCopilot
andcommitted
fix: reject invalid catalog equivalence
Reject boolean priorities during extension and preset equivalence checks and normalize stored bundle identities before duplicate matching. Add regression coverage and document these rules. Scope CLI test working-directory changes so Windows can clean up temporary projects before fixture teardown. Assisted-by: GitHub Copilot (model: gpt-6-astra, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 8819829 commit 690e61d

11 files changed

Lines changed: 190 additions & 36 deletions

File tree

‎docs/reference/bundles.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ Registers a project-scoped catalog source and persists it.
154154

155155
Adding a source is idempotent (identity is the source **id or url**): re-running `catalog add` with the same id/url and identical `--policy`/`--priority` is a successful no-op (exit code 0), so it is safe to include in a re-runnable workflow. Re-adding a matching id/url with *different* settings is rejected as a conflict rather than silently overwriting the existing source — remove it first to change it.
156156

157+
Surrounding whitespace in source ids and URLs is ignored when matching identities and comparing settings. No-ops and conflicts leave the existing configuration unchanged; they do not rewrite stored values to normalize them.
158+
157159
### Remove a Catalog Source
158160

159161
```bash

‎docs/reference/extensions.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ Adding a catalog is idempotent (identity is the catalog **name**): re-running `c
144144
145145
Surrounding whitespace in catalog names and URLs is ignored when comparing entries and stripped from newly added entries. A no-op leaves the existing configuration unchanged.
146146
147+
Stored priorities may use numeric strings, but YAML booleans (`true`/`false`) are invalid and are never equivalent to integer priorities (`1`/`0`). Re-adding a matching catalog with an invalid stored priority reports a conflict.
148+
147149
### Remove a Catalog
148150
149151
```bash

‎docs/reference/presets.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ Adding a catalog is idempotent (identity is the catalog **name**): re-running `c
113113

114114
Surrounding whitespace in catalog URLs is ignored when comparing entries and stripped from newly added entries. A no-op leaves the existing configuration unchanged.
115115

116+
Stored priorities may use numeric strings, but YAML booleans (`true`/`false`) are invalid and are never equivalent to integer priorities (`1`/`0`). Re-adding a matching catalog with an invalid stored priority reports a conflict.
117+
116118
### Remove a Catalog
117119

118120
```bash

‎src/specify_cli/bundler/commands_impl/catalog_config.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,10 @@ def add_source(
193193
"install_policy": install_policy.value,
194194
}
195195
for existing in catalogs:
196-
if existing.get("id") == resolved_id or existing.get("url") == url:
196+
if (
197+
str(existing.get("id", "")).strip() == resolved_id
198+
or str(existing.get("url", "")).strip() == url
199+
):
197200
# Idempotent add (#4505): identity is the source id or url. A rerun
198201
# requesting the same settings is a successful no-op; differing
199202
# settings are a conflict rather than a silent overwrite.

‎src/specify_cli/extensions/_commands.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -320,21 +320,21 @@ def install_extension_from_url(
320320
pass
321321

322322

323-
def _normalize_catalog_priority(value: object) -> object:
323+
def _normalize_catalog_priority(value: object) -> int | None:
324324
"""Normalize a stored catalog priority the way the catalog reader does.
325325
326326
The reader (``specify_cli/catalogs.py``) accepts integer-string priorities
327327
like ``"10"`` but rejects bools. Mirror that here so an equivalent rerun
328328
whose persisted priority is a supported string representation is still a
329329
no-op rather than a false conflict (#4505). A value that cannot be
330-
normalized is returned unchanged so it simply fails to compare equal.
330+
normalized returns ``None`` so it cannot compare equal to an integer.
331331
"""
332332
if isinstance(value, bool):
333-
return value
333+
return None
334334
try:
335335
return int(value)
336336
except (TypeError, ValueError, OverflowError):
337-
return value
337+
return None
338338

339339

340340
def _normalize_catalog_install_allowed(value: object) -> bool:

‎src/specify_cli/presets/_commands.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,22 +40,22 @@
4040
preset_app.add_typer(preset_catalog_app, name="catalog")
4141

4242

43-
def _normalize_catalog_priority(value: object) -> object:
43+
def _normalize_catalog_priority(value: object) -> int | None:
4444
"""Normalize a stored catalog priority the way the preset reader does.
4545
4646
The preset reader (``specify_cli/presets/__init__.py``) accepts
4747
integer-string priorities like ``"10"`` but rejects bools. Mirror that here
4848
so an equivalent rerun whose persisted priority is a supported string
4949
representation is still a no-op rather than a false conflict (#4505). A
50-
value that cannot be normalized is returned unchanged so it simply fails to
51-
compare equal.
50+
value that cannot be normalized returns ``None`` so it cannot compare equal
51+
to an integer.
5252
"""
5353
if isinstance(value, bool):
54-
return value
54+
return None
5555
try:
5656
return int(value)
5757
except (TypeError, ValueError, OverflowError):
58-
return value
58+
return None
5959

6060

6161
def _normalize_catalog_install_allowed(value: object) -> bool:

‎tests/contract/test_bundle_cli.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,32 @@ def test_catalog_add_duplicate_is_idempotent(project: Path):
228228
assert "already" in second.output
229229

230230

231+
@pytest.mark.parametrize("priority,exit_code", [(10, 0), (20, 1)])
232+
def test_catalog_add_normalizes_stored_identity(project: Path, priority, exit_code):
233+
config_path = project / ".specify" / "bundle-catalogs.yml"
234+
config_path.write_text(yaml.safe_dump({
235+
"schema_version": "1.0",
236+
"catalogs": [{
237+
"id": " \tlocal\t ",
238+
"url": " \thttps://example.com/catalog.json\t ",
239+
"priority": 10,
240+
"install_policy": "install-allowed",
241+
}],
242+
}), encoding="utf-8")
243+
original = config_path.read_bytes()
244+
modified_at = config_path.stat().st_mtime_ns
245+
246+
result = runner.invoke(app, [
247+
"bundle", "catalog", "add", "https://example.com/catalog.json",
248+
"--id", "local", "--policy", "install-allowed", "--priority", str(priority),
249+
], catch_exceptions=False)
250+
251+
assert result.exit_code == exit_code, result.output
252+
assert ("already" if exit_code == 0 else "different settings") in result.output
253+
assert config_path.read_bytes() == original
254+
assert config_path.stat().st_mtime_ns == modified_at
255+
256+
231257
def test_catalog_add_duplicate_different_settings_conflicts(project: Path):
232258
catalog = project / "local-catalog.json"
233259
write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")})

‎tests/test_extensions.py‎

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7468,7 +7468,6 @@ def test_catalog_add_normalizes_existing_identity(
74687468
from typer.testing import CliRunner
74697469
from specify_cli import app
74707470

7471-
monkeypatch.chdir(project_dir)
74727471
config_path = project_dir / ".specify" / "extension-catalogs.yml"
74737472
config_path.write_text(yaml.safe_dump({"catalogs": [{
74747473
"name": stored_name,
@@ -7479,11 +7478,13 @@ def test_catalog_add_normalizes_existing_identity(
74797478
original = config_path.read_bytes()
74807479
modified_at = config_path.stat().st_mtime_ns
74817480

7482-
result = CliRunner().invoke(app, [
7483-
"extension", "catalog", "add",
7484-
f"{padding}https://example.com/catalog.json{padding}",
7485-
"--name", name, "--priority", str(priority),
7486-
], catch_exceptions=False)
7481+
with monkeypatch.context() as scoped:
7482+
scoped.chdir(project_dir)
7483+
result = CliRunner().invoke(app, [
7484+
"extension", "catalog", "add",
7485+
f"{padding}https://example.com/catalog.json{padding}",
7486+
"--name", name, "--priority", str(priority),
7487+
], catch_exceptions=False)
74877488

74887489
assert result.exit_code == (0 if priority == 10 else 1), result.output
74897490
assert ("nothing to do" if priority == 10 else "different settings") in result.output
@@ -7551,6 +7552,36 @@ def test_catalog_add_string_representations_are_idempotent(self, tmp_path):
75517552
assert result.exit_code == 0, result.output
75527553
assert "nothing to do" in result.output
75537554

7555+
@pytest.mark.parametrize("stored_priority,priority", [
7556+
(True, 1), (False, 0), (1, 1), (0, 0), ("1", 1), ("0", 0),
7557+
])
7558+
def test_catalog_add_priority_equivalence(self, project_dir, monkeypatch, stored_priority, priority):
7559+
from typer.testing import CliRunner
7560+
from specify_cli import app
7561+
7562+
config_path = project_dir / ".specify" / "extension-catalogs.yml"
7563+
config_path.write_text(yaml.safe_dump({"catalogs": [{
7564+
"name": "mine",
7565+
"url": "https://example.com/catalog.json",
7566+
"priority": stored_priority,
7567+
"install_allowed": False,
7568+
}]}), encoding="utf-8")
7569+
original = config_path.read_bytes()
7570+
modified_at = config_path.stat().st_mtime_ns
7571+
7572+
with monkeypatch.context() as scoped:
7573+
scoped.chdir(project_dir)
7574+
result = CliRunner().invoke(app, [
7575+
"extension", "catalog", "add", "https://example.com/catalog.json",
7576+
"--name", "mine", "--priority", str(priority),
7577+
], catch_exceptions=False)
7578+
7579+
invalid = isinstance(stored_priority, bool)
7580+
assert result.exit_code == (1 if invalid else 0), result.output
7581+
assert ("different settings" if invalid else "nothing to do") in result.output
7582+
assert config_path.read_bytes() == original
7583+
assert config_path.stat().st_mtime_ns == modified_at
7584+
75547585
def test_catalog_add_escapes_config_saved_path_markup(self, tmp_path):
75557586
"""Catalog add's saved-path label should render literally under Rich."""
75567587
from typer.testing import CliRunner

‎tests/test_presets.py‎

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3939,7 +3939,6 @@ def test_catalog_add_normalizes_existing_url(
39393939
from typer.testing import CliRunner
39403940
from specify_cli import app
39413941

3942-
monkeypatch.chdir(project_dir)
39433942
config_path = project_dir / ".specify" / "preset-catalogs.yml"
39443943
config_path.write_text(yaml.safe_dump({"catalogs": [{
39453944
"name": "mine",
@@ -3950,10 +3949,12 @@ def test_catalog_add_normalizes_existing_url(
39503949
original = config_path.read_bytes()
39513950
modified_at = config_path.stat().st_mtime_ns
39523951

3953-
result = CliRunner().invoke(app, [
3954-
"preset", "catalog", "add", f"{padding}https://example.com/c.json{padding}",
3955-
"--name", "mine", "--priority", str(priority),
3956-
], catch_exceptions=False)
3952+
with monkeypatch.context() as scoped:
3953+
scoped.chdir(project_dir)
3954+
result = CliRunner().invoke(app, [
3955+
"preset", "catalog", "add", f"{padding}https://example.com/c.json{padding}",
3956+
"--name", "mine", "--priority", str(priority),
3957+
], catch_exceptions=False)
39573958

39583959
assert result.exit_code == (0 if priority == 10 else 1), result.output
39593960
assert ("nothing to do" if priority == 10 else "different settings") in result.output
@@ -4011,6 +4012,36 @@ def test_catalog_add_string_representations_are_idempotent(self, project_dir):
40114012
assert result.exit_code == 0, result.output
40124013
assert "nothing to do" in result.output
40134014

4015+
@pytest.mark.parametrize("stored_priority,priority", [
4016+
(True, 1), (False, 0), (1, 1), (0, 0), ("1", 1), ("0", 0),
4017+
])
4018+
def test_catalog_add_priority_equivalence(self, project_dir, monkeypatch, stored_priority, priority):
4019+
from typer.testing import CliRunner
4020+
from specify_cli import app
4021+
4022+
config_path = project_dir / ".specify" / "preset-catalogs.yml"
4023+
config_path.write_text(yaml.safe_dump({"catalogs": [{
4024+
"name": "mine",
4025+
"url": "https://example.com/catalog.json",
4026+
"priority": stored_priority,
4027+
"install_allowed": False,
4028+
}]}), encoding="utf-8")
4029+
original = config_path.read_bytes()
4030+
modified_at = config_path.stat().st_mtime_ns
4031+
4032+
with monkeypatch.context() as scoped:
4033+
scoped.chdir(project_dir)
4034+
result = CliRunner().invoke(app, [
4035+
"preset", "catalog", "add", "https://example.com/catalog.json",
4036+
"--name", "mine", "--priority", str(priority),
4037+
], catch_exceptions=False)
4038+
4039+
invalid = isinstance(stored_priority, bool)
4040+
assert result.exit_code == (1 if invalid else 0), result.output
4041+
assert ("different settings" if invalid else "nothing to do") in result.output
4042+
assert config_path.read_bytes() == original
4043+
assert config_path.stat().st_mtime_ns == modified_at
4044+
40144045
def test_catalog_remove_escapes_rich_markup(self, project_dir):
40154046
"""`preset catalog remove` must not parse the name as Rich markup."""
40164047
from typer.testing import CliRunner

‎tests/test_workflows.py‎

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9764,20 +9764,23 @@ def test_add_catalog_duplicate_outcomes(
97649764
from typer.testing import CliRunner
97659765
from specify_cli import app
97669766

9767-
monkeypatch.chdir(project_dir)
97689767
url = "https://example.com/catalog.json"
97699768
name_args = ["--name", name] if name is not None else []
97709769
runner = CliRunner()
9771-
first = runner.invoke(
9772-
app, [*command, f"{padding}{url}{padding}", *name_args], catch_exceptions=False
9773-
)
9770+
with monkeypatch.context() as scoped:
9771+
scoped.chdir(project_dir)
9772+
first = runner.invoke(
9773+
app, [*command, f"{padding}{url}{padding}", *name_args], catch_exceptions=False
9774+
)
97749775
assert first.exit_code == 0, first.output
97759776
assert "source added" in first.output
97769777
config_path = project_dir / ".specify" / config_filename
97779778
original = config_path.read_bytes()
97789779
modified_at = config_path.stat().st_mtime_ns
97799780

9780-
second = runner.invoke(app, [*command, url, *name_args], catch_exceptions=False)
9781+
with monkeypatch.context() as scoped:
9782+
scoped.chdir(project_dir)
9783+
second = runner.invoke(app, [*command, url, *name_args], catch_exceptions=False)
97819784

97829785
assert second.exit_code == 0, second.output
97839786
assert "already configured" in second.output
@@ -9789,9 +9792,11 @@ def test_add_catalog_duplicate_outcomes(
97899792
assert entries[0]["url"] == url
97909793
assert entries[0]["name"] == (name or "catalog-1")
97919794

9792-
conflict = runner.invoke(
9793-
app, [*command, url, "--name", "different"], catch_exceptions=False
9794-
)
9795+
with monkeypatch.context() as scoped:
9796+
scoped.chdir(project_dir)
9797+
conflict = runner.invoke(
9798+
app, [*command, url, "--name", "different"], catch_exceptions=False
9799+
)
97959800
assert conflict.exit_code == 1, conflict.output
97969801
assert "different name" in conflict.output
97979802
assert "source added" not in conflict.output
@@ -9808,7 +9813,6 @@ def test_add_catalog_existing_url_outcomes(
98089813
from typer.testing import CliRunner
98099814
from specify_cli import app
98109815

9811-
monkeypatch.chdir(project_dir)
98129816
url = "https://example.com/catalog.json"
98139817
config_path = project_dir / ".specify" / config_filename
98149818
config_path.write_text(yaml.safe_dump({"catalogs": [{
@@ -9822,9 +9826,11 @@ def test_add_catalog_existing_url_outcomes(
98229826
modified_at = config_path.stat().st_mtime_ns
98239827
name_args = ["--name", name] if name is not None else []
98249828

9825-
result = CliRunner().invoke(
9826-
app, [*command, f"{padding}{url}{padding}", *name_args], catch_exceptions=False
9827-
)
9829+
with monkeypatch.context() as scoped:
9830+
scoped.chdir(project_dir)
9831+
result = CliRunner().invoke(
9832+
app, [*command, f"{padding}{url}{padding}", *name_args], catch_exceptions=False
9833+
)
98289834

98299835
if name == "different":
98309836
assert result.exit_code == 1, result.output

0 commit comments

Comments
 (0)