Skip to content

Commit 9bcd0b2

Browse files
authored
Address review: collision-resistant module names, extra_files support, remove orphan dir
1 parent d5b9d6e commit 9bcd0b2

3 files changed

Lines changed: 158 additions & 14 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6097,12 +6097,43 @@ def _safe_fetch(url: str) -> bytes:
60976097
)
60986098
raise typer.Exit(1)
60996099

6100-
# Write validated files to the staged temp location, then atomically rename
6101-
# into place. Both paths are under steps_base_dir (same filesystem), so
6102-
# os.rename() is atomic on POSIX and won't leave a partially-written
6103-
# directory at step_dir on failure.
6100+
# Write the two required files.
61046101
(tmp_path / "step.yml").write_bytes(step_yml_content)
61056102
(tmp_path / "__init__.py").write_bytes(init_py_content)
6103+
6104+
# Optionally download additional package files declared in the catalog entry
6105+
# (e.g. helper modules). Each entry in ``extra_files`` is a mapping of
6106+
# relative-path → URL. step.yml and __init__.py are ignored here (already
6107+
# written). Paths are validated to stay within the step package directory to
6108+
# prevent path-traversal attacks.
6109+
extra_files = info.get("extra_files") or {}
6110+
if isinstance(extra_files, dict):
6111+
for rel_path, file_url in extra_files.items():
6112+
if rel_path in ("step.yml", "__init__.py"):
6113+
continue # already written above
6114+
dest = (tmp_path / rel_path).resolve()
6115+
try:
6116+
dest.relative_to(tmp_path)
6117+
except ValueError:
6118+
console.print(
6119+
f"[red]Error:[/red] extra_files path '{rel_path}' is outside "
6120+
"the step package directory"
6121+
)
6122+
raise typer.Exit(1)
6123+
try:
6124+
file_content = _safe_fetch(file_url)
6125+
except Exception as exc:
6126+
console.print(
6127+
f"[red]Error:[/red] Failed to download extra file '{rel_path}': {exc}"
6128+
)
6129+
raise typer.Exit(1)
6130+
dest.parent.mkdir(parents=True, exist_ok=True)
6131+
dest.write_bytes(file_content)
6132+
6133+
# Atomically rename the staging directory to the final location.
6134+
# Both paths are under steps_base_dir (same filesystem), so os.rename()
6135+
# is atomic on POSIX and won't leave a partially-written directory at
6136+
# step_dir on failure.
61066137
os.rename(tmp_path, step_dir)
61076138
finally:
61086139
# Clean up if the rename hasn't moved tmp_path yet (i.e. on any failure).
@@ -6145,9 +6176,7 @@ def workflow_step_remove(
61456176
raise typer.Exit(1)
61466177

61476178
registry = StepRegistry(project_root)
6148-
if not registry.is_installed(step_id):
6149-
console.print(f"[red]Error:[/red] Step type '{step_id}' is not installed")
6150-
raise typer.Exit(1)
6179+
in_registry = registry.is_installed(step_id)
61516180

61526181
steps_base_dir = (project_root / ".specify" / "workflows" / "steps").resolve()
61536182
step_dir = (steps_base_dir / step_id).resolve()
@@ -6156,11 +6185,28 @@ def workflow_step_remove(
61566185
except ValueError:
61576186
console.print(f"[red]Error:[/red] Invalid step id '{step_id}'")
61586187
raise typer.Exit(1)
6159-
if step_dir.exists():
6188+
6189+
dir_exists = step_dir.exists()
6190+
6191+
if not in_registry and not dir_exists:
6192+
console.print(f"[red]Error:[/red] Step type '{step_id}' is not installed")
6193+
raise typer.Exit(1)
6194+
6195+
if not in_registry and dir_exists:
6196+
# The registry was likely reset due to corruption. Warn the user that the
6197+
# directory is being removed even though there is no registry entry, so
6198+
# the orphaned package can be cleaned up and a fresh install attempted.
6199+
console.print(
6200+
f"[yellow]Warning:[/yellow] '{step_id}' has no registry entry "
6201+
"(registry may have been reset). Removing the orphaned directory."
6202+
)
6203+
6204+
if dir_exists:
61606205
import shutil
61616206
shutil.rmtree(step_dir)
61626207

6163-
registry.remove(step_id)
6208+
if in_registry:
6209+
registry.remove(step_id)
61646210
console.print(f"[green]✓[/green] Step type '{step_id}' uninstalled")
61656211

61666212

src/specify_cli/workflows/__init__.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ def load_custom_steps(project_root: Path) -> list[str]:
8080
Returns a list of type_keys that were successfully loaded.
8181
Silently skips packages that fail to import or validate.
8282
"""
83+
import hashlib as _hashlib
8384
import importlib.util as _importlib_util
8485
import re as _re
8586
import sys as _sys
@@ -111,9 +112,14 @@ def load_custom_steps(project_root: Path) -> list[str]:
111112
continue
112113

113114
# Sanitize type_key so the synthetic module name is a valid identifier
114-
# (e.g. "test-custom" → "_speckit_custom_step_test_custom").
115+
# (e.g. "test-custom" → "_speckit_custom_step_test_custom_<hash>").
116+
# The 8-char SHA-256 hash of the original type_key makes the name
117+
# collision-resistant when different type_keys produce the same
118+
# sanitized form (e.g. "a-b" and "a_b" both sanitize to "a_b" but
119+
# have different hashes).
115120
safe_key = _re.sub(r"[^A-Za-z0-9_]", "_", type_key)
116-
module_name = f"_speckit_custom_step_{safe_key}"
121+
key_hash = _hashlib.sha256(type_key.encode()).hexdigest()[:8]
122+
module_name = f"_speckit_custom_step_{safe_key}_{key_hash}"
117123

118124
# Treat the step directory as a proper package so that relative
119125
# imports inside the step (e.g. ``from .helpers import …``) work.

tests/test_workflows.py

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2256,6 +2256,7 @@ def test_skip_broken_init_py(self, project_dir):
22562256

22572257
def test_module_name_sanitized_for_hyphenated_type_key(self, project_dir):
22582258
"""type_key values with hyphens produce valid Python module identifiers."""
2259+
import hashlib
22592260
import sys
22602261
from specify_cli.workflows import load_custom_steps, STEP_REGISTRY
22612262

@@ -2281,10 +2282,14 @@ def execute(self, config, context):
22812282
assert "my-hyphen-step" in loaded
22822283
assert "my-hyphen-step" in STEP_REGISTRY
22832284
# Synthetic module name must be a valid identifier (hyphens → underscores)
2284-
assert "_speckit_custom_step_my_hyphen_step" in sys.modules
2285+
# and include a collision-resistant hash suffix.
2286+
key_hash = hashlib.sha256(b"my-hyphen-step").hexdigest()[:8]
2287+
module_name = f"_speckit_custom_step_my_hyphen_step_{key_hash}"
2288+
assert module_name in sys.modules
22852289

22862290
def test_package_relative_import(self, project_dir):
22872291
"""Steps can use relative imports to access sibling modules."""
2292+
import hashlib
22882293
import sys
22892294
from specify_cli.workflows import load_custom_steps, STEP_REGISTRY
22902295

@@ -2314,7 +2319,94 @@ def execute(self, config, context):
23142319
loaded = load_custom_steps(project_dir)
23152320
assert "pkg-step" in loaded
23162321
assert "pkg-step" in STEP_REGISTRY
2317-
# Verify the relative import actually resolved
2318-
module_name = "_speckit_custom_step_pkg_step"
2322+
# Verify the relative import actually resolved; module name includes hash suffix.
2323+
key_hash = hashlib.sha256(b"pkg-step").hexdigest()[:8]
2324+
module_name = f"_speckit_custom_step_pkg_step_{key_hash}"
23192325
assert module_name in sys.modules
23202326
assert sys.modules[module_name].PkgStep.helper == "hello"
2327+
2328+
def test_module_name_collision_resistance(self, project_dir):
2329+
"""'a-b' and 'a_b' produce different module names despite the same sanitized form."""
2330+
import hashlib
2331+
2332+
# Simulate the module name generation for two type_keys that sanitize the same way
2333+
def make_module_name(type_key: str) -> str:
2334+
import re
2335+
safe_key = re.sub(r"[^A-Za-z0-9_]", "_", type_key)
2336+
key_hash = hashlib.sha256(type_key.encode()).hexdigest()[:8]
2337+
return f"_speckit_custom_step_{safe_key}_{key_hash}"
2338+
2339+
name_a = make_module_name("a-b")
2340+
name_b = make_module_name("a_b")
2341+
assert name_a != name_b, "Module names for 'a-b' and 'a_b' must differ"
2342+
2343+
2344+
# ===== CLI Step Remove Tests =====
2345+
2346+
class TestWorkflowStepRemoveCLI:
2347+
"""Test the 'specify workflow step remove' CLI command edge cases."""
2348+
2349+
def test_remove_orphaned_directory(self, project_dir, monkeypatch):
2350+
"""step remove works when directory exists but registry entry is missing.
2351+
2352+
This covers the case where the registry was reset due to corruption.
2353+
"""
2354+
from typer.testing import CliRunner
2355+
from specify_cli import app
2356+
2357+
monkeypatch.chdir(project_dir)
2358+
2359+
# Create an orphaned step directory (no registry entry)
2360+
step_dir = project_dir / ".specify" / "workflows" / "steps" / "orphan-step"
2361+
step_dir.mkdir(parents=True)
2362+
(step_dir / "step.yml").write_text(
2363+
"step:\n type_key: orphan-step\n", encoding="utf-8"
2364+
)
2365+
(step_dir / "__init__.py").write_text("", encoding="utf-8")
2366+
2367+
runner = CliRunner()
2368+
result = runner.invoke(app, ["workflow", "step", "remove", "orphan-step"])
2369+
2370+
assert result.exit_code == 0, result.output
2371+
assert not step_dir.exists()
2372+
# Warning should be printed about missing registry entry
2373+
assert "Warning" in result.output or "warning" in result.output.lower()
2374+
2375+
def test_remove_not_installed(self, project_dir, monkeypatch):
2376+
"""step remove fails cleanly when neither directory nor registry entry exist."""
2377+
from typer.testing import CliRunner
2378+
from specify_cli import app
2379+
2380+
monkeypatch.chdir(project_dir)
2381+
2382+
runner = CliRunner()
2383+
result = runner.invoke(app, ["workflow", "step", "remove", "ghost-step"])
2384+
2385+
assert result.exit_code != 0
2386+
assert "not installed" in result.output
2387+
2388+
def test_remove_registered_step(self, project_dir, monkeypatch):
2389+
"""step remove works normally when both directory and registry entry exist."""
2390+
from typer.testing import CliRunner
2391+
from specify_cli import app
2392+
from specify_cli.workflows.catalog import StepRegistry
2393+
2394+
monkeypatch.chdir(project_dir)
2395+
2396+
# Set up a registered step with a directory
2397+
registry = StepRegistry(project_dir)
2398+
registry.add("my-step", {"name": "My Step", "type_key": "my-step", "version": "1.0.0"})
2399+
step_dir = project_dir / ".specify" / "workflows" / "steps" / "my-step"
2400+
step_dir.mkdir(parents=True)
2401+
(step_dir / "step.yml").write_text(
2402+
"step:\n type_key: my-step\n", encoding="utf-8"
2403+
)
2404+
(step_dir / "__init__.py").write_text("", encoding="utf-8")
2405+
2406+
runner = CliRunner()
2407+
result = runner.invoke(app, ["workflow", "step", "remove", "my-step"])
2408+
2409+
assert result.exit_code == 0, result.output
2410+
assert not step_dir.exists()
2411+
registry2 = StepRegistry(project_dir)
2412+
assert not registry2.is_installed("my-step")

0 commit comments

Comments
 (0)