Current section
Files
Jump to
Current section
Files
src/multipartkit@part.erl
-module(multipartkit@part).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/multipartkit/part.gleam").
-export([unchecked_new/5, new/5, all_headers/1, name/1, filename/1, content_type/1, body/1, equal_on_wire/2, list_equal_on_wire/2, header/2, headers/2]).
-export_type([part/0]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
-opaque part() :: {part,
list({binary(), binary()}),
gleam@option:option(binary()),
gleam@option:option(binary()),
gleam@option:option(binary()),
bitstring()}.
-file("src/multipartkit/part.gleam", 113).
?DOC(false).
-spec unchecked_new(
list({binary(), binary()}),
gleam@option:option(binary()),
gleam@option:option(binary()),
gleam@option:option(binary()),
bitstring()
) -> part().
unchecked_new(Headers, Name, Filename, Content_type, Body) ->
{part, Headers, Name, Filename, Content_type, Body}.
-file("src/multipartkit/part.gleam", 163).
?DOC(
" If `value` is `Some(v)` and `v` contains a CR / LF / NUL byte, surface\n"
" it as `InvalidHeaderValue(header_name, v)` so the user sees the same\n"
" error shape they would get for the raw header pair. `header_name` is\n"
" the wire header that synthesis would derive from this parameter\n"
" (`Content-Disposition` for `name` / `filename`, `Content-Type` for\n"
" `content_type`); reusing it makes the error point at the actual\n"
" failure mode without inventing a new error variant.\n"
).
-spec reject_breaking_bytes(binary(), gleam@option:option(binary())) -> {ok,
nil} |
{error, multipartkit@error:multipart_error()}.
reject_breaking_bytes(Header_name, Value) ->
case Value of
none ->
{ok, nil};
{some, V} ->
case multipartkit@internal@disposition:has_header_breaking_bytes(V) of
true ->
{error, {invalid_header_value, Header_name, V}};
false ->
{ok, nil}
end
end.
-file("src/multipartkit/part.gleam", 213).
-spec has_header_ci(list({binary(), binary()}), binary()) -> boolean().
has_header_ci(Headers, Lowercase_name) ->
case Headers of
[] ->
false;
[{K, _} | Rest] ->
case multipartkit@internal@text:equals_ci(K, Lowercase_name) of
true ->
true;
false ->
has_header_ci(Rest, Lowercase_name)
end
end.
-file("src/multipartkit/part.gleam", 183).
?DOC(
" Prepend `Content-Disposition` (when `name` is `Some(_)` and missing\n"
" from `existing`) and `Content-Type` (when `content_type` is `Some(_)`\n"
" and missing from `existing`) to mirror what the wire-side parser would\n"
" derive from those headers. Header order is `[Content-Disposition,\n"
" Content-Type, ...existing]`, matching what `multipartkit/form.add_file`\n"
" emits.\n"
).
-spec synthesise_missing_headers(
list({binary(), binary()}),
gleam@option:option(binary()),
gleam@option:option(binary()),
gleam@option:option(binary())
) -> list({binary(), binary()}).
synthesise_missing_headers(Existing, Name, Filename, Content_type) ->
With_content_type = case Content_type of
none ->
Existing;
{some, Ct} ->
case has_header_ci(Existing, <<"content-type"/utf8>>) of
true ->
Existing;
false ->
[{<<"Content-Type"/utf8>>, Ct} | Existing]
end
end,
case Name of
none ->
With_content_type;
{some, N} ->
case has_header_ci(
With_content_type,
<<"content-disposition"/utf8>>
) of
true ->
With_content_type;
false ->
[{<<"Content-Disposition"/utf8>>,
multipartkit@internal@disposition:build_form_data_value(
N,
Filename
)} |
With_content_type]
end
end.
-file("src/multipartkit/part.gleam", 235).
-spec drop_leading_ows(binary()) -> binary().
drop_leading_ows(Value) ->
case gleam_stdlib:string_pop_grapheme(Value) of
{ok, {<<" "/utf8>>, Rest}} ->
drop_leading_ows(Rest);
{ok, {<<"\t"/utf8>>, Rest}} ->
drop_leading_ows(Rest);
_ ->
Value
end.
-file("src/multipartkit/part.gleam", 242).
-spec drop_trailing_ows(binary()) -> binary().
drop_trailing_ows(Value) ->
gleam@bool:guard(
not (gleam_stdlib:string_ends_with(Value, <<" "/utf8>>) orelse gleam_stdlib:string_ends_with(
Value,
<<"\t"/utf8>>
)),
Value,
fun() -> drop_trailing_ows(gleam@string:drop_end(Value, 1)) end
).
-file("src/multipartkit/part.gleam", 231).
?DOC(
" Strip RFC 7230 §3.2.4 OWS — space (0x20) and horizontal tab (0x09) —\n"
" from both ends of `value`. Mirrors what the parser strips on the wire\n"
" side; using `string.trim` would also strip Unicode whitespace, which\n"
" is over-broad for an HTTP-style header.\n"
).
-spec trim_ows(binary()) -> binary().
trim_ows(Value) ->
_pipe = Value,
_pipe@1 = drop_leading_ows(_pipe),
drop_trailing_ows(_pipe@1).
-file("src/multipartkit/part.gleam", 250).
-spec valid_header_name(binary()) -> boolean().
valid_header_name(Name) ->
not (((gleam_stdlib:contains_string(Name, <<"\r"/utf8>>) orelse gleam_stdlib:contains_string(
Name,
<<"\n"/utf8>>
))
orelse gleam_stdlib:contains_string(Name, <<"\x{0000}"/utf8>>))
orelse gleam_stdlib:contains_string(Name, <<":"/utf8>>)).
-file("src/multipartkit/part.gleam", 259).
-spec valid_header_value(binary()) -> boolean().
valid_header_value(Value) ->
not ((gleam_stdlib:contains_string(Value, <<"\r"/utf8>>) orelse gleam_stdlib:contains_string(
Value,
<<"\n"/utf8>>
))
orelse gleam_stdlib:contains_string(Value, <<"\x{0000}"/utf8>>)).
-file("src/multipartkit/part.gleam", 136).
?DOC(
" Walk the input header list, rejecting CRLF / NUL injection attempts\n"
" and stripping RFC 7230 §3.2.4 OWS (space and horizontal tab) from the\n"
" surrounding edges of each value. The OWS stripping mirrors what the\n"
" parser does on the wire side, so a `Part` constructed with\n"
" `[#(\"X-Foo\", \" spaced \")]` round-trips equal to itself through\n"
" `encode → parse`. The returned list preserves the input order so\n"
" `all_headers` / `headers` keep their documented \"wire order\" property.\n"
).
-spec validate_and_normalise_headers(
list({binary(), binary()}),
list({binary(), binary()})
) -> {ok, list({binary(), binary()})} |
{error, multipartkit@error:multipart_error()}.
validate_and_normalise_headers(Headers, Acc) ->
case Headers of
[] ->
{ok, lists:reverse(Acc)};
[{Name, Value} | Rest] ->
gleam@bool:guard(
not valid_header_name(Name),
{error, {invalid_header_name, Name}},
fun() ->
gleam@bool:guard(
not valid_header_value(Value),
{error, {invalid_header_value, Name, Value}},
fun() ->
validate_and_normalise_headers(
Rest,
[{Name, trim_ows(Value)} | Acc]
)
end
)
end
)
end.
-file("src/multipartkit/part.gleam", 85).
?DOC(
" Construct a `Part`.\n"
"\n"
" The `name`, `filename`, and `content_type` parameters double as wire\n"
" instructions: when one of them is `Some(_)` and the corresponding header\n"
" is absent from `headers`, the constructor synthesises the missing header\n"
" (matching the shape `multipartkit/form.add_field` / `add_file` would\n"
" emit) so the cached value also appears in the encoded wire image and\n"
" survives a `multipartkit.encode |> multipartkit.parse` round trip.\n"
"\n"
" Synthesis rules:\n"
"\n"
" - `name: Some(n)` and no `Content-Disposition` header → prepends\n"
" `Content-Disposition: form-data; name=\"n\"` (with `; filename=...`\n"
" appended when `filename` is also `Some(_)`, using the RFC 5987\n"
" `filename*=` form for non-ASCII filenames).\n"
" - `content_type: Some(ct)` and no `Content-Type` header → prepends\n"
" `Content-Type: ct`.\n"
" - `filename: Some(_)` without `name` does not synthesise a\n"
" `Content-Disposition` (RFC 7578 §4.2 requires `name`); the value is\n"
" stored as the cache only.\n"
" - When the relevant header IS present in `headers`, the constructor\n"
" leaves the headers list untouched — the caller's explicit header wins.\n"
"\n"
" Header names and values are validated to prevent CRLF / NUL injection\n"
" into the encoded wire image. A header value that contains `\\r`, `\\n`,\n"
" or NUL would otherwise let an attacker who controls the value smuggle\n"
" additional header lines into the encoded part — the multipart variant\n"
" of CRLF response splitting (RFC 9110 §5.5 disallows these bytes in\n"
" `field-value`). Header names additionally cannot contain `:` (would\n"
" split the header at parse time). The same CRLF / NUL guard applies to\n"
" `name`, `filename`, and `content_type` because they may be promoted to\n"
" header values by the synthesis rules above. The constructor rejects\n"
" these inputs with `Error(InvalidHeaderName(_))` /\n"
" `Error(InvalidHeaderValue(_, _))` rather than silently emitting an\n"
" off-spec wire image.\n"
).
-spec new(
list({binary(), binary()}),
gleam@option:option(binary()),
gleam@option:option(binary()),
gleam@option:option(binary()),
bitstring()
) -> {ok, part()} | {error, multipartkit@error:multipart_error()}.
new(Headers, Name, Filename, Content_type, Body) ->
gleam@result:'try'(
validate_and_normalise_headers(Headers, []),
fun(Normalised) ->
gleam@result:'try'(
reject_breaking_bytes(<<"Content-Disposition"/utf8>>, Name),
fun(_) ->
gleam@result:'try'(
reject_breaking_bytes(
<<"Content-Disposition"/utf8>>,
Filename
),
fun(_) ->
gleam@result:'try'(
reject_breaking_bytes(
<<"Content-Type"/utf8>>,
Content_type
),
fun(_) ->
Synthesised = synthesise_missing_headers(
Normalised,
Name,
Filename,
Content_type
),
{ok,
{part,
Synthesised,
Name,
Filename,
Content_type,
Body}}
end
)
end
)
end
)
end
).
-file("src/multipartkit/part.gleam", 268).
?DOC(" All headers as `(name, value)` pairs in input order.\n").
-spec all_headers(part()) -> list({binary(), binary()}).
all_headers(Part) ->
erlang:element(2, Part).
-file("src/multipartkit/part.gleam", 275).
?DOC(
" The convenience `name` field derived from `Content-Disposition` for\n"
" `form-data` parts, or `None` for non-form parts and parts without a\n"
" disposition header.\n"
).
-spec name(part()) -> gleam@option:option(binary()).
name(Part) ->
erlang:element(3, Part).
-file("src/multipartkit/part.gleam", 281).
?DOC(
" The convenience `filename` field derived from `Content-Disposition`\n"
" for `form-data` parts, or `None`.\n"
).
-spec filename(part()) -> gleam@option:option(binary()).
filename(Part) ->
erlang:element(4, Part).
-file("src/multipartkit/part.gleam", 287).
?DOC(
" The `Content-Type` header value, or `None` if the part has no\n"
" `Content-Type` header.\n"
).
-spec content_type(part()) -> gleam@option:option(binary()).
content_type(Part) ->
erlang:element(5, Part).
-file("src/multipartkit/part.gleam", 292).
?DOC(" The raw body bytes of the part.\n").
-spec body(part()) -> bitstring().
body(Part) ->
erlang:element(6, Part).
-file("src/multipartkit/part.gleam", 327).
?DOC(
" Structural equality on the wire-level content of two `Part` values.\n"
"\n"
" Compares the headers list (preserving order, with case-sensitive\n"
" name matching that mirrors RFC 7578 §4.2) and the body bytes. The\n"
" convenience cache fields — `name`, `filename`, `content_type` — are\n"
" intentionally ignored because they are derived from the headers\n"
" and may differ between a `Part.new/5`-constructed value (where the\n"
" caller passes the cache) and a parsed `Part` (where the parser\n"
" derives the cache from `Content-Disposition` / `Content-Type`).\n"
" Two `Part`s that `equal_on_wire` returns `True` for will encode\n"
" to the same bytes via `multipartkit.encode/2` (modulo the\n"
" boundary string, which is supplied at encode time).\n"
"\n"
" Use this for property-style round-trip tests where the caller\n"
" passes one `Part` shape into the encoder and gets another\n"
" (cache-derived) shape back from the parser.\n"
).
-spec equal_on_wire(part(), part()) -> boolean().
equal_on_wire(A, B) ->
(erlang:element(2, A) =:= erlang:element(2, B)) andalso (erlang:element(
6,
A
)
=:= erlang:element(6, B)).
-file("src/multipartkit/part.gleam", 334).
?DOC(
" `equal_on_wire` lifted over a pair of `Part` lists. Returns `True`\n"
" when the lists have the same length and every paired element\n"
" satisfies `equal_on_wire`.\n"
).
-spec list_equal_on_wire(list(part()), list(part())) -> boolean().
list_equal_on_wire(A, B) ->
case {A, B} of
{[], []} ->
true;
{[], _} ->
false;
{_, []} ->
false;
{[First_a | Rest_a], [First_b | Rest_b]} ->
case equal_on_wire(First_a, First_b) of
true ->
list_equal_on_wire(Rest_a, Rest_b);
false ->
false
end
end.
-file("src/multipartkit/part.gleam", 347).
-spec find_header(list({binary(), binary()}), binary()) -> list(binary()).
find_header(Headers, Name) ->
gleam@list:filter_map(
Headers,
fun(Entry) ->
case multipartkit@internal@text:equals_ci(
erlang:element(1, Entry),
Name
) of
true ->
{ok, erlang:element(2, Entry)};
false ->
{error, nil}
end
end
).
-file("src/multipartkit/part.gleam", 298).
?DOC(
" Return the first header value whose name matches `name` ASCII\n"
" case-insensitively.\n"
).
-spec header(part(), binary()) -> gleam@option:option(binary()).
header(Part, Name) ->
case find_header(erlang:element(2, Part), Name) of
[First | _] ->
{some, First};
[] ->
none
end.
-file("src/multipartkit/part.gleam", 307).
?DOC(
" Return all header values whose name matches `name` ASCII case-insensitively\n"
" in the order they appear in the part headers.\n"
).
-spec headers(part(), binary()) -> list(binary()).
headers(Part, Name) ->
find_header(erlang:element(2, Part), Name).