From a27304a1913381f09c8d80898061d4a825e3b205 Mon Sep 17 00:00:00 2001 From: librelad Date: Sun, 31 May 2026 20:47:18 +0100 Subject: [PATCH] fix(distribution): harden the artifact apply pipeline (adversarial review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 4-lens adversarial security review of the Phase 2 applier raised 19 issues and confirmed 17 after per-finding verification. All are trust-boundary (they require the signing key), but several break the explicit "no code-exec, always reversible, nothing-silent" contract, so all 17 are fixed: Trust path — fail CLOSED, never misreport: - lpFetchIndex now surfaces the real signature state (LP_INDEX_SIGSTATE); artifactApply REFUSES to mutate unless the index is actually verified, and _artifactFetchPayload refuses an unsigned payload. The read path still tolerates dev/unsigned but now says "UNSIGNED" instead of "Signed + verified". - valid_until and index_serial are now MANDATORY + numeric in lpFetchIndex (missing = refuse) — closes the anti-withholding / anti-rollback fail-opens. Injection / code-exec (defense in depth even for a signed payload): - runFileWrite rootless branch no longer builds a `bash -c` shell string with the destination interpolated — it uses the argv form (like runFileOp), so a path with a quote can't inject a command as the install user. (shared-helper fix) - op paths must match a safe-filename charset (no quotes/$/backtick/;/newline); set-config-key values and set-compose-image refs are charset-guarded too. - content_b64 is validated as real base64 at precheck. Reversibility / honest failure: - dockerComposeUp now returns the real compose exit status (it always returned 0, so the updater's rollback gate AND the apply's start-failure detection were fail-open). (shared-helper fix) - set-config-key undo captures the WHOLE config file (lossless) instead of a lossy re-parsed scalar; edit-only (rejects an absent key). - _artifactReplayUndoFile returns non-zero if any inverse op fails; auto-rollback and revert now record "rollback-incomplete"/"revert-incomplete" + isError instead of falsely claiming success, and revert keeps the record for retry. - applied-record write failure is checked — apply rolls back rather than leave an un-revertable change. System-scope regen failure is no longer swallowed. - Writes are path-aware (configs/ -> runInstallWrite, container tree -> runFileWrite) so system-scope hotfixes write/restore correctly. - Checked lazy-sourcing surfaces a clear error instead of a bare exit 127. Unit-tested 35/35 (adds: command-sub value rejection, bad image-ref, invalid base64, quote/metachar path-injection rejection, replay-failure reporting). Co-Authored-By: Claude Opus 4.8 Signed-off-by: librelad --- .../commands/artifact/cli_artifact_apply.sh | 350 +++++++++++------- .../artifact/cli_artifact_commands.sh | 30 +- scripts/docker/app/compose/up_app.sh | 17 +- scripts/docker/command/run_privileged.sh | 6 +- scripts/source/artifacts.sh | 36 +- .../source/files/arrays/function_manifest.sh | 10 + 6 files changed, 290 insertions(+), 159 deletions(-) diff --git a/scripts/cli/commands/artifact/cli_artifact_apply.sh b/scripts/cli/commands/artifact/cli_artifact_apply.sh index bfbd32f..a8d1d54 100644 --- a/scripts/cli/commands/artifact/cli_artifact_apply.sh +++ b/scripts/cli/commands/artifact/cli_artifact_apply.sh @@ -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 can -# 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): -# * The 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). +# * 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 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// # 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 "NONE" end' <<<"$op_json")" - if [[ "$expect" != $'NONE' ]]; then - cur="$(grep -m1 "^$key=" "$(findConfigFileForOption "$key" 2>/dev/null)" 2>/dev/null | sed -E "s/^$key=//; s/^\"//; s/\".*$//; s/\s+#.*$//")" + _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 cur + 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 "${containers_dir%/}/$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 + 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; } + [[ -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 + 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)." + isError "op '$op' is not supported by this build -- rejecting the whole artifact (fail-closed)." return 1 ;; esac @@ -205,29 +252,32 @@ _artifactOpPrecheck() { } # ---------------------------------------------------------------------------- -# OP APPLY — mutate, and APPEND the precise inverse op (as compact JSON) to the -# file named by $4 (the undo log). Returns non-zero on failure. +# 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) - local key value cur f + # 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)" - cur="$(grep -m1 "^$key=" "$f" 2>/dev/null | sed -E "s/^$key=//; s/^\"//; s/\".*$//; s/\s+#.*$//")" + { [[ -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 k "$key" --arg v "$cur" '{op:"set-config-key", key:$k, value:$v}' >> "$undo_file" + 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_cur esc_img + local image cur f esc_img image="$(jq -r '.image' <<<"$op_json")" f="${containers_dir%/}/$app/docker-compose.yml" cur="$(_artifactComposeImage "$app")" - esc_cur="$(printf '%s' "$cur" | sed -e 's/[\/&]/\\&/g')" esc_img="$(printf '%s' "$image" | sed -e 's/[\/&]/\\&/g')" - # Replace the first image: line's value, preserving indentation. 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" ;; @@ -237,7 +287,7 @@ _artifactOpApply() { 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 | runFileWrite "$path"; then + 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" \ @@ -251,16 +301,14 @@ _artifactOpApply() { } # ---------------------------------------------------------------------------- -# OP UNDO — apply one inverse op recorded by _artifactOpApply (the revert path). +# 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-config-key) - local key value; key="$(jq -r '.key' <<<"$op_json")"; value="$(jq -r '.value' <<<"$op_json")" - updateConfigOption "$key" "$value" || return 1 - ;; set-compose-image) local image f esc_img; image="$(jq -r '.image' <<<"$op_json")" f="${containers_dir%/}/$app/docker-compose.yml" @@ -272,9 +320,9 @@ _artifactOpUndo() { 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 | runFileWrite "$path" || return 1 + printf '%s' "$content_b64" | base64 -d 2>/dev/null | _artifactWriteFile "$path" || return 1 else - runFileOp rm -f "$path" || return 1 + _artifactRmFile "$path" || return 1 fi ;; *) isError "undo: unknown inverse op '$op'"; return 1 ;; @@ -282,9 +330,54 @@ _artifactOpUndo() { 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 — the full pipeline (steps 0-9). Idempotent-ish: a -# re-apply re-prechecks (drift guards skip already-applied ops cleanly). +# artifactApply -- the full pipeline. Re-apply re-prechecks (drift guards +# skip already-applied ops cleanly). # ---------------------------------------------------------------------------- artifactApply() { local id="$1" @@ -302,12 +395,20 @@ artifactApply() { 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" "hotfix" "" "$id" "refused-unsigned" "$id" "$serial" "" + return 1 + fi isNotice "$title (scope=$scope${app:+, app=$app})" - # 4. PAYLOAD (fetch+verify) + # 4. PAYLOAD (fetch + sha256-pin + minisig-verify; refuses unsigned) local payload; payload="$(_artifactFetchPayload "$art")" || { updaterRecordHistory "$app" "hotfix" "" "$id" "verify-failed" "$id" "$serial" ""; return 1; } - # collect ops 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 @@ -318,18 +419,18 @@ artifactApply() { for (( i=0; i/dev/null 2>&1 || isNotice "No snapshot taken (no backup location?) — relying on the precise undo log." + 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." + 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) @@ -340,26 +441,42 @@ artifactApply() { if ! _artifactOpApply "$op_json" "$scope" "$app" "$undo_file"; then applied_ok=0; break; fi done - # bring the app up / regenerate system config so changes take effect - if [[ "$scope" == "app" ]]; then - dockerComposeUp "$app" >/dev/null 2>&1 || applied_ok=0 - else - declare -F webuiGenerateSystemConfigs >/dev/null 2>&1 && webuiGenerateSystemConfigs >/dev/null 2>&1 || true + # 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, then the snapshot as fallback. - isNotice "Apply failed — rolling back…" - _artifactReplayUndoFile "$undo_file" "$app" "reverse" - [[ "$scope" == "app" ]] && { restoreAppStart "$app" latest "" >/dev/null 2>&1 || true; dockerComposeUp "$app" >/dev/null 2>&1 || true; } + # 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" - updaterRecordHistory "$app" "hotfix" "" "$id" "rolled-back" "$id" "$serial" "" - isError "Hotfix $id failed and was rolled back." + 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) + History + serial high-water - _artifactWriteRecord "$id" "$art" "$serial" "$undo_file" + # 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" @@ -367,75 +484,40 @@ artifactApply() { return 0 } -# Replay every inverse op in $1; "reverse" applies them LIFO (for rollback). -_artifactReplayUndoFile() { - local file="$1" app="$2" order="$3" - [[ -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" || true; done - else - local l; for l in "${lines[@]}"; do [[ -n "$l" ]] && _artifactOpUndo "$l" "$app" || true; done - fi -} - -# Persist the applied-record (metadata + the undo log as a JSON array). -_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")" -} - -# 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" -} - # ---------------------------------------------------------------------------- -# artifactRevert — replay the applied-record's undo log (LIFO), then bring -# the app up / regen config. Removes the record + logs to History. +# artifactRevert -- 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; } + [[ -f "$rf" ]] || { isError "artifact: '$id' is not recorded as applied -- nothing to revert."; return 1; } isHeader "Reverting hotfix: $id" local rec; rec="$(cat "$rf")" local app; app="$(jq -r '.app // empty' <<<"$rec")" local serial; serial="$(jq -r '.serial // empty' <<<"$rec")" - # Replay the undo ops in reverse order (LIFO). local undo_file; undo_file="$(mktemp)" jq -c '.undo[]?' <<<"$rec" > "$undo_file" 2>/dev/null - _artifactReplayUndoFile "$undo_file" "$app" "reverse" + local rv_rc=0 + _artifactReplayUndoFile "$undo_file" "$app" "reverse" || rv_rc=1 rm -f "$undo_file" if [[ -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 - runFileOp rm -f "$rf" 2>/dev/null || true - _artifactRegenAppliedManifest - updaterRecordHistory "$app" "hotfix" "$id" "" "reverted" "$id" "$serial" "" - isSuccessful "Hotfix $id reverted." - return 0 + if [[ "$rv_rc" -eq 0 ]]; then + runFileOp rm -f "$rf" 2>/dev/null || true + _artifactRegenAppliedManifest + updaterRecordHistory "$app" "hotfix" "$id" "" "reverted" "$id" "$serial" "" + isSuccessful "Hotfix $id reverted." + return 0 + fi + updaterRecordHistory "$app" "hotfix" "$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 } diff --git a/scripts/cli/commands/artifact/cli_artifact_commands.sh b/scripts/cli/commands/artifact/cli_artifact_commands.sh index d527c95..acd0be8 100644 --- a/scripts/cli/commands/artifact/cli_artifact_commands.sh +++ b/scripts/cli/commands/artifact/cli_artifact_commands.sh @@ -17,17 +17,23 @@ cliHandleArtifactCommands() local sub="$initial_command2" local id="$initial_command3" - # Lazy-loader gap: ensure the read primitives are defined. These are new - # files; the array/manifest regen self-heals them on deploy, but this covers - # the window before that (mirrors cli_updater_commands.sh sourcing its - # generator). artifacts.sh leans on fetch.sh helpers, so load both. + # Lazy-loader gap: ensure the read primitives + apply pipeline are defined. + # These are new files; the array/manifest regen self-heals them on deploy, + # but this covers the window before that. Source CHECKED — a missing/corrupt + # file must surface a clear error, not degrade to a bare "command not found". if ! declare -F lpFetchIndex >/dev/null 2>&1; then - source "$install_scripts_dir/source/fetch.sh" 2>/dev/null - source "$install_scripts_dir/source/artifacts.sh" 2>/dev/null + local _f + for _f in source/fetch.sh source/artifacts.sh; do + if [[ ! -f "$install_scripts_dir/$_f" ]] || ! source "$install_scripts_dir/$_f"; then + isError "artifact: failed to load the read pipeline ($_f) — try: libreportal regen"; return 1 + fi + done fi - # The apply pipeline lives in a sibling file — source it on demand too. if ! declare -F artifactApply >/dev/null 2>&1; then - source "$install_scripts_dir/cli/commands/artifact/cli_artifact_apply.sh" 2>/dev/null + local _af="cli/commands/artifact/cli_artifact_apply.sh" + if [[ ! -f "$install_scripts_dir/$_af" ]] || ! source "$install_scripts_dir/$_af"; then + isError "artifact: failed to load the apply pipeline ($_af) — try: libreportal regen"; return 1 + fi fi case "$sub" in @@ -96,7 +102,13 @@ artifactListIndex() local serial generated_at serial="$(_lpJsonNum "$json" index_serial)" generated_at="$(lpIndexTop generated_at "$json")" - isNotice "Signed + verified. serial=${serial:-?} generated=${generated_at:-?}" + # Report the ACTUAL signature state — never claim "verified" when the feed was + # only accepted because signing isn't activated (dev/placeholder key). + if [[ "$LP_INDEX_SIGSTATE" == "verified" ]]; then + isNotice "Signed + verified. serial=${serial:-?} generated=${generated_at:-?}" + else + isNotice "UNSIGNED (signing not activated — dev/placeholder key). serial=${serial:-?} generated=${generated_at:-?}" + fi local ids; ids="$(lpIndexArtifactIds "$json")" if [[ -z "$ids" ]]; then diff --git a/scripts/docker/app/compose/up_app.sh b/scripts/docker/app/compose/up_app.sh index b1f6470..e222b09 100755 --- a/scripts/docker/app/compose/up_app.sh +++ b/scripts/docker/app/compose/up_app.sh @@ -8,8 +8,15 @@ dockerComposeUp() if [[ "$app_name" == "" ]]; then isError "Something went wrong...No app name provided..." + return 1 fi + # Real exit status of the compose command, so callers that gate on it (the + # updater's rollback, the artifact apply pipeline) aren't fail-open. checkSuccess + # only logs; it does NOT set the function's return — without this the function + # always returned 0 and a failed `up -d` looked like success. + local _rc=0 + isHeader "Docker Compose Up $app_name" # Make sure we are able to get the compose file @@ -115,25 +122,27 @@ dockerComposeUp() fi if [[ $CFG_DOCKER_INSTALL_TYPE == "rootless" ]]; then isNotice "Starting container for $app_name, this may take a while..." - local result; result=$(dockerCommandRunInstallUser "cd $containers_dir$app_name && COMPOSE_PROGRESS=plain docker compose $setup_compose up $_compose_quiet $_compose_build_flag -d") + local result; result=$(dockerCommandRunInstallUser "cd $containers_dir$app_name && COMPOSE_PROGRESS=plain docker compose $setup_compose up $_compose_quiet $_compose_build_flag -d"); _rc=$? checkSuccess "Started container for $app_name" elif [[ $CFG_DOCKER_INSTALL_TYPE == "rooted" ]]; then isNotice "Starting container for $app_name, this may take a while..." - local result; result=$(cd "$containers_dir$app_name" && COMPOSE_PROGRESS=plain docker compose $setup_compose up $_compose_quiet $_compose_build_flag -d) + local result; result=$(cd "$containers_dir$app_name" && COMPOSE_PROGRESS=plain docker compose $setup_compose up $_compose_quiet $_compose_build_flag -d); _rc=$? checkSuccess "Started container for $app_name" fi # Used for the CLI dockertype switcher. else if [[ $type == "rootless" ]]; then - local result; result=$(dockerCommandRunInstallUser "cd $containers_dir$app_name && docker compose $setup_compose down") + local result; result=$(dockerCommandRunInstallUser "cd $containers_dir$app_name && docker compose $setup_compose down"); _rc=$? checkSuccess "Shutting down container for $app_name" elif [[ $type == "rooted" ]]; then - local result; result=$(cd "$containers_dir$app_name" && docker compose $setup_compose down) + local result; result=$(cd "$containers_dir$app_name" && docker compose $setup_compose down); _rc=$? checkSuccess "Shutting down container for $app_name" fi fi else isNotice "Unable to find the compose file to docker compose up this application." + _rc=1 fi fi + return $_rc } diff --git a/scripts/docker/command/run_privileged.sh b/scripts/docker/command/run_privileged.sh index ff59f4f..668ad9f 100644 --- a/scripts/docker/command/run_privileged.sh +++ b/scripts/docker/command/run_privileged.sh @@ -51,7 +51,11 @@ runFileWrite() { fi local dest="$1" if [[ "$CFG_DOCKER_INSTALL_TYPE" == "rootless" ]]; then - dockerCommandRunInstallUser "tee ${append_flag[*]} '$dest' >/dev/null" + # --argv: pass tee + the destination as literal argv (no `bash -c`), so a + # path containing a quote/metachar can't break out of a shell string and + # inject a command. Mirrors runFileOp; the >/dev/null is the manager-side + # shell's (suppresses tee's stdout echo). stdin is preserved by sudo. + dockerCommandRunInstallUser --argv tee "${append_flag[@]}" "$dest" >/dev/null else runAsManager tee "${append_flag[@]}" "$dest" >/dev/null fi diff --git a/scripts/source/artifacts.sh b/scripts/source/artifacts.sh index bebcfb1..c65e007 100644 --- a/scripts/source/artifacts.sh +++ b/scripts/source/artifacts.sh @@ -50,9 +50,15 @@ lpArtifactRecordSerial() { # Echoes the verified JSON to stdout on success. Returns non-zero (printing # nothing usable) on ANY download / signature / freshness / rollback failure — # callers MUST NOT proceed on a non-zero return (fail-closed). +# On success, sets the global LP_INDEX_SIGSTATE to "verified" or "unsigned" so +# callers can distinguish a real signature from signing-not-activated (dev). The +# READ path tolerates "unsigned" (dev/git installs); the MUTATING apply path must +# refuse it (see artifactApply) — that asymmetry is the whole point of surfacing it. +LP_INDEX_SIGSTATE="" lpFetchIndex() { local cache="${1:-}" base channel tmp idx sig json valid_until nowts serial last base="$(lpReleaseBaseUrl)"; channel="$(lpReleaseChannel)" + LP_INDEX_SIGSTATE="" [[ -n "$(_lpFetchTool)" ]] || { isError "lpFetchIndex: need curl or wget"; return 1; } tmp="$(mktemp -d)"; idx="$tmp/index.json"; sig="$tmp/index.json.minisig" @@ -65,28 +71,36 @@ lpFetchIndex() { # Signature FIRST — never parse an unverified document to make trust # decisions. Fetch the .minisig best-effort; lpVerifyMinisig decides whether - # a missing/invalid signature is fatal (it is, once the key is real). + # a missing/invalid signature is fatal (it is, once the key is real) and + # echoes the resulting state, which we record for the apply-path gate. _lpDownload "$base/$channel/index.json.minisig" "$sig" 2>/dev/null || true - if ! lpVerifyMinisig "$idx" "$sig" >/dev/null; then rm -rf "$tmp"; return 1; fi + LP_INDEX_SIGSTATE="$(lpVerifyMinisig "$idx" "$sig")" || { rm -rf "$tmp"; return 1; } json="$(cat "$idx")" - # Freshness — refuse a signed-but-stale feed. + # Freshness — a valid index MUST carry a future valid_until. Missing / non- + # numeric / elapsed all refuse (a withheld or undated feed is the attack the + # anti-withholding guarantee exists to defeat — fail-closed, not fail-open). valid_until="$(_lpJsonNum "$json" valid_until)" - if [[ -n "$valid_until" ]]; then - nowts="$(date +%s 2>/dev/null)" - if [[ -n "$nowts" ]] && (( valid_until < nowts )); then - isError "lpFetchIndex: artifact index is stale (valid_until elapsed) — refusing"; rm -rf "$tmp"; return 1 - fi + nowts="$(date +%s 2>/dev/null)" + if [[ -z "$valid_until" ]]; then + isError "lpFetchIndex: index has no numeric valid_until — refusing (anti-withholding)"; rm -rf "$tmp"; return 1 + fi + if [[ -n "$nowts" ]] && (( valid_until < nowts )); then + isError "lpFetchIndex: artifact index is stale (valid_until elapsed) — refusing"; rm -rf "$tmp"; return 1 fi - # Anti-rollback — serial must not go backwards from the highest accepted. + # Anti-rollback — a valid index MUST carry index_serial, and it must not go + # backwards from the highest accepted (missing serial = missing anchor = refuse). serial="$(_lpJsonNum "$json" index_serial)" + if [[ -z "$serial" ]]; then + isError "lpFetchIndex: index has no numeric index_serial — refusing (anti-rollback anchor missing)"; rm -rf "$tmp"; return 1 + fi last="$(lpArtifactLastSerial)" - if [[ -n "$serial" ]] && (( serial < last )); then + if (( serial < last )); then isError "lpFetchIndex: index_serial $serial below last-seen $last (rollback) — refusing"; rm -rf "$tmp"; return 1 fi - [[ -n "$serial" ]] && lpArtifactRecordSerial "$serial" + lpArtifactRecordSerial "$serial" [[ -n "$cache" ]] && printf '%s' "$json" | runFileWrite "$cache" printf '%s' "$json" diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index 1f03a6d..82ea5bd 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -118,6 +118,9 @@ declare -gA LP_FN_MAP=( [_artifactReplayUndoFile]="cli/commands/artifact/cli_artifact_apply.sh" [_artifactResolve]="cli/commands/artifact/cli_artifact_apply.sh" [artifactRevert]="cli/commands/artifact/cli_artifact_apply.sh" + [_artifactRmFile]="cli/commands/artifact/cli_artifact_apply.sh" + [_artifactSafeScalar]="cli/commands/artifact/cli_artifact_apply.sh" + [_artifactWriteFile]="cli/commands/artifact/cli_artifact_apply.sh" [_artifactWriteRecord]="cli/commands/artifact/cli_artifact_apply.sh" [atomicWriteWebUI]="webui/data/utils/webui_atomic_write.sh" [authAdapter_adguard_setPassword]="adguard/scripts/adguard_auth.sh" @@ -1055,6 +1058,9 @@ declare -gA LP_FN_ROOT=( [_artifactReplayUndoFile]="scripts" [_artifactResolve]="scripts" [artifactRevert]="scripts" + [_artifactRmFile]="scripts" + [_artifactSafeScalar]="scripts" + [_artifactWriteFile]="scripts" [_artifactWriteRecord]="scripts" [atomicWriteWebUI]="scripts" [authAdapter_adguard_setPassword]="containers" @@ -1894,6 +1900,7 @@ LP_EAGER_FILES=( "scripts:docker/type_switcher/swap_docker_type.sh" "scripts:migrate/migrate_url_rewrite.sh" "scripts:setup/setup_lock.sh" + "scripts:source/artifacts.sh" "scripts:task/crontab_check_processor.sh" "scripts:task/crontab_task_processor.sh" "scripts:webui/data/generators/apps/webui_config.sh" @@ -2012,6 +2019,9 @@ _artifactRegenAppliedManifest() { source "${install_scripts_dir}cli/commands/art _artifactReplayUndoFile() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactReplayUndoFile "$@"; } _artifactResolve() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactResolve "$@"; } artifactRevert() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; artifactRevert "$@"; } +_artifactRmFile() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactRmFile "$@"; } +_artifactSafeScalar() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactSafeScalar "$@"; } +_artifactWriteFile() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactWriteFile "$@"; } _artifactWriteRecord() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactWriteRecord "$@"; } atomicWriteWebUI() { source "${install_scripts_dir}webui/data/utils/webui_atomic_write.sh"; atomicWriteWebUI "$@"; } authAdapter_adguard_setPassword() { source "${install_containers_dir}adguard/scripts/adguard_auth.sh"; authAdapter_adguard_setPassword "$@"; }