555e282cc4
pi-agent-plugin checks / lint (push) Has been cancelled
pi-agent-plugin checks / test (20) (push) Has been cancelled
pi-agent-plugin checks / test (22) (push) Has been cancelled
pi-agent-plugin checks / build (push) Has been cancelled
TypeScript SDK CI / check_changes (push) Has been cancelled
TypeScript SDK CI / changelog_check (push) Has been cancelled
ci / changelog_check (push) Has been cancelled
ci / check_changes (push) Has been cancelled
ci / build_mem0 (3.10) (push) Has been cancelled
ci / build_mem0 (3.11) (push) Has been cancelled
ci / build_mem0 (3.12) (push) Has been cancelled
CLI Node CI / lint (push) Has been cancelled
CLI Node CI / test (20) (push) Has been cancelled
CLI Node CI / test (22) (push) Has been cancelled
CLI Node CI / build (push) Has been cancelled
CLI Python CI / lint (push) Has been cancelled
CLI Python CI / test (3.10) (push) Has been cancelled
CLI Python CI / test (3.11) (push) Has been cancelled
CLI Python CI / test (3.12) (push) Has been cancelled
CLI Python CI / build (push) Has been cancelled
openclaw checks / lint (push) Has been cancelled
openclaw checks / test (20) (push) Has been cancelled
openclaw checks / test (22) (push) Has been cancelled
openclaw checks / build (push) Has been cancelled
opencode-plugin checks / build (push) Has been cancelled
TypeScript SDK CI / build_ts_sdk (20) (push) Has been cancelled
TypeScript SDK CI / build_ts_sdk (22) (push) Has been cancelled
TypeScript SDK CI / integration_ts_sdk (20) (push) Has been cancelled
TypeScript SDK CI / integration_ts_sdk (22) (push) Has been cancelled
757 lines
29 KiB
Python
757 lines
29 KiB
Python
import json
|
|
import os
|
|
import pickle
|
|
import tempfile
|
|
from unittest.mock import Mock, patch
|
|
|
|
import faiss
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from mem0.vector_stores.faiss import (
|
|
FAISS,
|
|
OutputData,
|
|
SafeUnpickler,
|
|
_safe_pickle_load,
|
|
_validate_docstore_structure,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_faiss_index():
|
|
index = Mock(spec=faiss.IndexFlatL2)
|
|
index.d = 128 # Dimension of the vectors
|
|
index.ntotal = 0 # Number of vectors in the index
|
|
return index
|
|
|
|
|
|
@pytest.fixture
|
|
def faiss_instance(mock_faiss_index):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
# Mock the faiss index creation
|
|
with patch("faiss.IndexFlatL2", return_value=mock_faiss_index):
|
|
# Mock the faiss.write_index function
|
|
with patch("faiss.write_index"):
|
|
# Create a FAISS instance with a temporary directory
|
|
faiss_store = FAISS(
|
|
collection_name="test_collection",
|
|
path=os.path.join(temp_dir, "test_faiss"),
|
|
distance_strategy="euclidean",
|
|
)
|
|
# Set up the mock index
|
|
faiss_store.index = mock_faiss_index
|
|
yield faiss_store
|
|
|
|
|
|
def test_create_col(faiss_instance, mock_faiss_index):
|
|
# Test creating a collection with euclidean distance
|
|
with patch("faiss.IndexFlatL2", return_value=mock_faiss_index) as mock_index_flat_l2:
|
|
with patch("faiss.write_index"):
|
|
faiss_instance.create_col(name="new_collection")
|
|
mock_index_flat_l2.assert_called_once_with(faiss_instance.embedding_model_dims)
|
|
|
|
# Test creating a collection with inner product distance
|
|
with patch("faiss.IndexFlatIP", return_value=mock_faiss_index) as mock_index_flat_ip:
|
|
with patch("faiss.write_index"):
|
|
faiss_instance.create_col(name="new_collection", distance="inner_product")
|
|
mock_index_flat_ip.assert_called_once_with(faiss_instance.embedding_model_dims)
|
|
|
|
|
|
def test_insert(faiss_instance, mock_faiss_index):
|
|
# Prepare test data
|
|
vectors = [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]
|
|
payloads = [{"name": "vector1"}, {"name": "vector2"}]
|
|
ids = ["id1", "id2"]
|
|
|
|
# Mock the numpy array conversion
|
|
with patch("numpy.array", return_value=np.array(vectors, dtype=np.float32)) as mock_np_array:
|
|
# Mock index.add
|
|
mock_faiss_index.add.return_value = None
|
|
|
|
# Call insert
|
|
faiss_instance.insert(vectors=vectors, payloads=payloads, ids=ids)
|
|
|
|
# Verify numpy.array was called
|
|
mock_np_array.assert_called_once_with(vectors, dtype=np.float32)
|
|
|
|
# Verify index.add was called
|
|
mock_faiss_index.add.assert_called_once()
|
|
|
|
# Verify docstore and index_to_id were updated
|
|
assert faiss_instance.docstore["id1"] == {"name": "vector1"}
|
|
assert faiss_instance.docstore["id2"] == {"name": "vector2"}
|
|
assert faiss_instance.index_to_id[0] == "id1"
|
|
assert faiss_instance.index_to_id[1] == "id2"
|
|
|
|
|
|
def test_search(faiss_instance, mock_faiss_index):
|
|
# Prepare test data
|
|
query_vector = [0.1, 0.2, 0.3]
|
|
|
|
# Setup the docstore and index_to_id mapping
|
|
faiss_instance.docstore = {"id1": {"name": "vector1"}, "id2": {"name": "vector2"}}
|
|
faiss_instance.index_to_id = {0: "id1", 1: "id2"}
|
|
|
|
# First, create the mock for the search return values
|
|
search_scores = np.array([[0.9, 0.8]])
|
|
search_indices = np.array([[0, 1]])
|
|
mock_faiss_index.search.return_value = (search_scores, search_indices)
|
|
|
|
# Then patch numpy.array only for the query vector conversion
|
|
with patch("numpy.array") as mock_np_array:
|
|
mock_np_array.return_value = np.array(query_vector, dtype=np.float32)
|
|
|
|
# Then patch _parse_output to return the expected results
|
|
expected_results = [
|
|
OutputData(id="id1", score=0.9, payload={"name": "vector1"}),
|
|
OutputData(id="id2", score=0.8, payload={"name": "vector2"}),
|
|
]
|
|
|
|
with patch.object(faiss_instance, "_parse_output", return_value=expected_results):
|
|
# Call search
|
|
results = faiss_instance.search(query="test query", vectors=query_vector, top_k=2)
|
|
|
|
# Verify numpy.array was called (but we don't check exact call arguments since it's complex)
|
|
assert mock_np_array.called
|
|
|
|
# Verify index.search was called
|
|
mock_faiss_index.search.assert_called_once()
|
|
|
|
# Verify results
|
|
assert len(results) == 2
|
|
assert results[0].id == "id1"
|
|
assert results[0].score == 0.9
|
|
assert results[0].payload == {"name": "vector1"}
|
|
assert results[1].id == "id2"
|
|
assert results[1].score == 0.8
|
|
assert results[1].payload == {"name": "vector2"}
|
|
|
|
|
|
def test_search_with_filters(faiss_instance, mock_faiss_index):
|
|
# Prepare test data
|
|
query_vector = [0.1, 0.2, 0.3]
|
|
|
|
# Setup the docstore and index_to_id mapping
|
|
faiss_instance.docstore = {"id1": {"name": "vector1", "category": "A"}, "id2": {"name": "vector2", "category": "B"}}
|
|
faiss_instance.index_to_id = {0: "id1", 1: "id2"}
|
|
|
|
# First set up the search return values
|
|
search_scores = np.array([[0.9, 0.8]])
|
|
search_indices = np.array([[0, 1]])
|
|
mock_faiss_index.search.return_value = (search_scores, search_indices)
|
|
|
|
# Patch numpy.array for query vector conversion
|
|
with patch("numpy.array") as mock_np_array:
|
|
mock_np_array.return_value = np.array(query_vector, dtype=np.float32)
|
|
|
|
# Directly mock the _parse_output method to return our expected values
|
|
# We're simulating that _parse_output filters to just the first result
|
|
all_results = [
|
|
OutputData(id="id1", score=0.9, payload={"name": "vector1", "category": "A"}),
|
|
OutputData(id="id2", score=0.8, payload={"name": "vector2", "category": "B"}),
|
|
]
|
|
|
|
# Replace the _apply_filters method to handle our test case
|
|
with patch.object(faiss_instance, "_parse_output", return_value=all_results):
|
|
with patch.object(faiss_instance, "_apply_filters", side_effect=lambda p, f: p.get("category") == "A"):
|
|
# Call search with filters
|
|
results = faiss_instance.search(
|
|
query="test query", vectors=query_vector, top_k=2, filters={"category": "A"}
|
|
)
|
|
|
|
# Verify numpy.array was called
|
|
assert mock_np_array.called
|
|
|
|
# Verify index.search was called
|
|
mock_faiss_index.search.assert_called_once()
|
|
|
|
# Verify filtered results - since we've mocked everything,
|
|
# we should get just the result we want
|
|
assert len(results) == 1
|
|
assert results[0].id == "id1"
|
|
assert results[0].score == 0.9
|
|
assert results[0].payload == {"name": "vector1", "category": "A"}
|
|
|
|
|
|
def test_search_with_filters_overfetch_not_truncated(faiss_instance, mock_faiss_index):
|
|
query_vector = [0.1, 0.2, 0.3]
|
|
|
|
# Four stored vectors: the two nearest fail the filter, the next two pass it.
|
|
faiss_instance.docstore = {
|
|
"id1": {"name": "v1", "category": "B"},
|
|
"id2": {"name": "v2", "category": "B"},
|
|
"id3": {"name": "v3", "category": "A"},
|
|
"id4": {"name": "v4", "category": "A"},
|
|
}
|
|
faiss_instance.index_to_id = {0: "id1", 1: "id2", 2: "id3", 3: "id4"}
|
|
|
|
# top_k=2 with filters -> fetch_k = 4; the index returns all four candidates.
|
|
search_scores = np.array([[0.9, 0.8, 0.7, 0.6]])
|
|
search_indices = np.array([[0, 1, 2, 3]])
|
|
mock_faiss_index.search.return_value = (search_scores, search_indices)
|
|
|
|
results = faiss_instance.search(
|
|
query="test query", vectors=query_vector, top_k=2, filters={"category": "A"}
|
|
)
|
|
|
|
# Two matching vectors exist among the over-fetched set, so we must get top_k of them.
|
|
assert len(results) == 2
|
|
assert [r.id for r in results] == ["id3", "id4"]
|
|
|
|
|
|
def test_delete(faiss_instance, mock_faiss_index):
|
|
# Setup the docstore and index_to_id mapping
|
|
faiss_instance.docstore = {"id1": {"name": "vector1"}, "id2": {"name": "vector2"}}
|
|
faiss_instance.index_to_id = {0: "id1", 1: "id2"}
|
|
|
|
# Mock reconstruct to return vectors for remaining entries
|
|
mock_faiss_index.reconstruct.side_effect = lambda idx: np.array(
|
|
[0.1, 0.2, 0.3] if idx == 0 else [0.4, 0.5, 0.6], dtype=np.float32
|
|
)
|
|
|
|
# Call delete
|
|
faiss_instance.delete(vector_id="id1")
|
|
|
|
# Verify the vector was removed from docstore
|
|
assert "id1" not in faiss_instance.docstore
|
|
assert "id2" in faiss_instance.docstore
|
|
|
|
# Verify the FAISS index was rebuilt
|
|
mock_faiss_index.reset.assert_called_once()
|
|
mock_faiss_index.add.assert_called_once()
|
|
|
|
# Verify index_to_id was remapped contiguously
|
|
assert faiss_instance.index_to_id == {0: "id2"}
|
|
|
|
|
|
def test_update(faiss_instance, mock_faiss_index):
|
|
# Setup the docstore and index_to_id mapping
|
|
faiss_instance.docstore = {"id1": {"name": "vector1"}, "id2": {"name": "vector2"}}
|
|
faiss_instance.index_to_id = {0: "id1", 1: "id2"}
|
|
|
|
# Test updating payload only
|
|
faiss_instance.update(vector_id="id1", payload={"name": "updated_vector1"})
|
|
assert faiss_instance.docstore["id1"] == {"name": "updated_vector1"}
|
|
|
|
# Test updating vector
|
|
# This requires mocking the delete and insert methods
|
|
with patch.object(faiss_instance, "delete") as mock_delete:
|
|
with patch.object(faiss_instance, "insert") as mock_insert:
|
|
new_vector = [0.7, 0.8, 0.9]
|
|
faiss_instance.update(vector_id="id2", vector=new_vector)
|
|
|
|
# Verify delete and insert were called
|
|
# Match the actual call signature (positional arg instead of keyword)
|
|
mock_delete.assert_called_once_with("id2")
|
|
mock_insert.assert_called_once()
|
|
|
|
|
|
def test_get(faiss_instance):
|
|
# Setup the docstore
|
|
faiss_instance.docstore = {"id1": {"name": "vector1"}, "id2": {"name": "vector2"}}
|
|
|
|
# Test getting an existing vector
|
|
result = faiss_instance.get(vector_id="id1")
|
|
assert result.id == "id1"
|
|
assert result.payload == {"name": "vector1"}
|
|
assert result.score is None
|
|
|
|
# Test getting a non-existent vector
|
|
result = faiss_instance.get(vector_id="id3")
|
|
assert result is None
|
|
|
|
|
|
def test_list(faiss_instance):
|
|
# Setup the docstore
|
|
faiss_instance.docstore = {
|
|
"id1": {"name": "vector1", "category": "A"},
|
|
"id2": {"name": "vector2", "category": "B"},
|
|
"id3": {"name": "vector3", "category": "A"},
|
|
}
|
|
|
|
# Test listing all vectors
|
|
results = faiss_instance.list()
|
|
# Fix the expected result - the list method returns a list of lists
|
|
assert len(results[0]) == 3
|
|
|
|
# Test listing with a limit
|
|
results = faiss_instance.list(top_k=2)
|
|
assert len(results[0]) == 2
|
|
|
|
# Test listing with filters
|
|
results = faiss_instance.list(filters={"category": "A"})
|
|
assert len(results[0]) == 2
|
|
for result in results[0]:
|
|
assert result.payload["category"] == "A"
|
|
|
|
|
|
def test_list_uninitialized_index_returns_nested_list(faiss_instance):
|
|
# Regression for the List[List[OutputData]] contract: callers (e.g.
|
|
# Memory.delete_all) do `vector_store.list(filters=...)[0]`, so an
|
|
# uninitialized index must return [[]] (one level deep) and NOT a bare
|
|
# [], which would make result[0] raise IndexError on an empty store.
|
|
faiss_instance.index = None
|
|
|
|
results = faiss_instance.list()
|
|
|
|
assert results == [[]]
|
|
# The contract callers rely on: result[0] is the (empty) memory list.
|
|
assert results[0] == []
|
|
|
|
|
|
def test_col_info(faiss_instance, mock_faiss_index):
|
|
# Mock index attributes
|
|
mock_faiss_index.ntotal = 5
|
|
mock_faiss_index.d = 128
|
|
|
|
# Get collection info
|
|
info = faiss_instance.col_info()
|
|
|
|
# Verify the returned info
|
|
assert info["name"] == "test_collection"
|
|
assert info["count"] == 5
|
|
assert info["dimension"] == 128
|
|
assert info["distance"] == "euclidean"
|
|
|
|
|
|
def test_delete_col(faiss_instance):
|
|
# Mock the os.remove function
|
|
with patch("os.remove") as mock_remove:
|
|
with patch("os.path.exists", return_value=True):
|
|
# Call delete_col
|
|
faiss_instance.delete_col()
|
|
|
|
# Verify os.remove was called for index, json docstore, and legacy pkl files
|
|
assert mock_remove.call_count == 3
|
|
|
|
# Verify the internal state was reset
|
|
assert faiss_instance.index is None
|
|
assert faiss_instance.docstore == {}
|
|
assert faiss_instance.index_to_id == {}
|
|
|
|
|
|
def test_normalize_L2(faiss_instance, mock_faiss_index):
|
|
# Setup a FAISS instance with normalize_L2=True
|
|
faiss_instance.normalize_L2 = True
|
|
|
|
# Prepare test data
|
|
vectors = [[0.1, 0.2, 0.3]]
|
|
|
|
# Mock numpy array conversion
|
|
# Mock numpy array conversion
|
|
with patch("numpy.array", return_value=np.array(vectors, dtype=np.float32)):
|
|
# Mock faiss.normalize_L2
|
|
with patch("faiss.normalize_L2") as mock_normalize:
|
|
# Call insert
|
|
faiss_instance.insert(vectors=vectors, ids=["id1"])
|
|
|
|
# Verify faiss.normalize_L2 was called
|
|
mock_normalize.assert_called_once()
|
|
|
|
|
|
# =============================================================================
|
|
# Security Tests for Pickle Deserialization Vulnerability Fix
|
|
# =============================================================================
|
|
|
|
|
|
class TestSafeUnpickler:
|
|
"""Tests for the SafeUnpickler class that prevents arbitrary code execution."""
|
|
|
|
def test_safe_unpickler_allows_basic_types(self):
|
|
"""SafeUnpickler should allow basic Python types."""
|
|
# Create a legitimate pickle with basic types
|
|
data = (
|
|
{"key1": "value1", "key2": {"nested": "dict"}},
|
|
{0: "id1", 1: "id2"},
|
|
)
|
|
pickled = pickle.dumps(data)
|
|
|
|
# Should load successfully
|
|
import io
|
|
|
|
result = SafeUnpickler(io.BytesIO(pickled)).load()
|
|
assert result == data
|
|
|
|
def test_safe_unpickler_blocks_os_system(self):
|
|
"""SafeUnpickler should block os.system execution attempts."""
|
|
# Generate the malicious payload dynamically to ensure correct format
|
|
import io
|
|
|
|
class Evil:
|
|
def __reduce__(self):
|
|
return (os.system, ("echo pwned",))
|
|
|
|
malicious_payload = pickle.dumps(Evil())
|
|
|
|
with pytest.raises(pickle.UnpicklingError) as exc_info:
|
|
SafeUnpickler(io.BytesIO(malicious_payload)).load()
|
|
|
|
assert "Unsafe pickle" in str(exc_info.value)
|
|
assert "posix.system" in str(exc_info.value)
|
|
|
|
def test_safe_unpickler_blocks_subprocess(self):
|
|
"""SafeUnpickler should block subprocess execution attempts."""
|
|
import subprocess
|
|
|
|
# Create a malicious pickle that tries to use subprocess
|
|
class MaliciousSubprocess:
|
|
def __reduce__(self):
|
|
return (subprocess.call, (["echo", "pwned"],))
|
|
|
|
malicious_payload = pickle.dumps(MaliciousSubprocess())
|
|
|
|
import io
|
|
|
|
with pytest.raises(pickle.UnpicklingError) as exc_info:
|
|
SafeUnpickler(io.BytesIO(malicious_payload)).load()
|
|
|
|
assert "Unsafe pickle" in str(exc_info.value)
|
|
|
|
def test_safe_unpickler_blocks_eval(self):
|
|
"""SafeUnpickler should block eval/exec attempts."""
|
|
|
|
# Create a malicious pickle that tries to use eval
|
|
class MaliciousEval:
|
|
def __reduce__(self):
|
|
return (eval, ("__import__('os').system('touch pwned')",))
|
|
|
|
malicious_payload = pickle.dumps(MaliciousEval())
|
|
|
|
import io
|
|
|
|
with pytest.raises(pickle.UnpicklingError) as exc_info:
|
|
SafeUnpickler(io.BytesIO(malicious_payload)).load()
|
|
|
|
assert "Unsafe pickle" in str(exc_info.value)
|
|
|
|
def test_safe_unpickler_blocks_arbitrary_modules(self):
|
|
"""SafeUnpickler should block imports from arbitrary modules."""
|
|
|
|
# Create a pickle that tries to load a class from a non-builtins module
|
|
class ArbitraryClass:
|
|
def __reduce__(self):
|
|
return (type, ("Evil", (), {}))
|
|
|
|
malicious_payload = pickle.dumps(ArbitraryClass())
|
|
|
|
import io
|
|
|
|
# This should either work (type is a builtin) or fail safely
|
|
# The key is it shouldn't execute arbitrary code
|
|
try:
|
|
result = SafeUnpickler(io.BytesIO(malicious_payload)).load()
|
|
# If it loads, verify it's just a benign type object
|
|
assert isinstance(result, type)
|
|
except pickle.UnpicklingError:
|
|
# This is also acceptable - blocking unknown patterns
|
|
pass
|
|
|
|
|
|
class TestSafePickleLoad:
|
|
"""Tests for the _safe_pickle_load function."""
|
|
|
|
def test_safe_pickle_load_with_valid_file(self):
|
|
"""_safe_pickle_load should load valid pickle files."""
|
|
with tempfile.NamedTemporaryFile(mode="wb", suffix=".pkl", delete=False) as f:
|
|
data = ({"id1": {"data": "test"}}, {0: "id1"})
|
|
pickle.dump(data, f)
|
|
temp_path = f.name
|
|
|
|
try:
|
|
result = _safe_pickle_load(temp_path)
|
|
assert result == data
|
|
finally:
|
|
os.unlink(temp_path)
|
|
|
|
def test_safe_pickle_load_blocks_malicious_file(self):
|
|
"""_safe_pickle_load should block malicious pickle files."""
|
|
|
|
# Generate the malicious payload dynamically
|
|
class Evil:
|
|
def __reduce__(self):
|
|
return (os.system, ("echo pwned",))
|
|
|
|
malicious_payload = pickle.dumps(Evil())
|
|
|
|
with tempfile.NamedTemporaryFile(mode="wb", suffix=".pkl", delete=False) as f:
|
|
f.write(malicious_payload)
|
|
temp_path = f.name
|
|
|
|
try:
|
|
with pytest.raises(pickle.UnpicklingError) as exc_info:
|
|
_safe_pickle_load(temp_path)
|
|
assert "Unsafe pickle" in str(exc_info.value)
|
|
finally:
|
|
os.unlink(temp_path)
|
|
|
|
|
|
class TestValidateDocstoreStructure:
|
|
"""Tests for the _validate_docstore_structure function."""
|
|
|
|
def test_valid_structure(self):
|
|
"""Should accept valid docstore structure."""
|
|
data = ({"id1": {"data": "test"}}, {0: "id1"})
|
|
docstore, index_to_id = _validate_docstore_structure(data)
|
|
assert docstore == {"id1": {"data": "test"}}
|
|
assert index_to_id == {0: "id1"}
|
|
|
|
def test_invalid_tuple_length(self):
|
|
"""Should reject tuples with wrong length."""
|
|
with pytest.raises(ValueError, match="expected tuple"):
|
|
_validate_docstore_structure(({}, {}, {}))
|
|
|
|
def test_invalid_docstore_type(self):
|
|
"""Should reject non-dict docstore."""
|
|
with pytest.raises(ValueError, match="docstore must be a dict"):
|
|
_validate_docstore_structure(("not a dict", {}))
|
|
|
|
def test_invalid_index_to_id_type(self):
|
|
"""Should reject non-dict index_to_id."""
|
|
with pytest.raises(ValueError, match="index_to_id must be a dict"):
|
|
_validate_docstore_structure(({}, "not a dict"))
|
|
|
|
def test_invalid_docstore_key_type(self):
|
|
"""Should reject non-string docstore keys."""
|
|
with pytest.raises(ValueError, match="Invalid docstore key type"):
|
|
_validate_docstore_structure(({123: {"data": "test"}}, {0: "id1"}))
|
|
|
|
def test_invalid_index_to_id_key_type(self):
|
|
"""Should reject non-int index_to_id keys."""
|
|
with pytest.raises(ValueError, match="Invalid index_to_id key type"):
|
|
_validate_docstore_structure(({"id1": {"data": "test"}}, {"0": "id1"}))
|
|
|
|
|
|
class TestFAISSSecurityIntegration:
|
|
"""Integration tests for FAISS security fixes."""
|
|
|
|
def test_faiss_saves_as_json(self):
|
|
"""FAISS should save docstore as JSON, not pickle."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
mock_index = Mock()
|
|
mock_index.d = 128
|
|
mock_index.ntotal = 0
|
|
|
|
with patch("mem0.vector_stores.faiss.faiss.IndexFlatL2", return_value=mock_index):
|
|
with patch("mem0.vector_stores.faiss.faiss.write_index"):
|
|
faiss_store = FAISS(
|
|
collection_name="test_security",
|
|
path=os.path.join(temp_dir, "test_faiss"),
|
|
distance_strategy="euclidean",
|
|
)
|
|
faiss_store.index = mock_index
|
|
|
|
# Insert some data
|
|
faiss_store.docstore = {"id1": {"data": "test"}}
|
|
faiss_store.index_to_id = {0: "id1"}
|
|
faiss_store._save()
|
|
|
|
# Verify JSON file was created
|
|
json_path = os.path.join(temp_dir, "test_faiss", "test_security.json")
|
|
pkl_path = os.path.join(temp_dir, "test_faiss", "test_security.pkl")
|
|
|
|
assert os.path.exists(json_path), "JSON docstore file should be created"
|
|
assert not os.path.exists(pkl_path), "Pickle file should NOT be created"
|
|
|
|
# Verify JSON content
|
|
with open(json_path, "r") as f:
|
|
data = json.load(f)
|
|
assert data["docstore"] == {"id1": {"data": "test"}}
|
|
assert data["index_to_id"] == {"0": "id1"}
|
|
|
|
def test_faiss_loads_json_preferentially(self):
|
|
"""FAISS should prefer JSON over pickle when both exist."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
faiss_path = os.path.join(temp_dir, "test_faiss")
|
|
os.makedirs(faiss_path)
|
|
|
|
# Create both JSON and pickle files with different data
|
|
json_data = {"docstore": {"id1": {"source": "json"}}, "index_to_id": {"0": "id1"}}
|
|
pkl_data = ({"id1": {"source": "pickle"}}, {0: "id1"})
|
|
|
|
with open(os.path.join(faiss_path, "test_pref.json"), "w") as f:
|
|
json.dump(json_data, f)
|
|
|
|
with open(os.path.join(faiss_path, "test_pref.pkl"), "wb") as f:
|
|
pickle.dump(pkl_data, f)
|
|
|
|
mock_index = Mock()
|
|
mock_index.d = 128
|
|
mock_index.ntotal = 1
|
|
|
|
with patch("mem0.vector_stores.faiss.faiss.read_index", return_value=mock_index):
|
|
with patch("mem0.vector_stores.faiss.faiss.write_index"):
|
|
faiss_store = FAISS.__new__(FAISS)
|
|
faiss_store.collection_name = "test_pref"
|
|
faiss_store.path = faiss_path
|
|
faiss_store.index = None
|
|
faiss_store.docstore = {}
|
|
faiss_store.index_to_id = {}
|
|
|
|
faiss_store._load(
|
|
os.path.join(faiss_path, "test_pref.faiss"),
|
|
os.path.join(faiss_path, "test_pref.pkl"),
|
|
)
|
|
|
|
# Should have loaded from JSON, not pickle
|
|
assert faiss_store.docstore == {"id1": {"source": "json"}}
|
|
|
|
def test_faiss_blocks_malicious_pickle_on_load(self):
|
|
"""FAISS should block loading of malicious pickle files."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
faiss_path = os.path.join(temp_dir, "test_faiss")
|
|
os.makedirs(faiss_path)
|
|
|
|
# Create a malicious pickle file (RCE payload)
|
|
class Evil:
|
|
def __reduce__(self):
|
|
return (os.system, (f"touch {temp_dir}/pwned",))
|
|
|
|
malicious_payload = pickle.dumps(Evil())
|
|
|
|
with open(os.path.join(faiss_path, "malicious.pkl"), "wb") as f:
|
|
f.write(malicious_payload)
|
|
|
|
mock_index = Mock()
|
|
mock_index.ntotal = 1
|
|
|
|
with patch("mem0.vector_stores.faiss.faiss.read_index", return_value=mock_index):
|
|
faiss_store = FAISS.__new__(FAISS)
|
|
faiss_store.collection_name = "malicious"
|
|
faiss_store.path = faiss_path
|
|
faiss_store.index = None
|
|
faiss_store.docstore = {}
|
|
faiss_store.index_to_id = {}
|
|
|
|
# Should raise an error, not execute the malicious payload
|
|
with pytest.raises(ValueError) as exc_info:
|
|
faiss_store._load(
|
|
os.path.join(faiss_path, "malicious.faiss"),
|
|
os.path.join(faiss_path, "malicious.pkl"),
|
|
)
|
|
|
|
assert "malicious pickle" in str(exc_info.value).lower() or "unsafe" in str(exc_info.value).lower()
|
|
|
|
# Verify the malicious command was NOT executed
|
|
pwned_file = os.path.join(temp_dir, "pwned")
|
|
assert not os.path.exists(pwned_file), "Malicious payload should NOT have been executed!"
|
|
|
|
def test_faiss_migrates_legacy_pickle_to_json(self):
|
|
"""FAISS should auto-migrate valid pickle files to JSON format."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
faiss_path = os.path.join(temp_dir, "test_faiss")
|
|
os.makedirs(faiss_path)
|
|
|
|
# Create a legitimate legacy pickle file
|
|
pkl_data = ({"id1": {"data": "legacy"}}, {0: "id1"})
|
|
with open(os.path.join(faiss_path, "legacy.pkl"), "wb") as f:
|
|
pickle.dump(pkl_data, f)
|
|
|
|
mock_index = Mock()
|
|
mock_index.d = 128
|
|
mock_index.ntotal = 1
|
|
|
|
with patch("mem0.vector_stores.faiss.faiss.read_index", return_value=mock_index):
|
|
with patch("mem0.vector_stores.faiss.faiss.write_index"):
|
|
faiss_store = FAISS.__new__(FAISS)
|
|
faiss_store.collection_name = "legacy"
|
|
faiss_store.path = faiss_path
|
|
faiss_store.index = None
|
|
faiss_store.docstore = {}
|
|
faiss_store.index_to_id = {}
|
|
|
|
faiss_store._load(
|
|
os.path.join(faiss_path, "legacy.faiss"),
|
|
os.path.join(faiss_path, "legacy.pkl"),
|
|
)
|
|
|
|
# Data should be loaded correctly
|
|
assert faiss_store.docstore == {"id1": {"data": "legacy"}}
|
|
assert faiss_store.index_to_id == {0: "id1"}
|
|
|
|
# JSON file should now exist (auto-migrated)
|
|
json_path = os.path.join(faiss_path, "legacy.json")
|
|
assert os.path.exists(json_path), "JSON file should be created during migration"
|
|
|
|
def test_delete_col_removes_json_and_pkl(self):
|
|
"""delete_col should remove both JSON and legacy pickle files."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
faiss_path = os.path.join(temp_dir, "test_faiss")
|
|
os.makedirs(faiss_path)
|
|
|
|
# Create both file types
|
|
json_path = os.path.join(faiss_path, "test_del.json")
|
|
pkl_path = os.path.join(faiss_path, "test_del.pkl")
|
|
faiss_index_path = os.path.join(faiss_path, "test_del.faiss")
|
|
|
|
with open(json_path, "w") as f:
|
|
json.dump({"docstore": {}, "index_to_id": {}}, f)
|
|
with open(pkl_path, "wb") as f:
|
|
pickle.dump(({}, {}), f)
|
|
with open(faiss_index_path, "w") as f:
|
|
f.write("dummy")
|
|
|
|
with patch("faiss.IndexFlatL2"):
|
|
faiss_store = FAISS.__new__(FAISS)
|
|
faiss_store.collection_name = "test_del"
|
|
faiss_store.path = faiss_path
|
|
faiss_store.index = Mock()
|
|
faiss_store.docstore = {}
|
|
faiss_store.index_to_id = {}
|
|
|
|
faiss_store.delete_col()
|
|
|
|
# Both files should be deleted
|
|
assert not os.path.exists(json_path), "JSON file should be deleted"
|
|
assert not os.path.exists(pkl_path), "PKL file should be deleted"
|
|
assert not os.path.exists(faiss_index_path), "FAISS index should be deleted"
|
|
|
|
|
|
class TestCosineNormalization:
|
|
"""Cosine distance must rank by angle, not raw inner-product magnitude.
|
|
|
|
Regression test for the bug where cosine used an IndexFlatIP index but never
|
|
L2-normalized vectors, so results were ranked by inner product instead of
|
|
cosine similarity.
|
|
"""
|
|
|
|
def test_cosine_ranks_by_angle_not_magnitude(self):
|
|
# Query is perfectly aligned with A (cosine 1.0) but A has a small
|
|
# magnitude, so its inner product (0.1) is lower than B's (0.5).
|
|
# Under correct cosine ranking, A must come first regardless.
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
store = FAISS(
|
|
collection_name="cosine_col",
|
|
path=os.path.join(temp_dir, "cosine"),
|
|
distance_strategy="cosine",
|
|
embedding_model_dims=2,
|
|
)
|
|
store.insert(
|
|
vectors=[[0.1, 0.0], [0.5, 0.5]],
|
|
payloads=[{"name": "A"}, {"name": "B"}],
|
|
ids=["A", "B"],
|
|
)
|
|
|
|
results = store.search(query="", vectors=[1.0, 0.0], top_k=2)
|
|
|
|
assert [r.id for r in results] == ["A", "B"]
|
|
# Scores are true cosine similarities, not raw inner products.
|
|
assert results[0].score == pytest.approx(1.0, abs=1e-5)
|
|
assert results[1].score == pytest.approx(0.70710677, abs=1e-5)
|
|
|
|
def test_cosine_normalizes_on_insert_and_search(self):
|
|
# A non-unit query that points the same direction as a stored vector
|
|
# should score ~1.0 once both sides are normalized.
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
store = FAISS(
|
|
collection_name="cosine_col2",
|
|
path=os.path.join(temp_dir, "cosine2"),
|
|
distance_strategy="cosine",
|
|
embedding_model_dims=3,
|
|
)
|
|
store.insert(vectors=[[3.0, 0.0, 0.0]], payloads=[{"name": "x"}], ids=["x"])
|
|
|
|
results = store.search(query="", vectors=[7.0, 0.0, 0.0], top_k=1)
|
|
|
|
assert results[0].id == "x"
|
|
assert results[0].score == pytest.approx(1.0, abs=1e-5)
|