Skip to content

Commit 0b99acd

Browse files
committed
fix: validate project name to reject path separators in apm init
apm init passed the project_name argument directly into Path(), causing '/' and '\' to be silently interpreted as filesystem path separators. On Windows this produced a low-level WinError instead of a clear message. Add _validate_project_name() in _helpers.py that rejects names containing '/' or '\', call it in init() before any Path usage, and re-prompt in interactive mode when the user enters an invalid name. Fixes #723 Closes #718 (discussion)
1 parent 35ad682 commit 0b99acd

3 files changed

Lines changed: 123 additions & 3 deletions

File tree

src/apm_cli/commands/_helpers.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,17 @@ def _validate_plugin_name(name):
414414
return bool(re.match(r"^[a-z][a-z0-9-]{0,63}$", name))
415415

416416

417+
def _validate_project_name(name):
418+
"""Validate that a project name does not contain path separators.
419+
420+
Project names are used directly as directory names and must not contain
421+
'/' or '\\' to prevent unintended filesystem path traversal.
422+
423+
Returns True if valid, False otherwise.
424+
"""
425+
return "/" not in name and "\\" not in name
426+
427+
417428
def _create_plugin_json(config):
418429
"""Create plugin.json file with package metadata.
419430

src/apm_cli/commands/init.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
_lazy_confirm,
2323
_rich_blank_line,
2424
_validate_plugin_name,
25+
_validate_project_name,
2526
)
2627

2728

@@ -47,6 +48,14 @@ def init(ctx, project_name, yes, plugin, verbose):
4748
if project_name == ".":
4849
project_name = None
4950

51+
# Reject names containing path separators before any filesystem use
52+
if project_name and not _validate_project_name(project_name):
53+
logger.error(
54+
f"Invalid project name '{project_name}': "
55+
"project names must not contain path separators ('/' or '\\\\')."
56+
)
57+
sys.exit(1)
58+
5059
# Determine project directory and name
5160
if project_name:
5261
project_dir = Path(project_name)
@@ -163,7 +172,7 @@ def init(ctx, project_name, yes, plugin, verbose):
163172

164173
def _interactive_project_setup(default_name, logger):
165174
"""Interactive setup for new APM projects with auto-detection."""
166-
from ._helpers import _auto_detect_author, _auto_detect_description
175+
from ._helpers import _auto_detect_author, _auto_detect_description, _validate_project_name
167176

168177
# Get auto-detected defaults
169178
auto_author = _auto_detect_author()
@@ -179,7 +188,15 @@ def _interactive_project_setup(default_name, logger):
179188
console.print("\n[info]Setting up your APM project...[/info]")
180189
console.print("[muted]Press ^C at any time to quit.[/muted]\n")
181190

182-
name = Prompt.ask("Project name", default=default_name).strip()
191+
while True:
192+
name = Prompt.ask("Project name", default=default_name).strip()
193+
if _validate_project_name(name):
194+
break
195+
console.print(
196+
f"[error]Invalid project name '{name}': "
197+
"project names must not contain path separators ('/' or '\\\\').[/error]"
198+
)
199+
183200
version = Prompt.ask("Version", default="1.0.0").strip()
184201
description = Prompt.ask("Description", default=auto_description).strip()
185202
author = Prompt.ask("Author", default=auto_author).strip()
@@ -201,7 +218,15 @@ def _interactive_project_setup(default_name, logger):
201218
logger.progress("Setting up your APM project...")
202219
logger.progress("Press ^C at any time to quit.")
203220

204-
name = click.prompt("Project name", default=default_name).strip()
221+
while True:
222+
name = click.prompt("Project name", default=default_name).strip()
223+
if _validate_project_name(name):
224+
break
225+
click.echo(
226+
f"{ERROR}Invalid project name '{name}': "
227+
"project names must not contain path separators ('/' or '\\\\').{RESET}"
228+
)
229+
205230
version = click.prompt("Version", default="1.0.0").strip()
206231
description = click.prompt("Description", default=auto_description).strip()
207232
author = click.prompt("Author", default=auto_author).strip()

tests/unit/test_init_command.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,3 +319,87 @@ def test_invalid_names(self):
319319
assert _validate_plugin_name("-plugin") is False
320320
assert _validate_plugin_name("a" * 65) is False
321321
assert _validate_plugin_name("My-Plugin") is False
322+
323+
324+
class TestProjectNameValidation:
325+
"""Unit tests for _validate_project_name helper."""
326+
327+
def test_valid_names(self):
328+
from apm_cli.commands._helpers import _validate_project_name
329+
330+
assert _validate_project_name("myproject") is True
331+
assert _validate_project_name("my-project") is True
332+
assert _validate_project_name("my_project") is True
333+
assert _validate_project_name("Project123") is True
334+
assert _validate_project_name("4") is True
335+
assert _validate_project_name(".") is True
336+
337+
def test_invalid_forward_slash(self):
338+
from apm_cli.commands._helpers import _validate_project_name
339+
340+
assert _validate_project_name("4/15") is False
341+
assert _validate_project_name("a/b") is False
342+
assert _validate_project_name("/leading") is False
343+
assert _validate_project_name("trailing/") is False
344+
345+
def test_invalid_backslash(self):
346+
from apm_cli.commands._helpers import _validate_project_name
347+
348+
assert _validate_project_name("a\\b") is False
349+
assert _validate_project_name("\\leading") is False
350+
assert _validate_project_name("trailing\\") is False
351+
352+
353+
class TestInitProjectNameValidation:
354+
"""Integration tests: apm init rejects project names with path separators."""
355+
356+
def setup_method(self):
357+
self.runner = CliRunner()
358+
try:
359+
self.original_dir = os.getcwd()
360+
except FileNotFoundError:
361+
self.original_dir = str(Path(__file__).parent.parent.parent)
362+
os.chdir(self.original_dir)
363+
364+
def teardown_method(self):
365+
try:
366+
os.chdir(self.original_dir)
367+
except (FileNotFoundError, OSError):
368+
os.chdir(str(Path(__file__).parent.parent.parent))
369+
370+
def test_init_rejects_forward_slash_in_name(self):
371+
"""apm init 4/15 must fail with a clear error, not a WinError."""
372+
with tempfile.TemporaryDirectory() as tmp_dir:
373+
os.chdir(tmp_dir)
374+
try:
375+
result = self.runner.invoke(cli, ["init", "4/15", "--yes"])
376+
assert result.exit_code != 0
377+
assert "Invalid project name" in result.output
378+
assert "4/15" in result.output
379+
# No directory should be created
380+
assert not Path("4").exists()
381+
finally:
382+
os.chdir(self.original_dir)
383+
384+
def test_init_rejects_backslash_in_name(self):
385+
"""apm init with a backslash in the name must fail with a clear error."""
386+
with tempfile.TemporaryDirectory() as tmp_dir:
387+
os.chdir(tmp_dir)
388+
try:
389+
result = self.runner.invoke(cli, ["init", "a\\b", "--yes"])
390+
assert result.exit_code != 0
391+
assert "Invalid project name" in result.output
392+
assert "a\\b" in result.output
393+
finally:
394+
os.chdir(self.original_dir)
395+
396+
def test_init_accepts_plain_name(self):
397+
"""apm init with a simple name still works normally."""
398+
with tempfile.TemporaryDirectory() as tmp_dir:
399+
os.chdir(tmp_dir)
400+
try:
401+
result = self.runner.invoke(cli, ["init", "my-project", "--yes"])
402+
assert result.exit_code == 0
403+
assert (Path(tmp_dir) / "my-project" / "apm.yml").exists()
404+
finally:
405+
os.chdir(self.original_dir)

0 commit comments

Comments
 (0)