From c7a0f2f78c24cc310e7b432cf875273ae54e54e5 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Sat, 1 Aug 2026 22:50:13 +0200 Subject: [PATCH] fix: prefer selected-file hash over inherited workflow or source hash --- core/downloader.py | 45 ++++++++-- core/sources/lora_manager_archive.py | 12 +++ tests/test_downloader_metadata.py | 29 +++++++ tests/test_downloads_tab_workflow_route.mjs | 86 +++++++++++++++++++ tests/test_lora_manager_archive.py | 16 +++- .../search/download_target_methods.js | 51 +++++++++-- 6 files changed, 223 insertions(+), 16 deletions(-) diff --git a/core/downloader.py b/core/downloader.py index 7ae8a35..6e748e5 100644 --- a/core/downloader.py +++ b/core/downloader.py @@ -377,7 +377,10 @@ def _find_metadata_file_info( selected_version: Dict[str, Any], filename: str, ) -> Dict[str, Any]: - for key in ("file_info", "file"): + # A selected file is the most specific provenance available. It must be + # checked before the source-level hash because the latter can still belong + # to the workflow's original model after the user chooses another variant. + for key in ("selected_file", "selectedFile", "file_info", "file"): value = source.get(key) if isinstance(value, dict): return value @@ -421,13 +424,28 @@ def _extract_expected_sha256(metadata: Optional[Dict[str, Any]]) -> str: path_metadata.get("filename"), ) file_info = _find_metadata_file_info(source, selected_version, str(filename)) - hashes = _as_dict(_first_present(file_info.get("hashes"), source.get("hashes"))) + file_hashes = _as_dict(file_info.get("hashes")) + file_sha256 = normalize_sha256( + _first_present( + file_info.get("sha256"), + file_info.get("hash"), + file_hashes.get("SHA256"), + file_hashes.get("sha256"), + ) + ) + if file_info: + # An explicit file entry without a hash means this provider did not + # declare a checksum for the selected URL. Do not fall back to a + # stale source/workflow hash and reject a user-selected download. + return file_sha256 + + # Keep the source-level fallback for providers that expose one hash but no + # file object. + hashes = _as_dict(source.get("hashes")) return normalize_sha256( _first_present( source.get("sha256"), source.get("hash"), - file_info.get("sha256"), - file_info.get("hash"), hashes.get("SHA256"), hashes.get("sha256"), ) @@ -545,15 +563,24 @@ def build_model_resolver_metadata( _first_present(source.get("creator"), details.get("creator"), path_metadata.get("creator")) ) file_info = _find_metadata_file_info(source, selected_version, str(filename)) - hashes = _as_dict(_first_present(file_info.get("hashes"), source.get("hashes"))) - sha256 = str( - _first_present( - source.get("sha256"), - source.get("hash"), + hashes = _as_dict(file_info.get("hashes") if file_info else source.get("hashes")) + hash_values = ( + ( file_info.get("sha256"), + file_info.get("hash"), hashes.get("SHA256"), hashes.get("sha256"), ) + if file_info + else ( + source.get("sha256"), + source.get("hash"), + hashes.get("SHA256"), + hashes.get("sha256"), + ) + ) + sha256 = str( + _first_present(*hash_values) or "" ).lower() direct_url = _first_present( diff --git a/core/sources/lora_manager_archive.py b/core/sources/lora_manager_archive.py index a47cdc1..6a91e03 100644 --- a/core/sources/lora_manager_archive.py +++ b/core/sources/lora_manager_archive.py @@ -417,6 +417,16 @@ def _build_result_from_row( if isinstance(version_data.get("files"), list) and version_data.get("files") else "" ) + primary_hashes = ( + primary_file.get("hashes") + if primary_file and isinstance(primary_file.get("hashes"), dict) + else {} + ) + primary_sha256 = ( + primary_file.get("sha256") + if primary_file + else "" + ) or primary_hashes.get("SHA256") or primary_hashes.get("sha256") tags = model_data.get("tags", []) if not isinstance(tags, list): @@ -457,6 +467,8 @@ def _build_result_from_row( images=version_data.get("images", []), match_type=match_type, confidence=confidence, + sha256=primary_sha256, + hashes=primary_hashes, description=model_data.get("description", ""), model_description=model_data.get("description", ""), creator=model_data.get("creator", {}), diff --git a/tests/test_downloader_metadata.py b/tests/test_downloader_metadata.py index 730319e..3c0dd22 100644 --- a/tests/test_downloader_metadata.py +++ b/tests/test_downloader_metadata.py @@ -10,6 +10,7 @@ from PIL import Image from core import downloader from core.downloader import ( + _extract_expected_sha256, build_lora_manager_metadata, download_model, get_progress, @@ -19,6 +20,34 @@ from core.path_utils import get_model_resolver_sidecar_path class DownloaderMetadataSidecarTests(unittest.TestCase): + def test_selected_file_hash_overrides_stale_source_hash(self): + stale_hash = "1" * 64 + selected_hash = "f" * 64 + metadata = { + "source": "lora_manager_archive", + "filename": "CBS_novuschroma21 style.safetensors", + "sha256": stale_hash, + "selected_file": { + "name": "CBS_novuschroma21 style.safetensors", + "hashes": {"SHA256": selected_hash}, + }, + } + + self.assertEqual(selected_hash, _extract_expected_sha256(metadata)) + + def test_selected_file_without_hash_does_not_fall_back_to_stale_source_hash(self): + stale_hash = "1" * 64 + metadata = { + "source": "lora_manager_archive", + "filename": "another-variant.safetensors", + "sha256": stale_hash, + "selected_file": { + "name": "another-variant.safetensors", + }, + } + + self.assertEqual("", _extract_expected_sha256(metadata)) + def test_keeps_model_description_separate_from_version_notes(self): with tempfile.TemporaryDirectory() as tmpdir: model_path = os.path.join(tmpdir, "model.safetensors") diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index c89dded..ade0e57 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -3549,6 +3549,92 @@ test('manual URL download metadata never inherits provider identity or hash from assert.equal('civitai' in metadata, false); }); +test('selected provider file hash overrides stale workflow hash', () => { + const getDownloadMetadata = eval(`(${extractMethod(downloadTargetMethodsSource, 'getDownloadMetadata')})`); + const staleHash = '1'.repeat(64); + const selectedHash = 'f'.repeat(64); + const missing = { + original_path: 'Other/CBS_novuschroma21 style.safetensors', + category: 'loras', + civitai_info: { sha256: staleHash, hashes: { SHA256: staleHash } }, + download_source: { source: 'civitai', sha256: staleHash } + }; + const source = { + source: 'lora_manager_archive', + details_source: 'lora_manager_archive', + model_id: 525933, + version_id: 123, + match_type: 'selected', + filename: 'CBS_novuschroma21 style.safetensors', + download_url: 'https://civitai.com/api/download/models/123', + selected_file: { + name: 'CBS_novuschroma21 style.safetensors', + hashes: { SHA256: selectedHash } + } + }; + const dialog = { + getCachedSearchSuggestionData() { + throw new Error('selected provider metadata must not read cached search metadata'); + }, + getCompatibleCivitaiSearchResult() { + throw new Error('selected provider metadata must not read compatible search metadata'); + }, + getFilenameFromPath(value = '') { + return String(value).split(/[\\/]/).at(-1) || ''; + } + }; + + const metadata = getDownloadMetadata.call(dialog, missing, source, { + filename: source.filename, + category: 'loras', + url: source.download_url, + pathMetadata: { filename: source.filename, category: 'loras' } + }); + + assert.equal(metadata.source, 'lora_manager_archive'); + assert.equal(metadata.sha256, selectedHash); + assert.deepEqual(metadata.selected_file.hashes, { SHA256: selectedHash }); +}); + +test('selected provider without a file hash does not inherit workflow hash', () => { + const getDownloadMetadata = eval(`(${extractMethod(downloadTargetMethodsSource, 'getDownloadMetadata')})`); + const staleHash = '1'.repeat(64); + const missing = { + original_path: 'Other/another-variant.safetensors', + category: 'loras', + civitai_info: { sha256: staleHash }, + download_source: { source: 'civitai', sha256: staleHash } + }; + const source = { + source: 'lora_manager_archive', + details_source: 'lora_manager_archive', + match_type: 'selected', + filename: 'another-variant.safetensors', + download_url: 'https://civitai.com/api/download/models/456', + selected_file: { name: 'another-variant.safetensors' } + }; + const dialog = { + getCachedSearchSuggestionData() { + throw new Error('selected provider metadata must not read cached search metadata'); + }, + getCompatibleCivitaiSearchResult() { + throw new Error('selected provider metadata must not read compatible search metadata'); + }, + getFilenameFromPath(value = '') { + return String(value).split(/[\\/]/).at(-1) || ''; + } + }; + + const metadata = getDownloadMetadata.call(dialog, missing, source, { + filename: source.filename, + category: 'loras', + url: source.download_url, + pathMetadata: { filename: source.filename, category: 'loras' } + }); + + assert.equal(metadata.sha256, ''); +}); + test('CivArchive metadata keeps its page URL when the download mirror is HuggingFace', () => { const getDownloadMetadata = eval(`(${extractMethod(downloadTargetMethodsSource, 'getDownloadMetadata')})`); const civarchivePage = 'https://civarchive.com/models/123?modelVersionId=456'; diff --git a/tests/test_lora_manager_archive.py b/tests/test_lora_manager_archive.py index ff6a67d..5563886 100644 --- a/tests/test_lora_manager_archive.py +++ b/tests/test_lora_manager_archive.py @@ -67,7 +67,19 @@ class LoraManagerArchiveTests(unittest.TestCase): # Insert dummy data conn.execute("INSERT INTO models VALUES (1, 'ghost_checkpoint', 'CHECKPOINT', ?)", (json.dumps({"tags": ["anime"]}),)) conn.execute("INSERT INTO model_versions VALUES (10, 1, 'v1.0', 'SD 1.5', 1, ?)", (json.dumps({"trainedWords": ["ghost_word"]}),)) - conn.execute("INSERT INTO model_files VALUES (100, 10, 'Model', ?)", (json.dumps({"name": "ghost_v1.safetensors"}),)) + archive_hash = "a" * 64 + conn.execute( + "INSERT INTO model_files VALUES (100, 10, 'Model', ?)", + ( + json.dumps( + { + "name": "ghost_v1.safetensors", + "downloadUrl": "https://civitai.com/api/download/models/10", + "hashes": {"SHA256": archive_hash}, + } + ), + ), + ) conn.commit() # Test querying candidates @@ -88,5 +100,7 @@ class LoraManagerArchiveTests(unittest.TestCase): self.assertEqual(result["filename"], "ghost_v1.safetensors") self.assertEqual(result["name"], "ghost_checkpoint") self.assertEqual(result["trained_words"], ["ghost_word"]) + self.assertEqual(result["sha256"], archive_hash) + self.assertEqual(result["hashes"], {"SHA256": archive_hash}) conn.close() diff --git a/web/resolver/search/download_target_methods.js b/web/resolver/search/download_target_methods.js index c9d63e4..afd6f4e 100644 --- a/web/resolver/search/download_target_methods.js +++ b/web/resolver/search/download_target_methods.js @@ -1328,15 +1328,22 @@ export const downloadTargetMethods = { || sourceData.match_type === 'custom_url' || sourceData.provided_url ); + const sourceIdentity = String( + sourceData.details_source || sourceData.source || sourceData.sourceKey || '' + ).trim().toLowerCase().replace(/[-\s]+/g, '_'); + const isProviderSelection = sourceData.match_type === 'selected' + || ['civitai', 'civarchive', 'huggingface', 'lora_manager_archive'].includes(sourceIdentity); + const useSourceProvenanceBoundary = isProvidedUrl || isProviderSelection; // A URL explicitly selected by the user is an authoritative provenance // boundary. Never fill its hash, provider IDs, or file details from an - // earlier fuzzy search result belonging to another provider. - const searchSuggestion = isProvidedUrl ? null : this.getCachedSearchSuggestionData(missing); - const compatibleCivitaiSearch = isProvidedUrl + // earlier fuzzy search result belonging to another provider. The same + // rule applies to a selected/provider result, including LoRA Archive. + const searchSuggestion = useSourceProvenanceBoundary ? null : this.getCachedSearchSuggestionData(missing); + const compatibleCivitaiSearch = useSourceProvenanceBoundary ? {} : (this.getCompatibleCivitaiSearchResult?.(missing) || {}); - const inheritedDownloadSource = isProvidedUrl ? {} : (missing?.download_source || {}); - const inheritedCivitaiInfo = isProvidedUrl ? {} : (missing?.civitai_info || {}); + const inheritedDownloadSource = useSourceProvenanceBoundary ? {} : (missing?.download_source || {}); + const inheritedCivitaiInfo = useSourceProvenanceBoundary ? {} : (missing?.civitai_info || {}); const merged = { ...inheritedCivitaiInfo, ...compatibleCivitaiSearch, @@ -1385,6 +1392,36 @@ export const downloadTargetMethods = { || (merged.creator_username ? { username: merged.creator_username } : null) || (merged.username ? { username: merged.username } : null) || null; + const getHashFromValue = (value) => { + if (!value || typeof value !== 'object') return ''; + const hashes = value.hashes && typeof value.hashes === 'object' ? value.hashes : {}; + return String( + value.sha256 + || value.hash + || hashes.SHA256 + || hashes.sha256 + || '' + ).trim().toLowerCase(); + }; + const getComparableFilename = (value) => String(value || '').split(/[\\/]+/).pop().trim().toLowerCase(); + const selectedFilename = getComparableFilename( + options.filename || sourceData.filename || sourceData.file_name || merged.filename + ); + const fileCandidates = [ + sourceData.selected_file, + sourceData.selectedFile, + sourceData.file_info, + sourceData.file, + ...(Array.isArray(sourceData.selected_version?.files) ? sourceData.selected_version.files : []), + ...(Array.isArray(sourceData.files) ? sourceData.files : []) + ].filter(file => file && typeof file === 'object'); + const selectedFile = fileCandidates.find(file => { + const filename = getComparableFilename(file.name || file.filename || file.fileName); + return selectedFilename && filename === selectedFilename; + }) || fileCandidates[0] || null; + const selectedFileSha256 = getHashFromValue(selectedFile); + const sourceDataSha256 = getHashFromValue(sourceData); + const selectedSourceSha256 = selectedFileSha256 || sourceDataSha256; const sourceName = sourceData.details_source || sourceData.source || sourceData.sourceKey @@ -1452,7 +1489,9 @@ export const downloadTargetMethods = { platform_url: sourceData.platform_url || sourceData.platformUrl || merged.platform_url || merged.platformUrl || '', download_url: options.url || merged.download_url || merged.downloadUrl || merged.url || '', size: merged.size || merged.size_bytes || '', - sha256: merged.sha256 || merged.hash || merged.hashes?.SHA256 || merged.hashes?.sha256 || '', + sha256: selectedSourceSha256 || (useSourceProvenanceBoundary + ? '' + : (merged.sha256 || merged.hash || merged.hashes?.SHA256 || merged.hashes?.sha256 || '')), path_metadata: pathMetadata };