#!/bin/bash

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

## AI-Assisted

## Compare two independent builds of the same target for bit-for-bit
## reproducibility.
##
## Usage:
##   dm-reproducible-compare-artifacts --target <iso|virtualbox|qcow2> \
##       --dir-a DIR --dir-b DIR --output REPORT
##
## --target  which image type to locate in each directory:
##             iso -> *.iso   virtualbox -> *.ova   qcow2 -> *.qcow2.libvirt.xz
## --dir-a   directory holding the first build's artifact (searched recursively).
## --dir-b   directory holding the second build's artifact.
## --output  report file written with the sha256 of each artifact and, when they
##           differ, a diffoscope explanation of what differs.
##
## Note that diffoscope only explains differences in differing images. Images
## are always reported as differing if there is a sha256 mismatch.
##
## Exit: 0 identical; 4 artifacts differ; 2 usage / setup / not-found error;
## 1 unexpected internal error.

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

usage() {
   printf '%s\n' "Usage:
  ${0##*/} --target <iso|virtualbox|qcow2> --dir-a DIR --dir-b DIR --output REPORT" >&2
   exit 2
}

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

if [ -z "${target}" ] || [ -z "${dir_a}" ] || [ -z "${dir_b}" ] || [ -z "${report}" ]; then
   usage
fi

case "${target}" in
   iso)
      artifact_glob="*.iso"
      ;;
   virtualbox)
      artifact_glob="*.ova"
      ;;
   qcow2)
      artifact_glob="*.qcow2.libvirt.xz"
      ;;
   *)
      printf '%s\n' "${0##*/}: unknown target: ${target} (want iso|virtualbox|qcow2)" >&2
      usage
      ;;
esac

## Locate the single artifact of the wanted type under a directory and store it
## in the global 'found_artifact'.
found_artifact=""
find_artifact() {
   local dir="$1"
   local -a match_list=()
   ## Prefix the dir with './' so paths like '-' don't confuse find.
   [ "${dir#-}" = "${dir}" ] || dir="./${dir}"
   if [ ! -d "${dir}" ]; then
      printf '%s\n' "${0##*/}: no such directory: ${dir}" >&2
      exit 2
   fi
   ## Process substitution suppresses errexit, so we use a status file to work
   ## around that.
   local find_status_file find_status
   find_status_file="$(mktemp)"
   ## '-H' dereferences the start point only., so we can handle symlink
   ## arguments without find escaping the build dir.
   mapfile -d '' -t match_list < <(
      find_exit_status=0
      find -H "${dir}" -type f -name "${artifact_glob}" -print0 \
         | LC_ALL=C sort -z \
         || find_exit_status="${PIPESTATUS[0]}"
      printf '%s\n' "${find_exit_status}" > "${find_status_file}"
   )
   find_status="$(cat -- "${find_status_file}")"
   safe-rm --force -- "${find_status_file}"
   if [ "${find_status}" != "0" ]; then
      printf '%s\n' "${0##*/}: find failed (exit ${find_status}) under ${dir}; refusing to compare a possibly incomplete match set" >&2
      exit 2
   fi
   if [ "${#match_list[@]}" -ne 1 ]; then
      printf '%s\n' "${0##*/}: expected exactly one ${artifact_glob} under ${dir}, found ${#match_list[@]}" >&2
      exit 2
   fi
   found_artifact="${match_list[0]}"
}

find_artifact "${dir_a}"
artifact_a="${found_artifact}"
find_artifact "${dir_b}"
artifact_b="${found_artifact}"

## Don't compare a file to itself or a hard link of itself.
if [ "${artifact_a}" -ef "${artifact_b}" ]; then
   printf '%s\n' "${0##*/}: A and B are the same file (${artifact_a}); need two independent builds" >&2
   exit 2
fi

## Don't clobber artifacts.
if [ "${report}" -ef "${artifact_a}" ] || [ "${report}" -ef "${artifact_b}" ]; then
   printf '%s\n' "${0##*/}: --output would overwrite an input artifact (${report})" >&2
   exit 2
fi

## Make sure we can write the report before getting started.
if ! printf '%s' "" | tee -- "${report}" >/dev/null 2>&1; then
   printf '%s\n' "${0##*/}: --output is not writable: ${report}" >&2
   exit 2
fi

## sha256sum prepends backslashes to output lines when a filename contains a
## newline or backslash. We just want the hash, so trim that off if needed.
sha_a="$(sha256sum -- "${artifact_a}")"
sha_a="${sha_a%% *}"
sha_a="${sha_a#\\}"
sha_b="$(sha256sum -- "${artifact_b}")"
sha_b="${sha_b%% *}"
sha_b="${sha_b#\\}"

{
   printf '%s\n' "reproducibility artifact comparison"
   printf '%s\n' "  target: ${target}"
   printf '%s\n' "  A: ${artifact_a}"
   printf '%s\n' "     sha256 ${sha_a}"
   printf '%s\n' "  B: ${artifact_b}"
   printf '%s\n' "     sha256 ${sha_b}"
} > "${report}"

if [ "${sha_a}" = "${sha_b}" ]; then
   printf '%s\n' "RESULT: identical (reproducible)" | tee --append -- "${report}" || true
   exit 0
fi

{
   printf '%s\n' "RESULT: artifacts DIFFER"
   printf '%s\n' "diffoscope explanation follows (best-effort; may be truncated or skipped):"
} | tee --append -- "${report}"

## Try to explain the mismatch, constraining diffoscope and providing it mount
## points instead of whole images when possible so it is less likely to OOM.
## rc 1 = differences detected, higher return codes indicate a crash.
##
## Untrusted images CANNOT be safely compared here, this script can be abused
## by a hostile image to leak files like /etc/shadow, and huge attack surface
## in userspace and kernelspace is exposed here. If not running in a sandbox
## or VM, only compare images that were locally built or that are signed (and
## can be verified) with a trusted key. (In the future, we could use something
## like libguestfs/guestfish to make this safer.)
diffoscope_bounded() {
   local privilege_prefix="${3:-}"
   (
      ulimit -v 8000000 || true
      ${privilege_prefix} \
      env TMPDIR=/var/tmp \
      timeout --kill-after=30 900 \
         diffoscope --text - --max-report-size 4194304 --max-diff-block-lines 512 \
         --max-diff-input-lines 100000 --max-diff-block-lines-saved 10000 \
         --exclude 'boot/initrd*' --exclude 'boot/vmlinuz*' \
         --exclude 'initrd*' --exclude 'vmlinuz*' \
         -- "${1}" "${2}"
   )
}

nbd_a=""
nbd_b=""

## Claim a free nbd device.
nbd_device_claim() {
   local candidate candidate_name
   for candidate in /dev/nbd* ; do
      [ -b "${candidate}" ] || continue
      candidate_name="${candidate##*/}"
      ## Ignore nbd partition devices.
      case "${candidate_name}" in
         nbd[0-9]*p[0-9]*)
            continue
            ;;
      esac
      ## A device with no sysfs node cannot be probed for busy-ness, skip it.
      [ -d "/sys/block/${candidate_name}" ] || continue
      if [ -e "/sys/block/${candidate_name}/pid" ]; then
         continue
      fi
      printf '%s\n' "${candidate}"
      return 0
   done
   return 1
}

## Wait for a just-attached device to publish its sysfs state and partitions.
nbd_device_settle() {
   local device="$1" name attempt partition
   name="${device##*/}"
   ## The disk device and partition devices show up at different times, so we
   ## check for each independently.
   for attempt in {1..50}; do
      if [ -e "/sys/block/${name}/pid" ]; then
         break
      fi
      sleep 0.2
   done
   [ -e "/sys/block/${name}/pid" ] || return 1
   for attempt in {1..50}; do
      for partition in "${device}"p* ; do
         if [ -b "${partition}" ]; then
            return 0
         fi
      done
      sleep 0.2
   done
   ## No partitions is not a fatal error, a device may legitimately have no
   ## partitions.
   return 0
}

## Mount both qcow2 images read-only so the filesystems can be diffed.
qcow2_filesystem_mounts_setup() {
   local image_a="$1" image_b="$2" partition partition_b modprobe_failed
   modprobe_failed=false
   if ! sudo --non-interactive modprobe nbd max_part=16 2>/dev/null; then
      modprobe_failed=true
   fi

   ## We scan for nbd devices regardless of if modprobe succeeded, since
   ## containers may make modprobe fail but be able to see nbd devices from
   ## the host anyway.
   if ! nbd_device_claim >/dev/null; then
      if [ "${modprobe_failed}" = "true" ]; then
         printf '%s\n' "${0##*/}: nbd unavailable: 'modprobe nbd' failed and no usable /dev/nbd* block device exists. Load 'nbd' on the HOST (the container has no kmod); note 'max_part' only takes effect at load time." >&2
      else
         printf '%s\n' "${0##*/}: nbd unavailable: 'modprobe nbd' succeeded but no usable /dev/nbd* block device is present (nbds_max=0, or the nodes are all busy)." >&2
      fi
      return 1
   fi
   if [ "${modprobe_failed}" = "true" ]; then
      printf '%s\n' "${0##*/}: 'modprobe nbd' unavailable here; using an already-present /dev/nbd* device." >&2
   fi
   sudo --non-interactive mkdir --parents -- "${mount_a}" "${mount_b}" || return 1
   nbd_a="$(nbd_device_claim)" || return 1
   sudo --non-interactive qemu-nbd --read-only --format=qcow2 --connect="${nbd_a}" -- "${image_a}" || return 1
   ## Claim B only after A is attached and its sysfs 'pid' is visible,
   ## otherwise we may try to attach image B to nbd device A.
   nbd_device_settle "${nbd_a}" || return 1
   nbd_b="$(nbd_device_claim)" || return 1
   sudo --non-interactive qemu-nbd --read-only --format=qcow2 --connect="${nbd_b}" -- "${image_b}" || return 1
   nbd_device_settle "${nbd_b}" || return 1
   ## Search for the root partition, as its partition number may vary.
   for partition in "${nbd_a}"p* ; do
      [ -b "${partition}" ] || continue
      sudo --non-interactive mount --read-only --options nodev,nosuid,noexec -- "${partition}" "${mount_a}" 2>/dev/null || continue
      if [ -d "${mount_a}/etc" ]; then
         partition_b="${nbd_b}p${partition##*p}"
         if sudo --non-interactive mount --read-only --options nodev,nosuid,noexec -- "${partition_b}" "${mount_b}" 2>/dev/null; then
            return 0
         fi
      fi
      sudo --non-interactive umount -- "${mount_a}" 2>/dev/null || true
   done
   return 1
}

## Unmount, retrying repeatedly until it succeeds or a timeout is hit.
mount_point_release() {
   local mount_point="$1" attempt status=0
   ## The EXIT trap may run this when the mountpoint no longer exists.
   [ -d "${mount_point}" ] || return 0
   mountpoint --quiet -- "${mount_point}" || status="$?"
   ## 32 is mountpoint's "not a mountpoint": already released.
   if [ "${status}" = "32" ]; then
      return 0
   fi
   if [ "${status}" != "0" ]; then
      printf '%s\n' "${0##*/}: cannot determine whether ${mount_point} is mounted (mountpoint exit ${status})" >&2
      return 1
   fi
   for attempt in {1..25}; do
      if sudo --non-interactive umount -- "${mount_point}" 2>/dev/null; then
         return 0
      fi
      sleep 0.2
   done
   return 1
}

filesystem_mounts_teardown() {
   local released_a="yes" released_b="yes"
   mount_point_release "${mount_a}" || released_a="no"
   mount_point_release "${mount_b}" || released_b="no"
   ## Don't disconnect a still-mounted device.
   if [ -n "${nbd_a}" ]; then
      if [ "${released_a}" = "yes" ]; then
         sudo --non-interactive qemu-nbd --disconnect "${nbd_a}" >/dev/null 2>&1 || true
         nbd_a=""
      else
         printf '%s\n' "${0##*/}: ${mount_a} is still mounted; leaving ${nbd_a} attached rather than disconnecting a live mount" >&2
      fi
   fi
   if [ -n "${nbd_b}" ]; then
      if [ "${released_b}" = "yes" ]; then
         sudo --non-interactive qemu-nbd --disconnect "${nbd_b}" >/dev/null 2>&1 || true
         nbd_b=""
      else
         printf '%s\n' "${0##*/}: ${mount_b} is still mounted; leaving ${nbd_b} attached rather than disconnecting a live mount" >&2
      fi
   fi
}

mount_a="$(mktemp --directory)"
mount_b="$(mktemp --directory)"
inner_a=""
inner_b=""
diffoscope_rc=0
explained="no"

## The released qcow2 artifact is a 'tar --xz' holding the image; unpack both so the disk
## images themselves can be attached.
unpack_cleanup() {
   safe-rm --recursive --force -- "${unpack_a:-}" "${unpack_b:-}" 2>/dev/null || true
   unpack_a=""
   unpack_b=""
}

## Each of the trap functions below progressively increases the scope of
## cleanup as appropriate for when the trap is set.

# shellcheck disable=SC2317
mount_points_cleanup() {
   rmdir -- "${mount_a}" "${mount_b}" 2>/dev/null || true
}
trap mount_points_cleanup EXIT

# shellcheck disable=SC2317
unpack_and_mount_points_cleanup() {
   unpack_cleanup
   mount_points_cleanup
}

# shellcheck disable=SC2317
filesystem_and_unpack_cleanup() {
   filesystem_mounts_teardown
   unpack_cleanup
   mount_points_cleanup
}

if [ "${target}" = "qcow2" ]; then
   unpack_a="$(mktemp --directory --tmpdir=/var/tmp)"
   unpack_b="$(mktemp --directory --tmpdir=/var/tmp)"
   trap unpack_and_mount_points_cleanup EXIT
   if tar --extract --xz --file "${artifact_a}" --directory "${unpack_a}" 2>> "${report}" \
      && tar --extract --xz --file "${artifact_b}" --directory "${unpack_b}" 2>> "${report}"; then
      ## Same 'find' exit code capture trick as earlier.
      qcow2_status_file="$(mktemp)"
      mapfile -d '' -t qcow2_members_a < <(
         find_exit_status=0
         find "${unpack_a}" -type f -name '*.qcow2' -print0 | LC_ALL=C sort -z || find_exit_status="${PIPESTATUS[0]}"
         printf '%s\n' "${find_exit_status}" > "${qcow2_status_file}"
      )
      qcow2_status_a="$(cat -- "${qcow2_status_file}")"
      mapfile -d '' -t qcow2_members_b < <(
         find_exit_status=0
         find "${unpack_b}" -type f -name '*.qcow2' -print0 | LC_ALL=C sort -z || find_exit_status="${PIPESTATUS[0]}"
         printf '%s\n' "${find_exit_status}" > "${qcow2_status_file}"
      )
      qcow2_status_b="$(cat -- "${qcow2_status_file}")"
      safe-rm --force -- "${qcow2_status_file}"
      if [ "${qcow2_status_a}" != "0" ] || [ "${qcow2_status_b}" != "0" ]; then
         printf '%s\n' "(could not reliably enumerate the archive's qcow2 members (find exited ${qcow2_status_a}/${qcow2_status_b}); comparing the artifacts directly instead)" >> "${report}"
      elif [ "${#qcow2_members_a[@]}" -eq 1 ] && [ "${#qcow2_members_b[@]}" -eq 1 ]; then
         inner_a="${qcow2_members_a[0]}"
         inner_b="${qcow2_members_b[0]}"
      else
         printf '%s\n' "(archives hold ${#qcow2_members_a[@]} and ${#qcow2_members_b[@]} qcow2 member(s); the filesystem route handles exactly one, comparing the artifacts directly instead)" >> "${report}"
      fi
   fi
fi

if [ -n "${inner_a}" ] && [ -n "${inner_b}" ]; then
   trap filesystem_and_unpack_cleanup EXIT
   if qcow2_filesystem_mounts_setup "${inner_a}" "${inner_b}"; then
      {
         printf '%s\n' "(comparing the mounted filesystems; the disk images themselves exceed diffoscope's memory)"
         printf '%s\n' "(ignore 'Device:' lines below: the two trees are on different nbd devices, so that field always differs)"
      } >> "${report}"
      diffoscope_bounded "${mount_a}" "${mount_b}" "sudo --non-interactive" >> "${report}" 2>&1 \
         || diffoscope_rc="$?"
      ## diffoscope rc 1 = there are differences, but diffoscope rc 0 != there
      ## are not differences. The disk might be laid out differently, or there
      ## may be a kernel or initrd difference. Return codes higher than 1 are
      ## crashes.
      if [ "${diffoscope_rc}" = "1" ]; then
         explained="yes"
      elif [ "${diffoscope_rc}" = "0" ]; then
         ## Future idea; explicitly diff the excluded kernel/initrd here before
         ## falling back? Unclear this is needed.
         printf '%s\n' "(the mounted root filesystems showed no difference outside the excluded kernel and initrd paths, so the difference is either IN those excluded files (boot/vmlinuz*, boot/initrd*) or outside the filesystems entirely -- container metadata, qcow2 header, partition table or bootloader; comparing the artifacts directly)" >> "${report}"
      else
         printf '%s\n' "(the mounted comparison failed with exit ${diffoscope_rc}; comparing the artifacts directly instead)" >> "${report}"
      fi
   fi
   filesystem_mounts_teardown
   unpack_cleanup
fi

if [ "${explained}" = "no" ]; then
   diffoscope_rc=0
   diffoscope_bounded "${artifact_a}" "${artifact_b}" >> "${report}" 2>&1 || diffoscope_rc="$?"
   ## If diffoscope finds nothing and exits 0, we've hit a diffoscope bug (as
   ## we already know the artifacts differ). The excluded paths should not
   ## matter, as diffoscope should at least show a hex dump diff.
   if [ "${diffoscope_rc}" = "0" ]; then
      printf '%s\n' "(diffoscope bug: reported no differences!)" >> "${report}"
   fi
fi

rmdir -- "${mount_a}" "${mount_b}" 2>/dev/null || true

if [ "${diffoscope_rc}" -gt 1 ]; then
   printf '%s\n' "(diffoscope could not explain the diff (exit ${diffoscope_rc}); the sha256 mismatch above is the verdict)" >> "${report}"
fi

exit 4
