LibrePortal/scripts/storage/storage_scan.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

149 lines
6.0 KiB
Bash

#!/bin/bash
# Candidate discovery — "what else could hold app data on this box?"
#
# Shared by the setup wizard's Storage step, `libreportal storage scan`, and the
# Disks view, so all three show the same answer.
#
# Filtering has to be aggressive rather than optional: a desktop-class install
# carries a dozen-plus snap loop mounts, and without pruning them the real answer
# is invisible. `lsblk -e7` drops loop devices at the source; the rest are pruned
# by mount point and filesystem type below.
# Filesystem types that are never a candidate.
_STORAGE_SKIP_FSTYPES="squashfs|overlay|overlay2|aufs|tmpfs|devtmpfs|ramfs|proc|sysfs|cgroup|cgroup2|configfs|debugfs|tracefs|securityfs|pstore|efivarfs|autofs|binfmt_misc|fusectl|mqueue|hugetlbfs|bpf|nsfs|iso9660|udf"
# Mount points that are never a candidate.
_storageSkipTarget()
{
local t="$1"
case "$t" in
""|/boot|/boot/*|/efi|/proc|/proc/*|/sys|/sys/*|/dev|/dev/*|/run|/run/*|/snap|/snap/*|/var/snap/*|/tmp|/var/lib/docker/*)
return 0 ;;
esac
return 1
}
# Emit one TSV record per candidate filesystem:
# <target> <source> <fstype> <size> <avail> <uuid> <removable> <role>
#
# role is one of: system | primary | storage:<id> | backup:<idx> | free
# — the thing that lets the Disks view show what LibrePortal does with a device
# without every caller re-deriving it.
storageScanCandidates()
{
command -v findmnt >/dev/null 2>&1 || return 0
local target source fstype uuid size avail rm_flag role dev
local primary_dev backup_devs=""
primary_dev=$(stat -c '%d' -- "$(primaryRoot)" 2>/dev/null)
# Registered storage roots, by device
local -A storage_dev=()
local _id _path _rest
if [[ -r "$lp_storage_registry" ]]; then
while IFS=$'\t' read -r _id _path _rest || [[ -n "$_id" ]]; do
[[ -z "$_path" || "$_id" == \#* ]] && continue
dev=$(stat -c '%d' -- "${_path%/}" 2>/dev/null) || continue
[[ -n "$dev" ]] && storage_dev["$dev"]="$_id"
done < "$lp_storage_registry"
fi
# Enabled backup locations, by device
local -A backup_dev=()
if declare -f resticEnabledLocations >/dev/null 2>&1 \
&& declare -f backupLocationResolvedPath >/dev/null 2>&1; then
local idx bpath probe
while IFS= read -r idx; do
[[ -z "$idx" ]] && continue
bpath=$(backupLocationResolvedPath "$idx" 2>/dev/null); bpath="${bpath%/}"
[[ -z "$bpath" ]] && continue
probe="$bpath"
while [[ -n "$probe" && "$probe" != "/" && ! -d "$probe" ]]; do probe="${probe%/*}"; done
dev=$(stat -c '%d' -- "${probe:-/}" 2>/dev/null) || continue
[[ -n "$dev" ]] && backup_dev["$dev"]="$idx"
done < <(resticEnabledLocations 2>/dev/null)
fi
local system_dev; system_dev=$(stat -c '%d' -- / 2>/dev/null)
# findmnt -P emits KEY="value" pairs: the only output form that survives a
# mount point containing spaces. -r/-n is space-separated and silently
# collapses every field into the first variable.
local line
while IFS= read -r line; do
[[ -z "$line" ]] && continue
target=""; source=""; fstype=""; size=""; avail=""; uuid=""
local kv key val
for kv in TARGET SOURCE FSTYPE SIZE AVAIL UUID; do
val="${line#*${kv}=\"}"
[[ "$val" == "$line" ]] && continue
val="${val%%\"*}"
case "$kv" in
TARGET) target="$val" ;;
SOURCE) source="$val" ;;
FSTYPE) fstype="$val" ;;
SIZE) size="$val" ;;
AVAIL) avail="$val" ;;
UUID) uuid="$val" ;;
esac
done
[[ -z "$target" ]] && continue
_storageSkipTarget "$target" && continue
[[ "$fstype" =~ ^($_STORAGE_SKIP_FSTYPES)$ ]] && continue
dev=$(stat -c '%d' -- "$target" 2>/dev/null)
rm_flag=0
if [[ "$source" == /dev/* ]] && command -v lsblk >/dev/null 2>&1; then
local r h
r=$(lsblk -no RM "$source" 2>/dev/null | head -1 | tr -d ' ')
h=$(lsblk -no HOTPLUG "$source" 2>/dev/null | head -1 | tr -d ' ')
[[ "$r" == "1" || "$h" == "1" ]] && rm_flag=1
fi
# Most specific role wins — a device can hold more than one.
if [[ -n "$dev" && -n "${storage_dev[$dev]:-}" ]]; then
role="storage:${storage_dev[$dev]}"
elif [[ -n "$dev" && "$dev" == "$primary_dev" ]]; then
role="primary"
elif [[ -n "$dev" && -n "${backup_dev[$dev]:-}" ]]; then
role="backup:${backup_dev[$dev]}"
elif [[ -n "$dev" && "$dev" == "$system_dev" ]]; then
role="system"
else
role="free"
fi
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$target" "$source" "$fstype" "$size" "$avail" "${uuid:-}" "$rm_flag" "$role"
done < <(findmnt -Pno TARGET,SOURCE,FSTYPE,SIZE,AVAIL,UUID 2>/dev/null)
}
# Human-readable form of the above, with a fitness verdict per candidate.
storageScan()
{
local target source fstype size avail uuid rm_flag role
isHeader "Storage candidates"
printf '%-26s %-10s %-8s %-8s %-12s %s\n' "MOUNT" "FS" "SIZE" "FREE" "ROLE" "VERDICT"
while IFS=$'\t' read -r target source fstype size avail uuid rm_flag role; do
[[ -z "$target" ]] && continue
local verdict="usable" sev check msg
local refusals="" warnings=""
while IFS=$'\t' read -r sev check msg; do
case "$sev" in
refuse) refusals+="${refusals:+; }$check" ;;
warn) warnings+="${warnings:+; }$check" ;;
esac
done < <(storageCheckPath "$target" 2>/dev/null)
if [[ -n "$refusals" ]]; then
verdict="unusable ($refusals)"
elif [[ -n "$warnings" ]]; then
verdict="usable, note: $warnings"
fi
printf '%-26s %-10s %-8s %-8s %-12s %s\n' \
"$target" "$fstype" "$size" "$avail" "$role" "$verdict"
done < <(storageScanCandidates)
}