mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-30 06:47:33 +00:00
The installers rebuild the app with jpackage, so the createDistributable jar fix never reached them. Ship skiko's native library as plain files copied out of skiko-awt-runtime-*.jar instead of the jar, which Compose 1.12 rewrites with the build time on every OS. On macOS the plugin also re-zips every jar with the build time: fix those ZIP header times before the DMG signs the app. Turn off line-ending conversion, which made Windows checkouts bundle CRLF resources. desktopdiff.py now also checks .deb/.rpm (with their scripts), .msi and .exe (files, tables and custom action DLLs, read with msitools) and .dmg, and prints the bundled JDK with --jdk. Dockerfile.desktop also rebuilds the .deb and .rpm; the README covers rebuilding on Windows and macOS.
447 lines
19 KiB
Python
447 lines
19 KiB
Python
#! /usr/bin/env python3
|
|
|
|
"""Compares two builds of the Amber desktop app.
|
|
|
|
Each argument is a release download or a directory:
|
|
|
|
.tar.xz read directly
|
|
.AppImage unpacked with unsquashfs (squashfs-tools)
|
|
.deb unpacked with dpkg-deb; its maintainer scripts and control file too
|
|
.rpm unpacked with rpm2cpio and cpio; its scripts and dependencies
|
|
(read with rpm) too
|
|
.msi unpacked with msiextract, and its tables and streams (the custom
|
|
action DLLs, icons) exported with msiinfo: msitools, on Linux or
|
|
WSL. msiexec /a is not used: it runs the package's own actions.
|
|
.exe the Windows installer: the .msi it carries is checked like one,
|
|
and the rest of the file must match byte for byte
|
|
.dmg mounted read-only with hdiutil (macOS only)
|
|
directory an unpacked app image: the folder with bin/ and lib/ (Linux),
|
|
Amber.exe and app/ (Windows), Amber.app (macOS), or an AppDir
|
|
|
|
Packages are unpacked, never run or installed. Every file, directory and
|
|
symlink must match: same path, same type, same symlink target, same executable
|
|
bit and the same bytes. Timestamps, owners and the other permission bits are
|
|
not compared, since they depend on the machine and umask, not on the build.
|
|
Nothing else is ignored. Compare like with like: an installer adds a few files
|
|
of its own (a .desktop entry, a copyright file) that the app image lacks.
|
|
|
|
The app bundles the runtime of the JDK it was built with, so a rebuild must use
|
|
the same Temurin release; the JDK each build bundles is printed.
|
|
"""
|
|
|
|
import hashlib
|
|
import os
|
|
import re
|
|
import stat
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
|
|
JVM_LIBRARIES = {"libjvm.so", "jvm.dll", "libjvm.dylib"}
|
|
JVM_VERSION = re.compile(rb"Server VM \(([0-9][^)]{0,40})\)")
|
|
|
|
|
|
class Entry:
|
|
def __init__(self, kind, executable=False, target=None, digest=None, jdk=None, note=False):
|
|
self.kind = kind
|
|
self.executable = executable
|
|
self.target = target
|
|
self.digest = digest
|
|
self.jdk = jdk
|
|
# Reported when it differs, but doesn't fail the comparison.
|
|
self.note = note
|
|
|
|
def describe(self):
|
|
if self.kind == "symlink":
|
|
return "symlink to %r" % self.target
|
|
if self.kind == "file":
|
|
return "file%s sha256=%s" % (" (executable)" if self.executable else "", self.digest)
|
|
return self.kind
|
|
|
|
def __eq__(self, other):
|
|
return (self.kind, self.executable, self.target, self.digest) == \
|
|
(other.kind, other.executable, other.target, other.digest)
|
|
|
|
|
|
def fileEntry(name, stream, executable):
|
|
digest = hashlib.sha256()
|
|
jdk = None
|
|
if os.path.basename(name) in JVM_LIBRARIES:
|
|
data = stream.read()
|
|
digest.update(data)
|
|
match = JVM_VERSION.search(data)
|
|
jdk = match.group(1).decode("ascii", "replace") if match else None
|
|
else:
|
|
for chunk in iter(lambda: stream.read(1 << 20), b""):
|
|
digest.update(chunk)
|
|
return Entry("file", executable, digest=digest.hexdigest(), jdk=jdk)
|
|
|
|
|
|
def isExecutable(mode):
|
|
return bool(mode & stat.S_IXUSR)
|
|
|
|
|
|
def run(command, **kwargs):
|
|
try:
|
|
return subprocess.run(command, stdout=subprocess.DEVNULL, **kwargs).returncode == 0
|
|
except FileNotFoundError:
|
|
print("%s is not installed" % command[0])
|
|
return False
|
|
|
|
|
|
def bytesEntry(data, note=False):
|
|
return Entry("file", digest=hashlib.sha256(data).hexdigest(), note=note)
|
|
|
|
|
|
def splitDebDepends(control):
|
|
"""The control file without its Depends field, and the field. jpackage
|
|
derives Depends from the packages that provide the app's libraries on the
|
|
build machine, so it moves with the distro's updates while the app stays
|
|
the same; it only names distro packages."""
|
|
kept, depends, inDepends = [], [], False
|
|
for line in control.splitlines(keepends=True):
|
|
if line[:1] in (b" ", b"\t") and inDepends:
|
|
depends.append(line)
|
|
continue
|
|
inDepends = line.startswith(b"Depends:")
|
|
(depends if inDepends else kept).append(line)
|
|
return b"".join(kept), b"".join(depends)
|
|
|
|
|
|
def output(command, **kwargs):
|
|
"""The command's stdout, or None if it is missing or fails."""
|
|
try:
|
|
result = subprocess.run(command, stdout=subprocess.PIPE, **kwargs)
|
|
except FileNotFoundError:
|
|
print("%s is not installed" % command[0])
|
|
return None
|
|
return result.stdout if result.returncode == 0 else None
|
|
|
|
|
|
def peResources(data):
|
|
"""Lists (type, name, offset, size) for the resources of a PE file, with
|
|
each type and name an int id or a string, or returns None if it isn't one."""
|
|
if data[:2] != b"MZ":
|
|
return None
|
|
pe, = struct.unpack_from("<I", data, 0x3C)
|
|
if data[pe:pe + 4] != b"PE\0\0":
|
|
return None
|
|
sections, optionalSize = struct.unpack_from("<H12xH", data, pe + 6)
|
|
optional = pe + 24
|
|
magic, = struct.unpack_from("<H", data, optional)
|
|
directories = optional + (112 if magic == 0x20B else 96)
|
|
resourceRva, _ = struct.unpack_from("<II", data, directories + 2 * 8)
|
|
table = optional + optionalSize
|
|
layout = [struct.unpack_from("<8xIIII", data, table + 40 * i) for i in range(sections)]
|
|
|
|
def offset(rva):
|
|
for size, address, rawSize, raw in layout:
|
|
if address <= rva < address + max(size, rawSize):
|
|
return raw + rva - address
|
|
raise ValueError("RVA %#x is outside every section" % rva)
|
|
|
|
base = offset(resourceRva)
|
|
|
|
def name(value):
|
|
if not value & 0x80000000:
|
|
return value
|
|
at = base + (value & 0x7FFFFFFF)
|
|
length, = struct.unpack_from("<H", data, at)
|
|
return data[at + 2:at + 2 + 2 * length].decode("utf-16-le")
|
|
|
|
def directory(at):
|
|
named, ids = struct.unpack_from("<HH", data, base + at + 12)
|
|
for i in range(named + ids):
|
|
key, child = struct.unpack_from("<II", data, base + at + 16 + 8 * i)
|
|
yield name(key), child
|
|
|
|
found = []
|
|
for kind, types in directory(0):
|
|
for resource, names in directory(types & 0x7FFFFFFF):
|
|
for _, language in directory(names & 0x7FFFFFFF):
|
|
rva, size = struct.unpack_from("<II", data, base + language)
|
|
found.append((kind, resource, offset(rva), size))
|
|
return found
|
|
|
|
|
|
class DesktopDiff:
|
|
def entries(self, path):
|
|
"""Maps each path relative to the build's root to its Entry, or
|
|
returns None when the build can't be verified."""
|
|
if os.path.isdir(path):
|
|
return self.directoryEntries(path)
|
|
unpack = {
|
|
".tar.xz": None,
|
|
".AppImage": self.unpackAppImage,
|
|
".deb": self.unpackDeb,
|
|
".rpm": self.unpackRpm,
|
|
".msi": self.unpackMsi,
|
|
".exe": self.unpackExe,
|
|
".dmg": self.mountDmg,
|
|
}
|
|
for suffix, unpacker in unpack.items():
|
|
if path.endswith(suffix):
|
|
if unpacker is None:
|
|
return self.tarEntries(path)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = os.path.join(tmp, "root")
|
|
return unpacker(os.path.abspath(path), root)
|
|
print("%s is not a directory or a %s file" % (path, ", ".join(unpack)))
|
|
return None
|
|
|
|
@staticmethod
|
|
def appImageOffset(path):
|
|
"""An AppImage is an ELF runtime followed by a squashfs image, which
|
|
starts where the ELF file ends: after its section header table."""
|
|
with open(path, "rb") as stream:
|
|
header = stream.read(64)
|
|
if len(header) < 64 or header[:4] != b"\x7fELF" or header[4] != 2 or header[5] != 1:
|
|
return None
|
|
sectionHeaders, = struct.unpack_from("<Q", header, 0x28)
|
|
entrySize, count = struct.unpack_from("<HH", header, 0x3A)
|
|
return sectionHeaders + entrySize * count
|
|
|
|
def unpackAppImage(self, path, root):
|
|
offset = self.appImageOffset(path)
|
|
if offset is None:
|
|
print("%s is not a 64-bit little-endian ELF AppImage" % path)
|
|
return None
|
|
if not run(["unsquashfs", "-no-progress", "-no-xattrs", "-offset", str(offset), "-dest", root, path]):
|
|
print("unsquashfs could not unpack %s" % path)
|
|
return None
|
|
return self.directoryEntries(root)
|
|
|
|
def unpackDeb(self, path, root):
|
|
control = root + "-control"
|
|
if not run(["dpkg-deb", "-x", path, root]) or not run(["dpkg-deb", "-e", path, control]):
|
|
print("dpkg-deb could not unpack %s" % path)
|
|
return None
|
|
entries = self.directoryEntries(root)
|
|
scripts = self.directoryEntries(control)
|
|
if entries is None or scripts is None:
|
|
return None
|
|
# postinst, prerm, ... run as root on install: they count as much as the files.
|
|
entries.update({"[deb control]/" + name: entry for name, entry in scripts.items() if name != "control"})
|
|
with open(os.path.join(control, "control"), "rb") as stream:
|
|
fields, depends = splitDebDepends(stream.read())
|
|
entries["[deb control]/control (without Depends)"] = bytesEntry(fields)
|
|
entries["[deb control]/Depends"] = Entry("file", digest=depends.decode("utf-8", "replace").strip(), note=True)
|
|
return entries
|
|
|
|
def unpackRpm(self, path, root):
|
|
os.makedirs(root)
|
|
with open(path, "rb") as rpm:
|
|
payload = subprocess.Popen(["rpm2cpio", "-"], stdin=rpm, stdout=subprocess.PIPE)
|
|
unpacked = run(["cpio", "-idm", "--quiet", "--no-absolute-filenames"], stdin=payload.stdout, cwd=root)
|
|
payload.stdout.close()
|
|
if payload.wait() != 0 or not unpacked:
|
|
print("rpm2cpio/cpio could not unpack %s" % path)
|
|
return None
|
|
entries = self.directoryEntries(root)
|
|
if entries is None:
|
|
return None
|
|
# The scriptlets run as root on install; the build host and time in
|
|
# the header are left out.
|
|
# Requires, like a .deb's Depends, follows the build machine's packages.
|
|
for query in ["--scripts", "--triggers", "--requires", "--provides", "--conflicts", "--obsoletes"]:
|
|
metadata = output(["rpm", "-qp", query, path], stderr=subprocess.DEVNULL)
|
|
if metadata is None:
|
|
print("rpm could not read %s %s" % (query, path))
|
|
return None
|
|
if query == "--requires":
|
|
text = ", ".join(sorted(metadata.decode("utf-8", "replace").split("\n")[:-1]))
|
|
entries["[rpm]/requires"] = Entry("file", digest=text, note=True)
|
|
else:
|
|
entries["[rpm]/" + query.lstrip("-")] = bytesEntry(metadata)
|
|
return entries
|
|
|
|
def unpackMsi(self, path, root):
|
|
os.makedirs(root)
|
|
if not run(["msiextract", "-C", root, path]):
|
|
print("msiextract could not unpack %s" % path)
|
|
return None
|
|
entries = self.directoryEntries(root)
|
|
tables = output(["msiinfo", "tables", path])
|
|
streams = output(["msiinfo", "streams", path])
|
|
if entries is None or tables is None or streams is None:
|
|
print("msiinfo could not read %s" % path)
|
|
return None
|
|
# The tables hold the custom actions, registry keys and shortcuts the
|
|
# installer sets up; the Binary streams are the custom action DLLs.
|
|
# The cabinets are left out (their files are compared above, and the
|
|
# cabinet itself stamps them with the build time), as is the summary
|
|
# information (build time and package revision).
|
|
for table in sorted(set(tables.decode().split()) - {"_SummaryInformation"}):
|
|
# Run where it can't litter: exporting Binary or Icon also writes
|
|
# their streams into a folder of that name.
|
|
data = output(["msiinfo", "export", path, table], cwd=root)
|
|
if data is None:
|
|
print("msiinfo could not export table %s of %s" % (table, path))
|
|
return None
|
|
# Three header lines (columns, types, keys), then the rows, whose
|
|
# stored order means nothing to Windows Installer.
|
|
lines = data.splitlines()
|
|
entries["[msi table]/" + table] = bytesEntry(b"\n".join(lines[:3] + sorted(lines[3:])))
|
|
for stream in sorted(set(streams.decode().splitlines())):
|
|
if stream.endswith(".cab") or stream.endswith("SummaryInformation"):
|
|
continue
|
|
data = output(["msiinfo", "extract", path, stream])
|
|
if data is None:
|
|
print("msiinfo could not extract stream %s of %s" % (stream, path))
|
|
return None
|
|
entries["[msi stream]/" + stream] = bytesEntry(data)
|
|
return entries
|
|
|
|
def unpackExe(self, path, root):
|
|
"""jpackage's .exe is a launcher with the .msi as its RCDATA "MSI"
|
|
resource. The .msi is checked like one; the rest of the .exe, with that
|
|
resource blanked out, must match byte for byte."""
|
|
with open(path, "rb") as stream:
|
|
data = bytearray(stream.read())
|
|
try:
|
|
resources = peResources(bytes(data))
|
|
except (struct.error, ValueError):
|
|
resources = None
|
|
msis = [(at, size) for kind, name, at, size in (resources or []) if kind == 10 and name == "MSI"]
|
|
if len(msis) != 1:
|
|
print("%s is not a jpackage installer: no single MSI resource" % path)
|
|
return None
|
|
at, size = msis[0]
|
|
os.makedirs(root)
|
|
msi = os.path.join(root, "embedded.msi")
|
|
with open(msi, "wb") as stream:
|
|
stream.write(data[at:at + size])
|
|
entries = self.unpackMsi(msi, os.path.join(root, "msi"))
|
|
if entries is None:
|
|
return None
|
|
data[at:at + size] = bytes(size)
|
|
entries["[exe]/launcher (MSI resource blanked out)"] = bytesEntry(bytes(data))
|
|
return entries
|
|
|
|
def mountDmg(self, path, root):
|
|
if sys.platform != "darwin":
|
|
print("A .dmg can only be checked on macOS")
|
|
return None
|
|
if not run(["hdiutil", "attach", "-readonly", "-nobrowse", "-noautoopen", "-mountpoint", root, path]):
|
|
print("hdiutil could not mount %s" % path)
|
|
return None
|
|
try:
|
|
entries = self.directoryEntries(root)
|
|
finally:
|
|
run(["hdiutil", "detach", root, "-force"])
|
|
# The volume's file system event log, written while the image was
|
|
# being built; not part of the app.
|
|
return entries and {name: entry for name, entry in entries.items()
|
|
if name != ".fseventsd" and not name.startswith(".fseventsd/")}
|
|
|
|
def directoryEntries(self, root):
|
|
entries = {}
|
|
for parent, dirs, files in os.walk(root):
|
|
for name in dirs + files:
|
|
full = os.path.join(parent, name)
|
|
rel = os.path.relpath(full, root).replace(os.sep, "/")
|
|
info = os.lstat(full)
|
|
if stat.S_ISLNK(info.st_mode):
|
|
entries[rel] = Entry("symlink", target=os.readlink(full))
|
|
elif stat.S_ISDIR(info.st_mode):
|
|
entries[rel] = Entry("directory")
|
|
elif stat.S_ISREG(info.st_mode):
|
|
with open(full, "rb") as stream:
|
|
entries[rel] = fileEntry(rel, stream, isExecutable(info.st_mode))
|
|
else:
|
|
print("Unexpected special file %r in %s!" % (rel, root))
|
|
return None
|
|
return entries
|
|
|
|
def tarEntries(self, path):
|
|
# The release tarball holds a single top-level Amber/ directory; paths
|
|
# are compared below it so a directory input lines up with it.
|
|
entries = {}
|
|
with tarfile.open(path, "r:xz") as tar:
|
|
for member in tar:
|
|
name = member.name.rstrip("/")
|
|
top, _, rel = name.partition("/")
|
|
if top != "Amber" or name.startswith("/") or ".." in name.split("/"):
|
|
print("Unexpected path %r in %s!" % (member.name, path))
|
|
return None
|
|
if not rel:
|
|
continue
|
|
# tar keeps every copy of a repeated name and extracting
|
|
# leaves the last one, so such an archive can't be verified.
|
|
if rel in entries:
|
|
print("Duplicate entry %r in %s!" % (rel, path))
|
|
return None
|
|
if member.issym():
|
|
entries[rel] = Entry("symlink", target=member.linkname)
|
|
elif member.isdir():
|
|
entries[rel] = Entry("directory")
|
|
elif member.isreg():
|
|
with tar.extractfile(member) as stream:
|
|
entries[rel] = fileEntry(rel, stream, isExecutable(member.mode))
|
|
else:
|
|
print("Unexpected entry type for %r in %s!" % (rel, path))
|
|
return None
|
|
return entries
|
|
|
|
@staticmethod
|
|
def jdk(entries):
|
|
versions = {entry.jdk for entry in entries.values() if entry.jdk}
|
|
return ", ".join(sorted(versions)) if versions else None
|
|
|
|
def compare(self, first, second):
|
|
firstEntries = self.entries(first)
|
|
secondEntries = self.entries(second)
|
|
if firstEntries is None or secondEntries is None:
|
|
return False
|
|
if not firstEntries:
|
|
print("%s is empty!" % first)
|
|
return False
|
|
|
|
firstJdk, secondJdk = self.jdk(firstEntries), self.jdk(secondEntries)
|
|
print("The first build bundles JDK %s, the second JDK %s" % (firstJdk or "(none found)", secondJdk or "(none found)"))
|
|
if firstJdk != secondJdk:
|
|
print("They can't match: rebuild with the Temurin release of the build you are checking.")
|
|
|
|
success = True
|
|
for name in sorted(firstEntries.keys() - secondEntries.keys()):
|
|
print("Only in the first build: %r" % name)
|
|
success = False
|
|
for name in sorted(secondEntries.keys() - firstEntries.keys()):
|
|
print("Only in the second build: %r" % name)
|
|
success = False
|
|
for name in sorted(firstEntries.keys() & secondEntries.keys()):
|
|
a, b = firstEntries[name], secondEntries[name]
|
|
if a == b:
|
|
continue
|
|
if a.note and b.note:
|
|
print("Note: %s differs (it follows the packages installed where each was built):" % name)
|
|
print(" first: %s" % a.digest)
|
|
print(" second: %s" % b.digest)
|
|
continue
|
|
print("Builds differ on %r: %s vs %s" % (name, a.describe(), b.describe()))
|
|
success = False
|
|
return success
|
|
|
|
|
|
if __name__ == '__main__':
|
|
if len(sys.argv) == 3 and sys.argv[1] == "--jdk":
|
|
entries = DesktopDiff().entries(sys.argv[2])
|
|
jdk = DesktopDiff.jdk(entries) if entries else None
|
|
print(jdk or "No bundled JDK found in %s" % sys.argv[2])
|
|
sys.exit(0 if jdk else 1)
|
|
|
|
if len(sys.argv) != 3:
|
|
print("Usage: desktopdiff <first build> <second build>")
|
|
print(" desktopdiff --jdk <build> prints the JDK a build bundles")
|
|
print("Each build is a directory or a .tar.xz, .AppImage, .deb, .rpm, .msi, .exe or .dmg file.")
|
|
sys.exit(1)
|
|
|
|
if DesktopDiff().compare(sys.argv[1], sys.argv[2]):
|
|
print("Builds match!")
|
|
sys.exit(0)
|
|
else:
|
|
print("Builds don't match!")
|
|
sys.exit(1)
|