diff --git a/Programs/CosmOS/README.md b/Programs/CosmOS/README.md index 8ea475f..6e0431b 100644 --- a/Programs/CosmOS/README.md +++ b/Programs/CosmOS/README.md @@ -23,6 +23,8 @@ for itself. - Working Directory: `cd` moves the machine, `dir` lists where it is, and the prompt says where that is once it is not the root. A program may move too, and the shell puts the working directory back when the program stops. +- Making Directories: `mkdir` and `rmdir` on the machine, and files written where their + path says, so a disk can be organised without the host tool. - Loadable Applications: Validate SBEX files, copy their Program and Data segments into the addresses for which they were assembled, and start them at their declared entry point. @@ -92,6 +94,8 @@ CosmOS currently provides these built-in commands: | `load ` | Read and validate an SBEX application, then place its code and data where its header requests. | | `run [words]` | Start the loaded application and make the rest of the line available to it as an argument. | | `cd [path]` | Go to a directory, or to the root with nothing after it. | +| `mkdir ` | Make a directory. | +| `rmdir ` | Remove one, if it is empty. | | ` [words]` | Any word the shell does not recognise is looked for on the disk as `.sbx`, and loaded and started if it is there. | | `delete ` | Remove a file from the filesystem and release its blocks. | | `rename ` | Give a file a different name without moving its contents. | @@ -194,10 +198,44 @@ path as it was typed, so `notes.txt` is the same key in two directories and noth the entry it holds would look wrong. It is the kind of stale that gets believed rather than noticed. -At this stage CosmOS **reads** directories and does not make them: there is no `mkdir`, and -files a program writes go in the directory you are in. Deleting or renaming a directory is -refused - deleting one would free its entry index, and since a parent is written as an -index, the next file created would take that index and inherit its children. +### Making And Removing Directories: + +`mkdir` and `rmdir` are the machine's own, so a disk can be organised without the host +tool. A file a program writes goes where its path says, and a bare name means the +directory you are in. + +**A directory costs one entry and no blocks at all.** Its start, block count and tail are +all zero, which 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. + +Making the first directory on a disk is what raises it from version one to version two, +because it is the only thing that makes the difference between them real. A disk stays +readable by anything that has never heard of a directory right up until it actually has +one. + +Four things are refused, and each refusal is the reason a separate command exists: + +**`rmdir` will not take a file and `delete` will not take a directory.** Neither can be +the one that removed more than was asked for. + +**A directory with anything in it is refused.** This is not politeness. A parent is an +entry *index*, and a freed index is handed to the next thing created - so the children of +a directory removed from under them would turn up inside whatever took its place. Nothing +points downward, so there would be no way to find them afterwards and no way to notice. + +**A name already used in that directory is refused.** Two entries with one name in one +place is a directory that cannot be searched sensibly: a search answers with whichever it +meets first, and the other becomes unreachable without ever having been deleted. The same +name in a *different* directory is fine, and is the point of the exercise. + +**`rename` will not move anything.** Only the twenty two bytes of the name change and the +parent is not among them, so `rename a/x b/y` would be a lie the disk went along with. + +`Tests/agree.sh` builds the same disk twice, once with SplitDisk and once with CosmOS, and +compares the images byte for byte. Every field one writes and the other only reads is +checked there and nowhere else: which entry a thing lands in, which block, what a +directory's unused fields hold, the version, the free count. ### Starting An Application By Name: diff --git a/Programs/CosmOS/Source/cosmos.asm b/Programs/CosmOS/Source/cosmos.asm index d234d57..9a5d58c 100644 --- a/Programs/CosmOS/Source/cosmos.asm +++ b/Programs/CosmOS/Source/cosmos.asm @@ -142,6 +142,16 @@ promptSay: CALL textSame BRQ doCd + SETD.0 CommandLine + SETD.1 MkdirName + CALL textSame + BRQ doMkdir + + SETD.0 CommandLine + SETD.1 RmdirName + CALL textSame + BRQ doRmdir + SETD.0 CommandLine SETD.1 DeleteName CALL textSame @@ -1006,6 +1016,61 @@ cdNotDirectory: SETD.0 NotDirectory BRI fileComplain +; ---- mkdir and rmdir ---- +; +; Two commands rather than one that works out what you meant, and delete stays for files +; only. Each of the four says exactly what it will take, so none of them can be the one +; that took away more than was asked for. +doMkdir: + SETD.0 DiskReady + LDA.0 + BRA fileNoDisk + + SETD.1 TextRest + LDD.0.1 + LDA.0 + BRA mkdirWhat + + CALL sbfsMakeDir + BNQ mkdirFailed + SETD.0 MadeText + CALL printString + CALL newLine + BRI prompt + +mkdirWhat: + SETD.0 MkdirWhat + BRI fileComplain +mkdirFailed: + SETD.0 MkdirNo + BRI fileComplain + +doRmdir: + SETD.0 DiskReady + LDA.0 + BRA fileNoDisk + + SETD.1 TextRest + LDD.0.1 + LDA.0 + BRA rmdirWhat + + ; What a name means on the disk is about to change, so the remembered file goes. + CALL fileForget + CALL sbfsRemoveDir + BNQ rmdirFailed + SETD.0 RemovedText + CALL printString + CALL newLine + BRI prompt + +rmdirWhat: + SETD.0 RmdirWhat + BRI fileComplain +rmdirFailed: + SETD.0 RmdirNo + BRI fileComplain + ; ---- Writing out where the machine is ---- ; ; Builds the working directory's path into CwdText and leaves CwdAt pointing at where it @@ -3122,6 +3187,18 @@ DirectoryText: "" NotDirectory: "that is not a directory" +MadeText: +"made" +RemovedText: +"removed" +MkdirWhat: +"mkdir what?" +RmdirWhat: +"rmdir what?" +MkdirNo: +"cannot make that: check the path, the name, and whether it is taken" +RmdirNo: +"cannot remove that: it must be a directory, and empty" Separator: "/" @@ -3151,7 +3228,9 @@ load read a program off the disk run [words] start what was loaded, and tell it those words [words] look where you are and then in /Apps, and start that" HelpCdText: -"cd [path] go to a directory, or to the root with nothing after it" +"cd [path] go to a directory, or to the root with nothing after it +mkdir make a directory +rmdir remove an empty one" HelpMoreText: "delete take it off the disk rename call it something else @@ -3285,6 +3364,10 @@ RenameName: "rename" CdName: "cd" +MkdirName: +"mkdir" +RmdirName: +"rmdir" HelpName: "help" ExitName: diff --git a/Programs/CosmOS/Source/sbfs.asm b/Programs/CosmOS/Source/sbfs.asm index e4b9c66..ac91efe 100644 --- a/Programs/CosmOS/Source/sbfs.asm +++ b/Programs/CosmOS/Source/sbfs.asm @@ -320,6 +320,173 @@ sbfsFindMissing: ADD RET +; ---- Splitting a path into where and what ---- +; +; DP0 points at a path. Everything but the last name is walked, so what comes back is the +; directory a thing should be MADE in and the name to make it under: SbfsAt is the +; directory and SbfsWanted holds the name, padded to twenty two the way an entry holds one. +; Q is zero if that worked. +; +; The head is copied rather than the path being cut in place. The path belongs to whoever +; called, and a routine that writes a zero byte into somebody else's string is one that has +; to put it back on every way out, including the ways out that failed. +; +; THE SEPARATOR STAYS ON THE END OF THE HEAD, and that is what makes one rule cover both +; kinds of path. "/x" leaves a head of "/", which is the root; "x" leaves an empty head, +; which is where the machine already is; and "A/x" leaves "A/", which is neither of those +; and needs no special case to say so. +sbfsWalkParent: + RSTA + SETD.1 SbfsHeadLen + STA.1 + SETD.1 SbfsSpanAt + STA.1 + + PSHD.0 + POPD.2 ; DP2 walks the path, DP0 stays on the front of it. + +sbfsSplitScan: + LDA.2 + BRA sbfsSplitEnd + INIB 0x2F + CCF + SUB + BNQ sbfsSplitStep + + ; A separator. The head runs to just past it, so the last one to be seen wins. + SETD.1 SbfsSpanAt + LDA.1 + INCA + SETD.1 SbfsHeadLen + STA.1 + +sbfsSplitStep: + SETD.1 SbfsSpanAt + LDA.1 + INCA + STA.1 + BRA sbfsSplitTooLong ; Round past two hundred and fifty five: not a path. + INCD.2 + BRI sbfsSplitScan + +sbfsSplitEnd: + ; The head, up to and including the separator that ended it. + PSHD.0 + POPD.2 + SETD.1 SbfsHead + SETD.0 SbfsHeadLen + LDA.0 + INIB 0d95 + CCF + SUB + BNC sbfsSplitTooLong ; Longer than there is room to copy it into. + + SETD.0 SbfsHeadLen + LDA.0 + SETD.0 SbfsSpanAt + STA.0 ; Counting it back down again. + +sbfsHeadCopy: + SETD.0 SbfsSpanAt + LDA.0 + BRA sbfsHeadDone + DECA + STA.0 + LDA.2 + STA.1 + INCD.2 + INCD.1 + BRI sbfsHeadCopy + +sbfsHeadDone: + RSTA + STA.1 ; The zero that ends the head. + + ; And the last name, which is whatever DP2 is now on. Measured before it is copied, + ; because twenty two is all an entry holds and a longer name is refused rather than cut + ; down - a name cut to twenty two characters is a different name and might well be + ; taken. Measuring is the only way to know: looking at the twenty third character of a + ; shorter name reads past the end of somebody else's string. + PSHD.2 + POPD.0 + LDA.0 + BRA sbfsSplitNoName ; The path ended in a separator, so it names nothing. + + RSTA + SETD.1 SbfsSpanAt + STA.1 +sbfsLeafMeasure: + LDA.2 + BRA sbfsLeafMeasured + SETD.1 SbfsSpanAt + LDA.1 + INCA + STA.1 + INIB 0d23 + CCF + SUB + BRQ sbfsSplitTooLong + INCD.2 + BRI sbfsLeafMeasure +sbfsLeafMeasured: + + ; KEPT SOMEWHERE OF ITS OWN, and this is not tidiness. Walking the head goes through + ; sbfsPathNext, which puts every name it meets into SbfsWanted on its way past - so the + ; last name of the head would land exactly where the leaf was and the thing would be + ; created under the name of the directory it was going into. "mkdir Apps/Deep" made + ; /Apps/Apps. + SETD.1 SbfsLeaf + CALL sbfsKeepName + + ; Now where it goes. The head is walked exactly the way any other path is. + SETD.0 SbfsHead + CALL sbfsWalk + BNQ sbfsSplitNoName + + ; And the leaf comes back out, now that nothing else is going to write there. + SETD.0 SbfsLeaf + SETD.1 SbfsWanted + INIA 0d22 + SETD.2 SbfsCount + STA.2 +sbfsLeafBack: + LDA.0 + STA.1 + INCD.0 + INCD.1 + LDA.2 + DECA + STA.2 + BNA sbfsLeafBack + + ; And it has to be somewhere things can be put. The root always is. + SETD.0 SbfsAt + LDA.0 + INCD.0 + LDB.0 + OR + BRQ sbfsSplitGood + SETD.0 SbfsFoundFlags + LDA.0 + INIB 0x02 + AND + BRQ sbfsSplitNoName + +sbfsSplitGood: + RSTA + RSTB + CCF + ADD + RET + +sbfsSplitTooLong: +sbfsSplitNoName: + RSTA + INIB 0d1 + CCF + ADD + RET + ; ---- Taking a path apart ---- ; ; Copies the next name out of the path into SbfsWanted, padded with zeroes to twenty two @@ -502,6 +669,21 @@ sbfsScanFound: ADD ; Q is zero: found. RET +; DP2 is on an entry. Q is zero if it lives in the directory SbfsHoldAt names. The third +; of these, and the reason all three are subroutines: a RET puts DP2 back on the entry. +sbfsMatchHold: + PSHD.2 + POPD.0 + DPUP.0 0d28 + SETD.2 SbfsHoldAt + CALL sbfsSameByte + BNQ sbfsMatchHoldDone + INCD.0 + INCD.2 + CALL sbfsSameByte +sbfsMatchHoldDone: + RET + ; DP2 is on an entry. Q is zero if it lives in the directory the machine is in. The same ; comparison sbfsMatchParent makes, against the other of the two places a walk can be. sbfsWalkHere: @@ -910,6 +1092,24 @@ sbfsMatchYes: ADD RET +; Twenty two bytes from DP0 to DP1, which is one name exactly as an entry holds it. Not +; sbfsCopyWord's job and not sbfsKeepName's either: this one is for a name that is already +; padded and is only being put somewhere safe. +sbfsCopyName: + INIA 0d22 + SETD.2 SbfsCount + STA.2 +sbfsCopyNameLoop: + LDA.0 + STA.1 + INCD.0 + INCD.1 + LDA.2 + DECA + STA.2 + BNA sbfsCopyNameLoop + RET + ; Copies the name at DP0 into DP1, twenty two bytes of it, padding with zeroes the way an ; entry is padded so that the two can be compared as they stand. sbfsKeepName: @@ -1261,9 +1461,13 @@ sbfsExtentDone: ; which has to be settled before it is made because nothing here can grow one afterwards. ; Q is zero if it was made, and then SbfsFileStart says where its blocks are. sbfsCreate: - SETD.1 SbfsWanted - CALL sbfsKeepName + ; Where it goes and what it is called come out of the path together. Everything below + ; works from SbfsAt and SbfsWanted, so anything that already knows those two can start + ; at sbfsCreateAt and skip the walking. + CALL sbfsWalkParent + BNQ sbfsCreateFailed +sbfsCreateAt: CALL sbfsFileExtent CALL sbfsAllocate BNQ sbfsCreateFailed @@ -1341,19 +1545,16 @@ sbfsCreateFill: LDA.0 STA.1 - ; THE ROOT, SAID OUT LOUD. A new file goes in the root because nothing here can put one - ; anywhere else yet, and the two bytes that say so are written rather than assumed to be - ; zero already. They would be - a free entry has been wiped by delete or has never been - ; used - but that is a fact about two other routines, and a fact kept somewhere else is - ; one that can be changed without this noticing. A file appearing inside a directory it - ; was never put in is not a failure anybody would think to look for. + ; WHICH DIRECTORY IT IS IN, written rather than left to be zero by luck. A free entry + ; has been wiped by delete or has never been used, so those two bytes would say the root + ; on their own - but that is a fact about two other routines, and a fact kept somewhere + ; else is one that can be changed without this noticing. A file turning up inside a + ; directory it was never put in is not a failure anybody would think to look for. PSHD.3 POPD.1 DPUP.1 0d28 - RSTA - STA.1 - INCD.1 - STA.1 + SETD.0 SbfsAt + CALL sbfsCopyWord PSHD.3 POPD.1 @@ -1597,6 +1798,272 @@ sbfsSameByte: XOR RET +; ---- Making a directory ---- +; +; DP0 names one. Q is zero if it was made. +; +; A directory costs ONE ENTRY AND NO BLOCKS AT ALL: its start, its block count and its tail +; all stay zero. That is what keeps the flat array of entries the whole allocation map, +; which is the thing this filesystem 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. +sbfsMakeDir: + CALL sbfsWalkParent + BNQ sbfsMakeFailed + + ; Nothing of that name in there already. Two entries with one name in one directory is a + ; directory that cannot be searched sensibly: a search answers with whichever it meets + ; first, and the other becomes unreachable without ever having been deleted. + CALL sbfsScanFor + BRQ sbfsMakeFailed + ; A scan that found nothing leaves the walk where it was, so SbfsAt still says where + ; this is going. Walking the head again to be sure would put the head's last name back + ; into SbfsWanted and undo the leaf. + + ; A free entry, found the same way creating a file finds one. + SETD.0 SbfsDirStart + SETD.1 SbfsBlock + CALL sbfsCopyWord + SETD.0 SbfsDirBlocks + INCD.0 + LDA.0 + SETD.1 SbfsLeft + STA.1 + +sbfsMakeBlock: + CALL sbfsReadBlock + BNQ sbfsMakeFailed + SETD.1 SbfsBuffer + CALL sbfsBufferOut + SETD.2 SbfsBuffer + INIA 0d8 + SETD.1 SbfsCount + STA.1 + +sbfsMakeEntry: + LDA.2 + INIB 0x01 + AND + BRQ sbfsMakeFill ; This one is free. + DPUP.2 0d32 + LDA.1 + DECA + STA.1 + BNA sbfsMakeEntry + + SETD.0 SbfsBlock + CALL sbfsStepWord + SETD.1 SbfsLeft + LDA.1 + DECA + STA.1 + BRA sbfsMakeFailed ; The directory is full. + BRI sbfsMakeBlock + +sbfsMakeFill: + PSHD.2 + POPD.3 + INIA 0x03 + STA.3 ; In use, and a directory. + + ; No blocks, no start and no tail. Written out rather than left alone, because a free + ; entry is not the only thing that lands here. + PSHD.3 + POPD.1 + INCD.1 + INIB 0d5 +sbfsMakeZero: + RSTA + STA.1 + INCD.1 + DECB + BNB sbfsMakeZero + + PSHD.3 + POPD.1 + DPUP.1 0d28 + SETD.0 SbfsAt + CALL sbfsCopyWord + + PSHD.3 + POPD.1 + DPUP.1 0d06 + SETD.0 SbfsWanted + INIA 0d22 + SETD.2 SbfsCount + STA.2 +sbfsMakeName: + LDA.0 + STA.1 + INCD.0 + INCD.1 + LDA.2 + DECA + STA.2 + BNA sbfsMakeName + + SETD.1 SbfsBuffer + CALL sbfsBufferIn + CALL sbfsWriteBlock + BNQ sbfsMakeFailed + + CALL sbfsRaiseVersion + RET + +sbfsMakeFailed: + RSTA + INIB 0d1 + CCF + ADD + RET + +; The disk now has a directory on it, so it is a version two disk and has to say so. +; +; THIS IS THE ONLY THING THAT RAISES THE NUMBER, because it is the only thing that makes +; the difference between the two versions real. A disk stays readable by anything that has +; never heard of a directory right up until it actually has one. +sbfsRaiseVersion: + RSTA + SETD.0 SbfsBlock + STA.0 + INCD.0 + STA.0 + CALL sbfsReadBlock + BNQ sbfsRaiseDone + SETD.1 SbfsBuffer + CALL sbfsBufferOut + SETD.0 SbfsBuffer + DPUP.0 0d04 + INIA 0d2 + STA.0 + SETD.1 SbfsBuffer + CALL sbfsBufferIn + CALL sbfsWriteBlock +sbfsRaiseDone: + RSTA + RSTB + CCF + ADD + RET + +; ---- Removing a directory ---- +; +; DP0 names one. Q is zero if it went. +; +; ANYTHING STILL INSIDE IT IS A REFUSAL, and that is not politeness. A parent is an entry +; INDEX, and a freed index is handed straight to the next thing put on the disk - so the +; children of a directory removed from under them would turn up inside whatever took its +; place. Nothing points downward, so there would be no way to find them afterwards and no +; way to notice. Emptying it first is the only safe order there is. +sbfsRemoveDir: + CALL sbfsFind + BNQ sbfsRemoveFailed + + SETD.0 SbfsFoundFlags + LDA.0 + INIB 0x02 + AND + BRQ sbfsRemoveFailed ; A file. Deleting is for those. + + ; Where it is, kept while the disk is asked whether anything lives in it - the asking + ; reads other blocks and leaves the walk somewhere else entirely. + SETD.0 SbfsAt + SETD.1 SbfsHoldAt + CALL sbfsCopyWord + + CALL sbfsHasChildren + BRQ sbfsRemoveFailed + + ; Found again, because looking for children read over the block the entry was in. + SETD.0 SbfsHoldAt + SETD.1 SbfsTarget + CALL sbfsCopyWord + SETD.0 SbfsTarget + CALL sbfsBackWord + CALL sbfsAtIndex + BNQ sbfsRemoveFailed + + CALL sbfsWipeFound + RET + +sbfsRemoveFailed: + RSTA + INIB 0d1 + CCF + ADD + RET + +; Q is zero if anything at all says its parent is SbfsHoldAt. Nothing points downward, so +; the only way to know what is in a directory is to ask everything whether it is in it. +sbfsHasChildren: + SETD.0 SbfsDirStart + SETD.1 SbfsBlock + CALL sbfsCopyWord + SETD.0 SbfsDirBlocks + INCD.0 + LDA.0 + SETD.1 SbfsLeft + STA.1 + +sbfsChildBlock: + CALL sbfsReadBlock + BRQ sbfsChildLoaded + RET ; The read failed, and Q says so - which reads as "yes", and + ; refusing to remove something on a disk that will not read is the + ; right way round to be wrong. +sbfsChildLoaded: + SETD.1 SbfsBuffer + CALL sbfsBufferOut + SETD.2 SbfsBuffer + INIA 0d8 + SETD.1 SbfsCount + STA.1 + +sbfsChildEntry: + LDA.2 + INIB 0x01 + AND + BRQ sbfsChildNext + + ; Through a CALL, so that DP2 comes back on the entry without anything having to work + ; out where that was. Doing the comparison in line here meant clobbering DP2 and then + ; rebuilding it from the buffer and the count - which had the subtraction the wrong way + ; round, walked the pointer off the end of the block, and let rmdir take a directory + ; with something still in it. Exactly the failure this routine exists to prevent. + CALL sbfsMatchHold + BRQ sbfsChildYes + +sbfsChildNext: + DPUP.2 0d32 + SETD.1 SbfsCount + LDA.1 + DECA + STA.1 + BNA sbfsChildEntry + + SETD.0 SbfsBlock + CALL sbfsStepWord + SETD.1 SbfsLeft + LDA.1 + DECA + STA.1 + BRA sbfsChildNone + BRI sbfsChildBlock + +sbfsChildYes: + RSTA + RSTB + CCF + ADD ; Q is zero: something lives there. + RET + +sbfsChildNone: + RSTA + INIB 0d1 + CCF + ADD + RET + ; ---- Deleting ---- ; ; Frees a file. DP0 names it, and Q is zero if it went. @@ -1627,6 +2094,16 @@ sbfsDelete: AND BNQ sbfsDeleteFailed + CALL sbfsWipeFound + RET + +; Throws away whatever the last find landed on, wherever it landed. DP3 is on the entry and +; SbfsBlock is the directory block it came out of, which is everything needed to change it +; and put it back. +; +; Removing a directory arrives here too. A directory has no blocks, so the count that goes +; back to the free total is nought and the sum is right without being a special case. +sbfsWipeFound: ; How much room it was taking, worked out before the entry that says so is thrown away. CALL sbfsFileExtent @@ -1693,16 +2170,24 @@ sbfsRename: SETD.2 SbfsSavedName STD.0.2 + ; The new name is a path like any other, so only its last name is the name. Renaming + ; something to "/A/notes.txt" used to call it that, all thirteen characters of it. PSHD.1 POPD.0 + CALL sbfsWalkParent + BNQ sbfsRenameFailed + SETD.0 SbfsAt + SETD.1 SbfsHoldAt + CALL sbfsCopyWord + SETD.0 SbfsWanted SETD.1 SbfsNewName - CALL sbfsKeepName ; Twenty two bytes, padded, the way an entry holds one. + CALL sbfsCopyName - ; Refused if something already answers to the new name. Two entries with one name is a - ; disk that cannot be searched sensibly: a search answers with whichever it meets first, - ; so the other becomes unreachable without ever having been deleted. - SETD.0 SbfsNewName - CALL sbfsFind + ; Refused if something already answers to that name in that directory. Two entries with + ; one name in one place is a directory that cannot be searched sensibly: a search answers + ; with whichever it meets first, and the other becomes unreachable without ever having + ; been deleted. + CALL sbfsScanFor BRQ sbfsRenameFailed SETD.2 SbfsSavedName @@ -1710,6 +2195,14 @@ sbfsRename: CALL sbfsFind BNQ sbfsRenameFailed + ; RENAMING DOES NOT MOVE ANYTHING. Only the twenty two bytes of the name change, and the + ; parent is not among them - so a new path naming a different directory would be a lie + ; the disk went along with. Refused instead. + SETD.0 SbfsUpParent + SETD.2 SbfsHoldAt + CALL sbfsCompareWord + BNQ sbfsRenameFailed + ; Not a directory. Renaming one would be safe enough, but the shell has no way to make ; one yet, so allowing it here would only be a way to reach something that cannot be ; got at any other way. @@ -1782,12 +2275,26 @@ sbfsSaveFile: SETD.1 SbfsSaveTail STA.1 - ; Refused up front if the name belongs to a directory. Left to itself the delete below - ; would refuse it, the rename at the end would refuse it too, and the save would fail - ; having already written a temporary that nothing would ever come back for. + ; WHERE IT GOES, WORKED OUT ONCE. Everything below is done in terms of a directory and + ; a name rather than a path, and that is what makes the careful order below work in a + ; subdirectory: the temporary has to be made in the SAME directory as the file, because + ; the rename at the end changes a name and does not move anything. SETD.2 SbfsSaveName LDD.0.2 - CALL sbfsFind + CALL sbfsWalkParent + BNQ sbfsSaveFailed + SETD.0 SbfsAt + SETD.1 SbfsSaveParent + CALL sbfsCopyWord + SETD.0 SbfsWanted + SETD.1 SbfsSaveLeaf + CALL sbfsCopyName + + ; Refused up front if that name belongs to a directory. Left to itself the delete below + ; would refuse it, the rename at the end would refuse it too, and the save would fail + ; then instead, having already written a temporary nothing would ever come back for. + CALL sbfsSaveWhere + CALL sbfsScanFor BNQ sbfsSaveNotThere SETD.0 SbfsFoundFlags LDA.0 @@ -1798,8 +2305,11 @@ sbfsSaveNotThere: ; A temporary left behind by a save that did not finish would be in the way. Whether ; there was one is not worth asking about, since either answer leads here. - SETD.0 SbfsTempName - CALL sbfsDelete + CALL sbfsSaveTemp + CALL sbfsScanFor + BNQ sbfsSaveNoTemp + CALL sbfsWipeFound +sbfsSaveNoTemp: SETD.0 SbfsFileBlocks SETD.2 SbfsSaveBlocks @@ -1809,8 +2319,8 @@ sbfsSaveNotThere: SETD.1 SbfsFileTail STA.1 - SETD.0 SbfsTempName - CALL sbfsCreate + CALL sbfsSaveTemp + CALL sbfsCreateAt BNQ sbfsSaveFailed SETD.2 SbfsSaveData @@ -1818,16 +2328,51 @@ sbfsSaveNotThere: CALL sbfsWriteFile BNQ sbfsSaveFailed - ; Now, and not before, the old one goes. It may not exist, which is what saving something - ; for the first time looks like from here. - SETD.2 SbfsSaveName - LDD.0.2 - CALL sbfsDelete + ; Now, and not before, the old one goes. It may not be there at all, which is what + ; saving something for the first time looks like from here. + CALL sbfsSaveWhere + CALL sbfsScanFor + BNQ sbfsSaveNoOld + CALL sbfsWipeFound +sbfsSaveNoOld: + ; And the temporary takes its name. Found again first, because everything above has been + ; reading other blocks over the one it lives in. + CALL sbfsSaveTemp + CALL sbfsScanFor + BNQ sbfsSaveFailed + + PSHD.3 + POPD.1 + DPUP.1 0d06 + SETD.0 SbfsSaveLeaf + CALL sbfsCopyName + + SETD.1 SbfsBuffer + CALL sbfsBufferIn + CALL sbfsWriteBlock + RET + +; The directory the file is going in, and the name it is going under. Said again before +; each step, because every step in between goes to the disk and leaves the walk somewhere +; else entirely. +sbfsSaveWhere: + SETD.0 SbfsSaveParent + SETD.1 SbfsAt + CALL sbfsCopyWord + SETD.0 SbfsSaveLeaf + SETD.1 SbfsWanted + CALL sbfsCopyName + RET + +; The same directory, under the name the half finished file is written as. +sbfsSaveTemp: + SETD.0 SbfsSaveParent + SETD.1 SbfsAt + CALL sbfsCopyWord SETD.0 SbfsTempName - SETD.2 SbfsSaveName - LDD.1.2 - CALL sbfsRename + SETD.1 SbfsWanted + CALL sbfsKeepName RET sbfsSaveFailed: @@ -1895,6 +2440,20 @@ SbfsCwd: ; plus one, so that zero is the root and a version one disk's zeroes already say it. SbfsPathAt: 0x00 0x00 + +; ---- What splitting a path off its last name keeps ---- +SbfsHead: + #Reserve 0d96 +SbfsHeadLen: + 0x00 +SbfsHoldAt: + 0x00 0x00 + +; The last name of a path, kept out of the way while the rest of the path is walked. +SbfsLeaf: + #Reserve 0d23 +SbfsSpanAt: + 0x00 SbfsPathState: 0x00 SbfsPathLeft: @@ -1958,6 +2517,10 @@ SbfsSaveBlocks: 0x00 0x00 SbfsSaveTail: 0x00 +SbfsSaveParent: + 0x00 0x00 +SbfsSaveLeaf: + #Reserve 0d23 ; What a document is called while it is being written and is not yet the real thing. A ; name nothing else is likely to want, and short enough to leave room for a long one. diff --git a/README.md b/README.md index 0602ba7..915700b 100644 --- a/README.md +++ b/README.md @@ -187,13 +187,14 @@ The disk images tests read from are built first by `Tests/makedisks.sh`, using S test that reads one is therefore checked against a filesystem written by different code from the same written specification, rather than against itself. -`Tests/run.sh` drives that comparison. Four more scripts run alongside it, and each exists +`Tests/run.sh` drives that comparison. Five more scripts run alongside it, and each exists because a recorded file cannot answer its question: - **`Tests/disk.sh`** checks the disk tool on its own: files of every awkward size onto an image and off again, and the things the format says cannot happen refused rather than half done. - **`Tests/terminal.sh`** checks what a recorded file cannot see. Piped output is buffered and flushed at exit, so a prompt shown before its answer is asked for and one shown an hour late produce identical files; and key mode only touches a terminal when there is one. Both have gone wrong here, and both were found by a person whose terminal stopped working rather than by anything in this suite. So it runs the emulator under a pseudo-terminal and asks directly: that a prompt arrives before input is read, that a keystroke arrives without Return, that the terminal is handed back however the machine dies, and that suspending and resuming leave it as they found it. - **`Tests/native.sh`** checks the assembler that runs on SplitBit against the one that runs on the host, byte for byte, on a boot image and four loadable programs, and then on CosmOS and on itself, and then on the CosmOS that CosmOS built. -- **`Tests/docs.sh`** checks the manuals against the code: that every instruction has a row and every row is an instruction, that the counts in the headings are right, that every directive is written down, that every service the system implements is described and every service described is implemented, that every routine the manuals promise exists, and that the worked examples still assemble to the bytes printed beside them. +- **`Tests/agree.sh`** checks the two implementations of SBFS against each other rather than each against itself, by building the same disk with SplitDisk and with CosmOS and comparing the images byte for byte. Every field one of them writes and the other only reads is checked there and nowhere else. +- **`Tests/docs.sh`** checks the manuals against the code: that every instruction has a row and every row is an instruction, that the counts in the headings are right, that every directive is written down, that every service the system implements is described and every service described is implemented, that every routine the manuals promise exists, that CosmOS still fits in the half of the machine its memory map gives it, and that the worked examples still assemble to the bytes printed beside them. A cycle count is deliberately **not** part of a recorded result. The last line of the emulator's output has the number taken out before anything is compared, keeping only whether the program stopped on its own or ran into its limit, which is behaviour. Two instructions added to CosmOS used to move that number in six unrelated files at once, so a real difference would have arrived in a crowd of meaningless ones. Anything that wants to measure cycles should say so in a test of its own. diff --git a/Tests/agree.sh b/Tests/agree.sh new file mode 100755 index 0000000..e4c0f34 --- /dev/null +++ b/Tests/agree.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# Checks the two implementations of SBFS against each other, on the same disk. +# +# SplitDisk and sbfs.asm are two programs written from one specification and sharing no +# code at all - one is C on the host, the other is SplitBit assembly running on the +# machine. disk.sh checks the host half against the format and run.sh checks the machine +# half against recorded output, but neither of those can catch the two of them agreeing +# with themselves and disagreeing with each other. +# +# SO THIS BUILDS THE SAME DISK TWICE, once with each, and compares the images byte for +# byte. Every field either one writes and the other only reads is checked here and nowhere +# else: which entry a thing lands in, which block, what a directory's unused fields hold, +# the version in the superblock, the free count. A disagreement in any of those is a disk +# one of them can read and the other cannot, and the way that is usually discovered is +# somebody's file coming back wrong months later. +# +# Written by Anachronaut + +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORK="$ROOT/Tests/build/agree" +ASM="$ROOT/Assembler" +TOOL="$ROOT/SplitDisk" +EMU="$ROOT/SplitBit" + +PASS=0 +FAIL=0 +FAILED_NAMES=() + +GREEN=$'\033[32m'; RED=$'\033[31m'; RESET=$'\033[0m' +[ -t 1 ] || { GREEN=""; RESET=""; RED=""; } + +report() { + local mark="$1" name="$2" note="${3:-}" + if [ "$mark" = "ok" ]; then + PASS=$((PASS + 1)); printf " [%sok %s] %-24s %s\n" "$GREEN" "$RESET" "$name" "$note" + else + FAIL=$((FAIL + 1)); FAILED_NAMES+=("$name") + printf " [%sFAIL%s] %-24s %s\n" "$RED" "$RESET" "$name" "$note" + fi +} + +for tool in "$ASM" "$TOOL" "$EMU"; do + [ -x "$tool" ] || { echo "$(basename "$tool") is not built."; exit 1; } +done + +rm -rf "$WORK"; mkdir -p "$WORK" +cd "$WORK" || exit 1 + +"$ASM" -I "$ROOT/Programs/CosmOS/Source" "$ROOT/Programs/CosmOS/Source/cosmos.asm" \ + -o cosmos.bin >/dev/null 2>&1 || { echo "CosmOS would not assemble."; exit 1; } + +echo "Checking the two SBFS implementations against each other." + +# ---- The same tree, made both ways ---- +# +# The order matters and is the same on both sides, because both allocate first fit and both +# take the first free entry: given the same operations in the same order they should reach +# the same bytes, and any difference is a real one rather than an artefact of the script. +"$TOOL" format host.img 128 2 >/dev/null +"$TOOL" mkdir host.img /Apps >/dev/null +"$TOOL" mkdir host.img /Apps/Deep >/dev/null +"$TOOL" mkdir host.img /Notes >/dev/null + +"$TOOL" format machine.img 128 2 >/dev/null +printf 'mkdir /Apps\nmkdir /Apps/Deep\nmkdir /Notes\nexit\n' \ + | "$EMU" cosmos.bin --fast --disk machine.img >/dev/null 2>&1 + +if cmp -s host.img machine.img; then + report ok "three directories" "byte for byte" +else + report FAIL "three directories" "$(cmp host.img machine.img 2>&1 | head -1)" +fi + +# ---- Removing one puts the disk back exactly ---- +# +# A wiped entry has to be indistinguishable from one that was never used, or a disk that +# has had something deleted stops matching a fresh one that never did. Both sides zero all +# thirty two bytes, and this is what says so. +"$TOOL" rmdir host.img /Apps/Deep >/dev/null +printf 'rmdir /Apps/Deep\nexit\n' | "$EMU" cosmos.bin --fast --disk machine.img >/dev/null 2>&1 +if cmp -s host.img machine.img; then + report ok "and removing one" "byte for byte" +else + report FAIL "and removing one" "$(cmp host.img machine.img 2>&1 | head -1)" +fi + +# ---- A file put down a path ---- +# +# The machine writes files through the careful order a save has to use - make a temporary, +# write it, delete the original, rename the temporary - and the host writes the entry once. +# Two quite different routes to what has to be the same disk. +# The editor goes on both disks FIRST and by the same route, so that the only thing left +# differing is how the payload got written. Putting it on one disk before the payload and +# the other after was enough to move every entry and block after it, and the comparison +# duly failed on a difference the script had introduced. +"$ASM" -I "$ROOT/Programs/Libraries" -I "$ROOT/Programs/CosmOS/Source" \ + "$ROOT/Programs/CosmOS/Apps/Edit.asm" -o Edit.sbx >/dev/null 2>&1 +"$TOOL" put host.img Edit.sbx >/dev/null +"$TOOL" put machine.img Edit.sbx >/dev/null + +printf 'a file that lives in a directory\n' > payload.txt +"$TOOL" put host.img payload.txt /Notes/payload.txt >/dev/null +printf 'load Edit.sbx\nrun /Notes/payload.txt\na\na file that lives in a directory\n.\nw\nq\nexit\n' \ + | "$EMU" cosmos.bin --fast --disk machine.img >/dev/null 2>&1 + +if cmp -s host.img machine.img; then + report ok "a file down a path" "byte for byte" +else + report FAIL "a file down a path" "$(cmp host.img machine.img 2>&1 | head -1)" +fi + +# ---- And each can read what the other wrote ---- +# +# Matching bytes and being readable are not the same claim. A field both of them write +# wrongly in the same way would pass every comparison above. +"$TOOL" get machine.img /Notes/payload.txt fromMachine.txt >/dev/null 2>&1 +if cmp -s payload.txt fromMachine.txt; then + report ok "the host reads it back" "$(wc -c < fromMachine.txt | tr -d ' ') bytes" +else + report FAIL "the host reads it back" "the file came back different" +fi + +"$TOOL" mkdir host.img /Notes/Inner >/dev/null +printf 'x' > deep.txt +"$TOOL" put host.img deep.txt /Notes/Inner/deep.txt >/dev/null +seen=$(printf 'cd /Notes/Inner\ndir\nexit\n' \ + | "$EMU" cosmos.bin --fast --disk host.img 2>&1 | grep -c "deep.txt") +if [ "$seen" -ge 1 ]; then + report ok "the machine reads it back" "found it three deep" +else + report FAIL "the machine reads it back" "the machine could not see it" +fi + +echo +if [ "$FAIL" -eq 0 ]; then + echo "All $PASS agreement checks passed." + exit 0 +fi +echo "$PASS passed, $FAIL failed: ${FAILED_NAMES[*]}" +exit 1 diff --git a/Tests/expected/cosmos.out b/Tests/expected/cosmos.out index 882fbcb..38ba6d4 100644 --- a/Tests/expected/cosmos.out +++ b/Tests/expected/cosmos.out @@ -4,6 +4,8 @@ load read a program off the disk run [words] start what was loaded, and tell it those words [words] look where you are and then in /Apps, and start that cd [path] go to a directory, or to the root with nothing after it +mkdir make a directory +rmdir remove an empty one delete take it off the disk rename call it something else monitor look at memory, change it, and jump into it diff --git a/Tests/expected/cosmosBreak.out b/Tests/expected/cosmosBreak.out index 452b8d9..0cc49e3 100644 --- a/Tests/expected/cosmosBreak.out +++ b/Tests/expected/cosmosBreak.out @@ -3,11 +3,11 @@ CosmOS > two stops, and what the registers were at each break at 400E A 11 B 22 Q 00 status 00 -DP0 2030 DP1 082E DP2 0000 DP3 4000 SP FFFF +DP0 2030 DP1 09BC DP2 0000 DP3 4000 SP FFFF press a key break at 4023 A 44 B 55 Q 00 status 00 -DP0 2000 DP1 082E DP2 0000 DP3 4000 SP FFF5 +DP0 2000 DP1 09BC DP2 0000 DP3 4000 SP FFF5 press a key carried on to the end finished diff --git a/Tests/expected/cosmosBuild.out b/Tests/expected/cosmosBuild.out new file mode 100644 index 0000000..c209576 --- /dev/null +++ b/Tests/expected/cosmosBuild.out @@ -0,0 +1,27 @@ +CosmOS +> 0 files +> made +> made +> made +> Apps +Notes +0 files, 2 directories +> /Apps> Deep +0 files, 1 directory +/Apps> cannot make that: check the path, the name, and whether it is taken +/Apps> made +/Apps> /Notes> Deep +0 files, 1 directory +/Notes> cannot remove that: it must be a directory, and empty +/Notes> removed +/Notes> that is a directory +/Notes> removed +/Notes> > cannot make that: check the path, the name, and whether it is taken +> cannot remove that: it must be a directory, and empty +> cannot remove that: it must be a directory, and empty +> removed +> removed +> 0 files +> halted +Execution halted. +[exit 0] diff --git a/Tests/expected/cosmosInvoke.out b/Tests/expected/cosmosInvoke.out index 242062c..cfa9dff 100644 --- a/Tests/expected/cosmosInvoke.out +++ b/Tests/expected/cosmosInvoke.out @@ -21,6 +21,8 @@ load read a program off the disk run [words] start what was loaded, and tell it those words [words] look where you are and then in /Apps, and start that cd [path] go to a directory, or to the root with nothing after it +mkdir make a directory +rmdir remove an empty one delete take it off the disk rename call it something else monitor look at memory, change it, and jump into it diff --git a/Tests/input/cosmosBuild.in b/Tests/input/cosmosBuild.in new file mode 100644 index 0000000..3be951c --- /dev/null +++ b/Tests/input/cosmosBuild.in @@ -0,0 +1,23 @@ +dir +mkdir Apps +mkdir Apps/Deep +mkdir Notes +dir +cd Apps +dir +mkdir Deep +mkdir /Notes/Deep +cd /Notes +dir +rmdir /Apps +rmdir /Apps/Deep +delete /Apps +rmdir /Notes/Deep +cd / +mkdir Apps/Deep/Inner +rmdir Apps/Deep/Inner +rmdir Apps/Deep +rmdir Apps +rmdir Notes +dir +exit diff --git a/Tests/makedisks.sh b/Tests/makedisks.sh index 41ab092..cabb9ab 100755 --- a/Tests/makedisks.sh +++ b/Tests/makedisks.sh @@ -219,6 +219,12 @@ awk 'BEGIN { for (i = 0; i < 30; i++) printf "line %02d: ABCDEFGHIJKLMNOPQRSTUVW printf 'this is not a program' > rooted.txt "$TOOL" put "$DISKS/tree.img" rooted.txt >/dev/null +# A blank disk for the machine to build a tree on itself. It starts as a version ONE disk +# with nothing at all on it, because half of what this checks is that making the first +# directory raises the version - the number says what is on a disk rather than what made +# it, so a disk with no directories is flat whoever formatted it. +"$TOOL" format "$DISKS/build.img" 128 2 >/dev/null + # A disk for moving about on. Two directories hold a file of THE SAME NAME with different # text in it, which is the fixture the working directory needs: "notes.txt" has to mean a # different file from each of them, and the only way to see that it does is for the two to diff --git a/Tests/manifest b/Tests/manifest index 4728193..fb891d1 100644 --- a/Tests/manifest +++ b/Tests/manifest @@ -343,6 +343,21 @@ cosmosTree | CosmOS/Source/cosmos.asm | run | cosmosTre # hold no blocks and must therefore be in nobody's way when a run of free ones is wanted. # The listing afterwards says where it landed and how big it is. cosmosTreeWrite | CosmOS/Source/cosmos.asm | run | cosmosTreeWrite.in | - | disks/treewrite.img +# The machine building its own tree. It starts with a blank version one disk and makes +# every directory on it, which is the half of the filesystem the machine could only read +# until now. +# +# The refusals are most of the test. rmdir will not take a file and delete will not take a +# directory, so neither can be the one that removed more than was asked for; a directory +# with anything in it is refused outright, because a parent is an entry INDEX and a freed +# index goes to the next thing created - the children would turn up inside whatever took +# its place, with nothing pointing downward to find them by. A name already taken in that +# directory is refused, and the same name in a different directory is not, which is the +# whole point of the exercise. +# +# The last dir is there to show the disk still adds up: every entry made and unmade, and +# nothing left over. +cosmosBuild | CosmOS/Source/cosmos.asm | run | cosmosBuild.in | - | disks/build.img # The working directory. cd moves the machine, the prompt says where it is once that is # not the root, and dir lists one directory rather than the whole disk. # diff --git a/makefile b/makefile index 59d24df..b9bde54 100644 --- a/makefile +++ b/makefile @@ -103,6 +103,8 @@ test: $(EMU_TARGET) $(ASM_TARGET) $(DSK_TARGET) strict @echo @./Tests/native.sh @echo + @./Tests/agree.sh + @echo @./Tests/docs.sh # Rebuild all three tools with the address and undefined behaviour sanitizers and run @@ -137,6 +139,8 @@ sanitize: @echo @./Tests/native.sh @echo + @./Tests/agree.sh + @echo @./Tests/docs.sh @$(MAKE) --no-print-directory clean @$(MAKE) --no-print-directory