#!/bin/bash

#
# Profile-sync-daemon by graysky <graysky AT archlinux DOT us>
# Inspired by some code originally written by Colin Verot
#

# shellcheck disable=1090,2154

# needed for debian >=8.x
PATH=$PATH:/sbin

if [[ -n "$LAUNCHED_BY_SYSTEMD" ]] ; then
# This script was invoked by systemd
# Thx to notes-jj: https://serverfault.com/questions/926349/systemd-tell-if-script-was-run-by-systemd-or-by-user
# No 'echo' ANSI escape codes
  BLD=''
  RED=''
  GRN=''
  BLU=''
  NRM=''
else
  BLD="\e[01m"
  RED="\e[01;31m"
  GRN="\e[01;32m"
  BLU="\e[01;34m"
  NRM="\e[00m"
fi
VERS="8.0"

# msg goes to stdout; warn, error and die go to stderr, and die exits 1
msg() { echo -e "${BLD}$*${NRM}"; }
warn() { echo -e " ${RED}Warning:${NRM}${BLD} $*${NRM}" >&2; }
error() { echo -e " ${RED}Error:${NRM}${BLD} $*${NRM}" >&2; }
die() { error "$@"; exit 1; }

user=$(id -un)
HOME="$(getent passwd "$user" | cut -d: -f6)"
TMPDIR="${TMPDIR:-/tmp}"
XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
PSDCONFDIR="$XDG_CONFIG_HOME/psd"
PSDCONF="$PSDCONFDIR/psd.conf"
SHAREDIR="/usr/share/psd"

if [[ ! -d "$SHAREDIR" ]]; then
  die "Missing ${BLU}$SHAREDIR${NRM}${BLD} - reinstall the package to use profile-sync-daemon."
fi

if [[ ! -d "$SHAREDIR/browsers" ]]; then
  die "Missing ${BLU}$SHAREDIR/browsers${NRM}${BLD} - reinstall the package to use profile-sync-daemon."
fi

if [[ $EUID -eq 0 ]]; then
  die "Do not call ${BLU}$0${NRM}${BLD} as root."
fi

# Create an XDG_RUNTIME_DIR if not given one (i.e. running without DE)
# See https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
if [[ -z "$XDG_RUNTIME_DIR" ]]; then
  XDG_RUNTIME_DIR="${TMPDIR}/runtime-${user}"
  echo -e " ${RED}WARNING:${NRM}${BLD} XDG_RUNTIME_DIR not set, defaulting to '${XDG_RUNTIME_DIR}'.${NRM}"
  if [[ ! -d "$XDG_RUNTIME_DIR" ]] && ! mkdir "$XDG_RUNTIME_DIR"; then
    die "Unable to create runtime directory '${XDG_RUNTIME_DIR}'."
  fi
  if ! chown "${user}" "${XDG_RUNTIME_DIR}"; then
    die "Unable to make user '${user}' owner of runtime directory '${XDG_RUNTIME_DIR}'."
  fi
  if ! chmod 0700 "${XDG_RUNTIME_DIR}"; then
    die "Unable to make runtime directory '${XDG_RUNTIME_DIR}' only accessible to user '$user'."
  fi
elif [[ ! -d "$XDG_RUNTIME_DIR" ]]; then
  die "Cannot find XDG_RUNTIME_DIR which should be set by systemd."
fi

VOLATILE="$XDG_RUNTIME_DIR/psd"
PID_FILE="$XDG_RUNTIME_DIR/psd.pid"
SUSP_PID_FILE="$XDG_RUNTIME_DIR/psd-suspend-sync.pid"
# the profiles a session synced, written before it touches any of them and
# removed when they are all back on disk; recovery reads it with the config
SESSION_FILE="$PSDCONFDIR/.psd-session"

# runs that change state are serialized on this lock (see take_lock); every
# background child is started with the fd closed so the lock dies with us
exec {lockfd}>>"$XDG_RUNTIME_DIR/psd.lock" ||
  die "Unable to open $XDG_RUNTIME_DIR/psd.lock"

# Setup check for config file
if [[ -f "$PSDCONF" ]]; then
  if [[ ! -f "$PID_FILE" ]]; then
    # do nothing if psd is currently running, otherwise
    # make sure only comments and variables/arrays are defined to prevent
    # problems like issue #166
    if grep -Eqv '^$|^#|^[^ ]*=[^;]*' "$PSDCONF"; then
      # something that isn't a blank line, comment, or variable present
      # so exit
      echo -e " ${RED}ERROR:${NRM}${BLD} Syntax error(s) detected in ${BLU}$PSDCONF${NRM}${BLD} - edit and try again.${NRM}"
      echo -e "${NRM}${BLD}Line number: offending comment${NRM}"
      grep -Evn '^$|^#|^[^ ]*=[^;]*' "$PSDCONF"
      exit 1
    fi
  fi
  . "$PSDCONF"
elif [[ -d "$HOME/.psd" ]]; then
  # first check to see if a legacy ~/.psd is present and then move it to the
  # new location
  mkdir -p "$PSDCONFDIR"
  rsync -aX "$HOME/.psd/" "$PSDCONFDIR/"
  rm -rf "$HOME/.psd"
  echo " The use of $HOME/.psd is deprecated. Existing config has been moved to $PSDCONFDIR"
  # source it again retaining any user options
  [[ -f "$PSDCONF" ]] && . "$PSDCONF"
else
  mkdir -p "$PSDCONFDIR"
  if [[ -f "$SHAREDIR/psd.conf" ]]; then
    cp "$SHAREDIR/psd.conf" "$PSDCONFDIR"
    echo -e " First time running psd so please edit ${BLU}$PSDCONF${NRM}${BLD} to your liking and run again.${NRM}"
    exit 0
  fi
fi

# if psd is active, source the snapshot of psd.conf preferentially
# version 6.03 renames this file so if older version is running then
# rotate the old name to the new one

[[ -f "$PSDCONFDIR/.$user@$(uname -n).pid.conf" ]] &&
  mv "$PSDCONFDIR/.$user@$(uname -n).pid.conf" "$PSDCONFDIR/.psd.conf"

if [[ -f "$PID_FILE" ]]; then
  if [[ -f "$PSDCONFDIR/.psd.conf" ]]; then
    PSDCONF="$PSDCONFDIR/.psd.conf"
    unset USE_SUSPSYNC BROWSERS USE_BACKUPS BACKUP_LIMIT
    . "$PSDCONF"
    # defining VOLATILE in the config is deprecated since v6.16
    VOLATILE="$XDG_RUNTIME_DIR/psd"
  fi
fi

# define default number of crash-recovery snapshots to save if the user did not
# and check that it is an integer if user did define it
if [[ -z "$BACKUP_LIMIT" ]]; then
  BACKUP_LIMIT=5
else
  if [[ "$BACKUP_LIMIT" =~ ^[0-9]+$ ]]; then
    # correctly setup
    true
  else
    die "Bad value for BACKUP_LIMIT detected!"
  fi
fi

# scope to sync defined in BROWSERS array or from list of supported browsers
if [[ -z "$BROWSERS" ]]; then
  mapfile -t BROWSERS < <(find "$SHAREDIR/browsers" -type f -printf "%f\n")
else
  if ! declare -p BROWSERS | grep -q 'declare -a'; then
    # did not setup as array so redefine it here
    IFS=' ' read -r -a BROWSERS <<< "${BROWSERS[@]}"
  fi
fi

# remove duplicate elements if user accidentally polluted array with same entries
# https://stackoverflow.com/questions/13648410/how-can-i-get-unique-values-from-an-array-in-bash
readarray -t BROWSERS < <(printf '%s\n' "${BROWSERS[@]}" | awk '!x[$0]++')

# simple function to determine user intent rather than using a null value
case "${USE_SUSPSYNC,,}" in
  y|yes|true|t|on|1|enabled|enable|use)
    SUSPSYNC=1
    ;;
  *)
    SUSPSYNC=0
    ;;
esac

# since the default for this one is a yes, need to force a null value to yes
[[ -z "${USE_BACKUPS,,}" ]] && USE_BACKUPS="yes"

case "${USE_BACKUPS,,}" in
  y|yes|true|t|on|1|enabled|enable|use)
    CRRE=1
    ;;
  *)
    CRRE=0
    ;;
esac

header() {
  echo -e "${BLD}Profile-sync-daemon v$VERS${NRM}"
  echo
}

dep_check() {
  # checks for dependencies and that psd.conf is setup correctly
  if ! command -v rsync >/dev/null 2>&1; then
    die "I require rsync but it's not installed."
  fi
  if ! command -v modinfo >/dev/null 2>&1; then
    die "I require modinfo but it's not installed."
  fi
  for _bin in flock findmnt; do
    command -v "$_bin" >/dev/null 2>&1 || die "I require $_bin but it's not installed."
  done
  if ! command -v awk >/dev/null 2>&1; then
    die "I require awk but it's not installed."
  fi
  if ! command -v gdbus >/dev/null 2>&1; then
    if [[ $SUSPSYNC -eq 1 ]]; then
      die "I require gdbus but it's not installed."
    fi
  fi

  if [[ -f /etc/psd.conf ]]; then
    die "This version of psd does not support /etc/psd.conf since it runs as your user. Remove it and use systemd's user mode: systemctl --user <action> psd.service"
  fi
}

config_check() {
  # fuse3 is a depends of fuse-overlayfs but there are static builds floating around so to protect data, check for both
  for _bin in fuse-overlayfs fusermount3; do
    if ! command -v "$_bin" >/dev/null 2>&1; then
      die "To use overlayfs mode, install fuse-overlayfs and fuse3"
    fi
  done

  for browser in "${BROWSERS[@]}"; do
    if [[ ! -f "$SHAREDIR/browsers/$browser" ]]; then
      # user defined an invalid browser
      die "${RED}$browser${NRM}${BLD} is not a supported browser. Check config file for typos: ${NRM}${BLU}$PSDCONF"
    fi
  done
}

ungraceful_state_check() {
  # put every profile left by a crash or a failed stop back on disk; returns
  # nonzero if any could not be, and keeps the session file for the next try
  local i failed=0 m unknown=0
  local -A known=()
  set_legacy
  session_profiles config
  for i in "${!PROF_D[@]}"; do
    use_profile "$i"
    known[$TMP]=1
    recover_profile || failed=1
  done
  # a mount of a profile neither the session nor the config lists, e.g. one
  # an older psd synced that left its browser's profile list; its dead mount
  # is detached so it does not block later stops, and the session record is
  # kept for when the profile is listed again
  while read -r m; do
    [[ -z "$m" || -n "${known[$m]}" ]] && continue
    unknown=1
    if ! mount_alive "$m"; then
      fusermount3 -u "$m" 2>/dev/null || fusermount3 -uz "$m"
    fi
    warn "$m belongs to a profile psd does not know any more; its writes since the last copy to disk are in $m-rw until reboot. List the profile again and run: psd recover"
  done < <(psd_mounts)
  [[ $failed -eq 0 ]] || return 1
  # what is left describes a session that is over
  if [[ ! -f "$PID_FILE" && $unknown -eq 0 ]]; then
    rm -f "$SESSION_FILE" "$PSDCONFDIR/.psd.conf"
  fi
}

# set_legacy: LEGACY=1 for a session started by psd 7.04 or older, which
# never marks BACK_OVFS complete; its config snapshot lacks
# PSD_SESSION_MARKERS and survives a crash
set_legacy() {
  LEGACY=0
  if [[ -f "$PSDCONFDIR/.psd.conf" ]] &&
    ! grep -qx 'PSD_SESSION_MARKERS=1' "$PSDCONFDIR/.psd.conf"; then
    LEGACY=1
  fi
}

# recover_profile: put the current profile back on disk as a real DIR. Each
# step leaves a state the next run finishes, so a recovery that is
# interrupted never demotes the newest copy.
recover_profile() {
  local now keep save aside replayed=0 legacy=$LEGACY
  # nothing changes BACKUP itself during a session, so a BACKUP that is not
  # the one this session recorded comes from a later session of an older psd
  # that kept this session's config snapshot
  if [[ -n "$BACKUP_CTIME" && -d "$BACKUP" ]] &&
    [[ "$(stat -c %.9Z "$BACKUP")" != "$BACKUP_CTIME" ]]; then
    legacy=1
  fi
  # psd never puts a marker in BACKUP; one there was carried along by an
  # older psd, which copies it with the profile, so markers mean nothing here
  if [[ -e "$BACKUP/.psd-complete" ]]; then
    legacy=1
  fi
  # all is well
  [[ -e "$DIR/.flagged" ]] && mount_alive "$TMP" && return 0
  now=$(date +%Y%m%d_%H%M%S)

  # a mount whose fuse daemon died; detach it before anything else, so a
  # failure leaves DIR as it was
  if mounted "$TMP" && ! mount_alive "$TMP"; then
    if ! fusermount3 -u "$TMP" 2>/dev/null && ! fusermount3 -uz "$TMP"; then
      error "Failed to detach the dead mount $TMP"
      return 1
    fi
  fi

  if [[ -h "$DIR" && "$(readlink "$DIR")" == "$TMP" ]]; then
    # psd's own link; while the mount is live it holds the newest data
    if mounted "$TMP"; then
      error "$TMP is still mounted but $DIR is not flagged; close the browser, then run: fusermount3 -u $TMP; psd recover"
      return 1
    fi
    echo "Ungraceful state detected for $DIR so fixing"
    # writes made after the last copy are in UPPER; while the link stays,
    # a failure leaves the profile pointing at them rather than missing
    if [[ -d "$BACKUP" ]] && unsaved_writes; then
      if ! replay_upper; then
        error "The writes in $UPPER could not be copied to disk (is it full?); they are kept until reboot. Free some space and run: psd recover"
        return 1
      fi
      replayed=1
    fi
    unlink "$DIR" || return 1
  elif [[ -h "$DIR" ]]; then
    # symlinked browser profiles are not supported
    if [[ -d $(readlink "$DIR") ]]; then
      error "${BLU}$DIR${NRM}${BLD} is a symlink, which psd does not support. Make the browser profile a real directory and try again."
      return 1
    fi
    echo "Ungraceful state detected for $DIR so fixing"
    unlink "$DIR" || return 1
  fi

  if [[ -d "$DIR" && ! -h "$DIR" && -e "$DIR/.psd-complete" ]]; then
    # a recovery stopped after promoting the newest copy; finish it
    echo "$DIR: finishing an interrupted recovery"
    if [[ $legacy -eq 0 ]]; then
      aside="$BACKUP-crashrecovery-$now-start"
    else
      aside="$BACKUP-crashrecovery-$now-unused"
    fi
    if [[ -d "$BACKUP" ]] && ! rmdir "$BACKUP" 2>/dev/null; then
      mv --no-target-directory "$BACKUP" "$aside" || return 1
      echo "$DIR: the copy made when psd started is kept as $aside"
    fi
    rm -f "$DIR/.psd-complete"
    flush "$DIR"
    unsaved_writes &&
      warn "$UPPER holds writes that were not copied to disk; they are lost at reboot"
    return 0
  fi

  # in the same boot, writes made after the last copy to disk are still in
  # UPPER over BACKUP
  if [[ -d "$BACKUP" ]] && ! mounted "$TMP" && unsaved_writes; then
    if ! replay_upper; then
      # keep the state for another try; a link keeps the browser from
      # creating a fresh profile meanwhile
      [[ -e "$DIR" || -h "$DIR" ]] || ln -sT "$TMP" "$DIR"
      error "The writes in $UPPER could not be copied to disk (is it full?); they are kept until reboot. Free some space and run: psd recover"
      return 1
    fi
    replayed=1
  fi

  [[ -d "$BACKUP" ]] || return 0

  if [[ -d "$DIR" ]]; then
    echo "Unexpected state detected: we have $BACKUP, but $DIR already exists. Moving $DIR to $DIR-old-profile-$now"
    mv --no-target-directory "$DIR" "$DIR-old-profile-$now" || return 1
  fi

  # BACKUP (the lowerdir) is readonly so it holds the profile as it was when
  # psd started, while BACK_OVFS holds the last resync. BACK_OVFS is only
  # newer if a resync into it completed, which is marked by .psd-complete,
  # otherwise it may be stale or partial
  keep=$BACKUP save=
  if [[ -d "$BACK_OVFS" ]]; then
    save=$BACK_OVFS
    if [[ -e "$BACK_OVFS/.psd-complete" && ! -e "$BACKUP/.psd-complete" ]]; then
      keep=$BACK_OVFS save=$BACKUP
    elif [[ $legacy -eq 1 ]] &&
      [[ -n "$(find "$BACK_OVFS" -mindepth 1 -maxdepth 1 -print -quit)" ]] &&
      [[ $(stat -c %Z "$BACK_OVFS") -gt $(stat -c %Z "$BACKUP") ]]; then
      # an old session: use its rule, the newer copy unless it is empty
      echo "$DIR: crash in a session started by an older psd, using its newer copy"
      # marked, so an interrupted promotion is finished rather than undone
      touch "$BACK_OVFS/.psd-complete" && flush "$BACK_OVFS" || return 1
      keep=$BACK_OVFS save=$BACKUP
    fi
  fi

  if [[ "$keep" == "$BACK_OVFS" ]]; then
    if [[ $replayed -eq 1 ]]; then
      echo "$DIR: restoring the writes left in tmpfs"
    else
      echo "$DIR: restoring the last copy written to disk"
    fi
  else
    echo "$DIR: restoring the copy made when psd started"
  fi
  # BACKUP is complete and older than a BACK_OVFS that won, and is set aside
  # below unless it is empty; a snapshot would only duplicate the copy
  # restored. A BACK_OVFS that lost may be partial, so it is no fallback.
  if [[ $CRRE -eq 1 ]] && { [[ "$keep" != "$BACK_OVFS" ]] ||
    [[ -z "$(find "$BACKUP" -mindepth 1 -maxdepth 1 -print -quit)" ]]; }; then
    # complete snapshots only ever carry the final name
    if cp -a --reflink=auto "$keep" "$BACKUP-crashrecovery-$now-partial" &&
      rm -f "$BACKUP-crashrecovery-$now-partial/.psd-complete" &&
      flush "$BACKUP-crashrecovery-$now-partial" &&
      mv --no-target-directory "$BACKUP-crashrecovery-$now-partial" "$BACKUP-crashrecovery-$now"; then
      echo "$DIR: a snapshot of it is kept as $BACKUP-crashrecovery-$now"
    else
      warn "Failed to snapshot $keep to $BACKUP-crashrecovery-$now"
    fi
  fi

  # the winner goes into place first; the other copy is never deleted, only
  # kept aside unless it is empty
  if [[ "$keep" == "$BACK_OVFS" ]]; then
    mv --no-target-directory "$BACK_OVFS" "$DIR" || return 1
    if [[ $legacy -eq 0 ]]; then
      # a copy this session marked is newer than BACKUP by construction, so
      # BACKUP goes with the snapshots and is rotated like them
      aside="$BACKUP-crashrecovery-$now-start"
    else
      aside="$BACKUP-crashrecovery-$now-unused"
    fi
    if ! rmdir "$BACKUP" 2>/dev/null; then
      mv --no-target-directory "$BACKUP" "$aside" || return 1
      echo "$DIR: the copy made when psd started is kept as $aside"
    fi
    rm -f "$DIR/.psd-complete"
  else
    if [[ -n "$save" ]] && ! rmdir "$save" 2>/dev/null; then
      mv --no-target-directory "$save" "$BACKUP-crashrecovery-$now-unused" || return 1
      echo "$DIR: the unused copy is kept as $BACKUP-crashrecovery-$now-unused"
    fi
    mv --no-target-directory "$BACKUP" "$DIR" || return 1
    rm -f "$DIR/.psd-complete"
  fi
  flush "$DIR"
}

# replay_upper: the fuse daemon of the current profile died and its newest
# writes are in UPPER. Mount UPPER over BACKUP again and copy the result to
# BACK_OVFS as a complete copy, which recovery then prefers. UPPER is kept
# until that copy is marked, so an interrupted replay is just done again.
replay_upper() {
  local rc=0
  msg "Copying the writes left in $UPPER to disk"
  remove_tree "$WORK"
  if install -dm"$(stat -c %a "$BACKUP")" "$TMP" &&
    install -dm700 "$WORK" && mount_overlay; then
    new_generation "$TMP" || rc=1
    fusermount3 -u "$TMP" 2>/dev/null || fusermount3 -uz "$TMP"
  else
    rc=1
  fi
  if [[ $rc -ne 0 ]]; then
    # an empty TMP would let a link to it look like a fresh profile
    mounted "$TMP" || rmdir "$TMP" 2>/dev/null
    return 1
  fi
  discard "$UPPER" "$WORK"
  rmdir "$TMP" 2>/dev/null
  return 0
}

# new_generation <src>: copy <src> to a new marked copy beside BACK_OVFS and
# swap it in, so the current complete copy is never touched. Unchanged files
# are hard links into the current copy. A leftover BACK_OVFS.new is never a
# recovery candidate.
new_generation() {
  local new=$BACK_OVFS.new err changes
  local -a base=()
  if ! can_exchange; then
    copy_generation "$1"
    return
  fi
  # an unchanged profile needs no new copy; only a dry run that worked and
  # found nothing to do proves that. Dir times carry no profile data (and
  # the marker changes the top one).
  if [[ -e "$BACK_OVFS/.psd-complete" ]] &&
    changes=$(rsync -aX --modify-window=-1 --omit-dir-times --delete --dry-run --itemize-changes --exclude .flagged --exclude .psd-complete "$1/" "$BACK_OVFS/" 2>/dev/null) &&
    [[ -z "$changes" ]]; then
    return 0
  fi
  # never copy into what is left of an earlier attempt
  remove_tree "$new" || return 1
  # unchanged files are linked from the current copy, or for the first copy
  # of a session from the last session's
  if [[ -d "$BACK_OVFS" ]]; then
    base=(--link-dest="$BACK_OVFS")
  elif [[ -d "$BACK_OVFS.base" ]]; then
    base=(--link-dest="$BACK_OVFS.base")
  fi
  install -dm"$(stat -c %a "$BACKUP")" "$new" || return 1
  if ! copy_tree -aX --modify-window=-1 "${base[@]}" --exclude .flagged --exclude .psd-complete "$1/" "$new/"; then
    if [[ -d "$BACK_OVFS" ]]; then
      error "Failed to copy $1 to $new (is the disk full?); $BACK_OVFS is kept"
    else
      error "Failed to copy $1 to $new (is the disk full?)"
    fi
    # its space may be needed by the next profile
    remove_tree "$new"
    return 1
  fi
  flush "$new" && touch "$new/.psd-complete" && flush "$new" || return 1
  if [[ ! -d "$BACK_OVFS" ]]; then
    mv --no-target-directory "$new" "$BACK_OVFS" && flush "$BACK_OVFS" || return 1
    remove_tree "$BACK_OVFS.base" || warn "Failed to remove $BACK_OVFS.base"
    return 0
  fi
  if ! err=$(LC_ALL=C mv --exchange --no-target-directory "$new" "$BACK_OVFS" 2>&1); then
    remove_tree "$new"
    if [[ "$err" == *"Invalid argument"* || "$err" == *"not supported"* ||
      "$err" == *"not implemented"* || "$err" == *"not permitted"* ]]; then
      # a filesystem or sandbox without RENAME_EXCHANGE
      if [[ ! -e "$XDG_RUNTIME_DIR/psd-no-exchange" ]]; then
        warn "$BACK_OVFS cannot be swapped on this filesystem, copying in place"
        touch "$XDG_RUNTIME_DIR/psd-no-exchange"
      fi
      copy_generation "$1"
      return
    fi
    error "Failed to swap $new in: $err"
    return 1
  fi
  flush "$BACK_OVFS" || return 1
  # the old copy is not needed any more; a leftover is removed next time
  remove_tree "$new" || warn "Failed to remove the old copy $new"
  return 0
}

# can_exchange: whether mv can swap two dirs (coreutils 9.5 or newer)
can_exchange() {
  if [[ -z "$CAN_EXCHANGE" ]]; then
    CAN_EXCHANGE=0
    mv --help 2>/dev/null | grep -q -- '--exchange' && CAN_EXCHANGE=1
  fi
  [[ $CAN_EXCHANGE -eq 1 ]]
}

# remove_tree <dir>: delete <dir>, also when it holds read-only dirs, which
# only the dirs need for that; files may be hard links into a copy in use
remove_tree() {
  [[ -e "$1" || -h "$1" ]] || return 0
  [[ -d "$1" && ! -h "$1" ]] &&
    find "$1" -type d ! -perm -u+wx -exec chmod u+wx {} + 2>/dev/null
  rm -rf "$1"
  if [[ -e "$1" || -h "$1" ]]; then
    error "Failed to remove $1"
    return 1
  fi
}

# discard <path>...: delete tmpfs dirs of a profile; each is first renamed
# into the trash, so an interrupted delete never leaves one half deleted
discard() {
  local trash=$VOLATILE/.psd-trash p
  mkdir -p "$trash" || return 1
  for p in "$@"; do
    [[ -e "$p" || -h "$p" ]] || continue
    mv --no-target-directory "$p" "$trash/${p##*/}.$$.$RANDOM" || return 1
  done
  remove_tree "$trash"
}

# mount_overlay: mount the current profile's overlay at TMP
mount_overlay() {
  local opts="lowerdir=$BACKUP,upperdir=$UPPER,workdir=$WORK"
  if [[ $FIREJAIL -eq 1 ]]; then
    opts="allow_root,$opts"
  fi
  # the fuse daemon outlives psd, so it must not inherit the lock
  fuse-overlayfs -o "$opts" "$TMP" {lockfd}>&-
  if ! mount_alive "$TMP"; then
    error "Failed to mount $TMP"
    return 1
  fi
}

# copy_tree <rsync args...>: rsync, where files that vanished during the copy
# (exit 24, e.g. a browser's journal files) are not an error: they are gone
copy_tree() {
  local rc=0
  rsync "$@" || rc=$?
  [[ $rc -eq 0 || $rc -eq 24 ]]
}

# copy_generation <src>: copy <src> to BACK_OVFS in place and mark it
# complete, for filesystems that cannot swap. The marker's removal is on disk
# before rsync rewrites files in place, and the data is on disk before the
# marker is put back; until then recovery falls back to BACKUP.
copy_generation() {
  if [[ ! -d "$BACK_OVFS" ]]; then
    install -dm"$(stat -c %a "$BACKUP")" "$BACK_OVFS" || return 1
  fi
  rm -f "$BACK_OVFS/.psd-complete"
  flush "$BACK_OVFS" || return 1
  if ! copy_tree -aX --modify-window=-1 --delete-after --inplace --no-whole-file --exclude .flagged --exclude .psd-complete "$1/" "$BACK_OVFS/"; then
    error "Failed to copy $1 to $BACK_OVFS"
    return 1
  fi
  flush "$BACK_OVFS" &&
    touch "$BACK_OVFS/.psd-complete" &&
    flush "$BACK_OVFS"
}

# write_file <file>: replace <file> with stdin so that it is either the old
# or the new content, also after a power cut
write_file() {
  rm -f "$1.new"
  if ! cat > "$1.new" || ! chmod 400 "$1.new" || ! flush "$1.new" ||
    ! mv -f "$1.new" "$1" || ! flush "$1"; then
    error "Failed to write $1"
    return 1
  fi
}

# session_profiles [config]: set PROF_* to the profiles of the running or
# last session, from the session file or, for a session started by an older
# psd, from the config. With "config" the config's profiles are added, so
# recovery also finds profiles that left a session and ones it never had.
session_profiles() {
  local b d t u w id='' trusted=0 line
  local -A seen=()
  local -a hint=()
  PROF_B=() PROF_D=() PROF_T=() PROF_U=() PROF_W=() PROF_C=()
  # the file belongs to the session only if its id is the one the running
  # session put in the PID file, or with none running, the one in the
  # config snapshot; older psd versions write neither
  if [[ -f "$SESSION_FILE" ]]; then
    read -r line < "$SESSION_FILE"
    id=${line#\# psd session }
    if [[ -n "$id" && "$id" != "$line" ]]; then
      if [[ -f "$PID_FILE" ]]; then
        [[ "$(< "$PID_FILE")" == "$id" ]] && trusted=1
      elif grep -qxF "PSD_SESSION_ID=$id" "$PSDCONFDIR/.psd.conf" 2>/dev/null; then
        trusted=1
      fi
    fi
  fi
  if [[ $trusted -eq 1 || "${1:-}" == config ]] && [[ -f "$SESSION_FILE" ]]; then
    while IFS=$'\t' read -r b d t u w c; do
      [[ -z "$b" || "$b" == \#* ]] && continue
      if ! valid_entry "$d" "$t" "$u" "$w"; then
        warn "Ignoring a malformed line in $SESSION_FILE"
        continue
      fi
      if [[ $trusted -eq 1 ]]; then
        add_profile "$b" "$d" "$t" "$u" "$w" "$c"
      else
        hint+=("$b"$'\t'"$d"$'\t'"$t"$'\t'"$u"$'\t'"$w")
      fi
    done < "$SESSION_FILE"
    [[ $trusted -eq 1 && "${1:-}" != config ]] && return 0
  fi
  for b in "${BROWSERS[@]}"; do
    load_env_for "$b"
    for d in "${DIRArr[@]}"; do
      profile_paths "$d"
      add_profile "$b" "$DIR" "$TMP" "$UPPER" "$WORK"
    done
  done
  # a session file of another session only adds profiles the config lacks
  for line in "${hint[@]}"; do
    IFS=$'\t' read -r b d t u w <<< "$line"
    add_profile "$b" "$d" "$t" "$u" "$w"
  done
}

# add_profile <browser> <dir> <tmp> <upper> <work> [ctime]: add to PROF_*,
# once per dir; ctime is BACKUP's as this session recorded it
add_profile() {
  [[ -n "${seen[$2]}" ]] && return
  seen[$2]=1
  PROF_B+=("$1") PROF_D+=("$2") PROF_T+=("$3") PROF_U+=("$4") PROF_W+=("$5")
  PROF_C+=("${6:-}")
}

# valid_entry <dir> <tmp> <upper> <work>: paths psd itself could have written
valid_entry() {
  local p
  [[ "$1" == /* ]] || return 1
  for p in "$2" "$3" "$4"; do
    [[ "$p" == "$VOLATILE"/* ]] || return 1
  done
  for p in "$@"; do
    [[ "/$p/" == */../* || "/$p/" == */./* ]] && return 1
  done
  return 0
}

# use_profile <i>: make entry <i> of PROF_* the current profile
use_profile() {
  if [[ -f "$SHAREDIR/browsers/${PROF_B[$1]}" ]]; then
    load_env_for "${PROF_B[$1]}"
  else
    browser=${PROF_B[$1]}
  fi
  DIR=${PROF_D[$1]}
  BACKUP=$DIR-backup
  BACK_OVFS=$DIR-back-ovfs
  TMP=${PROF_T[$1]}
  UPPER=${PROF_U[$1]}
  WORK=${PROF_W[$1]}
  BACKUP_CTIME=${PROF_C[$1]}
}

cleanup() {
  local browser
  for browser in "${BROWSERS[@]}"; do
    load_env_for "$browser"
    for item in "${DIRArr[@]}"; do
      DIR="$item"

      local CRASHArr=()
      crash_dirs "$DIR"

      if [[ ${#CRASHArr[@]} -gt 0 ]]; then
        echo -e "${BLD}Deleting ${#CRASHArr[@]} crashrecovery dir(s) for profile ${BLU}$DIR${NRM}"
        for backup in "${CRASHArr[@]}"; do
          echo -e "${BLD}${RED} $backup${NRM}"
          remove_tree "$backup"
        done
      else
        echo -e "${BLD}Found no crashrecovery dirs for: ${BLU}$DIR${NRM}${BLD}${NRM}"
      fi
      echo
    done
  done
}

# crash_dirs <dir> [snapshots]: set CRASHArr to the crashrecovery dirs of
# profile <dir>, newest first. With "snapshots" only the timestamped copies
# made by USE_BACKUPS are listed, not the -unused copies recovery sets aside.
crash_dirs() {
  local pattern path
  # escape glob characters in the profile name for find -name
  pattern=$(printf '%s' "${1##*/}" | sed 's/[][*?\\]/\\&/g')
  CRASHArr=()
  while IFS= read -d '' -r path; do
    if [[ "${2:-}" = snapshots ]] &&
      [[ ! "${path##*/}" =~ -backup-crashrecovery-[0-9]{8}_[0-9]{6}(-start)?$ ]]; then
      continue
    fi
    CRASHArr+=("$path")
  done < <(find "${1%/*}" -maxdepth 1 -type d -name "$pattern-backup-crashrecovery-*" -print0 | sort -r -z)
}

# flush <path>: make everything written so far to the filesystem holding
# <path> durable, so a marker written after it cannot outlive the data
flush() {
  if ! sync -f "$1"; then
    error "Failed to flush $1 to disk"
    return 1
  fi
}

# mounted <dir>: whether something is mounted on <dir>, live or with a dead
# fuse daemon
mounted() {
  findmnt -rn -M "$1" >/dev/null 2>&1
}

# mount_alive <dir>: whether <dir> is a mount whose fuse daemon still answers.
# mountpoint(1) reports a dead fuse mount as mounted, and cached attributes
# make files on it look present, so ask the daemon by listing the dir.
mount_alive() {
  mounted "$1" && ls -A -- "$1" >/dev/null 2>&1
}

# psd_mounts: the mountpoints in VOLATILE, live or with a dead daemon
psd_mounts() {
  local m
  findmnt -rn -o TARGET | while read -r m; do
    # raw output escapes blanks and backslashes as \xHH
    m=$(printf '%b' "$m")
    [[ "$m" == "$VOLATILE"/* ]] && echo "$m"
  done
}

load_env_for() {
  browser="$1"

  homedir=$HOME
  group=$(id -g "$user")

  ### Arrays
  # profileArr is transient used to store profile paths parsed from
  # firefox and aurora
  unset profileArr
  # DIRArr is a full path corrected for both relative and absolute paths
  # reset global variables and arrays
  unset DIRArr

  unset PSNAME check_suffix
  . "$SHAREDIR/browsers/$browser"
}

running_check() {
  # check for browsers running and refuse to start if so
  # without this cannot guarantee profile integrity
  local browser
  for browser in "${BROWSERS[@]}"; do
    load_env_for "$browser"
    [[ -z "$PSNAME" ]] && continue
    if browsers_running "$(proc_name "$PSNAME" | sed 's/[][\\.^$*+?(){}|]/\\&/g')"; then
      echo "Refusing to start; $browser is running by $user!"
      exit 1
    fi
  done
}

# runtime_space <dir>...: the free space in the runtime dir, where the
# browser's writes go, and the largest profile file; rewriting it (e.g. a
# database vacuum) briefly holds two copies there; for preview
runtime_space() {
  local avail largest
  avail=$(df --output=avail -k "$XDG_RUNTIME_DIR" 2>/dev/null | tail -n1)
  largest=$(find "$@" -type f -printf '%s\n' 2>/dev/null | sort -n | tail -n1)
  [[ "$avail" =~ ^[0-9]+$ && "$largest" =~ ^[0-9]+$ ]] || return 0
  printf '%s MiB free, largest file %s MiB (a rewrite may briefly need %s MiB)\n' \
    "$(( avail / 1024 ))" "$(( largest / 1048576 ))" "$(( largest * 2 / 1048576 ))"
}

# runtime_space_low: warn when the runtime dir is nearly full, since the
# browser cannot save once it is
runtime_space_low() {
  local used
  used=$(df --output=pcent "$XDG_RUNTIME_DIR" 2>/dev/null | tail -n1 | tr -dc 0-9)
  [[ -n "$used" ]] && (( used >= 90 )) &&
    warn "$XDG_RUNTIME_DIR is ${used}% full; the browser cannot save once it is full. Raise RuntimeDirectorySize in logind.conf."
  return 0
}

# proc_name <name>: <name> as the kernel shows it to pgrep, at most 15 chars
proc_name() {
  printf '%s' "${1:0:15}"
}

# profile_paths <dir>: set the paths psd uses for profile <dir> of the
# browser last loaded with load_env_for. Multi-profile browsers (check_suffix)
# name the tmpfs dirs after the last directory of the profile path.
profile_paths() {
  local suffix=
  [[ -n "$check_suffix" ]] && suffix="-${1##*/}"
  DIR=$1
  BACKUP=$1-backup
  BACK_OVFS=$1-back-ovfs
  TMP=$VOLATILE/$user-$browser$suffix
  UPPER=$VOLATILE/$user-$browser$suffix-rw
  WORK=$VOLATILE/.$user-$browser$suffix
}

dup_check() {
  # every profile gets its own tmpfs, upper and work dir named after the
  # browser and, for multi-profile browsers, the LAST directory of the profile
  # path; two profiles sharing any of those names would be synced over each
  # other, so refuse to run if any name repeats across all browsers
  local browser item name
  local -A seen=() paths=()
  for browser in "${BROWSERS[@]}"; do
    load_env_for "$browser"
    for item in "${DIRArr[@]}"; do
      # only profiles psd will actually sync, each path once
      [[ -d "$item" || -h "$item" || -d "$item-backup" ]] || continue
      # the session file is one tab separated line per profile
      if [[ "$item" == *$'\t'* || "$item" == *$'\n'* ]]; then
        die "${BLU}$item${NRM}${BLD}: profile paths with tabs or newlines are not supported."
      fi
      if [[ -n "${paths[$item]}" ]]; then
        # listed twice by one browser is harmless, by two is not
        [[ "${paths[$item]}" = "$browser" ]] && continue
        die "${BLU}$item${NRM}${BLD} is a profile of both ${paths[$item]} and $browser; remove one from BROWSERS."
      fi
      paths[$item]=$browser
      profile_paths "$item"
      for name in "${TMP##*/}" "${UPPER##*/}" "${WORK##*/}"; do
        if [[ -n "${seen[$name]}" ]]; then
          die "Profiles ${BLU}${seen[$name]}${NRM}${BLD} and ${BLU}$item${NRM}${BLD} would share ${BLU}$VOLATILE/$name${NRM}${BLD}. The last directory of each profile path must be unique, see the psd man page."
        fi
        seen[$name]=$item
      done
    done
  done
}

kill_browsers() {
  # give browsers that are already quitting (e.g. at logout) a moment, ask
  # the rest to quit at once, give them time to save their profiles, and
  # only then kill what is left. A second TERM can cut a browser's shutdown
  # short, so each gets one.
  local browser name pattern=
  local -A seen=()
  for browser in "${BROWSERS[@]}"; do
    load_env_for "$browser"
    [[ -n "$PSNAME" ]] || continue
    name=$(proc_name "$PSNAME")
    [[ -n "${seen[$name]}" ]] && continue
    seen[$name]=1
    # pgrep takes an extended regex
    pattern+=${pattern:+|}$(printf '%s' "$name" | sed 's/[][\\.^$*+?(){}|]/\\&/g')
  done
  [[ -n "$pattern" ]] && browsers_running "$pattern" || return 0
  wait_browsers "$pattern" 3 && return 0
  pkill -x -SIGTERM -u "$user" "$pattern"
  wait_browsers "$pattern" 10 && return 0
  warn "Browsers did not quit within 10 seconds, killing them"
  pkill -x -SIGKILL -u "$user" "$pattern"
  wait_browsers "$pattern" 2 && return 0
  warn "Browsers are still running: $pattern"
}

# browsers_running <pattern>: whether a live process of the user has a name
# matching <pattern> exactly; zombies are dead and only wait to be reaped
browsers_running() {
  local pid stat
  for pid in $(pgrep -x -u "$user" "$1"); do
    stat=$(< "/proc/$pid/stat") 2>/dev/null || continue
    stat=${stat##*) }
    [[ "${stat%% *}" != Z ]] && return 0
  done
  return 1
}

# wait_browsers <pattern> <seconds>: wait until no browser runs, at most
# <seconds> by the clock; returns nonzero if one still does
wait_browsers() {
  local end=$(( ${EPOCHREALTIME/[.,]/} + $2 * 1000000 ))
  while browsers_running "$1"; do
    (( ${EPOCHREALTIME/[.,]/} < end )) || return 1
    sleep .1
  done
}

# holders <dir>: the processes that have <dir> or a file in it open, or as
# their working or root dir, as "pid (name)"
holders() {
  local pattern link pid finder fd
  local -A seen=()
  pattern=$(printf '%s' "$1" | sed 's/[][*?\\]/\\&/g')
  exec {fd}< <(find /proc/[0-9]*/cwd /proc/[0-9]*/root /proc/[0-9]*/fd -maxdepth 1 \
    \( -lname "$pattern" -o -lname "$pattern/*" \) -print 2>/dev/null)
  # the find itself, whose working dir may be in <dir>
  finder=$!
  while IFS= read -r -u "$fd" link; do
    pid=${link#/proc/}
    pid=${pid%%/*}
    [[ -n "${seen[$pid]}" || "$pid" == "$$" || "$pid" == "$BASHPID" || "$pid" == "$finder" ]] && continue
    seen[$pid]=1
    echo "$pid ($(cat "/proc/$pid/comm" 2>/dev/null))"
  done
  exec {fd}<&-
}

# sync_profile <dir>: initial sync of one profile of the loaded browser. On
# failure nothing is left half done except what SYNCED_* records, which
# rollback_sync undoes.
sync_profile() {
  profile_paths "$1"

  if [[ -h "$DIR" ]]; then
    # recovery runs first, so a link here means another session owns it
    error "$DIR is already a link to $(readlink "$DIR"), not syncing it"
    return 1
  elif [[ ! -d "$DIR" ]]; then
    if [[ ! -d "$homedir" ]]; then
      error "$DIR does not exist! Is /home unmounted?"
      return 1
    elif [[ -d "$BACKUP" ]]; then
      error "$DIR does not exist but $BACKUP does, not syncing it"
      return 1
    fi
    # browser not installed
    return 0
  fi
  if mounted "$TMP"; then
    error "$TMP is still mounted from an earlier session, not syncing $DIR"
    return 1
  fi

  # writes left by an earlier session in this boot would be layered over the
  # profile on disk, which may be newer; keep them aside instead
  if unsaved_writes; then
    local stale
    stale="$XDG_RUNTIME_DIR/psd/.stale-${UPPER##*/}-$(date +%Y%m%d_%H%M%S)"
    mv --no-target-directory "$UPPER" "$stale" || return 1
    remove_tree "$WORK"
    warn "Writes left by an earlier session are kept in $stale until reboot, not used"
  fi

  # retain permissions on sync target
  PREFIXP=$(stat -c %a "$DIR")
  install -dm"$PREFIXP" --owner="$user" --group="$group" "$TMP" "$UPPER" "$WORK" || return 1

  # a BACK_OVFS left from a previous session is older than DIR, so it must
  # be out of the way before DIR becomes BACKUP; removing just its marker
  # would make it look newer than BACKUP to psd 7.04's recovery. It is kept
  # as the base of this session's first copy, which links unchanged files.
  if [[ -d "$BACK_OVFS" ]]; then
    remove_tree "$BACK_OVFS.base" &&
      mv --no-target-directory "$BACK_OVFS" "$BACK_OVFS.base" &&
      flush "$BACK_OVFS.base" || return 1
  fi
  # a marker an older psd copied into the profile
  rm -f "$DIR/.psd-complete"
  mv --no-target-directory "$DIR" "$BACKUP" || return 1
  SYNCED_BROWSER+=("$browser")
  SYNCED_DIR+=("$1")

  mount_overlay || return 1

  ln -sT "$TMP" "$DIR" &&
    chown -h "$user":"$group" "$DIR" &&
    touch "$DIR/.flagged" || return 1
  msg "$browser sync successful"
}

# unsaved_writes: whether UPPER holds anything besides psd's own .flagged
unsaved_writes() {
  [[ -d "$UPPER" ]] &&
    [[ -n "$(find "$UPPER" -mindepth 1 ! -path "$UPPER/.flagged" -print -quit)" ]]
}

# rollback_sync: undo the profiles synced by a failed start, newest first
rollback_sync() {
  local i rc=0
  for (( i = ${#SYNCED_DIR[@]} - 1; i >= 0; i-- )); do
    load_env_for "${SYNCED_BROWSER[i]}"
    profile_paths "${SYNCED_DIR[i]}"
    [[ -h "$DIR" ]] && unlink "$DIR"
    if mounted "$TMP"; then
      fusermount3 -u "$TMP" || fusermount3 -uz "$TMP"
    fi
    if mounted "$TMP"; then
      error "Failed to unmount $TMP; $BACKUP is left for crash recovery"
      rc=1
      continue
    fi
    if ! mv --no-target-directory "$BACKUP" "$DIR"; then
      rc=1
      continue
    fi
    # anything written in the moments the overlay was up is kept, not deleted
    rm -f "$UPPER/.flagged"
    if rmdir "$UPPER" 2>/dev/null; then
      remove_tree "$WORK"
    else
      warn "Writes made during the failed start are kept in $UPPER"
    fi
    rmdir "$TMP" 2>/dev/null
    msg "${SYNCED_BROWSER[i]} sync undone"
  done
  return $rc
}

# resync_profile: copy the current profile of this session to disk.
# Never moves, mounts or links anything: a profile that is not this session's
# link is left for the next start.
resync_profile() {
  if [[ ! -h "$DIR" || "$(readlink "$DIR")" != "$TMP" ]]; then
    [[ -e "$DIR" || -h "$DIR" ]] &&
      warn "$DIR is not synced by this session; it is synced at the next start"
    return 0
  fi
  if [[ ! -e "$DIR/.flagged" ]] || ! mount_alive "$TMP"; then
    error "The fuse mount of $DIR died; its writes since the last copy are in $UPPER until reboot. To save them, close the browser and run: systemctl --user restart psd"
    return 1
  fi

  # BACK_OVFS is created by the first resync rather than the initial sync so
  # a crash before it recovers from BACKUP; each resync writes a new copy
  # and swaps it in, so the last complete one is kept until then
  if ! new_generation "$DIR"; then
    error "Failed to resync $DIR"
    return 1
  fi
  COPIED=1
  msg "$browser resync successful"
}

# do_sync: the initial sync when no session is running, all or nothing, else a
# resync of every synced profile. Returns nonzero if anything failed.
do_sync() {
  local initial=0 browser item i failed=0 id='' c
  local -A copied=()
  [[ -f "$PID_FILE" ]] || initial=1

  if [[ $initial -eq 1 ]]; then
    local -A seen_dirs=()
    local -a items=() item_browsers=()
    for browser in "${BROWSERS[@]}"; do
      load_env_for "$browser"
      for item in "${DIRArr[@]}"; do
        # a def may list the same profile twice
        [[ -n "${seen_dirs[$item]}" ]] && continue
        seen_dirs[$item]=1
        items+=("$item")
        item_browsers+=("$browser")
      done
    done
    # recovery must know every profile this session may touch before the
    # first one is moved. The id ties the file to this session: it goes in
    # the config snapshot and the PID file, which older versions rewrite.
    id=$(< /proc/sys/kernel/random/uuid) || return 2
    local lines="# psd session $id"$'\n'
    for i in "${!items[@]}"; do
      load_env_for "${item_browsers[i]}"
      profile_paths "${items[i]}"
      lines+=$(printf '%s\t%s\t%s\t%s\t%s' "$browser" "$DIR" "$TMP" "$UPPER" "$WORK")$'\n'
    done
    printf '%s' "$lines" | write_file "$SESSION_FILE" || return 2
  fi

  # make a snapshot of psd.conf and redefine its location to this snapshot
  # while psd is running to keep any edits made to the "live" psd.conf from
  # potentially orphaning the snapshot copies thus preserving the data; a
  # snapshot left by a crash is replaced when the next session starts.
  # PSD_SESSION_MARKERS tells recovery that this session marks BACK_OVFS
  # complete, so it must be on disk before anything is synced.
  if [[ $initial -eq 1 || ! -f "$PSDCONFDIR/.psd.conf" ]]; then
    {
      echo "# Automatically generated file; DO NOT EDIT!"
      echo "# The purpose is to snapshot the settings used when psd was activated."
      echo "# Any edits to the live config: $PSDCONFDIR/psd.conf"
      echo "# will be applied the _next_ time psd is activated."
      echo "#"
      echo "USE_SUSPSYNC=\"$USE_SUSPSYNC\""
      echo "BROWSERS=\"${BROWSERS[*]}\""
      echo "USE_BACKUPS=\"$USE_BACKUPS\""
      echo "BACKUP_LIMIT=\"$BACKUP_LIMIT\""
      echo "PSD_SESSION_MARKERS=1"
      [[ -n "$id" ]] && echo "PSD_SESSION_ID=$id"
    } | write_file "$PSDCONFDIR/.psd.conf" || return 2
  fi

  if [[ $initial -eq 1 ]]; then
    SYNCED_BROWSER=()
    SYNCED_DIR=()
    for i in "${!items[@]}"; do
      load_env_for "${item_browsers[i]}"
      if ! sync_profile "${items[i]}"; then
        error "Sync of $browser failed, undoing this start"
        rollback_sync && rm -f "$SESSION_FILE"
        return 2
      fi
    done
    # record each BACKUP as moved, so recovery can tell it from one a later
    # session of an older psd made
    lines="# psd session $id"$'\n'
    for i in "${!items[@]}"; do
      load_env_for "${item_browsers[i]}"
      profile_paths "${items[i]}"
      c=
      [[ -d "$BACKUP" ]] && c=$(stat -c %.9Z "$BACKUP")
      lines+=$(printf '%s\t%s\t%s\t%s\t%s\t%s' "$browser" "$DIR" "$TMP" "$UPPER" "$WORK" "$c")$'\n'
    done
    if ! printf '%s' "$lines" | write_file "$SESSION_FILE"; then
      error "Undoing this start"
      rollback_sync && rm -f "$SESSION_FILE"
      return 2
    fi
    # the session exists only once every profile is synced
    if ! echo "$id" > "$PID_FILE"; then
      error "Failed to write $PID_FILE, undoing this start"
      rm -f "$PID_FILE"
      rollback_sync && rm -f "$SESSION_FILE"
      return 2
    fi
  else
    session_profiles
    for i in "${!PROF_D[@]}"; do
      use_profile "$i"
      COPIED=0
      resync_profile || failed=1
      [[ $COPIED -eq 1 ]] && copied[$TMP]=1
    done
    runtime_space_low
    [[ $failed -eq 0 ]] || return 2
    # a session started by an older psd has now marked every copy it keeps,
    # unless it has a mount this run did not copy
    if [[ -f "$PSDCONFDIR/.psd.conf" ]] &&
      ! grep -qx 'PSD_SESSION_MARKERS=1' "$PSDCONFDIR/.psd.conf" &&
      [[ -z "$(psd_mounts | while read -r item; do [[ -n "${copied[$item]}" ]] || echo "$item"; done)" ]]; then
      { cat "$PSDCONFDIR/.psd.conf" && echo "PSD_SESSION_MARKERS=1"; } |
        write_file "$PSDCONFDIR/.psd.conf" || return 2
    fi
  fi
}

enforce() {
  local browser
  for browser in "${BROWSERS[@]}"; do
  load_env_for "$browser"
  for DIR in "${DIRArr[@]}"; do
    local CRASHArr=()
    # only rotate timestamped snapshots, never the copies recovery set aside
    crash_dirs "$DIR" snapshots

    if [[ ${#CRASHArr[@]} -gt $BACKUP_LIMIT ]]; then
      for remove in "${CRASHArr[@]:$BACKUP_LIMIT}"; do
        remove_tree "$remove"
      done
    fi
    # a new copy whose swap was interrupted is never used
    remove_tree "$DIR-back-ovfs.new"
    # snapshots whose copy was interrupted; recovery finished since
    crash_dirs "$DIR"
    for remove in "${CRASHArr[@]}"; do
      [[ "${remove##*/}" =~ -backup-crashrecovery-[0-9]{8}_[0-9]{6}-partial$ ]] &&
        remove_tree "$remove"
    done
  done
  done
}

do_unsync() {
  local i failed=0 m held
  set_legacy
  session_profiles
  for i in "${!PROF_D[@]}"; do
    use_profile "$i"
    # check if user has browser profile
    if [[ -h "$DIR" && "$(readlink "$DIR")" != "$TMP" ]]; then
      warn "$DIR is not a link of this session, leaving it alone"
    elif [[ -h "$DIR" ]] && ! mount_alive "$TMP"; then
      # the fuse daemon died; recover what it left
      error "$TMP is not mounted or its fuse daemon died, recovering $DIR"
      recover_profile || failed=1
    elif [[ -h "$DIR" ]]; then
      # the link and mount stay until the data is on disk, so a failure
      # below leaves the profile usable in tmpfs

      held=$(holders "$TMP")
      [[ -n "$held" ]] &&
        warn "$DIR is still in use by: ${held//$'\n'/, }; what they write after the final copy is lost"
      # unmount overlayfs FIRST while BACKUP still exists at original location
      # moving BACKUP before unmounting breaks the overlayfs mount (lowerdir path disappears)
      # sync latest changes from overlayfs to BACK_OVFS, then to BACKUP before unmounting
      # always update BACK_OVFS from live overlayfs first to capture latest changes
      # a complete copy on disk first, so BACKUP is never the only copy
      # while it is rewritten in place
      if ! new_generation "$TMP"; then
        error "Failed to sync $TMP to $BACK_OVFS during unsync, leaving $DIR in tmpfs"
        failed=1
        continue
      fi
      if ! rsync -aX --modify-window=-1 --delete-after --inplace --no-whole-file --exclude .flagged --exclude .psd-complete "$BACK_OVFS/" "$BACKUP/"; then
        error "Failed to sync $BACK_OVFS to $BACKUP during unsync, leaving $BACKUP for crash recovery"
        failed=1
        continue
      fi
      # BACKUP was rewritten in place and becomes DIR below, where nothing
      # marks it as partial, so it must be on disk first
      if ! flush "$BACKUP"; then
        failed=1
        continue
      fi
      unlink "$DIR"
      # unmount (before moving BACKUP); everything is on disk by now, so
      # if something still holds the mount it is detached lazily and only
      # writes made after the copy above are lost
      if ! fusermount3 -u "$TMP"; then
        warn "$TMP is busy, detaching it lazily"
        fusermount3 -uz "$TMP"
      fi
      # never rm -rf through a live mount; leave this profile to crash
      # recovery and carry on with the others
      if mounted "$TMP"; then
        error "Failed to unmount $TMP, leaving it and $BACKUP in place"
        failed=1
        continue
      fi
      discard "$UPPER" "$WORK" "$TMP"

      [[ -h "$DIR" ]] && unlink "$DIR"
      # restore BACKUP (which now has the changes merged in)
      if [[ -d "$BACKUP" ]] && ! mv --no-target-directory "$BACKUP" "$DIR"; then
        error "Failed to move $BACKUP back to $DIR, leaving it for crash recovery"
        failed=1
        continue
      fi
      [[ -d "$TMP" ]] && ! mounted "$TMP" && rm -rf "$TMP"
      # the copy is now only the base of the next session's first copy; an
      # older psd must not find it marked next to a later session of its own
      if [[ -d "$BACK_OVFS" ]] && ! { remove_tree "$BACK_OVFS.base" &&
        mv --no-target-directory "$BACK_OVFS" "$BACK_OVFS.base"; }; then
        warn "Failed to set $BACK_OVFS aside"
      fi
      msg "$browser unsync successful"
    elif [[ -d "$BACKUP" ]]; then
      # an earlier unsync stopped after the copy; finish it like recovery
      recover_profile || failed=1
    else
      if [[ ! -d "$homedir" ]] ; then
        die "$DIR does not exist! Is /home unmounted?"
      fi
    fi
  done
  # the session ends only when every profile is back on disk; otherwise the
  # PID, config snapshot and session file are kept for recovery
  [[ $failed -eq 0 ]] || return 2
  # a mount left means a profile of this session that was not found
  m=$(psd_mounts)
  if [[ -n "$m" ]]; then
    error "Still mounted, the session is kept: ${m//$'\n'/ }"
    return 2
  fi
  rm -f "$PID_FILE" "$PSDCONFDIR/.psd.conf" "$SESSION_FILE"
}

# field <label> <value> [color]: one aligned line of the preview
field() {
  printf ' %b%-16s %b%b\n' "${3:-$BLD}" "$1" "$2" "$NRM"
}

parse() {
  psd_state=$(systemctl --user is-active psd)
  resync_state=$(systemctl --user is-active psd-resync.timer)
  psd_suspend_sync_state=$(pgrep -cf "psd-suspend-sync")
  if [[ "$psd_suspend_sync_state" != 0 ]]; then
    psss_state="enabled"
  else
    psss_state="disabled"
  fi
  [[ "$psd_state" = "active" ]] && psd_color="${GRN}" || psd_color="${RED}"
  [[ "$resync_state" = "active" ]] && resync_color="${GRN}" || resync_color="${RED}"
  [[ "$psd_suspend_sync_state" != 0 ]] && psss_color="${GRN}" || psss_color="${RED}"
  field "systemd service:" "${psd_color}$psd_state${NRM}${BLD}${NRM}"
  field "resync-timer:" "${resync_color}$resync_state${NRM}${BLD}${NRM}"
  field "sync on sleep:" "${psss_color}$psss_state${NRM}${BLD}${NRM}"
  echo
  echo -e "${BLD}Psd will manage the following per ${BLU}${PSDCONF}${NRM}${BLD}:${NRM}"
  echo
  local browser
  for browser in "${BROWSERS[@]}"; do
    load_env_for "$browser"
    for item in "${DIRArr[@]}"; do
      profile_paths "$item"
      # preview only reports; recovery is left to the next start
      if [[ ! -f $PID_FILE ]] && [[ -d "$BACKUP" || ( -h "$DIR" && ! -d "$DIR" ) ]]; then
        field "browser/psname:" "$browser/$PSNAME${NRM}"
        field "sync target:" "${BLU}$DIR${NRM}"
        field "needs recovery:" "${NRM}${BLD}left by a crash; starting psd will recover it${NRM}" "$RED"
        echo
        continue
      fi
      if [[ -d "$DIR" ]]; then
        local CRASHArr=()
        crash_dirs "$DIR"

        # get permissions on profile dir and be smart about it since symlinks are all 777
        [[ -f $PID_FILE ]] && TRUEP=$(stat -c %a "$BACKUP") || TRUEP=$(stat -c %a "$DIR")
        # since $XDG_RUNTIME_DIR is 700 by default so pass on by
        if [[ "$VOLATILE" = "$XDG_RUNTIME_DIR" ]]; then
          warn=
        else
          # using something other than $XDG_RUNTIME_DIR so check for privacy
          [[ $TRUEP -ne 700 ]] && warn=1
        fi
        # profile dir size
        psize=$(du -Dsm "$DIR" 2>/dev/null | awk '{ print $1 " MiB" }')
        field "browser/psname:" "$browser/$PSNAME${NRM}"
        field "owner/group id:" "$user/$group${NRM}"
        field "sync target:" "${BLU}$DIR${NRM}"
        if [[ $warn -eq 1 ]]; then
          field "" "${RED} Permissions are $TRUEP on this profile.${NRM}"
          field "" "${RED} Recommend a setting of 700 for increased privacy!${NRM}"
          warn=
        fi
        field "tmpfs dir:" "${GRN}$TMP${NRM}"
        field "profile size:" "$psize${NRM}"
        if [[ -f $PID_FILE ]]; then
          rwsize=$(du -Dsm "$UPPER" 2>/dev/null | awk '{ print $1 " MiB" }')
          field "overlayfs size:" "$rwsize${NRM}"
        else
          field "tmpfs space:" "$(runtime_space "$DIR")${NRM}"
        fi
        if [[ $BACKUP_LIMIT -ne 5 ]]; then
          # only print if not the hardcoded default
          field "backup limit:" "$BACKUP_LIMIT${NRM}"
        fi
        if [[ "${#CRASHArr[@]}" -eq 0 ]]; then
          field "recovery dirs:" "none${NRM}"
        else
          field "recovery dirs:" "${RED}${#CRASHArr[@]}${NRM}${BLD} <- delete with the c option${NRM}"
          for backup in "${CRASHArr[@]}"; do
            psize=$(du -Dsm "$backup" 2>/dev/null | awk '{ print $1 " MiB" }')
            field " dir path/size:" "${BLU}$backup ${NRM}${BLD}($psize)${NRM}"
          done
        fi
        echo
      fi
    done
  done
}

take_inhibit_lock() {
  # ensure we only take one lock at a time
  release_inhibit_lock

  # background this to avoid hanging on startup; in its own process group,
  # so the helper and the inhibitor it runs are stopped together
  setsid /usr/bin/psd-suspend-sync {lockfd}>&- &
  echo "$!" > "$SUSP_PID_FILE"
}

# take_lock wait|try: wait for other psd runs to finish, or with try exit
# quietly if one is running (a skipped resync is caught up by the next one)
take_lock() {
  if [[ $1 = try ]]; then
    flock -n "$lockfd"
    case $? in
      0) ;;
      1) msg "psd is busy, skipping"; exit 0 ;;
      *) die "Cannot take the lock $XDG_RUNTIME_DIR/psd.lock" ;;
    esac
  else
    # no timeout: going ahead without the lock would race a running sync,
    # and systemd's stop timeout already bounds a stuck holder
    flock "$lockfd" || die "Cannot take the lock $XDG_RUNTIME_DIR/psd.lock"
  fi
}

# recover: psd.service's ExecStopPost. It runs after every stop, also when
# the start failed or psd was killed, after systemd has killed the fuse
# daemons. A clean unsync left nothing to do.
recover() {
  live_check
  rm -f "$PID_FILE"
  ungraceful_state_check
}

# live_check: refuse to touch a session that is still running
live_check() {
  local i
  session_profiles config
  for i in "${!PROF_D[@]}"; do
    use_profile "$i"
    # a mount whose daemon died is not live; recovery detaches it
    if mount_alive "$TMP"; then
      die "psd is running and $DIR is live; stop it with: systemctl --user stop psd, or if it was started by hand: psd unsync"
    fi
  done
}

release_inhibit_lock() {
  local pgid pid pattern
  if [[ -f "$SUSP_PID_FILE" ]]; then
    pgid=$(< "$SUSP_PID_FILE")
    rm -f "$SUSP_PID_FILE"
    # only if the group is still the helper's
    [[ "$pgid" =~ ^[0-9]+$ ]] && pgrep -g "$pgid" -f psd-suspend-sync >/dev/null || return 0
    for pid in $(pgrep -g "$pgid"); do
      # recycle-inhibit-lock is run by the helper, inside its group
      [[ "$pid" == "$$" ]] || kill "$pid" 2>/dev/null
    done
  else
    # a helper started by an older psd; only its exact command line, never
    # an editor or shell that merely mentions it
    pattern='(/usr)?/bin/bash [^ ]*/psd-suspend-sync'
    pgrep -u "$user" -x -f "$pattern" >/dev/null && pkill -u "$user" -x -f "$pattern"
  fi
  return 0
}

case "$1" in
  p|P|Parse|parse|Preview|preview|debug)
    dep_check
    config_check
    dup_check
    header
    parse
    ;;
  c|C|clean|Clean)
    take_lock wait
    dep_check
    config_check
    dup_check
    header
    cleanup
    ;;
  startup)
    # target for psd.service's ExecStart= for consistent error handling
    take_lock wait
    if [[ -f $PID_FILE ]]; then
      # ExecStart only runs while psd.service is inactive, so this session
      # was not stopped cleanly, e.g. by a unit without ExecStopPost
      live_check
      warn "Found a session that was not stopped cleanly, recovering it"
      recover || exit 1
      # start over with the live config rather than that session's snapshot
      exec {lockfd}>&-
      exec "$0" startup
    fi
    dep_check
    config_check
    dup_check
    running_check
    ungraceful_state_check || die "Recovery failed, see above"
    [[ $SUSPSYNC -eq 1 ]] && take_inhibit_lock
    msg "psd startup check successful"
    ;;
  suspend-sync)
    # wait: a resync in progress has no complete copy until it finishes
    take_lock wait
    if [[ -f $PID_FILE ]]; then
      # system preparing for sleep mode, resync when psd is active
      do_sync || exit
    fi
    ;;
  recycle-inhibit-lock)
    # take inhibit lock for next suspend cycle
    take_inhibit_lock
    ;;
  sync|resync)
    if [[ -f $PID_FILE ]]; then
      take_lock try
      # an unsync may have ended the session while we waited for the check
      if [[ ! -f $PID_FILE ]]; then
        msg "psd is not running, skipping"
        exit 0
      fi
      do_sync || exit
    else
      take_lock wait
      dep_check
      config_check
      dup_check
      running_check
      ungraceful_state_check || die "Recovery failed, see above"
      do_sync || exit
      enforce
    fi
    ;;
  unsync)
    take_lock wait
    if [[ -f $PID_FILE ]]; then
      # a failed resync here must not stop the unsync
      do_sync || warn "Resync before unsync failed, unsyncing anyway"
      kill_browsers
      do_unsync
      rc=$?
      [[ $SUSPSYNC -eq 1 ]] && release_inhibit_lock
      exit $rc
    fi
    ;;
  recover)
    take_lock wait
    recover
    rc=$?
    [[ $SUSPSYNC -eq 1 ]] && release_inhibit_lock
    exit $rc
    ;;
  *)
    header
    echo -e " ${BLD}$0 ${NRM}${GRN}[option]${NRM}"
    echo -e " ${BLD} ${NRM}${GRN}preview${NRM}${BLD}	Parse config file (${NRM}${BLU}${PSDCONF}${NRM}${BLD}) to see which profiles will be managed.${NRM}"
    echo -e " ${BLD} ${NRM}${GRN}clean${NRM}${BLD}		Clean (delete without prompting) ALL crashrecovery dirs for all profiles.${NRM}"
    echo -e " ${BLD} ${NRM}${GRN}recover${NRM}${BLD}	Recover profiles left by a crash or a failed stop; psd.service runs it after every stop.${NRM}"
    echo
    echo -e " ${BLD}It is ${RED}HIGHLY DISCOURAGED${NRM}${BLD} to directly call $0 to sync, resync, or to unsync.${NRM}"
    echo
    if [[ -f /usr/lib/systemd/user/psd.service ]]; then
      echo -e " ${BLD}Instead, use systemd to start/stop profile-sync-daemon.${NRM}"
      echo
      echo -e " ${BLD}systemctl --user ${NRM}${GRN}[option]${NRM}${BLD} psd${NRM}"
      echo -e " ${BLD} ${NRM}${GRN}start${NRM}${BLD}		Turn on daemon; make symlinks and actively manage targets in tmpfs.${NRM}"
      echo -e " ${BLD} ${NRM}${GRN}stop${NRM}${BLD}		Turn off daemon; remove symlinks and rotate tmpfs data back to disc.${NRM}"
      echo -e " ${BLD} ${NRM}${GRN}enable${NRM}${BLD}	Autostart daemon when system comes up.${NRM}"
      echo -e " ${BLD} ${NRM}${GRN}disable${NRM}${BLD}	Remove daemon from the list of autostart daemons.${NRM}"
    elif [[ -f /etc/init.d/psd ]]; then
      echo -e " ${BLD}Instead, use the init system to start/stop profile-sync-daemon.${NRM}"
      echo
      echo -e " ${BLD}sudo service psd ${NRM}${GRN}[option]${NRM}${BLD} or /etc/init.d/psd ${NRM}${GRN}[option]${NRM}"
      echo -e " ${BLD} ${NRM}${GRN}start${NRM}${BLD}	Turn on daemon; make symlinks and actively manage targets in tmpfs.${NRM}"
      echo -e " ${BLD} ${NRM}${GRN}stop${NRM}${BLD}	Turn off daemon; remove symlinks and rotate tmpfs data back to disc.${NRM}"
    fi
    ;;
esac
exit 0

# vim:set ts=2 sw=2 et:
