Packages
macula
13.2.2
15.0.0-rc.2
15.0.0-rc.1
14.11.0
14.10.0
14.9.0
14.8.2
14.8.1
14.8.0
14.7.0
14.6.0
14.5.0
14.4.0
14.3.0
14.2.1
14.2.0
14.1.0
14.0.0
13.6.0
13.5.0
13.4.0
13.3.0
13.2.2
13.2.1
13.2.0
13.1.0
13.0.1
13.0.0
12.12.0
12.11.1
12.11.0
12.10.0
12.9.1
12.9.0
12.8.0
12.7.0
12.6.0
12.5.1
12.5.0
12.4.0
12.3.0
12.2.1
12.2.0
12.1.0
12.0.0
11.5.0
11.4.0
11.3.1
11.3.0
11.2.0
11.1.0
11.0.0
10.25.0
10.24.0
10.23.0
10.22.0
10.21.0
10.20.3
10.20.2
10.20.0
10.19.2
10.19.1
10.19.0
10.18.0
10.17.0
10.16.0
10.15.0
10.14.5
10.14.4
10.14.2
10.14.1
10.14.0
10.13.2
10.13.1
10.11.0
10.10.2
10.10.1
10.10.0
10.9.1
10.9.0
10.8.0
10.7.0
10.5.8
10.5.7
10.5.6
10.5.5
10.5.4
10.5.3
10.5.2
10.5.1
10.5.0
10.4.0
10.2.0
10.1.1
10.1.0
10.0.2
10.0.1
10.0.0
9.13.8
9.8.2
9.8.1
9.8.0
9.5.0
9.4.0
9.3.1
9.3.0
9.2.0
9.1.1
9.1.0
9.0.0
8.7.0
8.6.0
8.5.0
8.4.1
8.4.0
8.3.0
8.2.0
8.1.0
8.0.2
8.0.1
8.0.0
7.1.0
7.0.0
6.0.0
5.2.2
5.2.1
5.2.0
5.1.0
5.0.0
4.8.0
4.7.1
4.7.0
4.6.0
4.5.0
4.4.10
4.4.9
4.4.8
4.4.7
4.4.6
4.4.5
4.4.4
4.4.3
4.4.2
4.4.1
4.4.0
4.3.1
4.3.0
4.2.9
4.2.8
4.2.7
4.2.6
4.2.5
4.2.4
4.2.3
4.2.2
4.2.1
4.2.0
4.1.1
4.1.0
4.0.0
3.16.0
3.15.3
3.15.2
3.15.1
3.14.0
3.13.0
3.12.1
3.12.0
3.11.1
3.11.0
3.10.3
3.10.2
3.10.1
3.9.0
3.8.0
3.7.0
3.5.0
3.4.0
3.3.0
3.2.0
3.1.0
3.0.0
2.1.1
2.1.0
2.0.0
1.5.2
1.5.1
1.4.30
1.4.29
1.4.28
1.4.27
1.4.26
1.4.25
1.4.24
1.4.23
1.4.22
1.4.21
1.4.20
1.4.19
1.4.18
1.4.17
1.4.16
1.4.15
1.4.14
1.4.13
1.4.11
1.4.10
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.1
1.3.0
1.2.0
1.1.0
1.0.10
1.0.9
1.0.8
1.0.7
1.0.6
1.0.5
1.0.4
1.0.3
1.0.2
1.0.1
1.0.0
0.48.6
0.48.5
0.48.4
0.48.3
0.48.2
0.48.1
0.48.0
0.47.1
0.47.0
0.46.3
0.46.1
0.46.0
0.45.3
0.45.2
0.45.1
0.45.0
0.44.2
0.44.1
0.44.0
0.43.3
0.43.2
0.43.1
0.43.0
0.42.9
0.42.8
0.42.7
0.42.6
0.42.5
0.42.4
0.42.3
0.42.2
0.42.1
0.42.0
0.41.1
0.41.0
0.40.1
0.40.0
0.39.9
0.39.8
0.39.7
0.39.6
0.39.5
0.39.4
0.39.3
0.39.2
0.39.1
0.39.0
0.38.8
0.38.7
0.38.6
0.38.5
0.38.4
0.38.3
0.38.2
0.38.1
0.38.0
0.37.7
0.37.6
0.37.5
0.37.4
0.37.3
0.37.2
0.37.1
0.37.0
0.36.6
0.36.5
0.36.4
0.36.3
0.36.2
0.36.1
0.36.0
0.35.4
0.35.3
0.35.2
0.35.1
0.35.0
0.34.1
0.34.0
0.33.1
0.33.0
0.32.5
0.32.4
0.32.3
0.32.2
0.32.1
0.32.0
0.31.9
0.31.8
0.31.7
0.31.6
0.31.5
0.31.4
0.31.3
0.31.2
0.31.1
0.31.0
0.30.10
0.30.9
0.30.8
0.30.7
0.30.6
0.30.5
0.30.4
0.30.3
0.30.2
0.30.1
0.30.0
0.29.0
0.28.3
0.28.2
0.28.1
0.28.0
0.27.1
0.27.0
0.26.1
0.26.0
0.25.6
0.25.5
0.25.4
0.25.3
0.25.2
0.25.1
0.25.0
0.24.6
0.24.5
0.24.4
0.24.3
0.24.2
0.24.1
0.24.0
0.23.3
0.23.2
0.23.1
0.23.0
0.22.12
0.22.11
0.22.10
0.22.9
0.22.8
0.22.7
0.22.6
0.22.5
0.22.4
0.22.3
0.22.2
0.22.1
0.22.0
0.21.7
0.21.6
0.21.5
0.21.4
0.21.2
0.21.1
0.21.0
0.20.25
0.20.24
0.20.23
0.20.22
0.20.21
0.20.20
0.20.19
0.20.18
0.20.17
0.20.16
0.20.15
0.20.14
0.20.13
0.20.12
0.20.11
0.20.10
0.20.9
0.20.8
0.20.7
0.20.6
0.20.5
0.20.3
0.20.2
0.20.1
0.20.0
0.19.2
0.19.1
0.19.0
0.18.1
0.18.0
0.17.4
0.17.3
0.17.2
0.17.1
0.17.0
0.16.6
0.16.5
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.1
0.15.0
0.14.3
0.14.2
0.14.1
0.14.0
0.12.6
0.12.5
0.12.3
0.11.3
0.10.2
0.10.1
0.10.0
0.9.2
0.9.1
0.9.0
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.10
0.8.9
0.8.8
0.8.7
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.30
0.7.29
0.7.28
0.7.27
0.7.26
0.7.25
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.0
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.4
0.3.3
0.3.2
0.3.1
Macula HTTP/3 Mesh SDK — connect, subscribe, publish, call, advertise
Current section
Files
Jump to
Current section
Files
src/macula_owner_only_file.erl
%%%-------------------------------------------------------------------
%%% @doc Files only their owner can read, written atomically.
%%%
%%% For the secrets macula keeps on disk: identity keys, the distribution
%%% cookie and the certificates it generates.
%%%
%%% write/2 never lets another user of the host see the content and never
%%% writes through a symlink. A missing parent directory is created, the
%%% innermost one with mode 0700; an existing parent is used as it is. The
%%% content goes into a new file inside a new private directory next to the
%%% target. That directory is restricted to mode 0700 before the file is
%%% created, because a file starts with the default mode and a descriptor
%%% another user opens before a chmod keeps reading after it. The file is
%%% restricted to 0600 before any content is written, synced, and renamed over
%%% the target, which replaces a symlink at the target instead of writing
%%% through it. The private directory is removed afterwards, and on any error
%%% the file with it.
%%%
%%% read/1 follows symlinks and accepts a regular file that gives its group
%%% and others no access, 0600 or 0400, as Erlang's own cookie check does, and
%%% that belongs to the user the node runs as (macula_node_user), so another
%%% user of the host cannot give the node a secret of their own. A host
%%% without user ids skips the owner check. A path that is not a regular file
%%% is refused without being opened, and the mode and the owner are checked on
%%% the opened handle, so the file checked is the file read.
%%%
%%% A refusal names the file, what was found and what is required: the mode
%%% as an octal binary in file_permissions, the owner's user id and the node's
%%% in file_owner, the file type in file_type.
%%% @end
%%%-------------------------------------------------------------------
-module(macula_owner_only_file).
-include_lib("kernel/include/file.hrl").
-export([write/2, read/1]).
-export_type([refusal/0]).
-type refusal() ::
{file_permissions, #{file := file:name_all(), mode := binary(), required := binary()}}
| {file_owner, #{file := file:name_all(), owner := non_neg_integer(),
required := non_neg_integer()}}
| {file_type, #{file := file:name_all(), type := atom(), required := regular}}.
-type file_error() :: file:posix() | badarg | terminated | system_limit.
-define(FILE_MODE, 8#600).
-define(DIRECTORY_MODE, 8#700).
-define(GROUP_OR_OTHERS, 8#077).
-define(REQUIRED_MODE, <<"no access for group or others (0600 or 0400)">>).
-define(READ_CHUNK, 65536).
%%%===================================================================
%%% API
%%%===================================================================
%% @doc Write Content to Path so that only its owner can read it, replacing
%% whatever is at Path. On an error nothing is left behind and Path is
%% unchanged.
-spec write(file:name_all(), iodata()) -> ok | {error, file_error()}.
write(Path, Content) ->
in_parent(ensure_parent(filename:dirname(Path)), Path, Content).
%% @doc Read Path when it is a regular file its group and others have no
%% access to, following symlinks.
-spec read(file:name_all()) -> {ok, binary()} | {error, refusal() | file_error()}.
read(Path) ->
opened_if_regular(file:read_file_info(Path), Path).
%%%===================================================================
%%% Writing
%%%===================================================================
ensure_parent(Dir) ->
parent_for(filelib:is_dir(Dir), Dir).
parent_for(true, _Dir) ->
ok;
parent_for(false, Dir) ->
restricted_after(filelib:ensure_path(Dir), Dir).
restricted_after(ok, Dir) ->
file:change_mode(Dir, ?DIRECTORY_MODE);
restricted_after({error, _} = Error, _Dir) ->
Error.
in_parent(ok, Path, Content) ->
Private = private_directory_name(Path),
in_private_directory(file:make_dir(Private), Private, Path, Content);
in_parent({error, _} = Error, _Path, _Content) ->
Error.
%% A failed make_dir means the directory is not ours, so it is never removed.
in_private_directory(ok, Private, Path, Content) ->
Moved = moved_into_place(file:change_mode(Private, ?DIRECTORY_MODE), Private, Path, Content),
removed(Moved, Private);
in_private_directory({error, _} = Error, _Private, _Path, _Content) ->
Error.
moved_into_place(ok, Private, Path, Content) ->
Temporary = filename:join(Private, filename:basename(Path)),
renamed_after(written(Temporary, Content), Temporary, Path);
moved_into_place({error, _} = Error, _Private, _Path, _Content) ->
Error.
renamed_after(ok, Temporary, Path) ->
file:rename(Temporary, Path);
renamed_after({error, _} = Error, _Temporary, _Path) ->
Error.
%% After the rename the private directory is empty and the file lives at
%% Path, which removing the directory cannot touch, so a failure to remove
%% it does not fail the write.
removed(ok, Private) ->
_ = file:del_dir(Private),
ok;
removed({error, _} = Error, Private) ->
_ = file:del_dir_r(Private),
Error.
written(Temporary, Content) ->
filled(file:open(Temporary, [write, exclusive, raw, binary]), Temporary, Content).
filled({ok, Fd}, Temporary, Content) ->
Filled = content_after(file:change_mode(Temporary, ?FILE_MODE), Fd, Content),
first_error([Filled, file:close(Fd)]);
filled({error, _} = Error, _Temporary, _Content) ->
Error.
content_after(ok, Fd, Content) ->
synced_after(file:write(Fd, Content), Fd);
content_after({error, _} = Error, _Fd, _Content) ->
Error.
synced_after(ok, Fd) ->
file:sync(Fd);
synced_after({error, _} = Error, _Fd) ->
Error.
private_directory_name(Path) ->
Suffix = binary:encode_hex(crypto:strong_rand_bytes(8), lowercase),
filename:join(filename:dirname(Path), suffixed(filename:basename(Path), Suffix)).
suffixed(Base, Suffix) when is_binary(Base) ->
<<Base/binary, ".tmp-", Suffix/binary>>;
suffixed(Base, Suffix) ->
Base ++ ".tmp-" ++ binary_to_list(Suffix).
first_error([]) -> ok;
first_error([ok | Rest]) -> first_error(Rest);
first_error([{error, _} = Error | _]) -> Error.
%%%===================================================================
%%% Reading
%%%===================================================================
%% The type is checked before opening so that a FIFO or a device is never
%% opened, and again with the mode on the opened handle.
opened_if_regular({ok, #file_info{type = regular}}, Path) ->
read_opened(file:open(Path, [read, raw, binary]), Path);
opened_if_regular({ok, #file_info{type = Type}}, Path) ->
{error, not_regular(Path, Type)};
opened_if_regular({error, _} = Error, _Path) ->
Error.
read_opened({ok, Fd}, Path) ->
Read = read_checked(file:read_file_info(Fd), Fd, Path),
closed_after(Read, file:close(Fd));
read_opened({error, _} = Error, _Path) ->
Error.
closed_after(Read, ok) -> Read;
closed_after(_Read, {error, _} = Error) -> Error.
read_checked({ok, #file_info{type = regular, mode = Mode, uid = Owner}}, Fd, Path)
when Mode band ?GROUP_OR_OTHERS =:= 0 ->
owned(macula_node_user:effective_uid(), Owner, Fd, Path);
read_checked({ok, #file_info{type = regular, mode = Mode}}, _Fd, Path) ->
{error, {file_permissions, #{file => Path, mode => octal(Mode), required => ?REQUIRED_MODE}}};
read_checked({ok, #file_info{type = Type}}, _Fd, Path) ->
{error, not_regular(Path, Type)};
read_checked({error, _} = Error, _Fd, _Path) ->
Error.
%% The owner comes from the opened handle, as the mode does. A host without
%% user ids has no owner to check.
owned(none, _Owner, Fd, _Path) ->
read_all(Fd, []);
owned(Owner, Owner, Fd, _Path) ->
read_all(Fd, []);
owned(Required, Owner, _Fd, Path) ->
{error, {file_owner, #{file => Path, owner => Owner, required => Required}}}.
read_all(Fd, Chunks) ->
read_chunk(file:read(Fd, ?READ_CHUNK), Fd, Chunks).
read_chunk({ok, Data}, Fd, Chunks) -> read_all(Fd, [Data | Chunks]);
read_chunk(eof, _Fd, Chunks) -> {ok, iolist_to_binary(lists:reverse(Chunks))};
read_chunk({error, _} = Error, _Fd, _Chunks) -> Error.
not_regular(Path, Type) ->
{file_type, #{file => Path, type => Type, required => regular}}.
octal(Mode) ->
iolist_to_binary(io_lib:format("~4.8.0B", [Mode band 8#7777])).