#!/usr/bin/env bash
#
# write-device.sh — dd an artifact to a block device, with the guards.
#
# The single implementation of the dd path. build/build-image.sh and
# build/build-iso.sh both call this; neither one writes a device itself.
# The rule is in wiki alloy-distribution ("Do not rewrite the dd path"):
# a second copy of these checks is how a disk-eating bug gets introduced,
# because the copy is the one that does not get the fix.
#
# Usage:
#   build/write-device.sh <artifact> <device> [label]
#
# label is what the device is called in the closing line ("raw", "installer
# ISO"); it is cosmetic and defaults to "image".
#
# Refuses partitions and anything with a mounted filesystem, warns on a
# non-removable target, demands the device path typed back, and verifies the
# result with cmp before claiming success.

set -euo pipefail

die() { printf 'error: %s\n' "$*" >&2; exit 1; }

# priv / privc. run0 where it exists, sudo where it does not; see the header
# of build/privilege.sh for which of the two a call site wants.
# shellcheck source=build/privilege.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/privilege.sh"

ARTIFACT="${1:?usage: write-device.sh <artifact> <device> [label]}"
DEVICE="${2:?usage: write-device.sh <artifact> <device> [label]}"
LABEL="${3:-image}"

privc test -f "$ARTIFACT" || die "$ARTIFACT is not a file"
[ -b "$DEVICE" ] || die "$DEVICE is not a block device"

# A whole disk, not a partition. An ISO written to /dev/sda1 produces
# nothing bootable and quietly eats a filesystem on the way.
[ "$(lsblk -dnro TYPE "$DEVICE")" = "disk" ] \
  || die "$DEVICE is not a whole disk; pass the disk, not a partition"

# Any mountpoint at or below the device, not just / and /boot. The old
# guard matched those two patterns only, so a disk holding /home, /var or
# an active swap passed it and got written. This is the rule the installer
# itself applies (install.rs, Disk::blocker): anything mounted blocks.
# `lsblk -r` escapes a newline as \x0a, so a device with two mountpoints
# arrives as one run-together line. Unescape before printing: this list is
# read by someone deciding whether to erase a disk.
mounts="$(lsblk -nro MOUNTPOINTS "$DEVICE" 2>/dev/null \
          | sed 's/\\x0a/\n/g' | grep -v '^$' || true)"
if [ -n "$mounts" ]; then
  printf 'error: %s has mounted filesystems; refusing to write:\n' "$DEVICE" >&2
  printf '%s\n' "$mounts" | sed 's/^/  /' >&2
  exit 1
fi

echo
lsblk -o NAME,SIZE,TYPE,MOUNTPOINTS,MODEL,SERIAL,TRAN,RM,RO "$DEVICE"
echo
# Removable is worth saying out loud: on this box the system disk is nvme
# and a USB stick reports usb/RM=1, so a non-removable target is the shape
# of a mistake even when nothing is mounted on it.
if [ "$(lsblk -dnro RM "$DEVICE")" != "1" ]; then
  echo "WARNING: $DEVICE is not removable. This is the shape of an internal disk."
  echo
fi
printf 'This ERASES all data on %s. Type the device path to confirm: ' "$DEVICE"
read -r reply
[ "$reply" = "$DEVICE" ] || die "confirmation did not match; not writing"

echo "==> Writing $ARTIFACT to $DEVICE"
# `status=progress` redraws one line with carriage returns, which is what a
# person watching a five-gigabyte write wants and exactly wrong for anything
# that keeps what it is given. A log, a pipe or an agent's transcript has no
# cursor to move, so every update is retained and the write arrives as a single
# line thousands of characters long with the useful part at the far end.
#
# So the progress goes to a terminal and nowhere else. Without one, dd's default
# still prints the transfer summary once, at the end, which is the whole of what
# a log wants from it. Checked on stderr because that is where dd writes both.
if [ -t 2 ]; then
  priv dd if="$ARTIFACT" of="$DEVICE" bs=4M oflag=direct conv=fsync status=progress
else
  privc dd if="$ARTIFACT" of="$DEVICE" bs=4M oflag=direct conv=fsync
fi
sync

# Verify, because dd reporting success is not evidence the bytes landed.
# cmp over exactly the artifact's length is the authoritative check; a
# `dd | head -c N | sha256sum` pipeline reported phantom corruption on a
# write that cmp proved perfect (wiki alloy-build-notes).
echo "==> Verifying the write"
size="$(privc stat -c %s "$ARTIFACT")"
# A check that cannot fail is not a check: prove cmp can still disagree
# before trusting it to agree.
if privc cmp -s -n 4096 "$DEVICE" /dev/zero; then
  die "negative control passed, which means cmp is not comparing anything"
fi
privc cmp -n "$size" "$DEVICE" "$ARTIFACT" \
  || die "$DEVICE does not match $ARTIFACT; the write is bad"
echo "==> Verified $size bytes."
echo "==> Done. $DEVICE is now a bootable Alloy $LABEL."
