#!/bin/bash
# ---------------------------------------------------------------------------
# nginx-header-diff.sh
#
# Vergleicht die add_header-Direktiven einer NGINX-.conf-Datei mit dem
# effektiven Header-Satz einer Include-Datei (Includes werden rekursiv
# aufgeloest) und meldet je Block, was doppelt, was abweichend und was nur
# in der .conf steht.
#
# Aufruf:
#   nginx-header-diff.sh [-p PREFIX] [-d DIREKTIVE] <datei.conf> <datei.inc>
#
#   -p PREFIX     Basisverzeichnis fuer relative include-Pfade.
#                 Vorgabe: /etc/nginx
#   -d DIREKTIVE  Zusaetzlich zu beruecksichtigende Direktive, mehrfach
#                 angebbar.  Vorgabe: nur add_header.
#                 Sinnvoll z.B. -d proxy_hide_header.
#
# Ausgabeformat der Fundstellen entspricht grep -n:  datei:zeile: text
#
# Markierungen:
#   [DUPLIKAT]  Name und Wert identisch mit dem Include  -> Zeile loeschen
#   [KONFLIKT]  Name gleich, Wert abweichend             -> entscheiden
#   [NUR-CONF]  im Include nicht enthalten               -> Zeile behalten
#
# Grenzen:
#   - Vollzeilen-Kommentare werden ignoriert, ebenso Kommentare am
#     Zeilenende ausserhalb von Anfuehrungszeichen.
#   - Direktiven ueber mehrere Zeilen werden zusammengefasst, solange die
#     Fortsetzungszeilen kein ; { } enthalten.
#   - Geschweifte Klammern innerhalb von Direktiv-Werten (z.B. in
#     log_format) koennen die Blockerkennung stoeren.
#   - include-Pfade mit Platzhaltern (conf.d/*.inc) werden nicht aufgeloest.
# ---------------------------------------------------------------------------

set -u

prefix=/etc/nginx
directives=(add_header)

usage() {
    sed -n '3,30p' "$0" | sed 's/^# \{0,1\}//'
    exit "${1:-1}"
}

while getopts ':p:d:h' opt; do
    case "$opt" in
        p) prefix=$OPTARG ;;
        d) directives+=("$OPTARG") ;;
        h) usage 0 ;;
        :) echo "Fehlendes Argument zu -$OPTARG" >&2; exit 2 ;;
        \?) echo "Unbekannte Option -$OPTARG" >&2; exit 2 ;;
    esac
done
shift $((OPTIND - 1))

[ $# -eq 2 ] || usage 2

conf=$1
inc=$2

for f in "$conf" "$inc"; do
    [ -r "$f" ] || { echo "Nicht lesbar: $f" >&2; exit 2; }
done

dirlist=$(IFS='|'; echo "${directives[*]}")

# ---------------------------------------------------------------------------
# Gemeinsamer Scanner.  Gibt je logischer Direktive eine Zeile aus:
#   D <tab> startzeile <tab> direktive <tab> rest
#   O <tab> startzeile <tab> blocktext        (Blockanfang)
#   C <tab> startzeile                        (Blockende)
# ---------------------------------------------------------------------------
scan_awk='
function trim(s) { gsub(/^[ \t\r]+|[ \t\r]+$/, "", s); gsub(/[ \t]+/, " ", s); return s }

function emit(stmt,   d, rest) {
    stmt = trim(stmt)
    if (stmt == "") return
    d = stmt
    sub(/[ \t].*$/, "", d)
    rest = stmt
    sub(/^[^ \t]+[ \t]*/, "", rest)
    printf "D\t%d\t%s\t%s\n", startLine, d, trim(rest)
}

# Zeichenweise, damit ; { } innerhalb von Anfuehrungszeichen die Direktive
# nicht zerreissen (z.B. "max-age=63072000; includeSubDomains").
{
    len = length($0)
    for (i = 1; i <= len; i++) {
        c = substr($0, i, 1)

        if (q != "") {                      # innerhalb eines Strings
            buf = buf c
            if (c == q) q = ""
            continue
        }

        if (c == "#") break                 # Rest der Zeile ist Kommentar

        if (c == "\"" || c == "\x27") {
            if (trim(buf) == "") startLine = NR
            buf = buf c; q = c
            continue
        }

        if (c == ";") { emit(buf); buf = ""; continue }

        if (c == "{") {
            printf "O\t%d\t%s\n", (trim(buf) == "" ? NR : startLine), trim(buf)
            buf = ""
            continue
        }

        if (c == "}") {
            if (trim(buf) != "") emit(buf)
            printf "C\t%d\n", NR
            buf = ""
            continue
        }

        if (trim(buf) == "") {
            if (c == " " || c == "\t" || c == "\r") continue
            startLine = NR
        }
        buf = buf c
    }
    buf = buf " "                           # Zeilenumbruch als Worttrenner
}
END { emit(buf) }
'

# ---------------------------------------------------------------------------
# Header-Wert normalisieren: Anfuehrungszeichen entfernen, Whitespace
# zusammenfassen, "always" als eigenes Merkmal fuehren.
# ---------------------------------------------------------------------------
norm_awk='
function normValue(v,   n, i, tok, out, always, arr) {
    always = 0
    if (v ~ /[ \t]always$/ || v == "always") { always = 1; sub(/[ \t]*always$/, "", v) }
    gsub(/^[ \t]+|[ \t]+$/, "", v)
    n = split(v, arr, /[ \t]+/)
    out = ""
    for (i = 1; i <= n; i++) {
        tok = arr[i]
        gsub(/^["\x27]|["\x27]$/, "", tok)
        out = out (out == "" ? "" : " ") tok
    }
    gsub(/[ \t]*;[ \t]*/, "; ", out)
    gsub(/[ \t]+/, " ", out)
    return out (always ? " +always" : "")
}
'

# ---------------------------------------------------------------------------
# Includes rekursiv aufloesen und die effektiven Header einsammeln.
# ---------------------------------------------------------------------------
tmp=$(mktemp -d) || exit 1
trap 'rm -rf "$tmp"' EXIT

seen_file=$tmp/seen
: >"$seen_file"
: >"$tmp/inc-headers"

resolve() {
    local file=$1 depth=$2
    local real
    real=$(readlink -f -- "$file" 2>/dev/null || echo "$file")

    if grep -qxF -- "$real" "$seen_file"; then
        echo "Warnung: Include-Zyklus bei $file uebersprungen" >&2
        return
    fi
    echo "$real" >>"$seen_file"

    if [ ! -r "$file" ]; then
        echo "Warnung: Include nicht lesbar: $file" >&2
        return
    fi
    if [ "$depth" -gt 16 ]; then
        echo "Warnung: Include-Tiefe > 16 bei $file" >&2
        return
    fi

    local type line directive rest target
    while IFS=$'\t' read -r type line directive rest; do
        [ "$type" = "D" ] || continue
        case "$directive" in
            include)
                target=${rest%;}
                target=$(printf '%s' "$target" | tr -d "\"'" )
                target=$(printf '%s' "$target" | sed 's/[[:space:]]*$//')
                case "$target" in
                    /*) : ;;
                    *)  target=$prefix/$target ;;
                esac
                resolve "$target" $((depth + 1))
                ;;
            *)
                printf '%s\t%s\t%s\t%s\n' "$directive" "$rest" "$file" "$line" \
                    >>"$tmp/inc-headers"
                ;;
        esac
    done < <(awk "$scan_awk" "$file")
}

resolve "$inc" 0

# Nur die interessierenden Direktiven behalten und normalisieren.
awk -F'\t' -v dirs="$dirlist" "$norm_awk"'
BEGIN { n = split(dirs, d, "|"); for (i = 1; i <= n; i++) want[d[i]] = 1 }
{
    if (!($1 in want)) next
    rest = $2
    name = rest
    sub(/[ \t].*$/, "", name)
    value = rest
    sub(/^[^ \t]+[ \t]*/, "", value)
    # Feld 6: Originalschreibweise wie in der Datei, nur Whitespace normiert
    printf "%s\t%s\t%s\t%s\t%s\t%s\n", $1, tolower(name), normValue(value), $3, $4, rest
}' "$tmp/inc-headers" >"$tmp/inc-norm"

# ---------------------------------------------------------------------------
# Bericht
# ---------------------------------------------------------------------------
echo "=== Effektiver Header-Satz aus $inc ==="
if [ ! -s "$tmp/inc-norm" ]; then
    echo "  (keine passenden Direktiven gefunden)"
else
    awk -F'\t' '{ printf "  %-66s  [%s:%s]\n", $1 " " $6 ";", $4, $5 }' \
        "$tmp/inc-norm"
fi
echo

awk "$scan_awk" "$conf" \
| awk -F'\t' -v conf="$conf" -v inc="$inc" -v dirs="$dirlist" \
      -v incfile="$tmp/inc-norm" "$norm_awk"'
BEGIN {
    n = split(dirs, d, "|"); for (i = 1; i <= n; i++) want[d[i]] = 1

    while ((getline l < incfile) > 0) {
        split(l, f, "\t")
        key = f[1] "\t" f[2]
        incName[key] = 1
        incPair[key "\t" f[3]] = 1
        # Anzeige in der Originalschreibweise der Include-Datei
        incVals[key] = incVals[key] (incVals[key] == "" ? "" : " | ") f[1] " " f[6] ";"
    }
    close(incfile)

    # Basisname der Include-Datei fuer den Abgleich der include-Zeilen
    incBase = inc
    sub(/^.*\//, "", incBase)

    depth = 0
    blockCount = 0
}

function blockLabel(i) { return blockText[i] }

$1 == "O" {
    depth++
    blockCount++
    stack[depth] = blockCount
    blockText[blockCount] = ($3 == "" ? "(anonym)" : $3)
    blockLine[blockCount] = $2
    blockDepth[blockCount] = depth
    next
}

$1 == "C" {
    if (depth > 0) depth--
    next
}

$1 == "D" {
    cur = (depth > 0 ? stack[depth] : 0)
    if (cur == 0 && blockText[0] == "") {
        blockText[0] = "(Dateiebene)"
        blockLine[0] = 1
        blockDepth[0] = 0
    }

    if ($3 == "include") {
        t = $4
        sub(/[ \t]*;?[ \t]*$/, "", t)
        gsub(/["\x27]/, "", t)
        b = t
        sub(/^.*\//, "", b)
        if (b == incBase) hasInc[cur] = 1
        else otherInc[cur] = otherInc[cur] (otherInc[cur] == "" ? "" : ", ") b
        next
    }

    if (!($3 in want)) next

    rest = $4
    sub(/[ \t]*;[ \t]*$/, "", rest)
    name = rest
    sub(/[ \t].*$/, "", name)
    value = rest
    sub(/^[^ \t]+[ \t]*/, "", value)

    lname = tolower(name)
    key   = $3 "\t" lname
    nv    = normValue(value)

    if ((key "\t" nv) in incPair)      mark = "[DUPLIKAT]"
    else if (key in incName)           mark = "[KONFLIKT]"
    else                               mark = "[NUR-CONF]"

    hdrCount[cur]++
    i = hdrCount[cur]
    hLine[cur, i] = $2
    hText[cur, i] = $3 " " rest ";"
    hMark[cur, i] = mark
    hAlt [cur, i] = (mark == "[KONFLIKT]" ? incVals[key] : "")

    if (mark == "[DUPLIKAT]") nDup++
    else if (mark == "[KONFLIKT]") nCon++
    else nOnly++
    next
}

END {
    print "=== " conf " ==="
    print ""

    any = 0
    for (b = 0; b <= blockCount; b++) {
        if (hdrCount[b] == 0) continue
        any = 1

        indent = ""
        for (k = 1; k < blockDepth[b]; k++) indent = indent "  "

        printf "%s%s   (Zeile %s)\n", indent, blockLabel(b), blockLine[b]
        if (hasInc[b])
            printf "%s  include %s: vorhanden\n", indent, incBase
        else
            printf "%s  include %s: FEHLT -> einfuegen\n", indent, incBase
        if (otherInc[b] != "")
            printf "%s  weitere includes: %s\n", indent, otherInc[b]

        for (i = 1; i <= hdrCount[b]; i++) {
            printf "%s  %s:%s: %s  %s\n", indent, conf, hLine[b, i], hText[b, i], hMark[b, i]
            if (hAlt[b, i] != "")
                printf "%s      Include hat: %s\n", indent, hAlt[b, i]
        }
        print ""
    }

    if (!any) print "(keine passenden Direktiven gefunden)"

    print "---"
    printf "DUPLIKAT %d   KONFLIKT %d   NUR-CONF %d\n", nDup+0, nCon+0, nOnly+0
    if (nCon > 0)
        print "Hinweis: KONFLIKT-Zeilen von Hand entscheiden, bevor etwas geloescht wird."
}
'
