fix(recipes): reject the empty-hash placeholder when resolving LoRA hashes

The SHA256 of an empty byte string (written by repackaging tools into
safetensors metadata, or produced by hashing an empty/unreadable file)
was previously resolved against CivitAI's by-hash API, which can contain
polluted entries for it (e.g. a broken SD 1.5 LoRA whose AutoV3 equals
the placeholder) and falsely attributed the wrong model to a recipe.

Guard all lookup paths for the 10/12/64-char AutoV2/AutoV3/full-SHA256
spellings: CivitaiClient.get_model_by_hash/_fetch_version_by_hash return
not-found without a request, and ModelHashIndex ignores the placeholder
in has_hash/get_path/add_autov3.

The Automatic1111 metadata parser keeps the LoRA item itself when its
hash is the placeholder: it matches by filename locally, or retains the
entry with an empty hash flagged hashInvalid (unresolvable-hash state in
the UI, with reconnect as the remedy) instead of dropping it or resolving
it to a polluted CivitAI entry.
This commit is contained in:
Will Miao
2026-09-01 20:40:21 +08:00
parent 39e7c1376c
commit 1fd7cc0123
8 changed files with 235 additions and 7 deletions
@@ -503,3 +503,64 @@ async def test_parse_metadata_extracts_checkpoint_from_model_hash(monkeypatch):
assert result["model"] == checkpoint
assert result["base_model"] == "flux"
assert result["loras"] == []
@pytest.mark.asyncio
async def test_parse_metadata_keeps_empty_placeholder_hash_lora_unresolved(monkeypatch):
"""A LoRA hash equal to the SHA256("") placeholder must never be resolved
against CivitAI or the local hash index, but the LoRA item itself must be
kept: matched by filename locally when present, otherwise kept as an
unresolved entry (no hash) instead of being dropped."""
queried_hashes = []
async def fake_metadata_provider():
class Provider:
async def get_model_by_hash(self, model_hash):
queried_hashes.append(model_hash)
return None, "Model not found"
async def get_model_version_info(self, version_id):
raise AssertionError("get_model_version_info should not be called")
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
parser = AutomaticMetadataParser()
metadata_text = (
"photo of a DeLorean DMC12, <lora:dmc12bttf:1.2>, at night\n"
"Steps: 20, Sampler: Euler, CFG scale: 1, Seed: 2242760352, Size: 1280x720, "
"Model: flux1-dev, Model hash: 3f97fdc57a, "
'Lora hashes: "dmc12bttf: e3b0c44298fc"'
)
# Local file with the same name: the item is matched by filename.
scanner_with_local = LocalRecipeScanner({"dmc12bttf": local_lora("dmc12bttf")})
result = await parser.parse_metadata(metadata_text, recipe_scanner=scanner_with_local)
assert "e3b0c44298fc" not in queried_hashes
assert "e3b0c44298" not in queried_hashes
assert scanner_with_local.hash_queries == []
assert scanner_with_local.queries == ["dmc12bttf"]
assert len(result["loras"]) == 1
assert result["loras"][0]["file_name"] == "dmc12bttf"
assert result["loras"][0]["weight"] == 1.2
assert result["loras"][0]["existsLocally"] is True
assert result["loras"][0]["isDeleted"] is False
# No local file: the item is kept as unresolved (empty hash, flagged
# hashInvalid so the UI renders the unresolvable-hash badge).
scanner_without_local = LocalRecipeScanner({})
result = await parser.parse_metadata(metadata_text, recipe_scanner=scanner_without_local)
assert len(result["loras"]) == 1
lora = result["loras"][0]
assert lora["file_name"] == "dmc12bttf"
assert lora["weight"] == 1.2
assert lora["hash"] == ""
assert lora["hashInvalid"] is True
assert lora["existsLocally"] is False
assert lora["isDeleted"] is False
+29
View File
@@ -789,3 +789,32 @@ async def test_get_creator_model_count_never_raises(downloader):
client = await CivitaiClient.get_instance()
assert await client.get_creator_model_count("pixel") is None
@pytest.mark.parametrize(
"placeholder_hash",
[
"e3b0c44298", # AutoV2 (10 chars)
"e3b0c44298fc", # AutoV3 (12 chars)
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", # full SHA256
],
)
async def test_get_model_by_hash_rejects_empty_placeholder_without_request(downloader, placeholder_hash):
"""The empty-hash placeholder must never be resolved via the by-hash API:
CivitAI's index can contain polluted entries for it (e.g. a broken SD 1.5
LoRA whose AutoV3 equals the placeholder)."""
requested = []
async def fake_make_request(method, url, use_auth=True, **kwargs):
requested.append(url)
return True, {}
downloader.make_request = fake_make_request
client = await CivitaiClient.get_instance()
result, error = await client.get_model_by_hash(placeholder_hash)
assert result is None
assert error == "Model not found"
assert requested == []
+36
View File
@@ -1,5 +1,6 @@
import pytest
from py.services.model_hash_index import ModelHashIndex
from py.utils.constants import EMPTY_HASH_SHA256
class TestModelHashIndexRemoveByPath:
@@ -253,3 +254,38 @@ class TestModelHashIndexAutov3:
assert index.has_hash("abcdef123456") is False
assert index.get_path("fedcba654321") == "/models/ckpt.safetensors"
assert index.get_all_autov3() == {"fedcba654321": "/models/ckpt.safetensors"}
class TestModelHashIndexEmptyPlaceholder:
def test_add_autov3_rejects_empty_placeholder(self):
index = ModelHashIndex()
index.add_autov3("e3b0c44298fc", "/models/lora.safetensors")
assert "e3b0c44298fc" not in index.get_all_autov3()
def test_add_entry_rejects_empty_placeholder_autov3(self):
index = ModelHashIndex()
index.add_entry("abc123", "/models/lora.safetensors", autov3="e3b0c44298fc")
assert "e3b0c44298fc" not in index.get_all_autov3()
def test_has_hash_false_for_placeholder(self):
index = ModelHashIndex()
index.add_entry("abc123", "/models/lora.safetensors")
assert not index.has_hash("e3b0c44298")
assert not index.has_hash("e3b0c44298fc")
assert not index.has_hash(EMPTY_HASH_SHA256)
def test_get_path_none_for_placeholder(self):
index = ModelHashIndex()
index.add_entry("abc123", "/models/lora.safetensors")
assert index.get_path("e3b0c44298") is None
assert index.get_path("e3b0c44298fc") is None
assert index.get_path(EMPTY_HASH_SHA256) is None
def test_placeholder_autov3_does_not_clobber_existing_mapping(self):
# Registering a path with a placeholder autov3 must not replace or
# clear autov3 mappings already registered for other paths.
index = ModelHashIndex()
index.add_entry("a" * 64, "/models/real.safetensors", autov3="abcdef123456")
index.add_entry("b" * 64, "/models/other.safetensors", autov3="e3b0c44298fc")
assert index.get_path("abcdef123456") == "/models/real.safetensors"
assert index.get_all_autov3() == {"abcdef123456": "/models/real.safetensors"}