Skip to content

Commit 07e304c

Browse files
phernandezclaude
andauthored
fix: normalize paths to lowercase in cloud mode to prevent case collisions (#336)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2a1c06d commit 07e304c

2 files changed

Lines changed: 111 additions & 3 deletions

File tree

src/basic_memory/services/project_service.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ async def add_project(self, name: str, path: str, set_default: bool = False) ->
9797
set_default: Whether to set this project as the default
9898
9999
Raises:
100-
ValueError: If the project already exists
100+
ValueError: If the project already exists or path collides with existing project
101101
"""
102102
# If project_root is set, constrain all projects to that directory
103103
project_root = self.config_manager.config.project_root
@@ -108,11 +108,13 @@ async def add_project(self, name: str, path: str, set_default: bool = False) ->
108108
# Strip leading slashes, home directory references, and parent directory references
109109
clean_path = path.lstrip("/").replace("~/", "").replace("~", "")
110110

111-
# Remove any parent directory traversal attempts
111+
# Remove any parent directory traversal attempts and normalize to lowercase
112+
# to prevent case-sensitivity issues on Linux filesystems
112113
path_parts = []
113114
for part in clean_path.split("/"):
114115
if part and part != "." and part != "..":
115-
path_parts.append(part)
116+
# Convert to lowercase to ensure case-insensitive consistency
117+
path_parts.append(part.lower())
116118
clean_path = "/".join(path_parts) if path_parts else ""
117119

118120
# Construct path relative to project_root
@@ -124,6 +126,19 @@ async def add_project(self, name: str, path: str, set_default: bool = False) ->
124126
f"BASIC_MEMORY_PROJECT_ROOT is set to {project_root}. "
125127
f"All projects must be created under this directory. Invalid path: {path}"
126128
)
129+
130+
# Check for case-insensitive path collisions with existing projects
131+
existing_projects = await self.list_projects()
132+
for existing in existing_projects:
133+
if (
134+
existing.path.lower() == resolved_path.lower()
135+
and existing.path != resolved_path
136+
):
137+
raise ValueError(
138+
f"Path collision detected: '{resolved_path}' conflicts with existing project "
139+
f"'{existing.name}' at '{existing.path}'. "
140+
f"In cloud mode, paths are normalized to lowercase to prevent case-sensitivity issues."
141+
)
127142
else:
128143
resolved_path = Path(os.path.abspath(os.path.expanduser(path))).as_posix()
129144

tests/services/test_project_service.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -867,3 +867,96 @@ async def test_add_project_without_project_root_allows_arbitrary_paths(
867867
# Clean up
868868
if test_project_name in project_service.projects:
869869
await project_service.remove_project(test_project_name)
870+
871+
872+
@pytest.mark.skipif(os.name == "nt", reason="Project root constraints only tested on POSIX systems")
873+
@pytest.mark.asyncio
874+
async def test_add_project_with_project_root_normalizes_case(
875+
project_service: ProjectService, config_manager: ConfigManager, tmp_path, monkeypatch
876+
):
877+
"""Test that BASIC_MEMORY_PROJECT_ROOT normalizes paths to lowercase."""
878+
# Set up project root environment
879+
project_root_path = tmp_path / "app" / "data"
880+
project_root_path.mkdir(parents=True, exist_ok=True)
881+
882+
monkeypatch.setenv("BASIC_MEMORY_PROJECT_ROOT", str(project_root_path))
883+
884+
# Invalidate config cache so it picks up the new env var
885+
from basic_memory import config as config_module
886+
887+
config_module._CONFIG_CACHE = None
888+
889+
test_cases = [
890+
# (input_path, expected_normalized_path)
891+
("Documents/my-project", str(project_root_path / "documents" / "my-project")),
892+
("UPPERCASE/PATH", str(project_root_path / "uppercase" / "path")),
893+
("MixedCase/Path", str(project_root_path / "mixedcase" / "path")),
894+
("documents/Test-TWO", str(project_root_path / "documents" / "test-two")),
895+
]
896+
897+
for i, (input_path, expected_path) in enumerate(test_cases):
898+
test_project_name = f"case-normalize-test-{i}"
899+
900+
try:
901+
# Add the project
902+
await project_service.add_project(test_project_name, input_path)
903+
904+
# Verify the path was normalized to lowercase
905+
assert test_project_name in project_service.projects
906+
actual_path = project_service.projects[test_project_name]
907+
assert actual_path == expected_path, (
908+
f"Expected path {expected_path} but got {actual_path} for input {input_path}"
909+
)
910+
911+
# Clean up
912+
await project_service.remove_project(test_project_name)
913+
914+
except ValueError as e:
915+
pytest.fail(f"Unexpected ValueError for input path {input_path}: {e}")
916+
917+
918+
@pytest.mark.skipif(os.name == "nt", reason="Project root constraints only tested on POSIX systems")
919+
@pytest.mark.asyncio
920+
async def test_add_project_with_project_root_detects_case_collisions(
921+
project_service: ProjectService, config_manager: ConfigManager, tmp_path, monkeypatch
922+
):
923+
"""Test that BASIC_MEMORY_PROJECT_ROOT detects case-insensitive path collisions."""
924+
# Set up project root environment
925+
project_root_path = tmp_path / "app" / "data"
926+
project_root_path.mkdir(parents=True, exist_ok=True)
927+
928+
monkeypatch.setenv("BASIC_MEMORY_PROJECT_ROOT", str(project_root_path))
929+
930+
# Invalidate config cache so it picks up the new env var
931+
from basic_memory import config as config_module
932+
933+
config_module._CONFIG_CACHE = None
934+
935+
# First, create a project with lowercase path
936+
first_project = "documents-project"
937+
await project_service.add_project(first_project, "documents/basic-memory")
938+
939+
# Verify it was created with normalized lowercase path
940+
assert first_project in project_service.projects
941+
first_path = project_service.projects[first_project]
942+
assert first_path == str(project_root_path / "documents" / "basic-memory")
943+
944+
# Now try to create a project with the same path but different case
945+
# This should be normalized to the same lowercase path and not cause a collision
946+
# since both will be normalized to the same path
947+
second_project = "documents-project-2"
948+
try:
949+
# This should succeed because both get normalized to the same lowercase path
950+
await project_service.add_project(second_project, "documents/basic-memory")
951+
# If we get here, both should have the exact same path
952+
second_path = project_service.projects[second_project]
953+
assert second_path == first_path
954+
955+
# Clean up second project
956+
await project_service.remove_project(second_project)
957+
except ValueError:
958+
# This is expected if there's already a project with this exact path
959+
pass
960+
961+
# Clean up
962+
await project_service.remove_project(first_project)

0 commit comments

Comments
 (0)