#!/bin/bash
# Tojuri Emergency Recovery [SCOPE-01, SDLC-14, KEY-20].
#
# Gets your files back with no Tojuri account, no Tojuri server and no help
# from Tojuri: only your Recovery Kit, your own Amazon sign-in, and a Mac.
# It invents no cryptography [PR-05]: it hands your Recovery Code to restic,
# the open-source backup program Tojuri stores your files with, which already
# accepts it as a second password for your backup.
#
# Run it with no arguments and answer the questions:
#     bash recover.sh
# Secrets are typed at hidden prompts, never as arguments, so they do not
# land in your shell history or the process list [KEY-03].
#
# Works with the bash 3.2 and tools that ship with every Mac (curl, bunzip2,
# shasum, plutil, caffeinate). Nothing else to install.
set -euo pipefail

RESTIC_VERSION="0.19.1"
# SHA-256 of the unpacked official macOS arm64 binary, checked against the
# release Tojuri itself was built and tested with.
RESTIC_DARWIN_ARM64_SHA256="06582569ff2f10e1935a6f12187c76db02f0bae99e6e54098f2cdc374766d768"
# The same for Intel Macs; scripts/engine/fetch-restic-intel.sh pins the same value.
RESTIC_DARWIN_AMD64_SHA256="b2b553b402b9971b0c3f673d880397a421526f55a08f21fe5b82b5dd9e049a08"
# Crockford base32, then the 5 extra check symbols [KEY-10].
DATA_ALPHABET="0123456789ABCDEFGHJKMNPQRSTVWXYZ"
CHECK_ALPHABET="0123456789ABCDEFGHJKMNPQRSTVWXYZ*~\$=U"
WORK="$(mktemp -d /private/tmp/tojuri-recovery.XXXXXX)"
trap 'rm -rf "$WORK"' EXIT

say() { printf '%s\n' "$*"; }
fail() { printf '\nStopped: %s\n' "$*" >&2; exit 1; }

# Turns a code typed from the printed Kit into the password restic knows:
# capitals, no spaces or dashes, O read as 0 and I or L as 1, and the final
# check character verified and removed.
normalize_code() {
    local typed="$1" cleaned="" c i value remainder=0 prefix data last
    typed="$(printf '%s' "$typed" | tr '[:lower:]' '[:upper:]' | tr -d ' \t-')"
    for (( i = 0; i < ${#typed}; i++ )); do
        c="${typed:i:1}"
        case "$c" in O) c=0 ;; I|L) c=1 ;; esac
        cleaned="$cleaned$c"
    done
    case ${#cleaned} in
        33) data="${cleaned:0:32}"; last="${cleaned:32:1}" ;;
        32) data="$cleaned"; last="" ;;
        *) return 1 ;;
    esac
    for (( i = 0; i < 32; i++ )); do
        c="${data:i:1}"
        prefix="${DATA_ALPHABET%%"$c"*}"
        [ "${#prefix}" -lt 32 ] || return 1
        value=${#prefix}
        remainder=$(( (remainder * 32 + value) % 37 ))
    done
    if [ -n "$last" ] && [ "$last" != "${CHECK_ALPHABET:remainder:1}" ]; then
        return 2
    fi
    printf '%s' "$data"
}

# End of input means nobody is there to answer; stop rather than ask forever.
ask() { local answer; read -r -p "$1" answer || fail "no answer given"; printf '%s' "$answer"; }
ask_hidden() { local answer; read -r -s -p "$1" answer || fail "no answer given"; printf '\n' >&2; printf '%s' "$answer"; }

get_restic() {
    if [ -n "${RESTIC_BIN:-}" ]; then
        [ -x "$RESTIC_BIN" ] || fail "RESTIC_BIN is set but is not a program: $RESTIC_BIN"
        printf '%s' "$RESTIC_BIN"; return
    fi
    local arch asset url sums
    case "$(uname -m)" in
        arm64) arch=arm64 ;;
        x86_64) arch=amd64 ;;
        *) fail "this Mac's processor is not recognised; install restic $RESTIC_VERSION from https://github.com/restic/restic/releases and run again with RESTIC_BIN=/path/to/restic" ;;
    esac
    asset="restic_${RESTIC_VERSION}_darwin_${arch}.bz2"
    url="https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}"
    say "Downloading restic $RESTIC_VERSION from its official project page..." >&2
    curl -fsSL "$url/$asset" -o "$WORK/$asset" || fail "could not download $url/$asset"
    curl -fsSL "$url/SHA256SUMS" -o "$WORK/SHA256SUMS" || fail "could not download the checksum list"
    sums="$(grep " $asset\$" "$WORK/SHA256SUMS" | cut -d' ' -f1)"
    [ -n "$sums" ] || fail "the checksum list does not mention $asset"
    [ "$(shasum -a 256 "$WORK/$asset" | cut -d' ' -f1)" = "$sums" ] || fail "the download does not match the project's published checksum"
    bunzip2 -c "$WORK/$asset" > "$WORK/restic"
    chmod 700 "$WORK/restic"
    pinned="$RESTIC_DARWIN_ARM64_SHA256"
    [ "$arch" = amd64 ] && pinned="$RESTIC_DARWIN_AMD64_SHA256"
    if [ "$(shasum -a 256 "$WORK/restic" | cut -d' ' -f1)" != "$pinned" ]; then
        fail "the unpacked program does not match the version Tojuri was tested with"
    fi
    say "restic downloaded and verified." >&2
    printf '%s' "$WORK/restic"
}

say ""
say "Tojuri Emergency Recovery"
say "Have your Recovery Kit in front of you."
say ""

LOCATION="$(ask 'Storage location from your Kit (the name after "storage location", or a folder path for a backup kept on a drive): ')"
[ -n "$LOCATION" ] || fail "no storage location given"
REMOTE=0
if [ "${LOCATION:0:1}" = "/" ]; then
    [ -d "$LOCATION" ] || fail "there is no folder at $LOCATION"
    export RESTIC_REPOSITORY="$LOCATION"
else
    REMOTE=1
    REGION="$(ask 'Region from your Kit (for example us-east-2): ')"
    FOLDER="$(ask 'Folder in storage from your Kit (leave empty if your Kit shows none): ')"
    [ -n "$REGION" ] || fail "no region given"
    export RESTIC_REPOSITORY="s3:s3.${REGION}.amazonaws.com/${LOCATION}${FOLDER:+/$FOLDER}"
    if [ -z "${AWS_PROFILE:-}" ]; then
        say ""
        say "Now your own Amazon access key. Create one in the AWS console:"
        say "the menu under your account name (top right), Security credentials, Create access key."
        AWS_ACCESS_KEY_ID="$(ask 'Access key ID: ')"
        AWS_SECRET_ACCESS_KEY="$(ask_hidden 'Secret access key (hidden): ')"
        export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
    fi
fi

say ""
while true; do
    TYPED="$(ask_hidden 'Recovery Code, exactly as printed, dashes are fine (hidden): ')" || exit 1
    set +e
    CODE="$(normalize_code "$TYPED")"
    STATUS=$?
    set -e
    case $STATUS in
        0) break ;;
        2) say "The last character does not match. Check each group carefully and try again." ;;
        *) say "That does not look like a Recovery Code: it should be 8 groups of 4, plus one check character." ;;
    esac
done
export RESTIC_PASSWORD="$CODE"
unset TYPED CODE

RESTIC="$(get_restic)"

say ""
say "Checking your Recovery Code..."
"$RESTIC" snapshots --json --latest 1 > "$WORK/latest.json" 2> "$WORK/err.txt" || {
    grep -qi "wrong password\|no key found" "$WORK/err.txt" && fail "the Recovery Code did not open this backup. Check the code and the storage location."
    fail "$(head -1 "$WORK/err.txt")"
}
say "Recovery Code confirmed."

# The newest backup of each folder; Tojuri's own internal records are skipped.
IDS=""
NAMES=""
i=0
while ID="$(plutil -extract "$i.id" raw -o - "$WORK/latest.json" 2>/dev/null)"; do
    SOURCE="$(plutil -extract "$i.paths.0" raw -o - "$WORK/latest.json" 2>/dev/null || true)"
    WHEN="$(plutil -extract "$i.time" raw -o - "$WORK/latest.json" 2>/dev/null || true)"
    case "$SOURCE" in
        */Library/Application\ Support/Tojuri/*) ;;
        *) IDS="$IDS $ID"; NAMES="$NAMES|${SOURCE##*/}"; say "  ${SOURCE##*/}   (backed up ${WHEN%%T*})" ;;
    esac
    i=$(( i + 1 ))
done
[ -n "$IDS" ] || fail "this backup holds no folders to recover"

# Never restore blind [PR-20].
# shellcheck disable=SC2086
"$RESTIC" stats $IDS --mode restore-size --json > "$WORK/stats.json" 2>/dev/null || true
BYTES="$(plutil -extract total_size raw -o - "$WORK/stats.json" 2>/dev/null || echo 0)"
GB=$(( (BYTES + 500000000) / 1000000000 ))
say ""
if [ "$GB" -ge 1 ]; then say "That is about $GB GB."; else say "That is less than 1 GB."; fi
if [ "$REMOTE" = 1 ]; then
    say "Files backed up in the last 90 days start copying straight away. Older"
    say "ones are in Amazon's Deep Archive and are prepared first, at Amazon's"
    say "lowest-cost speed: up to 48 hours."
    say "On a large backup, asking Amazon for them can itself take some hours."
    say "Amazon charges for preparing and downloading: roughly \$99 to \$134 per TB."
    say "That bill comes from Amazon, not Tojuri. Keep this Mac awake and online."
fi
DEST="$(ask 'Folder to put your files in (it will be created): ')"
[ -n "$DEST" ] || fail "no destination folder given"
CONFIRM="$(ask 'Type YES to start: ')"
[ "$CONFIRM" = "YES" ] || { say "Stopped. Nothing was restored."; exit 0; }
mkdir -p "$DEST"

OPTIONS=(--target "$DEST" --sparse)
if [ "$REMOTE" = 1 ]; then
    # restic's own cold-storage support: it asks Amazon to prepare each file,
    # waits, then copies. Bulk is Amazon's cheapest preparation speed.
    export RESTIC_FEATURES="s3-restore"
    OPTIONS+=(-o s3.enable-restore=true -o s3.restore-tier=Bulk -o s3.restore-days=7 -o s3.restore-timeout=72h)
fi

# restic 0.19.1 defect [SDLC-01] (docs/ENGINE_PATCHES.md, patch 0001): when
# Amazon accepts a request to prepare an archived pack it answers "202
# Accepted", which this release mistakes for an error. The run then stops
# with exit code 1 and exactly that text, having asked for ONE pack. A pack
# already being prepared is passed over on the next run, never asked for
# twice, so running the same command again asks for the next one, until a
# run gets past the asking and waits for Amazon as intended. Matched as
# narrowly as the app's ArchiveRestoreSignals.isRestoreRequestAccepted: exit
# code 1 and a whole line that is the status text (or restic's exit message
# with library log lines appended). Anything else is a real error and stops.
# The cap bounds one sitting (at restic's 16 MiB packs, about 78 GiB of
# archived files); a rerun carries on where it stopped.
MAX_PREPARE_REQUESTS=5000
ASKED=0
SIGNATURE_A='202 accepted'
SIGNATURE_B='202 accepted also, the following messages were logged by a library:'

trimmed_lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'; }

# $1 exit code, $2 file holding restic's standard error.
request_accepted() {
    local line text
    [ "$1" -eq 1 ] || return 1
    while IFS= read -r line || [ -n "$line" ]; do
        text="$(trimmed_lower "$line")"
        [ "$text" = "$SIGNATURE_A" ] && return 0
        case "$text" in "$SIGNATURE_B"*) return 0 ;; esac
    done < "$2"
    return 1
}

# Keeps restic's standard error in $1 and shows it as it arrives, except the
# bare "202 Accepted" line, which here means good news and would read as an
# error to someone already stressed. A held-back line is kept in $1.held and
# shown after all if the run turns out to be a real failure.
show_errors() {
    local line
    : > "$1"
    : > "$1.held"
    while IFS= read -r line || [ -n "$line" ]; do
        printf '%s\n' "$line" >> "$1"
        case "$line" in *[0-9][0-9][0-9]*[Aa][Cc][Cc][Ee][Pp][Tt][Ee][Dd]*)
            if [ "$(trimmed_lower "$line")" = "$SIGNATURE_A" ]; then
                printf '%s\n' "$line" >> "$1.held"
                continue
            fi ;;
        esac
        printf '%s\n' "$line" >&2
    done
}

# restic's progress goes straight to this window (it shows live progress only
# on a terminal); only its standard error passes through show_errors.
exec 3>&1
restore_folder() { # $1 snapshot ID, $2 folder name
    local accepted=0 status
    while true; do
        set +e
        caffeinate -i "$RESTIC" restore "$1" "${OPTIONS[@]}" 2>&1 1>&3 3>&- | show_errors "$WORK/restore-err.txt"
        status=${PIPESTATUS[0]}
        set -e
        [ "$status" -eq 0 ] && return 0
        if [ "$REMOTE" = 1 ] && request_accepted "$status" "$WORK/restore-err.txt"; then
            accepted=$(( accepted + 1 ))
            ASKED=$(( ASKED + 1 ))
            if [ "$ASKED" -eq 1 ]; then
                say ""
                say "Amazon has started preparing your older files. This version of restic"
                say "asks for them one pack (one piece of your backup) at a time, so the"
                say "tool asks again until every pack is on its way. That is expected."
            fi
            if [ "$accepted" -le 10 ] || [ $(( accepted % 10 )) -eq 0 ]; then
                say "Asked Amazon to prepare $ASKED packs so far..."
            fi
            if [ "$accepted" -ge "$MAX_PREPARE_REQUESTS" ]; then
                fail "Amazon is preparing the $ASKED packs asked for so far, the most this tool asks for in one go. Nothing is lost: run this tool again with the same answers and it carries on from here instead of starting over. Your files are not restored yet."
            fi
            continue
        fi
        cat "$WORK/restore-err.txt.held" >&2
        if [ "$REMOTE" = 1 ]; then
            fail "restic could not finish restoring $2 (its message is just above). Files already copied to $DEST are not complete. Amazon keeps preparing anything already asked for ($ASKED packs this time), and prepared files stay ready for 7 days, so once the problem is fixed or has passed, running this tool again carries on from there."
        fi
        fail "restic could not finish restoring $2 (its message is just above). Files already copied to $DEST are not complete."
    done
}

n=1
for ID in $IDS; do
    n=$(( n + 1 ))
    NAME="$(printf '%s' "$NAMES" | cut -d'|' -f$n)"
    say ""
    say "Restoring $NAME ..."
    restore_folder "$ID" "$NAME"
done
say ""
say "Done. Your files are in $DEST, inside folders named after where they"
say "came from. Check them before deleting anything else."
