Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Borg-simple core

This script is a smaller, bare-case version of the Borg-simple script. It has only two functions: archive one folder to one drive, or extract one repo from one drive. The main motivation is that experts can review quickly the core functions. It has no repo list, no retention, no in-place restore, no config file.


#!/bin/bash
# borg-super-simple, v9

set -euo pipefail
case $- in *x*) printf 'refusing to run under set -x\n' >&2; exit 1 ;; esac

CONFIG_FILE="$HOME/.borg-config"
MOUNT_BASE="/media/$(id -un)"
REPO_SUBDIR=""
PASSPHRASE_PATH=""
ALL_DRIVES=""
SELF=$(readlink -f -- "$0")

declare -A SRC=() DRIVES=() EXCL=() ALLOW=() ARCH=() PASS=()
declare -a REPOS=()
CUR=""

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

# shellcheck disable=SC2088  # '~/' is a literal pattern here, expanded by hand
_abs() { case "$1" in /) printf '/\n' ;; /*) printf '%s\n' "${1%/}" ;;
                      '~') printf '%s\n' "$HOME" ;;
                      '~/'*) printf '%s\n' "$HOME/${1#'~/'}" ;;
                      *) printf '%s\n' "$HOME/${1%/}" ;; esac; }

repo_name()       { CUR="$1"; REPOS+=("$1"); ARCH["$1"]=1; }
backup_data()     { local p; for p in "$@"; do SRC[$CUR]+="$(_abs "$p")"$'\n'; done; }
backup_drives()   { DRIVES[$CUR]="$*"; }
exclude()         { local g; for g in "$@"; do EXCL[$CUR]+="$g"$'\n'; done; }
include_only()    { ALLOW[$CUR]=1; }
include_only_in() { ALLOW[$CUR]=1; }
archive()         { [[ "${1:-}" == no ]] && ARCH[$CUR]=0; return 0; }
set_pass()        { PASS["$1"]="$2"; }
keep() { :; }; restore_to() { :; }; compression() { :; }

require_private() {
    local mode
    [[ -r "$1" ]] || die "$1 not found or not readable"
    mode=$(stat -c '%a' "$1") || die "cannot stat $1"
    (( (8#$mode & 8#77) == 0 )) || die "$1 is reachable by group or other (mode $mode); run: chmod 600 $1"
}

# The version gate the full tool has, for the same reason and with more at stake
# here: this is the tool you reach for on the machine where things have gone
# wrong, so it must not be the one that quietly writes archives of a shape the
# full tool cannot restore. Below 1.4 the /./ anchor is ignored without a word,
# and every folder is stored under its full path instead of its own name, so
# `backup extract -i` later asks borg for 'Documents' and the archive holds
# 'home/john/Documents'. 2.x changed the archive layout and the command surface.
_require_borg() {
    command -v borg >/dev/null 2>&1 || die "borg not found; install it first"
    local out ver major minor
    out=$(borg --version 2>/dev/null) || die "borg is installed but 'borg --version' failed"
    ver="${out##* }"
    major="${ver%%.*}"; minor="${ver#*.}"; minor="${minor%%.*}"
    case "$major$minor" in
        ''|*[!0-9]*) die "cannot read a borg version out of '$out'; expected something like 'borg 1.4.0'" ;;
    esac
    if (( major >= 2 )); then
        die "borg $ver is 2.x, which changed the archive layout; install borg 1.4 or newer and below 2.0"
    fi
    if (( major < 1 || minor < 4 )); then
        die "borg $ver is too old: 1.4 is the first version that stores a folder at the top of the archive under its own name, and older borg ignores that silently, so these archives would hold full paths and 'backup extract -i' would not find the folders. Install borg 1.4 or newer (Debian trixie and Devuan Excalibur ship it; bookworm needs bookworm-backports)"
    fi
    return 0
}

load_config() {
    _require_borg
    require_private "$CONFIG_FILE"
    # An unimplemented directive must not abort the source, but a mistyped one
    # must not vanish either: it would silently drop a folder from the backup.
    # shellcheck disable=SC2317  # bash calls this indirectly while sourcing
    command_not_found_handle() { warn "ignoring unknown config directive '$1'"; return 0; }
    # shellcheck source=/dev/null
    source "$CONFIG_FILE" || die "failed to load $CONFIG_FILE"
    unset -f command_not_found_handle
    MOUNT_BASE=$(_abs "$MOUNT_BASE")
    [[ -d "$MOUNT_BASE" ]] || die "MOUNT_BASE '$MOUNT_BASE' is not a directory"
    [[ -z "$PASSPHRASE_PATH" ]] || PASSPHRASE_PATH=$(_abs "$PASSPHRASE_PATH")
}

# borg re-invokes _emit-pass through this, so the cleartext goes from gpg or cat
# straight into borg and never enters this process. Empty when there is no
# passphrase file, and borg then prompts.
_passcommand() {
    [[ -n "$PASSPHRASE_PATH" && -e "$PASSPHRASE_PATH" ]] || return 0
    case "$BASH$SELF$PASSPHRASE_PATH" in
        *[[:space:]\'\"\\]*) die "borg splits the passcommand without a shell, so these paths must have no whitespace, quotes or backslashes: '$BASH' '$SELF' '$PASSPHRASE_PATH'" ;;
    esac
    printf '%s %s _emit-pass %s %s\n' "$BASH" "$SELF" "$PASSPHRASE_PATH" "$1"
}

# Plain or GPG is decided by content, not by filename, so the same file the full
# tool reads works here whatever it is called.
_emit_pass() {
    (( $# == 2 )) || { printf '_emit-pass needs <file> <repo>\n' >&2; exit 2; }
    local ct
    require_private "$1"
    if LC_ALL=C grep -qaE '^[[:space:]]*set_pass[[:space:]]' -- "$1"; then
        ct=$(cat -- "$1") || exit 1
    elif LC_ALL=C grep -qa '[^[:print:][:space:]]' -- "$1" \
      || LC_ALL=C grep -qa '^-----BEGIN PGP MESSAGE-----' -- "$1"; then
        ct=$(gpg --quiet --decrypt "$1") || exit 1
    else
        ct=$(cat -- "$1") || exit 1
    fi
    eval "$ct"; unset ct
    [[ -n "${PASS[$2]:-}" ]] || { printf 'no passphrase for %s in %s\n' "$2" "$1" >&2; exit 1; }
    printf '%s' "${PASS[$2]}"
}

do_backup() {
    (( $# == 0 )) || die "usage: borg-super-simple backup"
    local repo drive src g root pc rc=0 crc
    local -a srcs pats roots
    for repo in ${REPOS[@]+"${REPOS[@]}"}; do
        (( ${ARCH[$repo]} )) && [[ -n "${SRC[$repo]:-}" ]] || continue
        [[ -z "${ALLOW[$repo]:-}" ]] || { warn "$repo uses include_only; this tool cannot apply allowlists, so it is skipped (use the full tool)"; rc=1; continue; }
        srcs=(); roots=(); pats=()
        while IFS= read -r src; do
            [[ -n "$src" ]] || continue
            srcs+=( "$(dirname "$src")/./$(basename "$src")" )
            roots+=( "${src#/}" )
        done <<< "${SRC[$repo]}"
        # borg matches patterns against the source path with its leading slash
        # off, never against the name the item is stored under, so anchor every
        # glob on each source root. '**/' also matches zero directories.
        while IFS= read -r g; do
            [[ -n "$g" ]] || continue
            if [[ "$g" == /* ]]; then pats+=( --pattern "- sh:${g#/}" )
            else for root in "${roots[@]}"; do pats+=( --pattern "- sh:$root/**/$g" ); done; fi
        done <<< "${EXCL[$repo]:-}"
        pc=$(_passcommand "$repo")
        for drive in ${DRIVES[$repo]:-$ALL_DRIVES}; do
            mountpoint -q "$MOUNT_BASE/$drive" || { warn "$drive not mounted; skipping $repo there"; rc=1; continue; }
            say "archiving $repo to $drive..."
            crc=0
            (
                export BORG_REPO="$MOUNT_BASE/$drive${REPO_SUBDIR:+/$REPO_SUBDIR}/$repo"
                [[ -z "$pc" ]] || export BORG_PASSCOMMAND="$pc"
                [[ -d "$BORG_REPO" ]] || borg init --encryption=repokey || exit 1
                borg create ${pats[@]+"${pats[@]}"} "::{now}" "${srcs[@]}"
            ) || crc=$?
            (( crc <= 1 )) || { warn "$repo on $drive failed (borg exited $crc)"; rc=1; }
        done
    done
    return "$rc"
}

do_extract() {
    (( $# == 2 )) || die "usage: borg-super-simple extract <repo> <drive>"
    local path latest pc
    local -a lock=()
    path="$MOUNT_BASE/$2${REPO_SUBDIR:+/$REPO_SUBDIR}/$1"
    [[ -d "$path" ]] || die "no repo at $path"
    [[ -w "$path" ]] || { lock=(--bypass-lock); warn "$path is not writable; reading with borg's lock bypassed"; }
    pc=$(_passcommand "$1")
    export BORG_REPO="$path"
    [[ -z "$pc" ]] || export BORG_PASSCOMMAND="$pc"
    latest=$(borg list ${lock[@]+"${lock[@]}"} --last 1 --short) || die "could not list $path"
    [[ -n "$latest" ]] || die "no archives in $path"
    say "extracting $1 from $2 (archive: $latest) into $PWD..."
    borg extract ${lock[@]+"${lock[@]}"} "::$latest"
}

case "${1:-}" in
    _emit-pass) shift; _emit_pass "$@" ;;
    backup)     shift; load_config; do_backup "$@" ;;
    extract)    shift; load_config; do_extract "$@" ;;
    *)          die "usage: borg-super-simple backup | borg-super-simple extract <repo> <drive>" ;;
esac