fix: prefer selected-file hash over inherited workflow or source hash

This commit is contained in:
Dariusz L
2026-08-01 22:50:13 +02:00
parent d906a14804
commit c7a0f2f78c
6 changed files with 223 additions and 16 deletions
+36 -9
View File
@@ -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(
+12
View File
@@ -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", {}),
+29
View File
@@ -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")
@@ -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';
+15 -1
View File
@@ -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()
+45 -6
View File
@@ -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
};