"""软授权锁(防君子不防小人)。 总开关就是下面的 `_ENABLED` 一行变量:置 False 则完全放行。 本锁只是授权边界的可见表达,真正的约束在 LICENSE.md 的法律条款—— 绕过、篡改、移除软锁或水印,即构成对授权的违反。 母版(技能开发目录)无 `data/.license.json` 且无公众版标记,本模块全部放行; 由 `tools/make_license_copy.py` 生成的定制副本带该状态文件, 按剩余次数与到期日限制使用,并以签名防止手工篡改状态文件。 公众版副本(`tools/make_license_copy.py --public-edition`)不带授权状态文件, 只带标记 `data/.public_edition.json`;首次 check 时就地签发个人副本 (初始 20 次、不限期、授权对象 public-personal),之后按定制副本同样校验。 退出码 4 = 授权不可用(次数用尽 / 已过期 / 状态文件被篡改)。 """ from __future__ import annotations import hashlib import json import os import secrets from datetime import date from pathlib import Path _ENABLED = True # 软锁总开关:False = 完全放行 # 评测豁免:环境变量 ZIPEC_EVAL=1 时 check/consume 直接放行—— # 跑测试集复测不签发、不扣次、不创建任何文件(见 评测/测试规则.md)。 _EVAL_ENV = "ZIPEC_EVAL" _SALT = "lingyao-miaojie-ziping-eightchar" _COMPANY = "四川灵爻妙解文化传播有限公司" STATE_REL = Path("data") / ".license.json" PUBLIC_MARKER_REL = Path("data") / ".public_edition.json" # 公众版首启签发的个人副本参数 PUBLIC_LICENSEE = "public-personal" PUBLIC_INITIAL_USES = 20 def _state_path(root: Path) -> Path: return root / STATE_REL def _sig(st: dict) -> str: raw = "|".join([ str(st["copy_id"]), str(st["licensee"]), str(st["max_uses"]), str(st["remaining_uses"]), str(st["expires_at"]), _SALT, ]) return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16] def _load(root: Path) -> dict | None: p = _state_path(root) if not p.is_file(): return None try: return json.loads(p.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): # 状态文件损坏按篡改变体处理:拒跑 return {"copy_id": "?", "licensee": "?", "remaining_uses": 0, "max_uses": 0, "expires_at": None, "sig": ""} def _deny(reason: str, st: dict) -> str: return (f"授权不可用:{reason}。" f"(副本编号 {st.get('copy_id', '?')},授权对象:{st.get('licensee', '?')})" f"本技能为测试授权副本,禁止商业用途与个人传播;" f"如需续期或正式授权,请联系{_COMPANY}。") def _issue_public_personal(root: Path) -> None: """公众版首启:就地签发个人副本(初始 20 次、不限期)。""" write_state(root, copy_id=f"LY-{date.today():%Y%m%d}-{secrets.token_hex(2).upper()}", licensee=PUBLIC_LICENSEE, max_uses=PUBLIC_INITIAL_USES, expires_at=None) def check(root: Path) -> str | None: """返回 None 表示放行;返回字符串表示拒跑原因(由调用方打印到 stderr,退出码 4)。""" if not _ENABLED: return None if os.environ.get(_EVAL_ENV) == "1": return None # 评测豁免:不签发、不扣次、不创建任何文件 st = _load(root) if st is None: if (root / PUBLIC_MARKER_REL).is_file(): _issue_public_personal(root) # 公众版首启:就地签发个人副本 st = _load(root) else: return None # 母版无状态文件,不锁 if st is None: # 签发失败(如磁盘只读)按母版放行,不阻断使用 return None if st.get("sig") != _sig(st): return _deny("授权状态校验失败(授权文件被改动过)", st) exp = st.get("expires_at") if exp and date.today() > date.fromisoformat(exp): return _deny(f"授权已于 {exp} 到期", st) if st.get("remaining_uses", 0) <= 0: return _deny("本副本的使用次数已用尽", st) return None def consume(root: Path) -> None: """成功排盘一次后扣减一次。仅在 check 放行后调用。""" if not _ENABLED: return if os.environ.get(_EVAL_ENV) == "1": return # 评测豁免:不扣次、不写状态文件 st = _load(root) if st is None: return st["remaining_uses"] = max(0, int(st.get("remaining_uses", 0)) - 1) st["sig"] = _sig(st) _state_path(root).write_text( json.dumps(st, ensure_ascii=False, indent=2), encoding="utf-8") def write_state(root: Path, *, copy_id: str, licensee: str, max_uses: int, expires_at: str | None = None) -> dict: """生成授权状态文件(供 tools/make_license_copy.py 调用,签名算法单点在此)。""" st = { "copy_id": copy_id, "licensee": licensee, "issued_at": date.today().isoformat(), "expires_at": expires_at, "max_uses": max_uses, "remaining_uses": max_uses, "granted_archives": [], } st["sig"] = _sig(st) p = _state_path(root) p.parent.mkdir(parents=True, exist_ok=True) p.write_text(json.dumps(st, ensure_ascii=False, indent=2), encoding="utf-8") return st def grant_uses(root: Path, n: int, archive_hash: str) -> bool: """脱敏档案共建的计励:推送成功后给本副本加 n 次。 同一盘(archive_hash)只计一次:已在已计励列表中则返回 False 且不加次。 母版(无授权状态文件)不计励,直接返回 True。 状态文件签名校验失败(被篡改)时拒绝计励,返回 False。 """ if not _ENABLED: return True st = _load(root) if st is None: return True # 母版无锁,不计 if st.get("sig") != _sig(st): return False granted = st.setdefault("granted_archives", []) if archive_hash in granted: return False st["remaining_uses"] = int(st.get("remaining_uses", 0)) + int(n) granted.append(archive_hash) st["sig"] = _sig(st) _state_path(root).write_text( json.dumps(st, ensure_ascii=False, indent=2), encoding="utf-8") return True