Validate the per-id install directory before any write and return it. Installs write to ``workflows_dir / / workflow.yml``. The `` `` segment comes from a workflow YAML or catalog key, so it must be checked before ``mkdir``/copy/download follows a symlink outside the project roo
(workflows_dir: Path, workflow_id: str)
| 109 | |
| 110 | |
| 111 | def _safe_workflow_id_dir(workflows_dir: Path, workflow_id: str) -> Path: |
| 112 | """Validate the per-id install directory before any write and return it. |
| 113 | |
| 114 | Installs write to ``workflows_dir / <id> / workflow.yml``. The ``<id>`` |
| 115 | segment comes from a workflow YAML or catalog key, so it must be checked |
| 116 | before ``mkdir``/copy/download follows a symlink outside the project root. |
| 117 | Rejects, with a clean ``typer.Exit``: |
| 118 | |
| 119 | - an ``<id>`` that is a symlink or an existing non-directory |
| 120 | (the latter would otherwise make ``mkdir`` raise); |
| 121 | - an ``<id>`` that is not a single workflow-id path segment or collides |
| 122 | with internal workflow storage directories; |
| 123 | - an ``<id>`` that escapes ``workflows_dir`` (path traversal); |
| 124 | - an ``<id>/workflow.yml`` leaf that is a symlink or an existing |
| 125 | non-file (either would otherwise make the later write/copy raise). |
| 126 | |
| 127 | The symlink/non-directory check runs *before* ``resolve()`` so a symlinked |
| 128 | ``<id>`` reports as a symlink rather than misleadingly as path traversal. |
| 129 | ``workflow_id`` is markup-escaped in output to avoid Rich markup injection. |
| 130 | """ |
| 131 | safe_id = _escape_markup(workflow_id) |
| 132 | _validate_workflow_id_or_exit(workflow_id) |
| 133 | |
| 134 | dest_dir = workflows_dir / workflow_id |
| 135 | _reject_unsafe_dir(dest_dir, f".specify/workflows/{safe_id}") |
| 136 | try: |
| 137 | dest_dir.resolve().relative_to(workflows_dir.resolve()) |
| 138 | except ValueError: |
| 139 | # Escape the repr (not the raw id) so backslashes added by repr cannot |
| 140 | # re-expose markup brackets to Rich. |
| 141 | console.print( |
| 142 | f"[red]Error:[/red] Invalid workflow ID: {_escape_markup(repr(workflow_id))}" |
| 143 | ) |
| 144 | raise typer.Exit(1) |
| 145 | workflow_yml = dest_dir / "workflow.yml" |
| 146 | if workflow_yml.is_symlink(): |
| 147 | console.print( |
| 148 | "[red]Error:[/red] Refusing to write through symlinked " |
| 149 | f".specify/workflows/{safe_id}/workflow.yml" |
| 150 | ) |
| 151 | raise typer.Exit(1) |
| 152 | if workflow_yml.exists() and not workflow_yml.is_file(): |
| 153 | console.print( |
| 154 | "[red]Error:[/red] " |
| 155 | f".specify/workflows/{safe_id}/workflow.yml exists but is not a file" |
| 156 | ) |
| 157 | raise typer.Exit(1) |
| 158 | return dest_dir |
| 159 | |
| 160 | |
| 161 | # Root helper re-fetched at call time so test monkeypatching of |