feat(update): add verifiable release manifest

This commit is contained in:
chengma
2026-07-13 11:58:05 +08:00
parent d4a3df4df0
commit 567d4e7b68
7 changed files with 281 additions and 17 deletions
+174
View File
@@ -0,0 +1,174 @@
"""发布包清单与服务端发布元数据生成。"""
import argparse
import hashlib
import json
from pathlib import Path
PACKAGE_FORMAT = "cmshopee-portable-v1"
UPDATER_PROTOCOL = 1
ENTRY_POINT = "cmshopee.exe"
MANIFEST_FILENAME = "package-manifest.json"
ALLOWED_ROOTS = {
ENTRY_POINT,
"_internal",
"version.txt",
"README.txt",
MANIFEST_FILENAME,
"cmshopee-updater.exe",
}
FORBIDDEN_ROOTS = {"data", ".cmshopee-update"}
class ReleaseManifestError(ValueError):
"""发布目录不符合自动升级包契约。"""
def sha256_file(path):
digest = hashlib.sha256()
with Path(path).open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _normalized_relative(path, root):
relative = path.relative_to(root).as_posix()
if relative.startswith("/") or ".." in Path(relative).parts:
raise ReleaseManifestError("发布包包含不安全的文件路径:%s" % relative)
return relative
def build_package_manifest(release_dir, app_version):
release_dir = Path(release_dir).resolve()
if not release_dir.is_dir():
raise ReleaseManifestError("发布目录不存在:%s" % release_dir)
required = [ENTRY_POINT, "_internal", "version.txt", "README.txt"]
missing = [name for name in required if not (release_dir / name).exists()]
if missing:
raise ReleaseManifestError("发布目录缺少必要项目:%s" % "、".join(missing))
if not (release_dir / "_internal").is_dir():
raise ReleaseManifestError("_internal 必须是目录")
version_text = (release_dir / "version.txt").read_text(
encoding="utf-8-sig"
).strip()
if version_text != app_version:
raise ReleaseManifestError(
"version.txt 与应用版本不一致:%s != %s"
% (version_text, app_version)
)
root_names = {entry.name for entry in release_dir.iterdir()}
forbidden = sorted(root_names & FORBIDDEN_ROOTS)
if forbidden:
raise ReleaseManifestError("发布目录包含用户数据:%s" % "、".join(forbidden))
unknown = sorted(root_names - ALLOWED_ROOTS)
if unknown:
raise ReleaseManifestError("发布目录包含未授权根项目:%s" % "、".join(unknown))
files = []
seen = set()
for path in sorted(release_dir.rglob("*"), key=lambda value: value.as_posix().lower()):
if path.is_symlink():
raise ReleaseManifestError("发布包不允许符号链接:%s" % path)
if not path.is_file() or path.name == MANIFEST_FILENAME:
continue
relative = _normalized_relative(path, release_dir)
key = relative.casefold()
if key in seen:
raise ReleaseManifestError("发布包包含重复路径:%s" % relative)
seen.add(key)
files.append(
{
"path": relative,
"size_bytes": path.stat().st_size,
"sha256": sha256_file(path),
}
)
replace_roots = sorted(root_names | {MANIFEST_FILENAME}, key=str.casefold)
return {
"schema_version": 1,
"package_format": PACKAGE_FORMAT,
"app_version": app_version,
"entry_point": ENTRY_POINT,
"updater_protocol": UPDATER_PROTOCOL,
"min_updater_protocol": UPDATER_PROTOCOL,
"replace_roots": replace_roots,
"files": files,
"signature_algorithm": "",
"manifest_signature": "",
}
def write_package_manifest(release_dir, app_version):
release_dir = Path(release_dir)
manifest = build_package_manifest(release_dir, app_version)
output = release_dir / MANIFEST_FILENAME
output.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
return output
def build_release_metadata(zip_path, app_version):
zip_path = Path(zip_path)
if not zip_path.is_file():
raise ReleaseManifestError("发布压缩包不存在:%s" % zip_path)
return {
"platform": "windows",
"release": {
"version": app_version,
"force_update": False,
"min_supported_version": "",
"download_url": "",
"download_filename": zip_path.name,
"sha256": sha256_file(zip_path),
"size_bytes": zip_path.stat().st_size,
"package_format": PACKAGE_FORMAT,
"updater_protocol": UPDATER_PROTOCOL,
"min_updater_protocol": UPDATER_PROTOCOL,
"release_notes": "",
"signature_algorithm": "",
"manifest_signature": "",
},
}
def write_release_metadata(zip_path, app_version, output_path):
metadata = build_release_metadata(zip_path, app_version)
output_path = Path(output_path)
output_path.write_text(
json.dumps(metadata, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
return output_path
def main(argv=None):
parser = argparse.ArgumentParser(description="生成蝦皮圈优化助手发布校验文件")
subparsers = parser.add_subparsers(dest="command", required=True)
manifest_parser = subparsers.add_parser("manifest")
manifest_parser.add_argument("--release-dir", required=True)
manifest_parser.add_argument("--version", required=True)
metadata_parser = subparsers.add_parser("metadata")
metadata_parser.add_argument("--zip", required=True)
metadata_parser.add_argument("--version", required=True)
metadata_parser.add_argument("--output", required=True)
args = parser.parse_args(argv)
if args.command == "manifest":
write_package_manifest(args.release_dir, args.version)
else:
write_release_metadata(args.zip, args.version, args.output)
return 0
if __name__ == "__main__":
raise SystemExit(main())