feat(update): add verifiable release manifest
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user