@@ -5303,6 +5303,95 @@ def test_remove_rejects_symlinked_steps_base_dir(self, project_dir, monkeypatch)
53035303 assert "Refusing to use symlinked step directory" in result .output
53045304
53055305
5306+ class TestWorkflowRemoveGuard :
5307+ def test_remove_rejects_traversal_registry_key (self , project_dir , monkeypatch ):
5308+ """A corrupted registry key must not let remove delete outside workflows/."""
5309+ from typer .testing import CliRunner
5310+ from specify_cli import app
5311+ from specify_cli .workflows .catalog import WorkflowRegistry
5312+
5313+ registry = WorkflowRegistry (project_dir )
5314+ registry .add ("../outside" , {"name" : "Bad" })
5315+ outside = project_dir / ".specify" / "outside"
5316+ outside .mkdir ()
5317+ sentinel = outside / "keep.txt"
5318+ sentinel .write_text ("keep" , encoding = "utf-8" )
5319+
5320+ monkeypatch .chdir (project_dir )
5321+ result = CliRunner ().invoke (app , ["workflow" , "remove" , "../outside" ])
5322+
5323+ assert result .exit_code != 0
5324+ assert "Invalid workflow ID" in result .output
5325+ assert sentinel .read_text (encoding = "utf-8" ) == "keep"
5326+
5327+ @pytest .mark .parametrize ("workflow_id" , ["runs" , "steps" ])
5328+ def test_remove_rejects_reserved_storage_ids (
5329+ self , project_dir , monkeypatch , workflow_id
5330+ ):
5331+ """Reserved workflow storage directories must never be removable workflows."""
5332+ from typer .testing import CliRunner
5333+ from specify_cli import app
5334+ from specify_cli .workflows .catalog import WorkflowRegistry
5335+
5336+ registry = WorkflowRegistry (project_dir )
5337+ registry .add (workflow_id , {"name" : "Bad" })
5338+ reserved_dir = project_dir / ".specify" / "workflows" / workflow_id
5339+ reserved_dir .mkdir (exist_ok = True )
5340+ sentinel = reserved_dir / "keep.txt"
5341+ sentinel .write_text ("keep" , encoding = "utf-8" )
5342+
5343+ monkeypatch .chdir (project_dir )
5344+ result = CliRunner ().invoke (app , ["workflow" , "remove" , workflow_id ])
5345+
5346+ assert result .exit_code != 0
5347+ assert "Invalid workflow ID" in result .output
5348+ assert sentinel .read_text (encoding = "utf-8" ) == "keep"
5349+
5350+ @pytest .mark .skipif (not hasattr (os , "symlink" ), reason = "symlinks are unavailable" )
5351+ def test_remove_refuses_symlinked_workflow_dir (self , project_dir , monkeypatch ):
5352+ """A symlinked workflow directory must not let remove delete its target."""
5353+ from typer .testing import CliRunner
5354+ from specify_cli import app
5355+ from specify_cli .workflows .catalog import WorkflowRegistry
5356+
5357+ registry = WorkflowRegistry (project_dir )
5358+ registry .add ("test-wf" , {"name" : "Test" })
5359+ outside = project_dir / "outside-workflow-remove-target"
5360+ outside .mkdir (exist_ok = True )
5361+ sentinel = outside / "keep.txt"
5362+ sentinel .write_text ("keep" , encoding = "utf-8" )
5363+ (project_dir / ".specify" / "workflows" / "test-wf" ).symlink_to (
5364+ outside , target_is_directory = True
5365+ )
5366+
5367+ monkeypatch .chdir (project_dir )
5368+ result = CliRunner ().invoke (app , ["workflow" , "remove" , "test-wf" ])
5369+
5370+ assert result .exit_code != 0
5371+ assert "symlinked .specify/workflows/test-wf" in result .output
5372+ assert sentinel .read_text (encoding = "utf-8" ) == "keep"
5373+ assert WorkflowRegistry (project_dir ).is_installed ("test-wf" )
5374+
5375+ def test_remove_refuses_non_directory_workflow_path (self , project_dir , monkeypatch ):
5376+ """A file at the workflow path must fail cleanly instead of crashing."""
5377+ from typer .testing import CliRunner
5378+ from specify_cli import app
5379+ from specify_cli .workflows .catalog import WorkflowRegistry
5380+
5381+ registry = WorkflowRegistry (project_dir )
5382+ registry .add ("test-wf" , {"name" : "Test" })
5383+ workflow_path = project_dir / ".specify" / "workflows" / "test-wf"
5384+ workflow_path .write_text ("not a directory" , encoding = "utf-8" )
5385+
5386+ monkeypatch .chdir (project_dir )
5387+ result = CliRunner ().invoke (app , ["workflow" , "remove" , "test-wf" ])
5388+
5389+ assert result .exit_code != 0
5390+ assert "exists but is not a directory" in result .output
5391+ assert workflow_path .read_text (encoding = "utf-8" ) == "not a directory"
5392+ assert WorkflowRegistry (project_dir ).is_installed ("test-wf" )
5393+
5394+
53065395class TestWorkflowAddSymlinkGuard :
53075396 @pytest .mark .skipif (not hasattr (os , "symlink" ), reason = "symlinks are unavailable" )
53085397 def test_add_refuses_symlinked_specify (self , temp_dir , monkeypatch ):
@@ -5439,6 +5528,34 @@ def test_safe_workflow_id_dir_escapes_markup_in_invalid_id(self, temp_dir, capsy
54395528 # Literal bracketed text survives; Rich did not consume it as a tag.
54405529 assert "[red]evil[/red]" in out
54415530
5531+ @pytest .mark .parametrize (
5532+ "workflow_id" ,
5533+ [
5534+ "runs" ,
5535+ "steps" ,
5536+ "nested/workflow" ,
5537+ "nested\\ workflow" ,
5538+ "bad id" ,
5539+ " bad-id" ,
5540+ "bad-id " ,
5541+ ],
5542+ )
5543+ def test_safe_workflow_id_dir_rejects_reserved_or_non_segment_ids (
5544+ self , temp_dir , workflow_id , capsys
5545+ ):
5546+ """Install IDs must not collide with workflow internals or create nested paths."""
5547+ import typer
5548+ from specify_cli .workflows ._commands import _safe_workflow_id_dir
5549+
5550+ workflows_dir = temp_dir / ".specify" / "workflows"
5551+ workflows_dir .mkdir (parents = True )
5552+
5553+ with pytest .raises (typer .Exit ):
5554+ _safe_workflow_id_dir (workflows_dir , workflow_id )
5555+
5556+ assert "Invalid workflow ID" in capsys .readouterr ().out
5557+ assert not (workflows_dir / workflow_id ).exists ()
5558+
54425559 @pytest .mark .skipif (not hasattr (os , "symlink" ), reason = "symlinks are unavailable" )
54435560 def test_list_refuses_symlinked_runs_dir (self , temp_dir , monkeypatch ):
54445561 """workflow commands using the project shim must refuse symlinked run storage."""
0 commit comments