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
+22 -5
View File
@@ -117,16 +117,33 @@ Without `-o` the output takes the source file's name, in the directory you calle
| Command | What it does |
| --- | --- |
| `format <image> [blocks] [dirblocks]` | Lay down a fresh filesystem. 512 blocks and 8 of directory by default, which is 128K and room for 64 files. |
| `list <image>` | Show what is on the disk. |
| `put <image> <file> [name]` | Put a host file onto it. Without a name it uses the file's own, which is often longer than the 22 characters a name may be. |
| `get <image> <name> [file]` | Take one off it. |
| `delete <image> <name>` | Remove one. |
| `format <image> [blocks] [dirblocks]` | Lay down a fresh filesystem. 512 blocks and 8 of directory by default, which is 128K and room for 64 entries. |
| `list <image> [path]` | Show the whole disk, or one directory of it. |
| `put <image> <file> [path]` | Put a host file onto it. Without a path it uses the file's own name, which is often longer than the 22 characters a name may be. |
| `get <image> <path> [file]` | Take one off it. |
| `delete <image> <path>` | Remove a file. |
| `mkdir <image> <path>` | Make a directory. |
| `rmdir <image> <path>` | Remove an empty one. |
SplitDisk speaks the same on disk format SplitBit does, so an image it makes is one the machine can read, and one the machine writes is one it can read back. It is a convenience rather than a necessity: SplitBit writes its own filesystem, and now assembles its own programs, so a disk can be filled without leaving the machine.
Files are laid down contiguously, so a disk can have free blocks without having them in one piece. When that happens `put` says so rather than putting part of a file on.
A path is names with `/` between them, always from the root, since a command line tool has nowhere to keep a working directory between one run and the next. `.` and `..` mean what they usually do, and `..` from the root is the root.
A disk has two ceilings and it is usually the less obvious one that bites: blocks, and **entries**. Every file and every directory costs one entry, and `list` says how many of them are gone as well as how many blocks are. On a disk of small files the entries run out long before the space does, which is a matter of how the disk was formatted rather than a limit of the format - `dirblocks` is carried per disk, and each one is 256 bytes and holds eight entries.
### Two Versions:
| Version | What it means |
| --- | --- |
| 1 | Flat. Every file is in the root, because there is nowhere else. |
| 2 | Directories. Each entry says which directory it is in. |
**A version one disk is already a valid version two disk.** The parent is stored as an entry index *plus one*, so the zeroes a version one disk has in those bytes read as "in the root" - which is exactly where all of its files are. There is nothing to convert.
A disk is at the lowest version that describes what is on it, so `format` makes a version one disk and `mkdir` is what raises it. That is deliberate: a disk stays readable by anything that has never heard of a directory right up until it actually has one. Compatibility runs one way, which is the ordinary shape of it - version one code reading a version two disk would list directories as strange empty files.
## Building Programs With Make:
The assembler is built to work with make. `-o` puts the output where the build system wants it, and `-M` writes out which libraries went into it, so that editing a library reassembles everything that includes it.
+495 -44
View File
@@ -51,6 +51,7 @@ static int writeBlock(FILE *image, uint16_t block, const uint8_t *from) {
}
typedef struct {
uint8_t version;
uint16_t diskBlocks;
uint16_t directoryStart;
uint16_t directoryBlocks;
@@ -68,11 +69,16 @@ static int readSuperblock(FILE *image, Superblock *super) {
fprintf(stderr, "Error: That is not a SplitBit disk. Format it first.\n");
return 1;
}
if (block[SBFS_SUPER_VERSION] != SBFS_VERSION) {
fprintf(stderr, "Error: That disk is version %u, and this understands version %u.\n",
block[SBFS_SUPER_VERSION], SBFS_VERSION);
// Either version is read. A version one disk has zeroes in the bytes version two
// uses for a parent, and zero is the root, so every file on it reads as a file in the
// root - which is exactly where it is. Nothing is converted here.
if (block[SBFS_SUPER_VERSION] != SBFS_VERSION_FLAT
&& block[SBFS_SUPER_VERSION] != SBFS_VERSION_TREE) {
fprintf(stderr, "Error: That disk is version %u, and this understands %u and %u.\n",
block[SBFS_SUPER_VERSION], SBFS_VERSION_FLAT, SBFS_VERSION_TREE);
return 1;
}
super->version = block[SBFS_SUPER_VERSION];
super->diskBlocks = readWord(block + SBFS_SUPER_DISK);
super->directoryStart = readWord(block + SBFS_SUPER_DIRSTART);
super->directoryBlocks = readWord(block + SBFS_SUPER_DIRBLOCKS);
@@ -84,7 +90,10 @@ static int writeSuperblock(FILE *image, const Superblock *super) {
uint8_t block[SBFS_BLOCK_BYTES];
memset(block, 0, sizeof(block));
memcpy(block, SBFS_MAGIC, SBFS_MAGIC_BYTES);
block[SBFS_SUPER_VERSION] = SBFS_VERSION;
// Written back at the version it was read at. Putting a file on a flat disk leaves it
// flat; only creating a directory on it makes the difference real, and only mkdir
// raises the number.
block[SBFS_SUPER_VERSION] = super->version;
writeWord(block + SBFS_SUPER_DISK, super->diskBlocks);
writeWord(block + SBFS_SUPER_DIRSTART, super->directoryStart);
writeWord(block + SBFS_SUPER_DIRBLOCKS, super->directoryBlocks);
@@ -136,6 +145,21 @@ static int entryInUse(const uint8_t *entry) {
return (entry[SBFS_ENTRY_FLAGS] & SBFS_FLAG_IN_USE) != 0;
}
static int entryIsDirectory(const uint8_t *entry) {
return (entry[SBFS_ENTRY_FLAGS] & SBFS_FLAG_DIRECTORY) != 0;
}
// The index of the entry this one lives in, or -1 for the root. Stored as index plus one
// so that a zeroed field - which is what every version one entry has - means the root.
static int entryParent(const uint8_t *entry) {
return SBFS_PARENT_INDEX(readWord(entry + SBFS_ENTRY_PARENT));
}
static void entrySetParent(uint8_t *entry, int parent) {
writeWord(entry + SBFS_ENTRY_PARENT,
parent < 0 ? SBFS_PARENT_ROOT : SBFS_PARENT_OF(parent));
}
static uint32_t entrySize(const uint8_t *entry) {
return (uint32_t)readWord(entry + SBFS_ENTRY_BLOCKS) * SBFS_BLOCK_BYTES
+ entry[SBFS_ENTRY_TAIL];
@@ -153,13 +177,58 @@ static void entryName(const uint8_t *entry, char *into) {
into[SBFS_NAME_BYTES] = '\0';
}
static int findByName(const Directory *directory, const char *name) {
// ---- Paths ----
//
// A path is names separated by '/', and every path this tool is given is from the root:
// there is no working directory on the host, and one would have nowhere to live between
// two runs of a command line tool. The machine is the side that gets one of those.
//
// The root is not an entry. It is the absence of a parent, so -1 stands for it throughout
// and is a perfectly good answer rather than a failure - which is why every function here
// returns its outcome separately from the index it found.
// How long a path this tool will carry. Nothing in the format says: a path is not stored
// anywhere, it is only ever walked, and what is stored is one name and one parent.
#define SBFS_PATH_BYTES 512
// Copies the next name out of a path and returns where the path goes on, or NULL when
// there are no more. Empty pieces - a leading separator, a doubled one, a trailing one -
// are skipped rather than refused, so "/Apps/" and "Apps" walk the same way.
static const char *nextComponent(const char *path, char *into, int *tooLong) {
while (*path == SBFS_SEPARATOR) {
path++;
}
if (*path == '\0') {
return NULL;
}
size_t n = 0;
while (*path != '\0' && *path != SBFS_SEPARATOR) {
if (n < SBFS_NAME_BYTES) {
into[n] = *path;
}
n++;
path++;
}
*tooLong = (n > SBFS_NAME_BYTES);
into[n > SBFS_NAME_BYTES ? SBFS_NAME_BYTES : n] = '\0';
return path;
}
// One named child of one directory. `parent` is an entry index, or -1 for the root.
//
// This is the whole of what a directory is. There is no list of children anywhere: being
// a child is a fact written in the child, so finding them means looking at all of them.
// That is the same walk the flat version did, with one more thing compared.
static int findIn(const Directory *directory, const char *name, int parent) {
char held[SBFS_NAME_BYTES + 1];
for (int i = 0; i < directory->entries; i++) {
const uint8_t *entry = entryAt(directory, i);
if (!entryInUse(entry)) {
continue;
}
if (entryParent(entry) != parent) {
continue;
}
entryName(entry, held);
if (strcmp(held, name) == 0) {
return i;
@@ -168,6 +237,102 @@ static int findByName(const Directory *directory, const char *name) {
return -1;
}
// Walks a whole path. Zero if it got there, and then *index is the entry it names or -1
// for the root. Anything else leaves *why saying what stopped it.
static int resolve(const Directory *directory, const char *path, int *index,
const char **why) {
int at = -1;
char name[SBFS_NAME_BYTES + 1];
int tooLong = 0;
const char *rest = path;
while ((rest = nextComponent(rest, name, &tooLong)) != NULL) {
if (tooLong) {
*why = "has a name longer than 22 characters in it";
return 1;
}
if (strcmp(name, ".") == 0) {
continue;
}
if (strcmp(name, "..") == 0) {
if (at >= 0) {
at = entryParent(entryAt(directory, at));
}
continue; // ".." from the root is the root.
}
// Only a directory can be walked through. The LAST thing on the path is not
// checked here, because only the caller knows whether it wanted a file or a
// directory, and it can say so far better than this can.
if (at >= 0 && !entryIsDirectory(entryAt(directory, at))) {
*why = "has something in it that is not a directory";
return 1;
}
int found = findIn(directory, name, at);
if (found < 0) {
*why = "is not there";
return 1;
}
at = found;
}
*index = at;
return 0;
}
// Walks all but the last name, so that a caller can make the last one. *parent comes back
// as the directory to make it in, and `leaf` as the name to make. `leaf` must have room
// for SBFS_NAME_BYTES + 1.
static int resolveParent(const Directory *directory, const char *path, char *leaf,
int *parent, const char **why) {
const char *cut = strrchr(path, SBFS_SEPARATOR);
const char *last = cut ? cut + 1 : path;
if (*last == '\0') {
*why = "ends in a separator, so it does not name anything";
return 1;
}
if (strlen(last) > SBFS_NAME_BYTES) {
*why = "ends in a name longer than 22 characters";
return 1;
}
if (strcmp(last, ".") == 0 || strcmp(last, "..") == 0) {
*why = "ends in a name that is already taken by the filesystem";
return 1;
}
strcpy(leaf, last);
if (cut == NULL) {
*parent = -1; // A bare name belongs in the root.
return 0;
}
char head[SBFS_PATH_BYTES];
size_t n = (size_t)(cut - path);
if (n >= sizeof(head)) {
*why = "is longer than this tool will carry";
return 1;
}
memcpy(head, path, n);
head[n] = '\0';
return resolve(directory, head, parent, why); // "" is the root, which is correct.
}
// Whether something can be made inside *parent at all. The root always can.
static int parentIsUsable(const Directory *directory, int parent, const char *path) {
if (parent >= 0 && !entryIsDirectory(entryAt(directory, parent))) {
fprintf(stderr, "Error: \"%s\" is inside something that is not a directory.\n", path);
return 0;
}
return 1;
}
// Whether a directory has anything in it. Nothing points down, so this is the only way
// to know: something is in it if something says it is.
static int directoryHasChildren(const Directory *directory, int parent) {
for (int i = 0; i < directory->entries; i++) {
const uint8_t *entry = entryAt(directory, i);
if (entryInUse(entry) && entryParent(entry) == parent) {
return 1;
}
}
return 0;
}
// Where the first free run of the wanted length begins, or -1 if there is not one.
//
// There is no allocation bitmap, and that is the design rather than an omission: with
@@ -248,6 +413,12 @@ static int commandFormat(const char *path, uint16_t blocks, uint16_t directoryBl
}
}
Superblock super;
// A DISK IS AT THE LOWEST VERSION THAT DESCRIBES WHAT IS ON IT, and a fresh one has
// no directories on it, so it is flat and flat is version one. The number says what
// the disk contains, not which tool made it - which is what lets everything built
// here still be mounted by a reader that has never heard of a directory. mkdir is
// what raises it, because mkdir is what makes the difference true.
super.version = SBFS_VERSION_FLAT;
super.diskBlocks = blocks;
super.directoryStart = SBFS_FIRST_DIRECTORY_BLOCK;
super.directoryBlocks = directoryBlocks;
@@ -262,7 +433,80 @@ static int commandFormat(const char *path, uint16_t blocks, uint16_t directoryBl
return 0;
}
static int commandList(const char *path) {
// The full path of an entry, built by walking up the parents and writing them out
// backwards. Nothing stores a path, so this is the only way to have one.
//
// The depth cap is what makes this safe on a disk whose parents form a loop: walking up
// would otherwise never reach the root. Running out of depth is reported as not fitting,
// which is what it is.
static int entryPath(const Directory *directory, int index, char *into, size_t room) {
int chain[64];
int depth = 0;
while (index >= 0 && depth < (int)(sizeof(chain) / sizeof(chain[0]))) {
chain[depth++] = index;
index = entryParent(entryAt(directory, index));
}
if (index >= 0) {
return -1; // Deeper than this will walk, or a loop.
}
size_t at = 0;
char name[SBFS_NAME_BYTES + 1];
for (int i = depth - 1; i >= 0; i--) {
entryName(entryAt(directory, chain[i]), name);
size_t n = strlen(name);
if (at + n + 2 > room) {
return -1;
}
into[at++] = SBFS_SEPARATOR;
memcpy(into + at, name, n);
at += n;
}
into[at] = '\0';
return (int)at;
}
// What a listing added up to. Kept together because the two numbers are only meaningful
// beside each other: a disk runs out of entries, not of files.
typedef struct {
int files;
int directories;
} Tally;
// Prints one directory and everything under it, depth first and in entry order, which is
// the order the machine will walk them in too.
//
// Recursing from the root can never loop, because it only ever descends into entries
// whose parent is where it already is. A cycle among entries is therefore not an infinite
// walk - it is a set of entries this never reaches, which is what the caller counts.
static void listTree(const Directory *directory, int parent, char *prefix, size_t at,
Tally *tally) {
char name[SBFS_NAME_BYTES + 1];
for (int i = 0; i < directory->entries; i++) {
const uint8_t *entry = entryAt(directory, i);
if (!entryInUse(entry) || entryParent(entry) != parent) {
continue;
}
entryName(entry, name);
size_t n = strlen(name);
if (at + n + 2 >= SBFS_PATH_BYTES) {
continue; // Deeper than this tool will print.
}
prefix[at] = SBFS_SEPARATOR;
memcpy(prefix + at + 1, name, n + 1);
if (entryIsDirectory(entry)) {
printf("%-34s %8s %7s %7s\n", prefix, "dir", "-", "-");
tally->directories++;
listTree(directory, i, prefix, at + 1 + n, tally);
} else {
printf("%-34s %8u %7u %7u\n", prefix, entrySize(entry),
readWord(entry + SBFS_ENTRY_START), entryBlocksUsed(entry));
tally->files++;
}
prefix[at] = '\0';
}
}
static int commandList(const char *path, const char *within) {
FILE *image = openImage(path, "rb");
if (image == NULL) {
return 1;
@@ -273,29 +517,78 @@ static int commandList(const char *path) {
fclose(image);
return 1;
}
printf("%s: %u blocks, %u of directory, %u entries.\n",
path, super.diskBlocks, super.directoryBlocks, directory.entries);
printf("%-22s %8s %7s %7s\n", "NAME", "BYTES", "START", "BLOCKS");
char name[SBFS_NAME_BYTES + 1];
int shown = 0;
for (int i = 0; i < directory.entries; i++) {
const uint8_t *entry = entryAt(&directory, i);
if (!entryInUse(entry)) {
continue;
printf("%s: %u blocks, %u of directory, %u entries, version %u.\n",
path, super.diskBlocks, super.directoryBlocks, directory.entries,
super.version);
printf("%-34s %8s %7s %7s\n", "NAME", "BYTES", "START", "BLOCKS");
char prefix[SBFS_PATH_BYTES];
Tally tally = { 0, 0 };
int start = -1;
if (within != NULL) {
const char *why = NULL;
if (resolve(&directory, within, &start, &why)) {
fprintf(stderr, "Error: \"%s\" %s.\n", within, why);
free(directory.bytes);
fclose(image);
return 1;
}
entryName(entry, name);
printf("%-22s %8u %7u %7u\n", name, entrySize(entry),
readWord(entry + SBFS_ENTRY_START), entryBlocksUsed(entry));
shown++;
if (start >= 0 && !entryIsDirectory(entryAt(&directory, start))) {
fprintf(stderr, "Error: \"%s\" is not a directory.\n", within);
free(directory.bytes);
fclose(image);
return 1;
}
}
// Listing one directory still prints whole paths, because a path that is only true
// relative to an argument the reader cannot see is worse than no path at all.
size_t from = 0;
prefix[0] = '\0';
if (start >= 0) {
int n = entryPath(&directory, start, prefix, sizeof(prefix));
if (n < 0) {
fprintf(stderr, "Error: \"%s\" is nested too deep to print.\n", within);
free(directory.bytes);
fclose(image);
return 1;
}
from = (size_t)n;
}
listTree(&directory, start, prefix, from, &tally);
// The count in the superblock is a cache, so say what the directory actually adds up
// to as well. If the two ever disagree, the directory is the one to believe.
uint16_t counted = countFree(&directory, &super);
printf("%d file%s, %u blocks free", shown, shown == 1 ? "" : "s", counted);
printf("%d file%s", tally.files, tally.files == 1 ? "" : "s");
if (tally.directories > 0) {
printf(", %d director%s", tally.directories,
tally.directories == 1 ? "y" : "ies");
}
// Entries are the ceiling nobody notices until they hit it, and on a disk of small
// files they run out long before the blocks do. Saying both means never having to
// work out which one is about to bite.
printf(", %d of %d entries used, %u blocks free",
tally.files + tally.directories, directory.entries, counted);
if (counted != super.freeBlocks) {
printf(" (the superblock says %u, which is stale)", super.freeBlocks);
}
printf(".\n");
// Everything in use should have been reached by walking down from the root. Anything
// that was not is pointing at a parent that is not there, or at itself, and that is
// worth saying out loud rather than quietly leaving off the listing.
if (within == NULL) {
int inUse = 0;
for (int i = 0; i < directory.entries; i++) {
if (entryInUse(entryAt(&directory, i))) {
inUse++;
}
}
if (inUse != tally.files + tally.directories) {
printf("%d entr%s in use but not reachable from the root.\n",
inUse - (tally.files + tally.directories),
inUse - (tally.files + tally.directories) == 1 ? "y is" : "ies are");
}
}
free(directory.bytes);
fclose(image);
return 0;
@@ -316,14 +609,6 @@ static int commandPut(const char *path, const char *hostFile, const char *asName
return 1;
}
if (strlen(asName) > SBFS_NAME_BYTES) {
fprintf(stderr, "Error: \"%s\" is %zu characters, and a name may be %d.\n"
" Give a shorter one as the last argument.\n",
asName, strlen(asName), SBFS_NAME_BYTES);
fclose(source);
return 1;
}
FILE *image = openImage(path, "r+b");
if (image == NULL) {
fclose(source);
@@ -336,8 +621,20 @@ static int commandPut(const char *path, const char *hostFile, const char *asName
fclose(image);
return 1;
}
if (findByName(&directory, asName) >= 0) {
fprintf(stderr, "Error: \"%s\" is already on the disk. Delete it first.\n", asName);
// Where it goes and what it is called are one argument. A bare name still means the
// root, so every command line that worked before this still works.
char leaf[SBFS_NAME_BYTES + 1];
int parent = -1;
const char *why = NULL;
if (resolveParent(&directory, asName, leaf, &parent, &why)) {
fprintf(stderr, "Error: \"%s\" %s.\n", asName, why);
goto failed;
}
if (!parentIsUsable(&directory, parent, asName)) {
goto failed;
}
if (findIn(&directory, leaf, parent) >= 0) {
fprintf(stderr, "Error: \"%s\" is already there. Delete it first.\n", asName);
goto failed;
}
int slot = -1;
@@ -380,7 +677,8 @@ static int commandPut(const char *path, const char *hostFile, const char *asName
writeWord(entry + SBFS_ENTRY_START, (uint16_t)start);
writeWord(entry + SBFS_ENTRY_BLOCKS, whole);
entry[SBFS_ENTRY_TAIL] = tail;
memcpy(entry + SBFS_ENTRY_NAME, asName, strlen(asName));
memcpy(entry + SBFS_ENTRY_NAME, leaf, strlen(leaf));
entrySetParent(entry, parent);
super.freeBlocks = countFree(&directory, &super);
if (writeDirectory(image, &super, &directory) || writeSuperblock(image, &super)) {
@@ -410,9 +708,16 @@ static int commandGet(const char *path, const char *name, const char *hostFile)
fclose(image);
return 1;
}
int slot = findByName(&directory, name);
if (slot < 0) {
fprintf(stderr, "Error: There is no \"%s\" on that disk.\n", name);
int slot = -1;
const char *why = NULL;
if (resolve(&directory, name, &slot, &why) || slot < 0) {
fprintf(stderr, "Error: \"%s\" %s.\n", name, why ? why : "is the root, which is not a file");
free(directory.bytes);
fclose(image);
return 1;
}
if (entryIsDirectory(entryAt(&directory, slot))) {
fprintf(stderr, "Error: \"%s\" is a directory. There is nothing to take off.\n", name);
free(directory.bytes);
fclose(image);
return 1;
@@ -460,9 +765,18 @@ static int commandDelete(const char *path, const char *name) {
fclose(image);
return 1;
}
int slot = findByName(&directory, name);
if (slot < 0) {
fprintf(stderr, "Error: There is no \"%s\" on that disk.\n", name);
int slot = -1;
const char *why = NULL;
if (resolve(&directory, name, &slot, &why) || slot < 0) {
fprintf(stderr, "Error: \"%s\" %s.\n", name, why ? why : "is the root, which cannot be deleted");
free(directory.bytes);
fclose(image);
return 1;
}
// delete is for files and rmdir is for directories, so that neither can be the one
// that took away more than was asked for.
if (entryIsDirectory(entryAt(&directory, slot))) {
fprintf(stderr, "Error: \"%s\" is a directory. Use rmdir.\n", name);
free(directory.bytes);
fclose(image);
return 1;
@@ -481,19 +795,142 @@ static int commandDelete(const char *path, const char *name) {
return failed;
}
// ---- mkdir ----
//
// A directory costs one entry and no blocks at all. Its start, blocks and tail stay zero,
// which is what keeps it out of the allocator's way: with files laid down contiguously an
// entry with no range cannot overlap anything.
//
// This is also the only thing that raises a disk from version one to version two, because
// it is the only thing that makes the difference between them real.
static int commandMakeDirectory(const char *path, const char *name) {
FILE *image = openImage(path, "r+b");
if (image == NULL) {
return 1;
}
Superblock super;
Directory directory;
if (readSuperblock(image, &super) || readDirectory(image, &super, &directory)) {
fclose(image);
return 1;
}
char leaf[SBFS_NAME_BYTES + 1];
int parent = -1;
const char *why = NULL;
int failed = 1;
if (resolveParent(&directory, name, leaf, &parent, &why)) {
fprintf(stderr, "Error: \"%s\" %s.\n", name, why);
goto done;
}
if (!parentIsUsable(&directory, parent, name)) {
goto done;
}
if (findIn(&directory, leaf, parent) >= 0) {
fprintf(stderr, "Error: \"%s\" is already there.\n", name);
goto done;
}
int slot = -1;
for (int i = 0; i < directory.entries && slot < 0; i++) {
if (!entryInUse(entryAt(&directory, i))) {
slot = i;
}
}
if (slot < 0) {
fprintf(stderr, "Error: The directory is full: %d entries, all taken.\n",
directory.entries);
goto done;
}
uint8_t *entry = entryAt(&directory, slot);
memset(entry, 0, SBFS_ENTRY_BYTES);
entry[SBFS_ENTRY_FLAGS] = SBFS_FLAG_IN_USE | SBFS_FLAG_DIRECTORY;
memcpy(entry + SBFS_ENTRY_NAME, leaf, strlen(leaf));
entrySetParent(entry, parent);
int raised = (super.version < SBFS_VERSION_TREE);
super.version = SBFS_VERSION_TREE;
super.freeBlocks = countFree(&directory, &super);
if (writeDirectory(image, &super, &directory) || writeSuperblock(image, &super)) {
goto done;
}
printf("Made \"%s\" as entry %d.\n", name, slot);
if (raised) {
printf("The disk is now version %d, because it has a directory on it.\n",
SBFS_VERSION_TREE);
}
failed = 0;
done:
free(directory.bytes);
fclose(image);
return failed;
}
// ---- rmdir ----
//
// Refuses a directory with anything in it, and that refusal is not politeness. Parents
// are entry indices, and a freed index is handed out again to the next thing put on the
// disk - so the children of a deleted directory would reappear inside whatever took its
// place. Emptying it first is the only safe order, and making the caller do that is the
// smallest way to guarantee it.
static int commandRemoveDirectory(const char *path, const char *name) {
FILE *image = openImage(path, "r+b");
if (image == NULL) {
return 1;
}
Superblock super;
Directory directory;
if (readSuperblock(image, &super) || readDirectory(image, &super, &directory)) {
fclose(image);
return 1;
}
int slot = -1;
const char *why = NULL;
int failed = 1;
if (resolve(&directory, name, &slot, &why)) {
fprintf(stderr, "Error: \"%s\" %s.\n", name, why);
goto done;
}
if (slot < 0) {
fprintf(stderr, "Error: The root is not something that can be removed.\n");
goto done;
}
if (!entryIsDirectory(entryAt(&directory, slot))) {
fprintf(stderr, "Error: \"%s\" is a file. Use delete.\n", name);
goto done;
}
if (directoryHasChildren(&directory, slot)) {
fprintf(stderr, "Error: \"%s\" still has things in it. Empty it first.\n", name);
goto done;
}
memset(entryAt(&directory, slot), 0, SBFS_ENTRY_BYTES);
super.freeBlocks = countFree(&directory, &super);
if (writeDirectory(image, &super, &directory) || writeSuperblock(image, &super)) {
goto done;
}
printf("Removed \"%s\".\n", name);
failed = 0;
done:
free(directory.bytes);
fclose(image);
return failed;
}
static void printUsage(const char *program) {
printf("Usage: %s <command> <image> [arguments]\n", program);
printf("\n");
printf("Commands:\n");
printf(" format <image> [blocks] [dirblocks] Lay down a fresh filesystem.\n");
printf(" list <image> Show what is on the disk.\n");
printf(" put <image> <file> [name] Put a host file onto it.\n");
printf(" get <image> <name> [file] Take one off it.\n");
printf(" delete <image> <name> Remove one.\n");
printf(" list <image> [path] Show the disk, or one directory of it.\n");
printf(" put <image> <file> [path] Put a host file onto it.\n");
printf(" get <image> <path> [file] Take one off it.\n");
printf(" delete <image> <path> Remove a file.\n");
printf(" mkdir <image> <path> Make a directory.\n");
printf(" rmdir <image> <path> Remove an empty one.\n");
printf("\n");
printf("Blocks are %d bytes. A name may be %d characters. Without one, put uses the\n",
printf("Blocks are %d bytes. A name may be %d characters, and a path is names with\n",
SBFS_BLOCK_BYTES, SBFS_NAME_BYTES);
printf("file's own name, which is often too long, and it will say so.\n");
printf("'%c' between them, always from the root. Without a path, put uses the file's\n",
SBFS_SEPARATOR);
printf("own name, which is often too long, and it will say so.\n");
}
// The part of a path after the last separator, so that put can default to a file's own
@@ -526,7 +963,21 @@ int main(int argc, char *argv[]) {
return commandFormat(path, (uint16_t)blocks, (uint16_t)directoryBlocks);
}
if (strcmp(command, "list") == 0) {
return commandList(path);
return commandList(path, (argc > 3) ? argv[3] : NULL);
}
if (strcmp(command, "mkdir") == 0) {
if (argc < 4) {
fprintf(stderr, "Error: mkdir needs a directory to make.\n");
return 1;
}
return commandMakeDirectory(path, argv[3]);
}
if (strcmp(command, "rmdir") == 0) {
if (argc < 4) {
fprintf(stderr, "Error: rmdir needs a directory to remove.\n");
return 1;
}
return commandRemoveDirectory(path, argv[3]);
}
if (strcmp(command, "put") == 0) {
if (argc < 4) {
+47 -4
View File
@@ -1,5 +1,5 @@
// sbfs.h
// The SplitBit Filesystem, version one.
// The SplitBit Filesystem, version two.
//
// This is the host side's copy of the format. The other implementation is SplitBit
// assembly running on the machine itself, so nothing can be shared between them except
@@ -19,7 +19,26 @@
#define SBFS_MAGIC "SBFS"
#define SBFS_MAGIC_BYTES 4
#define SBFS_VERSION 1
// ---- Versions ----
//
// Version two adds directories, and adds them without moving a single byte that version
// one had defined: the parent lives in two of the four bytes each entry already reserved,
// and a directory is a flag bit in a byte that was using one of its eight.
//
// A VERSION ONE DISK IS ALREADY A VALID VERSION TWO DISK. The parent field is written as
// the entry's index PLUS ONE, so that the zero a version one disk has in those reserved
// bytes reads as "in the root" - which is exactly what every file on a flat disk is in.
// There is nothing to convert and no tool to convert it with.
//
// Compatibility therefore runs one way, which is the ordinary shape of it: this reads
// either version, and version one code reading a version two disk would list directories
// as strange empty files. A disk is written back at the version it was read at, and is
// only raised to two by the thing that makes the difference real - the first directory
// created on it.
#define SBFS_VERSION_FLAT 1
#define SBFS_VERSION_TREE 2
#define SBFS_VERSION SBFS_VERSION_TREE
#define SBFS_BLOCK_BYTES 256
@@ -47,10 +66,22 @@
// 3 2 Whole blocks the file occupies
// 5 1 Bytes in the trailing part block, or zero if there is not one
// 6 22 Name, padded with zeroes
// 28 4 Reserved
// 28 2 Parent, as an entry index plus one. Zero is the root. (Version two.)
// 30 2 Reserved
//
// Thirty two divides two hundred and fifty six, so an entry never straddles a block and
// reading one never means handling a split.
// reading one never means handling a split. Version two did not change that, because it
// spent bytes that were already inside the entry.
//
// A DIRECTORY IS AN ENTRY WITH NO BLOCKS. Its start, blocks and tail are all zero, and it
// costs one entry and nothing else. That is what keeps the flat array of entries the
// whole allocation map: 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.
//
// Because parents are entry indices, and because nothing ever compacts the directory,
// those indices are stable for as long as an entry is in use. DELETING A DIRECTORY THAT
// STILL HAS CHILDREN MUST BE REFUSED: the freed index would be handed to some unrelated
// file later, and the orphans would reappear inside it.
#define SBFS_ENTRY_BYTES 32
#define SBFS_ENTRIES_PER_BLOCK (SBFS_BLOCK_BYTES / SBFS_ENTRY_BYTES)
@@ -61,8 +92,20 @@
#define SBFS_ENTRY_TAIL 5
#define SBFS_ENTRY_NAME 6
#define SBFS_NAME_BYTES 22
#define SBFS_ENTRY_PARENT 28
#define SBFS_FLAG_IN_USE 0x01
#define SBFS_FLAG_DIRECTORY 0x02
// The root is not an entry. It is the absence of a parent, written as zero, which is why
// the field is an index plus one and why a freshly zeroed entry is already in the root.
#define SBFS_PARENT_ROOT 0
#define SBFS_PARENT_OF(index) ((uint16_t)((index) + 1))
#define SBFS_PARENT_INDEX(parent) ((int)(parent) - 1)
// Paths are separated by this, and a leading one means "from the root". A name may not
// contain it, which is what makes a path unambiguous without any quoting.
#define SBFS_SEPARATOR '/'
// The directory begins at block 1 and is this many blocks unless told otherwise, which
// is sixty four files. The superblock carries the real number, so this is only what a
+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."