#!/bin/ksh -
#
# $OpenBSD: sysmerge.sh,v 1.184 2014/09/08 11:27:49 halex Exp $
#
# Copyright (c) 2008-2014 Antoine Jacoutot <ajacoutot@openbsd.org>
# Copyright (c) 1998-2003 Douglas Barton <DougB@FreeBSD.org>
#
# Permission to use, copy, modify, and distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
#

umask 0022

usage() {
	echo "usage: ${0##*/} [-bdp]" >&2 && exit 1
}

# OpenBSD /etc/rc v1.438
stripcom() {
	local _file="$1"
	local _line

	{
		while read _line ; do
			_line=${_line%%#*}		# strip comments
			test -z "$_line" && continue
			echo $_line
		done
	} < $_file
}

sm_echo() {
	echo "$@" | tee -a ${_WRKDIR}/sysmerge.log
}

sm_error() {
	(($#)) && sm_echo "!!!! ERROR: $@"

	# restore sum files from backups or remove the newly created ones
	for _i in ${_WRKDIR}/{etcsum,xetcsum,examplessum,pkgsum}; do
		_j=$(basename ${_i})
		if [[ -f ${_i} ]]; then
			mv ${_i} /usr/share/sysmerge/${_j}
		elif [[ -f /usr/share/sysmerge/${_j} ]]; then
			rm /usr/share/sysmerge/${_j}
		fi
	done

	# do not empty _WRKDIR, it may still contain our backup files
	rm -rf ${_TMPROOT}
	rmdir ${_WRKDIR} 2>/dev/null
	exit 1
}

trap "sm_error; exit 1" 1 2 3 13 15

sm_warn() {
	(($#)) && sm_echo "**** WARNING: $@" || true
}

sm_extract_sets() {
	${PKGMODE} && return
	local _e _x _set

	[[ -f /usr/share/sysmerge/etc.tgz ]] && _e=etc
	[[ -f /usr/share/sysmerge/xetc.tgz ]] && _x=xetc

	for _set in ${_e} ${_x}; do
		[[ -f /usr/share/sysmerge/${_set}sum ]] && \
			cp /usr/share/sysmerge/${_set}sum \
			${_WRKDIR}/${_set}sum
		tar -xzphf \
			/usr/share/sysmerge/${_set}.tgz || \
			sm_error "failed to extract ${_set}.tgz"
	done
}

# get pkg @sample information
exec_espie() {
	local _tmproot

	_tmproot=${_TMPROOT} /usr/bin/perl <<'EOF'
use strict;
use warnings;

package OpenBSD::PackingElement;

sub walk_sample
{
}

package OpenBSD::PackingElement::Sampledir;
sub walk_sample
{
	my $item = shift;
	print "0-DIR", " ",
	      $item->{owner} // "root", " ",
	      $item->{group} // "wheel", " ",
	      $item->{mode} // "0755", " ",
	      $ENV{'_tmproot'}, $item->fullname,
	      "\n";
}

package OpenBSD::PackingElement::Sample;
sub walk_sample
{
	my $item = shift;
	print "1-FILE", " ",
	      $item->{owner} // "root", " ",
	      $item->{group} // "wheel", " ",
	      $item->{mode} // "0644", " ",
	      $item->{copyfrom}->fullname, " ",
	      $ENV{'_tmproot'}, $item->fullname,
	      "\n";
}

package main;
use OpenBSD::PackageInfo;
use OpenBSD::PackingList;

for my $i (installed_packages()) {
	my $plist = OpenBSD::PackingList->from_installation($i);
	$plist->walk_sample();
}
EOF
}

sm_cp_pkg_samples() {
	! ${PKGMODE} && return
	local _install_args _i _ret=0 _sample

	[[ -f /usr/share/sysmerge/pkgsum ]] && \
		cp /usr/share/sysmerge/pkgsum ${_WRKDIR}/pkgsum

	# access to full base system hierarchy is implied in packages
	mtree -qdef /etc/mtree/4.4BSD.dist -U >/dev/null
	mtree -qdef /etc/mtree/BSD.x11.dist -U >/dev/null

	# @sample directories are processed first
	exec_espie | sort -u | while read _i; do
		set -A _sample -- ${_i}
		_install_args="-o ${_sample[1]} -g ${_sample[2]} -m ${_sample[3]}"
		if [[ ${_sample[0]} == "0-DIR" ]]; then
			install -d ${_install_args} ${_sample[4]} || _ret=1
		else
			# directory we want to copy the @sample file into
			# does not exist and is not a @sample so we have no
			# knowledge of the required owner/group/mode
			# (e.g. /var/www/usr/sbin in mail/femail,-chroot)
			_pkghier=${_sample[5]%/*}
			if [[ ! -d ${_pkghier} ]]; then
				sm_warn "skipping ${_sample[5]#${_TMPROOT}}: ${_pkghier#${_TMPROOT}} does not exist"
				continue
			fi
			install ${_install_args} \
				${_sample[4]} ${_sample[5]} || _ret=1
		fi
	done

	if [[ ${_ret} -eq 0 ]]; then
		find . -type f -exec sha256 '{}' + | sort \
			>./usr/share/sysmerge/pkgsum || _ret=1
	fi
	[[ ${_ret} -ne 0 ]] && \
		sm_error "failed to populate packages @samples and create sum file"
}

sm_init() {
	local _auto_upg _c _c1 _c2 _cursum _diff _i _k _j _cfdiff _cffiles
	local _ignorefile _cvsid1 _cvsid2 _matchsum _mismatch

	sm_extract_sets
	sm_add_user_grp
	sm_check_an_eg
	sm_cp_pkg_samples

	for _i in etcsum xetcsum pkgsum; do
		if [[ -f /usr/share/sysmerge/${_i} && \
			-f ./usr/share/sysmerge/${_i} ]] && \
			! ${DIFFMODE}; then
			# redirect stderr; file may not exist
			_matchsum=$(sha256 -c /usr/share/sysmerge/${_i} 2>/dev/null | \
				sed -n 's/^(SHA256) \(.*\): OK$/\1/p')
			# delete file in temproot if it has not changed since
			# last release and is present in current installation
			for _j in ${_matchsum}; do
				# skip sum files
				[[ ${_j} == ./usr/share/sysmerge/${_i} ]] && continue
				[[ -f ${_j#.} && -f ${_j} ]] && \
					rm ${_j}
			done

			# set auto-upgradable files
			_mismatch=$(diff -u ./usr/share/sysmerge/${_i} /usr/share/sysmerge/${_i} | \
				sed -n 's/^+SHA256 (\(.*\)).*/\1/p')
			for _k in ${_mismatch}; do
				# skip sum files
				[[ ${_k} == ./usr/share/sysmerge/${_i} ]] && continue
				# compare CVS Id first so if the file hasn't been modified,
				# it will be deleted from temproot and ignored from comparison;
				# several files are generated from scripts so CVS ID is not a
				# reliable way of detecting changes: leave for a full diff
				if ! ${PKGMODE} && \
					[[ ${_k} != ./etc/@(fbtab|ttys) && \
					! -h ${_k} ]]; then
					_cvsid1=$(sed -n "/[$]OpenBSD:.*Exp [$]/{p;q;}" ${_k#.} 2>/dev/null)
					_cvsid2=$(sed -n "/[$]OpenBSD:.*Exp [$]/{p;q;}" ${_k} 2>/dev/null)
					[[ -n ${_cvsid1} ]] && \
						[[ ${_cvsid1} == ${_cvsid2} ]] && \
						[[ -f ${_k} ]] && rm ${_k} && \
						continue
				fi
				# redirect stderr; file may not exist
				_cursum=$(cd / && sha256 ${_k} 2>/dev/null)
				[[ -n $(grep "${_cursum}" /usr/share/sysmerge/${_i}) && \
					-z $(grep "${_cursum}" ./usr/share/sysmerge/${_i}) ]] && \
					_auto_upg="${_auto_upg} ${_k}"
			done
			[[ -n ${_auto_upg} ]] && set -A AUTO_UPG -- ${_auto_upg}
		fi
		[[ -f ./usr/share/sysmerge/${_i} ]] && \
			mv ./usr/share/sysmerge/${_i} /usr/share/sysmerge/${_i}
	done

	# files we don't want/need to deal with
	_ignorefiles="/etc/group
		      /etc/localtime
		      /etc/mail/aliases.db
		      /etc/master.passwd
		      /etc/motd
		      /etc/passwd
		      /etc/pwd.db
		      /etc/spwd.db
		      /usr/share/sysmerge/etcsum
		      /usr/share/sysmerge/examplessum
		      /usr/share/sysmerge/xetcsum
		      /var/db/locate.database
		      /var/mail/root"
	[[ -f /etc/sysmerge.ignore ]] && \
		_ignorefiles="${_ignorefiles} $(stripcom /etc/sysmerge.ignore)"
	for _i in ${_ignorefiles}; do
		rm -f ./${_i}
	done

	# aliases(5) needs to be handled last in case mailer.conf(5) changes
	_c1=$(find . -type f -or -type l | grep -vE '^./etc/mail/aliases$')
	_c2=$(find . -type f -name aliases)
	for COMPFILE in ${_c1} ${_c2}; do
		IS_BIN=false
		IS_LINK=false
		TARGET=${COMPFILE#.}

		# links need to be treated in a different way
		if [[ -h ${COMPFILE} ]]; then
			IS_LINK=true
			[[ -h ${TARGET} && \
				$(readlink ${COMPFILE}) == $(readlink ${TARGET}) ]] && \
				rm ${COMPFILE} && continue
		elif [[ -f ${TARGET} ]]; then
			# empty files = binaries (to avoid comparison);
			# only process them if they don't exist on the system
			if [[ ! -s ${COMPFILE} ]]; then
				rm ${COMPFILE} && continue
			fi

			_diff=$(diff -q ${TARGET} ${COMPFILE} 2>&1)
			# files are the same: delete
			[[ $? -eq 0 ]] && rm ${COMPFILE} && continue
			# disable sdiff for binaries
			echo "${_diff}" | head -1 | grep -q "Binary files" && \
				IS_BIN=true
		else
			# missing files = binaries (to avoid comparison)
			IS_BIN=true
		fi

		sm_diff_loop
	done
}

sm_install() {
	local _dmode _fgrp _fmode _fown _instdir
	_instdir=${TARGET%/*}

	_dmode=$(stat -f "%OMp%OLp" ./${_instdir}) || return
	eval $(stat -f "_fmode=%OMp%OLp _fown=%Su _fgrp=%Sg" ${COMPFILE}) || return

	if [[ ! -d ${_instdir} ]]; then
		install -d -o root -g wheel -m ${_dmode} "${_instdir}" || return
	fi

	if ${IS_LINK}; then
		_linkt=$(readlink ${COMPFILE})
		(cd ${_instdir} && ln -sf ${_linkt} . && rm ${_TMPROOT}/${COMPFILE})
		return
	fi

	if [[ -f ${TARGET} ]]; then
		mkdir -p ${_BKPDIR}/${_instdir} || return
		cp -p ${TARGET} ${_BKPDIR}/${_instdir} || return
	fi

	if ! install -m ${_fmode} -o ${_fown} -g ${_fgrp} ${COMPFILE} ${_instdir}; then
		rm ${_BKPDIR}/${COMPFILE} && return 1
	fi
	rm ${COMPFILE}

	case "${TARGET}" in
	/etc/login.conf)
		if [[ -f /etc/login.conf.db ]]; then
			sm_echo " (running cap_mkdb(1), needs a relog)"
			sm_warn $(cap_mkdb /etc/login.conf 2>&1)
		else
			sm_echo
		fi
		;;
	/etc/mail/aliases)
		sm_echo " (running newaliases(8))"
		sm_warn $(newaliases 2>&1 >/dev/null)
		;;
	*)
		sm_echo
		;;
	esac
}

sm_add_user_grp() {
	local _g _p _gid _l _u _rest _newgrp _newusr
	local _pw="./etc/master.passwd"
	local _gr="./etc/group"

	${PKGMODE} && return

	while read _l; do
		_u=${_l%%:*}
		if [[ ${_u} != "root" ]]; then
			if [[ -z $(grep -E "^${_u}:" /etc/master.passwd) ]]; then
				sm_echo "===> Adding the ${_u} user"
				chpass -la ${_l} && \
					set -A _newusr -- ${_newusr[@]} ${_u}
			fi
		fi
	done <${_pw}

	while IFS=: read -r -- _g _p _gid _rest; do
		if [[ -z $(grep -E "^${_g}:" /etc/group) ]]; then
			sm_echo "===> Adding the ${_g} group"
			groupadd -g ${_gid} ${_g} && \
				set -A _newgrp -- ${_newgrp[@]} ${_g}
		fi
	done <${_gr}
}

sm_merge_loop() {
	local _instmerged _tomerge
	echo "===> Type h at the sdiff prompt (%) to get usage help\n"
	_tomerge=true
	while ${_tomerge}; do
		cp -p ${COMPFILE} ${COMPFILE}.merged
		sdiff -as -w ${_SWIDTH} -o ${COMPFILE}.merged \
			${TARGET} ${COMPFILE}
		_instmerged=v
		while [[ ${_instmerged} == "v" ]]; do
			echo
			echo "  Use 'e' to edit the merged file"
			echo "  Use 'i' to install the merged file"
			echo "  Use 'n' to view a diff between the merged and new files"
			echo "  Use 'o' to view a diff between the old and merged files"
			echo "  Use 'r' to re-do the merge"
			echo "  Use 'v' to view the merged file"
			echo "  Use 'x' to delete the merged file and go back to previous menu"
			echo "  Default is to leave the temporary file to deal with by hand"
			echo
			echo -n "===> How should I deal with the merged file? [Leave it for later] "
			read _instmerged
			case ${_instmerged} in
			[eE])
				echo "editing merged file...\n"
				${EDITOR} ${COMPFILE}.merged
				_instmerged=v
				;;
			[iI])
				mv ${COMPFILE}.merged ${COMPFILE}
				sm_echo -n "\n===> Merging ${TARGET}"
				sm_install || \
					(echo && sm_warn "problem merging ${TARGET}")
				_tomerge=false
				;;
			[nN])
				(
					echo "comparison between merged and new files:\n"
					diff -u ${COMPFILE}.merged ${COMPFILE}
				) | ${PAGER}
				_instmerged=v
				;;
			[oO])
				(
					echo "comparison between old and merged files:\n"
					diff -u ${TARGET} ${COMPFILE}.merged
				) | ${PAGER}
				_instmerged=v
				;;
			[rR])
				rm ${COMPFILE}.merged
				;;
			[vV])
				${PAGER} ${COMPFILE}.merged
				;;
			[xX])
				rm ${COMPFILE}.merged
				return 1
				;;
			'')
				sm_echo "===> ${COMPFILE} will remain for your consideration"
				_tomerge=false
				;;
			*)
				echo "invalid choice: ${_instmerged}"
				_instmerged=v
				;;
			esac
		done
	done
}

sm_diff_loop() {
	local i _handle _nonexistent _autoinst

	${BATCHMODE} && _handle=todo || _handle=v

	FORCE_UPG=false
	_nonexistent=false
	while [[ ${_handle} == @(v|todo) ]]; do
		if [[ -f ${TARGET} && -f ${COMPFILE} ]] && ! ${IS_LINK}; then
			if ! ${DIFFMODE}; then
				# automatically install files if current != new
				# and current = old
				for i in ${AUTO_UPG[@]}; do \
					[[ ${i} == ${COMPFILE} ]] && FORCE_UPG=true
				done
				# automatically install files which differ
				# only by CVS Id or that are binaries
				if [[ -z $(diff -q -I'[$]OpenBSD:.*$' ${TARGET} ${COMPFILE}) ]] || \
					${FORCE_UPG} || ${IS_BIN}; then
					sm_echo -n "===> Updating ${TARGET}"
					sm_install && \
						_autoinst="${_autoinst}${TARGET}\n" || \
						(echo && sm_warn "problem updating ${TARGET}")
					return
				fi
			fi
			if [[ ${_handle} == "v" ]]; then
				(
					echo "\n========================================================================\n"
					echo "===> Displaying differences between ${COMPFILE} and installed version:"
					echo
					diff -u ${TARGET} ${COMPFILE}
				) | ${PAGER}
				echo
			fi
		else
			# file does not exist on the target system
			if ${IS_LINK}; then
				if ${DIFFMODE}; then
					sm_echo && _nonexistent=true
				else
					sm_echo "===> Linking ${TARGET}"
					sm_install && \
						_autoinst="${_autoinst}${TARGET}\n" || \
						sm_warn "problem creating ${TARGET} link"
					return
				fi
			fi
			if ${DIFFMODE}; then
				sm_echo && _nonexistent=true
			else
				sm_echo -n "===> Installing ${TARGET}"
				sm_install && \
					_autoinst="${_autoinst}${TARGET}\n" || \
					(echo && sm_warn "problem installing ${TARGET}")
				return
			fi
		fi

		if ! ${BATCHMODE}; then
			echo "  Use 'd' to delete the temporary ${COMPFILE}"
			echo "  Use 'i' to install the temporary ${COMPFILE}"
			if ! ${_nonexistent} && ! ${IS_BIN} && \
				! ${IS_LINK}; then
				echo "  Use 'm' to merge the temporary and installed versions"
				echo "  Use 'v' to view the diff results again"
			fi
			echo
			echo "  Default is to leave the temporary file to deal with by hand"
			echo
			echo -n "How should I deal with this? [Leave it for later] "
			read _handle
		else
			unset _handle
		fi

		case ${_handle} in
		[dD])
			rm ${COMPFILE}
			echo "\n===> Deleting ${COMPFILE}"
			;;
		[iI])
			sm_echo
			if ${IS_LINK}; then
				sm_echo "===> Linking ${TARGET}"
				sm_install && \
					MERGED_FILES="${MERGED_FILES}${TARGET}\n" || \
					sm_warn "problem creating ${TARGET} link"
			else
				sm_echo -n "===> Updating ${TARGET}"
				sm_install && \
					MERGED_FILES="${MERGED_FILES}${TARGET}\n" || \
					(echo && sm_warn "problem updating ${TARGET}")
			fi
			;;
		[mM])
			if ! ${_nonexistent} && ! ${IS_BIN} && ! ${IS_LINK}; then
				sm_merge_loop && \
					MERGED_FILES="${MERGED_FILES}${TARGET}\n" || \
						_handle="todo"
			else
				echo "invalid choice: ${_handle}\n"
				_handle="todo"
			fi
			;;
		[vV])
			if ! ${_nonexistent} && ! ${IS_BIN} && ! ${IS_LINK}; then
				_handle="v"
			else
				echo "invalid choice: ${_handle}\n"
				_handle="todo"
			fi
			;;
		'')
			sm_echo "===> ${COMPFILE} will remain for your consideration"
			;;
		*)
			echo "invalid choice: ${_handle}\n"
			_handle="todo"
			continue
			;;
		esac
	done
}


sm_check_an_eg() {
	${PKGMODE} && return
	local _egmods _i _managed

	if [[ -f /usr/share/sysmerge/examplessum ]]; then
		cp /usr/share/sysmerge/examplessum ${_WRKDIR}/examplessum
		_egmods=$(cd / && \
			 sha256 -c /usr/share/sysmerge/examplessum 2>/dev/null | \
			 sed -n 's/^(SHA256) \(.*\): FAILED$/\1/p')
	fi
	for _i in ${_egmods}; do
		_i=${_i##*/}
		[[ -f /etc/${_i} ]] && \
			_managed="${_managed:+${_managed} }${_i}"
	done
	# only warn for files we care about
	[[ -n ${_managed} ]] && sm_warn "example(s) changed for: ${_managed}"
	mv ./usr/share/sysmerge/examplessum \
		/usr/share/sysmerge/examplessum
}

sm_post() {
	local _i _files_in_tmproot _files_in_bkpdir

	_files_in_tmproot=$(find . -type f ! -name \*.merged -size +0)
	[[ -d ${_BKPDIR} ]] && \
		_files_in_bkpdir=$(find ${_BKPDIR} -type f -size +0)

	[[ -n ${_files_in_tmproot} ]] && \
		sm_warn "some files are still left for comparison"

	mtree -qdef /etc/mtree/4.4BSD.dist -p / -U >/dev/null
	[[ -d /etc/X11 ]] && \
		mtree -qdef /etc/mtree/BSD.x11.dist -p / -U >/dev/null

	if [[ -e ${_WRKDIR}/sysmerge.log ]]; then
		rm ${_WRKDIR}/*sum
		sed '/^$/d' ${_WRKDIR}/sysmerge.log >${_WRKDIR}/sysmerge.log.bak
		mv ${_WRKDIR}/sysmerge.log.bak ${_WRKDIR}/sysmerge.log
		cd ${_WRKDIR} && \
			find . -type d -depth -empty -exec rmdir -p '{}' + 2>/dev/null
		echo "===> Log available at ${_WRKDIR}/sysmerge.log"
	else
		rm -rf ${_WRKDIR}
	fi
}

BATCHMODE=false
DIFFMODE=false
PKGMODE=false

while getopts bdp arg; do
	case ${arg} in
	b)	BATCHMODE=true;;
	d)	DIFFMODE=true;;
	p)	PKGMODE=true;;
	*)	usage;;
	esac
done
shift $(( OPTIND -1 ))
[[ $# -ne 0 ]] && usage

[[ $(id -u) -ne 0 ]] && echo "${0##*/}: need root privileges" && usage

# global constants
_WRKDIR=$(mktemp -d -p ${TMPDIR:=/var/tmp} sysmerge.XXXXXXXXXX) || exit 1
_BKPDIR=${_WRKDIR}/backups
_TMPROOT=${_WRKDIR}/temproot
_SWIDTH=$(stty size | awk '{w=$2} END {if (w==0) {w=80} print w}')
_RELINT=$(uname -r | tr -d '.')
readonly _WRKDIR _BKPDIR _TMPROOT _SWIDTH _RELINT

[[ -z ${VISUAL} ]] && EDITOR=${EDITOR:=/usr/bin/vi} || EDITOR=${VISUAL}
PAGER=${PAGER:=/usr/bin/more}

mkdir -p ${_TMPROOT} || sm_error "cannot create ${_TMPROOT}"
cd ${_TMPROOT} || sm_error "cannot enter ${_TMPROOT}"

sm_init
sm_post