247 lines
7.7 KiB
Python
247 lines
7.7 KiB
Python
#!/usr/bin/env python3
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import Any, Dict, Iterable, Optional, Tuple
|
||
|
||
from factory_common.signing import SignatureError, verify_file_signature
|
||
|
||
|
||
DEFAULT_CONFIG_NAME = "factory_private.json"
|
||
CONF_DIR_NAME = "conf"
|
||
CONFIG_ENV_KEYS: Tuple[str, ...] = ("FACTORY_PRIVATE_CONFIG", "FACTORY_PRIVATE_CONFIG_PATH")
|
||
CACHE_DIR_NAME = "talkingq-factory"
|
||
CACHE_FILENAME = "factory_private_path.txt"
|
||
_MISSING = object()
|
||
_ENC_MAGIC = b"FENC"
|
||
|
||
|
||
class EncryptedConfigError(Exception):
|
||
pass
|
||
|
||
|
||
def resolve_private_config_path(base_dir: Path, allow_plain: bool = True) -> Optional[Path]:
|
||
for key in CONFIG_ENV_KEYS:
|
||
value = os.getenv(key)
|
||
if value:
|
||
return Path(value).expanduser()
|
||
candidate = base_dir / CONF_DIR_NAME / DEFAULT_CONFIG_NAME
|
||
enc_candidate = Path(str(candidate) + ".enc")
|
||
if allow_plain and candidate.exists():
|
||
return candidate
|
||
if enc_candidate.exists():
|
||
return enc_candidate
|
||
return None
|
||
|
||
|
||
def resolve_private_config_source_path(base_dir: Path) -> Optional[Path]:
|
||
for key in CONFIG_ENV_KEYS:
|
||
value = os.getenv(key)
|
||
if value:
|
||
path = Path(value).expanduser()
|
||
if path.suffix == ".enc":
|
||
plain = Path(str(path)[:-4])
|
||
if plain.exists():
|
||
return plain
|
||
return path
|
||
candidate = base_dir / CONF_DIR_NAME / DEFAULT_CONFIG_NAME
|
||
if candidate.exists():
|
||
return candidate
|
||
enc_candidate = Path(str(candidate) + ".enc")
|
||
if enc_candidate.exists():
|
||
return enc_candidate
|
||
return None
|
||
|
||
|
||
def is_frozen() -> bool:
|
||
if getattr(sys, "frozen", False):
|
||
return True
|
||
if getattr(sys, "nuitka_version", None):
|
||
return True
|
||
if globals().get("__compiled__") is not None:
|
||
return True
|
||
return "__compiled__" in sys.modules
|
||
|
||
|
||
def _cache_dir() -> Path:
|
||
if os.name == "nt":
|
||
base = os.getenv("APPDATA") or os.getenv("LOCALAPPDATA")
|
||
if base:
|
||
return Path(base) / CACHE_DIR_NAME
|
||
return Path.home() / "AppData" / "Roaming" / CACHE_DIR_NAME
|
||
if sys.platform == "darwin":
|
||
return Path.home() / "Library" / "Application Support" / CACHE_DIR_NAME
|
||
base = os.getenv("XDG_STATE_HOME") or os.getenv("XDG_CONFIG_HOME")
|
||
if base:
|
||
return Path(base) / CACHE_DIR_NAME
|
||
return Path.home() / ".config" / CACHE_DIR_NAME
|
||
|
||
|
||
def _cache_path() -> Path:
|
||
return _cache_dir() / CACHE_FILENAME
|
||
|
||
|
||
def _load_cached_path() -> Optional[Path]:
|
||
path = _cache_path()
|
||
try:
|
||
raw = path.read_text(encoding="utf-8").strip()
|
||
except OSError:
|
||
return None
|
||
if not raw:
|
||
return None
|
||
return Path(raw).expanduser()
|
||
|
||
|
||
def _save_cached_path(path: Path) -> None:
|
||
cache_dir = _cache_dir()
|
||
try:
|
||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||
_cache_path().write_text(str(path), encoding="utf-8")
|
||
except OSError:
|
||
return
|
||
|
||
|
||
def _prompt_private_config_path(title: str, initial_dir: Optional[Path] = None) -> Optional[Path]:
|
||
try:
|
||
import tkinter as tk
|
||
from tkinter import filedialog
|
||
except Exception:
|
||
return None
|
||
root = tk.Tk()
|
||
root.withdraw()
|
||
try:
|
||
root.attributes("-topmost", True)
|
||
except Exception:
|
||
pass
|
||
dialog_args = {
|
||
"title": title,
|
||
"filetypes": [("JSON", "*.json"), ("All", "*.*")],
|
||
}
|
||
if initial_dir:
|
||
dialog_args["initialdir"] = str(initial_dir)
|
||
path = filedialog.askopenfilename(**dialog_args)
|
||
root.destroy()
|
||
if not path:
|
||
return None
|
||
return Path(path).expanduser()
|
||
|
||
|
||
def ensure_private_config_path(base_dir: Path, prompt_title: str = "选择工厂私有配置文件") -> Path:
|
||
path = resolve_private_config_path(base_dir)
|
||
if path is not None and path.exists():
|
||
_save_cached_path(path)
|
||
return path
|
||
cached = _load_cached_path()
|
||
if cached is not None and cached.exists():
|
||
os.environ["FACTORY_PRIVATE_CONFIG"] = str(cached)
|
||
return cached
|
||
initial_dir = cached.parent if cached else None
|
||
selected = _prompt_private_config_path(prompt_title, initial_dir=initial_dir)
|
||
if selected is None:
|
||
raise FileNotFoundError(
|
||
"缺少工厂私有配置文件。请设置环境变量 "
|
||
f"{'/'.join(CONFIG_ENV_KEYS)} 或在弹窗中选择配置文件。"
|
||
)
|
||
os.environ["FACTORY_PRIVATE_CONFIG"] = str(selected)
|
||
_save_cached_path(selected)
|
||
return selected
|
||
|
||
|
||
def _is_encrypted_payload(payload: bytes) -> bool:
|
||
return payload.startswith(_ENC_MAGIC)
|
||
|
||
|
||
def _load_private_config_from_path(
|
||
path: Path,
|
||
verify_signature: bool = True,
|
||
) -> Dict[str, Any]:
|
||
if verify_signature:
|
||
try:
|
||
verify_file_signature(path)
|
||
except SignatureError as exc:
|
||
raise PermissionError(f"私有配置签名校验失败:{exc}") from exc
|
||
try:
|
||
payload = path.read_bytes()
|
||
except OSError as exc: # noqa: BLE001
|
||
raise FileNotFoundError(f"无法读取私有配置文件:{path}") from exc
|
||
if _is_encrypted_payload(payload):
|
||
raise EncryptedConfigError("私有配置已加密。当前版本仅支持明文配置文件。")
|
||
else:
|
||
try:
|
||
raw = payload.decode("utf-8")
|
||
except UnicodeDecodeError as exc:
|
||
raise ValueError(f"私有配置文件格式错误:{path}") from exc
|
||
try:
|
||
data = json.loads(raw)
|
||
except json.JSONDecodeError as exc:
|
||
raise ValueError(f"私有配置文件格式错误:{path}") from exc
|
||
if not isinstance(data, dict):
|
||
raise ValueError("私有配置文件根节点必须是对象。")
|
||
return data
|
||
|
||
|
||
def load_private_config(base_dir: Path) -> Dict[str, Any]:
|
||
path = resolve_private_config_path(base_dir, allow_plain=True)
|
||
if path is None or not path.exists():
|
||
raise FileNotFoundError(
|
||
"缺少工厂私有配置文件。请设置环境变量 "
|
||
f"{'/'.join(CONFIG_ENV_KEYS)} 指定配置路径。"
|
||
)
|
||
return _load_private_config_from_path(path)
|
||
|
||
|
||
def load_private_config_from_path(path: Path) -> Dict[str, Any]:
|
||
return _load_private_config_from_path(path)
|
||
|
||
|
||
def load_private_config_source(base_dir: Path) -> Dict[str, Any]:
|
||
path = resolve_private_config_source_path(base_dir)
|
||
if path is None or not path.exists():
|
||
raise FileNotFoundError(
|
||
"缺少工厂私有配置文件。请设置环境变量 "
|
||
f"{'/'.join(CONFIG_ENV_KEYS)} 指定配置路径。"
|
||
)
|
||
if path.suffix == ".enc":
|
||
raise EncryptedConfigError(
|
||
"私有配置源需要明文 factory_private.json(打包用)。请提供明文配置文件。"
|
||
)
|
||
return _load_private_config_from_path(
|
||
path,
|
||
verify_signature=False,
|
||
)
|
||
|
||
|
||
def _walk(data: Dict[str, Any], keys: Iterable[str]) -> Any:
|
||
current: Any = data
|
||
for key in keys:
|
||
if not isinstance(current, dict) or key not in current:
|
||
return _MISSING
|
||
current = current[key]
|
||
return current
|
||
|
||
|
||
def get_required(
|
||
data: Dict[str, Any],
|
||
keys: Iterable[str],
|
||
expected_type: Optional[tuple[type, ...]] = None,
|
||
allow_empty: bool = False,
|
||
) -> Any:
|
||
value = _walk(data, keys)
|
||
if value is _MISSING:
|
||
raise KeyError("缺少私有配置项: " + ".".join(keys))
|
||
if expected_type and not isinstance(value, expected_type):
|
||
raise TypeError("私有配置类型错误: " + ".".join(keys))
|
||
if not allow_empty and isinstance(value, str) and not value.strip():
|
||
raise ValueError("私有配置不能为空: " + ".".join(keys))
|
||
return value
|
||
|
||
|
||
def get_optional(data: Dict[str, Any], keys: Iterable[str], default: Any = None) -> Any:
|
||
value = _walk(data, keys)
|
||
if value is _MISSING:
|
||
return default
|
||
return value
|