#!/bin/bash
# Written by Wren Linux extras/whonix.sh: updates Tor Browser in the Workstation,
# keeping your bookmarks and downloads. data/whonix-update runs this as
# the user: never as root, since a hacked browser can write in these folders.
#
# Whonix's update-torbrowser is a downloader, not an updater: it deletes
# ~/.tb/tor-browser and puts a new one in its place. The new one gets your Downloads and your
# bookmarks, and nothing else of the old profile: a hacked page can write to anything in a profile
# (settings, extensions, a changed database), and a profile carried over would keep it. The
# bookmarks are read out of the old database and written again as plain addresses and titles. So:
#   1. It only runs for a newer version. --noask would install an older one without
#      asking, and an old version, with its old holes, is what an attacker would offer.
#   2. The old folder is moved aside, whole: no copying, no room needed, and a
#      failure puts back exactly what was there.
#   3. The new folder gets your downloads, and your bookmarks rebuilt as bookmarks.html (checked
#      addresses and titles only), which Firefox imports on its first start. The old profile and
#      anything else there that the new one doesn't have (a page you saved beside Downloads, say)
#      goes to ~/.tb/left-from-browser-VERSION: kept, but out of the folder that runs.
#   4. The old folder goes.
# A power cut at any point is put right by the next run (resume).
set -u
set -o pipefail

TB=$HOME/.tb/tor-browser
KEEP=$TB.wren-keep
READY=$KEEP/.new-browser-ready
# The old folder on its way out: nothing in it counts any more
GONE=$TB.wren-gone
FEED=https://aus1.torproject.org/torbrowser/update_3/release/download-linux-x86_64.json
# What is yours, under Browser/, and comes over as it is (a new copy has an empty one)
YOURS=(Downloads)
# The profile: not carried over. Its bookmarks are (export_bookmarks)
PROFILES=TorBrowser/Data/Browser
PROFILE=profile.default
# Safest and JavaScript off, as Wren Linux put it in this image (data/tor-browser-safest.js)
SAFEST=${WREN_SAFEST:-/usr/local/etc/torbrowser.d/user.js}

# version_in: the version in the JSON on standard input, only if it looks like one
version_in() {
    python3 -I -c '
import json, re, sys
v = json.load(sys.stdin)["version"]
if not (isinstance(v, str) and len(v) <= 20 and re.fullmatch(r"[0-9]+(\.[0-9]+)+", v)):
    sys.exit(1)
print(v)' 2>/dev/null
}
version_here() { { version_in <"$TB/Browser/tbb_version.json"; } 2>/dev/null; }

# roll_back: the old folder back, whole, and whatever the update left gone
roll_back() {
    rm -rf "$TB"
    mv "$KEEP" "$TB"
}

# left_dir: where what isn't carried over is kept
left_dir() {
    local old
    old=$({ version_in <"$KEEP/Browser/tbb_version.json"; } 2>/dev/null) || old=old
    echo "$HOME/.tb/left-from-browser-$old"
}

# keep_the_rest: what is in the old browser's folder and not in the new one's, out of it.
# Some of this may only be old program files; nothing runs from there either way.
keep_the_rest() {
    local left f moved=false
    left=$(left_dir)
    while IFS= read -r -d '' f; do
        [ ! -e "$TB/Browser/${f##*/}" ] && [ ! -L "$TB/Browser/${f##*/}" ] || continue
        # (the program's own traces, not your files: what you browsed shouldn't stay behind)
        case ${f##*/} in .cache | .local) continue ;; esac
        mkdir -p "$left"
        mv --backup=numbered "$f" "$left/" || return 1
        moved=true
    done < <(find "$KEEP/Browser" -mindepth 1 -maxdepth 1 -print0)
    if $moved; then echo "Files that aren't part of the new Tor Browser are kept in $left"; fi
}

# export_bookmarks PLACES: the bookmarks in that Firefox database, as a bookmarks.html on standard
# output, or a failure. The file is a hacked browser's to change, so only checked text comes out:
# web addresses (http and https, no quote, space or angle bracket in them), titles with no control
# character or direction mark, escaped. No javascript:, data: or place: entries (a bookmarklet runs
# in a page), no tags, at most 5000 bookmarks and ten levels of folders. The database is read from a
# copy, together with its -wal file (a read in place would miss what Firefox hadn't yet written
# into it, and could change the original).
export_bookmarks() {
    python3 -I -c '
import html, os, re, shutil, sqlite3, sys, tempfile
src = sys.argv[1]
URL = re.compile(r"https?://[^\s\"<>\\\x00-\x1f\x7f]{1,2000}\Z")
BAD = re.compile("[\x00-\x1f\x7f-\x9f\u200e\u200f\u202a-\u202e\u2066-\u2069]")
MAX, DEPTH = 5000, 10
def text(t):
    return html.escape(BAD.sub("", t or "")[:200], quote=True)
with tempfile.TemporaryDirectory() as d:
    dst = os.path.join(d, "places.sqlite")
    shutil.copyfile(src, dst)
    if os.path.exists(src + "-wal"):
        shutil.copyfile(src + "-wal", dst + "-wal")
    con = sqlite3.connect(dst)
    rows = con.execute("select id, parent, type, fk, title from moz_bookmarks order by parent, position").fetchall()
    urls = dict(con.execute("select id, url from moz_places"))
kids = {}
for i, parent, kind, fk, title in rows:
    kids.setdefault(parent, []).append((i, kind, fk, title))
count = 0
out = []
def walk(parent, depth, pad):
    global count
    for i, kind, fk, title in kids.get(parent, []):
        if kind == 1:
            url = urls.get(fk) or ""
            if URL.match(url) and count < MAX:
                count += 1
                out.append("%s<DT><A HREF=\"%s\">%s</A>" % (pad, html.escape(url, quote=True), text(title)))
        elif kind == 2 and depth < DEPTH:
            out.append("%s<DT><H3>%s</H3>" % (pad, text(title)))
            out.append("%s<DL><p>" % pad)
            walk(i, depth + 1, pad + "    ")
            out.append("%s</DL><p>" % pad)
# 2 menu, 3 toolbar, 5 unfiled (4 are tags, 6 the phone: not carried over)
print("<!DOCTYPE NETSCAPE-Bookmark-file-1>")
print("<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">")
print("<TITLE>Bookmarks</TITLE>")
print("<H1>Bookmarks Menu</H1>")
print("<DL><p>")
out = []
walk(2, 1, "    ")
print("\n".join(out))
for root, mark, name in ((3, "PERSONAL_TOOLBAR_FOLDER", "Bookmarks Toolbar"), (5, "UNFILED_BOOKMARKS_FOLDER", "Other Bookmarks")):
    print("    <DT><H3 %s=\"true\">%s</H3>" % (mark, name))
    print("    <DL><p>")
    out = []
    walk(root, 1, "        ")
    print("\n".join(out))
    print("    </DL><p>")
print("</DL>")
' "$1"
}

# reinforce_safest: the new profile gets Safest and JavaScript off from here, written into its own
# user.js (which Firefox applies at every start), instead of trusting that Whonix's own start-up
# does it on a fresh profile. The settings are the file Wren Linux put in this image, root's. Run
# again, it replaces its own block and nothing else. A failure is said, not fatal: the update
# itself worked, and the person must set Safest by hand then.
reinforce_safest() {
    local dir=$TB/Browser/$PROFILES/$PROFILE js tmp
    js=$dir/user.js
    if [ ! -f "$SAFEST" ] || [ -L "$SAFEST" ]; then
        echo "WARNING: $SAFEST is missing, so Safest and JavaScript off were NOT set in the new Tor Browser. Set them in its shield settings."
        return 0
    fi
    mkdir -p "$dir" || return 1
    tmp=$dir/.user.js.wren
    {
        [ ! -f "$js" ] || sed '/^\/\/ >>> Wren Linux: Safest, JavaScript off >>>$/,/^\/\/ <<< Wren Linux: Safest <<<$/d' "$js"
        echo '// >>> Wren Linux: Safest, JavaScript off >>>'
        cat "$SAFEST"
        echo '// <<< Wren Linux: Safest <<<'
    } >"$tmp" && mv -f "$tmp" "$js" || { echo "WARNING: couldn't write $js, so Safest and JavaScript off were NOT set."; return 0; }
    echo "The new Tor Browser is at the Safest level with JavaScript off."
}

# reinforce_policies: the new browser gets the policy file that locks the Safest level (the slider
# immovable, about:config blocked), from the copy Wren Linux put in this image, root's, instead of
# trusting that Whonix's start-up (50_wren.conf, which puts it back at every browser start) still
# does. Without it the level is still Safest from user.js, only not locked, so a failure is said,
# not fatal.
POLICIES=${WREN_POLICIES:-/usr/local/etc/torbrowser.d/policies.json}
reinforce_policies() {
    local dir=$TB/Browser/distribution
    if [ ! -f "$POLICIES" ] || [ -L "$POLICIES" ]; then
        echo "WARNING: $POLICIES is missing, so the Safest level is NOT locked in the new Tor Browser."
        return 0
    fi
    { mkdir -p "$dir" && cp "$POLICIES" "$dir/policies.json"; } ||
        { echo "WARNING: couldn't write $dir/policies.json, so the Safest level is NOT locked."; return 0; }
    echo "The Safest level is locked by the browser's policy file."
}

# bring_bookmarks: the old profile's bookmarks into the new profile, and the old profile itself aside.
# Run again after a power cut: with the old profile already aside, there is nothing left to do.
bring_bookmarks() {
    local old=$KEEP/Browser/$PROFILES new=$TB/Browser/$PROFILES left tmp exported=false
    [ -d "$old" ] || return 0
    left=$(left_dir)
    mkdir -p "$new/$PROFILE" || return 1
    if [ -f "$old/$PROFILE/places.sqlite" ]; then
        tmp=$new/$PROFILE/.bookmarks.html.wren
        if export_bookmarks "$old/$PROFILE/places.sqlite" >"$tmp" 2>/dev/null; then
            mv -f "$tmp" "$new/$PROFILE/bookmarks.html" || return 1
            exported=true
            # Firefox imports bookmarks.html when its bookmarks are new, and turns this off itself
            grep -qs '^user_pref("browser.places.importBookmarksHTML", true);' "$new/$PROFILE/user.js" ||
                echo 'user_pref("browser.places.importBookmarksHTML", true);' >>"$new/$PROFILE/user.js"
            echo "Your bookmarks are rebuilt in the new Tor Browser (addresses and titles only)."
        else
            rm -f "$tmp"
            echo "Couldn't read your bookmarks from the old profile, so none were carried over."
        fi
    fi
    if $exported; then
        # Only the bookmarks and the downloads are kept: the rest of the old profile (history,
        # cookies, settings, anything a hacked page wrote) goes.
        rm -rf "${old:?}" || return 1
        echo "The old profile is deleted: only your bookmarks and Downloads are kept."
        return 0
    fi
    # Not read out: kept, out of the folder that runs, so the bookmarks can still be got at
    mkdir -p "$left" || return 1
    mv --backup=numbered "$old" "$left/profile" || return 1
    echo "The old profile is kept in $left/profile, out of the folder that runs."
}

# bring_yours: from the old folder to the new one, then the old one goes. Each item
# moves in one step, and one moved already is skipped, so this can run again. The old
# folder changes its name, in one step, before it is deleted: a delete cut off halfway
# may have taken the marker first, and that must not read as "not found good" (resume
# would then delete the new browser, with your things in it).
bring_yours() {
    local item
    for item in "${YOURS[@]}"; do
        [ -e "$KEEP/Browser/$item" ] || continue
        rm -rf "${TB:?}/Browser/$item"
        mkdir -p "$(dirname -- "$TB/Browser/$item")"
        mv "$KEEP/Browser/$item" "$TB/Browser/$item" || return 1
    done
    reinforce_safest || return 1
    reinforce_policies || return 1
    bring_bookmarks || return 1
    keep_the_rest || return 1
    mv "$KEEP" "$GONE" || return 1
    rm -rf "$GONE"
}

# resume: an earlier run was cut off. With the new browser found good, carry on
# bringing your things over; before that, back to how it was.
resume() {
    rm -rf "$GONE"
    [ -d "$KEEP" ] || return 0
    echo "An earlier update was cut off: putting that right first."
    if [ -e "$READY" ]; then bring_yours; else roll_back; fi
}

resume || { echo "Couldn't put right the earlier update; your things are in $KEEP"; exit 1; }

have=$(version_here) || have=
# (through the Gateway's Tor: the Workstation has no other way out)
new=$(curl --fail --silent --show-error --proto =https --tlsv1.2 --max-time 600 --max-filesize 1048576 "$FEED" | version_in) ||
    { echo "Couldn't read Tor Browser's newest version."; exit 1; }

if [ -n "$have" ] && dpkg --compare-versions "$have" eq "$new"; then
    echo "Tor Browser $have is the newest: your copy isn't touched."
    exit 0
fi
if [ -n "$have" ] && dpkg --compare-versions "$have" gt "$new"; then
    echo "You have Tor Browser $have, and the server says the newest is $new. An older one isn't installed:"
    echo "that could be an attack. Try again later, on another Tor circuit."
    exit 1
fi

echo "Tor Browser ${have:-(none yet)} to $new. Your bookmarks and Downloads are kept, nothing else of the old profile."
if [ -d "$TB" ]; then mv "$TB" "$KEEP" || { echo "Couldn't move the old Tor Browser aside."; exit 1; }; fi

# (it checks the signature itself)
update-torbrowser --noask --noaskstart --input none
rc=$?
# A download cut off (status 17) is tried again, where it stopped, a few times: a bridge is where
# this happens, and the people on bridges have the worst connections. Any other failure is not
# tried again, and the checks below stay what they were.
tries=1
while [ "$rc" -eq 17 ] && [ "$tries" -lt 4 ]; do
    tries=$((tries + 1))
    echo "The download was cut off. Trying again, where it stopped (try $tries of 4)."
    /bin/sleep "${WREN_RETRY_WAIT:-10}"
    update-torbrowser --noask --noaskstart --input none --resume
    rc=$?
done
now=$(version_here) || now=
# It says it worked and the version is really higher (the server was asked again: it
# could have changed its answer), or everything goes back as it was
if [ "$rc" -ne 0 ] || [ -z "$now" ] || { [ -n "$have" ] && ! dpkg --compare-versions "$now" gt "$have"; }; then
    echo "The update didn't work (update-torbrowser: $rc; version now: ${now:-none}). Your Tor Browser stays as it was."
    if [ -d "$KEEP" ]; then roll_back; else rm -rf "$TB"; fi
    exit 1
fi
if [ -d "$KEEP" ]; then
    sync
    : >"$READY"
    sync
    bring_yours || { echo "Couldn't bring your profile over; it is in $KEEP"; exit 1; }
else
    reinforce_safest || echo "WARNING: couldn't set Safest and JavaScript off in the new Tor Browser."
    reinforce_policies || echo "WARNING: couldn't lock the Safest level in the new Tor Browser."
fi
sync
echo "Tor Browser is $now now."
