Add signed launcher self-updates and publish Linux 0.1.3
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
"""Publisher policy and real minisign verification; keys exist only in tempdirs."""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import copy
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import publish_launcher_update as publisher
|
||||
|
||||
MINISIGN = os.environ.get("SHACRAFT_TEST_MINISIGN", "minisign")
|
||||
|
||||
|
||||
class PolicyTests(unittest.TestCase):
|
||||
def test_stable_versions_are_strict_and_order_numerically(self):
|
||||
self.assertGreater(publisher.version_tuple("0.1.10"), publisher.version_tuple("0.1.9"))
|
||||
for value in ("v0.1.3", "0.01.3", "0.1.3-beta", "0.1.3+build", "../0.1.3", 3):
|
||||
with self.subTest(value=value), self.assertRaises(publisher.InvalidRelease):
|
||||
publisher.version_tuple(value)
|
||||
|
||||
def test_platform_filename_policy(self):
|
||||
publisher.artifact_name("linux-x86_64", "ShaCraft.Launcher_0.1.3_amd64.AppImage")
|
||||
for platform, filename in (
|
||||
("linux-x86_64", "../bad.AppImage"), ("linux-x86_64", "foo.AppImage?secret"),
|
||||
("linux-x86_64", "%2e%2e.AppImage"), ("linux-x86_64", "install.exe"),
|
||||
("unknown", "test.AppImage"), ("darwin-aarch64", "installer.dmg"),
|
||||
):
|
||||
with self.subTest(filename=filename), self.assertRaises(publisher.InvalidRelease):
|
||||
publisher.artifact_name(platform, filename)
|
||||
|
||||
def test_duplicate_json_keys_are_rejected(self):
|
||||
with self.assertRaises(publisher.InvalidRelease):
|
||||
publisher.strict_json(b'{"version":"0.1.3","version":"9.0.0"}')
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which(MINISIGN), "minisign CLI required for signature integration tests")
|
||||
class SignatureTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="shacraft-update-test-")
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name)
|
||||
self.key = self.root / "fixture.key"
|
||||
public = self.root / "fixture.pub"
|
||||
subprocess.run(
|
||||
[MINISIGN, "-G", "-W", "-p", str(public), "-s", str(self.key)],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True,
|
||||
)
|
||||
self.public_key = base64.b64encode(public.read_bytes()).decode("ascii")
|
||||
self.downloads = self.root / "downloads"
|
||||
release = self.downloads / "0.1.3"
|
||||
release.mkdir(parents=True)
|
||||
self.artifact = release / "fixture.AppImage"
|
||||
self.artifact.write_bytes(b"isolated ShaCraft updater fixture; not an executable")
|
||||
self.payload = {
|
||||
"version": "0.1.3", "notes": "Проверка обновления", "pub_date": "2026-09-10T00:00:00Z",
|
||||
"platforms": {"linux-x86_64": {
|
||||
"url": publisher.ORIGIN + "0.1.3/fixture.AppImage",
|
||||
"signature": self.sign(self.artifact),
|
||||
}},
|
||||
}
|
||||
self.payload_path = self.root / "payload.json"
|
||||
self.output = self.root / "stable.json"
|
||||
|
||||
def sign(self, path):
|
||||
signature_path = path.with_name(path.name + ".minisig")
|
||||
subprocess.run(
|
||||
[MINISIGN, "-S", "-s", str(self.key), "-m", str(path), "-x", str(signature_path), "-q"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True,
|
||||
)
|
||||
signature = base64.b64encode(signature_path.read_bytes()).decode("ascii")
|
||||
path.with_name(path.name + ".sig").write_text(signature, encoding="ascii")
|
||||
return signature
|
||||
|
||||
def publish(self, payload=None, dry_run=False):
|
||||
self.payload_path.write_bytes(publisher.canonical(payload or self.payload))
|
||||
self.sign(self.payload_path)
|
||||
args = argparse.Namespace(
|
||||
payload=self.payload_path, signature=self.payload_path.with_name("payload.json.sig"),
|
||||
output=self.output, downloads_root=self.downloads, minisign=MINISIGN, dry_run=dry_run,
|
||||
)
|
||||
return publisher.publish(args, self.public_key)
|
||||
|
||||
def test_valid_signed_feed_binds_metadata_and_artifact(self):
|
||||
self.publish()
|
||||
envelope = publisher.strict_json(self.output.read_bytes())
|
||||
payload_bytes = publisher.decode_tauri(envelope["signedPayload"])
|
||||
self.assertEqual(publisher.strict_json(payload_bytes), self.payload)
|
||||
self.assertEqual({key: envelope[key] for key in publisher.FIELDS}, self.payload)
|
||||
self.assertIn("metadataSignature", envelope)
|
||||
self.assertEqual(self.output.stat().st_mode & 0o777, 0o644)
|
||||
|
||||
def test_tampered_artifact_is_rejected_before_publication(self):
|
||||
self.artifact.write_bytes(b"replaced executable")
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "signature verification failed"):
|
||||
self.publish()
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_tampered_metadata_signature_is_rejected(self):
|
||||
self.payload_path.write_bytes(publisher.canonical(self.payload))
|
||||
signature = self.sign(self.payload_path)
|
||||
self.payload["notes"] = "Changed after signing"
|
||||
self.payload_path.write_bytes(publisher.canonical(self.payload))
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "signature verification failed"):
|
||||
publisher.verify_signature(self.payload_path, signature, self.public_key, MINISIGN)
|
||||
|
||||
def test_same_version_or_downgrade_keeps_original_feed(self):
|
||||
self.publish()
|
||||
original = self.output.read_bytes()
|
||||
for version in ("0.1.3", "0.1.2"):
|
||||
payload = copy.deepcopy(self.payload)
|
||||
payload["version"] = version
|
||||
with self.subTest(version=version), self.assertRaisesRegex(
|
||||
publisher.InvalidRelease, "strictly increase"
|
||||
):
|
||||
self.publish(payload)
|
||||
self.assertEqual(self.output.read_bytes(), original)
|
||||
|
||||
def test_foreign_url_cannot_be_signed_into_feed(self):
|
||||
self.payload["platforms"]["linux-x86_64"]["url"] = "https://example.com/test.AppImage"
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "fixed ShaCraft release URL"):
|
||||
self.publish()
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_previous_version_must_also_be_authenticated(self):
|
||||
self.publish()
|
||||
envelope = publisher.strict_json(self.output.read_bytes())
|
||||
envelope["version"] = "99.0.0"
|
||||
self.output.write_bytes(publisher.canonical(envelope))
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "differ from signed metadata"):
|
||||
self.publish()
|
||||
|
||||
def test_missing_signature_or_symlink_is_rejected(self):
|
||||
original = self.artifact.read_bytes()
|
||||
target = self.root / "outside.AppImage"
|
||||
target.write_bytes(original)
|
||||
self.artifact.unlink()
|
||||
self.artifact.symlink_to(target)
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "regular file"):
|
||||
self.publish()
|
||||
self.artifact.unlink()
|
||||
self.artifact.write_bytes(original)
|
||||
self.payload["platforms"]["linux-x86_64"].pop("signature")
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "exactly url and signature"):
|
||||
self.publish()
|
||||
|
||||
def test_dry_run_verifies_without_creating_feed(self):
|
||||
self.publish(dry_run=True)
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user