#!/bin/bash
#
# LibrePortal artifact-index helpers — the READ side of the unified distribution
# primitive (see docs/roadmap/updates-and-distribution.md).
#
# An "artifact" is anything LibrePortal pulls from the outside and applies
# reversibly: a HOTFIX today; apps / themes / components later. They all share
# ONE team-signed catalog — the INDEX — published in the SAME release tree as
# latest.json:   $base/$channel/index.json   (+ index.json.minisig).
#
# This file is PHASE 1 of that primitive: fetch + verify + parse the index. It
# performs NO mutation. The apply pipeline (snapshot → declarative ops → verify →
# auto-rollback → History) is Phase 2 (scripts/cli/commands/artifact). Keeping
# the read side here means the trust core is testable on its own and the WebUI
# scan can surface "available artifacts" before any apply machinery exists.
#
# Trust chain — fail-closed at every step once the footprint key is real:
#   footprint pubkey --signs--> index.json --lists--> per-artifact {sha256, sig}
# Verification reuses lpVerifyMinisig (fetch.sh) — the EXACT anchor the release
# fetch uses — so the manager can't bless a forged catalog any more than a forged
# release. Two transparency guarantees, both jq-free so the trust core never
# depends on jq being present:
#   valid_until   — refuse a stale/withheld feed. A signed feed that simply stops
#                   advancing is the silent-withholding / targeting attack the
#                   warrant-canary model exists to defeat; treat a frozen feed as
#                   a signal, not as "no updates".
#   index_serial  — monotonic counter; refuse a serial below the highest we have
#                   already accepted (a rollback that re-introduces a pulled or
#                   again-vulnerable entry).

# The index sits next to latest.json on the same channel; reuse those resolvers
# (lpReleaseBaseUrl/lpReleaseChannel live in fetch.sh).
lpArtifactIndexUrl() { echo "$(lpReleaseBaseUrl)/$(lpReleaseChannel)/index.json"; }

# Runtime-owned high-water mark for index_serial (the anti-rollback anchor). It
# lives alongside the other generated updater data so it ships/clears with that
# state; the dir is in the container tree, so writes go through the container
# funnel. Reads are fine as any user (world-readable).
lpArtifactSerialFile() { echo "${containers_dir%/}/libreportal/frontend/data/updater/generated/.index_serial"; }
lpArtifactLastSerial() { local v; v=$(cat "$(lpArtifactSerialFile)" 2>/dev/null | tr -dc '0-9'); echo "${v:-0}"; }
lpArtifactRecordSerial() {
    local serial="$1" f; f="$(lpArtifactSerialFile)"
    [[ "$serial" =~ ^[0-9]+$ ]] || return 0
    runFileOp mkdir -p "$(dirname "$f")" 2>/dev/null || true
    printf '%s\n' "$serial" | runFileWrite "$f"
}

# Fetch + verify the signed artifact index.
#   $1 (optional): also cache the verified JSON to this path (for the WebUI scan).
# 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"
    # Silence the downloader's own stderr (curl's "could not resolve host" / 404
    # noise) — the caller's clean error message covers the failure. Consistent
    # with the .minisig fetch below.
    if ! _lpDownload "$base/$channel/index.json" "$idx" 2>/dev/null; then
        isError "lpFetchIndex: could not download the artifact index"; rm -rf "$tmp"; return 1
    fi

    # 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) and
    # echoes the resulting state, which we record for the apply-path gate.
    _lpDownload "$base/$channel/index.json.minisig" "$sig" 2>/dev/null || true
    LP_INDEX_SIGSTATE="$(lpVerifyMinisig "$idx" "$sig")" || { rm -rf "$tmp"; return 1; }

    json="$(cat "$idx")"

    # 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)"
    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 — 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 (( serial < last )); then
        isError "lpFetchIndex: index_serial $serial below last-seen $last (rollback) — refusing"; rm -rf "$tmp"; return 1
    fi
    lpArtifactRecordSerial "$serial"

    [[ -n "$cache" ]] && printf '%s' "$json" | runFileWrite "$cache"
    printf '%s' "$json"
    rm -rf "$tmp"
    return 0
}

# lpFetchIndexInto <varname> [cache_path] — lpFetchIndex run IN THIS SHELL so
# the LP_INDEX_SIGSTATE global actually reaches the caller. A plain
# var="$(lpFetchIndex)" capture strands that assignment in the substitution's
# subshell: the caller then reads the file-scope "" and the apply-path gate
# refuses even a correctly signed index. Any caller that inspects
# LP_INDEX_SIGSTATE after fetching MUST use this wrapper, not $(…).
lpFetchIndexInto() {
    local __lpfi_var="$1" __lpfi_tmp __lpfi_rc=0
    __lpfi_tmp="$(mktemp)"
    lpFetchIndex "${2:-}" > "$__lpfi_tmp" || __lpfi_rc=$?
    if (( __lpfi_rc == 0 )); then
        printf -v "$__lpfi_var" '%s' "$(cat "$__lpfi_tmp")"
    fi
    rm -f "$__lpfi_tmp"
    return "$__lpfi_rc"
}

# --- Parsing accessors -------------------------------------------------------
# The trust-critical fields (index_serial / valid_until / signature) are read
# jq-free above so the security core has no jq dependency. Enumerating the
# artifacts ARRAY for display is best-effort: jq when present (the runtime path
# has it — updaterRecordHistory already relies on it), with a flat grep fallback.

lpIndexTop() { _lpJsonStr "$2" "$1"; }   # lpIndexTop <field> <json>  -> top-level scalar

lpIndexArtifactIds() {                   # echo one artifact id per line
    local json="$1"
    if command -v jq >/dev/null 2>&1; then
        printf '%s' "$json" | jq -r '.artifacts[]?.id // empty' 2>/dev/null
        return 0
    fi
    printf '%s' "$json" | grep -oE '"id"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -E 's/.*"([^"]*)"$/\1/'
}

lpArtifactById() {                       # lpArtifactById <json> <id> -> the artifact object (jq only)
    local json="$1" id="$2"
    command -v jq >/dev/null 2>&1 || return 1
    printf '%s' "$json" | jq -ce --arg id "$id" '.artifacts[]? | select(.id==$id)' 2>/dev/null
}
