"""已安装物理插件的来源身份合同与存量迁移决策。""" from __future__ import annotations import re from dataclasses import dataclass, replace from datetime import datetime from enum import StrEnum from typing import Protocol _PLUGIN_ID_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9]{0,127}$") _ONLINE_SOURCE_KEY_PATTERN = re.compile( r"^github:[a-z0-9](?:[a-z0-9-]{0,38})/" r"[a-z0-9._-]{1,100}$" ) OFFICIAL_PLUGIN_SOURCE_KEY = "github:jxxghp/moviepilot-plugins" class TrustedPluginSourceType(StrEnum): """可被在线自动更新信任的来源类型。""" UNKNOWN = "unknown" OFFICIAL = "official" THIRD_PARTY = "third_party" class PluginPayloadSourceType(StrEnum): """最近一次已提交插件载荷的来源类型。""" UNKNOWN = "unknown" OFFICIAL = "official" THIRD_PARTY = "third_party" LOCAL = "local" class PluginBindingBasis(StrEnum): """物理插件来源身份的建立依据。""" LEGACY_UNBOUND = "legacy_unbound" LOCAL_ONLY = "local_only" OFFICIAL_DEFAULT = "official_default" TOFU = "tofu" EXPLICIT_INSTALL = "explicit_install" EXPLICIT_SOURCE_CHANGE = "explicit_source_change" class PluginMarketAvailability(StrEnum): """存量迁移观察市场候选时的可用状态。""" AVAILABLE = "available" UNAVAILABLE = "unavailable" class PluginIdentityConflictError(RuntimeError): """来源身份的首次创建或 revision 条件更新已失去竞争。""" def normalize_physical_plugin_id(plugin_id: str) -> str: """校验物理插件 ID,并返回大小写无关的数据库身份键。""" if plugin_id != plugin_id.strip() or not _PLUGIN_ID_PATTERN.fullmatch(plugin_id): raise ValueError("插件 ID 必须以字母开头且只能包含 ASCII 字母或数字") return plugin_id.lower() def validate_online_source_key(source_key: str) -> str: """校验由市场边界生成的稳定 GitHub 仓库身份键。""" value = source_key.strip().lower() if not _ONLINE_SOURCE_KEY_PATTERN.fullmatch(value): raise ValueError("在线插件来源必须使用 github:/ 规范键") return value def _validate_online_source_classification( source_key: str, source_type: TrustedPluginSourceType | PluginPayloadSourceType, ) -> None: """保证官方仓库键与官方来源类型始终双向一致。""" is_official_key = source_key == OFFICIAL_PLUGIN_SOURCE_KEY is_official_type = source_type.value == TrustedPluginSourceType.OFFICIAL.value if is_official_key != is_official_type: raise ValueError("官方来源类型只能对应 MoviePilot 官方插件仓库") def _validate_optional_text( value: str | None, *, field_name: str, max_length: int, ) -> None: """让应用层字符串合同与跨数据库列长度保持一致。""" if value is not None and len(value) > max_length: raise ValueError(f"{field_name} 长度不能超过 {max_length}") def _validate_receipt(receipt: str | None) -> str | None: """校验内容收据为带算法前缀的十六进制摘要。""" if receipt is None: return None value = receipt.strip().lower() if not re.fullmatch(r"sha256:[0-9a-f]{64}", value): raise ValueError("插件载荷收据必须为 sha256:<64 hex>") return value @dataclass(frozen=True, slots=True) class PluginIdentity: """一份物理插件的可信更新来源与当前载荷审计事实。""" plugin_id: str normalized_plugin_id: str trusted_source_type: TrustedPluginSourceType trusted_source_key: str | None binding_basis: PluginBindingBasis payload_source_type: PluginPayloadSourceType payload_source_key: str | None declared_version: str | None package_generation: str | None system_version: str | None supports_v3: bool | None supports_v3t: bool | None payload_receipt: str | None revision: int created_at: datetime updated_at: datetime bound_at: datetime | None payload_applied_at: datetime | None def __post_init__(self) -> None: """拒绝不能作为后续来源门禁事实的矛盾状态。""" normalized_id = normalize_physical_plugin_id(self.plugin_id) if self.normalized_plugin_id != normalized_id: raise ValueError("normalized_plugin_id 必须是 plugin_id 的规范化物理身份") if self.revision < 1: raise ValueError("来源身份 revision 必须从 1 开始") if self.created_at.tzinfo is None or self.updated_at.tzinfo is None: raise ValueError("来源身份审计时间必须包含时区") if self.updated_at < self.created_at: raise ValueError("来源身份更新时间不能早于创建时间") for audit_time in (self.bound_at, self.payload_applied_at): if audit_time is not None and audit_time.tzinfo is None: raise ValueError("来源身份审计时间必须包含时区") if audit_time is not None and not self.created_at <= audit_time <= self.updated_at: raise ValueError("来源身份审计时间必须位于创建与更新时间之间") trusted_key = ( validate_online_source_key(self.trusted_source_key) if self.trusted_source_key else None ) if self.trusted_source_type is TrustedPluginSourceType.UNKNOWN: if trusted_key is not None or self.bound_at is not None: raise ValueError("未绑定身份不能携带可信来源或绑定时间") if self.binding_basis not in { PluginBindingBasis.LEGACY_UNBOUND, PluginBindingBasis.LOCAL_ONLY, }: raise ValueError("未绑定身份只能使用存量迁移或本地插件依据") else: if trusted_key is None or self.bound_at is None: raise ValueError("已绑定身份必须携带规范来源和绑定时间") if self.binding_basis in { PluginBindingBasis.LEGACY_UNBOUND, PluginBindingBasis.LOCAL_ONLY, }: raise ValueError("已绑定身份不能使用未绑定来源依据") _validate_online_source_classification( trusted_key, self.trusted_source_type, ) if ( self.binding_basis is PluginBindingBasis.OFFICIAL_DEFAULT and self.trusted_source_type is not TrustedPluginSourceType.OFFICIAL ): raise ValueError("official_default 只能绑定官方来源") if ( self.binding_basis is PluginBindingBasis.TOFU and self.trusted_source_type is not TrustedPluginSourceType.THIRD_PARTY ): raise ValueError("TOFU 只能绑定唯一第三方在线来源") payload_key = ( validate_online_source_key(self.payload_source_key) if self.payload_source_key else None ) if self.payload_source_type in { PluginPayloadSourceType.OFFICIAL, PluginPayloadSourceType.THIRD_PARTY, }: if payload_key is None: raise ValueError("在线载荷必须携带规范来源") _validate_online_source_classification( payload_key, self.payload_source_type, ) elif payload_key is not None: raise ValueError("未知或本地载荷不能携带在线来源键") if self.payload_source_type is PluginPayloadSourceType.UNKNOWN: if any(( self.declared_version, self.package_generation, self.system_version, self.supports_v3 is not None, self.supports_v3t is not None, self.payload_receipt, self.payload_applied_at, )): raise ValueError("未知载荷不能携带版本、兼容声明、收据或应用时间") else: if not self.declared_version or not self.package_generation: raise ValueError("已知载荷必须携带声明版本和包代际") if self.payload_applied_at is None or self.payload_receipt is None: raise ValueError("已知载荷必须携带应用时间和内容收据") if ( self.payload_source_type is not PluginPayloadSourceType.LOCAL and ( payload_key != trusted_key or self.payload_source_type.value != self.trusted_source_type.value ) ): raise ValueError("在线载荷来源必须与可信更新来源一致") if ( self.binding_basis is PluginBindingBasis.LOCAL_ONLY and self.payload_source_type is not PluginPayloadSourceType.LOCAL ): raise ValueError("本地插件身份必须携带已提交的本地载荷事实") if self.package_generation not in {None, "v1", "v2", "v3"}: raise ValueError("插件包代际必须为 v1、v2 或 v3") _validate_optional_text( self.declared_version, field_name="插件声明版本", max_length=64, ) _validate_optional_text( self.system_version, field_name="插件系统版本要求", max_length=128, ) object.__setattr__(self, "trusted_source_key", trusted_key) object.__setattr__(self, "payload_source_key", payload_key) object.__setattr__(self, "payload_receipt", _validate_receipt(self.payload_receipt)) @dataclass(frozen=True, slots=True) class PluginSourceCandidate: """存量迁移可观察到的一个在线候选来源。""" source_type: TrustedPluginSourceType source_key: str def __post_init__(self) -> None: """候选只接受可绑定的规范在线来源。""" if self.source_type is TrustedPluginSourceType.UNKNOWN: raise ValueError("未知来源不能作为在线候选") object.__setattr__(self, "source_key", validate_online_source_key(self.source_key)) _validate_online_source_classification(self.source_key, self.source_type) def plan_legacy_plugin_identity( *, plugin_id: str, market_availability: PluginMarketAvailability, online_candidates: tuple[PluginSourceCandidate, ...], is_virtual_instance: bool, now: datetime, ) -> PluginIdentity | None: """为存量物理插件建立更新绑定,不冒充当前载荷来源。""" if is_virtual_instance: return None normalized_id = normalize_physical_plugin_id(plugin_id) candidates = { (candidate.source_type, candidate.source_key): candidate for candidate in online_candidates } official = sorted( ( candidate for candidate in candidates.values() if candidate.source_type is TrustedPluginSourceType.OFFICIAL ), key=lambda candidate: candidate.source_key, ) third_party = sorted( ( candidate for candidate in candidates.values() if candidate.source_type is TrustedPluginSourceType.THIRD_PARTY ), key=lambda candidate: candidate.source_key, ) trusted_type = TrustedPluginSourceType.UNKNOWN trusted_key = None basis = PluginBindingBasis.LEGACY_UNBOUND bound_at = None if market_availability is PluginMarketAvailability.AVAILABLE and official: trusted_type = TrustedPluginSourceType.OFFICIAL trusted_key = official[0].source_key basis = PluginBindingBasis.OFFICIAL_DEFAULT bound_at = now elif ( market_availability is PluginMarketAvailability.AVAILABLE and len(third_party) == 1 ): trusted_type = TrustedPluginSourceType.THIRD_PARTY trusted_key = third_party[0].source_key basis = PluginBindingBasis.TOFU bound_at = now return PluginIdentity( plugin_id=plugin_id, normalized_plugin_id=normalized_id, trusted_source_type=trusted_type, trusted_source_key=trusted_key, binding_basis=basis, payload_source_type=PluginPayloadSourceType.UNKNOWN, payload_source_key=None, declared_version=None, package_generation=None, system_version=None, supports_v3=None, supports_v3t=None, payload_receipt=None, revision=1, created_at=now, updated_at=now, bound_at=bound_at, payload_applied_at=None, ) class PluginIdentityRepository(Protocol): """来源身份条件写命令使用的无提交仓储端口。""" def get(self, plugin_id: str) -> PluginIdentity | None: """按规范化物理插件 ID 读取身份。""" def stage_create(self, identity: PluginIdentity) -> None: """暂存首次身份;唯一键竞争时抛出冲突错误。""" def stage_replace( self, identity: PluginIdentity, *, expected_revision: int, ) -> bool: """按 revision 条件暂存替换,并返回是否赢得竞争。""" class PluginIdentityUnitOfWork(Protocol): """来源身份条件写使用的事务端口。""" def commit(self) -> None: """提交当前条件写。""" def rollback(self) -> None: """回滚当前条件写。""" class WritePluginIdentityCommand: """以数据库 revision 原子创建或替换一份插件来源身份。""" def __init__( self, repository: PluginIdentityRepository, unit_of_work: PluginIdentityUnitOfWork, ) -> None: """保存仓储与事务所有者。""" self._repository = repository self._unit_of_work = unit_of_work def execute( self, identity: PluginIdentity, *, expected_revision: int | None, ) -> PluginIdentity: """首次写要求记录不存在,后续写要求 revision 精确匹配。""" candidate = replace( identity, normalized_plugin_id=normalize_physical_plugin_id(identity.plugin_id), revision=1 if expected_revision is None else expected_revision + 1, ) try: if expected_revision is None: if ( candidate.binding_basis is PluginBindingBasis.EXPLICIT_SOURCE_CHANGE ): raise PluginIdentityConflictError( "首次插件身份不能伪装成已确认的来源变更" ) self._repository.stage_create(candidate) else: current = self._repository.get(candidate.normalized_plugin_id) if current is None or current.revision != expected_revision: raise PluginIdentityConflictError( f"插件 {candidate.plugin_id} 的来源身份已被其他任务更新" ) immutable_binding = ( "plugin_id", "normalized_plugin_id", "trusted_source_type", "trusted_source_key", "binding_basis", "created_at", "bound_at", ) if any( getattr(candidate, field_name) != getattr(current, field_name) for field_name in immutable_binding ): raise PluginIdentityConflictError( "普通插件身份更新不能改变物理 ID 或可信来源绑定" ) if candidate.updated_at < current.updated_at: raise PluginIdentityConflictError( "插件身份更新时间不能早于已提交记录" ) if not self._repository.stage_replace( candidate, expected_revision=expected_revision, ): raise PluginIdentityConflictError( f"插件 {candidate.plugin_id} 的来源身份已被其他任务更新" ) self._unit_of_work.commit() return candidate except Exception: self._unit_of_work.rollback() raise