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
46 changes: 46 additions & 0 deletions src/socrates120x/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,53 @@ def main(argv: list[str] | None = None) -> int:
# ---------------------------------------------------------------------------


def _validate_slug(slug: str, *, kind: str = "project") -> str | None:
"""Return an error message if *slug* would escape its parent directory or
is otherwise unsafe to use as a single path component; ``None`` if OK.

Reject:
- empty / whitespace-only slugs (would resolve to the base dir)
- slugs containing a path separator (``/`` or ``\\``) — would nest or
traverse outside the intended parent
- slugs equal to ``.`` or ``..`` or that contain a ``..`` segment
- absolute paths (Path("/x") / "/etc" returns "/etc" — the slug wins)
- slugs containing NUL bytes (defensive against API misuse)

Allowed: alphanumeric, dash, underscore, dot (for slugs like ``v0.8.0``
or ``.hidden`` if the operator really wants those).
"""
if not slug or not slug.strip():
return f"{kind} slug cannot be empty."
if "\x00" in slug:
return f"{kind} slug cannot contain NUL bytes."
if "/" in slug or "\\" in slug:
return (
f"{kind} slug must be a single path component "
f"(no '/' or '\\\\'); got {slug!r}."
)
# PurePath of an absolute slug yields an absolute path, which would
# discard the base when joined with /. Reject explicitly.
if Path(slug).is_absolute():
return (
f"{kind} slug must be relative, not absolute; got {slug!r}. "
f"Use --base to choose a different parent directory."
)
parts = Path(slug).parts
if parts and (parts[0] == ".." or any(p == ".." for p in parts)):
return (
f"{kind} slug cannot contain '..' segments; got {slug!r}. "
f"Use --base to choose a different parent directory."
)
if slug in {".", ".."}:
return f"{kind} slug cannot be {slug!r}."
return None


def _cmd_init(args: argparse.Namespace) -> int:
slug_err = _validate_slug(args.project, kind="project")
if slug_err:
print(f"error: {slug_err}", file=sys.stderr)
return 2
target: Path = args.base.expanduser().resolve() / args.project

if not args.no_scaffold:
Expand Down
110 changes: 110 additions & 0 deletions tests/test_init_slug_safety.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Slug-safety tests for `socrates init <slug>`.

The slug is appended to --base to form the target directory. Without
validation:
- absolute slugs (e.g. /etc/passwd) replace the base entirely (Python
pathlib: Path("base") / "/etc/passwd" -> "/etc/passwd").
- slugs containing / or \\ nest unexpectedly or escape via ..
- empty slug resolves to the base dir itself, risking damage to siblings.

These tests pin the validation behavior added to _validate_slug.
"""

from __future__ import annotations

from pathlib import Path

import pytest

from socrates120x.cli import _validate_slug, main


@pytest.mark.parametrize(
"slug",
[
"",
" ",
"/etc/passwd",
"/tmp/abs",
"foo/bar",
"foo\\bar",
"..",
".",
"../../tmp/bad",
"valid/with/sep",
"with\x00null",
],
)
def test_validate_slug_rejects_unsafe(slug: str) -> None:
assert _validate_slug(slug) is not None, f"slug {slug!r} should be rejected"


@pytest.mark.parametrize(
"slug",
[
"quarterly-rebates",
"my_project",
"PROJECT123",
"v0.8.0",
".hidden",
"a-b-c-d",
"x",
],
)
def test_validate_slug_accepts_safe(slug: str) -> None:
assert _validate_slug(slug) is None, f"slug {slug!r} should be accepted"


def test_init_rejects_absolute_slug_before_scaffold(
tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
"""End-to-end: `socrates init /etc/passwd --base <tmp>` must error out
BEFORE touching the filesystem at /etc/passwd."""
base = tmp_path / "base"
base.mkdir()
argv = ["init", "/etc/passwd", "--base", str(base)]
rc = main(argv)
assert rc == 2
err = capsys.readouterr().err
# Any rejection message is fine — the load-bearing assertion is that the
# filesystem stayed clean. Validator order may catch this as either
# "absolute" or "contains /"; either is acceptable.
assert "error:" in err
# Most importantly: /etc/passwd/docs etc. must not have been created.
assert not (Path("/etc/passwd/docs")).exists()


def test_init_rejects_traversal_slug(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
base = tmp_path / "base"
base.mkdir()
sibling = tmp_path / "escape-target"
rc = main(["init", "../escape-target", "--base", str(base)])
assert rc == 2
err = capsys.readouterr().err
assert ".." in err
# The escape target must not have been created either.
assert not sibling.exists()


def test_init_rejects_nested_slug(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
base = tmp_path / "base"
base.mkdir()
rc = main(["init", "a/b", "--base", str(base)])
assert rc == 2
err = capsys.readouterr().err
assert "single path component" in err


def test_init_rejects_empty_slug(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
base = tmp_path / "base"
base.mkdir()
rc = main(["init", "", "--base", str(base)])
assert rc == 2
err = capsys.readouterr().err
assert "empty" in err
Loading