#!/bin/sh
# arctic-shell - an ephemeral, reproducible package environment
#
#   arctic-shell [--network] [--keep] <pkg>...
#   arctic-shell [--network] [--keep] <pkg>... -- <cmd>...
#   arctic-shell [--network] [--keep] <pkg>... -  <cmd>...
#
# Builds a scratch root with exactly the named packages installed (plus the
# same minimal glibc/busybox/toybox base every Arctic root needs to have a
# working shell at all), then chroots into it with the current directory
# available at the same path, so local files are right there.
#
#   arctic-shell clang                      drop into an interactive shell
#   arctic-shell clang -- clang file.c      run it, then tear the env down
#   arctic-shell clang -  clang file.c      run it, stay in the shell after
#   arctic-shell --keep rust cargo           reused next time the exact same
#                                            package set is asked for again
#
# --network gives the environment a real network connection; without it, the
# environment cannot reach the network at all (a separate network namespace,
# same as `unshare --net` on its own gives you). --keep leaves the built root
# on disk afterward instead of deleting it - `arctic gc` is what cleans up
# anything left over that has gone unused for a while.
#
# The prompt inside looks like: [arctic-shell@user|dir=/home/projects]
# shellcheck shell=sh disable=SC2039

set -u
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

need_root() { [ "$(id -u)" = "0" ] || { echo "Must be run as root." >&2; exit 1; }; }

if [ -t 1 ]; then
	R=$(printf '\033[0m'); TEAL=$(printf '\033[38;5;44m'); RED=$(printf '\033[38;5;203m')
else
	R= ; TEAL= ; RED=
fi
note() { printf '%s::%s %s\n' "$TEAL" "$R" "$*"; }
die()  { printf '%serror:%s %s\n' "$RED" "$R" "$*" >&2; exit 1; }

case "${1:-}" in
-h|--help|help) sed -n '2,25p' "$0" | sed 's/^# \?//'; exit 0 ;;
esac
need_root

NETWORK=0
KEEP=0
pkgs=""
mode=""    # "" | "exec" (--) | "keepopen" (-)
cmd=""

while [ $# -gt 0 ]; do
	case "$1" in
	--network) NETWORK=1; shift ;;
	--keep)    KEEP=1; shift ;;
	--)        shift; mode=exec; break ;;
	-)         shift; mode=keepopen; break ;;
	*)         pkgs="$pkgs $1"; shift ;;
	esac
done
if [ -n "${mode:-}" ]; then
	for a in "$@"; do
		cmd="$cmd $(printf '%s' "$a" | sed "s/'/'\\\\''/g" | sed "1s/^/'/;\$s/\$/'/")"
	done
fi

pkgs=$(printf '%s\n' $pkgs | sort -u | tr '\n' ' ')
pkgs=${pkgs% }
[ -n "$pkgs" ] || die "usage: arctic-shell [--network] [--keep] <pkg>... [-- cmd | - cmd]"

hash=$(printf '%s' "$pkgs" | { sha256sum 2>/dev/null || cksum; } | cut -d' ' -f1 | cut -c1-16)
BASE=${ARCTIC_SHELL_BASE:-/var/cache/arctic-shell}
ENVROOT="$BASE/$hash"
mkdir -p "$BASE"

note "environment $hash ($pkgs)"
if [ ! -d "$ENVROOT/usr" ]; then
	note "building it - this only happens once per package set"
	mkdir -p "$ENVROOT/var/lib/alpm/sync"
	# ALPM_DB follows ALPM_ROOT, so a scratch root starts with no sync index
	# of its own - copy the one this session already has, same as
	# arctic-strap does before its first target_alpm call. ALPM_CONF/
	# ALPM_REPOD stay host-absolute regardless (see libalpm.sh), so the repo
	# definitions themselves do not need copying in, only the index.
	cp -a /var/lib/alpm/sync/. "$ENVROOT/var/lib/alpm/sync/" 2>/dev/null || :
	# Arctic is merged-/usr - /bin, /sbin, /lib are symlinks into usr/, and
	# /lib64 -> usr/lib (not usr/lib64 - glibc has no separate lib64 here,
	# but the host gcc used for every native build still bakes in
	# /lib64/ld-linux-x86-64.so.2 as the interpreter regardless). A plain
	# package install has nowhere to create these; arctic-strap sets them up
	# by hand for the same reason before its first install.
	mkdir -p "$ENVROOT/usr/bin" "$ENVROOT/usr/sbin" "$ENVROOT/usr/lib"
	for l in bin sbin lib; do
		[ -e "$ENVROOT/$l" ] || ln -s "usr/$l" "$ENVROOT/$l"
	done
	[ -e "$ENVROOT/lib64" ] || ln -s usr/lib "$ENVROOT/lib64"
	# shellcheck disable=SC2086
	ALPM_ROOT="$ENVROOT" ALPM_YES=1 alpm ins glibc busybox toybox $pkgs \
		|| die "could not build the environment - see above"
	printf '%s\n' "$pkgs" >"$ENVROOT/.arctic-shell-pkgs"
else
	note "reusing the environment already built for this package set"
fi
date '+%s' >"$ENVROOT/.arctic-shell-last-used"
# --keep is sticky once set: a later invocation that forgets to pass it
# again must not silently delete an environment a previous run asked to
# keep. Only --keep itself ever writes the marker; nothing un-sets it here.
[ "$KEEP" = 1 ] && : >"$ENVROOT/.arctic-shell-keep"

# Bring the current directory along at the same path, so relative paths and
# $PWD inside the environment still mean what they meant outside it.
HERE=$PWD
mkdir -p "$ENVROOT$HERE"
mount --bind "$HERE" "$ENVROOT$HERE" 2>/dev/null

mkdir -p "$ENVROOT/dev" "$ENVROOT/proc" "$ENVROOT/sys" "$ENVROOT/etc"
mount --bind /dev  "$ENVROOT/dev"  2>/dev/null
mount -t proc proc "$ENVROOT/proc" 2>/dev/null
mount -t sysfs sys  "$ENVROOT/sys" 2>/dev/null
[ -f /etc/resolv.conf ] && cp -f /etc/resolv.conf "$ENVROOT/etc/resolv.conf" 2>/dev/null

# toybox umount has no -R (that is a util-linux flag) - unmount each bind by
# name and actually verify it let go before ever touching rm -rf. A tree
# with a live mount still under it must never be rm -rf'd: that is exactly
# how a chroot test once emptied this host's real /dev.
still_mounted() { awk -v d="$1" '$2==d{f=1} END{exit !f}' /proc/mounts; }

cleanup() {
	for m in "$ENVROOT$HERE" "$ENVROOT/dev" "$ENVROOT/proc" "$ENVROOT/sys"; do
		still_mounted "$m" || continue
		umount "$m" 2>/dev/null || umount -l "$m" 2>/dev/null
	done
	for m in "$ENVROOT$HERE" "$ENVROOT/dev" "$ENVROOT/proc" "$ENVROOT/sys"; do
		if still_mounted "$m"; then
			printf '  %sstill mounted, leaving in place: %s%s\n' "$RED" "$m" "$R" >&2
			return 1
		fi
	done
	if [ -f "$ENVROOT/.arctic-shell-keep" ]; then
		note "kept for next time: $ENVROOT"
	else
		rm -rf "$ENVROOT"
	fi
}
trap cleanup EXIT INT TERM

SH=/bin/sh
[ -x "$ENVROOT/bin/zsh" ] && SH=/bin/zsh

PS1_TAG="arctic-shell@$(id -un)|dir=$HERE"
inner="export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; export PS1='[$PS1_TAG]\$ '; export HOME=/root; cd '$HERE' 2>/dev/null || cd /"

run_in() {
	if [ "$NETWORK" = 1 ]; then
		chroot "$ENVROOT" "$SH" -c "$*"
	else
		unshare -n chroot "$ENVROOT" "$SH" -c "$*"
	fi
}

case "$mode" in
exec)
	run_in "$inner; $cmd"
	;;
keepopen)
	run_in "$inner; $cmd; exec $SH -i"
	;;
*)
	run_in "$inner; exec $SH -i"
	;;
esac
