JustBash.FS (JustBash v0.4.0)

View Source

JustBash's filesystem layer: a thin facade over the vfs library.

new/1 builds a %VFS{} mount table with a JustBash.FS.Memory backend mounted at /. Because the whole filesystem is a %VFS{}, additional backends (a read-only git repository via exgit, another in-memory scratch space, a caller-provided VFS.Mountable) can be mounted alongside it with VFS.mount/3 and every bash command sees them transparently.

Three groups of functions:

  • Core operations delegate to VFS and follow vfs conventions: reads return {:ok, payload, fs} (thread the updated fs forward — lazy backends cache on read), mutations return {:ok, fs}, and all errors are %VFS.Error{} structs. Paths in JustBash.FS.Special (/dev/null, and the /dev directory that holds it) are answered here before any backend is consulted.
  • POSIX extensions (lstat/2, symlink/3, readlink/2, link/3, chmod/3, append_file/3) dispatch through JustBash.FS.POSIX, degrading gracefully on backends without them.
  • Compositions (cp/4, mv/3) are built from the primitives, so they work across mounts.

Summary

Types

A path resolve_path/2 produced: an absolute string, or {:error, %VFS.Error{kind: :enoent}} when the operand was the empty pathname.

t()

Functions

Append content to a file, creating it if it doesn't exist.

Get the base name (last segment) of a path. The basename of / is /.

Hold spelling — a path as an operand wrote it, read relative to base — to what its spelling promised: {:ok, fs} unless it names nothing (an empty operand is ENOENT) or demands a directory (see directory_spelling?/1) and what it demands it of is not one.

Change file/directory permissions.

Copy a file, symlink, or (with recursive: true) directory tree.

Does the way path is spelled require it to name a directory?

Get the directory name (parent path) of a path.

Create a hard link.

Get stat information without following symlinks.

See VFS.mkdir/3. Pass parents: true for mkdir -p behavior.

Move/rename a file, symlink, or directory tree. Composed as a recursive copy followed by a recursive remove, so it works across mounts.

Create a new filesystem: a %VFS{} with a JustBash.FS.Memory backend (seeded with initial_files) mounted at /.

Normalize a filesystem path. Relative input is rooted at /.

Read the target of a symbolic link.

Resolve path relative to base. Absolute paths ignore the base.

See VFS.rm/3. Pass recursive: true to remove directory trees.

See VFS.stat/2. Follows symlinks.

The conventional strerror-style message for a %VFS.Error{} or error kind, as bash utilities print them.

Create a symbolic link at link_path pointing to target.

Validate a path => content map before seeding a filesystem with it.

See VFS.write_file/4. The default backend honors :mode and :mtime opts.

Types

cp_opts()

@type cp_opts() :: [recursive: boolean(), deadline: JustBash.Limit.Deadline.t() | nil]

mkdir_opts()

@type mkdir_opts() :: [{:parents, boolean()}]

resolved()

@type resolved() :: String.t() | {:error, VFS.Error.t()}

A path resolve_path/2 produced: an absolute string, or {:error, %VFS.Error{kind: :enoent}} when the operand was the empty pathname.

rm_opts()

@type rm_opts() :: [{:recursive, boolean()}]

t()

@type t() :: VFS.t()

write_opts()

@type write_opts() :: [mode: non_neg_integer(), mtime: DateTime.t()]

Functions

append_file(fs, error, content)

@spec append_file(t(), resolved(), binary()) :: {:ok, t()} | {:error, VFS.Error.t()}

Append content to a file, creating it if it doesn't exist.

basename(path)

@spec basename(String.t()) :: String.t()

Get the base name (last segment) of a path. The basename of / is /.

check_directory_spelling(fs, base, spelling)

@spec check_directory_spelling(t(), String.t(), String.t()) ::
  {:ok, t()} | {:error, VFS.Error.t()}

Hold spelling — a path as an operand wrote it, read relative to base — to what its spelling promised: {:ok, fs} unless it names nothing (an empty operand is ENOENT) or demands a directory (see directory_spelling?/1) and what it demands it of is not one.

What it demands it of is the last component the spelling names outright, not where resolve_path/2 says the whole thing lands: .. is collapsed lexically, so /f/.. resolves to / — a directory whatever /f is — while the kernel cannot walk up out of the regular file /f at all.

The error is the one stat("f/") itself gives: :enotdir when something that is not a directory is already there, naming the operand as the caller spelled it, and whatever stat/2 reported otherwise. :enoent means the destination does not exist yet, which a caller about to create the directory — a recursive copy, say — can ignore.

chmod(fs, error, mode)

@spec chmod(t(), resolved(), non_neg_integer()) ::
  {:ok, t()} | {:error, VFS.Error.t()}

Change file/directory permissions.

cp(fs, src, dest, opts \\ [])

@spec cp(t(), resolved(), resolved(), cp_opts()) ::
  {:ok, t()} | {:error, VFS.Error.t()}

Copy a file, symlink, or (with recursive: true) directory tree.

Composed from the vfs primitives plus the POSIX extensions, so it works across mounts: regular files copy content, mode, and mtime; symlinks copy the link itself when the destination backend supports symlinks.

A recursive copy whose destination lies inside the source fails with :einval instead of recursing forever — every pass would add new children under the source it is still walking. Both operands are resolved through any symlinked components before that test, so a destination that only reaches the source through a link is caught too.

:deadline — a JustBash.Limit.Deadline (or nil). A recursive copy is a single step as far as the step counter is concerned, so a large tree is otherwise unbounded; with a deadline, each directory descended into checks the wall clock and raises JustBash.Limit.ExceededError once it has passed.

directory_spelling?(path)

@spec directory_spelling?(String.t()) :: boolean()

Does the way path is spelled require it to name a directory?

POSIX resolves a trailing slash as a trailing /., so f/ is not another spelling of the regular file f — it is an assertion that f is a directory. The . and .. components the slash stands for say the same thing. resolve_path/2 normalizes all of them away, so a caller that writes through a user-supplied path asks this of the operand before trusting where it resolved to.

An empty operand names nothing at all, which is a different complaint.

dirname(path)

@spec dirname(String.t()) :: String.t()

Get the directory name (parent path) of a path.

exists?(fs, path)

@spec exists?(t(), resolved()) :: {boolean(), t()}

See VFS.exists?/2.

link(fs, error, error)

@spec link(t(), resolved(), resolved()) :: {:ok, t()} | {:error, VFS.Error.t()}

Create a hard link.

lstat(fs, error)

@spec lstat(t(), resolved()) :: {:ok, VFS.Stat.t(), t()} | {:error, VFS.Error.t()}

Get stat information without following symlinks.

mkdir(fs, path, opts \\ [])

@spec mkdir(t(), resolved(), mkdir_opts()) :: {:ok, t()} | {:error, VFS.Error.t()}

See VFS.mkdir/3. Pass parents: true for mkdir -p behavior.

mv(fs, error, error)

@spec mv(t(), resolved(), resolved()) :: {:ok, t()} | {:error, VFS.Error.t()}

Move/rename a file, symlink, or directory tree. Composed as a recursive copy followed by a recursive remove, so it works across mounts.

A destination symlink is replaced, not followed (POSIX rename/2 semantics) — the opposite of cp/4, which writes through it.

Moving a directory into its own subtree fails with :einval (see cp/4) rather than looping.

new(initial_files \\ %{})

@spec new(map()) :: t()

Create a new filesystem: a %VFS{} with a JustBash.FS.Memory backend (seeded with initial_files) mounted at /.

Raises ArgumentError when initial_files is not realizable as a filesystem: either one entry's path runs through another (see validate_initial_files!/1), or an entry collides with a directory the backend already holds, as %{"/" => "x"} does.

normalize_path(path)

@spec normalize_path(String.t()) :: String.t()

Normalize a filesystem path. Relative input is rooted at /.

read_file(fs, error)

@spec read_file(t(), resolved()) :: {:ok, binary(), t()} | {:error, VFS.Error.t()}

See VFS.read_file/2.

readdir(fs, error)

@spec readdir(t(), resolved()) :: {:ok, Enumerable.t(), t()} | {:error, VFS.Error.t()}

See VFS.readdir/2.

readlink(fs, error)

@spec readlink(t(), resolved()) :: {:ok, String.t(), t()} | {:error, VFS.Error.t()}

Read the target of a symbolic link.

resolve_path(base, path)

@spec resolve_path(String.t(), String.t()) :: resolved()

Resolve path relative to base. Absolute paths ignore the base.

An empty pathname names nothing. VFS.Path.join/2 collapses an empty component onto base, which made cat '' report "Is a directory" for the working directory. POSIX is the other way: open("") is ENOENT.

rm(fs, path, opts \\ [])

@spec rm(t(), resolved(), rm_opts()) :: {:ok, t()} | {:error, VFS.Error.t()}

See VFS.rm/3. Pass recursive: true to remove directory trees.

stat(fs, error)

@spec stat(t(), resolved()) :: {:ok, VFS.Stat.t(), t()} | {:error, VFS.Error.t()}

See VFS.stat/2. Follows symlinks.

stream_read(fs, path, opts \\ [])

@spec stream_read(t(), resolved(), keyword()) ::
  {:ok, Enumerable.t(), t()} | {:error, VFS.Error.t()}

See VFS.stream_read/3.

strerror(kind)

@spec strerror(VFS.Error.t() | atom()) :: String.t()

The conventional strerror-style message for a %VFS.Error{} or error kind, as bash utilities print them.

symlink(fs, target, error)

@spec symlink(t(), String.t(), resolved()) :: {:ok, t()} | {:error, VFS.Error.t()}

Create a symbolic link at link_path pointing to target.

validate_initial_files!(initial_files)

@spec validate_initial_files!(map()) :: :ok

Validate a path => content map before seeding a filesystem with it.

Raises ArgumentError when one entry's path runs through another entry — %{"/m/j" => "x", "/m/j/a.md" => "y"} asks for a file inside a regular file, which no filesystem can hold. See JustBash.FS.Memory.validate_initial_files!/1.

walk(fs, root, opts \\ [])

@spec walk(t(), String.t(), keyword()) :: Enumerable.t()

See VFS.walk/3.

This is an unbounded enumeration and no command in lib/ uses it; a caller that walks an untrusted tree should pipe it through JustBash.Limit.enforce_deadline/2, the way JustBash.Commands.Seq does.

write_file(fs, path, content, opts \\ [])

@spec write_file(t(), resolved(), binary(), write_opts()) ::
  {:ok, t()} | {:error, VFS.Error.t()}

See VFS.write_file/4. The default backend honors :mode and :mtime opts.