fix(download): decode percent-encoded stored filenames before saving

CivitAI stores non-ASCII file names percent-encoded; the mini endpoint's
raw fileName (preferred since #1100) returned that encoded string, so it
landed on disk literally as "%E7%8A%AC..." and the path-budget
truncation could cut mid-escape. Decode to valid non-ASCII UTF-8 names
before the on-disk name is fixed; literal % (stored as %25) and invalid
escapes pass through unchanged.
This commit is contained in:
Will Miao
2026-10-19 08:15:03 +08:00
parent b5d313113c
commit e95ff360cf
4 changed files with 200 additions and 1 deletions
+14 -1
View File
@@ -42,7 +42,7 @@ from ..utils.paid_access import (
)
from ..utils.file_utils import calculate_sha256, calculate_autov3
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
from ..utils.utils import calculate_filename_for_model, fit_stem_to_path_budget, sanitize_folder_name
from ..utils.utils import calculate_filename_for_model, decode_percent_encoded_filename, fit_stem_to_path_budget, sanitize_folder_name
from ..utils.exif_utils import ExifUtils
from ..utils.metadata_manager import MetadataManager
from ..utils.sidecar_paths import get_metadata_path, get_preview_dir
@@ -2241,6 +2241,19 @@ class DownloadManager:
file_name = file_info.get("name", "")
if not file_name:
return {"success": False, "error": "No filename found in file info"}
# CivitAI stores non-ASCII file names percent-encoded; the mini
# endpoint (and CivArchive) return that raw stored name, which
# must be decoded before it becomes the on-disk name — otherwise
# the literal "%E7%8A%AC..." string lands on the filesystem.
decoded_file_name = decode_percent_encoded_filename(file_name)
if decoded_file_name != file_name:
logger.info(
"[download] Decoded percent-encoded filename '%s' to '%s'",
file_name,
decoded_file_name,
)
file_name = decoded_file_name
file_info = {**file_info, "name": file_name}
# Raw stored filenames carry no length cap, and the fixed stem
# cap only applies to template renames; trim to the real path
# budget (model path and sidecar mirror alike) so the target
+27
View File
@@ -4,6 +4,7 @@ import logging
import os
import re
from typing import Any, Dict, List, Optional
from urllib.parse import unquote_to_bytes
from ..services.service_registry import ServiceRegistry
from ..config import config
from ..services.settings_manager import get_settings_manager
@@ -532,6 +533,32 @@ def _truncate_to_byte_budget(text: str, max_bytes: int) -> str:
return encoded[:max_bytes].decode("utf-8", errors="ignore")
def decode_percent_encoded_filename(name: str) -> str:
"""Decode a percent-encoded stored filename back to the real name.
CivitAI's backend stores file names with non-ASCII characters
percent-encoded (URL-quoted): the mini endpoint's ``fileName`` is that
raw stored name (e.g. ``%E7%8A%AC...safetensors``), while the REST
``files[].name`` is the decoded display name. Saving the raw string
literally would land ``%E7%8A%AC...`` on disk instead of the real
characters.
A literal ``%`` in an uploader's name is itself stored as ``%25``, so a
decoded result that stays pure ASCII (e.g. ``100%25cool`` ->
``100%cool``) is treated as a real name and returned unchanged — only
decodes that yield valid UTF-8 with non-ASCII characters are applied.
"""
if "%" not in name:
return name
try:
decoded = unquote_to_bytes(name).decode("utf-8", errors="strict")
except (UnicodeDecodeError, ValueError):
return name
if decoded == name or not any(ord(char) > 127 for char in decoded):
return name
return decoded
def fit_stem_to_path_budget(dir_path: str, stem: str, extension: str = "") -> str:
"""Truncate ``stem`` so the file and its companions fit the path budget.
@@ -2293,6 +2293,116 @@ async def test_download_falls_back_to_rest_name_when_mini_fails(
assert captured["file_path"].endswith("rewritten_v10.safetensors")
@pytest.mark.asyncio
async def test_download_decodes_percent_encoded_raw_file_name(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""CivitAI stores non-ASCII file names percent-encoded; the mini
endpoint's raw fileName must be decoded before it lands on disk."""
manager = DownloadManager()
encoded_name = (
"%E7%8A%AC%E5%A4%9C%E5%8F%89%E6%97%A5%E6%9A%AE2000s_anime_analog_painting_Style.safetensors"
)
metadata_provider.payload = {
"id": 42,
"model": {"type": "LORA", "tags": ["fantasy"]},
"baseModel": "Illustrious",
"creator": {"username": "Author"},
"files": [
{
"id": 1001,
"type": "Model",
"primary": True,
"name": "犬夜叉日暮2000s_anime_analog_painting_Style.safetensors",
"downloadUrl": "https://example.invalid/file.safetensors",
}
],
}
metadata_provider.get_version_file_mini = AsyncMock(
return_value={"fileName": encoded_name}
)
captured = {}
async def fake_execute_download(self, **kwargs):
captured["file_path"] = kwargs["metadata"].file_path
return {"success": True}
monkeypatch.setattr(
DownloadManager, "_execute_download", fake_execute_download, raising=False
)
result = await manager.download_from_civitai(
model_version_id=42,
save_dir=str(tmp_path),
use_default_paths=True,
progress_callback=None,
source=None,
)
assert result["success"] is True
assert captured["file_path"].endswith(
"犬夜叉日暮2000s_anime_analog_painting_Style.safetensors"
)
@pytest.mark.asyncio
async def test_download_decodes_percent_encoded_name_for_civarchive_source(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""CivArchive serves raw stored names too — the decode applies even
though the mini lookup is skipped."""
manager = DownloadManager()
encoded_name = (
"%E7%8A%AC%E5%A4%9C%E5%8F%89%E6%97%A5%E6%9A%AE2000s_anime_analog_painting_Style.safetensors"
)
metadata_provider.payload = {
"id": 99,
"model": {"type": "LORA", "tags": ["fantasy"]},
"baseModel": "Illustrious",
"creator": {"username": "Author"},
"files": [
{
"id": 1001,
"type": "Model",
"primary": True,
"name": encoded_name,
"downloadUrl": "https://example.invalid/file.safetensors",
}
],
}
metadata_provider.get_version_file_mini = AsyncMock()
monkeypatch.setattr(
download_manager,
"get_metadata_provider",
AsyncMock(return_value=metadata_provider),
)
captured = {}
async def fake_execute_download(self, **kwargs):
captured["file_path"] = kwargs["metadata"].file_path
return {"success": True}
monkeypatch.setattr(
DownloadManager, "_execute_download", fake_execute_download, raising=False
)
result = await manager.download_from_civitai(
model_version_id=99,
save_dir=str(tmp_path),
use_default_paths=True,
progress_callback=None,
source="civarchive",
)
assert result["success"] is True
assert captured["file_path"].endswith(
"犬夜叉日暮2000s_anime_analog_painting_Style.safetensors"
)
@pytest.mark.asyncio
async def test_download_skips_mini_lookup_for_civarchive_source(
monkeypatch, scanners, metadata_provider, tmp_path
+49
View File
@@ -16,6 +16,7 @@ from py.utils.utils import (
calculate_filename_for_model,
calculate_recipe_fingerprint,
calculate_relative_path_for_model,
decode_percent_encoded_filename,
fit_stem_to_path_budget,
get_lora_info,
get_lora_info_absolute,
@@ -744,3 +745,51 @@ def test_calculate_filename_without_target_dir_keeps_fixed_cap(isolated_settings
result = calculate_filename_for_model(model_data, "lora")
assert len(result) == MAX_FILENAME_STEM_LENGTH
# Real raw stored fileName served by CivitAI's mini endpoint for model
# version 1714548: non-ASCII names are percent-encoded server-side.
PERCENT_ENCODED_STORED_NAME = (
"%E7%8A%AC%E5%A4%9C%E5%8F%89%E6%97%A5%E6%9A%AE%E3%81%8B%E3%81%94"
"%E3%82%812000%E5%B9%B4%E4%BB%A3%E3%82%A2%E3%83%8B%E3%83%A1%E3%82%A2"
"%E3%83%8A%E3%83%AD%E3%82%B0%E4%BD%9C%E7%94%BB%E9%A2%A8illustrious"
"InuyashaHigurashi_Kagome2000s_anime_analog_painting_Styleillustrious"
".safetensors"
)
def test_decode_percent_encoded_filename_decodes_non_ascii_names():
result = decode_percent_encoded_filename(PERCENT_ENCODED_STORED_NAME)
assert result == (
"犬夜叉日暮かごめ2000年代アニメアナログ作画風illustrious"
"InuyashaHigurashi_Kagome2000s_anime_analog_painting_Styleillustrious"
".safetensors"
)
def test_decode_percent_encoded_filename_passes_plain_names_through():
assert decode_percent_encoded_filename("model.safetensors") == "model.safetensors"
def test_decode_percent_encoded_filename_keeps_ascii_only_escapes_literal():
# A literal "%" is stored as "%25", so decoding would yield pure ASCII:
# treat the string as a real name and leave it alone.
assert (
decode_percent_encoded_filename("100%25cool.safetensors")
== "100%25cool.safetensors"
)
def test_decode_percent_encoded_filename_keeps_invalid_escapes_literal():
assert (
decode_percent_encoded_filename("100%zzcool.safetensors")
== "100%zzcool.safetensors"
)
def test_decode_percent_encoded_filename_keeps_truncated_escapes_literal():
# A mid-triplet cut (e.g. from a previous length-truncation bug) does not
# decode to valid UTF-8 and must pass through untouched.
truncated = PERCENT_ENCODED_STORED_NAME[:100]
assert decode_percent_encoded_filename(truncated) == truncated