Files
BlackVortexAIandClaude Fable 5.1 b96668e347 Leave foreign staging files alone and check containment before file access
Only the migration's own staging file is removed; a stage left by another
process is neither promoted nor deleted, because it cannot be told apart
from one still being written. contained_file and resolve_lora_path now run
the root check before is_file, matching the documented order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 16:04:42 +02:00

229 lines
9.3 KiB
Python

"""Private runtime storage for BV Node Pack.
Trusted runtime data (Remote LLM settings and secrets, the response cache, LUT
working catalogs and channel selection, the LoRA header cache) lives below
ComfyUI's System User directory ``user/__bv_nodepack/``. ComfyUI never serves
System User directories through its ``/userdata`` HTTP API, unlike
``user/default/`` where versions up to 1.4.1 stored the same files.
Without ComfyUI's System User API the pack fails closed: nothing is read from or
written to the public ``user/default/bv_nodepack/`` tree, and the features that
need trusted storage report why they are unavailable instead of falling back.
"""
from __future__ import annotations
import os
import secrets
import shutil
import time
from pathlib import Path
from typing import Any
PRIVATE_USER_NAME = "bv_nodepack"
LEGACY_SUBPATH = ("default", "bv_nodepack")
UNAVAILABLE_MESSAGE = (
"BV Node Pack private storage is unavailable: this ComfyUI has no System User directory API "
"(folder_paths.get_system_user_directory). Update ComfyUI to use Remote LLM API keys, custom "
"provider settings, LUT catalog updates and persistent caches; other nodes keep working."
)
# Files that earlier versions kept in the public user tree, relative to that tree.
LEGACY_FILES = (
"remote_llm_settings.json",
"remote_llm_secrets.json",
"lut_catalog.json",
"lut_catalog.experimental.json",
"lut_catalog_settings.json",
"cache/lora_header_checks.json",
)
# Regenerable data is deleted from the public tree, never copied.
LEGACY_REGENERABLE_DIRS = ("cache/remote_llm",)
class PrivateStorageUnavailable(RuntimeError):
pass
def _folder_paths(module: Any = None) -> Any:
if module is not None:
return module
try:
import folder_paths
except ImportError:
return None
return folder_paths
def private_root(folder_paths_module: Any = None) -> Path | None:
"""The private storage root, or None when ComfyUI cannot provide one."""
module = _folder_paths(folder_paths_module)
getter = getattr(module, "get_system_user_directory", None) if module is not None else None
if getter is None:
return None
try:
root = Path(getter(PRIVATE_USER_NAME))
except (ValueError, TypeError, OSError):
return None
# The protection rests on ComfyUI excluding this prefix from public user paths.
if not root.name.startswith("__"):
return None
return root
def require_private_root(folder_paths_module: Any = None) -> Path:
root = private_root(folder_paths_module)
if root is None:
raise PrivateStorageUnavailable(UNAVAILABLE_MESSAGE)
return root
def private_path(*parts: str, folder_paths_module: Any = None) -> Path | None:
root = private_root(folder_paths_module)
return None if root is None else root.joinpath(*parts)
def legacy_root(folder_paths_module: Any = None) -> Path | None:
module = _folder_paths(folder_paths_module)
getter = getattr(module, "get_user_directory", None) if module is not None else None
if getter is None:
return None
try:
return Path(getter()).joinpath(*LEGACY_SUBPATH)
except (TypeError, OSError):
return None
RECOVERY_SUFFIX = ".recovered"
STAGING_MARKER = ".staging-"
def _publish_exclusive(target: Path, payload: bytes) -> None:
"""Publish ``payload`` under ``target`` atomically and exclusively.
The bytes are written, synced and verified in a uniquely named staging file
first; the final name is then created with a hard link, which fails when the
name already exists and never replaces anything. Other processes therefore
only ever see a complete, verified file under the final name, and a crash
can leave at most a staging file behind (never loaded, cleaned on the next
run). Once published, the file is never removed by this function.
"""
target.parent.mkdir(parents=True, exist_ok=True)
staging = target.with_name(f".{target.name}{STAGING_MARKER}{secrets.token_hex(6)}")
descriptor = os.open(staging, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
if staging.read_bytes() != payload:
raise OSError(f"verification failed for {staging}")
os.link(staging, target) # FileExistsError when the name is taken; no replace
finally:
# Only this call's own staging file is removed. Staging files left by a crashed
# process stay where they are: they are never loaded and another process cannot
# tell them apart from a stage that is still being written.
try:
staging.unlink(missing_ok=True)
except OSError:
pass
def _recovery_path(target: Path) -> Path:
stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
return target.with_name(f"{target.name}.legacy-{stamp}-{secrets.token_hex(4)}{RECOVERY_SUFFIX}")
def _migrate_file(source: Path, target: Path) -> tuple[str, str]:
"""Return (outcome, detail) for one public file; raises OSError on failure.
Outcomes: ``migrated`` (published privately, source removed), ``removed`` (private
file already held the same bytes, source removed), ``recovered`` (private file
differs or is not a regular file; source preserved as a private recovery file that
is never loaded automatically, then removed).
"""
payload = source.read_bytes()
try:
_publish_exclusive(target, payload)
except FileExistsError:
pass
else:
source.unlink(missing_ok=True)
return "migrated", ""
# Whatever holds the final name was published complete and verified (by this
# code or by the user); it is never touched here.
if target.is_file() and not target.is_symlink() and target.read_bytes() == payload:
source.unlink(missing_ok=True)
return "removed", "private copy holds the same bytes"
recovery = _recovery_path(target)
_publish_exclusive(recovery, payload)
source.unlink(missing_ok=True)
return "recovered", f"private copy differs; public file kept as {recovery.name}"
def migrate_legacy_storage(*, legacy: Path | None = None, private: Path | None = None) -> dict[str, list[str]]:
"""Move trusted files from the public tree into private storage, once and verifiably.
Contract: private files are never overwritten or replaced, not even by a
concurrent migration (staging file, then exclusive hard-link publication), and
a name that exists in private storage always holds a complete file. A public
file is removed only after its bytes are verified in private storage: as the
new private file, as an identical existing private file, or as a uniquely
named recovery file that is never loaded automatically. Regenerable caches are
deleted, not copied. Symbolic links are left alone and reported. A failure
leaves the public file in place; at most a staging file (never loaded, never
promoted, never removed by another process) remains. Never raises.
"""
report: dict[str, list[str]] = {"migrated": [], "removed": [], "recovered": [], "kept": [], "failed": []}
legacy = legacy if legacy is not None else legacy_root()
private = private if private is not None else private_root()
if legacy is None or private is None or not legacy.is_dir() or legacy.is_symlink():
return report
try:
if legacy.resolve() == private.resolve():
return report
except OSError:
return report
for relative in LEGACY_FILES:
source = legacy / relative
target = private / relative
try:
if source.is_symlink():
report["kept"].append(f"{relative}: symbolic link left in place")
continue
if not source.is_file():
continue
outcome, detail = _migrate_file(source, target)
report[outcome].append(relative if not detail else f"{relative}: {detail}")
except OSError as error:
report["failed"].append(f"{relative}: {error}")
for relative in LEGACY_REGENERABLE_DIRS:
path = legacy / relative
try:
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
report["removed"].append(f"{relative}: regenerable cache")
except OSError as error:
report["failed"].append(f"{relative}: {error}")
for path in (legacy / "cache", legacy):
try:
if path.is_dir() and not path.is_symlink() and not any(path.iterdir()):
path.rmdir()
except OSError:
pass
return report
def run_startup_migration() -> dict[str, list[str]]:
"""Startup hook: migrate once, report, never break node registration."""
try:
report = migrate_legacy_storage()
except Exception as error: # noqa: BLE001 - startup must not fail on storage problems
print(f"BV Node Pack: private storage migration failed: {error}")
return {"migrated": [], "removed": [], "recovered": [], "kept": [], "failed": [str(error)]}
if private_root() is None:
print(f"BV Node Pack: {UNAVAILABLE_MESSAGE}")
for kind in ("migrated", "removed", "recovered", "kept", "failed"):
for entry in report[kind]:
print(f"BV Node Pack: private storage {kind}: {entry}")
return report