diff --git a/src/basic_memory/services/project_service.py b/src/basic_memory/services/project_service.py index 519a2a51..3d5b035b 100644 --- a/src/basic_memory/services/project_service.py +++ b/src/basic_memory/services/project_service.py @@ -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() diff --git a/tests/services/test_project_service.py b/tests/services/test_project_service.py index 11931fb9..b0507176 100644 --- a/tests/services/test_project_service.py +++ b/tests/services/test_project_service.py @@ -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)