#!/bin/sh
set -e
. /usr/share/debconf/confmodule

DENIED=/tmp/missing-firmware-denied

if [ "x$1" = "x-n" ]; then
	NONINTERACTIVE=1
	shift
else
	NONINTERACTIVE=""
fi

IFACES="$@"

log () {
	logger -t check-missing-firmware "$@"
}

log_output() {
	log-output -t check-missing-firmware "$@"
}

# USB is special, and we don't want to take it all down:
get_usb_module() {
	address="$1"
	device="/sys/bus/usb/devices/$address"

	# Make sure there's a single subdirectory (e.g. 4-1.5:1.0 below 4-1.5):
	subdirs=$(find -L "$device" -maxdepth 1 -type d -name "$address:*")
	subdirs_n=$(echo "$subdirs" | wc -w)
	if [ $(echo "$subdirs" | wc -w) != 1 ]; then
		log "failed to perform usb $address lookup (got: $subdirs_n entries, expected: 1)"
		log "=> sticking with the usb module"
		echo 'usb'
		return
	fi

	# Make sure driver resolution returns something:
	driver=$(basename $(readlink "$subdirs/driver") 2>/dev/null)
	if [ "$driver" = "" ]; then
		log "failed to perform usb $address lookup (no driver found)"
		log "=> sticking with the usb module"
		echo 'usb'
		return
	fi
	echo $driver
}

# MHI is special, but different from USB; at least with ath11k_pci,
# /sys/bus/mhi/devices/mhi0* doesn't list anything ath11k-related.
# The mhi module's holders directory lists ath11k_pci and qrtr_mhi
# though!
get_mhi_holders() {
	holders=$(find -L /sys/module/mhi/holders/ -mindepth 1 -maxdepth 1 -exec basename {} ';')
	if [ "$holders" = "" ]; then
		log "failed to perform mhi lookup (no holders found)"
		log "=> sticking with the mhi module"
		echo 'mhi'
	else
		echo $holders
	fi
}

# Some modules only try to load firmware once brought up. So bring up and
# then down any interfaces specified by ethdetect. Don't touch interfaces
# that users might have configured (manually or via preseeding) though!
upnics() {
	for iface in $IFACES; do
		# Don't rely on ip's output, it lacks at least state UP/DOWN:
		sys_iface="/sys/class/net/$iface"
		if grep -qs ^up$ "$sys_iface/operstate"; then
			log "leaving network interface $iface alone (state=up)"
			continue
		elif [ -e "$sys_iface/master" ]; then
			# Most likely bonding:
			master=$(basename $(readlink "$sys_iface/master") 2>/dev/null)
			log "leaving network interface $iface alone (master=${master:-<unknown>})"
			continue
		fi
		log "taking network interface $iface up/down"
		ip link set "$iface" up || true
		ip link set "$iface" down || true
	done
}

# Checks if a given module is a nic module and has an interface that
# is up and has an IP address. Such modules should not be reloaded,
# to avoid taking down the network after it's been configured.
nic_is_configured() {
	module="$1"

	for iface in $(ip -o link show up | cut -d : -f 2); do
		dir="/sys/class/net/$iface/device/driver"
		if [ -e "$dir" ] && [ "$(basename "$(readlink "$dir")")" = "$module" ]; then
			if ip address show scope global dev "$iface" | grep -q 'scope global'; then
				return 0
			fi
		fi
	done

	return 1
}

get_fresh_dmesg() {
	dmesg_file=/tmp/dmesg.txt
	dmesg_ts=/tmp/dmesg-ts.txt

	# Get current dmesg:
	dmesg > $dmesg_file

	# Truncate if needed:
	if [ -f $dmesg_ts ]; then
		# Transform [foo] into \[foo\] to make it possible to search for
		# "^$tspattern" (-F for fixed string doesn't play well with ^ to
		# anchor the pattern on the left):
		tspattern=$(cat $dmesg_ts | sed 's,\[,\\[,;s,\],\\],')
		log "looking at dmesg again, restarting from timestamp: $(cat $dmesg_ts)"

		# Find the line number for the first match, empty if not found:
		ln=$(grep -n "^$tspattern" $dmesg_file |sed 's/:.*//'|head -n 1)
		if [ ! -z "$ln" ]; then
			log "timestamp found, truncating dmesg accordingly"
			sed -i "1,$ln d" $dmesg_file
		else
			log "timestamp not found, using whole dmesg"
		fi
	else
		log "looking at dmesg for the first time"
	fi

	if [ -s $dmesg_file ]; then
		# Save the last timestamp:
		grep -o '^\[ *[0-9.]\+\]' $dmesg_file | tail -n 1 > $dmesg_ts
		log "saving timestamp for a later use: $(cat $dmesg_ts)"
	else
		log "keeping timestamp (no new lines): $(cat $dmesg_ts)"
	fi

	# Write and clean-up:
	cat $dmesg_file
	rm $dmesg_file
}

check_missing () {
	upnics

	# Give modules some time to request firmware.
	sleep 1

	modules=""
	files=""

	# Parse dmesg using a started parttern to detect firmware
	# files the kernel drivers look for (#725714):
	fwlist=/tmp/check-missing-firmware-dmesg.list
	get_fresh_dmesg | sed -rn 's/^(\[[^]]*\] )?([^ ]+) ([^ ]+): firmware: failed to load ([^ ]+) .*/\2 \3 \4/p' > $fwlist
	while read module address fwfile ; do
	    # rewrite module is necessary
	    case "$module" in
		usb)
		    module=$(get_usb_module "$address")
		    log "using module $module instead of usb $address"
		;;
		mhi)
		    module=$(get_mhi_holders)
		    log "using $module instead of mhi"
		;;
	    esac

	    # ignore specific files:
	    #  - iwlwifi, debug-only (#969264, #966218)
	    if [ "$fwfile" = "iwl-debug-yoyo.bin" ]; then
		log "ignoring firmware file $fwfile requested by $module"
		continue
	    fi

	    log "looking for firmware file $fwfile requested by $module"
	    if [ ! -e /lib/firmware/$fwfile ] ; then
		if grep -q "^$fwfile$" $DENIED 2>/dev/null; then
		    log "listed in $DENIED"
		    continue
		fi
		files="${files:+$files }$fwfile"
		modules="$module${modules:+ $modules}"
	    fi
	done < $fwlist

	if [ -n "$modules" ]; then
		# Uniquify since a single module may request *many* firmware files,
		# and a file might be requested several times:
		modules=$(echo $modules | tr " " "\n" | sort -u)
		files=$(echo $files | tr " " "\n" | sort -u)
		log "missing firmware files ($files) for $modules"
		return 0
	else
		log "no missing firmware in loaded kernel modules"
		return 1
	fi
}

# If found, copy firmware file; preserve subdirs.
try_copy () {
	local fwfile=$1
	local sdir file f target

	sdir=$(dirname $fwfile | sed "s/^\.$//")
	file=$(basename $fwfile)
	for f in "/media/$fwfile" "/media/firmware/$fwfile" \
		 ${sdir:+"/media/$file" "/media/firmware/$file"}; do
		if [ -e "$f" ]; then
			target="/lib/firmware${sdir:+/$sdir}"
			log "copying loose file $file from '$(dirname $f)' to '$target'"
			mkdir -p "$target"
			rm -f "$target/$file"
			cp -aL "$f" "$target" || true
			break
		fi
	done
}

first_try=1
first_ask=1
ask_load_firmware () {
	if [ "$first_try" ]; then
		first_try=""
		return 0
	fi

	if [ "$NONINTERACTIVE" ]; then
		if [ ! "$first_ask" ]; then
			return 1
		else
			first_ask=""
			return 0
		fi
	fi

	db_subst hw-detect/load_firmware FILES "$files"
	if ! db_input high hw-detect/load_firmware; then
		if [ ! "$first_ask" ]; then
			exit 1;
		else
			first_ask=""
		fi
	fi
	if ! db_go; then
		exit 10 # back up
	fi
	db_get hw-detect/load_firmware
	if [ "$RET" = true ]; then
		return 0
	else
		echo "$files" | tr ' ' '\n' >> $DENIED
		return 1
	fi
}

list_deb_firmware () {
	udpkg -c "$1" \
		| grep '^\./lib/firmware/' \
		| sed -e 's!^\./lib/firmware/!!' \
		| grep -v '^$'
}

check_deb_arch () {
	arch=$(udpkg -f "$1" | grep '^Architecture:' | sed -e 's/Architecture: *//')
	[ "$arch" = all ] || [ "$arch" = "$(udpkg --print-architecture)" ]
}

get_deb_component () {
	# This trusts the contents of the .deb, but packages in the archive could
	# have overrides (controlled by ftpmaster):
	section=$(udpkg -f "$1" | grep '^Section:' | sed -e 's/Section: *//')
	if ! echo "$section" | grep -qs '/'; then
		echo "main"
	else
		echo "$section" | sed 's,/.*,,'
	fi
}

# Remove non-accepted firmware package
remove_pkg() {
	pkgname="$1"
	# Remove all files listed in /var/lib/dpkg/info/$pkgname.md5sums
	for file in $(cut -d" " -f 2- /var/lib/dpkg/info/$pkgname.md5sums) ; do
		rm /$file
	done
}

install_firmware_pkg () {
	# cache deb for installation into /target later
	mkdir -p /var/cache/firmware/
	cp -aL "$1" /var/cache/firmware/ || true
	filename="$(basename "$1")"
	pkgname="$(udpkg -f "$1" | grep '^Package:' | sed -e 's/^Package: *//')"
	udpkg --unpack "/var/cache/firmware/$filename"
	if [ -f /var/lib/dpkg/info/$pkgname.preinst ] ; then
		# Run preinst script to see if the firmware
		# license is accepted Exit code of preinst
		# decide if the package should be installed or
		# not.
		if /var/lib/dpkg/info/$pkgname.preinst ; then
			:
		else
			remove_pkg "$pkgname"
			rm "/var/cache/firmware/$filename"
			removed=1
		fi
	fi
	if [ "$removed" != 1 ]; then
		echo "$2" >> /var/cache/firmware/components
		echo "$pkgname $2 dmesg" >> /var/log/firmware-summary
	fi
}

# Try to load debs that contain the missing firmware.
# This does not use anna because debs can have arbitrary
# dependencies, which anna might try to install.
check_for_firmware() {
	echo "$files" | sed -e 's/ /\n/g' >/tmp/grepfor
	for dir in $@; do
		# An index file might exist, mapping firmware files to firmware
		# packages, saving us from iterating over each firmware *.deb:
		if [ -f $dir/Contents-firmware ]; then
			log "lookup with $dir/Contents-firmware"
			# Duplicating stdin makes license prompts work again (#1033921). The
			# workaround is meant for Bookworm, but this should be reconsidered
			# (#1035356, #1029843).
			{
			grep -f /tmp/grepfor $dir/Contents-firmware | while read fw_file fw_pkg_file component; do
				# Don't install a package for each file it ships!
				if grep -qs "^$fw_pkg_file$" /tmp/pkginstalled 2>/dev/null; then
					continue
				fi
				if check_deb_arch "$dir/$fw_pkg_file"; then
					log "installing firmware package $dir/$fw_pkg_file ($component)"
					install_firmware_pkg "$dir/$fw_pkg_file" "$component" <&9 || true
					echo "$fw_pkg_file" >> /tmp/pkginstalled
				fi
			done
			} 9<&0
			continue
		fi

		# If no such index exists, fall back to iterating over everyone:
		log "lookup without $dir/Contents-firmware"
		for filename in $dir/*.deb; do
			if [ -f "$filename" ]; then
				if check_deb_arch "$filename" && list_deb_firmware "$filename" | grep -qf /tmp/grepfor; then
					log "installing firmware package $filename"
					install_firmware_pkg "$filename" $(get_deb_component "$filename") || true
				fi
			fi
		done
	done
	rm -f /tmp/grepfor
	rm -f /tmp/pkginstalled
}

# For those who don't want to load any firmware, even if available on
# installation images (#1029848). The loop is still entered so that
# logs are generated.
db_get hw-detect/firmware-lookup
firmware_lookup="$RET"

# NOTE: The ask_load_firmware function returns true the first time around,
# without asking any questions. For consistency, skip mountmedia calls during
# the first iteration of the loop. For systems which have all firmware material
# found in {,/cdrom}/firmware, this also means a noticeable speed-up.
loop=0
while check_missing && ask_load_firmware; do
	loop=$((loop+1))
	log "mainloop iteration #$loop"

	if [ "$firmware_lookup" = "never" ]; then
		log "firmware lookup disabled (=$firmware_lookup), exiting"
		exit 0
	fi

	# first, check if needed firmware debs are available on the
	# PXE initrd or the installation CD.
	if [ -d /firmware ]; then
		check_for_firmware /firmware
	fi
	if [ -d /cdrom/firmware ]; then
		check_for_firmware /cdrom/firmware
	fi

	# Whether we should keep both mountmedia calls, and whether mountmedia
	# is doing a good job is discussed in #1029543:
	if [ "$loop" -gt 1 ]; then
		# second, look for loose firmware files on the media device.
		if mountmedia; then
			for file in $files; do
				try_copy "$file"
			done
			umount /media || true
		fi

		# last, look for firmware debs on the media device
		if mountmedia driver; then
			check_for_firmware /media /media/firmware
			umount /media || true
		fi
	fi

	# remove and reload modules so they see the new firmware
	for module in $modules; do
		if ! nic_is_configured $module; then
			log "removing and loading kernel module $module"
			log_output modprobe -r $module || true
			log_output modprobe $module || true

			# iterate to avoid dealing with multiplicity explicitly:
			for driver in $(find /sys/bus/*/drivers -name "$module"); do
				# module name mentioned in dmesg might differ from the actual module
				# (rtw_8821ce vs. rtw88_8821ce, see #973733); also beware of the
				# module symlink, it doesn't always exist:
				if [ -e "$driver/module" ]; then
					actual_module=$(basename $(readlink -f "$driver/module"))
					if [ "$actual_module" != "$module" ]; then
						log "removing and loading kernel module $actual_module as well (actual module for $module)"
						log_output modprobe -r $actual_module || true
						log_output modprobe $actual_module || true
					fi
				fi
			done
		fi
	done
done
