Files
cmshoppe/app/update_installer.py
T

415 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("新版安装包清单包含重复路径")
try:
size_bytes = int(item["size_bytes"])
except (KeyError, TypeError, ValueError) as exc:
raise UpdateInstallError("新版安装包清单文件大小无效") from exc
if size_bytes < 0:
raise UpdateInstallError("新版安装包清单文件大小无效")
declared[key] = (relative, size_bytes, 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)