Introduction
Today we will look at a specification for a simple file system and then at an implementation in C#.
Background
A little while ago I was writing a program that is able use a file as a file system. I was looking around the internet for a file system that was suitable for my needs. These two links are good sources for those who wants to look into many different file systems. https://en.wikipedia.org/wiki/List_of_file_systems and http://wiki.osdev.org/File_Systems While looking I became curious about how simple file systems could get. I looked for some, but my google fu was not strong enough. So I decided to roll my own file system. You can find the specification in the download section.
Read Only File System
The file system I wrote is called Rofs or Read only file system. It is dead simple to use. Here is an overview of a disk with Rofs on it.
The system allows for easy reading, but because of its simplicity it has no attributes, fault tolerance etc. For specific information, look at the pdf which you find in the download section.
Let's Code!
I have made an extraction of my implementation from the original project which is a console program that can work with multiple file systems. The class we are interested in is the Rofs
class. Let's take a look at its methods.
public Rofs(Stream stream) : base(stream)
{
if (!IsRofs(stream))
throw new ArgumentException("The stream doesn't contain a rofs.", "stream");
fileEntries = ReadStructure<ushort>(516);
}
The constructor is simple. Check if the stream contains a Rofs and figure out the number of file that are on it.
public static bool IsRofs(Stream disk)
{
disk.Seek(0x0200, SeekOrigin.Begin);
byte[] magic = { 0x52, 0x6f, 0x66, 0x73 };
byte[] data = new byte[4];
disk.Read(data, 0, 4);
return magic.SequenceEqual(data);
}
Many file systems have a magic that is used to identify a file system. Here we check for a specific array of bytes at a specific position on the disk, if the array matches the specific magic number then it is a Rofs. As you might have guessed this is not bullet proof, but it is very unlikely to fail.
public override bool Exist(string path)
{
if (path == null)
throw new ArgumentNullException("path");
else if (iso.GetByteCount(path) != path.Length)
throw new ArgumentException("Contains a non ISO-Latin-1 character.", "path");
foreach (FileEntry entry in GetFiles())
{
if (entry.Name == path)
return true;
}
return false;
}
This method is really simple. Loop over all file entries and check if the names matches, no biggie.
private IEnumerable<FileEntry> GetFiles()
{
for (ushort i = 0; i < fileEntries; i++)
yield return ReadStructure<FileEntry>(518 + 72 * i);
}
This method retrieves all file entries. A file entry is a struct that contains the name, position and size of a file. This is mainly used to retrieve files. The address 518 is the where the array of file entries start. 72 is the size of a file entry. Using these 2 numbers and knowing the total number of files on the disk we can easily find all file entries on the disk.
public override Stream Open(string path)
{
if (path == null)
throw new ArgumentNullException("path");
else if (iso.GetByteCount(path) != path.Length)
throw new ArgumentException("Contains a non ISO-Latin-1 character.", "path");
else if (!Exist(path))
throw new FileNotFoundException("The file doesn't exist.", path);
FileEntry entry = GetFile(path);
if (entry != null)
{
FileStream file = new FileStream();
byte[] data = ReadData(entry.Start, entry.Size);
file.Write(data, 0, data.Length);
file.Seek(0, SeekOrigin.Begin);
return file;
}
else
throw new FileNotFoundException("The file could not be found.", path);
}
First we need to find the file entry of the file we want to open. Then we check for its existence. Now that we know the file exists we open it for reading. We read the data from the file system and stores it in stream.
private FileEntry GetFile(string path)
{
foreach (FileEntry entry in GetFiles())
{
if (entry.Name == path)
return entry;
}
return null;
}
This method simplifies the task of finding a specific file entry by looping over each one then matching their names against a given name.
public static void CreateDisk(Stream destination, IDictionary<string, Stream> files)
{
if (destination == null)
throw new ArgumentNullException("destination");
else if (files == null)
throw new ArgumentNullException("files");
if (files.Count > ushort.MaxValue)
throw new ArgumentOutOfRangeException("files", "Too many files.");
byte[] bytes = { 0x52, 0x6f, 0x66, 0x73 };
destination.Seek(512, SeekOrigin.Begin);
destination.Write(bytes, 0, bytes.Length);
bytes = BitConverter.GetBytes((ushort)files.Count);
destination.Write(bytes, 0, bytes.Length);
List<FileEntry> entries = new List<FileEntry>();
uint start = 518 + (uint)(files.Count * 72);
destination.Seek(start, SeekOrigin.Begin);
foreach (KeyValuePair<string, Stream> file in files)
{
if (file.Key.Length != iso.GetByteCount(file.Key))
throw new ArgumentException("Path \"" + file.Key + "\" contains a non ISO-Latin-1 character.", "files");
FileEntry entry = new FileEntry();
entry.Name = file.Key;
entry.Start = start;
entry.Size = (uint)file.Value.Length;
entries.Add(entry);
start += entry.Size;
file.Value.CopyTo(destination);
}
destination.Seek(518, SeekOrigin.Begin);
for (int i = 0; i < entries.Count; i++)
{
bytes = Structures.ToBytes(entries[i]);
destination.Write(bytes, 0, bytes.Length);
}
}
Okay, this is the largest method. Because of the structure of Rofs it's much easier to create it when we know all the files that it should hold in advance. It is possible to add more files later on, but it requires massive re-arranging of the contents on the disk to do so.
However in this scenario we know about all file in advance. First we skip the first 512 bytes which are reserved for boot code. Next we write the magic number to the stream. Now we need to tell Rofs how many files it is going to contain. We know this by looking at the length of the files dictionary.
Next we figure where the start of the file contents. Remember that we know that the file entries start at 518, they have the size of 72 bytes and file contents are placed right after the last file entry. The only varible in that equation is the number of file entries. From that we get the formula y = 72x + 518. Here x is the number of file entries and y is the address of the data area. Then we write the file contents by looping over all the given files. We make sure that the file names are valid. Now that we have the file entries we can write them to the disk which is a simple matter of converting each FileEntry
to a byte[]
and writing it to the disk. The order that the file entries are writting down in has no consequence of the validity of the disk.