Merge feature/next: models that are links, taken as named

1.60's own places that took a model file by name use the new helpers
too: the scan choice (model_file_within), the editor's covers, Tidy's
move (model_reference_within). Opening a folder keeps 1.60's "no file
name opens the folder". move_into tells a rename from a copy by where the
link is (lstat), so Tidy moving a linked model renames the link instead
of copying the model disk's file onto the server. The open-folder test
stubs through path_utils and also refuses "..".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-16 21:18:31 +08:00
co-authored by Claude Opus 5.5
15 changed files with 128 additions and 28 deletions
+1 -1
View File
@@ -107,7 +107,7 @@ ComfyUI-Manager 收录审核检查的就是这几类口子(2026-10 审 Anomalo
1. **不执行下载来的或用户文件里的代码。** 数据不用 `pickle`、`eval`、`exec`、动态导入读;配置文件当数据解析。模型文件只读 safetensors / GGUF 的文件头,`.ckpt` / `.pt` / `.pth` / `.bin` 不打开,界面提醒它们可能带代码。
2. **下载要能核对。** 只从允许的 https 站点下载,先写 `.part`,核对 SHA-256 后才改名放入;没有可核对的指纹时在界面上说明。下载的东西只当数据用。
3. **接口不接受任意本机路径。** 只收“类型 + 根目录序号 + 相对路径”,在后端用 `resolve_within` 校验;需要指向本机某个文件时,由服务端自己找到它,只给页面一个令牌。
3. **接口不接受任意本机路径。** 只收“类型 + 根目录序号 + 相对路径”,在后端用 `resolve_within` 校验;需要指向本机某个文件时,由服务端自己找到它,只给页面一个令牌。模型文件本身可以是链接(云服务器上模型文件夹里全是指向平台模型盘的链接):文件夹部分照样用 `resolve_within`,文件用 `model_file_within` / `model_reference_within` 按名字取,不跟着链接走出去,封面、信息和改动都在链接旁边。
4. **会写入、移动、删除文件或替用户联网的接口只接受本机请求**(请求来自本机,没有经过代理,Host 也是本机)。用 `--listen` 开放到局域网时,别的设备只能查看。由 `api/local_guard.py` 的中间件统一把关:`/anomalous/` 下 GET 以外的请求默认只接受本机,确实只读的 POST 列进 `OPEN_POSTS`,不只是读的 GET(备份文件、联网、本机文件夹)列进 `LOCAL_GETS`;新接口要想清楚属于哪一类,不要为了方便放进 `OPEN_POSTS`。
5. **用户的文件删除一律进回收站,没有回收站就拒绝;从不覆盖**(同名另存为“名字 (2)”或拒绝)。插件自己产生的临时文件(`.part`、暂存区)可以直接删。
6. **只读的接口也按第 3 条来。** “只是读”不是例外;能按名字、编号取的,就不收路径,由服务端在自己登记的范围里找到文件。
+1
View File
@@ -186,6 +186,7 @@
- A model whose file header cannot be read (a download cut short, or another tool that rewrote the header and failed) used to look like a model Civitai simply does not know, inferred from the file. A scan now checks the header of every model it cannot match and names these **文件损坏**: a red badge on the card, a line in the details, a warning on the scan page, and first place in its unmatched list with advice to download the model again (then **Look up again**). Only the header is read. Models scanned before keep their old reason until they are looked up again.
### Fixes
- **Models that are links (模型是快捷方式/链接时)**: on a cloud server such as AutoDL every model in the models folder is a link into the platform's shared model disk. Scans wrote the info beside the link, but the cards read it from the disk behind it: a scanned model showed as not scanned, under the disk's file name (`6b4679…`) instead of its own, and editing, deleting, opening its folder or finding it from a saved workflow could be refused as "outside the models folder". A linked model is now taken as named, with its covers and info beside the link; edits, renames and deletes act on the link, never on the shared disk. Names leaving the folder (`..`) are refused as before.
- **Ctrl+Z after a scan (扫描后的 Ctrl+Z)**: after any scan, the first Ctrl+Z used to seem to do nothing: the workflow's saved fingerprints had changed, which ComfyUI counted as a step. A change of fingerprints alone no longer takes a step.
- **Esc in a model's Outputs window (作品窗口里按 Esc)**: Esc closes the window of pictures made with a model; it used to close the model's details behind it and leave the window open.
+2 -2
View File
@@ -31,7 +31,7 @@ from .metadata import get_metadata
from .model_catalog import _iter_search_models
from .model_constants import MEDIA_EXTENSIONS, MODEL_EXTENSIONS, PREVIEW_SUFFIXES
from .notebooks import _list_notebooks
from .path_utils import resolve_folder_subdir, resolve_within
from .path_utils import model_file_within, resolve_folder_subdir, resolve_within
from .recipe_store import _list_recipes, get_recipes_dir
from .scan_report import last_scan_path
from .scan_summary import _summarize
@@ -94,7 +94,7 @@ def _resolve_model(model_id):
try:
path_idx = int(path_idx)
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, "/" + subfolder)
file_path = resolve_within(target_dir, filename)
file_path = model_file_within(target_dir, filename)
except (ValueError, KeyError, TypeError):
raise ToolError(f"No models folder for {model_id}.")
if not filename.lower().endswith(MODEL_EXTENSIONS) or not os.path.isfile(file_path):
+2 -1
View File
@@ -222,7 +222,8 @@ def _get_metadata_cached(file_path, signature):
def get_metadata(file_path):
normalized_path = os.path.realpath(file_path)
# As named, not resolved: a model that is a link has its covers and info beside the link.
normalized_path = os.path.abspath(file_path)
signature = _metadata_signature(normalized_path)
# Metadata contains mutable lists. Never expose the cached object directly.
return copy.deepcopy(_get_metadata_cached(normalized_path, signature))
+2 -5
View File
@@ -14,7 +14,7 @@ from .folder_types import (
get_active_folder_types, get_active_model_roots,
get_active_physical_basenames, get_folder_view_mode,
)
from .path_utils import resolve_folder_subdir
from .path_utils import model_reference_within, resolve_folder_subdir
def _collect_folder_models(target_dir, folder_type, path_idx, rel_subfolder, page, limit):
"""Collect one folder in a worker thread with a single directory listing."""
@@ -447,11 +447,8 @@ def _resolve_paths_to_model_info_sync(paths, folder_types=None, exact_only=False
real_base_dir = os.path.realpath(base_dir)
roots.append((folder_type, path_idx, real_base_dir))
for original, _, relative_path in requested:
relative = relative_path.replace('/', os.sep)
candidate = os.path.realpath(os.path.join(real_base_dir, relative))
try:
if os.path.commonpath((real_base_dir, candidate)) != real_base_dir:
continue
candidate = model_reference_within(real_base_dir, relative_path.replace('/', os.sep))
except ValueError:
continue
if os.path.isfile(candidate) and is_model_file(candidate):
+2 -2
View File
@@ -10,7 +10,7 @@ import time
from aiohttp import web
from .utils import require_filename, resolve_folder_subdir, resolve_within
from .utils import model_file_within, resolve_folder_subdir
def _explorer_windows():
@@ -113,7 +113,7 @@ async def api_open_model_folder(request):
try:
_, folder = resolve_folder_subdir(data.get("type", ""), int(data.get("path_idx", 0)), data.get("subfolder", "/"))
filename = data.get("filename")
path = resolve_within(folder, require_filename(filename)) if filename else None
path = model_file_within(folder, filename) if filename else None
except (ValueError, KeyError, TypeError):
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
if not os.path.isfile(path) if path else not os.path.isdir(folder):
+3 -1
View File
@@ -257,8 +257,10 @@ def destination(data, size=0):
def _same_drive(first, second):
"""Whether `first` can be renamed to `second`; a link counts where the link is (moving
it moves the link, not the model disk behind it)."""
try:
return os.stat(first).st_dev == os.stat(os.path.dirname(second)).st_dev
return os.lstat(first).st_dev == os.stat(os.path.dirname(second)).st_dev
except OSError:
return False
+2 -2
View File
@@ -11,7 +11,7 @@ import folder_paths
from .model_constants import CIVITAI_BACKUP_SUFFIXES, MEDIA_EXTENSIONS, PREVIEW_SUFFIXES
from .trash import move_to_trash
from .utils import require_filename, resolve_folder_subdir, resolve_within
from .utils import model_file_within, require_filename, resolve_folder_subdir, resolve_within
def _cache_token(file_path):
try:
@@ -170,7 +170,7 @@ async def api_model_covers(request):
path_idx = int(request.query.get('path_idx', 0))
filename = require_filename(request.query.get('filename', ''))
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, request.query.get('subfolder', '/'))
file_path = resolve_within(target_dir, filename)
file_path = model_file_within(target_dir, filename)
except (ValueError, KeyError, TypeError):
return web.json_response({"error": "Invalid model location"}, status=400)
if not os.path.isfile(file_path):
+3 -3
View File
@@ -24,7 +24,7 @@ try:
from ..model_identity import USER_INFO_SUFFIX, read_json
except ImportError:
from model_identity import USER_INFO_SUFFIX, read_json
from .utils import require_filename, resolve_folder_subdir, resolve_within
from .utils import model_file_within, require_filename, resolve_folder_subdir
def _first_existing_sidecar(base_path, suffixes):
for suffix in suffixes:
@@ -97,7 +97,7 @@ async def api_delete_model(request):
try:
filename = require_filename(filename)
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
model_path = resolve_within(target_dir, filename)
model_path = model_file_within(target_dir, filename)
except (ValueError, KeyError):
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
if not os.path.exists(model_path):
@@ -167,7 +167,7 @@ async def api_update_metadata(request):
try:
filename = require_filename(extracted_filename)
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
file_path = resolve_within(target_dir, filename)
file_path = model_file_within(target_dir, filename)
except (ValueError, KeyError):
pass
+2 -2
View File
@@ -36,7 +36,7 @@ from .metadata import get_metadata
from .model_constants import MEDIA_EXTENSIONS, PREVIEW_SUFFIXES, SIDECAR_SUFFIXES
from .model_download import MODEL_EXTENSIONS, clean_folder, forget_file_lists
from .model_import import Refused, destination, import_types, move_into
from .path_utils import resolve_within
from .path_utils import model_reference_within
# (folder type, what the file is): it loads there all the same.
WORKS_ANYWAY = {("diffusion_models", "checkpoints")}
@@ -214,7 +214,7 @@ def move(data):
index = int(data.get("root", 0))
base = os.path.realpath(paths[index])
rel = clean_folder(data.get("rel"))
source = resolve_within(base, *rel.split("/"))
source = model_reference_within(base, rel.replace("/", os.sep))
except (ValueError, TypeError, IndexError):
raise Refused("bad_path")
if not os.path.isfile(source) or not rel.lower().endswith(MODEL_EXTENSIONS):
+18 -4
View File
@@ -32,16 +32,30 @@ def _model_roots():
yield folder_type, path_index, os.path.realpath(base_dir)
def model_file_within(directory, filename):
"""The model file `filename` (a bare name) in `directory`, which was resolved inside its
models folder already, as named: a model that is a link stays the link (on a cloud server
the models folder links into a shared, read-only model disk). Its covers and info are the
ones beside the link, edits are written there and a delete moves the link; only the folder
part must stay inside the models folder, and the name cannot leave it."""
return os.path.join(directory, require_filename(filename))
def model_reference_within(base_dir, relative):
"""model_file_within for a path relative to the models folder `base_dir` (subfolders and
a file name): the subfolders must stay inside it, through links too."""
head, name = os.path.split(relative)
return model_file_within(resolve_within(base_dir, head), name)
def _resolve_exact_model_reference(saved_value):
"""Resolve one saved model value without recursive scanning or hashing."""
if not isinstance(saved_value, str) or not saved_value.strip():
return None
relative_value = saved_value.replace("/", os.sep)
for folder_type, path_index, base_dir in _model_roots():
candidate = os.path.realpath(os.path.join(base_dir, relative_value))
try:
if os.path.commonpath((base_dir, candidate)) != base_dir:
continue
candidate = model_reference_within(base_dir, relative_value)
except ValueError:
continue
if not os.path.isfile(candidate) or not candidate.lower().endswith(MODEL_FILE_SUFFIXES):
@@ -68,7 +82,7 @@ def resolve_folder_subdir(folder_type, path_idx=0, subfolder='/'):
def require_filename(filename):
if not isinstance(filename, str) or not filename or os.path.basename(filename) != filename:
if not isinstance(filename, str) or filename in ("", ".", "..") or os.path.basename(filename) != filename:
raise ValueError("Invalid filename")
return filename
+2 -2
View File
@@ -9,7 +9,7 @@ from aiohttp import web
from .local_guard import is_local as _is_local
from .path_utils import atomic_write_json, resolve_folder_subdir
from .utils import resolve_within
from .utils import model_file_within
try:
from ..model_identity import USER_INFO_SUFFIX, read_json
@@ -38,7 +38,7 @@ def _set_scan_choice(targets, choice):
changed, failed = 0, []
for target_dir, name in picked:
try:
path = resolve_within(target_dir, name)
path = model_file_within(target_dir, name)
except ValueError:
failed.append(name)
continue
+13 -1
View File
@@ -19,7 +19,19 @@ small `api/utils.py` compatibility facade):
- `resolve_folder_subdir()` for configured model-folder boundaries;
- `resolve_within()` for containment below an owned root;
- `require_filename()` for single-file identifiers.
- `require_filename()` for single-file identifiers (never `.` or `..`);
- `model_file_within()` / `model_reference_within()` for a model file in a resolved folder.
A model file can be a link: on a cloud server (AutoDL) every model in the models folder links
into the platform's shared, read-only model disk, under a name like `6b4679…` without an
extension. Resolving the file would leave the models folder and lose its covers and info, which
are written beside the link. So the folder part is resolved and must stay inside its root
(through links too), and the file is taken as named: `model_file_within` is the folder joined
with a bare `require_filename` name, `get_metadata` reads the sidecars beside the path it is
given (`abspath`, not `realpath`), and edits, renames and deletes act on the link, never on the
disk behind it. Nothing in the plugin writes into a model file. Tidy's moves and the
import's `move_into` tell a rename from a copy by where the link is (`lstat`), so moving a
linked model renames the link instead of copying the model disk's file onto the server.
Never rely only on `..` rejection. Absolute Windows paths, UNC paths, alternate
separators, device paths, and symlinks can bypass naïve string checks. Media
+3 -2
View File
@@ -12,7 +12,7 @@ from unittest import mock
PLUGIN = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
from api import model_folder
from api import model_folder, path_utils
class _Request:
@@ -25,7 +25,7 @@ class _Request:
def ask(body, root):
with mock.patch.object(model_folder, "resolve_folder_subdir",
side_effect=lambda kind, idx, sub: (root, model_folder.resolve_within(root, sub.strip("/")))), \
side_effect=lambda kind, idx, sub: (root, path_utils.resolve_within(root, sub.strip("/")))), \
mock.patch.object(model_folder, "_reveal") as reveal, \
mock.patch.object(model_folder, "_open_folder") as open_folder:
response = asyncio.run(model_folder.api_open_model_folder(_Request(body)))
@@ -56,6 +56,7 @@ class OpenModelFolderTests(unittest.TestCase):
self.assertEqual(ask({"type": "loras", "subfolder": "/Anime", "filename": "b.safetensors"}, self.root)[0], 404)
self.assertEqual(ask({"type": "loras", "subfolder": "/../.."}, self.root)[0], 400)
self.assertEqual(ask({"type": "loras", "subfolder": "/", "filename": "../x"}, self.root)[0], 400)
self.assertEqual(ask({"type": "loras", "subfolder": "/Anime", "filename": ".."}, self.root)[0], 400)
if __name__ == "__main__":
+72
View File
@@ -0,0 +1,72 @@
"""A model that is a link (on a cloud server the models folder links into a shared, read-only
model disk) is the link: its covers and info are the ones beside it, and its name is the link's."""
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
PLUGIN = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
from api import metadata, model_import, path_utils
class ModelLinkTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(os.path.realpath(self.temp.name), "loras")
(self.root / "sub").mkdir(parents=True)
self.model = self.root / "sub" / "style.safetensors"
self.model.write_bytes(b"model")
Path(str(self.model)[:-12] + ".info").write_text(json.dumps({"id": 7, "modelId": 3, "name": "v1"}), encoding="utf-8")
metadata.clear_metadata_cache()
# The model is a link into the platform's model disk, outside the models folder: what
# resolving it gives (this computer cannot make links in tests).
real = os.path.realpath
outside = os.path.join(self.temp.name, "disk", "6b4679369a8f39728585bf250dd86e8")
self.resolve = mock.patch("os.path.realpath", side_effect=lambda p: outside if os.path.basename(p) == "style.safetensors" else real(p))
def tearDown(self):
self.temp.cleanup()
def test_its_info_and_name_are_the_ones_beside_the_link(self):
with self.resolve:
found = metadata.get_metadata(str(self.model))
self.assertEqual((found["info_source"], found["name"]), ("civitai", "v1"))
Path(str(self.model)[:-12] + ".info").unlink()
metadata.clear_metadata_cache()
with self.resolve:
self.assertEqual(metadata.get_metadata(str(self.model))["name"], "style") # not the disk's file name
def test_a_model_named_in_its_folder_stays_the_link_but_cannot_leave_the_folder(self):
folder = str(self.root / "sub")
with self.resolve:
self.assertEqual(path_utils.model_file_within(folder, "style.safetensors"), str(self.model))
self.assertEqual(path_utils.model_reference_within(str(self.root), os.path.join("sub", "style.safetensors")), str(self.model))
for name in ("..", ".", "", "../x.safetensors", "sub/style.safetensors"):
with self.assertRaises(ValueError, msg=name):
path_utils.model_file_within(folder, name)
with self.assertRaises(ValueError):
path_utils.model_reference_within(str(self.root), os.path.join("..", "elsewhere", "x.safetensors"))
def test_moving_a_linked_model_renames_the_link_instead_of_copying_the_disk(self):
# The link is on the server's disk; the file behind it is on the platform's model disk.
real_stat = os.stat
def stat(path, *args, **kwargs):
found = real_stat(path, *args, **kwargs)
if os.path.basename(path) != "style.safetensors":
return found
return os.stat_result((found.st_mode, found.st_ino, found.st_dev + 1, *tuple(found)[3:]))
dest = str(self.root / "style_moved.safetensors")
with mock.patch("os.stat", side_effect=stat):
self.assertTrue(model_import._same_drive(str(self.model), dest))
model_import.move_into(str(self.model), dest, keep=False)
self.assertTrue(os.path.isfile(dest))
self.assertFalse(self.model.exists())
if __name__ == "__main__":
unittest.main()