#!/bin/sh

# initialise and sanitise the shell execution environment
unset -v IFS
export LC_ALL=C
export PATH='/sbin:/bin:/usr/sbin:/usr/bin'

set -e -u

enable_log=
restrict_path_list=
allow_list=
allow_exact_list=
allow_rate_limit=1
allow_stream_buffer=1
allow_compress=1
compress_list='gzip|pigz|bzip2|pbzip2|bzip3|xz|lzop|lz4|zstd'

# note that the backslash is NOT a metacharacter in a POSIX bracket expression!
option_match='-[a-zA-Z0-9=-]+'   # matches short as well as long options
file_match_sane='/[0-9a-zA-Z_@+./-]*' # matches file path (equal to ${file_match} in btrbk < 0.32.0)
file_match="/[^']*" # btrbk >= 0.32.0 quotes file arguments: match all but single quote
file_arg_match="('${file_match}'|${file_match_sane})" # support btrbk < 0.32.0

log_cmd()
{
    local priority="$1"
    local authorisation_decision="$2"
    local reason="${3-}"

    if [ -n "${enable_log}" ]; then
        logger -p "${priority}" -t ssh_filter_btrbk.sh "${authorisation_decision} (Name: ${LOGNAME:-<unknown>}; Connection: ${SSH_CONNECTION:-<unknown>})${reason:+: ${reason}}: ${SSH_ORIGINAL_COMMAND}"
    fi
}

allow_cmd()
{
    local cmd="$1"

    allow_list="${allow_list}|${cmd}"
}

allow_exact_cmd()
{
    local cmd="$1"

    allow_exact_list="${allow_exact_list}|${cmd}"
}

reject_and_die()
{
    local reason="$1"

    log_cmd 'auth.err' 'btrbk REJECT' "${reason}"
    printf 'ERROR: ssh_filter_btrbk.sh: ssh command rejected: %s: %s\n' "${reason}" "${SSH_ORIGINAL_COMMAND}" >&2
    exit 255
}

run_cmd()
{
    log_cmd 'auth.info' 'btrbk ACCEPT'
    eval " ${SSH_ORIGINAL_COMMAND}"
}

reject_filtered_cmd()
{
    if [ -n "${restrict_path_list}" ]; then
	# match any of restrict_path_list,
	# or any file/directory (matching file_match) below restrict_path
	path_match="'(${restrict_path_list})(${file_match})?'"
	path_match_legacy="(${restrict_path_list})(${file_match_sane})?"
    else
	# match any absolute file/directory (matching file_match)
	path_match="'${file_match}'"
	path_match_legacy="${file_match_sane}"
    fi
    # btrbk >= 0.32.0 quotes files, allow both (legacy)
    path_match="(${path_match}|${path_match_legacy})"

    if [ -n "${allow_compress}" ]; then
        decompress_match="(${compress_list}) -d -c( -[pT][0-9]+)?"
        compress_match="(${compress_list}) -c( -[0-9])?( -[pT][0-9]+)?"
    else
        decompress_match=
        compress_match=
    fi

    # rate_limit_remote and stream_buffer_remote use combined
    # "mbuffer" as of btrbk-0.29.0
    if [ -n "${allow_stream_buffer}" ] || [ -n "${allow_rate_limit}" ]; then
        mbuffer_match='mbuffer -v 1 -q( -s [0-9]+[kmgKMG]?)?( -m [0-9]+[kmgKMG]?)?( -[rR] [0-9]+[kmgtKMGT]?)?'
    else
        mbuffer_match=
    fi

    # allow multiple paths (e.g. "btrfs subvolume snapshot <src> <dst>")
    allow_cmd_match="(${allow_list})( ${option_match})*( ${path_match})+"
    stream_in_match="(${decompress_match} \| )?(${mbuffer_match} \| )?"
    stream_out_match="( \| ${mbuffer_match})?( \| ${compress_match}$)?"

    # `grep`’s `-q`-option is not used as it may cause an exit status of `0` even
    # when an error occurred.

    allow_stream_match="^${stream_in_match}${allow_cmd_match}${stream_out_match}"
    if printf '%s' "${SSH_ORIGINAL_COMMAND}" | grep -E "${allow_stream_match}" >/dev/null 2>/dev/null; then
        return 0
    fi

    exact_cmd_match="^(${allow_exact_list})$";
    if printf '%s' "${SSH_ORIGINAL_COMMAND}" | grep -E "${exact_cmd_match}" >/dev/null 2>/dev/null; then
        return 0
    fi

    local formatted_restrict_path_list="$(printf '%s' "${restrict_path_list}" | sed 's/|/", "/g')"
    reject_and_die "disallowed command${restrict_path_list:+ (restrict-path: \"${formatted_restrict_path_list}\")}"
}


# check for "--sudo" option before processing other options
sudo_prefix=
for key in "$@"; do
    if [ "${key}" = '--sudo' ]; then
        sudo_prefix='sudo -n '
    fi
    if [ "${key}" = '--doas' ]; then
        sudo_prefix='doas -n '
    fi
done

while [ "$#" -ge 1 ]; do
    key="$1"

    case "${key}" in
      -l|--log)
          enable_log=1
          ;;

      --sudo|--doas)
          # already processed above
          ;;

      -p|--restrict-path)
          restrict_path_list="${restrict_path_list}|${2%/}"  # add to list while removing trailing slash
          shift # past argument
          ;;

      -s|--source)
          allow_cmd "${sudo_prefix}btrfs subvolume snapshot"
          allow_cmd "${sudo_prefix}btrfs send"
          ;;

      -t|--target)
          allow_cmd "${sudo_prefix}btrfs receive"
          allow_cmd "${sudo_prefix}mkdir"
          ;;

      -c|--compress)
          # deprecated option, compression is always allowed
          ;;

      -d|--delete)
          allow_cmd "${sudo_prefix}btrfs subvolume delete"
          ;;

      -i|--info)
          allow_cmd "${sudo_prefix}btrfs subvolume find-new"
          allow_cmd "${sudo_prefix}btrfs filesystem usage"
          ;;

      --snapshot)
          allow_cmd "${sudo_prefix}btrfs subvolume snapshot"
          ;;

      --send)
          allow_cmd "${sudo_prefix}btrfs send"
          ;;

      --receive)
          allow_cmd "${sudo_prefix}btrfs receive"
          ;;

      *)
          printf 'ERROR: ssh_filter_btrbk.sh: failed to parse command line option: %s\n' "${key}" >&2
          exit 255
          ;;
    esac
    shift
done

# NOTE: subvolume queries are NOT affected by "--restrict-path":
# btrbk also calls show/list on the mount point of the subvolume
allow_exact_cmd "${sudo_prefix}btrfs subvolume (show|list)( ${option_match})* ${file_arg_match}";
allow_cmd "${sudo_prefix}readlink"                    # resolve symlink
allow_exact_cmd "${sudo_prefix}test -d ${file_arg_match}" # check directory (only for compat=busybox)
allow_exact_cmd 'cat /proc/self/mountinfo'            # resolve mountpoints
allow_exact_cmd 'cat /proc/self/mounts'               # legacy, for btrbk < 0.27.0

# remove leading "|" on alternation lists
allow_list="${allow_list#\|}"
allow_exact_list="${allow_exact_list#\|}"
restrict_path_list="${restrict_path_list#\|}"

case "${SSH_ORIGINAL_COMMAND}" in
    *\.\./*)  reject_and_die 'directory traversal'  ;;
    *'
'*)           reject_and_die 'unsafe character LF'  ;;
    *\$*)     reject_and_die 'unsafe character "$"' ;;
    *\&*)     reject_and_die 'unsafe character "&"' ;;
    *\(*)     reject_and_die 'unsafe character "("' ;;
    *\{*)     reject_and_die 'unsafe character "{"' ;;
    *\;*)     reject_and_die 'unsafe character ";"' ;;
    *\<*)     reject_and_die 'unsafe character "<"' ;;
    *\>*)     reject_and_die 'unsafe character ">"' ;;
    *\`*)     reject_and_die 'unsafe character "`"' ;;
    *\|*)     [ -n "${allow_compress}" ] || [ -n "${allow_rate_limit}" ] || [ -n "${allow_stream_buffer}" ] || reject_and_die 'unsafe character "|"' ;;
esac

reject_filtered_cmd
run_cmd
