#!/usr/bin/env bash
# Checks SplitDisk against the SBFS format.
#
# The tool and the SplitBit side are two implementations of one written specification,
# and nothing but that document keeps them the same. This checks the host half on its
# own: that a file put onto a disk comes back off it byte for byte, that the sizes which
# exercise the block and tail arithmetic all survive, and that the things the format
# says cannot happen are refused rather than half done.
#
# Written by Anachronaut

set -u
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TOOL="$ROOT/SplitDisk"
WORK="$ROOT/Tests/build/disk"

PASS=0
FAIL=0
FAILED_NAMES=()

GREEN=$'\033[32m'; RED=$'\033[31m'; RESET=$'\033[0m'
[ -t 1 ] || { GREEN=""; RED=""; RESET=""; }

check() {
    local name="$1"; shift
    if "$@" >/dev/null 2>&1; then
        PASS=$((PASS + 1)); printf "  [%sok  %s] %s\n" "$GREEN" "$RESET" "$name"
    else
        FAIL=$((FAIL + 1)); FAILED_NAMES+=("$name")
        printf "  [%sFAIL%s] %s\n" "$RED" "$RESET" "$name"
    fi
}

# The opposite: the command is supposed to fail, and passing would be the bug.
refuses() {
    local name="$1"; shift
    if "$@" >/dev/null 2>&1; then
        FAIL=$((FAIL + 1)); FAILED_NAMES+=("$name")
        printf "  [%sFAIL%s] %s (it was allowed)\n" "$RED" "$RESET" "$name"
    else
        PASS=$((PASS + 1)); printf "  [%sok  %s] %s\n" "$GREEN" "$RESET" "$name"
    fi
}

if [ ! -x "$TOOL" ]; then
    echo "SplitDisk is not built."
    exit 1
fi

rm -rf "$WORK"; mkdir -p "$WORK"
cd "$WORK" || exit 1

echo "Checking SplitDisk against the SBFS format."

check "format a disk"                     "$TOOL" format work.img 64 2
refuses "refuse a disk with no room"      "$TOOL" format tiny.img 2 4
refuses "refuse an unformatted disk"      "$TOOL" list /dev/null

# The sizes that exercise every corner of blocks-plus-tail: nothing at all, less than a
# block, exactly a block, a part block, and an exact multiple.
: > empty.bin
printf 'x' > one.bin
head -c 256 /dev/urandom > exact.bin
head -c 700 /dev/urandom > part.bin
head -c 768 /dev/urandom > whole.bin

for f in empty.bin one.bin exact.bin part.bin whole.bin; do
    check "put $f" "$TOOL" put work.img "$f"
done

roundTrip() {
    "$TOOL" get work.img "$1" "got_$1" >/dev/null 2>&1 || return 1
    cmp -s "$1" "got_$1"
}
for f in empty.bin one.bin exact.bin part.bin whole.bin; do
    check "$f comes back byte for byte" roundTrip "$f"
done

refuses "refuse a name of 29 characters"  "$TOOL" put work.img part.bin twentyNineCharactersLong.asm
refuses "refuse a duplicate name"         "$TOOL" put work.img one.bin
refuses "refuse a file that is not there" "$TOOL" get work.img nosuch.bin out.bin
check   "delete"                          "$TOOL" delete work.img one.bin
refuses "the deleted file is gone"        "$TOOL" get work.img one.bin out.bin
check   "the name can be used again"      "$TOOL" put work.img one.bin

# Contiguous files mean a disk can have room without having room in one piece. That is a
# consequence of the format rather than a bug, so it is checked rather than worked around.
"$TOOL" format frag.img 16 1 >/dev/null 2>&1
head -c 1024 /dev/urandom > a.bin; cp a.bin b.bin; cp a.bin c.bin
"$TOOL" put frag.img a.bin >/dev/null 2>&1
"$TOOL" put frag.img b.bin >/dev/null 2>&1
"$TOOL" put frag.img c.bin >/dev/null 2>&1
"$TOOL" delete frag.img a.bin >/dev/null 2>&1
"$TOOL" delete frag.img c.bin >/dev/null 2>&1
head -c 2048 /dev/urandom > big.bin
refuses "refuse a file with no run long enough" "$TOOL" put frag.img big.bin
head -c 512 /dev/urandom > fits.bin
check   "but one that fits the gap goes on"     "$TOOL" put frag.img fits.bin

# ---- Directories ----
#
# Version two, which adds a parent to each entry and a flag bit saying an entry is a
# directory. Both come out of bytes the entry had already set aside, so nothing moved and
# a version one disk needs no converting: zero in those bytes means the root, which is
# exactly where every file on a flat disk is.
#
# The version is therefore a statement about what is ON a disk rather than about what made
# it, and these check that it is only raised when it becomes true.
"$TOOL" format tree.img 64 2 >/dev/null 2>&1
printf 'a file in the root' > root.txt
check   "a fresh disk is flat"            "$TOOL" put tree.img root.txt
version() { "$TOOL" list "$1" 2>/dev/null | head -1 | grep -q "version $2"; }
check   "and says it is version 1"        version tree.img 1
check   "make a directory"                "$TOOL" mkdir tree.img /Apps
check   "which raises it to version 2"    version tree.img 2
check   "make one inside it"              "$TOOL" mkdir tree.img /Apps/Source
check   "put a file down a path"          "$TOOL" put tree.img root.txt /Apps/Source/deep.txt

# The point of the whole exercise: a name means something different in each place, so the
# same one can be used twice without either being in the other's way.
check   "the same name in two places"     "$TOOL" put tree.img root.txt /Apps/root.txt
roundTripAt() {
    "$TOOL" get tree.img "$1" got_deep.txt >/dev/null 2>&1 || return 1
    cmp -s root.txt got_deep.txt
}
check   "it comes back byte for byte"     roundTripAt /Apps/Source/deep.txt
check   ". and .. walk the path"          roundTripAt /Apps/./Source/../root.txt
check   ".. from the root is the root"    roundTripAt /Apps/../../root.txt

# Each of these is a way the tree could be made to contradict itself, and each is refused
# rather than half done.
refuses "no file where a directory goes"  "$TOOL" put tree.img root.txt /root.txt/x.txt
refuses "no putting into thin air"        "$TOOL" put tree.img root.txt /Nowhere/x.txt
refuses "no duplicate in one directory"   "$TOOL" mkdir tree.img /Apps
refuses "no getting a directory"          "$TOOL" get tree.img /Apps out.bin
refuses "delete will not take a directory" "$TOOL" delete tree.img /Apps
refuses "rmdir will not take a file"      "$TOOL" rmdir tree.img /root.txt
refuses "nor the root"                    "$TOOL" rmdir tree.img /

# THE REFUSAL THAT MATTERS MOST. Parents are entry indices and a freed index is handed out
# again, so removing a directory with things still in it would let the next file created
# adopt them. Emptying it first is the only safe order.
refuses "no removing an occupied one"     "$TOOL" rmdir tree.img /Apps/Source
check   "empty it first"                  "$TOOL" delete tree.img /Apps/Source/deep.txt
check   "then it goes"                    "$TOOL" rmdir tree.img /Apps/Source

# A path is names with separators between them, and a name is still twenty two characters.
refuses "refuse a 23 character component" "$TOOL" mkdir tree.img /Apps/abcdefghijklmnopqrstuvw
refuses "refuse a path naming nothing"    "$TOOL" mkdir tree.img /Apps/

# A directory costs an entry and no blocks at all, which is what keeps the flat array of
# entries the whole allocation map. If a directory ever took a block, this would drop.
blocksFree() { "$TOOL" list "$1" 2>/dev/null | tail -1 | sed 's/.*used, //; s/ blocks free.*//'; }
before=$(blocksFree tree.img)
"$TOOL" mkdir tree.img /Empty >/dev/null 2>&1
check   "a directory costs no blocks"     [ "$before" = "$(blocksFree tree.img)" ]

# ---- A directory no bigger than the parent field can name ----
#
# Eight entries to a block and the parent is an index plus one in two bytes, so entry
# 65535 has no parent number: adding one wraps to zero, and zero is the root. Such an
# entry does not refuse what is put inside it. It writes the thing into the ROOT while
# reporting the path that was asked for, and then cannot find it again - so the same
# create succeeds over and over, piling up entries of one name in one directory, which is
# the exact corruption rename exists to refuse.
refuses "no directory past the wrap"      "$TOOL" format huge.img 65535 8192
check   "the largest that fits"           "$TOOL" format huge.img 65535 8191

# And a disk claiming one, which is what something that never checked would have written.
# The claim is in the superblock, so it does not need a disk that size to be made.
"$TOOL" format lying.img 64 2 >/dev/null
printf '\x20\x00' | dd of=lying.img bs=1 seek=10 conv=notrunc status=none
refuses "nor reading one that claims it"  "$TOOL" list lying.img

# ---- A boot area, and the two halves of the superblock that describe it ----
#
# bootBlocks and directoryStart say the same thing from two sides, so a disk where they
# disagree is one where there is no way to tell which is wrong. Both are refused.
check   "format with a boot area"         "$TOOL" format boot.img 512 4 32
check   "and it reads back"               "$TOOL" list boot.img
refuses "no boot area bigger than a disk" "$TOOL" format small.img 32 2 64
check   "and none at all is still fine"   "$TOOL" format plain.img 64 2

bootField() { python3 -c "
import sys
f = open(sys.argv[1], 'r+b'); f.seek(int(sys.argv[2])); f.write(bytes.fromhex(sys.argv[3]))
" "$@"; }
cp boot.img lying.boot.img
bootField lying.boot.img 14 0010          # Claims 16 blocks a slot, directory says 32.
refuses "nor a boot area that disagrees"  "$TOOL" list lying.boot.img
cp boot.img badslot.img
bootField badslot.img 16 07               # Names slot 7, and there are two.
refuses "nor a slot that does not exist"  "$TOOL" list badslot.img

# ---- Writing a boot slot, and choosing between them ----
#
# Two commands rather than one, deliberately: writing a slot and starting from it are
# different decisions, and joining them would make every write a commitment.
printf 'not really a bootloader' > stage.bin
check   "write a boot slot"               "$TOOL" boot boot.img stage.bin 0
check   "and the other one"               "$TOOL" boot boot.img stage.bin 1
check   "choose which one starts"         "$TOOL" bootslot boot.img 1
refuses "no third slot to write"          "$TOOL" boot boot.img stage.bin 2
refuses "nor a third to choose"           "$TOOL" bootslot boot.img 2
refuses "no boot slot without an area"    "$TOOL" boot plain.img stage.bin 0

# A slot holds what it holds. Something too big for one is refused rather than cut off,
# because half a bootloader is the failure with no way back.
head -c 9000 /dev/zero > toobig.bin        # A slot on boot.img is 32 blocks, so 8192.
refuses "nor more than a slot holds"      "$TOOL" boot boot.img toobig.bin 0

# THE WHOLE SLOT IS WRITTEN, not just the part the file fills. A slot still holding the
# tail of whatever was there before is one whose contents depend on its history.
printf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' > long.bin
"$TOOL" boot boot.img long.bin 0 >/dev/null
"$TOOL" boot boot.img stage.bin 0 >/dev/null
check   "and it is written whole"         python3 -c "
import sys
d = open('boot.img','rb').read()
slot = d[256:256 + 32 * 256]
sys.exit(1 if b'aaaa' in slot else 0)"

# ---- How the last start went ----
check   "the boot state reads"            "$TOOL" bootstate boot.img
check   "and can be set"                  "$TOOL" bootstate boot.img 2
refuses "but only to a state there is"    "$TOOL" bootstate boot.img 7
check   "a fresh disk is settled"         python3 -c "
import sys
sys.exit(0 if open('plain.img','rb').read()[17] == 0 else 1)"

# ---- Mirroring a host directory ----
#
# What the system disk is built with. Every file goes through put and every directory
# through mkdir, so this is a walk over machinery already checked above - what wants
# checking is the walk: that it goes all the way down, that it leaves behind what it was
# told to, and that it REFUSES a name the format cannot hold rather than skipping it, since
# a disk quietly missing a file is the failure a mirror exists to prevent.
mkdir -p tree/inner/deeper tree/leave
printf 'top' > tree/top.txt
printf 'inner' > tree/inner/middle.txt
printf 'deep' > tree/inner/deeper/bottom.txt
printf 'not this' > tree/leave/ignored.txt
: > tree/.hidden

"$TOOL" format mirror.img 256 8 >/dev/null
check "mirror a directory tree"           "$TOOL" mirror mirror.img tree /

"$TOOL" list mirror.img > mirrored.txt 2>&1
grep -q '/inner/deeper/bottom.txt' mirrored.txt \
    && { PASS=$((PASS + 1)); printf "  [%sok  %s] %s\n" "$GREEN" "$RESET" "it goes all the way down"; } \
    || { FAIL=$((FAIL + 1)); FAILED_NAMES+=("depth"); printf "  [%sFAIL%s] %s\n" "$RED" "$RESET" "it goes all the way down"; }
grep -q 'hidden' mirrored.txt \
    && { FAIL=$((FAIL + 1)); FAILED_NAMES+=("hidden"); printf "  [%sFAIL%s] %s\n" "$RED" "$RESET" "and leaves dotfiles behind"; } \
    || { PASS=$((PASS + 1)); printf "  [%sok  %s] %s\n" "$GREEN" "$RESET" "and leaves dotfiles behind"; }

# Named on the command line, which is how a project keeps what it builds out of what it
# wrote.
"$TOOL" format skipped.img 256 8 >/dev/null
check "mirror with something left out"    "$TOOL" mirror skipped.img tree / leave
"$TOOL" list skipped.img > skipped.txt 2>&1
grep -q 'ignored.txt' skipped.txt \
    && { FAIL=$((FAIL + 1)); FAILED_NAMES+=("skip"); printf "  [%sFAIL%s] %s\n" "$RED" "$RESET" "and the skipped one is not there"; } \
    || { PASS=$((PASS + 1)); printf "  [%sok  %s] %s\n" "$GREEN" "$RESET" "and the skipped one is not there"; }

# Twenty-three characters, one more than a directory entry holds.
printf 'too long' > tree/aNameOfTwentyThreeChars
"$TOOL" format refused.img 256 8 >/dev/null
refuses "a name too long stops the mirror" "$TOOL" mirror refused.img tree /
rm -f tree/aNameOfTwentyThreeChars

echo
if [ "$FAIL" -eq 0 ]; then
    echo "All $PASS disk tool checks passed."
    exit 0
fi
echo "$PASS passed, $FAIL failed: ${FAILED_NAMES[*]}"
exit 1
