# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Tests verify that malicious sparse tensors are rejected before they can trigger
out-of-bounds memory writes during to_dense() operations.
"""

import io

import numpy as np
import pybase64 as base64
import pytest
import torch

from vllm.exceptions import VLLMValidationError
from vllm.multimodal.media import AudioEmbeddingMediaIO, ImageEmbeddingMediaIO
from vllm.renderers.embed_utils import safe_load_prompt_embeds


@pytest.fixture
def model_config():
    """Mock ModelConfig for testing."""
    from vllm.config import ModelConfig

    return ModelConfig(
        model="facebook/opt-125m",
        tokenizer="facebook/opt-125m",
        tokenizer_mode="auto",
        trust_remote_code=False,
        dtype="float32",
        seed=0,
        enable_prompt_embeds=True,  # Required for prompt embeds tests
    )


def _encode_tensor(tensor: torch.Tensor) -> bytes:
    """Helper to encode a tensor as base64 bytes."""
    buffer = io.BytesIO()
    torch.save(tensor, buffer)
    buffer.seek(0)
    return base64.b64encode(buffer.read())


def _create_malicious_sparse_tensor() -> torch.Tensor:
    """
    Create a malicious sparse COO tensor with out-of-bounds indices.

    This tensor has indices that point beyond the declared shape, which would
    cause an out-of-bounds write when converted to dense format without
    validation.
    """
    # Create a 3x3 sparse tensor but with indices pointing to (10, 10)
    indices = torch.tensor([[10], [10]])  # Out of bounds for 3x3 shape
    values = torch.tensor([1.0])
    shape = (3, 3)

    # Create sparse tensor (this will be invalid). Pass `check_invariants=False`
    # explicitly so this fixture is robust to process-wide invariant-check state
    # left enabled by other tests (the global flag isn't thread-local, and
    # concurrent users of the `check_sparse_tensor_invariants` context manager
    # can leak the "enabled" state across tests).
    sparse_tensor = torch.sparse_coo_tensor(
        indices, values, shape, dtype=torch.float32, check_invariants=False
    )
    return sparse_tensor


def _create_valid_sparse_tensor() -> torch.Tensor:
    """Create a valid sparse COO tensor for baseline testing."""
    indices = torch.tensor([[0, 1, 2], [0, 1, 2]])
    values = torch.tensor([1.0, 2.0, 3.0])
    shape = (3, 3)

    sparse_tensor = torch.sparse_coo_tensor(indices, values, shape, dtype=torch.float32)
    return sparse_tensor


def _create_valid_dense_tensor() -> torch.Tensor:
    """Create a valid dense tensor for baseline testing."""
    return torch.randn(10, 768, dtype=torch.float32)  # (seq_len, hidden_size)


class TestPromptEmbedsValidation:
    """Test sparse tensor validation in prompt embeddings (Completions API)."""

    def test_valid_dense_tensor_accepted(self, model_config):
        """Baseline: Valid dense tensors should work normally."""
        valid_tensor = _create_valid_dense_tensor()
        encoded = _encode_tensor(valid_tensor)

        # Should not raise any exception
        result = safe_load_prompt_embeds(model_config, encoded)
        assert result.shape == valid_tensor.shape

    def test_valid_sparse_tensor_accepted(self):
        """Baseline: Valid sparse tensors should load successfully."""
        io_handler = ImageEmbeddingMediaIO()

        valid_sparse = _create_valid_sparse_tensor()
        encoded = _encode_tensor(valid_sparse)

        # Should not raise any exception (sparse tensors remain sparse)
        result = io_handler.load_base64("", encoded.decode("utf-8"))
        assert result.shape == valid_sparse.shape

    def test_malicious_sparse_tensor_rejected(self, model_config):
        """Security: Malicious sparse tensors should be rejected."""
        malicious_tensor = _create_malicious_sparse_tensor()
        encoded = _encode_tensor(malicious_tensor)

        # Should raise RuntimeError due to invalid sparse tensor
        with pytest.raises((RuntimeError, ValueError)) as exc_info:
            safe_load_prompt_embeds(model_config, encoded)

        # Error should indicate sparse tensor validation failure
        error_msg = str(exc_info.value).lower()
        assert "sparse" in error_msg or "index" in error_msg or "bounds" in error_msg

    def test_extremely_large_indices_rejected(self, model_config):
        """Security: Sparse tensors with extremely large indices should be rejected."""
        # Create tensor with indices far beyond reasonable bounds
        indices = torch.tensor([[999999], [999999]])
        values = torch.tensor([1.0])
        shape = (10, 10)

        malicious_tensor = torch.sparse_coo_tensor(
            indices, values, shape, dtype=torch.float32, check_invariants=False
        )
        encoded = _encode_tensor(malicious_tensor)

        with pytest.raises((RuntimeError, ValueError)):
            safe_load_prompt_embeds(model_config, encoded)

    def test_negative_indices_rejected(self, model_config):
        """Security: Sparse tensors with negative indices should be rejected."""
        # Create tensor with negative indices
        indices = torch.tensor([[-1], [-1]])
        values = torch.tensor([1.0])
        shape = (10, 10)

        malicious_tensor = torch.sparse_coo_tensor(
            indices, values, shape, dtype=torch.float32, check_invariants=False
        )
        encoded = _encode_tensor(malicious_tensor)

        with pytest.raises((RuntimeError, ValueError)):
            safe_load_prompt_embeds(model_config, encoded)

    def test_hidden_size_mismatch_rejected(self, model_config):
        """Tensors whose trailing dim doesn't match the model's hidden_size
        must be rejected at parse time."""
        # opt-125m has hidden_size=768, passing 512 triggers the check.
        wrong_hidden = torch.randn(10, 512, dtype=torch.float32)
        encoded = _encode_tensor(wrong_hidden)

        with pytest.raises(VLLMValidationError, match="hidden_size"):
            safe_load_prompt_embeds(model_config, encoded)

    def test_float_dtype_mismatch_cast_to_model_dtype(self, model_config):
        """Tensors whose dtype doesn't match the model's dtype but are still
        floating-point are cast, since API clients generally can't know the
        server's `--dtype` setting ahead of time."""
        # Fixture pins model dtype to float32, upload a bfloat16 tensor.
        mismatched_float = torch.randn(10, 768, dtype=torch.bfloat16)
        encoded = _encode_tensor(mismatched_float)

        result = safe_load_prompt_embeds(model_config, encoded)

        assert result.dtype == torch.float32
        assert result.shape == mismatched_float.shape

    def test_non_float_dtype_rejected(self, model_config):
        """Non-floating-point dtypes cannot be safely cast for embeddings
        (e.g. integer tensors almost certainly indicate caller confusion),
        so they are rejected at parse time."""
        non_float = torch.randint(0, 100, (10, 768), dtype=torch.int32)
        encoded = _encode_tensor(non_float)

        with pytest.raises(VLLMValidationError, match="floating-point"):
            safe_load_prompt_embeds(model_config, encoded)

    def test_non_2d_tensor_rejected(self, model_config):
        """Tensors that aren't 2D (even after squeezing a leading dim)
        must be rejected with a clear error."""
        # A 1D tensor cannot be interpreted as (num_tokens, hidden_size).
        bad = torch.randn(768, dtype=torch.float32)
        encoded = _encode_tensor(bad)

        with pytest.raises(VLLMValidationError, match="2D tensor"):
            safe_load_prompt_embeds(model_config, encoded)

    def test_non_tensor_payload_rejected(self, model_config):
        """Deserializing to a non-Tensor object must raise a clear error
        instead of propagating an AssertionError."""
        # `torch.save` will serialize a plain dict; `weights_only=True` allows
        # loading built-in containers, so this exercises the isinstance check.
        buffer = io.BytesIO()
        torch.save({"not": "a tensor"}, buffer)
        buffer.seek(0)
        encoded = base64.b64encode(buffer.read())

        with pytest.raises(VLLMValidationError, match="torch.Tensor"):
            safe_load_prompt_embeds(model_config, encoded)


class TestImageEmbedsValidation:
    """Test sparse tensor validation in image embeddings (Chat API)."""

    def test_valid_dense_tensor_accepted(self):
        """Baseline: Valid dense tensors should work normally."""
        io_handler = ImageEmbeddingMediaIO()

        valid_tensor = _create_valid_dense_tensor()
        encoded = _encode_tensor(valid_tensor)

        # Should not raise any exception
        result = io_handler.load_base64("", encoded.decode("utf-8"))
        assert result.shape == valid_tensor.shape

    def test_valid_sparse_tensor_accepted(self):
        """Baseline: Valid sparse tensors should load successfully."""
        io_handler = AudioEmbeddingMediaIO()

        valid_sparse = _create_valid_sparse_tensor()
        encoded = _encode_tensor(valid_sparse)

        # Should not raise any exception (sparse tensors remain sparse)
        result = io_handler.load_base64("", encoded.decode("utf-8"))
        assert result.shape == valid_sparse.shape

    def test_malicious_sparse_tensor_rejected(self):
        """Security: Malicious sparse tensors should be rejected."""
        io_handler = ImageEmbeddingMediaIO()

        malicious_tensor = _create_malicious_sparse_tensor()
        encoded = _encode_tensor(malicious_tensor)

        # Should raise RuntimeError due to invalid sparse tensor
        with pytest.raises((RuntimeError, ValueError)) as exc_info:
            io_handler.load_base64("", encoded.decode("utf-8"))

        error_msg = str(exc_info.value).lower()
        assert "sparse" in error_msg or "index" in error_msg or "bounds" in error_msg

    def test_load_bytes_validates(self):
        """Security: Validation should also work for load_bytes method."""
        io_handler = ImageEmbeddingMediaIO()

        malicious_tensor = _create_malicious_sparse_tensor()
        buffer = io.BytesIO()
        torch.save(malicious_tensor, buffer)
        buffer.seek(0)

        with pytest.raises((RuntimeError, ValueError)):
            io_handler.load_bytes(buffer.read())

    def test_valid_numpy_tensor_accepted(self):
        """numpy .npy format should load and return correct tensor."""
        io_handler = ImageEmbeddingMediaIO()

        arr = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=np.float32)
        buf = io.BytesIO()
        np.save(buf, arr)
        encoded = base64.b64encode(buf.getvalue()).decode("utf-8")

        result = io_handler.load_base64("", encoded)
        assert isinstance(result, torch.Tensor)
        assert result.shape == torch.Size([2, 3])
        assert result.dtype == torch.float32
        assert torch.allclose(result, torch.from_numpy(arr))

    def test_numpy_int32_tensor_accepted(self):
        """numpy int32 arrays should round-trip correctly."""

        io_handler = ImageEmbeddingMediaIO()

        arr = np.arange(280, dtype=np.int32)
        buf = io.BytesIO()
        np.save(buf, arr)
        encoded = base64.b64encode(buf.getvalue()).decode("utf-8")

        result = io_handler.load_base64("", encoded)
        assert result.dtype == torch.int32
        assert result.shape == torch.Size([280])
        assert (result == torch.from_numpy(arr)).all()

    def test_load_file_numpy_tensor_accepted(self, tmp_path):
        """numpy .npy files should load correctly via load_file."""

        io_handler = ImageEmbeddingMediaIO()

        arr = np.array([[1.5, 2.5], [3.5, 4.5]], dtype=np.float32)
        npy_path = tmp_path / "image_embeds.npy"
        np.save(npy_path, arr)

        result = io_handler.load_file(npy_path)
        assert isinstance(result, torch.Tensor)
        assert result.shape == torch.Size([2, 2])
        assert result.dtype == torch.float32
        assert torch.allclose(result, torch.from_numpy(arr))


class TestAudioEmbedsValidation:
    """Test sparse tensor validation in audio embeddings (Chat API)."""

    def test_valid_dense_tensor_accepted(self):
        """Baseline: Valid dense tensors should work normally."""
        io_handler = AudioEmbeddingMediaIO()

        valid_tensor = _create_valid_dense_tensor()
        encoded = _encode_tensor(valid_tensor)

        # Should not raise any exception
        result = io_handler.load_base64("", encoded.decode("utf-8"))
        assert result.shape == valid_tensor.shape

    def test_valid_sparse_tensor_accepted(self):
        """Baseline: Valid sparse tensors should be converted successfully."""
        io_handler = AudioEmbeddingMediaIO()

        valid_sparse = _create_valid_sparse_tensor()
        encoded = _encode_tensor(valid_sparse)

        # Should not raise any exception
        result = io_handler.load_base64("", encoded.decode("utf-8"))
        assert result.is_sparse is False

    def test_malicious_sparse_tensor_rejected(self):
        """Security: Malicious sparse tensors should be rejected."""
        io_handler = AudioEmbeddingMediaIO()

        malicious_tensor = _create_malicious_sparse_tensor()
        encoded = _encode_tensor(malicious_tensor)

        # Should raise RuntimeError due to invalid sparse tensor
        with pytest.raises((RuntimeError, ValueError)) as exc_info:
            io_handler.load_base64("", encoded.decode("utf-8"))

        error_msg = str(exc_info.value).lower()
        assert "sparse" in error_msg or "index" in error_msg or "bounds" in error_msg

    def test_load_bytes_validates(self):
        """Security: Validation should also work for load_bytes method."""
        io_handler = AudioEmbeddingMediaIO()

        malicious_tensor = _create_malicious_sparse_tensor()
        buffer = io.BytesIO()
        torch.save(malicious_tensor, buffer)
        buffer.seek(0)

        with pytest.raises((RuntimeError, ValueError)):
            io_handler.load_bytes(buffer.read())


class TestSparseTensorValidationIntegration:
    """
    These tests verify the complete attack chain is blocked at all entry points.
    """

    def test_attack_scenario_completions_api(self, model_config):
        """
        Simulate a complete attack through the Completions API.

        Attack scenario:
        1. Attacker crafts malicious sparse tensor
        2. Encodes it as base64
        3. Sends to /v1/completions with prompt_embeds parameter
        4. Server should reject before memory corruption occurs
        """
        # Step 1-2: Attacker creates malicious payload
        attack_payload = _encode_tensor(_create_malicious_sparse_tensor())

        # Step 3-4: Server processes and should reject
        with pytest.raises((RuntimeError, ValueError)):
            safe_load_prompt_embeds(model_config, attack_payload)

    def test_attack_scenario_chat_api_image(self):
        """
        Simulate attack through Chat API with image_embeds.

        Verifies the image embeddings path is protected.
        """
        io_handler = ImageEmbeddingMediaIO()
        attack_payload = _encode_tensor(_create_malicious_sparse_tensor())

        with pytest.raises((RuntimeError, ValueError)):
            io_handler.load_base64("", attack_payload.decode("utf-8"))

    def test_attack_scenario_chat_api_audio(self):
        """
        Simulate attack through Chat API with audio_embeds.

        Verifies the audio embeddings path is protected.
        """
        io_handler = AudioEmbeddingMediaIO()
        attack_payload = _encode_tensor(_create_malicious_sparse_tensor())

        with pytest.raises((RuntimeError, ValueError)):
            io_handler.load_base64("", attack_payload.decode("utf-8"))
