#!/usr/bin/env bash
# IMPORTANT: do not invoke this via `sh`/`dash`. The shebang pins bash so that
# `set -euo pipefail` and `set -o pipefail` are recognized and command errors
# fail the gate. Running it under a different shell silently drops the
# pipefail guarantee, masking advisory exits.
#
# Probe the real interpreter early and refuse to run if the caller used a
# different shell (e.g. `sh script/cpan-audit-project` from CI).
if [ -z "${BASH_VERSION:-}" ]; then
    printf '%s\n' 'cpan-audit-project must be invoked through bash; current shell is not bash' >&2
    exit 2
fi
set -euo pipefail

if [ "$#" -ne 1 ] || [ ! -d "$1" ]; then
  printf 'Usage: %s <isolated-perl5-library-root>\n' "$0" >&2
  exit 2
fi

repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
perl5_root="$(CDPATH= cd -- "$1" && pwd)"
exclusions="$repo_root/cpan-audit-exclusions.txt"

# ISOLATION PRECONDITION (DD-499).
#
# This gate asks "is the set of distributions installed in this root
# vulnerable?" - a question that is only ABOUT THE PRODUCT when the root holds
# the product's dependencies and nothing else. Pointed at a shared CPAN tree it
# answers faithfully about the tree and misleadingly about the product: on
# 2026-08-09 `$HOME/perl5/lib/perl5` produced exit 88 and twenty-four advisories
# across seven distributions, not one of which was a declared runtime
# dependency. Three rounds read that as a release blocker before anyone checked
# what it had measured, and one filed a ticket against it.
#
# So the subject is stated out loud, and a root that is not an isolated product
# root is REFUSED rather than audited. The refusal is deliberately a distinct
# exit status: 0 clean, 1 a disposition guard fired, 2 the caller made a usage
# error, 3 the gate was pointed at the wrong subject, 4 the gate could not run at
# all, 5 advisories were found. Collapsing "wrong subject" into "finding" is what
# made the original misreading so easy.
#
# Status 4 exists because the opposite collapse was also live (DD-517). When
# cpan-audit cannot start - a mixed-version library tree, a missing dependency -
# perl dies with status 1 or 2, and 1 is this gate's OWN code for a disposition
# guard. A tool that never ran was therefore indistinguishable from a real
# finding and from a fired guard, and the suite's "gate is non-zero for a
# vulnerable fixture" test passed on the strength of a crash. A gate that cannot
# look must never be mistaken for a gate that looked and found something.
#
# "Inside the checkout" is the test because that is what an isolated root IS
# here - CI builds one at local/lib/perl5 and audits it in place. It is a crisp
# check with no false positive against that path, which matters: a guard that
# red-lines CI would be worse than the misreading it prevents. A genuinely
# isolated root built elsewhere stays auditable through the opt-in below, which
# is explicit on purpose - the default has to be refusal, or the habit that
# caused DD-499 simply continues.
printf 'auditing library root: %s\n' "$perl5_root"
if [ "${DD_CPAN_AUDIT_ALLOW_EXTERNAL_ROOT:-}" != '1' ] \
  && [ "$perl5_root" != "$repo_root" ] \
  && case "$perl5_root" in "$repo_root"/*) false ;; *) true ;; esac; then
  cat >&2 <<EOF
$perl5_root is not an isolated product library root: it lies outside the
repository working tree $repo_root, so it is a shared CPAN tree carrying
whatever else has been installed on this machine. Advisories found there are
not this product's exposure, and reporting them as such is how DD-499 was
filed against a product whose real CVE position was clean.

To audit the product against a shared tree, use the gate built for that
question, which walks the transitive runtime closure of the declared chain and
ignores everything outside it:

    script/cpan-audit-declared-chain "$perl5_root"

To audit an isolated root, build one and point this gate at it, as CI does:

    cpanm --installdeps --local-lib-contained local .
    script/cpan-audit-project local/lib/perl5

If this root really is isolated and merely lives outside the checkout, say so
explicitly:

    DD_CPAN_AUDIT_ALLOW_EXTERNAL_ROOT=1 script/cpan-audit-project "$perl5_root"
EOF
  exit 3
fi

if grep -R -n -E "Plack::Middleware::XSendfile|(enable|enable_if)[[:space:]\(]+(['\"](Plack::Middleware::)?XSendfile['\"]|XSendfile([[:space:];,)]+|$))|X-Sendfile-Type|X-Accel-Mapping" \
  "$repo_root/app.psgi" "$repo_root/bin" "$repo_root/lib" "$repo_root/share"; then
  printf '%s\n' 'CPANSA-Plack-2026-7381 exclusion invalid: XSendfile middleware is activated by production code' >&2
  exit 1
fi

if grep -R -n -E 'File::Temp[[:space:]]*(->|::)[[:space:]]*safe_level[[:space:]]*\(' \
  "$repo_root/app.psgi" "$repo_root/bin" "$repo_root/lib" "$repo_root/share"; then
  printf '%s\n' 'CPANSA-File-Temp-2011-4116 exclusion invalid: File::Temp vulnerable safety checks are activated by production code' >&2
  exit 1
fi

# Dancer2 sessions are reached through the DSL keyword, not a method call, so an
# arrow-anchored pattern would have missed every real use of them. The keyword
# forms are anchored to statement position instead: unanchored `session '` also
# matched POD prose and tmux session strings, which would have made this guard
# fire on a tree that never touches Dancer2 sessions at all.
if grep -R -n -E "Dancer2::Session|^[[:space:]]*session[[:space:]]*[('\"]|=[[:space:]]*session[[:space:]]*\(|^[[:space:]]*set[[:space:]]+'?session'?[[:space:]]" \
  "$repo_root/app.psgi" "$repo_root/bin" "$repo_root/lib" "$repo_root/share"; then
  printf '%s\n' 'CPANSA-Dancer2-2026-13577 exclusion invalid: Dancer2 session handling is activated by production code' >&2
  exit 1
fi

if ! command -v cpan-audit >/dev/null 2>&1; then
  printf '%s\n' 'cpan-audit is not on PATH, so this gate audited nothing. That is not a clean result.' >&2
  exit 4
fi

# Captured rather than streamed, because the status alone cannot tell "found
# advisories" from "died before it could look": cpan-audit exits non-zero for
# both, and a Perl startup failure exits 1 or 2, which this gate already spends
# on its own guards. The OUTPUT does distinguish them - a run that audited
# anything names advisories, and a run that died prints a Perl diagnostic and no
# advisory at all - so the decision is made on that and the output is passed
# through unchanged either way.

# TWO RUNS, AND THEY ANSWER DIFFERENT QUESTIONS (DD-567).
#
# The first run includes the interpreter and exists to be READ. The second
# excludes it and is the only one allowed to decide the exit status, because the
# subject of THIS gate is the set of distributions installed in the isolated root
# it was given. The interpreter is not one of them: this distribution does not
# ship perl, cannot patch it, and does not declare it as a runtime dependency.
#
# That is not a judgement made here. CLAUDE.md already classes host interpreter
# advisories as environmental rather than release blockers, and
# script/cpan-audit-declared-chain - the gate this project treats as
# authoritative - already skips the interpreter outright. Until now the two gates
# disagreed about scope, and the one that said "in scope" ran FIRST in CI: on
# 2026-08-16 an advisory against perl 5.44.0 (CVE-2026-15534) failed this step and
# skipped the declared-chain audit, the full test run and the coverage gate with
# it. Five merges reached master with no test verdict at all while every local run
# reported PASS.
#
# The scope is what changed. The strictness did not: a distribution advisory in
# the isolated root still exits 5, including when an interpreter advisory appears
# in the same run, and a cpan-audit that died before auditing is still UNUSABLE
# rather than clean. Pinned by t/156-cpan-audit-interpreter-scope.t.
set +e
audit_output="$(cpan-audit installed "$perl5_root" --perl --exclude-file "$exclusions" 2>&1)"
set -e

printf '%s\n' "$audit_output"

set +e
verdict_output="$(cpan-audit installed "$perl5_root" --exclude-file "$exclusions" 2>&1)"
audit_status=$?
set -e

if [ "$audit_status" -eq 0 ]; then
  exit 0
fi

# DECIDE ON THE SUBJECT OF EACH ADVISORY, NOT ON A FLAG (DD-567, second attempt).
#
# The first attempt ran cpan-audit a second time without --perl and trusted that
# to drop the interpreter. It does not. Counted on 2026-08-17: perl is reported
# once with the flag and once without - the flag only changes how the version is
# rendered (==5.044000 versus 5.044000). CI proved the fix void while the local
# tests passed, because the SHIM emitted perl only under --perl and so encoded
# the assumption under test.
#
# So the subject is read directly. cpan-audit names each finding as
# "DIST (have VERSION) has N advisor...", and this gate's subject is the
# DISTRIBUTIONS installed in the isolated root. The interpreter is not one of
# them: this product does not ship perl, cannot patch it, and CLAUDE.md classes
# host interpreter advisories as environmental - a position
# script/cpan-audit-declared-chain already takes by skipping perl outright.
#
# ANSI is stripped first. CI output is coloured (ESC[31mperl (have ...) and a
# matcher that worked on the plain local form would pass every test here and fail
# in the only place that matters.
plain_verdict="$(printf '%s' "$verdict_output" | sed -e 's/\x1b\[[0-9;]*m//g')"
offending="$(printf '%s\n' "$plain_verdict" \
  | grep -E '^[A-Za-z0-9_:-]+ \(have [^)]*\) has [0-9]+ advisor' \
  | grep -vE '^perl \(have ' || true)"

if [ -n "$offending" ]; then
  # PRINT THE RUN THAT DECIDED (DD-574). The block above prints the run that
  # INCLUDES the interpreter, for visibility; this is the run whose subject is
  # the isolated root alone, and it is the one that just failed the gate.
  #
  # Without this the gate exits 5 having shown only the interpreter advisory,
  # so a reader concludes the interpreter failed it - which is precisely the
  # thing DD-567 exists to prevent. CI run 31982450230 did exactly that, and it
  # is worse than the bug DD-567 fixed: a gate that fails for a STATED wrong
  # reason at least tells you where to look.
  printf '%s\n' '--- the advisories that decided this gate (distributions only; the interpreter is not this gate subject) ---'
  printf '%s\n' "$offending"

  # A real finding. Remapped to a single status of its own rather than passed
  # through, because cpan-audit's own non-zero statuses vary with what it found
  # (65 and 66 have both been seen) and could collide with the gate's 1, 2, 3
  # and 4. A finding must not be able to impersonate a usage error.
  exit 5
fi

# ADVISORIES FOUND, BUT NONE OF THEM THIS GATE'S SUBJECT.
#
# cpan-audit exits non-zero for a finding, so reaching here with recognisable
# advisory lines means it audited successfully and everything it found was the
# interpreter's. That is a PASS for this gate, and it must be distinguished from
# the block below - a run that died before auditing, which also exits non-zero
# and has no advisory lines at all. Collapsing the two is the exact confusion
# status 4 was created to end (DD-517).
if printf '%s\n' "$plain_verdict" | grep -qE '^[A-Za-z0-9_:-]+ \(have [^)]*\) has [0-9]+ advisor'; then
  printf '%s\n' 'no advisory against any distribution in this root; the only findings are against the perl interpreter, which this gate does not judge - see script/cpan-audit-declared-chain'
  exit 0
fi

cat >&2 <<EOF
cpan-audit exited $audit_status without reporting a single advisory, which means
it did not audit $perl5_root - it failed before it could. The output above is its
diagnostic, not a security result.

This gate is reporting UNUSABLE (4) rather than a finding on purpose. Treating
the failure as a finding would be no safer: it would put a false advisory in
front of whoever reads it, and the next person to see the gate go green after
"fixing" it would have fixed nothing.

The usual cause is a library tree built for a different Perl than the one running
cpan-audit. Check that the interpreter and the tool come from the same install:

    command -v perl cpan-audit
    perl -e 'print "\$]\n"'
EOF
exit 4
