1575 lines
50 KiB
Python
1575 lines
50 KiB
Python
"""Shared implementation for deterministic release building and verification."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
import fnmatch
|
|
import gzip
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import os
|
|
from pathlib import Path, PurePosixPath
|
|
import re
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
from typing import BinaryIO, Mapping, Sequence
|
|
from urllib.parse import urlsplit
|
|
|
|
from scripts.repository_paths import (
|
|
ARTIFACTS_ROOT,
|
|
PROJECT_ROOT,
|
|
RELEASE_ARTIFACTS_ROOT,
|
|
ensure_project_imports,
|
|
)
|
|
|
|
ensure_project_imports()
|
|
CONFIG_PATH = PROJECT_ROOT / "release/config.json"
|
|
READ_SIZE = 256 * 1024
|
|
MAX_ARCHIVE_BYTES = 32 * 1024 * 1024
|
|
MAX_EXTRACTED_BYTES = 64 * 1024 * 1024
|
|
MAX_ARCHIVE_MEMBERS = 512
|
|
MAX_EMBEDDED_RELEASE_NOTES_CHARACTERS = 16_000
|
|
_ALLOWED_ARCHIVE_ROOTS = {"ptlinux", "ptdiag", "release.json"}
|
|
_ARCHIVE_LAYOUTS = {"flat", "wrapped"}
|
|
_SHA256_PATTERN = re.compile(r"[0-9a-f]{64}\Z")
|
|
_COMMIT_PATTERN = re.compile(r"[0-9a-f]{7,64}\Z")
|
|
_SOURCE_VERSION_PATTERN = re.compile(
|
|
r'^__version__ = "([0-9]+\.[0-9]+\.[0-9]+(?:-(?:beta|rc)\.[0-9]+)?)"$',
|
|
re.MULTILINE,
|
|
)
|
|
_PROJECT_VERSION_PATTERN = re.compile(
|
|
r'^version = "([0-9]+\.[0-9]+\.[0-9]+(?:-(?:beta|rc)\.[0-9]+)?)"$',
|
|
re.MULTILINE,
|
|
)
|
|
_LEGACY_VERSION_PATTERN = re.compile(
|
|
r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\Z"
|
|
)
|
|
|
|
|
|
class ReleaseToolError(RuntimeError):
|
|
"""Raised when release artifacts cannot be built or trusted."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PayloadFile:
|
|
relative_path: PurePosixPath
|
|
source: Path | None
|
|
data: bytes | None
|
|
mode: int
|
|
|
|
|
|
def load_config(path: Path = CONFIG_PATH) -> dict[str, object]:
|
|
try:
|
|
value = json.loads(
|
|
path.read_text(encoding="utf-8"),
|
|
object_pairs_hook=_unique_json_object,
|
|
)
|
|
except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc:
|
|
raise ReleaseToolError(f"invalid release configuration {path}: {exc}") from exc
|
|
if not isinstance(value, dict):
|
|
raise ReleaseToolError("release configuration must be a JSON object")
|
|
for key in (
|
|
"application",
|
|
"archive_layout",
|
|
"archive_prefix",
|
|
"channels",
|
|
"config_schema",
|
|
"legacy_archive_root_prefix",
|
|
"legacy_minimum_updater_version",
|
|
"manifest_version",
|
|
"minimum_config_schema",
|
|
"minimum_updater_version",
|
|
"release_branch",
|
|
"release_status_file",
|
|
"remote",
|
|
"repository_url",
|
|
"version_source",
|
|
"wrapped_bridge_versions",
|
|
"payload",
|
|
"required_files",
|
|
"exclusions",
|
|
):
|
|
if key not in value:
|
|
raise ReleaseToolError(f"release configuration is missing {key!r}")
|
|
if value["archive_layout"] not in _ARCHIVE_LAYOUTS:
|
|
raise ReleaseToolError(
|
|
"release configuration archive_layout must be 'flat' or 'wrapped'"
|
|
)
|
|
_string_list(value["wrapped_bridge_versions"], "wrapped_bridge_versions")
|
|
return value
|
|
|
|
|
|
def _archive_layout_for_version(
|
|
config: Mapping[str, object],
|
|
version: str,
|
|
override: str | None = None,
|
|
) -> str:
|
|
if override is not None:
|
|
layout = override
|
|
elif version in _string_list(
|
|
config.get("wrapped_bridge_versions"),
|
|
"wrapped_bridge_versions",
|
|
):
|
|
layout = "wrapped"
|
|
else:
|
|
layout = _required_string(config, "archive_layout")
|
|
if layout not in _ARCHIVE_LAYOUTS:
|
|
raise ReleaseToolError(f"unsupported release archive layout: {layout!r}")
|
|
return layout
|
|
|
|
|
|
def _minimum_updater_for_layout(
|
|
config: Mapping[str, object],
|
|
archive_layout: str,
|
|
) -> str:
|
|
key = (
|
|
"legacy_minimum_updater_version"
|
|
if archive_layout == "wrapped"
|
|
else "minimum_updater_version"
|
|
)
|
|
return _required_string(config, key)
|
|
|
|
|
|
def source_version(config: Mapping[str, object]) -> str:
|
|
path = PROJECT_ROOT / _required_string(config, "version_source")
|
|
try:
|
|
contents = path.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeError) as exc:
|
|
raise ReleaseToolError(f"cannot read launcher version from {path}: {exc}") from exc
|
|
match = _SOURCE_VERSION_PATTERN.search(contents)
|
|
if match is None:
|
|
raise ReleaseToolError(f"cannot find ptlinux.__version__ in {path}")
|
|
return match.group(1)
|
|
|
|
|
|
def validate_version_mirrors(config: Mapping[str, object], expected: str) -> None:
|
|
mirrors = _string_list(config.get("version_mirrors"), "version_mirrors")
|
|
for relative in mirrors:
|
|
path = PROJECT_ROOT / relative
|
|
try:
|
|
contents = path.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeError) as exc:
|
|
raise ReleaseToolError(f"cannot read version mirror {path}: {exc}") from exc
|
|
match = _PROJECT_VERSION_PATTERN.search(contents)
|
|
if match is None or match.group(1) != expected:
|
|
actual = match.group(1) if match is not None else "missing"
|
|
raise ReleaseToolError(
|
|
f"version mirror {relative} is {actual}, expected {expected}"
|
|
)
|
|
|
|
|
|
def build_release(
|
|
version: str,
|
|
channel: str,
|
|
output_directory: Path,
|
|
*,
|
|
repository_url: str | None = None,
|
|
tag: str | None = None,
|
|
commit: str | None = None,
|
|
source_date_epoch: int | None = None,
|
|
release_notes_file: Path | None = None,
|
|
signing_key: Path | None = None,
|
|
require_signature: bool = False,
|
|
allow_source_version_override: bool = False,
|
|
archive_layout: str | None = None,
|
|
) -> tuple[Path, ...]:
|
|
from ptlinux.update_resolver import validate_channel_version
|
|
|
|
config = load_config()
|
|
parsed = validate_channel_version(channel, version)
|
|
layout = _archive_layout_for_version(config, version, archive_layout)
|
|
minimum_updater_version = _minimum_updater_for_layout(config, layout)
|
|
expected_tag = parsed.tag
|
|
if tag is not None and tag != expected_tag:
|
|
raise ReleaseToolError(f"tag {tag!r} does not match version {version}")
|
|
tag = expected_tag
|
|
current_version = source_version(config)
|
|
validate_version_mirrors(config, current_version)
|
|
if current_version != version and not allow_source_version_override:
|
|
raise ReleaseToolError(
|
|
f"requested version {version} does not match launcher source {current_version}"
|
|
)
|
|
commit = commit or _git_output("rev-parse", "HEAD")
|
|
if _COMMIT_PATTERN.fullmatch(commit) is None:
|
|
raise ReleaseToolError(f"invalid release commit: {commit!r}")
|
|
epoch = source_date_epoch
|
|
if epoch is None:
|
|
epoch_text = os.environ.get("SOURCE_DATE_EPOCH")
|
|
epoch = int(epoch_text) if epoch_text is not None else int(
|
|
_git_output("show", "-s", "--format=%ct", commit)
|
|
)
|
|
if epoch < 0:
|
|
raise ReleaseToolError("SOURCE_DATE_EPOCH cannot be negative")
|
|
repository = repository_url or _required_string(config, "repository_url")
|
|
_validate_repository_url(repository)
|
|
release_notes = _read_release_notes(release_notes_file)
|
|
output = _safe_output_directory(output_directory)
|
|
if signing_key is not None:
|
|
signing_key = _safe_signing_key_path(
|
|
signing_key,
|
|
output_directory=output,
|
|
)
|
|
_clean_output_directory(output)
|
|
archive_prefix = _required_string(config, "archive_prefix")
|
|
archive_name = f"{archive_prefix}-{version}.tar.gz"
|
|
archive = output / archive_name
|
|
with tempfile.TemporaryDirectory(prefix="ptlinux-release-build-") as temporary:
|
|
staging = Path(temporary) / "release"
|
|
stage_release(
|
|
staging,
|
|
config,
|
|
version=version,
|
|
channel=channel,
|
|
tag=tag,
|
|
commit=commit,
|
|
archive_layout=layout,
|
|
)
|
|
if current_version != version:
|
|
_set_staged_source_version(staging, config, version)
|
|
_write_deterministic_archive(
|
|
archive,
|
|
staging,
|
|
epoch,
|
|
archive_layout=layout,
|
|
archive_root=(
|
|
f"{_required_string(config, 'legacy_archive_root_prefix')}{version}"
|
|
if layout == "wrapped"
|
|
else None
|
|
),
|
|
)
|
|
checksum = archive.with_suffix(archive.suffix + ".sha256")
|
|
_write_atomic(
|
|
checksum,
|
|
f"{sha256_file(archive)} {archive.name}\n".encode("ascii"),
|
|
)
|
|
manifest = output / "manifest.json"
|
|
generate_manifest(
|
|
manifest,
|
|
config=config,
|
|
file_root=staging,
|
|
archive=archive,
|
|
version=version,
|
|
channel=channel,
|
|
tag=tag,
|
|
commit=commit,
|
|
repository_url=repository,
|
|
release_timestamp=_timestamp_from_epoch(epoch),
|
|
release_notes=release_notes,
|
|
archive_layout=layout,
|
|
minimum_updater_version=minimum_updater_version,
|
|
)
|
|
artifacts: list[Path] = [archive, checksum, manifest]
|
|
if release_notes_file is not None:
|
|
release_notes_output = output / "release-notes.md"
|
|
_write_atomic(release_notes_output, release_notes.encode("utf-8"))
|
|
artifacts.append(release_notes_output)
|
|
signature = output / "manifest.json.sig"
|
|
if signing_key is not None:
|
|
sign_file(manifest, signature, signing_key)
|
|
verify_signature(
|
|
manifest,
|
|
signature,
|
|
_payload_source_for_destination(
|
|
config,
|
|
PurePosixPath("ptlinux/launcher-update-public.pem"),
|
|
),
|
|
)
|
|
artifacts.append(signature)
|
|
elif require_signature:
|
|
raise ReleaseToolError("a manifest signing key is required")
|
|
return tuple(artifacts)
|
|
|
|
|
|
def build_legacy_launcher_update(
|
|
*,
|
|
version: str,
|
|
output_directory: Path,
|
|
repository_url: str,
|
|
minimum_updater_version: str,
|
|
signing_key: Path | None,
|
|
require_signature: bool = False,
|
|
source_date_epoch: int,
|
|
) -> tuple[Path, ...]:
|
|
"""Build the schema-1 update artifacts consumed by deployed launchers.
|
|
|
|
The names, wrapped archive layout, minimal ``release.json``, and ``stable.json``
|
|
shape are compatibility contracts. Payload selection and archive serialization
|
|
live here so the historical ``tools/build_launcher_update.py`` entry point can
|
|
remain a forwarding wrapper.
|
|
"""
|
|
|
|
_validate_legacy_version(version, "release version")
|
|
_validate_legacy_version(minimum_updater_version, "minimum updater version")
|
|
config = load_config()
|
|
current_version = source_version(config)
|
|
if current_version != version:
|
|
raise ReleaseToolError(
|
|
f"requested version {version} does not match ptlinux.__version__ "
|
|
f"{current_version}"
|
|
)
|
|
_validate_repository_url(repository_url)
|
|
if require_signature and signing_key is None:
|
|
raise ReleaseToolError("a signing key is required for a production release")
|
|
if source_date_epoch < 0:
|
|
raise ReleaseToolError("source date epoch cannot be negative")
|
|
|
|
output = output_directory.expanduser().resolve()
|
|
if signing_key is not None:
|
|
signing_key = _safe_signing_key_path(
|
|
signing_key,
|
|
output_directory=output,
|
|
)
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
archive_root = (
|
|
f"{_required_string(config, 'legacy_archive_root_prefix')}{version}"
|
|
)
|
|
archive = output / f"{archive_root}.tar.gz"
|
|
archive_temporary = output / f".{archive.name}.{os.getpid()}.tmp"
|
|
archive_temporary.unlink(missing_ok=True)
|
|
try:
|
|
_write_legacy_launcher_archive(
|
|
archive_temporary,
|
|
archive_root=archive_root,
|
|
version=version,
|
|
mtime=source_date_epoch,
|
|
payload=collect_payload(config),
|
|
)
|
|
archive_temporary.chmod(0o644)
|
|
archive_temporary.replace(archive)
|
|
except BaseException:
|
|
archive_temporary.unlink(missing_ok=True)
|
|
raise
|
|
|
|
digest = sha256_file(archive)
|
|
checksum = archive.with_suffix(archive.suffix + ".sha256")
|
|
_write_atomic(checksum, f"{digest} {archive.name}\n".encode("ascii"))
|
|
repository = repository_url.rstrip("/")
|
|
manifest = {
|
|
"archive_sha256": digest,
|
|
"archive_size": archive.stat().st_size,
|
|
"archive_url": f"{repository}/releases/download/v{version}/{archive.name}",
|
|
"channel": "stable",
|
|
"minimum_updater_version": minimum_updater_version,
|
|
"notes_url": f"{repository}/releases/tag/v{version}",
|
|
"published_at": _timestamp_from_epoch(source_date_epoch),
|
|
"schema": 1,
|
|
"version": version,
|
|
}
|
|
manifest_path = output / "stable.json"
|
|
manifest_bytes = (
|
|
json.dumps(manifest, indent=2, sort_keys=True, separators=(",", ": ")) + "\n"
|
|
).encode("utf-8")
|
|
_write_atomic(manifest_path, manifest_bytes)
|
|
signature_path = output / "stable.json.sig"
|
|
artifacts: list[Path] = [archive, checksum, manifest_path]
|
|
if signing_key is not None:
|
|
sign_file(manifest_path, signature_path, signing_key)
|
|
public_key = _payload_source_for_destination(
|
|
config,
|
|
PurePosixPath("ptlinux/launcher-update-public.pem"),
|
|
)
|
|
try:
|
|
verify_signature(manifest_path, signature_path, public_key)
|
|
except ReleaseToolError as exc:
|
|
signature_path.unlink(missing_ok=True)
|
|
raise ReleaseToolError(
|
|
"the signing key does not match ptlinux/launcher-update-public.pem"
|
|
) from exc
|
|
artifacts.append(signature_path)
|
|
else:
|
|
signature_path.unlink(missing_ok=True)
|
|
print(
|
|
"Warning: stable.json was not signed; set PTLINUX_SIGNING_KEY for a "
|
|
"publishable release.",
|
|
file=sys.stderr,
|
|
)
|
|
return tuple(artifacts)
|
|
|
|
|
|
def stage_release(
|
|
destination: Path,
|
|
config: Mapping[str, object],
|
|
*,
|
|
version: str,
|
|
channel: str,
|
|
tag: str,
|
|
commit: str,
|
|
archive_layout: str,
|
|
) -> None:
|
|
if destination.exists():
|
|
raise ReleaseToolError(f"staging destination already exists: {destination}")
|
|
destination.mkdir(parents=True, mode=0o755)
|
|
payload = collect_payload(config)
|
|
config_schema, minimum_config_schema = _config_schema_range(config)
|
|
release_metadata = (
|
|
json.dumps(
|
|
{
|
|
"channel": channel,
|
|
"commit": commit,
|
|
"archive_layout": archive_layout,
|
|
"config_schema": config_schema,
|
|
"minimum_config_schema": minimum_config_schema,
|
|
"schema": 1,
|
|
"tag": tag,
|
|
"version": version,
|
|
},
|
|
indent=2,
|
|
sort_keys=True,
|
|
)
|
|
+ "\n"
|
|
).encode("utf-8")
|
|
payload.append(
|
|
PayloadFile(PurePosixPath("release.json"), None, release_metadata, 0o644)
|
|
)
|
|
_stage_payload_files(destination, payload)
|
|
_require_staged_files(destination, config)
|
|
|
|
|
|
def stage_runtime_payload(
|
|
destination: Path,
|
|
config: Mapping[str, object] | None = None,
|
|
*,
|
|
source_root: Path | None = None,
|
|
) -> None:
|
|
"""Stage the runtime files shared by installers and launcher updates.
|
|
|
|
`release.json` is generated by the caller because bootstrap installers and
|
|
signed launcher updates attach different source metadata to that file.
|
|
"""
|
|
|
|
release_config = load_config() if config is None else config
|
|
_stage_payload_files(
|
|
destination,
|
|
collect_payload(release_config, source_root=source_root),
|
|
)
|
|
_require_staged_files(
|
|
destination,
|
|
release_config,
|
|
allowed_missing={"release.json"},
|
|
)
|
|
|
|
|
|
def _stage_payload_files(
|
|
destination: Path,
|
|
payload: Sequence[PayloadFile],
|
|
) -> None:
|
|
seen: set[PurePosixPath] = set()
|
|
for item in sorted(payload, key=lambda entry: entry.relative_path.as_posix()):
|
|
if item.relative_path in seen:
|
|
raise ReleaseToolError(f"duplicate payload path: {item.relative_path}")
|
|
seen.add(item.relative_path)
|
|
target = destination.joinpath(*item.relative_path.parts)
|
|
target.parent.mkdir(parents=True, exist_ok=True, mode=0o755)
|
|
if item.source is not None:
|
|
data = item.source.read_bytes()
|
|
else:
|
|
assert item.data is not None
|
|
data = item.data
|
|
target.write_bytes(data)
|
|
target.chmod(item.mode)
|
|
for directory in (destination, *destination.rglob("*")):
|
|
if directory.is_dir():
|
|
directory.chmod(0o755)
|
|
|
|
|
|
def _require_staged_files(
|
|
destination: Path,
|
|
config: Mapping[str, object],
|
|
*,
|
|
allowed_missing: set[str] | None = None,
|
|
) -> None:
|
|
missing_allowed = set() if allowed_missing is None else allowed_missing
|
|
for required in _string_list(config.get("required_files"), "required_files"):
|
|
if required in missing_allowed:
|
|
continue
|
|
if not (destination / required).is_file():
|
|
raise ReleaseToolError(f"staging tree is missing required file: {required}")
|
|
|
|
|
|
def collect_payload(
|
|
config: Mapping[str, object],
|
|
*,
|
|
source_root: Path | None = None,
|
|
) -> list[PayloadFile]:
|
|
payload_config = config.get("payload")
|
|
if not isinstance(payload_config, dict):
|
|
raise ReleaseToolError("release payload configuration must be an object")
|
|
result: list[PayloadFile] = []
|
|
python_trees = payload_config.get("python_trees")
|
|
if not isinstance(python_trees, list):
|
|
raise ReleaseToolError("payload.python_trees must be an array")
|
|
for tree in python_trees:
|
|
if not isinstance(tree, dict):
|
|
raise ReleaseToolError("payload python tree must be an object")
|
|
source_name = _required_string(tree, "source")
|
|
source_relative = _safe_relative_path(source_name)
|
|
destination_name = tree.get("destination", source_name)
|
|
if not isinstance(destination_name, str) or not destination_name:
|
|
raise ReleaseToolError("payload python tree destination must be a string")
|
|
destination_relative = _safe_relative_path(destination_name)
|
|
tree_root = _payload_source_path(
|
|
source_relative,
|
|
source_root,
|
|
packaged_relative=destination_relative,
|
|
)
|
|
if not tree_root.is_dir() or tree_root.is_symlink():
|
|
raise ReleaseToolError(f"payload source tree is invalid: {source_name}")
|
|
exclusions = set(_string_list(tree.get("exclude", []), "payload exclude"))
|
|
for source in sorted(tree_root.rglob("*.py")):
|
|
relative_in_tree = source.relative_to(tree_root).as_posix()
|
|
if relative_in_tree in exclusions:
|
|
continue
|
|
_validate_source_file(source)
|
|
result.append(
|
|
PayloadFile(
|
|
destination_relative / PurePosixPath(relative_in_tree),
|
|
source,
|
|
None,
|
|
0o644,
|
|
)
|
|
)
|
|
configured_files = payload_config.get("files")
|
|
if not isinstance(configured_files, list):
|
|
raise ReleaseToolError("payload.files must be an array")
|
|
for file_config in configured_files:
|
|
if not isinstance(file_config, dict):
|
|
raise ReleaseToolError("payload file entry must be an object")
|
|
legacy_path = file_config.get("path")
|
|
if legacy_path is not None:
|
|
if "source" in file_config or "destination" in file_config:
|
|
raise ReleaseToolError(
|
|
"payload file entry cannot combine path with source or destination"
|
|
)
|
|
path_text = _required_string(file_config, "path")
|
|
source_relative = _safe_relative_path(path_text)
|
|
destination_relative = source_relative
|
|
else:
|
|
source_relative = _safe_relative_path(
|
|
_required_string(file_config, "source")
|
|
)
|
|
destination_relative = _safe_relative_path(
|
|
_required_string(file_config, "destination")
|
|
)
|
|
source = _payload_source_path(
|
|
source_relative,
|
|
source_root,
|
|
packaged_relative=destination_relative,
|
|
)
|
|
_validate_source_file(source)
|
|
mode_text = _required_string(file_config, "mode")
|
|
try:
|
|
mode = int(mode_text, 8)
|
|
except ValueError as exc:
|
|
raise ReleaseToolError(f"invalid payload mode {mode_text!r}") from exc
|
|
if mode not in {0o644, 0o755}:
|
|
raise ReleaseToolError(f"unsupported payload mode: {mode_text}")
|
|
result.append(PayloadFile(destination_relative, source, None, mode))
|
|
return result
|
|
|
|
|
|
def _payload_source_path(
|
|
relative: PurePosixPath,
|
|
source_root: Path | None,
|
|
*,
|
|
packaged_relative: PurePosixPath | None = None,
|
|
) -> Path:
|
|
if source_root is None:
|
|
return PROJECT_ROOT.joinpath(*relative.parts)
|
|
candidates = [source_root.joinpath(*relative.parts)]
|
|
if relative.parts[:1] == ("src",):
|
|
candidates.append(source_root.joinpath(*relative.parts[1:]))
|
|
if packaged_relative is not None:
|
|
candidates.append(source_root.joinpath(*packaged_relative.parts))
|
|
for candidate in candidates:
|
|
if candidate.exists():
|
|
return candidate
|
|
return candidates[0]
|
|
|
|
|
|
def generate_manifest(
|
|
destination: Path,
|
|
*,
|
|
config: Mapping[str, object],
|
|
file_root: Path,
|
|
archive: Path,
|
|
version: str,
|
|
channel: str,
|
|
tag: str,
|
|
commit: str,
|
|
repository_url: str,
|
|
release_timestamp: str,
|
|
release_notes: str = "",
|
|
release_title: str | None = None,
|
|
release_notes_url: str | None = None,
|
|
archive_layout: str | None = None,
|
|
minimum_updater_version: str | None = None,
|
|
) -> dict[str, object]:
|
|
from ptlinux.update_resolver import validate_channel_version
|
|
|
|
parsed = validate_channel_version(channel, version)
|
|
layout = _archive_layout_for_version(config, version, archive_layout)
|
|
if tag != parsed.tag:
|
|
raise ReleaseToolError(f"tag {tag!r} does not match version {version}")
|
|
if _COMMIT_PATTERN.fullmatch(commit) is None:
|
|
raise ReleaseToolError(f"invalid release commit: {commit!r}")
|
|
if not file_root.is_dir() or file_root.is_symlink():
|
|
raise ReleaseToolError(f"manifest file root is invalid: {file_root}")
|
|
if not archive.is_file() or archive.is_symlink():
|
|
raise ReleaseToolError(f"release archive is missing: {archive}")
|
|
_validate_repository_url(repository_url)
|
|
_parse_timestamp(release_timestamp)
|
|
repository = repository_url.rstrip("/")
|
|
digest = sha256_file(archive)
|
|
# Gitea release URLs exist before their attachments do. A CDN can cache that
|
|
# first empty response under the otherwise immutable asset URL, then keep
|
|
# serving it after the real archive is uploaded. The signed content digest
|
|
# gives the completed asset its own cache key without weakening verification.
|
|
archive_url = (
|
|
f"{repository}/releases/download/{tag}/{archive.name}?sha256={digest}"
|
|
)
|
|
notes_url = release_notes_url or f"{repository}/releases/tag/{tag}"
|
|
files: list[dict[str, object]] = []
|
|
for path in sorted(file_root.rglob("*"), key=lambda item: item.relative_to(file_root).as_posix()):
|
|
if path.is_symlink():
|
|
raise ReleaseToolError(f"staging tree contains a symbolic link: {path}")
|
|
if not path.is_file():
|
|
continue
|
|
relative = path.relative_to(file_root).as_posix()
|
|
files.append(
|
|
{
|
|
"path": relative,
|
|
"sha256": sha256_file(path),
|
|
"size": path.stat().st_size,
|
|
}
|
|
)
|
|
size = archive.stat().st_size
|
|
title = release_title or f"PristonTale EU Launcher {version}"
|
|
summary = _release_summary(release_notes, version)
|
|
embedded_release_notes = release_notes or f"PristonTale EU Launcher {version}.\n"
|
|
if len(embedded_release_notes) > MAX_EMBEDDED_RELEASE_NOTES_CHARACTERS:
|
|
embedded_release_notes = (
|
|
embedded_release_notes[:MAX_EMBEDDED_RELEASE_NOTES_CHARACTERS].rstrip()
|
|
+ "\n\nRelease notes continue at the signed release URL.\n"
|
|
)
|
|
minimum = minimum_updater_version or _minimum_updater_for_layout(config, layout)
|
|
manifest_schema = _required_integer(config, "manifest_version")
|
|
config_schema, minimum_config_schema = _config_schema_range(config)
|
|
manifest: dict[str, object] = {
|
|
"application": _required_string(config, "application"),
|
|
"archive": {
|
|
"layout": layout,
|
|
"name": archive.name,
|
|
"sha256": digest,
|
|
"size": size,
|
|
"url": archive_url,
|
|
},
|
|
# Legacy schema-1 fields remain during migration for deployed stable clients.
|
|
"archive_sha256": digest,
|
|
"archive_size": size,
|
|
"archive_url": archive_url,
|
|
"channel": channel,
|
|
"commit": commit,
|
|
"compatibility": {
|
|
"config_schema": config_schema,
|
|
"manifest_schema": manifest_schema,
|
|
"minimum_config_schema": minimum_config_schema,
|
|
"minimum_updater_version": minimum,
|
|
},
|
|
"files": files,
|
|
"minimum_updater_version": minimum,
|
|
"notes_url": notes_url,
|
|
"published_at": release_timestamp,
|
|
"release": {
|
|
"notes_markdown": embedded_release_notes,
|
|
"release_notes_url": notes_url,
|
|
"summary": summary,
|
|
"title": title,
|
|
},
|
|
"schema": manifest_schema,
|
|
"status": "active",
|
|
"tag": tag,
|
|
"version": version,
|
|
}
|
|
_write_atomic(
|
|
destination,
|
|
(json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode("utf-8"),
|
|
)
|
|
return manifest
|
|
|
|
|
|
def verify_release(
|
|
version: str,
|
|
channel: str,
|
|
output_directory: Path,
|
|
*,
|
|
public_key: Path | None = None,
|
|
require_signature: bool = False,
|
|
) -> None:
|
|
from ptlinux.update_resolver import validate_channel_version
|
|
|
|
config = load_config()
|
|
parsed = validate_channel_version(channel, version)
|
|
output = output_directory.expanduser().resolve()
|
|
archive = output / f"{_required_string(config, 'archive_prefix')}-{version}.tar.gz"
|
|
checksum = archive.with_suffix(archive.suffix + ".sha256")
|
|
manifest_path = output / "manifest.json"
|
|
for required in (archive, checksum, manifest_path):
|
|
if not required.is_file() or required.is_symlink():
|
|
raise ReleaseToolError(f"required release artifact is missing: {required}")
|
|
if archive.stat().st_size <= 0 or archive.stat().st_size > MAX_ARCHIVE_BYTES:
|
|
raise ReleaseToolError("release archive has an invalid size")
|
|
checksum_text = checksum.read_text(encoding="ascii")
|
|
expected_checksum = f"{sha256_file(archive)} {archive.name}\n"
|
|
if checksum_text != expected_checksum:
|
|
raise ReleaseToolError("archive checksum file does not match the archive")
|
|
manifest = _read_json_object(manifest_path)
|
|
archive_layout = _validate_manifest_artifact(
|
|
manifest,
|
|
config=config,
|
|
version=version,
|
|
channel=channel,
|
|
tag=parsed.tag,
|
|
archive=archive,
|
|
)
|
|
manifest_files = _manifest_files(manifest)
|
|
with tempfile.TemporaryDirectory(prefix="ptlinux-release-verify-") as temporary:
|
|
extracted = Path(temporary) / "release"
|
|
_extract_verified_archive(
|
|
archive,
|
|
extracted,
|
|
archive_layout=archive_layout,
|
|
archive_root=(
|
|
f"{_required_string(config, 'legacy_archive_root_prefix')}{version}"
|
|
if archive_layout == "wrapped"
|
|
else None
|
|
),
|
|
manifest_files=manifest_files,
|
|
config=config,
|
|
)
|
|
for required in _string_list(config.get("required_files"), "required_files"):
|
|
if not (extracted / required).is_file():
|
|
raise ReleaseToolError(f"release archive is missing required file: {required}")
|
|
packaged_version = _version_from_source_file(
|
|
extracted / _packaged_version_path(config)
|
|
)
|
|
if packaged_version != version:
|
|
raise ReleaseToolError(
|
|
f"packaged launcher version is {packaged_version}, expected {version}"
|
|
)
|
|
release_metadata = _read_json_object(extracted / "release.json")
|
|
for key, expected in (
|
|
("version", version),
|
|
("channel", channel),
|
|
("tag", parsed.tag),
|
|
("archive_layout", archive_layout),
|
|
):
|
|
if release_metadata.get(key) != expected:
|
|
raise ReleaseToolError(
|
|
f"release.json {key} does not match the release: "
|
|
f"{release_metadata.get(key)!r}"
|
|
)
|
|
signature = output / "manifest.json.sig"
|
|
if signature.exists():
|
|
if not signature.is_file() or signature.is_symlink():
|
|
raise ReleaseToolError("manifest signature is not a regular file")
|
|
verify_signature(
|
|
manifest_path,
|
|
signature,
|
|
public_key or _payload_source_for_destination(
|
|
config,
|
|
PurePosixPath("ptlinux/launcher-update-public.pem"),
|
|
),
|
|
)
|
|
elif require_signature:
|
|
raise ReleaseToolError("manifest signature is required but missing")
|
|
|
|
|
|
def _safe_signing_key_path(
|
|
private_key: Path,
|
|
*,
|
|
output_directory: Path | None = None,
|
|
) -> Path:
|
|
"""Validate that a private key is external, private, and a regular file."""
|
|
|
|
candidate = private_key.expanduser().absolute()
|
|
if candidate.is_symlink():
|
|
raise ReleaseToolError(
|
|
f"release signing key cannot be a symbolic link: {candidate}"
|
|
)
|
|
resolved = candidate.resolve()
|
|
location_pairs = (
|
|
(candidate, PROJECT_ROOT.absolute()),
|
|
(resolved, PROJECT_ROOT.resolve()),
|
|
(candidate, ARTIFACTS_ROOT.absolute()),
|
|
(resolved, ARTIFACTS_ROOT.resolve()),
|
|
)
|
|
if any(path == root or path.is_relative_to(root) for path, root in location_pairs):
|
|
raise ReleaseToolError(
|
|
"release signing key must be outside the repository and .artifacts: "
|
|
f"{candidate}"
|
|
)
|
|
if output_directory is not None:
|
|
output = output_directory.expanduser().resolve()
|
|
if resolved == output or resolved.is_relative_to(output):
|
|
raise ReleaseToolError(
|
|
f"release signing key must be outside release output: {candidate}"
|
|
)
|
|
if not resolved.is_file():
|
|
raise ReleaseToolError(f"release signing key is missing: {candidate}")
|
|
if resolved.stat().st_mode & 0o077:
|
|
raise ReleaseToolError(
|
|
"release signing key must not be accessible by group or other users: "
|
|
f"{candidate}"
|
|
)
|
|
return resolved
|
|
|
|
|
|
def sign_file(source: Path, signature: Path, private_key: Path) -> None:
|
|
key = _safe_signing_key_path(private_key)
|
|
openssl = shutil.which("openssl")
|
|
if openssl is None:
|
|
raise ReleaseToolError("OpenSSL is required to sign release manifests")
|
|
temporary = signature.with_name(f".{signature.name}.{os.getpid()}.tmp")
|
|
temporary.unlink(missing_ok=True)
|
|
commands = (
|
|
[openssl, "dgst", "-sha256", "-sign", str(key), "-out", str(temporary), str(source)],
|
|
[openssl, "pkeyutl", "-sign", "-rawin", "-inkey", str(key), "-in", str(source), "-out", str(temporary)],
|
|
)
|
|
last_error = ""
|
|
try:
|
|
for command in commands:
|
|
temporary.unlink(missing_ok=True)
|
|
result = subprocess.run(
|
|
command,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=30,
|
|
check=False,
|
|
)
|
|
if result.returncode == 0 and temporary.is_file():
|
|
temporary.chmod(0o644)
|
|
os.replace(temporary, signature)
|
|
return
|
|
last_error = result.stderr.strip()
|
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
last_error = str(exc)
|
|
finally:
|
|
temporary.unlink(missing_ok=True)
|
|
raise ReleaseToolError(f"OpenSSL could not sign manifest: {last_error}")
|
|
|
|
|
|
def verify_signature(source: Path, signature: Path, public_key: Path) -> None:
|
|
key = public_key.expanduser().resolve()
|
|
if not key.is_file() or key.is_symlink():
|
|
raise ReleaseToolError(f"manifest public key is missing: {key}")
|
|
openssl = shutil.which("openssl")
|
|
if openssl is None:
|
|
raise ReleaseToolError("OpenSSL is required to verify release manifests")
|
|
commands = (
|
|
[
|
|
openssl,
|
|
"dgst",
|
|
"-sha256",
|
|
"-verify",
|
|
str(key),
|
|
"-signature",
|
|
str(signature),
|
|
str(source),
|
|
],
|
|
[
|
|
openssl,
|
|
"pkeyutl",
|
|
"-verify",
|
|
"-rawin",
|
|
"-pubin",
|
|
"-inkey",
|
|
str(key),
|
|
"-sigfile",
|
|
str(signature),
|
|
"-in",
|
|
str(source),
|
|
],
|
|
)
|
|
for command in commands:
|
|
try:
|
|
result = subprocess.run(
|
|
command,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=20,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
continue
|
|
if result.returncode == 0:
|
|
return
|
|
raise ReleaseToolError("manifest signature verification failed")
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(READ_SIZE), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _write_deterministic_archive(
|
|
destination: Path,
|
|
staging: Path,
|
|
mtime: int,
|
|
*,
|
|
archive_layout: str,
|
|
archive_root: str | None,
|
|
) -> None:
|
|
if archive_layout not in _ARCHIVE_LAYOUTS:
|
|
raise ReleaseToolError(f"unsupported release archive layout: {archive_layout!r}")
|
|
if archive_layout == "wrapped" and not archive_root:
|
|
raise ReleaseToolError("wrapped release archive requires a root directory")
|
|
if archive_layout == "flat" and archive_root is not None:
|
|
raise ReleaseToolError("flat release archive cannot have a root directory")
|
|
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
|
|
temporary.unlink(missing_ok=True)
|
|
try:
|
|
with temporary.open("xb") as raw:
|
|
with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=mtime) as compressed:
|
|
with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as bundle:
|
|
if archive_root is not None:
|
|
information = _tar_info(archive_root, mtime, 0o755)
|
|
information.type = b"5"
|
|
bundle.addfile(information)
|
|
paths = list(staging.rglob("*"))
|
|
paths.sort(key=lambda item: item.relative_to(staging).as_posix())
|
|
for path in paths:
|
|
relative = path.relative_to(staging).as_posix()
|
|
archive_name = (
|
|
f"{archive_root}/{relative}"
|
|
if archive_root is not None
|
|
else relative
|
|
)
|
|
if path.is_symlink():
|
|
raise ReleaseToolError(f"staging tree contains a symlink: {path}")
|
|
if path.is_dir():
|
|
information = _tar_info(archive_name, mtime, 0o755)
|
|
information.type = b"5"
|
|
bundle.addfile(information)
|
|
elif path.is_file():
|
|
mode = stat.S_IMODE(path.stat().st_mode)
|
|
with path.open("rb") as handle:
|
|
_add_tar_file(
|
|
bundle,
|
|
information_name=archive_name,
|
|
handle=handle,
|
|
size=path.stat().st_size,
|
|
mtime=mtime,
|
|
mode=mode,
|
|
)
|
|
else:
|
|
raise ReleaseToolError(
|
|
f"staging tree contains a special file: {path}"
|
|
)
|
|
temporary.chmod(0o644)
|
|
os.replace(temporary, destination)
|
|
finally:
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
|
|
def _write_legacy_launcher_archive(
|
|
destination: Path,
|
|
*,
|
|
archive_root: str,
|
|
version: str,
|
|
mtime: int,
|
|
payload: Sequence[PayloadFile],
|
|
) -> None:
|
|
"""Write the original wrapped launcher archive byte-for-byte deterministically."""
|
|
|
|
metadata = (
|
|
json.dumps({"schema": 1, "version": version}, indent=2, sort_keys=True) + "\n"
|
|
).encode("utf-8")
|
|
with destination.open("xb") as raw:
|
|
with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=mtime) as compressed:
|
|
with tarfile.open(
|
|
fileobj=compressed,
|
|
mode="w",
|
|
format=tarfile.PAX_FORMAT,
|
|
) as bundle:
|
|
for directory in (
|
|
archive_root,
|
|
f"{archive_root}/ptdiag",
|
|
f"{archive_root}/ptlinux",
|
|
f"{archive_root}/ptlinux/native",
|
|
):
|
|
information = _tar_info(directory, mtime, 0o755)
|
|
information.type = b"5"
|
|
bundle.addfile(information)
|
|
with io.BytesIO(metadata) as handle:
|
|
_add_tar_file(
|
|
bundle,
|
|
information_name=f"{archive_root}/release.json",
|
|
handle=handle,
|
|
size=len(metadata),
|
|
mtime=mtime,
|
|
mode=0o644,
|
|
)
|
|
for item in sorted(
|
|
payload,
|
|
key=lambda entry: entry.relative_path.as_posix(),
|
|
):
|
|
if item.source is None:
|
|
raise ReleaseToolError(
|
|
f"legacy payload has no source: {item.relative_path}"
|
|
)
|
|
with item.source.open("rb") as handle:
|
|
_add_tar_file(
|
|
bundle,
|
|
information_name=(
|
|
f"{archive_root}/{item.relative_path.as_posix()}"
|
|
),
|
|
handle=handle,
|
|
size=item.source.stat().st_size,
|
|
mtime=mtime,
|
|
mode=item.mode,
|
|
)
|
|
|
|
|
|
def _tar_info(name: str, mtime: int, mode: int) -> tarfile.TarInfo:
|
|
information = tarfile.TarInfo(name)
|
|
information.uid = 0
|
|
information.gid = 0
|
|
information.uname = ""
|
|
information.gname = ""
|
|
information.mtime = mtime
|
|
information.mode = mode
|
|
return information
|
|
|
|
|
|
def _add_tar_file(
|
|
bundle: tarfile.TarFile,
|
|
*,
|
|
information_name: str,
|
|
handle: BinaryIO,
|
|
size: int,
|
|
mtime: int,
|
|
mode: int,
|
|
) -> None:
|
|
information = _tar_info(information_name, mtime, mode)
|
|
information.size = size
|
|
bundle.addfile(information, handle)
|
|
|
|
|
|
def _validate_manifest_artifact(
|
|
manifest: Mapping[str, object],
|
|
*,
|
|
config: Mapping[str, object],
|
|
version: str,
|
|
channel: str,
|
|
tag: str,
|
|
archive: Path,
|
|
) -> str:
|
|
expected_archive_path = f"/{tag}/{archive.name}"
|
|
expected = {
|
|
"schema": _required_integer(config, "manifest_version"),
|
|
"application": _required_string(config, "application"),
|
|
"version": version,
|
|
"channel": channel,
|
|
"tag": tag,
|
|
"status": "active",
|
|
}
|
|
for key in ("schema", "application", "version", "channel", "tag", "status"):
|
|
if manifest.get(key) != expected[key]:
|
|
raise ReleaseToolError(
|
|
f"manifest {key} is {manifest.get(key)!r}, expected {expected[key]!r}"
|
|
)
|
|
commit = manifest.get("commit")
|
|
if not isinstance(commit, str) or _COMMIT_PATTERN.fullmatch(commit) is None:
|
|
raise ReleaseToolError("manifest commit is invalid")
|
|
published_at = manifest.get("published_at")
|
|
if not isinstance(published_at, str):
|
|
raise ReleaseToolError("manifest published_at is invalid")
|
|
_parse_timestamp(published_at)
|
|
digest = sha256_file(archive)
|
|
size = archive.stat().st_size
|
|
if manifest.get("archive_sha256") != digest or manifest.get("archive_size") != size:
|
|
raise ReleaseToolError("legacy manifest archive metadata does not match")
|
|
archive_url = manifest.get("archive_url")
|
|
archive_parts = urlsplit(archive_url) if isinstance(archive_url, str) else None
|
|
if (
|
|
archive_parts is None
|
|
or archive_parts.scheme.casefold() != "https"
|
|
or not archive_parts.netloc
|
|
or archive_parts.path.endswith(expected_archive_path) is False
|
|
or archive_parts.query not in {"", f"sha256={digest}"}
|
|
or archive_parts.fragment
|
|
):
|
|
raise ReleaseToolError("manifest archive URL does not match tag and filename")
|
|
archive_value = manifest.get("archive")
|
|
if not isinstance(archive_value, dict):
|
|
raise ReleaseToolError("manifest archive metadata must be an object")
|
|
archive_layout = archive_value.get("layout", "wrapped")
|
|
if archive_layout not in _ARCHIVE_LAYOUTS:
|
|
raise ReleaseToolError("manifest archive layout is invalid")
|
|
if (
|
|
archive_value.get("name") != archive.name
|
|
or archive_value.get("size") != size
|
|
or archive_value.get("sha256") != digest
|
|
or archive_value.get("url") != archive_url
|
|
):
|
|
raise ReleaseToolError("manifest archive object does not match the archive")
|
|
expected_minimum = _minimum_updater_for_layout(config, archive_layout)
|
|
if manifest.get("minimum_updater_version") != expected_minimum:
|
|
raise ReleaseToolError("manifest minimum updater does not match archive layout")
|
|
compatibility = manifest.get("compatibility")
|
|
if (
|
|
not isinstance(compatibility, dict)
|
|
or compatibility.get("minimum_updater_version") != expected_minimum
|
|
):
|
|
raise ReleaseToolError(
|
|
"manifest compatibility minimum updater does not match archive layout"
|
|
)
|
|
release = manifest.get("release")
|
|
if not isinstance(release, dict):
|
|
raise ReleaseToolError("manifest release metadata must be an object")
|
|
for key in ("title", "summary", "release_notes_url", "notes_markdown"):
|
|
if not isinstance(release.get(key), str) or not release[key]:
|
|
raise ReleaseToolError(f"manifest release.{key} must be a non-empty string")
|
|
return archive_layout
|
|
|
|
|
|
def _manifest_files(manifest: Mapping[str, object]) -> dict[str, tuple[int, str]]:
|
|
value = manifest.get("files")
|
|
if (
|
|
not isinstance(value, list)
|
|
or not value
|
|
or len(value) > MAX_ARCHIVE_MEMBERS
|
|
):
|
|
raise ReleaseToolError("manifest files must be a non-empty array")
|
|
result: dict[str, tuple[int, str]] = {}
|
|
last_path = ""
|
|
for entry in value:
|
|
if not isinstance(entry, dict):
|
|
raise ReleaseToolError("manifest file entry must be an object")
|
|
path = entry.get("path")
|
|
size = entry.get("size")
|
|
digest = entry.get("sha256")
|
|
if not isinstance(path, str):
|
|
raise ReleaseToolError("manifest file path must be a string")
|
|
_safe_relative_path(path)
|
|
if path <= last_path or path in result:
|
|
raise ReleaseToolError("manifest files must be uniquely sorted by path")
|
|
if isinstance(size, bool) or not isinstance(size, int) or size < 0:
|
|
raise ReleaseToolError(f"manifest file size is invalid: {path}")
|
|
if not isinstance(digest, str) or _SHA256_PATTERN.fullmatch(digest) is None:
|
|
raise ReleaseToolError(f"manifest file SHA-256 is invalid: {path}")
|
|
result[path] = size, digest
|
|
last_path = path
|
|
return result
|
|
|
|
|
|
def _extract_verified_archive(
|
|
archive: Path,
|
|
destination: Path,
|
|
*,
|
|
archive_layout: str,
|
|
archive_root: str | None,
|
|
manifest_files: Mapping[str, tuple[int, str]],
|
|
config: Mapping[str, object],
|
|
) -> None:
|
|
if archive_layout not in _ARCHIVE_LAYOUTS:
|
|
raise ReleaseToolError(f"unsupported release archive layout: {archive_layout!r}")
|
|
if archive_layout == "wrapped" and not archive_root:
|
|
raise ReleaseToolError("wrapped release archive requires a root directory")
|
|
extracted_files: set[str] = set()
|
|
seen_paths: set[PurePosixPath] = set()
|
|
manifest_directories = {
|
|
parent.as_posix()
|
|
for name in manifest_files
|
|
for parent in PurePosixPath(name).parents
|
|
if parent.parts
|
|
}
|
|
total_size = 0
|
|
try:
|
|
bundle = tarfile.open(archive, "r:gz")
|
|
except (OSError, tarfile.TarError) as exc:
|
|
raise ReleaseToolError(f"archive cannot be opened: {exc}") from exc
|
|
with bundle:
|
|
members = bundle.getmembers()
|
|
if not members or len(members) > MAX_ARCHIVE_MEMBERS:
|
|
raise ReleaseToolError("archive has an invalid member count")
|
|
for member in members:
|
|
name = (
|
|
member.name[:-1]
|
|
if member.isdir() and member.name.endswith("/")
|
|
else member.name
|
|
)
|
|
raw_parts = name.split("/")
|
|
if (
|
|
not name
|
|
or "\\" in name
|
|
or "\x00" in name
|
|
or any(part in {"", ".", ".."} for part in raw_parts)
|
|
):
|
|
raise ReleaseToolError(f"archive contains unsafe path: {member.name!r}")
|
|
path = PurePosixPath(name)
|
|
if path.is_absolute() or path in seen_paths:
|
|
raise ReleaseToolError(
|
|
f"archive contains unsafe or duplicate path: {member.name!r}"
|
|
)
|
|
seen_paths.add(path)
|
|
if archive_layout == "wrapped":
|
|
if path.parts[0] != archive_root:
|
|
raise ReleaseToolError(
|
|
f"archive member is outside {archive_root}: {member.name}"
|
|
)
|
|
relative = PurePosixPath(*path.parts[1:])
|
|
if not relative.parts:
|
|
if not member.isdir():
|
|
raise ReleaseToolError("archive root must be a directory")
|
|
root_mode = member.mode & 0o777
|
|
if root_mode != 0o755:
|
|
raise ReleaseToolError("archive root has unsafe permissions")
|
|
continue
|
|
else:
|
|
relative = path
|
|
if member.issym() or member.islnk() or not (member.isdir() or member.isfile()):
|
|
raise ReleaseToolError(f"archive contains unsupported member: {member.name}")
|
|
if relative.parts[0] not in _ALLOWED_ARCHIVE_ROOTS:
|
|
raise ReleaseToolError(f"archive contains unexpected root path: {relative}")
|
|
if relative.parts[0] == "release.json" and (
|
|
len(relative.parts) != 1 or not member.isfile()
|
|
):
|
|
raise ReleaseToolError(
|
|
f"archive contains invalid release metadata path: {relative}"
|
|
)
|
|
if (
|
|
len(relative.parts) == 1
|
|
and relative.parts[0] in {"ptlinux", "ptdiag"}
|
|
and not member.isdir()
|
|
):
|
|
raise ReleaseToolError(
|
|
f"archive package root must be a directory: {relative}"
|
|
)
|
|
mode = member.mode & 0o777
|
|
if mode & 0o022 or mode not in {0o644, 0o755}:
|
|
raise ReleaseToolError(f"archive member has unsafe permissions: {member.name}")
|
|
_forbid_release_path(relative, config)
|
|
target = destination.joinpath(*relative.parts)
|
|
if member.isdir():
|
|
if relative.as_posix() not in manifest_directories:
|
|
raise ReleaseToolError(
|
|
f"archive directory is absent from manifest paths: {relative}"
|
|
)
|
|
target.mkdir(parents=True, exist_ok=True, mode=0o755)
|
|
continue
|
|
if relative.as_posix() in extracted_files:
|
|
raise ReleaseToolError(f"archive contains duplicate path: {relative}")
|
|
expected = manifest_files.get(relative.as_posix())
|
|
if expected is None:
|
|
raise ReleaseToolError(f"archive file is absent from manifest: {relative}")
|
|
if member.size != expected[0] or member.size < 0:
|
|
raise ReleaseToolError(f"archive file size differs from manifest: {relative}")
|
|
total_size += member.size
|
|
if total_size > MAX_EXTRACTED_BYTES:
|
|
raise ReleaseToolError("archive exceeds the extraction size limit")
|
|
source = bundle.extractfile(member)
|
|
if source is None:
|
|
raise ReleaseToolError(f"archive file cannot be read: {relative}")
|
|
data = source.read(member.size + 1)
|
|
if len(data) != member.size:
|
|
raise ReleaseToolError(f"archive file is truncated: {relative}")
|
|
if hashlib.sha256(data).hexdigest() != expected[1]:
|
|
raise ReleaseToolError(f"archive file hash differs from manifest: {relative}")
|
|
target.parent.mkdir(parents=True, exist_ok=True, mode=0o755)
|
|
target.write_bytes(data)
|
|
target.chmod(mode)
|
|
extracted_files.add(relative.as_posix())
|
|
if extracted_files != set(manifest_files):
|
|
missing = sorted(set(manifest_files).difference(extracted_files))
|
|
raise ReleaseToolError(f"manifest files are absent from archive: {', '.join(missing)}")
|
|
|
|
|
|
def _forbid_release_path(path: PurePosixPath, config: Mapping[str, object]) -> None:
|
|
text = path.as_posix()
|
|
allowed_pem = set(_string_list(config.get("allowed_pem_files", []), "allowed_pem_files"))
|
|
if text in allowed_pem:
|
|
return
|
|
exclusions = _string_list(config.get("exclusions"), "exclusions")
|
|
for pattern in exclusions:
|
|
if (
|
|
fnmatch.fnmatch(text, pattern)
|
|
or fnmatch.fnmatch(path.name, pattern)
|
|
or pattern in path.parts
|
|
):
|
|
raise ReleaseToolError(f"archive contains forbidden path: {text}")
|
|
|
|
|
|
def _safe_output_directory(path: Path) -> Path:
|
|
expanded = path.expanduser()
|
|
if expanded.is_symlink():
|
|
raise ReleaseToolError(f"output directory cannot be a symbolic link: {expanded}")
|
|
resolved = expanded.resolve()
|
|
project_release_path = RELEASE_ARTIFACTS_ROOT
|
|
if project_release_path.is_symlink():
|
|
raise ReleaseToolError(
|
|
"repository release-artifact directory cannot be a symbolic link: "
|
|
f"{project_release_path}"
|
|
)
|
|
project_releases = project_release_path.resolve()
|
|
temporary_roots = (
|
|
Path(tempfile.gettempdir()).resolve(),
|
|
Path("/var/tmp").resolve(),
|
|
)
|
|
unsafe = {
|
|
Path("/").resolve(),
|
|
PROJECT_ROOT.resolve(),
|
|
Path.home().resolve(),
|
|
*temporary_roots,
|
|
}
|
|
if resolved in unsafe or resolved in PROJECT_ROOT.resolve().parents:
|
|
raise ReleaseToolError(f"refusing unsafe output directory: {resolved}")
|
|
if resolved == project_releases or resolved.is_relative_to(project_releases):
|
|
return resolved
|
|
if resolved.is_relative_to(PROJECT_ROOT.resolve()):
|
|
raise ReleaseToolError(
|
|
"release output inside the repository must be below .artifacts/releases"
|
|
)
|
|
if any(resolved.is_relative_to(root) for root in temporary_roots):
|
|
return resolved
|
|
raise ReleaseToolError(
|
|
"release output must be below .artifacts/releases or a temporary directory"
|
|
)
|
|
|
|
|
|
def _clean_output_directory(path: Path) -> None:
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
for child in path.iterdir():
|
|
if child.is_symlink() or child.is_file():
|
|
child.unlink()
|
|
elif child.is_dir():
|
|
shutil.rmtree(child)
|
|
else:
|
|
raise ReleaseToolError(f"cannot clean special output path: {child}")
|
|
|
|
|
|
def _validate_source_file(path: Path) -> None:
|
|
if path.is_symlink() or not path.is_file():
|
|
raise ReleaseToolError(f"required payload file is missing or unsafe: {path}")
|
|
|
|
|
|
def _safe_relative_path(value: str) -> PurePosixPath:
|
|
if "\\" in value or "\x00" in value:
|
|
raise ReleaseToolError(f"unsafe relative release path: {value!r}")
|
|
raw_parts = value.split("/")
|
|
if any(part in {"", ".", ".."} for part in raw_parts):
|
|
raise ReleaseToolError(f"unsafe relative release path: {value!r}")
|
|
path = PurePosixPath(value)
|
|
if path.is_absolute() or not path.parts:
|
|
raise ReleaseToolError(f"unsafe relative release path: {value!r}")
|
|
return path
|
|
|
|
|
|
def _read_release_notes(path: Path | None) -> str:
|
|
if path is None:
|
|
return ""
|
|
try:
|
|
value = path.expanduser().resolve().read_text(encoding="utf-8")
|
|
except (OSError, UnicodeError) as exc:
|
|
raise ReleaseToolError(f"cannot read release notes {path}: {exc}") from exc
|
|
if not value.endswith("\n"):
|
|
value += "\n"
|
|
return value
|
|
|
|
|
|
def _release_summary(notes: str, version: str) -> str:
|
|
for line in notes.splitlines():
|
|
candidate = line.strip()
|
|
if not candidate or candidate.startswith("#"):
|
|
continue
|
|
candidate = re.sub(r"^[-*+]\s+", "", candidate)
|
|
return candidate[:280]
|
|
return f"PristonTale EU Launcher {version}."
|
|
|
|
|
|
def _timestamp_from_epoch(epoch: int) -> str:
|
|
return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def _parse_timestamp(value: str) -> datetime:
|
|
try:
|
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except ValueError as exc:
|
|
raise ReleaseToolError(f"invalid release timestamp: {value!r}") from exc
|
|
if parsed.tzinfo is None:
|
|
raise ReleaseToolError("release timestamp must include a timezone")
|
|
return parsed
|
|
|
|
|
|
def _validate_repository_url(value: str) -> None:
|
|
parts = urlsplit(value)
|
|
if (
|
|
parts.scheme.casefold() != "https"
|
|
or not parts.netloc
|
|
or parts.username is not None
|
|
or parts.password is not None
|
|
or parts.query
|
|
or parts.fragment
|
|
):
|
|
raise ReleaseToolError(
|
|
"repository URL must be HTTPS without credentials, query text, or a fragment"
|
|
)
|
|
|
|
|
|
def _validate_legacy_version(value: str, label: str) -> None:
|
|
if _LEGACY_VERSION_PATTERN.fullmatch(value) is None:
|
|
raise ReleaseToolError(f"{label} must use X.Y.Z format: {value!r}")
|
|
|
|
|
|
def _payload_source_for_destination(
|
|
config: Mapping[str, object],
|
|
destination: PurePosixPath,
|
|
) -> Path:
|
|
for item in collect_payload(config):
|
|
if item.relative_path == destination and item.source is not None:
|
|
return item.source
|
|
raise ReleaseToolError(f"release payload is missing {destination.as_posix()}")
|
|
|
|
|
|
def _read_json_object(path: Path) -> dict[str, object]:
|
|
try:
|
|
value = json.loads(
|
|
path.read_text(encoding="utf-8"),
|
|
object_pairs_hook=_unique_json_object,
|
|
)
|
|
except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc:
|
|
raise ReleaseToolError(f"invalid JSON file {path}: {exc}") from exc
|
|
if not isinstance(value, dict):
|
|
raise ReleaseToolError(f"JSON file must contain an object: {path}")
|
|
return value
|
|
|
|
|
|
def _required_string(value: Mapping[str, object], key: str) -> str:
|
|
item = value.get(key)
|
|
if not isinstance(item, str) or not item:
|
|
raise ReleaseToolError(f"release configuration {key!r} must be a non-empty string")
|
|
return item
|
|
|
|
|
|
def _required_integer(value: Mapping[str, object], key: str) -> int:
|
|
item = value.get(key)
|
|
if isinstance(item, bool) or not isinstance(item, int) or item <= 0:
|
|
raise ReleaseToolError(
|
|
f"release configuration {key!r} must be a positive integer"
|
|
)
|
|
return item
|
|
|
|
|
|
def _config_schema_range(config: Mapping[str, object]) -> tuple[int, int]:
|
|
config_schema = _required_integer(config, "config_schema")
|
|
minimum_config_schema = _required_integer(config, "minimum_config_schema")
|
|
if minimum_config_schema > config_schema:
|
|
raise ReleaseToolError(
|
|
"minimum_config_schema cannot exceed config_schema"
|
|
)
|
|
return config_schema, minimum_config_schema
|
|
|
|
|
|
def _version_from_source_file(path: Path) -> str:
|
|
try:
|
|
contents = path.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeError) as exc:
|
|
raise ReleaseToolError(f"cannot read launcher version from {path}: {exc}") from exc
|
|
match = _SOURCE_VERSION_PATTERN.search(contents)
|
|
if match is None:
|
|
raise ReleaseToolError(f"cannot find ptlinux.__version__ in {path}")
|
|
return match.group(1)
|
|
|
|
|
|
def _set_staged_source_version(
|
|
staging: Path,
|
|
config: Mapping[str, object],
|
|
version: str,
|
|
) -> None:
|
|
path = staging / _packaged_version_path(config)
|
|
try:
|
|
contents = path.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeError) as exc:
|
|
raise ReleaseToolError(
|
|
f"cannot prepare staged launcher version in {path}: {exc}"
|
|
) from exc
|
|
replacement = f'__version__ = "{version}"'
|
|
updated, count = _SOURCE_VERSION_PATTERN.subn(replacement, contents)
|
|
if count != 1:
|
|
raise ReleaseToolError(
|
|
f"cannot prepare staged launcher version in {path}: expected one version"
|
|
)
|
|
_write_atomic(path, updated.encode("utf-8"))
|
|
|
|
|
|
def _string_list(value: object, label: str) -> list[str]:
|
|
if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
|
|
raise ReleaseToolError(f"{label} must be an array of non-empty strings")
|
|
return list(value)
|
|
|
|
|
|
def _unique_json_object(pairs: Sequence[tuple[str, object]]) -> dict[str, object]:
|
|
value: dict[str, object] = {}
|
|
for key, item in pairs:
|
|
if key in value:
|
|
raise ValueError(f"duplicate JSON key: {key!r}")
|
|
value[key] = item
|
|
return value
|
|
|
|
|
|
def _write_atomic(path: Path, data: bytes) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
temporary.unlink(missing_ok=True)
|
|
try:
|
|
with temporary.open("xb") as handle:
|
|
handle.write(data)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
temporary.chmod(0o644)
|
|
os.replace(temporary, path)
|
|
finally:
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
|
|
def _packaged_version_path(config: Mapping[str, object]) -> PurePosixPath:
|
|
value = config.get("packaged_version_path")
|
|
if value is None:
|
|
# Older repository fixtures used one path because source and archive
|
|
# layouts were identical.
|
|
value = _required_string(config, "version_source")
|
|
if not isinstance(value, str) or not value:
|
|
raise ReleaseToolError("packaged_version_path must be a non-empty string")
|
|
return _safe_relative_path(value)
|
|
|
|
|
|
def _git_output(*arguments: str) -> str:
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "-C", str(PROJECT_ROOT), *arguments],
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=30,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
raise ReleaseToolError(f"cannot inspect Git release source: {exc}") from exc
|
|
if result.returncode != 0:
|
|
raise ReleaseToolError(result.stderr.strip() or "Git release source check failed")
|
|
return result.stdout.strip()
|