Some checks failed
Windows project source guards / test (push) Has been cancelled
743 lines
29 KiB
Python
743 lines
29 KiB
Python
#!/usr/bin/env python3
|
|
"""Resolve and pin the latest stable Windows core and routing assets.
|
|
|
|
The normal Windows build is deliberately lock-driven. This command is the
|
|
explicit online boundary that may inspect GitHub and refresh Xray, extended
|
|
sing-box, and the runetfreedom release-branch snapshot in
|
|
``scripts/core-lock.windows-x64.json``. Both ``--check`` and ``--write``
|
|
download the selected archives into private temporary files, verify or record
|
|
their exact digests, and delete those files before returning. ``--write``
|
|
replaces the lock atomically; a network or digest error therefore leaves the
|
|
previous lock untouched.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import copy
|
|
import hashlib
|
|
from http.client import HTTPException
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from typing import Any
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.parse import quote
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_LOCK_PATH = ROOT / "scripts" / "core-lock.windows-x64.json"
|
|
GITHUB_API_ROOT = "https://api.github.com"
|
|
USER_AGENT = "ZapretKVN-core-resolver/1"
|
|
# A GitHub page includes every release asset entry. The current extended
|
|
# sing-box page is a little over 4 MiB, so keep a bounded but sufficient cap
|
|
# rather than making the API read unbounded.
|
|
METADATA_LIMIT = 16 * 1024 * 1024
|
|
ARCHIVE_LIMIT = 512 * 1024 * 1024
|
|
|
|
XRAY_REPOSITORY = "XTLS/Xray-core"
|
|
XRAY_ASSET_NAME = "Xray-windows-64.zip"
|
|
SINGBOX_REPOSITORY = "shtorm-7/sing-box-extended"
|
|
ROUTING_REPOSITORY = "runetfreedom/russia-v2ray-rules-dat"
|
|
ROUTING_BRANCH = "release"
|
|
AMNEZIA_REPOSITORY = "amnezia-vpn/amneziawg-go"
|
|
AMNEZIA_MODULE = "github.com/amnezia-vpn/amneziawg-go/v3"
|
|
|
|
_XRAY_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
|
|
_SINGBOX_TAG_RE = re.compile(
|
|
r"^v(\d+)\.(\d+)\.(\d+)-extended-(\d+)\.(\d+)\.(\d+)$"
|
|
)
|
|
_SHA256_RE = re.compile(r"(?i)(?:sha256\s*:\s*)?([0-9a-f]{64})")
|
|
_FILE_MAPPING_RE = re.compile(
|
|
r'\{\n\s+"match": ("(?:\\.|[^"\\])*"),\n'
|
|
r'\s+"target": ("(?:\\.|[^"\\])*")\n\s+\}'
|
|
)
|
|
|
|
|
|
class ResolverError(RuntimeError):
|
|
"""A safe, user-actionable failure while resolving a core release."""
|
|
|
|
|
|
def _request_headers(url: str, accept: str) -> dict[str, str]:
|
|
"""Build request headers without exposing protected API credentials.
|
|
|
|
Anonymous GitHub API limits are deliberately small. The release host may
|
|
provide ``GITHUB_TOKEN`` through its protected environment; it is used only
|
|
for api.github.com and is never printed or persisted in the lock file.
|
|
"""
|
|
|
|
headers = {"Accept": accept, "User-Agent": USER_AGENT}
|
|
token = os.environ.get("GITHUB_TOKEN", "").strip()
|
|
if token and url.startswith(GITHUB_API_ROOT + "/"):
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
return headers
|
|
|
|
|
|
def _read_bounded(response: Any, *, limit: int) -> bytes:
|
|
body = response.read(limit + 1)
|
|
if len(body) > limit:
|
|
raise ResolverError(f"response exceeds the {limit}-byte safety limit")
|
|
return body
|
|
|
|
|
|
def fetch_bytes(url: str, *, timeout: float = 30.0, limit: int = METADATA_LIMIT) -> bytes:
|
|
"""Fetch a bounded response using the resolver's fixed public User-Agent."""
|
|
|
|
request = Request(url, headers=_request_headers(url, "application/json"))
|
|
try:
|
|
with urlopen(request, timeout=timeout) as response:
|
|
return _read_bounded(response, limit=limit)
|
|
except (HTTPException, HTTPError, URLError, TimeoutError, OSError) as exc:
|
|
raise ResolverError(f"download failed for {url}: {exc}") from exc
|
|
|
|
|
|
def fetch_json(url: str, *, timeout: float = 30.0) -> Any:
|
|
"""Fetch and decode one GitHub API JSON document."""
|
|
|
|
try:
|
|
return json.loads(fetch_bytes(url, timeout=timeout).decode("utf-8"))
|
|
except UnicodeDecodeError as exc:
|
|
raise ResolverError(f"GitHub API returned non-UTF-8 JSON: {url}") from exc
|
|
except json.JSONDecodeError as exc:
|
|
raise ResolverError(f"GitHub API returned malformed JSON: {url}") from exc
|
|
|
|
|
|
def github_latest_release(repository: str, *, timeout: float = 30.0) -> dict[str, Any]:
|
|
"""Read GitHub's latest stable release endpoint.
|
|
|
|
GitHub defines ``/releases/latest`` as the latest non-draft,
|
|
non-prerelease release. The normal resolver still applies the explicit
|
|
flag and tag checks below, so a changed API contract fails closed.
|
|
"""
|
|
|
|
url = f"{GITHUB_API_ROOT}/repos/{repository}/releases/latest"
|
|
payload = fetch_json(url, timeout=timeout)
|
|
if not isinstance(payload, dict):
|
|
raise ResolverError(f"GitHub latest release response is not an object: {repository}")
|
|
return payload
|
|
|
|
|
|
def github_branch_commit(
|
|
repository: str,
|
|
branch: str,
|
|
*,
|
|
timeout: float = 30.0,
|
|
) -> str:
|
|
"""Resolve one moving branch to an immutable Git commit."""
|
|
|
|
url = f"{GITHUB_API_ROOT}/repos/{repository}/commits/{quote(branch, safe='')}"
|
|
payload = fetch_json(url, timeout=timeout)
|
|
if not isinstance(payload, dict):
|
|
raise ResolverError(f"GitHub branch response is not an object: {repository}/{branch}")
|
|
commit = str(payload.get("sha") or "").lower()
|
|
if re.fullmatch(r"[0-9a-f]{40}", commit) is None:
|
|
raise ResolverError(f"GitHub returned an invalid commit for {repository}/{branch}")
|
|
return commit
|
|
|
|
|
|
def _release_is_stable(release: dict[str, Any]) -> bool:
|
|
# Missing flags are not treated as safe defaults. A release resolver must
|
|
# fail closed if GitHub changes the response shape.
|
|
return release.get("draft") is False and release.get("prerelease") is False
|
|
|
|
|
|
def _xray_version(tag: str) -> tuple[int, int, int] | None:
|
|
match = _XRAY_TAG_RE.fullmatch(tag)
|
|
if match is None:
|
|
return None
|
|
return tuple(int(part) for part in match.groups()) # type: ignore[return-value]
|
|
|
|
|
|
def _singbox_version(tag: str) -> tuple[int, int, int, int, int, int] | None:
|
|
match = _SINGBOX_TAG_RE.fullmatch(tag)
|
|
if match is None:
|
|
return None
|
|
return tuple(int(part) for part in match.groups()) # type: ignore[return-value]
|
|
|
|
|
|
def _stable_release(
|
|
releases: list[dict[str, Any]],
|
|
*,
|
|
version_parser: Any,
|
|
component: str,
|
|
) -> dict[str, Any]:
|
|
candidates: list[tuple[tuple[int, ...], dict[str, Any]]] = []
|
|
for release in releases:
|
|
if not _release_is_stable(release):
|
|
continue
|
|
tag = str(release.get("tag_name") or "")
|
|
version = version_parser(tag)
|
|
if version is not None:
|
|
candidates.append((version, release))
|
|
if not candidates:
|
|
raise ResolverError(f"no stable {component} release with a valid tag was found")
|
|
return max(candidates, key=lambda item: item[0])[1]
|
|
|
|
|
|
def select_stable_xray(releases: list[dict[str, Any]]) -> dict[str, Any]:
|
|
"""Select the highest non-draft, non-prerelease Xray release."""
|
|
|
|
return _stable_release(
|
|
releases,
|
|
version_parser=_xray_version,
|
|
component="Xray",
|
|
)
|
|
|
|
|
|
def select_stable_singbox(releases: list[dict[str, Any]]) -> dict[str, Any]:
|
|
"""Select the highest stable extended sing-box release."""
|
|
|
|
return _stable_release(
|
|
releases,
|
|
version_parser=_singbox_version,
|
|
component="sing-box Extended",
|
|
)
|
|
|
|
|
|
def _expected_asset_url(repository: str, tag: str, asset_name: str) -> str:
|
|
return (
|
|
f"https://github.com/{repository}/releases/download/"
|
|
f"{quote(tag, safe='')}/{quote(asset_name, safe='')}"
|
|
)
|
|
|
|
|
|
def select_exact_asset(
|
|
release: dict[str, Any],
|
|
*,
|
|
repository: str,
|
|
asset_name: str,
|
|
) -> dict[str, Any]:
|
|
"""Select one exact uploaded asset and reject wrong-architecture variants."""
|
|
|
|
assets = release.get("assets")
|
|
if not isinstance(assets, list):
|
|
raise ResolverError(f"release {release.get('tag_name', '')} has no assets list")
|
|
matches = [
|
|
asset
|
|
for asset in assets
|
|
if isinstance(asset, dict) and asset.get("name") == asset_name
|
|
]
|
|
if len(matches) != 1:
|
|
raise ResolverError(
|
|
f"release {release.get('tag_name', '')} must contain exactly one "
|
|
f"{asset_name}, found {len(matches)}"
|
|
)
|
|
asset = matches[0]
|
|
if asset.get("state") not in (None, "uploaded"):
|
|
raise ResolverError(f"asset {asset_name} is not uploaded")
|
|
try:
|
|
size = int(asset.get("size") or 0)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ResolverError(f"asset {asset_name} has an invalid size") from exc
|
|
if size <= 0:
|
|
raise ResolverError(f"asset {asset_name} has an empty or missing size")
|
|
|
|
expected_url = _expected_asset_url(
|
|
repository,
|
|
str(release.get("tag_name") or ""),
|
|
asset_name,
|
|
)
|
|
if asset.get("browser_download_url") != expected_url:
|
|
raise ResolverError(f"asset {asset_name} has an unexpected download URL")
|
|
return asset
|
|
|
|
|
|
def _digest_from_text(text: str) -> str | None:
|
|
matches = _SHA256_RE.findall(text)
|
|
if len(matches) != 1:
|
|
return None
|
|
return matches[0].lower()
|
|
|
|
|
|
def asset_digest(
|
|
release: dict[str, Any],
|
|
asset: dict[str, Any],
|
|
*,
|
|
repository: str,
|
|
timeout: float = 30.0,
|
|
) -> str:
|
|
"""Return the trusted SHA-256 for an asset or its exact upstream sidecar."""
|
|
|
|
raw_digest = asset.get("digest")
|
|
if isinstance(raw_digest, str):
|
|
parsed = _digest_from_text(raw_digest)
|
|
if parsed is not None and raw_digest.lower().strip().startswith("sha256:"):
|
|
return parsed
|
|
raise ResolverError(f"asset {asset.get('name', '')} has an invalid SHA-256 digest")
|
|
|
|
# Older GitHub API responses may omit ``digest``. A sidecar is accepted
|
|
# only when its name is exactly the selected archive plus ``.dgst`` and its
|
|
# URL is the corresponding GitHub release URL.
|
|
asset_name = str(asset.get("name") or "")
|
|
sidecar_name = asset_name + ".dgst"
|
|
sidecar = select_exact_asset(
|
|
release,
|
|
repository=repository,
|
|
asset_name=sidecar_name,
|
|
)
|
|
body = fetch_bytes(str(sidecar["browser_download_url"]), timeout=timeout, limit=16 * 1024)
|
|
parsed = _digest_from_text(body.decode("utf-8", errors="replace"))
|
|
if parsed is None:
|
|
raise ResolverError(f"sidecar {sidecar_name} has no unique SHA-256 digest")
|
|
return parsed
|
|
|
|
|
|
def download_and_hash(
|
|
url: str,
|
|
*,
|
|
timeout: float = 120.0,
|
|
) -> tuple[int, str]:
|
|
"""Download an archive privately, return its size and digest, then delete it."""
|
|
|
|
fd, temporary_name = tempfile.mkstemp(prefix="zapret-kvn-core-", suffix=".part")
|
|
temporary = Path(temporary_name)
|
|
total = 0
|
|
digest = hashlib.sha256()
|
|
try:
|
|
request = Request(url, headers=_request_headers(url, "application/octet-stream"))
|
|
try:
|
|
with os.fdopen(fd, "w+b") as output:
|
|
fd = -1
|
|
curl = shutil.which("curl")
|
|
if curl:
|
|
# curl negotiates HTTP/2. GitHub's HTTP/1.1 archive stream
|
|
# can be throttled until it is cut off with IncompleteRead.
|
|
# No API credentials are needed for public release archives.
|
|
deadline = max(timeout, 3600.0)
|
|
subprocess.run(
|
|
[curl, "--disable", "--fail", "--silent", "--show-error",
|
|
"--location", "--proto", "=https", "--proto-redir", "=https",
|
|
"--max-redirs", "5", "--connect-timeout", str(min(timeout, 30.0)),
|
|
"--max-time", str(deadline), "--max-filesize", str(ARCHIVE_LIMIT), url],
|
|
stdout=output, stderr=subprocess.PIPE, check=True, timeout=deadline + 10,
|
|
)
|
|
else:
|
|
with urlopen(request, timeout=timeout) as response:
|
|
received = 0
|
|
while chunk := response.read(1024 * 1024):
|
|
received += len(chunk)
|
|
if received > ARCHIVE_LIMIT:
|
|
raise ResolverError(f"archive exceeds the {ARCHIVE_LIMIT}-byte safety limit")
|
|
output.write(chunk)
|
|
output.seek(0)
|
|
while True:
|
|
chunk = output.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
total += len(chunk)
|
|
if total > ARCHIVE_LIMIT:
|
|
raise ResolverError(
|
|
f"archive exceeds the {ARCHIVE_LIMIT}-byte safety limit"
|
|
)
|
|
digest.update(chunk)
|
|
except (HTTPException, HTTPError, URLError, TimeoutError, OSError, subprocess.SubprocessError) as exc:
|
|
detail = str(exc)
|
|
if isinstance(exc, subprocess.CalledProcessError) and exc.stderr:
|
|
detail = exc.stderr.decode("utf-8", errors="replace").strip()
|
|
raise ResolverError(f"archive download failed for {url}: {detail}") from exc
|
|
if total <= 0:
|
|
raise ResolverError(f"archive download was empty: {url}")
|
|
return total, digest.hexdigest()
|
|
finally:
|
|
if fd >= 0:
|
|
os.close(fd)
|
|
try:
|
|
temporary.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
|
|
def download_and_verify(
|
|
url: str,
|
|
expected_sha256: str,
|
|
*,
|
|
timeout: float = 120.0,
|
|
) -> int:
|
|
"""Download an archive to a private temporary file and verify every byte."""
|
|
|
|
expected = expected_sha256.lower()
|
|
if re.fullmatch(r"[0-9a-f]{64}", expected) is None:
|
|
raise ResolverError(f"invalid expected SHA-256: {expected_sha256}")
|
|
total, actual = download_and_hash(url, timeout=timeout)
|
|
if actual != expected:
|
|
raise ResolverError(
|
|
f"SHA-256 mismatch for {url}: expected {expected}, got {actual}"
|
|
)
|
|
return total
|
|
|
|
|
|
def _validate_lock(lock: dict[str, Any]) -> None:
|
|
if lock.get("schema") != 1 or lock.get("platform") != "windows-x64":
|
|
raise ResolverError("unsupported core lock schema or platform")
|
|
sources = lock.get("sources")
|
|
if not isinstance(sources, list):
|
|
raise ResolverError("core lock sources must be a list")
|
|
ids = [source.get("id") for source in sources if isinstance(source, dict)]
|
|
if len(ids) != len(sources) or len(set(ids)) != len(ids):
|
|
raise ResolverError("core lock sources must contain unique object ids")
|
|
for required in (
|
|
"sing-box-extended",
|
|
"xray-core",
|
|
"hysteria",
|
|
"runetfreedom-routing-data",
|
|
):
|
|
if required not in ids:
|
|
raise ResolverError(f"core lock is missing {required}")
|
|
|
|
|
|
def read_lock(path: Path) -> dict[str, Any]:
|
|
try:
|
|
payload = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
raise ResolverError(f"cannot read core lock {path}: {exc}") from exc
|
|
if not isinstance(payload, dict):
|
|
raise ResolverError("core lock root must be a JSON object")
|
|
_validate_lock(payload)
|
|
return payload
|
|
|
|
|
|
def _source_by_id(lock: dict[str, Any], source_id: str) -> dict[str, Any]:
|
|
for source in lock["sources"]:
|
|
if source.get("id") == source_id:
|
|
return source
|
|
raise ResolverError(f"core lock is missing {source_id}")
|
|
|
|
|
|
def _replace_source(
|
|
source: dict[str, Any],
|
|
*,
|
|
release: dict[str, Any],
|
|
repository: str,
|
|
asset_name: str,
|
|
timeout: float,
|
|
) -> dict[str, Any]:
|
|
tag = str(release.get("tag_name") or "")
|
|
asset = select_exact_asset(release, repository=repository, asset_name=asset_name)
|
|
url = str(asset.get("browser_download_url") or "")
|
|
digest = asset_digest(
|
|
release,
|
|
asset,
|
|
repository=repository,
|
|
timeout=timeout,
|
|
)
|
|
size = download_and_verify(url, digest, timeout=max(timeout, 120.0))
|
|
api_size = int(asset["size"])
|
|
if size != api_size:
|
|
raise ResolverError(
|
|
f"downloaded size mismatch for {asset_name}: expected {api_size}, got {size}"
|
|
)
|
|
|
|
updated = copy.deepcopy(source)
|
|
updated["version"] = tag
|
|
updated["archive"] = asset_name
|
|
updated["url"] = url
|
|
updated["sha256"] = digest
|
|
updated["repository"] = repository
|
|
updated["channel"] = "stable"
|
|
updated["release_tag"] = tag
|
|
updated["release_prerelease"] = False
|
|
updated["asset_name"] = asset_name
|
|
updated["asset_size"] = int(asset["size"])
|
|
return updated
|
|
|
|
|
|
def _replace_routing_source(
|
|
source: dict[str, Any],
|
|
*,
|
|
commit: str,
|
|
timeout: float,
|
|
) -> dict[str, Any]:
|
|
archive = f"russia-v2ray-rules-dat-{commit}.zip"
|
|
url = f"https://github.com/{ROUTING_REPOSITORY}/archive/{commit}.zip"
|
|
size, digest = download_and_hash(url, timeout=max(timeout, 120.0))
|
|
updated = copy.deepcopy(source)
|
|
updated.update(
|
|
{
|
|
"version": commit,
|
|
"archive": archive,
|
|
"url": url,
|
|
"sha256": digest,
|
|
"repository": ROUTING_REPOSITORY,
|
|
"channel": ROUTING_BRANCH,
|
|
"branch": ROUTING_BRANCH,
|
|
"asset_size": size,
|
|
}
|
|
)
|
|
return updated
|
|
|
|
|
|
def amnezia_tag_key(tag: str) -> tuple | None:
|
|
match = re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?", tag)
|
|
if not match:
|
|
return None
|
|
major, minor, patch = map(int, match.groups()[:3])
|
|
pre = match.group(4)
|
|
suffix = tuple((0, int(p)) if p.isdigit() else (1, p) for p in (pre or "").split("."))
|
|
return major, minor, patch, pre is None, suffix
|
|
|
|
|
|
def resolve_amnezia_source(current: dict, *, timeout: float = 30.0) -> dict:
|
|
"""The official project publishes tags, not necessarily GitHub Releases."""
|
|
tags = []
|
|
for page in range(1, 17):
|
|
rows = fetch_json(f"{GITHUB_API_ROOT}/repos/{AMNEZIA_REPOSITORY}/tags?per_page=100&page={page}", timeout=timeout)
|
|
if not isinstance(rows, list):
|
|
raise ResolverError("Invalid official Amnezia tag response")
|
|
tags.extend(row for row in rows if isinstance(row, dict) and amnezia_tag_key(str(row.get("name", ""))))
|
|
if len(rows) < 100:
|
|
break
|
|
else:
|
|
raise ResolverError("Amnezia tag pagination exceeded its bound")
|
|
if not tags:
|
|
raise ResolverError("No versioned official Amnezia tag found")
|
|
selected = max(tags, key=lambda row: amnezia_tag_key(row["name"]))
|
|
version = selected["name"]
|
|
if amnezia_tag_key(version)[0] != 3:
|
|
raise ResolverError("Latest Amnezia major version requires an adapter update; refusing to ship an older tag")
|
|
commit = str(selected.get("commit", {}).get("sha", ""))
|
|
if re.fullmatch(r"[0-9a-f]{40}", commit) is None:
|
|
raise ResolverError("Invalid Amnezia commit")
|
|
result = subprocess.run(["go", "mod", "download", "-json", f"{AMNEZIA_MODULE}@{version}"],
|
|
cwd=ROOT, text=True, capture_output=True, timeout=max(60, timeout * 4))
|
|
if result.returncode:
|
|
raise ResolverError("Verified Amnezia module download failed: " + result.stderr.strip())
|
|
module = json.loads(result.stdout)
|
|
if module.get("Origin", {}).get("Hash") != commit or module.get("Version") != version:
|
|
raise ResolverError("Amnezia Git tag and Go module commit differ")
|
|
archive = Path(module["Zip"])
|
|
toolchain = str(current["toolchain"]["version"])
|
|
versions = fetch_json("https://go.dev/dl/?mode=json&include=all", timeout=timeout)
|
|
sdk = next((f for item in versions if item.get("version") == toolchain for f in item.get("files", [])
|
|
if f.get("os") == "windows" and f.get("arch") == "amd64" and f.get("kind") == "archive"), None)
|
|
if sdk is None or re.fullmatch(r"[0-9a-f]{64}", str(sdk.get("sha256", ""))) is None:
|
|
raise ResolverError("Pinned Windows Go SDK is missing from official metadata")
|
|
return {
|
|
"id": "amnezia", "repository": AMNEZIA_REPOSITORY, "channel": "official-tags",
|
|
"version": version, "commit": commit, "module": AMNEZIA_MODULE,
|
|
"module_sum": module["Sum"], "module_go_mod_sum": module["GoModSum"],
|
|
"sha256": hashlib.sha256(archive.read_bytes()).hexdigest(), "asset_size": archive.stat().st_size,
|
|
"release_prerelease": "-" in version, "url": f"https://github.com/{AMNEZIA_REPOSITORY}/tree/{commit}",
|
|
"toolchain": {"version": toolchain, "archive": sdk["filename"], "sha256": sdk["sha256"],
|
|
"url": f"https://go.dev/dl/{sdk['filename']}"},
|
|
}
|
|
|
|
|
|
def update_amnezia_runtime(source: dict, root: Path = ROOT) -> None:
|
|
directory = root / "runtime" / "amnezia"
|
|
version = source["version"]
|
|
subprocess.run(["go", "mod", "edit", f"-require={AMNEZIA_MODULE}@{version}"], cwd=directory, check=True)
|
|
subprocess.run(["go", "mod", "tidy"], cwd=directory, check=True, timeout=300)
|
|
# This also catches an upstream API/dependency change before publication.
|
|
subprocess.run(["go", "test", "-mod=readonly", "./..."], cwd=directory, check=True, timeout=300)
|
|
|
|
|
|
def resolve_singbox_build(version: str, *, timeout: float = 30.0) -> dict:
|
|
"""Freeze the source matching the release asset, not a different moving ref."""
|
|
path = f"github.com/{SINGBOX_REPOSITORY}"
|
|
result = subprocess.run(["go", "mod", "download", "-json", f"{path}@{version}"],
|
|
cwd=ROOT, text=True, capture_output=True, timeout=max(60, timeout * 4))
|
|
if result.returncode:
|
|
raise ResolverError("Verified sing-box source download failed: " + result.stderr.strip())
|
|
module = json.loads(result.stdout)
|
|
commit = module.get("Origin", {}).get("Hash", "")
|
|
if module.get("Version") != version or re.fullmatch(r"[0-9a-f]{40}", commit) is None:
|
|
raise ResolverError("sing-box source revision mismatch")
|
|
# Refuse an unreviewed dependency/patch combination before writing the lock.
|
|
mod_text = Path(module["GoMod"]).read_text(encoding="utf-8")
|
|
match = re.search(r"^replace github.com/sagernet/sing => github.com/shtorm-7/sing (\S+)$", mod_text, re.M)
|
|
manifest = ROOT / "core-patches/sing-udp.json"
|
|
patches = json.loads(manifest.read_text(encoding="utf-8"))
|
|
if not match or match.group(1) not in patches["versions"]:
|
|
raise ResolverError("Latest sing-box needs a verified UDP patch; refusing an older-core fallback")
|
|
return {"module": path, "version": version, "commit": commit,
|
|
"module_sum": module["Sum"], "module_go_mod_sum": module["GoModSum"],
|
|
"zip_sha256": hashlib.sha256(Path(module["Zip"]).read_bytes()).hexdigest(),
|
|
"udp_manifest_sha256": hashlib.sha256(manifest.read_bytes()).hexdigest()}
|
|
|
|
|
|
def resolve_lock(lock: dict[str, Any], *, timeout: float = 30.0) -> dict[str, Any]:
|
|
"""Resolve stable cores and one immutable routing-data snapshot."""
|
|
|
|
_validate_lock(lock)
|
|
candidate = copy.deepcopy(lock)
|
|
xray_release = select_stable_xray(
|
|
[github_latest_release(XRAY_REPOSITORY, timeout=timeout)]
|
|
)
|
|
singbox_release = select_stable_singbox(
|
|
[github_latest_release(SINGBOX_REPOSITORY, timeout=timeout)]
|
|
)
|
|
routing_commit = github_branch_commit(
|
|
ROUTING_REPOSITORY,
|
|
ROUTING_BRANCH,
|
|
timeout=timeout,
|
|
)
|
|
|
|
xray_source = _source_by_id(candidate, "xray-core")
|
|
singbox_source = _source_by_id(candidate, "sing-box-extended")
|
|
routing_source = _source_by_id(candidate, "runetfreedom-routing-data")
|
|
replacement_xray = _replace_source(
|
|
xray_source,
|
|
release=xray_release,
|
|
repository=XRAY_REPOSITORY,
|
|
asset_name=XRAY_ASSET_NAME,
|
|
timeout=timeout,
|
|
)
|
|
singbox_tag = str(singbox_release.get("tag_name") or "")
|
|
singbox_asset_name = f"sing-box-{singbox_tag.lstrip('v')}-windows-amd64-purego.zip"
|
|
replacement_singbox = _replace_source(
|
|
singbox_source,
|
|
release=singbox_release,
|
|
repository=SINGBOX_REPOSITORY,
|
|
asset_name=singbox_asset_name,
|
|
timeout=timeout,
|
|
)
|
|
replacement_routing = _replace_routing_source(
|
|
routing_source,
|
|
commit=routing_commit,
|
|
timeout=timeout,
|
|
)
|
|
|
|
for index, source in enumerate(candidate["sources"]):
|
|
if source.get("id") == "xray-core":
|
|
candidate["sources"][index] = replacement_xray
|
|
elif source.get("id") == "sing-box-extended":
|
|
candidate["sources"][index] = replacement_singbox
|
|
elif source.get("id") == "runetfreedom-routing-data":
|
|
candidate["sources"][index] = replacement_routing
|
|
if "amnezia" in candidate:
|
|
candidate["amnezia"] = resolve_amnezia_source(candidate["amnezia"], timeout=timeout)
|
|
if "singbox_build" in candidate:
|
|
candidate["singbox_build"] = resolve_singbox_build(singbox_tag, timeout=timeout)
|
|
return candidate
|
|
|
|
|
|
def lock_text(lock: dict[str, Any]) -> str:
|
|
# Keep the repository's compact two-field file mappings while using a
|
|
# deterministic JSON serializer for every other field. This avoids
|
|
# reformatting unrelated sources when --write changes one core pin.
|
|
text = json.dumps(lock, ensure_ascii=False, indent=2)
|
|
text = _FILE_MAPPING_RE.sub(
|
|
lambda match: f'{{ "match": {match.group(1)}, "target": {match.group(2)} }}',
|
|
text,
|
|
)
|
|
return text + "\n"
|
|
|
|
|
|
def atomic_write_lock(path: Path, lock: dict[str, Any]) -> None:
|
|
"""Write a lock by same-directory fsync + replace, never delete-first."""
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fd, temporary_name = tempfile.mkstemp(
|
|
prefix=f".{path.name}.",
|
|
suffix=".tmp",
|
|
dir=path.parent,
|
|
)
|
|
temporary = Path(temporary_name)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as output:
|
|
fd = -1
|
|
output.write(lock_text(lock))
|
|
output.flush()
|
|
os.fsync(output.fileno())
|
|
os.replace(temporary, path)
|
|
try:
|
|
directory_fd = os.open(path.parent, os.O_RDONLY)
|
|
except OSError:
|
|
directory_fd = -1
|
|
if directory_fd >= 0:
|
|
try:
|
|
try:
|
|
os.fsync(directory_fd)
|
|
except OSError:
|
|
# Directory fsync is unavailable on some Windows filesystems.
|
|
# The file itself was already flushed before os.replace;
|
|
# do not report failure after the atomic replacement succeeded.
|
|
pass
|
|
finally:
|
|
os.close(directory_fd)
|
|
except Exception:
|
|
if fd >= 0:
|
|
os.close(fd)
|
|
raise
|
|
finally:
|
|
try:
|
|
temporary.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
|
|
def _source_summary(lock: dict[str, Any]) -> list[str]:
|
|
summaries: list[str] = []
|
|
for source_id in (
|
|
"xray-core",
|
|
"sing-box-extended",
|
|
"runetfreedom-routing-data",
|
|
):
|
|
source = _source_by_id(lock, source_id)
|
|
summaries.append(f"{source_id}: {source['version']} sha256={source['sha256']}")
|
|
return summaries
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
description=(
|
|
"Resolve the latest stable Xray and extended sing-box Windows assets, "
|
|
"plus the latest runetfreedom release-branch snapshot, and optionally "
|
|
"atomically update the pinned core lock."
|
|
)
|
|
)
|
|
mode = parser.add_mutually_exclusive_group(required=True)
|
|
mode.add_argument("--check", action="store_true", help="resolve and verify without writing")
|
|
mode.add_argument(
|
|
"--write",
|
|
action="store_true",
|
|
help="resolve, verify, and atomically replace the lock",
|
|
)
|
|
parser.add_argument("--lock-file", type=Path, default=DEFAULT_LOCK_PATH)
|
|
parser.add_argument("--timeout", type=float, default=30.0)
|
|
parser.add_argument("--update-runtime", action="store_true", help="with --write, update and test the pinned Amnezia Go module")
|
|
parser.add_argument(
|
|
"--require-current",
|
|
action="store_true",
|
|
help="with --check, return an error when the verified stable lock differs",
|
|
)
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = build_parser().parse_args(argv)
|
|
try:
|
|
current = read_lock(args.lock_file)
|
|
candidate = resolve_lock(current, timeout=args.timeout)
|
|
print("Resolved stable core and routing sources:")
|
|
for summary in _source_summary(candidate):
|
|
print(f" {summary}")
|
|
if args.check:
|
|
if candidate == current:
|
|
print("Core lock is already up to date.")
|
|
else:
|
|
print("Core lock update is available; no files were written.")
|
|
if args.require_current:
|
|
return 2
|
|
return 0
|
|
if args.update_runtime:
|
|
update_amnezia_runtime(candidate["amnezia"])
|
|
if candidate == current:
|
|
print("Core lock is already up to date; no files were written.")
|
|
return 0
|
|
atomic_write_lock(args.lock_file, candidate)
|
|
print(f"Updated core lock atomically: {args.lock_file}")
|
|
return 0
|
|
except (ResolverError, OSError, subprocess.SubprocessError, ValueError) as exc:
|
|
print(f"core resolver error: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|