#!/bin/bash

## Copyright (C) 2026 - 2026 ENCRYPTED SUPPORT LLC <adrelanos@whonix.org>
## See the file COPYING for copying conditions.

## AI-Assisted

## Build ONE target TWICE with identical inputs and compare the two images for
## bit-for-bit reproducibility. This is the dev-runnable local equivalent of the
## local-reproducible-build-test CI workflow (two independent builds + a compare): a
## developer can reproduce, and iterate on, a reproducibility failure on their own
## machine without a CI round-trip.
##
## Usage:
##   ci/reproducible-build-twice --target <iso|virtualbox|qcow2> --arch <amd64|arm64> \
##       [--flavor FLAVOR] [--freshness frozen] [--output-dir DIR]
##
## --target      image type to build twice (iso | virtualbox | qcow2).
## --arch        build architecture (required, e.g. amd64) -- not defaulted, so the
##               compared arch is always explicit.
## --flavor      derivative flavor; defaults per target (iso -> kicksecure-lxqt,
##               virtualbox/qcow2 -> kicksecure-cli).
## --freshness   passed through to the build (default 'frozen': pins the Debian
##               snapshot, without which two builds diverge on package versions).
## --output-dir  where the two images + report are collected (default a fresh
##               directory beside the repo). The collected images are ~0.5 GB each,
##               but the BUILD needs far more transient room on the same
##               filesystem.
##
## The two builds run SEQUENTIALLY, never concurrently: two full image builds at
## once exhaust RAM/disk on a modest host. The verdict is NOT re-implemented here:
## the two build outputs are collected into a/ and b/ and handed to the single
## canonical comparator, developer-meta-files' dm-reproducible-compare-artifacts
## (whole-file sha256 for the verdict, plus a BEST-EFFORT diffoscope explanation
## when they differ -- it may be truncated or skipped, so it is a diagnosis aid,
## not a guarantee of localizing the mismatch to a specific in-image file). This
## script owns the build-twice orchestration; the comparison lives in exactly one
## place.
##
## Exit: 0 reproducible, 1 differ, 2 usage / artifact-not-found, 3 a build failed.

set -o errexit
set -o nounset
set -o pipefail
set -o errtrace
shopt -s inherit_errexit
shopt -s shift_verbose
export LC_ALL=C

## style-ok: no-has

script_dir_relative="$( dirname -- "${BASH_SOURCE[0]}" )"
MYDIR="$( cd -- "${script_dir_relative}" && pwd )"
source_code_folder_dist="$( cd -- "${MYDIR}/.." && pwd )"

usage() {
   printf '%s\n' "Usage:
  ${0##*/} --target <iso|virtualbox|qcow2> --arch <amd64|arm64> [--flavor FLAVOR] [--freshness frozen] [--output-dir DIR]" >&2
   exit 2
}

target=""
flavor=""
arch=""
freshness="frozen"
output_dir=""
while [ "$#" -gt 0 ]; do
   case "$1" in
      --target)
         [ "$#" -ge 2 ] || usage
         target="$2"
         shift 2
         ;;
      --flavor)
         [ "$#" -ge 2 ] || usage
         flavor="$2"
         shift 2
         ;;
      --arch)
         [ "$#" -ge 2 ] || usage
         arch="$2"
         shift 2
         ;;
      --freshness)
         [ "$#" -ge 2 ] || usage
         freshness="$2"
         shift 2
         ;;
      --output-dir)
         [ "$#" -ge 2 ] || usage
         output_dir="$2"
         shift 2
         ;;
      *)
         printf '%s\n' "${0##*/}: unexpected argument: $1" >&2
         usage
         ;;
   esac
done

case "${target}" in
   iso)
      artifact_glob="*.iso"
      [ -n "${flavor}" ] || flavor="kicksecure-lxqt"
      ;;
   virtualbox)
      artifact_glob="*.ova"
      [ -n "${flavor}" ] || flavor="kicksecure-cli"
      ;;
   qcow2)
      ## The shipped, released artifact is the compressed '.qcow2.libvirt.xz' (the
      ## canonical comparator's authoritative qcow2 form); collect and compare that,
      ## not the bare pre-release '.qcow2'. If a build stops before release packaging
      ## it will emit no '.xz' and build_one reports it -- run the packaging step.
      artifact_glob="*.qcow2.libvirt.xz"
      [ -n "${flavor}" ] || flavor="kicksecure-cli"
      ;;
   *)
      printf '%s\n' "${0##*/}: unknown or missing --target (want iso|virtualbox|qcow2)" >&2
      usage
      ;;
esac

if [ -z "${arch}" ]; then
   printf '%s\n' "${0##*/}: --arch is required (e.g. amd64)." >&2
   usage
fi

## The build writes its images to '$HOME/derivative-binary' (docker-run bind-mounts the
## host's binary volume there; help-steps/variables derives the same path from HOMEVAR),
## NOT to a directory inside the source tree.
[ -n "${binary_build_folder_dist:-}" ] || binary_build_folder_dist="${HOME}/derivative-binary"

## Collect into the binary volume: it is the only bind-mounted, writable location that
## outlives the '--rm' build container.
if [ -z "${output_dir}" ]; then
   output_dir="$( mktemp --directory --tmpdir="${binary_build_folder_dist}" reproducible-build-twice.XXXXXX )"
fi
mkdir --parents -- "${output_dir}"
report="${output_dir}/report.txt"

## Pin the image version for BOTH builds, from the pre-sign HEAD of this one tree.
## A per-build capture would be wrong here: sign-and-tag amends and tags HEAD, so a
## later build would derive the generated tag as its version and differ for a
## non-defect reason.
pushd -- "${source_code_folder_dist}" >/dev/null
## Mirrors help-steps/variables' derivation, because the paths built from it below
## must name the directory the BUILD writes:
##   --exclude  skips sign-tag-head's ephemeral '<tag>_<commit>_<key>' signing tags,
##              so re-running on this checkout does not derive the version from the
##              previous run's tag.
##   the strip  removes the release-channel suffix, exactly as variables does before
##              deriving 'dist_binary_build_folder'. Without it this searches
##              '<binary>/18.2.2.0-developers-only-161-g<sha>' while the build wrote
##              '<binary>/18.2.2.0-161-g<sha>', and reports 'produced no <glob>'
##              only AFTER a full build has already run.
dist_build_version="$( git describe --always --abbrev=1000000000 --exclude '*_*_*' )"
dist_build_version="${dist_build_version//-developers-only/}"
dist_build_version="${dist_build_version//-testers-only/}"
dist_build_version="${dist_build_version//-stable/}"

## dm-build-official does NOT sign, and the build's sq-git check rejects an unsigned
## HEAD, so mint an ephemeral key and sign+tag HEAD here -- BEFORE either build and
## after the version is pinned.
##
## Once for BOTH builds, deliberately. The signing key never reaches the image, so
## varying it between the two builds tests nothing this lane is for. The CI lane
## already covers key-independence for free: its two build jobs run on separate
## runners, each minting its own ephemeral key, and still produce bit-identical
## images.
##
## 'sign-and-tag' amends HEAD, which rewrites the COMMIT but not the TREE, so signing
## per build would NOT hand build b a different tree. It would, however, need a
## per-build keystore ('signing-key-create' reuses an existing key for DEBEMAIL,
## which the docker-run --key-mount persists) and a reset to the pre-sign commit
## between builds (build b's 'sign-tag-head' would otherwise abort on build a's tag
## being signed by a key its new policy does not authorize). Not worth that for a
## variable the image does not carry.
## Opt in to signing: sign-and-tag defaults off, but this harness reproduces the
## redistributable build (dm-build-official-one sets dist_build_redistributable=true,
## which git_sanity_test requires be signed), so the tree must be signed here.
export dist_build_sign_and_tag=true
./help-steps/signing-key-create
./help-steps/sign-and-tag
popd >/dev/null
export dist_build_version

## Run one build and collect its artifact into $output_dir/<tag>/.
build_one() {
   local tag="${1}" dest="${output_dir}/${2}" build_rc found
   printf '%s\n' "=== build ${tag}: ${flavor} ${target} ${arch} (freshness=${freshness}) ==="

   ## Remove this build's emitted-image directory so 'find' below sees only the current
   ## build's output. Scoped to the per-version directory: its siblings under
   ## '$binary_build_folder_dist' are the cowbuilder base, the local apt repo and the
   ## package caches, which a build legitimately reuses and must not lose.
   safe-rm --recursive --force -- "${binary_build_folder_dist}/${dist_build_version}" 2>/dev/null || true

   build_rc=0
   ## pushd/popd rather than a '( cd && ... )' subshell: scopes the cd without a
   ## subshell (and dodges the errexit-in-conditional-subshell footgun).
   pushd -- "${source_code_folder_dist}" >/dev/null
   ## The SAME entrypoint CI drives, with the same env shape, so a local verdict
   ## transfers: a separate build path diverges (different flags, no shared-base
   ## role) and turns build-path drift into phantom non-reproducibility.
   ## CI=true is what mocks the upload -- without it dm-prepare-release's rsync is
   ## LIVE, and a local reproducibility check must never publish. It also skips the
   ## ~/.ssh requirement. It does NOT force a dry run: parse-cmd only injects
   ## build_dry_run for a CI CROSS-build, and dist_build_target_arch is set here.
   CI=true \
     dist_build_target_arch="${arch}" \
     flavors_list="${flavor}" \
     dist_build_multi_target_list="${target}" \
     ./help-steps/dm-build-official --freshness "${freshness}" || build_rc="$?"
   popd >/dev/null
   if [ "${build_rc}" -ne 0 ]; then
      printf '%s\n' "${0##*/}: build ${tag} failed (exit ${build_rc})" >&2
      exit 3
   fi
   ## Take the first match via parameter expansion, not 'head -1': head closes the
   ## pipe after one line, which would SIGPIPE find/sort (exit 141 under pipefail +
   ## inherit_errexit) once their output exceeds the pipe buffer, breaking the exit
   ## contract with no diagnostic.
   ## A build that wrote nothing leaves no version directory at all, and 'find' on a
   ## missing path fails -- under errexit that exits 1, which this script's own
   ## contract documents as "images differ", and the message below never prints. An
   ## absent directory and an empty one mean the same thing here: no artifact.
   found=""
   if [ -d "${binary_build_folder_dist}/${dist_build_version}" ]; then
      found="$( find "${binary_build_folder_dist}/${dist_build_version}" -type f -name "${artifact_glob}" | LC_ALL=C sort )"
      found="${found%%$'\n'*}"
   fi
   if [ -z "${found}" ]; then
      printf '%s\n' "${0##*/}: build ${tag} produced no ${artifact_glob}" >&2
      exit 3
   fi
   ## Start from a clean $dest: a rerun into the same --output-dir with a different --flavor
   ## must not leave a stale artifact that the compare's 'find | sort | first' could pick over
   ## the newly built one. $dest is a dedicated per-tag subdir ($output_dir/a|b), safe to wipe.
   safe-rm --recursive --force -- "${dest}"
   mkdir --parents -- "${dest}"
   cp -- "${found}" "${dest}/"
   printf '%s\n' "  collected: ${dest}/${found##*/}"
}

build_one a a
build_one b b

## Verdict: delegate to the single canonical comparator (whole-file sha256, with a
## best-effort diffoscope explanation) instead of re-implementing it here. It locates
## exactly one artifact of $target under each dir, so the two build outputs collected
## into a/ and b/ are compared bit-for-bit. Its exit maps onto ours: 0 identical
## (reproducible), 1 differ, 2 setup / artifact-not-found.
compare_bin="${source_code_folder_dist}/packages/kicksecure/developer-meta-files/usr/bin/dm-reproducible-compare-artifacts"
compare_rc=0
"${compare_bin}" --target "${target}" --dir-a "${output_dir}/a" --dir-b "${output_dir}/b" --output "${report}" || compare_rc="$?"
exit "${compare_rc}"
