Files
MoviePilot/app/application/plugin/identity.py

434 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""已安装物理插件的来源身份合同与存量迁移决策。"""
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:<owner>/<repository> 规范键")
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