feat(metadata): add CivitAI AutoV3 hash support across all storage layers

- Three-state autov3 field (not-checked / checked-unavailable / 12-hex value)
  in .metadata.json sidecars, in-memory ModelHashIndex, and SQLite
  (models.autov3 column + autov3_index table) with column-presence migration
- Background self-terminating backfill for legacy rows: per-model-type
  concurrency guard, executor-offloaded I/O, Civitai-first resolution
  (SHA256-matched version file) falling back to the embedded safetensors
  header hash
- Civitai-first propagation on metadata refresh, scan, and download paths;
  reject the empty-string SHA256 placeholder and strip OneTrainer 0x prefix
- List API hash filters and hash index lookups accept 12-char AutoV3
- Cap safetensors header reads at 64 MiB to prevent crafted-file allocation
- Prevent stale AutoV3 mappings on file replacement while preserving them on
  same-file re-registration (lazy-hash completion)
This commit is contained in:
Will Miao
2026-08-08 14:30:34 +08:00
parent 4bf9a4b640
commit 97b9b1f62b
23 changed files with 1918 additions and 50 deletions

View File

@@ -1318,3 +1318,70 @@ class TestHfGroupKey:
"hf_url": "https://huggingface.co/user/repo",
}
assert BaseModelService._extract_group_key(item) == "hf:user/repo"
class TestApplyHashFilters:
"""_apply_hash_filters matches items by SHA256 or non-empty AutoV3."""
def _make_service(self):
return DummyService(model_type="stub", scanner=object(), metadata_class=BaseModelMetadata)
@pytest.mark.asyncio
async def test_matches_item_by_autov3(self):
service = self._make_service()
data = [
{"file_path": "/m/one.safetensors", "sha256": "a" * 64, "autov3": "abcdef123456"},
{"file_path": "/m/two.safetensors", "sha256": "b" * 64, "autov3": ""},
]
result = await service._apply_hash_filters(data, {"single_hash": "ABCDEF123456"})
assert [item["file_path"] for item in result] == ["/m/one.safetensors"]
@pytest.mark.asyncio
async def test_matches_item_by_sha256(self):
service = self._make_service()
data = [
{"file_path": "/m/one.safetensors", "sha256": "a" * 64, "autov3": ""},
]
result = await service._apply_hash_filters(data, {"single_hash": "A" * 64})
assert [item["file_path"] for item in result] == ["/m/one.safetensors"]
@pytest.mark.asyncio
async def test_empty_or_absent_autov3_never_matches(self):
service = self._make_service()
data = [
{"file_path": "/m/one.safetensors", "sha256": "a" * 64, "autov3": ""},
{"file_path": "/m/two.safetensors", "sha256": "b" * 64},
]
result = await service._apply_hash_filters(data, {"single_hash": "cdef123456ab"})
assert result == []
@pytest.mark.asyncio
async def test_multiple_hashes_match_autov3_and_sha256(self):
service = self._make_service()
data = [
{"file_path": "/m/one.safetensors", "sha256": "a" * 64, "autov3": "abcdef123456"},
{"file_path": "/m/two.safetensors", "sha256": "b" * 64, "autov3": ""},
]
result = await service._apply_hash_filters(
data, {"multiple_hashes": ["abcdef123456", "c" * 64]}
)
assert [item["file_path"] for item in result] == ["/m/one.safetensors"]
@pytest.mark.asyncio
async def test_no_hash_filters_returns_data_unchanged(self):
service = self._make_service()
data = [
{"file_path": "/m/one.safetensors", "sha256": "a" * 64, "autov3": "abcdef123456"},
]
result = await service._apply_hash_filters(data, {})
assert result == data