The first rung of booting from disk. A boot area is blocks between the superblock and the directory that the filesystem never allocates and never sees, and NOTHING WAS ADDED TO RESERVE THEM: both implementations work out the first usable block as directoryStart + directoryBlocks, and directoryStart has always been a field rather than a constant. Formatting with the directory moved up reserves everything below it. Neither allocator changed, on either side. Two new superblock fields in bytes that were reserved: bootBlocks at 14, per slot, and bootSlot at 16. A disk made before this has zero in both, which reads as "no boot area" - true, and the same shape as the version two parent field, where the value an older disk already held was the right answer without conversion. TWO SLOTS, ALWAYS. A boot slot is raw blocks with no entry to rename, so the write-a-temporary-and-rename ordering that protects every file cannot protect it, and a machine interrupted while updating its only slot would not boot at all - the one failure on this disk with no way back. Writing the slot that is not live and then moving one byte makes that a machine that boots what it had before. bootBlocks and directoryStart say the same thing from two sides, so a disk where they disagree is refused rather than guessed at, as is one naming a slot that does not exist. Checked where it matters: the HOST formats a disk with a boot area and the MACHINE fills it, then the reserved blocks are compared against zero. The machine's allocator is the one that had no idea any of this was happening, which is what makes that the check worth having. Six host checks besides, including both halves of the superblock disagreeing.
202 lines
9.2 KiB
Bash
Executable File
202 lines
9.2 KiB
Bash
Executable File
#!/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 16bitSegmentedSieveModern.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
|
|
|
|
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
|