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 d10f564..6b44b28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. 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 0985c98..de400d1 100644 --- a/api/metadata.py +++ b/api/metadata.py @@ -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)) diff --git a/api/model_catalog.py b/api/model_catalog.py index 6fb540b..9c44f45 100644 --- a/api/model_catalog.py +++ b/api/model_catalog.py @@ -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): diff --git a/api/model_folder.py b/api/model_folder.py index fb5c6b8..e67478f 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, 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): diff --git a/api/model_import.py b/api/model_import.py index a2a71fd..69eb2cf 100644 --- a/api/model_import.py +++ b/api/model_import.py @@ -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 diff --git a/api/model_media.py b/api/model_media.py index 2130c66..4b5153e 100644 --- a/api/model_media.py +++ b/api/model_media.py @@ -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): diff --git a/api/model_metadata.py b/api/model_metadata.py index 0e68a78..1f85c32 100644 --- a/api/model_metadata.py +++ b/api/model_metadata.py @@ -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 diff --git a/api/model_placement.py b/api/model_placement.py index 75123ac..e62ad09 100644 --- a/api/model_placement.py +++ b/api/model_placement.py @@ -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): 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/api/scan_choice.py b/api/scan_choice.py index 1cb0765..03c0776 100644 --- a/api/scan_choice.py +++ b/api/scan_choice.py @@ -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 diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index 3f0f88f..e5e3862 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -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 diff --git a/tests/test_model_folder.py b/tests/test_model_folder.py index 71b111c..ad84d8d 100644 --- a/tests/test_model_folder.py +++ b/tests/test_model_folder.py @@ -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__": diff --git a/tests/test_model_links.py b/tests/test_model_links.py new file mode 100644 index 0000000..2f16a89 --- /dev/null +++ b/tests/test_model_links.py @@ -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()