| #!/usr/bin/env python3 | |
| # | |
| # Copyright 2022 The Bazel Authors. All rights reserved. | |
| # | |
| # Licensed under the Apache License, Version 2.0 (the "License"); | |
| # you may not use this file except in compliance with the License. | |
| # You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, software | |
| # distributed under the License is distributed on an "AS IS" BASIS, | |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| # See the License for the specific language governing permissions and | |
| # limitations under the License. | |
| # pylint: disable=invalid-name | |
| # pylint: disable=line-too-long | |
| # pylint: disable=missing-function-docstring | |
| """A script to perform BCR validations for Bazel modules | |
| Validations performed are: | |
| - Verify versions in metadata.json matches existing versions | |
| - Verify the source archive URL match the source repositories | |
| - Verify the source archive URL is stable | |
| - Verify if the presubmit.yml file matches the previous version | |
| - If not, we should require BCR maintainer review. | |
| - Verify the checked in MODULE.bazel file matches the one in the extracted and patched source tree. | |
| - Verify attestations (SLSA provenance / VSA) referenced by attestations.json (if it exists). | |
| """ | |
| import argparse | |
| import ast | |
| import json | |
| import os | |
| import re | |
| import requests | |
| import shutil | |
| import subprocess | |
| import sys | |
| import tarfile | |
| import tempfile | |
| import yaml | |
| import zstandard | |
| from difflib import unified_diff | |
| from enum import Enum | |
| from pathlib import Path | |
| from urllib.parse import urlparse | |
| import attestations as attestations_lib | |
| import slsa | |
| from registry import RegistryClient | |
| from registry import UpstreamRegistry | |
| from registry import Version | |
| from registry import download | |
| from registry import download_file | |
| from registry import integrity | |
| from registry import integrity_for_comparison | |
| from registry import read | |
| from verify_stable_archives import UrlStability | |
| from verify_stable_archives import verify_stable_archive | |
| class BcrValidationResult(Enum): | |
| GOOD = 1 | |
| NEED_BCR_MAINTAINER_REVIEW = 2 | |
| FAILED = 3 | |
| RED = "\x1b[31m" | |
| GREEN = "\x1b[32m" | |
| YELLOW = "\x1b[33m" | |
| RESET = "\x1b[0m" | |
| COLOR = { | |
| BcrValidationResult.GOOD: GREEN, | |
| BcrValidationResult.NEED_BCR_MAINTAINER_REVIEW: YELLOW, | |
| BcrValidationResult.FAILED: RED, | |
| } | |
| UPSTREAM_MODULES_DIR_URL = "https://bcr.bazel.build/modules" | |
| DEFAULT_SLSA_VERIFIER_VERSION = "v2.7.1" | |
| ATTESTATIONS_DOCS_URL = "https://github.com/bazelbuild/bazel-central-registry/blob/main/docs/attestations.md" | |
| GITHUB_REPO_RE = re.compile(r"^github:([^/]+/[^/]+)$") | |
| GITHUB_URL_RE = re.compile(r"^https://github.com/([^/]+/[^/]+)") | |
| # Global cache for GitHub user IDs | |
| GITHUB_USER_ID_CACHE = {} | |
| # For the following modules, going from a release with attestations to one without | |
| # is merely a warning, not a fatal error. | |
| # TODO(fweikert): enforce compliance once attestation feature is more widely used. | |
| ATTESTATION_HISTORY_CHECK_OPT_OUT = frozenset( | |
| [ | |
| "gcc_toolchain", # unmaintained or just very slow to respond | |
| "protobuf", # accidentally introduced attestations.json | |
| ] | |
| ) | |
| def print_collapsed_group(name): | |
| print("\n\n--- {0}\n\n".format(name)) | |
| def print_expanded_group(name): | |
| print("\n\n+++ {0}\n\n".format(name)) | |
| def parse_module_versions(registry, check_all, inputs): | |
| """Parse module versions to be validated from input.""" | |
| if check_all: | |
| return registry.get_all_module_versions() | |
| if not inputs: | |
| return [] | |
| result = [] | |
| for s in inputs: | |
| if "@" in s: | |
| name, version = s.split("@") | |
| result.append((name, version)) | |
| else: | |
| result.extend(registry.get_module_versions(s)) | |
| return result | |
| def apply_patch(work_dir, patch_strip, patch_file): | |
| # Requires patch to be installed | |
| subprocess.run( | |
| ["patch", "--strip", str(patch_strip), "--force", "--fuzz", "0", "--ignore-whitespace", "--input", patch_file], | |
| shell=False, | |
| check=True, | |
| env=os.environ, | |
| cwd=work_dir, | |
| ) | |
| def fix_line_endings(lines): | |
| return [line.rstrip() + "\n" for line in lines] | |
| def extract_reference(repo_path, path): | |
| """ | |
| Extracts the reference from a path matching the pattern /<repo_path>/archive/<ref>.zip or /<repo_path>/archive/<ref>.tar.gz | |
| Args: | |
| repo_path: The repository path. | |
| path: The path to extract the reference from. | |
| Returns: | |
| The reference if found, otherwise None. | |
| """ | |
| pattern = rf"^/{re.escape(repo_path)}/archive/(.+)\.(zip|tar\.gz)$" | |
| match = re.search(pattern, path) | |
| if match: | |
| return match.group(1) | |
| return None | |
| def is_ref_in_original_repo(repo_path, reference) -> bool: | |
| """ | |
| Checks if the given reference is truly part of the original GitHub repository's history. | |
| Uses the unofficial '/latest-commit/<REF>' endpoint, which returns JSON containing "isSpoofed". | |
| Args: | |
| repo_path: The repository path. | |
| reference: The reference to check | |
| Returns: | |
| True if the reference is found AND not spoofed; False otherwise | |
| """ | |
| # Make sure the reference is not a pull request. | |
| # GitHub archive URLs carry the full ref form (e.g. "refs/pull/1234/head"). | |
| # Such refs exist in the base repo's namespace but point at commits the | |
| # project has not reviewed or accepted, so reject both the bare and "refs/"- | |
| # prefixed forms, covering "/merge" as well as "/head". | |
| if re.match(r"^(refs/)?pull/\d+/(head|merge)$", reference): | |
| return False | |
| url = f"https://github.com/{repo_path}/latest-commit/{reference}" | |
| headers = {"Accept": "application/json"} | |
| try: | |
| response = requests.get(url, headers=headers) | |
| except requests.RequestException: | |
| raise BcrValidationException(f"Failed to check if reference is from the original repository via {url}") | |
| if not response.status_code == 200: | |
| # reference doesn't exist at all | |
| return False | |
| data = response.json() | |
| if "isSpoofed" not in data: | |
| raise BcrValidationException(f"Missing 'isSpoofed' attribute in response from {url}: {data}") | |
| return not data.get("isSpoofed") | |
| def check_github_url(repo_path, source_url): | |
| parts = urlparse(source_url) | |
| # Avoid potential path manipulations with "../" | |
| normalized_path = os.path.abspath(parts.path) | |
| # If the URL doesn't start with https://github.com/<repo_path>, return False | |
| if parts.scheme != "https" or parts.netloc != "github.com" or not normalized_path.startswith(f"/{repo_path}/"): | |
| return False | |
| # Allow paths under /<repo_path>/releases/download | |
| if normalized_path.startswith(f"/{repo_path}/releases/download/"): | |
| return True | |
| # Otherwise, the source archive must match /<repo_path>/archive/<reference>.<extension> | |
| # And we check if the reference does come from the original repository. | |
| reference = extract_reference(repo_path, normalized_path) | |
| return reference and is_ref_in_original_repo(repo_path, reference) | |
| def get_github_user_id(github_username): | |
| """ | |
| Get the GitHub user ID for a given GitHub username, with caching. | |
| Args: | |
| github_username: The GitHub username to look up. | |
| Returns: | |
| The GitHub user ID if found, otherwise None. | |
| """ | |
| if github_username in GITHUB_USER_ID_CACHE: | |
| return GITHUB_USER_ID_CACHE[github_username] | |
| url = f"https://api.github.com/users/{github_username}" | |
| headers = {} | |
| github_token = os.getenv("GITHUB_TOKEN") | |
| if github_token: | |
| headers["Authorization"] = f"token {github_token}" | |
| response = requests.get(url, headers=headers) | |
| if response.status_code == 200: | |
| user_id = response.json().get("id") | |
| GITHUB_USER_ID_CACHE[github_username] = user_id | |
| return user_id | |
| raise requests.HTTPError(f"unexpected {response.status_code} status code from {url}", response=response) | |
| def is_valid_bazel_compatibility_for_overlay(bazel_compatibility): | |
| """ | |
| Returns whether the bazel_compatibility is valid for an overlay. | |
| See: https://bazel.build/rules/lib/globals/module#module | |
| Args: | |
| bazel_compatibility: List of bazel compatibility strings. | |
| Returns: | |
| Boolean indicating compatibility with source overlays. | |
| """ | |
| if not bazel_compatibility: | |
| return False | |
| for v in bazel_compatibility: | |
| m = re.fullmatch(r"^([><-]=?)(\d+\.\d+\.\d+)$", v) | |
| if not m or m.group(1) == "-": | |
| continue # Skip - versions | |
| version = tuple(int(i) for i in m.group(2).split(".")) | |
| if m.group(1) == ">": | |
| if version > (7, 2, 0): | |
| return True | |
| elif m.group(1) == ">=": | |
| if version >= (7, 2, 1): | |
| return True | |
| return False | |
| class BcrValidationException(Exception): | |
| """ | |
| Raised whenever we should stop the validation immediately. | |
| """ | |
| class BcrValidator: | |
| def __init__(self, registry, upstream, should_fix, slsa_verifier_version=DEFAULT_SLSA_VERIFIER_VERSION): | |
| self.validation_results = [] | |
| self.registry = registry | |
| self.upstream = upstream | |
| # Whether the validator should try to fix the detected error. | |
| self.should_fix = should_fix | |
| self._verifier = slsa.Verifier(slsa_verifier_version, tempfile.mkdtemp()) | |
| def report(self, type, message): | |
| color = COLOR[type] | |
| print(f"{color}{type}{RESET}: {message}\n") | |
| self.validation_results.append((type, message)) | |
| def verify_module_existence(self, module_name, version): | |
| """Verify the directory exists and the version is recorded in metadata.json.""" | |
| if not self.registry.contains(module_name, version): | |
| self.report(BcrValidationResult.FAILED, f"{module_name}@{version} doesn't exist.") | |
| raise BcrValidationException("The module to be validated doesn't exist!") | |
| versions = self.registry.get_metadata(module_name)["versions"] | |
| if version not in versions: | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| f"Version {version} is not recorded in {module_name}'s metadata.json file.", | |
| ) | |
| else: | |
| self.report( | |
| BcrValidationResult.GOOD, | |
| "The module exists and is recorded in metadata.json.", | |
| ) | |
| def verify_source_archive_url_match_github_repo(self, module_name, version): | |
| """Verify the source archive URL matches the github repo. For now, we only support github repositories check.""" | |
| source = self.registry.get_source(module_name, version) | |
| if source.get("type", "archive") != "archive": | |
| raise BcrValidationException('Module source "type" must be "archive" (the default)') | |
| source_url = source["url"] | |
| source_repositories = self.registry.get_metadata(module_name).get("repository", []) | |
| matched = not source_repositories | |
| for source_repository in source_repositories: | |
| if matched: | |
| break | |
| repo_type, repo_path = source_repository.split(":") | |
| if repo_type == "github": | |
| matched = check_github_url(repo_path, source_url) | |
| elif repo_type == "https": | |
| repo = urlparse(source_repository) | |
| parts = urlparse(source_url) | |
| expected_path = repo.path | |
| if not expected_path.endswith("/"): | |
| expected_path += "/" | |
| matched = ( | |
| parts.scheme == repo.scheme | |
| and parts.netloc == repo.netloc | |
| and os.path.abspath(parts.path).startswith(expected_path) | |
| ) | |
| if not matched: | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| f"The source URL of {module_name}@{version} ({source_url}) doesn't match any of the module's source repositories {source_repositories}.\n" | |
| + "If it's a GitHub URL, only the following forms are allowed:\n" | |
| + " 1) https://github.com/<OWNER>/<REPO>/releases/download/... (Recommended)\n" | |
| + " 2) https://github.com/<OWNER>/<REPO>/archive/<REF>.(tar.gz|zip) where REF must come from the original repository", | |
| ) | |
| else: | |
| self.report( | |
| BcrValidationResult.GOOD, | |
| "The source URL matches one of the source repositories.", | |
| ) | |
| def verify_source_archive_url_stability(self, module_name, version): | |
| """Verify source archive URL is stable""" | |
| source_url = self.registry.get_source(module_name, version)["url"] | |
| if verify_stable_archive(source_url) == UrlStability.UNSTABLE: | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| f"{module_name}@{version} is using an unstable source url: `{source_url}`.\n" | |
| + "If at all possible, you should use a release archive URL in the format of " | |
| + "`https://github.com/<ORGANIZATION>/<REPO>/releases/download/<version>/<name>.tar.gz` " | |
| + "to ensure the archive checksum stability.\n" | |
| + "See https://blog.bazel.build/2023/02/15/github-archive-checksum.html for more context.\n" | |
| + "If no release archives are available, please add a comment to your BCR PR with the text\n" | |
| + " @bazel-io skip_check unstable_url\n" | |
| + "and this check will be skipped.", | |
| ) | |
| else: | |
| self.report(BcrValidationResult.GOOD, "The source URL doesn't look unstable.") | |
| def verify_source_archive_url_integrity(self, module_name, version): | |
| """Verify the integrity value of the URL and mirror URLs is correct.""" | |
| source = self.registry.get_source(module_name, version) | |
| expected_integrity = source["integrity"] | |
| urls_to_check = [(source["url"], "main source archive URL")] | |
| mirror_urls = source.get("mirror_urls", []) | |
| for i, mirror_url in enumerate(mirror_urls): | |
| urls_to_check.append((mirror_url, f"mirror URL #{i + 1}")) | |
| all_good = True | |
| for url, description in urls_to_check: | |
| try: | |
| real_integrity = integrity_for_comparison(download(url), expected_integrity) | |
| if real_integrity != expected_integrity: | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| f"{module_name}@{version}'s {description} `{url}` has expected integrity value " | |
| f"`{expected_integrity}`, but the real integrity value is `{real_integrity}`!", | |
| ) | |
| all_good = False | |
| except Exception as e: | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| f"Failed to download or verify integrity for {description} `{url}` of {module_name}@{version}: {e}", | |
| ) | |
| all_good = False | |
| if all_good: | |
| self.report( | |
| BcrValidationResult.GOOD, | |
| "The source archive's integrity value matches all provided URLs.", | |
| ) | |
| def verify_presubmit_yml_change(self, module_name, version): | |
| """Verify if the presubmit.yml is similar enough to the previous version.""" | |
| latest_snapshot = self.upstream.get_latest_module_version(module_name) | |
| if not latest_snapshot: | |
| self.report( | |
| BcrValidationResult.NEED_BCR_MAINTAINER_REVIEW, | |
| f"Module version {module_name}@{version} is new, the presubmit.yml file " | |
| "should be reviewed by a BCR maintainer.", | |
| ) | |
| return | |
| # Load the current yaml from the local fork but the previous yml from upstream. | |
| # (The local fork might not have merged the latest upstream changes.) | |
| previous_presubmit_lines = latest_snapshot.presubmit_yml_lines() | |
| current_presubmit_yml = self.registry.get_presubmit_yml_path(module_name, version) | |
| current_presubmit_lines = open(current_presubmit_yml, "r").readlines() | |
| # When nothing at all is changed, we don't even need to parse the documents. | |
| if current_presubmit_lines == previous_presubmit_lines: | |
| self.report( | |
| BcrValidationResult.GOOD, | |
| "The presubmit.yml file exactly matches the previous version.", | |
| ) | |
| return | |
| # We'll compare the parsed representation of the two presubmit.yml files. | |
| # Differences in only comments / whitespace don't need BCR maintainer review. | |
| previous_presubmit_doc = yaml.safe_load("".join(previous_presubmit_lines)) | |
| current_presubmit_doc = yaml.safe_load("".join(current_presubmit_lines)) | |
| if current_presubmit_doc == previous_presubmit_doc: | |
| self.report( | |
| BcrValidationResult.GOOD, | |
| "The presubmit.yml file matches the previous version, modulo comments and whitespace.", | |
| ) | |
| return | |
| # Differences in only platform names or bazel versions don't need BCR maintainer review. | |
| for doc in (previous_presubmit_doc, current_presubmit_doc): | |
| for matrixed_node in (doc, doc.setdefault("bcr_test_module", {})): | |
| for scrub in ("bazel", "platform"): | |
| matrixed_node.setdefault("matrix", {})[scrub] = None | |
| if current_presubmit_doc == previous_presubmit_doc: | |
| self.report( | |
| BcrValidationResult.GOOD, | |
| "The presubmit.yml file matches the previous version, modulo supported platforms and versions.", | |
| ) | |
| return | |
| # The files were not similar enough. Flag for BCR maintainer review. | |
| diff = list( | |
| unified_diff( | |
| previous_presubmit_lines, | |
| current_presubmit_lines, | |
| fromfile="HEAD", | |
| tofile=str(current_presubmit_yml), | |
| ) | |
| ) | |
| self.report( | |
| BcrValidationResult.NEED_BCR_MAINTAINER_REVIEW, | |
| f"The presubmit.yml file of {module_name}@{version} differs from its previous version " | |
| f"{module_name}@{latest_snapshot.version}, the following presubmit.yml file change " | |
| "should be reviewed by a BCR maintainer.\n " + " ".join(diff), | |
| ) | |
| def add_module_dot_bazel_patch(self, diff, module_name, version): | |
| """Adding a patch file for MODULE.bazel according to the diff result.""" | |
| source = self.registry.get_source(module_name, version) | |
| patch_file = self.registry.get_patch_file_path(module_name, version, "module_dot_bazel.patch") | |
| patch_file.parent.mkdir(parents=True, exist_ok=True) | |
| open(patch_file, "w").writelines(diff) | |
| source["patch_strip"] = int(source.get("patch_strip", 0)) | |
| patches = source.get("patches", {}) | |
| patches["module_dot_bazel.patch"] = integrity(read(patch_file)) | |
| source["patches"] = patches | |
| source_json_content = json.dumps(source, indent=4) + "\n" | |
| self.registry.get_source_json_path(module_name, version).write_text(source_json_content) | |
| def _download_source_archive(self, source, output_dir): | |
| source_url = source["url"] | |
| tmp_dir = Path(tempfile.mkdtemp()) | |
| archive_file = tmp_dir.joinpath(source_url.split("/")[-1].split("?")[0]) | |
| download_file(source_url, archive_file) | |
| # Use archive_type from source.json if specified, otherwise let shutil guess from filename | |
| # https://bazel.build/rules/lib/repo/http#http_archive-type | |
| # https://docs.python.org/3/library/shutil.html#shutil.unpack_archive | |
| archive_type = source.get("archive_type") | |
| if not archive_type: | |
| for ext in ["tar.gz", "tgz", "tar.bz2", "tar.xz", "tar.zst", "tzst", "tar", "zip", "jar", "war", "aar"]: | |
| if str(archive_file).endswith("." + ext): | |
| archive_type = ext | |
| break | |
| # shutil has no native zstd support, so stream-decompress with the | |
| # `zstandard` package straight into tarfile. | |
| if archive_type in ("tar.zst", "tzst"): | |
| with open(archive_file, "rb") as fh, zstandard.ZstdDecompressor().stream_reader(fh) as reader: | |
| with tarfile.open(fileobj=reader, mode="r|") as tar: | |
| if sys.version_info >= (3, 12): | |
| tar.extractall(output_dir, filter="data") | |
| else: | |
| tar.extractall(output_dir) | |
| return | |
| format = { | |
| "tar.gz": "gztar", | |
| "tgz": "gztar", | |
| "tar.bz2": "bztar", | |
| "tar.xz": "xztar", | |
| "tar": "tar", | |
| "zip": "zip", | |
| "jar": "zip", | |
| "war": "zip", | |
| "aar": "zip", | |
| }.get(archive_type) | |
| # Use PEP 706 safe extraction if available (Python 3.12+) | |
| if sys.version_info >= (3, 12) and format != "zip": | |
| shutil.unpack_archive(str(archive_file), output_dir, format=format, filter="data") | |
| else: | |
| # Fallback for older Python versions. Since CI is 3.12+, this handles local dev compatibility. | |
| shutil.unpack_archive(str(archive_file), output_dir, format=format) | |
| @staticmethod | |
| def extract_attribute_from_module(module_dot_bazel_file, attribute, default=None): | |
| """Extract the value of the given attribute from `module()` call in the MODULE.bazel file content""" | |
| with open(module_dot_bazel_file, "r") as file: | |
| tree = ast.parse(file.read(), filename=module_dot_bazel_file) | |
| for node in tree.body: | |
| if ( | |
| isinstance(node, ast.Expr) | |
| and isinstance(node.value, ast.Call) | |
| and isinstance(node.value.func, ast.Name) | |
| and node.value.func.id == "module" | |
| ): | |
| for k in node.value.keywords: | |
| if k.arg == attribute: | |
| if isinstance(k.value, ast.Constant): | |
| return k.value.value | |
| if isinstance(k.value, ast.List): | |
| return [v.value for v in k.value.elts if isinstance(v, ast.Constant)] | |
| return default | |
| def verify_module_dot_bazel(self, module_name, version, check_compatibility_level=True): | |
| source = self.registry.get_source(module_name, version) | |
| if source.get("type", "archive") != "archive": | |
| raise BcrValidationException('Module source "type" must be "archive" (the default)') | |
| tmp_dir = Path(tempfile.mkdtemp()) | |
| output_dir = tmp_dir.joinpath("source_root") | |
| # Validate the stripped output directory. | |
| strip_prefix = source.get("strip_prefix", "") | |
| if strip_prefix: | |
| candidate = (output_dir / strip_prefix).resolve() | |
| try: | |
| candidate.relative_to(output_dir.resolve()) | |
| except ValueError: | |
| error_msg = ( | |
| "CRITICAL FAILURE: " | |
| f"strip_prefix '{strip_prefix}' resolves outside the " | |
| f"extraction directory. Resolved to: {candidate}" | |
| ) | |
| self.report(BcrValidationResult.FAILED, error_msg) | |
| shutil.rmtree(tmp_dir) | |
| raise BcrValidationException(error_msg) | |
| source_root = output_dir / strip_prefix | |
| self._download_source_archive(source, output_dir) | |
| module_file = self.registry.get_module_dot_bazel_path(module_name, version) | |
| if module_file.is_symlink(): | |
| self.report(BcrValidationResult.FAILED, f"{module_file} must not be a symlink.") | |
| # Apply patch files if there are any, also verify their integrity values | |
| source_root = output_dir.joinpath(source["strip_prefix"] if "strip_prefix" in source else "") | |
| if "patches" in source: | |
| for patch_name, expected_integrity in source["patches"].items(): | |
| patch_file = self.registry.get_patch_file_path(module_name, version, patch_name) | |
| actual_integrity = integrity(read(patch_file)) | |
| if actual_integrity != expected_integrity: | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| f"The patch file `{patch_file}` has expected integrity value `{expected_integrity}`, " | |
| f"but the real integrity value is `{actual_integrity}`.", | |
| ) | |
| if patch_file.is_symlink(): | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| f"The patch file `{patch_name}` is a symlink to `{patch_file.readlink()}`, " | |
| "which is not allowed because https://raw.githubusercontent.com/ will not follow it.", | |
| ) | |
| apply_patch(source_root, int(source.get("patch_strip", 0)), str(patch_file.resolve())) | |
| if "overlay" in source: | |
| overlay_dir = self.registry.get_overlay_dir(module_name, version) | |
| for overlay_file, expected_integrity in source["overlay"].items(): | |
| overlay_src = overlay_dir / overlay_file | |
| if overlay_src.is_symlink(): | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| f"The overlay file `{overlay_file}` is a symlink to `{overlay_src.readlink()}`, " | |
| "which is not allowed because https://raw.githubusercontent.com/ will not follow it.", | |
| ) | |
| overlay_dst = source_root / overlay_file | |
| try: | |
| overlay_dst.resolve().relative_to(source_root.resolve()) | |
| except ValueError as e: | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| f"The overlay file path `{overlay_file}` must point inside the source archive.\n {e}", | |
| ) | |
| continue | |
| try: | |
| actual_integrity = integrity(read(overlay_src)) | |
| except FileNotFoundError: | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| f"The overlay file `{overlay_file}` does not exist", | |
| ) | |
| continue | |
| if actual_integrity != expected_integrity: | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| f"The overlay file `{overlay_file}` has expected integrity value `{expected_integrity}`, " | |
| f"but the real integrity value is `{actual_integrity}`.", | |
| ) | |
| continue | |
| overlay_dst.parent.mkdir(parents=True, exist_ok=True) | |
| shutil.copy2(overlay_src, overlay_dst) | |
| bcr_module_dot_bazel = self.registry.get_module_dot_bazel_path(module_name, version) | |
| source_module_dot_bazel = source_root.joinpath("MODULE.bazel") | |
| if source_module_dot_bazel.exists(): | |
| source_module_dot_bazel_content = open(source_module_dot_bazel, "r").readlines() | |
| bcr_module_dot_bazel_content = open(bcr_module_dot_bazel, "r").readlines() | |
| source_module_dot_bazel_content = fix_line_endings(source_module_dot_bazel_content) | |
| bcr_module_dot_bazel_content = fix_line_endings(bcr_module_dot_bazel_content) | |
| file_name = "a/" * int(source.get("patch_strip", 0)) + "MODULE.bazel" | |
| diff = list( | |
| unified_diff( | |
| source_module_dot_bazel_content, | |
| bcr_module_dot_bazel_content, | |
| fromfile=file_name, | |
| tofile=file_name, | |
| ) | |
| ) | |
| if diff: | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| "Checked in MODULE.bazel file doesn't match the one in the extracted and patched sources.\n" | |
| + f"Please fix the MODULE.bazel file or you can add the following patch to {module_name}@{version}:\n" | |
| + " " | |
| + " ".join(diff), | |
| ) | |
| if self.should_fix: | |
| self.add_module_dot_bazel_patch(diff, module_name, version) | |
| else: | |
| self.report(BcrValidationResult.GOOD, "Checked in MODULE.bazel matches the sources.") | |
| else: | |
| self.report(BcrValidationResult.GOOD, "No MODULE.bazel in the source archive.") | |
| # Check the version in MODULE.bazel matches the version in directory name | |
| version_in_module_dot_bazel = BcrValidator.extract_attribute_from_module(bcr_module_dot_bazel, "version") | |
| if version_in_module_dot_bazel != version: | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| "Checked in MODULE.bazel version does not match the version of the module directory added.", | |
| ) | |
| # Check the compatibility_level in MODULE.bazel is monotonically increasing. Also cautiously fail if | |
| # it doesn't match the previous version's compatibility_level. Both checks are skippable. | |
| if check_compatibility_level: | |
| versions = self.registry.get_metadata(module_name)["versions"] | |
| versions.sort(key=Version) | |
| index = versions.index(version) | |
| current_compatibility_level = BcrValidator.extract_attribute_from_module( | |
| bcr_module_dot_bazel, "compatibility_level", 0 | |
| ) | |
| if index < len(versions) - 1: | |
| next_version = versions[index + 1] | |
| next_module_dot_bazel = self.registry.get_module_dot_bazel_path(module_name, next_version) | |
| next_compatibility_level = BcrValidator.extract_attribute_from_module( | |
| next_module_dot_bazel, "compatibility_level", 0 | |
| ) | |
| if current_compatibility_level > next_compatibility_level: | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| f"The new module version {version} has a higher compatibility level than the next version {next_version} ({current_compatibility_level} > {next_compatibility_level}).\n" | |
| + "If this is intentional, please comment on your PR `@bazel-io skip_check compatibility_level`\n" | |
| + "Learn more about when to increase the compatibility level at https://bazel.build/external/faq#incrementing-compatibility-level", | |
| ) | |
| if index > 0: | |
| # Find the most recent non-yanked version before the current one | |
| metadata = self.registry.get_metadata(module_name) | |
| yanked_versions = metadata.get("yanked_versions", {}) | |
| previous_version = None | |
| for i in range(index - 1, -1, -1): | |
| candidate_version = versions[i] | |
| if candidate_version not in yanked_versions: | |
| previous_version = candidate_version | |
| break | |
| if previous_version is not None: | |
| previous_module_dot_bazel = self.registry.get_module_dot_bazel_path(module_name, previous_version) | |
| previous_compatibility_level = BcrValidator.extract_attribute_from_module( | |
| previous_module_dot_bazel, "compatibility_level", 0 | |
| ) | |
| if current_compatibility_level != previous_compatibility_level: | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| f"The compatibility_level in the new module version ({current_compatibility_level}) doesn't match the previous version ({previous_compatibility_level}).\n" | |
| + "If this is intentional, please comment on your PR `@bazel-io skip_check compatibility_level`\n" | |
| + "Learn more about when to increase the compatibility level at https://bazel.build/external/faq#incrementing-compatibility-level", | |
| ) | |
| # Check that bazel_compatability is sufficient when using "overlay" | |
| if "overlay" in source: | |
| current_bazel_compatibility = BcrValidator.extract_attribute_from_module( | |
| bcr_module_dot_bazel, "bazel_compatibility", [] | |
| ) | |
| if not is_valid_bazel_compatibility_for_overlay(current_bazel_compatibility): | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| "When using overlay files the module must set `bazel_compatibility` constraints to " | |
| f"at least `['>=7.2.1']`, got {current_bazel_compatibility}. ", | |
| ) | |
| shutil.rmtree(tmp_dir) | |
| def check_if_bazel_version_is_set(self, tasks): | |
| for task_name, task_config in tasks.items(): | |
| if "bazel" not in task_config: | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| "Missing bazel version for task '%s' in the presubmit.yml file." % task_name, | |
| ) | |
| def validate_presubmit_tasks(self, module_name, version): | |
| presubmit_yml = self.registry.get_presubmit_yml_path(module_name, version) | |
| presubmit = yaml.safe_load(open(presubmit_yml, "r")) | |
| report_num_old = len(self.validation_results) | |
| tasks = presubmit.get("tasks", {}) | |
| self.check_if_bazel_version_is_set(tasks) | |
| test_module_tasks = {} | |
| if "bcr_test_module" in presubmit: | |
| test_module_tasks = presubmit["bcr_test_module"].get("tasks", {}) | |
| self.check_if_bazel_version_is_set(test_module_tasks) | |
| if not tasks and not test_module_tasks: | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| "At least one task should be specified in the presubmit.yml file.", | |
| ) | |
| report_num_new = len(self.validation_results) | |
| if report_num_new == report_num_old: | |
| self.report(BcrValidationResult.GOOD, "The presubmit.yml file is valid.") | |
| def verify_module_name_conflict(self): | |
| """Verify no module name conflict when ignoring case sensitivity.""" | |
| module_names = self.registry.get_all_modules() | |
| conflict_found = False | |
| module_group = {} | |
| for name in module_names: | |
| module_group.setdefault(name.lower(), []).append(name) | |
| for name, modules in module_group.items(): | |
| if len(modules) > 1: | |
| conflict_found = True | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| f"Module name conflict found: {', '.join(modules)}", | |
| ) | |
| if not conflict_found: | |
| self.report(BcrValidationResult.GOOD, "No module name conflict found.") | |
| def verify_no_symlinks(self): | |
| """Check there is no symlink under modules/ dir""" | |
| for dirpath, dirnames, filenames in os.walk(self.registry.root / "modules"): | |
| for name in dirnames + filenames: | |
| full_path = os.path.join(dirpath, name) | |
| if os.path.islink(full_path): | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| f"Symlink is not allowed: {full_path}", | |
| ) | |
| def validate_module(self, module_name, version, skipped_validations): | |
| print_expanded_group(f"Validating {module_name}@{version}") | |
| self.verify_module_existence(module_name, version) | |
| if "source_repo" not in skipped_validations: | |
| self.verify_source_archive_url_match_github_repo(module_name, version) | |
| if "url_stability" not in skipped_validations: | |
| self.verify_source_archive_url_stability(module_name, version) | |
| self.verify_source_archive_url_integrity(module_name, version) | |
| if "presubmit_yml" not in skipped_validations: | |
| self.verify_presubmit_yml_change(module_name, version) | |
| if "presubmit_task" not in skipped_validations: | |
| self.validate_presubmit_tasks(module_name, version) | |
| self.verify_module_dot_bazel(module_name, version, "compatibility_level" not in skipped_validations) | |
| if "attestations" not in skipped_validations: | |
| self.verify_attestations(module_name, version) | |
| def validate_metadata(self, modules): | |
| print_expanded_group(f"Validating metadata.json files for {modules}") | |
| for module_name in modules: | |
| self.verify_metadata_json(module_name) | |
| def verify_metadata_json(self, module_name): | |
| """Verify the metadata.json file is valid.""" | |
| try: | |
| metadata = self.registry.get_metadata(module_name) | |
| except json.JSONDecodeError as e: | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| f"Failed to load {module_name}'s metadata.json file: " + str(e), | |
| ) | |
| return | |
| sorted_versions = sorted(metadata["versions"], key=Version) | |
| if sorted_versions != metadata["versions"]: | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| f"{module_name}'s metadata.json file is not sorted by version.\n " | |
| f"Sorted versions: {sorted_versions}.\n " | |
| f"Original versions: {metadata['versions']}", | |
| ) | |
| for version in metadata["versions"]: | |
| if not self.registry.contains(module_name, version): | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| f"{module_name}@{version} doesn't exist, but it's recorded in {module_name}'s metadata.json file.", | |
| ) | |
| latest_version = metadata["versions"][-1] | |
| if not metadata.get("deprecated") and latest_version in metadata.get("yanked_versions", {}): | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| f"The latest version ({latest_version}) of {module_name} should not be yanked, " | |
| f"please make sure a newer version is available before yanking it.", | |
| ) | |
| maintainers = metadata.get("maintainers", []) | |
| for maintainer in maintainers: | |
| if "github" in maintainer: | |
| github_username = maintainer["github"] | |
| print("checking github user id for %s" % github_username) | |
| try: | |
| github_user_id = get_github_user_id(github_username) | |
| except Exception as e: | |
| raise BcrValidationException( | |
| f"Failed to get GitHub user ID for {github_username}. Please check the username." | |
| ) from e | |
| if github_user_id != maintainer.get("github_user_id"): | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| f"{module_name}'s metadata.json file has an invalid GitHub user ID for {github_username}\n" | |
| + f'Please add `"github_user_id": {github_user_id}` to the maintainer entry by running `bazel run //tools:bcr_validation -- --check_metadata={module_name} --fix`.', | |
| ) | |
| if self.should_fix: | |
| maintainer["github_user_id"] = github_user_id | |
| self.registry.get_metadata_path(module_name).write_text(json.dumps(metadata, indent=4) + "\n") | |
| else: | |
| self.report( | |
| BcrValidationResult.GOOD, | |
| f"{module_name}'s metadata.json file has a valid GitHub user ID for {github_username}", | |
| ) | |
| def verify_attestations(self, module_name, version): | |
| print_expanded_group("Verifying attestations") | |
| head_snapshot = self.upstream.get_latest_module_version(module_name) | |
| head_attestations_json = head_snapshot.attestations() if head_snapshot else None | |
| attestations_json = self.registry.get_attestations(module_name, version) | |
| if not attestations_json: | |
| if head_attestations_json: # Prevent regressions. | |
| verdict = ( | |
| BcrValidationResult.NEED_BCR_MAINTAINER_REVIEW | |
| if module_name in ATTESTATION_HISTORY_CHECK_OPT_OUT | |
| else BcrValidationResult.FAILED | |
| ) | |
| self.report( | |
| verdict, | |
| f"{module_name}@{version}: No attestations.json file even though " | |
| f"{module_name}@{head_snapshot.version} has one.", | |
| ) | |
| else: | |
| # TODO: Turn this into an error after the migration period | |
| self.report(BcrValidationResult.GOOD, f"{module_name}@{version}: No attestations to check.") | |
| return | |
| try: | |
| attestations = attestations_lib.parse_file(attestations_json, module_name, version, self.registry) | |
| except attestations_lib.Error as ex: | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| ( | |
| f"{module_name}@{version}: Encountered an error in attestations.json:" | |
| f" {ex} Please follow {ATTESTATIONS_DOCS_URL}." | |
| ), | |
| ) | |
| return | |
| gh_source_uris = self.get_github_source_uris(module_name) | |
| if not gh_source_uris: | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| ( | |
| f"{module_name}@{version}: Could not determine source URI. " | |
| "Please ensure that metadata.json contains at least one GitHub repository." | |
| ), | |
| ) | |
| return | |
| source_uri = self.get_expected_source_uri(attestations[0].url) | |
| if not source_uri: | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| (f"{module_name}@{version}: Only GitHub repositories are currently supported."), | |
| ) | |
| return | |
| if source_uri not in gh_source_uris: | |
| self.report( | |
| BcrValidationResult.FAILED, | |
| ( | |
| f"{module_name}@{version}: Expected source URI {source_uri}, " | |
| f"but got {', '.join(gh_source_uris)}." | |
| ), | |
| ) | |
| return | |
| success = True | |
| tmp_dir = tempfile.mkdtemp() | |
| for attestation in attestations: | |
| try: | |
| self._verifier.run(module_name, attestation, source_uri, version, tmp_dir) | |
| except attestations_lib.Error as ex: | |
| self.report(BcrValidationResult.FAILED, f"{module_name}@{version}: {ex}") | |
| success = False | |
| if success: | |
| self.report( | |
| BcrValidationResult.GOOD, | |
| f"Successfully verified attestations for {module_name}@{version}.", | |
| ) | |
| def get_github_source_uris(self, module_name): | |
| repos = self.registry.get_metadata(module_name)["repository"] | |
| matches = [GITHUB_REPO_RE.match(r) for r in repos] | |
| return [f"github.com/{m.group(1)}" for m in matches if m] | |
| def get_expected_source_uri(self, attestation_url): | |
| m = GITHUB_URL_RE.search(attestation_url) | |
| if not m: | |
| return None | |
| return f"github.com/{m.group(1)}" | |
| def global_checks(self): | |
| """General global checks for BCR""" | |
| self.verify_module_name_conflict() | |
| self.verify_no_symlinks() | |
| def getValidationReturnCode(self): | |
| # Calculate the overall return code | |
| # 0: All good | |
| # 1: BCR validation failed | |
| # 42: BCR validation passes, but some changes need BCR maintainer review before triggering follow up BCR presubmit jobs. | |
| result_codes = [code for code, _ in self.validation_results] | |
| if BcrValidationResult.FAILED in result_codes: | |
| return 1 | |
| if BcrValidationResult.NEED_BCR_MAINTAINER_REVIEW in result_codes: | |
| # Use a special return code to avoid conflict with other error code | |
| return 42 | |
| return 0 | |
| def main(argv=None): | |
| if argv is None: | |
| argv = sys.argv[1:] | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument( | |
| "--registry", | |
| type=str, | |
| default=".", | |
| help="Specify the root path of the registry (default: the current working directory).", | |
| ) | |
| parser.add_argument( | |
| "--check", | |
| type=str, | |
| action="append", | |
| help="Specify a Bazel module version you want to perform the BCR check on." | |
| + " (e.g. bazel_skylib@1.3.0). If no version is specified, all versions of that module are checked." | |
| + " This flag can be repeated to accept multiple module versions.", | |
| ) | |
| parser.add_argument( | |
| "--check_all", | |
| action="store_true", | |
| help="Check all Bazel modules in the registry, ignore other --check flags.", | |
| ) | |
| parser.add_argument( | |
| "--check_metadata", | |
| action="append", | |
| help="Check metadata for given modules in the registry.", | |
| ) | |
| parser.add_argument( | |
| "--check_all_metadata", | |
| action="store_true", | |
| help="Check all Bazel module metadata in the registry.", | |
| ) | |
| parser.add_argument( | |
| "--fix", | |
| action="store_true", | |
| help="Should the script try to fix the detected validation errors.", | |
| ) | |
| parser.add_argument( | |
| "--skip_validation", | |
| type=str, | |
| default=[], | |
| action="append", | |
| help="Bypass the given step for validating modules. Supported values are: " | |
| + '"url_stability", to bypass the URL stability check; ' | |
| + '"presubmit_yml", to bypass the presubmit.yml check; ' | |
| + '"presubmit_task", to bypass the presubmit.yml tasks check; ' | |
| + '"source_repo", to bypass the source repo verification; ' | |
| + '"attestations", to skip the attestations check. ' | |
| + "This flag can be repeated to skip multiple validations.", | |
| ) | |
| args = parser.parse_args(argv) | |
| if not args.check_all and not args.check and not args.check_all_metadata and not args.check_metadata: | |
| parser.print_help() | |
| return -1 | |
| registry = RegistryClient(args.registry) | |
| # Parse what module versions we should validate | |
| module_versions = parse_module_versions(registry, args.check_all, args.check) | |
| if module_versions: | |
| print_expanded_group("Module versions to be validated:") | |
| for name, version in module_versions: | |
| print(f"{name}@{version}") | |
| # TODO: Read url from flags to support forks. | |
| upstream = UpstreamRegistry(modules_dir_url=UPSTREAM_MODULES_DIR_URL) | |
| # Validate given module version. | |
| validator = BcrValidator(registry, upstream, args.fix) | |
| for name, version in module_versions: | |
| validator.validate_module(name, version, args.skip_validation) | |
| if args.check_all_metadata: | |
| # Validate all metadata.json | |
| validator.validate_metadata(validator.registry.get_all_modules()) | |
| else: | |
| # Validate metadata.json for given modules and all modified modules. | |
| modules = [] if not args.check_metadata else args.check_metadata | |
| modules_to_validate = set(modules + [name for name, _ in module_versions]) | |
| validator.validate_metadata(list(modules_to_validate)) | |
| # Perform some global checks | |
| validator.global_checks() | |
| return validator.getValidationReturnCode() | |
| if __name__ == "__main__": | |
| # Under 'bazel run' we want to run within the source folder instead of the execroot. | |
| if os.getenv("BUILD_WORKSPACE_DIRECTORY"): | |
| os.chdir(os.getenv("BUILD_WORKSPACE_DIRECTORY")) | |
| sys.exit(main()) |