Pure Erlang implementation of 9p2000 protocol
filesystem
fs
9p2000
erlang
9p
1% SPDX-FileCopyrightText: 2025 Łukasz Niemier <~@hauleth.dev>
2%
3% SPDX-License-Identifier: Apache-2.0
4
5-module(e9p_unfs).
6
7-moduledoc """
8Expose Unix Filesystem as 9p2000 mount
9""".
10
11-behaviour(e9p_fs).
12
13-include_lib("kernel/include/logger.hrl").
14-include_lib("kernel/include/file.hrl").
15
16-export([init/1, root/3, walk/4, stat/3, open/4, read/5, clunk/2, create/6,
17 write/5, remove/3, wstat/4]).
18
19% Create QID and Stat data for given path.
20qid(Root, Path) ->
21 FullPath = filename:join([Root] ++ Path),
22 case file:read_file_info(FullPath, [{time, posix}, raw]) of
23 {ok, #file_info{type = Type, inode = Inode} = FI} ->
24 QID = e9p:make_qid(Type, 0, Inode),
25 Stat = file_info_to_stat(Path, QID, FI),
26 {ok, QID, Stat};
27 {error, _} = Error -> Error
28 end.
29
30file_info_to_stat(
31 Path,
32 QID,
33 #file_info{
34 size = Len,
35 atime = Atime,
36 mtime = Mtime,
37 mode = Mode
38 }) ->
39 Name = if
40 Path == [] -> ~"/";
41 true -> lists:last(Path)
42 end,
43 #{
44 qid => QID,
45 mode => Mode,
46 atime => Atime,
47 mtime => Mtime,
48 length => Len,
49 name => Name
50 }.
51
52stat_to_file_info(Stat) ->
53 #{
54 mode := Mode,
55 atime := Atime,
56 mtime := Mtime
57 } = Stat,
58 #file_info{
59 mode = Mode,
60 atime = Atime,
61 mtime = Mtime
62 }.
63
64%% ====== Filesystem handlers ======
65
66-doc false.
67init(#{path := Path}) ->
68 {ok, #{root => unicode:characters_to_binary(Path)}}.
69
70-doc false.
71root(UName, AName, #{root := Root} = State) ->
72 ?LOG_INFO(#{uname => UName, aname => AName}),
73 maybe
74 {ok, Qid, _Stat} ?= qid(Root, []),
75 {ok, {Qid, []}, State}
76 end.
77
78-doc false.
79walk(_QID, Path, ~"..", #{root := Root} = State) ->
80 case qid(Root, lists:droplast(Path)) of
81 {ok, NQid, _Stat} -> {{NQid, []}, State};
82 {error, _} -> {false, State}
83 end;
84walk(_QID, Path, File, #{root := Root} = State) ->
85 case qid(Root, Path ++ [File]) of
86 {ok, NQid, _Stat} -> {{NQid, []}, State};
87 {error, _} -> {false, State}
88 end.
89
90-doc false.
91stat({QID, _}, Path, #{root := Root} = State) ->
92 FullPath = filename:join([Root] ++ Path),
93 case file:read_file_info(FullPath, [{time, posix}, raw]) of
94 {ok, FileInfo} ->
95 Stat = file_info_to_stat(Path, QID, FileInfo),
96 {ok, Stat, State};
97 {error, Error} ->
98 {error, Error, State}
99 end.
100
101-doc false.
102wstat(_QID, Path, Stat, #{root := Root} = State) ->
103 FileInfo = stat_to_file_info(Stat),
104 FullPath = filename:join([Root] ++ Path),
105
106 case file:write_file_info(FullPath, FileInfo, [{time, posix}, raw]) of
107 ok -> {ok, State};
108 {error, Reason} ->
109 {error, io_lib:format("Couldn't write file stat: ~p", [Reason]),
110 State}
111 end.
112
113-doc false.
114open({QID, []}, Path, Mode, #{root := Root} = State) ->
115 FullPath = filename:join([Root] ++ Path),
116 QS = case e9p:is_type(QID, directory) of
117 true ->
118 % Currently `file` module do not expose raw mode for listing
119 % file directory, so we need to call private `prim_file` module
120 % to access such functionality. Otherwise we can encounter
121 % deadlock.
122 %
123 % See: https://github.com/erlang/otp/issues/10593
124 {ok, List} = prim_file:list_dir(FullPath),
125 {dir, List};
126 false ->
127 {Trunc, Opts} = translate_mode(Mode),
128 {ok, FD} = file:open(FullPath, [raw, binary | Opts]),
129 if Trunc -> file:truncate(FD); true -> ok end,
130 {regular, FD}
131 end,
132 {ok, {QS, 0}, State}.
133
134-doc false.
135clunk({_, {regular, FD}}, State) ->
136 ok = file:close(FD),
137 {ok, State};
138clunk(_QID, State) ->
139 {ok, State}.
140
141-doc false.
142create(_QID, _Path, _Name, _Perm, _Mode, State) ->
143 {error, "Unsupported", State}.
144
145-doc false.
146remove({QID, _} = FID, Path, #{root := Root} = State0) ->
147 FullPath = filename:join([Root] ++ Path),
148 {ok, State} = clunk(FID, State0),
149 case case e9p:is_type(QID, directory) of
150 % Currently `file` module do not expose raw mode for listing
151 % file directory, so we need to call private `prim_file` module
152 % to access such functionality. Otherwise we can encounter
153 % deadlock.
154 %
155 % See: https://github.com/erlang/otp/issues/10593
156 true -> prim_file:del_dir(FullPath);
157 false -> file:delete(FullPath, [raw])
158 end of
159 ok -> {ok, State};
160 {error, Reason} ->
161 {error, io_lib:format("Failed to remove path: ~p", [Reason]), State}
162 end.
163
164translate_mode([trunc | Rest]) ->
165 {_, Mode} = translate_mode(Rest),
166 {true, Mode};
167translate_mode([read]) -> {false, [read]};
168translate_mode([write]) -> {false, [read, write]};
169translate_mode([append]) -> {false, [read, write]};
170translate_mode([exec]) -> {false, [read]}.
171
172-doc false.
173read({_QID, {regular, FD}}, _Path, Offset, Len, State) ->
174 case file:pread(FD, Offset, Len) of
175 {ok, Data} -> {ok, {{regular, FD}, Data}, State};
176 eof -> {ok, {{regular, FD}, []}, State};
177 {error, Err} -> {error, Err, State}
178 end;
179read({_QID, {dir, List}}, Path, _Offset, Len, #{root := Root} = State) ->
180 {Remaining, Data} = readdir(Root, Path, List, Len, []),
181 {ok, {{dir, Remaining}, Data}, State}.
182
183readdir(_Root, _Path, List, 0, Acc) -> {List, Acc};
184readdir(_Root, _Path, [], _Len, Acc) -> {[], Acc};
185readdir(Root, Path, [Next | Rest], Len, Acc) ->
186 {ok, _QID, Stat} = qid(Root, Path ++ [Next]),
187 Encoded = e9p_msg:encode_stat(Stat),
188 Size = iolist_size(Encoded),
189 if
190 Size > Len -> [];
191 true -> readdir(Root, Path, Rest, Len - Size, [Encoded | Acc])
192 end.
193
194-doc false.
195write({_QID, {regular, FD}}, _Path, Offset, Data, State) ->
196 case file:pwrite(FD, Offset, Data) of
197 ok -> {ok, {{regular, FD}, iolist_size(Data)}, State};
198 {error, Err} -> {error, io_lib:format("Write error ~p", [Err]), State}
199 end.