LibrePortal/scripts/cli/commands/artifact/cli_artifact_apply.sh
librelad 8b5e02c760 refactor(storage): resolve every app directory through appDir
The main sweep — ~260 call sites across ~100 files move from string
concatenation on a single root to appDir/storageAppDirs/storageAppConfigs.
On a single-root install the resolved paths are identical, so this is a
no-op until a location is registered.

Enumerators were the interesting half. `for d in "$containers_dir"/*/`
appears in the menus, the registry/artifact scanners and the DNS setup —
and a shell glob cannot list a rootless 751 tree at all, which is the
same bug config_find_file.sh already documents in a comment. Routing them
through storageAppDirs (which enumerates as the owning user) fixes that
alongside the multi-root work.

Three places needed judgement rather than substitution:

db_app_scan.sh deletes database rows and port allocations for apps whose
folder is missing, and reaps "empty" app dirs. With a storage location
unmounted, every app on it looks exactly like that. Each of those
branches now gates on appStorageAvailable first — an app on an unplugged
drive is skipped with a notice, never deleted.

instance_create.sh rewrites cloned hooks so an instance touches its own
directory instead of the base app's. Its sed matched ${containers_dir}<type>,
which this sweep just replaced with $(appDir <type>) — so it would have
silently stopped redirecting, and an instance would have written to the
original's files (the adguard auth adapter case its own comment warns
about). Now matches both appDir forms, verified against bare, quoted,
unrelated-app, legacy and prose cases.

peer_shell/peer_pull streamed and extracted relative to the primary root.
Both now use the app's own root, and peer_shell keeps a single-root
fallback since it runs as a restricted SSH shell with no LibrePortal env.

Also fixes a pre-existing bug found on the way: webui_app_config.sh
tested "$containers_dir/frontend/data/last_update", one level short of the
real tree under the libreportal app dir, so the WebUI refresh trigger
after a config update has never once fired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 04:09:51 +01:00

881 lines
47 KiB
Bash

#!/bin/bash
#
# Artifact APPLY pipeline -- Phase 2 of the unified distribution primitive
# (docs/roadmap/updates-and-distribution.md section 8.3). The MUTATING side: it
# takes a verified artifact from the signed index and applies it reversibly, and
# can revert it. Runs ONLY under the task system (cli_artifact_commands.sh
# enqueues; the processor re-invokes with LIBREPORTAL_TASK_EXEC=1).
#
# Design contracts (all enforced below, fail-closed):
# * Trust core == the release anchor: the index is minisign-verified against the
# root-owned footprint key by lpFetchIndex; APPLY additionally REFUSES unless
# that verification actually happened (LP_INDEX_SIGSTATE==verified) and the
# payload itself is sha256-pinned by the signed index AND minisig-verified.
# The publishers map + role gate stops a community key claiming official.
# * The op vocabulary is a CLOSED allowlist (no run-script/exec/shell, ever).
# An unsupported op rejects the WHOLE artifact at validation, before any write.
# * No op VALUE/PATH may carry a shell/quote/sed metacharacter (defense in depth
# even against a compromised-but-signed payload): _artifactSafeScalar + the
# _artifactPathAllowed charset gate enforce the "no code-exec" contract.
# * ALL-OR-NOTHING: every op is dry-prechecked first; one failed precondition
# skips the whole artifact untouched (recorded, so coverage gaps are visible).
# * Two-tier reversibility: every op records a precise inverse (the revert path);
# a best-effort snapshot is also taken. Rollback/revert NEVER report success
# when an inverse op failed -- they record an explicit "*-incomplete" state.
# * Mutations write only through the de-sudo funnels, path-aware (container tree
# -> runFileOp/runFileWrite; manager-owned configs/ -> runInstallOp/Write),
# never raw sudo. The install tree (our own code) is off-limits to hotfixes.
# --- paths -------------------------------------------------------------------
_artifactGenDir() { echo "$(webuiDir)/frontend/data/updater/generated"; }
_artifactAppliedDir() { echo "$(_artifactGenDir)/applied"; }
_artifactRecordFile() { echo "$(_artifactAppliedDir)/$1.json"; } # $1=id
# Require jq for the apply path. The TRUST core (index sig, payload sha256+sig) is
# jq-free; only walking the bounded op list / envelope fields needs structured
# parsing, and apply is a heavy, rare, privileged path where requiring jq is fine.
_artifactNeedJq() {
command -v jq >/dev/null 2>&1 && return 0
isError "artifact: jq is required to apply/revert artifacts (the op interpreter needs it) -- refusing."
return 1
}
# Reject a scalar that could inject when it flows into a shell/sed/sourced-config
# context. Op values come from a signed payload, but the "no code-exec" contract
# must hold even for a compromised-but-signed payload (defense in depth).
# Bans: double-quote, single-quote, backslash, $, backtick, and any whitespace
# control char (newline/CR/tab).
_artifactSafeScalar() {
case "$1" in
*'"'*|*"'"*|*'\'*|*'$'*|*'`'*) return 1 ;;
esac
[[ "$1" == *$'\n'* || "$1" == *$'\r'* || "$1" == *$'\t'* ]] && return 1
return 0
}
# Path-aware writer/remover: container tree -> runFileWrite/runFileOp (install user
# in rootless); manager-owned (configs/) -> runInstallWrite/runInstallOp. A
# system-scope hotfix patches configs/, which the container funnel can't write.
_artifactWriteFile() { # stdin -> $1
local path="$1"
if [[ -n "${containers_dir:-}" && "$path" == "${containers_dir%/}/"* ]]; then runFileWrite "$path"
else runInstallWrite "$path"; fi
}
_artifactRmFile() {
local path="$1"
if [[ -n "${containers_dir:-}" && "$path" == "${containers_dir%/}/"* ]]; then runFileOp rm -f "$path"
else runInstallOp rm -f "$path"; fi
}
# ----------------------------------------------------------------------------
# RESOLVE -- fetch+verify the index, pull out one artifact, check the gates.
# Sets globals: _ART_JSON (the artifact), _ART_INDEX (verified index json),
# _ART_TYPE, _ART_SCOPE, _ART_APP; LP_INDEX_SIGSTATE via lpFetchIndexInto.
# MUST be called directly, never as var="$(_artifactResolve …)" -- a command
# substitution strands every one of those globals (incl. the sigstate the
# apply gate enforces) in the subshell. Non-zero = refuse; 2 = not-applicable.
# ----------------------------------------------------------------------------
_artifactResolve() {
local id="$1"
lpFetchIndexInto _ART_INDEX || { isError "artifact: could not fetch/verify the index."; return 1; }
local art; art="$(printf '%s' "$_ART_INDEX" | jq -ce --arg id "$id" '.artifacts[]? | select(.id==$id)' 2>/dev/null)"
[[ -n "$art" ]] || { isError "artifact: id '$id' not found in the signed index."; return 1; }
local type; type="$(jq -r '.type // empty' <<<"$art")"
case "$type" in
hotfix|app) _ART_TYPE="$type" ;;
*) isError "artifact: type '$type' is not supported by this build (only 'hotfix' and 'app')."; return 1 ;;
esac
# --- trust: publishers map + role gate ---
local pub trust role
pub="$(jq -r '.publisher // empty' <<<"$art")"
trust="$(jq -r '.trust // "official"' <<<"$art")"
role="$(printf '%s' "$_ART_INDEX" | jq -r --arg p "$pub" '.publishers[$p].role // empty' 2>/dev/null)"
if [[ -z "$pub" || -z "$role" ]]; then
isError "artifact: publisher '$pub' is not in the index publishers map -- refusing."; return 1
fi
if [[ "$trust" == "official" && "$role" != "official" ]]; then
isError "artifact: '$id' claims trust=official but publisher '$pub' has role '$role' -- refusing."; return 1
fi
if [[ "$role" != "official" ]]; then
# Community/custom would require the artifact's own canonical-bytes signature
# against the publisher's key. Not enabled in this first-party-only build.
isError "artifact: publisher role '$role' (non-official) is not enabled yet -- refusing."; return 1
fi
# --- gates (applies_when) ---
_ART_APP="$(jq -r '.applies_when.app // empty' <<<"$art")"
_ART_SCOPE="system"; [[ -n "$_ART_APP" ]] && _ART_SCOPE="app"
if [[ "$_ART_TYPE" == "app" ]]; then
# For type:"app" the target IS the thing being added -- presence on the
# box is the bundle collision policy's call, not an applicability gate.
# The slug must be a safe shell-identifier folder name (CFG_<SLUG^^>_*).
if [[ ! "$_ART_APP" =~ ^[a-z0-9][a-z0-9_]{0,31}$ ]]; then
isError "artifact: app artifact '$id' has a missing/unsafe applies_when.app slug -- refusing."; return 1
fi
elif [[ "$_ART_SCOPE" == "app" && ! -d "$(appDir "$_ART_APP")" ]]; then
isNotice "artifact: '$id' targets '$_ART_APP' which is not installed -- not applicable."; return 2
fi
local minlp maxlp maxfp curlp curfp
minlp="$(jq -r '.applies_when.min_lp // empty' <<<"$art")"
maxlp="$(jq -r '.applies_when.max_lp // empty' <<<"$art")"
maxfp="$(jq -r '.applies_when.max_footprint // empty' <<<"$art")"
curlp="${CFG_LIBREPORTAL_VERSION:-$(cat "$script_dir/version" 2>/dev/null | tr -dc '0-9.')}"
if [[ -n "$minlp" && -n "$curlp" ]] && lpVersionGt "$minlp" "$curlp"; then
isNotice "artifact: '$id' needs LibrePortal >= $minlp (have ${curlp:-?}) -- not applicable."; return 2
fi
if [[ -n "$maxlp" && -n "$curlp" ]] && lpVersionGt "$curlp" "$maxlp"; then
isNotice "artifact: '$id' applies only up to LibrePortal $maxlp (have $curlp) -- not applicable."; return 2
fi
if [[ -n "$maxfp" ]]; then
curfp="$(lpInstalledFootprintVersion)"
if (( curfp > maxfp )); then
isNotice "artifact: '$id' applies only up to footprint $maxfp (have $curfp) -- not applicable."; return 2
fi
fi
_ART_JSON="$art"
}
# ----------------------------------------------------------------------------
# PAYLOAD download core -- fetch $1 to $4, sha256-pin against the SIGNED index's
# value ($2), minisig-verify against $3 (sig lands at $4.minisig). REFUSES an
# unsigned payload (signing-not-activated) -- this is the apply path.
# ----------------------------------------------------------------------------
_artifactDownloadVerified() {
local url="$1" want_sha="$2" sig_url="$3" dest="$4"
local base got pstate sf="$dest.minisig"
base="$(lpReleaseBaseUrl)"
[[ -n "$url" && -n "$want_sha" ]] || { isError "artifact: payload missing url/sha256 -- refusing."; return 1; }
case "$url" in http*://*) : ;; *) url="$base/$url" ;; esac
if ! _lpDownload "$url" "$dest" 2>/dev/null; then isError "artifact: payload download failed."; return 1; fi
got="$(_lpSha256 "$dest")"
if [[ "$got" != "$want_sha" ]]; then isError "artifact: payload CHECKSUM MISMATCH -- refusing."; return 1; fi
if [[ -n "$sig_url" ]]; then
case "$sig_url" in http*://*) : ;; *) sig_url="$base/$sig_url" ;; esac
_lpDownload "$sig_url" "$sf" 2>/dev/null || true
fi
pstate="$(lpVerifyMinisig "$dest" "$sf")" || return 1
if [[ "$pstate" != "verified" ]]; then
isError "artifact: refusing to APPLY an unsigned payload (signing not activated / footprint key missing)."; return 1
fi
return 0
}
# Fetch + verify an ops payload (payload.kind:"ops" -- the hotfix body).
# Echoes the verified payload JSON; non-zero on any failure.
_artifactFetchPayload() {
local art="$1" url want_sha sig_url tmp pf kind
kind="$(jq -r '.payload.kind // empty' <<<"$art")"
if [[ "$kind" != "ops" ]]; then
isError "artifact: payload.kind '$kind' has no ops interpreter -- refusing."; return 1
fi
url="$(jq -r '.payload.url // empty' <<<"$art")"
want_sha="$(jq -r '.payload.sha256 // empty' <<<"$art")"
sig_url="$(jq -r '.payload.sig // empty' <<<"$art")"
tmp="$(mktemp -d)"; pf="$tmp/payload.json"
_artifactDownloadVerified "$url" "$want_sha" "$sig_url" "$pf" || { rm -rf "$tmp"; return 1; }
cat "$pf"; rm -rf "$tmp"
}
# Fetch + verify a bundle payload (payload.kind:"bundle" -- an app definition
# tarball) into $2 (caller-owned workdir). Echoes the tarball path.
_artifactFetchBundle() {
local art="$1" workdir="$2" url want_sha sig_url kind tb
kind="$(jq -r '.payload.kind // empty' <<<"$art")"
if [[ "$kind" != "bundle" ]]; then
isError "artifact: payload.kind '$kind' is not a bundle -- refusing."; return 1
fi
url="$(jq -r '.payload.url // empty' <<<"$art")"
want_sha="$(jq -r '.payload.sha256 // empty' <<<"$art")"
sig_url="$(jq -r '.payload.sig // empty' <<<"$art")"
tb="$workdir/bundle.tar.gz"
_artifactDownloadVerified "$url" "$want_sha" "$sig_url" "$tb" || return 1
printf '%s' "$tb"
}
# --- path allowlist (the write-target firewall) -----------------------------
# scope:app -> only under $containers_dir/<app>/
# scope:system -> only under $configs_dir/ (the install/code tree is OFF-LIMITS
# to hotfixes -- code rides signed releases; fork 1)
# Also enforces a safe-filename charset so a path can never carry a shell/quote
# metacharacter into a funnel (belt-and-braces with the runFileWrite argv fix).
_artifactPathAllowed() {
local path="$1" scope="$2" app="$3" real root
[[ "$path" == *".."* ]] && return 1
[[ "$path" =~ ^[A-Za-z0-9._/@:+-]+$ ]] || return 1
real="$(realpath -m -- "$path" 2>/dev/null)"; [[ -n "$real" ]] || return 1
if [[ "$scope" == "app" ]]; then
root="$(realpath -m -- "$(appDir "$app")" 2>/dev/null)"
[[ "$real" == "$root/"* ]] && return 0
return 1
fi
root="$(realpath -m -- "${configs_dir%/}" 2>/dev/null)"
[[ -n "$root" && "$real" == "$root/"* ]] && return 0
return 1
}
# current image of an app's compose (first image: line), quotes/comment stripped.
_artifactComposeImage() {
local app="$1" f="$(appDir "$1")/docker-compose.yml"
[[ -f "$f" ]] || return 1
grep -m1 -E '^\s*image:' "$f" 2>/dev/null | sed -E 's/^\s*image:\s*//; s/["'"'"']//g; s/\s+#.*$//; s/\s+$//'
}
# ----------------------------------------------------------------------------
# BUNDLE (payload.kind:"bundle", type:"app") -- add an app DEFINITION to the
# App Center catalog: verify + quarantine-validate the tarball, place it in the
# definition tree (install_containers_dir), regen so it surfaces as a normal
# installable app.
#
# ORDERING CONTRACT (why this section is paranoid): the container scan
# live-sources every install_containers_dir/<app>/*.sh on EVERY CLI start, so
# placing a definition IS deferred code execution. Nothing may land there until
# (a) the trust gates upstream have passed (official publisher role at resolve,
# index sha256 pin + payload minisig in _artifactDownloadVerified) AND (b)
# every quarantine check below has passed. Community publishers are refused at
# resolve time; their host-script quarantine tier is a deferred phase.
# ----------------------------------------------------------------------------
# Validate tarball $1 for app slug $2 by extracting into quarantine dir $3.
# Fail-closed on ANY irregularity; writes nothing outside $3.
_artifactBundleValidate() {
local tarball="$1" slug="$2" qdir="$3"
local max_tar=$((5*1024*1024)) max_extracted=$((20*1024*1024)) max_entries=400
[[ "$slug" =~ ^[a-z0-9][a-z0-9_]{0,31}$ ]] || { isError "bundle: unsafe app slug '$slug'"; return 1; }
case " template libreportal tools scripts resources " in
*" $slug "*) isError "bundle: slug '$slug' is reserved"; return 1 ;;
esac
local size; size="$(stat -c %s "$tarball" 2>/dev/null || echo 0)"
(( size > 0 && size <= max_tar )) || { isError "bundle: tarball size ${size}B outside limits (max ${max_tar}B)"; return 1; }
# Listing pass -- names, entry types, declared sizes. Nothing extracted yet,
# so a crafted archive can't touch the disk before it is judged.
local names; names="$(tar -tzf "$tarball" 2>/dev/null)" || { isError "bundle: unreadable tarball"; return 1; }
[[ -n "$names" ]] || { isError "bundle: empty tarball"; return 1; }
local count; count="$(wc -l <<<"$names")"
(( count <= max_entries )) || { isError "bundle: $count entries (max $max_entries)"; return 1; }
local n
while IFS= read -r n; do
[[ "$n" =~ ^[A-Za-z0-9._/@:+-]+$ ]] || { isError "bundle: entry '$n' has unsafe characters"; return 1; }
[[ "$n" == /* ]] && { isError "bundle: absolute path '$n'"; return 1; }
[[ "$n" == ".."* || "$n" == *"/../"* || "$n" == *"/.." ]] && { isError "bundle: path traversal in '$n'"; return 1; }
[[ "$n" == "$slug" || "$n" == "$slug/"* ]] || { isError "bundle: entry '$n' outside the '$slug/' top-level dir"; return 1; }
done <<<"$names"
local badtype; badtype="$(tar -tvzf "$tarball" 2>/dev/null | awk '$1 !~ /^[d-]/ {print; exit}')"
[[ -z "$badtype" ]] || { isError "bundle: non-regular entry (link/device/fifo): $badtype"; return 1; }
local total; total="$(tar -tvzf "$tarball" 2>/dev/null | awk '{s+=$3} END{print s+0}')"
(( total <= max_extracted )) || { isError "bundle: declared extracted size ${total}B exceeds max ${max_extracted}B"; return 1; }
tar -xzf "$tarball" --no-same-owner --no-same-permissions -C "$qdir" 2>/dev/null || { isError "bundle: extraction failed"; return 1; }
# Re-check on disk (belt-and-braces with the listing pass), drop set-id bits.
local special; special="$(find "$qdir" \( -type l -o -type p -o -type s -o -type b -o -type c \) -print -quit 2>/dev/null)"
[[ -z "$special" ]] || { isError "bundle: non-regular file after extraction: $special"; return 1; }
chmod -R u-s,g-s "$qdir" 2>/dev/null || true
# The drop-in contract (docs/contributing/development.md): <slug>.config with
# TITLE + CATEGORY (parsed line-wise, NEVER sourced) and a compose template.
local cfg="$qdir/$slug/$slug.config" upper="${slug^^}"
[[ -f "$cfg" ]] || { isError "bundle: $slug.config missing"; return 1; }
grep -q "^CFG_${upper}_TITLE=" "$cfg" 2>/dev/null || { isError "bundle: CFG_${upper}_TITLE missing from $slug.config"; return 1; }
grep -q "^CFG_${upper}_CATEGORY=" "$cfg" 2>/dev/null || { isError "bundle: CFG_${upper}_CATEGORY missing from $slug.config"; return 1; }
[[ -f "$qdir/$slug/docker-compose.yml" ]] || { isError "bundle: docker-compose.yml missing"; return 1; }
# A syntax-broken .sh would break every future CLI start via the live-source.
local sh
while IFS= read -r sh; do
bash -n "$sh" 2>/dev/null || { isError "bundle: $sh fails bash -n -- refusing"; return 1; }
done < <(find "$qdir/$slug" -type f -name '*.sh' 2>/dev/null)
return 0
}
# The applied-record (if any) that owns app $1's definition -- echoes the record
# path. A definition WITHOUT one is local (hand-made / shipped with the install)
# and always wins over the catalog.
_artifactBundleOwner() {
local slug="$1" dir f
dir="$(_artifactAppliedDir)"
compgen -G "$dir/*.json" >/dev/null 2>&1 || return 1
for f in "$dir"/*.json; do
if [[ "$(jq -r 'select(.type=="app") | .app // empty' "$f" 2>/dev/null)" == "$slug" ]]; then
printf '%s' "$f"; return 0
fi
done
return 1
}
# The APPLY flow for a bundle. RESOLVE/trust/sigstate ran in artifactApply;
# everything after differs from ops (no live app to snapshot or compose-up --
# the undo log alone is the reversibility anchor, and the target is the
# definition tree, written only through the manager funnel).
_artifactApplyBundleFlow() {
local id="$1" art="$2" serial="$3"
local slug="$_ART_APP"
local live_dir="$(appDir "$slug")"
local def_root="${install_containers_dir%/}"
local def_dir="$def_root/$slug"
# --- collision policy: local state always beats the catalog ---
if [[ -d "$live_dir" ]]; then
isError "app '$slug' is installed -- updating a live app is the updater's job. Uninstall first to re-add its definition."
updaterRecordHistory "$slug" "app" "" "$id" "refused-installed" "$id" "$serial" ""
return 1
fi
local owner_rec="" prior_b64=""
if [[ -d "$def_dir" ]]; then
if ! owner_rec="$(_artifactBundleOwner "$slug")"; then
isError "a local definition for '$slug' already exists -- local definitions win; not overwriting."
updaterRecordHistory "$slug" "app" "" "$id" "refused-local-definition" "$id" "$serial" ""
return 1
fi
# Registry-owned -> definition update: pack the prior tree so the undo
# restores it precisely. Definitions are KB-scale; cap the record at ~1MB.
prior_b64="$(tar -czf - -C "$def_root" "$slug" 2>/dev/null | base64 -w0)"
if [[ -z "$prior_b64" ]] || (( ${#prior_b64} > 1400000 )); then
isError "existing '$slug' definition could not be snapshotted for undo (missing or too large) -- refusing."
updaterRecordHistory "$slug" "app" "" "$id" "refused-prior-snapshot" "$id" "$serial" ""
return 1
fi
fi
# --- fetch + quarantine-validate (nothing placed yet) ---
local work; work="$(mktemp -d)"
local tb
if ! tb="$(_artifactFetchBundle "$art" "$work")"; then
rm -rf "$work"
updaterRecordHistory "$slug" "app" "" "$id" "verify-failed" "$id" "$serial" ""
return 1
fi
local qdir="$work/quarantine"; mkdir -p "$qdir"
if ! _artifactBundleValidate "$tb" "$slug" "$qdir"; then
rm -rf "$work"
isNotice "Bundle validation failed -- nothing was written."
updaterRecordHistory "$slug" "app" "" "$id" "rejected-bundle" "$id" "$serial" ""
return 1
fi
# --- undo log first (the reversibility anchor) ---
local undo_file; undo_file="$(mktemp)"
if [[ -n "$prior_b64" ]]; then
jq -cn --arg app "$slug" --arg b "$prior_b64" '{op:"restore-app-definition", app:$app, tar_b64:$b}' >> "$undo_file"
else
jq -cn --arg app "$slug" '{op:"remove-app-definition", app:$app}' >> "$undo_file"
fi
# --- place: stage INSIDE the definition tree, then one rename (atomic vs
# the regen poll -- a half-copied definition is never scanned) ---
local staging="$def_root/.staging.$slug.$$" placed=1
runInstallOp rm -rf "$staging" 2>/dev/null || true
runInstallOp cp -a "$qdir/$slug" "$staging" || placed=0
if [[ "$placed" == "1" && -d "$def_dir" ]]; then
runInstallOp rm -rf "$def_dir" || placed=0
fi
if [[ "$placed" == "1" ]]; then
runInstallOp mv "$staging" "$def_dir" || placed=0
fi
rm -rf "$work"
# --- regen + verify the app actually surfaced in the App Center data ---
if [[ "$placed" == "1" ]]; then
isNotice "Definition placed -- refreshing the App Center catalog..."
{ declare -F lpRegenWebui >/dev/null 2>&1 && lpRegenWebui force >/dev/null 2>&1; } || true
local apps_json="$(webuiDir)/frontend/data/apps/generated/apps.json"
jq -e --arg app "$slug" '.apps[]? | select((.command // "") | endswith(" " + $app))' "$apps_json" >/dev/null 2>&1 || placed=0
fi
if [[ "$placed" != "1" ]]; then
isNotice "Bundle placement/verify failed -- rolling back..."
local rb_rc=0
_artifactReplayUndoFile "$undo_file" "$slug" "reverse" || rb_rc=1
runInstallOp rm -rf "$staging" 2>/dev/null || true
{ declare -F lpRegenWebui >/dev/null 2>&1 && lpRegenWebui force >/dev/null 2>&1; } || true
rm -f "$undo_file"
if [[ "$rb_rc" -eq 0 ]]; then
updaterRecordHistory "$slug" "app" "" "$id" "rolled-back" "$id" "$serial" ""
isError "App bundle $id failed and was rolled back cleanly."
else
updaterRecordHistory "$slug" "app" "" "$id" "rollback-incomplete" "$id" "$serial" ""
isError "App bundle $id failed AND rollback was incomplete -- MANUAL cleanup of $def_dir may be needed (see History)."
fi
return 1
fi
# --- record (same contract as ops: no persisted undo trail, no change) ---
if ! _artifactWriteRecord "$id" "$art" "$serial" "$undo_file"; then
isError "Could not persist the applied-record -- rolling back to stay reversible."
_artifactReplayUndoFile "$undo_file" "$slug" "reverse" || true
{ declare -F lpRegenWebui >/dev/null 2>&1 && lpRegenWebui force >/dev/null 2>&1; } || true
rm -f "$undo_file"
updaterRecordHistory "$slug" "app" "" "$id" "record-failed-rolled-back" "$id" "$serial" ""
return 1
fi
# A definition update retires the prior owning record -- one owner per app.
if [[ -n "$owner_rec" && "$owner_rec" != "$(_artifactRecordFile "$id")" ]]; then
runFileOp rm -f "$owner_rec" 2>/dev/null || true
fi
rm -f "$undo_file"
_artifactRegenAppliedManifest
updaterRecordHistory "$slug" "app" "" "$id" "applied" "$id" "$serial" "$id"
isSuccessful "App '$slug' added to the App Center catalog. Install it with: libreportal app install $slug (undo: libreportal artifact revert $id)"
return 0
}
# ----------------------------------------------------------------------------
# OP PRECHECK -- pure read; returns 0 if the op can be applied as-is, else 1 with
# a reason on stderr. This is the dry-run that enforces all-or-nothing.
# ----------------------------------------------------------------------------
_artifactOpPrecheck() {
local op_json="$1" scope="$2" app="$3" op
op="$(jq -r '.op // empty' <<<"$op_json")"
case "$op" in
set-config-key)
local key value expect cur f
key="$(jq -r '.key // empty' <<<"$op_json")"
value="$(jq -r '.value // empty' <<<"$op_json")"
[[ "$key" =~ ^CFG_[A-Z0-9_]+$ ]] || { isError "op set-config-key: bad key '$key'"; return 1; }
_artifactSafeScalar "$value" || { isError "op set-config-key: value has an unsafe character (quote/\$/backtick/backslash/newline)"; return 1; }
f="$(findConfigFileForOption "$key" 2>/dev/null)"
{ [[ -n "$f" ]] && grep -q "^$key=" "$f" 2>/dev/null; } || { isError "op set-config-key: key '$key' is not present -- skipping (edit-only, no create)"; return 1; }
expect="$(jq -r 'if has("expect_current") then .expect_current else " NONE" end' <<<"$op_json")"
if [[ "$expect" != $' NONE' ]]; then
cur="$(grep -m1 "^$key=" "$f" 2>/dev/null | sed -E "s/^$key=//; s/^\"//; s/\".*$//; s/\s+#.*$//")"
[[ "$cur" == "$expect" ]] || { isError "op set-config-key: $key is '$cur', expected '$expect' (drift)"; return 1; }
fi
;;
set-compose-image)
local from image cur
from="$(jq -r '.from // empty' <<<"$op_json")"
image="$(jq -r '.image // empty' <<<"$op_json")"
{ _artifactSafeScalar "$from" && _artifactSafeScalar "$image"; } || { isError "op set-compose-image: image/from has an unsafe character"; return 1; }
[[ "$image" =~ ^[A-Za-z0-9._:/@-]+$ ]] || { isError "op set-compose-image: '$image' is not a valid image reference"; return 1; }
[[ -d "$(appDir "$app")" ]] || { isError "op set-compose-image: app '$app' not installed"; return 1; }
cur="$(_artifactComposeImage "$app")"
[[ -n "$from" && "$cur" == "$from" ]] || { isError "op set-compose-image: image is '$cur', expected '$from' (drift)"; return 1; }
;;
patch-file-if-checksum-matches)
local path want got cb
path="$(jq -r '.path // empty' <<<"$op_json")"
_artifactPathAllowed "$path" "$scope" "$app" || { isError "op patch-file: path '$path' not in the allowlist"; return 1; }
[[ -f "$path" ]] || { isError "op patch-file: '$path' does not exist"; return 1; }
want="$(jq -r '.expect_sha256 // empty' <<<"$op_json")"
got="$(_lpSha256 "$path")"
[[ -n "$want" && "$got" == "$want" ]] || { isError "op patch-file: '$path' sha mismatch (drift) -- skipping"; return 1; }
cb="$(jq -r '.content_b64 // empty' <<<"$op_json")"
{ [[ -n "$cb" ]] && printf '%s' "$cb" | base64 -d >/dev/null 2>&1; } || { isError "op patch-file: content_b64 missing or not valid base64"; return 1; }
;;
set-data-file)
local path cb
path="$(jq -r '.path // empty' <<<"$op_json")"
_artifactPathAllowed "$path" "$scope" "$app" || { isError "op set-data-file: path '$path' not in the allowlist"; return 1; }
cb="$(jq -r '.content_b64 // empty' <<<"$op_json")"
{ [[ -n "$cb" ]] && printf '%s' "$cb" | base64 -d >/dev/null 2>&1; } || { isError "op set-data-file: content_b64 missing or not valid base64"; return 1; }
;;
*)
isError "op '$op' is not supported by this build -- rejecting the whole artifact (fail-closed)."
return 1
;;
esac
return 0
}
# ----------------------------------------------------------------------------
# OP APPLY -- mutate, and APPEND the precise inverse op (compact JSON) to $4 (the
# undo log). Pre-images are captured BEFORE the mutation. Returns non-zero on
# failure.
# ----------------------------------------------------------------------------
_artifactOpApply() {
local op_json="$1" scope="$2" app="$3" undo_file="$4" op
op="$(jq -r '.op // empty' <<<"$op_json")"
case "$op" in
set-config-key)
# Lossless undo: snapshot the WHOLE config file (exact bytes) and emit
# a restore-file inverse. Avoids the lossy "re-parse the prior scalar"
# trap (quotes / trailing #). LIFO replay handles multiple ops/file.
local key value f prior_b64
key="$(jq -r '.key' <<<"$op_json")"; value="$(jq -r '.value' <<<"$op_json")"
f="$(findConfigFileForOption "$key" 2>/dev/null)"
{ [[ -n "$f" && -f "$f" ]]; } || { isError "op set-config-key: config file for $key not found"; return 1; }
prior_b64="$(base64 -w0 < "$f" 2>/dev/null)"
updateConfigOption "$key" "$value" || return 1
jq -cn --arg p "$f" --arg b "$prior_b64" '{op:"restore-file", path:$p, existed:true, content_b64:$b}' >> "$undo_file"
;;
set-compose-image)
local image cur f esc_img
image="$(jq -r '.image' <<<"$op_json")"
f="$(appDir "$app")/docker-compose.yml"
cur="$(_artifactComposeImage "$app")"
esc_img="$(printf '%s' "$image" | sed -e 's/[\/&]/\\&/g')"
runFileOp sed -i "0,/^\([[:space:]]*\)image:.*/s//\1image: $esc_img/" "$f" || return 1
jq -cn --arg img "$cur" '{op:"set-compose-image", image:$img}' >> "$undo_file"
;;
patch-file-if-checksum-matches|set-data-file)
local path existed prior_b64 content_b64
path="$(jq -r '.path' <<<"$op_json")"
content_b64="$(jq -r '.content_b64 // empty' <<<"$op_json")"
[[ -n "$content_b64" ]] || { isError "op $op: missing content_b64"; return 1; }
if [[ -f "$path" ]]; then existed=true; prior_b64="$(base64 -w0 < "$path" 2>/dev/null)"; else existed=false; prior_b64=""; fi
if ! printf '%s' "$content_b64" | base64 -d 2>/dev/null | _artifactWriteFile "$path"; then
isError "op $op: write to '$path' failed"; return 1
fi
jq -cn --arg p "$path" --arg e "$existed" --arg b "$prior_b64" \
'{op:"restore-file", path:$p, existed:($e=="true"), content_b64:$b}' >> "$undo_file"
;;
*)
isError "op '$op': no apply handler (should have been rejected at precheck)"; return 1
;;
esac
return 0
}
# ----------------------------------------------------------------------------
# OP UNDO -- apply one inverse op recorded by _artifactOpApply (the revert path).
# Inverse ops are idempotent (re-applying the same restore is safe), so a retried
# revert cannot corrupt state.
# ----------------------------------------------------------------------------
_artifactOpUndo() {
local op_json="$1" app="$2" op
op="$(jq -r '.op // empty' <<<"$op_json")"
case "$op" in
set-compose-image)
local image f esc_img; image="$(jq -r '.image' <<<"$op_json")"
f="$(appDir "$app")/docker-compose.yml"
esc_img="$(printf '%s' "$image" | sed -e 's/[\/&]/\\&/g')"
runFileOp sed -i "0,/^\([[:space:]]*\)image:.*/s//\1image: $esc_img/" "$f" || return 1
;;
restore-file)
local path existed content_b64
path="$(jq -r '.path' <<<"$op_json")"; existed="$(jq -r '.existed' <<<"$op_json")"
content_b64="$(jq -r '.content_b64 // empty' <<<"$op_json")"
if [[ "$existed" == "true" ]]; then
printf '%s' "$content_b64" | base64 -d 2>/dev/null | _artifactWriteFile "$path" || return 1
else
_artifactRmFile "$path" || return 1
fi
;;
remove-app-definition)
# Inverse of a fresh bundle add. Refuses while the app is installed
# live -- a live app depends on its definition (uninstall first).
local slug live def
slug="$(jq -r '.app // empty' <<<"$op_json")"
[[ "$slug" =~ ^[a-z0-9][a-z0-9_]{0,31}$ ]] || { isError "undo: unsafe app slug '$slug'"; return 1; }
live="$(appDir "$slug")"; def="${install_containers_dir%/}/$slug"
if [[ -d "$live" ]]; then
isError "undo: app '$slug' is INSTALLED -- uninstall it before removing its definition."; return 1
fi
[[ -d "$def" ]] && { runInstallOp rm -rf "$def" || return 1; }
;;
restore-app-definition)
# Inverse of a registry definition update: put the packed prior
# definition tree back (unpack to a temp, then swap into place).
local slug tarb64 def rstage
slug="$(jq -r '.app // empty' <<<"$op_json")"
tarb64="$(jq -r '.tar_b64 // empty' <<<"$op_json")"
{ [[ "$slug" =~ ^[a-z0-9][a-z0-9_]{0,31}$ && -n "$tarb64" ]]; } || { isError "undo: bad restore-app-definition record"; return 1; }
def="${install_containers_dir%/}/$slug"
rstage="$(mktemp -d)"
if ! printf '%s' "$tarb64" | base64 -d 2>/dev/null | tar -xzf - --no-same-owner --no-same-permissions -C "$rstage" 2>/dev/null \
|| [[ ! -d "$rstage/$slug" ]]; then
isError "undo: could not unpack the prior '$slug' definition"; rm -rf "$rstage"; return 1
fi
runInstallOp rm -rf "$def" 2>/dev/null || true
runInstallOp cp -a "$rstage/$slug" "$def" || { rm -rf "$rstage"; return 1; }
rm -rf "$rstage"
;;
*) isError "undo: unknown inverse op '$op'"; return 1 ;;
esac
return 0
}
# Replay every inverse op in $1; "reverse" applies them LIFO (for rollback).
# Returns non-zero if ANY inverse op failed (so the caller never claims success).
_artifactReplayUndoFile() {
local file="$1" app="$2" order="$3" rc=0
[[ -s "$file" ]] || return 0
local lines; mapfile -t lines < "$file"
if [[ "$order" == "reverse" ]]; then
local n=${#lines[@]} j
for (( j=n-1; j>=0; j-- )); do [[ -n "${lines[j]}" ]] && { _artifactOpUndo "${lines[j]}" "$app" || rc=1; }; done
else
local l; for l in "${lines[@]}"; do [[ -n "$l" ]] && { _artifactOpUndo "$l" "$app" || rc=1; }; done
fi
return $rc
}
# Persist the applied-record (metadata + the undo log as a JSON array). Returns
# the write's exit status so the caller can react to a failed persist.
_artifactWriteRecord() {
local id="$1" art="$2" serial="$3" undo_file="$4"
local dir; dir="$(_artifactAppliedDir)"
runFileOp mkdir -p "$dir" 2>/dev/null || true
local ts; ts="$(date -Iseconds 2>/dev/null || date)"
local undo_arr="[]"
[[ -s "$undo_file" ]] && undo_arr="$(jq -cs '.' < "$undo_file" 2>/dev/null || echo '[]')"
local rec
rec="$(jq -cn --argjson art "$art" --arg ts "$ts" --arg serial "$serial" --argjson undo "$undo_arr" \
'{id:$art.id, type:$art.type, version:($art.version//1), app:($art.applies_when.app//null),
severity:($art.severity//"tweak"), title:($art.title//""), why:($art.why//""),
applied_at:$ts, serial:$serial, undo:$undo}')"
printf '%s' "$rec" | runFileWrite "$(_artifactRecordFile "$id")"
return ${PIPESTATUS[1]}
}
# Rebuild artifacts_applied.json (the WebUI-read manifest) from applied/*.json.
_artifactRegenAppliedManifest() {
local dir; dir="$(_artifactAppliedDir)"
local out; out="$(_artifactGenDir)/artifacts_applied.json"
local body="[]"
if compgen -G "$dir/*.json" >/dev/null 2>&1; then
body="$(jq -cs '[.[] | {id,type,app,severity,title,why,applied_at,serial,version}]' "$dir"/*.json 2>/dev/null || echo '[]')"
fi
local ts; ts="$(date -Iseconds 2>/dev/null || date)"
printf '%s' "$(jq -cn --argjson a "$body" --arg ts "$ts" '{generated_at:$ts, applied:$a}')" | runFileWrite "$out"
}
# ----------------------------------------------------------------------------
# artifactApply <id> -- the full pipeline. Re-apply re-prechecks (drift guards
# skip already-applied ops cleanly).
# ----------------------------------------------------------------------------
artifactApply() {
local id="$1"
_artifactNeedJq || return 1
[[ -n "$id" ]] || { isError "artifactApply: no id"; return 1; }
isHeader "Applying artifact: $id"
# 0. RESOLVE + gates (direct call -- the resolve globals + sigstate must
# land in THIS shell, not a substitution subshell)
local art rc
_ART_JSON=""
_artifactResolve "$id"; rc=$?
art="$_ART_JSON"
if [[ $rc -eq 2 ]]; then updaterRecordHistory "${_ART_APP:-}" "${_ART_TYPE:-hotfix}" "" "$id" "not-applicable" "$id" "$(_lpJsonNum "$_ART_INDEX" index_serial)" ""; return 0; fi
[[ $rc -eq 0 && -n "$art" ]] || { updaterRecordHistory "" "${_ART_TYPE:-hotfix}" "" "$id" "rejected" "$id" "" ""; return 1; }
local app="$_ART_APP" scope="$_ART_SCOPE" serial title
serial="$(_lpJsonNum "$_ART_INDEX" index_serial)"
title="$(jq -r '.title // empty' <<<"$art")"
# GATE: never mutate from an index that was accepted only because signing
# wasn't activated (dev/placeholder key). The read path tolerates that; apply
# must not.
if [[ "$LP_INDEX_SIGSTATE" != "verified" ]]; then
isError "artifact: the index is not signature-verified (signing not activated) -- refusing to APPLY."
updaterRecordHistory "$app" "${_ART_TYPE:-hotfix}" "" "$id" "refused-unsigned" "$id" "$serial" ""
return 1
fi
isNotice "$title (type=${_ART_TYPE}, scope=$scope${app:+, app=$app})"
# 4-bundle. type:"app" rides payload.kind:"bundle" through its own flow --
# RESOLVE/trust/sigstate above are shared; everything after differs (no live
# app to snapshot or compose-up; the definition tree is the target). The
# type/kind pairing is strict: any other combination is refused.
local kind; kind="$(jq -r '.payload.kind // "ops"' <<<"$art")"
if [[ "$_ART_TYPE" == "app" || "$kind" == "bundle" ]]; then
if [[ "$_ART_TYPE" != "app" || "$kind" != "bundle" ]]; then
isError "artifact: type '$_ART_TYPE' + payload.kind '$kind' is not a supported combination."
updaterRecordHistory "$app" "${_ART_TYPE:-hotfix}" "" "$id" "rejected" "$id" "$serial" ""
return 1
fi
_artifactApplyBundleFlow "$id" "$art" "$serial"
return $?
fi
# 4. PAYLOAD (fetch + sha256-pin + minisig-verify; refuses unsigned)
local payload; payload="$(_artifactFetchPayload "$art")" || { updaterRecordHistory "$app" "hotfix" "" "$id" "verify-failed" "$id" "$serial" ""; return 1; }
local ops_count; ops_count="$(jq '.ops | length' <<<"$payload" 2>/dev/null || echo 0)"
if ! [[ "$ops_count" =~ ^[0-9]+$ ]] || (( ops_count == 0 )); then
isError "artifact: payload has no ops."; updaterRecordHistory "$app" "hotfix" "" "$id" "empty" "$id" "$serial" ""; return 1
fi
# 6a. DRY-PRECHECK ALL (all-or-nothing)
local i op_json
for (( i=0; i<ops_count; i++ )); do
op_json="$(jq -c ".ops[$i]" <<<"$payload")"
if ! _artifactOpPrecheck "$op_json" "$scope" "$app"; then
isNotice "Precheck failed -- skipping the whole hotfix untouched (no changes made)."
updaterRecordHistory "$app" "hotfix" "" "$id" "skipped-precheck" "$id" "$serial" ""
return 0
fi
done
# 5. SNAPSHOT (best-effort safety net; the undo log is the guaranteed path)
if [[ "$scope" == "app" ]]; then
isNotice "Recovery snapshot of $app..."
backupAppStart "$app" >/dev/null 2>&1 || isNotice "No snapshot taken (no backup location?) -- relying on the precise undo log."
else
backupSystemConfig >/dev/null 2>&1 || isNotice "No system-config snapshot taken -- relying on the precise undo log."
fi
# 6b. APPLY (collect inverse ops into the undo log)
local undo_file; undo_file="$(mktemp)"
local applied_ok=1
for (( i=0; i<ops_count; i++ )); do
op_json="$(jq -c ".ops[$i]" <<<"$payload")"
if ! _artifactOpApply "$op_json" "$scope" "$app" "$undo_file"; then applied_ok=0; break; fi
done
# 7. bring the app up / regenerate system config so changes take effect
if [[ "$applied_ok" == "1" ]]; then
if [[ "$scope" == "app" ]]; then
dockerComposeUp "$app" >/dev/null 2>&1 || applied_ok=0
elif declare -F webuiGenerateSystemConfigs >/dev/null 2>&1; then
webuiGenerateSystemConfigs >/dev/null 2>&1 || applied_ok=0
fi
fi
if [[ "$applied_ok" != "1" ]]; then
# 8. AUTO-ROLLBACK -- replay the undo log LIFO; snapshot is the fallback.
isNotice "Apply failed -- rolling back..."
local rb_rc=0
_artifactReplayUndoFile "$undo_file" "$app" "reverse" || rb_rc=1
if [[ "$scope" == "app" ]]; then restoreAppStart "$app" latest "" >/dev/null 2>&1 || true; dockerComposeUp "$app" >/dev/null 2>&1 || true; fi
rm -f "$undo_file"
if [[ "$rb_rc" -eq 0 ]]; then
updaterRecordHistory "$app" "hotfix" "" "$id" "rolled-back" "$id" "$serial" ""
isError "Hotfix $id failed and was rolled back cleanly."
else
updaterRecordHistory "$app" "hotfix" "" "$id" "rollback-incomplete" "$id" "$serial" ""
isError "Hotfix $id failed AND auto-rollback was incomplete -- MANUAL recovery needed (see History / the app's snapshot)."
fi
return 1
fi
# 9. RECORD (applied-record with the undo log). If we can't persist the undo
# trail, roll back rather than leave an un-revertable change.
if ! _artifactWriteRecord "$id" "$art" "$serial" "$undo_file"; then
isError "Could not persist the applied-record -- rolling back to stay reversible."
_artifactReplayUndoFile "$undo_file" "$app" "reverse" || true
[[ "$scope" == "app" ]] && dockerComposeUp "$app" >/dev/null 2>&1 || true
rm -f "$undo_file"
updaterRecordHistory "$app" "hotfix" "" "$id" "record-failed-rolled-back" "$id" "$serial" ""
return 1
fi
rm -f "$undo_file"
_artifactRegenAppliedManifest
updaterRecordHistory "$app" "hotfix" "" "$id" "applied" "$id" "$serial" "$id"
isSuccessful "Hotfix $id applied. Reversible via: libreportal artifact revert $id"
return 0
}
# ----------------------------------------------------------------------------
# artifactRevert <id> -- replay the applied-record's undo log (LIFO), then bring
# the app up / regen config. Only removes the record + reports success if every
# inverse op succeeded; otherwise keeps the record for retry and reports honestly.
# ----------------------------------------------------------------------------
artifactRevert() {
local id="$1"
_artifactNeedJq || return 1
[[ -n "$id" ]] || { isError "artifactRevert: no id"; return 1; }
local rf; rf="$(_artifactRecordFile "$id")"
[[ -f "$rf" ]] || { isError "artifact: '$id' is not recorded as applied -- nothing to revert."; return 1; }
local rec; rec="$(cat "$rf")"
local rtype; rtype="$(jq -r '.type // "hotfix"' <<<"$rec")"
isHeader "Reverting ${rtype}: $id"
local app; app="$(jq -r '.app // empty' <<<"$rec")"
local serial; serial="$(jq -r '.serial // empty' <<<"$rec")"
local undo_file; undo_file="$(mktemp)"
jq -c '.undo[]?' <<<"$rec" > "$undo_file" 2>/dev/null
local rv_rc=0
_artifactReplayUndoFile "$undo_file" "$app" "reverse" || rv_rc=1
rm -f "$undo_file"
# Bring-up: an app record changed the DEFINITION tree (regen the catalog);
# a hotfix changed a live app (compose up) or system configs (regen those).
if [[ "$rtype" == "app" ]]; then
{ declare -F lpRegenWebui >/dev/null 2>&1 && lpRegenWebui force >/dev/null 2>&1; } || true
elif [[ -n "$app" ]]; then dockerComposeUp "$app" >/dev/null 2>&1 || true
else declare -F webuiGenerateSystemConfigs >/dev/null 2>&1 && webuiGenerateSystemConfigs >/dev/null 2>&1 || true; fi
if [[ "$rv_rc" -eq 0 ]]; then
runFileOp rm -f "$rf" 2>/dev/null || true
_artifactRegenAppliedManifest
updaterRecordHistory "$app" "$rtype" "$id" "" "reverted" "$id" "$serial" ""
isSuccessful "${rtype^} $id reverted."
return 0
fi
updaterRecordHistory "$app" "$rtype" "$id" "" "revert-incomplete" "$id" "$serial" "$id"
isError "Revert of $id was incomplete -- the applied-record is kept so you can retry: libreportal artifact revert $id (MANUAL recovery may be needed)."
return 1
}
# ----------------------------------------------------------------------------
# artifactApplyAuto -- enqueue apply tasks for the auto-eligible hotfixes, gated
# by CFG_HOTFIX_AUTO (security-breakage|all|off). Called from `updater check`.
# Only acts on a VERIFIED-signed index; only artifacts with auto==true, in the
# severity policy, applicable, and not already applied. Each apply is enqueued as
# its own task (visible in the task log + History) -- never applied inline here.
# ----------------------------------------------------------------------------
artifactApplyAuto() {
_artifactNeedJq || return 0
local policy="${CFG_HOTFIX_AUTO:-security-breakage}"
[[ "$policy" == "off" ]] && { isNotice "Hotfix auto-apply is off (CFG_HOTFIX_AUTO=off)."; return 0; }
local index; lpFetchIndexInto index || { isNotice "artifact apply-auto: no index available."; return 0; }
if [[ "$LP_INDEX_SIGSTATE" != "verified" ]]; then
isNotice "artifact apply-auto: index is unsigned (signing not activated) — not auto-applying."; return 0
fi
local ids id art sev app installed enqueued=0
# type=="hotfix" is a hard filter: apps are ALWAYS a deliberate user action
# (the publisher also forces auto:false on them -- belt and braces).
ids="$(printf '%s' "$index" | jq -r '.artifacts[]? | select(.auto==true and (.type=="hotfix")) | .id' 2>/dev/null)"
while IFS= read -r id; do
[[ -z "$id" ]] && continue
art="$(printf '%s' "$index" | jq -ce --arg id "$id" '.artifacts[]?|select(.id==$id)' 2>/dev/null)"
[[ -n "$art" ]] || continue
sev="$(jq -r '.severity // "tweak"' <<<"$art")"
case "$policy" in
all) : ;;
*) [[ "$sev" == "security" || "$sev" == "breakage" ]] || continue ;; # security-breakage (default)
esac
# skip if already applied
[[ -f "$(_artifactRecordFile "$id")" ]] && continue
# skip if app-scoped but the app isn't installed (applicable gate; full
# gates re-checked at apply time)
app="$(jq -r '.applies_when.app // empty' <<<"$art")"
[[ -n "$app" && ! -d "$(appDir "$app")" ]] && continue
# --detach: this runs inside the task processor's own poll, so following
# the new task in the foreground would wait for a task the (single-
# threaded) processor can't start until we return.
cliTaskRun "libreportal artifact apply $id" "artifact_apply" "$id" "--detach"
enqueued=$((enqueued + 1))
done <<< "$ids"
if (( enqueued > 0 )); then isSuccessful "Queued $enqueued auto-hotfix(es) for apply (policy: $policy)."
else isNotice "No new auto-hotfixes to apply (policy: $policy)."; fi
}
# ----------------------------------------------------------------------------
# appAddFromRegistry <slug-or-artifact-id> -- the `libreportal app add` verb.
# Resolves a type:"app" artifact by exact id or by its applies_when.app slug,
# then runs the standard apply pipeline (which re-fetches + re-verifies).
# ----------------------------------------------------------------------------
appAddFromRegistry() {
local arg="$1"
_artifactNeedJq || return 1
[[ -n "$arg" ]] || { isError "app add: need an app slug or artifact id."; return 1; }
[[ "$arg" =~ ^[A-Za-z0-9._-]+$ ]] || { isError "app add: argument has unsafe characters."; return 1; }
local index; lpFetchIndexInto index || { isError "app add: could not fetch/verify the artifact index."; return 1; }
local ids; ids="$(printf '%s' "$index" | jq -r --arg a "$arg" \
'[.artifacts[]? | select(.type=="app" and (.id==$a or (.applies_when.app // "")==$a)) | .id] | unique | .[]' 2>/dev/null)"
if [[ -z "$ids" ]]; then
isError "app add: no app '$arg' in the catalog. Browse it with: libreportal artifact index"; return 1
fi
if (( $(wc -l <<<"$ids") > 1 )); then
isError "app add: '$arg' matches more than one artifact -- use an exact id: $(tr '\n' ' ' <<<"$ids")"; return 1
fi
artifactApply "$ids"
}