Models that are links: taken as named, covers and info beside the link
On an AutoDL server every model in the models folder is a link into the platform's shared, read-only model disk, named like 6b4679... without an extension. Scans wrote the .info beside the link, but get_metadata resolved the path and read beside the disk's file: a scanned model showed as not scanned, under the disk's file name. Delete, edit, open folder, MCP and saved-workflow lookups resolved the file too, so they refused it as outside the models folder (or, for a link inside it, would have acted on the file behind it). path_utils.model_file_within joins a resolved folder with a bare name (require_filename, which now refuses "." and ".." too); model_reference_within resolves the subfolders of a relative path inside the root, through links as before, and takes the file as named. get_metadata reads beside the path it is given (abspath). Edits, renames and deletes act on the link; nothing writes into a model file. Checked with a link stood in by patching realpath (this computer cannot make links): the info beside the link is read and the name is the link's; "..", ".", "a/b" and a relative path leaving the root are refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
797a7218d8
commit
851a8cb2d5
@@ -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 条来。** “只是读”不是例外;能按名字、编号取的,就不收路径,由服务端在自己登记的范围里找到文件。
|
||||
|
||||
@@ -86,6 +86,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
@@ -204,7 +204,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))
|
||||
|
||||
@@ -15,7 +15,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."""
|
||||
@@ -448,11 +448,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, require_filename, resolve_folder_subdir
|
||||
|
||||
|
||||
def _explorer_windows():
|
||||
@@ -100,7 +100,7 @@ async def api_open_model_folder(request):
|
||||
try:
|
||||
filename = require_filename(data.get("filename", ""))
|
||||
_, folder = resolve_folder_subdir(data.get("type", ""), int(data.get("path_idx", 0)), data.get("subfolder", "/"))
|
||||
path = resolve_within(folder, filename)
|
||||
path = model_file_within(folder, filename)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
|
||||
if not os.path.isfile(path):
|
||||
|
||||
@@ -23,7 +23,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:
|
||||
@@ -96,7 +96,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):
|
||||
@@ -162,7 +162,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
|
||||
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -19,7 +19,17 @@ 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.
|
||||
|
||||
Never rely only on `..` rejection. Absolute Windows paths, UNC paths, alternate
|
||||
separators, device paths, and symlinks can bypass naïve string checks. Media
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""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, 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"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user