File fix-apt-update-error-expired-key-missing-release-file of Package kimi-utils-ubuntu

#!/usr/bin/env bash
# fix-apt-update-error-expired-key-missing-release-file.sh
# Detects apt-update errors (404, missing Release, EXPKEYSIG, …)
# and renames the offending source files and matching expired GPG keys.
#
# This is a cleaned-up, functionally equivalent refactor of the working script.
# Behaviour, messages and exit codes are preserved.

set -eo pipefail

# -------------------------
# Configuration (read-only)
# -------------------------
readonly SRC_DIR="/etc/apt/sources.list.d"
readonly APT_TRUST_DIR="/etc/apt/trusted.gpg.d"
readonly PATTERNS=("*.list" "*.sources")
readonly ERR_REGEX='Err:|EXPKEYSIG|GPG error|Failed to fetch|404|Not Found|does not have a Release file|is not signed'
export LANG=C

# -------------------------
# Runtime state / counters
# -------------------------
declare -A url_to_sig       # URL -> signature (empty if none)
declare -a all_src_files    # collected source files
declare -a expired_keys     # will hold matching GPG key paths (list form)
declare -A expired_keys_fps # map keyfile -> matching fprs csv
current_url=""
renamed_sources=0
renamed_keys=0

# -------------------------
# Logging helpers
# -------------------------
log()    { printf '[*] %s\n' "$*"; }
warn()   { printf '[!] %s\n' "$*" >&2; }
err()    { printf '[ERROR] %s\n' "$*" >&2; }

# -------------------------
# Utility helpers
# -------------------------
timestamp() { date +"%Y-%m-%d_%H-%M-%S"; }

# Append value to an array if not already present
unique_append() {
    local -n arr=$1; local val=$2
    for e in "${arr[@]:-}"; do [[ "$e" == "$val" ]] && return; done
    arr+=("$val")
}

# Simple validation for http(s) URL
is_http_url() { [[ $1 =~ ^https?:// ]]; }

# Extract the hex signature from an EXPKEYSIG line (returns empty if none)
extract_sig() { grep -oP 'EXPKEYSIG \K[0-9A-F]+' <<<"$1" || true; }

# -------------------------
# Key discovery (patched)
# -------------------------
# Find .gpg files that contain any fingerprint whose last 16 hex chars equal sig.
# Returns two outputs via named arrays: files_out (list of files) and fps_map (associative map file->comma-separated matching full fprs)
# Usage: find_keys_by_sig "$sig" files_out fps_map
find_keys_by_sig() {
    local sig=$1
    local -n files_out=$2
    local -n fps_map=$3
    files_out=()
    declare -A tmpmap=()

    while IFS= read -r keyfile; do
        # collect all fprs in this file
        local fprs
        fprs=$(gpg --batch --no-tty --with-colons --show-keys "$keyfile" 2>/dev/null |
              awk -F: '$1=="fpr" {print $10}' || true)
        if [[ -z "$fprs" ]]; then
            # fallback: human-readable greedy fingerprint extraction
            fprs=$(gpg --batch --no-tty --show-keys "$keyfile" 2>/dev/null |
                  grep -oE '[0-9A-F]{40}' || true)
        fi
        [[ -z "$fprs" ]] && continue

        local matched=""
        while IFS= read -r fpr; do
            [[ -z "$fpr" ]] && continue
            if [[ "${fpr: -16}" == "$sig" ]]; then
                if [[ -n "$matched" ]]; then
                    matched+=",${fpr}"
                else
                    matched="$fpr"
                fi
            fi
        done <<<"$fprs"

        if [[ -n "$matched" ]]; then
            files_out+=("$keyfile")
            tmpmap["$keyfile"]="$matched"
        fi
    done < <(find "$APT_TRUST_DIR" -type f -name '*.gpg' -print 2>/dev/null || true)

    # export map as csv-string values in associative array reference fps_map
    for k in "${!tmpmap[@]}"; do
        fps_map["$k"]="${tmpmap[$k]}"
    done
}

# -------------------------
# Key expiration detection (patched)
# -------------------------
# key_is_expired: robustly check expiration for only the fprs listed in the fps_csv for the given keyfile.
# Returns 0 if expired (all matching pubs expired), 1 otherwise.
# Usage: key_is_expired "<keyfile>" "<fps_csv>"
key_is_expired() {
    local keyfile=$1
    local fps_csv=$2
    local now
    now=$(date +%s)

    IFS=',' read -r -a want_fprs <<<"$fps_csv"
    for i in "${!want_fprs[@]}"; do
        want_fprs[$i]=$(tr '[:lower:]' '[:upper:]' <<<"${want_fprs[$i]}")
    done

    local seen_match=0 expired_match=0

    # Primary: parse --with-colons (deterministic)
    # pub parts: [0]=pub, [1]=flags, [6]=creation, [7]=expiry (epoch or 0)
    local col_line pub_expiry=""
    while IFS= read -r col_line; do
        IFS=':' read -r -a parts <<<"$col_line"
        case "${parts[0]}" in
            pub)
                pub_expiry="${parts[7]}"
                # normalize zero to empty
                [[ "$pub_expiry" == "0" ]] && pub_expiry=""
                # If flags include 'e' (expired) treat as expired even if expiry empty
                if [[ "${parts[1]}" == *e* ]]; then
                    pub_expiry=0
                fi
                ;;
            fpr)
                local fpr="${parts[9]}"
                fpr=$(tr '[:lower:]' '[:upper:]' <<<"$fpr")
                for want in "${want_fprs[@]}"; do
                    if [[ "$fpr" == "$want" ]]; then
                        seen_match=1
                        if [[ -n "$pub_expiry" && "$pub_expiry" =~ ^[0-9]+$ ]]; then
                            if (( pub_expiry <= now )); then
                                expired_match=1
                                break 2
                            fi
                        fi
                    fi
                done
                ;;
        esac
    done < <(gpg --batch --no-tty --with-colons --show-keys "$keyfile" 2>/dev/null || true)

    # Secondary fallback: human-readable parsing if no match found via with-colons
    if (( seen_match == 0 )); then
        local publine="" line
        while IFS= read -r line; do
            if [[ $line =~ ^[[:space:]]*pub[[:space:]] ]]; then
                publine="$line"
                continue
            fi
            if grep -qE '[0-9A-Fa-f]{40}' <<<"$line"; then
                local this_fpr
                this_fpr=$(grep -oEi '[0-9A-Fa-f]{40}' <<<"$line" | head -n1 | tr '[:lower:]' '[:upper:]')
                if [[ -n "$publine" ]]; then
                    for want in "${want_fprs[@]}"; do
                        if [[ "$this_fpr" == "$want" ]]; then
                            seen_match=1
                            local exp_date
                            exp_date=$(grep -oE 'expires:[[:space:]]*[0-9]{4}-[0-9]{2}-[0-9]{2}' <<<"$publine" | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' || true)
                            if [[ -n "$exp_date" ]]; then
                                local exp_epoch
                                exp_epoch=$(date -d "$exp_date" +%s 2>/dev/null || true)
                                if [[ -n "$exp_epoch" && "$exp_epoch" -le "$now" ]]; then
                                    expired_match=1
                                    break 3
                                fi
                            fi
                        fi
                    done
                    publine=""
                fi
            fi
        done < <(gpg --batch --no-tty --show-keys "$keyfile" 2>/dev/null || true)
    fi

    (( seen_match == 1 && expired_match == 1 )) && return 0
    return 1
}


# -------------------------
# Safe renaming helpers
# -------------------------
# Create destination name by appending a suffix; if destination exists add timestamp.
make_dst_name() {
    local src=$1; local suffix=$2
    local dst="${src}${suffix}"
    if [[ -e "$dst" ]]; then
        dst="${src}.$(timestamp)${suffix}"
    fi
    printf '%s' "$dst"
}

# Rename a file safely and log; increments renamed_sources counter.
safe_rename() {
    local src=$1
    local dst
    dst=$(make_dst_name "$src" ".orig")
    if mv -- "$src" "$dst"; then
        log "Renamed: $src → $dst"
        renamed_sources=$((renamed_sources+1))
    else
        warn "Failed to rename: $src"
    fi
}

# Rename a GPG key file safely and log; increments renamed_keys counter.
safe_rename_key() {
    local key=$1
    local dst
    dst=$(make_dst_name "$key" "~")
    if mv -- "$key" "$dst"; then
        log "Renamed GPG key: $key → $dst"
        renamed_keys=$((renamed_keys+1))
    else
        warn "Failed to rename GPG key: $key"
    fi
}

# -------------------------
# Preconditions
# -------------------------
if [[ $EUID -ne 0 ]]; then
    err "This script must be run as root."
    err "Use sudo or run as root."
    exit 2
fi

# -------------------------
# Run apt-get update and capture errors
# -------------------------
tmp_log=$(mktemp)
trap 'rm -f "$tmp_log"' EXIT

# Capture apt output; preserve errors but do not abort on non-zero
apt-get update 2>&1 | tee "$tmp_log" >/dev/null || true

# -------------------------
# Parse apt output for problematic lines
# -------------------------
# Use a temp file for filtered error lines to avoid process-substitution timing issues.
err_lines=$(mktemp)
trap 'rm -f "$tmp_log" "$err_lines"' EXIT
grep -Ei "$ERR_REGEX" "$tmp_log" > "$err_lines" || true

while IFS= read -r line; do
    if [[ "$line" =~ ^Err: ]]; then
        # Try to find the HTTP token on the line (last http* token)
        current_url=$(awk '{for(i=NF;i>0;i--) if($i~/^http/) {print $i; exit}}' <<<"$line")
        if [[ -n "${current_url:-}" ]] && is_http_url "$current_url"; then
            url_to_sig["$current_url"]=""
        else
            current_url=""
        fi
    elif [[ "$line" =~ EXPKEYSIG ]]; then
        local_sig=$(extract_sig "$line")
        # only attach sig to a present valid current_url
        if [[ -n "${current_url:-}" ]] && is_http_url "$current_url"; then
            url_to_sig["$current_url"]="$local_sig"
        fi
    fi
done < "$err_lines"

# If no entries, exit early with same user message as before
if (( ${#url_to_sig[@]} == 0 )); then
    log "No problematic repository errors detected."
    exit 0
fi

# -------------------------
# Gather all source list files
# -------------------------
for pat in "${PATTERNS[@]}"; do
    for f in "$SRC_DIR"/$pat; do
        [[ -e "$f" ]] && all_src_files+=("$f")
    done
done
[[ -f /etc/apt/sources.list ]] && all_src_files+=("/etc/apt/sources.list")

# -------------------------
# Process problematic URLs
# -------------------------
for url in "${!url_to_sig[@]}"; do
    # Skip invalid keys (defensive)
    [[ -z "${url:-}" ]] && continue
    [[ ! $(is_http_url "$url" && printf true) ]] && continue

    sig="${url_to_sig[$url]:-}"

    if [[ -z "$sig" ]]; then
        log "Renaming source files that reference URL without a valid release: $url"
        for src in "${all_src_files[@]:-}"; do
            if [[ -f "$src" ]] && grep -Fq "$url" "$src"; then
                safe_rename "$src"
            fi
        done
    else
        log "Looking for expired GPG keys for signature $sig (URL: $url)"
        # Updated call: collect files and per-file matching fprs
        declare -a matching_key_files=()
        declare -A matching_fps_map=()
        find_keys_by_sig "$sig" matching_key_files matching_fps_map

        if (( ${#matching_key_files[@]} > 0 )); then
            for key in "${matching_key_files[@]}"; do
                fps="${matching_fps_map[$key]}"
                if key_is_expired "$key" "$fps"; then
                    safe_rename_key "$key"
                else
                    log "GPG key not expired, skipping rename: $key"
                fi
            done
        else
            log "No matching GPG key found for signature $sig"
        fi
    fi
done

# -------------------------
# Final summary (keeps user-facing behaviour)
# -------------------------
if (( renamed_sources == 0 && renamed_keys == 0 )); then
    echo "It looks good. No errors encountered."
else
    log "All requested renames completed."
    log "Renamed ${renamed_sources} source(s) and ${renamed_keys} key(s)."
fi

exit 0
openSUSE Build Service is sponsored by