#!/usr/bin/env bash # parse-diagnostic-bundle.sh — summarise a Lumotia diagnostic bundle zip. # Usage: ./scripts/parse-diagnostic-bundle.sh path/to/tester-bundle.zip # Requires: unzip (required), jq (preferred; graceful grep fallback). # Privacy: never prints transcript text, audio content, or .db content. set -euo pipefail BOLD='\033[1m'; GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[0;33m'; RESET='\033[0m' pass() { printf "${GREEN} ✓${RESET} %s\n" "$*"; } fail() { printf "${RED} ×${RESET} %s\n" "$*"; OVERALL_FAIL=1; } warn() { printf "${YELLOW} !${RESET} %s\n" "$*"; } info() { printf " %s\n" "$*"; } header(){ printf "\n${BOLD}%s${RESET}\n" "$*"; } OVERALL_FAIL=0 # Argument + file validation [[ $# -lt 1 ]] && { echo "Usage: $0 path/to/tester-bundle.zip" >&2; exit 1; } BUNDLE="$1"; BUNDLE_NAME="$(basename "$BUNDLE")" [[ ! -f "$BUNDLE" ]] && { echo "ERROR: file not found: $BUNDLE" >&2; exit 2; } if ! file "$BUNDLE" 2>/dev/null | grep -qi 'zip'; then (dd if="$BUNDLE" bs=2 count=2 2>/dev/null | grep -q 'PK') \ || { echo "ERROR: $BUNDLE does not appear to be a zip archive." >&2; exit 2; } fi # jq check HAS_JQ=0 command -v jq &>/dev/null && HAS_JQ=1 \ || echo "WARNING: jq not found — falling back to grep-based extraction. Install jq for best results." >&2 # Extract TMPDIR_WORK="$(mktemp -d)"; trap 'rm -rf "$TMPDIR_WORK"' EXIT unzip -q "$BUNDLE" -d "$TMPDIR_WORK" 2>/dev/null \ || { echo "ERROR: failed to extract $BUNDLE" >&2; exit 3; } # Header TITLE="LUMOTIA DIAGNOSTIC BUNDLE SUMMARY: $BUNDLE_NAME" printf "\n${BOLD}%s${RESET}\n" "$TITLE" printf '%0.s=' $(seq 1 ${#TITLE}); printf '\n' # metadata.json → version + timestamps META="$TMPDIR_WORK/metadata.json"; GEN_AT=""; VERSION="" if [[ -f "$META" ]]; then if [[ $HAS_JQ -eq 1 ]]; then GEN_AT="$(jq -r '.generated_at // empty' "$META" 2>/dev/null)" VERSION="$(jq -r '.lumotia_version // empty' "$META" 2>/dev/null)" else GEN_AT="$(grep -o '"generated_at":[^,}]*' "$META" | grep -o '[0-9]*' | head -1)" VERSION="$(grep -o '"lumotia_version":"[^"]*"' "$META" | cut -d'"' -f4)" fi fi GEN_HUMAN="" if [[ -n "$GEN_AT" && "$GEN_AT" =~ ^[0-9]+$ ]]; then GEN_HUMAN="$(date -u -d "@$GEN_AT" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null \ || date -u -r "$GEN_AT" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null \ || echo "$GEN_AT (epoch)")" fi info "Generated: ${GEN_HUMAN:-unknown}" info "Lumotia version: ${VERSION:-unknown}" # system_info.txt → platform SYSINFO="$TMPDIR_WORK/system_info.txt" if [[ -f "$SYSINFO" ]]; then OS_LINE="$(grep -i '^OS:' "$SYSINFO" | head -1 | sed 's/^OS: *//')" ARCH_LINE="$(grep -i '^Arch:' "$SYSINFO" | head -1 | sed 's/^Arch: *//')" info "Platform: ${OS_LINE:-unknown} / ${ARCH_LINE:-unknown}" fi # Content inventory header "CONTENT INVENTORY:" check_path() { [[ -e "$TMPDIR_WORK/$2" ]] && pass "$1" || warn "$1 (not present)" } check_path "system_info.txt" "system_info.txt" check_path "preferences-redacted.json" "preferences-redacted.json" check_path "metadata.json" "metadata.json" LOG_DIR="$TMPDIR_WORK/logs"; LOG_COUNT=0 if [[ -d "$LOG_DIR" ]]; then LOG_COUNT="$(find "$LOG_DIR" -type f | wc -l)" pass "logs/ ($LOG_COUNT files, $(du -sh "$LOG_DIR" 2>/dev/null | cut -f1))" else warn "logs/ (directory not present)"; fi CRASH_DIR="$TMPDIR_WORK/crashes" if [[ -d "$CRASH_DIR" ]]; then CRASH_COUNT="$(find "$CRASH_DIR" -type f | wc -l)" [[ "$CRASH_COUNT" -eq 0 ]] \ && pass "crashes/ (0 files) — no crash dumps, good" \ || warn "crashes/ ($CRASH_COUNT files) — crash dumps present, review carefully" else pass "crashes/ (directory absent) — no crash dumps, good"; fi # Redaction check — bundle MUST NEVER contain these header "REDACTION CHECK (bundle MUST NEVER include these):" DENY_CLEAN=1 check_absent() { local hits; hits="$(find "$TMPDIR_WORK" -type f | { grep -iE "$2" 2>/dev/null || true; })" [[ -z "$hits" ]] && pass "$1" \ || { fail "$1 — FOUND: $(printf '%s' "$hits" | head -3 | tr '\n' ' ')"; DENY_CLEAN=0; } } check_absent "no .wav files" '\.wav$' check_absent "no .mp3/.opus/.ogg/.flac files" '\.(mp3|opus|ogg|flac)$' check_absent "no transcripts/ paths" '/transcripts/' check_absent "no captures/ paths" '/captures/' check_absent "no .db files" '\.(db|db-wal|db-shm)$' check_absent "no .env files" '(^|/)\.env(\.|$)' if [[ $DENY_CLEAN -eq 1 ]]; then printf "\n${GREEN} PASS: bundle passed redaction check.${RESET}\n" else printf "\n${RED} FAIL: bundle contains DENIED content — do not share this bundle.${RESET}\n" OVERALL_FAIL=1 fi # Log analysis header "LOG ANALYSIS (last 7 days):" if [[ -d "$LOG_DIR" && "$LOG_COUNT" -gt 0 ]]; then ALL_LOGS="$(find "$LOG_DIR" -type f -exec cat {} \;)" ERR_COUNT="$(printf '%s' "$ALL_LOGS" | { grep -iE '(ERROR|ERRO|\bERR\b)' 2>/dev/null || true; } | wc -l)" WARN_COUNT="$(printf '%s' "$ALL_LOGS" | { grep -iE '(WARN|WARNING)' 2>/dev/null || true; } | wc -l)" info "Errors: $ERR_COUNT" info "Warnings: $WARN_COUNT" if [[ "$ERR_COUNT" -gt 0 ]]; then info ""; info "TOP 3 ERROR PATTERNS:" printf '%s' "$ALL_LOGS" \ | { grep -iE '(ERROR|ERRO|\bERR\b)' || true; } \ | sed 's/^[0-9TZ:. -]*//' | sort | uniq -c | sort -rn | head -3 \ | while IFS= read -r line; do info " × $line"; done fi else warn "No log files found."; fi # Preferences (non-secret values) PREFS="$TMPDIR_WORK/preferences-redacted.json" header "PREFERENCES (redacted secrets):" if [[ ! -f "$PREFS" ]]; then warn "preferences-redacted.json not found" elif [[ $HAS_JQ -eq 1 ]]; then jq -r 'paths(scalars) as $p | getpath($p) | select(. != "[redacted]" and . != null and . != "") | "\($p | join(".")): \(.)"' "$PREFS" 2>/dev/null | head -30 \ | while IFS= read -r line; do info " $line"; done REDACTED_COUNT="$(jq '[.. | strings | select(. == "[redacted]")] | length' "$PREFS" 2>/dev/null || echo '?')" info " ($REDACTED_COUNT field(s) redacted by bundler)" else { grep -oE '"[^"]+": *("[^"]*"|[0-9.]+|true|false)' "$PREFS" || true; } \ | grep -v '\[redacted\]' | head -30 | while IFS= read -r line; do info " $line"; done fi # Verdict header "VERDICT:" if [[ $OVERALL_FAIL -eq 0 ]]; then printf "${GREEN} PASS: bundle looks healthy. Safe to inspect for bug triage.${RESET}\n\n" else printf "${RED} FAIL: one or more checks failed — review items marked × above.${RESET}\n\n" exit 1 fi