diff --git a/AGENTS.md b/AGENTS.md index 13205ff..52d5730 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` 校验;需要指向本机某个文件时,由服务端自己找到它,只给页面一个令牌。 +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 条来。** “只是读”不是例外;能按名字、编号取的,就不收路径,由服务端在自己登记的范围里找到文件。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f7a2e0..5e4868a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/api/mcp_tools.py b/api/mcp_tools.py index 1f40b97..ddfcff7 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 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): diff --git a/api/metadata.py b/api/metadata.py index e0ef760..f49897e 100644 --- a/api/metadata.py +++ b/api/metadata.py @@ -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)) diff --git a/api/model_catalog.py b/api/model_catalog.py index 1fb5364..aa1971e 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 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): diff --git a/api/model_folder.py b/api/model_folder.py index cd32bee..2f85ef8 100644 --- a/api/model_folder.py +++ b/api/model_folder.py @@ -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): diff --git a/api/model_metadata.py b/api/model_metadata.py index 5c1ae6c..4a3e281 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 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 diff --git a/api/path_utils.py b/api/path_utils.py index b2f931b..64b565b 100644 --- a/api/path_utils.py +++ b/api/path_utils.py @@ -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 diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index 6bf991e..4fbf71c 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -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 diff --git a/tests/test_model_links.py b/tests/test_model_links.py new file mode 100644 index 0000000..354145d --- /dev/null +++ b/tests/test_model_links.py @@ -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()