#!/bin/sh

__dest=/
__pb_mount_dir=/var/petitboot/mnt/dev/
plugin_abi=1
plugin_ext=pb-plugin
plugin_meta=pb-plugin.conf
plugin_meta_dir=etc/preboot-plugins/
plugin_meta_path=$plugin_meta_dir$plugin_meta
plugin_wrapper_dir=/var/lib/pb-plugins/bin

usage()
{
	cat <<EOF
Usage: $0 <command>

Where <command> is one of:
  install <FILE|URL> - install plugin from FILE/URL
  scan <DIR>         - look for available plugins on attached devices
  create <DIR>       - create a new plugin archive from DIR
  lint <FILE>        - perform a pre-distribution check on FILE
EOF
}

is_url()
{
	local url tmp
	url=$1
	tmp=${url#*://}
	[ "$url" != "$tmp" ]
}

download()
{
	local url file proto
	url=$1
	file=$2
	proto=${url%://*}

	case "$proto" in
	http)
		wget -O - "$url" > $file
		;;
	ftp)
		ncftpget -c "$url" > $file
		;;
	*)
		echo "error: Unsuported protocol $proto" >&2
		false
	esac
}

plugin_info()
{
	local title
	if [ "$PLUGIN_VENDOR" ]
	then
		title="$PLUGIN_VENDOR: $PLUGIN_NAME"
	else
		title="$PLUGIN_NAME"
	fi

	echo "$title"
	echo "  (version $PLUGIN_VERSION)"
}

parse_meta()
{
	local file name value IFS

	file=$1

	IFS='='
	while read -r name value
	do
		# Ensure we have a sensible variable name
		echo "$name" | grep -q '^PLUGIN_[A-Z_]*$' || continue

		# we know that $name has no quoting/expansion chars, but we
		# may need to do some basic surrounding-quote removal for
		# $value, without evaluating it
		value=$(echo "$value" | sed "s/^\([\"']\)\(.*\)\1\$/\2/g")

		export $name="$value"
	done < $file
}

# How the ABI versioning works:
#
#  - This script has an ABI defined ($plugin_abi)
#
#  - Plugins have a current ABI number ($PLUGIN_ABI), and a minimum supported
#    ABI number ($PLUGIN_ABI_MIN).
#
#  - A plugin is OK to run if:
#    - the plugin's ABI matches the script ABI, or
#    - the plugin's minimum ABI is lower than or equal to the script ABI
plugin_abi_check()
{
	[ -n "$PLUGIN_ABI" ] &&
		( [ $PLUGIN_ABI -eq $plugin_abi ] ||
		  [ $PLUGIN_ABI_MIN -le $plugin_abi ] )
}

do_wrap()
{
	local base binary dir

	base=$1
	binary=$2
	shift 2

	for dir in etc dev sys proc var
	do
		[ -e "$base/$dir" ] || mkdir -p "$base/$dir"
	done

	cp /etc/resolv.conf $base/etc
	mount -o bind /dev $base/dev
	mount -o bind /sys $base/sys
	mount -o bind /proc $base/proc
	mount -o bind /var $base/var

	chroot "$base" "$binary" "$@"

	umount $base/dev
	umount $base/sys
	umount $base/proc
	umount $base/var
}

__create_wrapper()
{
	local base binary wrapper

	base=$1
	binary=$2
	wrapper=$plugin_wrapper_dir/$(basename $binary)

	mkdir -p $plugin_wrapper_dir

	cat <<EOF > $wrapper
#!/bin/sh

exec $(realpath $0) __wrap '$base' '$binary' "\$@"
EOF

	chmod a+x $wrapper
}

do_install()
{
	local url name file __dest auto

	if [ "$1" == "auto" ]; then
		auto=y
		shift;
	fi

	url=$1

	if [ -z "$url" ]
	then
		echo "error: install requires a file/URL argument." >&2
		exit 1
	fi

	name=${url##*/}

	if is_url "$url"
	then
		file=$(mktemp)
		trap "rm '$file'" EXIT
		download "$url" "$file"
		if [ $? -ne 0 ]
		then
			echo "error: failed to download $url" >&2
			exit 1
		fi
	else
		file=$url
		if [ ! -r "$file" ]
		then
			echo "error: $file doesn't exist or is not readable" >&2
			exit 1
		fi
	fi

        echo "File '$name' has the following sha256 checksum:"
	echo
	sha256sum "$file" | cut -f1 -d' '
	echo

	if [ -z "$auto" ]
	then
		echo "Do you want to install this plugin? (y/N)"
		read resp

		case $resp in
		[yY]|[yY][eE][sS])
			;;
		*)
			echo "Cancelled"
			exit 0
			;;
		esac
	fi

	__dest=$(mktemp -d)
	gunzip -c "$file" | ( cd $__dest && cpio -i -d)

	if [ $? -ne 0 ]
	then
		echo "error: Failed to extract archive $url, exiting"
		rm -rf $__dest
		exit 1
	fi

	parse_meta $__dest/$plugin_meta_path

	if ! plugin_abi_check
	then
		echo "Plugin at $url is incompatible with this firmware," \
			"exiting."
		rm -rf $__dest
		exit 1
	fi

	for binary in ${PLUGIN_EXECUTABLES}
	do
		__create_wrapper "$__dest" "$binary"
	done

	pb-event plugin@local \
		name="$PLUGIN_NAME" id=$PLUGIN_ID version=$PLUGIN_VERSION \
		vendor="$PLUGIN_VENDOR" vendor_id=$PLUGIN_VENDOR_ID \
		date=$PLUGIN_DATE executables="$PLUGIN_EXECUTABLES" \
		source_file=$url installed="yes"

	echo "Plugin installed"
	plugin_info
}

do_scan_mount()
{
	local mnt dev plugin_path __meta_tmp
	mnt=$1
	dev=$(basename $mnt)

	for plugin_path in $mnt/*.$plugin_ext
	do
		[ -e "$plugin_path" ] || continue

		# extract plugin metadata to a temporary directory
		__meta_tmp=$(mktemp -d)
		[ -d $__meta_tmp ] || continue
		gunzip -c "$plugin_path" 2>/dev/null |
			(cd $__meta_tmp &&
				cpio -i -d $plugin_meta_path 2>/dev/null)
		if ! [ $? = 0 -a -e "$plugin_path" ]
		then
			rm -rf $__meta_tmp
			continue
		fi

		(
			parse_meta $__meta_tmp/$plugin_meta_path

			plugin_abi_check || exit 1

			printf "Plugin found on %s:\n" $dev
			plugin_info
			printf "\n"
			printf "To run this plugin:\n"
			printf "  $0 install $plugin_path\n"
			printf "\n"

			pb-event plugin@$dev name=$PLUGIN_NAME \
				path=$plugin_path installed="no"
		)
		if [ $? = 0 ]
		then
			found=1
		fi
		rm -rf $__meta_tmp
	done
}

do_scan()
{
	local found mnt dev locations
	found=0
	dev=$1

	if [ -z $dev ]; then
		locations=$__pb_mount_dir/*
	else
		echo "Scanning device $dev"
		locations=$dev
	fi

	for mnt in $locations
	do
		do_scan_mount $mnt
	done

	if [ "$found" = 0 ]
	then
		echo "No plugins found"
	fi
}

guided_meta()
{
	local vendorname vendorshortname
	local pluginname pluginnhortname
	local version date executable
	local file

	file=$1

cat <<EOF

Enter the vendor company / author name. This can contain spaces.
(eg. 'Example Corporation')
EOF
	read vendorname
cat <<EOF

Enter the vendor shortname. This should be a single-word abbreviation, in all
lower-case. This is only used in internal filenames.

Typically, a stock-ticker name is used for this (eg 'exco')
EOF
	read vendorshortname

cat <<EOF

Enter the descriptive plugin name. This can contain spaces, but should only be
a few words in length (eg 'RAID configuration utility')
EOF
	read pluginname

cat <<EOF

Enter the plugin shortname. This should not contain spaces, but hyphens are
fine (eg 'raid-config'). This is only used in internal filnames.
EOF
	read pluginshortname


cat <<EOF

Enter the plugin version. This should not contain spaces (eg 1.2):
EOF
	read version

cat <<EOF

Enter the full path (within the plugin root) to the plugin executable file(s).
These will be exposed as wrapper scripts, to be run from the standard petitboot
shell environment (eg, /usr/bin/my-raid-config).

If multiple executables are provided, separate with a space.
EOF
	read executables

	date=$(date +%Y-%m-%d)

	mkdir -p $(dirname $file)

	cat <<EOF > $file
PLUGIN_ABI='$plugin_abi'
PLUGIN_ABI_MIN='1'
PLUGIN_VENDOR='$vendorname'
PLUGIN_VENDOR_ID='$vendorshortname'
PLUGIN_NAME='$pluginname'
PLUGIN_ID='$pluginshortname'
PLUGIN_VERSION='$version'
PLUGIN_DATE='$date'
PLUGIN_EXECUTABLES='$executables'
EOF

}

do_create()
{
	local src meta_dir_abs meta_file
	src=$1

	if [ -z "$src" ]
	then
		echo "error: missing source directory" >&2
		usage
		exit 1
	fi

	if [ ! -d "$src" ]
	then
		echo "error: source directory missing" >&2
		exit 1
	fi

	meta_file=$src/$plugin_meta_path

	if [ ! -e $meta_file ]
	then
		echo "No plugin metadata file found. " \
			"Would you like to create one? (Y/n)"
		read resp
		case "$resp" in
		[nN]|[nN][oO])
			echo "Cancelled, exiting"
			exit 1
			;;
		esac
		guided_meta $meta_file || exit
	fi

	# Sanity check metadata file
	parse_meta $meta_file

	errors=0
	warnings=0

	lint_metadata

	if [ $errors -ne 0 ]
	then
		exit 1
	fi

	outfile=${PLUGIN_ID}-${PLUGIN_VERSION}.${plugin_ext}

	(
		cd $src
		find -mindepth 1 | cpio -o -Hnewc -v
	) | gzip -c > $outfile

	echo
	echo "Plugin metadata:"
	sed -e 's/^/  /' $meta_file
	echo

	echo "User-visible metadata:"
	plugin_info | sed -e 's/^/  /'

	echo

cat <<EOF
Plugin created in:
  $outfile

Ship this file in the top-level-directory of a USB device or CD to have it
automatically discoverable by 'pb-plugin scan'. This file can be re-named,
but must retain the .$plugin_ext extension to be discoverable.
EOF
}

lint_fatal()
{
	echo "fatal:" "$@"
	[ -d "$__dest" ] && rm -rf "$__dest"
	exit 1
}

lint_err()
{
	echo "error:" "$@"
	errors=$(($errors+1))
}

lint_warn()
{
	echo "warning:" "$@"
	warnings=$(($warnings+1))
}

lint_metadata()
{
	[ -n "$PLUGIN_ABI" ] ||
		lint_err "no PLUGIN_ABI defined in metadata"

	printf '%s' "$PLUGIN_ABI" | grep -q '[^0-9]' &&
		lint_err "PLUGIN_ABI has non-numeric characters"

	[ -n "$PLUGIN_ABI_MIN" ] ||
		lint_err "no PLUGIN_ABI_MIN defined in metadata"

	printf '%s' "$PLUGIN_ABI_MIN" | grep -q '[^0-9]' &&
		lint_err "PLUGIN_ABI_MIN has non-numeric characters"

	[ "$PLUGIN_ABI" = "$plugin_abi" ] ||
		lint_warn "PLUGIN_ABI (=$PLUGIN_ABI) is not $plugin_abi"

	[ -n "$PLUGIN_VENDOR" ] ||
		lint_err "no PLUGIN_VENDOR defined in metadata"

	[ -n "$PLUGIN_VENDOR_ID" ] ||
		lint_err "no PLUGIN_VENDOR_ID defined in metadata"

	printf '%s' "$PLUGIN_VENDOR_ID" | grep -q '[^a-z0-9-]' &&
		lint_err "PLUGIN_VENDOR_ID should only contain lowercase" \
			"alphanumerics and hyphens"

	[ -n "$PLUGIN_NAME" ] ||
		lint_err "no PLUGIN_NAME defined in metadata"

	[ -n "$PLUGIN_ID" ] ||
		lint_err "no PLUGIN_ID defined in metadata"

	printf '%s' "$PLUGIN_ID" | grep -q '[^a-z0-9-]' &&
		lint_err "PLUGIN_ID should only contain lowercase" \
			"alphanumerics and hyphens"

	[ "$PLUGIN_VERSION" ] ||
		lint_err "no PLUGIN_VERSION defined in metadata"

	[ -n "$PLUGIN_DATE" ] ||
		lint_err "no PLUGIN_DATE defined in metadata"

	[ -n "$PLUGIN_EXECUTABLES" ] ||
		lint_err "no PLUGIN_EXECUTABLES defined in metadata"
}

do_lint()
{
	local plugin_file errors warnings __dest executable dir

	plugin_file=$1
	errors=0
	warnings=0
	__dest=

	[ "${plugin_file##*.}" = $plugin_ext ] ||
		lint_err "Plugin file does not end with $plugin_ext"

	gunzip -c "$plugin_file" > /dev/null 2>&1 ||
		lint_fatal "Plugin can't be gunzipped"

	gunzip -c "$plugin_file" 2>/dev/null | cpio -t >/dev/null 2>&1 ||
		lint_fatal "Plugin can't be cpioed"

	__dest=$(mktemp -d)
	gunzip -c "$plugin_file" | ( cd $__dest && cpio -i -d 2>/dev/null)

	[ -e "$__dest/$plugin_meta_path" ] ||
		lint_fatal "No metadata file present (expecting" \
			"$plugin_meta_path)"

	parse_meta "$__dest/$plugin_meta_path"
	lint_metadata

	for executable in ${PLUGIN_EXECUTABLES}
	do
		exec_path="$__dest/$executable"
		[ -e "$exec_path" ] || {
			lint_err "PLUGIN_EXECUTABLES item $executable" \
				"doesn't exist"
			continue
		}

		[ -x "$exec_path" ] ||
			lint_err "PLUGIN_EXECUTABLES item $executable" \
				"isn't executable"
	done

	for dir in dev sys proc var
	do
		[ -e "$__dest/$dir" ] || continue

		[ -d "$__dest/$dir" ] ||
			lint_err "/$dir exists, but isn't a directory"

		[ "$(find $__dest/$dir -mindepth 1)" ] &&
			lint_warn "/$dir contains files/directories," \
				"these will be lost during chroot setup"
	done

	printf '%s: %d errors, %d warnings\n' $plugin_file $errors $warnings
	rm -rf $__dest
	[ $errors = 0 ]
}

test_http_download()
{
	local tmp ref

	tmp=$(mktemp -p $test_tmpdir)
	ref=$(mktemp -p $test_tmpdir)

	echo $RANDOM > $ref

	wget()
	{
		cat $ref
	}

	download http://example.com/test $tmp
	cmp -s "$ref" "$tmp"
}

test_ftp_download()
{
	local tmp ref

	tmp=$(mktemp -p $test_tmpdir)
	ref=$(mktemp -p $test_tmpdir)

	echo $RANDOM > $ref

	ncftpget()
	{
		cat $ref
	}

	download ftp://example.com/test $tmp
	cmp -s "$ref" "$tmp"
}

test_abi_check()
{
	(
		plugin_abi=$1
		PLUGIN_ABI=$2
		PLUGIN_ABI_MIN=$3
		plugin_abi_check
	)
}

test_scan()
{
	__pb_mount_dir="$test_tmpdir/mnt"
	mnt_dir="$__pb_mount_dir/sda"
	mkdir -p $mnt_dir/$plugin_meta_dir
	(
		echo "PLUGIN_ABI=$plugin_abi"
		echo "PLUGIN_NAME=test"
		echo "PLUGIN_VERSION=1"
		echo "PLUGIN_EXECUTABLES=/bin/sh"
	) > $mnt_dir/$plugin_meta_path
	(
		cd $mnt_dir;
		find -mindepth 1 | cpio -o -Hnewc 2>/dev/null
	) | gzip -c > $mnt_dir/test.$plugin_ext

	do_scan | grep -q 'test'
}

test_scan_nogzip()
{
	__pb_mount_dir="$test_tmpdir/mnt"
	mnt_dir="$__pb_mount_dir/sda"
	stderr_file="$test_tmpdir/stderr"

	mkdir -p $mnt_dir
	echo "invalid" > $mnt_dir/nogzip.$plugin_ext

	do_scan 2>$stderr_file | grep -q 'No plugins'

	[ $? = 0 ] || return 1

	if [ -s "$stderr_file" ]
	then
		echo "Scan with invalid (non-gzip) file produced error output" \
			>&2
		cat "$stderr_file"
		return 1
	fi
	true
}

test_scan_nocpio()
{
	__pb_mount_dir="$test_tmpdir/mnt"
	mnt_dir="$__pb_mount_dir/sda"
	stderr_file="$test_tmpdir/stderr"

	mkdir -p $mnt_dir
	echo "invalid" | gzip -c > $mnt_dir/nogzip.$plugin_ext

	do_scan 2>$stderr_file | grep -q 'No plugins'

	[ $? = 0 ] || return 1

	if [ -s "$stderr_file" ]
	then
		echo "Scan with invalid (non-cpio) file produced error output" \
			>&2
		cat "$stderr_file"
		return 1
	fi
	true
}

test_scan_multiple()
{
	__pb_mount_dir="$test_tmpdir/mnt"
	mnt_dir="$__pb_mount_dir/sda"
	outfile=$test_tmpdir/scan.out

	for i in 1 2
	do
		mkdir -p $mnt_dir/$plugin_meta_dir
		(
			echo "PLUGIN_ABI=$plugin_abi"
			echo "PLUGIN_NAME=test-$i"
			echo "PLUGIN_VERSION=1"
			echo "PLUGIN_EXECUTABLES=/bin/sh"
		) > $mnt_dir/$plugin_meta_path
		(
			cd $mnt_dir;
			find -mindepth 1 | cpio -o -Hnewc 2>/dev/null
		) | gzip -c > $mnt_dir/test-${i}.$plugin_ext
		rm -rf $mnt_dir/$plugin_meta_dir
	done

	do_scan >$outfile

	grep -q 'test-1' $outfile && grep -q 'test-2' $outfile
}

test_scan_wrongabi()
{
	__pb_mount_dir="$test_tmpdir/mnt"
	mnt_dir="$__pb_mount_dir/sda"
	mkdir -p $mnt_dir/$plugin_meta_dir
	(
		echo "PLUGIN_ABI=$(($plugin_abi + 1))"
		echo "PLUGIN_ABI_MIN=$(($plugin_abi + 1))"
		echo "PLUGIN_NAME=test"
		echo "PLUGIN_VERSION=1"
		echo "PLUGIN_EXECUTABLES=/bin/sh"
	) > $mnt_dir/$plugin_meta_path
	(
		cd $mnt_dir;
		find -mindepth 1 | cpio -o -Hnewc 2>/dev/null
	) | gzip -c > $mnt_dir/test.$plugin_ext

	do_scan | grep -q 'No plugins'
}

test_empty_scan()
{
	__pb_mount_dir="$test_tmpdir/mnt"
	mkdir -p $__pb_mount_dir
	do_scan | grep -q "No plugins"
}

test_setup()
{
	n=$(($n+1))

	test_tmpdir="$tests_tmpdir/$n"
	mkdir "$test_tmpdir"
}

test_teardown()
{
	true
}

test_failed=0
do_test()
{
	local tstr op

	tstr="$@"
	op=-eq

	if [ "x$1" = "x!" ]
	then
		op=-ne
		shift
	fi

	test_setup
	( $@ )
	local rc=$?
	test_teardown

	if [ $rc $op 0 ]
	then
		echo PASS: "$tstr"
	else
		echo FAIL: "$tstr"
		test_failed=1
		false
	fi
}

do_tests()
{
	local tests_tmpdir n

	tests_tmpdir=$(mktemp -d)
	n=0

	do_test ! is_url "/test"
	do_test ! is_url "./test"
	do_test ! is_url "../test"
	do_test ! is_url "test"
	do_test is_url "http://example.com/path"
	do_test is_url "git+ssh://example.com/path"
	do_test test_http_download
	do_test test_ftp_download
	do_test ! test_abi_check
	do_test ! test_abi_check 1
	do_test test_abi_check 1 1
	do_test test_abi_check 1 1 1
	do_test test_abi_check 1 2 0
	do_test test_abi_check 1 2 1
	do_test ! test_abi_check 1 2 2
	do_test test_scan
	do_test test_scan_nogzip
	do_test test_scan_nocpio
	do_test test_scan_multiple
	do_test test_scan_wrongabi
	do_test test_empty_scan

	if [ $test_failed = 0 ]
	then
		echo "$n tests passed"
	else
		echo "Tests failed"
	fi
	rm -rf "$tests_tmpdir"

	[ $test_failed = 0 ]
}

case "$1" in
install)
	shift
	do_install $@
	;;
scan)
	shift
	do_scan $@
	;;
create)
	shift
	do_create $@
	;;
lint)
	shift
	do_lint $@
	;;
__wrap)
	shift
	do_wrap $@
	;;
__test)
	shift
	do_tests $@
	;;
"")
	echo "error: Missing command" >&2
	usage
	exit 1
	;;
*)
	echo "Invalid command: $1" >&2
	usage
	exit 1
esac


