409 lines
16 KiB
Python
409 lines
16 KiB
Python
"""自动升级包的安全下载、解压和暂存校验。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import ipaddress
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import stat
|
|
import urllib.parse
|
|
import urllib.request
|
|
import uuid
|
|
import zipfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path, PurePosixPath
|
|
|
|
from .release_manifest import (
|
|
ALLOWED_ROOTS,
|
|
ENTRY_POINT,
|
|
MANIFEST_FILENAME,
|
|
PACKAGE_FORMAT,
|
|
UPDATER_PROTOCOL,
|
|
sha256_file,
|
|
)
|
|
|
|
|
|
MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024
|
|
MAX_EXTRACTED_BYTES = 2 * 1024 * 1024 * 1024
|
|
MAX_FILE_COUNT = 20000
|
|
MAX_COMPRESSION_RATIO = 200
|
|
DEFAULT_TRUSTED_HOSTS = frozenset({"cm.833729.com"})
|
|
RESERVED_NAMES = {
|
|
"CON",
|
|
"PRN",
|
|
"AUX",
|
|
"NUL",
|
|
*("COM%d" % number for number in range(1, 10)),
|
|
*("LPT%d" % number for number in range(1, 10)),
|
|
}
|
|
|
|
|
|
class UpdateInstallError(RuntimeError):
|
|
"""升级包不能安全下载或暂存。"""
|
|
|
|
|
|
class UpdateCancelled(UpdateInstallError):
|
|
"""用户取消升级包下载。"""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class UpdatePackageMetadata:
|
|
version: str
|
|
download_url: str
|
|
sha256: str
|
|
size_bytes: int
|
|
package_format: str
|
|
updater_protocol: int
|
|
min_updater_protocol: int = 1
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StagedUpdate:
|
|
version: str
|
|
zip_path: Path
|
|
staging_dir: Path
|
|
pending_path: Path
|
|
sha256: str
|
|
|
|
|
|
def metadata_from_update_info(info):
|
|
metadata = UpdatePackageMetadata(
|
|
version=str(getattr(info, "latest_version", "") or "").strip(),
|
|
download_url=str(getattr(info, "download_url", "") or "").strip(),
|
|
sha256=str(getattr(info, "sha256", "") or "").strip().lower(),
|
|
size_bytes=int(getattr(info, "size_bytes", 0) or 0),
|
|
package_format=str(getattr(info, "package_format", "") or "").strip(),
|
|
updater_protocol=int(getattr(info, "updater_protocol", 0) or 0),
|
|
min_updater_protocol=int(getattr(info, "min_updater_protocol", 0) or 1),
|
|
)
|
|
validate_metadata(metadata)
|
|
return metadata
|
|
|
|
|
|
def validate_metadata(metadata, trusted_hosts=DEFAULT_TRUSTED_HOSTS):
|
|
if not re.fullmatch(r"\d+(?:\.\d+)*", metadata.version):
|
|
raise UpdateInstallError("新版版本号格式不正确")
|
|
validate_download_url(metadata.download_url, trusted_hosts)
|
|
if not re.fullmatch(r"[0-9a-fA-F]{64}", metadata.sha256):
|
|
raise UpdateInstallError("新版安装包校验值缺失或格式不正确")
|
|
if metadata.size_bytes <= 0 or metadata.size_bytes > MAX_DOWNLOAD_BYTES:
|
|
raise UpdateInstallError("新版安装包大小不正确或超过限制")
|
|
if metadata.package_format != PACKAGE_FORMAT:
|
|
raise UpdateInstallError("新版安装包格式不受支持")
|
|
if metadata.updater_protocol != UPDATER_PROTOCOL:
|
|
raise UpdateInstallError("新版安装包更新协议不受支持")
|
|
if metadata.min_updater_protocol > UPDATER_PROTOCOL:
|
|
raise UpdateInstallError("当前更新器版本过低")
|
|
|
|
|
|
def validate_download_url(url, trusted_hosts=DEFAULT_TRUSTED_HOSTS):
|
|
parsed = urllib.parse.urlsplit(str(url or ""))
|
|
host = (parsed.hostname or "").lower().rstrip(".")
|
|
trusted = {value.lower().rstrip(".") for value in trusted_hosts}
|
|
if parsed.scheme.lower() != "https" or not host:
|
|
raise UpdateInstallError("新版下载地址必须使用 HTTPS")
|
|
if parsed.username or parsed.password:
|
|
raise UpdateInstallError("新版下载地址不能包含账号信息")
|
|
try:
|
|
address = ipaddress.ip_address(host)
|
|
except ValueError:
|
|
address = None
|
|
if address is not None and not address.is_global:
|
|
raise UpdateInstallError("新版下载地址不能指向内网")
|
|
if host not in trusted:
|
|
raise UpdateInstallError("新版下载地址不在受信任域名内")
|
|
return parsed
|
|
|
|
|
|
class _SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
def __init__(self, trusted_hosts):
|
|
super().__init__()
|
|
self.trusted_hosts = trusted_hosts
|
|
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
validate_download_url(newurl, self.trusted_hosts)
|
|
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
|
|
|
|
def _update_root(install_root):
|
|
return Path(install_root).resolve() / ".cmshopee-update"
|
|
|
|
|
|
def _safe_unlink(path):
|
|
try:
|
|
Path(path).unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
|
|
def download_package(
|
|
metadata,
|
|
install_root,
|
|
*,
|
|
trusted_hosts=DEFAULT_TRUSTED_HOSTS,
|
|
opener=None,
|
|
cancelled=None,
|
|
progress=None,
|
|
timeout=66,
|
|
):
|
|
validate_metadata(metadata, trusted_hosts)
|
|
validate_download_url(metadata.download_url, trusted_hosts)
|
|
downloads = _update_root(install_root) / "downloads"
|
|
downloads.mkdir(parents=True, exist_ok=True)
|
|
part_path = downloads / (metadata.version + ".zip.part")
|
|
zip_path = downloads / (metadata.version + ".zip")
|
|
_safe_unlink(part_path)
|
|
|
|
if shutil.disk_usage(str(downloads)).free < metadata.size_bytes:
|
|
raise UpdateInstallError("磁盘空间不足,无法下载新版")
|
|
|
|
request = urllib.request.Request(
|
|
metadata.download_url,
|
|
headers={"Accept": "application/zip", "User-Agent": "cmshopee-updater/1"},
|
|
)
|
|
if opener is None:
|
|
opener = urllib.request.build_opener(_SafeRedirectHandler(trusted_hosts))
|
|
|
|
digest = hashlib.sha256()
|
|
downloaded = 0
|
|
try:
|
|
with opener.open(request, timeout=timeout) as response, part_path.open("wb") as output:
|
|
validate_download_url(response.geturl(), trusted_hosts)
|
|
while True:
|
|
if cancelled and cancelled():
|
|
raise UpdateCancelled("已取消下载新版")
|
|
chunk = response.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
downloaded += len(chunk)
|
|
if downloaded > metadata.size_bytes or downloaded > MAX_DOWNLOAD_BYTES:
|
|
raise UpdateInstallError("新版安装包大小超过接口声明")
|
|
output.write(chunk)
|
|
digest.update(chunk)
|
|
if progress:
|
|
progress(downloaded, metadata.size_bytes)
|
|
if downloaded != metadata.size_bytes:
|
|
raise UpdateInstallError("新版安装包大小校验失败")
|
|
if digest.hexdigest().lower() != metadata.sha256.lower():
|
|
raise UpdateInstallError("新版安装包完整性校验失败")
|
|
os.replace(str(part_path), str(zip_path))
|
|
return zip_path
|
|
except UpdateInstallError:
|
|
_safe_unlink(part_path)
|
|
raise
|
|
except Exception as exc:
|
|
_safe_unlink(part_path)
|
|
raise UpdateInstallError("下载新版失败,请检查网络后重试") from exc
|
|
|
|
|
|
def _validate_member_name(name):
|
|
if not name or "\\" in name or name.startswith(("/", "\\")):
|
|
raise UpdateInstallError("新版安装包包含不安全路径")
|
|
if re.match(r"^[A-Za-z]:", name) or ":" in name:
|
|
raise UpdateInstallError("新版安装包包含不安全路径")
|
|
path = PurePosixPath(name)
|
|
if any(part in {"", ".", ".."} for part in path.parts):
|
|
raise UpdateInstallError("新版安装包包含路径穿越")
|
|
for part in path.parts:
|
|
if part.endswith((".", " ")):
|
|
raise UpdateInstallError("新版安装包包含 Windows 不支持的路径")
|
|
stem = part.split(".", 1)[0].upper()
|
|
if stem in RESERVED_NAMES:
|
|
raise UpdateInstallError("新版安装包包含 Windows 保留名称")
|
|
return path
|
|
|
|
|
|
def _is_zip_symlink(info):
|
|
mode = (info.external_attr >> 16) & 0xFFFF
|
|
return stat.S_ISLNK(mode)
|
|
|
|
|
|
def safe_extract(zip_path, target_dir):
|
|
target_dir = Path(target_dir)
|
|
seen = set()
|
|
total_size = 0
|
|
with zipfile.ZipFile(str(zip_path), "r") as archive:
|
|
infos = archive.infolist()
|
|
if len(infos) > MAX_FILE_COUNT:
|
|
raise UpdateInstallError("新版安装包文件数量超过限制")
|
|
validated = []
|
|
for info in infos:
|
|
path = _validate_member_name(info.filename.rstrip("/") if info.is_dir() else info.filename)
|
|
key = path.as_posix().casefold()
|
|
if key in seen:
|
|
raise UpdateInstallError("新版安装包包含重复路径")
|
|
seen.add(key)
|
|
if _is_zip_symlink(info):
|
|
raise UpdateInstallError("新版安装包不允许符号链接")
|
|
total_size += info.file_size
|
|
if total_size > MAX_EXTRACTED_BYTES:
|
|
raise UpdateInstallError("新版安装包解压大小超过限制")
|
|
if info.file_size and (
|
|
info.compress_size == 0
|
|
or info.file_size / max(info.compress_size, 1) > MAX_COMPRESSION_RATIO
|
|
):
|
|
raise UpdateInstallError("新版安装包压缩比异常")
|
|
validated.append((info, path))
|
|
|
|
target_dir.mkdir(parents=True, exist_ok=False)
|
|
for info, relative in validated:
|
|
destination = target_dir.joinpath(*relative.parts)
|
|
if info.is_dir():
|
|
destination.mkdir(parents=True, exist_ok=True)
|
|
continue
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
with archive.open(info, "r") as source, destination.open("wb") as output:
|
|
shutil.copyfileobj(source, output, length=1024 * 1024)
|
|
return target_dir
|
|
|
|
|
|
def validate_staging(staging_dir, metadata):
|
|
staging_dir = Path(staging_dir)
|
|
for name in (ENTRY_POINT, "_internal", "version.txt", MANIFEST_FILENAME):
|
|
if not (staging_dir / name).exists():
|
|
raise UpdateInstallError("新版安装包缺少必要程序文件")
|
|
if not (staging_dir / "_internal").is_dir():
|
|
raise UpdateInstallError("新版安装包依赖目录无效")
|
|
if (staging_dir / "version.txt").read_text(encoding="utf-8-sig").strip() != metadata.version:
|
|
raise UpdateInstallError("新版安装包版本不一致")
|
|
|
|
try:
|
|
manifest = json.loads((staging_dir / MANIFEST_FILENAME).read_text(encoding="utf-8"))
|
|
except (OSError, ValueError) as exc:
|
|
raise UpdateInstallError("新版安装包清单无效") from exc
|
|
if (
|
|
manifest.get("app_version") != metadata.version
|
|
or manifest.get("package_format") != metadata.package_format
|
|
or manifest.get("entry_point") != ENTRY_POINT
|
|
or int(manifest.get("updater_protocol") or 0) != metadata.updater_protocol
|
|
or int(manifest.get("min_updater_protocol") or 0) > UPDATER_PROTOCOL
|
|
):
|
|
raise UpdateInstallError("新版安装包清单与发布信息不一致")
|
|
|
|
replace_roots = manifest.get("replace_roots")
|
|
if not isinstance(replace_roots, list) or not replace_roots:
|
|
raise UpdateInstallError("新版安装包替换范围无效")
|
|
if any(str(root) not in ALLOWED_ROOTS for root in replace_roots):
|
|
raise UpdateInstallError("新版安装包包含未授权替换范围")
|
|
|
|
declared = {}
|
|
for item in manifest.get("files") or []:
|
|
if not isinstance(item, dict):
|
|
raise UpdateInstallError("新版安装包清单文件项无效")
|
|
relative = _validate_member_name(str(item.get("path") or ""))
|
|
if relative.parts[0] not in ALLOWED_ROOTS or relative.name == MANIFEST_FILENAME:
|
|
raise UpdateInstallError("新版安装包清单包含未授权文件")
|
|
key = relative.as_posix().casefold()
|
|
if key in declared:
|
|
raise UpdateInstallError("新版安装包清单包含重复路径")
|
|
declared[key] = (relative, int(item.get("size_bytes") or -1), str(item.get("sha256") or ""))
|
|
|
|
actual = {}
|
|
for path in staging_dir.rglob("*"):
|
|
if path.is_symlink():
|
|
raise UpdateInstallError("新版暂存目录不允许符号链接")
|
|
if not path.is_file() or path.name == MANIFEST_FILENAME:
|
|
continue
|
|
relative = PurePosixPath(path.relative_to(staging_dir).as_posix())
|
|
actual[relative.as_posix().casefold()] = relative
|
|
if set(actual) != set(declared):
|
|
raise UpdateInstallError("新版安装包文件与清单不一致")
|
|
for key, (relative, size_bytes, expected_hash) in declared.items():
|
|
path = staging_dir.joinpath(*relative.parts)
|
|
if path.stat().st_size != size_bytes or sha256_file(path).lower() != expected_hash.lower():
|
|
raise UpdateInstallError("新版安装包文件校验失败")
|
|
return manifest
|
|
|
|
|
|
def _write_pending(update_root, metadata, staging_dir, zip_path):
|
|
pending_path = update_root / "pending.json"
|
|
payload = {
|
|
"schema_version": 1,
|
|
"stage": "verified",
|
|
"version": metadata.version,
|
|
"install_root": str(update_root.parent),
|
|
"staging_dir": str(Path(staging_dir).relative_to(update_root).as_posix()),
|
|
"zip_path": str(Path(zip_path).relative_to(update_root).as_posix()),
|
|
"sha256": metadata.sha256,
|
|
"package_format": metadata.package_format,
|
|
"updater_protocol": metadata.updater_protocol,
|
|
}
|
|
temporary = pending_path.with_suffix(".json.tmp")
|
|
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
os.replace(str(temporary), str(pending_path))
|
|
return pending_path
|
|
|
|
|
|
def _discard_pending(update_root, payload=None):
|
|
if isinstance(payload, dict):
|
|
relative = payload.get("staging_dir")
|
|
if relative:
|
|
try:
|
|
staging_dir = (update_root / relative).resolve()
|
|
if update_root in staging_dir.parents and staging_dir.exists():
|
|
shutil.rmtree(str(staging_dir), ignore_errors=True)
|
|
except (OSError, ValueError):
|
|
pass
|
|
_safe_unlink(update_root / "pending.json")
|
|
|
|
|
|
def load_verified_pending(install_root, metadata):
|
|
update_root = _update_root(install_root)
|
|
pending_path = update_root / "pending.json"
|
|
payload = None
|
|
try:
|
|
payload = json.loads(pending_path.read_text(encoding="utf-8"))
|
|
if (
|
|
payload.get("stage") != "verified"
|
|
or payload.get("version") != metadata.version
|
|
or payload.get("sha256") != metadata.sha256
|
|
):
|
|
raise UpdateInstallError("待安装更新记录已失效")
|
|
staging_dir = (update_root / payload["staging_dir"]).resolve()
|
|
zip_path = (update_root / payload["zip_path"]).resolve()
|
|
if update_root not in staging_dir.parents or update_root not in zip_path.parents:
|
|
raise UpdateInstallError("待安装更新路径无效")
|
|
validate_staging(staging_dir, metadata)
|
|
if not zip_path.is_file() or sha256_file(zip_path) != metadata.sha256:
|
|
raise UpdateInstallError("待安装更新压缩包已失效")
|
|
return StagedUpdate(metadata.version, zip_path, staging_dir, pending_path, metadata.sha256)
|
|
except FileNotFoundError:
|
|
return None
|
|
except (OSError, ValueError, KeyError, UpdateInstallError):
|
|
_discard_pending(update_root, payload)
|
|
return None
|
|
|
|
|
|
def prepare_update(metadata, install_root, stage_callback=None, **download_kwargs):
|
|
validate_metadata(metadata, download_kwargs.get("trusted_hosts", DEFAULT_TRUSTED_HOSTS))
|
|
if stage_callback:
|
|
stage_callback("正在检查已下载的新版")
|
|
reusable = load_verified_pending(install_root, metadata)
|
|
if reusable is not None:
|
|
if stage_callback:
|
|
stage_callback("新版已经完成校验")
|
|
return reusable
|
|
update_root = _update_root(install_root)
|
|
if stage_callback:
|
|
stage_callback("正在下载新版")
|
|
zip_path = download_package(metadata, install_root, **download_kwargs)
|
|
staging_parent = update_root / "staging"
|
|
staging_parent.mkdir(parents=True, exist_ok=True)
|
|
staging_dir = staging_parent / (metadata.version + "-" + uuid.uuid4().hex[:12])
|
|
try:
|
|
if stage_callback:
|
|
stage_callback("正在校验并准备新版")
|
|
safe_extract(zip_path, staging_dir)
|
|
validate_staging(staging_dir, metadata)
|
|
pending_path = _write_pending(update_root, metadata, staging_dir, zip_path)
|
|
except Exception:
|
|
if staging_dir.exists():
|
|
shutil.rmtree(str(staging_dir), ignore_errors=True)
|
|
raise
|
|
if stage_callback:
|
|
stage_callback("新版已经完成校验")
|
|
return StagedUpdate(metadata.version, zip_path, staging_dir, pending_path, metadata.sha256)
|