Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/apm_cli/commands/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,21 @@ def _validate_plugin_name(name):
return bool(re.match(r"^[a-z][a-z0-9-]{0,63}$", name))


def _validate_project_name(name):
"""Validate that a project name is safe to use as a directory name.

Project names are used directly as directory names and must not contain
'/' or '\' so the name is not interpreted as a filesystem path,
and must not be '..' to prevent directory traversal.

Returns True if valid, False otherwise.
"""
if "/" in name or "\\" in name:
return False
if name == "..":
return False
return True

def _create_plugin_json(config):
"""Create plugin.json file with package metadata.

Expand Down
31 changes: 28 additions & 3 deletions src/apm_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
_lazy_confirm,
_rich_blank_line,
_validate_plugin_name,
_validate_project_name,
)


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

# Reject names containing path separators before any filesystem use
if project_name and not _validate_project_name(project_name):
logger.error(
f"Invalid project name '{project_name}': "
"project names must not contain path separators ('/' or '\\\\') or be '..'."
)
sys.exit(1)

# Determine project directory and name
if project_name:
project_dir = Path(project_name)
Expand Down Expand Up @@ -163,7 +172,7 @@ def init(ctx, project_name, yes, plugin, verbose):

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

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

name = Prompt.ask("Project name", default=default_name).strip()
while True:
name = Prompt.ask("Project name", default=default_name).strip()
if _validate_project_name(name):
break
console.print(
f"[error]Invalid project name '{name}': "
"project names must not contain path separators ('/' or '\\\\') or be '..'.[/error]"
)
Comment thread
edenfunf marked this conversation as resolved.
Comment thread
edenfunf marked this conversation as resolved.

version = Prompt.ask("Version", default="1.0.0").strip()
description = Prompt.ask("Description", default=auto_description).strip()
author = Prompt.ask("Author", default=auto_author).strip()
Expand All @@ -201,7 +218,15 @@ def _interactive_project_setup(default_name, logger):
logger.progress("Setting up your APM project...")
logger.progress("Press ^C at any time to quit.")

name = click.prompt("Project name", default=default_name).strip()
while True:
name = click.prompt("Project name", default=default_name).strip()
if _validate_project_name(name):
break
click.echo(
f"{ERROR}Invalid project name '{name}': "
f"project names must not contain path separators ('/' or '\\\\') or be '..'.{RESET}"
)

version = click.prompt("Version", default="1.0.0").strip()
description = click.prompt("Description", default=auto_description).strip()
author = click.prompt("Author", default=auto_author).strip()
Expand Down
108 changes: 108 additions & 0 deletions tests/unit/test_init_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,3 +319,111 @@ def test_invalid_names(self):
assert _validate_plugin_name("-plugin") is False
assert _validate_plugin_name("a" * 65) is False
assert _validate_plugin_name("My-Plugin") is False


class TestProjectNameValidation:
"""Unit tests for _validate_project_name helper."""

def test_valid_names(self):
from apm_cli.commands._helpers import _validate_project_name

assert _validate_project_name("myproject") is True
assert _validate_project_name("my-project") is True
assert _validate_project_name("my_project") is True
assert _validate_project_name("Project123") is True
assert _validate_project_name("4") is True
assert _validate_project_name(".") is True

def test_invalid_forward_slash(self):
from apm_cli.commands._helpers import _validate_project_name

assert _validate_project_name("4/15") is False
assert _validate_project_name("a/b") is False
assert _validate_project_name("/leading") is False
assert _validate_project_name("trailing/") is False

def test_invalid_backslash(self):
from apm_cli.commands._helpers import _validate_project_name

bs = chr(92) # one backslash character
assert _validate_project_name("a" + bs + "b") is False
assert _validate_project_name(bs + "leading") is False
assert _validate_project_name("trailing" + bs) is False

def test_invalid_dotdot(self):
from apm_cli.commands._helpers import _validate_project_name

assert _validate_project_name("..") is False

def test_dotdot_in_slash_path_caught_by_slash_check(self):
"""Names like a/../b are caught by the slash check, not the dotdot check."""
from apm_cli.commands._helpers import _validate_project_name

assert _validate_project_name("a/../b") is False # slash catches it


class TestInitProjectNameValidation:
"""Integration tests: apm init rejects project names with path separators or '..'."""

def setup_method(self):
self.runner = CliRunner()

def test_init_rejects_forward_slash_in_name(self):
"""apm init 4/15 must fail with a clear error, not a WinError."""
with self.runner.isolated_filesystem():
result = self.runner.invoke(cli, ["init", "4/15", "--yes"])
assert result.exit_code != 0
assert "Invalid project name" in result.output
assert "4/15" in result.output
assert not Path("4").exists()

def test_init_rejects_backslash_in_name(self):
"""apm init with a backslash in the name must fail with a clear error."""
bs = chr(92)
with self.runner.isolated_filesystem():
result = self.runner.invoke(cli, ["init", "a" + bs + "b", "--yes"])
assert result.exit_code != 0
assert "Invalid project name" in result.output
assert bs in result.output

def test_init_rejects_dotdot(self):
"""apm init .. must fail -- '..' would create a project in the parent directory."""
with self.runner.isolated_filesystem():
result = self.runner.invoke(cli, ["init", "..", "--yes"])
assert result.exit_code != 0
assert "Invalid project name" in result.output
assert ".." in result.output

def test_init_accepts_plain_name(self):
"""apm init with a simple name still works normally."""
with self.runner.isolated_filesystem() as tmp_dir:
result = self.runner.invoke(cli, ["init", "my-project", "--yes"])
assert result.exit_code == 0
assert (Path(tmp_dir) / "my-project" / "apm.yml").exists()

def test_init_interactive_reprompts_on_invalid_name_click(self):
"""In interactive mode, an invalid name triggers a re-prompt."""
with self.runner.isolated_filesystem() as tmp_dir:
# First input is invalid (contains '/'), second is valid.
# In no-argument interactive mode, the prompted name goes into apm.yml
# but does not create a subdirectory; apm.yml lands in the CWD.
result = self.runner.invoke(
cli,
["init"],
input="bad/name\nmy-project\n1.0.0\n\n\ny\n",
catch_exceptions=False,
)
assert "Invalid project name" in result.output
assert (Path(tmp_dir) / "apm.yml").exists()

def test_init_interactive_reprompts_on_dotdot_click(self):
"""In interactive mode, '..' triggers re-prompt."""
with self.runner.isolated_filesystem() as tmp_dir:
result = self.runner.invoke(
cli,
["init"],
input="..\nmy-project\n1.0.0\n\n\ny\n",
catch_exceptions=False,
)
assert "Invalid project name" in result.output
assert (Path(tmp_dir) / "apm.yml").exists()