Support signed deb self-updates with system authorization in 0.1.4
This commit is contained in:
@@ -15,13 +15,17 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import selectors
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
ORIGIN = "https://shacraft.ru/downloads/shacraft-launcher/"
|
||||
PLATFORMS = {
|
||||
"linux-x86_64": (".AppImage",),
|
||||
"linux-x86_64-appimage": (".AppImage",),
|
||||
"linux-x86_64-deb": (".deb",),
|
||||
"windows-x86_64": (".exe", ".msi"),
|
||||
"darwin-x86_64": (".app.tar.gz",),
|
||||
"darwin-aarch64": (".app.tar.gz",),
|
||||
@@ -29,6 +33,9 @@ PLATFORMS = {
|
||||
FIELDS = {"version", "notes", "pub_date", "platforms"}
|
||||
MAX_ARTIFACT_BYTES = 256 * 1024 * 1024
|
||||
MAX_METADATA_BYTES = 64 * 1024
|
||||
DEB_PACKAGE = "sha-craft-launcher"
|
||||
DPKG_DEB = "/usr/bin/dpkg-deb"
|
||||
PACKAGE_TOOL_ENV = {"PATH": "/usr/bin:/bin", "LC_ALL": "C"}
|
||||
|
||||
|
||||
class InvalidRelease(ValueError):
|
||||
@@ -110,6 +117,67 @@ def verify_signature(artifact, signature, public_key, minisign):
|
||||
raise InvalidRelease("signature verification failed")
|
||||
|
||||
|
||||
def bounded_command_output(command, limit=4096, timeout=10):
|
||||
"""Run a fixed package inspector without shell, inherited hooks or unbounded output."""
|
||||
process = None
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL, env=PACKAGE_TOOL_ENV,
|
||||
)
|
||||
deadline = time.monotonic() + timeout
|
||||
output = bytearray()
|
||||
with selectors.DefaultSelector() as selector:
|
||||
selector.register(process.stdout, selectors.EVENT_READ)
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0 or not selector.select(remaining):
|
||||
raise InvalidRelease("package inspection timed out")
|
||||
chunk = os.read(process.stdout.fileno(), min(4096, limit + 1 - len(output)))
|
||||
if not chunk:
|
||||
break
|
||||
output.extend(chunk)
|
||||
if len(output) > limit:
|
||||
raise InvalidRelease("package inspection exceeds output limit")
|
||||
returncode = process.wait(timeout=max(0.001, deadline - time.monotonic()))
|
||||
if returncode != 0:
|
||||
raise InvalidRelease("package inspection failed")
|
||||
return bytes(output)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise InvalidRelease("package inspection could not run") from exc
|
||||
finally:
|
||||
if process is not None:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait()
|
||||
process.stdout.close()
|
||||
|
||||
|
||||
def validate_artifact_format(platform, path, version):
|
||||
if platform in {"linux-x86_64", "linux-x86_64-appimage"}:
|
||||
with path.open("rb") as stream:
|
||||
header = stream.read(64)
|
||||
if (len(header) < 64 or header[:7] != b"\x7fELF\x02\x01\x01"
|
||||
or header[8:11] != b"AI\x02" or header[18:20] != b"\x3e\x00"):
|
||||
raise InvalidRelease("AppImage must be a type-2 x86_64 ELF image")
|
||||
elif platform == "linux-x86_64-deb":
|
||||
# Inspect only authenticated package bytes; dpkg-deb does not run maintainer scripts.
|
||||
output = bounded_command_output([
|
||||
DPKG_DEB, "--showformat=${Package}\n${Version}\n${Architecture}\n", "--show", str(path),
|
||||
])
|
||||
expected = f"{DEB_PACKAGE}\n{version}\namd64\n".encode("ascii")
|
||||
if output != expected:
|
||||
raise InvalidRelease("deb identity must match sha-craft-launcher, signed version and amd64")
|
||||
|
||||
|
||||
def validate_linux_aliases(platforms):
|
||||
if "linux-x86_64-appimage" in platforms or "linux-x86_64-deb" in platforms:
|
||||
legacy = platforms.get("linux-x86_64")
|
||||
exact = platforms.get("linux-x86_64-appimage")
|
||||
if legacy is None or exact is None or legacy != exact:
|
||||
raise InvalidRelease("format-aware Linux releases require identical legacy and AppImage entries")
|
||||
|
||||
|
||||
def strict_json(data):
|
||||
def unique(pairs):
|
||||
result = {}
|
||||
@@ -163,6 +231,7 @@ def validate_payload(payload, downloads_root, public_key, minisign):
|
||||
platforms = payload["platforms"]
|
||||
if not isinstance(platforms, dict) or not platforms:
|
||||
raise InvalidRelease("at least one signed updater artifact is required")
|
||||
validate_linux_aliases(platforms)
|
||||
release_dir = downloads_root.resolve() / payload["version"]
|
||||
if release_dir.is_symlink() or not release_dir.is_dir():
|
||||
raise InvalidRelease("release directory must be an existing real directory")
|
||||
@@ -177,6 +246,7 @@ def validate_payload(payload, downloads_root, public_key, minisign):
|
||||
local_path = release_dir / filename
|
||||
before = regular_file(local_path, MAX_ARTIFACT_BYTES)
|
||||
verify_signature(local_path, artifact["signature"], public_key, minisign)
|
||||
validate_artifact_format(platform, local_path, payload["version"])
|
||||
after = regular_file(local_path, MAX_ARTIFACT_BYTES)
|
||||
if (before.st_ino, before.st_size, before.st_mtime_ns) != (
|
||||
after.st_ino, after.st_size, after.st_mtime_ns
|
||||
|
||||
@@ -7,6 +7,7 @@ import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
@@ -15,6 +16,14 @@ import publish_launcher_update as publisher
|
||||
MINISIGN = os.environ.get("SHACRAFT_TEST_MINISIGN", "minisign")
|
||||
|
||||
|
||||
def appimage_fixture():
|
||||
header = bytearray(64)
|
||||
header[:7] = b"\x7fELF\x02\x01\x01"
|
||||
header[8:11] = b"AI\x02"
|
||||
header[18:20] = b"\x3e\x00"
|
||||
return bytes(header) + b"isolated format fixture; not a runnable launcher"
|
||||
|
||||
|
||||
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"))
|
||||
@@ -24,10 +33,14 @@ class PolicyTests(unittest.TestCase):
|
||||
|
||||
def test_platform_filename_policy(self):
|
||||
publisher.artifact_name("linux-x86_64", "ShaCraft.Launcher_0.1.3_amd64.AppImage")
|
||||
publisher.artifact_name("linux-x86_64-appimage", "ShaCraft.Launcher_0.1.4_amd64.AppImage")
|
||||
publisher.artifact_name("linux-x86_64-deb", "ShaCraft.Launcher_0.1.4_amd64.deb")
|
||||
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"),
|
||||
("linux-x86_64", "install.deb"), ("linux-x86_64-appimage", "install.deb"),
|
||||
("linux-x86_64-deb", "install.AppImage"),
|
||||
):
|
||||
with self.subTest(filename=filename), self.assertRaises(publisher.InvalidRelease):
|
||||
publisher.artifact_name(platform, filename)
|
||||
@@ -36,6 +49,20 @@ class PolicyTests(unittest.TestCase):
|
||||
with self.assertRaises(publisher.InvalidRelease):
|
||||
publisher.strict_json(b'{"version":"0.1.3","version":"9.0.0"}')
|
||||
|
||||
def test_package_inspection_is_bounded_and_clears_environment(self):
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "output limit"):
|
||||
publisher.bounded_command_output([sys.executable, "-c", "print('x' * 8192)"], limit=128)
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "timed out"):
|
||||
publisher.bounded_command_output([sys.executable, "-c", "import time; time.sleep(30)"], timeout=0.1)
|
||||
os.environ["SHACRAFT_INSPECTION_SECRET_TEST"] = "must-not-be-inherited"
|
||||
try:
|
||||
output = publisher.bounded_command_output([
|
||||
sys.executable, "-c", "import os; print(os.getenv('SHACRAFT_INSPECTION_SECRET_TEST', 'clean'))",
|
||||
])
|
||||
finally:
|
||||
del os.environ["SHACRAFT_INSPECTION_SECRET_TEST"]
|
||||
self.assertEqual(output, b"clean\n")
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which(MINISIGN), "minisign CLI required for signature integration tests")
|
||||
class SignatureTests(unittest.TestCase):
|
||||
@@ -54,7 +81,7 @@ class SignatureTests(unittest.TestCase):
|
||||
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.artifact.write_bytes(appimage_fixture())
|
||||
self.payload = {
|
||||
"version": "0.1.3", "notes": "Проверка обновления", "pub_date": "2026-09-10T00:00:00Z",
|
||||
"platforms": {"linux-x86_64": {
|
||||
@@ -151,6 +178,110 @@ class SignatureTests(unittest.TestCase):
|
||||
self.publish(dry_run=True)
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def make_deb(self, package="sha-craft-launcher", version=None, architecture="amd64"):
|
||||
if not Path(publisher.DPKG_DEB).is_file():
|
||||
self.skipTest("dpkg-deb required for real deb validation")
|
||||
version = version or self.payload["version"]
|
||||
tree = self.root / "deb-tree"
|
||||
control = tree / "DEBIAN"
|
||||
control.mkdir(parents=True, exist_ok=True)
|
||||
(control / "control").write_text(
|
||||
f"Package: {package}\nVersion: {version}\nArchitecture: {architecture}\n"
|
||||
"Maintainer: Test <test@example.invalid>\nDescription: isolated updater fixture\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
# Inspection must not execute a package script, even for an authenticated package.
|
||||
script = control / "preinst"
|
||||
script.write_text(f"#!/bin/sh\ntouch '{self.root / 'script-executed'}'\n", encoding="ascii")
|
||||
script.chmod(0o755)
|
||||
deb = self.artifact.with_name("fixture.deb")
|
||||
subprocess.run(
|
||||
[publisher.DPKG_DEB, "--build", "--root-owner-group", str(tree), str(deb)],
|
||||
env=publisher.PACKAGE_TOOL_ENV, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
timeout=10, check=True,
|
||||
)
|
||||
self.payload["platforms"]["linux-x86_64-appimage"] = copy.deepcopy(
|
||||
self.payload["platforms"]["linux-x86_64"]
|
||||
)
|
||||
self.payload["platforms"]["linux-x86_64-deb"] = {
|
||||
"url": publisher.ORIGIN + self.payload["version"] + "/fixture.deb", "signature": self.sign(deb),
|
||||
}
|
||||
return deb
|
||||
|
||||
def test_format_aware_release_preserves_legacy_appimage_and_verifies_real_deb(self):
|
||||
self.make_deb()
|
||||
notes = self.root / "notes.txt"
|
||||
notes.write_text(self.payload["notes"], encoding="utf-8")
|
||||
args = argparse.Namespace(
|
||||
version="0.1.3", artifact=[
|
||||
"linux-x86_64=fixture.AppImage", "linux-x86_64-appimage=fixture.AppImage",
|
||||
"linux-x86_64-deb=fixture.deb",
|
||||
], downloads_root=self.downloads, notes_file=notes, payload=self.payload_path,
|
||||
pub_date=self.payload["pub_date"], minisign=MINISIGN,
|
||||
)
|
||||
self.assertEqual(publisher.prepare(args, self.public_key), self.payload)
|
||||
self.publish()
|
||||
feed = publisher.verified_previous(self.output.read_bytes(), self.public_key, MINISIGN)
|
||||
self.assertEqual(feed["platforms"]["linux-x86_64"], feed["platforms"]["linux-x86_64-appimage"])
|
||||
self.assertEqual(set(feed["platforms"]), {"linux-x86_64", "linux-x86_64-appimage", "linux-x86_64-deb"})
|
||||
self.assertFalse((self.root / "script-executed").exists())
|
||||
|
||||
def test_authenticated_legacy_feed_advances_to_format_aware_release(self):
|
||||
self.publish()
|
||||
old_release = self.artifact.parent
|
||||
old_bytes = self.artifact.read_bytes()
|
||||
new_release = self.downloads / "0.1.4"
|
||||
shutil.copytree(old_release, new_release)
|
||||
self.artifact = new_release / self.artifact.name
|
||||
self.payload["version"] = "0.1.4"
|
||||
self.payload["platforms"]["linux-x86_64"]["url"] = publisher.ORIGIN + "0.1.4/fixture.AppImage"
|
||||
self.make_deb()
|
||||
self.publish()
|
||||
feed = publisher.verified_previous(self.output.read_bytes(), self.public_key, MINISIGN)
|
||||
self.assertEqual(feed["version"], "0.1.4")
|
||||
self.assertEqual(len(feed["platforms"]), 3)
|
||||
self.assertEqual((old_release / self.artifact.name).read_bytes(), old_bytes)
|
||||
|
||||
def test_linux_format_release_cannot_drop_or_repoint_legacy_entry(self):
|
||||
self.make_deb()
|
||||
for key in ("linux-x86_64", "linux-x86_64-appimage"):
|
||||
payload = copy.deepcopy(self.payload)
|
||||
del payload["platforms"][key]
|
||||
with self.subTest(key=key), self.assertRaisesRegex(publisher.InvalidRelease, "identical legacy"):
|
||||
self.publish(payload)
|
||||
payload = copy.deepcopy(self.payload)
|
||||
payload["platforms"]["linux-x86_64-appimage"]["url"] = publisher.ORIGIN + "0.1.3/other.AppImage"
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "identical legacy"):
|
||||
self.publish(payload)
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_deb_identity_must_match_application_signed_version_and_architecture(self):
|
||||
for changes in ({"package": "another-launcher"}, {"version": "9.0.0"}, {"architecture": "arm64"}):
|
||||
with self.subTest(changes=changes):
|
||||
self.make_deb(**changes)
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "deb identity"):
|
||||
self.publish()
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_signed_invalid_deb_is_rejected_without_running_package_scripts(self):
|
||||
deb = self.make_deb()
|
||||
deb.write_bytes(b"not a Debian archive")
|
||||
self.payload["platforms"]["linux-x86_64-deb"]["signature"] = self.sign(deb)
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "package inspection failed"):
|
||||
self.publish()
|
||||
self.assertFalse((self.root / "script-executed").exists())
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_signed_wrong_appimage_format_is_rejected(self):
|
||||
for changed_slice, replacement in ((slice(8, 11), b"AI\x01"), (slice(18, 20), b"\xb7\x00")):
|
||||
malformed = bytearray(appimage_fixture())
|
||||
malformed[changed_slice] = replacement
|
||||
self.artifact.write_bytes(malformed)
|
||||
self.payload["platforms"]["linux-x86_64"]["signature"] = self.sign(self.artifact)
|
||||
with self.subTest(replacement=replacement), self.assertRaisesRegex(publisher.InvalidRelease, "type-2 x86_64"):
|
||||
self.publish()
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user