#!/bin/sh
# script/lint: run the linter.
#
# The linter always runs at the version pinned by the Dockerfile's lint
# stage, so a local run and a CI run of the same tree cannot disagree.
# That FROM line (image tag plus digest) is the single source of truth
# for the linter version in this repo: bump it there and nothing else
# needs editing.
#
# Normally that means running the pinned image with docker. The one
# exception is running INSIDE that image: the Dockerfile's lint stage
# runs `make lint`, and there is no docker daemon in there. That stage
# sets VAULTIK_LINT_IN_CONTAINER=1, and only when that variable is set
# is a golangci-lint on PATH used directly - and then only if its
# version is exactly the pin. Version equality alone is deliberately NOT
# enough: it also matches a developer's locally installed copy of the
# same version, which is a different build with a different Go
# toolchain, reached by a different code path, and it would bypass the
# digest pin this script exists to enforce. /.dockerenv was considered
# as the context signal and rejected: dockerd creates it for `docker
# run`, but it is not reliably present during a BuildKit `docker build`,
# which is exactly the case the exception exists for.
#
# The linter's output is checked before it is believed: every run is
# audited by script/lint-audit for findings that cannot belong to this
# tree, and a run refused by golangci-lint's cross-process lock is
# retried rather than reported as a verdict. See the lock-retry loop in
# main and the header of script/lint-audit.
#
# Extra arguments are passed through to `golangci-lint run`, before
# `./...` (see script/lint-fix).
set -eu

ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
DOCKERFILE="$ROOT/Dockerfile"

# golangci-lint takes a cross-process lock and refuses to start while
# another instance holds it. That refusal is not a lint result, and
# exiting non-zero on it is indistinguishable to a caller from real
# findings - so it is retried rather than reported. Bounded, because a
# lock that is never released must fail rather than hang.
LOCK_MESSAGE="parallel golangci-lint is running"
LOCK_ATTEMPTS=6
LOCK_SLEEP=15

# The image reference of the Dockerfile's lint stage, tag and digest
# included, e.g.
# golangci/golangci-lint:v2.12.2-alpine@sha256:91b2...
lint_image() {
    awk '$1 == "FROM" && $3 == "AS" && $4 == "lint" { print $2; exit }' \
        "$DOCKERFILE"
}

# The bare version that image reference pins, e.g. 2.12.2
pinned_version() {
    lint_image | sed -e 's/@.*//' -e 's/.*://' -e 's/^v//' -e 's/-.*//'
}

# The version of the golangci-lint on PATH, if any, e.g. 2.12.2
#
# `version --short` prints the bare version and is the interface meant
# for this (checked against 2.10.1 and 2.12.2). The banner scrape below
# it is a fallback for a release where --short is absent or silent; the
# banner's exact wording is not a stable interface, which is why it is
# no longer the primary parse.
installed_version() {
    command -v golangci-lint >/dev/null 2>&1 || return 0

    short="$(golangci-lint version --short 2>/dev/null |
        tr -d '[:space:]' | sed -e 's/^v//')"
    case "$short" in
        *[0-9].[0-9]*.[0-9]*)
            echo "$short"
            return 0
            ;;
    esac

    golangci-lint version 2>/dev/null | awk '
        {
            for (i = 1; i <= NF; i++) {
                if ($i ~ /^[0-9]+\.[0-9]+\.[0-9]+$/) {
                    print $i
                    exit
                }
            }
        }'
}

# True inside the Dockerfile's lint stage, which sets this. Nothing else
# sets it: setting it by hand on a host is an explicit, visible decision
# to lint with an unpinned binary, not something reached by accident.
in_lint_container() {
    [ "${VAULTIK_LINT_IN_CONTAINER:-}" = "1" ]
}

require_docker() {
    image="$1"
    if ! command -v docker >/dev/null 2>&1; then
        cat >&2 <<EOF
lint: docker is required to run the pinned linter.

  pinned image: $image

Install docker. Linting with any other golangci-lint is not supported:
it is what lets a local run pass while CI fails. An installed
golangci-lint on PATH is not used, whatever its version; only the lint
stage of the Dockerfile itself runs the linter natively.
EOF
        exit 1
    fi
    if ! docker info >/dev/null 2>&1; then
        cat >&2 <<EOF
lint: the docker daemon is not reachable, so the pinned linter cannot
run.

  pinned image: $image

Start the daemon (and check DOCKER_HOST / your group membership). This
script will not fall back to a different linter version or to an
unpinned binary on PATH.
EOF
        exit 1
    fi
}

# Where the per-worktree caches live.
cache_home() {
    echo "${XDG_CACHE_HOME:-${HOME:-/tmp}/.cache}/vaultik-lint"
}

# A short, stable digest of this worktree's path.
path_digest() {
    if command -v sha256sum >/dev/null 2>&1; then
        printf '%s' "$ROOT" | sha256sum | cut -c1-12
    elif command -v shasum >/dev/null 2>&1; then
        printf '%s' "$ROOT" | shasum -a 256 | cut -c1-12
    else
        printf '%s' "$ROOT" | cksum | tr -cd '0-9' | cut -c1-12
    fi
}

# Caches for the containerized linter, private to THIS worktree.
#
# Keeping them out of the repo and persisting them between runs is what
# keeps the inner loop fast: a warm run costs about the same as a native
# one plus container startup. Keeping them keyed on the worktree path is
# what keeps them correct. One shared cache for the whole repo was the
# defect in issue #99: two worktrees of this repo have identical file
# contents, so their cache keys collide, and golangci-lint replays the
# stored results - including the file paths recorded when they were
# produced. That silently reports one worktree's findings, or one
# worktree's clean bill of health, for another.
cache_dir() {
    slug="$(printf '%s' "$(basename "$ROOT")" | tr -c 'A-Za-z0-9._-' '-')"
    echo "$(cache_home)/$slug-$(path_digest)"
}

# One cache per worktree means throwaway worktrees would otherwise leave
# caches behind forever. Each cache records the worktree it belongs to,
# and any cache whose worktree no longer exists is collected here, so
# growth is bounded by the number of worktrees that actually exist. The
# whole tree also sits under XDG_CACHE_HOME (~/.cache by default), so it
# is disposable by definition: `rm -rf "${XDG_CACHE_HOME:-~/.cache}/vaultik-lint"`
# costs nothing but the next run's cold cache.
prune_dead_caches() {
    home="$(cache_home)"
    if [ ! -d "$home" ]; then
        return 0
    fi
    for dir in "$home"/*; do
        if [ ! -f "$dir/worktree" ]; then
            continue
        fi
        owner="$(cat "$dir/worktree")"
        if [ -z "$owner" ]; then
            continue
        fi
        if [ ! -d "$owner" ]; then
            # The Go module cache inside is deliberately read-only, and
            # rm(1) cannot unlink a file out of a directory it may not
            # write, so the tree has to be made writable first. And
            # failing to tidy up is a housekeeping problem, never a
            # reason to fail a lint: without the fallback below, `set
            # -e` turns a stale cache that will not delete into a lint
            # error, which is a gate failing for a reason that has
            # nothing to do with the code. (Observed, not theorised.)
            chmod -R u+w "$dir" 2>/dev/null || true
            if ! rm -rf "$dir" 2>/dev/null; then
                # Restore the marker on a partial removal: an
                # unmarked leftover would be skipped by every future
                # run and never collected.
                mkdir -p "$dir" 2>/dev/null || true
                echo "$owner" >"$dir/worktree" 2>/dev/null || true
                echo "lint: could not remove stale cache $dir" >&2
            fi
        fi
    done
}

prepare_cache() {
    cache="$1"
    mkdir -p "$cache/go-build" "$cache/go-mod" "$cache/golangci-lint"
    echo "$ROOT" >"$cache/worktree"
}

# Run the linter, wherever it is that this script is allowed to run it.
run_linter() {
    if in_lint_container; then
        golangci-lint run "$@" ./...
        return $?
    fi

    docker run --rm \
        --user "$(id -u):$(id -g)" \
        --env HOME=/tmp \
        --env GOFLAGS=-buildvcs=false \
        --env GOCACHE=/cache/go-build \
        --env GOMODCACHE=/cache/go-mod \
        --env GOLANGCI_LINT_CACHE=/cache/golangci-lint \
        --volume "$ROOT:/src" \
        --volume "$CACHE:/cache" \
        --workdir /src \
        "$IMAGE" \
        golangci-lint run "$@" ./...
}

# Run the linter, streaming its combined output while also capturing it,
# and hand back its exit status. The output has to be inspected before
# it is believed, which is why this script no longer just execs the
# linter. `tee` would swallow the status, so it is smuggled out through
# a file: there is no pipefail in POSIX sh.
run_capture() {
    capture="$1"
    shift
    rm -f "$capture.status"
    {
        rc=0
        # `set -e` is in force inside this subshell too, so the status
        # has to be caught here: an unguarded non-zero exit (which is
        # what "the linter found something" looks like) would abort the
        # subshell before the status was ever written.
        run_linter "$@" 2>&1 || rc=$?
        echo "$rc" >"$capture.status"
    } | tee "$capture"

    if [ ! -s "$capture.status" ]; then
        echo "lint: the linter did not report an exit status" >&2
        exit 1
    fi
    read -r captured_status <"$capture.status"
    rm -f "$capture.status"
    return "$captured_status"
}

# Reject output that cannot describe this tree. See script/lint-audit
# for what that means and why: in short, a finding citing a file that is
# not here means the result being reported was produced somewhere else,
# and a PASS built out of another checkout's analysis is silent (issue
# #99). The audit therefore runs on clean output as well.
audit_output() {
    capture="$1"
    if ! "$ROOT/script/lint-audit" "$capture"; then
        if [ -n "$CACHE" ]; then
            echo "  this tree's lint cache: $CACHE" >&2
        fi
        exit 1
    fi
}

main() {
    cd "$ROOT"

    IMAGE="$(lint_image)"
    if [ -z "$IMAGE" ]; then
        echo "lint: no lint stage found in $DOCKERFILE" >&2
        exit 1
    fi

    CACHE=""
    if in_lint_container; then
        # No docker daemon in here, so there is no fallback: a mismatch
        # is a hard error rather than a quiet substitution.
        installed="$(installed_version)"
        pinned="$(pinned_version)"
        if [ -z "$installed" ] || [ "$installed" != "$pinned" ]; then
            cat >&2 <<EOF
lint: VAULTIK_LINT_IN_CONTAINER is set, so this is expected to be
running inside the Dockerfile's pinned lint image, but the golangci-lint
on PATH does not match the pin.

  pinned:    $pinned ($IMAGE)
  installed: ${installed:-<none>}
EOF
            exit 1
        fi
    else
        require_docker "$IMAGE"
        prune_dead_caches
        CACHE="$(cache_dir)"
        prepare_cache "$CACHE"
    fi

    capture="$(mktemp "${TMPDIR:-/tmp}/vaultik-lint.XXXXXX")"
    trap 'rm -f "$capture" "$capture.status"' EXIT HUP INT TERM

    attempt=1
    while :; do
        status=0
        run_capture "$capture" "$@" || status=$?

        if grep -Fq "$LOCK_MESSAGE" "$capture"; then
            if [ "$attempt" -lt "$LOCK_ATTEMPTS" ]; then
                echo "lint: another golangci-lint holds the lock;" \
                    "retrying in ${LOCK_SLEEP}s" \
                    "(attempt $attempt of $LOCK_ATTEMPTS)" >&2
                sleep "$LOCK_SLEEP"
                attempt=$((attempt + 1))
                continue
            fi
            cat >&2 <<EOF

lint: gave up after $LOCK_ATTEMPTS attempts, each blocked by another
golangci-lint holding the cross-process lock. This is NOT a lint
verdict: the tree was never analysed. Re-run when the other run has
finished.
EOF
            exit 1
        fi

        audit_output "$capture"
        exit "$status"
    done
}

main "$@"
