fix: v0.2.9 — review welle 1 (C-1, C-2, M-3)

C-1: __version__ now derived from package metadata via
importlib.metadata.version() so pyproject.toml is the single source of
truth. Previously stuck at "0.1.0" since the initial release.

C-2: Backfill CHANGELOG entries for 0.2.7 and 0.2.8 (both releases had
shipped without changelog updates) and add a 0.2.9 entry covering this
welle.

M-3: Reject project names containing path separators or other unsafe
characters before they reach _find_compose_path. Previously a name like
"../../etc" could traverse out of compose_base_path when the project was
not yet registered with Container Manager. Adds _validate_project_name
(regex ^[a-zA-Z0-9_-]+$, applied in read_compose, update_compose,
update_image_tag, update_env_var) plus parametrized tests for valid and
unsafe names and one rejection test per tool. 236 tests pass.

Also: ruff format autofix on three pre-existing files (cli.py,
config.py, test_config.py) — cosmetic only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 09:07:00 +02:00
parent a1a9388d88
commit 661460bfd9
9 changed files with 246 additions and 8 deletions
-1
View File
@@ -1,6 +1,5 @@
"""Tests for config.py."""
import pytest
import yaml
+120
View File
@@ -5,6 +5,8 @@ from unittest.mock import AsyncMock
import pytest
import yaml
from mcp_synology_container.modules.compose import _validate_project_name
def make_mock_mcp():
tools: dict = {}
@@ -345,3 +347,121 @@ def test_extract_version_prefix():
assert _extract_version_prefix("1.24") is None # no suffix
assert _extract_version_prefix("") is None
assert _extract_version_prefix("v2.0-rc1") is None # starts with 'v'
# ──────────────────────────────────────────────────────────────────────
# _validate_project_name — path-traversal guard
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"name",
[
"myapp",
"MyApp",
"my-app",
"my_app",
"app123",
"A",
"1",
"snake_case-and-dash_42",
],
)
def test_validate_project_name_accepts_safe_names(name: str) -> None:
assert _validate_project_name(name) is None
@pytest.mark.parametrize(
"name",
[
"", # empty
"../etc", # parent traversal
"../../etc/passwd", # multi-level traversal
"foo/../bar", # embedded traversal
"foo/bar", # forward slash
"foo\\bar", # backslash
".", # bare dot
"..", # bare dotdot
".hidden", # leading dot
"foo.bar", # dot inside (.yaml extensions, etc.)
"foo bar", # whitespace
" foo", # leading space
"foo ", # trailing space
"foo\tbar", # tab
"foo\nbar", # newline
"foo;rm", # shell metachar
"foo|bar",
"foo&bar",
"foo*bar",
"foo?bar",
"foo:bar",
"foo'bar",
'foo"bar',
"foo$bar",
"foo`bar",
"café", # non-ASCII letter
],
)
def test_validate_project_name_rejects_unsafe_names(name: str) -> None:
msg = _validate_project_name(name)
assert msg is not None
assert "invalid project name" in msg
@pytest.mark.asyncio
async def test_read_compose_rejects_traversal_name() -> None:
"""Traversal name must be rejected before any DSM call."""
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
mcp, tools = make_mock_mcp()
register_compose(mcp, make_config(), client)
result = await tools["read_compose"]("../../etc")
assert "invalid project name" in result
client.request.assert_not_called()
client.download_text.assert_not_called()
@pytest.mark.asyncio
async def test_update_compose_rejects_traversal_name() -> None:
"""update_compose with a traversal name must not validate YAML or upload."""
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
mcp, tools = make_mock_mcp()
register_compose(mcp, make_config(), client)
result = await tools["update_compose"](
"foo/../bar", "services:\n web:\n image: nginx\n", confirmed=True
)
assert "invalid project name" in result
client.upload_text.assert_not_called()
@pytest.mark.asyncio
async def test_update_image_tag_rejects_traversal_name() -> None:
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
mcp, tools = make_mock_mcp()
register_compose(mcp, make_config(), client)
result = await tools["update_image_tag"]("foo/bar", "web", "1.25", confirmed=True)
assert "invalid project name" in result
client.upload_text.assert_not_called()
client.download_text.assert_not_called()
@pytest.mark.asyncio
async def test_update_env_var_rejects_traversal_name() -> None:
from mcp_synology_container.modules.compose import register_compose
client = AsyncMock()
mcp, tools = make_mock_mcp()
register_compose(mcp, make_config(), client)
result = await tools["update_env_var"]("..", "web", "FOO", "bar", confirmed=True)
assert "invalid project name" in result
client.upload_text.assert_not_called()
client.download_text.assert_not_called()