Behaviour for an Elixir-backed virtual filesystem mounted into a session.
A :virtual_fs mount routes a script's filesystem operations under a vfs path
into your application: "files" can be generated on demand or proxied to a real
backing store. Register a backend with ExBashkit.Session.new/1:
ExBashkit.Session.new(virtual_fs: %{"/api" => {MyFs, config}})Each callback receives the per-mount arg (the config in {MyFs, config},
or nil for a bare MyFs) and a path rooted at the mount — for a mount
at /api, a read of /api/users/1.json arrives as /users/1.json, and the
mount root as /.
Implementing a backend
defmodule MyFs do
use ExBashkit.VirtualFs
@impl true
def read(config, path), do: {:ok, render(config, path)}
@impl true
def list(config, _path), do: {:ok, keys(config)}
enduse ExBashkit.VirtualFs provides default implementations for every callback,
so you implement only what your backend supports. Defaults:
- mutating/listing callbacks (
write,append,mkdir,remove,list) return{:error, :enotsup}; readreturns{:error, :enotsup};statis derived fromread/2— a backend that implements onlyread/2gets workingcat,stat, andtest -ffor files for free (at the cost of fetching content to size it; overridestat/2to avoid that).
Return values
All callbacks return tagged results. reason is an errno-style atom
(:enoent, :eacces, :eexist, :eisdir, :enotdir, :enotsup) or a
string, surfaced to the script as the matching filesystem error.
Function form
For quick or inline backends you may pass a single arity-1 function instead of a module. It receives a request map and returns the same tagged results:
virtual_fs: %{
"/api" => fn
%{op: :read, path: path} -> {:ok, render(path)}
%{op: :write, path: p, data: d} -> Store.put(p, d)
%{op: :list, path: _} -> {:ok, Store.keys()}
_ -> {:error, :enotsup}
end
}The request map always has :op and :path; :write/:append also carry
:data, and :mkdir/:remove carry :recursive.
What the shell does to each operation
| Script | Callback | Notes |
|---|---|---|
cat f, source f, $(<f) | read | |
echo x > f | write | truncating write |
echo x >> f | append | |
ls d | list | each read_dir entry |
rm f, rm -r d | remove | recursive set for -r |
mkdir d, mkdir -p a/b | mkdir | recursive set for -p |
test -e f, [ -f f ], tab-completion | stat | existence/type checks |
Examples
A read-only generator (function form)
Serve computed "files" — nothing is stored; content is produced on read. Only
read is needed; use's derived stat makes cat and test -f work, and
list makes ls work.
transform =
fn
%{op: :read, path: "/upper/" <> word} -> {:ok, String.upcase(word) <> "\n"}
%{op: :read, path: "/reverse/" <> word} -> {:ok, String.reverse(word) <> "\n"}
%{op: :read, path: _} -> {:error, :enoent}
%{op: :list, path: "/"} -> {:ok, [{"upper", :dir}, {"reverse", :dir}]}
_ -> {:error, :enotsup}
end
session = ExBashkit.Session.new(virtual_fs: %{"/x" => transform})
{:ok, %{stdout: "HELLO\n"}} = ExBashkit.Session.exec(session, "cat /x/upper/hello")A read-write store (behaviour form)
Back a mount with real state — here an Agent-held map, passed as the
per-mount arg via {KvFs, store}. Implementing read/write/remove/list
is enough; stat (and thus exists) is derived from read.
defmodule KvFs do
use ExBashkit.VirtualFs
@impl true
def read(store, "/" <> key) do
case Agent.get(store, &Map.get(&1, key)) do
nil -> {:error, :enoent}
value -> {:ok, value}
end
end
@impl true
def write(store, "/" <> key, data) do
Agent.update(store, &Map.put(&1, key, data))
:ok
end
@impl true
def remove(store, "/" <> key, _recursive) do
Agent.update(store, &Map.delete(&1, key))
:ok
end
@impl true
def list(store, "/"), do: {:ok, Agent.get(store, &Map.keys(&1))}
end
{:ok, store} = Agent.start_link(fn -> %{} end)
session = ExBashkit.Session.new(virtual_fs: %{"/kv" => {KvFs, store}})
{:ok, _} = ExBashkit.Session.exec(session, "echo 42 > /kv/answer")
{:ok, %{stdout: "42\n"}} = ExBashkit.Session.exec(session, "cat /kv/answer")Proxying to a real backend
Because a backend is just Elixir, read/list/write can delegate to anything
your app already has — a database, an HTTP API, an object store:
defmodule DocsFs do
use ExBashkit.VirtualFs
@impl true
def read(_arg, "/" <> slug) do
case MyApp.Docs.fetch(slug) do
{:ok, doc} -> {:ok, doc.body}
:error -> {:error, :enoent}
end
end
@impl true
def list(_arg, "/"), do: {:ok, MyApp.Docs.all_slugs()}
endA script can then grep, cat, and pipe over your data as if it were files —
with no real disk or process access.
Notes
existsis derived fromstat/2; the mount root always stats as a directory.rename,copy,symlink, andread_linkare not proxied in this version (somv/cpacross a virtual mount are unsupported);chmodis a silent no-op.- A backend must not call
ExBashkit.Session.exec/2on the same session that triggered the operation (it would deadlock on the session lock).
Summary
Types
A directory entry: a name, or a name tagged with its type.
A path rooted at the mount, e.g. /users/1.json or /.
An errno-style atom or a free-form string.
Types
A directory entry: a name, or a name tagged with its type.
@type path() :: String.t()
A path rooted at the mount, e.g. /users/1.json or /.
An errno-style atom or a free-form string.
Callbacks
@callback stat(arg :: term(), path()) :: {:ok, %{type: :file | :dir, size: non_neg_integer()}} | {:error, reason()}