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>
This commit is contained in:
Paul Hernandez
2025-10-05 17:56:58 -05:00
committed by GitHub
parent 2a1c06d9ad
commit 07e304ce8e
2 changed files with 111 additions and 3 deletions
+18 -3
View File
@@ -97,7 +97,7 @@ class ProjectService:
set_default: Whether to set this project as the default
Raises:
ValueError: If the project already exists
ValueError: If the project already exists or path collides with existing project
"""
# If project_root is set, constrain all projects to that directory
project_root = self.config_manager.config.project_root
@@ -108,11 +108,13 @@ class ProjectService:
# Strip leading slashes, home directory references, and parent directory references
clean_path = path.lstrip("/").replace("~/", "").replace("~", "")
# Remove any parent directory traversal attempts
# Remove any parent directory traversal attempts and normalize to lowercase
# to prevent case-sensitivity issues on Linux filesystems
path_parts = []
for part in clean_path.split("/"):
if part and part != "." and part != "..":
path_parts.append(part)
# Convert to lowercase to ensure case-insensitive consistency
path_parts.append(part.lower())
clean_path = "/".join(path_parts) if path_parts else ""
# Construct path relative to project_root
@@ -124,6 +126,19 @@ class ProjectService:
f"BASIC_MEMORY_PROJECT_ROOT is set to {project_root}. "
f"All projects must be created under this directory. Invalid path: {path}"
)
# Check for case-insensitive path collisions with existing projects
existing_projects = await self.list_projects()
for existing in existing_projects:
if (
existing.path.lower() == resolved_path.lower()
and existing.path != resolved_path
):
raise ValueError(
f"Path collision detected: '{resolved_path}' conflicts with existing project "
f"'{existing.name}' at '{existing.path}'. "
f"In cloud mode, paths are normalized to lowercase to prevent case-sensitivity issues."
)
else:
resolved_path = Path(os.path.abspath(os.path.expanduser(path))).as_posix()
+93
View File
@@ -867,3 +867,96 @@ async def test_add_project_without_project_root_allows_arbitrary_paths(
# Clean up
if test_project_name in project_service.projects:
await project_service.remove_project(test_project_name)
@pytest.mark.skipif(os.name == "nt", reason="Project root constraints only tested on POSIX systems")
@pytest.mark.asyncio
async def test_add_project_with_project_root_normalizes_case(
project_service: ProjectService, config_manager: ConfigManager, tmp_path, monkeypatch
):
"""Test that BASIC_MEMORY_PROJECT_ROOT normalizes paths to lowercase."""
# Set up project root environment
project_root_path = tmp_path / "app" / "data"
project_root_path.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("BASIC_MEMORY_PROJECT_ROOT", str(project_root_path))
# Invalidate config cache so it picks up the new env var
from basic_memory import config as config_module
config_module._CONFIG_CACHE = None
test_cases = [
# (input_path, expected_normalized_path)
("Documents/my-project", str(project_root_path / "documents" / "my-project")),
("UPPERCASE/PATH", str(project_root_path / "uppercase" / "path")),
("MixedCase/Path", str(project_root_path / "mixedcase" / "path")),
("documents/Test-TWO", str(project_root_path / "documents" / "test-two")),
]
for i, (input_path, expected_path) in enumerate(test_cases):
test_project_name = f"case-normalize-test-{i}"
try:
# Add the project
await project_service.add_project(test_project_name, input_path)
# Verify the path was normalized to lowercase
assert test_project_name in project_service.projects
actual_path = project_service.projects[test_project_name]
assert actual_path == expected_path, (
f"Expected path {expected_path} but got {actual_path} for input {input_path}"
)
# Clean up
await project_service.remove_project(test_project_name)
except ValueError as e:
pytest.fail(f"Unexpected ValueError for input path {input_path}: {e}")
@pytest.mark.skipif(os.name == "nt", reason="Project root constraints only tested on POSIX systems")
@pytest.mark.asyncio
async def test_add_project_with_project_root_detects_case_collisions(
project_service: ProjectService, config_manager: ConfigManager, tmp_path, monkeypatch
):
"""Test that BASIC_MEMORY_PROJECT_ROOT detects case-insensitive path collisions."""
# Set up project root environment
project_root_path = tmp_path / "app" / "data"
project_root_path.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("BASIC_MEMORY_PROJECT_ROOT", str(project_root_path))
# Invalidate config cache so it picks up the new env var
from basic_memory import config as config_module
config_module._CONFIG_CACHE = None
# First, create a project with lowercase path
first_project = "documents-project"
await project_service.add_project(first_project, "documents/basic-memory")
# Verify it was created with normalized lowercase path
assert first_project in project_service.projects
first_path = project_service.projects[first_project]
assert first_path == str(project_root_path / "documents" / "basic-memory")
# Now try to create a project with the same path but different case
# This should be normalized to the same lowercase path and not cause a collision
# since both will be normalized to the same path
second_project = "documents-project-2"
try:
# This should succeed because both get normalized to the same lowercase path
await project_service.add_project(second_project, "documents/basic-memory")
# If we get here, both should have the exact same path
second_path = project_service.projects[second_project]
assert second_path == first_path
# Clean up second project
await project_service.remove_project(second_project)
except ValueError:
# This is expected if there's already a project with this exact path
pass
# Clean up
await project_service.remove_project(first_project)