Fosi Fossil Service
Small teams. Open source. Built on Fossil.

CaaS / Lamina

Unified Management for Fossil Repositories

Public | Owner: CaaS | Default branch: trunk | Last update: 2026-10-25 00:41

Artifact c34f55428ca36a6385adaac56a1ca7e18522e12aa49efd057766a6b827eba102:


%%%-------------------------------------------------------------------
-module(nova_multipart_file_handler).
-moduledoc """
`nova_multipart_handler` that streams a file part to disk under a random name in `InitArgs``dir`. The client filename is never used in the path. The part is written as `<name>.part` and renamed on handle_end/1, so a file at its final name is always complete. Sweep `dir` for stale `*.part` files left by a process crash.

Only a client extension listed in `extensions` is kept on the stored name, lowercased. It is an allowlist because `dir` may be served by a web server, where a stored `.html` or `.svg` is stored XSS. Anything else is dropped, not rejected: the extension is client controlled and says nothing about the bytes.

Result: `#{path => Path}`.
""".
-behaviour(nova_multipart_handler).

-export([init/2, handle_data/2, handle_end/1, handle_abort/2]).

-type init_args() :: #{dir := file:filename_all(),
                       extensions => [binary()]}.

-export_type([init_args/0]).

-spec init(nova_multipart_handler:part_info(), InitArgs :: init_args()) ->
          {ok, HandlerState :: map()} | {error, Reason :: term()}.
init(#{filename := Filename}, #{dir := Dir} = InitArgs) ->
    Name = binary:encode_hex(crypto:strong_rand_bytes(16), lowercase),
    Ext = extension(Filename, maps:get(extensions, InitArgs, [])),
    FinalPath = filename:join(Dir, <<Name/binary, Ext/binary>>),
    TmpPath = filename:join(Dir, <<Name/binary, Ext/binary, ".part">>),
    case file:open(TmpPath, [write, exclusive, binary]) of
        {ok, Fd} ->
            {ok, #{fd => Fd, tmp_path => TmpPath, final_path => FinalPath}};
        {error, _} = Error ->
            Error
    end.

-spec handle_data(Data :: binary(), HandlerState :: map()) ->
          {ok, NewHandlerState :: map()} | {error, Reason :: term()}.
handle_data(Data, #{fd := Fd} = State) ->
    case file:write(Fd, Data) of
        ok -> {ok, State};
        {error, _} = Error -> Error
    end.

-spec handle_end(HandlerState :: map()) ->
          {ok, #{path := file:filename_all()}} | {error, Reason :: term()}.
handle_end(#{fd := Fd, tmp_path := TmpPath, final_path := FinalPath}) ->
    case file:close(Fd) of
        ok ->
            case file:rename(TmpPath, FinalPath) of
                ok ->
                    {ok, #{path => FinalPath}};
                {error, _} = Error ->
                    _ = file:delete(TmpPath),
                    Error
            end;
        {error, _} = Error ->
            _ = file:delete(TmpPath),
            Error
    end.

-spec handle_abort(Reason :: term(), HandlerState :: map()) -> ok.
handle_abort(_Reason, #{fd := Fd, tmp_path := TmpPath}) ->
    _ = file:close(Fd),
    _ = file:delete(TmpPath),
    ok.

%% Only the extension survives, and only via the allowlist: the filename is
%% otherwise never looked at, so `../../x.png' contributes exactly `.png'.
-spec extension(Filename :: binary(), Allowed :: [binary()]) -> binary().
extension(_Filename, []) ->
    ~"";
extension(Filename, Allowed) ->
    case filename:extension(Filename) of
        <<$., Ext/binary>> when byte_size(Ext) =< 16 ->
            Lower = <<<<(lower(C))>> || <<C>> <= Ext>>,
            case lists:member(Lower, Allowed) andalso is_token(Lower) of
                true -> <<$., Lower/binary>>;
                false -> ~""
            end;
        _ ->
            ~""
    end.

-spec lower(byte()) -> byte().
lower(C) when C >= $A, C =< $Z -> C + 32;
lower(C) -> C.

-spec is_token(binary()) -> boolean().
is_token(Bin) ->
    lists:all(fun(C) -> (C >= $a andalso C =< $z) orelse (C >= $0 andalso C =< $9) end,
              binary_to_list(Bin)).

Clone

You can clone this repository with the following command:

fossil clone https://fosi.dev/CaaS/Lamina

Contribute

The best way to contribute is through e-mail. See the project documentation for more details.