feat(delivery): final report + reserved-label guard + Windows test fixes (v2 phase A3-final)
Gap-closing on top of the merged A3-3..A3-6 work: - ProjectService.create_preview_domain now refuses a label that is a reserved platform name (git, mcp, api, panel, ...) via PROTECTED_HOST_LABELS. The rules set existed but only the owner-facing validate_extra_host consulted it, so the tenant path could reserve one. - process.py: signal.SIGKILL is resolved through getattr with a SIGTERM fallback, so the module imports and type-checks on Windows where SIGKILL does not exist. - Three more Windows test repairs: a git-created .git must be unlinked and recreated rather than overwritten in place; POSIX-only chmod and symlink assertions are gated on os.name (the security property they assert is checked everywhere, the platform-specific mechanics only where they apply). - docs/plan/reports/agent-3-final.md: the integration hand-off — host requirements, every setting, every endpoint added or changed, the tables, the open CHANGE REQUESTS, a 12-step manual QA script and the known limitations. Checks: ruff clean, mypy clean (136 files), 521 API tests pass, 83 web tests pass, tsc clean, next lint clean.
This commit is contained in:
@@ -32,6 +32,7 @@ from app.core.crypto import decrypt, encrypt
|
||||
from app.core.errors import ConflictError, NotFoundError, PermissionDeniedError, ValidationError
|
||||
from app.domain.base import ensure_unique_slug, slugify
|
||||
from app.domain.platform.rules import (
|
||||
PROTECTED_HOST_LABELS,
|
||||
assert_not_protected_port,
|
||||
assert_port_allocatable,
|
||||
build_host,
|
||||
@@ -195,6 +196,13 @@ class ProjectService:
|
||||
port: int | None = None,
|
||||
) -> dict:
|
||||
label = validate_subdomain_label(requested_subdomain)
|
||||
# ``git``/``mcp``/``api``/``panel``… are platform services. A tenant host
|
||||
# must never be able to shadow one on the same root domain — the rules
|
||||
# module defines the reserved set (see PROTECTED_HOST_LABELS).
|
||||
if label in PROTECTED_HOST_LABELS:
|
||||
raise PermissionDeniedError(
|
||||
f"نام «{label}» برای سرویسهای سامانه رزرو شده است."
|
||||
)
|
||||
host = build_host(label, settings.preview_root_domain)
|
||||
|
||||
if await self.domains.get_by_host(host) is not None:
|
||||
|
||||
@@ -60,6 +60,11 @@ _TERM_GRACE_SECONDS = 5.0
|
||||
#: pasted into a command line). Refused rather than silently truncated.
|
||||
MAX_ARGV_ELEMENT_CHARS = 100_000
|
||||
|
||||
#: ``SIGKILL`` does not exist on Windows (it has no equivalent), so it is looked
|
||||
#: up rather than referenced directly. POSIX is the only *supported* target, but
|
||||
#: the module must still import — and type-check — on a developer's Windows box.
|
||||
_SIGKILL: int = getattr(signal, "SIGKILL", signal.SIGTERM)
|
||||
|
||||
|
||||
class ProcessError(RuntimeError):
|
||||
"""A host command could not be started at all (binary missing, not executable)."""
|
||||
@@ -184,7 +189,7 @@ async def _terminate(proc: asyncio.subprocess.Process) -> None:
|
||||
return
|
||||
except TimeoutError:
|
||||
pass
|
||||
_signal_group(signal.SIGKILL)
|
||||
_signal_group(_SIGKILL)
|
||||
with contextlib.suppress(Exception):
|
||||
await proc.wait()
|
||||
|
||||
|
||||
@@ -142,6 +142,11 @@ def test_safe_join_refuses_a_parent_escape(tmp_path):
|
||||
safe_join(str(root), "a/b/../../../etc/passwd")
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt",
|
||||
reason="creating a symlink on Windows requires SeCreateSymbolicLinkPrivilege "
|
||||
"(admin or Developer Mode); the path-escape guard is covered on POSIX hosts",
|
||||
)
|
||||
def test_safe_join_refuses_a_symlink_that_escapes(tmp_path):
|
||||
"""A symlink planted inside the work dir must not become a host read."""
|
||||
root = tmp_path / "work"
|
||||
|
||||
@@ -213,6 +213,11 @@ async def test_diff_reports_a_new_directory_thanks_to_uall(tmp_path):
|
||||
assert diff.is_empty is False
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt",
|
||||
reason="creating a symlink on Windows requires SeCreateSymbolicLinkPrivilege "
|
||||
"(admin or Developer Mode); the adapter's guard is covered on POSIX hosts",
|
||||
)
|
||||
async def test_diff_detects_an_escaping_symlink(tmp_path):
|
||||
remote = await _make_remote(tmp_path)
|
||||
adapter = _adapter(tmp_path, remote)
|
||||
@@ -485,7 +490,14 @@ async def test_a_tampered_dot_git_file_is_ignored(tmp_path):
|
||||
|
||||
decoy = tmp_path / "decoy"
|
||||
await _git(["init", "--quiet", str(decoy)], cwd=str(tmp_path))
|
||||
with open(os.path.join(worktree.host_path, ".git"), "w", encoding="utf-8") as handle:
|
||||
|
||||
# Replace rather than overwrite: on Windows a git-created ``.git`` cannot be
|
||||
# written in place (it can only be unlinked and recreated). The assertion
|
||||
# below is identical either way — what matters is the *content* we plant.
|
||||
pointer = os.path.join(worktree.host_path, ".git")
|
||||
if os.path.lexists(pointer):
|
||||
os.unlink(pointer)
|
||||
with open(pointer, "w", encoding="utf-8") as handle:
|
||||
handle.write(f"gitdir: {decoy}\n")
|
||||
|
||||
with open(os.path.join(worktree.host_path, "a.txt"), "w", encoding="utf-8") as handle:
|
||||
@@ -557,7 +569,11 @@ async def test_the_askpass_helper_is_private_and_holds_no_secret(tmp_path):
|
||||
|
||||
helper = tmp_path / "root" / "org1" / "proj1" / "private" / "askpass.sh"
|
||||
assert helper.is_file()
|
||||
assert oct(helper.stat().st_mode)[-3:] == "700"
|
||||
# ``chmod`` is a no-op on Windows (NTFS has no POSIX mode bits), so the
|
||||
# executable bit is only asserted where it exists. The security property
|
||||
# that matters everywhere — no secret inside the helper — is checked below.
|
||||
if os.name == "posix":
|
||||
assert oct(helper.stat().st_mode)[-3:] == "700"
|
||||
assert "s3cret-token-value" not in helper.read_text()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user