#!/usr/bin/env bash
#
# Rewrite build/checksums.txt from the working tree.
#
#   ./build/update-checksums.sh            # write it
#   ./build/update-checksums.sh --check    # is it already what this would write?
#   ./build/update-checksums.sh --list     # just the paths it covers
#
# This is the only thing in the repository that writes that file. The build does
# not, the test suite does not, the git hook does not: a manifest that a build
# updates for you is a manifest that blesses whatever it finds, and the whole
# reason this one exists is that a modified simulated card has to fail loudly
# rather than quietly produce a different zip. So the bookkeeping is one command
# a person runs on purpose, and the diff it produces is the record that somebody
# meant it. Everything else only ever reads and compares.
#
# --check writes nothing either. It regenerates into memory and diffs, so it can
# be run from a hook or from CI without any chance of the check editing the thing
# it is checking.
#
# The file is generated whole, prose included. If you want to change what it says
# about itself, change the here-documents below; an edit made in checksums.txt
# would be overwritten the next time somebody runs this, and --check would call
# it a mismatch in the meantime.
#
# Requires: bash, and sha256sum (or shasum).

set -euo pipefail

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
MODE="write"

usage() {
    cat <<'USAGE'
Usage: update-checksums.sh [--check | --list] [--root DIR]

  (no option)  Rewrite build/checksums.txt from the files it covers
  --check      Report whether build/checksums.txt is already what this would
               write, and exit non-zero if it is not. Writes nothing.
  --list       Print the paths this covers, one per line, and exit
  --root DIR   Look at DIR instead of this checkout. For checking a tree that
               is not the working tree -- the git hook materialises the staged
               tree and points this at that.
  -h, --help   This message
USAGE
}

while [ "$#" -gt 0 ]; do
    case "$1" in
        --check)   MODE="check"; shift ;;
        --list)    MODE="list"; shift ;;
        --root)    ROOT="$(cd -- "$2" && pwd)"; shift 2 ;;
        -h|--help) usage; exit 0 ;;
        *)         echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
    esac
done

die() {
    echo "update-checksums: $*" >&2
    exit 1
}

if command -v sha256sum >/dev/null 2>&1; then
    sha256_of() { sha256sum -- "$1" | cut -d' ' -f1; }
elif command -v shasum >/dev/null 2>&1; then
    sha256_of() { shasum -a 256 -- "$1" | cut -d' ' -f1; }
else
    die "no sha256 tool found (looked for sha256sum and shasum)"
fi

# ---------------------------------------------------------------------------
# What the manifest covers
# ---------------------------------------------------------------------------
#
# Directories, not files, and every file found in them. That is the point: the
# four files added to src/web in one afternoon reached a deployment while the
# manifest still described the set from that morning, and a list of names would
# have gone stale exactly the same way. A directory cannot.
#
# src/web and src/shims are what a visitor is served as it stands. src/smartcard
# and src/fakes are copied whole into a wallet zip by build/build-wallet-zip.sh,
# so a file added to one of them changes that zip's hash.
#
# Excluded: src/web/pyodide, which is 26 MB of fetched runtime that
# build/fetch-assets.sh hash-checks where it fetches it and .gitignore keeps out
# of the repository, and __pycache__, which is generated and which the build
# refuses to package anyway.
#
# DOOM is excluded for the same reason and it matters more here, because these
# land in src/web itself rather than in a directory of their own: doom.js,
# doom.wasm and doom-run.js are built by build/build-doom-wasm.sh from a pinned
# doomgeneric, and the WAD is fetched from Freedoom's own release. Both are
# hash-checked where they are produced and both are gitignored. Left in, this
# manifest would say one thing on a machine that had built the game and another
# on a machine that had not, which is the one thing a manifest may never do.
# doom-boot.js is not excluded: it is this repository's own source.

SERVED_DIRS="src/web src/shims"
PACKAGED_DIRS="src/smartcard src/fakes"

list_dirs() {
    local dir
    for dir in $1; do
        [ -d "${ROOT}/${dir}" ] || die "missing ${ROOT}/${dir}"
        find "${ROOT}/${dir}" \
             -name '__pycache__' -prune -o \
             -path "${ROOT}/src/web/pyodide" -prune -o \
             -type f ! -name '*.pyc' \
                     ! -name 'doom.js' ! -name 'doom.wasm' \
                     ! -name 'doom-run.js' ! -name '*.wad' -print
    done | while IFS= read -r file; do
        printf '%s\n' "${file#"${ROOT}/"}"
    done | LC_ALL=C sort
}

hash_lines() {
    local rel
    while IFS= read -r rel; do
        printf '%s  %s\n' "$(sha256_of "${ROOT}/${rel}")" "${rel}"
    done < <(list_dirs "$1")
}

# ---------------------------------------------------------------------------
# The file itself
# ---------------------------------------------------------------------------

generate() {
    cat <<'HEADER'
# sha256 of every committed file that ends up in front of a visitor.
#
# Generated by build/update-checksums.sh, which is the only thing that writes
# it. No build and no hook ever rewrites this file: one that a build could
# refresh would bless whatever it found, and an unexpected change here is
# supposed to stop somebody rather than be tidied away. Regenerating is a
# command a person runs, and the diff is the evidence they meant it.
#
# Everything fetched is already content-addressed where it is fetched: the wallet
# and its Python dependencies by build/build-wallet-zip.sh, the Pyodide runtime
# by build/fetch-assets.sh. This file is the other half, the files that live in
# the repository and are served or packaged as they are. Without it "you can read
# all of it" is a claim about a moving target; with it, a change to any of them
# is a line in a diff that somebody had to write on purpose.
#
# Check them from the repository root with any of:
#
#     sha256sum -c build/checksums.txt          # the hashes below
#     ./build/fetch-assets.sh --check           # and that nothing is missing from them
#     ./build/update-checksums.sh --check       # and that this file is what it would be
#
# When a change to one of these files is deliberate, regenerate this file in the
# same commit as the change:
#
#     ./build/update-checksums.sh
#
# Both directions are checked, because they are the same mistake: a file that
# changed and a file that was added and listed nowhere both reach a visitor
# unannounced. build/build-wallet-zip.sh asks both questions of the packaged
# directories below before it stages either into a zip, and refuses to package a
# file that is not listed here at all.
HEADER

    cat <<'SERVED'

# Served as they stand. src/web is the page, its scripts and its icons; src/shims
# is the Python the page fetches at boot and writes into Pyodide's filesystem,
# including the shim that hands back a decoded QR payload. Verifying a wallet zip
# says nothing about any of them, and they are the code that decides what the
# wallet is shown.
#
# jsQR 1.4.0, Apache-2.0, is the file published as dist/jsQR.js in the npm
# package jsqr@1.4.0, unmodified. To confirm that independently, and not just
# that it matches the src/web/jsQR.js line below:
#
#     curl -sL https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz | tar xzO package/dist/jsQR.js | sha256sum

SERVED
    hash_lines "${SERVED_DIRS}"

    cat <<'PACKAGED'

# The stand-in packages, which build/build-wallet-zip.sh copies out of the working
# tree and into a wallet zip whole. Every other input to that zip is pinned by a
# commit sha or an artifact sha256, so these were the one way its bytes could move
# without anything saying so: edit the simulated card, get a different zip, and
# the only evidence was the hash it no longer matched. Both directories in full,
# not just the files that happen to be staged, because a file added to one of them
# lands in the zip too.

PACKAGED
    hash_lines "${PACKAGED_DIRS}"
}

CHECKSUMS_FILE="${ROOT}/build/checksums.txt"

case "${MODE}" in
    list)
        list_dirs "${SERVED_DIRS} ${PACKAGED_DIRS}"
        ;;

    check)
        [ -f "${CHECKSUMS_FILE}" ] || die "missing ${CHECKSUMS_FILE}"
        if diff -u -L "build/checksums.txt" -L "what update-checksums.sh would write" \
                "${CHECKSUMS_FILE}" <(generate); then
            echo "build/checksums.txt matches the files it covers"
        else
            die "build/checksums.txt does not match the files it covers.
If those changes are deliberate, run

    ./build/update-checksums.sh

and commit the result together with the files that changed."
        fi
        ;;

    write)
        generate > "${CHECKSUMS_FILE}.tmp"
        mv -- "${CHECKSUMS_FILE}.tmp" "${CHECKSUMS_FILE}"
        echo "wrote ${CHECKSUMS_FILE}"
        echo "  $(grep -c '^[0-9a-f]' "${CHECKSUMS_FILE}") files listed"
        echo "  review the diff before committing: this file is the record that a change was meant"
        ;;
esac
