from __future__ import annotations

import io
import os
import tarfile
import zipfile
from pathlib import Path

import pytest

from agents.sandbox import SandboxArchiveLimits
from agents.sandbox.entries import GCSMount, InContainerMountStrategy, MountpointMountPattern
from agents.sandbox.errors import (
    InvalidCompressionSchemeError,
    InvalidManifestPathError,
    WorkspaceArchiveWriteError,
)
from agents.sandbox.files import EntryKind, FileEntry
from agents.sandbox.manifest import Manifest
from agents.sandbox.sandboxes.unix_local import (
    UnixLocalSandboxSession,
    UnixLocalSandboxSessionState,
)
from agents.sandbox.session.archive_extraction import zipfile_compatible_stream
from agents.sandbox.session.archive_ops import extract_archive
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
from agents.sandbox.snapshot import NoopSnapshot
from agents.sandbox.types import ExecResult, Permissions


def _build_session(tmp_path: Path) -> UnixLocalSandboxSession:
    state = UnixLocalSandboxSessionState(
        manifest=Manifest(root=str(tmp_path / "workspace")),
        snapshot=NoopSnapshot(id="noop"),
    )
    return UnixLocalSandboxSession.from_state(state)


class _CountingExtractSession(BaseSandboxSession):
    def __init__(self, workspace_root: Path) -> None:
        self.state = UnixLocalSandboxSessionState(
            manifest=Manifest(root=str(workspace_root)),
            snapshot=NoopSnapshot(id="noop"),
        )
        self.ls_calls: list[Path] = []

    async def _exec_internal(
        self,
        *command: str | Path,
        timeout: float | None = None,
    ) -> ExecResult:
        _ = (command, timeout)
        raise AssertionError("exec() should not be called in this test")

    async def read(self, path: Path, *, user: object = None) -> io.IOBase:
        _ = user
        return self.normalize_path(path).open("rb")

    async def write(self, path: Path, data: io.IOBase, *, user: object = None) -> None:
        _ = user
        workspace_path = self.normalize_path(path)
        workspace_path.parent.mkdir(parents=True, exist_ok=True)
        payload = data.read()
        if isinstance(payload, str):
            payload = payload.encode("utf-8")
        workspace_path.write_bytes(payload)

    async def running(self) -> bool:
        return True

    async def persist_workspace(self) -> io.IOBase:
        return io.BytesIO()

    async def hydrate_workspace(self, data: io.IOBase) -> None:
        _ = data

    async def shutdown(self) -> None:
        return

    async def mkdir(
        self,
        path: Path | str,
        *,
        parents: bool = False,
        user: object = None,
    ) -> None:
        _ = user
        self.normalize_path(path).mkdir(parents=parents, exist_ok=True)

    async def ls(
        self,
        path: Path | str,
        *,
        user: object = None,
    ) -> list[FileEntry]:
        _ = user
        directory = self.normalize_path(path)
        self.ls_calls.append(directory)
        if not directory.exists():
            raise AssertionError(f"ls() called for missing directory: {directory}")

        entries: list[FileEntry] = []
        for child in directory.iterdir():
            if child.is_symlink():
                kind = EntryKind.SYMLINK
            elif child.is_dir():
                kind = EntryKind.DIRECTORY
            else:
                kind = EntryKind.FILE
            entries.append(
                FileEntry(
                    path=str(child),
                    permissions=Permissions(),
                    owner="root",
                    group="root",
                    size=0,
                    kind=kind,
                )
            )
        return entries


def _tar_bytes(*, members: dict[str, bytes]) -> io.BytesIO:
    buf = io.BytesIO()
    with tarfile.open(fileobj=buf, mode="w") as archive:
        for name, payload in members.items():
            info = tarfile.TarInfo(name=name)
            info.size = len(payload)
            archive.addfile(info, io.BytesIO(payload))
    buf.seek(0)
    return buf


def _zip_bytes(*, members: dict[str, bytes]) -> io.BytesIO:
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, mode="w") as archive:
        for name, payload in members.items():
            archive.writestr(name, payload)
    buf.seek(0)
    return buf


async def _assert_extract_rejects_member(
    tmp_path: Path,
    archive_name: str,
    data: io.IOBase,
    *,
    expected_member: str,
    expected_reason: str,
) -> Path:
    session = _build_session(tmp_path)
    await session.start()
    try:
        workspace = Path(session.state.manifest.root)
        with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
            await session.extract(archive_name, data)

        assert exc_info.value.context["member"] == expected_member
        assert exc_info.value.context["reason"] == expected_reason
        return workspace
    finally:
        await session.shutdown()


@pytest.mark.asyncio
async def test_extract_tar_writes_archive_and_unpacks_contents(tmp_path: Path) -> None:
    session = _build_session(tmp_path)
    await session.start()
    try:
        await session.extract(
            "bundle.tar",
            _tar_bytes(members={"nested/hello.txt": b"hello from tar"}),
        )
    finally:
        await session.shutdown()

    workspace = Path(session.state.manifest.root)
    assert (workspace / "bundle.tar").is_file()
    assert (workspace / "nested" / "hello.txt").read_text(encoding="utf-8") == "hello from tar"


@pytest.mark.asyncio
async def test_extract_zip_writes_archive_and_unpacks_contents(tmp_path: Path) -> None:
    session = _build_session(tmp_path)
    await session.start()
    try:
        await session.extract(
            "bundle.zip",
            _zip_bytes(members={"nested/hello.txt": b"hello from zip"}),
        )
    finally:
        await session.shutdown()

    workspace = Path(session.state.manifest.root)
    assert (workspace / "bundle.zip").is_file()
    assert (workspace / "nested" / "hello.txt").read_text(encoding="utf-8") == "hello from zip"


@pytest.mark.asyncio
async def test_extract_default_archive_limits_none_preserves_no_resource_limit_behavior(
    tmp_path: Path,
) -> None:
    session = _build_session(tmp_path)
    await session.start()
    try:
        await session.extract(
            "bundle.tar",
            _tar_bytes(members={"one.txt": b"1", "two.txt": b"2"}),
        )
    finally:
        await session.shutdown()

    workspace = Path(session.state.manifest.root)
    assert (workspace / "one.txt").read_text(encoding="utf-8") == "1"
    assert (workspace / "two.txt").read_text(encoding="utf-8") == "2"


def test_sandbox_archive_limits_defaults_enable_sdk_thresholds() -> None:
    limits = SandboxArchiveLimits()

    assert limits.max_input_bytes == 1024 * 1024 * 1024
    assert limits.max_extracted_bytes == 4 * 1024 * 1024 * 1024
    assert limits.max_members == 100_000


@pytest.mark.asyncio
async def test_extract_archive_rejects_missing_compression_scheme(tmp_path: Path) -> None:
    session = _CountingExtractSession(tmp_path / "workspace")

    with pytest.raises(InvalidCompressionSchemeError) as exc_info:
        await extract_archive(session, "bundle", io.BytesIO(b"not an archive"))

    assert exc_info.value.context["path"] == "bundle"
    assert exc_info.value.context["scheme"] is None


@pytest.mark.parametrize(
    ("kwargs", "message"),
    [
        ({"max_input_bytes": 0}, "archive_limits.max_input_bytes must be at least 1"),
        ({"max_extracted_bytes": 0}, "archive_limits.max_extracted_bytes must be at least 1"),
        ({"max_members": 0}, "archive_limits.max_members must be at least 1"),
    ],
)
def test_sandbox_archive_limits_rejects_non_positive_values(
    kwargs: dict[str, int],
    message: str,
) -> None:
    with pytest.raises(ValueError) as exc_info:
        SandboxArchiveLimits(**kwargs)

    assert str(exc_info.value) == message


@pytest.mark.asyncio
async def test_extract_rejects_archive_input_over_limit(tmp_path: Path) -> None:
    session = _build_session(tmp_path)
    await session.start()
    try:
        with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
            await session.extract(
                "bundle.zip",
                _ChunkedBinaryStream([b"123", b"45"]),
                archive_limits=SandboxArchiveLimits(
                    max_input_bytes=4,
                    max_extracted_bytes=None,
                    max_members=None,
                ),
            )

        assert exc_info.value.context["reason"] == "archive input size exceeds limit"
        assert exc_info.value.context["limit"] == 4
        assert exc_info.value.context["actual"] == 5
    finally:
        await session.shutdown()

    workspace = Path(session.state.manifest.root)
    assert not (workspace / "bundle.zip").exists()


@pytest.mark.asyncio
async def test_extract_tar_rejects_extracted_bytes_over_limit(
    tmp_path: Path,
) -> None:
    session = _build_session(tmp_path)
    await session.start()
    try:
        with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
            await session.extract(
                "bundle.tar",
                _tar_bytes(members={"large.txt": b"12345"}),
                archive_limits=SandboxArchiveLimits(
                    max_input_bytes=None,
                    max_extracted_bytes=4,
                    max_members=None,
                ),
            )

        assert exc_info.value.context["member"] == "large.txt"
        assert exc_info.value.context["reason"] == "archive extracted size exceeds limit"
        assert exc_info.value.context["limit"] == 4
        assert exc_info.value.context["actual"] == 5
    finally:
        await session.shutdown()

    workspace = Path(session.state.manifest.root)
    assert not (workspace / "large.txt").exists()


@pytest.mark.asyncio
async def test_extract_zip_rejects_extracted_bytes_over_limit(
    tmp_path: Path,
) -> None:
    session = _build_session(tmp_path)
    await session.start()
    try:
        with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
            await session.extract(
                "bundle.zip",
                _zip_bytes(members={"large.txt": b"12345"}),
                archive_limits=SandboxArchiveLimits(
                    max_input_bytes=None,
                    max_extracted_bytes=4,
                    max_members=None,
                ),
            )

        assert exc_info.value.context["member"] == "large.txt"
        assert exc_info.value.context["reason"] == "archive extracted size exceeds limit"
        assert exc_info.value.context["limit"] == 4
        assert exc_info.value.context["actual"] == 5
    finally:
        await session.shutdown()

    workspace = Path(session.state.manifest.root)
    assert not (workspace / "large.txt").exists()


@pytest.mark.asyncio
async def test_extract_tar_rejects_member_count_over_limit(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Path,
) -> None:
    data = _tar_bytes(members={"one.txt": b"1", "two.txt": b"2"})

    def fail_getmembers(_self: tarfile.TarFile) -> list[tarfile.TarInfo]:
        raise AssertionError("tar extraction should not materialize all members")

    monkeypatch.setattr(tarfile.TarFile, "getmembers", fail_getmembers)

    session = _build_session(tmp_path)
    await session.start()
    try:
        with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
            await session.extract(
                "bundle.tar",
                data,
                archive_limits=SandboxArchiveLimits(
                    max_input_bytes=None,
                    max_extracted_bytes=None,
                    max_members=1,
                ),
            )

        assert exc_info.value.context["member"] == "two.txt"
        assert exc_info.value.context["reason"] == "archive member count exceeds limit"
        assert exc_info.value.context["limit"] == 1
        assert exc_info.value.context["actual"] == 2
    finally:
        await session.shutdown()

    workspace = Path(session.state.manifest.root)
    assert not (workspace / "one.txt").exists()
    assert not (workspace / "two.txt").exists()


@pytest.mark.asyncio
async def test_extract_zip_rejects_member_count_over_limit(
    tmp_path: Path,
) -> None:
    session = _build_session(tmp_path)
    await session.start()
    try:
        with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
            await session.extract(
                "bundle.zip",
                _zip_bytes(members={"one.txt": b"1", "two.txt": b"2"}),
                archive_limits=SandboxArchiveLimits(
                    max_input_bytes=None,
                    max_extracted_bytes=None,
                    max_members=1,
                ),
            )

        assert exc_info.value.context["member"] == "two.txt"
        assert exc_info.value.context["reason"] == "archive member count exceeds limit"
        assert exc_info.value.context["limit"] == 1
        assert exc_info.value.context["actual"] == 2
    finally:
        await session.shutdown()

    workspace = Path(session.state.manifest.root)
    assert not (workspace / "one.txt").exists()
    assert not (workspace / "two.txt").exists()


@pytest.mark.asyncio
async def test_extract_archive_limits_none_disables_only_selected_limits(
    tmp_path: Path,
) -> None:
    session = _build_session(tmp_path)
    await session.start()
    try:
        await session.extract(
            "bundle.tar",
            _tar_bytes(members={"large.txt": b"12345"}),
            archive_limits=SandboxArchiveLimits(
                max_input_bytes=None,
                max_extracted_bytes=None,
                max_members=1,
            ),
        )
    finally:
        await session.shutdown()

    workspace = Path(session.state.manifest.root)
    assert (workspace / "large.txt").read_text(encoding="utf-8") == "12345"


@pytest.mark.asyncio
async def test_extract_archive_limits_per_call_override_session_default(
    tmp_path: Path,
) -> None:
    session = _build_session(tmp_path)
    session._set_archive_limits(
        SandboxArchiveLimits(max_input_bytes=None, max_extracted_bytes=None, max_members=1)
    )
    await session.start()
    try:
        await session.extract(
            "bundle.tar",
            _tar_bytes(members={"one.txt": b"1", "two.txt": b"2"}),
            archive_limits=SandboxArchiveLimits(
                max_input_bytes=None,
                max_extracted_bytes=None,
                max_members=2,
            ),
        )
    finally:
        await session.shutdown()

    workspace = Path(session.state.manifest.root)
    assert (workspace / "one.txt").read_text(encoding="utf-8") == "1"
    assert (workspace / "two.txt").read_text(encoding="utf-8") == "2"


@pytest.mark.asyncio
async def test_extract_uses_session_default_archive_limits(
    tmp_path: Path,
) -> None:
    session = _build_session(tmp_path)
    session._set_archive_limits(
        SandboxArchiveLimits(max_input_bytes=None, max_extracted_bytes=None, max_members=1)
    )
    await session.start()
    try:
        with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
            await session.extract(
                "bundle.tar",
                _tar_bytes(members={"one.txt": b"1", "two.txt": b"2"}),
            )

        assert exc_info.value.context["member"] == "two.txt"
        assert exc_info.value.context["reason"] == "archive member count exceeds limit"
        assert exc_info.value.context["limit"] == 1
        assert exc_info.value.context["actual"] == 2
    finally:
        await session.shutdown()


@pytest.mark.asyncio
async def test_extract_archive_limits_object_with_all_none_overrides_session_default(
    tmp_path: Path,
) -> None:
    session = _build_session(tmp_path)
    session._set_archive_limits(
        SandboxArchiveLimits(max_input_bytes=None, max_extracted_bytes=None, max_members=1)
    )
    await session.start()
    try:
        await session.extract(
            "bundle.tar",
            _tar_bytes(members={"one.txt": b"1", "two.txt": b"2"}),
            archive_limits=SandboxArchiveLimits(
                max_input_bytes=None,
                max_extracted_bytes=None,
                max_members=None,
            ),
        )
    finally:
        await session.shutdown()

    workspace = Path(session.state.manifest.root)
    assert (workspace / "one.txt").read_text(encoding="utf-8") == "1"
    assert (workspace / "two.txt").read_text(encoding="utf-8") == "2"


@pytest.mark.asyncio
async def test_extract_rejects_invalid_per_call_archive_limits(
    tmp_path: Path,
) -> None:
    limits = SandboxArchiveLimits(max_input_bytes=1)
    limits.max_input_bytes = 0
    session = _build_session(tmp_path)
    await session.start()
    try:
        with pytest.raises(ValueError) as exc_info:
            await session.extract(
                "bundle.tar",
                _tar_bytes(members={"one.txt": b"1"}),
                archive_limits=limits,
            )

        assert str(exc_info.value) == "archive_limits.max_input_bytes must be at least 1"
    finally:
        await session.shutdown()


class _NoSeekableZipStream(io.IOBase):
    def __init__(self, payload: bytes) -> None:
        self._buffer = io.BytesIO(payload)

    def tell(self) -> int:
        return self._buffer.tell()

    def seek(self, offset: int, whence: int = io.SEEK_SET) -> int:
        return self._buffer.seek(offset, whence)

    def read(self, size: int = -1) -> bytes:
        return self._buffer.read(size)


class _ChunkedBinaryStream(io.IOBase):
    def __init__(self, chunks: list[bytes]) -> None:
        self._chunks = list(chunks)
        self.headers = {"Content-Length": str(sum(len(chunk) for chunk in chunks))}

    def read(self, size: int = -1) -> bytes:
        if not self._chunks:
            return b""
        if size < 0:
            data = b"".join(self._chunks)
            self._chunks.clear()
            return data

        remaining = size
        out = bytearray()
        while remaining > 0 and self._chunks:
            chunk = self._chunks[0]
            if len(chunk) <= remaining:
                out.extend(self._chunks.pop(0))
                remaining -= len(chunk)
                continue
            out.extend(chunk[:remaining])
            self._chunks[0] = chunk[remaining:]
            remaining = 0
        return bytes(out)


class _SeekableFalseZipStream(io.IOBase):
    def __init__(self, payload: bytes) -> None:
        self._buffer = io.BytesIO(payload)

    def seekable(self) -> bool:
        return False

    def read(self, size: int = -1) -> bytes:
        return self._buffer.read(size)


def test_zipfile_compatible_stream_supports_streams_without_seekable() -> None:
    raw_stream = _NoSeekableZipStream(_zip_bytes(members={"file.txt": b"hello"}).getvalue())

    with zipfile_compatible_stream(raw_stream) as compatible:
        assert compatible.seekable() is True
        with zipfile.ZipFile(compatible) as archive:
            assert archive.read("file.txt") == b"hello"


def test_zipfile_compatible_stream_buffers_streams_with_seekable_false() -> None:
    raw_stream = _SeekableFalseZipStream(_zip_bytes(members={"file.txt": b"hello"}).getvalue())

    with zipfile_compatible_stream(raw_stream) as compatible:
        assert compatible.seekable() is True
        with zipfile.ZipFile(compatible) as archive:
            assert archive.read("file.txt") == b"hello"


@pytest.mark.asyncio
async def test_unix_local_write_accepts_chunked_non_seekable_binary_stream(tmp_path: Path) -> None:
    session = _build_session(tmp_path)
    await session.start()
    try:
        await session.write(
            Path("streamed.bin"),
            _ChunkedBinaryStream([b"hello ", b"from ", b"stream"]),
        )
    finally:
        await session.shutdown()

    workspace = Path(session.state.manifest.root)
    assert (workspace / "streamed.bin").read_bytes() == b"hello from stream"


@pytest.mark.asyncio
async def test_extract_tar_rejects_symlinked_parent_paths(tmp_path: Path) -> None:
    session = _build_session(tmp_path)
    await session.start()
    try:
        workspace = Path(session.state.manifest.root)
        outside = tmp_path / "outside"
        outside.mkdir()
        os.symlink(outside, workspace / "link", target_is_directory=True)

        with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
            await session.extract(
                "bundle.tar",
                _tar_bytes(members={"link/hello.txt": b"hello from tar"}),
            )

        assert exc_info.value.context["member"] == "link/hello.txt"
        assert exc_info.value.context["reason"] == "symlink in parent path: link"
        assert not (outside / "hello.txt").exists()
    finally:
        await session.shutdown()


@pytest.mark.asyncio
async def test_extract_zip_rejects_symlinked_parent_paths(tmp_path: Path) -> None:
    session = _build_session(tmp_path)
    await session.start()
    try:
        workspace = Path(session.state.manifest.root)
        outside = tmp_path / "outside"
        outside.mkdir()
        os.symlink(outside, workspace / "link", target_is_directory=True)

        with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
            await session.extract(
                "bundle.zip",
                _zip_bytes(members={"link/hello.txt": b"hello from zip"}),
            )

        assert exc_info.value.context["member"] == "link/hello.txt"
        assert exc_info.value.context["reason"] == "symlink in parent path: link"
        assert not (outside / "hello.txt").exists()
    finally:
        await session.shutdown()


@pytest.mark.asyncio
async def test_unix_local_hydrate_workspace_rejects_external_symlink_targets(
    tmp_path: Path,
) -> None:
    session = _build_session(tmp_path)
    await session.start()
    try:
        archive = io.BytesIO()
        with tarfile.open(fileobj=archive, mode="w") as tar:
            info = tarfile.TarInfo(name="leak")
            info.type = tarfile.SYMTYPE
            info.linkname = "/etc/passwd"
            tar.addfile(info)
        archive.seek(0)

        with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
            await session.hydrate_workspace(archive)

        assert exc_info.value.context["member"] == "leak"
        assert (
            exc_info.value.context["reason"] == "absolute symlink target not allowed: /etc/passwd"
        )
        assert not (Path(session.state.manifest.root) / "leak").exists()
    finally:
        await session.shutdown()


@pytest.mark.asyncio
async def test_extract_tar_rejects_windows_drive_member_paths(tmp_path: Path) -> None:
    await _assert_extract_rejects_member(
        tmp_path,
        "bundle.tar",
        _tar_bytes(members={"C:/tmp/evil.txt": b"evil"}),
        expected_member="C:/tmp/evil.txt",
        expected_reason="windows drive path",
    )


@pytest.mark.asyncio
async def test_extract_zip_rejects_windows_drive_member_paths(tmp_path: Path) -> None:
    await _assert_extract_rejects_member(
        tmp_path,
        "bundle.zip",
        _zip_bytes(members={r"C:\tmp\evil.txt": b"evil"}),
        expected_member=r"C:\tmp\evil.txt",
        expected_reason="windows drive path",
    )


@pytest.mark.asyncio
async def test_extract_tar_rejects_windows_separator_member_paths(tmp_path: Path) -> None:
    await _assert_extract_rejects_member(
        tmp_path,
        "bundle.tar",
        _tar_bytes(members={r"..\evil.txt": b"evil"}),
        expected_member=r"..\evil.txt",
        expected_reason="windows path separator",
    )


@pytest.mark.asyncio
async def test_extract_zip_rejects_windows_separator_member_paths(tmp_path: Path) -> None:
    await _assert_extract_rejects_member(
        tmp_path,
        "bundle.zip",
        _zip_bytes(members={r"\evil.txt": b"evil"}),
        expected_member=r"\evil.txt",
        expected_reason="windows path separator",
    )


@pytest.mark.asyncio
async def test_extract_tar_rejects_member_under_non_directory_member(tmp_path: Path) -> None:
    workspace = await _assert_extract_rejects_member(
        tmp_path,
        "bundle.tar",
        _tar_bytes(
            members={
                "nested/hello.txt": b"hello from tar",
                "nested": b"not a directory",
            }
        ),
        expected_member="nested/hello.txt",
        expected_reason="archive path descends through non-directory: nested",
    )

    assert not (workspace / "nested").exists()


@pytest.mark.asyncio
async def test_extract_zip_rejects_member_under_non_directory_member(tmp_path: Path) -> None:
    workspace = await _assert_extract_rejects_member(
        tmp_path,
        "bundle.zip",
        _zip_bytes(
            members={
                "nested/hello.txt": b"hello from zip",
                "nested": b"not a directory",
            }
        ),
        expected_member="nested/hello.txt",
        expected_reason="archive path descends through non-directory: nested",
    )

    assert not (workspace / "nested").exists()


@pytest.mark.asyncio
async def test_unix_local_persist_workspace_excludes_resolved_mount_path(tmp_path: Path) -> None:
    workspace_root = tmp_path / "workspace"
    actual_mount_path = workspace_root / "actual"
    actual_mount_path.mkdir(parents=True)
    (actual_mount_path / "remote.txt").write_text("remote", encoding="utf-8")
    (workspace_root / "keep.txt").write_text("keep", encoding="utf-8")

    state = UnixLocalSandboxSessionState(
        manifest=Manifest(
            root=str(workspace_root),
            entries={
                "logical": GCSMount(
                    bucket="bucket",
                    mount_path=Path("actual"),
                    mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
                )
            },
        ),
        snapshot=NoopSnapshot(id="noop"),
    )
    session = UnixLocalSandboxSession.from_state(state)

    archive = await session.persist_workspace()

    with tarfile.open(fileobj=archive, mode="r:*") as tar:
        names = set(tar.getnames())

    assert "./keep.txt" in names
    assert "./actual" not in names
    assert "./actual/remote.txt" not in names


@pytest.mark.asyncio
async def test_extract_tar_reuses_directory_listings_during_symlink_checks(tmp_path: Path) -> None:
    workspace = tmp_path / "workspace"
    workspace.mkdir()
    session = _CountingExtractSession(workspace)

    await session.extract(
        "bundle.tar",
        _tar_bytes(
            members={
                "nested/one.txt": b"one",
                "nested/two.txt": b"two",
            }
        ),
    )

    assert (workspace / "nested" / "one.txt").read_text(encoding="utf-8") == "one"
    assert (workspace / "nested" / "two.txt").read_text(encoding="utf-8") == "two"
    assert session.ls_calls == [
        workspace,
        workspace / "nested",
    ]


@pytest.mark.asyncio
async def test_unix_local_helpers_reject_paths_outside_workspace_root(tmp_path: Path) -> None:
    session = _build_session(tmp_path)
    await session.start()
    try:
        with pytest.raises(InvalidManifestPathError, match="must not escape root"):
            await session.ls("../outside")
        with pytest.raises(InvalidManifestPathError, match="must not escape root"):
            await session.mkdir("../outside", parents=True)
        with pytest.raises(InvalidManifestPathError, match="must not escape root"):
            await session.rm("../outside")
        with pytest.raises(InvalidManifestPathError, match="must be relative"):
            await session.extract("/tmp/bundle.tar", _tar_bytes(members={"a.txt": b"a"}))
    finally:
        await session.shutdown()


@pytest.mark.asyncio
async def test_unix_local_helpers_reject_symlink_escape_paths(tmp_path: Path) -> None:
    session = _build_session(tmp_path)
    await session.start()
    try:
        workspace = Path(session.state.manifest.root)
        outside = tmp_path / "outside"
        outside.mkdir()
        os.symlink(outside, workspace / "link", target_is_directory=True)

        with pytest.raises(InvalidManifestPathError, match="must not escape root"):
            await session.mkdir("link/nested", parents=True)
        with pytest.raises(InvalidManifestPathError, match="must not escape root"):
            await session.ls("link")
    finally:
        await session.shutdown()
