From 52ab7ffdf05060460c4fda538ece4c02a55403d4 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:32:39 +0200 Subject: [PATCH] fix(workflows): escape rich markup in id-mismatch errors and validate --from source early The two id-mismatch error paths interpolated repr() into Rich markup, so a stray bracket in a user typo could be parsed as markup. Route both through rich.markup.escape. `workflow add --from ` also validated the source only after downloading. Validate it up front so a URL/path/typo fails without a network fetch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/workflows/_commands.py | 12 ++++++++---- tests/test_workflows.py | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/workflows/_commands.py b/src/specify_cli/workflows/_commands.py index 975488bfb..8e245efbe 100644 --- a/src/specify_cli/workflows/_commands.py +++ b/src/specify_cli/workflows/_commands.py @@ -606,6 +606,10 @@ def workflow_add( project_root = _require_specify_project() registry = WorkflowRegistry(project_root) workflows_dir = project_root / ".specify" / "workflows" + # With --from, source names the expected workflow ID: validate it up + # front so a URL/path/typo fails without a network fetch. + if from_url is not None and not dev: + _validate_workflow_id_or_exit(source) # Reject a symlinked .specify / .specify/workflows before any write so an # install can't escape the project root (covers the local, URL, and # catalog branches below — all write beneath workflows_dir). @@ -643,8 +647,8 @@ def workflow_add( if expected_id is not None and definition.id != expected_id: console.print( - f"[red]Error:[/red] Workflow ID in YAML ({definition.id!r}) " - f"does not match the requested workflow ID ({expected_id!r})." + f"[red]Error:[/red] Workflow ID in YAML ({_escape_markup(repr(definition.id))}) " + f"does not match the requested workflow ID ({_escape_markup(repr(expected_id))})." ) raise typer.Exit(1) @@ -902,8 +906,8 @@ def _install_workflow_from_catalog( import shutil shutil.rmtree(workflow_dir, ignore_errors=True) console.print( - f"[red]Error:[/red] Workflow ID in YAML ({definition.id!r}) " - f"does not match catalog key ({workflow_id!r}). " + f"[red]Error:[/red] Workflow ID in YAML ({_escape_markup(repr(definition.id))}) " + f"does not match catalog key ({_escape_markup(repr(workflow_id))}). " f"The catalog entry may be misconfigured." ) raise typer.Exit(1) diff --git a/tests/test_workflows.py b/tests/test_workflows.py index e1613563c..7304b7c24 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -7332,6 +7332,30 @@ steps: assert "does not match" in result.output assert not WorkflowRegistry(project_dir).is_installed("align-wf") + def test_add_from_rejects_invalid_source_id_without_fetch(self, project_dir, monkeypatch): + """--from with a non-workflow-id source (URL, path, uppercase) fails before any network fetch.""" + from unittest.mock import patch + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + calls: list[str] = [] + + def _fake_open(url, timeout=None, extra_headers=None): + calls.append(url) + raise AssertionError(f"network fetch attempted: {url}") + + runner = CliRunner() + with patch("specify_cli.authentication.http.open_url", side_effect=_fake_open): + for bad_source in ("https://x/y.yml", "./local.yml", "BadCase"): + result = runner.invoke( + app, + ["workflow", "add", bad_source, "--from", "https://example.com/workflow.yml"], + ) + assert result.exit_code != 0 + assert "Invalid workflow ID" in result.output + assert calls == [] + # -- search --author ----------------------------------------------- def test_search_author_filters(self, project_dir, monkeypatch):