#!/bin/sh
# script/verify-build: assert that dist/ holds exactly what the build that just
# ran emitted, and that the compiled DEBUG state of that output is the one the
# caller asked for. Our own extension to scripts-to-rule-them-all, run at the
# end of make build / make build-debug.
#
# Why the DEBUG half exists: DEBUG makes the publicly committed test recovery
# phrase the output of wallet creation, so a release artifact built with it live
# hands every new wallet to anyone who reads the repo. The test suite cannot see
# this, because it loads src/shared/constants.js outside a bundle and takes the
# fallback branch; the property only exists in the emitted output, so it has to
# be asserted against the emitted output.
#
# Which mode to expect is an ARGUMENT (--expect release|debug) and is never
# taken from this script's environment. It used to be read from
# AUTISTMASK_DEBUG here, which meant an operator with AUTISTMASK_DEBUG=1
# exported in their shell could run the release target, get a debug build, and
# have it verified green and exit 0. There is also no default: a caller that
# does not say what it built gets a failure, because "no opinion" is not a
# state this can check anything against.
#
# Why the provenance half exists: on its own, a marker grep proves nothing
# about where the bytes came from. A 26-byte file containing only the marker
# string used to verify ok; the content script and manifest.json were not read
# at all; an entire hand-written dist/ passed. The list of files to check has
# therefore moved OUT of dist/: build.js writes a receipt naming every file it
# emitted, with each file's sha256 and whether it is one of the bundles
# containing src/shared/constants.js, and the Makefile creates that receipt
# path fresh per invocation, outside the repo, and deletes it afterwards.
#
# What that does and does not establish. It establishes that dist/ is byte for
# byte the output of the build.js run that just finished, with nothing added,
# nothing missing and nothing altered in between, and that the audited bundles
# in it compiled to the requested mode. It does NOT establish that the source
# tree or build.js were honest, and it says nothing at all to someone handed a
# dist/ from elsewhere: without the receipt from its own build they have no
# input to this check. That is signing, and it is not this control.
#
# It fails rather than passes whenever it cannot determine something. Minified
# output is not a stable contract, so "matched neither marker" is not evidence
# of anything and must never read as green; the same discipline applies to
# every read here, which is why a grep or a digest that could not be taken is
# a hard failure and not an absence of a problem.
set -eu

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

# Absolute path to this script, resolved before anything cd's anywhere.
# check_dist_tree re-invokes it through xargs, and $0 on its own may be
# relative to a directory we are about to leave.
SELF="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")"

# Internal re-entry flag; see scan_dist_paths.
SCAN_FLAG="--scan-dist-paths"

# A literal newline and tab, for the receipt-shape guards.
NEWLINE='
'
TAB='	'

MARKER_ON="autistmask-build-debug=on"
MARKER_OFF="autistmask-build-debug=off"

RECEIPT_HEADER="autistmask-build-receipt v1"

# Set by the arguments.
RECEIPT=""
EXPECT=""

# Set by read_marker, read_sha256 and parse_file_line respectively, plus the
# receipt line number the diagnostics quote.
MARKER=""
SHA=""
ENTRY_HASH=""
ENTRY_FLAG=""
ENTRY_PATH=""
LINENO_R=0

# The sha256 command, chosen by pick_sha256.
SHA256=""

# Totals: the shape pass counts what the receipt claims, the entries pass
# counts what was actually checked against dist/, and the summary reports the
# latter.
SHAPE_COUNT=0
SHAPE_AUDITED=0
COUNT=0
AUDITED=0

# Temporary file holding the NUL-delimited dist/ listing, removed by the EXIT
# trap because fail() exits from wherever it is called.
LISTING=""

fail() {
    echo "verify-build: FAIL: $*" >&2
    exit 1
}

usage() {
    echo "usage: verify-build --expect release|debug --receipt PATH" >&2
}

cleanup() {
    [ -z "$LISTING" ] || rm -f "$LISTING"
}
trap cleanup EXIT

# --- reading files ----------------------------------------------------------

# Is the literal $1 present in the file $2? Match (grep exit 0) and no-match
# (exit 1) are answers about the emitted output. Anything else (exit 2: the
# file could not be read) is not an answer at all, and must not be reported as
# "no marker" — that would blame the bundle for a permissions or I/O fault.
has_marker() {
    _hm_status=0
    grep -q -F -e "$1" -- "$2" || _hm_status=$?
    case "$_hm_status" in
    0) return 0 ;;
    1) return 1 ;;
    *)
        fail "grep exited $_hm_status reading $2, so the file could not be
    searched and its DEBUG state was not checked at all. That is a permissions
    or I/O fault on the artifact, not a change in the emitted output. Refusing
    to report success."
        ;;
    esac
}

# Pick the sha256 command once. All three print the digest as the first
# whitespace-delimited field. If none is present the digests cannot be taken at
# all, and this script has nothing left to check with, so it fails rather than
# degrading to the marker grep it used to be.
pick_sha256() {
    if command -v sha256sum >/dev/null 2>&1; then
        SHA256="sha256sum"
    elif command -v shasum >/dev/null 2>&1; then
        SHA256="shasum -a 256"
    elif command -v openssl >/dev/null 2>&1; then
        SHA256="openssl dgst -sha256 -r"
    else
        fail "no sha256 command found (tried sha256sum, shasum, openssl), so
    the emitted files cannot be checked against the build receipt at all.
    Refusing to report success."
    fi
}

# Digest of $1 into SHA. A digest that could not be taken is not a mismatch and
# not a pass: it means the artifact was never read.
read_sha256() {
    _rs_status=0
    # Word-split on purpose: SHA256 is a command with its arguments.
    # shellcheck disable=SC2086
    _rs_out="$($SHA256 "$1" 2>/dev/null)" || _rs_status=$?
    [ "$_rs_status" -eq 0 ] ||
        fail "$SHA256 exited $_rs_status on $1, so its bytes were never read
    and nothing was established about them. That is a permissions or I/O fault
    on the artifact, not a mismatch. Refusing to report success."

    SHA="${_rs_out%% *}"
    case "$SHA" in
    "" | *[!0-9a-f]*)
        fail "$SHA256 produced no usable digest for $1, so its bytes were never
    checked. Refusing to report success."
        ;;
    esac
    [ "${#SHA}" -eq 64 ] ||
        fail "$SHA256 produced a ${#SHA}-character digest for $1, which is not
    a sha256. Refusing to report success."
}

# Read one bundle's DEBUG state into MARKER. Exactly one marker must be
# present. Both means the ternary in constants.js was never folded, which is
# what happens when the __BUILD_DEBUG__ define goes missing from build.js:
# DEBUG stops being known at build time. Neither means we are reading output
# we do not understand. Both are hard failures; neither is ever treated as
# absence of a problem.
read_marker() {
    _file="$1"
    _on=no
    _off=no
    if has_marker "$MARKER_ON" "$_file"; then _on=yes; fi
    if has_marker "$MARKER_OFF" "$_file"; then _off=yes; fi

    if [ "$_on" = yes ] && [ "$_off" = yes ]; then
        fail "$_file carries both debug markers, so DEBUG was not resolved at
    build time: the ternary in src/shared/constants.js survived into the
    emitted output. This does not mean the debug branch is live in this
    artifact: an unresolved __BUILD_DEBUG__ is undeclared in extension
    context, so DEBUG evaluates to false at runtime. It does mean the
    release/debug distinction is no longer enforced at build time, and which
    way that fallback happens to evaluate is then an accident a refactor can
    flip. Check that build.js still defines __BUILD_DEBUG__."
    fi
    if [ "$_on" = no ] && [ "$_off" = no ]; then
        fail "$_file carries no debug marker, so its DEBUG state cannot be
    determined. Either BUILD_DEBUG_MARKER is gone from src/shared/constants.js
    or the emitted output changed shape. Refusing to report success."
    fi

    if [ "$_on" = yes ]; then
        MARKER="$MARKER_ON"
    else
        MARKER="$MARKER_OFF"
    fi
}

# --- the receipt ------------------------------------------------------------

# Split one "file <sha256> <A|P> <path>" line into ENTRY_HASH, ENTRY_FLAG and
# ENTRY_PATH, and require the shape rather than assuming it. The path is the
# remainder of the line, so a path carrying a space or a tab would be read back
# as something other than what was written; build.js refuses to emit such a
# name, and a receipt that contains one is malformed rather than describing a
# file. Every rejection here is a failure: a line that cannot be understood is
# a file that would otherwise go unchecked.
parse_file_line() {
    case "$1" in
    "file "*) ;;
    *)
        fail "$RECEIPT line $LINENO_R is not a file entry and this script does
    not know what it means: ${1}. Refusing to report success."
        ;;
    esac

    _pl="${1#file }"
    ENTRY_HASH="${_pl%% *}"
    _pl="${_pl#* }"
    ENTRY_FLAG="${_pl%% *}"
    ENTRY_PATH="${_pl#* }"

    case "$ENTRY_HASH" in
    "" | *[!0-9a-f]*) fail "$RECEIPT line $LINENO_R has no sha256: $1" ;;
    esac
    [ "${#ENTRY_HASH}" -eq 64 ] ||
        fail "$RECEIPT line $LINENO_R has a ${#ENTRY_HASH}-character digest,
    which is not a sha256: $1"

    case "$ENTRY_FLAG" in
    A | P) ;;
    *) fail "$RECEIPT line $LINENO_R has no A/P audit flag: $1" ;;
    esac

    case "$ENTRY_PATH" in
    dist/*) ;;
    *)
        fail "$RECEIPT line $LINENO_R names a path that is not under dist/:
    $ENTRY_PATH. The receipt describes the emitted tree and nothing else."
        ;;
    esac
    case "$ENTRY_PATH" in
    *" "* | *"$TAB"* | *"$NEWLINE"*)
        fail "$RECEIPT line $LINENO_R names a path containing whitespace, which
    cannot be read back unambiguously from a line-oriented receipt: $1"
        ;;
    esac
}

# Check one emitted file against its receipt entry: it must be a regular file
# with exactly the recorded bytes, and its debug marker must match what the
# caller said this build was.
check_entry() {
    [ ! -h "$ENTRY_PATH" ] ||
        fail "the receipt names $ENTRY_PATH but that path is a symlink. The
    build emits regular files only, so this is not the file it wrote. Refusing
    to report success."
    [ -f "$ENTRY_PATH" ] ||
        fail "the receipt names $ENTRY_PATH, which does not exist. dist/ does
    not hold what the build emitted."
    [ -s "$ENTRY_PATH" ] ||
        fail "the receipt names $ENTRY_PATH, which is empty. An empty file
    carries no marker and matches no digest, so this is a failure and not a
    pass."

    read_sha256 "$ENTRY_PATH"
    [ "$SHA" = "$ENTRY_HASH" ] ||
        fail "$ENTRY_PATH does not contain the bytes this build emitted: the
    receipt records $ENTRY_HASH and the file on disk is $SHA. Something wrote
    to dist/ after the build, so this artifact is not the one that was built."

    if [ "$ENTRY_FLAG" = A ]; then
        read_marker "$ENTRY_PATH"
        [ "$MARKER" = "$EXPECT" ] ||
            fail "$ENTRY_PATH is $MARKER but this build was told to expect
    $EXPECT. If AUTISTMASK_DEBUG=1 is exported in the shell that ran make
    build, that is why: the flag still reaches the compiler, and this is the
    check that stops the debug artifact being taken for a release one."
        echo "  ok: $ENTRY_PATH ($MARKER)"
        AUDITED=$((AUDITED + 1))
    else
        if has_marker "$MARKER_ON" "$ENTRY_PATH" ||
            has_marker "$MARKER_OFF" "$ENTRY_PATH"; then
            fail "$ENTRY_PATH carries a debug marker but the build did not
    record it as containing src/shared/constants.js. build.js selects audited
    bundles with an endsWith(\".js\") test; a marker-carrying file outside that
    set means the test no longer describes what is emitted, and the DEBUG state
    of this file was never asserted against anything."
        fi
    fi
    COUNT=$((COUNT + 1))
}

# Walk the receipt line by line, applying $1 to each file entry. The header and
# the root line are checked on the way past; the root line is what stops a
# receipt written by a build of some other tree being pointed at this one.
walk_receipt() {
    _wr_each="$1"
    LINENO_R=0
    _line=""
    while IFS= read -r _line || [ -n "$_line" ]; do
        LINENO_R=$((LINENO_R + 1))
        if [ "$LINENO_R" -eq 1 ]; then
            [ "$_line" = "$RECEIPT_HEADER" ] ||
                fail "$RECEIPT does not start with \"$RECEIPT_HEADER\", so it
    is not a build receipt this script understands. Refusing to report
    success."
            continue
        fi
        if [ "$LINENO_R" -eq 2 ]; then
            [ "$_line" = "root $ROOT" ] ||
                fail "$RECEIPT was written by a build of a different tree: it
    says \"$_line\" and this is $ROOT. A receipt only describes the dist/ of
    the tree it was built in."
            continue
        fi
        parse_file_line "$_line"
        "$_wr_each"
    done <"$RECEIPT"

    [ "$LINENO_R" -ge 2 ] ||
        fail "$RECEIPT is truncated: it has no root line, so it is not a
    receipt this script can check anything against."
}

# Pass one: the receipt has to be a receipt before anything is concluded from
# it. A line this script cannot read is a file that would go unchecked, and a
# receipt naming no audited bundle asserts no DEBUG state at all — both are
# failures, and both have to be established before the tree is walked against
# it, because a receipt entry that was misread would otherwise surface as a
# complaint about dist/.
count_entry() {
    SHAPE_COUNT=$((SHAPE_COUNT + 1))
    if [ "$ENTRY_FLAG" = A ]; then
        SHAPE_AUDITED=$((SHAPE_AUDITED + 1))
    fi
}

check_receipt_shape() {
    SHAPE_COUNT=0
    SHAPE_AUDITED=0
    walk_receipt count_entry

    [ "$SHAPE_COUNT" -gt 0 ] ||
        fail "$RECEIPT names no emitted files, so nothing was inspected. A
    build always emits some."
    [ "$SHAPE_AUDITED" -gt 0 ] ||
        fail "$RECEIPT names no bundle containing src/shared/constants.js, so
    no DEBUG state would be asserted at all. That is never correct, so it is a
    failure and not a pass."
}

# Pass three: every file the receipt names, checked against the bytes on disk.
check_receipt_entries() {
    walk_receipt check_entry
}

# --- the emitted tree -------------------------------------------------------

# The receipt says which files the build emitted. This says dist/ contains no
# others: an artifact that was added after the build, or that a hand-written
# dist/ brought with it, is not something the build vouches for and is not
# something this check may pass over.
#
# The walk has to be exhaustive and every name has to survive it intact, so
# four things are enforced rather than assumed:
#
#   - the walk is NUL-delimited and the paths reach the check as arguments, so
#     no name can be reshaped on the way in. Read line by line, a name with a
#     trailing space lost it to read's field splitting and the remnant then
#     matched a listed path, and a name containing a newline arrived as a
#     listed path plus an empty one. Both left an unchecked file in dist/ while
#     the script still reported success.
#   - find's exit status is checked. A subtree it cannot descend is reported on
#     stderr and then simply missing from the listing, so an unchecked status
#     turns "could not look" into "nothing was there" — the same conflation
#     has_marker exists to prevent. The status cannot be read off a pipeline,
#     so the listing lands in a file that xargs then reads back.
#   - symlinks are walked too (-type l), not skipped. The build emits none, so
#     a symlink under dist/ is a path the build did not produce, whatever it
#     points at, and it fails as one instead of being read through.
#   - dist/ itself must be a directory and not a symlink, which main asserts
#     before anything reads through it. find does not follow a symlink named on
#     its own command line, so a linked dist/ collapses this walk to one entry
#     and cross-checks nothing.
#
# Types other than regular files and symlinks are left out on purpose: a build
# emits none of them, and grep on a fifo would hang rather than fail.
check_dist_tree() {
    LISTING="$(mktemp "${TMPDIR:-/tmp}/verify-build-dist.XXXXXX")" ||
        fail "could not create a temporary file for the dist/ listing, so the
    tree was never walked. Refusing to report success."

    _find_status=0
    find dist \( -type f -o -type l \) -print0 >"$LISTING" || _find_status=$?
    [ "$_find_status" -eq 0 ] ||
        fail "find exited $_find_status enumerating dist/, so part of the tree
    was never walked and nothing was established about the files in it. Any
    file the build did not emit could be sitting there unchecked. That is a
    permissions or I/O fault on the artifact. Refusing to report success."

    _scan_status=0
    xargs -0 "$SELF" "$SCAN_FLAG" "$RECEIPT" <"$LISTING" || _scan_status=$?
    [ "$_scan_status" -eq 0 ] ||
        fail "the dist/ tree scan exited $_scan_status: either a path under
    dist/ failed the check reported above, or the scan could not be run at all.
    Refusing to report success."
}

# Does the receipt name the path $1? Compared as whole strings, never through
# grep: a path found under dist/ is attacker-shaped input, and a pattern is not
# the place to put one. The receipt's own paths are known to carry no
# whitespace by the time this runs — verify_receipt failed the run otherwise —
# so stripping the three leading fields recovers each one exactly.
receipt_names() {
    _rn_want="$1"
    _rn_line=""
    while IFS= read -r _rn_line || [ -n "$_rn_line" ]; do
        case "$_rn_line" in
        "file "*) ;;
        *) continue ;;
        esac
        [ "${_rn_line#file * * }" != "$_rn_want" ] || return 0
    done <"$RECEIPT"
    return 1
}

# The per-path half of check_dist_tree. It runs in a re-invocation of this
# script, so it uses the same helpers as the rest of the file rather than a
# second copy of them that could drift. Paths arrive as arguments and are never
# split, joined or trimmed.
scan_dist_paths() {
    for _file in "$@"; do
        if [ -h "$_file" ]; then
            fail "$_file is a symlink under dist/. The build emits regular
    files only, so this path is not something it produced, and what it points
    at is not what was verified. Refusing to report success."
        fi
        if receipt_names "$_file"; then
            continue
        fi
        fail "$_file is under dist/ but the build that just ran did not emit
    it. dist/ must contain exactly what the build produced: an extra file there
    is an artifact nothing vouches for, and shipping the directory ships it."
    done
}

# --- arguments --------------------------------------------------------------

# The expected mode and the receipt are stated by the caller. Nothing is read
# from the environment, and there is no default for either.
parse_args() {
    while [ "$#" -gt 0 ]; do
        case "$1" in
        --expect)
            [ "$#" -ge 2 ] || fail "--expect needs an argument (release|debug)."
            set_expect "$2"
            shift 2
            ;;
        --expect=*)
            set_expect "${1#--expect=}"
            shift
            ;;
        --receipt)
            [ "$#" -ge 2 ] || fail "--receipt needs a path."
            set_receipt "$2"
            shift 2
            ;;
        --receipt=*)
            set_receipt "${1#--receipt=}"
            shift
            ;;
        *)
            usage
            fail "unknown argument: $1"
            ;;
        esac
    done
}

set_expect() {
    [ -z "$EXPECT" ] || fail "--expect given more than once."
    case "$1" in
    release) EXPECT="$MARKER_OFF" ;;
    debug) EXPECT="$MARKER_ON" ;;
    *) fail "--expect takes release or debug, not \"$1\"." ;;
    esac
}

set_receipt() {
    [ -z "$RECEIPT" ] || fail "--receipt given more than once."
    [ -n "$1" ] || fail "--receipt was given an empty path."
    # Resolved against the caller's directory, before main cd's to the repo
    # root.
    case "$1" in
    /*) RECEIPT="$1" ;;
    *) RECEIPT="$PWD/$1" ;;
    esac
}

# --- main -------------------------------------------------------------------

main() {
    # Internal re-entry from check_dist_tree's xargs. Not part of the
    # command-line interface: nothing else invokes it, and it is a distinct
    # entry point rather than a mode flag threaded through the checks below.
    if [ "${1-}" = "$SCAN_FLAG" ]; then
        shift
        [ "$#" -ge 1 ] || fail "internal: $SCAN_FLAG needs the receipt path."
        RECEIPT="$1"
        shift
        cd "$ROOT"
        [ -r "$RECEIPT" ] ||
            fail "$RECEIPT became unreadable during the run, so the dist/ tree
    could not be checked against it. Refusing to report success."
        scan_dist_paths "$@"
        return 0
    fi

    parse_args "$@"

    [ -n "$EXPECT" ] || {
        usage
        fail "no --expect argument. The mode this build was supposed to produce
    has to be stated by whoever ran the build; it is not a default and it is
    not read from AUTISTMASK_DEBUG in this script's environment, because an
    operator with that exported would then have their debug build verified as
    the release one they asked for."
    }
    [ -n "$RECEIPT" ] || {
        usage
        fail "no --receipt argument. The list of files to check comes from the
    build that just ran, not from dist/: without it, a hand-written dist/ would
    be verifying itself. make build and make build-debug pass one."
    }

    pick_sha256
    cd "$ROOT"

    # Asserted here rather than left to grep. A symlinked dist/ used to fail
    # only because GNU grep exits 2 on a directory, so the tree walk hit
    # has_marker's I/O path by luck; under a grep that exits 1 instead, the
    # whole cross-check would have collapsed into a pass.
    if [ -h dist ]; then
        fail "dist is a symlink, not a directory. find does not follow a
    symlink named on its own command line, so the tree walk would see one entry
    instead of the emitted tree and establish nothing about it. Refusing to
    report success."
    fi
    [ -d dist ] ||
        fail "dist is not a directory, so there is no emitted tree to verify.
    build.js writes it; run make build first."

    case "$RECEIPT" in
    "$ROOT/dist" | "$ROOT/dist/"*)
        fail "the receipt is inside dist/ ($RECEIPT). A receipt that lives in
    the tree it describes is rewritten by whoever rewrites the tree, and vouches
    for nothing. make build keeps it outside the repo."
        ;;
    esac

    [ -e "$RECEIPT" ] ||
        fail "$RECEIPT is missing. build.js writes it at the end of a
    successful build; run make build rather than invoking this directly."
    [ -f "$RECEIPT" ] ||
        fail "$RECEIPT is not a regular file, so it is not a build receipt."
    [ -s "$RECEIPT" ] ||
        fail "$RECEIPT is empty, so the build wrote nothing to it and there is
    no account of what it emitted. build.js writes the receipt last, so an
    empty one means the build did not finish."
    [ -r "$RECEIPT" ] ||
        fail "$RECEIPT is not readable, so nothing was inspected. That is a
    permissions or I/O fault, not a pass."

    echo "Verifying emitted files against the build receipt (expecting" \
        "$EXPECT)..."

    # Order matters. The receipt has to be well-formed before it is used as an
    # expectation, and the tree has to be walkable in full before any single
    # file in it is pronounced on: a subtree that cannot be descended makes
    # every file under it look absent, and "could not look" must never be
    # reported as "was not there".
    check_receipt_shape
    check_dist_tree
    check_receipt_entries

    echo "verify-build: $COUNT emitted file(s) verified against the receipt," \
        "$AUDITED bundle(s) $EXPECT"
}

main "$@"
