This is an automated email from the ASF dual-hosted git repository.

shahar1 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 6920d2c25f4 Escape Rich markup in airflowctl variable and pool 
commands (#76419)
6920d2c25f4 is described below

commit 6920d2c25f47d4e813710e2882c2d0941eb465fc
Author: Vardhman Gupta <[email protected]>
AuthorDate: Wed Nov 4 23:39:54 2026 +0530

    Escape Rich markup in airflowctl variable and pool commands (#76419)
    
    User-supplied inputs such as file paths, imported variable keys, and server
    error responses containing square brackets or Rich markup tags can cause
    unhandled rich.errors.MarkupError crashes when printed via rich.print or
    Console().print. Escaping these strings ensures special characters are
    treated as literal text rather than formatting tags.
---
 .../src/airflowctl/ctl/commands/pool_command.py    |  3 +-
 .../airflowctl/ctl/commands/variable_command.py    |  9 ++---
 .../airflow_ctl/ctl/commands/test_pool_command.py  | 33 ++++++++++++++++
 .../ctl/commands/test_variable_command.py          | 45 ++++++++++++++++++++++
 4 files changed, 83 insertions(+), 7 deletions(-)

diff --git a/airflow-ctl/src/airflowctl/ctl/commands/pool_command.py 
b/airflow-ctl/src/airflowctl/ctl/commands/pool_command.py
index 588656a7bf8..f4de3ab8c6a 100644
--- a/airflow-ctl/src/airflowctl/ctl/commands/pool_command.py
+++ b/airflow-ctl/src/airflowctl/ctl/commands/pool_command.py
@@ -24,6 +24,7 @@ from pathlib import Path
 
 import rich
 from pydantic import ValidationError
+from rich.markup import escape
 
 from airflowctl.api.client import NEW_API_CLIENT, Client, ClientKind, 
provide_api_client
 from airflowctl.api.datamodels.generated import (
@@ -79,7 +80,7 @@ def export(args, api_client: Client = NEW_API_CLIENT) -> None:
             file_path = Path(args.file)
             with open(file_path, "w") as f:
                 json.dump(pools_list, f, indent=4, sort_keys=True)
-            rich.print(f"Exported {pools_response.total_entries} pool(s) to 
{args.file}")
+            rich.print(f"Exported {pools_response.total_entries} pool(s) to 
{escape(str(args.file))}")
         else:
             AirflowConsole().print_as(data=pools_list, output=args.output)
     except Exception as e:
diff --git a/airflow-ctl/src/airflowctl/ctl/commands/variable_command.py 
b/airflow-ctl/src/airflowctl/ctl/commands/variable_command.py
index 750b94eb77f..06d5913c932 100644
--- a/airflow-ctl/src/airflowctl/ctl/commands/variable_command.py
+++ b/airflow-ctl/src/airflowctl/ctl/commands/variable_command.py
@@ -36,15 +36,12 @@ from airflowctl.ctl.utils.validation import 
split_validation_errors
 
 
 def _print_file_error(message: str, file_path: str) -> None:
-    Console().print(f"[red]{message}: {file_path}", soft_wrap=True)
+    Console().print(f"[red]{message}: {escape(file_path)}[/red]", 
soft_wrap=True)
 
 
 @provide_api_client(kind=ClientKind.CLI)
 def import_(args, api_client=NEW_API_CLIENT) -> list[str]:
     """Import variables from a given file."""
-    success_message = "[green]Import successful! success: {success}[/green]"
-    errors_message = "[red]Import failed! errors: {errors}[/red]"
-
     if not os.path.exists(args.file):
         _print_file_error("Missing variable file", args.file)
         sys.exit(1)
@@ -87,8 +84,8 @@ def import_(args, api_client=NEW_API_CLIENT) -> list[str]:
     )
     result = api_client.variables.bulk(variables=bulk_body)
     if result.create.errors:
-        rich.print(errors_message.format(errors=result.create.errors))
+        rich.print(f"[red]Import failed! errors: 
{escape(str(result.create.errors))}[/red]")
         sys.exit(1)
 
-    rich.print(success_message.format(success=result.create.success))
+    rich.print(f"[green]Import successful! success: 
{escape(str(result.create.success))}[/green]")
     return result.create.success
diff --git a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_pool_command.py 
b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_pool_command.py
index f66350067a1..995afc078dd 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_pool_command.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_pool_command.py
@@ -312,3 +312,36 @@ class TestPoolExportCommand:
 
         with pytest.raises(SystemExit, match="Failed to export pools: API 
Error"):
             pool_command.export(mock.MagicMock(file=export_file, 
output="json"))
+
+    def test_export_json_file_path_containing_markup(self, mock_client, 
tmp_path, capsys):
+        """Test that exporting to a file path containing markup tags does not 
crash."""
+        markup_dir = tmp_path / "dir["
+        markup_dir.mkdir()
+        export_file = markup_dir / "test].json"
+        mock_pools = mock.MagicMock()
+        mock_pools.pools = [
+            type(
+                "Pool",
+                (),
+                {
+                    "name": "p",
+                    "slots": 1,
+                    "description": "",
+                    "include_deferred": False,
+                    "occupied_slots": 0,
+                    "running_slots": 0,
+                    "queued_slots": 0,
+                    "scheduled_slots": 0,
+                    "open_slots": 1,
+                    "deferred_slots": 0,
+                    "team_name": None,
+                },
+            )()
+        ]
+        mock_pools.total_entries = 1
+        mock_client.pools.list.return_value = mock_pools
+
+        pool_command.export(mock.MagicMock(file=export_file, output="json"))
+
+        out = capsys.readouterr().out.replace("\n", "")
+        assert str(export_file) in out
diff --git 
a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_variable_command.py 
b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_variable_command.py
index 8eac046cdb4..32853eeca1b 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_variable_command.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_variable_command.py
@@ -193,3 +193,48 @@ class TestCliVariableCommands:
                 self.parser.parse_args(["variables", "import", 
expected_json_path.as_posix()]),
                 api_client=api_client,
             )
+
+    def test_print_file_error_containing_markup(self, capsys):
+        variable_command._print_file_error("Missing variable file", 
"/tmp/[/x]/vars.json")
+        output = capsys.readouterr().out
+        assert "/tmp/[/x]/vars.json" in output
+
+    def test_import_success_prints_variable_keys_containing_markup(self, 
tmp_path, monkeypatch, capsys):
+        bulk_response = BulkResponse(
+            create=BulkActionResponse(success=["key_[/x]"], errors=[]), 
update=None, delete=None
+        )
+        api_client = SimpleNamespace(variables=SimpleNamespace(bulk=lambda 
variables: bulk_response))
+        monkeypatch.chdir(tmp_path)
+        expected_json_path = tmp_path / self.export_file_name
+        expected_json_path.write_text(json.dumps({"key_[/x]": "value"}))
+
+        response = variable_command.import_(
+            self.parser.parse_args(["variables", "import", 
expected_json_path.as_posix()]),
+            api_client=api_client,
+        )
+        assert response == ["key_[/x]"]
+        output = capsys.readouterr().out
+        assert "key_[/x]" in output
+
+    def test_import_error_containing_markup(self, tmp_path, monkeypatch, 
capsys):
+        bulk_response = BulkResponse(
+            create=BulkActionResponse(
+                success=[],
+                errors=[{"error": "Failed for [/x] key", "status_code": 400}],
+            ),
+            update=None,
+            delete=None,
+        )
+        api_client = SimpleNamespace(variables=SimpleNamespace(bulk=lambda 
variables: bulk_response))
+        monkeypatch.chdir(tmp_path)
+        expected_json_path = tmp_path / self.export_file_name
+        expected_json_path.write_text(json.dumps({self.key: self.value}))
+
+        with pytest.raises(SystemExit) as exit_info:
+            variable_command.import_(
+                self.parser.parse_args(["variables", "import", 
expected_json_path.as_posix()]),
+                api_client=api_client,
+            )
+        assert exit_info.value.code == 1
+        output = capsys.readouterr().out
+        assert "[/x]" in output

Reply via email to