LibrePortal/scripts/storage/storage_locations.sh
librelad ac4c11b5e9 fix(storage): move the app->location index out of configs/
Follow-up to 928e244, which stopped configs/ subdirectories being sourced
without a .category marker. That closed the hole; this removes the thing
that fell into it.

storageIndexFile pointed at configs/storage/app_locations. The file's
requirements are only "manager-owned" and "not on a removable disk" —
configs/ satisfies both, which is why I put it there, and it was still
wrong: that tree carries a third property the file violates. sourceScanFiles
SOURCES what it finds under configs/, and sourcing means executing.

The index is a TSV of "<slug><TAB><root>", which bash reads as a command
and its argument. Harmless while no slug matched a real executable. The
row for the app named `libreportal` armed it, because that IS the CLI on
PATH: sourcing ran `libreportal /libreportal-containers`, which re-entered
the scan, which sourced the file again — one process pair per level until
the host OOMed and took the desktop session with it.

It now lives at $system_dir/storage/app_locations, with a one-shot
migration so an install that already has an index keeps knowing where its
apps live rather than silently forgetting. libreportal-ownership
reconciles the new directory, and scan_files.sh gained a note that
configs/storage/ carries no .category on purpose.

scripts/dev/lp-configs-guard-test covers both ends: the index never lands
in configs/, a legacy one migrates, and a file of the exact detonating
shape placed in an unmarked configs/ subdirectory is not executed while a
marked category still loads.

Also wires sourceStorageLocations into the config scan beside
sourceBackupLocations — per-location configs sit at depth 3, below the
generic scan, and need their own walker like the backup ones do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:25:07 +01:00

204 lines
7.3 KiB
Bash

#!/bin/bash
# Manager-side storage-location management.
#
# Division of labour, which is the whole security story:
#
# root helper (libreportal-storage) owns the REGISTRY — which paths exist and
# may hold app data. Manager cannot write it.
# here owns the per-location CONFIG — friendly
# name, enabled flag, notes. Manager-owned,
# and none of it can redirect a root chown.
#
# The per-location config mirrors the backup-location layout exactly
# (configs/backup/locations/<idx>/location.config), so the WebUI config renderer,
# the CLI and the sourcing path all work with no new machinery. Note the PATH is
# deliberately NOT a field here: it is registry data, and changing it is
# `storage add`/`remove`, not a config edit.
storageLocationsDir()
{
printf '%s' "${configs_dir%/}/storage/locations"
}
storageLocationDir()
{
printf '%s' "$(storageLocationsDir)/$1"
}
storageLocationConfig()
{
printf '%s' "$(storageLocationDir "$1")/location.config"
}
# Source every per-location config so CFG_STORAGE_LOC_<id>_* are in the
# environment. Called from the libreportal_configs scan path, alongside
# sourceBackupLocations.
sourceStorageLocations()
{
local dir cfg
dir=$(storageLocationsDir)
[[ -d "$dir" ]] || return 0
local find_cmd=(find)
if [[ ! -r "$dir" || ! -x "$dir" ]] && declare -f runFileOp >/dev/null 2>&1; then
find_cmd=(runFileOp find)
fi
while IFS= read -r -d '' cfg; do
[[ -f "$cfg" ]] && source "$cfg"
done < <("${find_cmd[@]}" "$dir" -mindepth 2 -maxdepth 2 -name location.config -type f -print0 2>/dev/null)
}
_storageWriteLocationConfig()
{
local id="$1" name="$2" path="$3"
local dir cfg
dir=$(storageLocationDir "$id")
cfg=$(storageLocationConfig "$id")
runInstallOp mkdir -p "$dir"
{
echo "# Storage location $id — added $(date -Iseconds)."
echo "# The PATH is not editable here: it lives in the root-owned registry"
echo "# (/usr/local/lib/libreportal/storage.roots) so that editing config can"
echo "# never redirect a privileged chown. Use 'libreportal storage add/remove'."
echo "CFG_STORAGE_LOC_${id}_NAME=\"${name}\" # Name - Friendly label used by CFG_<APP>_STORAGE and shown in the UI"
echo "CFG_STORAGE_LOC_${id}_ENABLED=true # Enabled - Offer this location when choosing where an app's data lives [true:Yes|false:No]"
echo "CFG_STORAGE_LOC_${id}_PATH=\"${path}\" # Path - Where this location lives on disk (registry-owned; shown for reference) **READONLY**"
echo "CFG_STORAGE_LOC_${id}_NOTES=\"\" # Notes - Free text, e.g. which physical disk this is"
} | runInstallWrite "$cfg" >/dev/null
runInstallOp chmod 0640 "$cfg"
}
# Add a storage location: fitness first (so the user gets every reason at once),
# then the root helper's admission checks, then the manager-side config.
storageAdd()
{
local path="$1" name="$2" force="${3:-}"
if [[ -z "$path" ]]; then
isError "storageAdd requires a path"
return 1
fi
path="${path%/}"
isHeader "Adding storage location $path"
# --- fitness (§6) --------------------------------------------------------
local sev check msg refused=0
while IFS=$'\t' read -r sev check msg; do
[[ -z "$sev" ]] && continue
case "$sev" in
refuse) isError "$check: $msg"; refused=1 ;;
warn) isNotice "$check: $msg" ;;
info) isNotice "$check: $msg" ;;
esac
done < <(storageCheckPath "$path")
if (( refused )) && [[ "$force" != "--force" ]]; then
isError "Refusing to add '$path' — the failures above mean app data cannot work there."
return 1
fi
# --- admission (root helper, §3) ----------------------------------------
local id
if ! id=$(runStorage add "$path" ${name:+--name="$name"} 2>&1); then
isError "$id"
return 1
fi
id="${id##*$'\n'}"
if [[ ! "$id" =~ ^[0-9]+$ ]]; then
isError "Unexpected response from the storage helper: $id"
return 1
fi
_storageWriteLocationConfig "$id" "${name:-location-$id}" "$path"
source "$(storageLocationConfig "$id")" 2>/dev/null
# Long-lived processes (the task processor) hold a memoised root list.
storageCacheReset
isSuccessful "Storage location $id '${name:-location-$id}' added at $path"
_storageRefreshWebui
echo "$id"
}
# Regenerate the WebUI's storage data when that generator exists.
#
# `declare -f X >/dev/null && X` as a function's LAST statement makes the whole
# function return 1 whenever X is absent — the caller then reads a successful
# operation as a failure. Same trap _appCallHook documents in app_install.sh.
_storageRefreshWebui()
{
if declare -f webuiGenerateStorageLocations >/dev/null 2>&1; then
webuiGenerateStorageLocations || true
fi
return 0
}
# Remove a location. The helper refuses while app directories remain, and also
# when the drive is absent (we would be dropping the only record of where those
# apps live).
storageRemove()
{
local want="$1"
[[ -n "$want" ]] || { isError "storageRemove requires an id or path"; return 1; }
local id
if ! id=$(runStorage remove "$want" 2>&1); then
isError "$id"
return 1
fi
id="${id##*$'\n'}"
local dir; dir=$(storageLocationDir "$id")
[[ -d "$dir" ]] && runInstallOp rm -rf "$dir"
storageCacheReset
isSuccessful "Storage location $id removed"
_storageRefreshWebui
return 0
}
# Human-readable listing: id, name, path, state, apps, free space.
storageList()
{
local id path state name_var name apps free
isHeader "Storage locations"
printf '%-4s %-16s %-34s %-12s %-5s %s\n' "ID" "NAME" "PATH" "STATE" "APPS" "FREE"
printf '%-4s %-16s %-34s %-12s %-5s %s\n' "0" "default" "$(primaryRoot)" "ok" \
"$(storageAppsOnRoot "$(primaryRoot)" | wc -l | tr -d ' ')" \
"$(_storageFreeHuman "$(primaryRoot)")"
while IFS=$'\t' read -r id state path; do
[[ -z "$id" ]] && continue
name_var="CFG_STORAGE_LOC_${id}_NAME"
name="${!name_var:-location-$id}"
if [[ "$state" == "ok" ]]; then
apps=$(storageAppsOnRoot "$path" | wc -l | tr -d ' ')
free=$(_storageFreeHuman "$path")
else
apps="?"; free="-"
fi
printf '%-4s %-16s %-34s %-12s %-5s %s\n' "$id" "$name" "$path" "$state" "$apps" "$free"
done < <(runStorage verify 2>/dev/null)
}
_storageFreeHuman()
{
local p="$1" kb
kb=$(df -Pk "$p" 2>/dev/null | awk 'NR==2 {print $4}')
[[ -z "$kb" ]] && { printf '-'; return; }
if (( kb >= 1048576 )); then printf '%s GiB' "$((kb / 1048576))"
else printf '%s MiB' "$((kb / 1024))"; fi
}
# Apps living on one specific root.
storageAppsOnRoot()
{
local root="${1%/}" d
storageRootAvailable "$root" || return 0
local scan_op=""
declare -F runFileOp >/dev/null 2>&1 && scan_op="runFileOp"
while IFS= read -r d; do
[[ -n "$d" ]] && printf '%s\n' "${d##*/}"
done < <($scan_op find "$root" -mindepth 1 -maxdepth 1 -type d 2>/dev/null)
}