#!/bin/bash

VERSION=0.9.9

# TODO: distcc masquerade dir, pump mode
# TODO: fix interactive shell option
# TODO: maybe change the cpufreq governor?

# Configurables:

TMP=${TMP:-/tmp/SBo}
OUTPUT=${OUTPUT:-/tmp}
BUILDLOG=${BUILDLOG:-build.log}
DEFAULT_MAKEFLAGS="-j$(( $( nproc ) + 1 ))"

# End of configurables. It's probably best not to configure TMP or
# OUTPUT here (use the environment instead) anyway. Also it's probably
# convenient to add build.log to .git/info/exclude.

# If we're not running as root, re-exec as root, with args.
# Anything sbrun expects to possibly inherit from the caller's environment
# must be explicity set here as sudo will strip them from the environment
# before executing anything.
if [ "$(id -u)" != "0" ]; then
	exec sudo \
		TMP="$TMP" \
		OUTPUT="$OUTPUT" \
		MAKEFLAGS="$MAKEFLAGS" \
		BUILDLOG="$BUILDLOG" \
		DISTCC_HOSTS="$DISTCC_HOSTS" \
		SBODL_CACHEDIR="${SBODL_CACHEDIR:-$HOME/sbodl-cache}" \
		"$0" "$@"
fi

# This is a bit of a hack: I keep my tools in my user's ~/bin,
# and sourcing /etc/profile blows away PATH...
OLDPATH=$PATH
source /etc/profile
PATH=$OLDPATH:$PATH

[ -e "$BUILDLOG" ] && mv "$BUILDLOG" "$BUILDLOG".old

# Inherit MAKEFLAGS from env, if present.
MAKEFLAGS="${MAKEFLAGS:-$DEFAULT_MAKEFLAGS}"

# Defaults, changed by -options.
NETWORK="no"
STRACE=""
CLEANUP="no"
SRCSH="no"
PKGSH="no"
LOGDIR=""
NSENTER=""
TRACKFS=""

SELF=$(basename $0)

# unshare and nsenter use this. It's theoretically better to use
# an unpredictable filename (not one based on the PID), but anyone
# able to mess with /mnt already has root access.
NONET_PATH=/mnt/nonet.$SELF.$$

long_help() {
	exec perldoc "$0"
}

show_help() {
	# don't use warn here, log isn't open yet.
	if [ -n "$1" ]; then
		echo "$SELF: unknown option '$1'" 2>&1
	fi
	cat <<EOF
$SELF: paranoid SlackBuild wrapper.

$SELF written by B. Watson (urchlay@slackware.uk) and released
under the WTFPL. See http://www.wtfpl.net/txt/copying/ for details.

Usage: $SELF [-option [-option ...]] [script] [variable=value ...]

-c     Clean up (remove) source and package dirs after build completes.
-d     Download sources with 'sbodl'.
-D     Use distcc (enables -n, sets CC/CXX; set DISTCC_HOSTS yourself).
-I     Run an interactive shell in the source directory.
-i     Install built package with 'upkg'.
-jN    Run N make jobs in parallel.
-l     Lint the package with 'sbopkglint'.
-n     Allow the SlackBuild to access the network.
-p     Run an interactive shell in the \$PKG directory.
-Q     Build and install all deps first.
-q     Build and install deps first (skip installed deps).
-s     Run the script with strace -f, output in "strace.out".
-x     Run the script with "sh -x", enables shell command tracing.
-h, --help
       Show short usage message (you're reading it now) and exit.
-H, --long-help
       Show long help message and exit.
--man  Format the long help message as a man page, on stdout.
--version
       Print version number and exit.
script
     Run this script instead of the default .SlackBuild script.
variable=value ...
     Passed to script as environment variables.
EOF
}

# maybe add 2>/dev/null to these, but for now I wanna know if they fail.
cleanup_nonet() {
	if [ "$NETWORK" = "no" ]; then
		umount $NONET_PATH
		rm -f $NONET_PATH
	fi
}

cleanup_log() {
	[ -n "$LOGDIR" ] && rm -rf "$LOGDIR"
}

cleanup_privdir() {
	[ "$PRIVDIR" = "" ] && return
	umount $FAKEROOT/$OUTPUT
	umount $FAKEROOT/$TMP
	umount $FAKEROOT
	if [ "$?" != "0" ]; then
  		cat <<EOF | tee -a $BUILDLOG

********************
*
* \$FAKEROOT $FAKEROOT still mounted!
*
********************

Can't continue. This is likely a bug in sbrun, please contact
its author: urchlay@slackware.uk
EOF
		exit 1
	fi
	# We know this isn't still mounted because we would have
	# died with a "Can't continue" error, if it were.
	rm -rf $PRIVDIR
}

cleanup_build() {
	[ "$CLEANUP" = "yes" ] && ( rm -rf "$TMP" )
}

# Add a dir to $PATH, if not already present. This is actually kinda
# pointless, it would work just as well to always add dirs to PATH
# even if they're redundant.
ensure_path() {
	if ! echo "$PATH" | sed 's,:,\n,g' | grep -q "^$1\$"; then
		#echo "$1 not in PATH, adding"
		export PATH="$1:$PATH"
	fi
}

# Handle ^C gracefully. TODO: the exit status should be 128 plus
# the number of the signal received. The hard-coded 130 means SIGINT,
# the ^C signal, but we trap other signals too. Maybe use:
# http://stackoverflow.com/questions/2175647/is-it-possible-to-detect-which-trap-signal-in-bash
signal_handler() {
	cleanup_log
	cleanup_nonet
	cleanup_privdir
	cleanup_build
	exit 130
}

# Print a number of seconds as either MM:SS (if less than 1 hour)
# or HH:MM:SS (if >= 1 hour). This function could almost be replaced
# by:  TZ=GMT printf '%(%H:%M:%S)T\n' "$1"
# ...except print_hms doesn't display the hours if they're 00, and using
# printf that way won't handle durations longer than 23:59:59 because
# it's trying to print a time of day (24:00:00 would be 00:00:00 of the
# next day). Hopefully no SlackBuild takes over a day to run, but you
# never know...
print_hms() {
	local sec="$1" hrs min

	hrs=$(( $sec / 3600 ))
	sec=$(( $sec % 3600 ))

	min=$(( $sec / 60 ))
	sec=$(( $sec % 60 ))

	if [ "$hrs" -gt "0" ]; then
		printf '%02d:%02d:%02d\n' $hrs $min $sec
	else
		printf '%02d:%02d\n' $min $sec
	fi
}

# perl-flavoured error messenger
warn() {
	echo "$SELF:" "$@" 1>&2
	echo "$SELF:" "$@" >> $BUILDLOG
}

# Suicide squad, attack!
die() {
	warn "$@"
	exit 1
}

# -q and -Q
run_queue() {
	exec sbodeps $1 . | sbqrun -
}

### main()

# if these are in $PATH, 99.99% of all SBo builds will run
# correctly under sudo. Or maybe even 100%. At least, I can't
# remember running into problems, for quite a few years now.
ensure_path /sbin
ensure_path /usr/sbin
ensure_path /usr/share/texmf/bin

# we aren't using real --long-options, just these special cases:
if [ "$1" = "--help" ]; then
  show_help ; exit 0
elif [ "$1" = "--long-help" ]; then
  long_help ; exit 0
elif [ "$1" = "--man" ]; then
  exec pod2man --stderr -s1 -csbo-maintainer-tools -r$VERSION $0 ; exit 0
elif [ "$1" = "--version" ]; then
  echo $VERSION ; exit 0
fi

# save original args, as we're going to permute them, below
CMD="$0 $*"

# parse -options
OPTS="$( getopt -n $SELF -olj:nsxcIpDidqQhH -- "$@" )"
if [ "$?" != "0" ]; then
  show_help
  exit 1
fi

eval set -- "$OPTS"
while true; do
	case "$1" in
		-l)               LINTPKG="yes"           ;;
		-j)               shift; MAKEFLAGS="$1"   ;;
		-n)               NETWORK=yes             ;;
		-s)               STRACE=-f               ;;
		-x)               X="-x"                  ;;
		-c)               CLEANUP="yes"           ;;
		-I)               SRCSH="yes"             ;;
		-p)               PKGSH="yes"             ;;
		-D)               CC="distcc gcc"
		                  CXX="distcc g++"
		                  NETWORK=yes
		                  export CC CXX           ;;
		-i)               UPKG=yes                ;;
		-d)               SBODL=yes               ;;
		-q)               run_queue               ;;
		-Q)               run_queue -i            ;;
		-h)               show_help ; exit 0      ;;
		-H)               long_help ; exit 0      ;;
		--)               shift ; break           ;;
		*)                show_help "$1"; exit 1  ;; # never happens?
	esac
	shift
done

[ "$SBODL" = "yes" ] && sbodl

# warn and die append to the log, make sure it starts out empty.
# This is the only place we use tee $BUILDLOG (everything else appends).
{
echo -n "== $SELF starting up at "
date
echo -n "== directory: "
pwd
echo "== command: $CMD"
echo
} | tee $BUILDLOG

# set the build log's ownership to the calling user, or at least the
# user that owns the current directory.
chown "$( stat -c %U:%G . )" $BUILDLOG

# rest of arg parsing can use warn or die.
if echo "$1" | grep -qv '='; then
	SCRIPT="$1"
	shift
fi

# $ENV is only for showing to the user
ENV="MAKEFLAGS=$MAKEFLAGS"
export MAKEFLAGS TMP OUTPUT

# Add rest of args to environment. The echo|cut and eval stuff allows
# spaces to occur in the values. There is probably a better modern-bash
# way to do this, but (to me anyway) it'll be less readable.
for arg; do
	if echo "$arg" | grep -qv '='; then
		die "invalid/unknown argument '$1', try -h for help or -H for long help."
	else
		ENV="$ENV $arg"
		#eval export "$arg" # works but doesn't allow spaces
		var="$( echo "$arg" | cut -d= -f1 )"
		val="$( echo "$arg" | cut -d= -f2 )"
		eval "export $var='$val'"
	fi
done

# The easy way to remove the source and PKG dirs after the
# script runs is to guarantee they'll be the only things in
# $TMP. Normally, we don't create the $TMP dir, so we can
# catch 'script fails to create $TMP dir' errors. But with -c,
# we don't care about troubleshooting so much, and mktemp is
# the way to go.
if [ "$CLEANUP" = "yes" ]; then
	TMP="$( mktemp -d /tmp/sbrun.build.XXXXXX )"
	if [ -z "$TMP" ] || [ ! -d "$TMP" ]; then
		die "Can't create temp build dir in /tmp, bailing"
	fi
fi

# I wasn't gonna trap signals, but I can't break myself of the habit
# of hitting ^C.
# TODO: we should be trapping more signals here...
trap signal_handler INT TERM

# Used to do this, but it doesn't allow for cases where the directory
# has been renamed (foo.SlackBuild in a dir called foo.testing or foo.old).
#SCRIPT="./$( pwd | sed 's,.*/,,' ).SlackBuild"

# This is better, but during development, a user might have copies of the
# script named foo.old.SlackBuild and foo.new.SlackBuild, so not perfect.
# To allow for this, we now take an optional script name on the command line.
SCRIPT="${SCRIPT:-$( /bin/ls ./*.SlackBuild | head -1 )}"

if [ ! -e "$SCRIPT" ]; then
	die "$SCRIPT not found, bailing"
fi

{
	echo "Running $SCRIPT, logging to $BUILDLOG"
	echo "Environment:    $ENV"
	echo "Network access: $NETWORK"
	if [ "$STRACE" != "" ]; then
		echo "strace log:     strace.out"
	fi
	echo
} | tee -a $BUILDLOG

# Set up no-network namespace. This isn't foolproof, there are probably
# ways for a script being run by root to escape the namespace, but
# a script that did that would hopefully never get approved by our
# beloved moderators. Even though it's "no net", lots of stuff needs
# the loopback interface, and it doesn't hurt anything.
if [ "$NETWORK" = "no" ]; then
	touch $NONET_PATH
	unshare --net=$NONET_PATH ifconfig lo 127.0.0.1 up
	NSENTER="nsenter --net=$NONET_PATH"
fi

START_TIME="$( date +%s )"

### 20260923 bkw: mount the overlay here!
PRIVDIR="$( mktemp -td sbrun.priv.XXXXXXXXXX )"
UPPERDIR=$PRIVDIR/upperdir
WORKDIR=$PRIVDIR/workdir
FAKEROOT=$PRIVDIR/fake_root
mkdir -p $UPPERDIR $WORKDIR $FAKEROOT
echo "Private dir for overlay FS: $PRIVDIR" | tee -a $BUILDLOG

if [ "$STRACE" != "" ]; then
	PRECMD="strace -o $PRIVDIR/strace.out $STRACE"
fi

# Force-load the module (Slackware >= 15.0 ships the module at least)
modprobe overlay &> /dev/null
mount -t overlay overlay \
  -olowerdir=/,upperdir=$UPPERDIR,workdir=$WORKDIR \
  $FAKEROOT
# writes to $FAKEROOT/$TMP will pass through to the real $TMP
mkdir -p $TMP $OUTPUT # these have to exist to be mounted...
mount --bind $TMP $FAKEROOT/$TMP
mount --bind $OUTPUT $FAKEROOT/$OUTPUT

echo cd "'$( pwd )'" > $PRIVDIR/runme
echo $PRECMD bash $X $SCRIPT >> $PRIVDIR/runme

# Actually run the script.
(
  $NSENTER chroot $FAKEROOT bash $PRIVDIR/runme 2>&1; echo "$?" > $PRIVDIR/ret ) | tee -a $BUILDLOG
RET="$( cat $PRIVDIR/ret )"

END_TIME="$( date +%s )"

echo "$SCRIPT exit status: $RET" | tee -a $BUILDLOG

{
	echo -n "Elapsed time: "
	print_hms $(( $END_TIME - $START_TIME ))
} | tee -a $BUILDLOG

cleanup_nonet

# This file contains a list of files that were written during
# the build (and are left over in the upperdir).
TURDS=$TMP/sbrun.turds.$$

### 20260923 bkw:
# Anything in $UPPERDIR after umount was written there by the
# SlackBuild. Not everything there is worth bitching about.
# After overlay umount, exclude these dirs from complaints:
# /tmp /proc /var/tmp /root/.ccache /root/.cache /dev/pts /dev/shm
# also $TMP and $OUTPUT.
# For some reason, "root" gets created, too.
# Have to specify each dir by itself *without* trailing slash,
# then again with \/* to catch files/dirs under that dir.
( cd $PRIVDIR/upperdir
  find * \
    \! -path root \
    \! -path root/.cache/\* \
    \! -path root/.ccache/\* \
    \! -path tmp/\* \
    \! -path var/tmp/\* \
    \! -path proc/\* \
    \! -path dev/pts/\* \
    \! -path dev/shm/\* \
    \! -path $TMP/\* \
    \! -path $OUTPUT/\* \
    \! -path root/.cache \
    \! -path root/.ccache \
    \! -path tmp \
    \! -path var/tmp \
    \! -path proc \
    \! -path dev/pts \
    \! -path dev/shm \
    \! -path $TMP \
    \! -path $OUTPUT \
	 -print0 | xargs -r0 ls -bld > $TURDS
) 2>/dev/null

if [ -f $PRIVDIR/strace.out ]; then
	mv $PRIVDIR/strace.out .
	chown --reference=$SCRIPT strace.out
fi

cleanup_privdir

if [ -s $TURDS ]; then
	warn "WARNING: files altered outside the sandbox:"
	tee -a $BUILDLOG < $TURDS
fi
rm -f $TURDS

# If linting + installation were both requested, don't install
# the package if it fails to lint.
if [ "$LINTPKG" = "yes" ] && sbopkglint; then
	RET=$?
else
	UPKG=""
fi

# spawn shell(s) if requested. -I and -p are not mutually exclusive.

# TODO: do this cleaner?
if [ "$SRCSH" = "yes" ]; then
	if [ "$RET" != "0" ]; then
		warn "Script failed (status $RET), ignoring -I option"
	else
		SRCDIR="$( /bin/ls -td $TMP/*/ | grep -v /package- | head -1 )"
		( cd $SRCDIR && bash -login )
	fi
fi

# PRGNAM is problematic. We don't want to use $( basename $( pwd ) )
# because the directory might have been renamed (foo => foo.testing or
# foo.old). Reading the .info file is no good (this might not be an SBo
# build). For now, extract it from the script name, but eventually this
# won't work because I want to support passing a script name someday
# instead of hard-coding .SlackBuild.
if [ "$PKGSH" = "yes" ]; then
	PRGNAM="$( echo $SCRIPT | sed 's,^\./\(.*\)\.SlackBuild$,\1,' )"
	PKG=$TMP/package-$PRGNAM
	if [ -d "$PKG" ]; then
		( cd $PKG && bash -login )
	else
		warn "$PKG not found, ignoring -p option"
	fi
fi

cleanup_build

# Install the package if -i.
[ "$RET" = "0" ] && [ "$UPKG" = "yes" ] && upkg

# Our return status is that of the SlackBuild.
exit $RET

#### rest of this file is POD
: <<EOF

=pod

=head1 NAME

sbrun - paranoid SlackBuild wrapper

=head1 SYNOPSIS

B<sbrun> [-options ...] [script] [variable=value ...]

=head1 DESCRIPTION

B<sbrun> runs the SlackBuild script in the current directory,
with an optional custom environment.

By default, the SlackBuild can't access the network, and filesystem
activity is tracked: writes to system directories are flagged and
reported. Also, a complete log of the build's standard output
and standard error is written to "$BUILDLOG".

If B<sbrun> is called as a non-root user, it re-executes itself via
sudo. If you hate sudo, just run B<sbrun> as root.

B<sbrun> is designed for use with SBo scripts, but will work for any
SlackBuild (it doesn't refer to the SBo .info file).

=head1 OPTIONS

Options may be bundled (-tn is the same as -t -n), and mixed
freely with non-option arguments.

=over 4

=item B<-l>

Lint the package after it's built, with B<sbopkglint>(1). When
combined with B<-i>, the package will only be installed if it lints
successfully.

=item B<-j> I<N>

Run I<N> make jobs in parallel. Default is to use MAKEFLAGS from
the environment if set, otherwise the default is the number of
cores (as reported by B<nproc>(1)) plus one.

If a SlackBuild fails without B<-j1>, this is a bug in the SlackBuild
and you should add B<-j1> to the B<make> command in the script, or ask
its maintainer to add it.

=item B<-n>

Allow the SlackBuild to access the network. If a SlackBuild
fails without this flag, that's a bug in the SlackBuild and
should be fixed or reported to its maintainer (EMAIL in the .info file).

That said, there are a few scripts in the SBo repo that are
"grandfathered in" from before the no-net rule was made...

=item B<-s>

Run the script with "strace -f". The strace log will be written to the
current directory as "strace.out".

=item B<-x>

Run the script with "sh -x", enables shell command tracing.

=item B<-I>

Run an interactive shell in the source directory, after the script
completes *successfully* (nothing happens if it fails). Useful for
development, e.g. place 'exit 0' in the script wherever you need to
examine the state of the source directory. May not work as expected
if the SlackBuild creates multiple directories under $TMP, or if
multiple SlackBuilds are being run simultaneously.

=item B<-p>

Run an interactive shell in the $PKG directory, after the script
completes (successfully or otherwise, provided the directory
exists). Useful for development.

=item B<-c>

Clean up (remove) source and package directories after the
build completes. This option overrides $TMP from the environment.

=item B<-D>

Use distcc for the compile. You still have to set DISTCC_HOSTS in the
environment, or in one of distcc's config files. This option sets
CC and CXX, and allows network access.

=item B<-i>

Install the package after building it. This just runs "upkg" in the
SlackBuild directory, so "sbrun -i" is just a shortcut for typing
"sbrun && upkg". No package will be installed if the build script
fails.

=item B<-d>

Download the source before building the package. This just runs "sbodl"
in the SlackBuild directory. Note that this is annoying, if you use
sbrun's sudo support: the file ends up owned by root, not the user
you ran sbrun as.

=item B<-q>

Build and install dependencies, if they're not already installed. -q is
short for "queue", as this is a shortcut for: sbodeps | sbqrun -

=item B<-Q>

Build and install all dependencies (even if they're already installed).
Shortcut for: sbodeps -i | sbqrun -

=item B<-h>, B<--help>

Show short usage message and exit.

=item B<-H>, B<--long-help>

Show long help message (this documentation you're reading) and exit.

=item B<--man>

Format long help message as a man page, on standard output. Typical
use:

    sbrun --man > sbrun.1

=item B<[script]>

Run this script instead of the .SlackBuild script. Useful for
development, I hope. Useful also for Slack-derived distros that
use something other than .SlackBuild for their script names.

=item B<[variable=value ...]>

All arguments containing an = (and not beginning with -) are passed as
part of the SlackBuild script's environment. Options beginning with -
must occur before environment variables. Example:

     sbrun -j1 SDL2=no DOCS=yes

Leave off the -j1 to use the default number of jobs.

=back

=head1 EXIT STATUS

The exit status of sbrun is the exit status of the SlackBuild,
unless the B<-l> option is used. With B<-l>, the exit status is that
of sbopkglint.

=head1 FILE TRACKING

After the SlackBuild exits, any files written to outside of $TMP,
$OUTPUT, /tmp, /var/tmp, /root/.ccache, or /root/.cache
(collectively referred to as "the sandbox") are logged. Any
write outside the sandbox means a bug in the SlackBuild and should be
fixed or reported to its maintainer.

=head1 NOTES

=head2 Sequence of events

1. If not running as root, re-execute with the same arguments via sudo(8).

2. If -d was used, run sbodl(1).

3. Initialize no-net namespace (unless -n option was used).

4. Initialize file tracking (mount overlay filesystems)

5. Run the SlackBuild script.

6. Unmount the overlay filesystems and check for writes outside the sandbox.

7. Disable and clean up the no-net namespace (if used).

8. If the script failed, exit now.

9. If the -l option was used, call sbopkglint.

10. If the -I option was used, launch an interactive shell in the source directory (e.g. $TMP/whatever-1.2.3/). Wait for the shell to exit.

11. If the -p option was used, launch an interactive shell in the $PKG directory, and wait for it to exit.

12. If the -c option was used, remove the source and package directories.

13. If the -i option was used, install the package (unless -l was used and sbopkglint failed).

14. Exit.

=head2 /etc/sudoers

If you're going to run sbrun as a non-root user, you'll have to
add the user to /etc/sudoers. For my 'urchlay' user, I use:

   urchlay ALL=(ALL:ALL) ALL

...which, yes, is considered horribly insecure. However, development
systems shouldn't also be used for any kind of production use, and
you shouldn't be building SlackBuilds on a secure production host.

=head2 Why use this?

Why does sbrun exist? Why not use sbopkg, sbotools, or slackrepo? One
reason is that sbrun tracks writes to the host filesystem. Another
is that the other tools are cumbersome to use during the
edit/test/rewrite cycle (you have to commit every tiny change to git,
push it, then let the tool update its git repo). The main use case is,
edit the script in one terminal, and run sbrun repeatedly in another,
after each change.

Since it's *not* intended to replace sbotools or sbopkg, sbrun doesn't
do any of these things:

- download source files (though it will call 'sbodl' with the -d option).

- check source file md5sums.

- allow building multiple packages at once (queue files), though it
will call 'sbqrun' with the -q/-Q options (and sbqrun calls sbrun
for each dependency).

- install/upgrade/remove packages (it *just* builds them, though it will
call 'upkg' with the -i option).

- sync the repo, or even have any concept of a repo (it only deals
with a single SlackBuild script, in the current directory).

- anything to do with .info files. Nothing about sbrun is SBo-specific,
it'll work with Pat's or AlienBOB's or anyone else's scripts (which
is why it's called sbrun and not sborun).

=head2 git integration

Finally, a helpful hint: If you use git to push to SBo, you can't
add anything to .gitignore since it's tracked by git. But you can
use .git/info/exclude for the same purpose. Add build.log* and
strace.out there, to avoid git complaining about them being untracked.

=head1 COPYRIGHT

sbrun written by B. Watson (urchlay@slackware.uk) and released
under the WTFPL. See http://www.wtfpl.net/txt/copying/ for details.

=head1 SEE ALSO

B<sbopkglint>(1), B<sbodl>(1), B<sbofixinfo>(1), B<sbopkg>(8), B<sboinstall>(1)

=cut

EOF
