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:
@@ -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 条来。** “只是读”不是例外;能按名字、编号取的,就不收路径,由服务端在自己登记的范围里找到文件。
|
||||
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user