testsAndMisc/linux_configuration/scripts/single_use/features/keepass_sync.sh
Krzysztof kuhy Rudnicki 9dd9aeb50e Fix the 11 shellcheck findings that kept CI red since b83ce5d
"Pre-commit checks" has failed on every push since b83ce5d removed
shellcheck's --severity filter. The findings never appeared locally: CI
installs an older shellcheck than this machine, and 0.11.0 no longer emits
SC2002/SC2015/SC2119 at all. Reproduced by fetching shellcheck 0.9.0, which
matches CI's output exactly, and verified against both versions.

SC2015 x6 -- rewrote `A && B || C` as an explicit if. Each keeps its trailing
`|| true`, so a failing side effect still cannot abort the caller under set -e.
The first draft dropped it in run_phone.sh; a review pass caught that and it
was restored, since that call is the last statement of a function.

SC2119 x3 -- require_imagemagick and adb_select_device take an OPTIONAL first
argument, so the bare calls were already correct. Following shellcheck's
literal advice and passing "$@" would forward the script's own arguments as a
tool preference, which would be a real defect. Passing an explicit "" states
the same intent without changing behaviour, confirmed by running both forms.

SC2002 x2 -- `cat FILE | tr ...` becomes `tr ... < FILE`, keeping 2>/dev/null
so a missing file still yields an empty string.

No suppressions, no --severity filter, no exclusions added. Verified: 0
findings under both shellcheck versions, `pre-commit run shellcheck
--all-files` passes, test_adb_common 13/13, test_boot_repair 31/31, 1193
Python tests at 100% coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ReVsw7qovYkkGbqcU9tn7
2026-08-04 20:55:02 +02:00

191 lines
7.5 KiB
Bash
Executable File

#!/bin/bash
# keepass_sync.sh — bidirectional, merge-safe reconcile of the KeePass vault
# between this device's working copy and the canonical cloud copy.
#
# The vault is a single encrypted file that is NOT multi-writer safe, so a raw
# copy/PUT would clobber concurrent edits. Safety comes from `keepassxc-cli
# merge` (entry-level, timestamp-based, idempotent). This script:
# 1. reads the master password (+ dufs creds) from the OS keyring (secret-tool)
# 2. downloads the remote copy
# 3. backs up the local copy, then merges remote INTO local (local = union)
# 4. uploads the merged local back to remote, guarded by a hash compare-and-swap
# so a concurrent remote change is never clobbered (it retries instead)
#
# It is self-contained (no repo lib deps) so it runs identically on the Arch PC
# and the Ubuntu work laptop. Triggered by keepass-sync.{path,timer}. Idempotent
# and flock-guarded — safe to run at any time, in parallel invocations queue.
#
# Config (non-secret) is read from $KP_SYNC_CONFIG (default ~/.config/keepass-sync/config.env):
# LOCAL_DB=/home/<you>/Keepass/Passwords.kdbx
# REMOTE_MODE=file|webdav
# REMOTE_FILE=/home/<you>/cloud/Keepass/Passwords.kdbx # file mode (the PC)
# REMOTE_URL=https://host/Keepass/Passwords.kdbx # webdav mode (laptop)
# DUFS_USER=<user> # webdav mode
# Secrets come from the keyring (service "keepass-sync"):
# key=master -> the vault master password
# key=dufs -> the dufs web password (webdav mode only)
set -euo pipefail
readonly KP_SYNC_CONFIG="${KP_SYNC_CONFIG:-$HOME/.config/keepass-sync/config.env}"
readonly KEYRING_SERVICE="${KP_KEYRING_SERVICE:-keepass-sync}"
readonly STATE_DIR="${KP_STATE_DIR:-$HOME/.config/keepass-sync}"
readonly LOCKFILE="$STATE_DIR/lock"
readonly MAX_CAS_RETRIES=3
log() { printf '[keepass-sync] %s\n' "$*" >&2; }
die() { printf '[keepass-sync] ERROR: %s\n' "$*" >&2; exit 1; }
# Clean, non-fatal skip (keyring locked / server down) — retried next cycle.
skip() { printf '[keepass-sync] skip: %s\n' "$*" >&2; exit 0; }
# --- load config -------------------------------------------------------------
[[ -f "$KP_SYNC_CONFIG" ]] || die "config not found: $KP_SYNC_CONFIG (run setup_keepass_sync.sh)"
# shellcheck source=/dev/null
source "$KP_SYNC_CONFIG"
: "${LOCAL_DB:?LOCAL_DB not set in config}"
: "${REMOTE_MODE:?REMOTE_MODE not set in config}"
TMPDIR_RUN=""
cleanup() { [[ -n "$TMPDIR_RUN" && -d "$TMPDIR_RUN" ]] && rm -rf "$TMPDIR_RUN"; }
trap cleanup EXIT
# --- secrets -----------------------------------------------------------------
# The vault master password is NEVER stored: the open-wrapper passes it in
# memory via KP_MASTER_PW for the duration of a KeePass session. (A keyring
# lookup is kept only as an optional fallback, off by default.)
MASTER_PW="${KP_MASTER_PW:-}"
if [[ -z "$MASTER_PW" ]]; then
MASTER_PW="$(secret-tool lookup service "$KEYRING_SERVICE" key master 2>/dev/null || true)"
fi
[[ -n "$MASTER_PW" ]] || skip "no master password provided (KP_MASTER_PW unset, keyring empty)"
# The dufs *server* password (webdav transport only) is NOT the vault key, so it
# may be cached in the keyring; env var overrides.
DUFS_PASS=""
if [[ "$REMOTE_MODE" == "webdav" ]]; then
: "${REMOTE_URL:?REMOTE_URL required for webdav mode}"
: "${DUFS_USER:?DUFS_USER required for webdav mode}"
DUFS_PASS="${KP_DUFS_PW:-$(secret-tool lookup service "$KEYRING_SERVICE" key dufs 2>/dev/null || true)}"
[[ -n "$DUFS_PASS" ]] || skip "no dufs password (KP_DUFS_PW unset, keyring empty)"
fi
sha() { sha256sum "$1" | cut -d' ' -f1; }
# --- remote transport abstraction (file mode vs webdav mode) -----------------
# remote_get <dest> -> 0 if fetched, 1 if remote does not exist yet
# remote_hash -> prints remote sha256, or empty if remote missing
# remote_put <src> -> upload src as the new remote (atomic where possible)
remote_get() {
local dest="$1"
case "$REMOTE_MODE" in
file)
[[ -f "$REMOTE_FILE" ]] || return 1
cp -f "$REMOTE_FILE" "$dest" ;;
webdav)
curl -fsS -u "$DUFS_USER:$DUFS_PASS" -o "$dest" "$REMOTE_URL" 2>/dev/null || return 1 ;;
*) die "unknown REMOTE_MODE: $REMOTE_MODE" ;;
esac
}
remote_hash() {
case "$REMOTE_MODE" in
file) if [[ -f "$REMOTE_FILE" ]]; then sha "$REMOTE_FILE" || true; fi ;;
webdav) curl -fsS -u "$DUFS_USER:$DUFS_PASS" "${REMOTE_URL}?hash" 2>/dev/null || true ;;
esac
}
remote_put() {
local src="$1"
case "$REMOTE_MODE" in
file)
local dir; dir="$(dirname "$REMOTE_FILE")"
mkdir -p "$dir"
cp -f "$src" "$REMOTE_FILE.tmp.$$" && mv -f "$REMOTE_FILE.tmp.$$" "$REMOTE_FILE" ;;
webdav)
curl -fsS -u "$DUFS_USER:$DUFS_PASS" -T "$src" "$REMOTE_URL" -o /dev/null ;;
esac
}
# --- validate the master password against the local DB before touching it ----
verify_pw() {
printf '%s\n' "$MASTER_PW" | keepassxc-cli ls "$1" >/dev/null 2>&1
}
backup_local() {
local bdir
bdir="$(dirname "$LOCAL_DB")/.backup_$(date +%Y%m%d_%H%M%S)"
mkdir -p "$bdir"
cp -f "$LOCAL_DB" "$bdir/" && log "backed up local vault to $bdir"
}
# --- the reconcile, with bounded compare-and-swap retries --------------------
reconcile() {
TMPDIR_RUN="$(mktemp -d)"
local tmp_remote="$TMPDIR_RUN/remote.kdbx"
# Bootstrap: if the local vault is missing, seed it from the remote.
if [[ ! -f "$LOCAL_DB" ]]; then
if remote_get "$tmp_remote"; then
mkdir -p "$(dirname "$LOCAL_DB")"
cp -f "$tmp_remote" "$LOCAL_DB"
log "bootstrapped local vault from remote"
return 0
fi
skip "no local vault and no remote vault yet — nothing to do"
fi
verify_pw "$LOCAL_DB" || die "master password from keyring does not open $LOCAL_DB — refusing to touch it"
local attempt=0
while (( attempt < MAX_CAS_RETRIES )); do
attempt=$((attempt + 1))
local remote_hash_before; remote_hash_before="$(remote_hash)"
if [[ -z "$remote_hash_before" ]]; then
# No remote yet — publish our local copy as the first version.
remote_put "$LOCAL_DB" && log "published local vault to remote (first copy)"
return 0
fi
local local_before; local_before="$(sha "$LOCAL_DB")"
if [[ "$remote_hash_before" == "$local_before" ]]; then
log "already in sync (hash $local_before)"
return 0
fi
remote_get "$tmp_remote" || skip "remote unreachable"
# Merge remote INTO local so local becomes the union. Back up first;
# never proceed to upload if the merge fails.
backup_local
if ! printf '%s\n' "$MASTER_PW" | keepassxc-cli merge --same-credentials "$LOCAL_DB" "$tmp_remote" >/dev/null 2>&1; then
die "keepassxc-cli merge failed — local vault left intact (see latest .backup_*)"
fi
local local_after; local_after="$(sha "$LOCAL_DB")"
# Compare-and-swap: only upload if the remote hasn't changed since our
# read; otherwise someone else wrote concurrently — retry the whole
# reconcile with the new remote (merges are commutative, so it converges).
local remote_hash_now; remote_hash_now="$(remote_hash)"
if [[ "$remote_hash_now" != "$remote_hash_before" ]]; then
log "remote changed during merge (CAS) — retrying ($attempt/$MAX_CAS_RETRIES)"
continue
fi
if [[ "$local_after" != "$remote_hash_now" ]]; then
remote_put "$LOCAL_DB" && log "uploaded merged vault to remote"
fi
log "reconcile complete (local $local_after)"
return 0
done
die "gave up after $MAX_CAS_RETRIES CAS retries — remote is changing too fast; will retry next cycle"
}
# --- serialize: only one reconcile at a time ---------------------------------
mkdir -p "$STATE_DIR"
exec 9>"$LOCKFILE"
if ! flock -n 9; then
skip "another reconcile is running"
fi
reconcile