@ -1,28 +1,30 @@
#!/bin/bash
#
# Artifact APPLY pipeline — Phase 2 of the unified distribution primitive
# (docs/roadmap/updates-and-distribution.md §8.3). The MUTATING side: it takes a
# verified artifact from the signed index and applies it reversibly, then c an
# revert it. Runs ONLY under the task system (cli_artifact_commands.sh enqueues;
# the processor re-invokes with LIBREPORTAL_TASK_EXEC=1, which is when these run ).
# 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):
# * T he trust core is the same anchor as releases — the index is minisign-
# verified against the root-owned footprint key by lpFetchIndex (source/
# artifacts.sh); the payload is sha256-pinned by that signed index and itself
# minisign-verified. The publishers map + role gate makes a community key
# unable to masquerade as official (registry-ready; first-party-only today) .
# * T rust 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 as a skip, so coverage gaps
# are visible — a customised box may legitimately miss a fix).
# * Two-tier reversibility: every op records a precise inverse into a per-id
# applied-record (the revert path); a best-effort recovery snapshot is also
# taken when a backup location exists (the dirty-op fallback).
# * Mutations write only through the de-sudo funnels (runFileOp / runInstallOp /
# updateConfigOption), never raw sudo. The install tree (our own code) is
# off-limits to hotfixes — code fixes ride signed releases (fork 1).
# 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 " ${ containers_dir %/ } /libreportal/frontend/data/updater/generated " ; }
@ -34,14 +36,42 @@ _artifactRecordFile() { echo "$(_artifactAppliedDir)/$1.json"; } # $1=id
# 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."
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.
# RESOLVE -- fetch+verify the index, pull out one artifact, check the gates.
# Echoes the artifact JSON on success; non-zero (nothing usable) otherwise.
# Sets globals: _ART_INDEX (verified index json), _ART_SCOPE, _ART_APP.
# (LP_INDEX_SIGSTATE is set by lpFetchIndex; artifactApply enforces it.)
# ----------------------------------------------------------------------------
_artifactResolve( ) {
local id = " $1 "
@ -61,15 +91,15 @@ _artifactResolve() {
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
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
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
isError " artifact: publisher role ' $role ' (non-official) is not enabled yet -- refusing." ; return 1
fi
# --- gates (applies_when) ---
@ -77,7 +107,7 @@ _artifactResolve() {
_ART_SCOPE = "system" ; [ [ -n " $_ART_APP " ] ] && _ART_SCOPE = "app"
if [ [ " $_ART_SCOPE " = = "app" && ! -d " ${ containers_dir %/ } / $_ART_APP " ] ] ; then
isNotice " artifact: ' $id ' targets ' $_ART_APP ' which is not installed — not applicable." ; return 2
isNotice " artifact: ' $id ' targets ' $_ART_APP ' which is not installed -- not applicable." ; return 2
fi
local minlp maxlp maxfp curlp curfp
@ -86,15 +116,15 @@ _artifactResolve() {
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
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
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
isNotice " artifact: ' $id ' applies only up to footprint $maxfp (have $curfp ) -- not applicable." ; return 2
fi
fi
@ -102,11 +132,12 @@ _artifactResolve() {
}
# ----------------------------------------------------------------------------
# PAYLOAD — download, sha256-pin (from the signed index), minisig-verify.
# PAYLOAD -- download, sha256-pin (from the signed index), minisig-verify.
# REFUSES an unsigned payload (signing-not-activated) on the apply path.
# Echoes the verified payload JSON; non-zero on any failure.
# ----------------------------------------------------------------------------
_artifactFetchPayload( ) {
local art = " $1 " base channel url want_sha sig_url tmp pf sf got kind
local art = " $1 " base channel url want_sha sig_url tmp pf sf got kind pstate
base = " $( lpReleaseBaseUrl) " ; channel = " $( lpReleaseChannel) "
kind = " $( jq -r '.payload.kind // empty' <<< " $art " ) "
if [ [ " $kind " != "ops" ] ] ; then
@ -115,19 +146,21 @@ _artifactFetchPayload() {
url = " $( jq -r '.payload.url // empty' <<< " $art " ) "
want_sha = " $( jq -r '.payload.sha256 // empty' <<< " $art " ) "
sig_url = " $( jq -r '.payload.sig // empty' <<< " $art " ) "
[ [ -n " $url " && -n " $want_sha " ] ] || { isError "artifact: payload missing url/sha256 — refusing."; return 1; }
[ [ -n " $url " && -n " $want_sha " ] ] || { isError "artifact: payload missing url/sha256 -- refusing."; return 1; }
tmp = " $( mktemp -d) " ; pf = " $tmp /payload.json " ; sf = " $tmp /payload.sig "
# url/sig may be channel-relative ("stable/payloads/..") or absolute.
case " $url " in http*://*) : ; ; *) url = " $base / $url " ; ; esac
if ! _lpDownload " $url " " $pf " 2>/dev/null; then isError "artifact: payload download failed." ; rm -rf " $tmp " ; return 1; fi
got = " $( _lpSha256 " $pf " ) "
if [ [ " $got " != " $want_sha " ] ] ; then isError "artifact: payload CHECKSUM MISMATCH — refusing."; rm -rf " $tmp " ; return 1; fi
if [ [ " $got " != " $want_sha " ] ] ; then isError "artifact: payload CHECKSUM MISMATCH -- refusing."; rm -rf " $tmp " ; 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
if ! lpVerifyMinisig " $pf " " $sf " >/dev/null; then rm -rf " $tmp " ; return 1; fi
pstate = " $( lpVerifyMinisig " $pf " " $sf " ) " || { rm -rf " $tmp " ; return 1; }
if [ [ " $pstate " != "verified" ] ] ; then
isError "artifact: refusing to APPLY an unsigned payload (signing not activated / footprint key missing)." ; rm -rf " $tmp " ; return 1
fi
cat " $pf " ; rm -rf " $tmp "
}
@ -135,10 +168,13 @@ _artifactFetchPayload() {
# --- 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)
# 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 -- " ${ containers_dir %/ } / $app " 2>/dev/null) "
@ -150,7 +186,7 @@ _artifactPathAllowed() {
return 1
}
# current image of an app's compose (first image: line), quotes stripped.
# current image of an app's compose (first image: line), quotes /comment stripped.
_artifactComposeImage( ) {
local app = " $1 " f = " ${ containers_dir %/ } / $1 /docker-compose.yml "
[ [ -f " $f " ] ] || return 1
@ -158,7 +194,7 @@ _artifactComposeImage() {
}
# ----------------------------------------------------------------------------
# OP PRECHECK — pure read; returns 0 if the op can be applied as-is, else 1 with
# 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( ) {
@ -166,38 +202,49 @@ _artifactOpPrecheck() {
op = " $( jq -r '.op // empty' <<< " $op_json " ) "
case " $op " in
set-config-key)
local key expect cur
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; }
expect = " $( jq -r 'if has("expect_current") then .expect_current else "