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
19 changes: 18 additions & 1 deletion src/apm_cli/commands/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -622,17 +622,34 @@ def _validate_project_name(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.
and must not be '..' to prevent directory traversal. Must also be
non-empty: an empty/whitespace name writes an apm.yml with 'name: ""',
which APMPackage.from_apm_yml's identity check rejects on every later
install/lock/compile run (#2155).

Returns True if valid, False otherwise.
"""
if not name or not name.strip():
return False
if "/" in name or "\\" in name:
return False
if name == "..": # noqa: SIM103
return False
return True


def _resolve_bootstrap_project_name(candidate: str) -> str:
"""Return a valid apm.yml project name for the install auto-bootstrap path.

``Path.cwd().name`` (or ``Path.home().name``) is '' at a filesystem/drive
root -- e.g. a container with ``WORKDIR /``. Writing that candidate
straight into apm.yml would produce ``name: ''``, which
``APMPackage.from_apm_yml``'s identity check rejects on every later
install/lock/compile run (#2155). Falls back to a generic default instead.
"""
return candidate if _validate_project_name(candidate) else "my-project"


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

Expand Down
16 changes: 12 additions & 4 deletions src/apm_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
_create_plugin_json,
_get_console,
_get_default_config,
_resolve_bootstrap_project_name,
_rich_blank_line,
_validate_plugin_name,
_validate_project_name,
Expand Down Expand Up @@ -149,7 +150,8 @@ def _perform_init(
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 '..'."
"project names must be non-empty and must not contain "
"path separators ('/' or '\\\\') or be '..'."
)
Comment thread
nadav-y marked this conversation as resolved.
sys.exit(1)

Expand All @@ -162,7 +164,11 @@ def _perform_init(
final_project_name = project_name
else:
project_dir = Path.cwd()
final_project_name = project_dir.name
# project_dir.name is '' at a filesystem/drive root (e.g. cwd == "/",
# or a container WORKDIR of "/"). With --yes this flows straight to
# _get_default_config with no further validation, silently writing
# apm.yml with 'name: ""'.
final_project_name = _resolve_bootstrap_project_name(project_dir.name)
project_root = Path.cwd()

# Validate plugin name early
Expand Down Expand Up @@ -395,7 +401,8 @@ def _interactive_project_setup(default_name, logger):
break
console.print(
f"[error]Invalid project name '{name}': "
"project names must not contain path separators ('/' or '\\\\') or be '..'.[/error]"
"project names must be non-empty and must not contain "
"path separators ('/' or '\\\\') or be '..'.[/error]"
)

version = Prompt.ask("Version", default="1.0.0").strip()
Expand All @@ -412,7 +419,8 @@ def _interactive_project_setup(default_name, logger):
break
click.echo(
f"{ERROR}Invalid project name '{name}': "
f"project names must not contain path separators ('/' or '\\\\') or be '..'.{RESET}"
f"project names must be non-empty and must not contain "
f"path separators ('/' or '\\\\') or be '..'.{RESET}"
)

version = click.prompt("Version", default="1.0.0").strip()
Expand Down
2 changes: 2 additions & 0 deletions src/apm_cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@
from ._helpers import (
_create_minimal_apm_yml,
_get_default_config,
_resolve_bootstrap_project_name,
)

# CRITICAL: Shadow Python builtins that share names with Click commands
Expand Down Expand Up @@ -1429,6 +1430,7 @@ def install( # noqa: PLR0913

if not apm_yml_exists and packages:
project_name = Path.cwd().name if scope is InstallScope.PROJECT else Path.home().name
project_name = _resolve_bootstrap_project_name(project_name)
config = _get_default_config(project_name)
if manifest_targets := manifest_targets_from_target_option(target):
config["targets"] = manifest_targets
Expand Down
80 changes: 80 additions & 0 deletions tests/unit/test_init_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,39 @@ def test_dotdot_in_slash_path_caught_by_slash_check(self):

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

def test_invalid_empty_or_whitespace(self):
"""Empty/whitespace names must be rejected (#2155 regression).

An empty ``name`` passes through undetected into apm.yml, then
every later `from_apm_yml` call (install/lock/compile) fails with
"Invalid apm.yml identity: 'name' must be a non-empty string".
"""
from apm_cli.commands._helpers import _validate_project_name

assert _validate_project_name("") is False
assert _validate_project_name(" ") is False
assert _validate_project_name("\t\n") is False


class TestResolveBootstrapProjectName:
"""Unit tests for _resolve_bootstrap_project_name (install auto-bootstrap)."""

def test_passes_through_valid_name(self):
from apm_cli.commands._helpers import _resolve_bootstrap_project_name

assert _resolve_bootstrap_project_name("my-project") == "my-project"

def test_falls_back_when_empty(self):
"""Path.cwd().name is '' at a filesystem/drive root (#2155 regression)."""
from apm_cli.commands._helpers import _resolve_bootstrap_project_name

assert _resolve_bootstrap_project_name("") == "my-project"

def test_falls_back_when_whitespace(self):
from apm_cli.commands._helpers import _resolve_bootstrap_project_name

assert _resolve_bootstrap_project_name(" ") == "my-project"


class TestInitProjectNameValidation:
"""Integration tests: apm init rejects project names with path separators or '..'."""
Expand Down Expand Up @@ -493,6 +526,32 @@ def test_init_accepts_plain_name(self):
assert result.exit_code == 0
assert (Path(tmp_dir) / "my-project" / "apm.yml").exists()

def test_init_no_arg_yes_validates_cwd_derived_name(self):
"""apm init --yes (no project_name) must run the cwd-derived name
through _resolve_bootstrap_project_name, not _get_default_config
directly.

Regression test for the PR #2200 review finding: when no
project_name arg is given, the early `_validate_project_name`
check (guarded by `if project_name and ...`) is skipped entirely,
so with --yes the cwd-derived name flowed straight into
_get_default_config unvalidated. At a filesystem/drive root
(Path.cwd().name == '') this silently wrote apm.yml with
'name: ""'.
"""
with self.runner.isolated_filesystem():
with patch(
"apm_cli.commands.init._resolve_bootstrap_project_name",
return_value="resolved-name",
) as mock_resolve:
result = self.runner.invoke(cli, ["init", "--yes"], catch_exceptions=False)

assert result.exit_code == 0
mock_resolve.assert_called_once()
with open("apm.yml", encoding="utf-8") as f:
data = yaml.safe_load(f)
assert data["name"] == "resolved-name"

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:
Expand Down Expand Up @@ -520,6 +579,27 @@ def test_init_interactive_reprompts_on_dotdot_click(self):
assert "Invalid project name" in result.output
assert (Path(tmp_dir) / "apm.yml").exists()

def test_init_interactive_reprompts_on_blank_name_click(self):
"""A blank/whitespace name must re-prompt instead of writing name: ''.

Regression test for #2155: an empty name silently accepted here used
to write an apm.yml that every later `apm install`/`lock`/`compile`
rejected with "Invalid apm.yml identity: 'name' must be a non-empty
string", since APMPackage.from_apm_yml enforces a non-empty name.
"""
with self.runner.isolated_filesystem() as tmp_dir:
result = self.runner.invoke(
cli,
["init"],
input=" \nmy-project\n1.0.0\n\n\ny\ndone\ny\n",
catch_exceptions=False,
)
assert "Invalid project name" in result.output
apm_yml_path = Path(tmp_dir) / "apm.yml"
assert apm_yml_path.exists()
data = yaml.safe_load(apm_yml_path.read_text(encoding="utf-8"))
assert data["name"] == "my-project"


class TestInitTargetPrompt:
"""Test cases for the target selection prompt in apm init (S1-S7)."""
Expand Down
Loading