Skip to main content

src/barrel_p2p_discovery_file.erl

%%% -*- erlang -*-
%%% Copyright (c) 2026 Benoit Chesneau
%%% SPDX-License-Identifier: Apache-2.0
%%%
-module(barrel_p2p_discovery_file).
-behaviour(quic_discovery).

%% Filesystem-backed discovery: one file per registered node under
%% the configured `discovery_dir'. Useful for nodes sharing the same
%% host (or filesystem) so they auto-find each other without epmd.
%%
%% On-disk format (`<discovery_dir>/<node>.endpoint'):
%%
%%   {"<host>", <port>}.
%%
%% Read with `file:consult/1', written via tmpfile + `file:rename/2'
%% so concurrent readers never see a half-written file.
%%
%% `discovery_dir' is read from
%%   application:get_env(barrel_p2p, discovery_dir, "data/discovery").
%%
%% Stale entries: when a node restarts the file is rewritten in place,
%% so a stale entry causes at worst one failed connect followed by a
%% successful retry. We don't try to garbage-collect via timestamps;
%% callers can clean up explicitly with `unregister/1' if needed.

-export([init/1, register/3, lookup/2, list_nodes/1]).
-export([unregister/1]).

%%====================================================================
%% quic_discovery callbacks
%%====================================================================

init(_Opts) ->
    Dir = discovery_dir(),
    case filelib:ensure_dir(filename:join(Dir, "dummy")) of
        ok -> {ok, Dir};
        {error, R} -> {error, {discovery_dir_not_writable, Dir, R}}
    end.

register(Node, Port, _State) ->
    Dir = discovery_dir(),
    NodeStr = node_to_string(Node),
    Host = host_of(NodeStr),
    Term = io_lib:format("~p.~n", [{Host, Port}]),
    Path = endpoint_path(Dir, NodeStr),
    Tmp = Path ++ ".tmp",
    ok = filelib:ensure_dir(Path),
    case file:write_file(Tmp, iolist_to_binary(Term)) of
        ok ->
            case file:rename(Tmp, Path) of
                ok ->
                    {ok, Dir};
                Error ->
                    _ = file:delete(Tmp),
                    Error
            end;
        Error ->
            Error
    end.

lookup(Node, _Host) ->
    Path = endpoint_path(discovery_dir(), node_to_string(Node)),
    case file:consult(Path) of
        {ok, [{Host, Port}]} when is_integer(Port) ->
            {ok, {Host, Port}};
        {ok, _} ->
            {error, malformed_endpoint};
        {error, enoent} ->
            {error, not_found};
        {error, _} = Err ->
            Err
    end.

list_nodes(_Host) ->
    Dir = discovery_dir(),
    case file:list_dir(Dir) of
        {ok, Files} ->
            Nodes = lists:filtermap(
                fun(F) -> read_entry(filename:join(Dir, F)) end,
                lists:filter(fun is_endpoint_file/1, Files)
            ),
            {ok, Nodes};
        {error, enoent} ->
            {ok, []};
        {error, _} = Err ->
            Err
    end.

%%====================================================================
%% Public helpers
%%====================================================================

%% @doc Remove this node's endpoint file. Useful in `init:stop'-style
%% shutdown hooks.
-spec unregister(node() | string() | binary()) -> ok | {error, term()}.
unregister(Node) ->
    case file:delete(endpoint_path(discovery_dir(), node_to_string(Node))) of
        ok -> ok;
        {error, enoent} -> ok;
        Err -> Err
    end.

%%====================================================================
%% Internal
%%====================================================================

discovery_dir() ->
    application:get_env(barrel_p2p, discovery_dir, "data/discovery").

%% Normalise atoms, binaries, and strings to a single string form
%% so callers (upstream quic_dist + barrel_p2p_app) can pass either.
node_to_string(Node) when is_atom(Node) -> atom_to_list(Node);
node_to_string(Node) when is_binary(Node) -> binary_to_list(Node);
node_to_string(Node) when is_list(Node) -> Node.

endpoint_path(Dir, NodeStr) when is_list(NodeStr) ->
    filename:join(Dir, NodeStr ++ ".endpoint").

host_of(NodeStr) when is_list(NodeStr) ->
    case string:split(NodeStr, "@") of
        [_, Host] -> Host;
        _ -> "127.0.0.1"
    end.

is_endpoint_file(F) ->
    case lists:reverse(F) of
        "tniopdne." ++ _ -> true;
        _ -> false
    end.

read_entry(Path) ->
    Base = filename:basename(Path, ".endpoint"),
    BaseBin = list_to_binary(Base),
    %% Validate the filename matches a well-formed `name@host' atom
    %% before minting it. discovery_dir is potentially shared or
    %% writable; without this, crafted filenames could exhaust the
    %% atom table. Prefer an existing atom to avoid minting altogether.
    case barrel_p2p_dist_protocol:validate_node_name(BaseBin) of
        ok ->
            case file:consult(Path) of
                {ok, [{_Host, Port}]} when is_integer(Port) ->
                    {true, {materialise_node(Base, BaseBin), Port}};
                _ ->
                    false
            end;
        {error, _} ->
            false
    end.

materialise_node(Base, BaseBin) ->
    try
        list_to_existing_atom(Base)
    catch
        error:badarg ->
            binary_to_atom(BaseBin, utf8)
    end.