#!/bin/sh

## BUILD-TIME SUBSTITUTIONS
### <bin>_FALLBACK: if `<bin>` isn't on PATH, then use this instead
BWRAP_FALLBACK='@bwrap@'
CAPSH_FALLBACK='@capsh@'
ENV_FALLBACK='@env@'
IP_FALLBACK='@ip@'
IPTABLES_FALLBACK='@iptables@'
LANDLOCK_SANDBOXER_FALLBACK='@landlockSandboxer@'
PASTA_FALLBACK='@pasta@'


## EARLY DEBUG HOOKS

isDebug=

enableDebug() {
  isDebug=1
  set -x
}

debug() {
  if [ -n "$isDebug" ]; then
    printf "[debug] %s" "$1" >&2
  fi
}

# if requested, enable debugging as early as possible
if [ -n "$SANEBOX_DEBUG" ]; then
  enableDebug
fi

## INTERPRETER CONFIGURATION

# enable native implementations for common utils like `realpath`
# to get a pretty meaningful speedup.
# see: <https://unix.stackexchange.com/a/558605>
# XXX 2024/02/17: nixpkgs bash initializes this to nonsense FHS directories:
# "/usr/local/lib/bash:/usr/lib/bash:/opt/local/lib/bash:/usr/pkg/lib/bash:/opt/pkg/lib/bash"
# BASH_LOADABLES_PATH="$BASH_LOADABLES_PATH:@bash@/lib/bash"
# enable -f realpath realpath
# enable -f dirname dirname


## MUTABLE GLOBAL VARIABLES AND HELPER FUNCTIONS

# isDisable: set non-empty to invoke the binary without any sandboxing
isDisable=
# isDryRun: don't actually execute the program or sandbox: just print the command which would be run (and which the user may run from their own shell)
isDryRun=
# linkCache: associative array mapping canonical symlinks to canonical targets
# used to speed up `readlink` operations
declare -A linkCache

### values derived directly from $argv
# cliArgs: the actual command we'll be running inside the sandbox
cliArgs=()
# type of sandbox to use
# - "bwrap"
# - "landlock"
# - "capshonly"
# - "pastaonly"
# - "none"
method=
# autodetect: set non-empty to add any path-like entities intended for the binary's CLI, into its sandbox.
# - "existing" (file or directory)
# - "existingFile" (file only; no directories)
# - "parent"
# - "existingOrParent"
# - "existingFileOrParent"
autodetect=
# paths: list of paths to make available inside the sandbox.
#        this could contain duplicates, non-canonical paths (`a/../b`), paths that don't exist, etc.
paths=()
# linux capabilities to provide to the sandbox, like `sys_admin` (no `cap_` prefix here)
capabilities=()
# keepNamespace:
# - "cgroup"
# - "ipc"
# - "pid": if this process may wany to query /proc/$PID/... of parent/sibling processes.
# - "uts"
# - "all": as if all the above were specified
keepNamespace=()
# name of some network device to make available to the sandbox, if any.
# or "all" to keep all devices available
netDev=
# IPv4 address of the default gateway associated with the bridged network device (usually that's just the VPN's IP addr)
netGateway=default
# list of IP addresses to use for DNS servers inside the sandbox (not supported by all backends)
dns=()
# list of `VAR=VALUE` environment variables to add to the sandboxed program's environment
portalEnv=()

# arguments to forward onto a specific backend (if that backend is active)
bwrapFlags=()

usage() {
  echo 'sanebox: run a program inside a sandbox'
  echo 'USAGE: sanebox [sandbox-arg ...] program [sandbox-arg|program-arg ...] [--] [program-arg ...]'
  echo ''
  echo 'sandbox args and program args may be intermixed, but the first `--` anywhere signals the end of the sandbox args and the start of program args'
  echo
  echo 'sandbox args:'
  echo '  --sanebox-help'
  echo '      show this message'
  echo '  --sanebox-debug'
  echo '      print debug messages to stderr'
  echo '  --sanebox-replace-cli <bin>'
  echo '      invoke <bin> under the sandbox instead of any program previously listed'
  echo '      also clears and earlier arguments intended for the program'
  echo '  --sanebox-disable'
  echo '      invoke the program directly, instead of inside a sandbox'
  echo '  --sanebox-dry-run'
  echo '      show what would be `exec`uted but do not perform any action'
  echo '  --sanebox-method <bwrap|capshonly|pastaonly|landlock|none>'
  echo '      use a specific sandboxer'
  echo '  --sanebox-autodetect <existing|existingFile|existingFileOrParent|existingOrParent|parent>'
  echo '      add files which appear later as CLI arguments into the sandbox'
  echo '  --sanebox-cap <sys_admin|net_raw|net_admin|...>'
  echo '      allow the sandboxed program to use the provided linux capability (both inside and outside the sandbox)'
  echo '  --sanebox-portal'
  echo '      set environment variables so that the sandboxed program will attempt to use xdg-desktop-portal for operations like opening files'
  echo '  --sanebox-no-portal'
  echo '      undo a previous `--sanebox-portal` arg'
  echo '  --sanebox-bwrap-arg <arg>'
  echo '  --sanebox-pasta-arg <arg>'
  echo '  --sanebox-net-dev <iface>|all'
  echo '  --sanebox-net-gateway <ip-address>'
  echo '  --sanebox-dns <server>|host'
  echo '  --sanebox-keep-namespace <cgroup|ipc|pid|uts|all>'
  echo '      do not unshare the provided linux namespace'
  echo '  --sanebox-path <path>'
  echo '      allow access to the host <path> within the sandbox'
  echo '      path is interpreted relative to the working directory if not absolute'
  echo '  --sanebox-home-path <path>'
  echo '      allow access to the host <path>, relative to HOME'
  echo '  --sanebox-run-path <path>'
  echo '      allow access to the host <path>, relative to XDG_RUNTIME_DIR'
  echo '  --sanebox-add-pwd'
  echo '      shorthand for `--sanebox-path $PWD`'
  echo
  echo 'the following environment variables are also considered and propagated to children:'
  echo '  SANEBOX_DISABLE=1'
  echo '      equivalent to `--sanebox-disable`'
  echo '  SANEBOX_DEBUG=1'
  echo '      equivalent to `--sanebox-debug`, but activates earlier'
  echo '  SANEBOX_PREPEND=...'
  echo '      act as though the provided arg string appeared at the start of the CLI'
  echo '  SANEBOX_APPEND=...'
  echo '      act as though the provided arg string appeared at the end of the CLI'
}


## UTILITIES/BOILERPLATE

# `relativeToPwd out-var path`
# if `path` is absolute, returns `path`
# otherwise, joins `path` onto `$PWD`
relativeToPwd() {
  local outvar=$1
  local path=$2
  if [ "${path:0:1}" == "/" ]; then
    declare -g "$outvar"="$path"
  else
    declare -g "$outvar"="$PWD/path"
  fi
}

# `splitHead <headVar> <tailVar> <input>`: write the top-level directory to `headVar` and set `tailVar` to the remaining path.
# input is assumed to be a full path.
# both outputs inherit the leading slash (except if the path has only one item, in which case `tailVar=`.
# example: splitHead myHead myTail /path/to/thing
#   myHead=/path
#   myTail=/to/thing
# example: splitHead myHead myTail /top
#   myHead=/top
#   myTail=
splitHead() {
  local outHead=$1
  local outTail=$2
  local path=$3

  # chomp leading `/`
  path=${path:1}
  local leadingComp=${path%%/*}
  local compLen=${#leadingComp}
  local tail=${path:$compLen}

  declare -g "$outHead"="/$leadingComp"
  declare -g "$outTail"="$tail"
}

# `normPath outVar "$path"`
# remove duplicate //, reduce '.' and '..' (naively).
# expects a full path as input
# chomps trailing slashes.
# does not resolve symlinks, nor check for existence of any component of the path.
normPath() {
  local outVar=$1
  local unparsed=$2
  local comps=()
  while [ -n "$unparsed" ]; do
    splitHead _npThisComp _npUnparsed "$unparsed"
    unparsed=$_npUnparsed
    local thisComp=$_npThisComp

    case $thisComp in
      (/..)
        # "go up" path component => delete the leaf dir (if any)
        if [ ${#comps[@]} -ne 0 ]; then
          unset comps[-1]
        fi
        ;;
      (/. | / | "") ;;
      (*)
        # normal, non-empty path component => append it
        comps+=("$thisComp")
        ;;
    esac
  done

  # join the components
  if [ ${#comps[@]} -eq 0 ]; then
    declare -g "$outVar"="/"
  else
    local joined=
    for comp in "${comps[@]}"; do
      joined=$joined$comp
    done
    declare -g "$outVar"="$joined"
  fi
}

# normPathBashBuiltin() {
#   # normPath implementation optimized by using bash builtins (`help realpath`):
#   # -s: canonicalize `.` and `..` without resolving symlinks
#   # -c: enforce that each component exist (intentionally omitted)
#   # XXX: THIS DOESN'T WORK: if bash is asked to canonicalize a path with symlinks, it either (1) resolves the symlink (default) or (2) aborts (-s). there's no equivalent to coreutils
#   realpath -s "$1"
# }

# legacy coreutils normPath: definitive, but slow (requires a fork/exec subshell).
# bash `normPath` aims to be equivalent to this one.
# normPathCoreutils() {
#   # `man realpath`:
#   # --logical: resolve `..` before dereferencing symlinks
#   # --no-symlinks: don't follow symlinks
#   # --canonicalize-missing: don't error if path components don't exist
#   realpath --logical --no-symlinks --canonicalize-missing "$1"
# }

# `parent outVar "$path"`
# return the path to this file or directory's parent, even if the input doesn't exist.
parent() {
  normPath "$1" "$2/.."
}

# `locate outVar <bin-name> </path/to/default>` => if `<bin-name>` is on PATH, then return that, else </path/to/default>
locate() {
  local outVar="$1"
  local bin="$2"
  local loc="$3"
  # N.B.: `bin=$(command -v $bin)` would make more sense, but it forks.
  for dir in ${PATH//:/ }; do
    if [ -e "$dir/$bin" ]; then
      loc="$dir/$bin"
      break
    fi
  done
  declare -g "$outVar"="$loc"
}

# `urldecode outVar <uri>`
# convert e.g. `file:///Local%20Users/foo.mp3` to `file:///Local Users/foo.mp3`
urldecode() {
  local outVar=$1
  shift

  # source: <https://stackoverflow.com/q/6250698>
  # replace each `+` with space
  local i=${*//+/ }
  # then replace each `%` with `\x`
  # and have `echo` evaluate the escape sequences
  local decoded=$(echo -e "${i//%/\\x}")
  declare -g "$outVar"="$decoded"
}

# `contains needle ${haystack[0]} ${haystack[1]} ...`
# evals `true` if the first argument is equal to any of the other args
contains() {
  local needle=$1
  shift
  for item in "$@"; do
    if [ "$needle" = "$item" ]; then
      return 0
    fi
  done
  return 1
}

# `readlinkOnce outVar path`: writes the link target to outVar
# or sets outVar="" if path isn't a link.
# unlink `derefOnce`, this only acts if the leaf of `path` is a symlink,
# not if it's an ordinary entry within a symlinked directory.
readlinkOnce() {
  local outVar=$1
  local path=$2
  local linkTarget=
  if [ -v "linkCache[$path]" ]; then
    linkTarget=${linkCache[$path]}
  elif [ -L "$path" ]; then
    # path is a link, but not in the cache
    linkTarget=$(readlink "$path")
    # insert it into the cache, in case we traverse it again
    linkCache[$path]=$linkTarget
  else
    # remember for later that this path doesn't represent a link.
    # empty target is used to indicate a non-symlink (i.e. ordinary file/directory).
    #   i think a symlink can *technically* point to "" (via `symlink` syscall), but `ln -s` doesn't allow it
    linkCache[$path]=
  fi
  declare -g "$outVar"="$linkTarget"
}

# `derefOnce outVar path`: walks from `/` to `path` and derefs the first symlink it encounters.
# the dereferenced equivalent of `path` is written to `outVar`.
# the dereferenced path may yet contain more unresolved symlinks.
# if no links are encountered, then `outVar` is set empty.
derefOnce() {
  local outVar=$1
  local source=$2
  local target=

  local walked=
  local unwalked=$source
  while [ -n "$unwalked" ]; do
    splitHead _head _unwalked "$unwalked"
    unwalked=$_unwalked
    walked=$walked$_head

    readlinkOnce _linkTarget "$walked"
    if [ -n "$_linkTarget" ]; then
      target=$_linkTarget$unwalked
      break
    fi
  done

  # make absolute
  if [ -n "$target" ]; then
    if [ "${target:0:1}" != / ]; then
      # `walked` is a relative link.
      # then, the link is relative to the parent directory of `walked`
      target=$walked/../$target
    fi
    # canonicalize
    normPath _normTarget "$target"
    target=$_normTarget
  fi
  declare -g "$outVar"="$target"
}


## HELPERS

# subroutine of `tryArgAsPath` for after the arg has been converted into a valid (but possibly not existing) path.
# adds an entry to `paths` and evals `true` on success;
# evals `false` if the path couldn't be added, for any reason.
tryPath() {
  local path=$1
  local how=$2

  case $how in
    (existing)
      # the caller wants to access either a file, or a directory (possibly a symlink to such a thing)
      if [ -e "$path" ]; then
        relativeToPwd _absPath "$path"
        paths+=("$_absPath")
        return 0
      fi
      return 1
      ;;
    (existingFile)
      # the caller wants to access a file, and explicitly *not* a directory (though it could be a symlink *to a file*)
      if [ -f "$path" ]; then
        relativeToPwd _absPath "$path"
        paths+=("$_absPath")
        return 0
      fi
      return 1
      ;;
    (parent)
      # the caller wants access to the entire directory containing this directory regardless of the file's existence.
      parent _tryPathParent "$path"
      tryPath "$_tryPathParent" "existing"
      ;;
    (existingOrParent)
      # the caller wants access to the path, or write access to the parent directory so it may create the path if it doesn't exist.
      tryPath "$path" "existing" || tryPath "$path" "parent"
      ;;
    (existingFileOrParent)
      # the caller wants access to the file, or write access to the parent directory so it may create the file if it doesn't exist.
      tryPath "$path" "existingFile" || tryPath "$path" "parent"
      ;;
  esac
}

# if the argument looks path-like, then add it to paths.
# this function ingests absolute, relative, or file:///-type URIs.
# but it converts any such path into an absolute path before adding it to paths.
tryArgAsPath() {
  local arg=$1
  local how=$2
  # norecurseFlag is used internally by this function when it recurses
  local norecurseFlag=$3
  local path=
  case $arg in
    (/*)
      # absolute path
      path=$arg
      ;;
    (file:///*)
      # URI to an absolute path which is presumably on this vfs
      # commonly found when xdg-open/mimeo passes a path on to an application
      # if URIs to relative paths exist, this implementation doesn't support them
      urldecode _path "${arg:7}"
      path=$_path
      ;;
    (*)
      # could be a CLI argument or a relative path
      # want to handle:
      # - `--file=$path`
      # - `file=$path`
      # - `$path`
      if [ -z "$norecurseFlag" ]; then
        local pathInFlag=${arg#*=}
        if [ "$pathInFlag" != "$arg" ]; then
          tryArgAsPath "$pathInFlag" "$how" --norecurse
          # 0.01% chance this was a path which contained an equal sign and not a flag, but don't handle that for now:
          return
        fi
      fi

      if [ "${arg:0:1}" = "-" ]; then
        # 99% chance it's a CLI argument. if not, use `./-<...>`
        return
      fi

      # try it as a relative path
      path=$PWD/$arg
      ;;
  esac

  tryPath "$path" "$how"
}


## ARGV PARSING LOOP
#  parse CLI args into the variables declared above
#  args not intended for this helper are put into $parseArgsExtra
parseArgsExtra=()
parseArgs() {
  while [ "$#" -ne 0 ]; do
    local arg=$1
    shift
    case $arg in
      (--)
        # rest of args are for the CLI, and not for us.
        # consider two cases:
        # - sanebox --sanebox-flag1 -- /nix/store/.../mpv --arg0 arg1
        # - sanebox /nix/store/.../mpv --arg0 -- arg1
        # in the first case, we swallow the -- and treat the rest as CLI args.
        # in the second case, the -- is *probably* intended for the application.
        #    but it could be meant for us. do the most conservative thing here
        #    and stop our own parsing, and also forward the -- to the wrapped binary.
        #
        # this mode of argument parsing is clearly ambiguous, it's probably worth reducing our own API in the future
        if [ -n "$parseArgsExtra" ]; then
          parseArgsExtra+=("--")
        fi
        parseArgsExtra+=("$@")
        break
        ;;
      (--sanebox-help)
        usage
        exit 0
        ;;
      (--sanebox-debug)
        enableDebug
        ;;
      (--sanebox-replace-cli)
        # keep the sandbox flags, but clear any earlier CLI args.
        # this lets the user do things like `mpv --sanebox-replace-cli sh` to enter a shell
        # with the sandbox that `mpv` would see.
        parseArgsExtra=()
        ;;
      (--sanebox-disable)
        isDisable=1
        ;;
      (--sanebox-dry-run)
        isDryRun=1
        ;;
      (--sanebox-method)
        method=$1
        shift
        ;;
      (--sanebox-autodetect)
        # autodetect: crawl the CLI program's args & bind any which look like paths into the sandbox.
        # this is handy for e.g. media players or document viewers.
        # it's best combined with some two-tiered thing.
        #   e.g. first drop to the broadest path set of interest (Music,Videos,tmp, ...), then drop via autodetect.
        autodetect=$1
        shift
        ;;
      (--sanebox-cap)
        # N.B.: these named temporary variables ensure that "set -x" causes $1 to be printed
        local cap=$1
        shift
        capabilities+=("$cap")
        ;;
      (--sanebox-portal)
        # instruct glib/gtk apps to perform actions such as opening external files via dbus calls to org.freedesktop.portal.*.
        # note that GIO_USE_PORTALS primarily acts as a *fallback*: apps only open files via the portal if they don't know how to themelves.
        # this switch is typically accompanied by removing all MIME associations from the app's view, then.
        # GTK_USE_PORTALS is the old name, beginning to be phased out as of 2023-10-02
        portalEnv=("GIO_USE_PORTALS=1" "GTK_USE_PORTAL=1" "NIXOS_XDG_OPEN_USE_PORTAL=1")
        ;;
      (--sanebox-no-portal)
        # override a previous --sanebox-portal call
        portalEnv=()
        ;;
      (--sanebox-bwrap-arg)
        local bwrapFlag=$1
        shift
        bwrapFlags+=("$bwrapFlag")
        ;;
      (--sanebox-pasta-arg)
        local pastaArg=$1
        shift
        pastaArgs+=("$pastaArg")
        ;;
      (--sanebox-net-dev)
        netDev=$1
        shift
        ;;
      (--sanebox-net-gateway)
        netGateway=$1
        shift
        ;;
      (--sanebox-dns)
        local dnsServer=$1
        shift
        if [ "$dnsServer" = default ]; then
          dns=()
        else
          dns+=("$dnsServer")
        fi
        ;;
      (--sanebox-keep-namespace)
        local namespace=$1
        shift
        # TODO: should this include `net` namespace??
        if [ "$namespace" = all ]; then
          keepNamespace+=("cgroup" "ipc" "pid" "uts")
        else
          keepNamespace+=("$namespace")
        fi
        ;;
      (--sanebox-path)
        local path=$1
        shift
        relativeToPwd _absPath "$path"
        paths+=("$_absPath")
        ;;
      (--sanebox-home-path)
        local path=$1
        shift
        paths+=("$HOME/$path")
        ;;
      (--sanebox-run-path)
        local path=$1
        shift
        paths+=("$XDG_RUNTIME_DIR/$path")
        ;;
      (--sanebox-add-pwd)
        paths+=("$PWD")
        ;;
      (*)
        parseArgsExtra+=("$arg")
        ;;
    esac
  done
}


## BUBBLEWRAP BACKEND

bwrapUnshareCgroup=(--unshare-cgroup)
bwrapUnshareIpc=(--unshare-ipc)
bwrapUnshareNet=(--unshare-net)
bwrapUnsharePid=(--unshare-pid)
bwrapUnshareUts=(--unshare-uts)
bwrapVirtualizeDev=(--dev /dev)
bwrapVirtualizeProc=(--proc /proc)
bwrapVirtualizeTmp=(--tmpfs /tmp)
bwrapUsePasta=

bwrapSetup() {
  debug "bwrapSetup: noop"
}
bwrapIngestPath() {
  # N.B.: use --dev-bind-try instead of --dev-bind for platform-specific paths like /run/opengl-driver-32
  #   which don't exist on aarch64, as the -try variant will gracefully fail (i.e. not bind it).
  # N.B.: `test -r` for paths like /mnt/servo/media, which may otherwise break bwrap when offline with
  #   "bwrap: Can't get type of source /mnt/...: Input/output error"
  # HOWEVER, paths such as `/run/secrets` are not readable, so don't do that (or, try `test -e` if this becomes a problem again).
  # HOWEVER, `test -e` hangs (for ~10s?) on broken mount points or mount subpaths. it handles mount superpaths fine. e.g.:
  # - /mnt/servo/media/Pictures -> prone to hanging (subdir of mount)
  # - /mnt/servo/media          -> prone to hanging (root mount point)
  # - /mnt/servo                -> never hangs
  # may be possible to place ever mount in a subdir, and mount the super dir?
  # or maybe configure remote mounts to somehow never hang.
  # test -r "$1" && bwrapFlags+=("--dev-bind-try" "$1" "$1")

  # N.B.: test specifically whether this path is a link, not whether it's a non-symlink under a symlink'd dir.
  # this way, the filetype of this path is *always* the same both inside and outside the sandbox.
  readlinkOnce linkTarget "$1"
  if [ -n "$linkTarget" ]; then
    bwrapFlags+=("--symlink" "$linkTarget" "$1")
  else
    bwrapFlags+=("--dev-bind-try" "$1" "$1")
  fi

  # default to virtualizing a few directories in a way that's safe (doesn't impact outside environment)
  # and maximizes compatibility with apps. but if explicitly asked for the directory, then remove the virtual
  # device and bind it as normal.
  case $1 in
    (/)
      bwrapVirtualizeDev=()
      bwrapVirtualizeProc=()
      bwrapVirtualizeTmp=()
      ;;
    (/dev)
      bwrapVirtualizeDev=()
      ;;
    (/proc)
      bwrapVirtualizeProc=()
      ;;
    (/tmp)
      bwrapVirtualizeTmp=()
      ;;
  esac
}
bwrapIngestNetDev() {
  local dev="$1"
  bwrapUnshareNet=()
  if [ "$dev" != "all" ]; then
    bwrapUsePasta=1
    pastaonlyIngestNetDev "$dev"
  fi
}
bwrapIngestNetGateway() {
  bwrapUsePasta=1
  pastaonlyIngestNetGateway "$1"
}
bwrapIngestDns() {
  bwrapUsePasta=1
  pastaonlyIngestDns "$1"
}
bwrapIngestKeepNamespace() {
  case $1 in
    (cgroup)
      bwrapUnshareCgroup=()
      ;;
    (ipc)
      bwrapUnshareIpc=()
      ;;
    (pid)
      bwrapUnsharePid=()
      ;;
    (uts)
      bwrapUnshareUts=()
      ;;
  esac
}
bwrapIngestCapability() {
  bwrapFlags+=("--cap-add" "cap_$1")
}

bwrapGetCli() {
  # --unshare-all implies the following:
  # --unshare-pid: mean that the /proc mount does not expose /proc/$PID/ for every other process on the machine.
  # --unshare-net creates a new net namespace with only the loopback interface.
  #               if `bwrapFlags` contains --share-net, thiss is canceled and the program sees an unsandboxed network.
  # --unshare-ipc
  # --unshare-cgroup
  # --unshare-uts
  # --unshare-user (implicit to every non-suid call to bwrap)
  locate _bwrap "bwrap" "$BWRAP_FALLBACK"
  locate _env "env" "$ENV_FALLBACK"
  if [ -n "$bwrapUsePasta" ]; then
    # pasta drops us into an environment where we're root, but some apps complain if run as root.
    # TODO: this really belongs on the `pastaonlyGetCli` side.
    # TODO: i think we need to add `/dev/net/tun` to the namespace for nested pasta calls to work?
    bwrapFlags+=(
      # --unshare-user is necessary for --uid to work when called as pseudo root
      --unshare-user
      --uid "$UID"
      --gid "${GROUPS[0]}"
    )
  fi
  cliArgs=(
    "$_bwrap" "${bwrapUnshareCgroup[@]}" "${bwrapUnshareIpc[@]}"
      "${bwrapUnshareNet[@]}" "${bwrapUnsharePid[@]}"
      "${bwrapUnshareUts[@]}"
      "${bwrapVirtualizeDev[@]}" "${bwrapVirtualizeProc[@]}" "${bwrapVirtualizeTmp[@]}"
      "${bwrapFlags[@]}" --
    "$_env" "${portalEnv[@]}" "${cliArgs[@]}"
  )
  if [ -n "$bwrapUsePasta" ]; then
    pastaonlyGetCli
  fi
}


## LANDLOCK BACKEND

landlockPaths=
landlockNetFlags=(LL_TCP_BIND= LL_TCP_CONNECT=)

landlockSetup() {
  # other sandboxing methods would create fake /dev, /proc, /tmp filesystems
  # but landlock can't do that. so bind a minimal number of assumed-to-exist files.
  # note that most applications actually do start without these, but maybe produce weird errors during their lifetime.
  # typical failure mode:
  # - /tmp: application can't perform its task
  # - /dev/{null,random,urandom,zero}: application warns but works around it
  paths+=(
    /dev/null
    /dev/random
    /dev/urandom
    /dev/zero
    /tmp
  )
  # /dev/{stderr,stdin,stdout} are links to /proc/self/fd/N
  # and /proc/self is a link to /proc/<N>.
  # there seems to be an issue, observed with wireshark, in binding these.
  # maybe i bound the symlinks but not the actual data being pointed to.
  # if you want to bind /dev/std*, then also bind all of /proc.
  # /proc/self
  # "/proc/$$"
  # /dev/stderr
  # /dev/stdin
  # /dev/stdout
}
landlockIngestPath() {
  # TODO: escape colons
  if [ -e "$1" ]; then
    # landlock is fd-based and requires `open`ing the path;
    # sandboxer will error if that part fails.
    if [ -z "$landlockPaths" ]; then
      # avoid leading :, which would otherwise cause a "no such file" error.
      landlockPaths=$1
    else
      landlockPaths=$landlockPaths:$1
    fi
  fi
}
landlockIngestNetDev() {
  local dev="$1"
  if [ "$dev" == "all" ]; then
    landlockNetFlags=()
  else
    debug "landlockIngestNetDev: $dev: unsupported (landlock cannot selectively restrict devices)"
  fi
}
landlockIngestNetGateway() {
  debug "landlockIngestNetGateway: noop"
}
landlockIngestDns() {
  debug "landlockIngestDns: noop"
}
landlockIngestKeepNamespace() {
  debug "landlockIngestKeepNamespace: noop"
}
landlockIngestCapability() {
  capshonlyIngestCapability "$1"
}
landlockGetCli() {
  # landlock sandboxer has no native support for capabilities (except that it sets nonewprivs),
  # so trampoline through `capsh` as well, to drop privs.
  # N.B: capsh passes its arg to bash (via /nix/store/.../bash), which means you have to `-c "my command"` to
  # invoke the actual user command.
  locate _sandboxer "sandboxer" "$LANDLOCK_SANDBOXER_FALLBACK"
  locate _capsh "capsh" "$CAPSH_FALLBACK"
  locate _env "env" "$ENV_FALLBACK"
  cliArgs=("$_env" LL_FS_RO= LL_FS_RW="$landlockPaths" "${landlockNetFlags[@]}"
    "$_sandboxer"
    "$_capsh" "--caps=$capshCapsArg" --no-new-privs --shell="$_env" -- "${portalEnv[@]}" "${cliArgs[@]}"
  )
}


## CAPSH-ONLY BACKEND
# this backend exists because apps which are natively bwrap may complain about having ambient privileges.
# then, run them in a capsh sandbox, which ignores any path sandboxing and just lowers privs to what's needed.

capshCapsArg=

capshonlySetup() {
  debug "capshonlySetup: noop"
}
capshonlyIngestPath() {
  debug "capshonlyIngestPath: stubbed"
}
capshonlyIngestNetDev() {
  debug "capshonlyIngestNetDev: '$1': stubbed (capsh network is always unrestricted)"
}
capshonlyIngestNetGateway() {
  debug "capshonlyIngestNetGateway: '$1': stubbed (capsh network is always unrestricted)"
}
capshonlyIngestDns() {
  debug "capshonlyIngestDns: '$1': stubbed (capsh network is always unrestricted)"
}
capshonlyIngestKeepNamespace() {
  debug "capshonlyIngestKeepNamespace: noop"
}
capshonlyIngestCapability() {
  # N.B. `capsh` parsing of `--caps=X` arg is idiosyncratic:
  # - valid: `capsh --caps=CAP_FOO,CAP_BAR=eip -- <cmd>`
  # - valid: `capsh --caps= -- <cmd>`
  # - invalid: `capsh --caps=CAP_FOO,CAP_BAR -- <cmd>`
  # - invalid: `capsh --caps==eip -- <cmd>`
  #
  # `capsh --caps=CAP_FOO=eip -- true` will fail if we don't have CAP_FOO,
  # but for my use i'd still like to try running the command even if i can't grant it all capabilities.
  # therefore, only grant it those capabilities i know will succeed.
  if capsh "--has-p=cap_$1" 2>/dev/null; then
    if [ -z "$capshCapsArg" ]; then
      capshCapsArg=cap_$1=eip
    else
      capshCapsArg=cap_$1,$capshCapsArg
    fi
  else
    debug "capsh: don't have capability $1"
  fi
}

capshonlyGetCli() {
  locate _capsh "capsh" "$CAPSH_FALLBACK"
  locate _env "env" "$ENV_FALLBACK"
  cliArgs=(
    "$_capsh" "--caps=$capshCapsArg" --no-new-privs --shell="$_env" -- "${portalEnv[@]}" "${cliArgs[@]}"
  )
}


## PASTA-ONLY BACKEND
# this backend exists mostly as a helper for the bwrap backend

pastaArgs=()
pastaNetSetup=
pastaOutboundPorts=()
pastaonlySetup() {
  debug "pastaonlySetup: noop"
}
pastaonlyIngestPath() {
  debug "pastaonlyIngestPath: noop"
}
pastaonlyIngestNetDev() {
  local dev=$1
  case $dev in
    (all)
      ;;
    (*)
      pastaArgs+=(--outbound-if4 "$dev")
      ;;
  esac
}
pastaonlyIngestNetGateway() {
  pastaArgs+=(--gateway "$1")
}
pastaonlyIngestDns() {
  local dns=$1
  case "$dns" in
    (host)
      # use the host's DNS resolver
      if ! contains 53 "${pastaOutboundPorts[@]}"; then
        pastaOutboundPorts+=(53)
      fi
      ;;
    (*)
      locate _iptables "iptables" "$IPTABLES_FALLBACK"
      locate _ip "ip" "$IP_FALLBACK"
      # NAT DNS requests to localhost to the VPN's DNS resolver
      # claim the whole 127.0.0.x space, because some setups place the DNS on a different address of localhost.
      pastaNetSetup="$_iptables -A OUTPUT -t nat -p udp --dport 53 -m iprange --dst-range 127.0.0.1-127.0.0.255 -j DNAT --to-destination $1:53; $pastaNetSetup"
      pastaNetSetup="$_ip addr del 127.0.0.1/8 dev lo; $pastaNetSetup"
      ;;
  esac
}
pastaonlyIngestKeepNamespace() {
  :
}
pastaonlyIngestCapability() {
  :
}
pastaonlyGetCli() {
  cliArgs=(
    "/bin/sh" "-c"
    "$pastaNetSetup exec"' "$0" "$@"'
    "${cliArgs[@]}"
  )
  locate _pasta "pasta" "$PASTA_FALLBACK"
  local pastaOutboundPortsStr=none
  if [ "${#pastaOutboundPorts[@]}" -ne 0 ]; then
    pastaOutboundPortsStr="${pastaOutboundPorts[*]}"
    pastaOutboundPortsStr="${pastaOutboundPortsStr// /,}"
  fi
  cliArgs=(
    "$_pasta" --ipv4-only -U "$pastaOutboundPortsStr" -T "$pastaOutboundPortsStr" -u none -t none --config-net
      "${pastaArgs[@]}" --
    "${cliArgs[@]}"
  )
}


## NONE BACKEND
# this backend exists only to allow benchmarking
noneSetup() {
  :
}
noneIngestPath() {
  :
}
noneIngestNetDev() {
  :
}
noneIngestNetGateway() {
  :
}
noneIngestDns() {
  :
}
noneIngestKeepNamespace() {
  :
}
noneIngestCapability() {
  :
}
noneGetCli() {
  :
}


## ARGUMENT POST-PROCESSING

loadLinkCache() {
  if ! [ -e /etc/sanebox/symlink-cache ]; then
    # don't error if the link cache is inaccessible.
    # this can happen during nix builds e.g.
    return
  fi

  # readarray -t: reads some file into an array; each line becomes one element
  readarray -t _linkCacheArray < /etc/sanebox/symlink-cache
  for link in "${_linkCacheArray[@]}"; do
    # XXX: bash doesn't give a good way to escape the tab character, but that's what we're splitting on here.
    local from=${link%%	*}
    local to=${link##*	}
    linkCache[$from]=$to
  done
}

### autodetect: if one of the CLI args looks like a path, that could be an input or output file
#   so allow access to it.
maybeAutodetectPaths() {
  if [ -n "$autodetect" ]; then
    for arg in "${cliArgs[@]:1}"; do
      tryArgAsPath "$arg" "$autodetect"
    done
  fi
}

### path sorting: if the app has access both to /FOO and /FOO/BAR, some backends get confused.
#   notably bwrap, --bind /FOO /FOO followed by --bind /FOO/BAR /FOO/BAR results in /FOO being accessible but /FOO/BAR *not*.
#   so reduce the paths to the minimal set which includes those requested.
canonicalizePaths() {
  # remove '//' and simplify '.', '..' paths, into canonical absolute logical paths.
  local canonPaths=()
  for path in "${paths[@]}"; do
    normPath _canonPath "$path"
    canonPaths+=("$_canonPath")
  done
  paths=("${canonPaths[@]}")
}
expandLink() {
  derefOnce _linkTarget "$1"
  if [ -n "$_linkTarget" ]; then
    # add + expand the symlink further, but take care to avoid infinite recursion
    if ! contains "$_linkTarget" "${paths[@]}"; then
      paths+=("$_linkTarget")
      expandLink "$_linkTarget"
    fi
  fi
}
### expand `paths` until it contains no symlinks whose target isn't also in `paths`
expandLinks() {
  for path in "${paths[@]}"; do
    expandLink "$path"
  done
}
removeSubpaths() {
  # remove subpaths, but the result might include duplicates.
  # TODO: make this not be O(n^2)!
  local toplevelPaths=()
  for path in "${paths[@]}"; do
    local isSubpath=
    for other in "${paths[@]}"; do
      if [[ "$path" =~ ^"$other"/ ]] || [ "$other" = / -a "$path" != / ]; then
        # N.B.: $path lacks a trailing slash, so this never matches self.
        # UNLESS $path or $other is exactly `/`, which we special-case.
        isSubpath=1
      fi
    done
    if [ -z "$isSubpath" ]; then
      toplevelPaths+=("$path")
    fi
  done

  # remove duplicated paths.
  local canonicalizedPaths=()
  for path in "${toplevelPaths[@]}"; do
    if ! contains "$path" "${canonicalizedPaths[@]}"; then
      canonicalizedPaths+=("$path")
    fi
  done
  paths=("${canonicalizedPaths[@]}")
}


## TOPLEVEL ADAPTERS
#  - convert CLI args/env into internal structures
#  - convert internal structures into backend-specific structures

### parse arguments, with consideration of any which may be injected via the environment
parseArgsAndEnvironment() {
  if [ -n "$SANEBOX_DISABLE" ]; then
    isDisable=1
  fi

  if [ -n "$SANEBOX_PREPEND" ]; then
    parseArgs $SANEBOX_PREPEND
  fi

  parseArgs "$@"
  cliArgs+=("${parseArgsExtra[@]}")

  if [ -n "$SANEBOX_APPEND" ]; then
    parseArgs $SANEBOX_APPEND
  fi
}

### convert generic args into sandbox-specific args
ingestForBackend() {
  for path in "${paths[@]}"; do
    "$method"IngestPath "$path"
  done

  for cap in "${capabilities[@]}"; do
    "$method"IngestCapability "$cap"
  done

  if [ -n "$netDev" ]; then
    "$method"IngestNetDev "$netDev"
  fi

  if [ "$netGateway" != default ]; then
    "$method"IngestNetGateway "$netGateway"
  fi

  for addr in "${dns[@]}"; do
    "$method"IngestDns "$addr"
  done

  for ns in "${keepNamespace[@]}"; do
    "$method"IngestKeepNamespace "$ns"
  done
}


## TOPLEVEL EXECUTION
#  no code evaluated before this point should be dependent on user args / environment.

parseArgsAndEnvironment "$@"

# variables meant to be inherited
# N.B.: SANEBOX_DEBUG FREQUENTLY BREAKS APPLICATIONS WHICH PARSE STDOUT
# example is wireshark parsing stdout of dumpcap;
# in such a case invoke the app with --sanebox-debug instead of the env var.
export SANEBOX_DEBUG=$SANEBOX_DEBUG
export SANEBOX_DISABLE=$SANEBOX_DISABLE
export SANEBOX_PREPEND=$SANEBOX_PREPEND
export SANEBOX_APPEND=$SANEBOX_APPEND

if [ -z "$isDisable" ]; then
  loadLinkCache
  # method-specific setup could add additional paths that need binding, so do that before canonicalization
  "$method"Setup
  maybeAutodetectPaths
  canonicalizePaths
  expandLinks
  removeSubpaths

  ingestForBackend
  "$method"GetCli
fi

if [ -n "$isDryRun" ]; then
  echo "dry-run: ${cliArgs[*]}"
  exit 0
fi

exec "${cliArgs[@]}"

echo "sandbox glue failed for method='$method'"
exit 1
