From 0e81e2f4568250fce3b578e53ed313896a1dd454 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <207458273+DemonGatanjieu@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:32:15 +0800 Subject: [PATCH] Revert "Models that are links: taken as named, covers and info beside the link" This reverts commit 851a8cb. The owner keeps the cloud-server work in 1.61 (feature/remote-access), so 1.59 ships as it was; the change lives on there. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- CHANGELOG.md | 1 - api/mcp_tools.py | 4 +-- api/metadata.py | 3 +- api/model_catalog.py | 7 +++-- api/model_folder.py | 4 +-- api/model_metadata.py | 6 ++-- api/path_utils.py | 22 +++----------- docs/architecture/backend.md | 12 +------- tests/test_model_links.py | 57 ------------------------------------ 10 files changed, 19 insertions(+), 99 deletions(-) delete mode 100644 tests/test_model_links.py diff --git a/AGENTS.md b/AGENTS.md index 52d5730..13205ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 校验;需要指向本机某个文件时,由服务端自己找到它,只给页面一个令牌。模型文件本身可以是链接(云服务器上模型文件夹里全是指向平台模型盘的链接):文件夹部分照样用 `resolve_within`,文件用 `model_file_within` / `model_reference_within` 按名字取,不跟着链接走出去,封面、信息和改动都在链接旁边。 +3. **接口不接受任意本机路径。** 只收“类型 + 根目录序号 + 相对路径”,在后端用 `resolve_within` 校验;需要指向本机某个文件时,由服务端自己找到它,只给页面一个令牌。 4. **会写入、移动、删除文件或替用户联网的接口只接受本机请求**(请求来自本机,没有经过代理,Host 也是本机)。用 `--listen` 开放到局域网时,别的设备只能查看。由 `api/local_guard.py` 的中间件统一把关:`/anomalous/` 下 GET 以外的请求默认只接受本机,确实只读的 POST 列进 `OPEN_POSTS`,不只是读的 GET(备份文件、联网、本机文件夹)列进 `LOCAL_GETS`;新接口要想清楚属于哪一类,不要为了方便放进 `OPEN_POSTS`。 5. **用户的文件删除一律进回收站,没有回收站就拒绝;从不覆盖**(同名另存为“名字 (2)”或拒绝)。插件自己产生的临时文件(`.part`、暂存区)可以直接删。 6. **只读的接口也按第 3 条来。** “只是读”不是例外;能按名字、编号取的,就不收路径,由服务端在自己登记的范围里找到文件。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e4868a..7f7a2e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,7 +86,6 @@ - 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. diff --git a/api/mcp_tools.py b/api/mcp_tools.py index ddfcff7..1f40b97 100644 --- a/api/mcp_tools.py +++ b/api/mcp_tools.py @@ -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 model_file_within, resolve_folder_subdir, resolve_within +from .path_utils import 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 = model_file_within(target_dir, filename) + file_path = resolve_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): diff --git a/api/metadata.py b/api/metadata.py index f49897e..e0ef760 100644 --- a/api/metadata.py +++ b/api/metadata.py @@ -204,8 +204,7 @@ def _get_metadata_cached(file_path, signature): def get_metadata(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) + normalized_path = os.path.realpath(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)) diff --git a/api/model_catalog.py b/api/model_catalog.py index aa1971e..1fb5364 100644 --- a/api/model_catalog.py +++ b/api/model_catalog.py @@ -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 model_reference_within, resolve_folder_subdir +from .path_utils import 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,8 +448,11 @@ 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: - candidate = model_reference_within(real_base_dir, relative_path.replace('/', os.sep)) + if os.path.commonpath((real_base_dir, candidate)) != real_base_dir: + continue except ValueError: continue if os.path.isfile(candidate) and is_model_file(candidate): diff --git a/api/model_folder.py b/api/model_folder.py index 2f85ef8..cd32bee 100644 --- a/api/model_folder.py +++ b/api/model_folder.py @@ -10,7 +10,7 @@ import time from aiohttp import web -from .utils import model_file_within, require_filename, resolve_folder_subdir +from .utils import require_filename, resolve_folder_subdir, resolve_within 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 = model_file_within(folder, filename) + path = resolve_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): diff --git a/api/model_metadata.py b/api/model_metadata.py index 4a3e281..5c1ae6c 100644 --- a/api/model_metadata.py +++ b/api/model_metadata.py @@ -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 model_file_within, require_filename, resolve_folder_subdir +from .utils import require_filename, resolve_folder_subdir, resolve_within 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 = model_file_within(target_dir, filename) + model_path = resolve_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 = model_file_within(target_dir, filename) + file_path = resolve_within(target_dir, filename) except (ValueError, KeyError): pass diff --git a/api/path_utils.py b/api/path_utils.py index 64b565b..b2f931b 100644 --- a/api/path_utils.py +++ b/api/path_utils.py @@ -32,30 +32,16 @@ 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: - candidate = model_reference_within(base_dir, relative_value) + if os.path.commonpath((base_dir, candidate)) != base_dir: + continue except ValueError: continue if not os.path.isfile(candidate) or not candidate.lower().endswith(MODEL_FILE_SUFFIXES): @@ -82,7 +68,7 @@ def resolve_folder_subdir(folder_type, path_idx=0, subfolder='/'): def require_filename(filename): - if not isinstance(filename, str) or filename in ("", ".", "..") or os.path.basename(filename) != filename: + if not isinstance(filename, str) or not filename or os.path.basename(filename) != filename: raise ValueError("Invalid filename") return filename diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index 4fbf71c..6bf991e 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -19,17 +19,7 @@ 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 (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. +- `require_filename()` for single-file identifiers. Never rely only on `..` rejection. Absolute Windows paths, UNC paths, alternate separators, device paths, and symlinks can bypass naïve string checks. Media diff --git a/tests/test_model_links.py b/tests/test_model_links.py deleted file mode 100644 index 354145d..0000000 --- a/tests/test_model_links.py +++ /dev/null @@ -1,57 +0,0 @@ -"""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()