#!/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