Low Level DesignWorked problems
Design an in-memory file system
Design an in-memory file system step by step for a low level design interview. Grow from a map of paths to a composite tree of files and directories, paths resolved one name at a time, move and delete that carry whole subtrees, Unix-style permissions, and a tree shared by many threads, with code in Java, Python, C#, Go and TypeScript.
- Read first
- Object modelingThread safety basics
- Patterns
- Composite
“Design an in-memory file system: create directories, write and read files, list a directory.” Then: “now add permissions”, or “how does rename work?”
A file system is how a computer organizes files: folders (called directories) that hold
files and other directories, all reached by a path like /home/ana/notes. In this problem, it
lives in memory, as objects. There are no disks.
Files and directories are both things in a tree, and a directory holds things. That shape is the
Composite pattern, and it gives the first half of the answer. The interesting parts are around
the tree. How a path like /home/ana/../ben/./notes becomes a walk through it. Why rename is cheap
in a tree and expensive in a map. What stops a directory being moved inside itself. And how a
permission on one directory can hide everything below it.
The answer in one minute
Section titled “The answer in one minute”If you only have a minute, this is the design. The rest of the page explains how we get here.
- Files and directories share one type,
Entry, and a directory holds entries. That’s the Composite pattern. A directory’s size is the sum of the sizes of whatever it holds. - No entry stores its full path. Each one stores only its name and its parent. A path is a walk from the root. So moving a directory changes one link, however much is inside it.
- Paths are cleaned up once, before anything else.
.and empty parts are dropped,..steps back one name, and a path that doesn’t start with/is refused. - Every operation goes through one walk, and the walk checks permissions. Execute permission on a directory lets you pass through it. So one closed directory hides everything inside it.
- One read-write lock guards the tree. Reads run together. Changes run one at a time, because a move reads one place and changes others.
We’ll build this in four stages, then share the tree between threads.
Scope it
Section titled “Scope it”Before drawing anything, we ask a few questions. Each answer changes a class. Anything that doesn’t, we assume and move on.
Design an in-memory file system.
Whether you scope it to a data structure problem before drawing anything. Disks, blocks and inodes are a different interview. Candidates who ask “in memory, so no blocks or caching?” spend their time on the tree and the operations.
Here are the questions that matter:
- In memory, or on a disk? In memory: a tree of objects. On a disk, it’s blocks and a journal, which is an operating systems course.
- Which operations? Make a directory, write, read, list, move, delete, and size.
- Relative paths, and a current directory? No. Every path starts at the root, with
/. - Permissions? Yes: an owner and everyone else, with read, write and execute.
- Several threads at once? Yes.
These answers give us one promise that matters more than the others: every operation costs the depth of the path, not the size of the file system. Moving a directory with a million files inside must be as cheap as moving one file. Three smaller promises come with it. The tree is always a tree: nothing is ever inside itself, and a name is a file or a directory, never both. A user can’t read, change or list what the permissions forbid, even by going around them. And many threads can use it at once without losing changes.
We leave out disks, links, and relative paths.
Go deeperThree more questions, and what each changes1 min
| Question | We assume | If the answer were different |
|---|---|---|
| Links, hard or symbolic? | No | A hard link makes the tree a graph; a symlink is a file holding a path to follow |
| File content as text or bytes? | Text, replaced whole | Bytes with offsets add append and partial writes |
| Search by name? | No | A walk of the subtree, or an index from name to entries |
Build it
Section titled “Build it”We’ll build the design in four stages. Each stage adds only the classes that a new requirement needs. In each diagram, the new parts are outlined, and the colors show what each class does (the key is under every diagram).
Stage 1: A map from path to content
Section titled “Stage 1: A map from path to content”Start simple: write a file, read it back, list a directory. What’s your first version?
Whether you can see, and say, why a flat map breaks down: listing looks at everything, folders don’t really exist, and rename touches every file under the folder.
Start with the smallest thing that works: a map from each file’s full path to its content. Reading and writing a file are one lookup each. Listing a directory looks at every path that starts with the directory’s name, and keeps the next name along.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// Every file is a key in one map, from its full path to its content.class FileSystem { private final Map<String, String> files = new HashMap<>();
void write(String path, String content) { files.put(path, content); }
String read(String path) { return files.get(path); }
// Lists the names directly inside a folder. // flaw 1: scans every file in the system to answer for one folder. // flaw 2: a folder exists only while a file is inside it, so there are no empty folders, and // nothing stops "/a" being a file while "/a/b" is one too. // flaw 3: renaming a folder means rewriting the key of every file under it. // flaw 4: "/a//b" and "/a/./b" are different keys for the same file. List<String> ls(String dir) { String prefix = dir.endsWith("/") ? dir : dir + "/"; Set<String> names = new TreeSet<>(); for (String p : files.keySet()) { if (p.startsWith(prefix)) names.add(p.substring(prefix.length()).split("/")[0]); } return new ArrayList<>(names); }}# Every file is a key in one dict, from its full path to its content.class FileSystem: def __init__(self): self._files: dict[str, str] = {}
def write(self, path: str, content: str) -> None: self._files[path] = content
def read(self, path: str) -> str | None: return self._files.get(path)
# Lists the names directly inside a folder. # # flaw 1: scans every file in the system to answer for one folder. # flaw 2: a folder exists only while a file is inside it, so there are no empty folders, # and nothing stops "/a" being a file while "/a/b" is one too. # flaw 3: renaming a folder means rewriting the key of every file under it. # flaw 4: "/a//b" and "/a/./b" are different keys for the same file. def ls(self, directory: str) -> list[str]: prefix = directory if directory.endswith("/") else directory + "/" return sorted({p[len(prefix):].split("/")[0] for p in self._files if p.startswith(prefix)})// Every file is a key in one dictionary, from its full path to its content.public sealed class FileSystem{ private readonly Dictionary<string, string> _files = [];
public void Write(string path, string content) => _files[path] = content;
public string? Read(string path) => _files.GetValueOrDefault(path);
// Lists the names directly inside a folder. // flaw 1: scans every file in the system to answer for one folder. // flaw 2: a folder exists only while a file is inside it, so there are no empty folders, and // nothing stops "/a" being a file while "/a/b" is one too. // flaw 3: renaming a folder means rewriting the key of every file under it. // flaw 4: "/a//b" and "/a/./b" are different keys for the same file. public List<string> Ls(string dir) { var prefix = dir.EndsWith('/') ? dir : dir + "/"; return _files.Keys .Where(p => p.StartsWith(prefix, StringComparison.Ordinal)) .Select(p => p[prefix.Length..].Split('/')[0]) .Distinct() .Order(StringComparer.Ordinal) .ToList(); }}// FileSystem keeps every file as a key in one map, from its full path to its content.type FileSystem struct { files map[string]string}
func NewFileSystem() *FileSystem { return &FileSystem{files: map[string]string{}} }
func (fs *FileSystem) Write(path, content string) { fs.files[path] = content }
func (fs *FileSystem) Read(path string) (string, bool) { c, ok := fs.files[path] return c, ok}
// Ls lists the names directly inside a folder.// flaw 1: scans every file in the system to answer for one folder.// flaw 2: a folder exists only while a file is inside it, so there are no empty folders, and// nothing stops "/a" being a file while "/a/b" is one too.// flaw 3: renaming a folder means rewriting the key of every file under it.// flaw 4: "/a//b" and "/a/./b" are different keys for the same file.func (fs *FileSystem) Ls(dir string) []string { prefix := strings.TrimSuffix(dir, "/") + "/" seen := map[string]bool{} for p := range fs.files { if rest, ok := strings.CutPrefix(p, prefix); ok { seen[strings.Split(rest, "/")[0]] = true } } return slices.Sorted(maps.Keys(seen))}// Every file is a key in one map, from its full path to its content.class FileSystem { private readonly files = new Map<string, string>();
write(path: string, content: string): void { this.files.set(path, content); }
read(path: string): string | undefined { return this.files.get(path); }
// Lists the names directly inside a folder. // flaw 1: scans every file in the system to answer for one folder. // flaw 2: a folder exists only while a file is inside it, so there are no empty folders, and // nothing stops "/a" being a file while "/a/b" is one too. // flaw 3: renaming a folder means rewriting the key of every file under it. // flaw 4: "/a//b" and "/a/./b" are different keys for the same file. ls(dir: string): string[] { const prefix = dir.endsWith('/') ? dir : dir + '/'; const names = new Set<string>(); for (const p of this.files.keys()) { if (p.startsWith(prefix)) names.add(p.slice(prefix.length).split('/')[0]); } return [...names].sort(); }}It works, but it has four problems, and we leave them in on purpose. Listing one directory looks at
every file in the system. A directory exists only while some file is inside it, so there are no
empty directories, and nothing stops /a being a file while /a/b is a file too. Renaming
/home/ana to /home/anna means rewriting the key of every file under it. And /a//b and
/a/./b are different keys for the same file. Three of the four come from storing full paths, so
the next stage stops storing them.
Stage 2: A tree of entries
Section titled “Stage 2: A tree of entries”Make directories real: they can be empty, they can be listed without a search, and a name is either a file or a directory. What are your classes now?
Whether you reach the Composite pattern: a shared Entry type for files and directories, and a
directory that holds entries. They also watch where the path cleaning goes, and whether .. is
handled or ignored.
A file system is a tree, so we model it as one. An Entry is anything in the tree. It has a
name and a parent. A File is an entry with content. A Directory is an entry
with children: a map from name to entry. The root is a directory with no parent.
A directory holds entries, and doesn’t care which kind. So a directory can hold files and directories alike, to any depth. That’s the Composite pattern: one interface for a single thing and for a group of things.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
size() shows what the pattern buys. A file’s size is the length of its content. A directory’s
size is the sum of its children’s sizes. It doesn’t need to know which children are files: each
child answers for itself, and a directory’s answer asks its own children.
Every operation now starts the same way. First we parse the path into a list of names: split on
/, drop empty parts and ., let .. remove the name before it, and refuse a path that doesn’t
start with /. Then we resolve the names, by walking from the root, one child lookup per name.
The cost is the depth of the path, not the number of files.
- Color key
- The node it returns
Errors are returned as values, not thrown. NOT_FOUND means a name is missing. NOT_A_DIRECTORY
means the walk met a file where it needed a directory. IS_A_DIRECTORY means someone tried to read
or write a directory. Operations that return something return an Outcome: the result, plus
the value when it’s OK.
enum Result { OK, NOT_FOUND, NOT_A_DIRECTORY, IS_A_DIRECTORY, ALREADY_EXISTS, INVALID_PATH }
// A result and, when it is OK, a value.record Outcome<T>(Result result, T value) { static <T> Outcome<T> ok(T value) { return new Outcome<>(Result.OK, value); } static <T> Outcome<T> fail(Result result) { return new Outcome<>(result, null); } boolean failed() { return result != Result.OK; }}
// The composite. A file and a directory are both entries, so code that walks the tree, like// size(), asks each entry the same question and lets it answer for itself.abstract class Entry { String name; Directory parent; // null only for the root
Entry(String name, Directory parent) { this.name = name; this.parent = parent; }
abstract long size();}
class File extends Entry { String content = "";
File(String name, Directory parent) { super(name, parent); }
long size() { return content.length(); }}
class Directory extends Entry { final SortedMap<String, Entry> children = new TreeMap<>(); // sorted, so listing needs no sort
Directory(String name, Directory parent) { super(name, parent); }
// A directory's size is its children's, whatever they are. long size() { long total = 0; for (Entry e : children.values()) total += e.size(); return total; }}
class FileSystem { private final Directory root = new Directory("", null);
// Creates a directory. With parents, missing directories on the way are created too, and an // existing directory is fine, like mkdir -p. Result mkdir(String path, boolean parents) { List<String> names = parse(path); if (names == null) return Result.INVALID_PATH; Directory dir = root; for (int i = 0; i < names.size(); i++) { boolean last = i == names.size() - 1; Entry next = dir.children.get(names.get(i)); if (next == null) { if (!last && !parents) return Result.NOT_FOUND; Directory created = new Directory(names.get(i), dir); dir.children.put(created.name, created); dir = created; } else if (!(next instanceof Directory d)) { return Result.NOT_A_DIRECTORY; } else if (last && !parents) { return Result.ALREADY_EXISTS; } else { dir = d; } } return names.isEmpty() && !parents ? Result.ALREADY_EXISTS : Result.OK; }
// Creates the file if it's missing, and replaces its content. Result write(String path, String content) { List<String> names = parse(path); if (names == null) return Result.INVALID_PATH; if (names.isEmpty()) return Result.IS_A_DIRECTORY; Outcome<Directory> parent = directory(names.subList(0, names.size() - 1)); if (parent.failed()) return parent.result(); String name = names.get(names.size() - 1); Entry existing = parent.value().children.get(name); if (existing instanceof Directory) return Result.IS_A_DIRECTORY; File file = existing == null ? new File(name, parent.value()) : (File) existing; file.content = content; parent.value().children.put(name, file); return Result.OK; }
Outcome<String> read(String path) { Outcome<Entry> found = find(path); if (found.failed()) return Outcome.fail(found.result()); if (!(found.value() instanceof File f)) return Outcome.fail(Result.IS_A_DIRECTORY); return Outcome.ok(f.content); }
// A directory lists its children's names, in order; a file lists itself, as ls does. Outcome<List<String>> ls(String path) { Outcome<Entry> found = find(path); if (found.failed()) return Outcome.fail(found.result()); if (found.value() instanceof Directory d) return Outcome.ok(List.copyOf(d.children.keySet())); return Outcome.ok(List.of(found.value().name)); }
Outcome<Long> size(String path) { Outcome<Entry> found = find(path); return found.failed() ? Outcome.fail(found.result()) : Outcome.ok(found.value().size()); }
private Outcome<Entry> find(String path) { List<String> names = parse(path); return names == null ? Outcome.fail(Result.INVALID_PATH) : resolve(names); }
// Walks from the root, one name at a time. The cost is the path's depth, not the number of // files in the system. private Outcome<Entry> resolve(List<String> names) { Entry at = root; for (String name : names) { if (!(at instanceof Directory dir)) return Outcome.fail(Result.NOT_A_DIRECTORY); at = dir.children.get(name); if (at == null) return Outcome.fail(Result.NOT_FOUND); } return Outcome.ok(at); }
private Outcome<Directory> directory(List<String> names) { Outcome<Entry> found = resolve(names); if (found.failed()) return Outcome.fail(found.result()); if (!(found.value() instanceof Directory d)) return Outcome.fail(Result.NOT_A_DIRECTORY); return Outcome.ok(d); }
// Turns "/a/./b//c/../d" into [a, b, d]. Every path is absolute, so a missing leading slash // is a mistake rather than something relative to guess about. ".." at the root stays there. static List<String> parse(String path) { if (!path.startsWith("/")) return null; List<String> names = new ArrayList<>(); for (String part : path.split("/")) { if (part.isEmpty() || part.equals(".")) continue; if (!part.equals("..")) names.add(part); else if (!names.isEmpty()) names.remove(names.size() - 1); } return names; }}class Result(Enum): OK = "ok" NOT_FOUND = "not_found" NOT_A_DIRECTORY = "not_a_directory" IS_A_DIRECTORY = "is_a_directory" ALREADY_EXISTS = "already_exists" INVALID_PATH = "invalid_path"
# A result and, when it is OK, a value.@dataclass(frozen=True)class Outcome(Generic[T]): result: Result value: T | None = None
@property def failed(self) -> bool: return self.result is not Result.OK
# The composite. A file and a directory are both entries, so code that walks the tree, like# size(), asks each entry the same question and lets it answer for itself.class Entry(ABC): def __init__(self, name: str, parent: "Directory | None"): self.name = name self.parent = parent # None only for the root
@abstractmethod def size(self) -> int: ...
class File(Entry): def __init__(self, name: str, parent: "Directory"): super().__init__(name, parent) self.content = ""
def size(self) -> int: return len(self.content)
class Directory(Entry): def __init__(self, name: str, parent: "Directory | None"): super().__init__(name, parent) self.children: dict[str, Entry] = {}
# A directory's size is its children's, whatever they are. def size(self) -> int: return sum(child.size() for child in self.children.values())
class FileSystem: def __init__(self): self._root = Directory("", None)
# Creates a directory. With parents, missing directories on the way are created too, and # an existing directory is fine, like mkdir -p. def mkdir(self, path: str, parents: bool = False) -> Result: names = parse(path) if names is None: return Result.INVALID_PATH directory = self._root for i, name in enumerate(names): last = i == len(names) - 1 nxt = directory.children.get(name) if nxt is None: if not last and not parents: return Result.NOT_FOUND created = Directory(name, directory) directory.children[name] = created directory = created elif not isinstance(nxt, Directory): return Result.NOT_A_DIRECTORY elif last and not parents: return Result.ALREADY_EXISTS else: directory = nxt return Result.ALREADY_EXISTS if not names and not parents else Result.OK
# Creates the file if it's missing, and replaces its content. def write(self, path: str, content: str) -> Result: names = parse(path) if names is None: return Result.INVALID_PATH if not names: return Result.IS_A_DIRECTORY parent = self._directory(names[:-1]) if parent.failed: return parent.result existing = parent.value.children.get(names[-1]) if isinstance(existing, Directory): return Result.IS_A_DIRECTORY file = existing if isinstance(existing, File) else File(names[-1], parent.value) file.content = content parent.value.children[file.name] = file return Result.OK
def read(self, path: str) -> Outcome[str]: found = self._find(path) if found.failed: return Outcome(found.result) if not isinstance(found.value, File): return Outcome(Result.IS_A_DIRECTORY) return Outcome(Result.OK, found.value.content)
# A directory lists its children's names, in order; a file lists itself, as ls does. def ls(self, path: str) -> Outcome[list[str]]: found = self._find(path) if found.failed: return Outcome(found.result) if isinstance(found.value, Directory): return Outcome(Result.OK, sorted(found.value.children)) return Outcome(Result.OK, [found.value.name])
def size(self, path: str) -> Outcome[int]: found = self._find(path) return Outcome(found.result) if found.failed else Outcome(Result.OK, found.value.size())
def _find(self, path: str) -> Outcome[Entry]: names = parse(path) return Outcome(Result.INVALID_PATH) if names is None else self._resolve(names)
# Walks from the root, one name at a time. The cost is the path's depth, not the number # of files in the system. def _resolve(self, names: list[str]) -> Outcome[Entry]: at: Entry = self._root for name in names: if not isinstance(at, Directory): return Outcome(Result.NOT_A_DIRECTORY) nxt = at.children.get(name) if nxt is None: return Outcome(Result.NOT_FOUND) at = nxt return Outcome(Result.OK, at)
def _directory(self, names: list[str]) -> Outcome[Directory]: found = self._resolve(names) if found.failed: return Outcome(found.result) if not isinstance(found.value, Directory): return Outcome(Result.NOT_A_DIRECTORY) return Outcome(Result.OK, found.value)
# Turns "/a/./b//c/../d" into [a, b, d]. Every path is absolute, so a missing leading slash# is a mistake rather than something relative to guess about. ".." at the root stays there.def parse(path: str) -> list[str] | None: if not path.startswith("/"): return None names: list[str] = [] for part in path.split("/"): if part in ("", "."): continue if part != "..": names.append(part) elif names: names.pop() return namespublic enum Result { Ok, NotFound, NotADirectory, IsADirectory, AlreadyExists, InvalidPath }
// A result and, when it is Ok, a value.public sealed record Outcome<T>(Result Result, T? Value = default){ public bool Failed => Result != Result.Ok; public static Outcome<T> Ok(T value) => new(Result.Ok, value); public static Outcome<T> Fail(Result result) => new(result);}
// The composite. A file and a directory are both entries, so code that walks the tree, like// Size(), asks each entry the same question and lets it answer for itself.public abstract class Entry(string name, Directory? parent){ public string Name { get; set; } = name; public Directory? Parent { get; set; } = parent; // null only for the root
public abstract long Size();}
public sealed class File(string name, Directory parent) : Entry(name, parent){ public string Content { get; set; } = "";
public override long Size() => Content.Length;}
public sealed class Directory(string name, Directory? parent) : Entry(name, parent){ // Sorted, so listing needs no sort. Ordinal, so the order doesn't depend on the culture. public SortedDictionary<string, Entry> Children { get; } = new(StringComparer.Ordinal);
// A directory's size is its children's, whatever they are. public override long Size() => Children.Values.Sum(e => e.Size());}
public sealed class FileSystem{ private readonly Directory _root = new("", null);
// Creates a directory. With parents, missing directories on the way are created too, and an // existing directory is fine, like mkdir -p. public Result Mkdir(string path, bool parents = false) { if (Parse(path) is not { } names) return Result.InvalidPath; var dir = _root; for (var i = 0; i < names.Count; i++) { var last = i == names.Count - 1; switch (dir.Children.GetValueOrDefault(names[i])) { case null: if (!last && !parents) return Result.NotFound; var created = new Directory(names[i], dir); dir.Children[names[i]] = created; dir = created; break; case Directory d: if (last && !parents) return Result.AlreadyExists; dir = d; break; default: return Result.NotADirectory; } } return names.Count == 0 && !parents ? Result.AlreadyExists : Result.Ok; }
// Creates the file if it's missing, and replaces its content. public Result Write(string path, string content) { if (Parse(path) is not { } names) return Result.InvalidPath; if (names.Count == 0) return Result.IsADirectory; var parent = Dir(names[..^1]); if (parent.Failed) return parent.Result; var name = names[^1]; switch (parent.Value!.Children.GetValueOrDefault(name)) { case Directory: return Result.IsADirectory; case File f: f.Content = content; break; default: parent.Value.Children[name] = new File(name, parent.Value) { Content = content }; break; } return Result.Ok; }
public Outcome<string> Read(string path) { var found = Find(path); if (found.Failed) return Outcome<string>.Fail(found.Result); return found.Value is File f ? Outcome<string>.Ok(f.Content) : Outcome<string>.Fail(Result.IsADirectory); }
// A directory lists its children's names, in order; a file lists itself, as ls does. public Outcome<List<string>> Ls(string path) { var found = Find(path); if (found.Failed) return Outcome<List<string>>.Fail(found.Result); return Outcome<List<string>>.Ok(found.Value is Directory d ? [.. d.Children.Keys] : [found.Value!.Name]); }
public Outcome<long> Size(string path) { var found = Find(path); return found.Failed ? Outcome<long>.Fail(found.Result) : Outcome<long>.Ok(found.Value!.Size()); }
private Outcome<Entry> Find(string path) => Parse(path) is { } names ? Resolve(names) : Outcome<Entry>.Fail(Result.InvalidPath);
// Walks from the root, one name at a time. The cost is the path's depth, not the number of // files in the system. private Outcome<Entry> Resolve(List<string> names) { Entry at = _root; foreach (var name in names) { if (at is not Directory dir) return Outcome<Entry>.Fail(Result.NotADirectory); if (!dir.Children.TryGetValue(name, out var next)) return Outcome<Entry>.Fail(Result.NotFound); at = next; } return Outcome<Entry>.Ok(at); }
private Outcome<Directory> Dir(List<string> names) { var found = Resolve(names); if (found.Failed) return Outcome<Directory>.Fail(found.Result); return found.Value is Directory d ? Outcome<Directory>.Ok(d) : Outcome<Directory>.Fail(Result.NotADirectory); }
// Turns "/a/./b//c/../d" into [a, b, d]. Every path is absolute, so a missing leading slash // is a mistake rather than something relative to guess about. ".." at the root stays there. public static List<string>? Parse(string path) { if (!path.StartsWith('/')) return null; var names = new List<string>(); foreach (var part in path.Split('/')) { if (part is "" or ".") continue; if (part != "..") names.Add(part); else if (names.Count > 0) names.RemoveAt(names.Count - 1); } return names; }}type Result int
const ( OK Result = iota NotFound NotADirectory IsADirectory AlreadyExists InvalidPath)
// Entry is the composite. A file and a directory are both entries, so code that walks the tree,// like Size, asks each entry the same question and lets it answer for itself.type Entry interface { Size() int64 base() *node}
// node is what every entry has, embedded in File and Directory.type node struct { name string parent *Directory // nil only for the root}
func (n *node) base() *node { return n }
type File struct { node content string}
func (f *File) Size() int64 { return int64(len(f.content)) }
type Directory struct { node children map[string]Entry}
func newDirectory(name string, parent *Directory) *Directory { return &Directory{node{name, parent}, map[string]Entry{}}}
// Size of a directory is its children's, whatever they are.func (d *Directory) Size() int64 { var total int64 for _, e := range d.children { total += e.Size() } return total}
type FileSystem struct { root *Directory}
func NewFileSystem() *FileSystem { return &FileSystem{root: newDirectory("", nil)} }
// Mkdir creates a directory. With parents, missing directories on the way are created too, and// an existing directory is fine, like mkdir -p.func (fs *FileSystem) Mkdir(path string, parents bool) Result { names, ok := parse(path) if !ok { return InvalidPath } dir := fs.root for i, name := range names { last := i == len(names)-1 switch next := dir.children[name].(type) { case nil: if !last && !parents { return NotFound } created := newDirectory(name, dir) dir.children[name] = created dir = created case *Directory: if last && !parents { return AlreadyExists } dir = next default: return NotADirectory } } if len(names) == 0 && !parents { return AlreadyExists } return OK}
// Write creates the file if it's missing, and replaces its content.func (fs *FileSystem) Write(path, content string) Result { names, ok := parse(path) if !ok { return InvalidPath } if len(names) == 0 { return IsADirectory } parent, r := fs.directory(names[:len(names)-1]) if r != OK { return r } name := names[len(names)-1] switch existing := parent.children[name].(type) { case *Directory: return IsADirectory case *File: existing.content = content default: parent.children[name] = &File{node{name, parent}, content} } return OK}
func (fs *FileSystem) Read(path string) (string, Result) { e, r := fs.find(path) if r != OK { return "", r } f, ok := e.(*File) if !ok { return "", IsADirectory } return f.content, OK}
// Ls lists a directory's children's names, in order; a file lists itself, as ls does.func (fs *FileSystem) Ls(path string) ([]string, Result) { e, r := fs.find(path) if r != OK { return nil, r } if d, ok := e.(*Directory); ok { return slices.Sorted(maps.Keys(d.children)), OK } return []string{e.base().name}, OK}
func (fs *FileSystem) Size(path string) (int64, Result) { e, r := fs.find(path) if r != OK { return 0, r } return e.Size(), OK}
func (fs *FileSystem) find(path string) (Entry, Result) { names, ok := parse(path) if !ok { return nil, InvalidPath } return fs.resolve(names)}
// resolve walks from the root, one name at a time. The cost is the path's depth, not the// number of files in the system.func (fs *FileSystem) resolve(names []string) (Entry, Result) { var at Entry = fs.root for _, name := range names { dir, ok := at.(*Directory) if !ok { return nil, NotADirectory } if at, ok = dir.children[name]; !ok { return nil, NotFound } } return at, OK}
func (fs *FileSystem) directory(names []string) (*Directory, Result) { e, r := fs.resolve(names) if r != OK { return nil, r } d, ok := e.(*Directory) if !ok { return nil, NotADirectory } return d, OK}
// parse turns "/a/./b//c/../d" into [a b d]. Every path is absolute, so a missing leading slash// is a mistake rather than something relative to guess about. ".." at the root stays there.func parse(path string) ([]string, bool) { if !strings.HasPrefix(path, "/") { return nil, false } names := []string{} for _, part := range strings.Split(path, "/") { switch part { case "", ".": case "..": if len(names) > 0 { names = names[:len(names)-1] } default: names = append(names, part) } } return names, true}type Result = 'ok' | 'not-found' | 'not-a-directory' | 'is-a-directory' | 'already-exists' | 'invalid-path';
// A result and, when it is ok, a value.interface Outcome<T> { result: Result; value?: T;}
const fail = <T>(result: Result): Outcome<T> => ({ result });const ok = <T>(value: T): Outcome<T> => ({ result: 'ok', value });
// The composite. A file and a directory are both entries, so code that walks the tree, like// size(), asks each entry the same question and lets it answer for itself.abstract class Entry { constructor( public name: string, public parent: Directory | undefined, // undefined only for the root ) {}
abstract size(): number;}
class File extends Entry { content = '';
size(): number { return this.content.length; }}
class Directory extends Entry { readonly children = new Map<string, Entry>();
// A directory's size is its children's, whatever they are. size(): number { let total = 0; for (const child of this.children.values()) total += child.size(); return total; }}
class FileSystem { private readonly root = new Directory('', undefined);
// Creates a directory. With parents, missing directories on the way are created too, and an // existing directory is fine, like mkdir -p. mkdir(path: string, parents = false): Result { const names = parse(path); if (!names) return 'invalid-path'; let dir = this.root; for (const [i, name] of names.entries()) { const last = i === names.length - 1; const next = dir.children.get(name); if (!next) { if (!last && !parents) return 'not-found'; const created = new Directory(name, dir); dir.children.set(name, created); dir = created; } else if (!(next instanceof Directory)) { return 'not-a-directory'; } else if (last && !parents) { return 'already-exists'; } else { dir = next; } } return names.length === 0 && !parents ? 'already-exists' : 'ok'; }
// Creates the file if it's missing, and replaces its content. write(path: string, content: string): Result { const names = parse(path); if (!names) return 'invalid-path'; if (names.length === 0) return 'is-a-directory'; const parent = this.directory(names.slice(0, -1)); if (!parent.value) return parent.result; const name = names[names.length - 1]; const existing = parent.value.children.get(name); if (existing instanceof Directory) return 'is-a-directory'; const file = existing instanceof File ? existing : new File(name, parent.value); file.content = content; parent.value.children.set(name, file); return 'ok'; }
read(path: string): Outcome<string> { const found = this.find(path); if (!found.value) return fail(found.result); if (!(found.value instanceof File)) return fail('is-a-directory'); return ok(found.value.content); }
// A directory lists its children's names, in order; a file lists itself, as ls does. ls(path: string): Outcome<string[]> { const found = this.find(path); if (!found.value) return fail(found.result); if (found.value instanceof Directory) return ok([...found.value.children.keys()].sort()); return ok([found.value.name]); }
size(path: string): Outcome<number> { const found = this.find(path); return found.value ? ok(found.value.size()) : fail(found.result); }
private find(path: string): Outcome<Entry> { const names = parse(path); return names ? this.resolve(names) : fail('invalid-path'); }
// Walks from the root, one name at a time. The cost is the path's depth, not the number of // files in the system. private resolve(names: string[]): Outcome<Entry> { let at: Entry = this.root; for (const name of names) { if (!(at instanceof Directory)) return fail('not-a-directory'); const next = at.children.get(name); if (!next) return fail('not-found'); at = next; } return ok(at); }
private directory(names: string[]): Outcome<Directory> { const found = this.resolve(names); if (!found.value) return fail(found.result); return found.value instanceof Directory ? ok(found.value) : fail('not-a-directory'); }}
// Turns "/a/./b//c/../d" into [a, b, d]. Every path is absolute, so a missing leading slash is a// mistake rather than something relative to guess about. ".." at the root stays there.function parse(path: string): string[] | undefined { if (!path.startsWith('/')) return undefined; const names: string[] = []; for (const part of path.split('/')) { if (part === '' || part === '.') continue; if (part !== '..') names.push(part); else names.pop(); } return names;}Go deeperCleaning up a path, one name at a time2 min
Let’s parse /home/ana/../ben/./notes by hand. We keep a list of names, and look at each part
between slashes in turn.
- The path starts with
/, so it’s allowed. The list starts empty. homeis a normal name, so we add it. The list is[home].anais a normal name. The list is[home, ana]...means “go back one”. We remove the last name. The list is[home].benis a normal name. The list is[home, ben]..means “stay here”, so we skip it.notesis a normal name. The list is[home, ben, notes].
Now the walk: from the root, look up home, then ben inside it, then notes inside that. Three
lookups, however many files the system holds.
Two small cases. .. at the root has nothing to remove, so it does nothing, just as cd .. at /
stays at /. And // produces an empty part between the slashes, which we skip like ..
Why clean the path first, instead of following .. during the walk? Because then every operation
gets the same simple list, and the walk only ever goes down. It also means .. works through a name
that doesn’t exist: /home/nobody/../ben reaches /home/ben. Unix does the opposite and would
refuse it. Either rule is fine, as long as you say which one you chose.
Stage 3: Move and delete
Section titled “Stage 3: Move and delete”Add rename and move, and delete. What does moving a directory with a hundred thousand files in it cost?
Whether you see that a move only relinks one entry, so it costs the same for any size of directory. The red flag is missing the check that stops a directory being moved inside itself, which cuts it off from the root.
A move takes an entry out of its parent’s children, and puts it into the target directory’s children, under the new name. Rename is just a move within the same directory. Nothing below the entry stores a path, so everything inside comes along by itself. It’s one remove, one insert, and the entry’s name and parent updated.
Two checks keep the tree a tree. First, the new name must be free in the target directory.
Second, a directory must not move inside itself. Moving /home/ana to /home/ana/backup/ana
would make it its own ancestor, and no path from the root could reach it any more. To catch that,
we walk up from the target directory to the root. If we meet the entry on the way, we refuse. The
root itself can’t move.
Why walk up from the target, and not down through the entry? Walking up costs the target’s depth, usually a handful of steps. Walking down through everything inside the entry could mean millions of entries. Same answer, very different cost.
- Color key
- The node that moves
Delete removes the entry’s name from its parent. A directory with anything in it is deleted
only when the caller asks for a recursive delete, as rm -r does, so one mistyped path doesn’t
take a whole subtree with it. Once the entry is unlinked, nothing points to it, and the garbage
collector frees it and everything inside.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
enum Result { OK, NOT_FOUND, NOT_A_DIRECTORY, IS_A_DIRECTORY, ALREADY_EXISTS, NOT_EMPTY, INVALID_PATH, INVALID_MOVE }
class FileSystem { private final Directory root = new Directory("", null);
// Creates a directory. With parents, missing directories on the way are created too, and an // existing directory is fine, like mkdir -p. Result mkdir(String path, boolean parents) { List<String> names = parse(path); if (names == null) return Result.INVALID_PATH; Directory dir = root; for (int i = 0; i < names.size(); i++) { boolean last = i == names.size() - 1; Entry next = dir.children.get(names.get(i)); if (next == null) { if (!last && !parents) return Result.NOT_FOUND; Directory created = new Directory(names.get(i), dir); dir.children.put(created.name, created); dir = created; } else if (!(next instanceof Directory d)) { return Result.NOT_A_DIRECTORY; } else if (last && !parents) { return Result.ALREADY_EXISTS; } else { dir = d; } } return names.isEmpty() && !parents ? Result.ALREADY_EXISTS : Result.OK; }
// Creates the file if it's missing, and replaces its content. Result write(String path, String content) { List<String> names = parse(path); if (names == null) return Result.INVALID_PATH; if (names.isEmpty()) return Result.IS_A_DIRECTORY; Outcome<Directory> parent = directory(names.subList(0, names.size() - 1)); if (parent.failed()) return parent.result(); String name = names.get(names.size() - 1); Entry existing = parent.value().children.get(name); if (existing instanceof Directory) return Result.IS_A_DIRECTORY; File file = existing == null ? new File(name, parent.value()) : (File) existing; file.content = content; parent.value().children.put(name, file); return Result.OK; }
Outcome<String> read(String path) { Outcome<Entry> found = find(path); if (found.failed()) return Outcome.fail(found.result()); if (!(found.value() instanceof File f)) return Outcome.fail(Result.IS_A_DIRECTORY); return Outcome.ok(f.content); }
// A directory lists its children's names, in order; a file lists itself, as ls does. Outcome<List<String>> ls(String path) { Outcome<Entry> found = find(path); if (found.failed()) return Outcome.fail(found.result()); if (found.value() instanceof Directory d) return Outcome.ok(List.copyOf(d.children.keySet())); return Outcome.ok(List.of(found.value().name)); }
Outcome<Long> size(String path) { Outcome<Entry> found = find(path); return found.failed() ? Outcome.fail(found.result()) : Outcome.ok(found.value().size()); }
// Moves or renames an entry; to is its full new path. The subtree moves with it, because // only the entry's own parent and name change: nothing below it stores a path. Result move(String from, String to) { List<String> src = parse(from); List<String> dst = parse(to); if (src == null || dst == null) return Result.INVALID_PATH; if (src.isEmpty() || dst.isEmpty()) return Result.INVALID_MOVE; // the root stays where it is Outcome<Entry> found = resolve(src); if (found.failed()) return found.result(); Outcome<Directory> target = directory(dst.subList(0, dst.size() - 1)); if (target.failed()) return target.result(); String newName = dst.get(dst.size() - 1); if (target.value().children.containsKey(newName)) return Result.ALREADY_EXISTS; // A directory can't move inside itself, or it would be cut off from the root. Walking up // from the target to the root finds it if it's an ancestor. Entry entry = found.value(); for (Directory d = target.value(); d != null; d = d.parent) { if (d == entry) return Result.INVALID_MOVE; } entry.parent.children.remove(entry.name); entry.name = newName; entry.parent = target.value(); target.value().children.put(newName, entry); return Result.OK; }
// A directory with anything in it is deleted only when asked to be recursive, like rm -r. // Unlinking it from its parent is enough: nothing else points into the subtree. Result delete(String path, boolean recursive) { List<String> names = parse(path); if (names == null || names.isEmpty()) return Result.INVALID_PATH; Outcome<Entry> found = resolve(names); if (found.failed()) return found.result(); Entry entry = found.value(); if (entry instanceof Directory d && !d.children.isEmpty() && !recursive) return Result.NOT_EMPTY; entry.parent.children.remove(entry.name); return Result.OK; }
private Outcome<Entry> find(String path) { List<String> names = parse(path); return names == null ? Outcome.fail(Result.INVALID_PATH) : resolve(names); }
// Walks from the root, one name at a time. The cost is the path's depth, not the number of // files in the system. private Outcome<Entry> resolve(List<String> names) { Entry at = root; for (String name : names) { if (!(at instanceof Directory dir)) return Outcome.fail(Result.NOT_A_DIRECTORY); at = dir.children.get(name); if (at == null) return Outcome.fail(Result.NOT_FOUND); } return Outcome.ok(at); }
private Outcome<Directory> directory(List<String> names) { Outcome<Entry> found = resolve(names); if (found.failed()) return Outcome.fail(found.result()); if (!(found.value() instanceof Directory d)) return Outcome.fail(Result.NOT_A_DIRECTORY); return Outcome.ok(d); }
// Turns "/a/./b//c/../d" into [a, b, d]. Every path is absolute, so a missing leading slash // is a mistake rather than something relative to guess about. ".." at the root stays there. static List<String> parse(String path) { if (!path.startsWith("/")) return null; List<String> names = new ArrayList<>(); for (String part : path.split("/")) { if (part.isEmpty() || part.equals(".")) continue; if (!part.equals("..")) names.add(part); else if (!names.isEmpty()) names.remove(names.size() - 1); } return names; }}class Result(Enum): OK = "ok" NOT_FOUND = "not_found" NOT_A_DIRECTORY = "not_a_directory" IS_A_DIRECTORY = "is_a_directory" ALREADY_EXISTS = "already_exists" NOT_EMPTY = "not_empty" INVALID_PATH = "invalid_path" INVALID_MOVE = "invalid_move"
class FileSystem: def __init__(self): self._root = Directory("", None)
# Creates a directory. With parents, missing directories on the way are created too, and # an existing directory is fine, like mkdir -p. def mkdir(self, path: str, parents: bool = False) -> Result: names = parse(path) if names is None: return Result.INVALID_PATH directory = self._root for i, name in enumerate(names): last = i == len(names) - 1 nxt = directory.children.get(name) if nxt is None: if not last and not parents: return Result.NOT_FOUND created = Directory(name, directory) directory.children[name] = created directory = created elif not isinstance(nxt, Directory): return Result.NOT_A_DIRECTORY elif last and not parents: return Result.ALREADY_EXISTS else: directory = nxt return Result.ALREADY_EXISTS if not names and not parents else Result.OK
# Creates the file if it's missing, and replaces its content. def write(self, path: str, content: str) -> Result: names = parse(path) if names is None: return Result.INVALID_PATH if not names: return Result.IS_A_DIRECTORY parent = self._directory(names[:-1]) if parent.failed: return parent.result existing = parent.value.children.get(names[-1]) if isinstance(existing, Directory): return Result.IS_A_DIRECTORY file = existing if isinstance(existing, File) else File(names[-1], parent.value) file.content = content parent.value.children[file.name] = file return Result.OK
def read(self, path: str) -> Outcome[str]: found = self._find(path) if found.failed: return Outcome(found.result) if not isinstance(found.value, File): return Outcome(Result.IS_A_DIRECTORY) return Outcome(Result.OK, found.value.content)
# A directory lists its children's names, in order; a file lists itself, as ls does. def ls(self, path: str) -> Outcome[list[str]]: found = self._find(path) if found.failed: return Outcome(found.result) if isinstance(found.value, Directory): return Outcome(Result.OK, sorted(found.value.children)) return Outcome(Result.OK, [found.value.name])
def size(self, path: str) -> Outcome[int]: found = self._find(path) return Outcome(found.result) if found.failed else Outcome(Result.OK, found.value.size())
# Moves or renames an entry; dst_path is its full new path. The subtree moves with it, # because only the entry's own parent and name change: nothing below it stores a path. def move(self, src_path: str, dst_path: str) -> Result: src, dst = parse(src_path), parse(dst_path) if src is None or dst is None: return Result.INVALID_PATH if not src or not dst: return Result.INVALID_MOVE # the root stays where it is found = self._resolve(src) if found.failed: return found.result target = self._directory(dst[:-1]) if target.failed: return target.result if dst[-1] in target.value.children: return Result.ALREADY_EXISTS # A directory can't move inside itself, or it would be cut off from the root. Walking up # from the target to the root finds it if it's an ancestor. entry = found.value d: Directory | None = target.value while d is not None: if d is entry: return Result.INVALID_MOVE d = d.parent del entry.parent.children[entry.name] entry.name = dst[-1] entry.parent = target.value target.value.children[entry.name] = entry return Result.OK
# A directory with anything in it is deleted only when asked to be recursive, like # rm -r. Unlinking it from its parent is enough: nothing else points into the subtree. def delete(self, path: str, recursive: bool = False) -> Result: names = parse(path) if not names: return Result.INVALID_PATH found = self._resolve(names) if found.failed: return found.result entry = found.value if isinstance(entry, Directory) and entry.children and not recursive: return Result.NOT_EMPTY del entry.parent.children[entry.name] return Result.OK
def _find(self, path: str) -> Outcome[Entry]: names = parse(path) return Outcome(Result.INVALID_PATH) if names is None else self._resolve(names)
# Walks from the root, one name at a time. The cost is the path's depth, not the number # of files in the system. def _resolve(self, names: list[str]) -> Outcome[Entry]: at: Entry = self._root for name in names: if not isinstance(at, Directory): return Outcome(Result.NOT_A_DIRECTORY) nxt = at.children.get(name) if nxt is None: return Outcome(Result.NOT_FOUND) at = nxt return Outcome(Result.OK, at)
def _directory(self, names: list[str]) -> Outcome[Directory]: found = self._resolve(names) if found.failed: return Outcome(found.result) if not isinstance(found.value, Directory): return Outcome(Result.NOT_A_DIRECTORY) return Outcome(Result.OK, found.value)public enum Result { Ok, NotFound, NotADirectory, IsADirectory, AlreadyExists, NotEmpty, InvalidPath, InvalidMove }
public sealed class FileSystem{ private readonly Directory _root = new("", null);
// Creates a directory. With parents, missing directories on the way are created too, and an // existing directory is fine, like mkdir -p. public Result Mkdir(string path, bool parents = false) { if (Parse(path) is not { } names) return Result.InvalidPath; var dir = _root; for (var i = 0; i < names.Count; i++) { var last = i == names.Count - 1; switch (dir.Children.GetValueOrDefault(names[i])) { case null: if (!last && !parents) return Result.NotFound; var created = new Directory(names[i], dir); dir.Children[names[i]] = created; dir = created; break; case Directory d: if (last && !parents) return Result.AlreadyExists; dir = d; break; default: return Result.NotADirectory; } } return names.Count == 0 && !parents ? Result.AlreadyExists : Result.Ok; }
// Creates the file if it's missing, and replaces its content. public Result Write(string path, string content) { if (Parse(path) is not { } names) return Result.InvalidPath; if (names.Count == 0) return Result.IsADirectory; var parent = Dir(names[..^1]); if (parent.Failed) return parent.Result; var name = names[^1]; switch (parent.Value!.Children.GetValueOrDefault(name)) { case Directory: return Result.IsADirectory; case File f: f.Content = content; break; default: parent.Value.Children[name] = new File(name, parent.Value) { Content = content }; break; } return Result.Ok; }
public Outcome<string> Read(string path) { var found = Find(path); if (found.Failed) return Outcome<string>.Fail(found.Result); return found.Value is File f ? Outcome<string>.Ok(f.Content) : Outcome<string>.Fail(Result.IsADirectory); }
// A directory lists its children's names, in order; a file lists itself, as ls does. public Outcome<List<string>> Ls(string path) { var found = Find(path); if (found.Failed) return Outcome<List<string>>.Fail(found.Result); return Outcome<List<string>>.Ok(found.Value is Directory d ? [.. d.Children.Keys] : [found.Value!.Name]); }
public Outcome<long> Size(string path) { var found = Find(path); return found.Failed ? Outcome<long>.Fail(found.Result) : Outcome<long>.Ok(found.Value!.Size()); }
// Moves or renames an entry; to is its full new path. The subtree moves with it, because only // the entry's own parent and name change: nothing below it stores a path. public Result Move(string from, string to) { if (Parse(from) is not { } src || Parse(to) is not { } dst) return Result.InvalidPath; if (src.Count == 0 || dst.Count == 0) return Result.InvalidMove; // the root stays where it is var found = Resolve(src); if (found.Failed) return found.Result; var target = Dir(dst[..^1]); if (target.Failed) return target.Result; var newName = dst[^1]; if (target.Value!.Children.ContainsKey(newName)) return Result.AlreadyExists; // A directory can't move inside itself, or it would be cut off from the root. Walking up // from the target to the root finds it if it's an ancestor. var entry = found.Value!; for (var d = target.Value; d is not null; d = d.Parent) { if (d == entry) return Result.InvalidMove; } entry.Parent!.Children.Remove(entry.Name); entry.Name = newName; entry.Parent = target.Value; target.Value.Children[newName] = entry; return Result.Ok; }
// A directory with anything in it is deleted only when asked to be recursive, like rm -r. // Unlinking it from its parent is enough: nothing else points into the subtree. public Result Delete(string path, bool recursive = false) { if (Parse(path) is not { Count: > 0 } names) return Result.InvalidPath; var found = Resolve(names); if (found.Failed) return found.Result; var entry = found.Value!; if (entry is Directory { Children.Count: > 0 } && !recursive) return Result.NotEmpty; entry.Parent!.Children.Remove(entry.Name); return Result.Ok; }
private Outcome<Entry> Find(string path) => Parse(path) is { } names ? Resolve(names) : Outcome<Entry>.Fail(Result.InvalidPath);
// Walks from the root, one name at a time. The cost is the path's depth, not the number of // files in the system. private Outcome<Entry> Resolve(List<string> names) { Entry at = _root; foreach (var name in names) { if (at is not Directory dir) return Outcome<Entry>.Fail(Result.NotADirectory); if (!dir.Children.TryGetValue(name, out var next)) return Outcome<Entry>.Fail(Result.NotFound); at = next; } return Outcome<Entry>.Ok(at); }
private Outcome<Directory> Dir(List<string> names) { var found = Resolve(names); if (found.Failed) return Outcome<Directory>.Fail(found.Result); return found.Value is Directory d ? Outcome<Directory>.Ok(d) : Outcome<Directory>.Fail(Result.NotADirectory); }
// Turns "/a/./b//c/../d" into [a, b, d]. Every path is absolute, so a missing leading slash // is a mistake rather than something relative to guess about. ".." at the root stays there. public static List<string>? Parse(string path) { if (!path.StartsWith('/')) return null; var names = new List<string>(); foreach (var part in path.Split('/')) { if (part is "" or ".") continue; if (part != "..") names.Add(part); else if (names.Count > 0) names.RemoveAt(names.Count - 1); } return names; }}type Result int
const ( OK Result = iota NotFound NotADirectory IsADirectory AlreadyExists NotEmpty InvalidPath InvalidMove)
type FileSystem struct { root *Directory}
func NewFileSystem() *FileSystem { return &FileSystem{root: newDirectory("", nil)} }
// Mkdir creates a directory. With parents, missing directories on the way are created too, and// an existing directory is fine, like mkdir -p.func (fs *FileSystem) Mkdir(path string, parents bool) Result { names, ok := parse(path) if !ok { return InvalidPath } dir := fs.root for i, name := range names { last := i == len(names)-1 switch next := dir.children[name].(type) { case nil: if !last && !parents { return NotFound } created := newDirectory(name, dir) dir.children[name] = created dir = created case *Directory: if last && !parents { return AlreadyExists } dir = next default: return NotADirectory } } if len(names) == 0 && !parents { return AlreadyExists } return OK}
// Write creates the file if it's missing, and replaces its content.func (fs *FileSystem) Write(path, content string) Result { names, ok := parse(path) if !ok { return InvalidPath } if len(names) == 0 { return IsADirectory } parent, r := fs.directory(names[:len(names)-1]) if r != OK { return r } name := names[len(names)-1] switch existing := parent.children[name].(type) { case *Directory: return IsADirectory case *File: existing.content = content default: parent.children[name] = &File{node{name, parent}, content} } return OK}
func (fs *FileSystem) Read(path string) (string, Result) { e, r := fs.find(path) if r != OK { return "", r } f, ok := e.(*File) if !ok { return "", IsADirectory } return f.content, OK}
// Ls lists a directory's children's names, in order; a file lists itself, as ls does.func (fs *FileSystem) Ls(path string) ([]string, Result) { e, r := fs.find(path) if r != OK { return nil, r } if d, ok := e.(*Directory); ok { return slices.Sorted(maps.Keys(d.children)), OK } return []string{e.base().name}, OK}
func (fs *FileSystem) Size(path string) (int64, Result) { e, r := fs.find(path) if r != OK { return 0, r } return e.Size(), OK}
// Move moves or renames an entry; to is its full new path. The subtree moves with it, because// only the entry's own parent and name change: nothing below it stores a path.func (fs *FileSystem) Move(from, to string) Result { src, ok1 := parse(from) dst, ok2 := parse(to) if !ok1 || !ok2 { return InvalidPath } if len(src) == 0 || len(dst) == 0 { return InvalidMove // the root stays where it is } e, r := fs.resolve(src) if r != OK { return r } target, r := fs.directory(dst[:len(dst)-1]) if r != OK { return r } newName := dst[len(dst)-1] if _, taken := target.children[newName]; taken { return AlreadyExists } // A directory can't move inside itself, or it would be cut off from the root. Walking up // from the target to the root finds it if it's an ancestor. n := e.base() for d := target; d != nil; d = d.parent { if &d.node == n { return InvalidMove } } delete(n.parent.children, n.name) n.name, n.parent = newName, target target.children[newName] = e return OK}
// Delete removes a directory with anything in it only when asked to be recursive, like rm -r.// Unlinking it from its parent is enough: nothing else points into the subtree.func (fs *FileSystem) Delete(path string, recursive bool) Result { names, ok := parse(path) if !ok || len(names) == 0 { return InvalidPath } e, r := fs.resolve(names) if r != OK { return r } if d, isDir := e.(*Directory); isDir && len(d.children) > 0 && !recursive { return NotEmpty } n := e.base() delete(n.parent.children, n.name) return OK}
func (fs *FileSystem) find(path string) (Entry, Result) { names, ok := parse(path) if !ok { return nil, InvalidPath } return fs.resolve(names)}
// resolve walks from the root, one name at a time. The cost is the path's depth, not the// number of files in the system.func (fs *FileSystem) resolve(names []string) (Entry, Result) { var at Entry = fs.root for _, name := range names { dir, ok := at.(*Directory) if !ok { return nil, NotADirectory } if at, ok = dir.children[name]; !ok { return nil, NotFound } } return at, OK}
func (fs *FileSystem) directory(names []string) (*Directory, Result) { e, r := fs.resolve(names) if r != OK { return nil, r } d, ok := e.(*Directory) if !ok { return nil, NotADirectory } return d, OK}type Result = | 'ok' | 'not-found' | 'not-a-directory' | 'is-a-directory' | 'already-exists' | 'not-empty' | 'invalid-path' | 'invalid-move';
class FileSystem { private readonly root = new Directory('', undefined);
// Creates a directory. With parents, missing directories on the way are created too, and an // existing directory is fine, like mkdir -p. mkdir(path: string, parents = false): Result { const names = parse(path); if (!names) return 'invalid-path'; let dir = this.root; for (const [i, name] of names.entries()) { const last = i === names.length - 1; const next = dir.children.get(name); if (!next) { if (!last && !parents) return 'not-found'; const created = new Directory(name, dir); dir.children.set(name, created); dir = created; } else if (!(next instanceof Directory)) { return 'not-a-directory'; } else if (last && !parents) { return 'already-exists'; } else { dir = next; } } return names.length === 0 && !parents ? 'already-exists' : 'ok'; }
// Creates the file if it's missing, and replaces its content. write(path: string, content: string): Result { const names = parse(path); if (!names) return 'invalid-path'; if (names.length === 0) return 'is-a-directory'; const parent = this.directory(names.slice(0, -1)); if (!parent.value) return parent.result; const name = names[names.length - 1]; const existing = parent.value.children.get(name); if (existing instanceof Directory) return 'is-a-directory'; const file = existing instanceof File ? existing : new File(name, parent.value); file.content = content; parent.value.children.set(name, file); return 'ok'; }
read(path: string): Outcome<string> { const found = this.find(path); if (!found.value) return fail(found.result); if (!(found.value instanceof File)) return fail('is-a-directory'); return ok(found.value.content); }
// A directory lists its children's names, in order; a file lists itself, as ls does. ls(path: string): Outcome<string[]> { const found = this.find(path); if (!found.value) return fail(found.result); if (found.value instanceof Directory) return ok([...found.value.children.keys()].sort()); return ok([found.value.name]); }
size(path: string): Outcome<number> { const found = this.find(path); return found.value ? ok(found.value.size()) : fail(found.result); }
// Moves or renames an entry; to is its full new path. The subtree moves with it, because only // the entry's own parent and name change: nothing below it stores a path. move(from: string, to: string): Result { const src = parse(from); const dst = parse(to); if (!src || !dst) return 'invalid-path'; if (src.length === 0 || dst.length === 0) return 'invalid-move'; // the root stays where it is const found = this.resolve(src); if (!found.value) return found.result; const target = this.directory(dst.slice(0, -1)); if (!target.value) return target.result; const newName = dst[dst.length - 1]; if (target.value.children.has(newName)) return 'already-exists'; // A directory can't move inside itself, or it would be cut off from the root. Walking up from // the target to the root finds it if it's an ancestor. const entry = found.value; for (let d: Directory | undefined = target.value; d; d = d.parent) { if (d === entry) return 'invalid-move'; } entry.parent!.children.delete(entry.name); entry.name = newName; entry.parent = target.value; target.value.children.set(newName, entry); return 'ok'; }
// A directory with anything in it is deleted only when asked to be recursive, like rm -r. // Unlinking it from its parent is enough: nothing else points into the subtree. delete(path: string, recursive = false): Result { const names = parse(path); if (!names || names.length === 0) return 'invalid-path'; const found = this.resolve(names); if (!found.value) return found.result; const entry = found.value; if (entry instanceof Directory && entry.children.size > 0 && !recursive) return 'not-empty'; entry.parent!.children.delete(entry.name); return 'ok'; }
private find(path: string): Outcome<Entry> { const names = parse(path); return names ? this.resolve(names) : fail('invalid-path'); }
// Walks from the root, one name at a time. The cost is the path's depth, not the number of // files in the system. private resolve(names: string[]): Outcome<Entry> { let at: Entry = this.root; for (const name of names) { if (!(at instanceof Directory)) return fail('not-a-directory'); const next = at.children.get(name); if (!next) return fail('not-found'); at = next; } return ok(at); }
private directory(names: string[]): Outcome<Directory> { const found = this.resolve(names); if (!found.value) return fail(found.result); return found.value instanceof Directory ? ok(found.value) : fail('not-a-directory'); }}Stage 4: Owners and permissions
Section titled “Stage 4: Owners and permissions”Several users share the file system. Ana’s private notes shouldn’t be readable by Ben, and Ben shouldn’t be able to delete Ana’s files. How do you add that?
Whether the checks happen in one place, on the right entry for each operation, and whether you know that execute on a directory is what lets you pass through it. A design that only checks the final file lets Ben list a directory he was locked out of.
Every entry gets an owner and a Mode: three permission bits for the owner, and three for
everyone else, as in Unix without groups. Read is 4, write is 2 and execute is 1, so 7 is all
three, 6 is read and write, and 5 is read and execute. New directories get 7 for the owner and 5
for others. New files get 6 and 4. Every call now says which user makes it, and the superuser,
root, passes every check.
On a file, the bits mean what they say. On a directory, they mean something more specific, and this is the part to get right:
| Bit on a directory | Lets you | Checked by |
|---|---|---|
| Read | List its names | ls |
| Write | Add, remove or rename names in it | mkdir, creating a file, move, delete |
| Execute | Pass through it to anything inside | every walk, at every directory on the path |
So deleting a file needs write on its directory, not on the file, because delete changes the directory’s list of names. And a directory with no execute for others (mode 7 and 0) hides everything inside it, however open the files themselves are. The walk stops at the door.
- Color key
- Where the check fails
- The error returned
Moving needs write on both the directory it leaves and the one it enters. chmod (change the mode)
is for the owner or root, and chown (change the owner) is for root only, so nobody can hand a file
to someone else.
All the checks go through one method, can(user, entry, access), and the walk in resolve checks
execute on each directory it passes. Every operation uses that one walk, so no operation can reach
an entry without passing the same doors.
Go deeperFollowing Ben's walk to Ana's diary1 min
Ana’s private directory has mode 7 and 0: everything for Ana, nothing for anyone else. Inside it,
her diary has mode 6 and 4, so others may read it. Ben asks to read /home/ana/private/diary.
- The walk starts at the root. Ben has execute on
/(others have 5), so he may pass. homehas mode 7 and 5. Execute for others, so he passes.anahas mode 7 and 5. He passes.privatehas mode 7 and 0. No execute for others. The walk stops here withPERMISSION_DENIED, and never looks inside.
The diary’s own bits were never checked, because Ben never reached it. That’s the point of checking execute at every door: the owner of a directory controls everything below it, without having to change the mode of every file inside.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// What a call gets back. Errors are values, as a shell prints them, not exceptions.enum Result { OK, NOT_FOUND, NOT_A_DIRECTORY, IS_A_DIRECTORY, ALREADY_EXISTS, NOT_EMPTY, INVALID_PATH, INVALID_MOVE, PERMISSION_DENIED}
// Who may do what with an entry: read, write and execute bits for the owner and for everyone// else, as in Unix without groups. 7 is all three, 6 is read and write, 5 is read and execute.// On a directory, read lets you list it, write lets you add and remove names in it, and execute// lets you pass through it on the way to something inside.record Mode(int owner, int others) { static final int READ = 4, WRITE = 2, EXECUTE = 1;
boolean allows(boolean isOwner, int access) { return ((isOwner ? owner : others) & access) == access; }}
// The composite. A file and a directory are both entries, so code that walks the tree, like// size(), asks each entry the same question and lets it answer for itself.abstract class Entry { String name; Directory parent; String owner; Mode mode;
Entry(String name, Directory parent, String owner, Mode mode) { this.name = name; this.parent = parent; this.owner = owner; this.mode = mode; }
abstract long size();}
// A file holds text. New files start as rw-r--r--: the owner reads and writes, others read.class File extends Entry { String content = "";
File(String name, Directory parent, String owner) { super(name, parent, owner, new Mode(6, 4)); }
long size() { return content.length(); }}
// A directory holds named entries. New ones start as rwxr-xr-x. The map is sorted, so ls lists// names in order with no extra work.class Directory extends Entry { final SortedMap<String, Entry> children = new TreeMap<>();
Directory(String name, Directory parent, String owner) { super(name, parent, owner, new Mode(7, 5)); }
long size() { long total = 0; for (Entry e : children.values()) total += e.size(); return total; }}
// Every call names the user making it. New entries belong to that user. The superuser, root,// passes every check.class FileSystem { static final String ROOT = "root"; private final Directory root = new Directory("", null, ROOT);
// Walks the path one name at a time. With parents (like mkdir -p) missing directories on the // way are created, and an existing one is fine. Result mkdir(String user, String path, boolean parents) { List<String> names = parse(path); if (names == null) return Result.INVALID_PATH; Directory dir = root; for (int i = 0; i < names.size(); i++) { if (!can(user, dir, Mode.EXECUTE)) return Result.PERMISSION_DENIED; boolean last = i == names.size() - 1; Entry next = dir.children.get(names.get(i)); if (next == null) { if (!last && !parents) return Result.NOT_FOUND; if (!can(user, dir, Mode.WRITE)) return Result.PERMISSION_DENIED; Directory created = new Directory(names.get(i), dir, user); dir.children.put(created.name, created); dir = created; } else if (!(next instanceof Directory d)) { return Result.NOT_A_DIRECTORY; } else if (last && !parents) { return Result.ALREADY_EXISTS; } else { dir = d; } } return names.isEmpty() && !parents ? Result.ALREADY_EXISTS : Result.OK; }
// Changing a file's content needs write on the file; creating one needs write on its directory. Result write(String user, String path, String content) { List<String> names = parse(path); if (names == null) return Result.INVALID_PATH; if (names.isEmpty()) return Result.IS_A_DIRECTORY; Outcome<Directory> parent = directory(user, names.subList(0, names.size() - 1)); if (parent.failed()) return parent.result(); String name = names.get(names.size() - 1); Entry existing = parent.value().children.get(name); if (existing instanceof Directory) return Result.IS_A_DIRECTORY; if (!can(user, existing == null ? parent.value() : existing, Mode.WRITE)) return Result.PERMISSION_DENIED; File file = existing == null ? new File(name, parent.value(), user) : (File) existing; file.content = content; parent.value().children.put(name, file); return Result.OK; }
Outcome<String> read(String user, String path) { Outcome<Entry> found = find(user, path); if (found.failed()) return Outcome.fail(found.result()); if (!(found.value() instanceof File f)) return Outcome.fail(Result.IS_A_DIRECTORY); if (!can(user, f, Mode.READ)) return Outcome.fail(Result.PERMISSION_DENIED); return Outcome.ok(f.content); }
// A directory lists its children. A file lists just its own name, as ls does. Outcome<List<String>> ls(String user, String path) { Outcome<Entry> found = find(user, path); if (found.failed()) return Outcome.fail(found.result()); if (!(found.value() instanceof Directory d)) return Outcome.ok(List.of(found.value().name)); if (!can(user, d, Mode.READ)) return Outcome.fail(Result.PERMISSION_DENIED); return Outcome.ok(List.copyOf(d.children.keySet())); }
// Only needs to reach the entry. A stricter version would also need read on every directory // it adds up, as du does. Outcome<Long> size(String user, String path) { Outcome<Entry> found = find(user, path); return found.failed() ? Outcome.fail(found.result()) : Outcome.ok(found.value().size()); }
// Taking a name out of one directory and putting it in another changes both, so both need // write. The entry's own mode doesn't matter, as in Unix. Result move(String user, String from, String to) { List<String> src = parse(from); List<String> dst = parse(to); if (src == null || dst == null) return Result.INVALID_PATH; if (src.isEmpty() || dst.isEmpty()) return Result.INVALID_MOVE; Outcome<Entry> found = resolve(user, src); if (found.failed()) return found.result(); Outcome<Directory> target = directory(user, dst.subList(0, dst.size() - 1)); if (target.failed()) return target.result(); Entry entry = found.value(); if (!can(user, entry.parent, Mode.WRITE) || !can(user, target.value(), Mode.WRITE)) { return Result.PERMISSION_DENIED; } String newName = dst.get(dst.size() - 1); if (target.value().children.containsKey(newName)) return Result.ALREADY_EXISTS; // A directory can't move inside itself: walk up from the target and make sure we never meet it. for (Directory d = target.value(); d != null; d = d.parent) { if (d == entry) return Result.INVALID_MOVE; } entry.parent.children.remove(entry.name); entry.name = newName; entry.parent = target.value(); target.value().children.put(newName, entry); return Result.OK; }
// Needs write on the directory the name is removed from. Unix also needs write on every // directory inside a recursive delete; this version doesn't check below the top. Result delete(String user, String path, boolean recursive) { List<String> names = parse(path); if (names == null || names.isEmpty()) return Result.INVALID_PATH; Outcome<Entry> found = resolve(user, names); if (found.failed()) return found.result(); Entry entry = found.value(); if (!can(user, entry.parent, Mode.WRITE)) return Result.PERMISSION_DENIED; if (entry instanceof Directory d && !d.children.isEmpty() && !recursive) return Result.NOT_EMPTY; entry.parent.children.remove(entry.name); return Result.OK; }
// Only the owner, or root, changes an entry's mode. Result chmod(String user, String path, Mode mode) { Outcome<Entry> found = find(user, path); if (found.failed()) return found.result(); if (!user.equals(ROOT) && !user.equals(found.value().owner)) return Result.PERMISSION_DENIED; found.value().mode = mode; return Result.OK; }
// Only root gives an entry away, so nobody can push a file onto someone else. Result chown(String user, String path, String owner) { Outcome<Entry> found = find(user, path); if (found.failed()) return found.result(); if (!user.equals(ROOT)) return Result.PERMISSION_DENIED; found.value().owner = owner; return Result.OK; }
// The one permission check. Root passes; everyone else gets the owner bits or the others bits. private boolean can(String user, Entry entry, int access) { return user.equals(ROOT) || entry.mode.allows(user.equals(entry.owner), access); }
private Outcome<Entry> find(String user, String path) { List<String> names = parse(path); return names == null ? Outcome.fail(Result.INVALID_PATH) : resolve(user, names); }
// Passing through a directory needs execute on it, so a directory with no execute for // others hides everything inside, however open the files themselves are. private Outcome<Entry> resolve(String user, List<String> names) { Entry at = root; for (String name : names) { if (!(at instanceof Directory dir)) return Outcome.fail(Result.NOT_A_DIRECTORY); if (!can(user, dir, Mode.EXECUTE)) return Outcome.fail(Result.PERMISSION_DENIED); at = dir.children.get(name); if (at == null) return Outcome.fail(Result.NOT_FOUND); } return Outcome.ok(at); }
// Like resolve, but the entry must be a directory: where a new name is about to go. private Outcome<Directory> directory(String user, List<String> names) { Outcome<Entry> found = resolve(user, names); if (found.failed()) return Outcome.fail(found.result()); if (!(found.value() instanceof Directory d)) return Outcome.fail(Result.NOT_A_DIRECTORY); return Outcome.ok(d); }
// Turns "/a/./b/../c" into [a, c]. Only absolute paths are accepted; ".." at the top stays at // the top, as in Unix. Returns null for a path that isn't absolute. static List<String> parse(String path) { if (!path.startsWith("/")) return null; List<String> names = new ArrayList<>(); for (String part : path.split("/")) { if (part.isEmpty() || part.equals(".")) continue; if (!part.equals("..")) names.add(part); else if (!names.isEmpty()) names.remove(names.size() - 1); } return names; }}# What a call gets back. Errors are values, as a shell prints them, not exceptions.class Result(Enum): OK = "ok" NOT_FOUND = "not_found" NOT_A_DIRECTORY = "not_a_directory" IS_A_DIRECTORY = "is_a_directory" ALREADY_EXISTS = "already_exists" NOT_EMPTY = "not_empty" INVALID_PATH = "invalid_path" INVALID_MOVE = "invalid_move" PERMISSION_DENIED = "permission_denied"
# Who may do what with an entry: read, write and execute bits for the owner and for everyone# else, as in Unix without groups. 7 is all three, 6 is read and write, 5 is read and execute.# On a directory, read lets you list it, write lets you add and remove names in it, and execute# lets you pass through it on the way to something inside.@dataclass(frozen=True)class Mode: owner: int others: int
READ: ClassVar[int] = 4 WRITE: ClassVar[int] = 2 EXECUTE: ClassVar[int] = 1
def allows(self, is_owner: bool, access: int) -> bool: return ((self.owner if is_owner else self.others) & access) == access
# The composite. A file and a directory are both entries, so code that walks the tree,# like size(), asks each entry the same question and lets it answer for itself.class Entry(ABC): def __init__(self, name: str, parent: "Directory | None", owner: str, mode: Mode): self.name = name self.parent = parent self.owner = owner self.mode = mode
@abstractmethod def size(self) -> int: ...
# A file holds text. New files start as rw-r--r--: the owner reads and writes, others read.class File(Entry): def __init__(self, name: str, parent: "Directory", owner: str): super().__init__(name, parent, owner, Mode(6, 4)) self.content = ""
def size(self) -> int: return len(self.content)
# A directory holds named entries. New ones start as rwxr-xr-x.class Directory(Entry): def __init__(self, name: str, parent: "Directory | None", owner: str): super().__init__(name, parent, owner, Mode(7, 5)) self.children: dict[str, Entry] = {}
def size(self) -> int: return sum(child.size() for child in self.children.values())
ROOT = "root"
# Every call names the user making it. New entries belong to that user. The superuser,# root, passes every check.class FileSystem: def __init__(self): self._root = Directory("", None, ROOT)
# Walks the path one name at a time. With parents (like mkdir -p) missing directories on # the way are created, and an existing one is fine. def mkdir(self, user: str, path: str, parents: bool = False) -> Result: names = parse(path) if names is None: return Result.INVALID_PATH directory = self._root for i, name in enumerate(names): if not self._can(user, directory, Mode.EXECUTE): return Result.PERMISSION_DENIED last = i == len(names) - 1 nxt = directory.children.get(name) if nxt is None: if not last and not parents: return Result.NOT_FOUND if not self._can(user, directory, Mode.WRITE): return Result.PERMISSION_DENIED created = Directory(name, directory, user) directory.children[name] = created directory = created elif not isinstance(nxt, Directory): return Result.NOT_A_DIRECTORY elif last and not parents: return Result.ALREADY_EXISTS else: directory = nxt return Result.ALREADY_EXISTS if not names and not parents else Result.OK
# Changing a file's content needs write on the file; creating one needs write on its # directory. def write(self, user: str, path: str, content: str) -> Result: names = parse(path) if names is None: return Result.INVALID_PATH if not names: return Result.IS_A_DIRECTORY parent = self._directory(user, names[:-1]) if parent.failed: return parent.result existing = parent.value.children.get(names[-1]) if isinstance(existing, Directory): return Result.IS_A_DIRECTORY if not self._can(user, existing or parent.value, Mode.WRITE): return Result.PERMISSION_DENIED file = existing if isinstance(existing, File) else File(names[-1], parent.value, user) file.content = content parent.value.children[file.name] = file return Result.OK
def read(self, user: str, path: str) -> Outcome[str]: found = self._find(user, path) if found.failed: return Outcome(found.result) if not isinstance(found.value, File): return Outcome(Result.IS_A_DIRECTORY) if not self._can(user, found.value, Mode.READ): return Outcome(Result.PERMISSION_DENIED) return Outcome(Result.OK, found.value.content)
# A directory lists its children, in order. A file lists just its own name, as ls does. def ls(self, user: str, path: str) -> Outcome[list[str]]: found = self._find(user, path) if found.failed: return Outcome(found.result) if not isinstance(found.value, Directory): return Outcome(Result.OK, [found.value.name]) if not self._can(user, found.value, Mode.READ): return Outcome(Result.PERMISSION_DENIED) return Outcome(Result.OK, sorted(found.value.children))
# Only needs to reach the entry. A stricter version would also need read on every # directory it adds up, as du does. def size(self, user: str, path: str) -> Outcome[int]: found = self._find(user, path) return Outcome(found.result) if found.failed else Outcome(Result.OK, found.value.size())
# Taking a name out of one directory and putting it in another changes both, so both # need write. The entry's own mode doesn't matter, as in Unix. def move(self, user: str, src_path: str, dst_path: str) -> Result: src, dst = parse(src_path), parse(dst_path) if src is None or dst is None: return Result.INVALID_PATH if not src or not dst: return Result.INVALID_MOVE found = self._resolve(user, src) if found.failed: return found.result target = self._directory(user, dst[:-1]) if target.failed: return target.result entry = found.value if not self._can(user, entry.parent, Mode.WRITE) or not self._can(user, target.value, Mode.WRITE): return Result.PERMISSION_DENIED if dst[-1] in target.value.children: return Result.ALREADY_EXISTS # A directory can't move inside itself: walk up from the target and make sure we never # meet it. d: Directory | None = target.value while d is not None: if d is entry: return Result.INVALID_MOVE d = d.parent del entry.parent.children[entry.name] entry.name = dst[-1] entry.parent = target.value target.value.children[entry.name] = entry return Result.OK
# Needs write on the directory the name is removed from. Unix also needs write on every # directory inside a recursive delete; this version doesn't check below the top. def delete(self, user: str, path: str, recursive: bool = False) -> Result: names = parse(path) if not names: return Result.INVALID_PATH found = self._resolve(user, names) if found.failed: return found.result entry = found.value if not self._can(user, entry.parent, Mode.WRITE): return Result.PERMISSION_DENIED if isinstance(entry, Directory) and entry.children and not recursive: return Result.NOT_EMPTY del entry.parent.children[entry.name] return Result.OK
# Only the owner, or root, changes an entry's mode. def chmod(self, user: str, path: str, mode: Mode) -> Result: found = self._find(user, path) if found.failed: return found.result if user not in (ROOT, found.value.owner): return Result.PERMISSION_DENIED found.value.mode = mode return Result.OK
# Only root gives an entry away, so nobody can push a file onto someone else. def chown(self, user: str, path: str, owner: str) -> Result: found = self._find(user, path) if found.failed: return found.result if user != ROOT: return Result.PERMISSION_DENIED found.value.owner = owner return Result.OK
# The one permission check. Root passes; everyone else gets the owner or others bits. def _can(self, user: str, entry: Entry, access: int) -> bool: return user == ROOT or entry.mode.allows(user == entry.owner, access)
def _find(self, user: str, path: str) -> Outcome[Entry]: names = parse(path) return Outcome(Result.INVALID_PATH) if names is None else self._resolve(user, names)
# Passing through a directory needs execute on it, so a directory with no execute for # others hides everything inside, however open the files themselves are. def _resolve(self, user: str, names: list[str]) -> Outcome[Entry]: at: Entry = self._root for name in names: if not isinstance(at, Directory): return Outcome(Result.NOT_A_DIRECTORY) if not self._can(user, at, Mode.EXECUTE): return Outcome(Result.PERMISSION_DENIED) nxt = at.children.get(name) if nxt is None: return Outcome(Result.NOT_FOUND) at = nxt return Outcome(Result.OK, at)
# Like _resolve, but the entry must be a directory: where a new name is about to go. def _directory(self, user: str, names: list[str]) -> Outcome[Directory]: found = self._resolve(user, names) if found.failed: return Outcome(found.result) if not isinstance(found.value, Directory): return Outcome(Result.NOT_A_DIRECTORY) return Outcome(Result.OK, found.value)// What a call gets back. Errors are values, as a shell prints them, not exceptions.public enum Result{ Ok, NotFound, NotADirectory, IsADirectory, AlreadyExists, NotEmpty, InvalidPath, InvalidMove, PermissionDenied,}
// Who may do what with an entry: read, write and execute bits for the owner and for everyone// else, as in Unix without groups. 7 is all three, 6 is read and write, 5 is read and execute.// On a directory, read lets you list it, write lets you add and remove names in it, and execute// lets you pass through it on the way to something inside.public sealed record Mode(int Owner, int Others){ public const int Read = 4, Write = 2, Execute = 1;
public bool Allows(bool isOwner, int access) => ((isOwner ? Owner : Others) & access) == access;}
// The composite. A file and a directory are both entries, so code that walks the tree, like// Size(), asks each entry the same question and lets it answer for itself.public abstract class Entry(string name, Directory? parent, string owner, Mode mode){ public string Name { get; set; } = name; public Directory? Parent { get; set; } = parent; public string Owner { get; set; } = owner; public Mode Mode { get; set; } = mode;
public abstract long Size();}
// A file holds text. New files start as rw-r--r--: the owner reads and writes, others read.public sealed class File(string name, Directory parent, string owner) : Entry(name, parent, owner, new Mode(6, 4)){ public string Content { get; set; } = "";
public override long Size() => Content.Length;}
// A directory holds named entries. New ones start as rwxr-xr-x.public sealed class Directory(string name, Directory? parent, string owner) : Entry(name, parent, owner, new Mode(7, 5)){ public SortedDictionary<string, Entry> Children { get; } = new(StringComparer.Ordinal);
public override long Size() => Children.Values.Sum(e => e.Size());}
// Every call names the user making it. New entries belong to that user. The superuser, root,// passes every check.public sealed class FileSystem{ public const string Root = "root"; private readonly Directory _root = new("", null, Root);
// Walks the path one name at a time. With parents (like mkdir -p) missing directories on the // way are created, and an existing one is fine. public Result Mkdir(string user, string path, bool parents = false) { if (Parse(path) is not { } names) return Result.InvalidPath; var dir = _root; for (var i = 0; i < names.Count; i++) { if (!Can(user, dir, Mode.Execute)) return Result.PermissionDenied; var last = i == names.Count - 1; switch (dir.Children.GetValueOrDefault(names[i])) { case null: if (!last && !parents) return Result.NotFound; if (!Can(user, dir, Mode.Write)) return Result.PermissionDenied; var created = new Directory(names[i], dir, user); dir.Children[names[i]] = created; dir = created; break; case Directory d: if (last && !parents) return Result.AlreadyExists; dir = d; break; default: return Result.NotADirectory; } } return names.Count == 0 && !parents ? Result.AlreadyExists : Result.Ok; }
// Changing a file's content needs write on the file; creating one needs write on its directory. public Result Write(string user, string path, string content) { if (Parse(path) is not { } names) return Result.InvalidPath; if (names.Count == 0) return Result.IsADirectory; var parent = Dir(user, names[..^1]); if (parent.Failed) return parent.Result; var name = names[^1]; switch (parent.Value!.Children.GetValueOrDefault(name)) { case Directory: return Result.IsADirectory; case File f: if (!Can(user, f, Mode.Write)) return Result.PermissionDenied; f.Content = content; break; default: if (!Can(user, parent.Value, Mode.Write)) return Result.PermissionDenied; parent.Value.Children[name] = new File(name, parent.Value, user) { Content = content }; break; } return Result.Ok; }
public Outcome<string> Read(string user, string path) { var found = Find(user, path); if (found.Failed) return Outcome<string>.Fail(found.Result); if (found.Value is not File f) return Outcome<string>.Fail(Result.IsADirectory); return Can(user, f, Mode.Read) ? Outcome<string>.Ok(f.Content) : Outcome<string>.Fail(Result.PermissionDenied); }
// A directory lists its children, in order. A file lists just its own name, as ls does. public Outcome<List<string>> Ls(string user, string path) { var found = Find(user, path); if (found.Failed) return Outcome<List<string>>.Fail(found.Result); if (found.Value is not Directory d) return Outcome<List<string>>.Ok([found.Value!.Name]); if (!Can(user, d, Mode.Read)) return Outcome<List<string>>.Fail(Result.PermissionDenied); return Outcome<List<string>>.Ok([.. d.Children.Keys]); }
// Only needs to reach the entry. A stricter version would also need read on every directory // it adds up, as du does. public Outcome<long> Size(string user, string path) { var found = Find(user, path); return found.Failed ? Outcome<long>.Fail(found.Result) : Outcome<long>.Ok(found.Value!.Size()); }
// Taking a name out of one directory and putting it in another changes both, so both need // write. The entry's own mode doesn't matter, as in Unix. public Result Move(string user, string from, string to) { if (Parse(from) is not { } src || Parse(to) is not { } dst) return Result.InvalidPath; if (src.Count == 0 || dst.Count == 0) return Result.InvalidMove; var found = Resolve(user, src); if (found.Failed) return found.Result; var target = Dir(user, dst[..^1]); if (target.Failed) return target.Result; var entry = found.Value!; if (!Can(user, entry.Parent!, Mode.Write) || !Can(user, target.Value!, Mode.Write)) return Result.PermissionDenied; var newName = dst[^1]; if (target.Value!.Children.ContainsKey(newName)) return Result.AlreadyExists; // A directory can't move inside itself: walk up from the target and make sure we never meet it. for (var d = target.Value; d is not null; d = d.Parent) { if (d == entry) return Result.InvalidMove; } entry.Parent!.Children.Remove(entry.Name); entry.Name = newName; entry.Parent = target.Value; target.Value.Children[newName] = entry; return Result.Ok; }
// Needs write on the directory the name is removed from. Unix also needs write on every // directory inside a recursive delete; this version doesn't check below the top. public Result Delete(string user, string path, bool recursive = false) { if (Parse(path) is not { Count: > 0 } names) return Result.InvalidPath; var found = Resolve(user, names); if (found.Failed) return found.Result; var entry = found.Value!; if (!Can(user, entry.Parent!, Mode.Write)) return Result.PermissionDenied; if (entry is Directory { Children.Count: > 0 } && !recursive) return Result.NotEmpty; entry.Parent!.Children.Remove(entry.Name); return Result.Ok; }
// Only the owner, or root, changes an entry's mode. public Result Chmod(string user, string path, Mode mode) { var found = Find(user, path); if (found.Failed) return found.Result; if (user != Root && user != found.Value!.Owner) return Result.PermissionDenied; found.Value!.Mode = mode; return Result.Ok; }
// Only root gives an entry away, so nobody can push a file onto someone else. public Result Chown(string user, string path, string owner) { var found = Find(user, path); if (found.Failed) return found.Result; if (user != Root) return Result.PermissionDenied; found.Value!.Owner = owner; return Result.Ok; }
// The one permission check. Root passes; everyone else gets the owner bits or the others bits. private static bool Can(string user, Entry entry, int access) => user == Root || entry.Mode.Allows(user == entry.Owner, access);
private Outcome<Entry> Find(string user, string path) => Parse(path) is { } names ? Resolve(user, names) : Outcome<Entry>.Fail(Result.InvalidPath);
// Passing through a directory needs execute on it, so a directory with no execute for others // hides everything inside, however open the files themselves are. private Outcome<Entry> Resolve(string user, List<string> names) { Entry at = _root; foreach (var name in names) { if (at is not Directory dir) return Outcome<Entry>.Fail(Result.NotADirectory); if (!Can(user, dir, Mode.Execute)) return Outcome<Entry>.Fail(Result.PermissionDenied); if (!dir.Children.TryGetValue(name, out var next)) return Outcome<Entry>.Fail(Result.NotFound); at = next; } return Outcome<Entry>.Ok(at); }
// Like Resolve, but the entry must be a directory: where a new name is about to go. private Outcome<Directory> Dir(string user, List<string> names) { var found = Resolve(user, names); if (found.Failed) return Outcome<Directory>.Fail(found.Result); return found.Value is Directory d ? Outcome<Directory>.Ok(d) : Outcome<Directory>.Fail(Result.NotADirectory); }
public static List<string>? Parse(string path) { if (!path.StartsWith('/')) return null; var names = new List<string>(); foreach (var part in path.Split('/')) { if (part is "" or ".") continue; if (part != "..") names.Add(part); else if (names.Count > 0) names.RemoveAt(names.Count - 1); } return names; }}// Result is what a call gets back. Errors are values, as a shell prints them.type Result int
const ( OK Result = iota NotFound NotADirectory IsADirectory AlreadyExists NotEmpty InvalidPath InvalidMove PermissionDenied)
// Mode says who may do what with an entry: read, write and execute bits for the owner and for// everyone else, as in Unix without groups. 7 is all three, 6 is read and write, 5 is read and// execute. On a directory, read lets you list it, write lets you add and remove names in it, and// execute lets you pass through it on the way to something inside.type Mode struct{ Owner, Others int }
const ( Read = 4 Write = 2 Execute = 1)
func (m Mode) allows(isOwner bool, access int) bool { bits := m.Others if isOwner { bits = m.Owner } return bits&access == access}
// node is what every entry has, embedded in File and Directory.type node struct { name string parent *Directory owner string mode Mode}
func (n *node) base() *node { return n }
// File holds text. New files start as rw-r--r--: the owner reads and writes, others read.type File struct { node content string}
func (f *File) Size() int64 { return int64(len(f.content)) }
// Directory holds named entries. New ones start as rwxr-xr-x.type Directory struct { node children map[string]Entry}
func newDirectory(name string, parent *Directory, owner string) *Directory { return &Directory{node{name, parent, owner, Mode{7, 5}}, map[string]Entry{}}}
func (d *Directory) Size() int64 { var total int64 for _, e := range d.children { total += e.Size() } return total}
// Root is the superuser, who passes every check.const Root = "root"
// FileSystem takes the user making each call. New entries belong to that user.type FileSystem struct { root *Directory}
func NewFileSystem() *FileSystem { return &FileSystem{root: newDirectory("", nil, Root)} }
// Mkdir walks the path one name at a time. With parents (like mkdir -p) missing directories on// the way are created, and an existing one is fine.func (fs *FileSystem) Mkdir(user, path string, parents bool) Result { names, ok := parse(path) if !ok { return InvalidPath } dir := fs.root for i, name := range names { if !can(user, &dir.node, Execute) { return PermissionDenied } last := i == len(names)-1 switch next := dir.children[name].(type) { case nil: if !last && !parents { return NotFound } if !can(user, &dir.node, Write) { return PermissionDenied } created := newDirectory(name, dir, user) dir.children[name] = created dir = created case *Directory: if last && !parents { return AlreadyExists } dir = next default: return NotADirectory } } if len(names) == 0 && !parents { return AlreadyExists } return OK}
// Write needs write on the file to change it, or on its directory to create it.func (fs *FileSystem) Write(user, path, content string) Result { names, ok := parse(path) if !ok { return InvalidPath } if len(names) == 0 { return IsADirectory } parent, r := fs.directory(user, names[:len(names)-1]) if r != OK { return r } name := names[len(names)-1] switch existing := parent.children[name].(type) { case *Directory: return IsADirectory case *File: if !can(user, &existing.node, Write) { return PermissionDenied } existing.content = content default: if !can(user, &parent.node, Write) { return PermissionDenied } parent.children[name] = &File{node{name, parent, user, Mode{6, 4}}, content} } return OK}
func (fs *FileSystem) Read(user, path string) (string, Result) { e, r := fs.find(user, path) if r != OK { return "", r } f, ok := e.(*File) if !ok { return "", IsADirectory } if !can(user, &f.node, Read) { return "", PermissionDenied } return f.content, OK}
// Ls lists a directory's children, in order. A file lists just its own name, as ls does.func (fs *FileSystem) Ls(user, path string) ([]string, Result) { e, r := fs.find(user, path) if r != OK { return nil, r } d, ok := e.(*Directory) if !ok { return []string{e.base().name}, OK } if !can(user, &d.node, Read) { return nil, PermissionDenied } return slices.Sorted(maps.Keys(d.children)), OK}
// Size only needs to reach the entry. A stricter version would also need read on every// directory it adds up, as du does.func (fs *FileSystem) Size(user, path string) (int64, Result) { e, r := fs.find(user, path) if r != OK { return 0, r } return e.Size(), OK}
// Move takes a name out of one directory and puts it in another, which changes both, so both// need write. The entry's own mode doesn't matter, as in Unix.func (fs *FileSystem) Move(user, from, to string) Result { src, ok1 := parse(from) dst, ok2 := parse(to) if !ok1 || !ok2 { return InvalidPath } if len(src) == 0 || len(dst) == 0 { return InvalidMove } e, r := fs.resolve(user, src) if r != OK { return r } target, r := fs.directory(user, dst[:len(dst)-1]) if r != OK { return r } n := e.base() if !can(user, &n.parent.node, Write) || !can(user, &target.node, Write) { return PermissionDenied } newName := dst[len(dst)-1] if _, taken := target.children[newName]; taken { return AlreadyExists } // A directory can't move inside itself: walk up from the target and make sure we never meet it. for d := target; d != nil; d = d.parent { if &d.node == n { return InvalidMove } } delete(n.parent.children, n.name) n.name, n.parent = newName, target target.children[newName] = e return OK}
// Delete needs write on the directory the name is removed from. Unix also needs write on every// directory inside a recursive delete; this version doesn't check below the top.func (fs *FileSystem) Delete(user, path string, recursive bool) Result { names, ok := parse(path) if !ok || len(names) == 0 { return InvalidPath } e, r := fs.resolve(user, names) if r != OK { return r } n := e.base() if !can(user, &n.parent.node, Write) { return PermissionDenied } if d, isDir := e.(*Directory); isDir && len(d.children) > 0 && !recursive { return NotEmpty } delete(n.parent.children, n.name) return OK}
// Chmod is for the owner, or root.func (fs *FileSystem) Chmod(user, path string, mode Mode) Result { e, r := fs.find(user, path) if r != OK { return r } n := e.base() if user != Root && user != n.owner { return PermissionDenied } n.mode = mode return OK}
// Chown is for root only, so nobody can push a file onto someone else.func (fs *FileSystem) Chown(user, path, owner string) Result { e, r := fs.find(user, path) if r != OK { return r } if user != Root { return PermissionDenied } e.base().owner = owner return OK}
// can is the one permission check. Root passes; everyone else gets the owner or others bits.func can(user string, n *node, access int) bool { return user == Root || n.mode.allows(user == n.owner, access)}
func (fs *FileSystem) find(user, path string) (Entry, Result) { names, ok := parse(path) if !ok { return nil, InvalidPath } return fs.resolve(user, names)}
// resolve needs execute on every directory it passes through, so a directory with no execute// for others hides everything inside, however open the files themselves are.func (fs *FileSystem) resolve(user string, names []string) (Entry, Result) { var at Entry = fs.root for _, name := range names { dir, ok := at.(*Directory) if !ok { return nil, NotADirectory } if !can(user, &dir.node, Execute) { return nil, PermissionDenied } if at, ok = dir.children[name]; !ok { return nil, NotFound } } return at, OK}
// directory is like resolve, but the entry must be a directory: where a new name is about to go.func (fs *FileSystem) directory(user string, names []string) (*Directory, Result) { e, r := fs.resolve(user, names) if r != OK { return nil, r } d, ok := e.(*Directory) if !ok { return nil, NotADirectory } return d, OK}// What a call gets back. Errors are values, as a shell prints them, not exceptions.type Result = | 'ok' | 'not-found' | 'not-a-directory' | 'is-a-directory' | 'already-exists' | 'not-empty' | 'invalid-path' | 'invalid-move' | 'permission-denied';
// Who may do what with an entry: read, write and execute bits for the owner and for everyone// else, as in Unix without groups. 7 is all three, 6 is read and write, 5 is read and execute.// On a directory, read lets you list it, write lets you add and remove names in it, and execute// lets you pass through it on the way to something inside.class Mode { static readonly READ = 4; static readonly WRITE = 2; static readonly EXECUTE = 1;
constructor( readonly owner: number, readonly others: number, ) {}
allows(isOwner: boolean, access: number): boolean { return ((isOwner ? this.owner : this.others) & access) === access; }}
// The composite. A file and a directory are both entries, so code that walks the tree, like// size(), asks each entry the same question and lets it answer for itself.abstract class Entry { constructor( public name: string, public parent: Directory | undefined, public owner: string, public mode: Mode, ) {}
abstract size(): number;}
// A file holds text. New files start as rw-r--r--: the owner reads and writes, others read.class File extends Entry { content = '';
constructor(name: string, parent: Directory, owner: string) { super(name, parent, owner, new Mode(6, 4)); }
size(): number { return this.content.length; }}
// A directory holds named entries. New ones start as rwxr-xr-x.class Directory extends Entry { readonly children = new Map<string, Entry>();
constructor(name: string, parent: Directory | undefined, owner: string) { super(name, parent, owner, new Mode(7, 5)); }
size(): number { let total = 0; for (const child of this.children.values()) total += child.size(); return total; }}
// The superuser, who passes every check.const ROOT = 'root';
// Every call names the user making it. New entries belong to that user.class FileSystem { private readonly root = new Directory('', undefined, ROOT);
// Walks the path one name at a time. With parents (like mkdir -p) missing directories on the way // are created, and an existing one is fine. mkdir(user: string, path: string, parents = false): Result { const names = parse(path); if (!names) return 'invalid-path'; let dir = this.root; for (const [i, name] of names.entries()) { if (!can(user, dir, Mode.EXECUTE)) return 'permission-denied'; const last = i === names.length - 1; const next = dir.children.get(name); if (!next) { if (!last && !parents) return 'not-found'; if (!can(user, dir, Mode.WRITE)) return 'permission-denied'; const created = new Directory(name, dir, user); dir.children.set(name, created); dir = created; } else if (!(next instanceof Directory)) { return 'not-a-directory'; } else if (last && !parents) { return 'already-exists'; } else { dir = next; } } return names.length === 0 && !parents ? 'already-exists' : 'ok'; }
// Changing a file's content needs write on the file; creating one needs write on its directory. write(user: string, path: string, content: string): Result { const names = parse(path); if (!names) return 'invalid-path'; if (names.length === 0) return 'is-a-directory'; const parent = this.directory(user, names.slice(0, -1)); if (!parent.value) return parent.result; const name = names[names.length - 1]; const existing = parent.value.children.get(name); if (existing instanceof Directory) return 'is-a-directory'; if (!can(user, existing ?? parent.value, Mode.WRITE)) return 'permission-denied'; const file = existing instanceof File ? existing : new File(name, parent.value, user); file.content = content; parent.value.children.set(name, file); return 'ok'; }
read(user: string, path: string): Outcome<string> { const found = this.find(user, path); if (!found.value) return fail(found.result); if (!(found.value instanceof File)) return fail('is-a-directory'); if (!can(user, found.value, Mode.READ)) return fail('permission-denied'); return ok(found.value.content); }
// A directory lists its children, in order. A file lists just its own name, as ls does. ls(user: string, path: string): Outcome<string[]> { const found = this.find(user, path); if (!found.value) return fail(found.result); if (!(found.value instanceof Directory)) return ok([found.value.name]); if (!can(user, found.value, Mode.READ)) return fail('permission-denied'); return ok([...found.value.children.keys()].sort()); }
// Only needs to reach the entry. A stricter version would also need read on every directory it // adds up, as du does. size(user: string, path: string): Outcome<number> { const found = this.find(user, path); return found.value ? ok(found.value.size()) : fail(found.result); }
// Taking a name out of one directory and putting it in another changes both, so both need // write. The entry's own mode doesn't matter, as in Unix. move(user: string, from: string, to: string): Result { const src = parse(from); const dst = parse(to); if (!src || !dst) return 'invalid-path'; if (src.length === 0 || dst.length === 0) return 'invalid-move'; const found = this.resolve(user, src); if (!found.value) return found.result; const target = this.directory(user, dst.slice(0, -1)); if (!target.value) return target.result; const entry = found.value; if (!can(user, entry.parent!, Mode.WRITE) || !can(user, target.value, Mode.WRITE)) return 'permission-denied'; const newName = dst[dst.length - 1]; if (target.value.children.has(newName)) return 'already-exists'; // A directory can't move inside itself: walk up from the target and make sure we never meet it. for (let d: Directory | undefined = target.value; d; d = d.parent) { if (d === entry) return 'invalid-move'; } entry.parent!.children.delete(entry.name); entry.name = newName; entry.parent = target.value; target.value.children.set(newName, entry); return 'ok'; }
// Needs write on the directory the name is removed from. Unix also needs write on every // directory inside a recursive delete; this version doesn't check below the top. delete(user: string, path: string, recursive = false): Result { const names = parse(path); if (!names || names.length === 0) return 'invalid-path'; const found = this.resolve(user, names); if (!found.value) return found.result; const entry = found.value; if (!can(user, entry.parent!, Mode.WRITE)) return 'permission-denied'; if (entry instanceof Directory && entry.children.size > 0 && !recursive) return 'not-empty'; entry.parent!.children.delete(entry.name); return 'ok'; }
// Only the owner, or root, changes an entry's mode. chmod(user: string, path: string, mode: Mode): Result { const found = this.find(user, path); if (!found.value) return found.result; if (user !== ROOT && user !== found.value.owner) return 'permission-denied'; found.value.mode = mode; return 'ok'; }
// Only root gives an entry away, so nobody can push a file onto someone else. chown(user: string, path: string, owner: string): Result { const found = this.find(user, path); if (!found.value) return found.result; if (user !== ROOT) return 'permission-denied'; found.value.owner = owner; return 'ok'; }
private find(user: string, path: string): Outcome<Entry> { const names = parse(path); return names ? this.resolve(user, names) : fail('invalid-path'); }
// Passing through a directory needs execute on it, so a directory with no execute for others // hides everything inside, however open the files themselves are. private resolve(user: string, names: string[]): Outcome<Entry> { let at: Entry = this.root; for (const name of names) { if (!(at instanceof Directory)) return fail('not-a-directory'); if (!can(user, at, Mode.EXECUTE)) return fail('permission-denied'); const next = at.children.get(name); if (!next) return fail('not-found'); at = next; } return ok(at); }
// Like resolve, but the entry must be a directory: where a new name is about to go. private directory(user: string, names: string[]): Outcome<Directory> { const found = this.resolve(user, names); if (!found.value) return fail(found.result); return found.value instanceof Directory ? ok(found.value) : fail('not-a-directory'); }}
// The one permission check. Root passes; everyone else gets the owner bits or the others bits.function can(user: string, entry: Entry, access: number): boolean { return user === ROOT || entry.mode.allows(user === entry.owner, access);}Go deeperMode bits, or an access list per entry1 min
| Owner and others bits (our choice) | An access control list per entry | |
|---|---|---|
| What it can say | Two classes of user | Any user or group, any right |
| Size per entry | Two small numbers | A list that can grow |
| Cost of a check | A bit mask | A search through the list |
| Familiar to readers | Yes, it’s chmod 750 |
Yes, it’s Windows and cloud storage |
Bits are enough to show the design. An access control list could replace Mode behind the same
can method, which is the point of having one method for every check.
This is enough for a mid-level answer. A composite tree, paths cleaned up and walked, move as a relink with the inside-itself check, and permissions checked. The rest of the page is what makes it a senior answer.
The full design
Section titled “The full design”Here is everything together. FileSystem is what callers use, and it does every walk and every
check. SharedFileSystem wraps it with a lock for many threads. The tree itself is Directory and
File, both kinds of Entry.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
// The complete design, built from every stage on this page.
import java.util.*;import java.util.concurrent.locks.*;import java.util.function.*;
// Every call names the user making it. New entries belong to that user. The superuser, root,// passes every check.class FileSystem { static final String ROOT = "root"; private final Directory root = new Directory("", null, ROOT);
// Walks the path one name at a time. With parents (like mkdir -p) missing directories on the // way are created, and an existing one is fine. Result mkdir(String user, String path, boolean parents) { List<String> names = parse(path); if (names == null) return Result.INVALID_PATH; Directory dir = root; for (int i = 0; i < names.size(); i++) { if (!can(user, dir, Mode.EXECUTE)) return Result.PERMISSION_DENIED; boolean last = i == names.size() - 1; Entry next = dir.children.get(names.get(i)); if (next == null) { if (!last && !parents) return Result.NOT_FOUND; if (!can(user, dir, Mode.WRITE)) return Result.PERMISSION_DENIED; Directory created = new Directory(names.get(i), dir, user); dir.children.put(created.name, created); dir = created; } else if (!(next instanceof Directory d)) { return Result.NOT_A_DIRECTORY; } else if (last && !parents) { return Result.ALREADY_EXISTS; } else { dir = d; } } return names.isEmpty() && !parents ? Result.ALREADY_EXISTS : Result.OK; }
// Changing a file's content needs write on the file; creating one needs write on its directory. Result write(String user, String path, String content) { List<String> names = parse(path); if (names == null) return Result.INVALID_PATH; if (names.isEmpty()) return Result.IS_A_DIRECTORY; Outcome<Directory> parent = directory(user, names.subList(0, names.size() - 1)); if (parent.failed()) return parent.result(); String name = names.get(names.size() - 1); Entry existing = parent.value().children.get(name); if (existing instanceof Directory) return Result.IS_A_DIRECTORY; if (!can(user, existing == null ? parent.value() : existing, Mode.WRITE)) return Result.PERMISSION_DENIED; File file = existing == null ? new File(name, parent.value(), user) : (File) existing; file.content = content; parent.value().children.put(name, file); return Result.OK; }
Outcome<String> read(String user, String path) { Outcome<Entry> found = find(user, path); if (found.failed()) return Outcome.fail(found.result()); if (!(found.value() instanceof File f)) return Outcome.fail(Result.IS_A_DIRECTORY); if (!can(user, f, Mode.READ)) return Outcome.fail(Result.PERMISSION_DENIED); return Outcome.ok(f.content); }
// A directory lists its children. A file lists just its own name, as ls does. Outcome<List<String>> ls(String user, String path) { Outcome<Entry> found = find(user, path); if (found.failed()) return Outcome.fail(found.result()); if (!(found.value() instanceof Directory d)) return Outcome.ok(List.of(found.value().name)); if (!can(user, d, Mode.READ)) return Outcome.fail(Result.PERMISSION_DENIED); return Outcome.ok(List.copyOf(d.children.keySet())); }
// Only needs to reach the entry. A stricter version would also need read on every directory // it adds up, as du does. Outcome<Long> size(String user, String path) { Outcome<Entry> found = find(user, path); return found.failed() ? Outcome.fail(found.result()) : Outcome.ok(found.value().size()); }
// Taking a name out of one directory and putting it in another changes both, so both need // write. The entry's own mode doesn't matter, as in Unix. Result move(String user, String from, String to) { List<String> src = parse(from); List<String> dst = parse(to); if (src == null || dst == null) return Result.INVALID_PATH; if (src.isEmpty() || dst.isEmpty()) return Result.INVALID_MOVE; Outcome<Entry> found = resolve(user, src); if (found.failed()) return found.result(); Outcome<Directory> target = directory(user, dst.subList(0, dst.size() - 1)); if (target.failed()) return target.result(); Entry entry = found.value(); if (!can(user, entry.parent, Mode.WRITE) || !can(user, target.value(), Mode.WRITE)) { return Result.PERMISSION_DENIED; } String newName = dst.get(dst.size() - 1); if (target.value().children.containsKey(newName)) return Result.ALREADY_EXISTS; // A directory can't move inside itself: walk up from the target and make sure we never meet it. for (Directory d = target.value(); d != null; d = d.parent) { if (d == entry) return Result.INVALID_MOVE; } entry.parent.children.remove(entry.name); entry.name = newName; entry.parent = target.value(); target.value().children.put(newName, entry); return Result.OK; }
// Needs write on the directory the name is removed from. Unix also needs write on every // directory inside a recursive delete; this version doesn't check below the top. Result delete(String user, String path, boolean recursive) { List<String> names = parse(path); if (names == null || names.isEmpty()) return Result.INVALID_PATH; Outcome<Entry> found = resolve(user, names); if (found.failed()) return found.result(); Entry entry = found.value(); if (!can(user, entry.parent, Mode.WRITE)) return Result.PERMISSION_DENIED; if (entry instanceof Directory d && !d.children.isEmpty() && !recursive) return Result.NOT_EMPTY; entry.parent.children.remove(entry.name); return Result.OK; }
// Only the owner, or root, changes an entry's mode. Result chmod(String user, String path, Mode mode) { Outcome<Entry> found = find(user, path); if (found.failed()) return found.result(); if (!user.equals(ROOT) && !user.equals(found.value().owner)) return Result.PERMISSION_DENIED; found.value().mode = mode; return Result.OK; }
// Only root gives an entry away, so nobody can push a file onto someone else. Result chown(String user, String path, String owner) { Outcome<Entry> found = find(user, path); if (found.failed()) return found.result(); if (!user.equals(ROOT)) return Result.PERMISSION_DENIED; found.value().owner = owner; return Result.OK; }
// The one permission check. Root passes; everyone else gets the owner bits or the others bits. private boolean can(String user, Entry entry, int access) { return user.equals(ROOT) || entry.mode.allows(user.equals(entry.owner), access); }
private Outcome<Entry> find(String user, String path) { List<String> names = parse(path); return names == null ? Outcome.fail(Result.INVALID_PATH) : resolve(user, names); }
// Passing through a directory needs execute on it, so a directory with no execute for // others hides everything inside, however open the files themselves are. private Outcome<Entry> resolve(String user, List<String> names) { Entry at = root; for (String name : names) { if (!(at instanceof Directory dir)) return Outcome.fail(Result.NOT_A_DIRECTORY); if (!can(user, dir, Mode.EXECUTE)) return Outcome.fail(Result.PERMISSION_DENIED); at = dir.children.get(name); if (at == null) return Outcome.fail(Result.NOT_FOUND); } return Outcome.ok(at); }
// Like resolve, but the entry must be a directory: where a new name is about to go. private Outcome<Directory> directory(String user, List<String> names) { Outcome<Entry> found = resolve(user, names); if (found.failed()) return Outcome.fail(found.result()); if (!(found.value() instanceof Directory d)) return Outcome.fail(Result.NOT_A_DIRECTORY); return Outcome.ok(d); }
// Turns "/a/./b/../c" into [a, c]. Only absolute paths are accepted; ".." at the top stays at // the top, as in Unix. Returns null for a path that isn't absolute. static List<String> parse(String path) { if (!path.startsWith("/")) return null; List<String> names = new ArrayList<>(); for (String part : path.split("/")) { if (part.isEmpty() || part.equals(".")) continue; if (!part.equals("..")) names.add(part); else if (!names.isEmpty()) names.remove(names.size() - 1); } return names; }}
// What a call gets back. Errors are values, as a shell prints them, not exceptions.enum Result { OK, NOT_FOUND, NOT_A_DIRECTORY, IS_A_DIRECTORY, ALREADY_EXISTS, NOT_EMPTY, INVALID_PATH, INVALID_MOVE, PERMISSION_DENIED}
// A result and, when it is OK, a value.record Outcome<T>(Result result, T value) { static <T> Outcome<T> ok(T value) { return new Outcome<>(Result.OK, value); } static <T> Outcome<T> fail(Result result) { return new Outcome<>(result, null); } boolean failed() { return result != Result.OK; }}
// The composite. A file and a directory are both entries, so code that walks the tree, like// size(), asks each entry the same question and lets it answer for itself.abstract class Entry { String name; Directory parent; String owner; Mode mode;
Entry(String name, Directory parent, String owner, Mode mode) { this.name = name; this.parent = parent; this.owner = owner; this.mode = mode; }
abstract long size();}
// A file holds text. New files start as rw-r--r--: the owner reads and writes, others read.class File extends Entry { String content = "";
File(String name, Directory parent, String owner) { super(name, parent, owner, new Mode(6, 4)); }
long size() { return content.length(); }}
// A directory holds named entries. New ones start as rwxr-xr-x. The map is sorted, so ls lists// names in order with no extra work.class Directory extends Entry { final SortedMap<String, Entry> children = new TreeMap<>();
Directory(String name, Directory parent, String owner) { super(name, parent, owner, new Mode(7, 5)); }
long size() { long total = 0; for (Entry e : children.values()) total += e.size(); return total; }}
// Who may do what with an entry: read, write and execute bits for the owner and for everyone// else, as in Unix without groups. 7 is all three, 6 is read and write, 5 is read and execute.// On a directory, read lets you list it, write lets you add and remove names in it, and execute// lets you pass through it on the way to something inside.record Mode(int owner, int others) { static final int READ = 4, WRITE = 2, EXECUTE = 1;
boolean allows(boolean isOwner, int access) { return ((isOwner ? owner : others) & access) == access; }}
// One read-write lock for the whole tree. Reads (read, ls, size) share it, so they run in// parallel; anything that changes the tree takes it alone. A move touches two directories and// checks every ancestor of the target, so a lock per directory would need a fixed order to// avoid deadlock, and a walk that takes and drops locks down the path. One lock is correct by// construction, and an in-memory tree operation takes microseconds, so it is rarely the// bottleneck.class SharedFileSystem { private final FileSystem fs; private final ReadWriteLock lock = new ReentrantReadWriteLock();
SharedFileSystem(FileSystem fs) { this.fs = fs; }
Result mkdir(String user, String path, boolean parents) { return writing(() -> fs.mkdir(user, path, parents)); }
Result write(String user, String path, String content) { return writing(() -> fs.write(user, path, content)); }
Result move(String user, String from, String to) { return writing(() -> fs.move(user, from, to)); }
Result delete(String user, String path, boolean recursive) { return writing(() -> fs.delete(user, path, recursive)); }
Result chmod(String user, String path, Mode mode) { return writing(() -> fs.chmod(user, path, mode)); }
Outcome<String> read(String user, String path) { return reading(() -> fs.read(user, path)); }
Outcome<List<String>> ls(String user, String path) { return reading(() -> fs.ls(user, path)); }
Outcome<Long> size(String user, String path) { return reading(() -> fs.size(user, path)); }
// Many readers at once, or one writer alone. finally releases the lock even if the call throws. private <T> T reading(Supplier<T> op) { lock.readLock().lock(); try { return op.get(); } finally { lock.readLock().unlock(); } }
private <T> T writing(Supplier<T> op) { lock.writeLock().lock(); try { return op.get(); } finally { lock.writeLock().unlock(); } }}# The complete design, built from every stage on this page.
from __future__ import annotations
import threadingfrom abc import ABC, abstractmethodfrom dataclasses import dataclassfrom enum import Enumfrom typing import ClassVar, Generic, TypeVar
T = TypeVar("T")
# Every call names the user making it. New entries belong to that user. The superuser,# root, passes every check.class FileSystem: def __init__(self): self._root = Directory("", None, ROOT)
# Walks the path one name at a time. With parents (like mkdir -p) missing directories on # the way are created, and an existing one is fine. def mkdir(self, user: str, path: str, parents: bool = False) -> Result: names = parse(path) if names is None: return Result.INVALID_PATH directory = self._root for i, name in enumerate(names): if not self._can(user, directory, Mode.EXECUTE): return Result.PERMISSION_DENIED last = i == len(names) - 1 nxt = directory.children.get(name) if nxt is None: if not last and not parents: return Result.NOT_FOUND if not self._can(user, directory, Mode.WRITE): return Result.PERMISSION_DENIED created = Directory(name, directory, user) directory.children[name] = created directory = created elif not isinstance(nxt, Directory): return Result.NOT_A_DIRECTORY elif last and not parents: return Result.ALREADY_EXISTS else: directory = nxt return Result.ALREADY_EXISTS if not names and not parents else Result.OK
# Changing a file's content needs write on the file; creating one needs write on its # directory. def write(self, user: str, path: str, content: str) -> Result: names = parse(path) if names is None: return Result.INVALID_PATH if not names: return Result.IS_A_DIRECTORY parent = self._directory(user, names[:-1]) if parent.failed: return parent.result existing = parent.value.children.get(names[-1]) if isinstance(existing, Directory): return Result.IS_A_DIRECTORY if not self._can(user, existing or parent.value, Mode.WRITE): return Result.PERMISSION_DENIED file = existing if isinstance(existing, File) else File(names[-1], parent.value, user) file.content = content parent.value.children[file.name] = file return Result.OK
def read(self, user: str, path: str) -> Outcome[str]: found = self._find(user, path) if found.failed: return Outcome(found.result) if not isinstance(found.value, File): return Outcome(Result.IS_A_DIRECTORY) if not self._can(user, found.value, Mode.READ): return Outcome(Result.PERMISSION_DENIED) return Outcome(Result.OK, found.value.content)
# A directory lists its children, in order. A file lists just its own name, as ls does. def ls(self, user: str, path: str) -> Outcome[list[str]]: found = self._find(user, path) if found.failed: return Outcome(found.result) if not isinstance(found.value, Directory): return Outcome(Result.OK, [found.value.name]) if not self._can(user, found.value, Mode.READ): return Outcome(Result.PERMISSION_DENIED) return Outcome(Result.OK, sorted(found.value.children))
# Only needs to reach the entry. A stricter version would also need read on every # directory it adds up, as du does. def size(self, user: str, path: str) -> Outcome[int]: found = self._find(user, path) return Outcome(found.result) if found.failed else Outcome(Result.OK, found.value.size())
# Taking a name out of one directory and putting it in another changes both, so both # need write. The entry's own mode doesn't matter, as in Unix. def move(self, user: str, src_path: str, dst_path: str) -> Result: src, dst = parse(src_path), parse(dst_path) if src is None or dst is None: return Result.INVALID_PATH if not src or not dst: return Result.INVALID_MOVE found = self._resolve(user, src) if found.failed: return found.result target = self._directory(user, dst[:-1]) if target.failed: return target.result entry = found.value if not self._can(user, entry.parent, Mode.WRITE) or not self._can(user, target.value, Mode.WRITE): return Result.PERMISSION_DENIED if dst[-1] in target.value.children: return Result.ALREADY_EXISTS # A directory can't move inside itself: walk up from the target and make sure we never # meet it. d: Directory | None = target.value while d is not None: if d is entry: return Result.INVALID_MOVE d = d.parent del entry.parent.children[entry.name] entry.name = dst[-1] entry.parent = target.value target.value.children[entry.name] = entry return Result.OK
# Needs write on the directory the name is removed from. Unix also needs write on every # directory inside a recursive delete; this version doesn't check below the top. def delete(self, user: str, path: str, recursive: bool = False) -> Result: names = parse(path) if not names: return Result.INVALID_PATH found = self._resolve(user, names) if found.failed: return found.result entry = found.value if not self._can(user, entry.parent, Mode.WRITE): return Result.PERMISSION_DENIED if isinstance(entry, Directory) and entry.children and not recursive: return Result.NOT_EMPTY del entry.parent.children[entry.name] return Result.OK
# Only the owner, or root, changes an entry's mode. def chmod(self, user: str, path: str, mode: Mode) -> Result: found = self._find(user, path) if found.failed: return found.result if user not in (ROOT, found.value.owner): return Result.PERMISSION_DENIED found.value.mode = mode return Result.OK
# Only root gives an entry away, so nobody can push a file onto someone else. def chown(self, user: str, path: str, owner: str) -> Result: found = self._find(user, path) if found.failed: return found.result if user != ROOT: return Result.PERMISSION_DENIED found.value.owner = owner return Result.OK
# The one permission check. Root passes; everyone else gets the owner or others bits. def _can(self, user: str, entry: Entry, access: int) -> bool: return user == ROOT or entry.mode.allows(user == entry.owner, access)
def _find(self, user: str, path: str) -> Outcome[Entry]: names = parse(path) return Outcome(Result.INVALID_PATH) if names is None else self._resolve(user, names)
# Passing through a directory needs execute on it, so a directory with no execute for # others hides everything inside, however open the files themselves are. def _resolve(self, user: str, names: list[str]) -> Outcome[Entry]: at: Entry = self._root for name in names: if not isinstance(at, Directory): return Outcome(Result.NOT_A_DIRECTORY) if not self._can(user, at, Mode.EXECUTE): return Outcome(Result.PERMISSION_DENIED) nxt = at.children.get(name) if nxt is None: return Outcome(Result.NOT_FOUND) at = nxt return Outcome(Result.OK, at)
# Like _resolve, but the entry must be a directory: where a new name is about to go. def _directory(self, user: str, names: list[str]) -> Outcome[Directory]: found = self._resolve(user, names) if found.failed: return Outcome(found.result) if not isinstance(found.value, Directory): return Outcome(Result.NOT_A_DIRECTORY) return Outcome(Result.OK, found.value)
# What a call gets back. Errors are values, as a shell prints them, not exceptions.class Result(Enum): OK = "ok" NOT_FOUND = "not_found" NOT_A_DIRECTORY = "not_a_directory" IS_A_DIRECTORY = "is_a_directory" ALREADY_EXISTS = "already_exists" NOT_EMPTY = "not_empty" INVALID_PATH = "invalid_path" INVALID_MOVE = "invalid_move" PERMISSION_DENIED = "permission_denied"
# A result and, when it is OK, a value.@dataclass(frozen=True)class Outcome(Generic[T]): result: Result value: T | None = None
@property def failed(self) -> bool: return self.result is not Result.OK
# The composite. A file and a directory are both entries, so code that walks the tree,# like size(), asks each entry the same question and lets it answer for itself.class Entry(ABC): def __init__(self, name: str, parent: "Directory | None", owner: str, mode: Mode): self.name = name self.parent = parent self.owner = owner self.mode = mode
@abstractmethod def size(self) -> int: ...
# A file holds text. New files start as rw-r--r--: the owner reads and writes, others read.class File(Entry): def __init__(self, name: str, parent: "Directory", owner: str): super().__init__(name, parent, owner, Mode(6, 4)) self.content = ""
def size(self) -> int: return len(self.content)
# A directory holds named entries. New ones start as rwxr-xr-x.class Directory(Entry): def __init__(self, name: str, parent: "Directory | None", owner: str): super().__init__(name, parent, owner, Mode(7, 5)) self.children: dict[str, Entry] = {}
def size(self) -> int: return sum(child.size() for child in self.children.values())
# Turns "/a/./b//c/../d" into [a, b, d]. Every path is absolute, so a missing leading slash# is a mistake rather than something relative to guess about. ".." at the root stays there.def parse(path: str) -> list[str] | None: if not path.startswith("/"): return None names: list[str] = [] for part in path.split("/"): if part in ("", "."): continue if part != "..": names.append(part) elif names: names.pop() return names
# Who may do what with an entry: read, write and execute bits for the owner and for everyone# else, as in Unix without groups. 7 is all three, 6 is read and write, 5 is read and execute.# On a directory, read lets you list it, write lets you add and remove names in it, and execute# lets you pass through it on the way to something inside.@dataclass(frozen=True)class Mode: owner: int others: int
READ: ClassVar[int] = 4 WRITE: ClassVar[int] = 2 EXECUTE: ClassVar[int] = 1
def allows(self, is_owner: bool, access: int) -> bool: return ((self.owner if is_owner else self.others) & access) == access
ROOT = "root"
# One lock for the whole tree, taken by every call. A move touches two directories and# checks every ancestor of the target, so a lock per directory would need a fixed order to# avoid deadlock. One lock is correct by construction, and an in-memory tree operation is# quick, so it is rarely the bottleneck. Python's standard library has no read-write lock, so# reads take the same lock; the Java, C# and Go versions let reads share it.class SharedFileSystem: def __init__(self, fs: FileSystem): self._fs = fs self._lock = threading.Lock()
def mkdir(self, user: str, path: str, parents: bool = False) -> Result: with self._lock: return self._fs.mkdir(user, path, parents)
def write(self, user: str, path: str, content: str) -> Result: with self._lock: return self._fs.write(user, path, content)
def move(self, user: str, src: str, dst: str) -> Result: with self._lock: return self._fs.move(user, src, dst)
def delete(self, user: str, path: str, recursive: bool = False) -> Result: with self._lock: return self._fs.delete(user, path, recursive)
def chmod(self, user: str, path: str, mode: Mode) -> Result: with self._lock: return self._fs.chmod(user, path, mode)
def read(self, user: str, path: str) -> Outcome[str]: with self._lock: return self._fs.read(user, path)
def ls(self, user: str, path: str) -> Outcome[list[str]]: with self._lock: return self._fs.ls(user, path)
def size(self, user: str, path: str) -> Outcome[int]: with self._lock: return self._fs.size(user, path)// The complete design, built from every stage on this page.
// Every call names the user making it. New entries belong to that user. The superuser, root,// passes every check.public sealed class FileSystem{ public const string Root = "root"; private readonly Directory _root = new("", null, Root);
// Walks the path one name at a time. With parents (like mkdir -p) missing directories on the // way are created, and an existing one is fine. public Result Mkdir(string user, string path, bool parents = false) { if (Parse(path) is not { } names) return Result.InvalidPath; var dir = _root; for (var i = 0; i < names.Count; i++) { if (!Can(user, dir, Mode.Execute)) return Result.PermissionDenied; var last = i == names.Count - 1; switch (dir.Children.GetValueOrDefault(names[i])) { case null: if (!last && !parents) return Result.NotFound; if (!Can(user, dir, Mode.Write)) return Result.PermissionDenied; var created = new Directory(names[i], dir, user); dir.Children[names[i]] = created; dir = created; break; case Directory d: if (last && !parents) return Result.AlreadyExists; dir = d; break; default: return Result.NotADirectory; } } return names.Count == 0 && !parents ? Result.AlreadyExists : Result.Ok; }
// Changing a file's content needs write on the file; creating one needs write on its directory. public Result Write(string user, string path, string content) { if (Parse(path) is not { } names) return Result.InvalidPath; if (names.Count == 0) return Result.IsADirectory; var parent = Dir(user, names[..^1]); if (parent.Failed) return parent.Result; var name = names[^1]; switch (parent.Value!.Children.GetValueOrDefault(name)) { case Directory: return Result.IsADirectory; case File f: if (!Can(user, f, Mode.Write)) return Result.PermissionDenied; f.Content = content; break; default: if (!Can(user, parent.Value, Mode.Write)) return Result.PermissionDenied; parent.Value.Children[name] = new File(name, parent.Value, user) { Content = content }; break; } return Result.Ok; }
public Outcome<string> Read(string user, string path) { var found = Find(user, path); if (found.Failed) return Outcome<string>.Fail(found.Result); if (found.Value is not File f) return Outcome<string>.Fail(Result.IsADirectory); return Can(user, f, Mode.Read) ? Outcome<string>.Ok(f.Content) : Outcome<string>.Fail(Result.PermissionDenied); }
// A directory lists its children, in order. A file lists just its own name, as ls does. public Outcome<List<string>> Ls(string user, string path) { var found = Find(user, path); if (found.Failed) return Outcome<List<string>>.Fail(found.Result); if (found.Value is not Directory d) return Outcome<List<string>>.Ok([found.Value!.Name]); if (!Can(user, d, Mode.Read)) return Outcome<List<string>>.Fail(Result.PermissionDenied); return Outcome<List<string>>.Ok([.. d.Children.Keys]); }
// Only needs to reach the entry. A stricter version would also need read on every directory // it adds up, as du does. public Outcome<long> Size(string user, string path) { var found = Find(user, path); return found.Failed ? Outcome<long>.Fail(found.Result) : Outcome<long>.Ok(found.Value!.Size()); }
// Taking a name out of one directory and putting it in another changes both, so both need // write. The entry's own mode doesn't matter, as in Unix. public Result Move(string user, string from, string to) { if (Parse(from) is not { } src || Parse(to) is not { } dst) return Result.InvalidPath; if (src.Count == 0 || dst.Count == 0) return Result.InvalidMove; var found = Resolve(user, src); if (found.Failed) return found.Result; var target = Dir(user, dst[..^1]); if (target.Failed) return target.Result; var entry = found.Value!; if (!Can(user, entry.Parent!, Mode.Write) || !Can(user, target.Value!, Mode.Write)) return Result.PermissionDenied; var newName = dst[^1]; if (target.Value!.Children.ContainsKey(newName)) return Result.AlreadyExists; // A directory can't move inside itself: walk up from the target and make sure we never meet it. for (var d = target.Value; d is not null; d = d.Parent) { if (d == entry) return Result.InvalidMove; } entry.Parent!.Children.Remove(entry.Name); entry.Name = newName; entry.Parent = target.Value; target.Value.Children[newName] = entry; return Result.Ok; }
// Needs write on the directory the name is removed from. Unix also needs write on every // directory inside a recursive delete; this version doesn't check below the top. public Result Delete(string user, string path, bool recursive = false) { if (Parse(path) is not { Count: > 0 } names) return Result.InvalidPath; var found = Resolve(user, names); if (found.Failed) return found.Result; var entry = found.Value!; if (!Can(user, entry.Parent!, Mode.Write)) return Result.PermissionDenied; if (entry is Directory { Children.Count: > 0 } && !recursive) return Result.NotEmpty; entry.Parent!.Children.Remove(entry.Name); return Result.Ok; }
// Only the owner, or root, changes an entry's mode. public Result Chmod(string user, string path, Mode mode) { var found = Find(user, path); if (found.Failed) return found.Result; if (user != Root && user != found.Value!.Owner) return Result.PermissionDenied; found.Value!.Mode = mode; return Result.Ok; }
// Only root gives an entry away, so nobody can push a file onto someone else. public Result Chown(string user, string path, string owner) { var found = Find(user, path); if (found.Failed) return found.Result; if (user != Root) return Result.PermissionDenied; found.Value!.Owner = owner; return Result.Ok; }
// The one permission check. Root passes; everyone else gets the owner bits or the others bits. private static bool Can(string user, Entry entry, int access) => user == Root || entry.Mode.Allows(user == entry.Owner, access);
private Outcome<Entry> Find(string user, string path) => Parse(path) is { } names ? Resolve(user, names) : Outcome<Entry>.Fail(Result.InvalidPath);
// Passing through a directory needs execute on it, so a directory with no execute for others // hides everything inside, however open the files themselves are. private Outcome<Entry> Resolve(string user, List<string> names) { Entry at = _root; foreach (var name in names) { if (at is not Directory dir) return Outcome<Entry>.Fail(Result.NotADirectory); if (!Can(user, dir, Mode.Execute)) return Outcome<Entry>.Fail(Result.PermissionDenied); if (!dir.Children.TryGetValue(name, out var next)) return Outcome<Entry>.Fail(Result.NotFound); at = next; } return Outcome<Entry>.Ok(at); }
// Like Resolve, but the entry must be a directory: where a new name is about to go. private Outcome<Directory> Dir(string user, List<string> names) { var found = Resolve(user, names); if (found.Failed) return Outcome<Directory>.Fail(found.Result); return found.Value is Directory d ? Outcome<Directory>.Ok(d) : Outcome<Directory>.Fail(Result.NotADirectory); }
public static List<string>? Parse(string path) { if (!path.StartsWith('/')) return null; var names = new List<string>(); foreach (var part in path.Split('/')) { if (part is "" or ".") continue; if (part != "..") names.Add(part); else if (names.Count > 0) names.RemoveAt(names.Count - 1); } return names; }}
// What a call gets back. Errors are values, as a shell prints them, not exceptions.public enum Result{ Ok, NotFound, NotADirectory, IsADirectory, AlreadyExists, NotEmpty, InvalidPath, InvalidMove, PermissionDenied,}
// A result and, when it is Ok, a value.public sealed record Outcome<T>(Result Result, T? Value = default){ public bool Failed => Result != Result.Ok; public static Outcome<T> Ok(T value) => new(Result.Ok, value); public static Outcome<T> Fail(Result result) => new(result);}
// The composite. A file and a directory are both entries, so code that walks the tree, like// Size(), asks each entry the same question and lets it answer for itself.public abstract class Entry(string name, Directory? parent, string owner, Mode mode){ public string Name { get; set; } = name; public Directory? Parent { get; set; } = parent; public string Owner { get; set; } = owner; public Mode Mode { get; set; } = mode;
public abstract long Size();}
// A file holds text. New files start as rw-r--r--: the owner reads and writes, others read.public sealed class File(string name, Directory parent, string owner) : Entry(name, parent, owner, new Mode(6, 4)){ public string Content { get; set; } = "";
public override long Size() => Content.Length;}
// A directory holds named entries. New ones start as rwxr-xr-x.public sealed class Directory(string name, Directory? parent, string owner) : Entry(name, parent, owner, new Mode(7, 5)){ public SortedDictionary<string, Entry> Children { get; } = new(StringComparer.Ordinal);
public override long Size() => Children.Values.Sum(e => e.Size());}
// Who may do what with an entry: read, write and execute bits for the owner and for everyone// else, as in Unix without groups. 7 is all three, 6 is read and write, 5 is read and execute.// On a directory, read lets you list it, write lets you add and remove names in it, and execute// lets you pass through it on the way to something inside.public sealed record Mode(int Owner, int Others){ public const int Read = 4, Write = 2, Execute = 1;
public bool Allows(bool isOwner, int access) => ((isOwner ? Owner : Others) & access) == access;}
// One read-write lock for the whole tree. Reads (Read, Ls, Size) share it, so they run in// parallel; anything that changes the tree takes it alone. A move touches two directories and// checks every ancestor of the target, so a lock per directory would need a fixed order to// avoid deadlock, and a walk that takes and drops locks down the path. One lock is correct by// construction, and an in-memory tree operation takes microseconds, so it is rarely the// bottleneck.public sealed class SharedFileSystem(FileSystem fs){ private readonly ReaderWriterLockSlim _lock = new();
public Result Mkdir(string user, string path, bool parents = false) => Writing(() => fs.Mkdir(user, path, parents));
public Result Write(string user, string path, string content) => Writing(() => fs.Write(user, path, content));
public Result Move(string user, string from, string to) => Writing(() => fs.Move(user, from, to));
public Result Delete(string user, string path, bool recursive = false) => Writing(() => fs.Delete(user, path, recursive));
public Result Chmod(string user, string path, Mode mode) => Writing(() => fs.Chmod(user, path, mode));
public Outcome<string> Read(string user, string path) => Reading(() => fs.Read(user, path));
public Outcome<List<string>> Ls(string user, string path) => Reading(() => fs.Ls(user, path));
public Outcome<long> Size(string user, string path) => Reading(() => fs.Size(user, path));
// Many readers at once, or one writer alone. finally releases the lock even if the call throws. private T Reading<T>(Func<T> op) { _lock.EnterReadLock(); try { return op(); } finally { _lock.ExitReadLock(); } }
private T Writing<T>(Func<T> op) { _lock.EnterWriteLock(); try { return op(); } finally { _lock.ExitWriteLock(); } }}// The complete design, built from every stage on this page.
package filesystem
import ( "maps" "slices" "strings" "sync")
// Entry is the composite. A file and a directory are both entries, so code that walks the tree,// like Size, asks each entry the same question and lets it answer for itself.type Entry interface { Size() int64 base() *node}
// parse turns "/a/./b//c/../d" into [a b d]. Every path is absolute, so a missing leading slash// is a mistake rather than something relative to guess about. ".." at the root stays there.func parse(path string) ([]string, bool) { if !strings.HasPrefix(path, "/") { return nil, false } names := []string{} for _, part := range strings.Split(path, "/") { switch part { case "", ".": case "..": if len(names) > 0 { names = names[:len(names)-1] } default: names = append(names, part) } } return names, true}
// Result is what a call gets back. Errors are values, as a shell prints them.type Result int
const ( OK Result = iota NotFound NotADirectory IsADirectory AlreadyExists NotEmpty InvalidPath InvalidMove PermissionDenied)
// Mode says who may do what with an entry: read, write and execute bits for the owner and for// everyone else, as in Unix without groups. 7 is all three, 6 is read and write, 5 is read and// execute. On a directory, read lets you list it, write lets you add and remove names in it, and// execute lets you pass through it on the way to something inside.type Mode struct{ Owner, Others int }
const ( Read = 4 Write = 2 Execute = 1)
func (m Mode) allows(isOwner bool, access int) bool { bits := m.Others if isOwner { bits = m.Owner } return bits&access == access}
// node is what every entry has, embedded in File and Directory.type node struct { name string parent *Directory owner string mode Mode}
func (n *node) base() *node { return n }
// File holds text. New files start as rw-r--r--: the owner reads and writes, others read.type File struct { node content string}
func (f *File) Size() int64 { return int64(len(f.content)) }
// Directory holds named entries. New ones start as rwxr-xr-x.type Directory struct { node children map[string]Entry}
func newDirectory(name string, parent *Directory, owner string) *Directory { return &Directory{node{name, parent, owner, Mode{7, 5}}, map[string]Entry{}}}
func (d *Directory) Size() int64 { var total int64 for _, e := range d.children { total += e.Size() } return total}
// Root is the superuser, who passes every check.const Root = "root"
// FileSystem takes the user making each call. New entries belong to that user.type FileSystem struct { root *Directory}
func NewFileSystem() *FileSystem { return &FileSystem{root: newDirectory("", nil, Root)} }
// Mkdir walks the path one name at a time. With parents (like mkdir -p) missing directories on// the way are created, and an existing one is fine.func (fs *FileSystem) Mkdir(user, path string, parents bool) Result { names, ok := parse(path) if !ok { return InvalidPath } dir := fs.root for i, name := range names { if !can(user, &dir.node, Execute) { return PermissionDenied } last := i == len(names)-1 switch next := dir.children[name].(type) { case nil: if !last && !parents { return NotFound } if !can(user, &dir.node, Write) { return PermissionDenied } created := newDirectory(name, dir, user) dir.children[name] = created dir = created case *Directory: if last && !parents { return AlreadyExists } dir = next default: return NotADirectory } } if len(names) == 0 && !parents { return AlreadyExists } return OK}
// Write needs write on the file to change it, or on its directory to create it.func (fs *FileSystem) Write(user, path, content string) Result { names, ok := parse(path) if !ok { return InvalidPath } if len(names) == 0 { return IsADirectory } parent, r := fs.directory(user, names[:len(names)-1]) if r != OK { return r } name := names[len(names)-1] switch existing := parent.children[name].(type) { case *Directory: return IsADirectory case *File: if !can(user, &existing.node, Write) { return PermissionDenied } existing.content = content default: if !can(user, &parent.node, Write) { return PermissionDenied } parent.children[name] = &File{node{name, parent, user, Mode{6, 4}}, content} } return OK}
func (fs *FileSystem) Read(user, path string) (string, Result) { e, r := fs.find(user, path) if r != OK { return "", r } f, ok := e.(*File) if !ok { return "", IsADirectory } if !can(user, &f.node, Read) { return "", PermissionDenied } return f.content, OK}
// Ls lists a directory's children, in order. A file lists just its own name, as ls does.func (fs *FileSystem) Ls(user, path string) ([]string, Result) { e, r := fs.find(user, path) if r != OK { return nil, r } d, ok := e.(*Directory) if !ok { return []string{e.base().name}, OK } if !can(user, &d.node, Read) { return nil, PermissionDenied } return slices.Sorted(maps.Keys(d.children)), OK}
// Size only needs to reach the entry. A stricter version would also need read on every// directory it adds up, as du does.func (fs *FileSystem) Size(user, path string) (int64, Result) { e, r := fs.find(user, path) if r != OK { return 0, r } return e.Size(), OK}
// Move takes a name out of one directory and puts it in another, which changes both, so both// need write. The entry's own mode doesn't matter, as in Unix.func (fs *FileSystem) Move(user, from, to string) Result { src, ok1 := parse(from) dst, ok2 := parse(to) if !ok1 || !ok2 { return InvalidPath } if len(src) == 0 || len(dst) == 0 { return InvalidMove } e, r := fs.resolve(user, src) if r != OK { return r } target, r := fs.directory(user, dst[:len(dst)-1]) if r != OK { return r } n := e.base() if !can(user, &n.parent.node, Write) || !can(user, &target.node, Write) { return PermissionDenied } newName := dst[len(dst)-1] if _, taken := target.children[newName]; taken { return AlreadyExists } // A directory can't move inside itself: walk up from the target and make sure we never meet it. for d := target; d != nil; d = d.parent { if &d.node == n { return InvalidMove } } delete(n.parent.children, n.name) n.name, n.parent = newName, target target.children[newName] = e return OK}
// Delete needs write on the directory the name is removed from. Unix also needs write on every// directory inside a recursive delete; this version doesn't check below the top.func (fs *FileSystem) Delete(user, path string, recursive bool) Result { names, ok := parse(path) if !ok || len(names) == 0 { return InvalidPath } e, r := fs.resolve(user, names) if r != OK { return r } n := e.base() if !can(user, &n.parent.node, Write) { return PermissionDenied } if d, isDir := e.(*Directory); isDir && len(d.children) > 0 && !recursive { return NotEmpty } delete(n.parent.children, n.name) return OK}
// Chmod is for the owner, or root.func (fs *FileSystem) Chmod(user, path string, mode Mode) Result { e, r := fs.find(user, path) if r != OK { return r } n := e.base() if user != Root && user != n.owner { return PermissionDenied } n.mode = mode return OK}
// Chown is for root only, so nobody can push a file onto someone else.func (fs *FileSystem) Chown(user, path, owner string) Result { e, r := fs.find(user, path) if r != OK { return r } if user != Root { return PermissionDenied } e.base().owner = owner return OK}
// can is the one permission check. Root passes; everyone else gets the owner or others bits.func can(user string, n *node, access int) bool { return user == Root || n.mode.allows(user == n.owner, access)}
func (fs *FileSystem) find(user, path string) (Entry, Result) { names, ok := parse(path) if !ok { return nil, InvalidPath } return fs.resolve(user, names)}
// resolve needs execute on every directory it passes through, so a directory with no execute// for others hides everything inside, however open the files themselves are.func (fs *FileSystem) resolve(user string, names []string) (Entry, Result) { var at Entry = fs.root for _, name := range names { dir, ok := at.(*Directory) if !ok { return nil, NotADirectory } if !can(user, &dir.node, Execute) { return nil, PermissionDenied } if at, ok = dir.children[name]; !ok { return nil, NotFound } } return at, OK}
// directory is like resolve, but the entry must be a directory: where a new name is about to go.func (fs *FileSystem) directory(user string, names []string) (*Directory, Result) { e, r := fs.resolve(user, names) if r != OK { return nil, r } d, ok := e.(*Directory) if !ok { return nil, NotADirectory } return d, OK}
// SharedFileSystem puts one read-write mutex around the whole tree. Reads (Read, Ls, Size)// share it, so they run in parallel; anything that changes the tree takes it alone. A move// touches two directories and checks every ancestor of the target, so a mutex per directory// would need a fixed order to avoid deadlock, and a walk that takes and drops locks down the// path. One mutex is correct by construction, and an in-memory tree operation takes// microseconds, so it is rarely the bottleneck.type SharedFileSystem struct { mu sync.RWMutex fs *FileSystem}
func NewSharedFileSystem(fs *FileSystem) *SharedFileSystem { return &SharedFileSystem{fs: fs} }
func (s *SharedFileSystem) Mkdir(user, path string, parents bool) Result { s.mu.Lock() defer s.mu.Unlock() return s.fs.Mkdir(user, path, parents)}
func (s *SharedFileSystem) Write(user, path, content string) Result { s.mu.Lock() defer s.mu.Unlock() return s.fs.Write(user, path, content)}
func (s *SharedFileSystem) Move(user, from, to string) Result { s.mu.Lock() defer s.mu.Unlock() return s.fs.Move(user, from, to)}
func (s *SharedFileSystem) Delete(user, path string, recursive bool) Result { s.mu.Lock() defer s.mu.Unlock() return s.fs.Delete(user, path, recursive)}
func (s *SharedFileSystem) Chmod(user, path string, mode Mode) Result { s.mu.Lock() defer s.mu.Unlock() return s.fs.Chmod(user, path, mode)}
func (s *SharedFileSystem) Read(user, path string) (string, Result) { s.mu.RLock() defer s.mu.RUnlock() return s.fs.Read(user, path)}
func (s *SharedFileSystem) Ls(user, path string) ([]string, Result) { s.mu.RLock() defer s.mu.RUnlock() return s.fs.Ls(user, path)}
func (s *SharedFileSystem) Size(user, path string) (int64, Result) { s.mu.RLock() defer s.mu.RUnlock() return s.fs.Size(user, path)}// The complete design, built from every stage on this page.
// Every call names the user making it. New entries belong to that user.class FileSystem { private readonly root = new Directory('', undefined, ROOT);
// Walks the path one name at a time. With parents (like mkdir -p) missing directories on the way // are created, and an existing one is fine. mkdir(user: string, path: string, parents = false): Result { const names = parse(path); if (!names) return 'invalid-path'; let dir = this.root; for (const [i, name] of names.entries()) { if (!can(user, dir, Mode.EXECUTE)) return 'permission-denied'; const last = i === names.length - 1; const next = dir.children.get(name); if (!next) { if (!last && !parents) return 'not-found'; if (!can(user, dir, Mode.WRITE)) return 'permission-denied'; const created = new Directory(name, dir, user); dir.children.set(name, created); dir = created; } else if (!(next instanceof Directory)) { return 'not-a-directory'; } else if (last && !parents) { return 'already-exists'; } else { dir = next; } } return names.length === 0 && !parents ? 'already-exists' : 'ok'; }
// Changing a file's content needs write on the file; creating one needs write on its directory. write(user: string, path: string, content: string): Result { const names = parse(path); if (!names) return 'invalid-path'; if (names.length === 0) return 'is-a-directory'; const parent = this.directory(user, names.slice(0, -1)); if (!parent.value) return parent.result; const name = names[names.length - 1]; const existing = parent.value.children.get(name); if (existing instanceof Directory) return 'is-a-directory'; if (!can(user, existing ?? parent.value, Mode.WRITE)) return 'permission-denied'; const file = existing instanceof File ? existing : new File(name, parent.value, user); file.content = content; parent.value.children.set(name, file); return 'ok'; }
read(user: string, path: string): Outcome<string> { const found = this.find(user, path); if (!found.value) return fail(found.result); if (!(found.value instanceof File)) return fail('is-a-directory'); if (!can(user, found.value, Mode.READ)) return fail('permission-denied'); return ok(found.value.content); }
// A directory lists its children, in order. A file lists just its own name, as ls does. ls(user: string, path: string): Outcome<string[]> { const found = this.find(user, path); if (!found.value) return fail(found.result); if (!(found.value instanceof Directory)) return ok([found.value.name]); if (!can(user, found.value, Mode.READ)) return fail('permission-denied'); return ok([...found.value.children.keys()].sort()); }
// Only needs to reach the entry. A stricter version would also need read on every directory it // adds up, as du does. size(user: string, path: string): Outcome<number> { const found = this.find(user, path); return found.value ? ok(found.value.size()) : fail(found.result); }
// Taking a name out of one directory and putting it in another changes both, so both need // write. The entry's own mode doesn't matter, as in Unix. move(user: string, from: string, to: string): Result { const src = parse(from); const dst = parse(to); if (!src || !dst) return 'invalid-path'; if (src.length === 0 || dst.length === 0) return 'invalid-move'; const found = this.resolve(user, src); if (!found.value) return found.result; const target = this.directory(user, dst.slice(0, -1)); if (!target.value) return target.result; const entry = found.value; if (!can(user, entry.parent!, Mode.WRITE) || !can(user, target.value, Mode.WRITE)) return 'permission-denied'; const newName = dst[dst.length - 1]; if (target.value.children.has(newName)) return 'already-exists'; // A directory can't move inside itself: walk up from the target and make sure we never meet it. for (let d: Directory | undefined = target.value; d; d = d.parent) { if (d === entry) return 'invalid-move'; } entry.parent!.children.delete(entry.name); entry.name = newName; entry.parent = target.value; target.value.children.set(newName, entry); return 'ok'; }
// Needs write on the directory the name is removed from. Unix also needs write on every // directory inside a recursive delete; this version doesn't check below the top. delete(user: string, path: string, recursive = false): Result { const names = parse(path); if (!names || names.length === 0) return 'invalid-path'; const found = this.resolve(user, names); if (!found.value) return found.result; const entry = found.value; if (!can(user, entry.parent!, Mode.WRITE)) return 'permission-denied'; if (entry instanceof Directory && entry.children.size > 0 && !recursive) return 'not-empty'; entry.parent!.children.delete(entry.name); return 'ok'; }
// Only the owner, or root, changes an entry's mode. chmod(user: string, path: string, mode: Mode): Result { const found = this.find(user, path); if (!found.value) return found.result; if (user !== ROOT && user !== found.value.owner) return 'permission-denied'; found.value.mode = mode; return 'ok'; }
// Only root gives an entry away, so nobody can push a file onto someone else. chown(user: string, path: string, owner: string): Result { const found = this.find(user, path); if (!found.value) return found.result; if (user !== ROOT) return 'permission-denied'; found.value.owner = owner; return 'ok'; }
private find(user: string, path: string): Outcome<Entry> { const names = parse(path); return names ? this.resolve(user, names) : fail('invalid-path'); }
// Passing through a directory needs execute on it, so a directory with no execute for others // hides everything inside, however open the files themselves are. private resolve(user: string, names: string[]): Outcome<Entry> { let at: Entry = this.root; for (const name of names) { if (!(at instanceof Directory)) return fail('not-a-directory'); if (!can(user, at, Mode.EXECUTE)) return fail('permission-denied'); const next = at.children.get(name); if (!next) return fail('not-found'); at = next; } return ok(at); }
// Like resolve, but the entry must be a directory: where a new name is about to go. private directory(user: string, names: string[]): Outcome<Directory> { const found = this.resolve(user, names); if (!found.value) return fail(found.result); return found.value instanceof Directory ? ok(found.value) : fail('not-a-directory'); }}
// What a call gets back. Errors are values, as a shell prints them, not exceptions.type Result = | 'ok' | 'not-found' | 'not-a-directory' | 'is-a-directory' | 'already-exists' | 'not-empty' | 'invalid-path' | 'invalid-move' | 'permission-denied';
// A result and, when it is ok, a value.interface Outcome<T> { result: Result; value?: T;}
const fail = <T>(result: Result): Outcome<T> => ({ result });
const ok = <T>(value: T): Outcome<T> => ({ result: 'ok', value });
// The composite. A file and a directory are both entries, so code that walks the tree, like// size(), asks each entry the same question and lets it answer for itself.abstract class Entry { constructor( public name: string, public parent: Directory | undefined, public owner: string, public mode: Mode, ) {}
abstract size(): number;}
// A file holds text. New files start as rw-r--r--: the owner reads and writes, others read.class File extends Entry { content = '';
constructor(name: string, parent: Directory, owner: string) { super(name, parent, owner, new Mode(6, 4)); }
size(): number { return this.content.length; }}
// A directory holds named entries. New ones start as rwxr-xr-x.class Directory extends Entry { readonly children = new Map<string, Entry>();
constructor(name: string, parent: Directory | undefined, owner: string) { super(name, parent, owner, new Mode(7, 5)); }
size(): number { let total = 0; for (const child of this.children.values()) total += child.size(); return total; }}
// Turns "/a/./b//c/../d" into [a, b, d]. Every path is absolute, so a missing leading slash is a// mistake rather than something relative to guess about. ".." at the root stays there.function parse(path: string): string[] | undefined { if (!path.startsWith('/')) return undefined; const names: string[] = []; for (const part of path.split('/')) { if (part === '' || part === '.') continue; if (part !== '..') names.push(part); else names.pop(); } return names;}
// Who may do what with an entry: read, write and execute bits for the owner and for everyone// else, as in Unix without groups. 7 is all three, 6 is read and write, 5 is read and execute.// On a directory, read lets you list it, write lets you add and remove names in it, and execute// lets you pass through it on the way to something inside.class Mode { static readonly READ = 4; static readonly WRITE = 2; static readonly EXECUTE = 1;
constructor( readonly owner: number, readonly others: number, ) {}
allows(isOwner: boolean, access: number): boolean { return ((isOwner ? this.owner : this.others) & access) === access; }}
// The superuser, who passes every check.const ROOT = 'root';
// The one permission check. Root passes; everyone else gets the owner bits or the others bits.function can(user: string, entry: Entry, access: number): boolean { return user === ROOT || entry.mode.allows(user === entry.owner, access);}
// One thread runs this code, and every FileSystem method finishes without awaiting, so no// other call can run in the middle of one: the tree needs no lock. The trouble starts when a// change must also be saved somewhere that takes time, such as a journal on disk that is// replayed after a restart. Two calls can change the tree in one order while their journal// writes finish in the other, and the replayed tree then differs from the one that was running.// So each change and its journal write happen as one step, one change at a time, through a// promise chain. Reads stay synchronous and see the tree as it is.class SharedFileSystem { private tail: Promise<unknown> = Promise.resolve();
constructor( private readonly fs: FileSystem, private readonly journal: (entry: string) => Promise<void>, ) {}
mkdir(user: string, path: string, parents = false): Promise<Result> { return this.change(() => this.fs.mkdir(user, path, parents), `mkdir ${user} ${path} ${parents}`); }
write(user: string, path: string, content: string): Promise<Result> { return this.change(() => this.fs.write(user, path, content), `write ${user} ${path} ${JSON.stringify(content)}`); }
move(user: string, from: string, to: string): Promise<Result> { return this.change(() => this.fs.move(user, from, to), `move ${user} ${from} ${to}`); }
delete(user: string, path: string, recursive = false): Promise<Result> { return this.change(() => this.fs.delete(user, path, recursive), `delete ${user} ${path} ${recursive}`); }
read(user: string, path: string): Outcome<string> { return this.fs.read(user, path); }
ls(user: string, path: string): Outcome<string[]> { return this.fs.ls(user, path); }
// Waits for every change before it, applies this one, and journals it if it succeeded. A // failed change leaves the tree alone, so there is nothing to replay. private change(apply: () => Result, entry: string): Promise<Result> { const run = this.tail.then(async () => { const result = apply(); if (result === 'ok') await this.journal(entry); return result; }); this.tail = run.catch(() => undefined); // one failed journal write mustn't block the rest return run; }}Three ideas hold it together:
- Every entry knows only its name and its parent. Paths are worked out, never stored. So a move is one relink, and the tree can never disagree with a stored path. Files and directories are handled alike (Composite).
- Every operation goes through one walk. Parsing, resolving and the execute check happen in the same place for every call, behind one class that callers use with plain paths (Facade).
- The tree stays a tree. Names are unique in a directory, and a directory never moves inside itself. Thread safety is added around it, without changing it (Decorator).
Stress it
Section titled “Stress it”So far, one call runs at a time. Now a web server with many threads shares the file system.
Two threads change the tree at once
Section titled “Two threads change the tree at once”The file system is shared by a web server with many threads. Two requests create files in the same directory at the same moment. What can go wrong, and how do you stop it?
Whether you name what’s shared (each directory’s children map, and the parent links a move changes), pick how much to lock, and can say why a lock per directory is harder than it looks once move is involved.
Each directory’s children map is shared. Two threads adding a name to the same directory at once can corrupt the map, or lose one of the names. Go’s runtime even stops the program when two threads write one map at once.
A move is worse. It reads the target’s ancestors, then changes two directories and the entry’s parent. Picture two moves at the same time:
- Thread A moves
/ainto/b/x. It walks up from/b/xand doesn’t meet/a. Check passed. - Thread B moves
/binto/a/y. It walks up from/a/yand doesn’t meet/b. Check passed. - Both moves happen. Now
/ais inside/b, and/bis inside/a. Neither can be reached from the root.
Each check was right about the tree it saw. But the tree changed between the check and the move.
The clearly correct fix is one read-write lock for the whole tree. Reads (read, ls, size)
share it, so they run in parallel. Anything that changes the tree takes it alone, so each change
sees a tree nobody else is changing. It lives in a wrapper, SharedFileSystem, so the tree code
stays single-threaded and easy to test.
The better fix, only if measured, is a lock per directory. Unrelated writes could then run in parallel. But a move touches two paths and checks ancestors, so it must lock both paths in a fixed order, or two moves could each hold a lock the other needs, and wait forever (a deadlock). That is real complexity, so it’s worth it only when one lock is shown to be the bottleneck.
| Option | How | Correct with move? | Cost |
|---|---|---|---|
| 1. One read-write lock for the tree (our choice) | Changes go alone, reads go together | Yes | Writes to unrelated directories wait for each other |
| 2. A lock per directory, taken down the path | Take the child’s lock, then drop the parent’s | Needs care | Move must lock two paths in a fixed order to avoid deadlock |
| 3. A thread-safe map per directory | Thread-safe children maps, no other locks | No | Each map is safe; the ancestor check across them isn’t |
| 4. Copy on write | Every change builds a new path to the root and swaps it in | Yes | Each write copies the path; readers never wait |
// One read-write lock for the whole tree. Reads (read, ls, size) share it, so they run in// parallel; anything that changes the tree takes it alone. A move touches two directories and// checks every ancestor of the target, so a lock per directory would need a fixed order to// avoid deadlock, and a walk that takes and drops locks down the path. One lock is correct by// construction, and an in-memory tree operation takes microseconds, so it is rarely the// bottleneck.class SharedFileSystem { private final FileSystem fs; private final ReadWriteLock lock = new ReentrantReadWriteLock();
SharedFileSystem(FileSystem fs) { this.fs = fs; }
Result mkdir(String user, String path, boolean parents) { return writing(() -> fs.mkdir(user, path, parents)); }
Result write(String user, String path, String content) { return writing(() -> fs.write(user, path, content)); }
Result move(String user, String from, String to) { return writing(() -> fs.move(user, from, to)); }
Result delete(String user, String path, boolean recursive) { return writing(() -> fs.delete(user, path, recursive)); }
Result chmod(String user, String path, Mode mode) { return writing(() -> fs.chmod(user, path, mode)); }
Outcome<String> read(String user, String path) { return reading(() -> fs.read(user, path)); }
Outcome<List<String>> ls(String user, String path) { return reading(() -> fs.ls(user, path)); }
Outcome<Long> size(String user, String path) { return reading(() -> fs.size(user, path)); }
// Many readers at once, or one writer alone. finally releases the lock even if the call throws. private <T> T reading(Supplier<T> op) { lock.readLock().lock(); try { return op.get(); } finally { lock.readLock().unlock(); } }
private <T> T writing(Supplier<T> op) { lock.writeLock().lock(); try { return op.get(); } finally { lock.writeLock().unlock(); } }}# One lock for the whole tree, taken by every call. A move touches two directories and# checks every ancestor of the target, so a lock per directory would need a fixed order to# avoid deadlock. One lock is correct by construction, and an in-memory tree operation is# quick, so it is rarely the bottleneck. Python's standard library has no read-write lock, so# reads take the same lock; the Java, C# and Go versions let reads share it.class SharedFileSystem: def __init__(self, fs: FileSystem): self._fs = fs self._lock = threading.Lock()
def mkdir(self, user: str, path: str, parents: bool = False) -> Result: with self._lock: return self._fs.mkdir(user, path, parents)
def write(self, user: str, path: str, content: str) -> Result: with self._lock: return self._fs.write(user, path, content)
def move(self, user: str, src: str, dst: str) -> Result: with self._lock: return self._fs.move(user, src, dst)
def delete(self, user: str, path: str, recursive: bool = False) -> Result: with self._lock: return self._fs.delete(user, path, recursive)
def chmod(self, user: str, path: str, mode: Mode) -> Result: with self._lock: return self._fs.chmod(user, path, mode)
def read(self, user: str, path: str) -> Outcome[str]: with self._lock: return self._fs.read(user, path)
def ls(self, user: str, path: str) -> Outcome[list[str]]: with self._lock: return self._fs.ls(user, path)
def size(self, user: str, path: str) -> Outcome[int]: with self._lock: return self._fs.size(user, path)// One read-write lock for the whole tree. Reads (Read, Ls, Size) share it, so they run in// parallel; anything that changes the tree takes it alone. A move touches two directories and// checks every ancestor of the target, so a lock per directory would need a fixed order to// avoid deadlock, and a walk that takes and drops locks down the path. One lock is correct by// construction, and an in-memory tree operation takes microseconds, so it is rarely the// bottleneck.public sealed class SharedFileSystem(FileSystem fs){ private readonly ReaderWriterLockSlim _lock = new();
public Result Mkdir(string user, string path, bool parents = false) => Writing(() => fs.Mkdir(user, path, parents));
public Result Write(string user, string path, string content) => Writing(() => fs.Write(user, path, content));
public Result Move(string user, string from, string to) => Writing(() => fs.Move(user, from, to));
public Result Delete(string user, string path, bool recursive = false) => Writing(() => fs.Delete(user, path, recursive));
public Result Chmod(string user, string path, Mode mode) => Writing(() => fs.Chmod(user, path, mode));
public Outcome<string> Read(string user, string path) => Reading(() => fs.Read(user, path));
public Outcome<List<string>> Ls(string user, string path) => Reading(() => fs.Ls(user, path));
public Outcome<long> Size(string user, string path) => Reading(() => fs.Size(user, path));
// Many readers at once, or one writer alone. finally releases the lock even if the call throws. private T Reading<T>(Func<T> op) { _lock.EnterReadLock(); try { return op(); } finally { _lock.ExitReadLock(); } }
private T Writing<T>(Func<T> op) { _lock.EnterWriteLock(); try { return op(); } finally { _lock.ExitWriteLock(); } }}// SharedFileSystem puts one read-write mutex around the whole tree. Reads (Read, Ls, Size)// share it, so they run in parallel; anything that changes the tree takes it alone. A move// touches two directories and checks every ancestor of the target, so a mutex per directory// would need a fixed order to avoid deadlock, and a walk that takes and drops locks down the// path. One mutex is correct by construction, and an in-memory tree operation takes// microseconds, so it is rarely the bottleneck.type SharedFileSystem struct { mu sync.RWMutex fs *FileSystem}
func NewSharedFileSystem(fs *FileSystem) *SharedFileSystem { return &SharedFileSystem{fs: fs} }
func (s *SharedFileSystem) Mkdir(user, path string, parents bool) Result { s.mu.Lock() defer s.mu.Unlock() return s.fs.Mkdir(user, path, parents)}
func (s *SharedFileSystem) Write(user, path, content string) Result { s.mu.Lock() defer s.mu.Unlock() return s.fs.Write(user, path, content)}
func (s *SharedFileSystem) Move(user, from, to string) Result { s.mu.Lock() defer s.mu.Unlock() return s.fs.Move(user, from, to)}
func (s *SharedFileSystem) Delete(user, path string, recursive bool) Result { s.mu.Lock() defer s.mu.Unlock() return s.fs.Delete(user, path, recursive)}
func (s *SharedFileSystem) Chmod(user, path string, mode Mode) Result { s.mu.Lock() defer s.mu.Unlock() return s.fs.Chmod(user, path, mode)}
func (s *SharedFileSystem) Read(user, path string) (string, Result) { s.mu.RLock() defer s.mu.RUnlock() return s.fs.Read(user, path)}
func (s *SharedFileSystem) Ls(user, path string) ([]string, Result) { s.mu.RLock() defer s.mu.RUnlock() return s.fs.Ls(user, path)}
func (s *SharedFileSystem) Size(user, path string) (int64, Result) { s.mu.RLock() defer s.mu.RUnlock() return s.fs.Size(user, path)}// One thread runs this code, and every FileSystem method finishes without awaiting, so no// other call can run in the middle of one: the tree needs no lock. The trouble starts when a// change must also be saved somewhere that takes time, such as a journal on disk that is// replayed after a restart. Two calls can change the tree in one order while their journal// writes finish in the other, and the replayed tree then differs from the one that was running.// So each change and its journal write happen as one step, one change at a time, through a// promise chain. Reads stay synchronous and see the tree as it is.class SharedFileSystem { private tail: Promise<unknown> = Promise.resolve();
constructor( private readonly fs: FileSystem, private readonly journal: (entry: string) => Promise<void>, ) {}
mkdir(user: string, path: string, parents = false): Promise<Result> { return this.change(() => this.fs.mkdir(user, path, parents), `mkdir ${user} ${path} ${parents}`); }
write(user: string, path: string, content: string): Promise<Result> { return this.change(() => this.fs.write(user, path, content), `write ${user} ${path} ${JSON.stringify(content)}`); }
move(user: string, from: string, to: string): Promise<Result> { return this.change(() => this.fs.move(user, from, to), `move ${user} ${from} ${to}`); }
delete(user: string, path: string, recursive = false): Promise<Result> { return this.change(() => this.fs.delete(user, path, recursive), `delete ${user} ${path} ${recursive}`); }
read(user: string, path: string): Outcome<string> { return this.fs.read(user, path); }
ls(user: string, path: string): Outcome<string[]> { return this.fs.ls(user, path); }
// Waits for every change before it, applies this one, and journals it if it succeeded. A // failed change leaves the tree alone, so there is nothing to replay. private change(apply: () => Result, entry: string): Promise<Result> { const run = this.tail.then(async () => { const result = apply(); if (result === 'ok') await this.journal(entry); return result; }); this.tail = run.catch(() => undefined); // one failed journal write mustn't block the rest return run; }}Why is this correct? A move holds the write lock from its ancestor check until its last change. No other change can run in between, so the tree it checked is the tree it changes. Reads hold the read lock, so they never see a move half done.
What does it cost? Two writes to unrelated directories wait for each other. Most file system work is reading, and a write here is a few map operations, so the wait is short.
Go deeperHow each language does the lock1 min
Java, C# and Go use a real read-write lock. Python’s standard library has none, so every call takes
one plain lock, which is still correct, just with less parallel reading. TypeScript runs on one
thread, and every tree method finishes without an await, so the tree needs no lock at all.
What TypeScript needs instead is order, when a change must also be saved somewhere slow, like a journal on disk. Each change and its journal write go through a promise chain one at a time. So the journal holds the changes in the order they were applied, and replaying it rebuilds the same tree.
How you’d prove it works
Section titled “How you’d prove it works”The tree is plain objects with no threads, so most tests call it directly and check the results.
Every test starts with /home/ana owned by Ana, and /home/ben owned by Ben. The tests that
matter:
- Paths with
.,..and doubled slashes reach the same file. A relative path is refused. mkdirwithout parents refuses a missing parent. With parents, it creates them, and accepts a directory that already exists.- A file can’t be used as a directory, and a directory can’t be read or written as a file.
- Listing is in name order, and a directory’s size adds up everything below it.
- A move carries everything inside. A directory can’t move into itself or onto a taken name.
- A non-empty directory needs a recursive delete.
- Ben can read Ana’s file, but not change, delete or move it, or create anything in her directory.
- A directory closed to others hides even an open file inside it. Root can still read it.
- Eight threads creating 1,000 files each in one directory leave exactly 8,000.
Go deeperThe tests in five languages2 min
The last test runs on SharedFileSystem. Without the lock, it fails about half the time in Java,
and always in Go under the race detector. In TypeScript, the journal test fails every time without
the promise chain.
static final String ROOT = FileSystem.ROOT;FileSystem fs;
// /home/ana belongs to ana and /home/ben to ben, as root would set them up.@BeforeEachvoid setUp() { fs = new FileSystem(); for (String user : List.of("ana", "ben")) { fs.mkdir(ROOT, "/home/" + user, true); fs.chown(ROOT, "/home/" + user, user); }}
@Testvoid pathsAreNormalisedAndMustBeAbsolute() { assertEquals(Result.OK, fs.mkdir("ana", "/home/ana/docs", false)); assertEquals(Result.OK, fs.write("ana", "/home/ana/./docs/../docs//a.txt", "hi")); assertEquals("hi", fs.read("ana", "/home/ana/docs/a.txt").value()); assertEquals(List.of("a.txt"), fs.ls("ana", "/home/ana/docs/").value()); assertEquals(Result.INVALID_PATH, fs.read("ana", "home/ana/docs/a.txt").result());}
@Testvoid mkdirNeedsTheParentsUnlessAskedToCreateThem() { assertEquals(Result.NOT_FOUND, fs.mkdir("ana", "/home/ana/x/y", false)); assertEquals(Result.OK, fs.mkdir("ana", "/home/ana/x/y", true)); assertEquals(Result.OK, fs.mkdir("ana", "/home/ana/x/y", true)); assertEquals(Result.ALREADY_EXISTS, fs.mkdir("ana", "/home/ana/x/y", false));}
@Testvoid filesAndDirectoriesAreNotInterchangeable() { fs.write("ana", "/home/ana/f", "x"); assertEquals(Result.NOT_A_DIRECTORY, fs.mkdir("ana", "/home/ana/f/g", true)); assertEquals(Result.IS_A_DIRECTORY, fs.read("ana", "/home/ana").result()); assertEquals(Result.IS_A_DIRECTORY, fs.write("ana", "/home/ana", "x")); assertEquals(List.of("f"), fs.ls("ana", "/home/ana/f").value());}
@Testvoid listingIsSortedAndSizeAddsUpTheSubtree() { fs.write("ana", "/home/ana/b.txt", "12345"); fs.write("ana", "/home/ana/a.txt", "123"); fs.mkdir("ana", "/home/ana/sub", false); fs.write("ana", "/home/ana/sub/c", "12"); assertEquals(List.of("a.txt", "b.txt", "sub"), fs.ls("ana", "/home/ana").value()); assertEquals(10L, (long) fs.size("ana", "/home/ana").value());}
@Testvoid moveRenamesAndCarriesTheSubtree() { fs.mkdir("ana", "/home/ana/p/q", true); fs.write("ana", "/home/ana/p/q/n", "x"); assertEquals(Result.OK, fs.move("ana", "/home/ana/p", "/home/ana/r")); assertEquals("x", fs.read("ana", "/home/ana/r/q/n").value()); assertEquals(Result.NOT_FOUND, fs.read("ana", "/home/ana/p/q/n").result());}
@Testvoid aDirectoryCannotMoveIntoItselfOrOntoAName() { fs.mkdir("ana", "/home/ana/p/q", true); fs.write("ana", "/home/ana/f", "x"); assertEquals(Result.INVALID_MOVE, fs.move("ana", "/home/ana/p", "/home/ana/p/q/p")); assertEquals(Result.ALREADY_EXISTS, fs.move("ana", "/home/ana/f", "/home/ana/p")); assertEquals(Result.INVALID_MOVE, fs.move(ROOT, "/", "/home/root"));}
@Testvoid deletingADirectoryWithContentsMustBeRecursive() { fs.mkdir("ana", "/home/ana/p/q", true); assertEquals(Result.NOT_EMPTY, fs.delete("ana", "/home/ana/p", false)); assertEquals(Result.OK, fs.delete("ana", "/home/ana/p", true)); assertEquals(Result.NOT_FOUND, fs.ls("ana", "/home/ana/p").result());}
@Testvoid othersCanReadButNotChange() { fs.write("ana", "/home/ana/notes", "hello"); assertEquals("hello", fs.read("ben", "/home/ana/notes").value()); assertEquals(Result.PERMISSION_DENIED, fs.write("ben", "/home/ana/notes", "mine now")); assertEquals(Result.PERMISSION_DENIED, fs.mkdir("ben", "/home/ana/x", false)); assertEquals(Result.PERMISSION_DENIED, fs.delete("ben", "/home/ana/notes", false)); assertEquals(Result.PERMISSION_DENIED, fs.move("ben", "/home/ana/notes", "/home/ben/notes"));}
@Testvoid aClosedDirectoryHidesEverythingInside() { fs.mkdir("ana", "/home/ana/private", false); fs.write("ana", "/home/ana/private/diary", "dear diary"); assertEquals(Result.OK, fs.chmod("ana", "/home/ana/private", new Mode(7, 0))); assertEquals(Result.PERMISSION_DENIED, fs.read("ben", "/home/ana/private/diary").result()); assertEquals(Result.PERMISSION_DENIED, fs.ls("ben", "/home/ana/private").result()); assertEquals(Result.PERMISSION_DENIED, fs.chmod("ben", "/home/ana/private", new Mode(7, 7))); assertEquals("dear diary", fs.read(ROOT, "/home/ana/private/diary").value());}
@Testvoid concurrentCreatesInOneDirectoryAllLand() throws Exception { SharedFileSystem shared = new SharedFileSystem(fs); CountDownLatch start = new CountDownLatch(1); List<Thread> threads = new ArrayList<>(); for (int t = 0; t < 8; t++) { int id = t; Thread th = new Thread(() -> { try { start.await(); } catch (InterruptedException e) { return; } for (int i = 0; i < 1000; i++) shared.write("ana", "/home/ana/f" + id + "-" + i, "x"); }); th.start(); threads.add(th); } start.countDown(); for (Thread th : threads) th.join(); assertEquals(8000, shared.ls("ana", "/home/ana").value().size()); assertEquals(8000L, (long) shared.size("ana", "/home/ana").value());}# /home/ana belongs to ana and /home/ben to ben, as root would set them up.def make_fs() -> FileSystem: fs = FileSystem() for user in ("ana", "ben"): fs.mkdir(ROOT, f"/home/{user}", parents=True) fs.chown(ROOT, f"/home/{user}", user) return fs
def test_paths_are_normalised_and_must_be_absolute(): fs = make_fs() assert fs.mkdir("ana", "/home/ana/docs") is Result.OK assert fs.write("ana", "/home/ana/./docs/../docs//a.txt", "hi") is Result.OK assert fs.read("ana", "/home/ana/docs/a.txt").value == "hi" assert fs.ls("ana", "/home/ana/docs/").value == ["a.txt"] assert fs.read("ana", "home/ana/docs/a.txt").result is Result.INVALID_PATH
def test_mkdir_needs_the_parents_unless_asked_to_create_them(): fs = make_fs() assert fs.mkdir("ana", "/home/ana/x/y") is Result.NOT_FOUND assert fs.mkdir("ana", "/home/ana/x/y", parents=True) is Result.OK assert fs.mkdir("ana", "/home/ana/x/y", parents=True) is Result.OK assert fs.mkdir("ana", "/home/ana/x/y") is Result.ALREADY_EXISTS
def test_files_and_directories_are_not_interchangeable(): fs = make_fs() fs.write("ana", "/home/ana/f", "x") assert fs.mkdir("ana", "/home/ana/f/g", parents=True) is Result.NOT_A_DIRECTORY assert fs.read("ana", "/home/ana").result is Result.IS_A_DIRECTORY assert fs.write("ana", "/home/ana", "x") is Result.IS_A_DIRECTORY assert fs.ls("ana", "/home/ana/f").value == ["f"]
def test_listing_is_sorted_and_size_adds_up_the_subtree(): fs = make_fs() fs.write("ana", "/home/ana/b.txt", "12345") fs.write("ana", "/home/ana/a.txt", "123") fs.mkdir("ana", "/home/ana/sub") fs.write("ana", "/home/ana/sub/c", "12") assert fs.ls("ana", "/home/ana").value == ["a.txt", "b.txt", "sub"] assert fs.size("ana", "/home/ana").value == 10
def test_move_renames_and_carries_the_subtree(): fs = make_fs() fs.mkdir("ana", "/home/ana/p/q", parents=True) fs.write("ana", "/home/ana/p/q/n", "x") assert fs.move("ana", "/home/ana/p", "/home/ana/r") is Result.OK assert fs.read("ana", "/home/ana/r/q/n").value == "x" assert fs.read("ana", "/home/ana/p/q/n").result is Result.NOT_FOUND
def test_a_directory_cannot_move_into_itself_or_onto_a_name(): fs = make_fs() fs.mkdir("ana", "/home/ana/p/q", parents=True) fs.write("ana", "/home/ana/f", "x") assert fs.move("ana", "/home/ana/p", "/home/ana/p/q/p") is Result.INVALID_MOVE assert fs.move("ana", "/home/ana/f", "/home/ana/p") is Result.ALREADY_EXISTS assert fs.move(ROOT, "/", "/home/root") is Result.INVALID_MOVE
def test_deleting_a_directory_with_contents_must_be_recursive(): fs = make_fs() fs.mkdir("ana", "/home/ana/p/q", parents=True) assert fs.delete("ana", "/home/ana/p") is Result.NOT_EMPTY assert fs.delete("ana", "/home/ana/p", recursive=True) is Result.OK assert fs.ls("ana", "/home/ana/p").result is Result.NOT_FOUND
def test_others_can_read_but_not_change(): fs = make_fs() fs.write("ana", "/home/ana/notes", "hello") assert fs.read("ben", "/home/ana/notes").value == "hello" assert fs.write("ben", "/home/ana/notes", "mine now") is Result.PERMISSION_DENIED assert fs.mkdir("ben", "/home/ana/x") is Result.PERMISSION_DENIED assert fs.delete("ben", "/home/ana/notes") is Result.PERMISSION_DENIED assert fs.move("ben", "/home/ana/notes", "/home/ben/notes") is Result.PERMISSION_DENIED
def test_a_closed_directory_hides_everything_inside(): fs = make_fs() fs.mkdir("ana", "/home/ana/private") fs.write("ana", "/home/ana/private/diary", "dear diary") assert fs.chmod("ana", "/home/ana/private", Mode(7, 0)) is Result.OK assert fs.read("ben", "/home/ana/private/diary").result is Result.PERMISSION_DENIED assert fs.ls("ben", "/home/ana/private").result is Result.PERMISSION_DENIED assert fs.chmod("ben", "/home/ana/private", Mode(7, 7)) is Result.PERMISSION_DENIED assert fs.read(ROOT, "/home/ana/private/diary").value == "dear diary"
def test_concurrent_creates_in_one_directory_all_land(): shared = SharedFileSystem(make_fs()) start = threading.Event()
def create(t: int) -> None: start.wait() for i in range(1000): shared.write("ana", f"/home/ana/f{t}-{i}", "x")
threads = [threading.Thread(target=create, args=(t,)) for t in range(8)] for th in threads: th.start() start.set() for th in threads: th.join() assert len(shared.ls("ana", "/home/ana").value) == 8000 assert shared.size("ana", "/home/ana").value == 8000private const string Root = FileSystem.Root;private readonly FileSystem _fs = new();
// /home/ana belongs to ana and /home/ben to ben, as root would set them up.public FileSystemTests(){ foreach (var user in new[] { "ana", "ben" }) { _fs.Mkdir(Root, $"/home/{user}", parents: true); _fs.Chown(Root, $"/home/{user}", user); }}
[Fact]public void PathsAreNormalisedAndMustBeAbsolute(){ Assert.Equal(Result.Ok, _fs.Mkdir("ana", "/home/ana/docs")); Assert.Equal(Result.Ok, _fs.Write("ana", "/home/ana/./docs/../docs//a.txt", "hi")); Assert.Equal("hi", _fs.Read("ana", "/home/ana/docs/a.txt").Value); Assert.Equal(new[] { "a.txt" }, _fs.Ls("ana", "/home/ana/docs/").Value); Assert.Equal(Result.InvalidPath, _fs.Read("ana", "home/ana/docs/a.txt").Result);}
[Fact]public void MkdirNeedsTheParentsUnlessAskedToCreateThem(){ Assert.Equal(Result.NotFound, _fs.Mkdir("ana", "/home/ana/x/y")); Assert.Equal(Result.Ok, _fs.Mkdir("ana", "/home/ana/x/y", parents: true)); Assert.Equal(Result.Ok, _fs.Mkdir("ana", "/home/ana/x/y", parents: true)); Assert.Equal(Result.AlreadyExists, _fs.Mkdir("ana", "/home/ana/x/y"));}
[Fact]public void FilesAndDirectoriesAreNotInterchangeable(){ _fs.Write("ana", "/home/ana/f", "x"); Assert.Equal(Result.NotADirectory, _fs.Mkdir("ana", "/home/ana/f/g", parents: true)); Assert.Equal(Result.IsADirectory, _fs.Read("ana", "/home/ana").Result); Assert.Equal(Result.IsADirectory, _fs.Write("ana", "/home/ana", "x")); Assert.Equal(new[] { "f" }, _fs.Ls("ana", "/home/ana/f").Value);}
[Fact]public void ListingIsSortedAndSizeAddsUpTheSubtree(){ _fs.Write("ana", "/home/ana/b.txt", "12345"); _fs.Write("ana", "/home/ana/a.txt", "123"); _fs.Mkdir("ana", "/home/ana/sub"); _fs.Write("ana", "/home/ana/sub/c", "12"); Assert.Equal(new[] { "a.txt", "b.txt", "sub" }, _fs.Ls("ana", "/home/ana").Value); Assert.Equal(10L, _fs.Size("ana", "/home/ana").Value);}
[Fact]public void MoveRenamesAndCarriesTheSubtree(){ _fs.Mkdir("ana", "/home/ana/p/q", parents: true); _fs.Write("ana", "/home/ana/p/q/n", "x"); Assert.Equal(Result.Ok, _fs.Move("ana", "/home/ana/p", "/home/ana/r")); Assert.Equal("x", _fs.Read("ana", "/home/ana/r/q/n").Value); Assert.Equal(Result.NotFound, _fs.Read("ana", "/home/ana/p/q/n").Result);}
[Fact]public void ADirectoryCannotMoveIntoItselfOrOntoAName(){ _fs.Mkdir("ana", "/home/ana/p/q", parents: true); _fs.Write("ana", "/home/ana/f", "x"); Assert.Equal(Result.InvalidMove, _fs.Move("ana", "/home/ana/p", "/home/ana/p/q/p")); Assert.Equal(Result.AlreadyExists, _fs.Move("ana", "/home/ana/f", "/home/ana/p")); Assert.Equal(Result.InvalidMove, _fs.Move(Root, "/", "/home/root"));}
[Fact]public void DeletingADirectoryWithContentsMustBeRecursive(){ _fs.Mkdir("ana", "/home/ana/p/q", parents: true); Assert.Equal(Result.NotEmpty, _fs.Delete("ana", "/home/ana/p")); Assert.Equal(Result.Ok, _fs.Delete("ana", "/home/ana/p", recursive: true)); Assert.Equal(Result.NotFound, _fs.Ls("ana", "/home/ana/p").Result);}
[Fact]public void OthersCanReadButNotChange(){ _fs.Write("ana", "/home/ana/notes", "hello"); Assert.Equal("hello", _fs.Read("ben", "/home/ana/notes").Value); Assert.Equal(Result.PermissionDenied, _fs.Write("ben", "/home/ana/notes", "mine now")); Assert.Equal(Result.PermissionDenied, _fs.Mkdir("ben", "/home/ana/x")); Assert.Equal(Result.PermissionDenied, _fs.Delete("ben", "/home/ana/notes")); Assert.Equal(Result.PermissionDenied, _fs.Move("ben", "/home/ana/notes", "/home/ben/notes"));}
[Fact]public void AClosedDirectoryHidesEverythingInside(){ _fs.Mkdir("ana", "/home/ana/private"); _fs.Write("ana", "/home/ana/private/diary", "dear diary"); Assert.Equal(Result.Ok, _fs.Chmod("ana", "/home/ana/private", new Mode(7, 0))); Assert.Equal(Result.PermissionDenied, _fs.Read("ben", "/home/ana/private/diary").Result); Assert.Equal(Result.PermissionDenied, _fs.Ls("ben", "/home/ana/private").Result); Assert.Equal(Result.PermissionDenied, _fs.Chmod("ben", "/home/ana/private", new Mode(7, 7))); Assert.Equal("dear diary", _fs.Read(Root, "/home/ana/private/diary").Value);}
[Fact]public void ConcurrentCreatesInOneDirectoryAllLand(){ var shared = new SharedFileSystem(_fs); using var start = new ManualResetEventSlim(); var threads = Enumerable.Range(0, 8).Select(t => new Thread(() => { start.Wait(); for (var i = 0; i < 1000; i++) shared.Write("ana", $"/home/ana/f{t}-{i}", "x"); })).ToList(); threads.ForEach(th => th.Start()); start.Set(); threads.ForEach(th => th.Join()); Assert.Equal(8000, shared.Ls("ana", "/home/ana").Value!.Count); Assert.Equal(8000L, shared.Size("ana", "/home/ana").Value);}// newFS gives ana /home/ana and ben /home/ben, as root would set them up.func newFS() *FileSystem { fs := NewFileSystem() for _, user := range []string{"ana", "ben"} { fs.Mkdir(Root, "/home/"+user, true) fs.Chown(Root, "/home/"+user, user) } return fs}
func expect[T comparable](t *testing.T, want, got T) { t.Helper() if want != got { t.Fatalf("want %v, got %v", want, got) }}
func ls(t *testing.T, fs *FileSystem, user, path string) string { t.Helper() names, r := fs.Ls(user, path) expect(t, OK, r) return strings.Join(names, " ")}
func TestPathsAreNormalisedAndMustBeAbsolute(t *testing.T) { fs := newFS() expect(t, OK, fs.Mkdir("ana", "/home/ana/docs", false)) expect(t, OK, fs.Write("ana", "/home/ana/./docs/../docs//a.txt", "hi")) got, _ := fs.Read("ana", "/home/ana/docs/a.txt") expect(t, "hi", got) expect(t, "a.txt", ls(t, fs, "ana", "/home/ana/docs/")) _, r := fs.Read("ana", "home/ana/docs/a.txt") expect(t, InvalidPath, r)}
func TestMkdirNeedsTheParentsUnlessAskedToCreateThem(t *testing.T) { fs := newFS() expect(t, NotFound, fs.Mkdir("ana", "/home/ana/x/y", false)) expect(t, OK, fs.Mkdir("ana", "/home/ana/x/y", true)) expect(t, OK, fs.Mkdir("ana", "/home/ana/x/y", true)) expect(t, AlreadyExists, fs.Mkdir("ana", "/home/ana/x/y", false))}
func TestFilesAndDirectoriesAreNotInterchangeable(t *testing.T) { fs := newFS() fs.Write("ana", "/home/ana/f", "x") expect(t, NotADirectory, fs.Mkdir("ana", "/home/ana/f/g", true)) _, r := fs.Read("ana", "/home/ana") expect(t, IsADirectory, r) expect(t, IsADirectory, fs.Write("ana", "/home/ana", "x")) expect(t, "f", ls(t, fs, "ana", "/home/ana/f"))}
func TestListingIsSortedAndSizeAddsUpTheSubtree(t *testing.T) { fs := newFS() fs.Write("ana", "/home/ana/b.txt", "12345") fs.Write("ana", "/home/ana/a.txt", "123") fs.Mkdir("ana", "/home/ana/sub", false) fs.Write("ana", "/home/ana/sub/c", "12") expect(t, "a.txt b.txt sub", ls(t, fs, "ana", "/home/ana")) size, _ := fs.Size("ana", "/home/ana") expect(t, int64(10), size)}
func TestMoveRenamesAndCarriesTheSubtree(t *testing.T) { fs := newFS() fs.Mkdir("ana", "/home/ana/p/q", true) fs.Write("ana", "/home/ana/p/q/n", "x") expect(t, OK, fs.Move("ana", "/home/ana/p", "/home/ana/r")) got, _ := fs.Read("ana", "/home/ana/r/q/n") expect(t, "x", got) _, r := fs.Read("ana", "/home/ana/p/q/n") expect(t, NotFound, r)}
func TestADirectoryCannotMoveIntoItselfOrOntoAName(t *testing.T) { fs := newFS() fs.Mkdir("ana", "/home/ana/p/q", true) fs.Write("ana", "/home/ana/f", "x") expect(t, InvalidMove, fs.Move("ana", "/home/ana/p", "/home/ana/p/q/p")) expect(t, AlreadyExists, fs.Move("ana", "/home/ana/f", "/home/ana/p")) expect(t, InvalidMove, fs.Move(Root, "/", "/home/root"))}
func TestDeletingADirectoryWithContentsMustBeRecursive(t *testing.T) { fs := newFS() fs.Mkdir("ana", "/home/ana/p/q", true) expect(t, NotEmpty, fs.Delete("ana", "/home/ana/p", false)) expect(t, OK, fs.Delete("ana", "/home/ana/p", true)) _, r := fs.Ls("ana", "/home/ana/p") expect(t, NotFound, r)}
func TestOthersCanReadButNotChange(t *testing.T) { fs := newFS() fs.Write("ana", "/home/ana/notes", "hello") got, _ := fs.Read("ben", "/home/ana/notes") expect(t, "hello", got) expect(t, PermissionDenied, fs.Write("ben", "/home/ana/notes", "mine now")) expect(t, PermissionDenied, fs.Mkdir("ben", "/home/ana/x", false)) expect(t, PermissionDenied, fs.Delete("ben", "/home/ana/notes", false)) expect(t, PermissionDenied, fs.Move("ben", "/home/ana/notes", "/home/ben/notes"))}
func TestAClosedDirectoryHidesEverythingInside(t *testing.T) { fs := newFS() fs.Mkdir("ana", "/home/ana/private", false) fs.Write("ana", "/home/ana/private/diary", "dear diary") expect(t, OK, fs.Chmod("ana", "/home/ana/private", Mode{7, 0})) _, r := fs.Read("ben", "/home/ana/private/diary") expect(t, PermissionDenied, r) _, r = fs.Ls("ben", "/home/ana/private") expect(t, PermissionDenied, r) expect(t, PermissionDenied, fs.Chmod("ben", "/home/ana/private", Mode{7, 7})) got, _ := fs.Read(Root, "/home/ana/private/diary") expect(t, "dear diary", got)}
func TestConcurrentCreatesInOneDirectoryAllLand(t *testing.T) { shared := NewSharedFileSystem(newFS()) var wg sync.WaitGroup start := make(chan struct{}) for g := range 8 { wg.Add(1) go func() { defer wg.Done() <-start for i := range 1000 { shared.Write("ana", fmt.Sprintf("/home/ana/f%d-%d", g, i), "x") } }() } close(start) wg.Wait() names, _ := shared.Ls("ana", "/home/ana") expect(t, 8000, len(names)) size, _ := shared.Size("ana", "/home/ana") expect(t, int64(8000), size)}import { test } from 'node:test';import assert from 'node:assert/strict';
// ana owns /home/ana and ben owns /home/ben, as root would set them up.function newFs(): FileSystem { const fs = new FileSystem(); for (const user of ['ana', 'ben']) { fs.mkdir(ROOT, `/home/${user}`, true); fs.chown(ROOT, `/home/${user}`, user); } return fs;}
test('paths are normalised and must be absolute', () => { const fs = newFs(); assert.equal(fs.mkdir('ana', '/home/ana/docs'), 'ok'); assert.equal(fs.write('ana', '/home/ana/./docs/../docs//a.txt', 'hi'), 'ok'); assert.equal(fs.read('ana', '/home/ana/docs/a.txt').value, 'hi'); assert.deepEqual(fs.ls('ana', '/home/ana/docs/').value, ['a.txt']); assert.equal(fs.read('ana', 'home/ana/docs/a.txt').result, 'invalid-path');});
test('mkdir needs the parents unless asked to create them', () => { const fs = newFs(); assert.equal(fs.mkdir('ana', '/home/ana/x/y'), 'not-found'); assert.equal(fs.mkdir('ana', '/home/ana/x/y', true), 'ok'); assert.equal(fs.mkdir('ana', '/home/ana/x/y', true), 'ok'); assert.equal(fs.mkdir('ana', '/home/ana/x/y'), 'already-exists');});
test('files and directories are not interchangeable', () => { const fs = newFs(); fs.write('ana', '/home/ana/f', 'x'); assert.equal(fs.mkdir('ana', '/home/ana/f/g', true), 'not-a-directory'); assert.equal(fs.read('ana', '/home/ana').result, 'is-a-directory'); assert.equal(fs.write('ana', '/home/ana', 'x'), 'is-a-directory'); assert.deepEqual(fs.ls('ana', '/home/ana/f').value, ['f']);});
test('listing is sorted and size adds up the subtree', () => { const fs = newFs(); fs.write('ana', '/home/ana/b.txt', '12345'); fs.write('ana', '/home/ana/a.txt', '123'); fs.mkdir('ana', '/home/ana/sub'); fs.write('ana', '/home/ana/sub/c', '12'); assert.deepEqual(fs.ls('ana', '/home/ana').value, ['a.txt', 'b.txt', 'sub']); assert.equal(fs.size('ana', '/home/ana').value, 10);});
test('move renames and carries the subtree', () => { const fs = newFs(); fs.mkdir('ana', '/home/ana/p/q', true); fs.write('ana', '/home/ana/p/q/n', 'x'); assert.equal(fs.move('ana', '/home/ana/p', '/home/ana/r'), 'ok'); assert.equal(fs.read('ana', '/home/ana/r/q/n').value, 'x'); assert.equal(fs.read('ana', '/home/ana/p/q/n').result, 'not-found');});
test('a directory cannot move into itself or onto a name', () => { const fs = newFs(); fs.mkdir('ana', '/home/ana/p/q', true); fs.write('ana', '/home/ana/f', 'x'); assert.equal(fs.move('ana', '/home/ana/p', '/home/ana/p/q/p'), 'invalid-move'); assert.equal(fs.move('ana', '/home/ana/f', '/home/ana/p'), 'already-exists'); assert.equal(fs.move(ROOT, '/', '/home/root'), 'invalid-move');});
test('deleting a directory with contents must be recursive', () => { const fs = newFs(); fs.mkdir('ana', '/home/ana/p/q', true); assert.equal(fs.delete('ana', '/home/ana/p'), 'not-empty'); assert.equal(fs.delete('ana', '/home/ana/p', true), 'ok'); assert.equal(fs.ls('ana', '/home/ana/p').result, 'not-found');});
test('others can read but not change', () => { const fs = newFs(); fs.write('ana', '/home/ana/notes', 'hello'); assert.equal(fs.read('ben', '/home/ana/notes').value, 'hello'); assert.equal(fs.write('ben', '/home/ana/notes', 'mine now'), 'permission-denied'); assert.equal(fs.mkdir('ben', '/home/ana/x'), 'permission-denied'); assert.equal(fs.delete('ben', '/home/ana/notes'), 'permission-denied'); assert.equal(fs.move('ben', '/home/ana/notes', '/home/ben/notes'), 'permission-denied');});
test('a closed directory hides everything inside', () => { const fs = newFs(); fs.mkdir('ana', '/home/ana/private'); fs.write('ana', '/home/ana/private/diary', 'dear diary'); assert.equal(fs.chmod('ana', '/home/ana/private', new Mode(7, 0)), 'ok'); assert.equal(fs.read('ben', '/home/ana/private/diary').result, 'permission-denied'); assert.equal(fs.ls('ben', '/home/ana/private').result, 'permission-denied'); assert.equal(fs.chmod('ben', '/home/ana/private', new Mode(7, 7)), 'permission-denied'); assert.equal(fs.read(ROOT, '/home/ana/private/diary').value, 'dear diary');});
test('the journal records changes in the order they were made', async () => { const journal: string[] = []; // Each journal write takes a random time, as a disk does under load. const slowJournal = (entry: string) => new Promise<void>((done) => setTimeout(() => (journal.push(entry), done()), Math.random() * 10)); const shared = new SharedFileSystem(newFs(), slowJournal); const names = Array.from({ length: 20 }, (_, i) => `/home/ana/d${i}`); await Promise.all(names.map((n) => shared.mkdir('ana', n))); assert.deepEqual( journal, names.map((n) => `mkdir ana ${n} false`), );});Go deeperEvery edge case, and where it is handled2 min
| Case | What happens | Where |
|---|---|---|
.. at the root |
Stays at the root | parse |
| Relative path | INVALID_PATH |
parse |
mkdir -p of a directory that exists |
OK | mkdir |
| A file where a directory was needed | NOT_A_DIRECTORY |
resolve, mkdir |
| Read or write a directory | IS_A_DIRECTORY |
read, write |
| Move onto an existing name | ALREADY_EXISTS; nothing is overwritten |
move |
| Move a directory inside itself | INVALID_MOVE |
move, the walk up |
| Move or delete the root | INVALID_MOVE or INVALID_PATH |
move, delete |
| Delete a directory with contents | NOT_EMPTY unless recursive |
delete |
| A closed directory on the path | PERMISSION_DENIED before anything inside is seen |
resolve |
| Recursive delete of directories you can’t write | Allowed: only the top is checked, unlike Unix | delete |
| Two threads change one directory | One at a time, under the tree’s write lock | SharedFileSystem |
Wrap up
Section titled “Wrap up”The decisions, and what each one costs
Section titled “The decisions, and what each one costs”| Decision | We chose | We gave up |
|---|---|---|
| Structure | A composite tree of Entry, File and Directory |
One-lookup access by full path |
| Paths | Cleaned up once, followed by walking; never stored | A path is a walk to work out |
| Move | Relink one entry; refuse a move into itself | Nothing: it’s cheap and safe |
| Delete | Unlink; a non-empty directory needs recursive | Checks on each directory inside a recursive delete |
| Permissions | Owner and others bits, execute checked on every directory | Groups and per-user lists |
| Threads | One read-write lock around the tree | Parallel writes to unrelated directories |
Go deeperGenuinely different designs, and when to pick them2 min
A path-keyed map with a sorted index. Stage 1 isn’t always wrong. Object stores like S3 keep flat keys, and list a “directory” by reading a range of a sorted index. That costs the number of results, not the number of keys. They give up cheap rename, which is why renaming a folder in S3 copies every object.
A tree that never changes in place. Every change creates new nodes from the changed entry up to a new root, and shares everything else with the old tree. Readers keep the root they started with and never wait, snapshots are free, and undo is just keeping old roots. Git’s trees work this way. Each write costs a path’s worth of new nodes.
Inodes and directory entries. Separate the file (the inode: content, owner, mode, link count) from its names (entries in directories). This is what real file systems do, and it’s what makes hard links possible. In an interview, it’s worth naming as the next step, not starting with.
How to spend 45 minutes
Section titled “How to spend 45 minutes”- 0 to 3. In memory, absolute paths, the operations, permissions, threads.
- 3 to 5. The promises. Name “the cost is the path’s depth, even for a big move” as the hard one.
- 5 to 22. The flat map and its problems, the composite tree with parse and resolve, move with
the ancestor check and delete, then owners and mode bits. Code
resolveandmove. - 22 to 24. Walk through the full diagram: names and parents only, one walk for everything.
- 24 to 29. One read-write lock, and why a lock per directory is harder with move.
- 29 to 45. Links, then your own questions.
If time is short, skip permissions and spend it on move and the walk. The ancestor check and the cost of a move are what this question is decided on.
Follow-up questions
Section titled “Follow-up questions”Users want shortcuts: a name in one directory that points to a file or directory somewhere else. What changes?
Whether you know the two kinds of link, and what each does to the tree. A hard link makes it a graph, which breaks the parent link and “delete frees everything inside”. A symbolic link keeps it a tree, but makes the walk follow a path, which can loop.
Most extensions fit into one place:
| Extension | What changes | Changes the tree’s shape? |
|---|---|---|
| Symbolic links | A Link entry; resolve follows it, with a limit on hops |
No |
| Hard links | Names separate from files; a link count; no single parent | Yes |
| Groups | A group per entry, a third set of bits, a user’s groups | No |
| Find by name | A walk of the subtree, or a name index updated on every change | No |
| Byte content and append | File holds bytes; append and read(offset, length) |
No |
| Quotas | A size counter per user, updated by write, move and delete | No |
Go deeperAdding links, step by step2 min
A symbolic link (symlink) is the easy one.
- Add a new kind of entry,
Link, whose content is a path. It sits in a directory like any file. - Teach
resolveto follow it. When the walk meets a link in the middle of a path, it parses the link’s path and carries on from there, with the rest of the original path added on. - Guard against loops.
acan point tob, andbback toa. Soresolvecounts the links it has followed, and gives up after a limit, as Unix does after 40.
The tree stays a tree: a link is just an entry with one parent. Deleting the target leaves the link
pointing at nothing, which is allowed, and reads through it return NOT_FOUND.
A hard link is a much bigger change. It puts the same file in two directories, under two names.
- Separate names from files. A directory now maps names to files, and a file no longer has one name or one parent.
- Count names per file. Deleting a name lowers the count. The file is freed only when the count reaches zero.
- Forbid hard links to directories. With them, a directory could be inside itself through a second name, and the inside-itself check would no longer work. Unix forbids them for this reason.
This separation of names from files is exactly what Unix calls the inode, which is why it’s the “next step” design above.
Go deeperFour more follow-up questions2 min
Interviewer asksHow would you print an entry's full path?
Walk up through the parents to the root, collecting names, then reverse them. It costs the depth, and it’s always right, because a path is worked out from the tree, not stored beside it.
Interviewer asksHow would you support a current directory and relative paths?
A session per user holds its current directory, as an entry, not a path, so moving that directory
doesn’t break the session. A relative path is resolved starting from that entry instead of the
root. cd is a resolve that must end at a directory the user has execute on.
Interviewer asksWhat if directories hold millions of entries?
A hash map of children keeps lookup O(1), but listing in order means sorting. A sorted map keeps listing in order, at O(log n) per lookup. For very large directories, listing should also come in pages, returning a cursor instead of every name at once.
Interviewer asksHow do you make it survive a restart?
Add every change to a journal before applying it, and replay the journal at startup, with a snapshot of the whole tree now and then, so replay stays short. The TypeScript version of the threads stage shows why the journal’s order must match the order the changes were applied.
Further reading
Section titled “Further reading”- Design a logging framework, another design with one facade over a tree-shaped configuration
- Design an LRU cache, for when the children map needs eviction
- The Linux
path_resolution(7)man page, how a real kernel walks a path and checks permissions - Refactoring.Guru: Composite, the pattern this design is built on
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.