LibrePortal/scripts/system/libreportal-ownership
librelad c9779d6581 restore: create staging as the principal that writes to it
`restore system` reported

    ✓ Success System config restored to: /libreportal-system/restore/system-config

for a directory that did not exist. Nothing had been written — on the step the
whole restore ordering depends on, since the system config carries every other
backup location's credentials.

Restore stages through $SYSTEM_DIR, which the manager owns, but the thing that
writes into the staging tree is restic, and runBackupOp runs it as the container
user. Both call sites created the directory as the wrong principal, in opposite
directions:

  backupRestoreSystemConfig  runFileOp mkdir  -> container user; denied on the
                             0751 manager-owned restore_dir, and unchecked
  storageRestoreAppTo        runInstallOp mkdir -> manager; restic could then
                             not create anything beneath it

Restic reports a permission denial as "ignoring error ..." and still exits 0, so
the callers' success checks were satisfied either way.

libreportal-ownership gains restore-stage (creates it cowner:MANAGER 0750 —
owner writes, manager traverses to confirm and review) and restore-unstage
(removes it; neither principal can, so staging trees simply accumulated). Both
confine the path to one component directly under the restore/migrate area.
footprint_version 8 -> 9.

backupRestoreSystemConfig now verifies the tree landed as the user that wrote
it, because the manager cannot read inside its own staging directory.

Verified on a live install: system config stages 57 real files, and the
relocation branch of storageRestoreAppTo ran for the first time — speedtest
restored from a snapshot taken at /libreportal-containers/speedtest into
/libreportal-alt/speedtest via stage-and-move, staging cleaned up afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:41:21 +01:00

473 lines
21 KiB
Bash

#!/bin/bash
# LibrePortal ownership-reconcile helper — the ONLY root-privileged file-ownership
# operation the manager is allowed to trigger via sudo.
#
# Why this exists: under Model A the runtime executes AS the manager (libreportal),
# so establishing the ownership model (manager owns the control plane, the docker
# install user owns the containers + backups) needs root. Granting the manager a
# blanket `sudo chown`/`sudo chmod` would be root-equivalent (chown /etc/sudoers,
# etc.). Instead this script — installed root:root 0755 to /usr/local/lib/libreportal/
# by init.sh, so the manager cannot modify it — performs a FIXED set of reconciles
# on FIXED LibrePortal paths only. The roots and the manager name are BAKED at
# install (sed placeholders), never read at runtime from a manager-writable config;
# the single free argument (an app name / relpath) is strictly validated.
#
# Layout — three independently-relocatable roots, each owned by ONE principal:
# SYSTEM_DIR manager-owned control plane (configs/logs/install/db/ssl/ssh/…)
# CONTAINERS_DIR container-user-owned live app data (apps live directly under it)
# BACKUPS_DIR container-user-owned backup repos (own mount-able)
#
# Self-contained ON PURPOSE: it must NOT source any manager-owned code (incl.
# paths.sh), or it would re-open the very escalation it exists to close. init.sh is
# the source of truth for the install; it bakes the values into the installed copy.
set -u
[[ $EUID -eq 0 ]] || { echo "libreportal-ownership: must run as root" >&2; exit 1; }
# Baked by init.sh at install (placeholders replaced). An unbaked copy (run
# directly from the repo before baking) still contains the "__" sentinel, which no
# real absolute path does — fall back to the defaults in that case only.
MANAGER="__MANAGER__"
CONTAINERS_DIR="__CONTAINERS_DIR__"
BACKUPS_DIR="__BACKUPS_DIR__"
SYSTEM_DIR="__SYSTEM_DIR__"
[[ "$MANAGER" == *"__"* || -z "$MANAGER" ]] && MANAGER="libreportal"
[[ "$SYSTEM_DIR" == *"__"* || -z "$SYSTEM_DIR" ]] && SYSTEM_DIR="/libreportal-system"
[[ "$CONTAINERS_DIR" == *"__"* || -z "$CONTAINERS_DIR" ]] && CONTAINERS_DIR="/libreportal-containers"
[[ "$BACKUPS_DIR" == *"__"* || -z "$BACKUPS_DIR" ]] && BACKUPS_DIR="/libreportal-backups"
# Refuse to operate on dangerous roots even if mis-baked (defence in depth).
for _d in "$SYSTEM_DIR" "$CONTAINERS_DIR" "$BACKUPS_DIR"; do
case "$_d" in
/|/etc|/usr|/bin|/sbin|/lib|/lib64|/boot|/proc|/sys|/dev|/run|/home|/root|/var|/tmp)
echo "libreportal-ownership: refusing dangerous root '$_d'" >&2; exit 1 ;;
/*) ;; # absolute — ok
*) echo "libreportal-ownership: root must be absolute: '$_d'" >&2; exit 1 ;;
esac
done
CONFIGS_DIR="$SYSTEM_DIR/configs"
LOGS_DIR="$SYSTEM_DIR/logs"
INSTALL_DIR="$SYSTEM_DIR/install"
SSL_DIR="$SYSTEM_DIR/ssl"
SSH_DIR="$SYSTEM_DIR/ssh"
RESTORE_DIR="$SYSTEM_DIR/restore"
MIGRATE_DIR="$SYSTEM_DIR/migrate"
# Storage-location bookkeeping the manager writes (the app -> root index).
# Deliberately NOT under configs/: that tree is SOURCED, and a data file there
# is executed. See storageIndexFile in scripts/source/paths.sh.
STORAGE_DIR="$SYSTEM_DIR/storage"
DB_PATH="$SYSTEM_DIR/database.db"
WEBUI_DIR="$CONTAINERS_DIR/libreportal"
TASK_DIR="$WEBUI_DIR/frontend/data/tasks"
DB_CFG="$CONFIGS_DIR/general/general_docker_install"
# Root-owned storage registry — the only authority on which roots may hold apps.
STORAGE_REGISTRY="/usr/local/lib/libreportal/storage.roots"
# Current docker mode, read authoritatively from config (read-only — informs the
# container OWNER choice, not any path).
_mode() {
local m
m=$(grep -h '^CFG_DOCKER_INSTALL_TYPE=' "$DB_CFG" 2>/dev/null | head -1 | cut -d= -f2 | awk '{print $1}')
echo "${m:-rootless}"
}
# Who owns container/backup data for a mode: rooted -> the manager; rootless -> the
# configured docker install user (must be a real account, else fall back).
_container_owner() {
local mode="$1" appusr=""
if [[ "$mode" == "rootless" ]]; then
appusr=$(grep -h '^CFG_DOCKER_INSTALL_USER=' "$DB_CFG" 2>/dev/null | head -1 | cut -d= -f2 | awk '{print $1}')
if [[ -n "$appusr" ]] && id -u "$appusr" >/dev/null 2>&1; then
echo "$appusr"; return
fi
echo "dockerinstall"
else
echo "$MANAGER"
fi
}
# Validate + resolve an app name to its container dir (reject traversal/odd names).
_app_dir() {
local app="$1"
[[ "$app" =~ ^[A-Za-z0-9._-]+$ && "$app" != "." && "$app" != ".." ]] \
|| { echo "libreportal-ownership: invalid app name" >&2; return 1; }
local d="$CONTAINERS_DIR/$app"
[[ -d "$d" ]] || { echo "libreportal-ownership: no such app dir: $d" >&2; return 1; }
printf '%s' "$d"
}
# Let the rootless container user reach the few system-tree files it must read as
# bind-mount sources (the WebUI's configs/webui/*), WITHOUT exposing the rest of
# the control plane — or those files' contents to other local users.
#
# Access is granted via the GROUP, not world: under rootless the container's gid 0
# maps to the container owner's gid on the host, so group-read is enough for the
# container while other local users get nothing. Owner stays the manager so the
# control plane can still rewrite them; the dir keeps only o+x (traverse, not list).
# This is what keeps secrets like webui_logins from being world-readable.
_webui_bind_access() {
chmod o+x "$SYSTEM_DIR" 2>/dev/null
[[ -d "$CONFIGS_DIR" ]] && chmod o+x "$CONFIGS_DIR" 2>/dev/null
if [[ -d "$CONFIGS_DIR/webui" ]]; then
local cowner; cowner="$(_container_owner "$(_mode)")"
chown "$MANAGER:$cowner" "$CONFIGS_DIR/webui" 2>/dev/null
chmod 0751 "$CONFIGS_DIR/webui" 2>/dev/null
find "$CONFIGS_DIR/webui" -maxdepth 1 -type f \
-exec chown "$MANAGER:$cowner" {} \; -exec chmod 0640 {} \; 2>/dev/null
fi
}
# Control plane -> manager; container + backup roots -> container owner.
reconcile() {
local mode="${1:-$(_mode)}"
local cowner; cowner="$(_container_owner "$mode")"
if [[ -d "$SYSTEM_DIR" ]]; then
chown "$MANAGER:$MANAGER" "$SYSTEM_DIR"
local p
for p in "$CONFIGS_DIR" "$LOGS_DIR" "$INSTALL_DIR" "$SSL_DIR" "$SSH_DIR" \
"$RESTORE_DIR" "$MIGRATE_DIR" "$STORAGE_DIR" "$DB_PATH"; do
[[ -e "$p" ]] && chown -R "$MANAGER:$MANAGER" "$p"
done
[[ -f "$DB_PATH" ]] && chmod o+r "$DB_PATH"
_webui_bind_access
fi
# Data + backups: wholly the container owner's (rootless requires it; this is
# also what lets restic — which runs AS that user — write the backup repos).
local d
for d in "$CONTAINERS_DIR" "$BACKUPS_DIR"; do
if [[ -d "$d" ]]; then
chown "$cowner:$cowner" "$d"
chmod o+x "$d"
fi
done
[[ -d "$WEBUI_DIR" ]] && chown -R "$cowner:$cowner" "$WEBUI_DIR"
}
# Traversal (+x) bits only, on the structural LibrePortal dirs.
traversal() {
[[ -d "$SYSTEM_DIR" ]] && chmod o+x "$SYSTEM_DIR"
local d
for d in "$INSTALL_DIR" "$SSL_DIR" "$SSH_DIR" "$RESTORE_DIR" "$MIGRATE_DIR"; do
[[ -d "$d" ]] && find "$d" -maxdepth 2 -type d -exec chmod +x {} \;
done
_webui_bind_access
local mode cowner; mode="$(_mode)"; cowner="$(_container_owner "$mode")"
for d in "$CONTAINERS_DIR" "$BACKUPS_DIR"; do
if [[ -d "$d" ]]; then
chown "$cowner:$cowner" "$d"
chmod o+x "$d"
fi
done
}
# Per-app structural perms + ownership of the LibrePortal-managed files only.
app_perms() {
[[ -d "$CONTAINERS_DIR" ]] || return 0
local mode cowner; mode="$(_mode)"; cowner="$(_container_owner "$mode")"
chmod +x "$CONTAINERS_DIR" 2>/dev/null
local app_dir app f
for app_dir in "$CONTAINERS_DIR"/*/; do
[[ -d "$app_dir" ]] || continue
app="$(basename "$app_dir")"
chmod +x "$app_dir" 2>/dev/null
chmod o+r "$app_dir" 2>/dev/null
find "$app_dir" -type f -name '*docker-compose*' -exec chmod o+r {} \;
for f in migrate.txt "$app.config" docker-compose.yml "docker-compose.$app.yml"; do
[[ -e "$app_dir$f" ]] && chown "$cowner:$cowner" "$app_dir$f"
done
done
}
# LibrePortal's own (regenerable) WebUI container dir -> container owner.
webui() {
local mode cowner; mode="$(_mode)"; cowner="$(_container_owner "$mode")"
[[ -d "$WEBUI_DIR" ]] && chown -R "$cowner:$cowner" "$WEBUI_DIR"
}
# Ensure the apps DB is manager-owned + world-readable (reclaims a stray
# root/other-owned DB; the WebUI reads it).
db_own() {
[[ -f "$DB_PATH" ]] || return 0
chown "$MANAGER:$MANAGER" "$DB_PATH"
chmod o+r "$DB_PATH"
}
# Structural containers/ top dir only -> container owner + traversable.
containers_top() {
local mode cowner; mode="$(_mode)"; cowner="$(_container_owner "$mode")"
if [[ -d "$CONTAINERS_DIR" ]]; then
chown "$cowner:$cowner" "$CONTAINERS_DIR"
chmod o+x "$CONTAINERS_DIR"
fi
}
# The backups root -> container owner + traversable (restic runs AS that user).
backups_top() {
local mode cowner; mode="$(_mode)"; cowner="$(_container_owner "$mode")"
if [[ -d "$BACKUPS_DIR" ]]; then
chown "$cowner:$cowner" "$BACKUPS_DIR"
chmod o+x "$BACKUPS_DIR"
fi
}
# The task IPC dir -> container owner (reclaims stale manager/root-owned files).
taskdir() {
local mode cowner; mode="$(_mode)"; cowner="$(_container_owner "$mode")"
[[ -d "$TASK_DIR" ]] && chown -R "$cowner:$cowner" "$TASK_DIR"
}
# Some apps' data must be owned by nobody (65534) inside the container.
app_data_nobody() {
local d; d="$(_app_dir "${1:-}")" || return 1
[[ -d "$d/data" ]] && chown -R 65534:65534 "$d/data"
}
# Wipe an entire app data tree, including container sub-UID dirs the
# manager / dockerinstall user can't reach (e.g. invidious/postgresdata uid
# 232070, nextcloud/html uid 33). Used by uninstall + restore-overwrite —
# both previously ran `rm -rf` via runFileOp (= as dockerinstall) and silently
# left sub-UID dirs behind, breaking reinstall + leaking storage.
# Idempotent: a missing dir is success (caller wants "ensure gone"). Refuses
# the WebUI's own slot (libreportal) — removing it would brick the WebUI.
app_data_remove() {
local app="${1:-}"
[[ "$app" =~ ^[A-Za-z0-9._-]+$ && "$app" != "." && "$app" != ".." ]] \
|| { echo "libreportal-ownership: invalid app name" >&2; return 1; }
[[ "$app" == "libreportal" ]] \
&& { echo "libreportal-ownership: refusing to remove the WebUI app dir" >&2; return 1; }
local d="$CONTAINERS_DIR/$app"
[[ -d "$d" ]] || return 0
rm -rf -- "$d"
}
# Move an app directory to another storage root.
#
# Root because it must be: app data holds files owned by rootless sub-UIDs
# (postgres at 231141 and friends) that the manager can neither read nor
# recreate, so an unprivileged copy silently loses them.
#
# The destination root is validated against the ROOT-OWNED storage registry, not
# against anything the caller says — the same trust boundary as everywhere else
# here. A path that is not a registered location is refused outright.
#
# Copy-verify-then-delete, never `mv` across filesystems: a half-completed move
# that has already unlinked the source is unrecoverable, and this runs on the
# only copy of someone's data.
app_move() {
local app="${1:-}" dest_root="${2:-}"
[[ "$app" =~ ^[A-Za-z0-9._-]+$ && "$app" != "." && "$app" != ".." ]] \
|| { echo "libreportal-ownership: invalid app name" >&2; return 1; }
[[ "$app" == "libreportal" ]] \
&& { echo "libreportal-ownership: the WebUI app dir cannot be moved" >&2; return 1; }
[[ -n "$dest_root" && "$dest_root" == /* ]] \
|| { echo "libreportal-ownership: destination must be an absolute path" >&2; return 1; }
dest_root="${dest_root%/}"
# The destination must be a REGISTERED storage root (or the primary one).
local ok=0
[[ "$dest_root" == "$CONTAINERS_DIR" ]] && ok=1
if (( ! ok )) && [[ -r "$STORAGE_REGISTRY" ]]; then
local _id _path _rest
while IFS=$'\t' read -r _id _path _rest || [[ -n "$_id" ]]; do
[[ -z "$_path" || "$_id" == \#* ]] && continue
[[ "${_path%/}" == "$dest_root" ]] && { ok=1; break; }
done < "$STORAGE_REGISTRY"
fi
(( ok )) || { echo "libreportal-ownership: '$dest_root' is not a registered storage location" >&2; return 1; }
# Find the source across every known root.
local src="" root
for root in "$CONTAINERS_DIR" $(awk -F'\t' '$1 !~ /^#/ && $2 != "" {print $2}' "$STORAGE_REGISTRY" 2>/dev/null); do
root="${root%/}"
[[ -d "$root/$app" ]] && { src="$root/$app"; break; }
done
[[ -n "$src" ]] || { echo "libreportal-ownership: no such app dir for '$app'" >&2; return 1; }
local dst="$dest_root/$app"
[[ -e "$dst" ]] && { echo "libreportal-ownership: '$dst' already exists" >&2; return 1; }
[[ "$src" == "$dst" ]] && return 0
# Same filesystem -> rename is atomic and instant. Different -> full copy,
# verified, and only then unlink the source.
local src_dev dst_dev
src_dev=$(stat -c '%d' -- "$src" 2>/dev/null)
dst_dev=$(stat -c '%d' -- "$dest_root" 2>/dev/null)
if [[ -n "$src_dev" && "$src_dev" == "$dst_dev" ]]; then
mv -- "$src" "$dst" || return 1
return 0
fi
cp -a --reflink=auto -- "$src" "$dst" || { rm -rf -- "$dst"; return 1; }
# Verify before deleting anything: file counts and total bytes must match.
local src_n dst_n src_b dst_b
src_n=$(find "$src" -mindepth 1 2>/dev/null | wc -l)
dst_n=$(find "$dst" -mindepth 1 2>/dev/null | wc -l)
src_b=$(du -sb "$src" 2>/dev/null | awk '{print $1}')
dst_b=$(du -sb "$dst" 2>/dev/null | awk '{print $1}')
if [[ "$src_n" != "$dst_n" ]]; then
echo "libreportal-ownership: copy verification failed ($src_n entries at source, $dst_n at destination). Source left intact at $src" >&2
return 1
fi
if [[ -n "$src_b" && -n "$dst_b" && "$src_b" != "$dst_b" ]]; then
echo "libreportal-ownership: copy verification failed ($src_b bytes at source, $dst_b at destination). Source left intact at $src" >&2
return 1
fi
rm -rf -- "$src"
return 0
}
# Adopt a restored tree into place: move <staged> to <dest_root>/<app>.
#
# Separate from app_move because the source is a staging directory under the
# system tree, not a live app dir — but the same reasoning applies: the tree
# carries container sub-UIDs the manager cannot recreate, and the destination
# root is validated against the ROOT-OWNED registry rather than trusted from
# the caller.
app_adopt() {
local app="${1:-}" staged="${2:-}" dest_root="${3:-}"
[[ "$app" =~ ^[A-Za-z0-9._-]+$ && "$app" != "." && "$app" != ".." ]] \
|| { echo "libreportal-ownership: invalid app name" >&2; return 1; }
[[ -n "$staged" && "$staged" == /* && -d "$staged" ]] \
|| { echo "libreportal-ownership: staged tree must be an existing absolute path" >&2; return 1; }
[[ "$staged" == *..* ]] \
&& { echo "libreportal-ownership: invalid staged path" >&2; return 1; }
# Staging must live under the system tree — never an arbitrary location.
[[ "$staged" == "$RESTORE_DIR"/* || "$staged" == "$MIGRATE_DIR"/* ]] \
|| { echo "libreportal-ownership: staged tree must be under the restore/migrate area" >&2; return 1; }
[[ -n "$dest_root" && "$dest_root" == /* ]] \
|| { echo "libreportal-ownership: destination must be an absolute path" >&2; return 1; }
dest_root="${dest_root%/}"
local ok=0
[[ "$dest_root" == "$CONTAINERS_DIR" ]] && ok=1
if (( ! ok )) && [[ -r "$STORAGE_REGISTRY" ]]; then
local _id _path _rest
while IFS=$'\t' read -r _id _path _rest || [[ -n "$_id" ]]; do
[[ -z "$_path" || "$_id" == \#* ]] && continue
[[ "${_path%/}" == "$dest_root" ]] && { ok=1; break; }
done < "$STORAGE_REGISTRY"
fi
(( ok )) || { echo "libreportal-ownership: '$dest_root' is not a registered storage location" >&2; return 1; }
local dst="$dest_root/$app"
rm -rf -- "$dst"
mkdir -p -- "$dest_root"
local s_dev d_dev
s_dev=$(stat -c '%d' -- "$staged" 2>/dev/null)
d_dev=$(stat -c '%d' -- "$dest_root" 2>/dev/null)
if [[ -n "$s_dev" && "$s_dev" == "$d_dev" ]]; then
mv -- "$staged" "$dst" || return 1
else
cp -a --reflink=auto -- "$staged" "$dst" || { rm -rf -- "$dst"; return 1; }
rm -rf -- "$staged"
fi
return 0
}
# Chown one LibrePortal-managed file under an app dir to the container owner.
# relpath is validated: no traversal, no absolute path, safe charset only.
app_file() {
local d rel mode cowner
d="$(_app_dir "${1:-}")" || return 1
rel="${2:-}"
[[ -n "$rel" && "$rel" != /* && "$rel" != *..* && "$rel" =~ ^[A-Za-z0-9._/-]+$ ]] \
|| { echo "libreportal-ownership: invalid relpath" >&2; return 1; }
mode="$(_mode)"; cowner="$(_container_owner "$mode")"
[[ -e "$d/$rel" ]] && chown "$cowner:$cowner" "$d/$rel"
}
# Create a staging directory under the restore/migrate area, owned by the
# container user.
#
# Restore stages through $SYSTEM_DIR, which the MANAGER owns — but the thing
# that writes into the staging tree is restic, and runBackupOp runs it as the
# container user. So whoever created the directory, the other one could not
# write to it, and both existing call sites created it as the wrong principal:
#
# backupRestoreSystemConfig runFileOp mkdir -> container user, denied on a
# 0751 manager-owned restore_dir, and the failure
# was never checked. restic then wrote nothing,
# exited 0 ("ignoring error ... permission denied"),
# and the CLI reported "System config restored to:
# <path>" for a path that did not exist.
# storageRestoreAppTo runInstallOp mkdir -> manager, so restic could
# not create anything beneath it.
#
# Root has to bridge that, the same way webui-bind already bridges the mirror
# case. The path is confined to the restore/migrate area and the name to a
# single component, so this cannot be pointed anywhere else.
# Shared gate for the staging actions: exactly one component directly below the
# restore or migrate area, no traversal, no arbitrary path.
_restore_stage_ok() {
local path="${1:-}"
[[ -n "$path" && "$path" == /* && "$path" != *..* ]] \
|| { echo "libreportal-ownership: invalid staging path" >&2; return 1; }
path="${path%/}"
local parent="${path%/*}" leaf="${path##*/}"
[[ "$parent" == "$RESTORE_DIR" || "$parent" == "$MIGRATE_DIR" ]] \
|| { echo "libreportal-ownership: staging must sit directly under the restore/migrate area" >&2; return 1; }
[[ "$leaf" =~ ^[A-Za-z0-9._-]+$ && "$leaf" != "." && "$leaf" != ".." ]] \
|| { echo "libreportal-ownership: invalid staging name" >&2; return 1; }
return 0
}
# Remove a staging tree. Root's job for the same reason creating it was: the
# tree belongs to the container user but sits in a directory the manager owns,
# so neither of them can unlink it — the manager cannot delete the container
# user's files inside, and the container user cannot remove the entry from the
# manager's directory. Left to itself the staging dir simply accumulated.
restore_unstage() {
local path="${1:-}"
_restore_stage_ok "$path" || return 1
path="${path%/}"
[[ -e "$path" ]] || return 0
rm -rf -- "$path"
}
restore_stage() {
local path="${1:-}"
_restore_stage_ok "$path" || return 1
path="${path%/}"
local parent="${path%/*}"
local mode cowner; mode="$(_mode)"; cowner="$(_container_owner "$mode")"
mkdir -p -- "$parent" || return 1
chown "$MANAGER:$MANAGER" -- "$parent" 2>/dev/null
chmod 0751 -- "$parent" 2>/dev/null
mkdir -p -- "$path" || return 1
# Owner writes (restic, as the container user), group traverses (the
# manager, which has to confirm the tree landed and is the account a human
# reviews it from). Same shape as webui-bind, mirrored.
chown "$cowner:$MANAGER" -- "$path" || return 1
chmod 0750 -- "$path" || return 1
return 0
}
action="${1:-}"; shift 2>/dev/null || true
case "$action" in
reconcile) reconcile "${1:-}";;
traversal) traversal;;
containers-top) containers_top;;
backups-top) backups_top;;
db-own) db_own;;
app-perms) app_perms;;
webui) webui;;
webui-bind) _webui_bind_access;;
taskdir) taskdir;;
app-data-nobody) app_data_nobody "${1:-}";;
app-data-remove) app_data_remove "${1:-}";;
app-file) app_file "${1:-}" "${2:-}";;
app-move) app_move "${1:-}" "${2:-}";;
app-adopt) app_adopt "${1:-}" "${2:-}" "${3:-}";;
restore-stage) restore_stage "${1:-}";;
restore-unstage) restore_unstage "${1:-}";;
*) echo "usage: libreportal-ownership {reconcile [mode]|traversal|containers-top|backups-top|db-own|app-perms|webui|webui-bind|taskdir|app-data-nobody <app>|app-data-remove <app>|app-file <app> <relpath>|app-move <app> <dest-root>|app-adopt <app> <staged> <dest-root>|restore-stage <path>|restore-unstage <path>}" >&2; exit 2;;
esac