Skip to main content

src/livery_s3_redirect.erl

%% SPDX-License-Identifier: Apache-2.0
%% Copyright 2026 Benoit Chesneau
-module(livery_s3_redirect).
-moduledoc """
Client layer: follow S3 region redirects.

AWS answers a request aimed at the wrong region with a redirect that names the
right one: `400 AuthorizationHeaderMalformed` carrying a `<Region>` element (the
endpoint is reachable but the request was signed for the wrong region), or `301
PermanentRedirect` carrying an `<Endpoint>` element (the request must go to a
different host). The correct region is also echoed in the `x-amz-bucket-region`
header. This layer detects those, rewrites the request (signing region and/or
host) and retries once, so the inner signing layer re-signs for the new target.

It is placed above the retry layer and below signing. Single-region
S3-compatible stores never emit these responses, so the layer is a no-op there.
Host rewriting follows AWS's virtual-hosted `<Endpoint>` and keeps the path, so
it targets virtual-hosted addressing (the AWS default). Streamed response bodies
are left untouched; detection then relies on the `x-amz-bucket-region` header.
""".

-export([call/3]).

-spec call(livery_client:request(), livery_client:next(), map()) ->
    livery_client:result().
call(Req, Next, Opts) ->
    follow(Req, Next, maps:get(max, Opts, 1)).

-spec follow(livery_client:request(), livery_client:next(), non_neg_integer()) ->
    livery_client:result().
follow(Req, Next, 0) ->
    Next(Req);
follow(Req, Next, Left) ->
    case Next(Req) of
        {ok, Resp} = Result ->
            case redirect(Req, Resp) of
                undefined -> Result;
                Req1 -> follow(Req1, Next, Left - 1)
            end;
        {error, _} = Error ->
            Error
    end.

%% The rewritten request to retry, or `undefined` when this is not a redirect.
-spec redirect(livery_client:request(), livery_client:response()) ->
    livery_client:request() | undefined.
redirect(Req, #{status := Status} = Resp) when Status =:= 301; Status =:= 307 ->
    case error_field(Resp, <<"Endpoint">>) of
        undefined -> undefined;
        Host -> with_region(Req#{url => swap_host(maps:get(url, Req), Host)}, region(Resp))
    end;
redirect(Req, #{status := 400} = Resp) ->
    case error_field(Resp, <<"Code">>) of
        <<"AuthorizationHeaderMalformed">> ->
            case region(Resp) of
                undefined -> undefined;
                Region -> with_region(Req, Region)
            end;
        _ ->
            undefined
    end;
redirect(_Req, _Resp) ->
    undefined.

%% The corrected region: the `x-amz-bucket-region` header, else `<Region>`.
-spec region(livery_client:response()) -> binary() | undefined.
region(Resp) ->
    case header(<<"x-amz-bucket-region">>, Resp) of
        undefined -> error_field(Resp, <<"Region">>);
        Region -> Region
    end.

-spec with_region(livery_client:request(), binary() | undefined) -> livery_client:request().
with_region(Req, undefined) ->
    Req;
with_region(Req, Region) ->
    Meta = maps:get(meta, Req, #{}),
    Req#{meta => Meta#{region => Region}}.

%% Replace the authority of an absolute URL, keeping scheme, path, and query.
-spec swap_host(binary(), binary()) -> binary().
swap_host(Url, Host) ->
    [Scheme, Rest] = binary:split(Url, <<"://">>),
    PathQuery =
        case binary:split(Rest, <<"/">>) of
            [_Authority, PQ] -> <<"/", PQ/binary>>;
            [_Authority] -> <<>>
        end,
    <<Scheme/binary, "://", Host/binary, PathQuery/binary>>.

-spec header(binary(), livery_client:response()) -> binary() | undefined.
header(Name, #{headers := Headers}) ->
    L = string:lowercase(Name),
    case lists:search(fun({K, _}) -> string:lowercase(K) =:= L end, Headers) of
        {value, {_, V}} -> V;
        false -> undefined
    end.

%% Read a child element's text from a full-body XML error. Streamed bodies are
%% left untouched (header-only detection still applies).
-spec error_field(livery_client:response(), binary()) -> binary() | undefined.
error_field(#{body := {full, Bin}}, Field) when Bin =/= <<>> ->
    case livery_s3_xml:parse(Bin) of
        {ok, Tree} -> livery_s3_xml:text(Tree, Field);
        {error, _} -> undefined
    end;
error_field(_Resp, _Field) ->
    undefined.