#!/bin/bash

# Copyright (c) 2015 SpinetiX.
#
# This script mounts partitions from USB storage (sticks or harddisks)
# upon device insertion and umounts them upon device removal. Every
# partition present containing a recognized filesystem is mounted.
# If a filesystem is found on the whole device instead of a partition
# table then that is mounted instead.
#
# This script is to be used as a udev helper only!
#
# Filesystems on the USB storage are always mounted under /media/usbX,
# where X is an integer. Up to 10 devices can be mounted this way. Bind
# mounts from this location (or a subdir) to another location may also
# be performed by hooks.
#
# Depending on the filesystem type the filesystem is mounted read-only
# or read-write. For filesystems that can be mounted read-write it is
# necessary that INTERNAL_USB_STORAGE is set to yes in
# /etc/default/media-mount, otherwise they are mounted read-only.
#
# Hooks can be installed by applications to add bind mounts and perform
# other operations when a device is mounted or removed. The hooks should
# be installed under $HOOKDIR. They are called with the argument "add"
# just after mounting the new device or "remove" just before unmounting
# the device. This script takes care of unmounting all bind mounts and
# removing them from fstab, so they need not be removed by the hooks.
# The hooks are called with the fstab lock held and the following
# environment variables are set:
#
# MNTDIR: the directory where the device's filesystem was mounted
# MNTDEV: the device being handled
# MNTRW: set to non-empty if filesystem mounted read-write; only valid on "add"
#
# In all cases execution and device files are disabled on mounted filesystems
# for security reasons (note that disabling execution also disables suid).
#
# Note that this script may be executed by udev even before the root fs is
# mounted read-write and all pre-required setup is done. To overcome this
# limitation this script is complemented by the boot time script
# media-mount.sh. Before that boot time script runs the actions received are
# just queued as pending. The pending actions will be fired by
# the media-mount.sh boot script, after that script runs the actions are no
# longer queued as pending but directly executed. All this is done with
# proper locking so that we do not miss any actions. All necessary state
# is stored under the "$STATEDIR".

#
# Configuration
#

# Load general helpers and config
. /etc/media-mount/media-mount-functions
load_sysconfig

#
# Constants
#

# Make sure we have a sane PATH
export PATH=/usr/bin:/bin:/usr/sbin:/sbin

# Hook directory
HOOKDIR=/etc/media-mount/mount.d

# The mount point base name for external storage, index number added after
MOUNTPOINTBASE=$MEDIADIR/usb

# Mount options for VFAT filesystems
VFAT_MOUNTOPTS="utf8,nodev,noexec,noauto,fmask=0333,dmask=0222"

# Mount options for EXFAT filesystems
EXFAT_MOUNTOPTS="utf8,nodev,noexec,noauto,fmask=0333,dmask=0222"

# Mount options for EXT3 and EXT4 filesystems
EXT34_MOUNTOPTS="noatime,errors=remount-ro,commit=60,noauto,nodev,noexec"

# The maximum index for the mountpoint
MAX_INDEX=10

#
# Variables
#

# The type of storage, either "internal" or "external"
STORAGE_TYPE=

# The mount point, mount options and read-write option
MOUNTPOINT=
MOUNTOPTS=
MOUNTRW=

# The filesystem type
FSTYPE=

#
# Mountpoint management
#

# Sets the type of storage and then selects and makes a mountpoint accordingly
make_mountpoint() {

    local RET

    for (( i=1; i <= $MAX_INDEX; i++)) ; do
        MOUNTPOINT=$MOUNTPOINTBASE$i
        if ! grep -q "[[:space:]]$MOUNTPOINT[[:space:]]" /etc/fstab ; then
            mkdir -p $MOUNTPOINT
            RET=$?
            if [ $RET -ne 0 ]; then
                echo "ERROR: failed creating mount point '$MOUNTPOINT'"
            fi
            return $RET
        fi
    done

    MOUNTPOINT=
    return 1
}

fstab_add() {
    local entry
    if ! mktmpfstab; then
        echo "ERROR: cannot create temporary fstab while adding entry"
        return 1
    fi
    if [ "$INTERNAL_USB_STORAGE" != "yes" ]; then
        MOUNTRW= # no read-write if not going to be used as internal storage
    fi
    [ -n "$MOUNTRW" ] || MOUNTOPTS="$MOUNTOPTS,ro"
    entry="$DEVNAME $MOUNTPOINT $FSTYPE $MOUNTOPTS 0 0"
    echo "INFO: adding '$entry' to fstab"
    echo "$entry" >> "$TMPFSTAB"
    if [ $? -eq 0 ]; then
        savetmpfstab
    else
        rmtmpfstab
        return 1
    fi
}

dev_is_managed() {
    grep -q "^[[:space:]]*$DEVNAME[[:space:]]\+$MEDIADIR/" /etc/fstab
}

dev_is_mounted() {
    grep -q "^$DEVNAME[[:space:]]" /proc/mounts
}

# Gets the mountpoint for $DEVNAME in fstab and initializes MOUNTPOINT with it
# returns non-zero if $DEVNAME is not found
dev_get_mountpoint() {
    local dev mpt fstype opts dump pass junk
    while read dev mpt fstype opts dump pass junk; do
        [ "$dev" = "" -o "$dev" = "#" ] && continue # comment or empty line
        if [ "${DEVNAME%/}" = "${dev%/}" ]; then
            MOUNTPOINT="${mpt%/}"
            return 0
        fi
    done < /etc/fstab
    MOUNTPOINT=
    return 1
}

# Checks if $DEVNAME is mounted at the given directory
# arguments: the directory path
dev_mounted_at_dir() {
    local dir="${1%/}"
    local dev mpt fstype opts dump pass junk
    while read dev mpt fstype opts dump pass junk; do
        [ "$dev" = "" -o "$dev" = "#" ] && continue # comment or empty line
        [ "${mpt%/}" = "$dir" -a "$DEVNAME" = "$dev" ] && return 0
    done < /proc/mounts
    return 1
}

# Unmounts all mounts of $DEVNAME
dev_umount_all() {
    local list
    local dev mpt fstype opts dump pass junk

    # NOTE: in /proc/mounts bind mounts report the device path of the
    # source mount instead of the source directory used for the bind
    # so all binds appear as being mounts of the block device

    # Just in case there is a bind mount whose mount point is within
    # the filesystem space of the original mount we need to run the
    # unmounts in reverse order and we unmount by mount point to avoid
    # ambiguities.

    list=
    while read dev mpt fstype opts dump pass junk; do
        [ "$dev" = "" -o "$dev" = "#" ] && continue # comment or empty line
        [ "$DEVNAME" = "$dev" ] && list="${mpt%/} $list"
    done < /proc/mounts

    # If the device cannot be unmounted at least attempt to remount it
    # read-only so that no further write access can be done to this device.
    # If still present then do a lazy unmount so that no new files should
    # be opened from it an it will be eventually gone.

    for mpt in $list; do
        umount -r "$mpt"
        if ! dev_mounted_at_dir "$mpt"; then
            echo "INFO: successfully unmounted '$DEVNAME' at '$mpt'"
        else
            echo "WARNING: could not unmount '$DEVNAME' at '$mpt', trying lazy unmount"
            umount -l "$mpt"
            if [ $? -ne 0 ]; then
                echo "ERROR: failed lazy unmounting '$DEVNAME' at '$mpt'"
            else
                echo "INFO: lazy unmounted '$DEVNAME' at '$mpt'"
            fi
        fi
    done
}

fstab_remove() {
    if ! mktmpfstab; then
        echo "ERROR: cannot create temporary fstab while removing entry"
        return 1
    fi
    echo "INFO: removing '$DEVNAME' and '$MOUNTPOINT' from fstab"
    grep -v "^[[:space:]]*\($MOUNTPOINT[/[:space:]]\|$DEVNAME[[:space:]]\)" \
        /etc/fstab > "$TMPFSTAB"
    if [ $? -eq 0 ]; then
        savetmpfstab
        return
    else
        rmtmpfstab
        return 1
    fi
}

#
# Mount config
#

# Parses the mount config and adjusts global config variables accordingly
# The file is a property value file. # and ; characters start a comment until
# the end-of-line, property name and value are separated by a = in the same
# line, leading and trailing whitespace in names and values are ignored.
# If a property is assigned no value (i.e. there is no = in the line) then
# it is assigned the value "yes".
#
# The only property currently implemented is "read-only". It disables
# read-write mode if set to "yes", "true" or "1", but read-write mode
# cannot be enabled if not already set by the system level configurations.
#
parse_mount_config() {
    local conf="$MOUNTPOINT"/"$MOUNTCONFIGFILE"
    local old_ifs="$IFS"
    local line prop val

    [ -s "$MOUNTPOINT"/"$MOUNTCONFIGFILE" ] || return 0

    section=
    IFS="="
    shopt -s extglob
    while read -r -n 2048 line; do
        line="${line##+([[:space:]])}" # remove leading whitespace
        line="${line%%[#;]*}" # remove comments
        [ -z "$line" ] && continue # empty line or pure comment
        line="${line%%+([[:space:]])}" # remove trailing whitespace
        prop="${line%%=*}"
        if [ "$prop" = "$line" ]; then
            val="yes"
        else
            val="${line##*=}"
        fi
        prop="${prop%%+([[:space:]])}" # remove trailing whitespace
        val="${val##+([[:space:]])}" # remove leading whitespace
        case "$prop" in
            read-only)
                case "$val" in
                    yes|true|1)
                        MOUNTRW=
                        ;;
                esac
                ;;
        esac
    done < "$conf"
    shopt -u extglob
    IFS="$oldifs"

    return 0
}

#
# Hooks
#

# Runs all the executable files in the hook directory passing the arguments;
# files are processed in alphabetical order.
run_hooks() {
    local f

    for f in "$HOOKDIR"/*; do
        [ -x "$f" ] && \
            MNTDIR="$MOUNTPOINT" MNTDEV="$DEVNAME" MNTRW="$MOUNTRW" "$f" "$@"
    done
}

#
# Device checking
#

manage_dev() {
    # if the device is connected via USB then one of the components of
    # the sysfs device path is the name of the usb root, which is of
    # the form "usbN" (N integer > 0).
    if [ "${DEVPATH/\/usb[1-9]\//}" = "${DEVPATH}" ]; then
        echo "INFO: not a USB storage device"
        return 1 # not a USB device
    fi

    return 0
}

# Detects the the type of filesystem, initializing the variables
# FSTYPE and MOUNTOPTS
detect_fs_type() {

    # NOTE: blkid from e2fsprogs >= 1.36 shows ext3 with main type "ext3" and
    # vfat with main type "vfat", so there is no need for looking at the secondary type
    FSTYPE="$(blkid -c /dev/null -s TYPE -o value "$DEVNAME")"

    if [ $? -ne 0 ]; then
        echo "ERROR: failed discovering filesystem type"
        return 1
    fi

    if [ -z "$FSTYPE" ]; then
        echo "INFO: no filesystem present, ignoring"
        return 1
    fi

    MOUNTRW= # by default do not mount read-write
    case "$FSTYPE" in
        vfat|msdos)
            FSTYPE=vfat # mount pure msdos as vfat, we do not have pure msdos support
            MOUNTOPTS="$VFAT_MOUNTOPTS"
            ;;
        exfat)
            MOUNTOPTS="$EXFAT_MOUNTOPTS"
            ;;
        ext3|ext4)
            MOUNTOPTS="$EXT34_MOUNTOPTS"
            MOUNTRW=1 # can be mounted read-write
            ;;
        *)
            echo "ERROR: unrecognized filesystem type '$FSTYPE'"
            return 1
            ;;
    esac

    return 0

}

# We first mount read-only, check the config and then remount read-write
# if allowed
do_mount() {
    local mpt="$1"
    local rw

    mount -r "$mpt" || return

    parse_mount_config

    if [ -n "$MOUNTRW" ]; then
        if ! mount -o remount,rw "$mpt"; then
            echo "INFO: failed re-mounting in read-write mode"
            return 1
        fi
        echo "INFO: mounted read-write"
    else
        echo "INFO: mounted read-only"
    fi
    return 0
}

process_remap_ids() {
    local mpt="$1"

    if ! [ -f "$mpt"/.remap-ids ]; then
        if [ -f "$mpt"/.remap-ids.errors ]; then
            echo "WARNING: a past ID remapping of storage mounted at '$mpt' logged the following errors, problems may occur"
            cat "$mpt"/.remap-ids.errors
        fi
        return
    elif [ -z "$MOUNTRW" ]; then
        echo "WARNING: storage mounted at '$mpt' has pending ID remap but is mounted read-only"
        return
    fi

    echo "INFO: doing ID remapping for storage mounted at '$mpt'"

    if ! /usr/libexec/spxsysconf/path-remap-ids "$mpt"; then
        echo "ERROR: incomplete remapping, problems may occur"
        if [ -f "$mpt"/.remap-ids.errors ]; then
            echo "INFO: logged errors are"
            cat "$mpt"/.remap-ids.errors
        fi
    fi
}

# this function performs the action
action() {
    echo "INFO: '$ACTION' '$DEVNAME' at '$DEVPATH'"

    if [ "$ACTION" = "add" ]; then
        manage_dev || return 0
        detect_fs_type || return 1
        fstab_lock || return 1
        if ! make_mountpoint; then
            echo "ERROR: failed creating mountpoint"
        elif ! fstab_add; then
            echo "ERROR: failed adding fstab entry"
        elif ! do_mount $MOUNTPOINT; then
            echo "ERROR: failed mounting at '$MOUNTPOINT'"
        else
            echo "INFO: successfully mounted USB storage at '$MOUNTPOINT'"
            process_remap_ids $MOUNTPOINT
            run_hooks add
        fi
        fstab_unlock
    elif [ "$ACTION" = "remove" ]; then
        fstab_lock || return 1
        if dev_is_managed; then
            dev_get_mountpoint
            if dev_is_mounted; then
                run_hooks remove
                dev_umount_all
            fi
            fstab_remove || echo "ERROR: failed removing device from fstab"
        fi
        fstab_unlock
    fi
}

# Main processing function
main() {
    # old udev versions have DEVPATH as the "symbolic" location and
    # PHYSDEVPATH as the sysfs device path, newer versions have
    # DEVPATH as the sysfs device path and no PHYSDEVPATH; convert
    # into the new scheme.
    if [ -n "$PHYSDEVPATH" ]; then
        export DEVPATH="$PHYSDEVPATH"
    fi
    if [ -z "$DEVNAME" -o -z "$DEVPATH" ]; then
        echo "ERROR: missing 'DEVNAME' or 'DEVPATH'"
        return 1
    fi
    if [ "$SUBSYSTEM" != "block" ]; then
        echo "ERROR: called outside of block subsystem"
        return 1
    fi

    if [ -e "$ERRORFILE" ]; then
        echo "ERROR: error indicator set, cannot process '$ACTION' '$DEVNAME' at '$DEVPATH'" >&2
        return 1
    fi

    # Make sure state dir exists
    [ -d "$STATEDIR" ] || mkdir -p "$STATEDIR"

    # If not everything is ready enter the action as pending,
    # we first check the ready file without locking but we
    # need to recheck after locking to avoid races
    if [ ! -f "$READYFILE" -a "$MEDIAMOUNTREADY" != "yes" ]; then
        state_lock || return
        echo "INFO: pending '$ACTION' '$DEVNAME' at '$DEVPATH'"
        if [ ! -f "$READYFILE" ]; then
            # not ready, mark as pending
            cat >> "$PENDINGFILE" <<EOF
--START--
ACTION=$ACTION
DEVNAME=$DEVNAME
DEVPATH=$DEVPATH
--END--
EOF
            state_unlock
            return
        fi
    # The necessary stuff is ready, we can do the work ourselves
    state_unlock
    fi

    action
    echo "INFO: '$ACTION' done"
}

#
# Main
#

main 2>&1 | logger -p daemon.info -t "media-mount [$$]"

exit 0
