ci: verify signed packages before protected draft publication
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/target/
|
||||
Generated
+23
@@ -0,0 +1,23 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||
|
||||
[[package]]
|
||||
name = "shacraft-release-verifier"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"minisign-verify",
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "shacraft-release-verifier"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
base64 = "=0.22.1"
|
||||
minisign-verify = "=0.2.5"
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Uses precisely the Tauri updater 2.11 signature verification primitive.
|
||||
//! Upstream: plugins/updater/src/updater.rs::verify_signature (MIT/Apache-2.0).
|
||||
use base64::Engine;
|
||||
use minisign_verify::{PublicKey, Signature};
|
||||
use std::{env, fs, process::ExitCode};
|
||||
|
||||
fn verify() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = env::args_os().skip(1).collect();
|
||||
if args.len() != 3 {
|
||||
return Err("expected PUBLIC_KEY_FILE DATA_FILE SIGNATURE_FILE".into());
|
||||
}
|
||||
let decode = |path: &std::ffi::OsStr| -> Result<String, Box<dyn std::error::Error>> {
|
||||
let encoded = fs::read_to_string(path)?;
|
||||
let bytes = base64::engine::general_purpose::STANDARD.decode(encoded.trim())?;
|
||||
Ok(String::from_utf8(bytes)?)
|
||||
};
|
||||
let public_key = PublicKey::decode(&decode(&args[0])?)?;
|
||||
let signature = Signature::decode(&decode(&args[2])?)?;
|
||||
public_key.verify(&fs::read(&args[1])?, &signature, true)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match verify() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(_) => {
|
||||
// Never echo key material or signer diagnostics into release logs.
|
||||
eprintln!("Tauri updater signature verification failed");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assemble/verify release assets. No network or publication in this module."""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import tomllib
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import release_formats
|
||||
|
||||
REPOSITORY = "emil28092005/shacraft-launcher"
|
||||
ORIGIN = f"https://github.com/{REPOSITORY}/releases/download"
|
||||
PLATFORMS = {
|
||||
"windows-x86_64": ("nsis/*.exe", "-setup.exe"),
|
||||
"linux-x86_64": ("appimage/*.AppImage", ".AppImage"),
|
||||
"darwin-aarch64": ("macos/*.app.tar.gz", ".app.tar.gz"),
|
||||
"darwin-x86_64": ("macos/*.app.tar.gz", ".app.tar.gz"),
|
||||
}
|
||||
MANUAL = {
|
||||
"windows-x86_64-msi": ("windows-x86_64", "msi/*.msi", ".msi"),
|
||||
"linux-x86_64-deb": ("linux-x86_64", "deb/*.deb", ".deb"),
|
||||
"darwin-aarch64-dmg": ("darwin-aarch64", "dmg/*.dmg", ".dmg"),
|
||||
"darwin-x86_64-dmg": ("darwin-x86_64", "dmg/*.dmg", ".dmg"),
|
||||
}
|
||||
FIELDS = {"url", "signature", "sha256", "size"}
|
||||
MAX_SIZE = 1024**3
|
||||
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def version_tag(version, tag):
|
||||
require(
|
||||
re.fullmatch(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", version),
|
||||
"release version must be stable MAJOR.MINOR.PATCH",
|
||||
)
|
||||
major, minor, patch = map(int, version.split("."))
|
||||
require(major <= 255 and minor <= 255 and patch <= 65535, "version exceeds MSI limits")
|
||||
require(tag == f"v{version}", "tag/version mismatch")
|
||||
|
||||
|
||||
def filename(version, platform, suffix):
|
||||
return f"shacraft-launcher_{version}_{platform}{suffix}"
|
||||
|
||||
|
||||
def expected_names(version):
|
||||
return {filename(version, key, value[1]) for key, value in PLATFORMS.items()} | {
|
||||
filename(version, platform, suffix) for platform, _, suffix in MANUAL.values()
|
||||
}
|
||||
|
||||
|
||||
def canonical(data):
|
||||
return (json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def unique_object(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
require(key not in result, "duplicate JSON field")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def read_json(path):
|
||||
return json.loads(path.read_bytes(), object_pairs_hook=unique_object)
|
||||
|
||||
|
||||
def public_key():
|
||||
value = os.environ.get("SHACRAFT_UPDATER_PUBLIC_KEY", "").strip()
|
||||
require(bool(value), "SHACRAFT_UPDATER_PUBLIC_KEY is missing")
|
||||
try:
|
||||
decoded = base64.b64decode(value, validate=True).decode("utf-8")
|
||||
lines = decoded.splitlines()
|
||||
raw = base64.b64decode(lines[1], validate=True)
|
||||
require(
|
||||
lines[0].startswith("untrusted comment:") and len(raw) == 42 and raw[:2] == b"Ed",
|
||||
"invalid Tauri public key",
|
||||
)
|
||||
except (ValueError, IndexError, UnicodeError) as error:
|
||||
raise ValueError("invalid Tauri public key") from error
|
||||
return value
|
||||
|
||||
|
||||
def verifier_path(root):
|
||||
return os.environ.get(
|
||||
"RELEASE_VERIFIER",
|
||||
str(
|
||||
root
|
||||
/ "scripts/release-verifier/target/release"
|
||||
/ ("shacraft-release-verifier.exe" if os.name == "nt" else "shacraft-release-verifier")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def verify_signature(root, data, signature):
|
||||
with tempfile.TemporaryDirectory(prefix="shacraft-verify-") as temporary:
|
||||
key = Path(temporary) / "public-key"
|
||||
key.write_text(public_key(), encoding="utf-8")
|
||||
result = subprocess.run(
|
||||
[verifier_path(root), str(key), str(data), str(signature)],
|
||||
capture_output=True,
|
||||
timeout=120,
|
||||
check=False,
|
||||
)
|
||||
require(result.returncode == 0, f"invalid updater signature: {data.name}")
|
||||
|
||||
|
||||
def signer(root, data):
|
||||
private = os.environ.get("TAURI_SIGNING_PRIVATE_KEY", "")
|
||||
require(bool(private.strip()), "TAURI_SIGNING_PRIVATE_KEY is missing")
|
||||
environment = dict(os.environ)
|
||||
# An omitted password means an unencrypted key. Never allow a CI prompt;
|
||||
# encrypted keys without the correct password fail in the signer.
|
||||
environment.setdefault("TAURI_SIGNING_PRIVATE_KEY_PASSWORD", "")
|
||||
# Builds accept a key path; signer sign accepts the encoded key contents.
|
||||
try:
|
||||
is_key_path = len(private) < 4096 and "\n" not in private and Path(private).is_file()
|
||||
except OSError:
|
||||
is_key_path = False
|
||||
if is_key_path:
|
||||
environment["TAURI_SIGNING_PRIVATE_KEY"] = Path(private).read_text().strip()
|
||||
environment.pop("TAURI_SIGNING_PRIVATE_KEY_PATH", None)
|
||||
result = subprocess.run(
|
||||
["node", str(root / "node_modules/@tauri-apps/cli/tauri.js"), "signer", "sign", str(data)],
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
timeout=120,
|
||||
check=False,
|
||||
)
|
||||
require(result.returncode == 0, "Tauri signing failed (check protected signing credentials)")
|
||||
|
||||
|
||||
def preflight(root, version, tag, check_git=False, signing=False):
|
||||
version_tag(version, tag)
|
||||
versions = [
|
||||
read_json(root / "package.json")["version"],
|
||||
read_json(root / "package-lock.json")["version"],
|
||||
read_json(root / "package-lock.json")["packages"][""]["version"],
|
||||
read_json(root / "src-tauri/tauri.conf.json")["version"],
|
||||
tomllib.loads((root / "src-tauri/Cargo.toml").read_text())["package"]["version"],
|
||||
]
|
||||
require(all(value == version for value in versions), "source versions disagree with release")
|
||||
if check_git:
|
||||
|
||||
def git(*args):
|
||||
return subprocess.check_output(["git", *args], cwd=root, text=True).strip()
|
||||
|
||||
require(
|
||||
git("rev-parse", "HEAD") == git("rev-parse", f"refs/tags/{tag}^{{commit}}"),
|
||||
"checkout does not match existing release tag",
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "merge-base", "--is-ancestor", "HEAD", "origin/main"], cwd=root, check=True
|
||||
)
|
||||
if signing:
|
||||
require(os.environ.get("SHACRAFT_UPDATER_TEST_BUILD") != "1", "CI test keys cannot release")
|
||||
pinned = (root / "src-tauri/updater-public-key.txt").read_text().strip()
|
||||
require(public_key() == pinned, "release public key differs from committed updater key")
|
||||
with tempfile.TemporaryDirectory(prefix="shacraft-key-check-") as temporary:
|
||||
challenge = Path(temporary) / "key-check"
|
||||
challenge.write_bytes(f"ShaCraft release key check {tag}\n".encode())
|
||||
signer(root, challenge)
|
||||
verify_signature(root, challenge, Path(str(challenge) + ".sig"))
|
||||
|
||||
|
||||
def write_build_config(destination):
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(
|
||||
canonical(
|
||||
{
|
||||
"bundle": {"createUpdaterArtifacts": True},
|
||||
"plugins": {"updater": {"pubkey": public_key()}},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def ci_key(root, directory):
|
||||
require(not os.environ.get("TAURI_SIGNING_PRIVATE_KEY"), "CI must not receive production key")
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
key = directory / "DISPOSABLE-CI-ONLY.key"
|
||||
result = subprocess.run(
|
||||
[
|
||||
"node",
|
||||
str(root / "node_modules/@tauri-apps/cli/tauri.js"),
|
||||
"signer",
|
||||
"generate",
|
||||
"--ci",
|
||||
"--password",
|
||||
"",
|
||||
"--write-keys",
|
||||
str(key),
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=120,
|
||||
check=False,
|
||||
)
|
||||
require(result.returncode == 0, "disposable CI key generation failed")
|
||||
key.chmod(0o600)
|
||||
values = {
|
||||
"TAURI_SIGNING_PRIVATE_KEY": str(key),
|
||||
"TAURI_SIGNING_PRIVATE_KEY_PASSWORD": "",
|
||||
"SHACRAFT_UPDATER_PUBLIC_KEY": Path(str(key) + ".pub").read_text().strip(),
|
||||
"SHACRAFT_UPDATER_TEST_BUILD": "1",
|
||||
}
|
||||
os.environ.update(values)
|
||||
write_build_config(directory / "updater-build.json")
|
||||
with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as stream:
|
||||
for name, value in values.items():
|
||||
require("\n" not in value and "\r" not in value, "invalid CI environment value")
|
||||
stream.write(f"{name}={value}\n")
|
||||
|
||||
|
||||
def collect(root, bundle, destination, platform, version):
|
||||
version_tag(version, f"v{version}")
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
require(not list(destination.iterdir()), "collection destination must be empty")
|
||||
entries = [(PLATFORMS[platform][0], PLATFORMS[platform][1], True)]
|
||||
entries += [
|
||||
(glob, suffix, suffix == ".msi") for key, glob, suffix in MANUAL.values() if key == platform
|
||||
]
|
||||
for glob, suffix, required_signature in entries:
|
||||
matches = list(bundle.glob(glob))
|
||||
require(
|
||||
len(matches) == 1 and matches[0].is_file() and not matches[0].is_symlink(),
|
||||
f"expected exactly one {platform} {glob}",
|
||||
)
|
||||
source = matches[0]
|
||||
main = bundle.parent / "shacraft-launcher.exe" if platform == "windows-x86_64" else None
|
||||
release_formats.validate(source, platform, suffix, version, main)
|
||||
target = destination / filename(version, platform, suffix)
|
||||
shutil.copyfile(source, target)
|
||||
source_signature = Path(str(source) + ".sig")
|
||||
signature = Path(str(target) + ".sig")
|
||||
if required_signature:
|
||||
require(
|
||||
source_signature.is_file() and not source_signature.is_symlink(),
|
||||
f"missing generated updater signature: {source.name}",
|
||||
)
|
||||
shutil.copyfile(source_signature, signature)
|
||||
else:
|
||||
signer(root, target) # Tauri does not produce deb/DMG updater signatures.
|
||||
verify_signature(root, target, signature)
|
||||
if os.environ.get("SHACRAFT_UPDATER_TEST_BUILD") == "1":
|
||||
(destination / "CI_NOT_FOR_RELEASE.txt").write_text(
|
||||
"DISPOSABLE TEST KEY. These CI artifacts are not deployable releases.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def descriptor(root, directory, version, tag, platform, suffix):
|
||||
name = filename(version, platform, suffix)
|
||||
path, sig = directory / name, directory / (name + ".sig")
|
||||
require(
|
||||
path.is_file() and not path.is_symlink() and sig.is_file() and not sig.is_symlink(),
|
||||
f"missing or unsafe release asset: {name}",
|
||||
)
|
||||
size = path.stat().st_size
|
||||
require(0 < size <= MAX_SIZE, "invalid package size")
|
||||
verify_signature(root, path, sig)
|
||||
release_formats.validate(path, platform, suffix, version)
|
||||
with path.open("rb") as stream:
|
||||
digest = hashlib.file_digest(stream, "sha256").hexdigest()
|
||||
signature = sig.read_text(encoding="utf-8").strip()
|
||||
require(0 < len(signature) <= 2048, "invalid signature length")
|
||||
return {"url": f"{ORIGIN}/{tag}/{name}", "signature": signature, "sha256": digest, "size": size}
|
||||
|
||||
|
||||
def metadata(root, directory, version, tag, notes, date):
|
||||
version_tag(version, tag)
|
||||
require(len(notes.encode()) <= 4096, "release notes too long")
|
||||
require(
|
||||
re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", date), "expected UTC publication date"
|
||||
)
|
||||
datetime.fromisoformat(date.replace("Z", "+00:00"))
|
||||
return {
|
||||
"schemaVersion": 1,
|
||||
"version": version,
|
||||
"tag": tag,
|
||||
"notes": notes,
|
||||
"pub_date": date,
|
||||
"platforms": {
|
||||
key: descriptor(root, directory, version, tag, key, suffix)
|
||||
for key, (_, suffix) in PLATFORMS.items()
|
||||
},
|
||||
"manualPackages": {
|
||||
key: descriptor(root, directory, version, tag, platform, suffix)
|
||||
for key, (platform, _, suffix) in MANUAL.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def verify_release(root, directory, version, tag):
|
||||
version_tag(version, tag)
|
||||
names = expected_names(version)
|
||||
expected = names | {name + ".sig" for name in names} | {"latest.json", "latest.json.sig"}
|
||||
require(
|
||||
{path.name for path in directory.iterdir()} == expected,
|
||||
"release asset set is incomplete or unexpected",
|
||||
)
|
||||
path = directory / "latest.json"
|
||||
require(path.stat().st_size <= 32768, "metadata exceeds limit")
|
||||
require(
|
||||
not path.is_symlink() and not (directory / "latest.json.sig").is_symlink(),
|
||||
"unsafe metadata",
|
||||
)
|
||||
verify_signature(
|
||||
root, path, directory / "latest.json.sig"
|
||||
) # Verify exact bytes BEFORE parsing.
|
||||
actual = read_json(path)
|
||||
require(
|
||||
isinstance(actual, dict)
|
||||
and set(actual)
|
||||
== {"schemaVersion", "version", "tag", "notes", "pub_date", "platforms", "manualPackages"},
|
||||
"unexpected metadata fields",
|
||||
)
|
||||
require(
|
||||
type(actual["schemaVersion"]) is int and actual["schemaVersion"] == 1,
|
||||
"unsupported metadata schema",
|
||||
)
|
||||
require(actual["version"] == version and actual["tag"] == tag, "signed version/tag mismatch")
|
||||
require(
|
||||
isinstance(actual["notes"], str) and isinstance(actual["pub_date"], str),
|
||||
"invalid metadata text",
|
||||
)
|
||||
for section, keys in (("platforms", PLATFORMS), ("manualPackages", MANUAL)):
|
||||
require(
|
||||
isinstance(actual[section], dict) and set(actual[section]) == set(keys),
|
||||
"unexpected platform/package set",
|
||||
)
|
||||
for item in actual[section].values():
|
||||
require(isinstance(item, dict) and set(item) == FIELDS, "unexpected descriptor fields")
|
||||
require(
|
||||
type(item["size"]) is int and 0 < item["size"] <= MAX_SIZE,
|
||||
"invalid descriptor size",
|
||||
)
|
||||
require(
|
||||
all(isinstance(item[name], str) for name in ("url", "signature", "sha256")),
|
||||
"invalid descriptor text",
|
||||
)
|
||||
expected_metadata = metadata(root, directory, version, tag, actual["notes"], actual["pub_date"])
|
||||
require(
|
||||
actual == expected_metadata,
|
||||
"metadata does not match exact platforms, URLs, hashes, sizes or signatures",
|
||||
)
|
||||
require(path.read_bytes() == canonical(expected_metadata), "metadata is not canonical")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
check = sub.add_parser("preflight")
|
||||
check.add_argument("--version", required=True)
|
||||
check.add_argument("--tag", required=True)
|
||||
check.add_argument("--check-git", action="store_true")
|
||||
check.add_argument("--signing", action="store_true")
|
||||
check.add_argument("--config", type=Path)
|
||||
ci = sub.add_parser("ci-key")
|
||||
ci.add_argument("--directory", required=True, type=Path)
|
||||
gather = sub.add_parser("collect")
|
||||
gather.add_argument("--bundle", required=True, type=Path)
|
||||
gather.add_argument("--directory", required=True, type=Path)
|
||||
gather.add_argument("--platform", required=True, choices=PLATFORMS)
|
||||
gather.add_argument("--version", required=True)
|
||||
for name in ("metadata", "verify"):
|
||||
item = sub.add_parser(name)
|
||||
item.add_argument("--directory", required=True, type=Path)
|
||||
item.add_argument("--version", required=True)
|
||||
item.add_argument("--tag", required=True)
|
||||
if name == "metadata":
|
||||
item.add_argument("--date", required=True)
|
||||
item.add_argument("--notes", default="")
|
||||
args = parser.parse_args()
|
||||
root = args.root.resolve()
|
||||
if args.command == "preflight":
|
||||
preflight(root, args.version, args.tag, args.check_git, args.signing)
|
||||
if args.config:
|
||||
write_build_config(args.config)
|
||||
elif args.command == "ci-key":
|
||||
ci_key(root, args.directory)
|
||||
elif args.command == "collect":
|
||||
collect(root, args.bundle, args.directory, args.platform, args.version)
|
||||
elif args.command == "metadata":
|
||||
require(os.environ.get("SHACRAFT_UPDATER_TEST_BUILD") != "1", "CI artifacts cannot release")
|
||||
data = metadata(root, args.directory, args.version, args.tag, args.notes, args.date)
|
||||
path = args.directory / "latest.json"
|
||||
require(
|
||||
not path.exists() and not (args.directory / "latest.json.sig").exists(),
|
||||
"metadata already exists",
|
||||
)
|
||||
path.write_bytes(canonical(data))
|
||||
signer(root, path)
|
||||
verify_release(root, args.directory, args.version, args.tag)
|
||||
else:
|
||||
verify_release(root, args.directory, args.version, args.tag)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (ValueError, OSError, subprocess.SubprocessError, KeyError, TypeError) as error:
|
||||
print(f"Release validation failed: {error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""No network: reject unsafe draft listings before invoking GitHub download."""
|
||||
|
||||
import copy
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import release
|
||||
import release_github
|
||||
|
||||
|
||||
class DraftAssetGateTests(unittest.TestCase):
|
||||
def draft(self):
|
||||
packages = release.expected_names("0.3.0")
|
||||
names = packages | {name + ".sig" for name in packages} | {"latest.json", "latest.json.sig"}
|
||||
self.assertEqual(len(names), 18)
|
||||
return {
|
||||
"id": 123, "tag_name": "v0.3.0", "draft": True, "prerelease": False,
|
||||
"assets": [{"name": name, "id": index, "state": "uploaded", "size": 1, "digest": None}
|
||||
for index, name in enumerate(sorted(names), 1)],
|
||||
}
|
||||
|
||||
def assert_no_download(self, data):
|
||||
with (patch.object(release_github, "api", return_value=data),
|
||||
patch.object(release_github, "gh") as github,
|
||||
patch.object(release, "verify_release") as verify):
|
||||
with self.assertRaises(ValueError):
|
||||
release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0")
|
||||
github.assert_not_called()
|
||||
verify.assert_not_called()
|
||||
|
||||
def fake_download(self, listing):
|
||||
def download(*args):
|
||||
self.assertEqual(args[:3], ("release", "download", "v0.3.0"))
|
||||
destination = Path(args[args.index("--dir") + 1])
|
||||
for asset in listing["assets"]:
|
||||
(destination / asset["name"]).write_bytes(b"x" * asset["size"])
|
||||
return download
|
||||
|
||||
def test_exact_complete_listing_still_downloads_and_verifies_actual_contents(self):
|
||||
listing = self.draft()
|
||||
with (patch.object(release_github, "api", side_effect=[listing, listing]) as api,
|
||||
patch.object(release_github, "gh", side_effect=self.fake_download(listing)) as github,
|
||||
patch.object(release, "verify_release") as verify):
|
||||
self.assertEqual(release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0"), listing)
|
||||
github.assert_called_once()
|
||||
verify.assert_called_once()
|
||||
self.assertEqual(api.call_count, 2)
|
||||
|
||||
def test_unexpected_traversal_absolute_and_excessive_names_cannot_download(self):
|
||||
for name in ["extra.exe", "../latest.json", r"..\latest.json", "/tmp/latest.json",
|
||||
"folder/latest.json", "latest.JSON", "x" * 4096]:
|
||||
with self.subTest(name=name[:50]):
|
||||
listing = self.draft()
|
||||
listing["assets"][0]["name"] = name
|
||||
self.assert_no_download(listing)
|
||||
listing = self.draft()
|
||||
listing["assets"].append({"name": "extra.txt", "id": 100, "state": "uploaded", "size": 1})
|
||||
self.assert_no_download(listing)
|
||||
|
||||
def test_missing_duplicate_and_unfinished_files_cannot_download(self):
|
||||
for index in range(18):
|
||||
with self.subTest(missing=index):
|
||||
listing = self.draft()
|
||||
listing["assets"].pop(index)
|
||||
self.assert_no_download(listing)
|
||||
listing = self.draft()
|
||||
listing["assets"].append(copy.deepcopy(listing["assets"][0]))
|
||||
self.assert_no_download(listing)
|
||||
listing = self.draft()
|
||||
listing["assets"][0]["state"] = "new"
|
||||
self.assert_no_download(listing)
|
||||
|
||||
def test_every_asset_size_must_be_a_positive_integer_within_its_limit(self):
|
||||
listing = self.draft()
|
||||
for index, asset in enumerate(listing["assets"]):
|
||||
limit = 32768 if asset["name"] == "latest.json" else 8192 if asset["name"].endswith(".sig") else 1024**3
|
||||
for size in [0, -1, True, False, 1.0, "1", None, limit + 1]:
|
||||
with self.subTest(name=asset["name"], size=size):
|
||||
altered = copy.deepcopy(listing)
|
||||
altered["assets"][index]["size"] = size
|
||||
self.assert_no_download(altered)
|
||||
|
||||
def test_declared_size_boundaries_are_accepted_without_allocating_large_files(self):
|
||||
listing = self.draft()
|
||||
for asset in listing["assets"]:
|
||||
asset["size"] = 32768 if asset["name"] == "latest.json" else 8192 if asset["name"].endswith(".sig") else 1024**3
|
||||
with (patch.object(release_github, "api", return_value=listing),
|
||||
patch.object(release_github, "gh", side_effect=RuntimeError("download boundary reached")) as github):
|
||||
with self.assertRaisesRegex(RuntimeError, "download boundary reached"):
|
||||
release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0")
|
||||
github.assert_called_once()
|
||||
|
||||
def test_valid_listing_does_not_bypass_signature_failure_or_remote_race_checks(self):
|
||||
listing = self.draft()
|
||||
with (patch.object(release_github, "api", return_value=listing) as api,
|
||||
patch.object(release_github, "gh", side_effect=self.fake_download(listing)) as github,
|
||||
patch.object(release, "verify_release", side_effect=ValueError("invalid updater signature")) as verify):
|
||||
with self.assertRaisesRegex(ValueError, "invalid updater signature"):
|
||||
release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0")
|
||||
github.assert_called_once()
|
||||
verify.assert_called_once()
|
||||
self.assertEqual(api.call_count, 1)
|
||||
changed = copy.deepcopy(listing)
|
||||
changed["assets"][0]["id"] += 100
|
||||
with (patch.object(release_github, "api", side_effect=[listing, changed]),
|
||||
patch.object(release_github, "gh", side_effect=self.fake_download(listing)),
|
||||
patch.object(release, "verify_release") as verify):
|
||||
with self.assertRaisesRegex(ValueError, "changed during verification"):
|
||||
release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0")
|
||||
verify.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Read-only package checks. Never execute or install a package under inspection."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import plistlib
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import tarfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise ValueError(f"Package validation failed: {message}")
|
||||
|
||||
|
||||
def pe_machine(data):
|
||||
require(len(data) >= 64 and data[:2] == b"MZ", "invalid PE DOS header")
|
||||
offset = struct.unpack_from("<I", data, 60)[0]
|
||||
require(
|
||||
64 <= offset <= len(data) - 26 and data[offset : offset + 4] == b"PE\0\0",
|
||||
"invalid PE header",
|
||||
)
|
||||
return struct.unpack_from("<H", data, offset + 4)[0], struct.unpack_from(
|
||||
"<H", data, offset + 24
|
||||
)[0]
|
||||
|
||||
|
||||
def pe_x64(data):
|
||||
require(pe_machine(data) == (0x8664, 0x20B), "launcher executable must be AMD64 PE32+")
|
||||
|
||||
|
||||
def appimage(path):
|
||||
with path.open("rb") as stream:
|
||||
data = stream.read(64)
|
||||
require(
|
||||
len(data) == 64 and data[:6] == b"\x7fELF\x02\x01", "AppImage must be little-endian ELF64"
|
||||
)
|
||||
require(data[8:11] == b"AI\x02", "AppImage must use Type 2 format")
|
||||
require(struct.unpack_from("<H", data, 18)[0] == 62, "AppImage must target AMD64")
|
||||
|
||||
|
||||
def safe_member(name):
|
||||
name = name.removeprefix("./")
|
||||
path = PurePosixPath(name)
|
||||
require(
|
||||
not path.is_absolute() and ".." not in path.parts and "\\" not in name,
|
||||
"unsafe archive member",
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def mac_app(path, platform, version):
|
||||
with tarfile.open(path, "r:gz") as archive:
|
||||
members = {}
|
||||
for index, item in enumerate(archive):
|
||||
require(index < 10000, "too many app archive entries")
|
||||
name = safe_member(item.name).rstrip("/")
|
||||
require(name not in members, "duplicate app archive member")
|
||||
members[name] = item
|
||||
plists = [name for name in members if name.endswith(".app/Contents/Info.plist")]
|
||||
require(len(plists) == 1, "expected exactly one app Info.plist")
|
||||
info = members[plists[0]]
|
||||
require(info.isfile() and 0 < info.size <= 1024 * 1024, "invalid app Info.plist")
|
||||
details = plistlib.loads(archive.extractfile(info).read())
|
||||
executable = details.get("CFBundleExecutable", "")
|
||||
require(
|
||||
isinstance(executable, str)
|
||||
and executable not in {"", ".", ".."}
|
||||
and "/" not in executable
|
||||
and "\\" not in executable,
|
||||
"invalid app executable name",
|
||||
)
|
||||
require(details.get("CFBundleShortVersionString") == version, "app version mismatch")
|
||||
main = plists[0].removesuffix("Info.plist") + "MacOS/" + executable
|
||||
require(main in members and members[main].isfile(), "app executable missing or linked")
|
||||
data = archive.extractfile(members[main]).read(32)
|
||||
require(
|
||||
len(data) == 32 and data[:4] == b"\xcf\xfa\xed\xfe",
|
||||
"expected a thin little-endian Mach-O64 executable",
|
||||
)
|
||||
cpu, _, kind = struct.unpack_from("<III", data, 4)
|
||||
wanted = 0x0100000C if platform == "darwin-aarch64" else 0x01000007
|
||||
require(cpu == wanted and kind == 2, "app Mach-O architecture/type mismatch")
|
||||
|
||||
|
||||
def deb(path, version):
|
||||
control = None
|
||||
debian_binary = None
|
||||
with path.open("rb") as stream:
|
||||
require(stream.read(8) == b"!<arch>\n", "invalid deb ar header")
|
||||
seen = set()
|
||||
while header := stream.read(60):
|
||||
require(len(header) == 60 and header[58:] == b"`\n", "invalid deb ar member")
|
||||
name = header[:16].decode("ascii").strip().removesuffix("/")
|
||||
require(name not in seen and len(seen) < 20, "duplicate/excessive deb members")
|
||||
seen.add(name)
|
||||
size = int(header[48:58].decode("ascii").strip())
|
||||
require(
|
||||
0 <= size <= 1024**3 and stream.tell() + size <= path.stat().st_size,
|
||||
"invalid/truncated deb member size",
|
||||
)
|
||||
if name == "debian-binary":
|
||||
require(size <= 16, "invalid debian-binary size")
|
||||
debian_binary = stream.read(size)
|
||||
elif name in {"control.tar.gz", "control.tar.xz", "control.tar"}:
|
||||
require(control is None and size <= 8 * 1024**2, "invalid deb control archive")
|
||||
control = stream.read(size)
|
||||
require(len(control) == size, "truncated deb control archive")
|
||||
else:
|
||||
stream.seek(size, 1)
|
||||
if size % 2:
|
||||
require(stream.read(1) == b"\n", "invalid ar padding")
|
||||
require(
|
||||
debian_binary == b"2.0\n"
|
||||
and control is not None
|
||||
and any(name.startswith("data.tar") for name in seen),
|
||||
"missing deb version/control/data",
|
||||
)
|
||||
with tarfile.open(fileobj=io.BytesIO(control), mode="r:*") as archive:
|
||||
matches = [item for item in archive if safe_member(item.name) == "control"]
|
||||
require(
|
||||
len(matches) == 1 and matches[0].isfile() and matches[0].size <= 1024**2,
|
||||
"invalid deb control file",
|
||||
)
|
||||
text = archive.extractfile(matches[0]).read().decode("utf-8")
|
||||
fields = {}
|
||||
for line in text.splitlines():
|
||||
if line and not line[0].isspace():
|
||||
key, separator, value = line.partition(":")
|
||||
require(separator and key not in fields, "invalid/duplicate deb control field")
|
||||
fields[key] = value.strip()
|
||||
require(fields.get("Architecture") == "amd64", "deb Architecture must be amd64")
|
||||
require(fields.get("Version") == version, "deb version mismatch")
|
||||
|
||||
|
||||
def expected_nsis_payload(original):
|
||||
# tauri-cli-v2.11.4 / tauri-bundler::patch_binary changes only the first
|
||||
# complete token, then restores the original on-disk main after each bundle.
|
||||
# Reproduce that exact operation; never mask PE checksums, sections,
|
||||
# Authenticode certificate tables or arbitrary matching regions.
|
||||
token = b"__TAURI_BUNDLE_TYPE_VAR_UNK"
|
||||
patched = b"__TAURI_BUNDLE_TYPE_VAR_NSS"
|
||||
offset = original.find(token)
|
||||
require(offset >= 0, "built launcher lacks the expected Tauri bundle-type marker")
|
||||
return original[:offset] + patched + original[offset + len(token) :]
|
||||
|
||||
|
||||
def nsis_payload(path, built_main):
|
||||
tool = shutil.which("7z") or r"C:\Program Files\7-Zip\7z.exe"
|
||||
require(Path(tool).is_file(), "7-Zip is required to inspect NSIS payload")
|
||||
listing = subprocess.run(
|
||||
[tool, "l", "-slt", "-sccUTF-8", "--", str(path)],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=60,
|
||||
)
|
||||
require(listing.returncode == 0, "7-Zip cannot inspect NSIS payload")
|
||||
matches = []
|
||||
for line in listing.stdout.decode("utf-8", errors="strict").splitlines():
|
||||
if line.startswith("Path = "):
|
||||
entry = line.removeprefix("Path = ")
|
||||
if PurePosixPath(entry.replace("\\", "/")).name == built_main.name:
|
||||
matches.append(entry)
|
||||
require(len(matches) == 1, "expected exactly one bundled launcher EXE in NSIS")
|
||||
extracted = subprocess.run(
|
||||
[tool, "x", "-so", "-bd", "--", str(path), matches[0]],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=60,
|
||||
)
|
||||
require(extracted.returncode == 0, "7-Zip cannot extract NSIS launcher for inspection")
|
||||
pe_x64(extracted.stdout)
|
||||
require(
|
||||
extracted.stdout == expected_nsis_payload(built_main.read_bytes()),
|
||||
"NSIS payload differs from the x64 build plus the exact Tauri NSIS marker patch",
|
||||
)
|
||||
|
||||
|
||||
def windows(path, suffix, version, built_main):
|
||||
with path.open("rb") as stream:
|
||||
header = stream.read(4096)
|
||||
if suffix == ".msi":
|
||||
require(
|
||||
len(header) >= 512
|
||||
and header[:8] == bytes.fromhex("d0cf11e0a1b11ae1")
|
||||
and header[28:30] == b"\xfe\xff",
|
||||
"invalid MSI compound-file header",
|
||||
)
|
||||
else:
|
||||
# Tauri's NSIS x64 package legitimately uses an x86-unicode installer stub.
|
||||
require(
|
||||
pe_machine(header) in {(0x14C, 0x10B), (0x8664, 0x20B)}, "unsupported NSIS PE wrapper"
|
||||
)
|
||||
if built_main is None:
|
||||
return # Full payload/COM checks run on the Windows collection runner.
|
||||
require(built_main.is_file() and not built_main.is_symlink(), "built launcher EXE is missing")
|
||||
pe_x64(built_main.read_bytes())
|
||||
if suffix != ".msi":
|
||||
nsis_payload(path, built_main)
|
||||
else:
|
||||
script = Path(__file__).with_name("release_msi.ps1")
|
||||
result = subprocess.run(
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-File",
|
||||
str(script),
|
||||
"-PackagePath",
|
||||
str(path.resolve()),
|
||||
],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=60,
|
||||
)
|
||||
require(result.returncode == 0, "cannot read MSI summary/properties")
|
||||
details = json.loads(result.stdout.decode("utf-8-sig"))
|
||||
require(details.get("template", "").split(";")[0] == "x64", "MSI template must be x64")
|
||||
require(details.get("version") == version, "MSI ProductVersion mismatch")
|
||||
|
||||
|
||||
def validate(path, platform, suffix, version, built_main=None):
|
||||
if suffix == ".AppImage":
|
||||
appimage(path)
|
||||
elif suffix == ".deb":
|
||||
deb(path, version)
|
||||
elif suffix == ".app.tar.gz":
|
||||
mac_app(path, platform, version)
|
||||
elif suffix == ".dmg":
|
||||
with path.open("rb") as stream:
|
||||
require(path.stat().st_size >= 512, "truncated DMG")
|
||||
stream.seek(-512, 2)
|
||||
require(stream.read(4) == b"koly", "invalid DMG UDIF trailer")
|
||||
else:
|
||||
windows(path, suffix, version, built_main)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Execute the actual trusted workflow gate against disposable local Git history."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(os.environ.get("RELEASE_TEST_ROOT", Path(__file__).resolve().parents[1]))
|
||||
WORKFLOWS = [ROOT / ".github/workflows" / name for name in ("release.yml", "release-publish.yml")]
|
||||
GATE = " - name: Resolve tag using trusted workflow Git commands\n"
|
||||
|
||||
|
||||
def workflow_gate(workflow):
|
||||
after = workflow.read_text().split(GATE, 1)[1]
|
||||
lines = after.split(" run: |\n", 1)[1].splitlines()
|
||||
body = []
|
||||
for line in lines:
|
||||
if not line.startswith(" "):
|
||||
break
|
||||
body.append(line[10:])
|
||||
return "\n".join(body) + "\n"
|
||||
|
||||
|
||||
class ReleaseGateTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="shacraft-source-gate-")
|
||||
self.directory = Path(self.temporary.name)
|
||||
self.environment = dict(os.environ, GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull)
|
||||
self.git("init", "--initial-branch=main")
|
||||
self.git("config", "user.email", "test@example.invalid")
|
||||
self.git("config", "user.name", "Release Gate Test")
|
||||
(self.directory / "reviewed.txt").write_text("Reviewed source\n")
|
||||
self.git("add", ".")
|
||||
self.git("commit", "-m", "Reviewed main commit")
|
||||
self.good = self.git("rev-parse", "HEAD")
|
||||
self.git("update-ref", "refs/remotes/origin/main", self.good)
|
||||
self.git("tag", "v0.2.0")
|
||||
self.git("checkout", "-b", "unreviewed")
|
||||
(self.directory / "scripts").mkdir()
|
||||
# This validator would falsely accept its own tag if a workflow ran it.
|
||||
(self.directory / "scripts/release.py").write_text(
|
||||
"from pathlib import Path\nPath('untrusted-code-ran').write_text('bypassed')\n"
|
||||
)
|
||||
self.git("add", ".")
|
||||
self.git("commit", "-m", "Unreviewed tag with false validator")
|
||||
self.bad = self.git("rev-parse", "HEAD")
|
||||
self.git("tag", "v9.9.9")
|
||||
self.git("checkout", "main")
|
||||
|
||||
def tearDown(self):
|
||||
self.temporary.cleanup()
|
||||
|
||||
def git(self, *args):
|
||||
return subprocess.check_output(["git", *args], cwd=self.directory,
|
||||
env=self.environment, text=True, stderr=subprocess.DEVNULL).strip()
|
||||
|
||||
def run_gate(self, workflow, tag):
|
||||
output = self.directory / (workflow.stem + "-output")
|
||||
output.unlink(missing_ok=True)
|
||||
result = subprocess.run(["bash", "-c", workflow_gate(workflow)], cwd=self.directory,
|
||||
env=dict(self.environment, RELEASE_TAG=tag, GITHUB_OUTPUT=str(output)),
|
||||
capture_output=True, text=True, check=False)
|
||||
return result, output.read_text() if output.exists() else ""
|
||||
|
||||
def test_main_tag_emits_immutable_commit(self):
|
||||
for workflow in WORKFLOWS:
|
||||
with self.subTest(workflow=workflow.name):
|
||||
result, output = self.run_gate(workflow, "v0.2.0")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(output, f"commit={self.good}\n")
|
||||
|
||||
def test_unreviewed_tag_cannot_replace_its_own_ancestry_validator(self):
|
||||
for workflow in WORKFLOWS:
|
||||
with self.subTest(workflow=workflow.name):
|
||||
result, output = self.run_gate(workflow, "v9.9.9")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(output, "")
|
||||
self.assertFalse((self.directory / "untrusted-code-ran").exists())
|
||||
|
||||
def test_missing_or_retargeted_tag_cannot_change_validated_source(self):
|
||||
for workflow in WORKFLOWS:
|
||||
with self.subTest(workflow=workflow.name):
|
||||
result, output = self.run_gate(workflow, "v8.8.8")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(output, "")
|
||||
self.git("tag", "-f", "v0.2.0", self.good)
|
||||
result, output = self.run_gate(workflow, "v0.2.0")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.git("tag", "-f", "v0.2.0", self.bad)
|
||||
self.assertEqual(output, f"commit={self.good}\n")
|
||||
result, fresh_output = self.run_gate(workflow, "v0.2.0")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(fresh_output, "")
|
||||
|
||||
def test_workflows_execute_candidate_code_only_after_gate_and_checkout_sha(self):
|
||||
for workflow in WORKFLOWS:
|
||||
with self.subTest(workflow=workflow.name):
|
||||
text = workflow.read_text()
|
||||
preflight = text.split(" preflight:\n", 1)[1].split("\n build:\n", 1)[0].split("\n publish:\n", 1)[0]
|
||||
before, after = preflight.split(GATE, 1)
|
||||
self.assertEqual(before.count("uses: actions/checkout@v4"), 1)
|
||||
self.assertIn("fetch-depth: 0", before)
|
||||
self.assertNotIn("ref:", before) # Initial checkout is trusted workflow main.
|
||||
self.assertIn("release.version_tag", before)
|
||||
self.assertLess(after.index("git merge-base --is-ancestor"), after.index("uses: actions/checkout@v4"))
|
||||
self.assertIn("ref: ${{ steps.source.outputs.commit }}", after)
|
||||
self.assertNotIn("ref: ${{ inputs.tag }}", text)
|
||||
downstream = text[len(text.split(" preflight:\n", 1)[0]) + len(" preflight:\n") + len(preflight):]
|
||||
self.assertGreater(downstream.count("ref: ${{ needs.preflight.outputs.commit }}"), 0)
|
||||
self.assertEqual(downstream.count("uses: actions/checkout@v4"), downstream.count("ref: ${{ needs.preflight.outputs.commit }}"))
|
||||
self.assertEqual(text.count("uses: actions/checkout@v4"), text.count("persist-credentials: false"))
|
||||
|
||||
def test_write_token_is_only_exposed_to_explicit_final_publish_step(self):
|
||||
text = WORKFLOWS[1].read_text().split("\n publish:\n", 1)[1]
|
||||
job_environment = re.search(r"(?m)^ env:\n((?: .*\n)+)", text)
|
||||
self.assertIsNotNone(job_environment)
|
||||
self.assertNotIn("GH_TOKEN", job_environment.group(1))
|
||||
before, final = text.split(" - name: Re-download, verify signatures/metadata/assets and explicitly publish\n", 1)
|
||||
self.assertNotIn("GH_TOKEN", before)
|
||||
self.assertIn("GH_TOKEN: ${{ github.token }}", final)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Explicit workflow-only GitHub release operations, with fail-closed gates."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import release
|
||||
|
||||
ENVIRONMENTS = {"launcher-release", "launcher-release-publish"}
|
||||
|
||||
|
||||
def gh(*args, missing=False):
|
||||
result = subprocess.run(["gh", *args], capture_output=True, text=True, timeout=900, check=False)
|
||||
if result.returncode:
|
||||
try:
|
||||
body = json.loads(result.stdout)
|
||||
except ValueError:
|
||||
body = {}
|
||||
if missing and str(body.get("status")) == "404":
|
||||
return None
|
||||
raise ValueError("GitHub operation failed; no permission or validation bypass is allowed")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def api(path, missing=False):
|
||||
value = gh("api", f"repos/{release.REPOSITORY}/{path}", missing=missing)
|
||||
return None if value is None else json.loads(value)
|
||||
|
||||
|
||||
def validate_environment(data):
|
||||
release.require(isinstance(data, dict), "protected environment is absent")
|
||||
policy = data.get("deployment_branch_policy") or {}
|
||||
release.require(
|
||||
policy.get("protected_branches") is True and policy.get("custom_branch_policies") is False,
|
||||
"release environment must allow protected branches only",
|
||||
)
|
||||
rules = data.get("protection_rules") or []
|
||||
reviewers = next((rule for rule in rules if rule.get("type") == "required_reviewers"), {})
|
||||
release.require(
|
||||
reviewers.get("prevent_self_review") is True, "environment must prevent self review"
|
||||
)
|
||||
allowed = reviewers.get("reviewers") or []
|
||||
release.require(
|
||||
any(
|
||||
item.get("type") in {"User", "Team"}
|
||||
and type((item.get("reviewer") or {}).get("id")) is int
|
||||
and item["reviewer"]["id"] > 0
|
||||
for item in allowed
|
||||
),
|
||||
"release environment requires an independent reviewer",
|
||||
)
|
||||
|
||||
|
||||
def workflow_guard():
|
||||
release.require(
|
||||
os.environ.get("GITHUB_REPOSITORY") == release.REPOSITORY, "wrong release repository"
|
||||
)
|
||||
release.require(
|
||||
os.environ.get("GITHUB_EVENT_NAME") == "workflow_dispatch",
|
||||
"release must be dispatched manually",
|
||||
)
|
||||
release.require(
|
||||
os.environ.get("GITHUB_REF") == "refs/heads/main", "release workflow must run from main"
|
||||
)
|
||||
|
||||
|
||||
def gate(environment):
|
||||
workflow_guard()
|
||||
release.require(environment in ENVIRONMENTS, "unexpected release environment")
|
||||
release.require(
|
||||
api("branches/main").get("protected") is True, "main must be a protected branch"
|
||||
)
|
||||
validate_environment(api(f"environments/{environment}", missing=True))
|
||||
# Jobs consume this output only after validation. Never reference a missing
|
||||
# environment directly: GitHub would create it without protection rules.
|
||||
if os.environ.get("GITHUB_OUTPUT"):
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as stream:
|
||||
stream.write(f"environment={environment}\n")
|
||||
|
||||
|
||||
def asset_snapshot(data):
|
||||
release.require(
|
||||
data.get("draft") is True and data.get("prerelease") is False,
|
||||
"expected a stable DRAFT release",
|
||||
)
|
||||
assets = data.get("assets") or []
|
||||
result = {}
|
||||
for asset in assets:
|
||||
name = asset["name"]
|
||||
release.require(
|
||||
name not in result and asset.get("state") == "uploaded",
|
||||
"duplicate or incomplete release asset",
|
||||
)
|
||||
result[name] = (asset["id"], asset["size"], asset.get("digest"))
|
||||
return result
|
||||
|
||||
|
||||
def verify_draft(root, version, tag):
|
||||
release.version_tag(version, tag)
|
||||
before = api(f"releases/tags/{tag}")
|
||||
release.require(before.get("tag_name") == tag, "draft tag mismatch")
|
||||
snapshot = asset_snapshot(before)
|
||||
packages = release.expected_names(version)
|
||||
expected = packages | {name + ".sig" for name in packages} | {"latest.json", "latest.json.sig"}
|
||||
release.require(set(snapshot) == expected, "draft asset set is incomplete or unexpected")
|
||||
# Reject remote names and declared sizes before gh writes any asset locally.
|
||||
# This is only a pre-download bound; signatures and actual bytes are still
|
||||
# checked below, followed by the remote identity/race check.
|
||||
for name, (_, size, _) in snapshot.items():
|
||||
limit = (
|
||||
32 * 1024 if name == "latest.json"
|
||||
else 8 * 1024 if name.endswith(".sig")
|
||||
else release.MAX_SIZE
|
||||
)
|
||||
release.require(type(size) is int and 0 < size <= limit, "invalid remote draft asset size")
|
||||
with tempfile.TemporaryDirectory(prefix="shacraft-draft-check-") as temporary:
|
||||
directory = Path(temporary)
|
||||
gh("release", "download", tag, "--repo", release.REPOSITORY, "--dir", str(directory))
|
||||
release.verify_release(root, directory, version, tag)
|
||||
release.require(
|
||||
set(snapshot) == {p.name for p in directory.iterdir()},
|
||||
"draft assets changed during download",
|
||||
)
|
||||
for path in directory.iterdir():
|
||||
release.require(snapshot[path.name][1] == path.stat().st_size, "draft size mismatch")
|
||||
after = api(f"releases/tags/{tag}")
|
||||
release.require(
|
||||
after["id"] == before["id"] and asset_snapshot(after) == snapshot,
|
||||
"draft changed during verification",
|
||||
)
|
||||
return after
|
||||
|
||||
|
||||
def draft(root, directory, version, tag):
|
||||
workflow_guard()
|
||||
release.preflight(root, version, tag, check_git=True, signing=True)
|
||||
release.verify_release(root, directory, version, tag)
|
||||
with tempfile.TemporaryDirectory(prefix="shacraft-release-notes-") as temporary:
|
||||
notes = Path(temporary) / "notes.md"
|
||||
release_notes = release.read_json(directory / "latest.json")["notes"]
|
||||
instructions = (
|
||||
f"https://github.com/{release.REPOSITORY}/blob/{tag}/docs/updater-release.md"
|
||||
"#installed-package-migration-and-recovery"
|
||||
)
|
||||
notes.write_text(
|
||||
release_notes + "\n\n" +
|
||||
"[Установка, переход с 0.1.1 и восстановление](" + instructions + ").\n" +
|
||||
"Используйте прежний тип пакета. Обновление .deb выполняется через менеджер пакетов.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
gh(
|
||||
"release",
|
||||
"create",
|
||||
tag,
|
||||
*[str(path) for path in sorted(directory.iterdir())],
|
||||
"--repo",
|
||||
release.REPOSITORY,
|
||||
"--draft",
|
||||
"--verify-tag",
|
||||
"--title",
|
||||
f"ShaCraft Launcher {version}",
|
||||
"--notes-file",
|
||||
str(notes),
|
||||
)
|
||||
verify_draft(root, version, tag)
|
||||
print(
|
||||
"Complete draft uploaded and re-verified. Publication requires the separate protected workflow."
|
||||
)
|
||||
|
||||
|
||||
def publish(root, version, tag, confirmation):
|
||||
workflow_guard()
|
||||
release.require(
|
||||
confirmation == f"publish {tag}", "explicit publication confirmation does not match tag"
|
||||
)
|
||||
release.preflight(root, version, tag, check_git=True)
|
||||
pinned = (root / "src-tauri/updater-public-key.txt").read_text().strip()
|
||||
release.require(
|
||||
release.public_key() == pinned, "publication key differs from committed updater key"
|
||||
)
|
||||
gate("launcher-release-publish") # Re-check protection immediately before publication.
|
||||
current = api("releases/latest", missing=True)
|
||||
if current is not None:
|
||||
old_tag = current.get("tag_name", "")
|
||||
release.version_tag(old_tag.removeprefix("v"), old_tag)
|
||||
release.require(
|
||||
tuple(map(int, version.split("."))) > tuple(map(int, old_tag[1:].split("."))),
|
||||
"publication must advance the stable release version",
|
||||
)
|
||||
verify_draft(root, version, tag)
|
||||
gh("release", "edit", tag, "--repo", release.REPOSITORY, "--draft=false", "--latest")
|
||||
print("Verified release published by explicit protected operator workflow.")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
item = sub.add_parser("gate")
|
||||
item.add_argument("environment", choices=sorted(ENVIRONMENTS))
|
||||
for name in ("draft", "publish"):
|
||||
item = sub.add_parser(name)
|
||||
item.add_argument("--version", required=True)
|
||||
item.add_argument("--tag", required=True)
|
||||
if name == "draft":
|
||||
item.add_argument("--directory", required=True, type=Path)
|
||||
else:
|
||||
item.add_argument("--confirmation", required=True)
|
||||
args = parser.parse_args()
|
||||
if args.command == "gate":
|
||||
gate(args.environment)
|
||||
elif args.command == "draft":
|
||||
draft(args.root.resolve(), args.directory, args.version, args.tag)
|
||||
else:
|
||||
publish(args.root.resolve(), args.version, args.tag, args.confirmation)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (ValueError, OSError, subprocess.SubprocessError, KeyError, TypeError) as error:
|
||||
print(f"Release stopped: {error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,14 @@
|
||||
param([Parameter(Mandatory=$true)][string]$PackagePath)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
||||
# MSIDBOPEN_READONLY = 0. Reading COM properties never installs the package.
|
||||
$installer = New-Object -ComObject WindowsInstaller.Installer
|
||||
$summary = $installer.GetType().InvokeMember('SummaryInformation', 'GetProperty', $null, $installer, @($PackagePath, 0))
|
||||
$template = $summary.GetType().InvokeMember('Property', 'GetProperty', $null, $summary, @(7))
|
||||
$database = $installer.GetType().InvokeMember('OpenDatabase', 'InvokeMethod', $null, $installer, @($PackagePath, 0))
|
||||
$view = $database.GetType().InvokeMember('OpenView', 'InvokeMethod', $null, $database, @('SELECT `Value` FROM `Property` WHERE `Property` = ''ProductVersion'''))
|
||||
$view.GetType().InvokeMember('Execute', 'InvokeMethod', $null, $view, $null) | Out-Null
|
||||
$record = $view.GetType().InvokeMember('Fetch', 'InvokeMethod', $null, $view, $null)
|
||||
if ($null -eq $record) { throw 'Missing MSI ProductVersion' }
|
||||
$version = $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, @(1))
|
||||
@{ template = [string]$template; version = [string]$version } | ConvertTo-Json -Compress
|
||||
@@ -0,0 +1,531 @@
|
||||
"""Real Tauri signature checks using disposable keys, never release credentials."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import plistlib
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import release
|
||||
from release_assets_test import DraftAssetGateTests # noqa: F401; unittest discovery
|
||||
from release_gate_test import ReleaseGateTests # noqa: F401; unittest discovery
|
||||
import release_formats
|
||||
import release_github
|
||||
|
||||
ROOT = Path(os.environ.get("RELEASE_TEST_ROOT", Path(__file__).resolve().parents[1]))
|
||||
|
||||
|
||||
def fixture_pe(machine=0x8664, magic=0x20B):
|
||||
data = bytearray(256)
|
||||
data[:2] = b"MZ"
|
||||
struct.pack_into("<I", data, 60, 128)
|
||||
data[128:132] = b"PE\0\0"
|
||||
struct.pack_into("<H", data, 132, machine)
|
||||
struct.pack_into("<H", data, 152, magic)
|
||||
marker = b"__TAURI_BUNDLE_TYPE_VAR_UNK"
|
||||
data[200 : 200 + len(marker)] = marker
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def fixture_tar(files):
|
||||
stream = io.BytesIO()
|
||||
with tarfile.open(fileobj=stream, mode="w:gz") as archive:
|
||||
for name, data in files.items():
|
||||
info = tarfile.TarInfo(name)
|
||||
info.size = len(data)
|
||||
archive.addfile(info, io.BytesIO(data))
|
||||
return stream.getvalue()
|
||||
|
||||
|
||||
def fixture_deb(architecture="amd64", version="0.3.0"):
|
||||
control = fixture_tar(
|
||||
{
|
||||
"./control": f"Package: shacraft-launcher\nVersion: {version}\nArchitecture: {architecture}\n".encode()
|
||||
}
|
||||
)
|
||||
result = bytearray(b"!<arch>\n")
|
||||
for name, data in [
|
||||
("debian-binary", b"2.0\n"),
|
||||
("control.tar.gz", control),
|
||||
("data.tar.gz", fixture_tar({})),
|
||||
]:
|
||||
header = f"{name + '/':<16}{0:<12}{0:<6}{0:<6}{100644:<8}{len(data):<10}`\n".encode()
|
||||
assert len(header) == 60
|
||||
result.extend(header + data + (b"\n" if len(data) % 2 else b""))
|
||||
return bytes(result)
|
||||
|
||||
|
||||
def fixture_package(name, version="0.3.0"):
|
||||
if name.endswith(".app.tar.gz"):
|
||||
cpu = 0x0100000C if "aarch64" in name else 0x01000007
|
||||
executable = struct.pack("<IIIIIIII", 0xFEEDFACF, cpu, 0, 2, 0, 0, 0, 0)
|
||||
return fixture_tar(
|
||||
{
|
||||
"ShaCraft.app/Contents/Info.plist": plistlib.dumps(
|
||||
{
|
||||
"CFBundleExecutable": "shacraft-launcher",
|
||||
"CFBundleShortVersionString": version,
|
||||
}
|
||||
),
|
||||
"ShaCraft.app/Contents/MacOS/shacraft-launcher": executable,
|
||||
}
|
||||
)
|
||||
if name.endswith(".AppImage"):
|
||||
data = bytearray(64)
|
||||
data[:6] = b"\x7fELF\x02\x01"
|
||||
data[8:11] = b"AI\x02"
|
||||
struct.pack_into("<H", data, 18, 62)
|
||||
return bytes(data)
|
||||
if name.endswith(".deb"):
|
||||
return fixture_deb(version=version)
|
||||
if name.endswith(".dmg"):
|
||||
return b"koly" + bytes(508)
|
||||
if name.endswith(".msi"):
|
||||
data = bytearray(512)
|
||||
data[:8] = bytes.fromhex("d0cf11e0a1b11ae1")
|
||||
data[28:30] = b"\xfe\xff"
|
||||
return bytes(data)
|
||||
return fixture_pe(0x14C, 0x10B) # x86 NSIS wrapper is valid for an x64 payload.
|
||||
|
||||
|
||||
class ReleaseTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.temporary = tempfile.TemporaryDirectory(prefix="shacraft-release-tests-")
|
||||
cls.base = Path(cls.temporary.name)
|
||||
# A new unencrypted disposable key per test process. No static private
|
||||
# key or credentials are stored in the repository or test output.
|
||||
cls.key = cls.base / "test-only.key"
|
||||
environment = dict(os.environ)
|
||||
for name in (
|
||||
"TAURI_SIGNING_PRIVATE_KEY",
|
||||
"TAURI_SIGNING_PRIVATE_KEY_PATH",
|
||||
"TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
|
||||
):
|
||||
environment.pop(name, None)
|
||||
result = subprocess.run(
|
||||
[
|
||||
"node",
|
||||
str(ROOT / "node_modules/@tauri-apps/cli/tauri.js"),
|
||||
"signer",
|
||||
"generate",
|
||||
"--ci",
|
||||
"--password",
|
||||
"",
|
||||
"--write-keys",
|
||||
str(cls.key),
|
||||
],
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
timeout=120,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode:
|
||||
raise RuntimeError("disposable Tauri signer setup failed")
|
||||
cls.key.chmod(0o600)
|
||||
cls.key_values = {
|
||||
"SHACRAFT_UPDATER_PUBLIC_KEY": Path(str(cls.key) + ".pub").read_text().strip(),
|
||||
"TAURI_SIGNING_PRIVATE_KEY": cls.key.read_text().strip(),
|
||||
"TAURI_SIGNING_PRIVATE_KEY_PASSWORD": "",
|
||||
"SHACRAFT_UPDATER_TEST_BUILD": "0",
|
||||
}
|
||||
cls.originals = cls.base / "complete"
|
||||
cls.originals.mkdir()
|
||||
with patch.dict(os.environ, cls.key_values):
|
||||
for name in release.expected_names("0.3.0"):
|
||||
path = cls.originals / name
|
||||
path.write_bytes(fixture_package(name))
|
||||
release.signer(ROOT, path)
|
||||
data = release.metadata(
|
||||
ROOT, cls.originals, "0.3.0", "v0.3.0", "Test only", "2026-09-09T00:00:00Z"
|
||||
)
|
||||
(cls.originals / "latest.json").write_bytes(release.canonical(data))
|
||||
release.signer(ROOT, cls.originals / "latest.json")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.temporary.cleanup()
|
||||
|
||||
def setUp(self):
|
||||
self.case = tempfile.TemporaryDirectory(dir=self.base)
|
||||
self.directory = Path(self.case.name) / "assets"
|
||||
shutil.copytree(self.originals, self.directory)
|
||||
self.environment = patch.dict(os.environ, self.key_values)
|
||||
self.environment.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.environment.stop()
|
||||
self.case.cleanup()
|
||||
|
||||
def verify(self):
|
||||
release.verify_release(ROOT, self.directory, "0.3.0", "v0.3.0")
|
||||
|
||||
def resign_metadata(self, mutate):
|
||||
path = self.directory / "latest.json"
|
||||
data = json.loads(path.read_bytes())
|
||||
mutate(data)
|
||||
path.write_bytes(release.canonical(data))
|
||||
release.signer(ROOT, path)
|
||||
|
||||
def test_complete_release_and_metadata_generation_are_deterministic(self):
|
||||
self.verify()
|
||||
first = release.metadata(
|
||||
ROOT, self.directory, "0.3.0", "v0.3.0", "Test only", "2026-09-09T00:00:00Z"
|
||||
)
|
||||
self.assertEqual(release.canonical(first), (self.directory / "latest.json").read_bytes())
|
||||
|
||||
def test_package_bit_flip_fails_actual_plugin_verification(self):
|
||||
path = self.directory / release.filename("0.3.0", "linux-x86_64", ".AppImage")
|
||||
path.write_bytes(path.read_bytes() + b"tampered")
|
||||
with self.assertRaisesRegex(ValueError, "invalid updater signature"):
|
||||
self.verify()
|
||||
|
||||
def test_metadata_substitution_is_rejected_before_json_parsing(self):
|
||||
(self.directory / "latest.json").write_bytes(b"not even JSON")
|
||||
with self.assertRaisesRegex(ValueError, "invalid updater signature"):
|
||||
self.verify()
|
||||
|
||||
def test_valid_signature_for_other_artifact_does_not_suffice(self):
|
||||
linux = release.filename("0.3.0", "linux-x86_64", ".AppImage")
|
||||
windows = release.filename("0.3.0", "windows-x86_64", "-setup.exe")
|
||||
shutil.copyfile(self.directory / (windows + ".sig"), self.directory / (linux + ".sig"))
|
||||
with self.assertRaisesRegex(ValueError, "invalid updater signature"):
|
||||
self.verify()
|
||||
|
||||
def test_wrong_public_key_fails_real_verifier(self):
|
||||
encoded = release.public_key()
|
||||
import base64
|
||||
|
||||
lines = base64.b64decode(encoded).decode().splitlines()
|
||||
raw = bytearray(base64.b64decode(lines[1]))
|
||||
raw[-1] ^= 1
|
||||
lines[1] = base64.b64encode(raw).decode()
|
||||
wrong = base64.b64encode(("\n".join(lines) + "\n").encode()).decode()
|
||||
with patch.dict(os.environ, {"SHACRAFT_UPDATER_PUBLIC_KEY": wrong}):
|
||||
with self.assertRaisesRegex(ValueError, "invalid updater signature"):
|
||||
self.verify()
|
||||
|
||||
def test_all_platforms_and_manual_packages_are_mandatory(self):
|
||||
for name in sorted(release.expected_names("0.3.0")):
|
||||
path = self.directory / (name + ".sig")
|
||||
original = path.read_bytes()
|
||||
path.unlink()
|
||||
with self.subTest(name=name), self.assertRaisesRegex(ValueError, "asset set"):
|
||||
self.verify()
|
||||
path.write_bytes(original)
|
||||
|
||||
def test_extra_ci_marker_or_file_blocks_promotion(self):
|
||||
(self.directory / "CI_NOT_FOR_RELEASE.txt").write_text("test key")
|
||||
with self.assertRaisesRegex(ValueError, "asset set"):
|
||||
self.verify()
|
||||
|
||||
def test_signed_metadata_must_match_tag_and_exact_source_assets(self):
|
||||
alterations = [
|
||||
lambda x: x.update(version="0.2.0"),
|
||||
lambda x: x.update(tag="v0.4.0"),
|
||||
lambda x: x["platforms"].pop("darwin-aarch64"),
|
||||
lambda x: x["platforms"].update({"linux-aarch64": x["platforms"]["linux-x86_64"]}),
|
||||
lambda x: x["manualPackages"].pop("windows-x86_64-msi"),
|
||||
lambda x: x["platforms"]["linux-x86_64"].update(url="https://evil.invalid/package"),
|
||||
lambda x: x["platforms"]["linux-x86_64"].update(
|
||||
url=x["platforms"]["linux-x86_64"]["url"].replace("v0.3.0", "v0.2.0")
|
||||
),
|
||||
lambda x: x["platforms"]["linux-x86_64"].update(sha256="0" * 64),
|
||||
lambda x: x["platforms"]["linux-x86_64"].update(size=True),
|
||||
lambda x: x.update(pub_date="2026-09-09T00:00:00+03:00"),
|
||||
lambda x: x.update(extra="not allowed"),
|
||||
]
|
||||
for index, alter in enumerate(alterations):
|
||||
shutil.copyfile(self.originals / "latest.json", self.directory / "latest.json")
|
||||
self.resign_metadata(alter)
|
||||
with self.subTest(index=index), self.assertRaises(ValueError):
|
||||
self.verify()
|
||||
|
||||
def test_duplicate_json_fields_are_rejected_even_if_signed(self):
|
||||
path = self.directory / "latest.json"
|
||||
path.write_bytes(
|
||||
path.read_bytes().replace(
|
||||
b'"schemaVersion": 1,', b'"schemaVersion": 1, "schemaVersion": 1,'
|
||||
)
|
||||
)
|
||||
release.signer(ROOT, path)
|
||||
with self.assertRaisesRegex(ValueError, "duplicate JSON"):
|
||||
self.verify()
|
||||
|
||||
def test_collect_requires_generated_updater_sig_and_signs_manual_deb(self):
|
||||
bundle = Path(self.case.name) / "bundle"
|
||||
(bundle / "appimage").mkdir(parents=True)
|
||||
(bundle / "deb").mkdir()
|
||||
app = bundle / "appimage/Test.AppImage"
|
||||
app.write_bytes(fixture_package("Test.AppImage"))
|
||||
(bundle / "deb/Test.deb").write_bytes(fixture_package("Test.deb"))
|
||||
output = Path(self.case.name) / "collected"
|
||||
with self.assertRaisesRegex(ValueError, "missing generated updater signature"):
|
||||
release.collect(ROOT, bundle, output, "linux-x86_64", "0.3.0")
|
||||
shutil.rmtree(output)
|
||||
release.signer(ROOT, app)
|
||||
release.collect(ROOT, bundle, output, "linux-x86_64", "0.3.0")
|
||||
self.assertEqual(len(list(output.iterdir())), 4)
|
||||
for suffix in (".AppImage", ".deb"):
|
||||
data = output / release.filename("0.3.0", "linux-x86_64", suffix)
|
||||
release.verify_signature(ROOT, data, Path(str(data) + ".sig"))
|
||||
|
||||
def test_missing_key_fails_and_optional_password_never_prompts(self):
|
||||
data = self.directory / "latest.json"
|
||||
with patch.dict(os.environ, {"TAURI_SIGNING_PRIVATE_KEY": ""}):
|
||||
with self.assertRaisesRegex(ValueError, "PRIVATE_KEY is missing"):
|
||||
release.signer(ROOT, data)
|
||||
with patch.dict(os.environ):
|
||||
os.environ.pop("TAURI_SIGNING_PRIVATE_KEY_PASSWORD", None)
|
||||
release.signer(ROOT, data)
|
||||
release.verify_signature(ROOT, data, Path(str(data) + ".sig"))
|
||||
|
||||
def test_release_preflight_rejects_placeholder_mismatch_and_test_mode(self):
|
||||
source = Path(self.case.name) / "source"
|
||||
(source / "src-tauri").mkdir(parents=True)
|
||||
(source / "package.json").write_text('{"version":"0.3.0"}')
|
||||
(source / "package-lock.json").write_text(
|
||||
'{"version":"0.3.0","packages":{"":{"version":"0.3.0"}}}'
|
||||
)
|
||||
(source / "src-tauri/tauri.conf.json").write_text('{"version":"0.3.0"}')
|
||||
(source / "src-tauri/Cargo.toml").write_text('[package]\nversion="0.3.0"\n')
|
||||
(source / "src-tauri/updater-public-key.txt").write_text("UNCONFIGURED")
|
||||
with self.assertRaisesRegex(ValueError, "differs from committed"):
|
||||
release.preflight(source, "0.3.0", "v0.3.0", signing=True)
|
||||
with patch.dict(os.environ, {"SHACRAFT_UPDATER_TEST_BUILD": "1"}):
|
||||
with self.assertRaisesRegex(ValueError, "test keys cannot release"):
|
||||
release.preflight(source, "0.3.0", "v0.3.0", signing=True)
|
||||
(source / "package.json").write_text('{"version":"0.3.1"}')
|
||||
with self.assertRaisesRegex(ValueError, "source versions disagree"):
|
||||
release.preflight(source, "0.3.0", "v0.3.0")
|
||||
|
||||
|
||||
class PackageFormatTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="shacraft-format-tests-")
|
||||
self.root = Path(self.temporary.name)
|
||||
|
||||
def tearDown(self):
|
||||
self.temporary.cleanup()
|
||||
|
||||
def package(self, name, data=None):
|
||||
path = self.root / name
|
||||
path.write_bytes(fixture_package(name) if data is None else data)
|
||||
return path
|
||||
|
||||
def test_mac_archive_binds_main_executable_cpu_and_version(self):
|
||||
path = self.package("darwin-aarch64.app.tar.gz")
|
||||
release_formats.mac_app(path, "darwin-aarch64", "0.3.0")
|
||||
for platform, version in [("darwin-x86_64", "0.3.0"), ("darwin-aarch64", "0.2.0")]:
|
||||
with self.subTest(platform=platform, version=version), self.assertRaises(ValueError):
|
||||
release_formats.mac_app(path, platform, version)
|
||||
|
||||
def test_mac_archive_rejects_traversal_and_missing_binary(self):
|
||||
for files in (
|
||||
{"../Bad.app/Contents/Info.plist": b"bad"},
|
||||
{
|
||||
"ShaCraft.app/Contents/Info.plist": plistlib.dumps(
|
||||
{"CFBundleExecutable": "missing", "CFBundleShortVersionString": "0.3.0"}
|
||||
)
|
||||
},
|
||||
):
|
||||
path = self.package("darwin-aarch64.app.tar.gz", fixture_tar(files))
|
||||
with self.assertRaises(ValueError):
|
||||
release_formats.mac_app(path, "darwin-aarch64", "0.3.0")
|
||||
|
||||
def test_appimage_rejects_arm64_wrong_class_and_type1(self):
|
||||
path = self.package("linux-x86_64.AppImage")
|
||||
release_formats.appimage(path)
|
||||
for offset, data in [(18, struct.pack("<H", 183)), (4, b"\x01"), (10, b"\x01")]:
|
||||
bad = bytearray(fixture_package(path.name))
|
||||
bad[offset : offset + len(data)] = data
|
||||
path.write_bytes(bad)
|
||||
with self.assertRaises(ValueError):
|
||||
release_formats.appimage(path)
|
||||
|
||||
def test_deb_control_binds_architecture_and_version(self):
|
||||
path = self.package("linux-x86_64.deb")
|
||||
release_formats.deb(path, "0.3.0")
|
||||
for architecture, version in [("arm64", "0.3.0"), ("amd64", "0.2.0")]:
|
||||
path.write_bytes(fixture_deb(architecture, version))
|
||||
with self.assertRaises(ValueError):
|
||||
release_formats.deb(path, "0.3.0")
|
||||
|
||||
def test_nsis_stub_may_be_x86_but_extracted_main_must_equal_x64_build(self):
|
||||
wrapper = self.package("setup.exe")
|
||||
main = self.package("shacraft-launcher.exe", fixture_pe())
|
||||
listing = subprocess.CompletedProcess(
|
||||
[], 0, b"Path = setup.exe\nPath = $INSTDIR/shacraft-launcher.exe\n", b""
|
||||
)
|
||||
for extracted, success in [
|
||||
(release_formats.expected_nsis_payload(fixture_pe()), True),
|
||||
(fixture_pe(), False),
|
||||
(release_formats.expected_nsis_payload(fixture_pe(0xAA64, 0x20B)), False),
|
||||
(release_formats.expected_nsis_payload(fixture_pe()) + b"different", False),
|
||||
]:
|
||||
with (
|
||||
patch.object(release_formats.shutil, "which", return_value=str(main)),
|
||||
patch.object(
|
||||
release_formats.subprocess,
|
||||
"run",
|
||||
side_effect=[listing, subprocess.CompletedProcess([], 0, extracted, b"")],
|
||||
),
|
||||
):
|
||||
if success:
|
||||
release_formats.windows(wrapper, "-setup.exe", "0.3.0", main)
|
||||
else:
|
||||
with self.assertRaises(ValueError):
|
||||
release_formats.windows(wrapper, "-setup.exe", "0.3.0", main)
|
||||
|
||||
def test_nsis_identity_allows_only_the_actual_first_bundle_marker_patch(self):
|
||||
original = fixture_pe() + b"__TAURI_BUNDLE_TYPE_VAR_UNK"
|
||||
expected = release_formats.expected_nsis_payload(original)
|
||||
self.assertEqual(
|
||||
expected,
|
||||
original.replace(b"__TAURI_BUNDLE_TYPE_VAR_UNK", b"__TAURI_BUNDLE_TYPE_VAR_NSS", 1),
|
||||
)
|
||||
self.assertEqual(expected.count(b"__TAURI_BUNDLE_TYPE_VAR_UNK"), 1)
|
||||
with self.assertRaisesRegex(ValueError, "bundle-type marker"):
|
||||
release_formats.expected_nsis_payload(
|
||||
fixture_pe().replace(b"__TAURI_BUNDLE_TYPE_VAR_UNK", b"__TAURI_BUNDLE_TYPE_VAR_MSI")
|
||||
)
|
||||
# No certificate table/checksum/other byte range is ignored.
|
||||
mutated = bytearray(expected)
|
||||
mutated[190] ^= 1
|
||||
self.assertNotEqual(bytes(mutated), release_formats.expected_nsis_payload(original))
|
||||
self.assertNotEqual(
|
||||
expected.replace(b"_VAR_NSS", b"_VAR_MSI"),
|
||||
release_formats.expected_nsis_payload(original),
|
||||
)
|
||||
|
||||
def test_msi_readonly_metadata_requires_x64_and_version(self):
|
||||
msi = self.package("setup.msi")
|
||||
main = self.package("shacraft-launcher.exe", fixture_pe())
|
||||
for architecture, version, success in [
|
||||
("x64;1033", "0.3.0", True),
|
||||
("Intel;1033", "0.3.0", False),
|
||||
("x64;1033", "0.2.0", False),
|
||||
]:
|
||||
result = subprocess.CompletedProcess(
|
||||
[], 0, json.dumps({"template": architecture, "version": version}).encode(), b""
|
||||
)
|
||||
with patch.object(release_formats.subprocess, "run", return_value=result) as command:
|
||||
if success:
|
||||
release_formats.windows(msi, ".msi", "0.3.0", main)
|
||||
else:
|
||||
with self.assertRaises(ValueError):
|
||||
release_formats.windows(msi, ".msi", "0.3.0", main)
|
||||
self.assertIn("release_msi.ps1", command.call_args.args[0][4])
|
||||
|
||||
|
||||
class WorkflowPolicyTests(unittest.TestCase):
|
||||
def protected(self):
|
||||
return {
|
||||
"deployment_branch_policy": {
|
||||
"protected_branches": True,
|
||||
"custom_branch_policies": False,
|
||||
},
|
||||
"protection_rules": [
|
||||
{
|
||||
"type": "required_reviewers",
|
||||
"prevent_self_review": True,
|
||||
"reviewers": [{"type": "User", "reviewer": {"id": 123}}],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def test_protected_environment_requires_existing_independent_review(self):
|
||||
release_github.validate_environment(self.protected())
|
||||
for data in (
|
||||
None,
|
||||
{},
|
||||
{"deployment_branch_policy": {}},
|
||||
dict(self.protected(), protection_rules=[]),
|
||||
):
|
||||
with self.subTest(data=data), self.assertRaises(ValueError):
|
||||
release_github.validate_environment(data)
|
||||
for mutate in (
|
||||
lambda d: d["deployment_branch_policy"].update(protected_branches=False),
|
||||
lambda d: d["protection_rules"][0].update(prevent_self_review=False),
|
||||
lambda d: d["protection_rules"][0].update(reviewers=[]),
|
||||
):
|
||||
data = self.protected()
|
||||
mutate(data)
|
||||
with self.assertRaises(ValueError):
|
||||
release_github.validate_environment(data)
|
||||
|
||||
def test_gate_does_not_emit_missing_environment_name(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "output"
|
||||
env = {
|
||||
"GITHUB_REPOSITORY": release.REPOSITORY,
|
||||
"GITHUB_EVENT_NAME": "workflow_dispatch",
|
||||
"GITHUB_REF": "refs/heads/main",
|
||||
"GITHUB_OUTPUT": str(output),
|
||||
}
|
||||
with (
|
||||
patch.dict(os.environ, env),
|
||||
patch.object(release_github, "api", side_effect=[{"protected": True}, None]),
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "absent"):
|
||||
release_github.gate("launcher-release")
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
def test_publication_never_runs_without_exact_confirmation(self):
|
||||
env = {
|
||||
"GITHUB_REPOSITORY": release.REPOSITORY,
|
||||
"GITHUB_EVENT_NAME": "workflow_dispatch",
|
||||
"GITHUB_REF": "refs/heads/main",
|
||||
}
|
||||
with patch.dict(os.environ, env), patch.object(release_github, "gh") as client:
|
||||
with self.assertRaisesRegex(ValueError, "confirmation"):
|
||||
release_github.publish(ROOT, "0.3.0", "v0.3.0", "publish v0.2.0")
|
||||
client.assert_not_called()
|
||||
|
||||
def test_asset_snapshot_rejects_public_or_incomplete_draft(self):
|
||||
data = {
|
||||
"draft": True,
|
||||
"prerelease": False,
|
||||
"assets": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "latest.json",
|
||||
"size": 10,
|
||||
"state": "uploaded",
|
||||
"digest": "sha256:abc",
|
||||
}
|
||||
],
|
||||
}
|
||||
self.assertEqual(release_github.asset_snapshot(data)["latest.json"][0], 1)
|
||||
for change in (
|
||||
{"draft": False},
|
||||
{"prerelease": True},
|
||||
{"assets": [dict(data["assets"][0], state="new")]},
|
||||
{"assets": data["assets"] * 2},
|
||||
):
|
||||
with self.assertRaises(ValueError):
|
||||
release_github.asset_snapshot(dict(data, **change))
|
||||
|
||||
def test_version_and_tag_cannot_inject_paths_or_exceed_msi_limits(self):
|
||||
release.version_tag("0.3.0", "v0.3.0")
|
||||
for version, tag in (
|
||||
("0.3.0", "main"),
|
||||
("0.3.0", "v0.3.0/evil"),
|
||||
("01.3.0", "v01.3.0"),
|
||||
("0.3.0-beta", "v0.3.0-beta"),
|
||||
("256.0.0", "v256.0.0"),
|
||||
("0.0.65536", "v0.0.65536"),
|
||||
):
|
||||
with self.subTest(version=version, tag=tag), self.assertRaises(ValueError):
|
||||
release.version_tag(version, tag)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user