Wren Linux

wren-linux / bin / usb

#!/bin/bash
# usb: written by Wren Linux parts/usb.sh
# USB sticks and drives, mounted as you (by udisks2), never as root:
#   usb                   list them: what is on each, and where it is mounted
#   usb mount [NAME]      mount it under /media/$USER/ (an encrypted one asks for
#                         its passphrase first); NAME is sdb1, /dev/sdb1 or the
#                         stick's label, and can be left out when there is one stick
#   usb unmount [NAME]    unmount it, lock it if encrypted, and switch it off:
#                         then it is safe to pull out (also: usb eject)
# Every stick is mounted nosuid,nodev,noexec: nothing on it runs as a program
# (bash x.sh still runs a script on it). FAT, exFAT and NTFS keep no owners or
# permissions of their own, so whoever mounts them owns every file: mounted
# with sudo mount, that is root, and you can't write to it. usb mounts them as
# you, readable by you only (other users on this computer get nothing). Found
# one mounted by root, it offers to mount it again as you.
set -euo pipefail

# (no control characters: mount points are named after the stick's own label)
say() { local m="$*"; printf 'usb: %s\n' "${m//[[:cntrl:]]/?}" >&2; }
die() { say "$*"; exit 1; }
# File systems with no owners of their own: the mounter's user owns every file
NO_OWNERS=" vfat exfat ntfs ntfs3 msdos "
# Where a stick may be mounted by someone else and still be ours to unmount
# (never /, /boot, /home: those are this computer's own disks)
OURS_UNDER=(/media/ /run/media/ /mnt/)

# The block devices, one per line: PATH TYPE FSTYPE SIZE PARENT MOUNTPOINT LABEL,
# tab-separated (lsblk's escapes undone)
declare -A TYPE FST SIZE PARENT MNT LABEL HOT
devices() {
    local line k v rest
    while IFS= read -r line; do
        declare -A f=()
        rest=$line
        while [[ $rest =~ ^\ *([A-Z:]+)=\"([^\"]*)\"(.*)$ ]]; do
            k=${BASH_REMATCH[1]} v=${BASH_REMATCH[2]} rest=${BASH_REMATCH[3]}
            # lsblk writes a " or a space in a label as \x22, \x20
            printf -v v '%b' "$v"
            f[$k]=$v
        done
        local p=${f[PATH]:-}
        [ -n "$p" ] || continue
        ALL+=("$p")
        TYPE[$p]=${f[TYPE]:-} FST[$p]=${f[FSTYPE]:-} SIZE[$p]=${f[SIZE]:-}
        # the label is the stick's own, from whoever made it: no control
        # characters (an escape sequence would be printed to your terminal)
        PARENT[$p]=${f[PKNAME]:-} MNT[$p]=${f[MOUNTPOINT]:-} LABEL[$p]=${f[LABEL]//[[:cntrl:]]/?}
        HOT[$p]=${f[RM]:-0}${f[TRAN]:-}
    done < <(lsblk -Pp -o PATH,TYPE,FSTYPE,SIZE,PKNAME,MOUNTPOINT,LABEL,HOTPLUG,RM,TRAN 2>/dev/null)
}
ALL=()
devices

# disk_of DEV: the whole drive DEV is on
disk_of() {
    local d=$1
    while [ -n "${PARENT[$d]:-}" ]; do d=${PARENT[$d]}; done
    echo "$d"
}
# is_stick DEV: on a USB drive or one marked removable. Not "hot-pluggable"
# alone: many laptops say that of their internal SATA disks.
is_stick() {
    local d h
    d=$(disk_of "$1")
    h=${HOT[$d]:-0}
    [[ $h == 1* || $h == *usb ]]
}
# shown TEXT: TEXT without control characters, to print (a mount point is
# named after the stick's label)
shown() { printf '%s' "${1//[[:cntrl:]]/?}"; }
# The sticks' file systems (and locked encrypted ones), in lsblk's order
STICKS=()
for p in "${ALL[@]}"; do
    [ -n "${FST[$p]}" ] && is_stick "$p" && STICKS+=("$p")
done

# opened DEV: the unlocked device of an encrypted DEV, if it is open
opened() {
    local p
    for p in "${ALL[@]}"; do
        if [ "${PARENT[$p]}" = "$1" ] && [ "${TYPE[$p]}" = crypt ]; then echo "$p"; return; fi
    done
}
# owner_ok DEV: mounted as you (a file system with no owners: its uid= is yours)
owner_ok() {
    local opts
    case $NO_OWNERS in *" ${FST[$1]} "*) ;; *) return 0 ;; esac
    opts=$(findmnt -no OPTIONS -S "$1" 2>/dev/null | head -n1)
    [[ ,$opts, == *,uid=$(id -u),* ]]
}
ours_to_touch() {
    local u
    for u in "${OURS_UNDER[@]}"; do [[ $1 == "$u"* ]] && return 0; done
    return 1
}
describe() {
    local p=$1 what
    what=${FST[$p]}
    case " $what " in
        " crypto_LUKS ") what="encrypted" ;;
        *" vfat "* | *" msdos "*) what="FAT" ;;
        " exfat ") what="exFAT" ;;
        *" ntfs"*) what="NTFS" ;;
    esac
    printf '%s' "$what"
}

list() {
    local p o
    if [ ${#STICKS[@]} -eq 0 ]; then
        echo "No USB sticks or drives are plugged in."
        return
    fi
    for p in "${STICKS[@]}"; do
        printf '  %-11s %-7s %-10s %-16s ' "${p#/dev/}" "${SIZE[$p]}" "$(describe "$p")" "${LABEL[$p]:-(no label)}"
        if [ "${FST[$p]}" = crypto_LUKS ]; then
            o=$(opened "$p")
            if [ -n "$o" ]; then echo "unlocked, as ${o#/dev/}"; else echo "locked: usb mount ${p#/dev/}"; fi
        elif [ -n "${MNT[$p]}" ]; then
            if owner_ok "$p"; then
                echo "mounted at $(shown "${MNT[$p]}")"
            else
                echo "mounted at $(shown "${MNT[$p]}") BY ROOT: you can't write to it (usb mount ${p#/dev/} fixes it)"
            fi
        else
            echo "not mounted: usb mount ${p#/dev/}"
        fi
    done
}

# pick NAME VERB: the stick NAME means (or the only one, when NAME is empty)
pick() {
    local name=$1 p hits=()
    for p in "${STICKS[@]}"; do
        # an unlocked encrypted stick is used through its unlocked device
        [ "${TYPE[$p]}" = crypt ] && continue
        if [ -z "$name" ] || [ "$p" = "$name" ] || [ "${p#/dev/}" = "$name" ] || [ "${LABEL[$p]}" = "$name" ]; then
            hits+=("$p")
        fi
    done
    if [ -z "$name" ] && [ ${#hits[@]} -gt 1 ]; then
        list >&2
        die "there is more than one: say which, as usb $2 NAME"
    fi
    [ ${#hits[@]} -gt 0 ] || {
        list >&2
        if [ -n "$name" ]; then die "$name is not a USB stick or drive"; fi
        die "nothing to $2"
    }
    [ ${#hits[@]} -eq 1 ] || die "more than one stick is called $name: use its device name (usb lists them)"
    echo "${hits[0]}"
}

yes_no() {
    local a
    [ -t 0 ] || return 1
    read -rp "$1 [y/N] " a
    [[ $a == [yY]* ]]
}

# mount_it DEV: mount DEV as you
mount_it() {
    local dev=$1 opts=nosuid,nodev,noexec out
    case $NO_OWNERS in
        # yours alone: folders rwx------, files rw------- (FAT has no x for files)
        *" ${FST[$dev]} "*) opts+=,dmask=0077,fmask=0177 ;;
    esac
    out=$(udisksctl mount -b "$dev" -o "$opts") || die "couldn't mount $dev"
    shown "$out"
    echo
    case $NO_OWNERS in
        *" ${FST[$dev]} "*)
            echo "$(describe "$dev") keeps no owners of its own: here every file is yours alone;"
            echo "on another computer, anyone who has the stick can read all of it." ;;
    esac
}

cmd_mount() {
    local dev o out u
    dev=$(pick "${1:-}" mount)
    if [ "${FST[$dev]}" = crypto_LUKS ]; then
        o=$(opened "$dev")
        if [ -z "$o" ]; then
            out=$(udisksctl unlock -b "$dev") || die "couldn't unlock $dev"
            # "Unlocked /dev/sdb1 as /dev/dm-3."
            o=${out##* as }
            o=${o%.}
            [[ $o =~ ^/dev/[a-z0-9/_-]+$ ]] || die "unlocked, but can't tell as what: $(shown "$out")"
            # what was unlocked is a new device, with its own file system. udev
            # reads that file system a moment later; and lsblk names the device
            # /dev/mapper/NAME, not the /dev/dm-N udisksctl says: so wait, look
            # again, and take the stick's unlocked child, whatever its name
            if command -v udevadm >/dev/null; then udevadm settle -t 5 2>/dev/null || true; fi
            ALL=() STICKS=()
            devices
            for p in "${ALL[@]}"; do [ -n "${FST[$p]}" ] && is_stick "$p" && STICKS+=("$p"); done
            u=$(opened "$dev")
            if [ -n "$u" ]; then o=$u; fi
        fi
        dev=$o
        [ -n "${FST[$dev]:-}" ] || die "$dev was unlocked but holds no file system that can be mounted"
    fi
    if [ -n "${MNT[$dev]:-}" ]; then
        if owner_ok "$dev"; then
            echo "Already mounted at $(shown "${MNT[$dev]}")"
            return
        fi
        say "${dev#/dev/} is mounted at $(shown "${MNT[$dev]}") by root (sudo mount): its files are root's, so you can't write to it."
        ours_to_touch "${MNT[$dev]}" || die "${MNT[$dev]} isn't where sticks are mounted; not touching it"
        yes_no "Unmount it (sudo asks for your password) and mount it again as you?" ||
            die "left as it is. Later: usb mount ${dev#/dev/}"
        sudo umount "${MNT[$dev]}" || die "couldn't unmount ${MNT[$dev]} (is a program using it?)"
        # sudo mount made the folder; it is empty now, and in the way of nothing
        rmdir "${MNT[$dev]}" 2>/dev/null || sudo rmdir "${MNT[$dev]}" 2>/dev/null || true
    fi
    mount_it "$dev"
}

cmd_unmount() {
    local dev o disk
    dev=$(pick "${1:-}" unmount)
    o=$dev
    [ "${FST[$dev]}" != crypto_LUKS ] || o=$(opened "$dev")
    if [ -n "$o" ] && [ -n "${MNT[$o]:-}" ]; then
        # a drive this computer runs from (/, /home) is never unmounted or switched off
        ours_to_touch "${MNT[$o]}" || die "${o#/dev/} is mounted at ${MNT[$o]}, not where sticks are; not touching it"
        if owner_ok "$o"; then
            udisksctl unmount -b "$o" || die "couldn't unmount ${MNT[$o]}: close the programs using it, then try again"
        else
            say "${MNT[$o]} was mounted by root; sudo unmounts it."
            sudo umount "${MNT[$o]}" || die "couldn't unmount ${MNT[$o]}: close the programs using it, then try again"
        fi
    fi
    [ "${FST[$dev]}" != crypto_LUKS ] || [ -z "$o" ] || udisksctl lock -b "$dev" || die "couldn't lock $dev"
    disk=$(disk_of "$dev")
    # the other file systems on the same drive must be unmounted too, before it is switched off
    for p in "${STICKS[@]}"; do
        if [ "$p" != "$o" ] && [ "$(disk_of "$p")" = "$disk" ] && [ -n "${MNT[$p]}" ]; then
            ours_to_touch "${MNT[$p]}" || { echo "Unmounted: safe to pull out."; return; }
            echo "Unmounted. ${disk#/dev/} also has ${p#/dev/} mounted at $(shown "${MNT[$p]}"): usb unmount ${p#/dev/} too, then pull it out."
            return
        fi
    done
    if udisksctl power-off -b "$disk" 2>/dev/null; then
        echo "Done: ${disk#/dev/} is switched off, and safe to pull out."
    else
        echo "Unmounted: safe to pull out."
    fi
}

case ${1:-list} in
    list | ls) list ;;
    mount) cmd_mount "${2:-}" ;;
    unmount | umount | eject) cmd_unmount "${2:-}" ;;
    -h | --help | help) sed -n '3,15p' "$0" | sed 's/^# \{0,1\}//' ;;
    *) die "usb list | mount [NAME] | unmount [NAME]   (usb --help says more)" ;;
esac