D1: teach SplitDisk directories, without moving a byte

SBFS version two adds directories out of space each entry had already set
aside: two of the four reserved bytes become a parent, and one of the seven
spare flag bits says an entry is a directory. The entry is still thirty two
bytes, so it still divides two hundred and fifty six and still never straddles
a block, and nothing in the block layer knows anything happened.

A directory is an entry with no blocks. That is what keeps the flat array of
entries the whole allocation map, which is the property the format is built
on: with files laid down contiguously, every block is inside some entry's
range or it is not, and an entry with no range is in nobody's way. There is
still no allocation table to consult and none to keep right.

THE PARENT IS AN INDEX PLUS ONE, so zero means the root. A version one disk has
zeroes in those bytes, and "in the root" is exactly where every file on a flat
disk is - so a version one image is already a valid version two image, with
nothing to convert and no tool to convert it with.

A disk is at the lowest version that describes what is on it. format makes a
version one disk and mkdir is what raises it, so everything built here stays
readable by a reader that has never heard of a directory right up until it
really does have one. That is what lets this land before the machine knows
anything: the whole existing suite passes untouched.

The tool gains mkdir and rmdir, and list, put, get and delete take paths. list
also now reports entries used against entries available, because a disk has two
ceilings and the entry one is the one nobody notices until it bites.

rmdir refuses a directory with anything in it, and that is not politeness:
parents are entry indices, a freed index gets handed out again, and the
children of a removed directory would reappear inside whatever took its place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
This commit is contained in:
Anachronaut
2026-08-24 18:41:11 -04:00
co-authored by Claude Opus 5
parent f4fb56606e
commit 78e9eef472
4 changed files with 623 additions and 54 deletions
+58
View File
@@ -97,6 +97,64 @@ 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)" ]
echo
if [ "$FAIL" -eq 0 ]; then
echo "All $PASS disk tool checks passed."