"""DocsService path-containment guard (CodeQL path-traversal fix, 2026-06-29).

The old ``if ".." in path`` substring guard is bypassable: ``Path(base) /
"/etc/passwd"`` evaluates to ``/etc/passwd`` (pathlib resets on an absolute
right operand), so an absolute ``path`` escapes ``DOCS_BASE_PATH`` and the
``unlink``/``read_text`` sink hits an arbitrary file. These tests pin a
resolve-and-contain guard on ``read_doc`` / ``delete_doc`` and the pure
helper behind it.

``read_doc`` / ``delete_doc`` only consult the agent catalog (no DB), so a
``MagicMock`` session is enough — no Postgres required.
"""

from __future__ import annotations

from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch

import pytest
from roboco.services.base import ValidationError
from roboco.services.docs import DocsService, _resolve_contained_path

if TYPE_CHECKING:
    from pathlib import Path

    from pytest import TempPathFactory


# ---------------------------------------------------------------------------
# Pure helper
# ---------------------------------------------------------------------------


def test_resolve_rejects_absolute_path(tmp_path: Path) -> None:
    with pytest.raises(ValidationError):
        _resolve_contained_path(tmp_path, "/etc/passwd")


def test_resolve_rejects_traversal_escape(tmp_path: Path) -> None:
    with pytest.raises(ValidationError):
        _resolve_contained_path(tmp_path, "backend/../../etc/passwd")


def test_resolve_rejects_empty(tmp_path: Path) -> None:
    with pytest.raises(ValidationError):
        _resolve_contained_path(tmp_path, "")


def test_resolve_rejects_nul(tmp_path: Path) -> None:
    with pytest.raises(ValidationError):
        _resolve_contained_path(tmp_path, "backend\x00evil.md")


def test_resolve_rejects_dotdot(tmp_path: Path) -> None:
    # A '..' segment is still rejected (traversal).
    with pytest.raises(ValidationError):
        _resolve_contained_path(tmp_path, "backend/../etc/passwd")


def test_resolve_rejects_dot_segment(tmp_path: Path) -> None:
    # rel='.' resolves to the base dir itself and would hand read_doc/delete_doc
    # a directory (500 / failed unlink). Reject it cleanly.
    with pytest.raises(ValidationError):
        _resolve_contained_path(tmp_path, ".")


def test_resolve_rejects_inner_dot_segment(tmp_path: Path) -> None:
    # A '.' segment mid-path (a/./b) is also rejected.
    with pytest.raises(ValidationError):
        _resolve_contained_path(tmp_path, "backend/./endpoints.md")


def test_resolve_accepts_dotdot_inside_filename(tmp_path: Path) -> None:
    # '..' as CHARACTERS inside a single component (not a '..' segment) is a
    # legit filename — the old '..' substring ban false-rejected this; the
    # segment check allows it (it resolves safely under base).
    resolved = _resolve_contained_path(tmp_path, "v1..v2.md")
    assert resolved == (tmp_path / "v1..v2.md").resolve()
    assert tmp_path.resolve() in resolved.parents


def test_resolve_accepts_nested_relative(tmp_path: Path) -> None:
    resolved = _resolve_contained_path(tmp_path, "backend/api/endpoints.md")
    assert resolved == (tmp_path / "backend/api/endpoints.md").resolve()
    assert tmp_path.resolve() in resolved.parents


# ---------------------------------------------------------------------------
# Service sinks — read_doc / delete_doc must reject the absolute bypass
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_read_doc_rejects_absolute_path(
    tmp_path: Path, tmp_path_factory: TempPathFactory
) -> None:
    # A file OUTSIDE the docs base — an absolute `path` must not reach its sink.
    outside = tmp_path_factory.mktemp("outside") / "secret.md"
    outside.write_text("secret", encoding="utf-8")
    svc = DocsService(MagicMock())
    with (
        patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path),
        pytest.raises(ValidationError),
    ):
        await svc.read_doc(agent_id="be-doc", path=str(outside))
    # Unchanged: the outside file is intact (the bypass would have read it).
    assert outside.read_text(encoding="utf-8") == "secret"


@pytest.mark.asyncio
async def test_delete_doc_rejects_absolute_path(
    tmp_path: Path, tmp_path_factory: TempPathFactory
) -> None:
    # A file OUTSIDE the docs base — an absolute `path` must not reach unlink.
    outside = tmp_path_factory.mktemp("outside") / "marker.md"
    outside.write_text("marker", encoding="utf-8")
    svc = DocsService(MagicMock())
    with (
        patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path),
        pytest.raises(ValidationError),
    ):
        await svc.delete_doc(agent_id="be-doc", path=str(outside))
    # The bypass would have DELETED this file — it must survive.
    assert outside.exists()


@pytest.mark.asyncio
async def test_read_doc_rejects_traversal(tmp_path: Path) -> None:
    svc = DocsService(MagicMock())
    with (
        patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path),
        pytest.raises(ValidationError),
    ):
        await svc.read_doc(agent_id="be-doc", path="backend/../../etc/passwd")


@pytest.mark.asyncio
async def test_read_doc_rejects_dot_segment_cleanly(tmp_path: Path) -> None:
    # path='.' must raise ValidationError (clean 400), NOT reach the read sink
    # and 500 on a directory. Proves the guard fails graceful, not loud.
    svc = DocsService(MagicMock())
    with (
        patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path),
        pytest.raises(ValidationError),
    ):
        await svc.read_doc(agent_id="be-doc", path=".")


@pytest.mark.asyncio
async def test_read_doc_normal_relative_still_works(tmp_path: Path) -> None:
    # Regression guard: a legit nested doc reads fine under the new guard.
    doc = tmp_path / "backend" / "api" / "endpoints.md"
    doc.parent.mkdir(parents=True, exist_ok=True)
    doc.write_text("# Endpoints", encoding="utf-8")
    svc = DocsService(MagicMock())
    with patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path):
        content, size = await svc.read_doc(
            agent_id="be-doc", path="backend/api/endpoints.md"
        )
    assert content == "# Endpoints"
    assert size == len("# Endpoints")
