# 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 copy import errno import hashlib import json import logging import os import re import subprocess import typing from collections import defaultdict from itertools import dropwhile from pathlib import Path import mozpack.path as mozpath import toml import tomlkit from looseversion import LooseVersion from mozboot.util import MINIMUM_RUST_VERSION from mozbuild.base import BuildEnvironmentNotFoundException, MozbuildObject if typing.TYPE_CHECKING: import datetime # Type of a TOML value. TomlItem = typing.Union[ str, list["TomlItem"], dict[str, "TomlItem"], bool, int, float, "datetime.datetime", "datetime.date", "datetime.time", ] CARGO_CONFIG_TEMPLATE = """\ # This file contains vendoring instructions for cargo. # It was generated by `mach vendor rust`. # Please do not edit. {config} # Take advantage of the fact that cargo will treat lines starting with # # as comments to add preprocessing directives. This file can thus by copied # as-is to $topsrcdir/.cargo/config.toml with no preprocessing to be used there # (for e.g. independent tasks building rust code), or be preprocessed by # the build system to produce a .cargo/config.toml with the right content. #define REPLACE_NAME {replace_name} #define VENDORED_DIRECTORY {directory} # We explicitly exclude the following section when preprocessing because # it would overlap with the preprocessed [source."@REPLACE_NAME@"], and # cargo would fail. #ifndef REPLACE_NAME [source.{replace_name}] directory = "{directory}" #endif # Thankfully, @REPLACE_NAME@ is unlikely to be a legitimate source, so # cargo will ignore it when it's here verbatim. #filter substitution [source."@REPLACE_NAME@"] directory = "@top_srcdir@/@VENDORED_DIRECTORY@" """ CARGO_LOCK_NOTICE = """ NOTE: `cargo vendor` may have made changes to your Cargo.lock. To restore your Cargo.lock to the HEAD version, run `git checkout -- Cargo.lock` or `hg revert Cargo.lock`. """ # Some crates here would also be caught by the name prefix check, # but putting specific crates here allows for more granular advice # for what to use instead. Even more crates could have granular # advice here, but it's probably not worthwhile to provide granular # advice about crates whose vendoring is unlikely to be attempted, # such as the `rust_icu_` crates. PACKAGES_WE_DONT_WANT = { "icu": "Use the specific ICU4X crate (crates whose name starts with icu_) instead of the metacrate.", "rust_icu": "Use ICU4X (crates whose name starts with icu_) instead", "unic": "Use ICU4X (crates whose name starts with icu_) instead", "unicode-canonical-combining-class": "Use icu_normalizer instead", "unicode-case-mapping": "Use icu_casemap instead", "unicode-bidi-mirroring": "Use icu_properties instead", "unicode-ccc": "Use icu_normalizer instead", "unicode-general-category": "Use icu_properties instead", "unicode-id": "Use icu_properties instead", "unicode-id-start": "Use icu_properties instead", # Impractical to require icu_properties instead of unicode-ident at this time. # "unicode-ident": "Use icu_properties instead", "unicode-joining-type": "Use icu_properties instead", "unicode-linebreak": "Use icu_segmenter instead", "unicode-normalization": "Use icu_normalizer instead", "unicode-properties": "Use icu_properties instead", "unicode-script": "Use icu_properties instead", "unicode-segmentation": "Use icu_segmenter instead", "unicode-xid": "Use icu_properties instead", "unicode_categories": "Use icu_properties instead", "unicode_names": "Avoid including data for Unicode character names", "unicode_names2": "Avoid including data for Unicode character names", "feruca": "Use icu_collator instead", "idna_mapping": "Make sure to use a version of idna_adapter that uses ICU4X", "unic-bidi": "Use unicode-bidi instead", "unic-idna": "Use idna instead", "unic-normal": "Use icu_normalizer instead", "unic-segment": "Use icu_segmenter instead", "unic-ucd": "Use icu_properties instead", "num-format": "Use icu_decimal instead", "encoding": "Use encoding_rs instead", # Impractical to require icu_casemap instead of unicase at this time. # "unicase": "Use icu_casemap instead", } PREFIXES_WE_DONT_WANT = { "unicode-": "Use ICU4X (crates whose name starts with icu_) instead", "unic-": "Use ICU4X (crates whose name starts with icu_) instead", "rust_icu_": "Use ICU4X (crates whose name starts with icu_) instead", "unicode_": "Use ICU4X (crates whose name starts with icu_) instead", } ALLOWED_DESPITE_PREFIX = { "unicode-bidi", # Out of scope for ICU4X; used with ICU4X data "unicode-bidi-ffi", # FFI for previous "unicode-ident", # Impractical to require icu_properties at this time "unicode-width", # icu_properties has the raw data but not the algorithm "unic-langid", # We want to migrate to icu_locale eventually "unic-langid-ffi", # FFI for previous "unic-langid-impl", # Implementation detail of unic-langid } SEEN_ALLOWED_DESPITE_PREFIX = set() PACKAGES_WE_ALWAYS_WANT_AN_OVERRIDE_OF = [ "autocfg", "cmake", "vcpkg", "windows", "windows-targets", ] def dont_want_package(name): if reason := PACKAGES_WE_DONT_WANT.get(name): return reason if name in ALLOWED_DESPITE_PREFIX: SEEN_ALLOWED_DESPITE_PREFIX.add(name) return None for prefix, reason in PREFIXES_WE_DONT_WANT.items(): if name.startswith(prefix): return reason class VendorRust(MozbuildObject): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._issues = [] def serialize_issues_json(self): return json.dumps({ "Cargo.lock": [ { "path": "Cargo.lock", "column": None, "line": None, "level": "error" if level == logging.ERROR else "warning", "message": msg, } for (level, msg) in self._issues ] }) def generate_diff_stream(self): return self.repository.diff_stream() def log(self, level, action, params, format_str): if level >= logging.WARNING: self._issues.append((level, format_str.format(**params))) super().log(level, action, params, format_str) def get_cargo_path(self): try: return self.substs["CARGO"] except (BuildEnvironmentNotFoundException, KeyError): if "MOZ_AUTOMATION" in os.environ: cargo = os.path.join( os.environ["MOZ_FETCHES_DIR"], "rustc", "bin", "cargo" ) assert os.path.exists(cargo) return cargo # Default if this tree isn't configured. from mozfile import which cargo = which("cargo") if not cargo: raise OSError( errno.ENOENT, ( "Could not find 'cargo' on your $PATH. " "Hint: have you run `mach build` or `mach configure`?" ), ) return cargo def cargo_version(self, cargo): out = ( subprocess .check_output([cargo, "--version"]) .splitlines()[0] .decode("UTF-8") ) if not out.startswith("cargo"): raise RuntimeError("Unexpected `cargo --version` output") return LooseVersion(out.split()[1]) def check_cargo_version(self, cargo): """ Ensure that Cargo is new enough. """ try: version = self.cargo_version(cargo) except RuntimeError: return False # Cargo 1.90.0 changed vendoring in a way that creates a lot of noise # if we go back and forth between vendoring with an older version and # a newer version. Only allow the newer versions. minimum_rust_version = MINIMUM_RUST_VERSION if LooseVersion("1.90.0") >= MINIMUM_RUST_VERSION: minimum_rust_version = "1.90.0" if version < minimum_rust_version: self.log( logging.ERROR, "cargo_version", {}, f"Cargo >= {minimum_rust_version} required (install Rust {minimum_rust_version} or newer)", ) return False self.log(logging.DEBUG, "cargo_version", {}, "cargo is new enough") return True def has_modified_files(self): """ Ensure that there aren't any uncommitted changes to files in the working copy, since we're going to change some state on the user. Allow changes to Cargo.{toml,lock} since that's likely to be a common use case. """ modified = [ f for f in self.repository.get_changed_files("M") if os.path.basename(f) not in ("Cargo.toml", "Cargo.lock") and not f.startswith("supply-chain/") ] if modified: self.log( logging.ERROR, "modified_files", {}, """You have uncommitted changes to the following files: {files} Please commit or stash these changes before vendoring, or re-run with `--ignore-modified`. """.format(files="\n".join(sorted(modified))), ) return modified def _ensure_cargo(self): """ Ensures all the necessary cargo bits are installed. Returns the path to cargo if successful, None otherwise. """ cargo = self.get_cargo_path() if not self.check_cargo_version(cargo): return None return cargo # A whitelist of acceptable license identifiers for the # packages.license field from https://spdx.org/licenses/. Cargo # documentation claims that values are checked against the above # list and that multiple entries can be separated by '/'. We # choose to list all combinations instead for the sake of # completeness and because some entries below obviously do not # conform to the format prescribed in the documentation. # # It is insufficient to have additions to this whitelist reviewed # solely by a build peer; any additions must be checked by somebody # competent to review licensing minutiae. # Licenses for code used at runtime. Please see the above comment before # adding anything to this list. RUNTIME_LICENSE_WHITELIST = [ "0BSD", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", # BSD-2-Clause and BSD-3-Clause are ok, but packages using them # must be added to the appropriate section of about:licenses. # To encourage people to remember to do that, we do not whitelist # the licenses themselves, and we require the packages to be added # to RUNTIME_LICENSE_PACKAGE_WHITELIST below. "CC0-1.0", "ISC", "MIT", "MPL-2.0", "Unicode-3.0", "Unicode-DFS-2016", "Unlicense", "Zlib", ] # Licenses for code used at build time (e.g. code generators). Please see the above # comments before adding anything to this list. BUILDTIME_LICENSE_WHITELIST = { "BSD-3-Clause": [ "bindgen", "fuchsia-zircon", "fuchsia-zircon-sys", "fuchsia-cprng", "glsl", "instant", ] } # This whitelist should only be used for packages that use an acceptable # license, but that also need to explicitly mentioned in about:license. RUNTIME_LICENSE_PACKAGE_WHITELIST = { "BSD-2-Clause": [ "arrayref", "mach", "qlog", ], "BSD-3-Clause": [ "jxl", "jxl_macros", "jxl_simd", "jxl_transforms", "subtle", "uritemplate-next", ], } # ICU4X is distributed as individual crates that all share the same LICENSE # that will need to be individually added to the allow list below. We'll # define the SHA256 once here, to make the review process easier as new # ICU4X crates are vendored into the tree. ICU4X_LICENSE_SHA256 = ( "853f87c96f3d249f200fec6db1114427bc8bdf4afddc93c576956d78152ce978" ) # This whitelist should only be used for packages that use a # license-file and for which the license-file entry has been # reviewed. The table is keyed by package names and maps to the # sha256 hash of the license file that we reviewed. # # As above, it is insufficient to have additions to this whitelist # reviewed solely by a build peer; any additions must be checked by # somebody competent to review licensing minutiae. RUNTIME_LICENSE_FILE_PACKAGE_WHITELIST = { # MIT "deque": "6485b8ed310d3f0340bf1ad1f47645069ce4069dcc6bb46c7d5c6faf41de1fdb", # we're whitelisting this fuchsia crate because it doesn't get built in the final # product but has a license-file that needs ignoring "fuchsia-cprng": "03b114f53e6587a398931762ee11e2395bfdba252a329940e2c8c9e81813845b", # ICU4X uses Unicode v3 license "icu_calendar": ICU4X_LICENSE_SHA256, "icu_calendar_data": ICU4X_LICENSE_SHA256, "icu_collections": ICU4X_LICENSE_SHA256, "icu_locid": ICU4X_LICENSE_SHA256, "icu_locid_transform": ICU4X_LICENSE_SHA256, "icu_locid_transform_data": ICU4X_LICENSE_SHA256, "icu_properties": ICU4X_LICENSE_SHA256, "icu_properties_data": ICU4X_LICENSE_SHA256, "icu_provider": ICU4X_LICENSE_SHA256, "icu_provider_adapters": ICU4X_LICENSE_SHA256, "icu_provider_macros": ICU4X_LICENSE_SHA256, "icu_segmenter": ICU4X_LICENSE_SHA256, "litemap": ICU4X_LICENSE_SHA256, "tinystr": ICU4X_LICENSE_SHA256, "writeable": ICU4X_LICENSE_SHA256, "yoke": ICU4X_LICENSE_SHA256, "yoke-derive": ICU4X_LICENSE_SHA256, "zerofrom": ICU4X_LICENSE_SHA256, "zerofrom-derive": ICU4X_LICENSE_SHA256, "zerovec": ICU4X_LICENSE_SHA256, "zerovec-derive": ICU4X_LICENSE_SHA256, } @staticmethod def runtime_license(package, license_string): """Cargo docs say: --- https://doc.rust-lang.org/cargo/reference/manifest.html This is an SPDX 2.1 license expression for this package. Currently crates.io will validate the license provided against a whitelist of known license and exception identifiers from the SPDX license list 2.4. Parentheses are not currently supported. Multiple licenses can be separated with a `/`, although that usage is deprecated. Instead, use a license expression with AND and OR operators to get more explicit semantics. --- But I have no idea how you can meaningfully AND licenses, so we will abort if that is detected. We'll handle `/` and OR as equivalent and approve is any is in our approved list.""" # This specific AND combination has been reviewed for encoding_rs. if ( license_string == "(Apache-2.0 OR MIT) AND BSD-3-Clause" and package == "encoding_rs" ): return True # This specific AND combination has been reviewed for unicode-ident. if ( license_string == "(MIT OR Apache-2.0) AND Unicode-DFS-2016" and package == "unicode-ident" ): return True if re.search(r"\s+AND", license_string): return False license_list = re.split(r"\s*/\s*|\s+OR\s+", license_string) for license in license_list: if license in VendorRust.RUNTIME_LICENSE_WHITELIST: return True if package in VendorRust.RUNTIME_LICENSE_PACKAGE_WHITELIST.get(license, []): return True return False def _check_licenses(self, vendor_dir: str) -> bool: def verify_acceptable_license(package: str, license: str) -> bool: self.log(logging.DEBUG, "package_license", {}, f"has license {license}") if not self.runtime_license(package, license): if license not in self.BUILDTIME_LICENSE_WHITELIST: self.log( logging.ERROR, "package_license_error", {}, f"""Package {package} has a non-approved license: {license}. Please request license review on the package's license. If the package's license is approved, please add it to the whitelist of suitable licenses. """, ) return False elif package not in self.BUILDTIME_LICENSE_WHITELIST[license]: self.log( logging.ERROR, "package_license_error", {}, f"""Package {package} has a license that is approved for build-time dependencies: {license} but the package itself is not whitelisted as being a build-time only package. If your package is build-time only, please add it to the whitelist of build-time only packages. Otherwise, you need to request license review on the package's license. If the package's license is approved, please add it to the whitelist of suitable licenses. """, ) return False return True def check_package(package_name: str) -> bool: self.log( logging.DEBUG, "package_check", {}, f"Checking license for {package_name}", ) toml_file = os.path.join(vendor_dir, package_name, "Cargo.toml") with open(toml_file, encoding="utf-8") as fh: toml_data = toml.load(fh) package_entry: dict[str, TomlItem] = toml_data["package"] license = package_entry.get("license", None) license_file = package_entry.get("license-file", None) if license is not None and type(license) is not str: self.log( logging.ERROR, "package_invalid_license_format", {}, f"package {package_name} has an invalid `license` field (expected a string)", ) return False if license_file is not None and type(license_file) is not str: self.log( logging.ERROR, "package_invalid_license_format", {}, f"package {package_name} has an invalid `license-file` field (expected a string)", ) return False # License information is optional for crates to provide, but # we require it. if not license and not license_file: self.log( logging.ERROR, "package_no_license", {}, f"package {package_name} does not provide a license", ) return False # The Cargo.toml spec suggests that crates should either have # `license` or `license-file`, but not both. We might as well # be defensive about that, though. if license and license_file: self.log( logging.ERROR, "package_many_licenses", {}, f"package {package_name} provides too many licenses", ) return False if license: return verify_acceptable_license(package_name, license) # otherwise, it's a custom license in a separate file assert license_file is not None self.log( logging.DEBUG, "package_license_file", {}, f"package has license-file {license_file}", ) if package_name not in self.RUNTIME_LICENSE_FILE_PACKAGE_WHITELIST: self.log( logging.ERROR, "package_license_file_unknown", {}, f"""Package {package_name} has an unreviewed license file: {license_file}. Please request review on the provided license; if approved, the package can be added to the whitelist of packages whose licenses are suitable. """, ) return False approved_hash = self.RUNTIME_LICENSE_FILE_PACKAGE_WHITELIST[package_name] with open( os.path.join(vendor_dir, package_name, license_file), "rb" ) as license_buf: current_hash = hashlib.sha256(license_buf.read()).hexdigest() if current_hash != approved_hash: self.log( logging.ERROR, "package_license_file_mismatch", {}, f"""Package {package_name} has changed its license file: {license_file} (hash {current_hash}). Please request review on the provided license; if approved, please update the license file's hash. """, ) return False return True # Force all of the packages to be checked for license information # before reducing via `all`, so all license issues are found in a # single `mach vendor rust` invocation. results = [ check_package(p) for p in os.listdir(vendor_dir) if os.path.isdir(os.path.join(vendor_dir, p)) ] return all(results) def _check_build_rust(self, cargo_lock): ret = True crates = {} for path in Path(self.topsrcdir).glob("build/rust/**/Cargo.toml"): with open(path) as fh: cargo_toml = toml.load(fh) relative_path = path.relative_to(self.topsrcdir) package = cargo_toml["package"] key = (package["name"], package["version"]) if key in crates: self.log( logging.ERROR, "build_rust", { "path": crates[key], "path2": relative_path, "crate": key[0], "version": key[1], }, "{path} and {path2} both contain {crate} {version}", ) ret = False crates[key] = relative_path for package in cargo_lock["package"]: key = (package["name"], package["version"]) if key in crates and "source" not in package: crates.pop(key) for (name, version), path in crates.items(): self.log( logging.ERROR, "build_rust", {"path": path, "crate": name, "version": version}, "{crate} {version} has an override in {path} that is not used", ) ret = False return ret def vendor(self, ignore_modified=False, force=False): from mozbuild.mach_commands import cargo_vet self.populate_logger() self.log_manager.enable_unstructured() if not ignore_modified and self.has_modified_files(): return False cargo = self._ensure_cargo() if not cargo: self.log(logging.ERROR, "cargo_not_found", {}, "Cargo was not found.") return False relative_vendor_dir = "third_party/rust" vendor_dir = mozpath.join(self.topsrcdir, relative_vendor_dir) # We use check_call instead of mozprocess to ensure errors are displayed. # We do an |update -p| here to regenerate the Cargo.lock file with minimal # changes. See bug 1324462 res = subprocess.run( [cargo, "update", "-p", "gkrust"], cwd=self.topsrcdir, check=False ) if res.returncode: self.log(logging.ERROR, "cargo_update_failed", {}, "Cargo update failed.") return False with open(os.path.join(self.topsrcdir, "Cargo.lock")) as fh: cargo_lock = toml.load(fh) failed = False for package in cargo_lock.get("patch", {}).get("unused", []): self.log( logging.ERROR, "unused_patch", {"crate": package["name"]}, """Unused patch in top-level Cargo.toml for {crate}.""", ) failed = True if not self._check_build_rust(cargo_lock): failed = True grouped = defaultdict(list) for package in cargo_lock["package"]: if package["name"] in PACKAGES_WE_ALWAYS_WANT_AN_OVERRIDE_OF: # When the in-tree version is used, there is `source` for # it in Cargo.lock, which is what we expect. if package.get("source"): self.log( logging.ERROR, "non_overridden", { "crate": package["name"], "version": package["version"], "source": package["source"], }, "Crate {crate} v{version} must be overridden but isn't " "and comes from {source}.", ) failed = True elif reason := dont_want_package(package["name"]): self.log( logging.ERROR, "undesirable", { "crate": package["name"], "version": package["version"], "reason": reason, }, "Crate {crate} is not desirable: {reason}", ) failed = True grouped[package["name"]].append(package) for name in ALLOWED_DESPITE_PREFIX: if name not in SEEN_ALLOWED_DESPITE_PREFIX: self.log( logging.ERROR, "unused_allowed_despite_prefix", {"crate": name}, "ALLOWED_DESPITE_PREFIX contains {crate}, " "but that crate is not actually used (anymore?).", ) failed = True for name, packages in grouped.items(): # Allow to have crates of the same name when one depends on the other. num = len([ p for p in packages if all(d.split()[0] != name for d in p.get("dependencies", [])) ]) if num > 1: self.log( logging.ERROR, "duplicate_crate", { "crate": name, "num": num, }, "There are {num} different versions of crate {crate}. " "Please avoid the extra duplication.", ) failed = True # Only emit warnings for cargo-vet for now. env = os.environ.copy() env["PATH"] = os.pathsep.join(( str(Path(cargo).parent), os.environ["PATH"], )) flags = ["--output-format=json"] if "MOZ_AUTOMATION" in os.environ: flags.append("--locked") flags.append("--frozen") res = cargo_vet( self, flags, stdout=subprocess.PIPE, env=env, ) if res.returncode: try: vet = json.loads(res.stdout) except Exception: # Most likely, if we're in a situation where stdout is not JSON, # stderr will have had some error message printed out, so falling # back to a failure case with no additional error message is fine. vet = {} logged_error = False for failure in vet.get("failures", []): failure["crate"] = failure.pop("name") self.log( logging.ERROR, "cargo_vet_failed", failure, "Missing audit for {crate}:{version} (requires {missing_criteria})." " Run `./mach cargo vet` for more information.", ) logged_error = True # NOTE: This could log more information, but the violation JSON # output isn't super stable yet, so it's probably simpler to tell # the caller to run `./mach cargo vet` directly. for key in vet.get("violations", {}).keys(): self.log( logging.ERROR, "cargo_vet_failed", {"key": key}, "Violation conflict for {key}. Run `./mach cargo vet` for more information.", ) logged_error = True if "error" in vet: # NOTE: The error format produced by cargo-vet is from the # `miette` crate, and can include a lot of metadata and context. # If we want to show more details in the future, we can expand # this rendering to also include things like source labels and # related error metadata. error = vet["error"] self.log( logging.ERROR, "cargo_vet_failed", error, "Vet {severity}: {message}", ) if "help" in error: self.log(logging.INFO, "cargo_vet_failed", error, " help: {help}") for cause in error.get("causes", []): self.log( logging.INFO, "cargo_vet_failed", {"cause": cause}, " cause: {cause}", ) for related in error.get("related", []): self.log( logging.INFO, "cargo_vet_failed", related, " related {severity}: {message}", ) self.log( logging.INFO, "cargo_vet_failed", {}, "Run `./mach cargo vet` for more information.", ) logged_error = True if not logged_error: self.log( logging.ERROR, "cargo_vet_failed", {}, "Unknown vet error. Run `./mach cargo vet` for more information.", ) failed = True # If we failed when checking the crates list and/or running `cargo vet`, # stop before invoking `cargo vendor`. if failed and not force: return False res = subprocess.run( [cargo, "vendor", vendor_dir], cwd=self.topsrcdir, stdout=subprocess.PIPE, check=False, ) if res.returncode: self.log(logging.ERROR, "cargo_vendor_failed", {}, "Cargo vendor failed.") return False output = res.stdout.decode("UTF-8") # Get the snippet of configuration that cargo vendor outputs, and # update .cargo/config.toml with it. # XXX(bug 1576765): Hopefully do something better after # https://github.com/rust-lang/cargo/issues/7280 is addressed. config = "\n".join( dropwhile(lambda l: not l.startswith("["), output.splitlines()) ) # The config is toml; parse it as such. config = toml.loads(config) # For each replace-with, extract their configuration and update the # corresponding directory to be relative to topsrcdir. replaces = { v["replace-with"] for v in config["source"].values() if "replace-with" in v } # We only really expect one replace-with if len(replaces) != 1: self.log( logging.ERROR, "vendor_failed", dict(replaces=replaces), """cargo vendor didn't output a unique replace-with. Found: {replaces}.""", ) return False replace_name = replaces.pop() replace = config["source"].pop(replace_name) replace["directory"] = mozpath.relpath( mozpath.normsep(os.path.normcase(replace["directory"])), mozpath.normsep(os.path.normcase(self.topsrcdir)), ) cargo_config = os.path.join(self.topsrcdir, ".cargo", "config.toml.in") with open(cargo_config, "w", encoding="utf-8", newline="\n") as fh: fh.write( CARGO_CONFIG_TEMPLATE.format( config=toml.dumps(config), replace_name=replace_name, directory=replace["directory"], ) ) def recursive_sort(obj): if isinstance(obj, tomlkit.items.Table): new_obj = obj.copy() body = [(k, recursive_sort(v)) for k, v in new_obj.value.body] # Only order the direct elements in the Table. Anything after # the first whitespace (key is None) or AoT is not expected to # be a direct element. This is a more or less assumption based # on the original order cargo will have written out. for n, (k, v) in enumerate(body): if k is None or v.is_aot(): break else: n = len(body) body[:n] = sorted(body[:n], key=lambda x: str(x[0])) new_obj.value.body[:] = body return new_obj if isinstance(obj, tomlkit.items.AoT): # Somehow obj.copy() yields a list instead of a AoT new_obj = copy.copy(obj) body = [recursive_sort(v) for v in new_obj.body] new_obj.body[:] = body return new_obj return obj # cargo 1.94 started removing .gitattributes and .gitignore files, as well # as vendoring files older versions didn't. It's a tough sell to bump the vendoring # requirement to 1.94 when we're still using 1.90 on CI, so adjust the tree for the # common denominator. for package in cargo_lock["package"]: source = package.get("source") if not source: continue unlinked = [] package_dir = Path(vendor_dir) / package["name"] # All differing files are dotfiles. for path in package_dir.glob("**/.*"): if path.name in ( ".gitattributes", ".gitignore", ".vscode", ".cargo-ok", ): if path.is_dir(): for root_path, dirs, files in os.walk(path, topdown=False): root = Path(root_path) for name in files: to_unlink = root / name try: to_unlink.unlink() unlinked.append( mozpath.normsep( str(to_unlink.relative_to(package_dir)) ) ) except FileNotFoundError: pass for name in dirs: try: (root / name).rmdir() except OSError: pass else: try: path.unlink() unlinked.append( mozpath.normsep(str(path.relative_to(package_dir))) ) except FileNotFoundError: pass # Update the checksums with the changes we made. checksum_json = package_dir / ".cargo-checksum.json" with checksum_json.open(encoding="utf-8") as fh: checksum_data = json.load(fh) # cargo 1.97 additionally started adding a "$comment" field to those # files. Remove it for consistency across versions. checksum_data.pop("$comment", None) for path in unlinked: try: del checksum_data["files"][path] except KeyError: pass with checksum_json.open(mode="w", encoding="utf-8") as fh: json.dump(checksum_data, fh, separators=(",", ":")) if not self._check_licenses(vendor_dir) and not force: self.log( logging.ERROR, "license_check_failed", {}, f"""The changes from `mach vendor rust` will NOT be added to version control. {CARGO_LOCK_NOTICE}""", ) self.repository.clean_directory(vendor_dir) return False self.repository.add_remove_files(vendor_dir) # explicitly add the content of the Cargo.toml.orig files as they are # covered by the hgignore pattern "*.orig". vendor_path = Path(vendor_dir) extra_files = list(vendor_path.glob("**/Cargo.toml.orig")) extra_files += list(vendor_path.glob("**/.*")) if extra_files: self.repository.add_remove_files(*extra_files, force=True) # 100k is a reasonable upper bound on source file size. FILESIZE_LIMIT = 100 * 1024 large_files = set() cumulative_added_size = 0 for f in self.repository.get_changed_files("A"): path = mozpath.join(self.topsrcdir, f) size = os.stat(path).st_size cumulative_added_size += size if size > FILESIZE_LIMIT: large_files.add(f) # Forcefully complain about large files being added, as history has # shown that large-ish files typically are not needed. if large_files: self.log( logging.ERROR, "filesize_check", {}, """The following files exceed the filesize limit of {size}: {files} If you can't reduce the size of these files, talk to a build peer (on the #build channel at https://chat.mozilla.org) about the particular large files you are adding. The changes from `mach vendor rust` will NOT be added to version control. {notice}""".format( files="\n".join(sorted(large_files)), size=FILESIZE_LIMIT, notice=CARGO_LOCK_NOTICE, ), ) if not force: self.repository.forget_add_remove_files(vendor_dir) self.repository.clean_directory(vendor_dir) return False # Only warn for large imports, since we may just have large code # drops from time to time (e.g. importing features into m-c). SIZE_WARN_THRESHOLD = 5 * 1024 * 1024 if cumulative_added_size >= SIZE_WARN_THRESHOLD: self.log( logging.WARN, "filesize_check", {}, f"""Your changes add {cumulative_added_size} bytes of added files. Please consider finding ways to reduce the size of the vendored packages. For instance, check the vendored packages for unusually large test or benchmark files that don't need to be published to crates.io and submit a pull request upstream to ignore those files when publishing.""", ) if "MOZ_AUTOMATION" in os.environ: changed = self.repository.get_changed_files(mode="staged") for file in changed: self.log( logging.ERROR, "vendor-change", {"file": file}, "File was modified by vendor: {file}", ) if changed: return False return True