fix: prefer selected-file hash over inherited workflow or source hash
This commit is contained in:
+36
-9
@@ -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(
|
||||
|
||||
@@ -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", {}),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user