#!/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.
# CORPUS PRECONDITION (DD-790).
#
# Every verdict below is only as good as the advisory database behind it, and until
# now no run said which database that was. On 2026-09-06 this project's database was
# thirty days old and did not contain URI at all, so a genuine advisory against the
# installed URI 5.34 was not merely missed - it was unreportable. The run said
# "clean" in the words it uses when it has actually looked.
#
# THE STAMP COMES FROM THE BINARY WE ACTUALLY SHELL OUT TO. A `perl -MCPANSA::DB`
# one-liner here could resolve a different @INC than the cpan-audit on PATH and
# report the version of a database no verdict came from - this card's own defect,
# rebuilt inside its fix. `cpan-audit --version` names what the tool loaded.
#
# Upstream already warns about this (cpan-audit --fresh, CPAN::Audit::FreshnessCheck)
# but measured on 2026-09-06 it writes to STDERR and leaves the exit status alone -
# 91 with the flag and 91 without - so no caller reading a status can see it. The
# threshold below is upstream's own CPAN_AUDIT_FRESH_DAYS, so one name moves the
# warning and this refusal together; only the consequence is ours.
set +e
version_output="$(cpan-audit --version 2>&1)"
version_rc=$?
set -e
if [ "$version_rc" -ne 0 ]; then
  printf 'did not audit: cannot establish the advisory database version - cpan-audit --version exited %s\n' "$version_rc" >&2
  exit 4
fi

db_stamp="$(printf '%s\n' "$version_output" | awk '/CPANSA::DB/ {print $2; exit}')"
if [ -z "$db_stamp" ]; then
  db_stamp="$(printf '%s\n' "$version_output" | awk '/CPAN::Audit::DB/ {print $2; exit}')"
fi

# A stamp we cannot parse is a corpus we cannot vouch for, so it refuses rather than
# defaulting to acceptance - the gate must never report clean on a database whose
# provenance it could not establish.
if ! printf '%s' "$db_stamp" | grep -qE '^[0-9]{8}([.][0-9]+)?$'; then
  printf 'advisory database: %s (age unknown)\n' "${db_stamp:-unreported}"
  printf 'did not audit: cpan-audit --version reported no YYYYMMDD.NNN database stamp, so the corpus cannot be established\n' >&2
  exit 4
fi

# The AGE is computed in perl, and that is not a contradiction of the rule above.
# The rule is about the STAMP: it must come from the binary that produces the
# verdict, because a stamp read from anywhere else can describe a database no
# verdict came from. Arithmetic on a stamp already in hand carries no such risk.
#
# It is perl rather than date(1) because `date -d` is a GNU extension. BSD and
# macOS date reject it, so the original version of this block was silently
# Linux-only: on a Mac it would have fallen into the guard below and refused to
# audit at all, turning a portable gate into one that fail-closes on half its
# platforms. perl is already a hard dependency here - the gate audits a Perl
# library root and cpan-audit is itself perl - so this adds nothing new.
db_age_days="$(perl -e '
    my ($y, $m, $d) = ($ARGV[0] =~ /^(\d{4})(\d{2})(\d{2})/) or exit 1;
    sub dfc { my ($y,$m,$d)=@_; $y -= ($m<=2?1:0);
              my $e=int(($y>=0?$y:$y-399)/400); my $yoe=$y-$e*400;
              my $doy=int((153*($m+($m>2?-3:9))+2)/5)+$d-1;
              return $e*146097 + $yoe*365+int($yoe/4)-int($yoe/100)+$doy - 719468; }
    my @n = gmtime(time);
    print dfc($n[5]+1900, $n[4]+1, $n[3]) - dfc($y, $m, $d);
' "$db_stamp" 2>/dev/null || true)"
if [ -z "$db_age_days" ]; then
  printf 'advisory database: %s (age unknown)\n' "$db_stamp"
  printf 'did not audit: could not compute the age of advisory database stamp %s\n' "$db_stamp" >&2
  exit 4
fi

# 21, stricter than upstream's 30 on purpose: 30 days is precisely the age at which
# this project's database silently lacked the advisory a clean verdict was cited to
# disprove. CI installs CPANSA-DB fresh on every run, so this cannot wedge a runner.
#
# 21 rather than a rounder number because the limit must EXCEED THE LARGEST REAL GAP
# BETWEEN PUBLICATIONS, or the gate refuses during a normal quiet spell with no newer
# database to install. Measured from the CPAN index on 2026-09-06 over 40 CPANSA-DB
# releases across 223 days: median gap 4, mean 5.7, MAXIMUM 18, one gap of 39 over
# 14 days and none over 30. That 18 is the max of a 223-day window - one sample of
# the tail, not the tail - so 21 is "above the largest gap seen", not a proven
# ceiling. Keep this in step with DEFAULT_MAX_DB_AGE_DAYS in
# script/cpan-audit-declared-chain; they are one policy expressed in two languages.
db_age_limit="${CPAN_AUDIT_FRESH_DAYS:-21}"
if ! printf '%s' "$db_age_limit" | grep -qE '^[0-9]+$'; then
  printf 'did not audit: CPAN_AUDIT_FRESH_DAYS is set to %s, which is not a whole number of days\n' "$db_age_limit" >&2
  exit 4
fi

# Printed on EVERY run, including clean ones. The clean path is the one readers
# believe, so it is the one that must carry the corpus.
printf 'advisory database: %s (%s days old)\n' "$db_stamp" "$db_age_days"

if [ "$db_age_days" -gt "$db_age_limit" ]; then
  # THE RECIPE CREATES ITS OWN DIRECTORY AND NEVER NAMES A FIXED PATH (DD-798).
  # A fixed path under world-writable /tmp is predictable; /tmp is 1777, so the
  # sticky bit stops a user deleting another's files but not creating that
  # directory first; cpanm REUSES an existing directory rather than refusing it;
  # and the next line puts it FIRST on PERL5LIB. Together that is a security tool
  # advising the user to load Perl from a location they do not control - CWE-377
  # and CWE-378. The audited root IS still echoed at the end of the recipe so the
  # command can be re-run as printed: that path is the caller's own argument, not
  # one this gate invents, which is the distinction two drafts of the test got
  # wrong before the assertion was narrowed to the created-and-prepended directory.
  printf 'did not audit: advisory database is %s days old (%s) and the limit is %s. Refresh it WITHOUT disturbing a shared CPAN tree:\n' \
    "$db_age_days" "$db_stamp" "$db_age_limit" >&2
  printf '    DIR=$(mktemp -d)\n' >&2
  printf '    cpanm --local-lib-contained "$DIR" CPANSA::DB\n' >&2
  printf '    PERL5LIB="$DIR/lib/perl5:$PERL5LIB" %s %s\n' "$0" "$perl5_root" >&2
  printf 'Or set CPAN_AUDIT_FRESH_DAYS if you accept auditing against a corpus that old.\n' >&2
  exit 4
fi

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
