From e1e725792b70dfcb85af089dd0872f69412b9441 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Mon, 3 Aug 2026 12:59:04 +0200 Subject: [PATCH] Unify shared path normalization --- core/download/metadata.py | 4 ++-- core/metadata_builder.py | 4 ++-- core/path_utils.py | 12 ++++++++++++ core/services/civitai_search_service.py | 6 ++++-- core/services/hash_service.py | 5 +++-- tests/test_downloader_boundaries.py | 2 ++ tests/test_hash_routes.py | 17 ++++++++++++++++- 7 files changed, 41 insertions(+), 9 deletions(-) diff --git a/core/download/metadata.py b/core/download/metadata.py index 42c8da9..e671fb5 100644 --- a/core/download/metadata.py +++ b/core/download/metadata.py @@ -12,6 +12,7 @@ from ..path_utils import ( MODEL_RESOLVER_METADATA_SCHEMA_VERSION, find_metadata_sidecar_path, get_filename_from_path, + normalize_metadata_file_path, read_merged_model_metadata, ) from ..resolver import normalize_sha256 @@ -116,8 +117,7 @@ def _coerce_size(value: Any) -> int: return 0 -def _normalise_metadata_file_path(path_value: str) -> str: - return str(path_value or "").replace(os.sep, "/") +_normalise_metadata_file_path = normalize_metadata_file_path def _resolve_lora_manager_model_type(category: str, source_type: Any = "") -> str: diff --git a/core/metadata_builder.py b/core/metadata_builder.py index bd0acec..4d5fbbc 100644 --- a/core/metadata_builder.py +++ b/core/metadata_builder.py @@ -31,6 +31,7 @@ from .path_utils import ( find_external_metadata_sidecar_path, get_filename_from_path, get_model_resolver_sidecar_path, + normalize_metadata_file_path, read_json_safe, read_safetensors_header, write_json_atomic, @@ -255,8 +256,7 @@ def _merge_unique_strings(*values: Any) -> List[str]: return result -def _normalise_metadata_file_path(path_value: str) -> str: - return str(path_value or "").replace(os.sep, "/") +_normalise_metadata_file_path = normalize_metadata_file_path def _model_type_for_category(category: str) -> str: diff --git a/core/path_utils.py b/core/path_utils.py index f5bc8c0..6a06ec8 100644 --- a/core/path_utils.py +++ b/core/path_utils.py @@ -55,6 +55,18 @@ def get_path_identity(path: str) -> str: return os.path.normcase(os.path.abspath(path)) +def normalize_absolute_path(path_value: Any, path_module=os.path) -> str: + """Return an absolute, normalized and symlink-resolved path.""" + return path_module.realpath( + path_module.abspath(path_module.normpath(str(path_value))) + ) + + +def normalize_metadata_file_path(path_value: Any) -> str: + """Normalize metadata paths to portable forward-slash separators.""" + return str(path_value or "").replace(os.sep, "/") + + def get_path_abs(path_value: Any) -> str: """Return absolute path safely, handling OSError/ValueError.""" try: diff --git a/core/services/civitai_search_service.py b/core/services/civitai_search_service.py index bf6c8c6..ad022fa 100644 --- a/core/services/civitai_search_service.py +++ b/core/services/civitai_search_service.py @@ -1,5 +1,6 @@ """CivitAI model search service.""" +from .. import path_utils from ..routes.context import RouteContext from ..sources.common import is_remote_link_marked_dead from .model_utils import CivitAISearchDependencies, ModelServiceDependencies @@ -99,8 +100,9 @@ class CivitAISearchService(ModelServiceDependencies): file_path = find_local_file_path(filename, category) if file_path: - file_path = _os.path.realpath( - _os.path.abspath(_os.path.normpath(str(file_path))) + file_path = path_utils.normalize_absolute_path( + file_path, + path_module=_os.path, ) if not is_path_in_configured_model_roots(file_path): if resolved_path: diff --git a/core/services/hash_service.py b/core/services/hash_service.py index bb43a85..f3424c1 100644 --- a/core/services/hash_service.py +++ b/core/services/hash_service.py @@ -71,8 +71,9 @@ class HashService: return self.extension.logger def _normalize_path(self, path): - return self.os.path.realpath( - self.os.path.abspath(self.os.path.normpath(str(path))) + return path_utils.normalize_absolute_path( + path, + path_module=self.os.path, ) async def local_model_hashes(self, request): diff --git a/tests/test_downloader_boundaries.py b/tests/test_downloader_boundaries.py index 8cd68e5..a4d79be 100644 --- a/tests/test_downloader_boundaries.py +++ b/tests/test_downloader_boundaries.py @@ -5,6 +5,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch from core.download.api import context as downloader +from core.path_utils import normalize_metadata_file_path class DownloaderBoundaryTests(unittest.TestCase): @@ -60,6 +61,7 @@ class DownloaderBoundaryTests(unittest.TestCase): self.assertEqual(12, downloader._coerce_size("12.9")) self.assertEqual(0, downloader._coerce_size("not-a-size")) self.assertEqual("models/subfolder", downloader._normalise_metadata_file_path("models\\subfolder")) + self.assertEqual("models/subfolder", normalize_metadata_file_path("models\\subfolder")) def test_metadata_source_and_model_type_helpers_cover_provider_variants(self): self.assertEqual( diff --git a/tests/test_hash_routes.py b/tests/test_hash_routes.py index ae1719f..fc11825 100644 --- a/tests/test_hash_routes.py +++ b/tests/test_hash_routes.py @@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from aiohttp import web -from core.path_utils import HashCalculationCancelled +from core.path_utils import HashCalculationCancelled, normalize_absolute_path from core.routes.context import RouteContext from core.routes.hashes import register_hash_routes from core.services.hash_service import HashService @@ -134,6 +134,21 @@ async def test_local_model_hashes_uses_model_path_and_returns_metadata(): ) +def test_hash_service_path_normalization_uses_shared_helper_and_injected_path_module(): + _, values = _build_hash_routes() + service = HashService(RouteContext(values)) + model_path = "models/checkpoints/../loras/model.safetensors" + + with patch( + "core.path_utils.normalize_absolute_path", + wraps=normalize_absolute_path, + ) as normalize_path: + result = service._normalize_path(model_path) + + assert result == normalize_absolute_path(model_path) + normalize_path.assert_called_once_with(model_path, path_module=os.path) + + @pytest.mark.asyncio async def test_hash_routes_validate_local_metadata_and_preview_paths(): handlers, values = _build_hash_routes()