diff options
Diffstat (limited to 'python/mozbuild/mozbuild/repackaging')
-rw-r--r-- | python/mozbuild/mozbuild/repackaging/__init__.py | 0 | ||||
-rw-r--r-- | python/mozbuild/mozbuild/repackaging/application_ini.py | 66 | ||||
-rw-r--r-- | python/mozbuild/mozbuild/repackaging/deb.py | 694 | ||||
-rw-r--r-- | python/mozbuild/mozbuild/repackaging/dmg.py | 56 | ||||
-rw-r--r-- | python/mozbuild/mozbuild/repackaging/installer.py | 55 | ||||
-rw-r--r-- | python/mozbuild/mozbuild/repackaging/mar.py | 93 | ||||
-rw-r--r-- | python/mozbuild/mozbuild/repackaging/msi.py | 122 | ||||
-rw-r--r-- | python/mozbuild/mozbuild/repackaging/msix.py | 1193 | ||||
-rw-r--r-- | python/mozbuild/mozbuild/repackaging/pkg.py | 46 | ||||
-rw-r--r-- | python/mozbuild/mozbuild/repackaging/test/python.ini | 4 | ||||
-rw-r--r-- | python/mozbuild/mozbuild/repackaging/test/test_msix.py | 53 |
11 files changed, 2382 insertions, 0 deletions
diff --git a/python/mozbuild/mozbuild/repackaging/__init__.py b/python/mozbuild/mozbuild/repackaging/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/python/mozbuild/mozbuild/repackaging/__init__.py diff --git a/python/mozbuild/mozbuild/repackaging/application_ini.py b/python/mozbuild/mozbuild/repackaging/application_ini.py new file mode 100644 index 0000000000..f11c94f781 --- /dev/null +++ b/python/mozbuild/mozbuild/repackaging/application_ini.py @@ -0,0 +1,66 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. + +from mozpack.files import FileFinder +from six import string_types +from six.moves import configparser + + +def get_application_ini_value( + finder_or_application_directory, section, value, fallback=None +): + """Find string with given `section` and `value` in any `application.ini` + under given directory or finder. + + If string is not found and `fallback` is given, find string with given + `section` and `fallback` instead. + + Raises an `Exception` if no string is found.""" + + return next( + get_application_ini_values( + finder_or_application_directory, + dict(section=section, value=value, fallback=fallback), + ) + ) + + +def get_application_ini_values(finder_or_application_directory, *args): + """Find multiple strings for given `section` and `value` pairs. + Additional `args` should be dictionaries with keys `section`, `value`, + and optional `fallback`. Returns an iterable of strings, one for each + dictionary provided. + + `fallback` is treated as with `get_application_ini_value`. + + Raises an `Exception` if any string is not found.""" + + if isinstance(finder_or_application_directory, string_types): + finder = FileFinder(finder_or_application_directory) + else: + finder = finder_or_application_directory + + # Packages usually have a top-level `firefox/` directory; search below it. + for p, f in finder.find("**/application.ini"): + data = f.open().read().decode("utf-8") + parser = configparser.ConfigParser() + parser.read_string(data) + + for d in args: + rc = None + try: + rc = parser.get(d["section"], d["value"]) + except configparser.NoOptionError: + if "fallback" not in d: + raise + else: + rc = parser.get(d["section"], d["fallback"]) + + if rc is None: + raise Exception("Input does not contain an application.ini file") + + yield rc + + # Process only the first `application.ini`. + break diff --git a/python/mozbuild/mozbuild/repackaging/deb.py b/python/mozbuild/mozbuild/repackaging/deb.py new file mode 100644 index 0000000000..3e01680437 --- /dev/null +++ b/python/mozbuild/mozbuild/repackaging/deb.py @@ -0,0 +1,694 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. + +import datetime +import json +import logging +import os +import shutil +import subprocess +import tarfile +import tempfile +import zipfile +from email.utils import format_datetime +from pathlib import Path +from string import Template + +import mozfile +import mozpack.path as mozpath +import requests +from mozilla_version.gecko import GeckoVersion +from redo import retry + +from mozbuild.repackaging.application_ini import get_application_ini_values + + +class NoDebPackageFound(Exception): + """Raised when no .deb is found after calling dpkg-buildpackage""" + + def __init__(self, deb_file_path) -> None: + super().__init__( + f"No {deb_file_path} package found after calling dpkg-buildpackage" + ) + + +class HgServerError(Exception): + """Raised when Hg responds with an error code that is not 404 (i.e. when there is an outage)""" + + def __init__(self, msg) -> None: + super().__init__(msg) + + +_DEB_ARCH = { + "all": "all", + "x86": "i386", + "x86_64": "amd64", +} +# At the moment the Firefox build baseline is jessie. +# The debian-repackage image defined in taskcluster/docker/debian-repackage/Dockerfile +# bootstraps the /srv/jessie-i386 and /srv/jessie-amd64 chroot environments we use to +# create the `.deb` repackages. By running the repackage using chroot we generate shared +# library dependencies that match the Firefox build baseline +# defined in taskcluster/scripts/misc/build-sysroot.sh +_DEB_DIST = "jessie" + + +def repackage_deb( + log, + infile, + output, + template_dir, + arch, + version, + build_number, + release_product, + release_type, + fluent_localization, + fluent_resource_loader, +): + if not tarfile.is_tarfile(infile): + raise Exception("Input file %s is not a valid tarfile." % infile) + + tmpdir = _create_temporary_directory(arch) + source_dir = os.path.join(tmpdir, "source") + try: + mozfile.extract_tarball(infile, source_dir) + application_ini_data = _extract_application_ini_data(infile) + build_variables = _get_build_variables( + application_ini_data, + arch, + version, + build_number, + depends="${shlibs:Depends},", + ) + + _copy_plain_deb_config(template_dir, source_dir) + _render_deb_templates(template_dir, source_dir, build_variables) + + app_name = application_ini_data["name"] + with open( + mozpath.join(source_dir, app_name.lower(), "is-packaged-app"), "w" + ) as f: + f.write("This is a packaged app.\n") + + _inject_deb_distribution_folder(source_dir, app_name) + _inject_deb_desktop_entry_file( + log, + source_dir, + build_variables, + release_product, + release_type, + fluent_localization, + fluent_resource_loader, + ) + _generate_deb_archive( + source_dir, + target_dir=tmpdir, + output_file_path=output, + build_variables=build_variables, + arch=arch, + ) + + finally: + shutil.rmtree(tmpdir) + + +def repackage_deb_l10n( + input_xpi_file, input_tar_file, output, template_dir, version, build_number +): + arch = "all" + + tmpdir = _create_temporary_directory(arch) + source_dir = os.path.join(tmpdir, "source") + try: + langpack_metadata = _extract_langpack_metadata(input_xpi_file) + langpack_dir = mozpath.join(source_dir, "firefox", "distribution", "extensions") + application_ini_data = _extract_application_ini_data(input_tar_file) + langpack_id = langpack_metadata["langpack_id"] + build_variables = _get_build_variables( + application_ini_data, + arch, + version, + build_number, + depends=application_ini_data["remoting_name"], + # Debian package names are only lowercase + package_name_suffix=f"-l10n-{langpack_id.lower()}", + description_suffix=f" - {langpack_metadata['description']}", + ) + _copy_plain_deb_config(template_dir, source_dir) + _render_deb_templates(template_dir, source_dir, build_variables) + + os.makedirs(langpack_dir, exist_ok=True) + shutil.copy( + input_xpi_file, + mozpath.join( + langpack_dir, + f"{langpack_metadata['browser_specific_settings']['gecko']['id']}.xpi", + ), + ) + _generate_deb_archive( + source_dir=source_dir, + target_dir=tmpdir, + output_file_path=output, + build_variables=build_variables, + arch=arch, + ) + finally: + shutil.rmtree(tmpdir) + + +def _extract_application_ini_data(input_tar_file): + with tempfile.TemporaryDirectory() as d: + with tarfile.open(input_tar_file) as tar: + application_ini_files = [ + tar_info + for tar_info in tar.getmembers() + if tar_info.name.endswith("/application.ini") + ] + if len(application_ini_files) == 0: + raise ValueError( + f"Cannot find any application.ini file in archive {input_tar_file}" + ) + if len(application_ini_files) > 1: + raise ValueError( + f"Too many application.ini files found in archive {input_tar_file}. " + f"Found: {application_ini_files}" + ) + + tar.extract(application_ini_files[0], path=d) + + return _extract_application_ini_data_from_directory(d) + + +def _extract_application_ini_data_from_directory(application_directory): + values = get_application_ini_values( + application_directory, + dict(section="App", value="Name"), + dict(section="App", value="CodeName", fallback="Name"), + dict(section="App", value="Vendor"), + dict(section="App", value="RemotingName"), + dict(section="App", value="BuildID"), + ) + + data = { + "name": next(values), + "display_name": next(values), + "vendor": next(values), + "remoting_name": next(values), + "build_id": next(values), + } + data["timestamp"] = datetime.datetime.strptime(data["build_id"], "%Y%m%d%H%M%S") + + return data + + +def _get_build_variables( + application_ini_data, + arch, + version_string, + build_number, + depends, + package_name_suffix="", + description_suffix="", +): + version = GeckoVersion.parse(version_string) + # Nightlies don't have build numbers + deb_pkg_version = ( + f"{version}~{application_ini_data['build_id']}" + if version.is_nightly + else f"{version}~build{build_number}" + ) + remoting_name = application_ini_data["remoting_name"].lower() + + return { + "DEB_DESCRIPTION": f"{application_ini_data['vendor']} {application_ini_data['display_name']}" + f"{description_suffix}", + "DEB_PKG_INSTALL_PATH": f"usr/lib/{remoting_name}", + "DEB_PKG_NAME": f"{remoting_name}{package_name_suffix}", + "DEB_PKG_VERSION": deb_pkg_version, + "DEB_CHANGELOG_DATE": format_datetime(application_ini_data["timestamp"]), + "DEB_ARCH_NAME": _DEB_ARCH[arch], + "DEB_DEPENDS": depends, + } + + +def _copy_plain_deb_config(input_template_dir, source_dir): + template_dir_filenames = os.listdir(input_template_dir) + plain_filenames = [ + mozpath.basename(filename) + for filename in template_dir_filenames + if not filename.endswith(".in") + ] + os.makedirs(mozpath.join(source_dir, "debian"), exist_ok=True) + + for filename in plain_filenames: + shutil.copy( + mozpath.join(input_template_dir, filename), + mozpath.join(source_dir, "debian", filename), + ) + + +def _render_deb_templates( + input_template_dir, source_dir, build_variables, exclude_file_names=None +): + exclude_file_names = [] if exclude_file_names is None else exclude_file_names + + template_dir_filenames = os.listdir(input_template_dir) + template_filenames = [ + mozpath.basename(filename) + for filename in template_dir_filenames + if filename.endswith(".in") and filename not in exclude_file_names + ] + os.makedirs(mozpath.join(source_dir, "debian"), exist_ok=True) + + for file_name in template_filenames: + with open(mozpath.join(input_template_dir, file_name)) as f: + template = Template(f.read()) + with open(mozpath.join(source_dir, "debian", Path(file_name).stem), "w") as f: + f.write(template.substitute(build_variables)) + + +def _inject_deb_distribution_folder(source_dir, app_name): + with tempfile.TemporaryDirectory() as git_clone_dir: + subprocess.check_call( + [ + "git", + "clone", + "https://github.com/mozilla-partners/deb.git", + git_clone_dir, + ], + ) + shutil.copytree( + mozpath.join(git_clone_dir, "desktop/deb/distribution"), + mozpath.join(source_dir, app_name.lower(), "distribution"), + ) + + +def _inject_deb_desktop_entry_file( + log, + source_dir, + build_variables, + release_product, + release_type, + fluent_localization, + fluent_resource_loader, +): + desktop_entry_file_text = _generate_browser_desktop_entry_file_text( + log, + build_variables, + release_product, + release_type, + fluent_localization, + fluent_resource_loader, + ) + desktop_entry_file_filename = f"{build_variables['DEB_PKG_NAME']}.desktop" + os.makedirs(mozpath.join(source_dir, "debian"), exist_ok=True) + with open( + mozpath.join(source_dir, "debian", desktop_entry_file_filename), "w" + ) as f: + f.write(desktop_entry_file_text) + + +def _generate_browser_desktop_entry_file_text( + log, + build_variables, + release_product, + release_type, + fluent_localization, + fluent_resource_loader, +): + localizations = _create_fluent_localizations( + fluent_resource_loader, fluent_localization, release_type, release_product, log + ) + desktop_entry = _generate_browser_desktop_entry(build_variables, localizations) + desktop_entry_file_text = "\n".join(desktop_entry) + return desktop_entry_file_text + + +def _create_fluent_localizations( + fluent_resource_loader, fluent_localization, release_type, release_product, log +): + brand_fluent_filename = "brand.ftl" + l10n_central_url = "https://hg.mozilla.org/l10n-central" + desktop_entry_fluent_filename = "linuxDesktopEntry.ftl" + + l10n_dir = tempfile.mkdtemp() + + loader = fluent_resource_loader(os.path.join(l10n_dir, "{locale}")) + + localizations = {} + linux_l10n_changesets = _load_linux_l10n_changesets( + "browser/locales/l10n-changesets.json" + ) + locales = ["en-US"] + locales.extend(linux_l10n_changesets.keys()) + en_US_brand_fluent_filename = _get_en_US_brand_fluent_filename( + brand_fluent_filename, release_type, release_product + ) + + for locale in locales: + locale_dir = os.path.join(l10n_dir, locale) + os.mkdir(locale_dir) + localized_desktop_entry_filename = os.path.join( + locale_dir, desktop_entry_fluent_filename + ) + if locale == "en-US": + en_US_desktop_entry_fluent_filename = os.path.join( + "browser/locales/en-US/browser", desktop_entry_fluent_filename + ) + shutil.copyfile( + en_US_desktop_entry_fluent_filename, + localized_desktop_entry_filename, + ) + else: + non_en_US_desktop_entry_fluent_filename = os.path.join( + "browser/browser", desktop_entry_fluent_filename + ) + non_en_US_fluent_resource_file_url = os.path.join( + l10n_central_url, + locale, + "raw-file", + linux_l10n_changesets[locale]["revision"], + non_en_US_desktop_entry_fluent_filename, + ) + response = requests.get(non_en_US_fluent_resource_file_url) + response = retry( + requests.get, + args=[non_en_US_fluent_resource_file_url], + attempts=5, + sleeptime=3, + jitter=2, + ) + mgs = "Missing {fluent_resource_file_name} for {locale}: received HTTP {status_code} for GET {resource_file_url}" + params = { + "fluent_resource_file_name": desktop_entry_fluent_filename, + "locale": locale, + "resource_file_url": non_en_US_fluent_resource_file_url, + "status_code": response.status_code, + } + action = "repackage-deb" + if response.status_code == 404: + log( + logging.WARNING, + action, + params, + mgs, + ) + continue + if response.status_code != 200: + log( + logging.ERROR, + action, + params, + mgs, + ) + raise HgServerError(mgs.format(**params)) + + with open(localized_desktop_entry_filename, "w", encoding="utf-8") as f: + f.write(response.text) + + shutil.copyfile( + en_US_brand_fluent_filename, + os.path.join(locale_dir, brand_fluent_filename), + ) + + fallbacks = [locale] + if locale != "en-US": + fallbacks.append("en-US") + localizations[locale] = fluent_localization( + fallbacks, [desktop_entry_fluent_filename, brand_fluent_filename], loader + ) + + return localizations + + +def _get_en_US_brand_fluent_filename( + brand_fluent_filename, release_type, release_product +): + branding_fluent_filename_template = os.path.join( + "browser/branding/{brand}/locales/en-US", brand_fluent_filename + ) + if release_type == "nightly": + return branding_fluent_filename_template.format(brand="nightly") + elif release_type == "beta" and release_product == "firefox": + return branding_fluent_filename_template.format(brand="official") + elif release_type == "beta" and release_product == "devedition": + return branding_fluent_filename_template.format(brand="aurora") + else: + return branding_fluent_filename_template.format(brand="unofficial") + + +def _load_linux_l10n_changesets(l10n_changesets_filename): + with open(l10n_changesets_filename) as l10n_changesets_file: + l10n_changesets = json.load(l10n_changesets_file) + return { + locale: changeset + for locale, changeset in l10n_changesets.items() + if any(platform.startswith("linux") for platform in changeset["platforms"]) + } + + +def _generate_browser_desktop_entry(build_variables, localizations): + mime_types = [ + "application/json", + "application/pdf", + "application/rdf+xml", + "application/rss+xml", + "application/x-xpinstall", + "application/xhtml+xml", + "application/xml", + "audio/flac", + "audio/ogg", + "audio/webm", + "image/avif", + "image/gif", + "image/jpeg", + "image/png", + "image/svg+xml", + "image/webp", + "text/html", + "text/xml", + "video/ogg", + "video/webm", + "x-scheme-handler/chrome", + "x-scheme-handler/http", + "x-scheme-handler/https", + ] + + categories = [ + "GNOME", + "GTK", + "Network", + "WebBrowser", + ] + + actions = [ + { + "name": "new-window", + "message": "desktop-action-new-window-name", + "command": f"{build_variables['DEB_PKG_NAME']} --new-window %u", + }, + { + "name": "new-private-window", + "message": "desktop-action-new-private-window-name", + "command": f"{build_variables['DEB_PKG_NAME']} --private-window %u", + }, + { + "name": "open-profile-manager", + "message": "desktop-action-open-profile-manager", + "command": f"{build_variables['DEB_PKG_NAME']} --ProfileManager", + }, + ] + + desktop_entry = _desktop_entry_section( + "Desktop Entry", + [ + { + "key": "Version", + "value": "1.0", + }, + { + "key": "Type", + "value": "Application", + }, + { + "key": "Exec", + "value": f"{build_variables['DEB_PKG_NAME']} %u", + }, + { + "key": "Terminal", + "value": "false", + }, + { + "key": "X-MultipleArgs", + "value": "false", + }, + { + "key": "Icon", + "value": build_variables["DEB_PKG_NAME"], + }, + { + "key": "StartupWMClass", + "value": build_variables["DEB_PKG_NAME"], + }, + { + "key": "Categories", + "value": _desktop_entry_list(categories), + }, + { + "key": "MimeType", + "value": _desktop_entry_list(mime_types), + }, + { + "key": "StartupNotify", + "value": "true", + }, + { + "key": "Actions", + "value": _desktop_entry_list([action["name"] for action in actions]), + }, + {"key": "Name", "value": "desktop-entry-name", "l10n": True}, + {"key": "Comment", "value": "desktop-entry-comment", "l10n": True}, + {"key": "GenericName", "value": "desktop-entry-generic-name", "l10n": True}, + {"key": "Keywords", "value": "desktop-entry-keywords", "l10n": True}, + { + "key": "X-GNOME-FullName", + "value": "desktop-entry-x-gnome-full-name", + "l10n": True, + }, + ], + localizations, + ) + + for action in actions: + desktop_entry.extend( + _desktop_entry_section( + f"Desktop Action {action['name']}", + [ + { + "key": "Name", + "value": action["message"], + "l10n": True, + }, + { + "key": "Exec", + "value": action["command"], + }, + ], + localizations, + ) + ) + + return desktop_entry + + +def _desktop_entry_list(iterable): + delimiter = ";" + return f"{delimiter.join(iterable)}{delimiter}" + + +def _desktop_entry_attribute(key, value, locale=None, localizations=None): + if not locale and not localizations: + return f"{key}={value}" + if locale and locale == "en-US": + return f"{key}={localizations[locale].format_value(value)}" + else: + return f"{key}[{locale.replace('-', '_')}]={localizations[locale].format_value(value)}" + + +def _desktop_entry_section(header, attributes, localizations): + desktop_entry_section = [f"[{header}]"] + l10n_attributes = [attribute for attribute in attributes if attribute.get("l10n")] + non_l10n_attributes = [ + attribute for attribute in attributes if not attribute.get("l10n") + ] + for attribute in non_l10n_attributes: + desktop_entry_section.append( + _desktop_entry_attribute(attribute["key"], attribute["value"]) + ) + for attribute in l10n_attributes: + for locale in localizations: + desktop_entry_section.append( + _desktop_entry_attribute( + attribute["key"], attribute["value"], locale, localizations + ) + ) + desktop_entry_section.append("") + return desktop_entry_section + + +def _generate_deb_archive( + source_dir, target_dir, output_file_path, build_variables, arch +): + command = _get_command(arch) + subprocess.check_call(command, cwd=source_dir) + deb_arch = _DEB_ARCH[arch] + deb_file_name = f"{build_variables['DEB_PKG_NAME']}_{build_variables['DEB_PKG_VERSION']}_{deb_arch}.deb" + deb_file_path = mozpath.join(target_dir, deb_file_name) + + if not os.path.exists(deb_file_path): + raise NoDebPackageFound(deb_file_path) + + subprocess.check_call(["dpkg-deb", "--info", deb_file_path]) + shutil.move(deb_file_path, output_file_path) + + +def _get_command(arch): + deb_arch = _DEB_ARCH[arch] + command = [ + "dpkg-buildpackage", + # TODO: Use long options once we stop supporting Debian Jesse. They're more + # explicit. + # + # Long options were added in dpkg 1.18.8 which is part of Debian Stretch. + # + # https://git.dpkg.org/cgit/dpkg/dpkg.git/commit/?h=1.18.x&id=293bd243a19149165fc4fd8830b16a51d471a5e9 + # https://packages.debian.org/stretch/dpkg-dev + "-us", # --unsigned-source + "-uc", # --unsigned-changes + "-b", # --build=binary + ] + + if deb_arch != "all": + command.append(f"--host-arch={deb_arch}") + + if _is_chroot_available(arch): + flattened_command = " ".join(command) + command = [ + "chroot", + _get_chroot_path(arch), + "bash", + "-c", + f"cd /tmp/*/source; {flattened_command}", + ] + + return command + + +def _create_temporary_directory(arch): + if _is_chroot_available(arch): + return tempfile.mkdtemp(dir=f"{_get_chroot_path(arch)}/tmp") + else: + return tempfile.mkdtemp() + + +def _is_chroot_available(arch): + return os.path.isdir(_get_chroot_path(arch)) + + +def _get_chroot_path(arch): + deb_arch = "amd64" if arch == "all" else _DEB_ARCH[arch] + return f"/srv/{_DEB_DIST}-{deb_arch}" + + +_MANIFEST_FILE_NAME = "manifest.json" + + +def _extract_langpack_metadata(input_xpi_file): + with tempfile.TemporaryDirectory() as d: + with zipfile.ZipFile(input_xpi_file) as zip: + zip.extract(_MANIFEST_FILE_NAME, path=d) + + with open(mozpath.join(d, _MANIFEST_FILE_NAME)) as f: + return json.load(f) diff --git a/python/mozbuild/mozbuild/repackaging/dmg.py b/python/mozbuild/mozbuild/repackaging/dmg.py new file mode 100644 index 0000000000..883927f214 --- /dev/null +++ b/python/mozbuild/mozbuild/repackaging/dmg.py @@ -0,0 +1,56 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. + +import tarfile +from pathlib import Path + +import mozfile +from mozpack.dmg import create_dmg + +from mozbuild.bootstrap import bootstrap_toolchain +from mozbuild.repackaging.application_ini import get_application_ini_value + + +def repackage_dmg(infile, output): + + if not tarfile.is_tarfile(infile): + raise Exception("Input file %s is not a valid tarfile." % infile) + + # Resolve required tools + dmg_tool = bootstrap_toolchain("dmg/dmg") + if not dmg_tool: + raise Exception("DMG tool not found") + hfs_tool = bootstrap_toolchain("dmg/hfsplus") + if not hfs_tool: + raise Exception("HFS tool not found") + mkfshfs_tool = bootstrap_toolchain("hfsplus/newfs_hfs") + if not mkfshfs_tool: + raise Exception("MKFSHFS tool not found") + + with mozfile.TemporaryDirectory() as tmp: + tmpdir = Path(tmp) + mozfile.extract_tarball(infile, tmpdir) + + # Remove the /Applications symlink. If we don't, an rsync command in + # create_dmg() will break, and create_dmg() re-creates the symlink anyway. + symlink = tmpdir / " " + if symlink.is_file(): + symlink.unlink() + + volume_name = get_application_ini_value( + str(tmpdir), "App", "CodeName", fallback="Name" + ) + + # The extra_files argument is empty [] because they are already a part + # of the original dmg produced by the build, and they remain in the + # tarball generated by the signing task. + create_dmg( + source_directory=tmpdir, + output_dmg=Path(output), + volume_name=volume_name, + extra_files=[], + dmg_tool=Path(dmg_tool), + hfs_tool=Path(hfs_tool), + mkfshfs_tool=Path(mkfshfs_tool), + ) diff --git a/python/mozbuild/mozbuild/repackaging/installer.py b/python/mozbuild/mozbuild/repackaging/installer.py new file mode 100644 index 0000000000..9bd17613bf --- /dev/null +++ b/python/mozbuild/mozbuild/repackaging/installer.py @@ -0,0 +1,55 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. + +import os +import shutil +import tempfile +import zipfile + +import mozpack.path as mozpath + +from mozbuild.action.exe_7z_archive import archive_exe +from mozbuild.util import ensureParentDir + + +def repackage_installer( + topsrcdir, tag, setupexe, package, output, package_name, sfx_stub, use_upx +): + if package and not zipfile.is_zipfile(package): + raise Exception("Package file %s is not a valid .zip file." % package) + if package is not None and package_name is None: + raise Exception("Package name must be provided, if a package is provided.") + if package is None and package_name is not None: + raise Exception( + "Package name must not be provided, if a package is not provided." + ) + + # We need the full path for the tag and output, since we chdir later. + tag = mozpath.realpath(tag) + output = mozpath.realpath(output) + ensureParentDir(output) + + tmpdir = tempfile.mkdtemp() + old_cwd = os.getcwd() + try: + if package: + z = zipfile.ZipFile(package) + z.extractall(tmpdir) + z.close() + + # Copy setup.exe into the root of the install dir, alongside the + # package. + shutil.copyfile(setupexe, mozpath.join(tmpdir, mozpath.basename(setupexe))) + + # archive_exe requires us to be in the directory where the package is + # unpacked (the tmpdir) + os.chdir(tmpdir) + + sfx_package = mozpath.join(topsrcdir, sfx_stub) + + archive_exe(package_name, tag, sfx_package, output, use_upx) + + finally: + os.chdir(old_cwd) + shutil.rmtree(tmpdir) diff --git a/python/mozbuild/mozbuild/repackaging/mar.py b/python/mozbuild/mozbuild/repackaging/mar.py new file mode 100644 index 0000000000..f215c17238 --- /dev/null +++ b/python/mozbuild/mozbuild/repackaging/mar.py @@ -0,0 +1,93 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. + +import os +import shutil +import subprocess +import sys +import tarfile +import tempfile +import zipfile +from pathlib import Path + +import mozfile +import mozpack.path as mozpath + +from mozbuild.repackaging.application_ini import get_application_ini_value +from mozbuild.util import ensureParentDir + +_BCJ_OPTIONS = { + "x86": ["--x86"], + "x86_64": ["--x86"], + "aarch64": [], + # macOS Universal Builds + "macos-x86_64-aarch64": [], +} + + +def repackage_mar(topsrcdir, package, mar, output, arch=None, mar_channel_id=None): + if not zipfile.is_zipfile(package) and not tarfile.is_tarfile(package): + raise Exception("Package file %s is not a valid .zip or .tar file." % package) + if arch and arch not in _BCJ_OPTIONS: + raise Exception( + "Unknown architecture {}, available architectures: {}".format( + arch, list(_BCJ_OPTIONS.keys()) + ) + ) + + ensureParentDir(output) + tmpdir = tempfile.mkdtemp() + try: + if tarfile.is_tarfile(package): + filelist = mozfile.extract_tarball(package, tmpdir) + else: + z = zipfile.ZipFile(package) + z.extractall(tmpdir) + filelist = z.namelist() + z.close() + + toplevel_dirs = set([mozpath.split(f)[0] for f in filelist]) + excluded_stuff = set([" ", ".background", ".DS_Store", ".VolumeIcon.icns"]) + toplevel_dirs = toplevel_dirs - excluded_stuff + # Make sure the .zip file just contains a directory like 'firefox/' at + # the top, and find out what it is called. + if len(toplevel_dirs) != 1: + raise Exception( + "Package file is expected to have a single top-level directory" + "(eg: 'firefox'), not: %s" % toplevel_dirs + ) + ffxdir = mozpath.join(tmpdir, toplevel_dirs.pop()) + + make_full_update = mozpath.join( + topsrcdir, "tools/update-packaging/make_full_update.sh" + ) + + env = os.environ.copy() + env["MOZ_PRODUCT_VERSION"] = get_application_ini_value(tmpdir, "App", "Version") + env["MAR"] = mozpath.normpath(mar) + if arch: + env["BCJ_OPTIONS"] = " ".join(_BCJ_OPTIONS[arch]) + if mar_channel_id: + env["MAR_CHANNEL_ID"] = mar_channel_id + # The Windows build systems have xz installed but it isn't in the path + # like it is on Linux and Mac OS X so just use the XZ env var so the mar + # generation scripts can find it. + xz_path = mozpath.join(topsrcdir, "xz/xz.exe") + if os.path.exists(xz_path): + env["XZ"] = mozpath.normpath(xz_path) + + cmd = [make_full_update, output, ffxdir] + if sys.platform == "win32": + # make_full_update.sh is a bash script, and Windows needs to + # explicitly call out the shell to execute the script from Python. + + mozillabuild = os.environ["MOZILLABUILD"] + if (Path(mozillabuild) / "msys2").exists(): + cmd.insert(0, mozillabuild + "/msys2/usr/bin/bash.exe") + else: + cmd.insert(0, mozillabuild + "/msys/bin/bash.exe") + subprocess.check_call(cmd, env=env) + + finally: + shutil.rmtree(tmpdir) diff --git a/python/mozbuild/mozbuild/repackaging/msi.py b/python/mozbuild/mozbuild/repackaging/msi.py new file mode 100644 index 0000000000..b0b1b09983 --- /dev/null +++ b/python/mozbuild/mozbuild/repackaging/msi.py @@ -0,0 +1,122 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. + +import os +import shutil +import subprocess +import sys +import tempfile +from xml.dom import minidom + +import mozpack.path as mozpath + +from mozbuild.util import ensureParentDir + +_MSI_ARCH = { + "x86": "x86", + "x86_64": "x64", +} + + +def update_wsx(wfile, pvalues): + + parsed = minidom.parse(wfile) + + # construct a dictinary for the pre-processing options + # iterate over that list and add them to the wsx xml doc + for k, v in pvalues.items(): + entry = parsed.createProcessingInstruction("define", k + ' = "' + v + '"') + root = parsed.firstChild + parsed.insertBefore(entry, root) + # write out xml to new wfile + new_w_file = wfile + ".new" + with open(new_w_file, "w") as fh: + parsed.writexml(fh) + shutil.move(new_w_file, wfile) + return wfile + + +def repackage_msi( + topsrcdir, wsx, version, locale, arch, setupexe, candle, light, output +): + if sys.platform != "win32": + raise Exception("repackage msi only works on windows") + if not os.path.isdir(topsrcdir): + raise Exception("%s does not exist." % topsrcdir) + if not os.path.isfile(wsx): + raise Exception("%s does not exist." % wsx) + if version is None: + raise Exception("version name must be provided.") + if locale is None: + raise Exception("locale name must be provided.") + if arch is None or arch not in _MSI_ARCH.keys(): + raise Exception( + "arch name must be provided and one of {}.".format(_MSI_ARCH.keys()) + ) + if not os.path.isfile(setupexe): + raise Exception("%s does not exist." % setupexe) + if candle is not None and not os.path.isfile(candle): + raise Exception("%s does not exist." % candle) + if light is not None and not os.path.isfile(light): + raise Exception("%s does not exist." % light) + embeddedVersion = "0.0.0.0" + # Version string cannot contain 'a' or 'b' when embedding in msi manifest. + if "a" not in version and "b" not in version: + if version.endswith("esr"): + parts = version[:-3].split(".") + else: + parts = version.split(".") + while len(parts) < 4: + parts.append("0") + embeddedVersion = ".".join(parts) + + wsx = mozpath.realpath(wsx) + setupexe = mozpath.realpath(setupexe) + output = mozpath.realpath(output) + ensureParentDir(output) + + if sys.platform == "win32": + tmpdir = tempfile.mkdtemp() + old_cwd = os.getcwd() + try: + wsx_file = os.path.split(wsx)[1] + shutil.copy(wsx, tmpdir) + temp_wsx_file = os.path.join(tmpdir, wsx_file) + temp_wsx_file = mozpath.realpath(temp_wsx_file) + pre_values = { + "Vendor": "Mozilla", + "BrandFullName": "Mozilla Firefox", + "Version": version, + "AB_CD": locale, + "Architecture": _MSI_ARCH[arch], + "ExeSourcePath": setupexe, + "EmbeddedVersionCode": embeddedVersion, + } + # update wsx file with inputs from + newfile = update_wsx(temp_wsx_file, pre_values) + wix_object_file = os.path.join(tmpdir, "installer.wixobj") + env = os.environ.copy() + if candle is None: + candle = "candle.exe" + cmd = [candle, "-out", wix_object_file, newfile] + subprocess.check_call(cmd, env=env) + wix_installer = wix_object_file.replace(".wixobj", ".msi") + if light is None: + light = "light.exe" + light_cmd = [ + light, + "-cultures:neutral", + "-sw1076", + "-sw1079", + "-out", + wix_installer, + wix_object_file, + ] + subprocess.check_call(light_cmd, env=env) + os.remove(wix_object_file) + # mv file to output dir + shutil.move(wix_installer, output) + finally: + os.chdir(old_cwd) + shutil.rmtree(tmpdir) diff --git a/python/mozbuild/mozbuild/repackaging/msix.py b/python/mozbuild/mozbuild/repackaging/msix.py new file mode 100644 index 0000000000..707096c499 --- /dev/null +++ b/python/mozbuild/mozbuild/repackaging/msix.py @@ -0,0 +1,1193 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. + +r"""Repackage ZIP archives (or directories) into MSIX App Packages. + +# Known issues + +- The icons in the Start Menu have a solid colour tile behind them. I think + this is an issue with plating. +""" + +import functools +import itertools +import logging +import os +import re +import shutil +import subprocess +import sys +import time +import urllib +from collections import defaultdict +from pathlib import Path + +import mozpack.path as mozpath +from mach.util import get_state_dir +from mozfile import which +from mozpack.copier import FileCopier +from mozpack.files import FileFinder, JarFinder +from mozpack.manifests import InstallManifest +from mozpack.mozjar import JarReader +from mozpack.packager.unpack import UnpackFinder +from six.moves import shlex_quote + +from mozbuild.repackaging.application_ini import get_application_ini_values +from mozbuild.util import ensureParentDir + + +def log_copy_result(log, elapsed, destdir, result): + COMPLETE = ( + "Elapsed: {elapsed:.2f}s; From {dest}: Kept {existing} existing; " + "Added/updated {updated}; " + "Removed {rm_files} files and {rm_dirs} directories." + ) + copy_result = COMPLETE.format( + elapsed=elapsed, + dest=destdir, + existing=result.existing_files_count, + updated=result.updated_files_count, + rm_files=result.removed_files_count, + rm_dirs=result.removed_directories_count, + ) + log(logging.INFO, "msix", {"copy_result": copy_result}, "{copy_result}") + + +# See https://docs.microsoft.com/en-us/uwp/schemas/appxpackage/uapmanifestschema/element-identity. +_MSIX_ARCH = {"x86": "x86", "x86_64": "x64", "aarch64": "arm64"} + + +@functools.lru_cache(maxsize=None) +def sdk_tool_search_path(): + from mozbuild.configure import ConfigureSandbox + + sandbox = ConfigureSandbox({}, argv=["configure"]) + sandbox.include_file( + str(Path(__file__).parent.parent.parent.parent.parent / "moz.configure") + ) + return sandbox._value_for(sandbox["sdk_bin_path"]) + [ + "c:/Windows/System32/WindowsPowershell/v1.0" + ] + + +def find_sdk_tool(binary, log=None): + if binary.lower().endswith(".exe"): + binary = binary[:-4] + + maybe = os.environ.get(binary.upper()) + if maybe: + log( + logging.DEBUG, + "msix", + {"binary": binary, "path": maybe}, + "Found {binary} in environment: {path}", + ) + return mozpath.normsep(maybe) + + maybe = which(binary, extra_search_dirs=sdk_tool_search_path()) + if maybe: + log( + logging.DEBUG, + "msix", + {"binary": binary, "path": maybe}, + "Found {binary} on path: {path}", + ) + return mozpath.normsep(maybe) + + return None + + +def get_embedded_version(version, buildid): + r"""Turn a display version into "dotted quad" notation. + + N.b.: some parts of the MSIX packaging ecosystem require the final part of + the dotted quad to be identically 0, so we enforce that here. + """ + + # It's irritating to roll our own version parsing, but the tree doesn't seem + # to contain exactly what we need at this time. + version = version.rsplit("esr", 1)[0] + alpha = "a" in version + + tail = None + if "a" in version: + head, tail = version.rsplit("a", 1) + if tail != "1": + # Disallow anything beyond `X.Ya1`. + raise ValueError( + f"Alpha version not of the form X.0a1 is not supported: {version}" + ) + tail = buildid + elif "b" in version: + head, tail = version.rsplit("b", 1) + if len(head.split(".")) > 2: + raise ValueError( + f"Beta version not of the form X.YbZ is not supported: {version}" + ) + elif "rc" in version: + head, tail = version.rsplit("rc", 1) + if len(head.split(".")) > 2: + raise ValueError( + f"Release candidate version not of the form X.YrcZ is not supported: {version}" + ) + else: + head = version + + components = (head.split(".") + ["0", "0", "0"])[:3] + if tail: + components[2] = tail + + if alpha: + # Nightly builds are all `X.0a1`, which isn't helpful. Include build ID + # to disambiguate. But each part of the dotted quad is 16 bits, so we + # have to squash. + if components[1] != "0": + # Disallow anything beyond `X.0a1`. + raise ValueError( + f"Alpha version not of the form X.0a1 is not supported: {version}" + ) + + # Last two digits only to save space. Nightly builds in 2066 and 2099 + # will be impacted, but future us can deal with that. + year = buildid[2:4] + if year[0] == "0": + # Avoid leading zero, like `.0YMm`. + year = year[1:] + month = buildid[4:6] + day = buildid[6:8] + if day[0] == "0": + # Avoid leading zero, like `.0DHh`. + day = day[1:] + hour = buildid[8:10] + + components[1] = "".join((year, month)) + components[2] = "".join((day, hour)) + + version = "{}.{}.{}.0".format(*components) + + return version + + +def get_appconstants_sys_mjs_values(finder, *args): + r"""Extract values, such as the display version like `MOZ_APP_VERSION_DISPLAY: + "...";`, from the omnijar. This allows to determine the beta number, like + `X.YbW`, where the regular beta version is only `X.Y`. Takes a list of + names and returns an iterator of the unique such value found for each name. + Raises an exception if a name is not found or if multiple values are found. + """ + lines = defaultdict(list) + for _, f in finder.find("**/modules/AppConstants.sys.mjs"): + # MOZ_OFFICIAL_BRANDING is split across two lines, so remove line breaks + # immediately following ":"s so those values can be read. + data = f.open().read().decode("utf-8").replace(":\n", ":") + for line in data.splitlines(): + for arg in args: + if arg in line: + lines[arg].append(line) + + for arg in args: + (value,) = lines[arg] # We expect exactly one definition. + _, _, value = value.partition(":") + value = value.strip().strip('",;') + yield value + + +def get_branding(use_official, topsrcdir, build_app, finder, log=None): + """Figure out which branding directory to use.""" + conf_vars = mozpath.join(topsrcdir, build_app, "confvars.sh") + + def conf_vars_value(key): + lines = open(conf_vars).readlines() + for line in lines: + line = line.strip() + if line and line[0] == "#": + continue + if key not in line: + continue + _, _, value = line.partition("=") + if not value: + continue + log( + logging.INFO, + "msix", + {"key": key, "conf_vars": conf_vars, "value": value}, + "Read '{key}' from {conf_vars}: {value}", + ) + return value + log( + logging.ERROR, + "msix", + {"key": key, "conf_vars": conf_vars}, + "Unable to find '{key}' in {conf_vars}!", + ) + + # Branding defaults + branding_reason = "No branding set" + branding = conf_vars_value("MOZ_BRANDING_DIRECTORY") + + if use_official: + # Read MOZ_OFFICIAL_BRANDING_DIRECTORY from confvars.sh + branding_reason = "'MOZ_OFFICIAL_BRANDING' set" + branding = conf_vars_value("MOZ_OFFICIAL_BRANDING_DIRECTORY") + else: + # Check if --with-branding was used when building + log( + logging.INFO, + "msix", + {}, + "Checking buildconfig.html for --with-branding build flag.", + ) + for _, f in finder.find("**/chrome/toolkit/content/global/buildconfig.html"): + data = f.open().read().decode("utf-8") + match = re.search(r"--with-branding=([a-z/]+)", data) + if match: + branding_reason = "'--with-branding' set" + branding = match.group(1) + + log( + logging.INFO, + "msix", + { + "branding_reason": branding_reason, + "branding": branding, + }, + "{branding_reason}; Using branding from '{branding}'.", + ) + return mozpath.join(topsrcdir, branding) + + +def unpack_msix(input_msix, output, log=None, verbose=False): + r"""Unpack the given MSIX to the given output directory. + + MSIX packages are ZIP files, but they are Zip64/version 4.5 ZIP files, so + `mozjar.py` doesn't yet handle. Unpack using `unzip{.exe}` for simplicity. + + In addition, file names inside the MSIX package are URL quoted. URL unquote + here. + """ + + log( + logging.INFO, + "msix", + { + "input_msix": input_msix, + "output": output, + }, + "Unpacking input MSIX '{input_msix}' to directory '{output}'", + ) + + unzip = find_sdk_tool("unzip.exe", log=log) + if not unzip: + raise ValueError("unzip is required; set UNZIP or PATH") + + subprocess.check_call( + [unzip, input_msix, "-d", output] + (["-q"] if not verbose else []), + universal_newlines=True, + ) + + # Sanity check: is this an MSIX? + temp_finder = FileFinder(output) + if not temp_finder.contains("AppxManifest.xml"): + raise ValueError("MSIX file does not contain 'AppxManifest.xml'?") + + # Files in the MSIX are URL encoded/quoted; unquote here. + for dirpath, dirs, files in os.walk(output): + # This is a one way to update (in place, for os.walk) the variable `dirs` while iterating + # over it and `files`. + for i, (p, var) in itertools.chain( + enumerate((f, files) for f in files), enumerate((g, dirs) for g in dirs) + ): + q = urllib.parse.unquote(p) + if p != q: + log( + logging.DEBUG, + "msix", + { + "dirpath": dirpath, + "p": p, + "q": q, + }, + "URL unquoting '{p}' -> '{q}' in {dirpath}", + ) + + var[i] = q + os.rename(os.path.join(dirpath, p), os.path.join(dirpath, q)) + + # The "package root" of our MSIX packages is like "Mozilla Firefox Beta Package Root", i.e., it + # varies by channel. This is an easy way to determine it. + for p, _ in temp_finder.find("**/application.ini"): + relpath = os.path.split(p)[0] + + # The application executable, like `firefox.exe`, is in this directory. + return mozpath.normpath(mozpath.join(output, relpath)) + + +def repackage_msix( + dir_or_package, + topsrcdir, + channel=None, + distribution_dirs=[], + version=None, + vendor=None, + displayname=None, + app_name=None, + identity=None, + publisher=None, + publisher_display_name="Mozilla Corporation", + arch=None, + output=None, + force=False, + log=None, + verbose=False, + makeappx=None, +): + if not channel: + raise Exception("channel is required") + if channel not in ( + "official", + "beta", + "aurora", + "nightly", + "unofficial", + ): + raise Exception("channel is unrecognized: {}".format(channel)) + + # TODO: maybe we can fish this from the package directly? Maybe from a DLL, + # maybe from application.ini? + if arch is None or arch not in _MSIX_ARCH.keys(): + raise Exception( + "arch name must be provided and one of {}.".format(_MSIX_ARCH.keys()) + ) + + if not os.path.exists(dir_or_package): + raise Exception("{} does not exist".format(dir_or_package)) + + if ( + os.path.isfile(dir_or_package) + and os.path.splitext(dir_or_package)[1] == ".msix" + ): + # The convention is $MOZBUILD_STATE_PATH/cache/$FEATURE. + msix_dir = mozpath.normsep( + mozpath.join( + get_state_dir(), + "cache", + "mach-msix", + "msix-unpack", + ) + ) + + if os.path.exists(msix_dir): + shutil.rmtree(msix_dir) + ensureParentDir(msix_dir) + + dir_or_package = unpack_msix(dir_or_package, msix_dir, log=log, verbose=verbose) + + log( + logging.INFO, + "msix", + { + "input": dir_or_package, + }, + "Adding files from '{input}'", + ) + + if os.path.isdir(dir_or_package): + finder = FileFinder(dir_or_package) + else: + finder = JarFinder(dir_or_package, JarReader(dir_or_package)) + + values = get_application_ini_values( + finder, + dict(section="App", value="CodeName", fallback="Name"), + dict(section="App", value="Vendor"), + ) + + first = next(values) + if not displayname: + displayname = "Mozilla {}".format(first) + + if channel == "beta": + # Release (official) and Beta share branding. Differentiate Beta a little bit. + displayname += " Beta" + + second = next(values) + vendor = vendor or second + + # For `AppConstants.sys.mjs` and `brand.properties`, which are in the omnijar in packaged + # builds. The nested langpack XPI files can't be read by `mozjar.py`. + unpack_finder = UnpackFinder(finder, unpack_xpi=False) + + values = get_appconstants_sys_mjs_values( + unpack_finder, + "MOZ_OFFICIAL_BRANDING", + "MOZ_BUILD_APP", + "MOZ_APP_NAME", + "MOZ_APP_VERSION_DISPLAY", + "MOZ_BUILDID", + ) + try: + use_official_branding = {"true": True, "false": False}[next(values)] + except KeyError as err: + raise Exception( + f"Unexpected value '{err.args[0]}' found for 'MOZ_OFFICIAL_BRANDING'." + ) from None + + build_app = next(values) + + _temp = next(values) + if not app_name: + app_name = _temp + + if not version: + display_version = next(values) + buildid = next(values) + version = get_embedded_version(display_version, buildid) + log( + logging.INFO, + "msix", + { + "version": version, + "display_version": display_version, + "buildid": buildid, + }, + "AppConstants.sys.mjs display version is '{display_version}' and build ID is" + + " '{buildid}': embedded version will be '{version}'", + ) + + # TODO: Bug 1721922: localize this description via Fluent. + lines = [] + for _, f in unpack_finder.find("**/chrome/en-US/locale/branding/brand.properties"): + lines.extend( + line + for line in f.open().read().decode("utf-8").splitlines() + if "brandFullName" in line + ) + (brandFullName,) = lines # We expect exactly one definition. + _, _, brandFullName = brandFullName.partition("=") + brandFullName = brandFullName.strip() + + if channel == "beta": + # Release (official) and Beta share branding. Differentiate Beta a little bit. + brandFullName += " Beta" + + branding = get_branding( + use_official_branding, topsrcdir, build_app, unpack_finder, log + ) + if not os.path.isdir(branding): + raise Exception("branding dir {} does not exist".format(branding)) + + template = os.path.join(topsrcdir, build_app, "installer", "windows", "msix") + + # Discard everything after a '#' comment character. + locale_allowlist = set( + locale.partition("#")[0].strip().lower() + for locale in open(os.path.join(template, "msix-all-locales")).readlines() + if locale.partition("#")[0].strip() + ) + + # The convention is $MOZBUILD_STATE_PATH/cache/$FEATURE. + output_dir = mozpath.normsep( + mozpath.join( + get_state_dir(), "cache", "mach-msix", "msix-temp-{}".format(channel) + ) + ) + + # Like 'Firefox Package Root', 'Firefox Nightly Package Root', 'Firefox Beta + # Package Root'. This is `BrandFullName` in the installer, and we want to + # be close but to not match. By not matching, we hope to prevent confusion + # and/or errors between regularly installed builds and App Package builds. + instdir = "{} Package Root".format(displayname) + + # The standard package name is like "CompanyNoSpaces.ProductNoSpaces". + identity = identity or "{}.{}".format(vendor, displayname).replace(" ", "") + + # We might want to include the publisher ID hash here. I.e., + # "__{publisherID}". My locally produced MSIX was named like + # `Mozilla.MozillaFirefoxNightly_89.0.0.0_x64__4gf61r4q480j0`, suggesting also a + # missing field, but it's not necessary, since this is just an output file name. + package_output_name = "{identity}_{version}_{arch}".format( + identity=identity, version=version, arch=_MSIX_ARCH[arch] + ) + # The convention is $MOZBUILD_STATE_PATH/cache/$FEATURE. + default_output = mozpath.normsep( + mozpath.join( + get_state_dir(), "cache", "mach-msix", "{}.msix".format(package_output_name) + ) + ) + output = output or default_output + log(logging.INFO, "msix", {"output": output}, "Repackaging to: {output}") + + m = InstallManifest() + m.add_copy(mozpath.join(template, "Resources.pri"), "Resources.pri") + + m.add_pattern_copy(mozpath.join(branding, "msix", "Assets"), "**", "Assets") + m.add_pattern_copy(mozpath.join(template, "VFS"), "**", "VFS") + + copier = FileCopier() + + # TODO: Bug 1710147: filter out MSVCRT files and use a dependency instead. + for p, f in finder: + if not os.path.isdir(dir_or_package): + # In archived builds, `p` is like "firefox/firefox.exe"; we want just "firefox.exe". + pp = os.path.relpath(p, app_name) + else: + # In local builds and unpacked MSIX directories, `p` is like "firefox.exe" already. + pp = p + + if pp.startswith("distribution"): + # Treat any existing distribution as a distribution directory, + # potentially with language packs. This makes it easy to repack + # unpacked MSIXes. + distribution_dir = mozpath.join(dir_or_package, "distribution") + if distribution_dir not in distribution_dirs: + distribution_dirs.append(distribution_dir) + + continue + + copier.add(mozpath.normsep(mozpath.join("VFS", "ProgramFiles", instdir, pp)), f) + + # Locales to declare as supported in `AppxManifest.xml`. + locales = set(["en-US"]) + + for distribution_dir in [ + mozpath.join(template, "distribution") + ] + distribution_dirs: + log( + logging.INFO, + "msix", + {"dir": distribution_dir}, + "Adding distribution files from {dir}", + ) + + # In automation, we have no easy way to remap the names of artifacts fetched from dependent + # tasks. In particular, langpacks will be named like `target.langpack.xpi`. The fetch + # tasks do allow us to put them in a per-locale directory, so that the entire set can be + # fetched. Here we remap the names. + finder = FileFinder(distribution_dir) + + for p, f in finder: + locale = None + if os.path.basename(p) == "target.langpack.xpi": + # Turn "/path/to/LOCALE/target.langpack.xpi" into "LOCALE". This is how langpacks + # are presented in CI. + base, locale = os.path.split(os.path.dirname(p)) + + # Like "locale-LOCALE/langpack-LOCALE@firefox.mozilla.org.xpi". This is what AMO + # serves and how flatpak builds name langpacks, but not how snap builds name + # langpacks. I can't explain the discrepancy. + dest = mozpath.normsep( + mozpath.join( + base, + f"locale-{locale}", + f"langpack-{locale}@{app_name}.mozilla.org.xpi", + ) + ) + + log( + logging.DEBUG, + "msix", + {"path": p, "dest": dest}, + "Renaming langpack {path} to {dest}", + ) + + elif os.path.basename(p).startswith("langpack-"): + # Turn "/path/to/langpack-LOCALE@firefox.mozilla.org.xpi" into "LOCALE". This is + # how langpacks are presented from an unpacked MSIX. + _, _, locale = os.path.basename(p).partition("langpack-") + locale, _, _ = locale.partition("@") + dest = p + + else: + dest = p + + if locale: + locale = locale.strip().lower() + locales.add(locale) + log( + logging.DEBUG, + "msix", + {"locale": locale, "dest": dest}, + "Distributing locale '{locale}' from {dest}", + ) + + dest = mozpath.normsep( + mozpath.join("VFS", "ProgramFiles", instdir, "distribution", dest) + ) + if copier.contains(dest): + log( + logging.INFO, + "msix", + {"dest": dest, "path": mozpath.join(finder.base, p)}, + "Skipping duplicate: {dest} from {path}", + ) + continue + + log( + logging.DEBUG, + "msix", + {"dest": dest, "path": mozpath.join(finder.base, p)}, + "Adding distribution path: {dest} from {path}", + ) + + copier.add( + dest, + f, + ) + + locales.remove("en-US") + + # Windows MSIX packages support a finite set of locales: see + # https://docs.microsoft.com/en-us/windows/uwp/publish/supported-languages, which is encoded in + # https://searchfox.org/mozilla-central/source/browser/installer/windows/msix/msix-all-locales. + # We distribute all of the langpacks supported by the release channel in our MSIX, which is + # encoded in https://searchfox.org/mozilla-central/source/browser/locales/all-locales. But we + # only advertise support in the App manifest for the intersection of that set and the set of + # supported locales. + # + # We distribute all langpacks to avoid the following issue. Suppose a user manually installs a + # langpack that is not supported by Windows, and then updates the installed MSIX package. MSIX + # package upgrades are essentially paveover installs, so there is no opportunity for Firefox to + # update the langpack before the update. But, since all langpacks are bundled with the MSIX, + # that langpack will be up-to-date, preventing one class of YSOD. + unadvertised = set() + if locale_allowlist: + unadvertised = locales - locale_allowlist + locales = locales & locale_allowlist + for locale in sorted(unadvertised): + log( + logging.INFO, + "msix", + {"locale": locale}, + "Not advertising distributed locale '{locale}' that is not recognized by Windows", + ) + + locales = ["en-US"] + list(sorted(locales)) + resource_language_list = "\n".join( + f' <Resource Language="{locale}" />' for locale in locales + ) + + defines = { + "APPX_ARCH": _MSIX_ARCH[arch], + "APPX_DISPLAYNAME": brandFullName, + "APPX_DESCRIPTION": brandFullName, + # Like 'Mozilla.MozillaFirefox', 'Mozilla.MozillaFirefoxBeta', or + # 'Mozilla.MozillaFirefoxNightly'. + "APPX_IDENTITY": identity, + # Like 'Firefox Package Root', 'Firefox Nightly Package Root', 'Firefox + # Beta Package Root'. See above. + "APPX_INSTDIR": instdir, + # Like 'Firefox%20Package%20Root'. + "APPX_INSTDIR_QUOTED": urllib.parse.quote(instdir), + "APPX_PUBLISHER": publisher, + "APPX_PUBLISHER_DISPLAY_NAME": publisher_display_name, + "APPX_RESOURCE_LANGUAGE_LIST": resource_language_list, + "APPX_VERSION": version, + "MOZ_APP_DISPLAYNAME": displayname, + "MOZ_APP_NAME": app_name, + # Keep synchronized with `toolkit\mozapps\notificationserver\NotificationComServer.cpp`. + "MOZ_INOTIFICATIONACTIVATION_CLSID": "916f9b5d-b5b2-4d36-b047-03c7a52f81c8", + } + + m.add_preprocess( + mozpath.join(template, "AppxManifest.xml.in"), + "AppxManifest.xml", + [], + defines=defines, + marker="<!-- #", # So that we can have well-formed XML. + ) + m.populate_registry(copier) + + output_dir = mozpath.abspath(output_dir) + ensureParentDir(output_dir) + + start = time.monotonic() + result = copier.copy( + output_dir, remove_empty_directories=True, skip_if_older=not force + ) + if log: + log_copy_result(log, time.monotonic() - start, output_dir, result) + + if verbose: + # Dump AppxManifest.xml contents for ease of debugging. + log(logging.DEBUG, "msix", {}, "AppxManifest.xml") + log(logging.DEBUG, "msix", {}, ">>>") + for line in open(mozpath.join(output_dir, "AppxManifest.xml")).readlines(): + log(logging.DEBUG, "msix", {}, line[:-1]) # Drop trailing line terminator. + log(logging.DEBUG, "msix", {}, "<<<") + + if not makeappx: + makeappx = find_sdk_tool("makeappx.exe", log=log) + if not makeappx: + raise ValueError( + "makeappx is required; " "set MAKEAPPX or WINDOWSSDKDIR or PATH" + ) + + # `makeappx.exe` supports both slash and hyphen style arguments; `makemsix` + # supports only hyphen style. `makeappx.exe` allows to overwrite and to + # provide more feedback, so we prefer invoking with these flags. This will + # also accommodate `wine makeappx.exe`. + stdout = subprocess.run( + [makeappx], check=False, capture_output=True, universal_newlines=True + ).stdout + is_makeappx = "MakeAppx Tool" in stdout + + if is_makeappx: + args = [makeappx, "pack", "/d", output_dir, "/p", output, "/overwrite"] + else: + args = [makeappx, "pack", "-d", output_dir, "-p", output] + if verbose and is_makeappx: + args.append("/verbose") + joined = " ".join(shlex_quote(arg) for arg in args) + log(logging.INFO, "msix", {"args": args, "joined": joined}, "Invoking: {joined}") + + sys.stdout.flush() # Otherwise the subprocess output can be interleaved. + if verbose: + subprocess.check_call(args, universal_newlines=True) + else: + # Suppress output unless we fail. + try: + subprocess.check_output(args, universal_newlines=True) + except subprocess.CalledProcessError as e: + sys.stderr.write(e.output) + raise + + return output + + +def _sign_msix_win(output, force, log, verbose): + powershell_exe = find_sdk_tool("powershell.exe", log=log) + if not powershell_exe: + raise ValueError("powershell is required; " "set POWERSHELL or PATH") + + def powershell(argstring, check=True): + "Invoke `powershell.exe`. Arguments are given as a string to allow consumer to quote." + args = [powershell_exe, "-c", argstring] + joined = " ".join(shlex_quote(arg) for arg in args) + log( + logging.INFO, "msix", {"args": args, "joined": joined}, "Invoking: {joined}" + ) + return subprocess.run( + args, check=check, universal_newlines=True, capture_output=True + ).stdout + + signtool = find_sdk_tool("signtool.exe", log=log) + if not signtool: + raise ValueError( + "signtool is required; " "set SIGNTOOL or WINDOWSSDKDIR or PATH" + ) + + # Our first order of business is to find, or generate, a (self-signed) + # certificate. + + # These are baked into enough places under `browser/` that we need not + # extract constants. + vendor = "Mozilla" + publisher = "CN=Mozilla Corporation, OU=MSIX Packaging" + friendly_name = "Mozilla Corporation MSIX Packaging Test Certificate" + + # The convention is $MOZBUILD_STATE_PATH/cache/$FEATURE. + crt_path = mozpath.join( + get_state_dir(), + "cache", + "mach-msix", + "{}.crt".format(friendly_name).replace(" ", "_").lower(), + ) + crt_path = mozpath.abspath(crt_path) + ensureParentDir(crt_path) + + pfx_path = crt_path.replace(".crt", ".pfx") + + # TODO: maybe use an actual password. For now, just something that won't be + # brute-forced. + password = "193dbfc6-8ff7-4a95-8f32-6b4468626bd0" + + if force or not os.path.isfile(crt_path): + log( + logging.INFO, + "msix", + {"crt_path": crt_path}, + "Creating new self signed certificate at: {}".format(crt_path), + ) + + thumbprints = [ + thumbprint.strip() + for thumbprint in powershell( + ( + "Get-ChildItem -Path Cert:\CurrentUser\My" + '| Where-Object {{$_.Subject -Match "{}"}}' + '| Where-Object {{$_.FriendlyName -Match "{}"}}' + "| Select-Object -ExpandProperty Thumbprint" + ).format(vendor, friendly_name) + ).splitlines() + ] + if len(thumbprints) > 1: + raise Exception( + "Multiple certificates with friendly name found: {}".format( + friendly_name + ) + ) + + if len(thumbprints) == 1: + thumbprint = thumbprints[0] + else: + thumbprint = None + + if not thumbprint: + thumbprint = ( + powershell( + ( + 'New-SelfSignedCertificate -Type Custom -Subject "{}" ' + '-KeyUsage DigitalSignature -FriendlyName "{}"' + " -CertStoreLocation Cert:\CurrentUser\My" + ' -TextExtension @("2.5.29.37={{text}}1.3.6.1.5.5.7.3.3", ' + '"2.5.29.19={{text}}")' + "| Select-Object -ExpandProperty Thumbprint" + ).format(publisher, friendly_name) + ) + .strip() + .upper() + ) + + if not thumbprint: + raise Exception( + "Failed to find or create certificate with friendly name: {}".format( + friendly_name + ) + ) + + powershell( + 'Export-Certificate -Cert Cert:\CurrentUser\My\{} -FilePath "{}"'.format( + thumbprint, crt_path + ) + ) + log( + logging.INFO, + "msix", + {"crt_path": crt_path}, + "Exported public certificate: {crt_path}", + ) + + powershell( + ( + 'Export-PfxCertificate -Cert Cert:\CurrentUser\My\{} -FilePath "{}"' + ' -Password (ConvertTo-SecureString -String "{}" -Force -AsPlainText)' + ).format(thumbprint, pfx_path, password) + ) + log( + logging.INFO, + "msix", + {"pfx_path": pfx_path}, + "Exported private certificate: {pfx_path}", + ) + + # Second, to find the right thumbprint to use. We do this here in case + # we're coming back to an existing certificate. + + log( + logging.INFO, + "msix", + {"crt_path": crt_path}, + "Signing with existing self signed certificate: {crt_path}", + ) + + thumbprints = [ + thumbprint.strip() + for thumbprint in powershell( + 'Get-PfxCertificate -FilePath "{}" | Select-Object -ExpandProperty Thumbprint'.format( + crt_path + ) + ).splitlines() + ] + if len(thumbprints) > 1: + raise Exception("Multiple thumbprints found for PFX: {}".format(pfx_path)) + if len(thumbprints) == 0: + raise Exception("No thumbprints found for PFX: {}".format(pfx_path)) + thumbprint = thumbprints[0] + log( + logging.INFO, + "msix", + {"thumbprint": thumbprint}, + "Signing with certificate with thumbprint: {thumbprint}", + ) + + # Third, do the actual signing. + + args = [ + signtool, + "sign", + "/a", + "/fd", + "SHA256", + "/f", + pfx_path, + "/p", + password, + output, + ] + if not verbose: + subprocess.check_call(args, universal_newlines=True) + else: + # Suppress output unless we fail. + try: + subprocess.check_output(args, universal_newlines=True) + except subprocess.CalledProcessError as e: + sys.stderr.write(e.output) + raise + + # As a convenience to the user, tell how to use this certificate if it's not + # already trusted, and how to work with MSIX files more generally. + if verbose: + root_thumbprints = [ + root_thumbprint.strip() + for root_thumbprint in powershell( + "Get-ChildItem -Path Cert:\LocalMachine\Root\{} " + "| Select-Object -ExpandProperty Thumbprint".format(thumbprint), + check=False, + ).splitlines() + ] + if thumbprint not in root_thumbprints: + log( + logging.INFO, + "msix", + {"thumbprint": thumbprint}, + "Certificate with thumbprint not found in trusted roots: {thumbprint}", + ) + log( + logging.INFO, + "msix", + {"crt_path": crt_path, "output": output}, + r"""\ +# Usage +To trust this certificate (requires an elevated shell): +powershell -c 'Import-Certificate -FilePath "{crt_path}" -Cert Cert:\LocalMachine\Root\' +To verify this MSIX signature exists and is trusted: +powershell -c 'Get-AuthenticodeSignature -FilePath "{output}" | Format-List *' +To install this MSIX: +powershell -c 'Add-AppPackage -path "{output}"' +To see details after installing: +powershell -c 'Get-AppPackage -name Mozilla.MozillaFirefox(Beta,...)' + """.strip(), + ) + + return 0 + + +def _sign_msix_posix(output, force, log, verbose): + makeappx = find_sdk_tool("makeappx", log=log) + + if not makeappx: + raise ValueError("makeappx is required; " "set MAKEAPPX or PATH") + + openssl = find_sdk_tool("openssl", log=log) + + if not openssl: + raise ValueError("openssl is required; " "set OPENSSL or PATH") + + if "sign" not in subprocess.run(makeappx, capture_output=True).stdout.decode( + "utf-8" + ): + raise ValueError( + "makeappx must support 'sign' operation. ", + "You probably need to build Mozilla's version of it: ", + "https://github.com/mozilla/msix-packaging/tree/johnmcpms/signing", + ) + + def run_openssl(args, check=True, capture_output=True): + full_args = [openssl, *args] + joined = " ".join(shlex_quote(arg) for arg in full_args) + log( + logging.INFO, + "msix", + {"args": args}, + f"Invoking: {joined}", + ) + return subprocess.run( + full_args, + check=check, + capture_output=capture_output, + universal_newlines=True, + ) + + # These are baked into enough places under `browser/` that we need not + # extract constants. + cn = "Mozilla Corporation" + ou = "MSIX Packaging" + friendly_name = "Mozilla Corporation MSIX Packaging Test Certificate" + # Password is needed when generating the cert, but + # "makeappx" explicitly does _not_ support passing it + # so it ends up getting removed when we create the pfx + password = "temp" + + cache_dir = mozpath.join(get_state_dir(), "cache", "mach-msix") + ca_crt_path = mozpath.join(cache_dir, "MozillaMSIXCA.cer") + ca_key_path = mozpath.join(cache_dir, "MozillaMSIXCA.key") + csr_path = mozpath.join(cache_dir, "MozillaMSIX.csr") + crt_path = mozpath.join(cache_dir, "MozillaMSIX.cer") + key_path = mozpath.join(cache_dir, "MozillaMSIX.key") + pfx_path = mozpath.join( + cache_dir, + "{}.pfx".format(friendly_name).replace(" ", "_").lower(), + ) + pfx_path = mozpath.abspath(pfx_path) + ensureParentDir(pfx_path) + + if force or not os.path.isfile(pfx_path): + log( + logging.INFO, + "msix", + {"pfx_path": pfx_path}, + "Creating new self signed certificate at: {}".format(pfx_path), + ) + + # Ultimately, we only end up using the CA certificate + # and the pfx (aka pkcs12) bundle containing the signing key + # and certificate. The other things we create along the way + # are not used for subsequent signing for testing. + # To get those, we have to do a few things: + # 1) Create a new CA key and certificate + # 2) Create a new signing key + # 3) Create a CSR with that signing key + # 4) Create the certificate with the CA key+cert from the CSR + # 5) Convert the signing key and certificate to a pfx bundle + args = [ + "req", + "-x509", + "-days", + "7200", + "-sha256", + "-newkey", + "rsa:4096", + "-keyout", + ca_key_path, + "-out", + ca_crt_path, + "-outform", + "PEM", + "-subj", + f"/OU={ou} CA/CN={cn} CA", + "-passout", + f"pass:{password}", + ] + run_openssl(args) + args = [ + "genrsa", + "-des3", + "-out", + key_path, + "-passout", + f"pass:{password}", + ] + run_openssl(args) + args = [ + "req", + "-new", + "-key", + key_path, + "-out", + csr_path, + "-subj", + # We actually want these in the opposite order, to match what's + # included in the AppxManifest. Openssl ends up reversing these + # for some reason, so we put them in backwards here. + f"/OU={ou}/CN={cn}", + "-passin", + f"pass:{password}", + ] + run_openssl(args) + args = [ + "x509", + "-req", + "-sha256", + "-days", + "7200", + "-in", + csr_path, + "-CA", + ca_crt_path, + "-CAcreateserial", + "-CAkey", + ca_key_path, + "-out", + crt_path, + "-outform", + "PEM", + "-passin", + f"pass:{password}", + ] + run_openssl(args) + args = [ + "pkcs12", + "-export", + "-inkey", + key_path, + "-in", + crt_path, + "-name", + friendly_name, + "-passin", + f"pass:{password}", + # All three of these options (-keypbe, -certpbe, and -passout) + # are necessary to create a pfx bundle that won't even prompt + # for a password. If we miss one, we will still get a password + # prompt for the blank password. + "-keypbe", + "NONE", + "-certpbe", + "NONE", + "-passout", + "pass:", + "-out", + pfx_path, + ] + run_openssl(args) + + args = [makeappx, "sign", "-p", output, "-c", pfx_path] + if not verbose: + subprocess.check_call( + args, + universal_newlines=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + else: + # Suppress output unless we fail. + try: + subprocess.check_output(args, universal_newlines=True) + except subprocess.CalledProcessError as e: + sys.stderr.write(e.output) + raise + + if verbose: + log( + logging.INFO, + "msix", + { + "ca_crt_path": ca_crt_path, + "ca_crt": mozpath.basename(ca_crt_path), + "output_path": output, + "output": mozpath.basename(output), + }, + r"""\ +# Usage +First, transfer the root certificate ({ca_crt_path}) and signed MSIX +({output_path}) to a Windows machine. +To trust this certificate ({ca_crt_path}), run the following in an elevated shell: +powershell -c 'Import-Certificate -FilePath "{ca_crt}" -Cert Cert:\LocalMachine\Root\' +To verify this MSIX signature exists and is trusted: +powershell -c 'Get-AuthenticodeSignature -FilePath "{output}" | Format-List *' +To install this MSIX: +powershell -c 'Add-AppPackage -path "{output}"' +To see details after installing: +powershell -c 'Get-AppPackage -name Mozilla.MozillaFirefox(Beta,...)' + """.strip(), + ) + + +def sign_msix(output, force=False, log=None, verbose=False): + """Sign an MSIX with a locally generated self-signed certificate.""" + + if sys.platform.startswith("win"): + return _sign_msix_win(output, force, log, verbose) + else: + return _sign_msix_posix(output, force, log, verbose) diff --git a/python/mozbuild/mozbuild/repackaging/pkg.py b/python/mozbuild/mozbuild/repackaging/pkg.py new file mode 100644 index 0000000000..e7699ce5c4 --- /dev/null +++ b/python/mozbuild/mozbuild/repackaging/pkg.py @@ -0,0 +1,46 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. + +import shutil +import tarfile +from pathlib import Path + +import mozfile +from mozpack.pkg import create_pkg + +from mozbuild.bootstrap import bootstrap_toolchain + + +def repackage_pkg(infile, output): + + if not tarfile.is_tarfile(infile): + raise Exception("Input file %s is not a valid tarfile." % infile) + + xar_tool = bootstrap_toolchain("xar/xar") + if not xar_tool: + raise Exception("Could not find xar tool.") + mkbom_tool = bootstrap_toolchain("mkbom/mkbom") + if not mkbom_tool: + raise Exception("Could not find mkbom tool.") + # Note: CPIO isn't standard on all OS's + cpio_tool = shutil.which("cpio") + if not cpio_tool: + raise Exception("Could not find cpio.") + + with mozfile.TemporaryDirectory() as tmpdir: + mozfile.extract_tarball(infile, tmpdir) + + app_list = list(Path(tmpdir).glob("*.app")) + if len(app_list) != 1: + raise Exception( + "Input file should contain a single .app file. %s found." + % len(app_list) + ) + create_pkg( + source_app=Path(app_list[0]), + output_pkg=Path(output), + mkbom_tool=Path(mkbom_tool), + xar_tool=Path(xar_tool), + cpio_tool=Path(cpio_tool), + ) diff --git a/python/mozbuild/mozbuild/repackaging/test/python.ini b/python/mozbuild/mozbuild/repackaging/test/python.ini new file mode 100644 index 0000000000..f51fad30a3 --- /dev/null +++ b/python/mozbuild/mozbuild/repackaging/test/python.ini @@ -0,0 +1,4 @@ +[DEFAULT] +subsuite = mozbuild + +[test_msix.py] diff --git a/python/mozbuild/mozbuild/repackaging/test/test_msix.py b/python/mozbuild/mozbuild/repackaging/test/test_msix.py new file mode 100644 index 0000000000..f6735dcc75 --- /dev/null +++ b/python/mozbuild/mozbuild/repackaging/test/test_msix.py @@ -0,0 +1,53 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import unittest + +from mozunit import main + +from mozbuild.repackaging.msix import get_embedded_version + + +class TestMSIX(unittest.TestCase): + def test_embedded_version(self): + """Test embedded version extraction.""" + + buildid = "YYYY0M0D0HMmSs" + for input, output in [ + ("X.0a1", "X.YY0M.D0H.0"), + ("X.YbZ", "X.Y.Z.0"), + ("X.Yesr", "X.Y.0.0"), + ("X.Y.Zesr", "X.Y.Z.0"), + ("X.YrcZ", "X.Y.Z.0"), + ("X.Y", "X.Y.0.0"), + ("X.Y.Z", "X.Y.Z.0"), + ]: + version = get_embedded_version(input, buildid) + self.assertEqual(version, output) + # Some parts of the MSIX packaging ecosystem require the final digit + # in the dotted quad to be 0. + self.assertTrue(version.endswith(".0")) + + buildid = "YYYYMmDdHhMmSs" + for input, output in [ + ("X.0a1", "X.YYMm.DdHh.0"), + ]: + version = get_embedded_version(input, buildid) + self.assertEqual(version, output) + # Some parts of the MSIX packaging ecosystem require the final digit + # in the dotted quad to be 0. + self.assertTrue(version.endswith(".0")) + + for input in [ + "X.Ya1", + "X.0a2", + "X.Y.ZbW", + "X.Y.ZrcW", + ]: + with self.assertRaises(ValueError): + get_embedded_version(input, buildid) + + +if __name__ == "__main__": + main() |