diff --git a/src/apm_cli/commands/_helpers.py b/src/apm_cli/commands/_helpers.py index 6f220455f..895a9984d 100644 --- a/src/apm_cli/commands/_helpers.py +++ b/src/apm_cli/commands/_helpers.py @@ -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. diff --git a/src/apm_cli/commands/init.py b/src/apm_cli/commands/init.py index 5d032d12f..f97ff46df 100644 --- a/src/apm_cli/commands/init.py +++ b/src/apm_cli/commands/init.py @@ -22,6 +22,7 @@ _lazy_confirm, _rich_blank_line, _validate_plugin_name, + _validate_project_name, ) @@ -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) @@ -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() @@ -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]" + ) + 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() @@ -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() diff --git a/tests/unit/test_init_command.py b/tests/unit/test_init_command.py index 6da5accb6..135d91f02 100644 --- a/tests/unit/test_init_command.py +++ b/tests/unit/test_init_command.py @@ -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()