Skip to content

Commit 9e77d13

Browse files
nadav-yclaude
andcommitted
fix(init): reject empty/whitespace project names
_validate_project_name only checked for path separators and '..', so an empty or whitespace-only name (e.g. `apm install <pkg>` auto-bootstrapping apm.yml from Path.cwd().name at a container's unset root WORKDIR, or a blank `apm init` prompt) silently wrote `name: ''` into apm.yml. Every later install/lock/compile then failed with "Invalid apm.yml identity: 'name' must be a non-empty string" once #2155 tightened APMPackage.from_apm_yml's identity check. Reject empty/whitespace names in _validate_project_name, and add _resolve_bootstrap_project_name() so the install auto-bootstrap path falls back to "my-project" instead of writing an empty name. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 5875577 commit 9e77d13

4 files changed

Lines changed: 80 additions & 4 deletions

File tree

src/apm_cli/commands/_helpers.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -622,17 +622,34 @@ def _validate_project_name(name):
622622
623623
Project names are used directly as directory names and must not contain
624624
'/' or '\' so the name is not interpreted as a filesystem path,
625-
and must not be '..' to prevent directory traversal.
625+
and must not be '..' to prevent directory traversal. Must also be
626+
non-empty: an empty/whitespace name writes an apm.yml with 'name: ""',
627+
which APMPackage.from_apm_yml's identity check rejects on every later
628+
install/lock/compile run (#2155).
626629
627630
Returns True if valid, False otherwise.
628631
"""
632+
if not name or not name.strip():
633+
return False
629634
if "/" in name or "\\" in name:
630635
return False
631636
if name == "..": # noqa: SIM103
632637
return False
633638
return True
634639

635640

641+
def _resolve_bootstrap_project_name(candidate: str) -> str:
642+
"""Return a valid apm.yml project name for the install auto-bootstrap path.
643+
644+
``Path.cwd().name`` (or ``Path.home().name``) is '' at a filesystem/drive
645+
root -- e.g. a container with ``WORKDIR /``. Writing that candidate
646+
straight into apm.yml would produce ``name: ''``, which
647+
``APMPackage.from_apm_yml``'s identity check rejects on every later
648+
install/lock/compile run (#2155). Falls back to a generic default instead.
649+
"""
650+
return candidate if _validate_project_name(candidate) else "my-project"
651+
652+
636653
def _create_plugin_json(config):
637654
"""Create plugin.json file with package metadata.
638655

src/apm_cli/commands/init.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,8 @@ def _perform_init(
149149
if project_name and not _validate_project_name(project_name):
150150
logger.error(
151151
f"Invalid project name '{project_name}': "
152-
"project names must not contain path separators ('/' or '\\\\') or be '..'."
152+
"project names must be non-empty and must not contain "
153+
"path separators ('/' or '\\\\') or be '..'."
153154
)
154155
sys.exit(1)
155156

@@ -395,7 +396,8 @@ def _interactive_project_setup(default_name, logger):
395396
break
396397
console.print(
397398
f"[error]Invalid project name '{name}': "
398-
"project names must not contain path separators ('/' or '\\\\') or be '..'.[/error]"
399+
"project names must be non-empty and must not contain "
400+
"path separators ('/' or '\\\\') or be '..'.[/error]"
399401
)
400402

401403
version = Prompt.ask("Version", default="1.0.0").strip()
@@ -412,7 +414,8 @@ def _interactive_project_setup(default_name, logger):
412414
break
413415
click.echo(
414416
f"{ERROR}Invalid project name '{name}': "
415-
f"project names must not contain path separators ('/' or '\\\\') or be '..'.{RESET}"
417+
f"project names must be non-empty and must not contain "
418+
f"path separators ('/' or '\\\\') or be '..'.{RESET}"
416419
)
417420

418421
version = click.prompt("Version", default="1.0.0").strip()

src/apm_cli/commands/install.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@
121121
from ._helpers import (
122122
_create_minimal_apm_yml,
123123
_get_default_config,
124+
_resolve_bootstrap_project_name,
124125
)
125126

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

14301431
if not apm_yml_exists and packages:
14311432
project_name = Path.cwd().name if scope is InstallScope.PROJECT else Path.home().name
1433+
project_name = _resolve_bootstrap_project_name(project_name)
14321434
config = _get_default_config(project_name)
14331435
if manifest_targets := manifest_targets_from_target_option(target):
14341436
config["targets"] = manifest_targets

tests/unit/test_init_command.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,39 @@ def test_dotdot_in_slash_path_caught_by_slash_check(self):
453453

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

456+
def test_invalid_empty_or_whitespace(self):
457+
"""Empty/whitespace names must be rejected (#2155 regression).
458+
459+
An empty ``name`` passes through undetected into apm.yml, then
460+
every later `from_apm_yml` call (install/lock/compile) fails with
461+
"Invalid apm.yml identity: 'name' must be a non-empty string".
462+
"""
463+
from apm_cli.commands._helpers import _validate_project_name
464+
465+
assert _validate_project_name("") is False
466+
assert _validate_project_name(" ") is False
467+
assert _validate_project_name("\t\n") is False
468+
469+
470+
class TestResolveBootstrapProjectName:
471+
"""Unit tests for _resolve_bootstrap_project_name (install auto-bootstrap)."""
472+
473+
def test_passes_through_valid_name(self):
474+
from apm_cli.commands._helpers import _resolve_bootstrap_project_name
475+
476+
assert _resolve_bootstrap_project_name("my-project") == "my-project"
477+
478+
def test_falls_back_when_empty(self):
479+
"""Path.cwd().name is '' at a filesystem/drive root (#2155 regression)."""
480+
from apm_cli.commands._helpers import _resolve_bootstrap_project_name
481+
482+
assert _resolve_bootstrap_project_name("") == "my-project"
483+
484+
def test_falls_back_when_whitespace(self):
485+
from apm_cli.commands._helpers import _resolve_bootstrap_project_name
486+
487+
assert _resolve_bootstrap_project_name(" ") == "my-project"
488+
456489

457490
class TestInitProjectNameValidation:
458491
"""Integration tests: apm init rejects project names with path separators or '..'."""
@@ -520,6 +553,27 @@ def test_init_interactive_reprompts_on_dotdot_click(self):
520553
assert "Invalid project name" in result.output
521554
assert (Path(tmp_dir) / "apm.yml").exists()
522555

556+
def test_init_interactive_reprompts_on_blank_name_click(self):
557+
"""A blank/whitespace name must re-prompt instead of writing name: ''.
558+
559+
Regression test for #2155: an empty name silently accepted here used
560+
to write an apm.yml that every later `apm install`/`lock`/`compile`
561+
rejected with "Invalid apm.yml identity: 'name' must be a non-empty
562+
string", since APMPackage.from_apm_yml enforces a non-empty name.
563+
"""
564+
with self.runner.isolated_filesystem() as tmp_dir:
565+
result = self.runner.invoke(
566+
cli,
567+
["init"],
568+
input=" \nmy-project\n1.0.0\n\n\ny\ndone\ny\n",
569+
catch_exceptions=False,
570+
)
571+
assert "Invalid project name" in result.output
572+
apm_yml_path = Path(tmp_dir) / "apm.yml"
573+
assert apm_yml_path.exists()
574+
data = yaml.safe_load(apm_yml_path.read_text(encoding="utf-8"))
575+
assert data["name"] == "my-project"
576+
523577

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

0 commit comments

Comments
 (0)