| #!/bin/bash |
| # SPDX-License-Identifier: GPL-2.0-only |
| # Script to check commits for UAPI backwards compatibility |
| |
| set -o errexit |
| set -o pipefail |
| |
| print_usage() { |
| name=$(basename "$0") |
| cat << EOF |
| $name - check for UAPI header stability across Git commits |
| |
| By default, the script will check to make sure the latest commit (or current |
| dirty changes) did not introduce ABI changes when compared to HEAD^1. You can |
| check against additional commit ranges with the -b and -p options. |
| |
| The script will not check UAPI headers for architectures other than the one |
| defined in ARCH. |
| |
| Usage: $name [-b BASE_REF] [-p PAST_REF] [-j N] [-l ERROR_LOG] [-i] [-q] [-v] |
| |
| Options: |
| -b BASE_REF Base git reference to use for comparison. If unspecified or empty, |
| will use any dirty changes in tree to UAPI files. If there are no |
| dirty changes, HEAD will be used. |
| -p PAST_REF Compare BASE_REF to PAST_REF (e.g. -p v6.1). If unspecified or empty, |
| will use BASE_REF^1. Must be an ancestor of BASE_REF. Only headers |
| that exist on PAST_REF will be checked for compatibility. |
| -j JOBS Number of checks to run in parallel (default: number of CPU cores). |
| -l ERROR_LOG Write error log to file (default: no error log is generated). |
| -i Ignore ambiguous changes that may or may not break UAPI compatibility. |
| -q Quiet operation. |
| -v Verbose operation (print more information about each header being checked). |
| |
| Environmental args: |
| ABIDIFF Custom path to abidiff binary |
| CC C compiler (default is "gcc") |
| ARCH Target architecture for the UAPI check (default is host arch) |
| |
| Exit codes: |
| $SUCCESS) Success |
| $FAIL_ABI) ABI difference detected |
| $FAIL_PREREQ) Prerequisite not met |
| EOF |
| } |
| |
| readonly SUCCESS=0 |
| readonly FAIL_ABI=1 |
| readonly FAIL_PREREQ=2 |
| |
| # Print to stderr |
| eprintf() { |
| # shellcheck disable=SC2059 |
| printf "$@" >&2 |
| } |
| |
| # Expand an array with a specific character (similar to Python string.join()) |
| join() { |
| local IFS="$1" |
| shift |
| printf "%s" "$*" |
| } |
| |
| # Create abidiff suppressions |
| gen_suppressions() { |
| # Common enum variant names which we don't want to worry about |
| # being shifted when new variants are added. |
| local -a enum_regex=( |
| ".*_AFTER_LAST$" |
| ".*_CNT$" |
| ".*_COUNT$" |
| ".*_END$" |
| ".*_LAST$" |
| ".*_MASK$" |
| ".*_MAX$" |
| ".*_MAX_BIT$" |
| ".*_MAX_BPF_ATTACH_TYPE$" |
| ".*_MAX_ID$" |
| ".*_MAX_SHIFT$" |
| ".*_NBITS$" |
| ".*_NETDEV_NUMHOOKS$" |
| ".*_NFT_META_IIFTYPE$" |
| ".*_NL80211_ATTR$" |
| ".*_NLDEV_NUM_OPS$" |
| ".*_NUM$" |
| ".*_NUM_ELEMS$" |
| ".*_NUM_IRQS$" |
| ".*_SIZE$" |
| ".*_TLSMAX$" |
| "^MAX_.*" |
| "^NUM_.*" |
| ) |
| |
| # Common padding field names which can be expanded into |
| # without worrying about users. |
| local -a padding_regex=( |
| ".*end$" |
| ".*pad$" |
| ".*pad[0-9]?$" |
| ".*pad_[0-9]?$" |
| ".*padding$" |
| ".*padding[0-9]?$" |
| ".*padding_[0-9]?$" |
| ".*res$" |
| ".*resv$" |
| ".*resv[0-9]?$" |
| ".*resv_[0-9]?$" |
| ".*reserved$" |
| ".*reserved[0-9]?$" |
| ".*reserved_[0-9]?$" |
| ".*rsvd[0-9]?$" |
| ".*unused$" |
| ) |
| |
| cat << EOF |
| [suppress_type] |
| type_kind = enum |
| changed_enumerators_regexp = $(join , "${enum_regex[@]}") |
| EOF |
| |
| for p in "${padding_regex[@]}"; do |
| cat << EOF |
| [suppress_type] |
| type_kind = struct |
| has_data_member_inserted_at = offset_of_first_data_member_regexp(${p}) |
| EOF |
| done |
| |
| if [ "$IGNORE_AMBIGUOUS_CHANGES" = "true" ]; then |
| cat << EOF |
| [suppress_type] |
| type_kind = struct |
| has_data_member_inserted_at = end |
| has_size_change = yes |
| EOF |
| fi |
| } |
| |
| # Check if git tree is dirty |
| tree_is_dirty() { |
| ! git diff --quiet |
| } |
| |
| # Get list of files installed in $ref |
| get_file_list() { |
| local -r ref="$1" |
| local -r tree="$(get_header_tree "$ref")" |
| |
| # Print all installed headers, filtering out ones that can't be compiled |
| find "$tree" -type f -name '*.h' -printf '%P\n' | grep -v -f "$INCOMPAT_LIST" |
| } |
| |
| # Add to the list of incompatible headers |
| add_to_incompat_list() { |
| local -r ref="$1" |
| |
| # Start with the usr/include/Makefile to get a list of the headers |
| # that don't compile using this method. |
| if [ ! -f usr/include/Makefile ]; then |
| eprintf "error - no usr/include/Makefile present at %s\n" "$ref" |
| eprintf "Note: usr/include/Makefile was added in the v5.3 kernel release\n" |
| exit "$FAIL_PREREQ" |
| fi |
| { |
| # shellcheck disable=SC2016 |
| printf 'all: ; @echo $(no-header-test)\n' |
| cat usr/include/Makefile |
| } | SRCARCH="$ARCH" make --always-make -f - | tr " " "\n" \ |
| | grep -v "asm-generic" >> "$INCOMPAT_LIST" |
| |
| # The makefile also skips all asm-generic files, but prints "asm-generic/%" |
| # which won't work for our grep match. Instead, print something grep will match. |
| printf "asm-generic/.*\.h\n" >> "$INCOMPAT_LIST" |
| } |
| |
| # Compile the simple test app |
| do_compile() { |
| local -r inc_dir="$1" |
| local -r header="$2" |
| local -r out="$3" |
| printf "int main(void) { return 0; }\n" | \ |
| "$CC" -c \ |
| -o "$out" \ |
| -x c \ |
| -O0 \ |
| -std=c90 \ |
| -fno-eliminate-unused-debug-types \ |
| -g \ |
| "-I${inc_dir}" \ |
| -include "$header" \ |
| - |
| } |
| |
| # Run make headers_install |
| run_make_headers_install() { |
| local -r ref="$1" |
| local -r install_dir="$(get_header_tree "$ref")" |
| make -j "$MAX_THREADS" ARCH="$ARCH" INSTALL_HDR_PATH="$install_dir" \ |
| headers_install > /dev/null |
| } |
| |
| # Install headers for both git refs |
| install_headers() { |
| local -r base_ref="$1" |
| local -r past_ref="$2" |
| |
| for ref in "$base_ref" "$past_ref"; do |
| printf "Installing user-facing UAPI headers from %s... " "${ref:-dirty tree}" |
| if [ -n "$ref" ]; then |
| git archive --format=tar --prefix="${ref}-archive/" "$ref" \ |
| | (cd "$TMP_DIR" && tar xf -) |
| ( |
| cd "${TMP_DIR}/${ref}-archive" |
| run_make_headers_install "$ref" |
| add_to_incompat_list "$ref" "$INCOMPAT_LIST" |
| ) |
| else |
| run_make_headers_install "$ref" |
| add_to_incompat_list "$ref" "$INCOMPAT_LIST" |
| fi |
| printf "OK\n" |
| done |
| sort -u -o "$INCOMPAT_LIST" "$INCOMPAT_LIST" |
| sed -i -e '/^$/d' "$INCOMPAT_LIST" |
| } |
| |
| # Print the path to the headers_install tree for a given ref |
| get_header_tree() { |
| local -r ref="$1" |
| printf "%s" "${TMP_DIR}/${ref}/usr" |
| } |
| |
| # Check file list for UAPI compatibility |
| check_uapi_files() { |
| local -r base_ref="$1" |
| local -r past_ref="$2" |
| local -r abi_error_log="$3" |
| |
| local passed=0; |
| local failed=0; |
| local -a threads=() |
| set -o errexit |
| |
| printf "Checking changes to UAPI headers between %s and %s...\n" "$past_ref" "${base_ref:-dirty tree}" |
| # Loop over all UAPI headers that were installed by $past_ref (if they only exist on $base_ref, |
| # there's no way they're broken and no way to compare anyway) |
| while read -r file; do |
| if [ "${#threads[@]}" -ge "$MAX_THREADS" ]; then |
| if wait "${threads[0]}"; then |
| passed=$((passed + 1)) |
| else |
| failed=$((failed + 1)) |
| fi |
| threads=("${threads[@]:1}") |
| fi |
| |
| check_individual_file "$base_ref" "$past_ref" "$file" & |
| threads+=("$!") |
| done < <(get_file_list "$past_ref") |
| |
| for t in "${threads[@]}"; do |
| if wait "$t"; then |
| passed=$((passed + 1)) |
| else |
| failed=$((failed + 1)) |
| fi |
| done |
| |
| if [ -n "$abi_error_log" ]; then |
| printf 'Generated by "%s %s" from git ref %s\n\n' \ |
| "$0" "$*" "$(git rev-parse HEAD)" > "$abi_error_log" |
| fi |
| |
| while read -r error_file; do |
| { |
| cat "$error_file" |
| printf "\n\n" |
| } | tee -a "${abi_error_log:-/dev/null}" >&2 |
| done < <(find "$TMP_DIR" -type f -name '*.error' | sort) |
| |
| total="$((passed + failed))" |
| if [ "$failed" -gt 0 ]; then |
| eprintf "error - %d/%d UAPI headers compatible with %s appear _not_ to be backwards compatible\n" \ |
| "$failed" "$total" "$ARCH" |
| if [ -n "$abi_error_log" ]; then |
| eprintf "Failure summary saved to %s\n" "$abi_error_log" |
| fi |
| else |
| printf "All %d UAPI headers compatible with %s appear to be backwards compatible\n" \ |
| "$total" "$ARCH" |
| fi |
| |
| return "$failed" |
| } |
| |
| # Check an individual file for UAPI compatibility |
| check_individual_file() { |
| local -r base_ref="$1" |
| local -r past_ref="$2" |
| local -r file="$3" |
| |
| local -r base_header="$(get_header_tree "$base_ref")/${file}" |
| local -r past_header="$(get_header_tree "$past_ref")/${file}" |
| |
| if [ ! -f "$base_header" ]; then |
| mkdir -p "$(dirname "$base_header")" |
| printf "==== UAPI header %s was removed between %s and %s ====" \ |
| "$file" "$past_ref" "$base_ref" \ |
| > "${base_header}.error" |
| return 1 |
| fi |
| |
| compare_abi "$file" "$base_header" "$past_header" "$base_ref" "$past_ref" |
| } |
| |
| # Perform the A/B compilation and compare output ABI |
| compare_abi() { |
| local -r file="$1" |
| local -r base_header="$2" |
| local -r past_header="$3" |
| local -r base_ref="$4" |
| local -r past_ref="$5" |
| local -r log="${TMP_DIR}/log/${file}.log" |
| local -r error_log="${TMP_DIR}/log/${file}.error" |
| |
| mkdir -p "$(dirname "$log")" |
| |
| if ! do_compile "$(get_header_tree "$base_ref")/include" "$base_header" "${base_header}.bin" 2> "$log"; then |
| { |
| warn_str=$(printf "==== Could not compile version of UAPI header %s at %s ====\n" \ |
| "$file" "$base_ref") |
| printf "%s\n" "$warn_str" |
| cat "$log" |
| printf -- "=%.0s" $(seq 0 ${#warn_str}) |
| } > "$error_log" |
| return 1 |
| fi |
| |
| if ! do_compile "$(get_header_tree "$past_ref")/include" "$past_header" "${past_header}.bin" 2> "$log"; then |
| { |
| warn_str=$(printf "==== Could not compile version of UAPI header %s at %s ====\n" \ |
| "$file" "$past_ref") |
| printf "%s\n" "$warn_str" |
| cat "$log" |
| printf -- "=%.0s" $(seq 0 ${#warn_str}) |
| } > "$error_log" |
| return 1 |
| fi |
| |
| local ret=0 |
| "$ABIDIFF" --non-reachable-types \ |
| --suppressions "$SUPPRESSIONS" \ |
| "${past_header}.bin" "${base_header}.bin" > "$log" || ret="$?" |
| if [ "$ret" -eq 0 ]; then |
| if [ "$VERBOSE" = "true" ]; then |
| printf "No ABI differences detected in %s from %s -> %s\n" \ |
| "$file" "$past_ref" "$base_ref" |
| fi |
| else |
| # Bits in abidiff's return code can be used to determine the type of error |
| if [ $((ret & 0x2)) -gt 0 ]; then |
| eprintf "error - abidiff did not run properly\n" |
| exit 1 |
| fi |
| |
| if [ "$IGNORE_AMBIGUOUS_CHANGES" = "true" ] && [ "$ret" -eq 4 ]; then |
| return 0 |
| fi |
| |
| # If the only changes were additions (not modifications to existing APIs), then |
| # there's no problem. Ignore these diffs. |
| if grep "Unreachable types summary" "$log" | grep -q "0 removed" && |
| grep "Unreachable types summary" "$log" | grep -q "0 changed"; then |
| return 0 |
| fi |
| |
| { |
| warn_str=$(printf "==== ABI differences detected in %s from %s -> %s ====" \ |
| "$file" "$past_ref" "$base_ref") |
| printf "%s\n" "$warn_str" |
| sed -e '/summary:/d' -e '/changed type/d' -e '/^$/d' -e 's/^/ /g' "$log" |
| printf -- "=%.0s" $(seq 0 ${#warn_str}) |
| if cmp "$past_header" "$base_header" > /dev/null 2>&1; then |
| printf "\n%s did not change between %s and %s...\n" "$file" "$past_ref" "${base_ref:-dirty tree}" |
| printf "It's possible a change to one of the headers it includes caused this error:\n" |
| grep '^#include' "$base_header" |
| printf "\n" |
| fi |
| } > "$error_log" |
| |
| return 1 |
| fi |
| } |
| |
| # Check that a minimum software version number is satisfied |
| min_version_is_satisfied() { |
| local -r min_version="$1" |
| local -r version_installed="$2" |
| |
| printf "%s\n%s\n" "$min_version" "$version_installed" \ |
| | sort -Vc > /dev/null 2>&1 |
| } |
| |
| # Make sure we have the tools we need and the arguments make sense |
| check_deps() { |
| ABIDIFF="${ABIDIFF:-abidiff}" |
| CC="${CC:-gcc}" |
| ARCH="${ARCH:-$(uname -m)}" |
| if [ "$ARCH" = "x86_64" ]; then |
| ARCH="x86" |
| fi |
| |
| local -r abidiff_min_version="2.4" |
| local -r libdw_min_version_if_clang="0.171" |
| |
| if ! command -v "$ABIDIFF" > /dev/null 2>&1; then |
| eprintf "error - abidiff not found!\n" |
| eprintf "Please install abigail-tools version %s or greater\n" "$abidiff_min_version" |
| eprintf "See: https://sourceware.org/libabigail/manual/libabigail-overview.html\n" |
| return 1 |
| fi |
| |
| local -r abidiff_version="$("$ABIDIFF" --version | cut -d ' ' -f 2)" |
| if ! min_version_is_satisfied "$abidiff_min_version" "$abidiff_version"; then |
| eprintf "error - abidiff version too old: %s\n" "$abidiff_version" |
| eprintf "Please install abigail-tools version %s or greater\n" "$abidiff_min_version" |
| eprintf "See: https://sourceware.org/libabigail/manual/libabigail-overview.html\n" |
| return 1 |
| fi |
| |
| if ! command -v "$CC" > /dev/null 2>&1; then |
| eprintf 'error - %s not found\n' "$CC" |
| return 1 |
| fi |
| |
| if "$CC" --version | grep -q clang; then |
| local -r libdw_version="$(ldconfig -v 2>/dev/null | grep -v SKIPPED | grep -m 1 -o 'libdw-[0-9]\+.[0-9]\+' | cut -c 7-)" |
| if ! min_version_is_satisfied "$libdw_min_version_if_clang" "$libdw_version"; then |
| eprintf "error - libdw version too old for use with clang: %s\n" "$libdw_version" |
| eprintf "Please install libdw from elfutils version %s or greater\n" "$libdw_min_version_if_clang" |
| eprintf "See: https://sourceware.org/elfutils/\n" |
| return 1 |
| fi |
| fi |
| |
| if [ ! -d "arch/${ARCH}" ]; then |
| eprintf 'error - ARCH "%s" is not a subdirectory under arch/\n' "$ARCH" |
| eprintf "Please set ARCH to one of:\n%s\n" "$(find arch -maxdepth 1 -mindepth 1 -type d -printf '%f ' | fmt)" |
| return 1 |
| fi |
| |
| if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then |
| eprintf "error - this script requires the kernel tree to be initialized with Git\n" |
| return 1 |
| fi |
| |
| if ! git rev-parse --verify "$past_ref" > /dev/null 2>&1; then |
| printf 'error - invalid git reference "%s"\n' "$past_ref" |
| return 1 |
| fi |
| |
| if [ -n "$base_ref" ]; then |
| if ! git merge-base --is-ancestor "$past_ref" "$base_ref" > /dev/null 2>&1; then |
| printf 'error - "%s" is not an ancestor of base ref "%s"\n' "$past_ref" "$base_ref" |
| return 1 |
| fi |
| if [ "$(git rev-parse "$base_ref")" = "$(git rev-parse "$past_ref")" ]; then |
| printf 'error - "%s" and "%s" are the same reference\n' "$past_ref" "$base_ref" |
| return 1 |
| fi |
| fi |
| } |
| |
| run() { |
| local base_ref="$1" |
| local past_ref="$2" |
| local abi_error_log="$3" |
| shift 3 |
| |
| if [ -z "$KERNEL_SRC" ]; then |
| KERNEL_SRC="$(realpath "$(dirname "$0")"/..)" |
| fi |
| |
| cd "$KERNEL_SRC" |
| |
| if [ -z "$base_ref" ] && ! tree_is_dirty; then |
| base_ref=HEAD |
| fi |
| |
| if [ -z "$past_ref" ]; then |
| if [ -n "$base_ref" ]; then |
| past_ref="${base_ref}^1" |
| else |
| past_ref=HEAD |
| fi |
| fi |
| |
| if ! check_deps; then |
| exit "$FAIL_PREREQ" |
| fi |
| |
| TMP_DIR=$(mktemp -d) |
| readonly TMP_DIR |
| trap 'rm -rf "$TMP_DIR"' EXIT |
| |
| readonly INCOMPAT_LIST="${TMP_DIR}/incompat_list.txt" |
| touch "$INCOMPAT_LIST" |
| |
| readonly SUPPRESSIONS="${TMP_DIR}/suppressions.txt" |
| gen_suppressions > "$SUPPRESSIONS" |
| |
| # Run make install_headers for both refs |
| install_headers "$base_ref" "$past_ref" |
| |
| # Check for any differences in the installed header trees |
| if diff -r -q "$(get_header_tree "$base_ref")" "$(get_header_tree "$past_ref")" > /dev/null 2>&1; then |
| printf "No changes to UAPI headers were applied between %s and %s\n" "$past_ref" "${base_ref:-dirty tree}" |
| exit "$SUCCESS" |
| fi |
| |
| if ! check_uapi_files "$base_ref" "$past_ref" "$abi_error_log"; then |
| exit "$FAIL_ABI" |
| fi |
| } |
| |
| main() { |
| MAX_THREADS=$(nproc) |
| VERBOSE="false" |
| IGNORE_AMBIGUOUS_CHANGES="false" |
| quiet="false" |
| local base_ref="" |
| while getopts "hb:p:j:l:iqv" opt; do |
| case $opt in |
| h) |
| print_usage |
| exit "$SUCCESS" |
| ;; |
| b) |
| base_ref="$OPTARG" |
| ;; |
| p) |
| past_ref="$OPTARG" |
| ;; |
| j) |
| MAX_THREADS="$OPTARG" |
| ;; |
| l) |
| abi_error_log="$OPTARG" |
| ;; |
| i) |
| IGNORE_AMBIGUOUS_CHANGES="true" |
| ;; |
| q) |
| quiet="true" |
| VERBOSE="false" |
| ;; |
| v) |
| VERBOSE="true" |
| quiet="false" |
| ;; |
| *) |
| exit "$FAIL_PREREQ" |
| esac |
| done |
| |
| if [ "$quiet" = "true" ]; then |
| exec > /dev/null 2>&1 |
| fi |
| |
| run "$base_ref" "$past_ref" "$abi_error_log" "$@" |
| } |
| |
| main "$@" |